harness-everything 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/ci.yml +0 -82
- package/.github/workflows/doc-governance.yml +91 -0
- package/.github/workflows/release.yml +5 -2
- package/CHANGELOG.md +30 -0
- package/ci/mechanism-2ax-doc-lifecycle.test.js +142 -12
- package/ci/mechanism-prb-outline-review.test.js +965 -47
- package/docs/doc-workflow-reconciliation.md +47 -1
- package/docs/document-lifecycle.md +19 -0
- package/docs/repository-contract.md +1 -0
- package/hooks/scripts/lib/doc-reconciliation-provider.js +333 -9
- package/hooks/scripts/state-persist.js +5 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +333 -9
- package/plugins/harness-everything/hooks/scripts/state-persist.js +5 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
- package/scripts/doc-governance-guard.js +31 -8
|
@@ -1260,7 +1260,53 @@ flowchart LR
|
|
|
1260
1260
|
| Worker and D | The dispatch key is `(session_id, tool_use_id)`. Exactly one matching Task/Agent result must carry `tool_response.status === 'completed'`; its observed `tool_response.agentId` must map to exactly one `SubagentStart` and one `SubagentStop` with the same `session_id` and `agent_id`. Require PreToolUse < SubagentStart < SubagentStop in journal order, but do not order the completed result against Stop. Both worker events need exact canonical `cwd === workerRoot`. `prompt_id` and `parent_tool_use_id` are not correlation keys; `interrupted` is not completion proof. Async launches, failures, cancellation, missing/duplicate events, replayed IDs, worktree isolation and root mismatch stay unresolved. D is checked against the current candidate separately from O. O must be a strict ancestor of D. A squash may retain the cycle only when a coordinator correspondence record proves identical target blobs between retained pre-merge D and the merged tree; changed bytes need fresh final quality review. |
|
|
1261
1261
|
| Checker outcome | `outline-confirmation` has its own pass/unresolved lane. Generic command success and waivers never satisfy it. No D is incomplete/unresolved. Its result is separate from the aggregate result over every MUST derived from original B→D; an outline pass never waives lint, conflict, quality review, requirement-sync or other MUSTs. |
|
|
1262
1262
|
|
|
1263
|
-
Capture and validation failures stay fail-open to host tools and withhold evidence credit.
|
|
1263
|
+
Capture and validation failures stay fail-open to host tools and withhold evidence credit. Writer activity is decided per captured record by the phase-aware policy below (see [Writer policy](#writer-policy-phase-aware)); anything it does not allow stays `outline-writer-activity-ambiguous`. A same-root subagent start is unresolved as a competing writer when it may overlap the exclusive draft interval, from accepted dispatch PreToolUse through the uniquely correlated draft worker’s Stop. A uniquely identified agent whose Start and unique Stop both precede dispatch, or whose Start occurs strictly after that worker Stop, does not establish overlap by its Start alone. Missing, duplicated or inconsistent lifecycle evidence stays unresolved. This exception does not authorize writes outside the correlated draft worker/root or relax independent review, artifact identity or final-byte validation.
|
|
1264
|
+
|
|
1265
|
+
### Writer policy (phase-aware)
|
|
1266
|
+
|
|
1267
|
+
Each captured writer record is decided by principal × phase × operation × target. Captured identity and journal position are the only inputs; a command string never decides on its own. This replaces the earlier blanket rule that rejected any writer other than the draft worker.
|
|
1268
|
+
|
|
1269
|
+
```mermaid
|
|
1270
|
+
flowchart LR
|
|
1271
|
+
R["captured writer record"] --> P{"journal phase"}
|
|
1272
|
+
P -->|"before reviewer launch"| B["B→O: coordinator writes or git add/commit of the pinned target"]
|
|
1273
|
+
P -->|"reviewer launch to its Stop"| V["review: no writer"]
|
|
1274
|
+
P -->|"review Stop to dispatch"| H["review→dispatch: no writer (W3 unverifiable)"]
|
|
1275
|
+
P -->|"dispatch to worker Stop"| D["draft: correlated worker in its root"]
|
|
1276
|
+
P -->|"after worker Stop"| A["after D: no writer"]
|
|
1277
|
+
B --> S["O commit must change exactly the pinned target(s) against B"]
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
| Phase | Principal | Operation and target | Result |
|
|
1281
|
+
|---|---|---|---|
|
|
1282
|
+
| Before reviewer launch | Coordinator: no `agent_id`, author `session_id` | `Write`, `Edit` or `apply_patch` whose every path is a pinned target | Allowed |
|
|
1283
|
+
| Before reviewer launch | Coordinator | `git add [--] <literal pinned file>...` or `git commit -m <message>`; R11-1 (b): record before review, validate at dispatch; uncaptured/pre-B index effects are UNCHECKED | Allowed only under this grammar; unbound or unverified index effects receive no credit |
|
|
1284
|
+
| Before reviewer launch | Any | Any other write, chained or unknown shell command, or a path that is not the target | `outline-writer-activity-ambiguous` |
|
|
1285
|
+
| Review | Reviewer or any other agent | Any writer | Ambiguous |
|
|
1286
|
+
| Review to dispatch | Coordinator | Any write, including handoff, lease, pin and merge-correspondence files | Ambiguous |
|
|
1287
|
+
| Dispatch to worker Stop | Correlated worker, `cwd` equal to the worker root | Any writer | Allowed |
|
|
1288
|
+
| Dispatch to worker Stop | Other agent or coordinator | Any writer | Ambiguous; an overlapping same-root start is `outline-competing-writer-start` |
|
|
1289
|
+
| After worker Stop | Any | Any writer | Ambiguous |
|
|
1290
|
+
|
|
1291
|
+
- The reviewer launch is the first journal record that is the reviewer's own, or the launch result sharing its `tool_use_id`. The review ends at the reviewer's `SubagentStop`.
|
|
1292
|
+
- Write-family records carry `writeTargets` (the `file_path`, or the `apply_patch` headers). A record without a target is ambiguous.
|
|
1293
|
+
- **Exact target spelling.** For pinned `T` under captured root `R`, credit requires every extracted file-tool or Git-add operand to match `T`, `R/T` or product-canonical `C/T` byte-for-byte (Windows native-separator forms are allowed only for file tools), with captured cwd exactly `R` or `C`; normalization, folding, expansion and URL decoding never create identity. Other host renderings remain UNCHECKED without retained payload.
|
|
1294
|
+
- **F1 symlink boundary.** Coordinator writer credit requires an in-root path with no symlink components and a regular current leaf (Git add also requires a regular leaf at PreToolUse); O citations are parsed only from an exact pinned-tree `blob` entry of mode `100644` or `100755`. The PreToolUse topology snapshot does not lock the path through the operation, so any unbound operation-time state is UNCHECKED; locally rewritten events remain within the F13 limit.
|
|
1295
|
+
- The O commit scope check requires `git diff --name-only B O` to equal the pinned target paths, otherwise `outline-commit-scope-invalid`.
|
|
1296
|
+
- **Pre-review Git identity (W2).** This is the immutable Git object identity of Outline O, taken before independent review. It is not the Git author's name or email. Harness records `{baselineCommit, outlineCommit, outlineTree, targetPaths, targetBlobs}` (per target: `blobOid` and `storedBytesSha256`) at each `SubagentStart` inside an outline task. The reviewer's launch record therefore predates its review, and the reviewer does not echo any identity. Dispatch requires the handoff identity to equal the reviewer's record exactly; any difference is `outline-identity-mismatch`, and a fresh review is needed for a new O.
|
|
1297
|
+
|
|
1298
|
+
```mermaid
|
|
1299
|
+
flowchart LR
|
|
1300
|
+
S["reviewer SubagentStart<br/>records O identity"] --> R["review<br/>bound to that record"]
|
|
1301
|
+
R --> D{"dispatch: handoff identity equal?"}
|
|
1302
|
+
D -->|"yes"| W["worker starts from clean O"]
|
|
1303
|
+
D -->|"no: amend, replacement, tree or target change"| X["outline-identity-mismatch: fresh review"]
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
- Suggested contract wording (owner, #341 comment 6708822276): "Exact task and target-path binding, captured writer operations, and immutable Outline O Git commit/tree/artifact identity validated before independent review and revalidated at dispatch."
|
|
1307
|
+
- **W3 stays rejected.** Handoff, lease, pin and merge-correspondence files are written outside capture and their content is not bound at capture time, so no coordinator state write is verifiable from evidence yet. The dispatch seam still validates those bytes; the writes themselves are rejected.
|
|
1308
|
+
- **W4 is unchanged.** Outline credit still needs a host-captured `SubagentStop` review and a correlated Task/Agent result from an agent distinct from the coordinator and worker. Only the Claude Code host is captured for outline credit; OpenCode and other reviewer providers are not.
|
|
1309
|
+
- Negative controls: the mechanism file injects each rejected case through the real hook, provider and `pilot.assess()` (`writer-policy negative controls`, `pre-review writer positive controls`).
|
|
1264
1310
|
|
|
1265
1311
|
Retries use a fresh attempt, token, clean worktree and immutable handoff; only one non-superseded leaf is current. An identical same-tool-ID/input callback may reuse its stored seam, but repeated PreToolUse records remain ambiguous for evidence credit; changed input or token reuse is unresolved. OpenCode and Codex remain unsupported for positive outline credit.
|
|
1266
1312
|
|
|
@@ -4,6 +4,25 @@ Contract for the documentation family: project docs configuration (D5), document
|
|
|
4
4
|
|
|
5
5
|
Documentation is a **consumer** of the #320 workflow/obligation abstractions. Nothing here is a second router, a standing maintenance loop or a cognitive lock: every check is a pure function that reports diagnostics.
|
|
6
6
|
|
|
7
|
+
## Governance tiers and placement (#401)
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart TD
|
|
11
|
+
A["Document or path change"] --> B{"T1 scope?"}
|
|
12
|
+
B -->|membership, policy or guard implementation| C["REVIEW_REQUIRED"]
|
|
13
|
+
B -->|ordinary content revision| D["No U5; run lifecycle, conflict and link checks"]
|
|
14
|
+
B -->|no| E{"T3 evidence or notes?"}
|
|
15
|
+
E -->|yes| F["No document-governance verdict; ordinary CI still applies"]
|
|
16
|
+
E -->|no| G["T2: ordinary CI; generated views use regenerate-equals"]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Tier or convention | Scope and handling |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| **T1 — strict** | ADRs, `docs/policy/`, future `docs/prd/`, named authoritative docs, root `AGENTS.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `PRIVACY.md` and `TERMS.md`, the guard workflow, guard/doc-check/lifecycle/resolver code, policy JSON, the error-ledger schema/generator, `package.json`, generic runners/helpers and policy-bearing fixtures. Membership, delete/rename, policy-map and governance-implementation changes require review. Ordinary content revisions do not require U5; normal lifecycle, conflict and link checks still apply. Root files are membership-only. |
|
|
22
|
+
| **T2 — ordinary CI** | Other docs and skills, plus generated views checked by regenerate-equals. No document-governance verdict. |
|
|
23
|
+
| **T3 — records** | Benchmark/evidence payloads, non-policy fixtures, error-ledger records, `CHANGELOG.md` and notes receive no document-governance verdict; ordinary CI, security and provenance checks still apply. The ledger schema/generator and policy-bearing fixtures remain T1. |
|
|
24
|
+
| **Placement** | Put new authoritative docs in `docs/adr/`, `docs/policy/` or future `docs/prd/`; put observations in `docs/records/`. Keep existing authoritative files such as `docs/architecture.md`, `docs/doc-workflow-reconciliation.md` and this contract as named exceptions. No existing files move in this slice. |
|
|
25
|
+
|
|
7
26
|
## Scope of this slice (original 2026-10-20 checkpoint; Phase 3b/3c status updated 2026-10-25)
|
|
8
27
|
|
|
9
28
|
This table distinguishes merged mechanism code from end-to-end workflow integration. The last reviewed `main` commit is `29b1d65b398e2622af97bf067078373b7fa76b26` (v0.36.2). That release commit carries `[skip ci]` and only bumps package/distribution versions on top of `e5d22584e243d5b5e587ee0279625e97a9033160`, whose full-gate `CI` run [39374474269](https://github.com/dyphn1/Harness-everything/actions/runs/39374474269) completed with every job succeeded. The historical Windows System One resident teardown (`S1-RS15`) was observed in attempt 1 of [run 39175240370](https://github.com/dyphn1/Harness-everything/actions/runs/39175240370) on `main@75e04359ae5bf11a41739f8eb04d25a76ee8f3a7`; that run completed successfully on attempt 2 and says nothing about `29b1d65`. Treat the resident teardown as a known platform limitation only when a current run on the exact SHA reproduces it, and require an all-green full-gate run for that SHA before calling any baseline green.
|
|
@@ -20,6 +20,7 @@ This page is generated from checked-in runtime and workflow sources. Do not edit
|
|
|
20
20
|
| --- | --- | --- | --- |
|
|
21
21
|
| `behavioral-evals.yml` | 24 | schedule, workflow_dispatch | n/a |
|
|
22
22
|
| `ci.yml` | 22, 24 | workflow_call | `test`, `test:mutations`, `test:release:semantics`, `waza:check`, `test:consistency`, `test:references`, `test:release`, `test:collision`, `test:plugin:openai`, `test:plugin:submission`, `test:platform:compatibility`, `test:portability` |
|
|
23
|
+
| `doc-governance.yml` | 24 | workflow_call | n/a |
|
|
23
24
|
| `release.yml` | 24 | workflow_dispatch | n/a |
|
|
24
25
|
|
|
25
26
|
## Drift prevention
|
|
@@ -68,6 +68,7 @@ const OUTLINE_CAPTURE_MAPPING = Object.freeze({
|
|
|
68
68
|
const OUTLINE_SEAM_SCHEMA = 'harness.outline-dispatch-seam.v1';
|
|
69
69
|
const OUTLINE_HANDOFF_SCHEMA = 'harness.outline-handoff.v1';
|
|
70
70
|
const OUTLINE_LEASE_SCHEMA = 'harness.outline-writer-lease.v1';
|
|
71
|
+
const OUTLINE_WRITE_TOOLS = ['Write', 'Edit', 'apply_patch'];
|
|
71
72
|
const OUTLINE_INVENTORY_SOURCE = '/Users/daniel.chang/Desktop/Skills/Harness-everything/.worktrees/packets/adr-e1-2026-10-27/report-inv.md';
|
|
72
73
|
const OUTLINE_INVENTORY_SHA256 = '2741bd4514d2cd68f57d93a2b3ce94cbe597ca62d30e26f0ca58e882df5b03c6';
|
|
73
74
|
const OUTLINE_INVENTORY_PIN = Object.freeze({
|
|
@@ -960,6 +961,47 @@ function outlineHandoffDirectory(sessionDir, taskId) {
|
|
|
960
961
|
return path.join(sessionDir, 'doc-reconciliation', 'outline-confirmation', taskId);
|
|
961
962
|
}
|
|
962
963
|
|
|
964
|
+
// #341 step 1: Harness records O's identity at each SubagentStart inside an outline task. A reviewer's launch record predates
|
|
965
|
+
// its review, so the identity it carries is the O the reviewer evaluated; the record is not taken from the reviewer's prose.
|
|
966
|
+
function captureOutlineStartIdentity({ sessionDir } = {}) {
|
|
967
|
+
const taskState = outlineTaskState(sessionDir);
|
|
968
|
+
if (taskState.state !== 'accepted') return null;
|
|
969
|
+
const root = taskState.baseline.root;
|
|
970
|
+
try {
|
|
971
|
+
const commit = outlineGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']).trim();
|
|
972
|
+
const tree = outlineGit(root, ['rev-parse', '--verify', 'HEAD^{tree}']).trim();
|
|
973
|
+
const targetPaths = outlineGit(root, ['diff', '--name-only', '--no-renames', '-z', taskState.baseline.commit, commit])
|
|
974
|
+
.split('\0').filter(Boolean).sort(compareKeys);
|
|
975
|
+
const targetBlobs = {};
|
|
976
|
+
for (const target of targetPaths) {
|
|
977
|
+
targetBlobs[target] = { blobOid: outlineGit(root, ['rev-parse', `${commit}:${target}`]).trim(),
|
|
978
|
+
storedBytesSha256: outlineSha(outlineGit(root, ['show', `${commit}:${target}`], true)) };
|
|
979
|
+
}
|
|
980
|
+
return { state: 'accepted', identity: { baselineCommit: taskState.baseline.commit, outlineCommit: commit,
|
|
981
|
+
outlineTree: tree, targetPaths, targetBlobs } };
|
|
982
|
+
} catch {
|
|
983
|
+
return { state: 'unresolved', reason: 'outline-identity-unavailable' };
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// The reviewer has exactly one launch record, it precedes the review Stop, and its identity equals the handoff's O identity.
|
|
988
|
+
function validateOutlineIdentity({ handoff, captures } = {}) {
|
|
989
|
+
const refs = (Array.isArray(captures) ? captures : []).filter(recordOf);
|
|
990
|
+
const stop = refs.find(item => item.ref === handoff.reviewCaptureRef && item.event === 'SubagentStop');
|
|
991
|
+
if (!stop || !textOf(stop.agent_id)) return { state: 'unresolved', reason: 'outline-review-capture-missing-or-ambiguous' };
|
|
992
|
+
const starts = refs.filter(item => item.event === 'SubagentStart' && item.agent_id === stop.agent_id);
|
|
993
|
+
if (starts.length !== 1) return { state: 'unresolved', reason: 'outline-review-result-missing-or-ambiguous' };
|
|
994
|
+
if (refs.indexOf(starts[0]) > refs.indexOf(stop)) return { state: 'unresolved', reason: 'outline-identity-mismatch' };
|
|
995
|
+
const recorded = starts[0].outlineIdentity;
|
|
996
|
+
if (!recordOf(recorded) || recorded.state !== 'accepted' || !recordOf(recorded.identity)) {
|
|
997
|
+
return { state: 'unresolved', reason: 'outline-identity-unavailable' };
|
|
998
|
+
}
|
|
999
|
+
const current = { baselineCommit: handoff.baseline?.commit, outlineCommit: handoff.outline?.commit,
|
|
1000
|
+
outlineTree: handoff.outline?.tree, targetPaths: handoff.targetPaths, targetBlobs: handoff.outline?.targetBlobs };
|
|
1001
|
+
if (captureJson(recorded.identity) !== captureJson(current)) return { state: 'unresolved', reason: 'outline-identity-mismatch' };
|
|
1002
|
+
return { state: 'accepted', identityDigest: outlineSha(Buffer.from(captureJson(recorded.identity))) };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
963
1005
|
function validateOutlineReview({ handoff, attemptDir, captures, targetPaths, outlineDigests, inventory, sessionId } = {}) {
|
|
964
1006
|
const refs = Array.isArray(captures) ? captures : [];
|
|
965
1007
|
const matches = refs.filter(item => recordOf(item) && item.ref === handoff.reviewCaptureRef && item.event === 'SubagentStop');
|
|
@@ -1006,6 +1048,15 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
|
|
|
1006
1048
|
|| handoff.baseline.tree !== baseline.tree || handoff.configDigest !== taskBinding.configDigest) {
|
|
1007
1049
|
return { state: 'unresolved', reason: 'handoff-identity-mismatch' };
|
|
1008
1050
|
}
|
|
1051
|
+
// O must change exactly the declared targets. Checked before the identity so an extra file reports its scope reason.
|
|
1052
|
+
const changedAtO = outlineGit(baseline.root, ['diff', '--name-only', '--no-renames', '-z', baseline.commit, handoff.outline.commit])
|
|
1053
|
+
.split('\0').filter(Boolean).sort(compareKeys);
|
|
1054
|
+
if (captureJson(changedAtO) !== captureJson(Array.isArray(handoff.targetPaths) ? [...handoff.targetPaths].sort(compareKeys) : null)) {
|
|
1055
|
+
return { state: 'unresolved', reason: 'outline-commit-scope-invalid' };
|
|
1056
|
+
}
|
|
1057
|
+
// Dispatch must still name the O the reviewer recorded: checked before the worktree, lease and review checks.
|
|
1058
|
+
const recordedIdentity = validateOutlineIdentity({ handoff, captures });
|
|
1059
|
+
if (recordedIdentity.state !== 'accepted') return recordedIdentity;
|
|
1009
1060
|
const markerRoot = canonicalOutlineRoot(parsed.workerRoot);
|
|
1010
1061
|
const declaredRoot = canonicalOutlineRoot(handoff.draftWorktreeRoot);
|
|
1011
1062
|
if (!sameCanonicalPath(markerRoot, declaredRoot)) return { state: 'unresolved', reason: 'worker-root-mismatch' };
|
|
@@ -1049,6 +1100,9 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
|
|
|
1049
1100
|
for (const target of targetPaths) {
|
|
1050
1101
|
const atBaseline = outlineGit(baseline.root, ['ls-tree', '-r', '--name-only', baseline.commit, '--', target]).trim();
|
|
1051
1102
|
if (atBaseline.split('\n').filter(Boolean).includes(target)) return { state: 'unresolved', reason: 'outline-target-existed-at-B' };
|
|
1103
|
+
if (!outlineGitTreeHasRegularBlob(markerRoot, handoff.outline.tree, target)) {
|
|
1104
|
+
return { state: 'unresolved', reason: 'outline-target-object-not-regular' };
|
|
1105
|
+
}
|
|
1052
1106
|
const bytes = outlineGit(markerRoot, ['show', `${handoff.outline.commit}:${target}`], true);
|
|
1053
1107
|
const blobOid = outlineGit(markerRoot, ['rev-parse', `${handoff.outline.commit}:${target}`]).trim();
|
|
1054
1108
|
const expected = outlineBlobs[target];
|
|
@@ -1087,7 +1141,7 @@ function validateOutlineHandoffAtDispatch({ payload, parsed, sessionDir, workspa
|
|
|
1087
1141
|
return { state: 'accepted', taskBinding, baseline, handoff, handoffDigest, attemptDir, markerRoot,
|
|
1088
1142
|
worktree: { root: markerRoot, head, tree, statusDigest, clean: true },
|
|
1089
1143
|
inventory, inventoryPinDigest: outlineSha(pinLoaded.bytes), row: citedRow, targetPaths, outlineDigests, review,
|
|
1090
|
-
leaseDigest: leaseLoaded.digest, leaseId: lease.id };
|
|
1144
|
+
leaseDigest: leaseLoaded.digest, leaseId: lease.id, identityDigest: recordedIdentity.identityDigest };
|
|
1091
1145
|
}
|
|
1092
1146
|
|
|
1093
1147
|
function outlineDispatchUnresolved(parsed, reason) {
|
|
@@ -1147,7 +1201,8 @@ function captureOutlineDispatch(input = {}) {
|
|
|
1147
1201
|
tokenDigest: parsed.tokenDigest, promptDigest: parsed.promptDigest, blockDigest: parsed.blockDigest,
|
|
1148
1202
|
dispatchInputDigest: parsed.dispatchInputDigest, handoffDigest: validated.handoffDigest,
|
|
1149
1203
|
reviewCaptureRef: validated.review.review.ref, reviewCaptureDigest: validated.review.captureDigest,
|
|
1150
|
-
reviewDigest: validated.review.reviewDigest,
|
|
1204
|
+
reviewDigest: validated.review.reviewDigest, outlineIdentityDigest: validated.identityDigest,
|
|
1205
|
+
inventoryPinDigest: validated.inventoryPinDigest,
|
|
1151
1206
|
writerLeaseDigest: validated.leaseDigest, writerLeaseId: validated.leaseId,
|
|
1152
1207
|
coordinatorPrincipal: payload.session_id, reviewerPrincipal: validated.review.reviewerPrincipal,
|
|
1153
1208
|
worktree: validated.worktree, validationReasons: [],
|
|
@@ -1384,11 +1439,208 @@ function outlineAncestry(root, ancestor, descendant) {
|
|
|
1384
1439
|
|
|
1385
1440
|
function possibleWriterCapture(item) {
|
|
1386
1441
|
if (!recordOf(item)) return false;
|
|
1387
|
-
if (
|
|
1442
|
+
if (OUTLINE_WRITE_TOOLS.includes(item.tool_name)) return true;
|
|
1388
1443
|
if (!['Bash', 'PowerShell', 'exec_command'].includes(item.tool_name)) return false;
|
|
1389
1444
|
return !['read-only', 'worktree-setup'].includes(classifyShell(item.command));
|
|
1390
1445
|
}
|
|
1391
1446
|
|
|
1447
|
+
function writeTargetsOf(toolName, toolInput) {
|
|
1448
|
+
if (toolName === 'apply_patch') {
|
|
1449
|
+
const patch = typeof toolInput === 'string' ? toolInput : (recordOf(toolInput) ? String(toolInput.patch || toolInput.input || '') : '');
|
|
1450
|
+
const targets = [];
|
|
1451
|
+
for (const match of patch.matchAll(/^\*\*\* ([^\r\n]*)(?:\r?$)/gm)) {
|
|
1452
|
+
const header = match[1];
|
|
1453
|
+
if (['Begin Patch', 'End Patch', 'End of File'].includes(header)) continue;
|
|
1454
|
+
const fileHeader = /^(?:Add File|Update File|Delete File|Move to): (.*)$/.exec(header);
|
|
1455
|
+
if (!fileHeader) return null;
|
|
1456
|
+
targets.push(fileHeader[1]);
|
|
1457
|
+
}
|
|
1458
|
+
return targets;
|
|
1459
|
+
}
|
|
1460
|
+
const file = recordOf(toolInput) ? toolInput.file_path || toolInput.path : undefined;
|
|
1461
|
+
return typeof file === 'string' && file.length ? [file] : [];
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function validOutlineTarget(target) {
|
|
1465
|
+
return typeof target === 'string' && target.length > 0 && !target.includes('\0') && !target.includes('\\')
|
|
1466
|
+
&& !target.includes(':') && !path.posix.isAbsolute(target)
|
|
1467
|
+
&& target.split('/').every(part => part && part !== '.' && part !== '..');
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function outlineRootSpellings(root) {
|
|
1471
|
+
try {
|
|
1472
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) return [];
|
|
1473
|
+
return [...new Set([root, canonicalOutlineRoot(root)])];
|
|
1474
|
+
} catch { return []; }
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function outlineCwdMatchesRoot(cwd, root, allowVerifiedAlias = false) {
|
|
1478
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
|
|
1479
|
+
if (outlineRootSpellings(root).includes(cwd)) return true;
|
|
1480
|
+
if (!allowVerifiedAlias) return false;
|
|
1481
|
+
try { return canonicalOutlineRoot(cwd) === canonicalOutlineRoot(root); }
|
|
1482
|
+
catch { return false; }
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function outlineCaptureRootSpellings(root, cwd) {
|
|
1486
|
+
const roots = outlineRootSpellings(root);
|
|
1487
|
+
// Hook setup may retain the canonical root while the captured cwd keeps its original spelling; this affects topology only.
|
|
1488
|
+
if (outlineCwdMatchesRoot(cwd, root, true)) roots.push(cwd);
|
|
1489
|
+
return [...new Set(roots)];
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function outlineRelativeSpellingKey(spelling, toolName) {
|
|
1493
|
+
if (validOutlineTarget(spelling)) return spelling;
|
|
1494
|
+
if (process.platform !== 'win32' || !OUTLINE_WRITE_TOOLS.includes(toolName)
|
|
1495
|
+
|| !spelling.includes(path.sep) || spelling.includes('/')) return null;
|
|
1496
|
+
const candidate = spelling.split(path.sep).join('/');
|
|
1497
|
+
return validOutlineTarget(candidate) ? candidate : null;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function outlineSpellingKey(spelling, { root, cwd, toolName } = {}) {
|
|
1501
|
+
if (typeof spelling !== 'string' || !spelling.length || spelling.includes('\0')
|
|
1502
|
+
|| !outlineCwdMatchesRoot(cwd, root, true)) return null;
|
|
1503
|
+
const relative = outlineRelativeSpellingKey(spelling, toolName);
|
|
1504
|
+
if (relative) return relative;
|
|
1505
|
+
for (const rootSpelling of outlineCaptureRootSpellings(root, cwd)) {
|
|
1506
|
+
const prefix = rootSpelling.endsWith(path.sep) ? rootSpelling : `${rootSpelling}${path.sep}`;
|
|
1507
|
+
if (!spelling.startsWith(prefix)) continue;
|
|
1508
|
+
return outlineRelativeSpellingKey(spelling.slice(prefix.length), toolName);
|
|
1509
|
+
}
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function outlineTargetSpellings(target, { root, toolName } = {}) {
|
|
1514
|
+
if (!validOutlineTarget(target)) return [];
|
|
1515
|
+
const targetSpellings = [target];
|
|
1516
|
+
if (process.platform === 'win32' && OUTLINE_WRITE_TOOLS.includes(toolName)) {
|
|
1517
|
+
targetSpellings.push(target.split('/').join(path.sep));
|
|
1518
|
+
}
|
|
1519
|
+
const spellings = [...targetSpellings];
|
|
1520
|
+
for (const rootSpelling of outlineRootSpellings(root)) {
|
|
1521
|
+
const prefix = rootSpelling.endsWith(path.sep) ? rootSpelling : `${rootSpelling}${path.sep}`;
|
|
1522
|
+
for (const renderedTarget of targetSpellings) spellings.push(prefix + renderedTarget);
|
|
1523
|
+
}
|
|
1524
|
+
return spellings;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// Capture can prove a lexical path/topology before the later handoff supplies the pin. Its candidate is non-authorizing;
|
|
1528
|
+
// the assessment call below must match the same raw spelling against the validated pinned target set.
|
|
1529
|
+
function recognizeOutlineTarget(spelling, { root, cwd, targets, toolName } = {}) {
|
|
1530
|
+
const hasPinnedTargets = Array.isArray(targets);
|
|
1531
|
+
if (typeof spelling !== 'string' || !spelling.length || spelling.includes('\0')
|
|
1532
|
+
|| !outlineCwdMatchesRoot(cwd, root, !hasPinnedTargets)) return null;
|
|
1533
|
+
if (Array.isArray(targets)) {
|
|
1534
|
+
const target = targets.find(item => outlineTargetSpellings(item, { root, toolName }).includes(spelling));
|
|
1535
|
+
return target ? { candidate: target, target } : null;
|
|
1536
|
+
}
|
|
1537
|
+
const candidate = outlineSpellingKey(spelling, { root, cwd, toolName });
|
|
1538
|
+
if (!candidate) return null;
|
|
1539
|
+
return { candidate, target: null };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function outlineBToOGitAllowed(command, { cwd, root, targets, topology }) {
|
|
1543
|
+
const parsed = safeTokenize(String(command || '').trim());
|
|
1544
|
+
if (!parsed.ok || parsed.tokens[0] !== 'git' || parsed.tokens.length < 2) return false;
|
|
1545
|
+
const [operation, ...args] = parsed.tokens.slice(1);
|
|
1546
|
+
if (operation === 'add') {
|
|
1547
|
+
const hasOperandMarker = args[0] === '--';
|
|
1548
|
+
const operands = hasOperandMarker ? args.slice(1) : args;
|
|
1549
|
+
if (!operands.length || !Array.isArray(topology) || topology.length !== operands.length) return false;
|
|
1550
|
+
if (!hasOperandMarker && operands.some(operand => operand.startsWith('-'))) return false;
|
|
1551
|
+
return operands.every((operand, index) => {
|
|
1552
|
+
if (!operand || operand.startsWith(':') || /[*?\[\]{}]/.test(operand) || /[\\/]$/.test(operand)) return false;
|
|
1553
|
+
const target = recognizeOutlineTarget(operand, { root, cwd, targets, toolName: 'Bash' })?.target;
|
|
1554
|
+
const proof = topology[index];
|
|
1555
|
+
return Boolean(target && proof?.path === target && proof.state === 'regular'
|
|
1556
|
+
&& outlineFilesystemProof(root, root, target, false).state === 'regular');
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
return operation === 'commit' && args.length === 2 && args[0] === '-m' && args[1] !== '';
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function outlineFilesystemProof(root, cwd, target, allowMissing) {
|
|
1563
|
+
let relativePath;
|
|
1564
|
+
try {
|
|
1565
|
+
const canonicalRoot = canonicalOutlineRoot(root);
|
|
1566
|
+
const base = canonicalOutlineRoot(cwd || root);
|
|
1567
|
+
if (base !== canonicalRoot) return { path: null, state: 'unresolved' };
|
|
1568
|
+
const relative = path.relative(canonicalRoot, path.resolve(canonicalRoot, target));
|
|
1569
|
+
if (!relative || path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) {
|
|
1570
|
+
return { path: null, state: 'unresolved' };
|
|
1571
|
+
}
|
|
1572
|
+
const absolute = path.resolve(canonicalRoot, relative);
|
|
1573
|
+
relativePath = path.relative(canonicalRoot, absolute);
|
|
1574
|
+
if (!relativePath || path.isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${path.sep}`)) {
|
|
1575
|
+
return { path: null, state: 'unresolved' };
|
|
1576
|
+
}
|
|
1577
|
+
const relativeKey = relativePath.split(path.sep).join('/');
|
|
1578
|
+
let current = canonicalRoot;
|
|
1579
|
+
const parts = relativePath.split(path.sep);
|
|
1580
|
+
for (let index = 0; index < parts.length; index++) {
|
|
1581
|
+
current = path.join(current, parts[index]);
|
|
1582
|
+
let stat;
|
|
1583
|
+
try { stat = fs.lstatSync(current); }
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
return error.code === 'ENOENT' && allowMissing
|
|
1586
|
+
? { path: relativeKey, state: 'absent' } : { path: relativeKey, state: 'unresolved' };
|
|
1587
|
+
}
|
|
1588
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 && !stat.isDirectory())) {
|
|
1589
|
+
return { path: relativeKey, state: 'unresolved' };
|
|
1590
|
+
}
|
|
1591
|
+
if (index === parts.length - 1) return { path: relativeKey, state: stat.isFile() ? 'regular' : 'unresolved' };
|
|
1592
|
+
}
|
|
1593
|
+
} catch { /* filesystem state is evidence only when every component can be checked */ }
|
|
1594
|
+
return { path: relativePath ? relativePath.split(path.sep).join('/') : null, state: 'unresolved' };
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function outlineGitTreeHasRegularBlob(root, tree, target) {
|
|
1598
|
+
try {
|
|
1599
|
+
const entries = outlineGit(root, ['--literal-pathspecs', 'ls-tree', '-z', tree, '--', target], true)
|
|
1600
|
+
.toString('utf8').split('\0').filter(Boolean);
|
|
1601
|
+
if (entries.length !== 1) return false;
|
|
1602
|
+
const tab = entries[0].indexOf('\t');
|
|
1603
|
+
if (tab < 0 || entries[0].slice(tab + 1) !== target) return false;
|
|
1604
|
+
const [mode, type, oid] = entries[0].slice(0, tab).split(/\s+/);
|
|
1605
|
+
return type === 'blob' && ['100644', '100755'].includes(mode) && /^[0-9a-f]+$/i.test(oid);
|
|
1606
|
+
} catch { return false; }
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Phase-aware writer decision (W1–W3). Principal, phase and operation-time path topology come from captured records.
|
|
1610
|
+
// Allowed: coordinator Write/Edit/apply_patch of exactly a pinned target before the reviewer lifecycle starts; coordinator
|
|
1611
|
+
// target-bound git add or simple git commit before review; the correlated draft worker
|
|
1612
|
+
// between dispatch and its Stop. Everything else, including any coordinator state write after review, stays ambiguous.
|
|
1613
|
+
function outlineWriterAllowed(item, { index, sessionId, root, targets, worker, workerRoot, reviewStartIndex, reviewStopIndex,
|
|
1614
|
+
dispatchIndex, workerStopIndex, captures }) {
|
|
1615
|
+
const phase = index < reviewStartIndex ? 'b-to-o' : index <= reviewStopIndex ? 'review'
|
|
1616
|
+
: index < dispatchIndex ? 'review-to-dispatch' : index <= workerStopIndex ? 'dispatch-to-draft' : 'after-draft';
|
|
1617
|
+
if (phase === 'dispatch-to-draft') return item.agent_id === worker.agent_id && sameCanonicalPath(item.cwd, workerRoot);
|
|
1618
|
+
if (phase !== 'b-to-o' || textOf(item.agent_id) || item.session_id !== sessionId) return false;
|
|
1619
|
+
if (item.event !== 'PreToolUse') {
|
|
1620
|
+
const pre = (Array.isArray(captures) ? captures : []).find(record => record.event === 'PreToolUse'
|
|
1621
|
+
&& record.session_id === item.session_id && record.tool_use_id === item.tool_use_id
|
|
1622
|
+
&& record.tool_name === item.tool_name && record.command === item.command
|
|
1623
|
+
&& captureJson(record.writeTargets || null) === captureJson(item.writeTargets || null)
|
|
1624
|
+
&& samePathKey(record.cwd || root, item.cwd || root));
|
|
1625
|
+
return Boolean(pre && outlineWriterAllowed(pre, { index: captures.indexOf(pre), sessionId, root, targets, worker, workerRoot,
|
|
1626
|
+
reviewStartIndex, reviewStopIndex, dispatchIndex, workerStopIndex, captures }));
|
|
1627
|
+
}
|
|
1628
|
+
if (OUTLINE_WRITE_TOOLS.includes(item.tool_name)) {
|
|
1629
|
+
const topology = item.outlineWriteTopology;
|
|
1630
|
+
return Array.isArray(item.writeTargets) && item.writeTargets.length > 0
|
|
1631
|
+
&& Array.isArray(topology) && topology.length === item.writeTargets.length
|
|
1632
|
+
&& item.writeTargets.every((file, index) => {
|
|
1633
|
+
const target = recognizeOutlineTarget(file, { root, cwd: item.cwd, targets, toolName: item.tool_name })?.target;
|
|
1634
|
+
const proof = topology[index];
|
|
1635
|
+
return Boolean(target && proof?.path === target && ['absent', 'regular'].includes(proof.state)
|
|
1636
|
+
&& outlineFilesystemProof(root, root, target, false).state === 'regular');
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
const cwd = item.cwd;
|
|
1640
|
+
return item.tool_name === 'Bash' && outlineCwdMatchesRoot(cwd, root)
|
|
1641
|
+
&& outlineBToOGitAllowed(item.command, { cwd, root, targets, topology: item.outlineWriteTopology });
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1392
1644
|
function sameCanonicalPath(left, right) {
|
|
1393
1645
|
try {
|
|
1394
1646
|
fs.statSync(left);
|
|
@@ -1418,7 +1670,10 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
|
|
|
1418
1670
|
const dispatch = cap?.outlineDispatch;
|
|
1419
1671
|
if (!recordOf(dispatch) || dispatch.schema !== OUTLINE_DISPATCH_SCHEMA || dispatch.state !== 'accepted'
|
|
1420
1672
|
|| !textOf(dispatch.seamRef) || dispatch.captureRef !== cap.ref || dispatch.toolUseId !== cap.tool_use_id) {
|
|
1421
|
-
|
|
1673
|
+
// The dispatch-time reason (for example outline-identity-mismatch) is kept beside the wrapper code, so the rejection names its cause.
|
|
1674
|
+
const reasons = recordOf(dispatch) && Array.isArray(dispatch.reasons) ? dispatch.reasons.filter(reason => /^[a-z0-9-]+$/.test(String(reason))) : [];
|
|
1675
|
+
return { status: 'rejected', diagnostics: [diagnostic('outline-pre-dispatch-seam-unresolved', { reason: 'outline-pre-dispatch-seam-unresolved' }),
|
|
1676
|
+
...reasons.map(reason => diagnostic(reason, { reason }))] };
|
|
1422
1677
|
}
|
|
1423
1678
|
if (cap.event !== 'PreToolUse' || !['Task', 'Agent'].includes(cap.tool_name)
|
|
1424
1679
|
|| cap.session_id !== safeInput.sessionId || cap.observedIdentity?.taskPin?.taskId !== safeInput.taskId) {
|
|
@@ -1475,6 +1730,9 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
|
|
|
1475
1730
|
const outlineDigests = {};
|
|
1476
1731
|
const reviewRows = {};
|
|
1477
1732
|
for (const target of targetPaths) {
|
|
1733
|
+
if (!outlineGitTreeHasRegularBlob(seam.worktree.root, handoff.outline.tree, target)) {
|
|
1734
|
+
return fail('outline-target-object-not-regular');
|
|
1735
|
+
}
|
|
1478
1736
|
const bytes = outlineGit(seam.worktree.root, ['show', `${handoff.outline.commit}:${target}`], true);
|
|
1479
1737
|
outlineDigests[target] = outlineSha(bytes);
|
|
1480
1738
|
const citation = parseOutlineCitation(bytes, target);
|
|
@@ -1527,9 +1785,44 @@ function resolveOutlineEvidence({ safeInput, capture, captures, request, originR
|
|
|
1527
1785
|
&& item.event === 'SubagentStart' && item.ref !== worker.ref && sameCanonicalPath(item.cwd, workerRoot)
|
|
1528
1786
|
&& !stoppedBeforeDispatch(item) && !startedAfterWorkerStop(item));
|
|
1529
1787
|
if (competingStarts.length) return fail('outline-competing-writer-start');
|
|
1530
|
-
|
|
1531
|
-
|
|
1788
|
+
// Reviewer lifecycle is exactly one Start and one Stop for its identity; a duplicate or missing record withholds credit.
|
|
1789
|
+
const reviewerLifecycle = event => journal.filter(item => recordOf(item) && item.event === event
|
|
1790
|
+
&& item.agent_id === review.reviewerPrincipal).length;
|
|
1791
|
+
if (reviewerLifecycle('SubagentStart') !== 1 || reviewerLifecycle('SubagentStop') !== 1) {
|
|
1792
|
+
return fail('outline-review-result-missing-or-ambiguous');
|
|
1793
|
+
}
|
|
1794
|
+
// The O the reviewer recorded at launch must still be the O dispatch and the handoff name (#341 step 1).
|
|
1795
|
+
const identityNow = validateOutlineIdentity({ handoff, captures });
|
|
1796
|
+
if (identityNow.state !== 'accepted' || identityNow.identityDigest !== seam.outlineIdentityDigest) {
|
|
1797
|
+
return fail(identityNow.reason || 'outline-identity-mismatch');
|
|
1798
|
+
}
|
|
1799
|
+
// Review window: the reviewer's first record (or its launch result) through its SubagentStop.
|
|
1800
|
+
const reviewLaunchIds = new Set(journal.filter(item => recordOf(item) && item.event === 'PostToolUse'
|
|
1801
|
+
&& ['Task', 'Agent'].includes(item.tool_name) && item.agent_id === review.reviewerPrincipal && textOf(item.tool_use_id))
|
|
1802
|
+
.map(item => item.tool_use_id));
|
|
1803
|
+
const writerWindow = {
|
|
1804
|
+
sessionId: safeInput.sessionId, root: taskState.baseline.root, targets: targetPaths, worker, workerRoot,
|
|
1805
|
+
captures: journal,
|
|
1806
|
+
reviewStartIndex: journal.findIndex(item => recordOf(item) && (item.agent_id === review.reviewerPrincipal
|
|
1807
|
+
|| (textOf(item.tool_use_id) && reviewLaunchIds.has(item.tool_use_id)))),
|
|
1808
|
+
reviewStopIndex: journal.indexOf(review.review), dispatchIndex, workerStopIndex: journal.indexOf(correlation.stop),
|
|
1809
|
+
};
|
|
1810
|
+
const unattributedWriters = journal.filter((item, index) => possibleWriterCapture(item)
|
|
1811
|
+
&& !outlineWriterAllowed(item, { ...writerWindow, index }));
|
|
1532
1812
|
if (unattributedWriters.length) return fail('outline-writer-activity-ambiguous');
|
|
1813
|
+
// The B→O commit is accepted only if O changes exactly the pinned target(s) relative to B.
|
|
1814
|
+
const oChanged = outlineGit(seam.worktree.root, ['diff', '--name-only', '--no-renames', '-z', taskState.baseline.commit,
|
|
1815
|
+
handoff.outline.commit]).split('\0').filter(Boolean).sort(compareKeys);
|
|
1816
|
+
if (captureJson(oChanged) !== captureJson([...targetPaths].sort(compareKeys))) return fail('outline-commit-scope-invalid');
|
|
1817
|
+
// The cumulative diff hides content committed then reverted inside B..O, so every commit (merges too, per parent) must touch only the targets.
|
|
1818
|
+
const targetSet = new Set(targetPaths);
|
|
1819
|
+
const rangeCommits = outlineGit(seam.worktree.root, ['rev-list', `${taskState.baseline.commit}..${handoff.outline.commit}`])
|
|
1820
|
+
.split('\n').filter(Boolean);
|
|
1821
|
+
for (const commit of rangeCommits) {
|
|
1822
|
+
const touched = outlineGit(seam.worktree.root, ['diff-tree', '-r', '--root', '-m', '--no-commit-id', '--name-only',
|
|
1823
|
+
'--no-renames', '-z', commit]).split('\0').filter(Boolean);
|
|
1824
|
+
if (touched.some(file => !targetSet.has(file))) return fail('outline-commit-scope-invalid');
|
|
1825
|
+
}
|
|
1533
1826
|
|
|
1534
1827
|
let candidateRoot;
|
|
1535
1828
|
try { candidateRoot = canonicalOutlineRoot(safeInput.workspaceRoot); }
|
|
@@ -2227,12 +2520,40 @@ function buildCaptureRecord(payload, eventName, workspaceRoot) {
|
|
|
2227
2520
|
responseDigest: captureDigest(toolResponse),
|
|
2228
2521
|
responseRef: `host-payload/${event}/${captureId}`,
|
|
2229
2522
|
};
|
|
2523
|
+
const parsedWriterCommand = event === 'PreToolUse' && payload.tool_name === 'Bash'
|
|
2524
|
+
? safeTokenize(record.command.trim()) : null;
|
|
2525
|
+
const writerMayNeedCwd = OUTLINE_WRITE_TOOLS.includes(payload.tool_name)
|
|
2526
|
+
|| (parsedWriterCommand?.ok && parsedWriterCommand.tokens[0] === 'git'
|
|
2527
|
+
&& ['add', 'commit'].includes(parsedWriterCommand.tokens[1]));
|
|
2230
2528
|
if (typeof payload.cwd === 'string' && payload.cwd) record.cwd = payload.cwd;
|
|
2231
|
-
else if (!['SubagentStart', 'SubagentStop'].includes(event)
|
|
2529
|
+
else if (!writerMayNeedCwd && !['SubagentStart', 'SubagentStop'].includes(event)
|
|
2530
|
+
&& typeof workspaceRoot === 'string' && workspaceRoot) record.cwd = workspaceRoot;
|
|
2232
2531
|
if (payload.prompt_id !== undefined) record.prompt_id = payload.prompt_id;
|
|
2233
2532
|
if (payload.parent_tool_use_id !== undefined) record.parent_tool_use_id = payload.parent_tool_use_id;
|
|
2234
2533
|
// The launch tool name separates the Agent/Task result surface and the subagent launch from executed tools (F4/F5).
|
|
2235
2534
|
if (typeof payload.tool_name === 'string') record.tool_name = payload.tool_name;
|
|
2535
|
+
// The write target and its PreToolUse topology snapshot are path evidence for W1/W2; absent for other tools.
|
|
2536
|
+
if (OUTLINE_WRITE_TOOLS.includes(payload.tool_name)) record.writeTargets = writeTargetsOf(payload.tool_name, payload.tool_input);
|
|
2537
|
+
// shortcut: this snapshot cannot close capture-to-operation races; upgrade if the host can bind path proof atomically.
|
|
2538
|
+
if (event === 'PreToolUse') {
|
|
2539
|
+
let topologyTargets = OUTLINE_WRITE_TOOLS.includes(payload.tool_name) ? record.writeTargets : null;
|
|
2540
|
+
let allowMissing = OUTLINE_WRITE_TOOLS.includes(payload.tool_name);
|
|
2541
|
+
if (payload.tool_name === 'Bash') {
|
|
2542
|
+
const parsed = safeTokenize(record.command.trim());
|
|
2543
|
+
if (parsed.ok && parsed.tokens[0] === 'git' && parsed.tokens[1] === 'add') {
|
|
2544
|
+
const args = parsed.tokens.slice(2);
|
|
2545
|
+
topologyTargets = args[0] === '--' ? args.slice(1) : args;
|
|
2546
|
+
allowMissing = false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
if (Array.isArray(topologyTargets)) {
|
|
2550
|
+
record.outlineWriteTopology = topologyTargets.map(target => {
|
|
2551
|
+
const recognized = recognizeOutlineTarget(target, { root: workspaceRoot, cwd: record.cwd, toolName: payload.tool_name });
|
|
2552
|
+
return recognized ? outlineFilesystemProof(workspaceRoot, record.cwd, recognized.candidate, allowMissing)
|
|
2553
|
+
: { path: null, state: 'unresolved' };
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2236
2557
|
if (outlinePre) {
|
|
2237
2558
|
if (Object.prototype.hasOwnProperty.call(toolInput, 'run_in_background')) record.run_in_background = toolInput.run_in_background;
|
|
2238
2559
|
record.isolationSet = Object.prototype.hasOwnProperty.call(toolInput, 'isolation');
|
|
@@ -2317,6 +2638,8 @@ function buildCaptureRecord(payload, eventName, workspaceRoot) {
|
|
|
2317
2638
|
// Each capture is one event file, created exclusively and never rewritten by this code. The compatibility journal
|
|
2318
2639
|
// `doc-reconciliation-capture.json` is rebuilt from those files, and readers compare the two. This detects accidental and
|
|
2319
2640
|
// journal-only edits. It does not stop the local agent from rewriting event files, which the owner's bound accepts (F13).
|
|
2641
|
+
// F13 includes rewriting the reviewer Start event's W2 `outlineIdentity`; W2 revalidation assumes authentic captured
|
|
2642
|
+
// events and does not authenticate locally rewritten capture state.
|
|
2320
2643
|
const EVENT_DIR = 'doc-reconciliation-capture';
|
|
2321
2644
|
const JOURNAL_FILE = 'doc-reconciliation-capture.json';
|
|
2322
2645
|
const EVENT_NAME = /^\d{8}\.json$/;
|
|
@@ -2410,8 +2733,9 @@ function reviewRecordIndependent(cap, captures, principals) {
|
|
|
2410
2733
|
module.exports = { createClaudeOriginProvider, createOpencodeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
|
|
2411
2734
|
registeredInstalledCheck: resolveDocCheckInvocation, installedDocCheck,
|
|
2412
2735
|
fileDigest, SHIPPED_DOC_CHECK_DIGEST, trustRisk, journalExecutors, reviewRecordIndependent,
|
|
2413
|
-
captureOutlineDispatch, writeImmutableEvent, rebuildJournalView, readCaptureView,
|
|
2414
|
-
|
|
2736
|
+
captureOutlineDispatch, captureOutlineStartIdentity, writeImmutableEvent, rebuildJournalView, readCaptureView,
|
|
2737
|
+
readOpencodeCaptureView, observedIdentityFor,
|
|
2738
|
+
_internals: { validateOutlineIdentity, captureJson, captureDigest, digestText, canonicalCapture, parseOutlineDispatchPrompt,
|
|
2415
2739
|
parseInventoryPinRows, validateOutlineInventoryPin, resolveInventoryRow, parseOutlineCitation, resolveOutlineAttempt,
|
|
2416
2740
|
captureOutlineDispatch, resolveOutlineEvidence, verifyOutlineMergeCorrespondence, validateOutlineCaptureQualification,
|
|
2417
2741
|
outlineCaptureMappingValid, correlateOutlineDispatch, OUTLINE_CAPTURE_MAPPING, OUTLINE_INVENTORY_PIN } };
|
|
@@ -148,6 +148,11 @@ function persistPilotCapture(payload, eventName, sessionId, root) {
|
|
|
148
148
|
// Observation-time identity: the workflow and pin in force when the host event was captured.
|
|
149
149
|
const observed = provider.observedIdentityFor(sessionDir);
|
|
150
150
|
if (observed) entry.observedIdentity = observed;
|
|
151
|
+
// #341 step 1: O's identity is taken at launch records, so a reviewer's identity predates its review.
|
|
152
|
+
if (eventName === 'SubagentStart') {
|
|
153
|
+
const outlineIdentity = provider.captureOutlineStartIdentity({ sessionDir });
|
|
154
|
+
if (outlineIdentity) entry.outlineIdentity = outlineIdentity;
|
|
155
|
+
}
|
|
151
156
|
if (eventName === 'PreToolUse' && (entry.tool_name === 'Agent' || entry.tool_name === 'Task')) {
|
|
152
157
|
try {
|
|
153
158
|
const existing = provider.readCaptureView(sessionDir);
|
package/package.json
CHANGED