diff --git a/docs/agents/document-drafts.md b/docs/agents/document-drafts.md index f5bf188..800a702 100644 --- a/docs/agents/document-drafts.md +++ b/docs/agents/document-drafts.md @@ -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. diff --git a/docs/agents/shared-context-and-memory.md b/docs/agents/shared-context-and-memory.md index 79f2484..a96b216 100644 --- a/docs/agents/shared-context-and-memory.md +++ b/docs/agents/shared-context-and-memory.md @@ -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. diff --git a/scripts/document-drafts.py b/scripts/document-drafts.py index bb373dc..2c4eef1 100644 --- a/scripts/document-drafts.py +++ b/scripts/document-drafts.py @@ -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: diff --git a/scripts/test-document-drafts.py b/scripts/test-document-drafts.py index cca211b..06a3f4c 100644 --- a/scripts/test-document-drafts.py +++ b/scripts/test-document-drafts.py @@ -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'