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
2 changes: 1 addition & 1 deletion docs/agents/document-drafts.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ Supported inputs: UTF-8 `.md`, `.txt`, `.rst`, `.adoc`; at most 100 exact paths

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 `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.
This copy reuses the reviewed shared helper, SHA-256 `f9f919523098e8ac0a00d8082e593c0bfca969bb05163f5d1f957f31262c39fe`. 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.
2 changes: 1 addition & 1 deletion docs/agents/shared-context-and-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ An authorised task includes routine exact-file document handoffs and isolated ta

Prefer task-owned draft copies when document checks can run independently. Copy only exact authorised files outside the source checkout, keeping an immutable baseline, editable draft and source hashes in the existing task scratch area. These are proposals, not another canonical document or ledger. Reading/copying needs no takeover and acquires no original ownership; a busy owner need not block drafting. Preserve project, purpose, audience and data restrictions; never copy whole repositories, secret files or private sessions. Use an isolated worktree when repository tooling is required. Reconcile baseline/current original/draft, preserve conflicts and previous candidates, then pass a clean result with the current source hash to the existing integrator. A copy helper never grants permission to change originals; claims, project gates, serial application and a fresh source-hash check remain required.

If the owner cannot be reached, preserve a recoverable current version, prepare the edit in a separate branch/worktree, and use the project's existing exact-file takeover process citing this standing permission and the original task. Record the source version and one integration owner in the existing checkpoint. Silence and age never release claims; a missing/unreadable required log still blocks. No empty replacement logs or blanket directory takeovers.
If the owner cannot be reached, preserve a recoverable current version, prepare the edit in a separate branch/worktree, and use the project's existing exact-file takeover process citing this standing permission and the original task. Record the source version and one integration owner in the existing checkpoint. Silence and age never release claims. Follow the owning project's guards for absent logs; unreadable existing claim stores required by those guards still block. Never clear configuration or create an empty replacement log to conceal holds. No blanket directory takeovers.

One integrator compares original, latest and proposed versions, reconciles compatible edits and runs required checks. Re-read current content and claims before a serial write; changed inputs require fresh reconciliation. Use a supported lock/version precondition where available. A claim log is not an atomic lock. If serial integration is unavailable, retain a ready patch, report the technical dependency and continue independent work; repeated permission requests do not resolve it.

Expand Down
2 changes: 2 additions & 0 deletions scripts/document-drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ def reconcile(workspace, manifest, resolved):
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'
elif conflict:
review_reason = old.get('review_reason')
elif name in resolved:
raise ValueError('resolution inputs changed; inspect new merge')
else:
Expand Down
67 changes: 66 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 @@ -235,6 +235,71 @@ def test_both_inputs_changed_require_review_of_retained_candidate(self):
self.assertIn('both', self.reconcile().get('review_reason') or '')
self.assertFalse(self.reconcile(['note.md'])['conflict'])


def _assert_review_reason_survives_one_input_change(self, changed_input):
base = b''.join(f'line {i}\n'.encode() for i in range(1, 25))
self.document.write_bytes(base)
self.workspace = self.root / f'reason-{changed_input}'
self.manifest = d.prepare(self.source, self.workspace, 'reason', ['note.md'])
initial = self.reconcile()
candidate = self.workspace / initial['candidate']
candidate.write_bytes(base.replace(b'line 12\n', b'reviewed twelve\n'))
self.reconcile(['note.md'])

source = base.replace(b'line 21\n', b'peer twenty-one\n')
draft = base.replace(b'line 1\n', b'drafted one\n')
self.document.write_bytes(source)
self.draft(draft)
blocked = self.reconcile()
self.assertTrue(blocked['conflict'])
reason = blocked['review_reason']
self.assertIn('both source and draft changed', reason)
retained = {
key: (self.workspace / blocked[key]).read_bytes()
for key in ('candidate', 'source_snapshot', 'draft_snapshot')
}

if changed_input == 'source':
source = source.replace(b'line 23\n', b'later source twenty-three\n')
expected_later = b'later source twenty-three\n'
self.document.write_bytes(source)
else:
draft = draft.replace(b'line 3\n', b'later draft three\n')
expected_later = b'later draft three\n'
self.draft(draft)

result = self.reconcile()
self.assertNotIn('error', result)
self.assertTrue(result['conflict'])
self.assertEqual(reason, result['review_reason'])
proposed = (self.workspace / result['candidate']).read_bytes()
for expected in (
b'reviewed twelve\n', b'peer twenty-one\n',
b'drafted one\n', expected_later,
):
self.assertIn(expected, proposed)
self.assertNotIn(b'<<<<<<< ', proposed)
for key, contents in retained.items():
self.assertEqual(contents, (self.workspace / blocked[key]).read_bytes())
with self.assertRaisesRegex(ValueError, 'unresolved conflicts'):
d.export(self.workspace, self.manifest)
self.assertEqual(reason, self.reconcile()['review_reason'])

resolved = self.reconcile(['note.md'])
self.assertFalse(resolved['conflict'])
self.assertIsNone(resolved['review_reason'])
exported = d.export(self.workspace, self.manifest)
self.assertEqual(1, len(exported))
self.assertEqual(proposed, base64.b64decode(exported[0]['after_base64']))
self.assertEqual(d.digest(source), exported[0]['before_sha256'])
self.assertEqual(source, self.document.read_bytes())

def test_review_reason_survives_later_source_only_change(self):
self._assert_review_reason_survives_one_input_change('source')

def test_review_reason_survives_later_draft_only_change(self):
self._assert_review_reason_survives_one_input_change('draft')

def test_legacy_reconciliation_without_input_snapshots_fails_closed(self):
self.reconcile()
record_path = self.workspace / 'reconciliation.json'
Expand Down