Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/agents/document-drafts.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ python3 scripts/document-drafts.py reconcile ../task-scratch/document-draft --so
python3 scripts/document-drafts.py export ../task-scratch/document-draft --source .
```

Replace the synthetic document argument with the actual document. Edit only its copy under `draft/`. Resume the same scratch workspace; preparation preserves existing drafts. Reconciliation compares the immutable baseline, latest original and draft, keeps earlier candidates, and merges compatible changes. Inspect and resolve reported conflicts, then acknowledge the candidate with `reconcile ... --resolved docs/example.md`. Editing an already clean candidate also requires explicit review; ordinarily edit the draft and reconcile again.
Replace the synthetic document argument with the actual document. Edit only its copy under `draft/`. Resume the same scratch workspace; preparation preserves existing drafts. Reconciliation keeps the baseline, source/draft input snapshots and earlier candidates. It carries reviewed candidate edits forward when an input changes and merges compatible changes. Inspect and resolve reported conflicts, then acknowledge the candidate with `reconcile ... --resolved docs/example.md`. When both source and draft move, export requires review of the retained inputs and new candidate even if the text has no conflict markers. Editing an already clean candidate also requires explicit review; ordinarily edit the draft and reconcile again.

Older workspaces can gain input snapshots by reconciling once while their inputs still match the last reconciliation. If those inputs have already changed and snapshots are unavailable or altered, export is blocked: preserve the old workspace and reconcile its reviewed work in a fresh task-owned workspace against the current source. Never discard the earlier candidate or request another takeover permission merely to prepare that recovery.

Export emits absolute `path`, current `before_sha256` and `after_base64` fields for the existing integrator. It is a proposal. The integrator must check claims and required gates, establish a serial write, compare the current original hash immediately before applying, and re-reconcile changed inputs. The helper has no apply command and grants no release, takeover, publication or additional authority. Keep governing-rule and substantive conflicts for their authorised decision-maker.

Supported inputs: UTF-8 `.md`, `.txt`, `.rst`, `.adoc`; at most 100 exact paths and 10 MiB total. Traversal, hidden/sensitive/configuration names, links/reparse points, changed baselines, stale exports and busy locks fail closed. Filename screening does not certify contents safe. Never copy secrets, private sessions or data outside the task purpose/audience. Never reclaim an uncertain lock by age.

Run the synthetic safety/merge checks with `python3 scripts/test-document-drafts.py`. The dedicated Document drafts workflow runs the same checks on a hosted Linux runner after relevant PR changes. That proves helper behaviour on that runner; it does not prove any chat loaded or followed the rules.

This copy reuses the reviewed shared helper, SHA-256 `44c3105999475492fd8fea7f21a188a3748ece28aedbc7fc3f571522ead9d947`. Maintain helper and tests together; compare reviewed hashes before updating another copy. No private instruction source or Windows installation is required on cloud hosts. A cloud checkout must select this feature branch or a main revision containing it; a PR does not change existing checkouts. Coordinate only task-safe metadata through supported native task tools with destination authority. If a host is offline, preserve the draft and continue independent authorised work; do not collect raw sessions or create a replacement chat service.
This copy reuses the reviewed shared helper, SHA-256 `83ea03fc3430b34207b95a0e0fca2aebdff42c1a9a32c2b8d59817513e31ef4c`. Maintain helper and tests together; compare reviewed hashes before updating another copy. No private instruction source or Windows installation is required on cloud hosts. A cloud checkout must select this feature branch or a main revision containing it; a PR does not change existing checkouts. Coordinate only task-safe metadata through supported native task tools with destination authority. If a host is offline, preserve the draft and continue independent authorised work; do not collect raw sessions or create a replacement chat service.
82 changes: 56 additions & 26 deletions scripts/document-drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,21 @@ def prepare(source, workspace, task, names):
return manifest


def merge_bytes(workspace, proposed, base, latest):
if latest == base or latest == proposed:
return proposed, False
if proposed == base:
return latest, False
with tempfile.TemporaryDirectory(dir=workspace) as temporary:
paths = [Path(temporary) / n for n in ('draft', 'base', 'source')]
for path, data in zip(paths, (proposed, base, latest)):
path.write_bytes(data)
result = subprocess.run(['git', 'merge-file', '-p', '-L', 'draft', '-L', 'base', '-L', 'latest', *map(str, paths)], capture_output=True, timeout=30)
if result.returncode < 0 or result.returncode > 127:
raise ValueError('git merge-file failed')
return result.stdout, result.returncode > 0


def reconcile(workspace, manifest, resolved):
manifest = load(workspace, manifest['source'])
previous_path = metadata_path(workspace, 'reconciliation.json')
Expand All @@ -188,35 +203,50 @@ def reconcile(workspace, manifest, resolved):
draft = read_text(safe_path(workspace / 'draft', name))
old = prior.get(name)
conflict = False
# Preserve the previous candidate, including manual resolution, whenever inputs match.
if old and old.get('before_sha256') == digest(source) and old.get('draft_sha256') == digest(draft) and 'candidate' in old:
merged = read_text(safe_path(workspace, old['candidate']))
conflict = old['conflict']
if not conflict and digest(merged) != old['candidate_sha256'] and name not in resolved:
raise ValueError('clean candidate edited; explicitly review with --resolved')
if name in resolved:
if any(line.startswith((b'<<<<<<< ', b'>>>>>>> ', b'||||||| ')) for line in merged.splitlines()):
raise ValueError('conflict markers remain')
conflict = False
review_reason = None
if old and 'candidate' in old:
previous_candidate = read_text(safe_path(workspace, old['candidate']))
source_changed = old.get('before_sha256') != digest(source)
draft_changed = old.get('draft_sha256') != digest(draft)
edited = digest(previous_candidate) != old['candidate_sha256']
if not source_changed and not draft_changed:
merged, conflict = previous_candidate, old['conflict']
review_reason = old.get('review_reason')
if not conflict and edited and name not in resolved:
raise ValueError('clean candidate edited; explicitly review with --resolved')
if name in resolved:
if any(line.startswith((b'<<<<<<< ', b'>>>>>>> ', b'||||||| ')) for line in merged.splitlines()):
raise ValueError('conflict markers remain')
conflict = False
review_reason = None
else:
if name in resolved:
raise ValueError('resolution inputs changed; inspect new merge')
if not old.get('source_snapshot') or not old.get('draft_snapshot'):
raise ValueError('previous input snapshots unavailable; preserve candidate and reconcile in a fresh task-owned workspace')
old_source = read_text(safe_path(workspace, old['source_snapshot']))
old_draft = read_text(safe_path(workspace, old['draft_snapshot']))
if digest(old_source) != old['before_sha256'] or digest(old_draft) != old['draft_sha256']:
raise ValueError('previous input snapshot changed')
merged, source_conflict = merge_bytes(workspace, previous_candidate, old_source, source)
merged, draft_conflict = merge_bytes(workspace, merged, old_draft, draft)
# Both inputs moving can make rebasing order-dependent. Keep
# all versions and require review before this proposal exports.
conflict = old['conflict'] or edited or source_conflict or draft_conflict or (source_changed and draft_changed)
if source_changed and draft_changed:
review_reason = 'both source and draft changed; inspect retained inputs and candidate before --resolved'
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
elif name in resolved:
raise ValueError('resolution inputs changed; inspect new merge')
elif source == base or source == draft:
merged = draft
elif draft == base:
merged = source
else:
with tempfile.TemporaryDirectory(dir=workspace) as temporary:
paths = [Path(temporary) / n for n in ('draft', 'base', 'source')]
for path, data in zip(paths, (draft, base, source)):
path.write_bytes(data)
result = subprocess.run(['git', 'merge-file', '-p', '-L', 'draft', '-L', 'base', '-L', 'latest', *map(str, paths)], capture_output=True, timeout=30)
if result.returncode < 0 or result.returncode > 127:
raise ValueError('git merge-file failed')
merged, conflict = result.stdout, result.returncode > 0
target = safe_path(generation, name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(merged)
output.append(dict(path=name, before_sha256=digest(source), draft_sha256=digest(draft), candidate=target.relative_to(workspace).as_posix(), candidate_sha256=digest(merged), conflict=conflict))
merged, conflict = merge_bytes(workspace, draft, base, source)
snapshots = {}
for folder, data in [('candidate', merged), ('source-input', source), ('draft-input', draft)]:
target = safe_path(generation / folder, name)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('xb') as stream:
stream.write(data)
snapshots[folder] = target.relative_to(workspace).as_posix()
output.append(dict(path=name, before_sha256=digest(source), draft_sha256=digest(draft), candidate=snapshots['candidate'], candidate_sha256=digest(merged), source_snapshot=snapshots['source-input'], draft_snapshot=snapshots['draft-input'], conflict=conflict, review_reason=review_reason))
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
failed = dict(prior.get(name, {}))
failed.update(path=name, error=str(error), conflict=True)
Expand Down
130 changes: 129 additions & 1 deletion scripts/test-document-drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class DocumentDraftTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.root = Path(self.temp.name).resolve()
self.source = self.root / 'source'
self.source.mkdir()
self.workspace = self.root / 'copies'
Expand Down Expand Up @@ -140,6 +140,134 @@ def test_clean_candidate_edit_requires_explicit_review(self):
self.assertEqual(b'human edit\n', base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64']))
self.assertEqual(b'human edit\n', candidate.read_bytes())

def test_acknowledged_candidate_survives_later_source_change(self):
base = b''.join(f'line {i}\n'.encode() for i in range(1, 16))
self.document.write_bytes(base)
self.workspace = self.root / 'reviewed-source'
self.manifest = d.prepare(self.source, self.workspace, 'reviewed', ['note.md'])
self.draft(base.replace(b'line 1\n', b'drafted one\n'))
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(candidate.read_bytes().replace(b'line 5\n', b'reviewed five\n'))
self.assertFalse(self.reconcile(['note.md'])['conflict'])
latest = base.replace(b'line 12\n', b'latest twelve\n')
self.document.write_bytes(latest)
result = self.reconcile()
self.assertFalse(result['conflict'], result)
proposed = base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64'])
self.assertIn(b'reviewed five\n', proposed)
self.assertIn(b'latest twelve\n', proposed)
self.assertIn(b'drafted one\n', proposed)
self.assertEqual(latest, self.document.read_bytes())
self.assertIn(b'reviewed five\n', candidate.read_bytes())
latest = latest.replace(b'line 15\n', b'latest fifteen\n')
self.document.write_bytes(latest)
self.assertFalse(self.reconcile()['conflict'])
proposed = base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64'])
self.assertIn(b'reviewed five\n', proposed)
self.assertIn(b'latest twelve\n', proposed)
self.assertIn(b'latest fifteen\n', proposed)

def test_acknowledged_candidate_survives_later_draft_change(self):
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(self.original.replace(b'two\n', b'reviewed two\n'))
self.assertFalse(self.reconcile(['note.md'])['conflict'])
self.draft(self.original.replace(b'five\n', b'drafted five\n'))
result = self.reconcile()
self.assertFalse(result['conflict'], result)
proposed = base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64'])
self.assertIn(b'reviewed two\n', proposed)
self.assertIn(b'drafted five\n', proposed)

def test_reviewed_edit_conflicts_with_later_overlapping_source(self):
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(self.original.replace(b'two\n', b'reviewed two\n'))
self.reconcile(['note.md'])
self.document.write_bytes(self.original.replace(b'two\n', b'peer two\n'))
result = self.reconcile()
self.assertTrue(result['conflict'])
with self.assertRaises(ValueError):
d.export(self.workspace, self.manifest)
self.assertIn(b'reviewed two', (self.workspace / result['candidate']).read_bytes())

def test_source_revert_is_preserved_with_reviewed_edit(self):
base = b''.join(f'line {i}\n'.encode() for i in range(1, 16))
self.document.write_bytes(base)
self.workspace = self.root / 'source-revert'
self.manifest = d.prepare(self.source, self.workspace, 'revert', ['note.md'])
self.document.write_bytes(base.replace(b'line 1\n', b'peer one\n'))
self.draft(base.replace(b'line 12\n', b'drafted twelve\n'))
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(candidate.read_bytes().replace(b'line 6\n', b'reviewed six\n'))
self.reconcile(['note.md'])
self.document.write_bytes(base)
self.assertFalse(self.reconcile()['conflict'])
proposed = base64.b64decode(d.export(self.workspace, self.manifest)[0]['after_base64'])
self.assertIn(b'line 1\n', proposed)
self.assertNotIn(b'peer one\n', proposed)
self.assertIn(b'reviewed six\n', proposed)
self.assertIn(b'drafted twelve\n', proposed)

def test_both_inputs_changed_require_review_of_retained_candidate(self):
base = b''.join(f'line {i}\n'.encode() for i in range(1, 19))
self.document.write_bytes(base)
self.workspace = self.root / 'both-inputs'
self.manifest = d.prepare(self.source, self.workspace, 'both', ['note.md'])
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(base.replace(b'line 8\n', b'reviewed eight\n'))
self.reconcile(['note.md'])
self.document.write_bytes(base.replace(b'line 17\n', b'peer seventeen\n'))
self.draft(base.replace(b'line 1\n', b'drafted one\n'))
result = self.reconcile()
self.assertTrue(result['conflict'])
self.assertIn('both', result.get('review_reason', ''))
with self.assertRaises(ValueError):
d.export(self.workspace, self.manifest)
proposed = (self.workspace / result['candidate']).read_bytes()
self.assertIn(b'reviewed eight\n', proposed)
self.assertIn(b'peer seventeen\n', proposed)
self.assertIn(b'drafted one\n', proposed)
self.assertNotIn(b'<<<<<<< ', proposed)
self.assertIn('both', self.reconcile().get('review_reason') or '')
self.assertFalse(self.reconcile(['note.md'])['conflict'])

def test_legacy_reconciliation_without_input_snapshots_fails_closed(self):
self.reconcile()
record_path = self.workspace / 'reconciliation.json'
record = json.loads(record_path.read_text())
record['files'][0].pop('source_snapshot', None)
record['files'][0].pop('draft_snapshot', None)
record_path.write_text(json.dumps(record))
self.document.write_bytes(self.original + b'peer change\n')
self.assertIn('snapshot', self.reconcile().get('error', ''))
with self.assertRaises(ValueError):
d.export(self.workspace, self.manifest)

def test_changed_input_snapshot_blocks_rebase_and_export(self):
initial = self.reconcile()
snapshot = self.workspace / initial['source_snapshot']
snapshot.write_bytes(b'tampered earlier source\n')
self.document.write_bytes(self.original + b'new source\n')
self.assertIn('snapshot changed', self.reconcile().get('error', ''))
with self.assertRaises(ValueError):
d.export(self.workspace, self.manifest)

def test_both_input_cancellation_retains_versions_for_explicit_review(self):
self.draft(self.original.replace(b'three\n', b'old draft three\n'))
self.reconcile()
self.document.write_bytes(self.original.replace(b'three\n', b'old draft three\n'))
self.draft(self.original)
result = self.reconcile()
self.assertTrue(result['conflict'])
with self.assertRaises(ValueError):
d.export(self.workspace, self.manifest)
self.assertIn(b'old draft three\n', (self.workspace / result['source_snapshot']).read_bytes())
self.assertEqual(self.original, (self.workspace / result['draft_snapshot']).read_bytes())

def test_multiple_real_git_conflicts_are_preserved(self):
base = b'first\n' + b'unchanged\n' * 12 + b'last\n'
# Fresh workspace preserves the actual baseline for this fixture.
Expand Down
Loading