#!/usr/bin/env python3
"""Signum local workspace connector v0.1.0, Python 3.11+ (stdlib only).

Install outside project folders; invoke with `python -I /path/signum_workspace.py`.
No listener, remote shell, package installation, project hooks or automatic execution.
`watch` only proposes source snapshots. `run` is an explicit LOCAL operation.
This is NOT a sandbox: trusted programs run with your OS account's access.
"""
from __future__ import annotations
import argparse
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import tempfile
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
import uuid

VERSION = '0.1.0'
MAX_FILE = 65536
MAX_TOTAL = 98304
MAX_FILES = 64
MAX_RESPONSE = 2 * 1024 * 1024
DENY_DIRS = {'.git', '.ssh', '.aws', '.azure', '.signum-workspace', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.cache'}
TEXT_EXT = {'.sig','.nsl','.py','.js','.mjs','.cjs','.ts','.tsx','.jsx','.go','.rs','.c','.cc','.cpp','.h','.hpp','.java','.html','.css','.sql','.sh','.ps1','.json','.md','.txt','.toml','.yaml','.yml','.xml','.gitignore','.lock'}
NAMES = {'readme','license','makefile','dockerfile','.gitignore','.env.example','.env.sample'}
SECRET = re.compile(r'-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----|\b(?:ghp_|github_pat_)[A-Za-z0-9_]{25,}|\bAKIA[0-9A-Z]{16}\b')

class Refused(RuntimeError):
    """A safety, consistency, or protocol gate refused an operation."""


def canonical(value: object) -> bytes:
    return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')


def digest(value: object) -> str:
    return hashlib.sha256(canonical(value)).hexdigest()


def portable_path(value: str) -> str:
    if not isinstance(value, str) or not 1 <= len(value) <= 180 or value != unicodedata.normalize('NFC', value):
        raise Refused('Invalid source path.')
    if value.startswith('/') or '//' in value or any(not (c.isalpha() or c.isdecimal() or c in '._ /-') for c in value):
        raise Refused('Use a portable relative source path, without escapes or controls.')
    for part in value.split('/'):
        low = part.lower()
        if not part or part in {'.','..'} or part.startswith(' ') or part.endswith((' ','.')) or re.match(r'^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)', low):
            raise Refused('Reserved path component: ' + part)
        if low in DENY_DIRS or low in {'id_rsa','id_ed25519','credentials','credentials.json'} or (low.startswith('.env') and low not in {'.env.example','.env.sample'}):
            raise Refused('Credential/build directory is not a source path: ' + part)
    return value


def validated_files(files: object) -> list[dict[str,str]]:
    if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES:
        raise Refused('A snapshot must contain 1–64 text files.')
    out, seen, total = [], set(), 0
    for item in files:
        if not isinstance(item, dict) or set(item) != {'path','content'} or not isinstance(item['content'], str):
            raise Refused('Malformed source record.')
        path = portable_path(item['path']); text = item['content']; data = text.encode('utf-8')
        if '\x00' in text or len(data) > MAX_FILE or SECRET.search(text):
            raise Refused('Binary, oversized, or likely credential-bearing source: ' + path)
        key = path.lower()
        if key in seen: raise Refused('Case-colliding file path: ' + path)
        seen.add(key); total += len(data)
        out.append({'path':path,'content':text})
    if total > MAX_TOTAL: raise Refused('Snapshot exceeds 96 KiB. Select a smaller source project.')
    if any('/'.join(p.split('/')[:i]) in seen for p in seen for i in range(1,len(p.split('/')))):
        raise Refused('A file is also used as a directory.')
    # Matches JS's UTF-16 code-unit sort, including non-BMP letters.
    return sorted(out, key=lambda f:f['path'].encode('utf-16-be'))


def no_link(path: Path, *, regular: bool = False) -> os.stat_result:
    s = path.lstat()
    if stat.S_ISLNK(s.st_mode) or getattr(s,'st_file_attributes',0) & 0x400:
        raise Refused('Symlink or Windows reparse point refused: ' + str(path))
    if regular and (not stat.S_ISREG(s.st_mode) or s.st_nlink != 1):
        raise Refused('Only ordinary, non-hardlinked files are accepted: ' + str(path))
    return s


def check_chain(path: Path) -> None:
    for parent in reversed(path.parents):
        if parent.exists(): no_link(parent)
    if path.exists() or path.is_symlink(): no_link(path)


def project_root(value: str | Path) -> Path:
    root = Path(os.path.abspath(os.path.expanduser(str(value))))
    check_chain(root)
    if not root.is_dir() or root == Path(root.anchor) or root == Path.home():
        raise Refused('Choose a dedicated project folder, not your home or drive root.')
    return root


def read_source(root: Path, relative: str) -> str:
    portable_path(relative); p=root / relative; check_chain(p)
    before=no_link(p,regular=True)
    if before.st_size > MAX_FILE: raise Refused('Oversized text file: '+relative)
    flags=os.O_RDONLY | getattr(os,'O_NOFOLLOW',0)
    fd=os.open(p, flags)
    try:
        s=os.fstat(fd)
        if (s.st_dev,s.st_ino,s.st_size)!=(before.st_dev,before.st_ino,before.st_size) or s.st_nlink!=1:
            raise Refused('File changed while opening: '+relative)
        data=os.read(fd,MAX_FILE+1)
        after=os.fstat(fd)
        if (after.st_size,after.st_mtime_ns)!=(s.st_size,s.st_mtime_ns): raise Refused('File changed while reading: '+relative)
    finally: os.close(fd)
    if len(data)>MAX_FILE: raise Refused('Oversized text file: '+relative)
    try: return data.decode('utf-8','strict')
    except UnicodeError as exc: raise Refused('Not UTF-8 source: '+relative) from exc


def snapshot(root: Path) -> list[dict[str,str]]:
    # No dependency discovery or project configuration is executed.
    check_chain(root); found=[]; visited=0
    for folder, dirs, names in os.walk(root, followlinks=False):
        visited+=1
        if visited>1000: raise Refused('Too many directories; select a smaller source root.')
        for d in list(dirs):
            p=Path(folder)/d
            no_link(p)
            if d.lower() in DENY_DIRS: dirs.remove(d)
        for n in names:
            p=Path(folder)/n; low=n.lower()
            if low.startswith('.env') and low not in {'.env.example','.env.sample'}: continue
            if low in {'id_rsa','id_ed25519','credentials','credentials.json'} or p.suffix.lower() in {'.pem','.key','.pfx','.p12'}: continue
            if p.suffix.lower() not in TEXT_EXT and low not in NAMES: continue
            rel=p.relative_to(root).as_posix()
            found.append({'path':rel,'content':read_source(root,rel)})
            if len(found)>MAX_FILES: raise Refused('More than 64 selected text files; choose a smaller root.')
    return validated_files(found)


def selected_snapshot(root: Path,box: Path) -> list[dict[str,str]]:
    manifest=load_json(box/'manifest.json',{})
    paths=manifest.get('paths',[]) if isinstance(manifest,dict) else []
    if not isinstance(paths,list) or len(paths)>MAX_FILES or any(not isinstance(p,str) for p in paths):
        raise Refused('Invalid local selection manifest.')
    files=[]
    for rel in paths:
        portable_path(rel)
        if (root/rel).is_symlink():raise Refused('Selected path became a symlink: '+rel)
        if (root/rel).exists():files.append({'path':rel,'content':read_source(root,rel)})
    if not files:raise Refused('No selected source files remain. Explicitly include a file before pushing.')
    return validated_files(files)


def state_home(root: Path, base: str | Path | None = None) -> Path:
    home=Path(base) if base else Path.home()/'.signum-workspaces'
    home=Path(os.path.abspath(home));check_chain(home)
    if home == root or root in home.parents: raise Refused('Connector state must be outside the project.')
    home.mkdir(mode=0o700,parents=True,exist_ok=True)
    box=home/hashlib.sha256(str(root).encode()).hexdigest()[:32]
    box.mkdir(mode=0o700,exist_ok=True);check_chain(box)
    return box


def save_json(path: Path,value: object) -> None:
    check_chain(path.parent)
    if path.exists(): no_link(path,regular=True)
    fd,tmp=tempfile.mkstemp(prefix='.pending-',dir=path.parent)
    try:
        with os.fdopen(fd,'wb') as f:
            f.write(canonical(value));f.flush();os.fsync(f.fileno())
        os.replace(tmp,path)
        if os.name!='nt':os.chmod(path,0o600)
    finally:
        if os.path.exists(tmp): os.unlink(tmp)


def load_json(path: Path, default: object = None) -> object:
    if not path.exists(): return default
    no_link(path,regular=True)
    if path.stat().st_size>MAX_RESPONSE:raise Refused('Oversized local state.')
    return json.loads(path.read_text('utf-8'))


def origin_url(value: str) -> str:
    u=urllib.parse.urlsplit(value)
    if u.scheme!='https' or not u.hostname or u.username or u.password or u.path not in {'','/'} or u.query or u.fragment or u.port not in {None,443}:
        raise Refused('Site must be an exact HTTPS origin without credentials, path or query.')
    return 'https://'+u.netloc.lower()


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self,req,fp,code,msg,headers,newurl):
        raise Refused('Redirect refused: credentials are never forwarded to another route or host.')


class Connection:
    def __init__(self,origin: str,project_id: str,token: str):
        self.origin=origin_url(origin)
        if not re.fullmatch(r'[a-f0-9-]{36}',project_id): raise Refused('Invalid project ID.')
        if not re.fullmatch(r'lp_[a-f0-9]{64}',token): raise Refused('Use a project-scoped credential from People & agents, not a Hive or account credential.')
        self.project_id=project_id;self._token=token
        self.opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
    def request(self,suffix: str='',body: dict | None=None) -> dict:
        if not re.fullmatch(r'(?:snapshot|changes|receipts)?',suffix): raise Refused('Unsupported sync operation.')
        route='/api/projects/'+self.project_id+('/'+suffix if suffix else '')
        headers={'Authorization':'Bearer '+self._token,'Accept':'application/json','User-Agent':'Signum-Workspace/'+VERSION}
        if body is not None:headers['Content-Type']='application/json'
        req=urllib.request.Request(self.origin+route,data=canonical(body) if body is not None else None,headers=headers,method='POST' if body is not None else 'GET')
        try:
            with self.opener.open(req,timeout=20) as resp:
                data=resp.read(MAX_RESPONSE+1)
                if len(data)>MAX_RESPONSE:raise Refused('Oversized server response.')
                if 'application/json' not in resp.headers.get('Content-Type',''):raise Refused('Expected JSON, not a login page or download.')
                obj=json.loads(data)
                if not isinstance(obj,dict):raise Refused('Malformed response.')
                return obj
        except urllib.error.HTTPError as exc:
            raw=exc.read(8192)
            try:reason=json.loads(raw).get('error','Request refused')
            except (ValueError,AttributeError):reason='Request refused'
            # No raw headers, URL query, response source or credential logging.
            raise Refused('HTTP '+str(exc.code)+': '+str(reason)[:300]) from None
        except (urllib.error.URLError,TimeoutError) as exc:
            raise Refused('Connection failed; local files were not changed.') from exc


def validate_snapshot(value: object,project_id: str) -> dict:
    if not isinstance(value,dict) or value.get('format')!='signum-project/1' or value.get('project_id')!=project_id or type(value.get('version')) is not int or value['version']<1:
        raise Refused('Snapshot identity/version mismatch.')
    files=validated_files(value.get('files'))
    if value.get('digest')!=digest(files): raise Refused('Snapshot checksum mismatch.')
    # Intentionally discard every extra field, including commands or claimed trust.
    return {'format':'signum-project/1','project_id':project_id,'version':value['version'],'digest':digest(files),'files':files}


def inspect_snapshot(current: list, incoming: list) -> list[str]:
    a={f['path']:f['content'] for f in current};b={f['path']:f['content'] for f in incoming}
    return [('add' if p not in a else 'remove' if p not in b else 'change')+' '+p for p in sorted(set(a)|set(b)) if a.get(p)!=b.get(p)]


def apply_snapshot(root: Path,box: Path,base: dict | None,staged: dict) -> dict:
    incoming=validated_files(staged['files']); current=(selected_snapshot(root,box) if base and (box/'manifest.json').exists() else snapshot(root)) if any(root.iterdir()) else []
    if base and digest(current)!=base['digest']:raise Refused('Local work differs from the last synchronized base. Compare and preserve it before applying.')
    if not base and current:raise Refused('First apply requires an empty project folder; existing files are never silently overwritten.')
    old={f['path']:f['content'] for f in current};new={f['path']:f['content'] for f in incoming}
    # Preserve a complete source backup and plan before mutation. Unexpected files
    # are never removed. A partial IO failure leaves this recovery record intact.
    backup=box/('backup-'+str(time.time_ns())+'.json')
    save_json(backup,{'root':str(root),'files':current,'base':base,'target_digest':staged['digest']})
    for path in set(old)|set(new):
        portable_path(path);check_chain(root/path)
        if (root/path).exists():no_link(root/path,regular=True)
    if current and digest(selected_snapshot(root,box) if base and (box/'manifest.json').exists() else snapshot(root))!=digest(current):raise Refused('Source changed during apply preparation.')
    try:
        for path,text in new.items():
            target=root/path;target.parent.mkdir(parents=True,exist_ok=True);check_chain(target.parent)
            if target.exists() and read_source(root,path)!=old.get(path):raise Refused('Concurrent file edit: '+path)
            fd,tmp=tempfile.mkstemp(prefix='.signum-write-',dir=target.parent)
            try:
                with os.fdopen(fd,'wb') as f:f.write(text.encode('utf-8'));f.flush();os.fsync(f.fileno())
                check_chain(target);os.replace(tmp,target)
            finally:
                if os.path.exists(tmp):os.unlink(tmp)
        for path,text in old.items():
            if path not in new:
                if read_source(root,path)!=text:raise Refused('Concurrent file edit: '+path)
                (root/path).unlink()
        if digest(validated_files([{'path':f['path'],'content':read_source(root,f['path'])} for f in incoming]))!=staged['digest']:raise Refused('Final source verification failed.')
    except (OSError,Refused) as exc:
        raise Refused('Apply incomplete. Original source backup: '+str(backup)+'. '+str(exc)) from exc
    save_json(box/'base.json',staged);save_json(box/'manifest.json',{'paths':[f['path'] for f in incoming]});save_json(box/'trust.json',{})
    return {'applied':staged['version'],'digest':staged['digest'],'backup':str(backup),'execution_authorized':False}


def child_environment() -> dict[str,str]:
    # Inheriting arbitrary process variables would expose sync/API credentials.
    allowed={'SYSTEMROOT','WINDIR','COMSPEC','HOME','USERPROFILE','APPDATA','LOCALAPPDATA','TEMP','TMP','LANG','LC_ALL','TZ'}
    out={k:v for k,v in os.environ.items() if k.upper() in allowed}
    # Do not inherit a project-controlled PATH or dot-directory executable search.
    if os.name=='nt': out['PATH']=os.path.join(os.environ.get('SYSTEMROOT',r'C:\Windows'),'System32')
    else:out['PATH']='/usr/bin:/bin'
    return out


def execution_profile(name: str,executable: str,arguments: list[str],root: Path,timeout: int=60) -> dict:
    if not re.fullmatch(r'[a-z][a-z0-9_-]{0,30}',name):raise Refused('Use a short lowercase profile name.')
    supplied=Path(executable).expanduser()
    if not supplied.is_absolute():raise Refused('Use an absolute path to an installed executable.')
    # OS package-manager symlinks may resolve to a real executable, but the
    # resolved file must be outside the untrusted workspace and is fingerprinted.
    p=supplied.resolve(strict=True)
    if p==root or root in p.parents or not p.is_file():raise Refused('The executable must be installed outside this project.')
    if p.suffix.lower() in {'.cmd','.bat','.ps1','.sh'} or any(x in {'cmd','powershell','pwsh','bash','sh','dash','ash','ksh','zsh','fish','busybox','wscript','cscript','mshta'} for x in [p.stem.lower(),supplied.stem.lower()]):
        raise Refused('Shell/batch profiles are not supported. Choose the real compiler/runtime executable.')
    if len(arguments)>40 or any(not isinstance(a,str) or '\x00' in a or len(a)>4096 for a in arguments):raise Refused('Invalid argument list.')
    if not 1<=timeout<=300:raise Refused('Timeout must be 1–300 seconds.')
    return {'name':name,'executable':str(p),'arguments':arguments,'timeout':timeout,'executable_hash':hashlib.sha256(p.read_bytes()).hexdigest()}


def trust_material(root: Path,profile: dict) -> dict:
    p=execution_profile(profile['name'],profile['executable'],profile['arguments'],root,profile['timeout'])
    files=snapshot(root)
    return {'root':str(root),'source_digest':digest(files),'profile_digest':digest(p),'executable_hash':p['executable_hash']}


def run_local(root: Path,box: Path,name: str) -> dict:
    profiles=load_json(box/'profiles.json',{})
    if name not in profiles:raise Refused('Define a local profile first.')
    profile=profiles[name];material=trust_material(root,profile);trust=load_json(box/'trust.json',{})
    if trust.get('material')!=material or type(trust.get('expires_at')) not in {int,float} or trust['expires_at']<=time.time():
        raise Refused('Restricted mode: review and trust this exact source/profile locally first.')
    # Single-use approval; a site response can never refresh this record.
    save_json(box/'trust.json',{})
    report={'id':str(uuid.uuid4()),'digest':material['source_digest'],'profile':name,'locally_reported':True}
    started=time.monotonic()
    kwargs={'cwd':str(root),'env':child_environment(),'shell':False,'stdin':subprocess.DEVNULL}
    if os.name=='nt':kwargs['creationflags']=subprocess.CREATE_NEW_PROCESS_GROUP
    else:kwargs['start_new_session']=True
    # Output stays in the user's terminal; no capture, retention, or auto upload.
    proc=subprocess.Popen([profile['executable'],*profile['arguments']],**kwargs)
    timed_out=False
    try:code=proc.wait(timeout=profile['timeout'])
    except (subprocess.TimeoutExpired,KeyboardInterrupt):
        timed_out=True
        if os.name=='nt':proc.kill()
        else:
            try:os.killpg(proc.pid,signal.SIGKILL)
            except ProcessLookupError:pass
        code=proc.wait(timeout=10)
    report.update(exit_code=code,duration_ms=round((time.monotonic()-started)*1000),timed_out=timed_out)
    save_json(box/'last-receipt.json',report)
    return report


def confirmation(message: str,expected: str) -> None:
    if not sys.stdin.isatty():raise Refused('This operation requires an interactive local confirmation.')
    print(message)
    if input('Type '+expected+' to continue: ').strip()!=expected:raise Refused('Cancelled.')


def main(argv: list[str] | None=None) -> int:
    parser=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--folder',required=True,help='Dedicated source folder')
    sub=parser.add_subparsers(dest='command',required=True)
    init=sub.add_parser('init');init.add_argument('--site',required=True);init.add_argument('--project',required=True)
    sub.add_parser('status');sub.add_parser('fetch');sub.add_parser('apply')
    push=sub.add_parser('push');push.add_argument('--title',default='Local workspace changes')
    watch=sub.add_parser('watch');watch.add_argument('--interval',type=int,default=10)
    profile=sub.add_parser('profile');profile.add_argument('name');profile.add_argument('--executable',required=True);profile.add_argument('--timeout',type=int,default=60);profile.add_argument('--args',dest='arguments',nargs=argparse.REMAINDER,default=[])
    tr=sub.add_parser('trust');tr.add_argument('name')
    run=sub.add_parser('run');run.add_argument('name')
    sub.add_parser('untrust');sub.add_parser('report')
    include=sub.add_parser('include');include.add_argument('paths',nargs='+')
    args=parser.parse_args(argv);root=project_root(args.folder);box=state_home(root)
    if args.command=='init':
        site=origin_url(args.site)
        if not re.fullmatch(r'[a-f0-9-]{36}',args.project):raise Refused('Invalid project identifier.')
        if (box/'connection.json').exists():raise Refused('Already connected; use a different dedicated folder to avoid changing its trust identity.')
        confirmation('Connect only source synchronization to '+site+' project '+args.project+'. No commands or automatic downloads are authorized.','CONNECT')
        save_json(box/'connection.json',{'site':site,'project':args.project,'version':VERSION})
        print('Connected in restricted mode. No credential saved. Run fetch to stage a source snapshot.');return 0
    if args.command=='status':
        files=snapshot(root) if any(root.iterdir()) else [];base=load_json(box/'base.json',{});conn=load_json(box/'connection.json',{})
        print(json.dumps({'files':[f['path'] for f in files],'digest':digest(files),'base_version':base.get('version'),'site':conn.get('site'),'project':conn.get('project'),'execution':'local explicit one-use approval only','state_directory':str(box)},indent=2));return 0
    if args.command=='include':
        manifest=load_json(box/'manifest.json',{'paths':[]});paths=list(manifest['paths'])
        for rel in args.paths:
            portable_path(rel);read_source(root,rel)
            if rel not in paths:paths.append(rel)
        validated_files([{'path':p,'content':read_source(root,p)} for p in paths if (root/p).exists()])
        confirmation('Add these source paths to future uploads: '+', '.join(args.paths)+'. Generated output and other unselected files remain local.','INCLUDE')
        save_json(box/'manifest.json',{'paths':paths});save_json(box/'trust.json',{});print('Selection updated locally. Nothing uploaded.');return 0
    if args.command=='profile':
        a=args.arguments
        if a and a[0]=='--':a=a[1:]
        p=execution_profile(args.name,args.executable,a,root,args.timeout)
        confirmation('Define a local command: '+str([p['executable'],*p['arguments']])+'. This definition is never downloaded from the site.','DEFINE')
        ps=load_json(box/'profiles.json',{});ps[args.name]=p;save_json(box/'profiles.json',ps);save_json(box/'trust.json',{});print('Profile saved locally. Still restricted.');return 0
    if args.command=='trust':
        ps=load_json(box/'profiles.json',{})
        if args.name not in ps:raise Refused('Profile not defined.')
        material=trust_material(root,ps[args.name])
        confirmation('NOT A SANDBOX. The program can read/change your files and contact the network as your OS user. Use a disposable VM without secrets for unfamiliar code.\nProfile: '+str(ps[args.name])+'\nSource: '+material['source_digest']+'\nThis approves ONE run, expires after 10 minutes, and does not pin dependencies.','RUN '+material['source_digest'][:12])
        save_json(box/'trust.json',{'material':material,'expires_at':time.time()+600});print('One local run approved. Source/runtime/profile changes invalidate it.');return 0
    if args.command=='untrust':save_json(box/'trust.json',{});print('Local execution approval removed.');return 0
    if args.command=='run':print(json.dumps(run_local(root,box,args.name),indent=2));return 0
    conn=load_json(box/'connection.json')
    if not conn:raise Refused('Initialize this folder first.')
    if args.command=='apply':
        staged=load_json(box/'staged.json')
        if not staged:raise Refused('Fetch a snapshot first.')
        staged=validate_snapshot(staged,conn['project'])
        confirmation('Apply reviewed files from staged revision '+str(staged['version'])+'? Original source is backed up; no code runs.','APPLY '+staged['digest'][:12])
        print(json.dumps(apply_snapshot(root,box,load_json(box/'base.json'),staged),indent=2));return 0
    token=os.environ.get('SIGNUM_PROJECT_TOKEN') or getpass.getpass('Project-scoped credential (not saved): ')
    client=Connection(conn['site'],conn['project'],token)
    if args.command=='fetch':
        staged=validate_snapshot(client.request('snapshot'),conn['project']);save_json(box/'staged.json',staged)
        current=snapshot(root) if any(root.iterdir()) else []
        print('\n'.join(inspect_snapshot(current,staged['files'])) or 'No source differences.')
        print('Snapshot staged OUTSIDE the project. Review '+str(box/'staged.json')+'. Nothing was applied or executed.');return 0
    if args.command=='report':
        r=load_json(box/'last-receipt.json')
        if not r:raise Refused('No local receipt available.')
        confirmation('Share only this locally reported execution metadata (no output, paths or environment):\n'+json.dumps(r,indent=2),'SHARE RECEIPT')
        data={k:r[k] for k in ['id','digest','profile','exit_code','duration_ms']}
        print(json.dumps(client.request('receipts',data),indent=2));return 0
    def propose(title: str) -> None:
        base=load_json(box/'base.json')
        if not base:raise Refused('Fetch and explicitly apply the initial base before proposing local changes.')
        files=selected_snapshot(root,box);sha=digest(files)
        if sha==base['digest']:print('No source changes.');return
        key=digest({'base':base['digest'],'files':files})
        journal=load_json(box/'journal.json',{})
        if journal.get('key')==key and journal.get('id'):print('Already proposed: '+journal['id']);return
        data={'title':title,'reason':'Local source snapshot; no execution is implied.','base_version':base['version'],'base_digest':base['digest'],'files':files,'request_key':'local-'+key}
        save_json(box/'pending-source.json',data)
        result=client.request('changes',data)
        save_json(box/'journal.json',{'key':key,'id':result['id'],'digest':sha})
        print('Proposed change '+result['id']+'. Project source was NOT merged or run.')
    if args.command=='push':propose(args.title);return 0
    if args.command=='watch':
        if not 5<=args.interval<=300:raise Refused('Watch interval must be 5–300 seconds.')
        confirmation('Watch proposes changes from this selected folder. It never pulls, merges, installs, executes or uploads output. Stop with Ctrl+C.','WATCH')
        failures=0
        while True:
            try:
                propose('Local workspace update');failures=0
            except Refused as exc:
                if 'Connection failed' not in str(exc):raise
                failures+=1;print('Offline: pending source retained locally; no remote result is assumed.',file=sys.stderr)
            time.sleep(min(300,args.interval*(2**min(failures,4))))
    return 0

if __name__=='__main__':
    try:sys.exit(main())
    except (Refused,ValueError,OSError) as exc:
        print('Stopped: '+str(exc),file=sys.stderr);sys.exit(2)
    except KeyboardInterrupt:
        print('\nStopped. Local source preserved.',file=sys.stderr);sys.exit(130)
