agents-handoff 2.0.4 → 2.0.5

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/CHANGELOG.md CHANGED
@@ -15,6 +15,48 @@ version, and it is the version published to npm: the repository root is the
15
15
 
16
16
  Add entries under the matching heading as changes land.
17
17
 
18
+ ## [2.0.5] - 2026-10-09
19
+
20
+ ### Fixed
21
+
22
+ - **Real Claude Code and Codex exports parse.** The engine read text only from a top-level
23
+ `text`/`content`/`parts[]`, so a genuine Claude Code transcript (`message.content[]`) or a Codex
24
+ CLI rollout (`payload`) produced **zero turns** while `--harness claude-code` merely labelled the
25
+ result. Nested blocks, `message.content[]`, `parts[]`, `item.content[]`, `output` and the Codex
26
+ `payload` wrapper are read now, and a record whose every typed block is a reasoning block
27
+ classifies as `THOUGHT`.
28
+ - **A malformed transcript line is no longer dropped in silence.** It stops the build with
29
+ `exit 5`, naming the line number and reason. Dropping it produced a handoff that looked complete
30
+ and was missing part of the session; this restores the fail-closed rule the rest of the tool
31
+ already followed. `--allow-bad-lines` skips such lines deliberately and prints how many were
32
+ skipped.
33
+ - **`promote` enforces the evidence gate.** It ran the gate, printed the verdict and promoted
34
+ regardless (`void gateRun`), so a `REJECTED` handoff was stamped as promoted. It now refuses with
35
+ `exit 6`, names the failed checks, leaves the manifest unstamped, and records `promoted_gate`.
36
+ `--force` is the deliberate override, recorded as `FORCED` rather than `VERIFIED`.
37
+ - **A rebuild no longer destroys the evidence contract.** `HANDOFF.md` is regenerated on every
38
+ build, which erased the hand-authored `RESULT:`/`EVIDENCE:` block the gate reads — so growing a
39
+ session deleted the gate's own input. The block is carried across verbatim under its own heading
40
+ and hashed into `evidence_contract_sha256` on the manifest.
41
+ - `verify-gate` reports a rejection in its exit code (`6`), not only in its JSON.
42
+
43
+ ### Added
44
+
45
+ - Regression tests for all four defects above, driving the real CLI: a Claude Code transcript, a
46
+ Codex rollout, a mixed valid/corrupt source, a rebuild after a hand-authored contract, and the
47
+ promote / gate / `--force` path (39 tests, up from 34).
48
+
49
+ ### Fixed
50
+
51
+ - `skill.json` carried the previous release version (2.0.4) while `SKILL.md`, `package.json` and
52
+ the installer said 2.0.5 — a published contradiction with no runtime consumer to catch it,
53
+ even though this changelog tells readers the two manifests carry one release version. They now
54
+ agree, and the suite fails if they ever diverge again.
55
+ - `install/CHANGELOG.md` described the installer as a separate package whose first release was
56
+ published to npm; there is no separate installer package on the registry. It now states how
57
+ the installer actually ships (inside the root `agents-handoff` package), keeping the 1.x
58
+ entries as development history.
59
+
18
60
  ## [2.0.4] - 2026-10-09
19
61
 
20
62
  ### Added
@@ -216,6 +258,10 @@ documentation site.
216
258
  [docs/PROVENANCE.md](https://github.com/Alot1z/agent-handoff/blob/main/docs/PROVENANCE.md),
217
259
  not implied.
218
260
 
219
- [Unreleased]: https://github.com/Alot1z/agent-handoff/compare/v2.0.1...main
261
+ [Unreleased]: https://github.com/Alot1z/agent-handoff/compare/v2.0.5...main
262
+ [2.0.5]: https://github.com/Alot1z/agent-handoff/compare/v2.0.1...v2.0.5
263
+ [2.0.4]: https://www.npmjs.com/package/agents-handoff/v/2.0.4
264
+ [2.0.3]: https://www.npmjs.com/package/agents-handoff/v/2.0.3
265
+ [2.0.2]: https://www.npmjs.com/package/agents-handoff/v/2.0.2
220
266
  [2.0.1]: https://github.com/Alot1z/agent-handoff/compare/v2.0.0...v2.0.1
221
267
  [2.0.0]: https://github.com/Alot1z/agent-handoff/tree/v2.0.0
package/SKILL.md CHANGED
@@ -6,7 +6,7 @@ description: >-
6
6
  session as a portable, sha256-proven handoff folder a fresh agent can continue
7
7
  from with zero shared memory, with versioned contracts, an evidence gate, and
8
8
  backup/verify/rollback on every write. Zero runtime dependencies, no network.
9
- version: 2.0.4
9
+ version: 2.0.5
10
10
  domain: orchestration
11
11
  tokens: 900
12
12
  allowed-tools: Bash(node:*), Read, Edit, Write
@@ -143,5 +143,5 @@ Dispatch re-runs the evidence gate first; carries manifest sha256; dry-run by de
143
143
  | Handoff format + schema | `docs/FORMAT.md`, `templates/` |
144
144
  | Installation | `docs/INSTALL.md` |
145
145
 
146
- **Version**: 2.0.0
147
- **Last Updated**: 2026-10-08
146
+ **Version**: 2.0.5
147
+ **Last Updated**: 2026-10-09
@@ -140,10 +140,11 @@ that produced it. A probe kind with no implementation returns `unknown` rather t
140
140
  `healthy`. `check` exits non-zero when a required capability is `unknown`, because an
141
141
  unanswered question is not a pass.
142
142
 
143
- The same rule applies to the evidence gate. `verify-gate` returns `REJECTED` in its JSON
144
- when a check fails, and exits 0 either way, so the verdict has to be read rather than
145
- inferred from a status code. `promote` prints the gate result without enforcing it, which is
146
- stated in the command reference rather than left for a caller to discover.
143
+ The same rule applies to the evidence gate. `verify-gate` returns `REJECTED` in its JSON when a
144
+ check fails **and exits 6**, so a caller that reads only the status code still fails closed
145
+ instead of reading a rejection as success. `promote` runs the same gate and refuses with
146
+ `exit 6` unless `--force` is passed; a forced promotion is recorded as `promoted_gate: "FORCED"`
147
+ so it stays distinguishable from a verified one.
147
148
 
148
149
  ## Boundaries
149
150
 
package/docs/CHANGELOG.md CHANGED
@@ -19,6 +19,37 @@ version, and it is the version published to npm: the repository root is the
19
19
 
20
20
  Add entries under the matching heading as changes land.
21
21
 
22
+ ## [2.0.5] - 2026-10-09
23
+
24
+ ### Fixed
25
+
26
+ - **Real Claude Code and Codex exports parse.** The engine read text only from a top-level
27
+ `text`/`content`/`parts[]`, so a genuine Claude Code transcript (`message.content[]`) or a Codex
28
+ CLI rollout (`payload`) produced **zero turns** while `--harness claude-code` merely labelled the
29
+ result. Nested blocks, `message.content[]`, `parts[]`, `item.content[]`, `output` and the Codex
30
+ `payload` wrapper are read now, and a record whose every typed block is a reasoning block
31
+ classifies as `THOUGHT`.
32
+ - **A malformed transcript line is no longer dropped in silence.** It stops the build with
33
+ `exit 5`, naming the line number and reason. Dropping it produced a handoff that looked complete
34
+ and was missing part of the session; this restores the fail-closed rule the rest of the tool
35
+ already followed. `--allow-bad-lines` skips such lines deliberately and prints how many were
36
+ skipped.
37
+ - **`promote` enforces the evidence gate.** It ran the gate, printed the verdict and promoted
38
+ regardless (`void gateRun`), so a `REJECTED` handoff was stamped as promoted. It now refuses with
39
+ `exit 6`, names the failed checks, leaves the manifest unstamped, and records `promoted_gate`.
40
+ `--force` is the deliberate override, recorded as `FORCED` rather than `VERIFIED`.
41
+ - **A rebuild no longer destroys the evidence contract.** `HANDOFF.md` is regenerated on every
42
+ build, which erased the hand-authored `RESULT:`/`EVIDENCE:` block the gate reads — so growing a
43
+ session deleted the gate's own input. The block is carried across verbatim under its own heading
44
+ and hashed into `evidence_contract_sha256` on the manifest.
45
+ - `verify-gate` reports a rejection in its exit code (`6`), not only in its JSON.
46
+
47
+ ### Added
48
+
49
+ - Regression tests for all four defects above, driving the real CLI: a Claude Code transcript, a
50
+ Codex rollout, a mixed valid/corrupt source, a rebuild after a hand-authored contract, and the
51
+ promote / gate / `--force` path (39 tests, up from 34).
52
+
22
53
  ## [2.0.4] - 2026-10-09
23
54
 
24
55
  ### Added
package/docs/CLI.md CHANGED
@@ -60,6 +60,7 @@ source hash writes nothing and prints `handoff: up-to-date`.
60
60
  | 2 | Usage error, for example `build` without `--source`. |
61
61
  | 3 | The id prefix matched more than one session, or none. |
62
62
  | 4 | No match for `show`/`verify`/`rename`/`retitle`; also `build` when no turn could be parsed from the source. |
63
+ | 5 | `build` found unparseable JSONL line(s) it would otherwise skip silently. The message names the line number and reason; pass `--allow-bad-lines` to accept the damaged source deliberately. |
63
64
 
64
65
  ## `tools/agents-handoff.mjs` — runtime layer
65
66
 
@@ -71,7 +72,7 @@ once. Behaviour per command is described in [LEVEL4.md](LEVEL4.md).
71
72
  |---|---|
72
73
  | `auto --source <file>` | Build only when the source is newer than the stored manifest; skip within `--min-fresh-ms` (default 60000). |
73
74
  | `verify-gate <id-prefix>` | Five checks: `sha`, `counts`, `payload`, `contract`, `evidence`. |
74
- | `promote <id-prefix>` | Stamp `promoted_at` and `promoted_by` on the manifest. |
75
+ | `promote <id-prefix>` | Run the evidence gate and, only if it passes, stamp `promoted_at`, `promoted_by` and `promoted_gate` on the manifest. `--force` overrides a rejected gate on purpose and records `promoted_gate: "FORCED"`. |
75
76
  | `merge <a> <b>` | Compose two sessions of one project into `<a>+merge+<b>`. |
76
77
  | `dispatch <id-prefix> --task <objective>` | Hand the continuation to a worker. See [LEVEL5.md](LEVEL5.md). |
77
78
  | `federated-merge --from <root> [--from <root> …] [--dry-run]` | Import sessions from another store root. |
@@ -87,9 +88,11 @@ old one exists only so a command that already names it does not break.
87
88
  `dispatch` flags: `--task` (required), `--role` (default `implementation-agent`), `--parent`
88
89
  (default `handoff:<id>`), `--broker <root>`, `--live`.
89
90
 
90
- `verify-gate` exits 0 for both `VERIFIED` and `REJECTED`. Read `ok` or `verdict` from the
91
- JSON; the exit code alone does not report a rejection. `promote` prints the gate result but
92
- does not enforce it — run `verify-gate` and branch on the verdict first.
91
+ `verify-gate` reports a rejection twice: `ok: false` and `verdict: "REJECTED"` in the JSON, and
92
+ `exit 6`. A gate that failed is not a success, so a caller that only reads the exit code still
93
+ fails closed. `promote` runs the same gate and **refuses** (`exit 6`) when it is rejected — pass
94
+ `--force` to override deliberately, which is recorded in the manifest as `promoted_gate:
95
+ "FORCED"` rather than `"VERIFIED"`.
93
96
 
94
97
  ### Exit codes
95
98
 
@@ -98,6 +101,9 @@ does not enforce it — run `verify-gate` and branch on the verdict first.
98
101
  | 0 | Success. |
99
102
  | 1 | A mutating command failed; the lock was released. |
100
103
  | 2 | Usage or configuration error, including an unresolvable store root. |
104
+ | 3 | A lock is held, an id prefix is ambiguous, or a referenced path is missing. |
105
+ | 4 | No session matches the prefix. |
106
+ | 6 | An evidence gate rejected the handoff (`verify-gate` and `promote`). |
101
107
  | 3 | A lock is held, an id prefix is ambiguous or missing, or a referenced path is missing. |
102
108
  | 4 | No session matches the prefix. |
103
109
 
@@ -67,13 +67,34 @@ is declared by the versioned JSON Schemas the runtime validates against
67
67
 
68
68
  | Format | How it is detected | Fields read |
69
69
  |---|---|---|
70
- | JSONL | `.jsonl` extension | `seq`, `ts`/`timestamp`, `role`, `kind`, and the text from `text`, `content`, or `parts[].text` |
70
+ | JSONL | `.jsonl` extension | `seq`, `ts`/`timestamp`, `role`, `kind`/`type`, and the text from the top level (`text`, `content`, `output`), from a nested message (`message.content[]`, `message.text`), from an item array (`parts[]`, `item.content[]`), or from a Codex rollout wrapper (`payload.text`, `payload.content[]`, `payload.output`) |
71
71
  | Plain text | Any other extension | A line opening with `user:`, `human:`, `assistant:`, `ai:`, `system:` or `tool:` (or `>` instead of `:`); following lines are appended to that turn |
72
72
 
73
- Turn classification, in this order: `kind` containing `tool`, or `role: "tool"`, becomes `TOOL`;
74
- `kind` containing `reason` or `think` becomes `THOUGHT`; `role: "user"` (or `kind: "human"`) becomes
75
- `USER`; `role: "assistant"` (or `kind: "ai"`) becomes `AGENT`; everything else becomes `OTHER`.
76
- Malformed JSONL lines and turns with empty text are skipped.
73
+ This reads the shapes harnesses actually write, not just the canonical one. Claude Code nests the
74
+ turn under `message.content[]` (`{type:"text"}`, `{type:"thinking"}`,
75
+ `{type:"tool_use"}`, `{type:"tool_result"}`); the Codex CLI rollout wraps each event in
76
+ `payload` (`response_item` → `message` / `function_call` / `function_call_output`). Reading only
77
+ top-level fields parses those exports to zero turns, which is why `--harness` is a label and not
78
+ a parser: nothing here branches on the harness name.
79
+
80
+ Turn classification, in this order: a `kind`/`type` containing `tool`, or `role: "tool"`, or
81
+ `function_call`, becomes `TOOL`; `kind`/`type` containing `reason` or `think`, or a record whose
82
+ every typed content block is a reasoning block, becomes `THOUGHT`; `role: "user"` (or
83
+ `kind: "human"`) becomes `USER`; `role: "assistant"` (or `kind: "ai"`) becomes `AGENT`; everything
84
+ else becomes `OTHER`.
85
+
86
+ ## Malformed and empty input
87
+
88
+ The two cases are different and are not treated the same way.
89
+
90
+ | Case | Behaviour |
91
+ |---|---|
92
+ | A JSONL line that is not valid JSON | **The build stops** with `exit 5` and names the line number and reason. Pass `--allow-bad-lines` to skip them on purpose, which prints a warning and keeps going. |
93
+ | A line that parses but carries no user/assistant text | Counted as `skipped` — this is normal harness metadata (usage, summaries, system events), not corruption. |
94
+ | A source where *no* turn parses at all | `exit 4`, whether or not bad lines were found. |
95
+
96
+ Failing closed is deliberate. Silently dropping an unparseable line produces a handoff that looks
97
+ complete while missing part of the session, and nothing downstream can tell the difference.
77
98
 
78
99
  ## Session sources
79
100
 
@@ -122,6 +143,9 @@ Root resolution order, first match wins: `HANDOFFS_ROOT`, then a config-declared
122
143
  | 0 | Success |
123
144
  | 1 | Integrity failure (manifest mismatch, corrupted manifest) or an unexpected error |
124
145
  | 2 | Usage error, missing source file, or an invalid config |
146
+ | 3 | An id prefix is ambiguous, or a lock is held |
147
+ | 4 | No usable turns were parsed, or no session matches the prefix |
148
+ | 5 | Unparseable JSONL line(s) with `--allow-bad-lines` absent |
125
149
  | 3 | Ambiguous session-id prefix |
126
150
  | 4 | No parsable turns, no handoffs, or no match for the prefix |
127
151
 
package/docs/FORMAT.md CHANGED
@@ -105,6 +105,9 @@ checks everything else, and treats the absence as neither a pass nor a failure.
105
105
  | `turn_count` | Number of turns in `timeline.jsonl` after the build. |
106
106
  | `counts` | Turn counts by class: `USER`, `AGENT`, `THOUGHT`, `TOOL`. |
107
107
  | `manifest_sha256` | Self-hash: SHA-256 of the manifest JSON with this field removed. |
108
+ | `evidence_contract_sha256` | SHA-256 of the hand-authored evidence block carried into `HANDOFF.md`. Present only when such a block exists. This is what makes "the contract survived the rebuild" checkable rather than asserted. |
109
+ | `promoted_at`, `promoted_by` | Set by the L4 `promote` command after its evidence gate passes. |
110
+ | `promoted_gate` | `VERIFIED` when the gate passed, `FORCED` when `promote --force` overrode a rejection. |
108
111
  | `prev_project` | Set when `rename` moves the session to another project. |
109
112
  | `titled_from` | Previous directory names, set by `retitle`. |
110
113
 
@@ -124,16 +127,22 @@ Turn text is taken from `text`, then `content`, then `parts[].text`.
124
127
 
125
128
  ## Accepted input
126
129
 
127
- A source file ending in `.jsonl` is read line by line. Each line is parsed
128
- independently; a line that does not parse, or whose text is blank, is skipped. `seq` is used
129
- when it is a finite number, and the line index otherwise.
130
+ A source file ending in `.jsonl` is read line by line. Each line is parsed independently. Text is
131
+ taken from the top level (`text`, `content`, `output`), from a nested message
132
+ (`message.content[]`), from an item array (`parts[]`, `item.content[]`), or from a Codex rollout
133
+ `payload` — so a real Claude Code or Codex export parses without preprocessing. `seq` is used when
134
+ it is a finite number, and the line index otherwise.
135
+
136
+ A line that parses but whose text is blank is counted as skipped: that is normal harness metadata,
137
+ not damage. A line that does **not** parse is treated as corruption and stops the build with
138
+ `exit 5`, naming the line, unless `--allow-bad-lines` is passed.
130
139
 
131
140
  Any other extension is parsed as text: a line matching `user:`, `human:`, `assistant:`,
132
141
  `ai:`, `system:` or `tool:` (optionally prefixed with `#`) starts a turn, and following
133
142
  lines are appended to it. The role marker decides the class. Adapters that produce either
134
143
  shape are listed in [ADAPTERS.md](https://github.com/Alot1z/agent-handoff/blob/main/refs/ADAPTERS.md).
135
144
 
136
- If no turn parses, the build fails with exit 4.
145
+ If no turn parses, the build fails with exit 4 — whether or not unparseable lines were found.
137
146
 
138
147
  ## Session id and directory name
139
148
 
@@ -156,6 +165,12 @@ Each build appends only the turns with `seq > watermark`, then sets `watermark`
156
165
  highest `seq` seen and increments `revisions`. A rebuild with no new turns and an unchanged
157
166
  `raw_sha256` prints `handoff: up-to-date` and writes nothing.
158
167
 
168
+ A rebuild regenerates `HANDOFF.md`, so the **evidence contract is carried across verbatim**:
169
+ the `RESULT`/`WHAT_CHANGED`/`VALIDATION`/`EVIDENCE`/`BLOCKERS`/`RISKS`/`FOLLOW_UP` block a human or
170
+ agent authored is read from the existing `HANDOFF.md`, re-emitted under
171
+ `## Evidence contract (hand-authored — preserved across rebuilds)`, and hashed into
172
+ `evidence_contract_sha256`. Without that, growing a session would destroy the gate's own input.
173
+
159
174
  ## Provenance and verification
160
175
 
161
176
  | Value | Definition |
@@ -58,7 +58,9 @@ The marker is `user|human|assistant|ai|system|tool`, optionally prefixed by `#`
58
58
  by `:` or `>`. Lines after a marker are appended to that turn until the next marker. A
59
59
  `system:` line becomes OTHER, so it stays in the record without appearing as dialogue.
60
60
 
61
- If no usable turn is parsed, the build fails with exit 4 rather than writing an empty handoff.
61
+ If no usable turn is parsed, the build fails with exit 4 rather than writing an empty handoff. A
62
+ JSONL line that does not parse fails the build with exit 5 instead of being skipped silently;
63
+ `--allow-bad-lines` accepts a damaged source on purpose and warns about how many lines it skipped.
62
64
 
63
65
  ## Build a handoff
64
66
 
@@ -162,6 +164,7 @@ and does not detect.
162
164
  | 2 | Usage or configuration error: missing `--source`, missing argument, invalid config. |
163
165
  | 3 | Ambiguous id prefix — more than one session matched. |
164
166
  | 4 | No usable turns, no handoffs, or no session matching the prefix. |
167
+ | 5 | Unparseable JSONL line(s), unless `--allow-bad-lines` was passed. |
165
168
 
166
169
  ## CI recipe
167
170
 
package/docs/LEVEL4.md CHANGED
@@ -20,7 +20,7 @@ node tools/agents-handoff.mjs <command> [args]
20
20
  |---|---|
21
21
  | `auto --source <file>` | Build only when the source is newer than the stored manifest. |
22
22
  | `verify-gate <id-prefix>` | Five checks: sha, counts, payload, contract, evidence. |
23
- | `promote <id-prefix>` | Stamp promotion metadata on a handoff's manifest. |
23
+ | `promote <id-prefix>` | Run the evidence gate, then stamp promotion metadata on a handoff's manifest if it passed. |
24
24
  | `merge <a> <b>` | Compose two sessions of one project into one handoff. |
25
25
  | `dispatch <id-prefix> --task <objective>` | Hand the continuation to a worker (Level 5). |
26
26
  | `federated-merge --from <root>` | Import sessions from another store root. |
@@ -74,16 +74,26 @@ manifest is visible as a nonzero status.
74
74
  node tools/agents-handoff.mjs promote <id-prefix>
75
75
  ```
76
76
 
77
- The manifest is backed up to `manifest.json.bak`, the gate above is run and its verdict is
78
- printed, and the manifest is then stamped with `promoted_at` and `promoted_by` and re-hashed.
77
+ The manifest is backed up to `manifest.json.bak`, the gate above runs, and the manifest is then
78
+ stamped with `promoted_at`, `promoted_by` and `promoted_gate` and re-hashed.
79
79
 
80
- Two honest caveats:
80
+ The gate is **enforced**, not printed and discarded. A `REJECTED` verdict stops the command with
81
+ `exit 6`, names the checks that failed, and leaves the manifest unstamped — so an unverified
82
+ handoff cannot become a promoted one by accident:
81
83
 
82
- - The gate result is **printed, not enforced**. The stamp is written even when the verdict is
83
- `REJECTED`; a caller that wants a gate must run `verify-gate` and branch on `verdict`
84
- before calling `promote`.
85
- - Promotion is local. It writes one flag pair into one manifest file; it contacts no external
86
- system and publishes nothing.
84
+ ```bash
85
+ node tools/agents-handoff.mjs promote <id-prefix>
86
+ # agents-handoff: promote refused: evidence gate REJECTED (failed: contract) — add the evidence
87
+ # contract to HANDOFF.md, or pass --force to override deliberately
88
+ ```
89
+
90
+ `--force` is the deliberate override for the cases where the gate's input genuinely does not
91
+ apply. It still records what happened: `promoted_gate` is `VERIFIED` after a passing gate and
92
+ `FORCED` after an override, so a forced promotion is distinguishable afterwards from a verified
93
+ one rather than looking identical in the manifest.
94
+
95
+ One honest caveat: promotion is local. It writes flag fields into one manifest file; it contacts
96
+ no external system and publishes nothing.
87
97
 
88
98
  ## `merge` — composing two sessions
89
99
 
@@ -200,3 +210,4 @@ The state directory is `<repo>/.agents-handoff`, or `AGENT_HANDOFF_STATE_DIR` wh
200
210
  | 2 | Usage or configuration error, including an unresolvable store root. |
201
211
  | 3 | A lock is held, an id prefix is ambiguous, or a referenced path is missing. |
202
212
  | 4 | No session matches the prefix. |
213
+ | 6 | The evidence gate rejected the handoff (`verify-gate`, and `promote` unless `--force`). |
package/docs/SECURITY.md CHANGED
@@ -51,7 +51,8 @@ or as a request. Concretely:
51
51
 
52
52
  - A tool call recorded in a transcript is copied verbatim as text into `TOOLS.md`. The engine does not run it.
53
53
  - A transcript cannot change the engine's arguments, the store root, or the exit code beyond a parse failure.
54
- - A transcript with no parsable turns stops the run (`exit 4`). Individual malformed JSONL lines are skipped.
54
+ - A transcript with no parsable turns stops the run (`exit 4`).
55
+ - A malformed JSONL line stops the run (`exit 5`) and is named by line number, because a silent skip would produce a handoff that looks complete and is not. `--allow-bad-lines` is the explicit, warning-printing way to accept a damaged source.
55
56
 
56
57
  The realistic risk is not code execution but content. A handoff is a readable document that a later
57
58
  human or agent may treat as instructions, and it inherits whatever instructions the transcript
@@ -94,20 +94,32 @@ for the merged session. `index` reports the same sessions as stale.
94
94
  seven contract fields — `RESULT`, `WHAT_CHANGED`, `VALIDATION`, `EVIDENCE`, `BLOCKERS`,
95
95
  `RISKS`, `FOLLOW_UP` — each starting a line.
96
96
 
97
- ## `verify-gate` exits 0 but the verdict says REJECTED
97
+ ## `verify-gate` exits 6 and the verdict says REJECTED
98
98
 
99
- **Cause:** not a bug. `verify-gate` exits 0 for both verdicts so that "the gate ran" and
100
- "the gate passed" stay distinguishable. Read `ok` or `verdict` from the JSON.
99
+ **Cause:** at least one of the five checks (`sha`, `counts`, `payload`, `contract`, `evidence`)
100
+ failed. The JSON names which, and the message on stderr names them too.
101
101
 
102
- **Fix:** branch on the verdict in any script that calls it. The same applies to `promote`,
103
- which prints the gate result but stamps the manifest either way.
102
+ **Fix:** fix the named check. The usual one is `contract`, which requires `RESULT`,
103
+ `WHAT_CHANGED`, `VALIDATION`, `EVIDENCE`, `BLOCKERS`, `RISKS` and `FOLLOW_UP` — each starting a
104
+ line — in `HANDOFF.md`. That block is hand-authored; a rebuild preserves it and records its
105
+ sha256 as `evidence_contract_sha256` on the manifest.
104
106
 
105
- ## `promote` stamped a session that is not verified
107
+ ## `promote` refused: evidence gate REJECTED
106
108
 
107
- **Cause:** expected behaviour, and stated in the command reference. Promotion is a local
108
- stamp, not an enforcement.
109
+ **Cause:** the gate failed and promotion is gated on it, so the manifest was left unstamped.
109
110
 
110
- **Fix:** run `verify-gate` first and only call `promote` when the verdict is `VERIFIED`.
111
+ **Fix:** add the evidence contract to `HANDOFF.md` (see above) and re-run. If the gate genuinely
112
+ cannot apply — an imported session with no hand-authored contract, say — `--force` is the
113
+ recorded override: the manifest then carries `promoted_gate: "FORCED"` instead of `"VERIFIED"`.
114
+
115
+ ## `build` failed with `unparseable JSONL line(s)` (exit 5)
116
+
117
+ **Cause:** a line in the source is not valid JSON. It used to be dropped in silence, which
118
+ produced a handoff that looked complete and was not.
119
+
120
+ **Fix:** repair the export, or pass `--allow-bad-lines` to skip those lines on purpose. The
121
+ warning names how many lines were skipped, so the omission is visible in the build output rather
122
+ than buried in the result.
111
123
 
112
124
  ## `capability-registry check` fails with `unknown`
113
125
 
@@ -1,11 +1,19 @@
1
1
  # Changelog — agents-handoff
2
2
 
3
- The installer is a separate package from the skill. It follows the same
3
+ The installer ships inside the root
4
+ [`agents-handoff`](https://www.npmjs.com/package/agents-handoff) package: its
5
+ `install/package.json` is `private` and tracks the release version, and the published
6
+ `agents-handoff` bin points at `install/install.mjs`. There is no separate installer
7
+ package on the npm registry (verified 2026-10-09: `agents-handoff` serves
8
+ `0.0.0-stage`, `2.0.2`, `2.0.3`, `2.0.4`), so installer changes are recorded in the
9
+ [root changelog](https://github.com/Alot1z/agent-handoff/blob/main/CHANGELOG.md) under
10
+ the release version. The 1.x entries below are retained as the installer's own
11
+ development history — they are not npm releases. Both changelogs follow the same
4
12
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format and Semantic Versioning.
5
13
 
6
14
  ## [1.1.0] — 2026-10-09
7
15
 
8
- First release published to npm. `npx agents-handoff` now installs the skill on a
16
+ The installer gained a download path: `npx agents-handoff` now installs the skill on a
9
17
  machine that has neither a checkout nor an unpacked archive.
10
18
 
11
19
  ### Added
@@ -38,7 +38,7 @@ const API_LATEST = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/rele
38
38
  // `list` read back out of an installed copy. The literal below is only the fallback for the
39
39
  // run that has no tree beside it — the published package fetching an archive — and the suite
40
40
  // asserts it against SKILL.md, so a release that bumps one cannot leave the other behind.
41
- const FALLBACK_SKILL_VERSION = '2.0.4';
41
+ const FALLBACK_SKILL_VERSION = '2.0.5';
42
42
  function skillVersion() {
43
43
  try {
44
44
  const md = fs.readFileSync(path.join(SOURCE_DIR, 'SKILL.md'), 'utf8');
@@ -433,6 +433,23 @@ function resolveTargets() {
433
433
  return targets;
434
434
  }
435
435
 
436
+ // `--path X` names ONE installation exactly, and must not be widened into a machine-wide search.
437
+ // Discovery is the answer to the BARE verb — "everything on this machine" — so `verify --path
438
+ // ./my-copy` failing because some unrelated copy elsewhere is stale was the search leaking into
439
+ // an explicit request. `--path` is documented as bypassing the search; this makes it true.
440
+ function explicitTarget() {
441
+ if (!CUSTOM_PATH) return null;
442
+ const p = resolveInstallPath(LOCATION, CUSTOM_PATH);
443
+ return { label: 'explicit --path', dir: path.dirname(p), path: p };
444
+ }
445
+ // The targets a verb acts on: named harnesses/custom dirs if any were given, else the explicit
446
+ // `--path` if one was given, else every installation found on this machine.
447
+ function targetList() {
448
+ if (MULTI_TARGET) return resolveTargets();
449
+ const one = explicitTarget();
450
+ return one ? [one] : discoverInstalls();
451
+ }
452
+
436
453
  // EVERY installation this machine has, not just the resolved one. `update` and `verify` with no
437
454
  // harness flag act on this list, because "keep my install current" is about the copies that
438
455
  // exist, not about the one target the resolver happens to pick: a machine can hold the same
@@ -923,9 +940,7 @@ function doRemove(installPath, force) { log(`\nRemoving from ${installPath}...`
923
940
  // installation found on the machine is verified — the same list `update` acts on, so
924
941
  // "update everything" and "verify everything" cannot disagree about what exists.
925
942
  function verify(location, customPath) {
926
- const targets = MULTI_TARGET
927
- ? resolveTargets()
928
- : discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
943
+ const targets = targetList();
929
944
  if (!targets.length) {
930
945
  error('Nothing to verify: no agents-handoff installation found.\n' +
931
946
  ' Install one with: npx agents-handoff --all');
@@ -1097,9 +1112,7 @@ async function verifyPackageOne(target) {
1097
1112
  }
1098
1113
 
1099
1114
  async function verifyPackage() {
1100
- const targets = MULTI_TARGET
1101
- ? resolveTargets()
1102
- : discoverInstalls().map((t) => ({ label: t.label, dir: t.dir, path: t.path }));
1115
+ const targets = targetList();
1103
1116
  if (!targets.length) {
1104
1117
  error('Nothing to verify: no agents-handoff installation found.\n' +
1105
1118
  ' Install one with: npx agents-handoff --all');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-handoff",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Internal manifest for the in-tree installer. The published package is the repository root (agents-handoff), whose bin `agents-handoff` points here.",
5
5
  "private": true,
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-handoff",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Write, verify, and hand off complete AI working sessions across any harness (Claude Code, Codex, DeepSeek Harness, plain JSONL or text logs). Cross-harness session capture with sha256 provenance and verified continuation.",
5
5
  "type": "module",
6
6
  "main": "tools/handoff.mjs",
package/skill.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "agents-handoff",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "Write, verify, and hand off complete AI working sessions across any harness (Claude Code, Codex, DeepSeek Harness, plain JSONL or text logs). Cross-harness session capture + verified continuation.",
5
5
  "domain": "orchestration",
6
6
  "installer": {
7
7
  "type": "npx",
8
8
  "package": "agents-handoff",
9
- "packageVersion": "2.0.4",
9
+ "packageVersion": "2.0.5",
10
10
  "repository": "https://github.com/Alot1z/agent-handoff",
11
11
  "installCommand": "npx agents-handoff"
12
12
  },
@@ -96,8 +96,7 @@ function cmdAuto() {
96
96
  // ---------------------------------------------------------------- verify-gate
97
97
  const CONTRACT_FIELDS = ['RESULT', 'WHAT_CHANGED', 'VALIDATION', 'EVIDENCE', 'BLOCKERS', 'RISKS', 'FOLLOW_UP'];
98
98
 
99
- function cmdVerifyGate() {
100
- const pre = process.argv[3];
99
+ function runVerifyGate(pre) {
101
100
  if (!pre) die(2, 'usage: verify-gate <id-prefix>');
102
101
  const { hit, man } = readManifest(pre);
103
102
  const checks = {};
@@ -130,7 +129,14 @@ function cmdVerifyGate() {
130
129
  checks.evidence = resultLine === 'DONE' && !hasEvidence ? 'FAIL' : 'PASS';
131
130
 
132
131
  const ok = Object.values(checks).every((v) => v === 'PASS');
133
- console.log(JSON.stringify({ ok, verdict: ok ? 'VERIFIED' : 'REJECTED', session: hit.id, checks }, null, 2));
132
+ const failed = Object.keys(checks).filter((k) => checks[k] !== 'PASS');
133
+ return { ok, verdict: ok ? 'VERIFIED' : 'REJECTED', session: hit.id, checks, failed, hit, man };
134
+ }
135
+ // A REJECTED gate is not a success: the exit code carries the verdict (fail closed).
136
+ function cmdVerifyGate() {
137
+ const r = runVerifyGate(process.argv[3]);
138
+ console.log(JSON.stringify({ ok: r.ok, verdict: r.verdict, session: r.session, checks: r.checks }, null, 2));
139
+ if (!r.ok) process.exit(6);
134
140
  }
135
141
 
136
142
  // ---------------------------------------------------------------- promote
@@ -144,20 +150,22 @@ function cmdPromote() {
144
150
  // Backup manifest before touching it (backup -> apply -> verify).
145
151
  const mp = path.join(hit.dir, 'manifest.json');
146
152
  fs.copyFileSync(mp, mp + '.bak');
147
- // Evidence-gated: never promote a handoff whose gate failed.
148
- const gateRun = (() => {
149
- const saved = process.argv;
150
- process.argv = ['node', 'agents-handoff.mjs', 'verify-gate', hit.id.slice(0, 16)];
151
- try { cmdVerifyGate(); } catch (e) { /* capture below */ }
152
- process.argv = saved;
153
- })();
154
- void gateRun;
155
- // The verify-gate printed the verdict; a promoted handoff is marked in the manifest.
153
+ // Evidence-gated: never promote a handoff whose gate failed. The verdict is USED here;
154
+ // the previous version printed it and threw it away (`void gateRun`), so a REJECTED
155
+ // handoff was promoted anyway — the gate was decorative.
156
+ const gate = runVerifyGate(hit.id.slice(0, 16));
157
+ if (!gate.ok && !process.argv.includes('--force')) {
158
+ lock.release();
159
+ die(6, 'promote refused: evidence gate ' + gate.verdict +
160
+ (gate.failed.length ? ' (failed: ' + gate.failed.join(', ') + ')' : '') +
161
+ ' — add the evidence contract to HANDOFF.md, or pass --force to override deliberately');
162
+ }
156
163
  man.promoted_at = now();
157
164
  man.promoted_by = 'agents-handoff L4 promote';
165
+ man.promoted_gate = gate.ok ? 'VERIFIED' : 'FORCED';
158
166
  man.manifest_sha256 = sha(JSON.stringify((o => { delete o.manifest_sha256; return o; })(Object.assign({}, man))));
159
167
  fs.writeFileSync(mp, JSON.stringify(man, null, 2));
160
- console.log(JSON.stringify({ ok: true, action: 'promoted', session: hit.id, promoted_at: man.promoted_at }));
168
+ console.log(JSON.stringify({ ok: true, action: 'promoted', session: hit.id, gate: man.promoted_gate, promoted_at: man.promoted_at }));
161
169
  } catch (e) {
162
170
  die(1, 'promote failed: ' + (e && e.message));
163
171
  } finally {
package/tools/handoff.mjs CHANGED
@@ -29,30 +29,91 @@ const argOf = (n, f) => { const i = process.argv.indexOf(n); return i >= 0 && i
29
29
  const hasFlag = n => process.argv.includes(n);
30
30
  const fence = String.fromCharCode(96).repeat(3);
31
31
 
32
+ // The `type` of each nested content block, if the record has a content array at all. The
33
+ // documented rule is "kind containing reason/think becomes THOUGHT", and in a real export the
34
+ // kind lives on the BLOCK, not on the record.
35
+ function blockTypes(r) {
36
+ const pl = (r && r.payload) || {};
37
+ const arr = [r && r.content, r && r.parts, r && r.message && r.message.content,
38
+ pl.content, r && r.item && r.item.content].find(Array.isArray);
39
+ return (arr || []).map((p) => String((p && p.type) || '').toLowerCase()).filter(Boolean);
40
+ }
41
+
32
42
  function classify(r) {
33
- const k = String(r.kind || '').toLowerCase(), role = String(r.role || '').toLowerCase();
34
- if (k.includes('tool') || role === 'tool') return 'TOOL';
35
- if (k.includes('reason') || k.includes('think')) return 'THOUGHT';
36
- if (role === 'user' || k === 'human') return 'USER';
37
- if (role === 'assistant' || k === 'ai') return 'AGENT';
43
+ // `payload` is the Codex CLI rollout wrapper: the event type and the role live one level
44
+ // down, so reading only the top level mislabels (or drops) every Codex turn.
45
+ const pl = (r && r.payload) || {};
46
+ const mrole = String((r.message && r.message.role) || pl.role || '');
47
+ const k = String(r.kind || r.type || '').toLowerCase();
48
+ const kp = String(pl.kind || pl.type || '').toLowerCase();
49
+ const role = String(r.role || mrole || '').toLowerCase();
50
+ const ks = k + ' ' + kp;
51
+ if (ks.includes('tool') || role === 'tool' || ks.includes('function_call')) return 'TOOL';
52
+ if (ks.includes('reason') || ks.includes('think')) return 'THOUGHT';
53
+ // A record whose every typed block is reasoning is a THOUGHT turn. A record that MIXES
54
+ // reasoning with text/tool blocks is not — it keeps its AGENT class and keeps the text.
55
+ const bt = blockTypes(r);
56
+ if (bt.length && bt.every((t) => t.includes('think') || t.includes('reason'))) return 'THOUGHT';
57
+ if (role === 'user' || k === 'human' || k === 'user') return 'USER';
58
+ if (role === 'assistant' || k === 'ai' || k === 'assistant') return 'AGENT';
38
59
  return 'OTHER';
39
60
  }
61
+ // One element of a nested content array: an Anthropic block, an OpenAI part, a Codex item.
62
+ function partText(p) {
63
+ if (typeof p === 'string') return p;
64
+ if (!p || typeof p !== 'object') return '';
65
+ if (typeof p.text === 'string') return p.text;
66
+ if (typeof p.thinking === 'string') return p.thinking;
67
+ if (typeof p.content === 'string') return p.content;
68
+ if (Array.isArray(p.content)) return p.content.map(partText).filter(Boolean).join('\n');
69
+ if (p.type === 'tool_use') return 'tool_use ' + (p.name || '') + ' ' + JSON.stringify(p.input === undefined ? {} : p.input);
70
+ if (p.type === 'tool_result') return 'tool_result ' + (typeof p.content === 'string' ? p.content : JSON.stringify(p.content === undefined ? {} : p.content));
71
+ if (p.type === 'function_call') return 'function_call ' + (p.name || '') + ' ' + String(p.arguments || '');
72
+ if (p.type === 'function_call_output') return 'function_call_output ' + String(p.output || '');
73
+ return '';
74
+ }
75
+ // Real harness exports NEST the text: Claude Code keeps it under message.content[],
76
+ // Codex/OpenAI under content[] or output[]. Reading only top-level fields is exactly
77
+ // why --harness was a label instead of a parser; every shipped shape is read here.
40
78
  function textOf(r) {
41
- if (typeof r.text === 'string') return r.text;
42
- if (typeof r.content === 'string') return r.content;
43
- if (Array.isArray(r.parts)) return r.parts.map(p => p && p.text ? p.text : '').join(' ');
79
+ if (!r || typeof r !== 'object') return '';
80
+ const pl = r.payload || {};
81
+ if (typeof r.text === 'string' && r.text.trim()) return r.text;
82
+ for (const v of [r.content, r.output, r.message && r.message.content, r.message && r.message.text,
83
+ pl.content, pl.output, pl.text, pl.message && pl.message.content]) {
84
+ if (typeof v === 'string' && v.trim()) return v;
85
+ }
86
+ for (const v of [r.parts, r.content, r.output, r.message && r.message.content, r.item && r.item.content,
87
+ pl.parts, pl.content, pl.output, pl.items]) {
88
+ if (Array.isArray(v)) {
89
+ const s = v.map(partText).filter(Boolean).join('\n').trim();
90
+ if (s) return s;
91
+ }
92
+ }
93
+ // A bare Codex function call/output carries no `content` at all — only name/arguments/output.
94
+ if (pl.type === 'function_call' || pl.type === 'function_call_output') return partText(pl);
44
95
  return '';
45
96
  }
97
+ // Lines that are not valid JSON are CORRUPTION and are reported, never dropped in
98
+ // silence (doctrine #1: fail closed on ambiguity). Lines that parse but carry no
99
+ // user/assistant text are normal harness metadata (summaries, usage, system events)
100
+ // and are counted as `skipped`, not as corruption.
101
+ const fmtLines = (bs) => bs.slice(0, 5).map(b => b.line + ' (' + b.reason + ')').join(', ') + (bs.length > 5 ? ' +' + (bs.length - 5) + ' more' : '');
46
102
  function loadTurns(src) {
47
103
  const raw = fs.readFileSync(src, 'utf8');
104
+ const badLines = [];
105
+ let skipped = 0;
48
106
  if (/\.jsonl$/i.test(src)) {
49
- return raw.split(/\r?\n/).filter(Boolean).map((l, i) => {
107
+ const turns = [];
108
+ raw.split(/\r?\n/).forEach((l, i) => {
109
+ if (!l.trim()) return;
50
110
  let r = null;
51
- try { r = JSON.parse(l); } catch (e) { return null; }
111
+ try { r = JSON.parse(l); } catch (e) { badLines.push({ line: i + 1, reason: 'invalid JSON', sample: l.trim().slice(0, 80) }); return; }
52
112
  const t = textOf(r);
53
- if (!t.trim()) return null;
54
- return { seq: Number.isFinite(+r.seq) ? +r.seq : i, ts: String(r.ts || r.timestamp || ''), cls: classify(r), text: t };
55
- }).filter(Boolean);
113
+ if (!t.trim()) { skipped++; return; }
114
+ return turns.push({ seq: Number.isFinite(+r.seq) ? +r.seq : i, ts: String(r.ts || r.timestamp || (r.message && r.message.created_at) || ''), cls: classify(r), text: t });
115
+ });
116
+ return { turns, badLines, skipped };
56
117
  }
57
118
  const turns = []; let cur = null;
58
119
  raw.split(/\r?\n/).forEach(l => {
@@ -60,7 +121,22 @@ function loadTurns(src) {
60
121
  if (m) { cur = { seq: turns.length, ts: '', cls: /^u|^h/i.test(m[1]) ? 'USER' : (/^a/i.test(m[1]) ? 'AGENT' : (/^tool/i.test(m[1]) ? 'TOOL' : 'OTHER')), text: m[2] }; turns.push(cur); }
61
122
  else if (cur) cur.text += '\n' + l;
62
123
  });
63
- return turns.filter(t => t.text.trim());
124
+ return { turns: turns.filter(t => t.text.trim()), badLines, skipped };
125
+ }
126
+ // A hand-authored evidence contract (RESULT:/EVIDENCE:/... at line start) is the gate's
127
+ // INPUT. Rebuilding regenerates HANDOFF.md, which used to erase it — silently destroying
128
+ // the very input the gate requires. It is now carried across every rebuild verbatim.
129
+ function preservedEvidence(mdPath) {
130
+ if (!fs.existsSync(mdPath)) return null;
131
+ const lines = fs.readFileSync(mdPath, 'utf8').split(/\r?\n/);
132
+ const isField = (l) => /^(RESULT|WHAT_CHANGED|VALIDATION|EVIDENCE|BLOCKERS|RISKS|FOLLOW_UP)\s*:/.test(l);
133
+ let first = -1, last = -1;
134
+ lines.forEach((l, i) => { if (isField(l)) { if (first < 0) first = i; last = i; } });
135
+ if (first < 0) return null;
136
+ let end = last;
137
+ for (let i = last + 1; i < lines.length; i++) { if (/^#{1,6}\s/.test(lines[i])) break; end = i; }
138
+ const block = lines.slice(first, end + 1).join('\n').replace(/\s+$/, '');
139
+ return block.trim() ? block : null;
64
140
  }
65
141
  const NOTICE_RE = /^(the )?(approval policy|approval|policy changed|system[: ]|notice\b|context low|<\w)/i;
66
142
  function pickObjective(turns, override) {
@@ -160,8 +236,15 @@ function build() {
160
236
  let src = argOf('--source', () => { const i = process.argv.indexOf('build'); return i >= 0 ? process.argv[i + 1] : null; });
161
237
  if (src && !fs.existsSync(src)) die(2, 'source not found: ' + src);
162
238
  if (!src) die(2, 'usage: build --source <file> [--session id] [--harness n] [--model m] [--project name] [--objective text]');
163
- const turnsAll = loadTurns(src);
164
- if (!turnsAll.length) die(4, 'no usable turns parsed from ' + src);
239
+ const parsed = loadTurns(src);
240
+ const turnsAll = parsed.turns;
241
+ if (!turnsAll.length) die(4, 'no usable turns parsed from ' + src +
242
+ (parsed.badLines.length ? ' — ' + parsed.badLines.length + ' unparseable line(s) at ' + fmtLines(parsed.badLines) : ''));
243
+ if (parsed.badLines.length && !hasFlag('--allow-bad-lines')) {
244
+ die(5, 'refusing to continue: ' + parsed.badLines.length + ' unparseable JSONL line(s) at ' + fmtLines(parsed.badLines) +
245
+ ' — fixing the input is the default (doctrine #1); pass --allow-bad-lines to skip them explicitly');
246
+ }
247
+ if (parsed.badLines.length) console.error('handoff: WARNING — skipped ' + parsed.badLines.length + ' unparseable line(s) because --allow-bad-lines was given');
165
248
  let firstRaw = null;
166
249
  try { firstRaw = JSON.parse(fs.readFileSync(src, 'utf8').split(/\r?\n/)[0]); } catch (e) {}
167
250
  const mArg = argOf('--model', () => null);
@@ -202,6 +285,9 @@ function build() {
202
285
  const stateNow = ((agents.at(-1) || users.at(-1) || { text: '(none)' }).text || '').slice(-1200);
203
286
  const openLoops = [...new Set(turns.flatMap(t => String(t.text).split(/\r?\n/)).filter(l => /(next step|todo|blocked|open loop|remaining work)/i.test(l)).map(l => l.trim()))].slice(0, 20);
204
287
  const toolCalls = turns.filter(t => t.cls === 'TOOL');
288
+ const evidenceBlock = preservedEvidence(path.join(dir, 'HANDOFF.md'));
289
+ if (evidenceBlock) man.evidence_contract_sha256 = sha(evidenceBlock);
290
+ else delete man.evidence_contract_sha256;
205
291
  man.watermark = Math.max(...turnsAll.map(t => t.seq));
206
292
  man.raw_sha256 = rsha;
207
293
  man.revisions++;
@@ -232,7 +318,13 @@ function build() {
232
318
  '- sources: ' + man.source_paths.join(', '),
233
319
  '- raw sha256: ' + rsha,
234
320
  '- manifest sha256: ' + man.manifest_sha256,
235
- '- incremental: revisions=' + man.revisions + ' (update-not-recreate)', '');
321
+ '- incremental: revisions=' + man.revisions + ' (update-not-recreate)', '',
322
+ '## Evidence contract (hand-authored — preserved across rebuilds)', '',
323
+ ...(evidenceBlock
324
+ ? [evidenceBlock, '', '- contract sha256: ' + man.evidence_contract_sha256 +
325
+ ' (carried forward verbatim; the L4 gate reads this section)']
326
+ : ['(none yet) — add RESULT/WHAT_CHANGED/VALIDATION/EVIDENCE/BLOCKERS/RISKS/FOLLOW_UP and the next rebuild keeps it;',
327
+ '`agents-handoff.mjs verify-gate <id>` reads this section.']));
236
328
  fs.writeFileSync(path.join(dir, 'HANDOFF.md'), md.join('\n'));
237
329
  // TOOLS.md carries EVERY tool call verbatim (full text, never truncated) —
238
330
  // this is the lossless fidelity tier that answers "does the handoff contain all calls/bits".
@@ -421,6 +421,13 @@ test('runtime: an approved workspace wins over a broad personal-data root it liv
421
421
  // out — so this test is about the tree that has one, and an installed copy skips it by name
422
422
  // rather than failing the suite that proves the installed copy works.
423
423
  const INSTALLER_SRC = path.join(REPO, 'install', 'install.mjs');
424
+ // This file travels to the published repository, and it also runs from the authoring skill
425
+ // tree. `package.json` is NOT one of those two trees' shared files: sync-upstream publishes an
426
+ // allowlist of skill files, and the root package.json is authored in repo-upstream only. A
427
+ // publish-tree assertion therefore cannot be satisfied in the authoring tree, and is named as
428
+ // such rather than failed there. The published tree — and CI, which runs at the repo root —
429
+ // still runs it.
430
+ const IS_PUBLISH_TREE = fs.existsSync(path.join(REPO, 'package.json'));
424
431
  test('installer: every manifest source resolves here, and the fallback version matches SKILL.md', (t) => {
425
432
  if (!fs.existsSync(INSTALLER_SRC)) {
426
433
  return t.skip('no install/ in this tree — an installed copy omits the installer by design');
@@ -447,6 +454,16 @@ test('installer: every manifest source resolves here, and the fallback version m
447
454
  assert.ok(version, 'SKILL.md must declare a version');
448
455
  assert.equal(fallback[1], version[1],
449
456
  'installer fallback version must equal SKILL.md version');
457
+
458
+ // skill.json is PUBLISHED metadata: the changelog tells readers that package.json and
459
+ // skill.json carry the release version, and the installer copies it into every install.
460
+ // It lagged at 2.0.4 while SKILL.md and package.json said 2.0.5 — a published
461
+ // contradiction with no runtime consumer to catch it, so the test is the catcher.
462
+ const skillJson = JSON.parse(fs.readFileSync(path.join(REPO, 'skill.json'), 'utf8'));
463
+ assert.equal(skillJson.version, version[1],
464
+ 'skill.json version must equal SKILL.md version');
465
+ assert.equal(skillJson.installer && skillJson.installer.packageVersion, version[1],
466
+ 'skill.json installer.packageVersion must equal SKILL.md version');
450
467
  });
451
468
 
452
469
  // The published identity is one name in one manifest, and every page a user reads has to agree
@@ -457,6 +474,9 @@ test('package: the published name, the packed file list and the docs agree', (t)
457
474
  if (!fs.existsSync(INSTALLER_SRC)) {
458
475
  return t.skip('no install/ in this tree — an installed copy omits the installer by design');
459
476
  }
477
+ if (!IS_PUBLISH_TREE) {
478
+ return t.skip('authoring skill tree — the root package.json is authored in repo-upstream');
479
+ }
460
480
  const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'));
461
481
  assert.equal(pkg.name, 'agents-handoff', 'the published package name');
462
482
  const skillMd = fs.readFileSync(path.join(REPO, 'SKILL.md'), 'utf8');
@@ -648,6 +668,33 @@ for (const t of [
648
668
  assert.ok(/not on the npm registry|unreachable/.test(r.stdout + r.stderr), 'reason: ' + r.stdout + r.stderr);
649
669
  } finally { fs.rmSync(home, { recursive: true, force: true }); }
650
670
  }],
671
+
672
+ // `--path X` names ONE installation. It used to fall through to machine-wide discovery, so
673
+ // `verify --path ./my-copy` reported on (and failed because of) every OTHER copy on the
674
+ // machine — an explicit request silently widened into a different question.
675
+ ['installer: verify --path checks exactly that copy, not every copy on the machine', (t) => {
676
+ if (noInstaller(t)) return;
677
+ const home = scratchHome();
678
+ try {
679
+ // A decoy the search SHOULD find and fail, so the assertion below cannot pass vacuously.
680
+ assert.equal(runInstaller(['--agents'], home).status, 0, 'setup: decoy install');
681
+ const decoy = installAt(home, '.agents');
682
+ fs.writeFileSync(path.join(decoy, 'SKILL.md'), 'tampered\n');
683
+
684
+ // A clean copy somewhere the search does not look.
685
+ const copy = path.join(home, 'elsewhere', 'agents-handoff');
686
+ assert.equal(runInstaller(['--path', copy], home).status, 0, 'setup: clean copy');
687
+
688
+ const scoped = runInstaller(['verify', '--path', copy], home);
689
+ assert.equal(scoped.status, 0, 'verify --path must ignore an unrelated stale copy: ' + scoped.stdout + scoped.stderr);
690
+ assert.ok(!scoped.stdout.includes(decoy), 'the out-of-scope copy is not even reported: ' + scoped.stdout);
691
+
692
+ // Discovery still works — this is what makes the assertion above meaningful.
693
+ const bare = runInstaller(['verify'], home);
694
+ assert.notEqual(bare.status, 0, 'a bare verify must still find (and fail) the tampered copy: ' + bare.stdout);
695
+ assert.ok(bare.stdout.includes(decoy), 'the bare verify names the copy it found: ' + bare.stdout);
696
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
697
+ }],
651
698
  ]) {
652
699
  test(t[0], t[1]);
653
700
  }
@@ -666,3 +713,186 @@ test('rebuild of identical source is idempotent (up-to-date, revision stable)',
666
713
  assert.equal(rev2, rev1, 'revision must not bump on identical rebuild');
667
714
  } finally { fs.rmSync(root, { recursive: true, force: true }); }
668
715
  });
716
+
717
+ // ===========================================================================
718
+ // REGRESSION BLOCK — the four verified defects from the 2026-10-09 audit.
719
+ // Each of these FAILS against 2.0.4 as published; each passes here.
720
+ // ===========================================================================
721
+
722
+ // Every manifest under a scratch store root. The session directory name is derived from
723
+ // the source basename, so tests that build from a differently-named file must not hard-code
724
+ // 'minimal-transcript' the way sessionDir() does.
725
+ function findManifests(root) {
726
+ const out = [];
727
+ const walk = (d) => {
728
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
729
+ const p = path.join(d, e.name);
730
+ if (e.isDirectory()) walk(p); else if (e.name === 'manifest.json') out.push(p);
731
+ }
732
+ };
733
+ const proj = path.join(root, 'projects');
734
+ if (fs.existsSync(proj)) walk(proj);
735
+ return out;
736
+ }
737
+ function timelineOf(manifestPath) {
738
+ return fs.readFileSync(path.join(path.dirname(manifestPath), 'timeline.jsonl'), 'utf8')
739
+ .split(/\r?\n/).filter(Boolean).map((l) => JSON.parse(l));
740
+ }
741
+ // The L4 runtime is a SEPARATE executable that acts on a STORE, not on a transcript.
742
+ function runL4(args, root) {
743
+ return spawnSync(process.execPath, [path.join(REPO, 'tools', 'agents-handoff.mjs'), ...args], {
744
+ cwd: REPO,
745
+ env: Object.assign({}, process.env, { HANDOFFS_ROOT: root, AGENT_HANDOFF_STATE_DIR: STATE_DIR }),
746
+ encoding: 'utf8',
747
+ });
748
+ }
749
+
750
+ // Fix A — the parser read only TOP-LEVEL text/content/parts, so a real Claude Code or Codex
751
+ // export (which NESTS the text) parsed to zero usable turns, while `--harness claude-code`
752
+ // merely labelled the result. These are the shapes the harnesses actually write.
753
+ test('A: a real Claude Code transcript (nested message.content[]) parses into turns', () => {
754
+ const root = scratch();
755
+ try {
756
+ const src = path.join(root, 'claude-code-session.jsonl');
757
+ fs.writeFileSync(src, [
758
+ JSON.stringify({ type: 'user', message: { role: 'user', content: 'Create src/sum.js exporting add(a,b).' }, timestamp: '2026-10-09T10:00:00.000Z' }),
759
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'plan the module and its test' }] }, timestamp: '2026-10-09T10:00:01.000Z' }),
760
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Added src/sum.js and exported add().' }] }, timestamp: '2026-10-09T10:00:02.000Z' }),
761
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Write', input: { file_path: 'src/sum.js' } }] }, timestamp: '2026-10-09T10:00:03.000Z' }),
762
+ ].join('\n') + '\n');
763
+ const r = run(['build', '--source', src, '--project', 'cc-proj', '--harness', 'claude-code'], root);
764
+ assert.equal(r.status, 0, 'the real Claude Code shape must build: ' + r.stderr);
765
+ const mans = findManifests(root);
766
+ assert.equal(mans.length, 1, 'exactly one session built, got ' + mans.length);
767
+ const tl = timelineOf(mans[0]);
768
+ assert.equal(tl.length, 4, 'all four Claude Code records become turns, got ' + tl.length);
769
+ assert.deepEqual(tl.map((t) => t.class), ['USER', 'THOUGHT', 'AGENT', 'AGENT']);
770
+ assert.ok(tl[0].text.includes('Create src/sum.js'), 'the nested user text is kept');
771
+ assert.ok(tl[2].text.includes('Added src/sum.js'), 'the nested text block is kept: ' + tl[2].text);
772
+ assert.ok(tl[3].text.includes('tool_use Write'), 'the nested tool_use call is kept: ' + tl[3].text);
773
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
774
+ });
775
+
776
+ // The Codex CLI rollout wraps every event in `payload`. Reading only the top level loses the
777
+ // type AND the role, so a Codex session recorded nothing at all.
778
+ test('A: a Codex CLI rollout (payload-nested) parses, and its calls classify as TOOL', () => {
779
+ const root = scratch();
780
+ try {
781
+ const src = path.join(root, 'rollout-codex.jsonl');
782
+ fs.writeFileSync(src, [
783
+ JSON.stringify({ timestamp: '2026-10-09T11:00:00.000Z', type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run the full test suite.' }] } }),
784
+ JSON.stringify({ timestamp: '2026-10-09T11:00:01.000Z', type: 'response_item', payload: { type: 'function_call', name: 'shell', arguments: '{"command":["node","tools/handoff.test.mjs"]}' } }),
785
+ JSON.stringify({ timestamp: '2026-10-09T11:00:02.000Z', type: 'response_item', payload: { type: 'function_call_output', output: 'tests 34 pass 34' } }),
786
+ JSON.stringify({ timestamp: '2026-10-09T11:00:03.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'All 34 tests pass.' }] } }),
787
+ ].join('\n') + '\n');
788
+ const r = run(['build', '--source', src, '--project', 'codex-proj', '--harness', 'codex'], root);
789
+ assert.equal(r.status, 0, 'the Codex rollout shape must build: ' + r.stderr);
790
+ const tl = timelineOf(findManifests(root)[0]);
791
+ assert.equal(tl.length, 4, 'all four Codex records become turns, got ' + tl.length);
792
+ assert.deepEqual(tl.map((t) => t.class), ['USER', 'TOOL', 'TOOL', 'AGENT']);
793
+ assert.ok(tl[1].text.includes('function_call shell'), 'the call and its arguments are kept: ' + tl[1].text);
794
+ assert.ok(tl[2].text.includes('34 pass 34'), 'the call output is kept: ' + tl[2].text);
795
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
796
+ });
797
+
798
+ // Fix B — a malformed JSONL line used to be dropped in SILENCE (`return null`), which turns
799
+ // corruption into a quietly incomplete handoff. Doctrine #1 is fail-closed on ambiguity.
800
+ test('B: unparseable JSONL lines fail closed (exit 5) and name the line; --allow-bad-lines is explicit', () => {
801
+ const root = scratch();
802
+ try {
803
+ const src = path.join(root, 'mixed.jsonl');
804
+ fs.writeFileSync(src, [
805
+ JSON.stringify({ role: 'user', text: 'Do the thing.' }),
806
+ '{"role": "assistant", "text": ',
807
+ JSON.stringify({ role: 'assistant', text: 'Done.' }),
808
+ ].join('\n') + '\n');
809
+ const strict = run(['build', '--source', src, '--project', 'mixed'], root);
810
+ assert.equal(strict.status, 5, 'a mixed file must fail closed, got ' + strict.status + ' ' + strict.stderr);
811
+ assert.ok(/unparseable JSONL line\(s\) at 2 \(invalid JSON\)/.test(strict.stderr), 'the bad LINE is named: ' + strict.stderr);
812
+ const lax = run(['build', '--source', src, '--project', 'mixed', '--allow-bad-lines'], root);
813
+ assert.equal(lax.status, 0, '--allow-bad-lines proceeds: ' + lax.stderr);
814
+ assert.ok(/WARNING — skipped 1 unparseable line/.test(lax.stderr), 'the skip is announced, never silent: ' + lax.stderr);
815
+ assert.equal(timelineOf(findManifests(root)[0]).length, 2, 'the two good turns are the handoff');
816
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
817
+ });
818
+
819
+ // Fix D — HANDOFF.md is the gate's INPUT (the gate reads RESULT:/EVIDENCE: out of it), but a
820
+ // rebuild regenerated the file and erased the hand-authored contract, destroying the very
821
+ // input the next gate run needs.
822
+ test('D: a hand-authored evidence contract survives a rebuild and is hashed into the manifest', () => {
823
+ const root = scratch();
824
+ try {
825
+ const src = path.join(root, 'growing.jsonl');
826
+ fs.copyFileSync(FIXTURE, src);
827
+ assert.equal(run(['build', '--source', src, '--project', 'ev-proj'], root).status, 0, 'setup build');
828
+ const manP = findManifests(root)[0];
829
+ const mdP = path.join(path.dirname(manP), 'HANDOFF.md');
830
+ assert.ok(/\(none yet\)/.test(fs.readFileSync(mdP, 'utf8')), 'a fresh handoff still has no contract');
831
+ const contract = [
832
+ 'RESULT: PARTIAL',
833
+ 'WHAT_CHANGED: src/sum.js added',
834
+ 'VALIDATION: node --test tools/handoff.test.mjs',
835
+ 'EVIDENCE: dist/sum.js sha256=abc123',
836
+ 'BLOCKERS: none',
837
+ 'RISKS: none',
838
+ 'FOLLOW_UP: wire sum() into the CLI', ''].join('\n');
839
+ fs.appendFileSync(mdP, '\n' + contract);
840
+ // Grow the source so the rebuild is a REAL rewrite, not the up-to-date short circuit.
841
+ fs.appendFileSync(src, JSON.stringify({ seq: 9, ts: '2026-10-09T12:00:00Z', role: 'assistant', text: 'Wired sum() into the CLI.' }) + '\n');
842
+ const b2 = run(['build', '--source', src, '--project', 'ev-proj'], root);
843
+ assert.equal(b2.status, 0, 'rebuild: ' + b2.stderr);
844
+ assert.ok(!b2.stdout.includes('up-to-date'), 'the rebuild must actually rewrite HANDOFF.md: ' + b2.stdout);
845
+ const md2 = fs.readFileSync(mdP, 'utf8');
846
+ for (const field of ['RESULT:', 'WHAT_CHANGED:', 'VALIDATION:', 'EVIDENCE:', 'BLOCKERS:', 'RISKS:', 'FOLLOW_UP:']) {
847
+ assert.ok(md2.includes(field), field + ' was destroyed by the rebuild');
848
+ }
849
+ assert.ok(md2.includes('dist/sum.js sha256=abc123'), 'the evidence VALUE survived verbatim');
850
+ const man = JSON.parse(fs.readFileSync(manP, 'utf8'));
851
+ assert.ok(/^[0-9a-f]{64}$/.test(man.evidence_contract_sha256 || ''), 'the contract is hashed into the manifest: ' + man.evidence_contract_sha256);
852
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
853
+ });
854
+
855
+ // Fix C — `promote` called the gate, printed its verdict and threw it away (`void gateRun`),
856
+ // so a REJECTED handoff was promoted anyway: the gate was decorative.
857
+ test('C: promote refuses a handoff whose evidence gate FAILED; --force is the auditable override', () => {
858
+ const root = scratch();
859
+ try {
860
+ assert.equal(run(['build', '--source', FIXTURE, '--project', 'gate-proj'], root).status, 0, 'setup build');
861
+ const manP = findManifests(root)[0];
862
+ const id = JSON.parse(fs.readFileSync(manP, 'utf8')).session;
863
+ const pre = id.slice(0, 16);
864
+
865
+ // (1) no contract at all → the gate FAILS and the EXIT CODE carries that verdict.
866
+ const g1 = runL4(['verify-gate', pre], root);
867
+ assert.equal(g1.status, 6, 'a REJECTED gate must not exit 0: ' + g1.stdout + g1.stderr);
868
+ assert.equal(JSON.parse(g1.stdout).verdict, 'REJECTED', 'verdict: ' + g1.stdout);
869
+
870
+ // (2) promote must REFUSE. This is the defect: it used to print REJECTED and promote.
871
+ const p1 = runL4(['promote', pre], root);
872
+ assert.equal(p1.status, 6, 'promote must refuse a failed gate, got ' + p1.status + ' ' + p1.stdout + p1.stderr);
873
+ assert.ok(/promote refused: evidence gate REJECTED/.test(p1.stderr), 'the refusal names the gate: ' + p1.stderr);
874
+ assert.equal(JSON.parse(fs.readFileSync(manP, 'utf8')).promoted_at, undefined, 'a refused promote must not mark the manifest');
875
+
876
+ // (3) --force is deliberate and RECORDED as forced, never as verified.
877
+ const p2 = runL4(['promote', pre, '--force'], root);
878
+ assert.equal(p2.status, 0, 'the override proceeds: ' + p2.stdout + p2.stderr);
879
+ assert.equal(JSON.parse(p2.stdout).gate, 'FORCED', 'an override is recorded as FORCED: ' + p2.stdout);
880
+
881
+ // (4) the positive control: with a real contract the gate PASSES and promote carries VERIFIED.
882
+ const mdP = path.join(path.dirname(manP), 'HANDOFF.md');
883
+ fs.appendFileSync(mdP, '\n' + [
884
+ 'RESULT: PARTIAL',
885
+ 'WHAT_CHANGED: src/sum.js added',
886
+ 'VALIDATION: node --test tools/handoff.test.mjs',
887
+ 'EVIDENCE: dist/sum.js sha256=abc123',
888
+ 'BLOCKERS: none',
889
+ 'RISKS: none',
890
+ 'FOLLOW_UP: wire sum() into the CLI', ''].join('\n'));
891
+ const g2 = runL4(['verify-gate', pre], root);
892
+ assert.equal(g2.status, 0, 'a complete contract must pass the gate: ' + g2.stdout + g2.stderr);
893
+ assert.equal(JSON.parse(g2.stdout).verdict, 'VERIFIED');
894
+ const p3 = runL4(['promote', pre], root);
895
+ assert.equal(p3.status, 0, 'a verified handoff promotes without --force: ' + p3.stdout + p3.stderr);
896
+ assert.equal(JSON.parse(p3.stdout).gate, 'VERIFIED', 'a clean gate records VERIFIED: ' + p3.stdout);
897
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
898
+ });