agents-handoff 2.0.3 → 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 +76 -1
- package/README.md +7 -0
- package/SKILL.md +3 -3
- package/docs/ARCHITECTURE.md +5 -4
- package/docs/CHANGELOG.md +60 -0
- package/docs/CLI.md +10 -4
- package/docs/COMPATIBILITY.md +53 -5
- package/docs/FORMAT.md +19 -4
- package/docs/INTEGRATION.md +4 -1
- package/docs/LEVEL4.md +20 -9
- package/docs/SECURITY.md +2 -1
- package/docs/TROUBLESHOOTING.md +21 -9
- package/docs/index.md +2 -0
- package/install/CHANGELOG.md +10 -2
- package/install/install.mjs +20 -7
- package/install/package.json +1 -1
- package/package.json +1 -1
- package/skill.json +2 -2
- package/tools/agents-handoff.mjs +21 -13
- package/tools/handoff.mjs +109 -17
- package/tools/handoff.test.mjs +230 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,77 @@ 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
|
+
|
|
60
|
+
## [2.0.4] - 2026-10-09
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- A demo animation on the README and the documentation home page: one run installing into every
|
|
65
|
+
harness, a session captured and verified, and an installation proved against the published
|
|
66
|
+
tarball (`assets/handoff-demo.gif`).
|
|
67
|
+
- **[Compatibility](https://github.com/Alot1z/agent-handoff/blob/main/docs/COMPATIBILITY.md)**
|
|
68
|
+
(`docs/COMPATIBILITY.md`) now records what was measured rather than what
|
|
69
|
+
is assumed: Node 26 verified, and Bun verified as an alternative runtime — the engine's
|
|
70
|
+
verbs run unchanged there, and the suite passes 34/34 with `bun test --timeout 30000` (Bun's
|
|
71
|
+
default 5-second per-test timeout is shorter than the suite's child-process tests). It also
|
|
72
|
+
states why the artifacts are JavaScript rather than TypeScript: the contracts are the
|
|
73
|
+
versioned JSON Schemas the runtime validates against, and a build step would put a compiler
|
|
74
|
+
between a user and a working tool.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- `--update` and `--verify` reported "no installations found" on a machine whose copies predate
|
|
79
|
+
the 2.0.3 rename, because they only looked for `agents-handoff/`. Both now recognise the
|
|
80
|
+
`agent-handoff/` directory every earlier release installed and act on it in place.
|
|
81
|
+
- The global-root search missed `~/.config`, which is where a desktop client keeps its
|
|
82
|
+
account-skill store on Windows too — so that store's installation was invisible to `--list`,
|
|
83
|
+
`--update`, `--verify` and `doctor` even though the client kept reading it.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- `remove` states what it keeps before asking, so its confirmation matches what it does.
|
|
88
|
+
|
|
18
89
|
## [2.0.3] - 2026-10-09
|
|
19
90
|
|
|
20
91
|
### Added
|
|
@@ -187,6 +258,10 @@ documentation site.
|
|
|
187
258
|
[docs/PROVENANCE.md](https://github.com/Alot1z/agent-handoff/blob/main/docs/PROVENANCE.md),
|
|
188
259
|
not implied.
|
|
189
260
|
|
|
190
|
-
[Unreleased]: https://github.com/Alot1z/agent-handoff/compare/v2.0.
|
|
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
|
|
191
266
|
[2.0.1]: https://github.com/Alot1z/agent-handoff/compare/v2.0.0...v2.0.1
|
|
192
267
|
[2.0.0]: https://github.com/Alot1z/agent-handoff/tree/v2.0.0
|
package/README.md
CHANGED
|
@@ -11,6 +11,13 @@ reasoning, and the provenance to prove where each byte came from — is captured
|
|
|
11
11
|
that a fresh agent can continue from with zero shared memory. Zero runtime dependencies,
|
|
12
12
|
Node.js 18 or newer, nothing read from the network at run time.
|
|
13
13
|
|
|
14
|
+

|
|
16
|
+
|
|
17
|
+
*An abbreviated run of 2.0.3: `--all` installs into every harness found, a session is captured
|
|
18
|
+
and verified, and `--verify-package` proves the installed copy is the published one. The sample
|
|
19
|
+
session is illustrative; the command and output shapes are the real ones.*
|
|
20
|
+
|
|
14
21
|
## Install
|
|
15
22
|
|
|
16
23
|
```bash
|
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.
|
|
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.
|
|
147
|
-
**Last Updated**: 2026-10-
|
|
146
|
+
**Version**: 2.0.5
|
|
147
|
+
**Last Updated**: 2026-10-09
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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,66 @@ 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
|
+
|
|
53
|
+
## [2.0.4] - 2026-10-09
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- A demo animation on the README and the documentation home page: one run installing into every
|
|
58
|
+
harness, a session captured and verified, and an installation proved against the published
|
|
59
|
+
tarball (`assets/handoff-demo.gif`).
|
|
60
|
+
- **[Compatibility](https://github.com/Alot1z/agent-handoff/blob/main/docs/COMPATIBILITY.md)**
|
|
61
|
+
(`docs/COMPATIBILITY.md`) now records what was measured rather than what
|
|
62
|
+
is assumed: Node 26 verified, and Bun verified as an alternative runtime — the engine's
|
|
63
|
+
verbs run unchanged there, and the suite passes 34/34 with `bun test --timeout 30000` (Bun's
|
|
64
|
+
default 5-second per-test timeout is shorter than the suite's child-process tests). It also
|
|
65
|
+
states why the artifacts are JavaScript rather than TypeScript: the contracts are the
|
|
66
|
+
versioned JSON Schemas the runtime validates against, and a build step would put a compiler
|
|
67
|
+
between a user and a working tool.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- `--update` and `--verify` reported "no installations found" on a machine whose copies predate
|
|
72
|
+
the 2.0.3 rename, because they only looked for `agents-handoff/`. Both now recognise the
|
|
73
|
+
`agent-handoff/` directory every earlier release installed and act on it in place.
|
|
74
|
+
- The global-root search missed `~/.config`, which is where a desktop client keeps its
|
|
75
|
+
account-skill store on Windows too — so that store's installation was invisible to `--list`,
|
|
76
|
+
`--update`, `--verify` and `doctor` even though the client kept reading it.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- `remove` states what it keeps before asking, so its confirmation matches what it does.
|
|
81
|
+
|
|
22
82
|
## [2.0.3] - 2026-10-09
|
|
23
83
|
|
|
24
84
|
### 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>` |
|
|
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`
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -37,19 +37,64 @@ dependencies.
|
|
|
37
37
|
| 18.x | SUPPORTED (minimum) |
|
|
38
38
|
| 20.x | SUPPORTED |
|
|
39
39
|
| 22.x | SUPPORTED |
|
|
40
|
+
| 26.x | VERIFIED (developed and released on it) |
|
|
40
41
|
| < 18 | UNSUPPORTED |
|
|
41
42
|
|
|
43
|
+
## Other runtimes: Bun, Deno, TypeScript
|
|
44
|
+
|
|
45
|
+
The tools are plain ESM with `node:` built-ins and no dependencies, so another runtime that
|
|
46
|
+
implements those APIs runs them unchanged. Bun was tested against this release:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun tools/handoff.mjs config
|
|
50
|
+
bun tools/handoff.mjs build --source transcript.jsonl --project my-project
|
|
51
|
+
bun tools/handoff.mjs verify my-project # PASS, same hashes as Node
|
|
52
|
+
bun tools/agents-handoff.mjs index
|
|
53
|
+
bun test --timeout 30000 tools/handoff.test.mjs # 34 pass, 0 fail
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
One caveat, and it is a property of the runner rather than of the tools: the suite spawns child
|
|
57
|
+
Node processes, and Bun's default per-test timeout is 5 seconds, so raising it
|
|
58
|
+
(`--timeout 30000`) is what makes all 34 tests pass — at the default, one test times out.
|
|
59
|
+
|
|
60
|
+
The published artifact is JavaScript, not TypeScript, and there is no build step to add one.
|
|
61
|
+
What TypeScript would have declared — the shape of a handoff, a manifest and the LLM payload —
|
|
62
|
+
is declared by the versioned JSON Schemas the runtime validates against
|
|
63
|
+
(`schemas/handoff.schema.json`, `templates/HANDOFF.llm.schema.json`,
|
|
64
|
+
`handoff.config.schema.json`), and `docs/CLI.md` is the interface of record for the commands.
|
|
65
|
+
|
|
42
66
|
## Input formats the engine parses
|
|
43
67
|
|
|
44
68
|
| Format | How it is detected | Fields read |
|
|
45
69
|
|---|---|---|
|
|
46
|
-
| JSONL | `.jsonl` extension | `seq`, `ts`/`timestamp`, `role`, `kind`, and the text from `text`, `content`,
|
|
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`) |
|
|
47
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 |
|
|
48
72
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
|
|
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.
|
|
53
98
|
|
|
54
99
|
## Session sources
|
|
55
100
|
|
|
@@ -98,6 +143,9 @@ Root resolution order, first match wins: `HANDOFFS_ROOT`, then a config-declared
|
|
|
98
143
|
| 0 | Success |
|
|
99
144
|
| 1 | Integrity failure (manifest mismatch, corrupted manifest) or an unexpected error |
|
|
100
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 |
|
|
101
149
|
| 3 | Ambiguous session-id prefix |
|
|
102
150
|
| 4 | No parsable turns, no handoffs, or no match for the prefix |
|
|
103
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
|
-
|
|
129
|
-
|
|
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 |
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -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>` |
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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`).
|
|
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
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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
|
|
97
|
+
## `verify-gate` exits 6 and the verdict says REJECTED
|
|
98
98
|
|
|
99
|
-
**Cause:**
|
|
100
|
-
|
|
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:**
|
|
103
|
-
|
|
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`
|
|
107
|
+
## `promote` refused: evidence gate REJECTED
|
|
106
108
|
|
|
107
|
-
**Cause:**
|
|
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:**
|
|
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
|
|
package/docs/index.md
CHANGED
|
@@ -10,6 +10,8 @@ or a colleague can read and continue from without the original chat. It builds f
|
|
|
10
10
|
transcript or an adapter export, keeps a hash chain so a handoff can be re-verified, and
|
|
11
11
|
merges later turns into the same session instead of duplicating it.
|
|
12
12
|
|
|
13
|
+

|
|
14
|
+
|
|
13
15
|
## Quick start
|
|
14
16
|
|
|
15
17
|
```bash
|
package/install/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# Changelog — agents-handoff
|
|
2
2
|
|
|
3
|
-
The installer
|
|
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
|
-
|
|
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
|
package/install/install.mjs
CHANGED
|
@@ -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.
|
|
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 =
|
|
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 =
|
|
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');
|
package/install/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-handoff",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"packageVersion": "2.0.5",
|
|
10
10
|
"repository": "https://github.com/Alot1z/agent-handoff",
|
|
11
11
|
"installCommand": "npx agents-handoff"
|
|
12
12
|
},
|
package/tools/agents-handoff.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
if (
|
|
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
|
-
|
|
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) {
|
|
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
|
|
54
|
-
return { seq: Number.isFinite(+r.seq) ? +r.seq : i, ts: String(r.ts || r.timestamp || ''), cls: classify(r), text: t };
|
|
55
|
-
})
|
|
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
|
|
164
|
-
|
|
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".
|
package/tools/handoff.test.mjs
CHANGED
|
@@ -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
|
+
});
|