fleetproof 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (33) hide show
  1. {fleetproof-0.3.0/src/fleetproof.egg-info → fleetproof-0.3.1}/PKG-INFO +26 -12
  2. {fleetproof-0.3.0 → fleetproof-0.3.1}/README.md +25 -11
  3. {fleetproof-0.3.0 → fleetproof-0.3.1}/pyproject.toml +1 -1
  4. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/__init__.py +1 -1
  5. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/cli.py +62 -1
  6. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/hookgate.py +95 -17
  7. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/ledger.py +141 -0
  8. {fleetproof-0.3.0 → fleetproof-0.3.1/src/fleetproof.egg-info}/PKG-INFO +26 -12
  9. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_cli.py +55 -0
  10. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_hookgate.py +249 -0
  11. {fleetproof-0.3.0 → fleetproof-0.3.1}/LICENSE +0 -0
  12. {fleetproof-0.3.0 → fleetproof-0.3.1}/setup.cfg +0 -0
  13. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/__main__.py +0 -0
  14. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/checker.py +0 -0
  15. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/checks.py +0 -0
  16. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/config.py +0 -0
  17. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/report.py +0 -0
  18. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/runlog.py +0 -0
  19. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/telemetry.py +0 -0
  20. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/telemetry_export.py +0 -0
  21. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/SOURCES.txt +0 -0
  22. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/dependency_links.txt +0 -0
  23. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/entry_points.txt +0 -0
  24. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/requires.txt +0 -0
  25. {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/top_level.txt +0 -0
  26. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_adversarial_regressions.py +0 -0
  27. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_checker.py +0 -0
  28. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_checks.py +0 -0
  29. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_ledger.py +0 -0
  30. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_report.py +0 -0
  31. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_runlog.py +0 -0
  32. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_telemetry.py +0 -0
  33. {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_telemetry_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleetproof
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Independent, out-of-band verification for agent fleets.
5
5
  Author: Gathrazio
6
6
  License: MIT
@@ -161,10 +161,12 @@ SubagentStop is the gate. Each step is its own reason to refuse the stop:
161
161
  2. Pin drift. Every dispatch pins the SHA-256 of the check spec that was in force
162
162
  when the work was ordered. If the spec changed mid-flight, the stop is blocked
163
163
  rather than graded against a spec the agent could have edited itself.
164
- 3. That agent's tier of the spec. A blocking failure records `contradicted` and
165
- blocks; a pass records `verified` and closes the dispatch. If the tier selects
166
- no checks at all, no verdict is recorded — an absent grade must never read as a
167
- passing one.
164
+ 3. That agent's tier of the spec, unioned with any checks declared on the
165
+ dispatch's own manifest (`{"id", "cmd"}` entries — blocking, expect-exit0).
166
+ A blocking failure records `contradicted` and blocks; a pass records
167
+ `verified` and closes the dispatch. If nothing is runnable — the tier selects
168
+ no repo checks and the manifest declares none — no verdict is recorded: an
169
+ absent grade must never read as a passing one.
168
170
 
169
171
  The Stop hook keeps its v0.1 job and adds a ledger sweep. A dispatch that reported
170
172
  and then never terminated blocks the bridge from stopping: something started
@@ -179,6 +181,10 @@ fleetproof dispatch new --prompt "..." # --prompt-file for a real, long o
179
181
  fleetproof dispatch report <run-id> --report report.json
180
182
  fleetproof dispatch close <run-id>
181
183
 
184
+ fleetproof dispatch intent --agent recon --prompt-file p.md \
185
+ [--manifest m.json] [--tier lane] # declare the NEXT spawn of an agent
186
+ # type; the start capture consumes it
187
+
182
188
  fleetproof fleet # the board, newest first
183
189
  fleetproof fleet --open # only what has not terminated
184
190
  fleetproof fleet --format json # full records, for an agent to read
@@ -194,12 +200,16 @@ transition trail, and which process wrote each transition.
194
200
 
195
201
  ### What the ledger does not do yet
196
202
 
197
- - The spawn prompt is in neither subagent hook payload. A captured dispatch records
198
- a placeholder saying so, rather than inventing a prompt it never saw. To get the
199
- real prompt on the record, dispatch through `fleetproof dispatch new`.
200
- - Captured subagents are recorded as `lane` tier. Real nesting depth is not visible
201
- from the payload — there is no parent-agent field — so declaring one beats
202
- inferring it wrongly. What that costs in practice is a pilot question.
203
+ - The spawn prompt is in neither subagent hook payload, so an undeclared spawn
204
+ still records a placeholder saying so, rather than inventing a prompt it never
205
+ saw. To get the real prompt (and manifest, and tier) onto a captured dispatch,
206
+ write an intent sidecar first: `fleetproof dispatch intent --agent <type>
207
+ --prompt-file <file>` writes `.fleetproof/intents/<agent_type>.json`, and the
208
+ next SubagentStart of that agent type consumes it — one intent, one spawn.
209
+ - Captured subagents without a declared intent tier are recorded as `lane` tier.
210
+ Real nesting depth is not visible from the payload — there is no parent-agent
211
+ field — so declaring one beats inferring it wrongly. What that costs in
212
+ practice is a pilot question.
203
213
  - An agent killed mid-turn can reach SubagentStop with an empty final message,
204
214
  which report-before-idle blocks. Blocking the stop of an agent that is already
205
215
  gone is not useful; how often it happens is under observation.
@@ -267,7 +277,11 @@ operator-attested. The checker writes verdicts from a separate process, and v0.3
267
277
  chains a rolling hash over the records as they're written (`anchor` gives you a
268
278
  head you can sign or store elsewhere) — but a local ledger on a shared filesystem
269
279
  is not tamper-proof against everything that can write to it, and FleetProof will
270
- not pretend otherwise.
280
+ not pretend otherwise. The dispatch-intent sidecar (0.3.1) sits in the same
281
+ trust class as `checks.json` itself: operator-attested, exactly as forgeable as
282
+ the rest of `.fleetproof/`, no worse — anything that can write the repo can
283
+ write an intent, and the manifest checks it carries run as shell commands at
284
+ the stop gate the same way repo checks do.
271
285
 
272
286
  ## Install (each line is one command in Claude Code)
273
287
 
@@ -140,10 +140,12 @@ SubagentStop is the gate. Each step is its own reason to refuse the stop:
140
140
  2. Pin drift. Every dispatch pins the SHA-256 of the check spec that was in force
141
141
  when the work was ordered. If the spec changed mid-flight, the stop is blocked
142
142
  rather than graded against a spec the agent could have edited itself.
143
- 3. That agent's tier of the spec. A blocking failure records `contradicted` and
144
- blocks; a pass records `verified` and closes the dispatch. If the tier selects
145
- no checks at all, no verdict is recorded — an absent grade must never read as a
146
- passing one.
143
+ 3. That agent's tier of the spec, unioned with any checks declared on the
144
+ dispatch's own manifest (`{"id", "cmd"}` entries — blocking, expect-exit0).
145
+ A blocking failure records `contradicted` and blocks; a pass records
146
+ `verified` and closes the dispatch. If nothing is runnable — the tier selects
147
+ no repo checks and the manifest declares none — no verdict is recorded: an
148
+ absent grade must never read as a passing one.
147
149
 
148
150
  The Stop hook keeps its v0.1 job and adds a ledger sweep. A dispatch that reported
149
151
  and then never terminated blocks the bridge from stopping: something started
@@ -158,6 +160,10 @@ fleetproof dispatch new --prompt "..." # --prompt-file for a real, long o
158
160
  fleetproof dispatch report <run-id> --report report.json
159
161
  fleetproof dispatch close <run-id>
160
162
 
163
+ fleetproof dispatch intent --agent recon --prompt-file p.md \
164
+ [--manifest m.json] [--tier lane] # declare the NEXT spawn of an agent
165
+ # type; the start capture consumes it
166
+
161
167
  fleetproof fleet # the board, newest first
162
168
  fleetproof fleet --open # only what has not terminated
163
169
  fleetproof fleet --format json # full records, for an agent to read
@@ -173,12 +179,16 @@ transition trail, and which process wrote each transition.
173
179
 
174
180
  ### What the ledger does not do yet
175
181
 
176
- - The spawn prompt is in neither subagent hook payload. A captured dispatch records
177
- a placeholder saying so, rather than inventing a prompt it never saw. To get the
178
- real prompt on the record, dispatch through `fleetproof dispatch new`.
179
- - Captured subagents are recorded as `lane` tier. Real nesting depth is not visible
180
- from the payload — there is no parent-agent field — so declaring one beats
181
- inferring it wrongly. What that costs in practice is a pilot question.
182
+ - The spawn prompt is in neither subagent hook payload, so an undeclared spawn
183
+ still records a placeholder saying so, rather than inventing a prompt it never
184
+ saw. To get the real prompt (and manifest, and tier) onto a captured dispatch,
185
+ write an intent sidecar first: `fleetproof dispatch intent --agent <type>
186
+ --prompt-file <file>` writes `.fleetproof/intents/<agent_type>.json`, and the
187
+ next SubagentStart of that agent type consumes it — one intent, one spawn.
188
+ - Captured subagents without a declared intent tier are recorded as `lane` tier.
189
+ Real nesting depth is not visible from the payload — there is no parent-agent
190
+ field — so declaring one beats inferring it wrongly. What that costs in
191
+ practice is a pilot question.
182
192
  - An agent killed mid-turn can reach SubagentStop with an empty final message,
183
193
  which report-before-idle blocks. Blocking the stop of an agent that is already
184
194
  gone is not useful; how often it happens is under observation.
@@ -246,7 +256,11 @@ operator-attested. The checker writes verdicts from a separate process, and v0.3
246
256
  chains a rolling hash over the records as they're written (`anchor` gives you a
247
257
  head you can sign or store elsewhere) — but a local ledger on a shared filesystem
248
258
  is not tamper-proof against everything that can write to it, and FleetProof will
249
- not pretend otherwise.
259
+ not pretend otherwise. The dispatch-intent sidecar (0.3.1) sits in the same
260
+ trust class as `checks.json` itself: operator-attested, exactly as forgeable as
261
+ the rest of `.fleetproof/`, no worse — anything that can write the repo can
262
+ write an intent, and the manifest checks it carries run as shell commands at
263
+ the stop gate the same way repo checks do.
250
264
 
251
265
  ## Install (each line is one command in Claude Code)
252
266
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fleetproof"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Independent, out-of-band verification for agent fleets."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -5,7 +5,7 @@ The agent may author the checks; it never executes and grades its own work.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- __version__ = "0.2.1"
8
+ __version__ = "0.3.1"
9
9
 
10
10
  from .runlog import (
11
11
  RunRecord,
@@ -13,7 +13,8 @@ surface as it can. Subcommands:
13
13
  record PostToolUse-hook entry: append an evidence record from hook stdin
14
14
  subagent-start SubagentStart-hook entry: put a spawning subagent on the ledger
15
15
  subagent-stop SubagentStop-hook entry: the per-subagent report-and-verify gate
16
- dispatch ledger verbs: new / report / close a dispatch
16
+ dispatch ledger verbs: new / report / close a dispatch; intent writes the
17
+ sidecar the SubagentStart capture consumes
17
18
  fleet the dispatch board — every dispatch, its state, and whether it reported
18
19
  telemetry summary (local-only aggregates) / export (allowlisted bundle) /
19
20
  anchor (chain head) / loss (the one raw-loss question per failure)
@@ -61,6 +62,7 @@ from .ledger import (
61
62
  list_dispatches,
62
63
  load_dispatch,
63
64
  record_report,
65
+ write_intent,
64
66
  )
65
67
  # The board and the HTML report describe a dispatch with the same words, defined
66
68
  # once in report.py. Two surfaces disagreeing about what a state is called is how
@@ -333,6 +335,49 @@ def _cmd_dispatch_new(args: argparse.Namespace) -> int:
333
335
  return 0
334
336
 
335
337
 
338
+ def _cmd_dispatch_intent(args: argparse.Namespace) -> int:
339
+ """Write the dispatch-intent sidecar the next SubagentStart capture consumes.
340
+
341
+ Exists so dispatchers never hand-write the JSON: the prompt comes from a
342
+ file (same rationale as ``dispatch new --prompt-file`` — shell-quoting a
343
+ real prompt on Windows is how prompts get mangled), the manifest is
344
+ validated here where an error still has someone to land on, and the printed
345
+ path is the audit surface: the file that will vanish when the spawn
346
+ consumes it.
347
+ """
348
+ try:
349
+ prompt = Path(args.prompt_file).read_text(encoding="utf-8")
350
+ except OSError as e:
351
+ _emit_error("bad_prompt",
352
+ f"Could not read prompt file {args.prompt_file}: {e}", args.format)
353
+ return 2
354
+
355
+ manifest = None
356
+ if args.manifest:
357
+ try:
358
+ manifest = _load_json_file(args.manifest, "manifest")
359
+ except ValueError as e:
360
+ _emit_error("bad_manifest", str(e), args.format)
361
+ return 2
362
+ # Same tolerance as dispatch new: a bare manifest object or a wrapper
363
+ # with a "manifest" key both work.
364
+ inner = manifest.get("manifest")
365
+ if isinstance(inner, dict):
366
+ manifest = inner
367
+
368
+ try:
369
+ path = write_intent(args.agent, prompt, manifest=manifest, tier=args.tier)
370
+ except LedgerError as e:
371
+ _emit_error("ledger_error", str(e), args.format)
372
+ return 1
373
+ if args.format == "json":
374
+ print(json.dumps({"ok": True, "agent_type": args.agent,
375
+ "intent_path": str(path)}))
376
+ else:
377
+ print(str(path))
378
+ return 0
379
+
380
+
336
381
  def _cmd_dispatch_report(args: argparse.Namespace) -> int:
337
382
  try:
338
383
  payload = _load_json_file(args.report, "report")
@@ -613,6 +658,22 @@ def build_parser() -> argparse.ArgumentParser:
613
658
  _add_format(p_dnew)
614
659
  p_dnew.set_defaults(func=_cmd_dispatch_new)
615
660
 
661
+ p_dint = dsub.add_parser(
662
+ "intent",
663
+ help="Write the dispatch-intent sidecar for the next spawn of one agent type.")
664
+ p_dint.add_argument("--agent", required=True,
665
+ help="The agent_type the harness will report for the spawn.")
666
+ p_dint.add_argument("--prompt-file", required=True,
667
+ help="File holding the dispatch prompt, verbatim.")
668
+ p_dint.add_argument("--manifest", default=None,
669
+ help="JSON file with the dispatch manifest "
670
+ "(bare object or a {'manifest': ...} wrapper).")
671
+ p_dint.add_argument("--tier", choices=sorted(VALID_TIERS), default=None,
672
+ help="Declare the tier. Omit to record the captured-subagent "
673
+ "default (lane).")
674
+ _add_format(p_dint)
675
+ p_dint.set_defaults(func=_cmd_dispatch_intent)
676
+
616
677
  p_drep = dsub.add_parser("report", help="Record what a dispatched agent claimed.")
617
678
  p_drep.add_argument("run_id")
618
679
  p_drep.add_argument("--report", required=True, help="JSON file holding the report.")
@@ -26,8 +26,10 @@ everything here is designed to fail open around the gaps):
26
26
  - SubagentStart carries ``{session_id, transcript_path, cwd, hook_event_name,
27
27
  agent_id, agent_type}`` and cannot block.
28
28
  - SubagentStop adds ``last_assistant_message`` and can block, same contract as Stop.
29
- - The spawn *prompt* is in neither payload. So a captured dispatch records a
30
- placeholder prompt and says so, rather than inventing the prompt it did not see.
29
+ - The spawn *prompt* is in neither payload. A captured dispatch records a
30
+ placeholder prompt and says so, rather than inventing the prompt it did not
31
+ see — unless the dispatcher declared it up front via an intent sidecar
32
+ (``.fleetproof/intents/<agent_type>.json``), which the start capture consumes.
31
33
  - There is no correlation field between the Task-tool call that spawned an agent
32
34
  and that agent's stop, so ``agent_id`` + ``session_id`` is the only join key.
33
35
  """
@@ -50,6 +52,7 @@ from .checker import (
50
52
  )
51
53
  from .checks import (
52
54
  SPEC_DRIFT_NOTE,
55
+ Check,
53
56
  CheckSpecError,
54
57
  load_checks,
55
58
  short_spec_hash,
@@ -64,6 +67,7 @@ from .ledger import (
64
67
  TIER_BRIDGE,
65
68
  LedgerError,
66
69
  close_dispatch,
70
+ consume_intent,
67
71
  create_dispatch,
68
72
  find_dispatch_by_agent,
69
73
  list_dispatches,
@@ -367,7 +371,8 @@ def _suppress_on_retry(payload: dict[str, Any], decision: dict[str, Any]) -> boo
367
371
 
368
372
  # The harness does not put the spawn prompt in either subagent payload, so a
369
373
  # captured dispatch says exactly that instead of pretending to quote a prompt it
370
- # never saw. Phase C's pilot measures how much this costs us.
374
+ # never saw. The intent sidecar is the dispatcher's way around the gap; this
375
+ # placeholder is what every dispatch without one records.
371
376
  UNCAPTURED_PROMPT = "[uncaptured] subagent {agent_type} spawn — prompt not in harness payload"
372
377
 
373
378
  # Policy v0.2: any subagent captured inside a session is lane tier. Real nesting
@@ -392,11 +397,30 @@ def _placeholder_prompt(agent_type: str | None) -> str:
392
397
 
393
398
 
394
399
  def capture_subagent_start(payload: dict[str, Any]) -> str:
395
- """Put a spawning subagent on the ledger before it does any work."""
400
+ """Put a spawning subagent on the ledger before it does any work.
401
+
402
+ When the dispatcher left an intent sidecar for this agent type
403
+ (``.fleetproof/intents/<agent_type>.json``, written via ``fleetproof
404
+ dispatch intent``), the dispatch is created with the intent's real prompt,
405
+ manifest, and tier — the harness still does not carry the spawn prompt, so
406
+ the sidecar is the only route it has onto the record. The intent is
407
+ consumed on match: one intent, one spawn, and a second spawn of the same
408
+ agent type gets exactly the placeholder capture a repo without sidecars
409
+ always got. Degradations (malformed intent, malformed manifest, bad tier)
410
+ are surfaced on stderr and in the manifest notes, never fatal — this runs
411
+ inside a hook that must not break the spawn.
412
+ """
396
413
  agent_id, agent_type = _agent_fields(payload)
414
+ intent, notes = consume_intent(agent_type)
415
+ for note in notes:
416
+ sys.stderr.write(f"[fleetproof] {note}\n")
417
+ prompt = intent["prompt"] if intent else _placeholder_prompt(agent_type)
418
+ manifest = intent["manifest"] if intent else None
419
+ tier = (intent["tier"] if intent else None) or CAPTURED_SUBAGENT_TIER
397
420
  return create_dispatch(
398
- _placeholder_prompt(agent_type),
399
- tier=CAPTURED_SUBAGENT_TIER,
421
+ prompt,
422
+ tier=tier,
423
+ manifest=manifest,
400
424
  agent={"agent_id": agent_id, "agent_type": agent_type, "capture": CAPTURE_START},
401
425
  by="hook",
402
426
  )
@@ -468,6 +492,49 @@ def _try_build_telemetry(run_id: str, check_report=None, checks=None) -> None:
468
492
  sys.stderr.write(f"[fleetproof] telemetry build failed for {run_id}: {e}\n")
469
493
 
470
494
 
495
+ def _manifest_checks(dispatch) -> list[Check]:
496
+ """Runnable blocking checks declared on the dispatch's own manifest.
497
+
498
+ A manifest check is an ``{"id", "cmd"}`` entry: always blocking, always
499
+ expect-exit0. The richer expectation kinds stay a ``checks.json`` feature —
500
+ the manifest is a per-dispatch contract, and its checks exist so a
501
+ manifest-bearing dispatch can grade without pre-registering into the
502
+ spec-hash-pinned repo file.
503
+
504
+ An entry that is not that shape is skipped with a stderr note rather than
505
+ run half-parsed. A skipped check is never a passing check: its id never
506
+ reaches the executed set, so the deliverable it vouched for reads as
507
+ uncovered — the error lands on the self-critical side of the metric.
508
+ """
509
+ entries = (dispatch.manifest or {}).get("checks")
510
+ if not isinstance(entries, list):
511
+ return []
512
+ out: list[Check] = []
513
+ seen: set[str] = set()
514
+ for i, entry in enumerate(entries):
515
+ where = f"manifest check [{i}] on dispatch {dispatch.run_id}"
516
+ if not isinstance(entry, dict):
517
+ sys.stderr.write(f"[fleetproof] {where} is not an object; skipped\n")
518
+ continue
519
+ cid, cmd = entry.get("id"), entry.get("cmd")
520
+ if not isinstance(cid, str) or not cid or not isinstance(cmd, str) or not cmd.strip():
521
+ sys.stderr.write(
522
+ f"[fleetproof] {where} needs a string 'id' and 'cmd'; skipped\n")
523
+ continue
524
+ if "\n" in cmd or "\r" in cmd:
525
+ # Same rule the spec loader enforces (finding H5): cmd.exe executes
526
+ # only the first line, and a check that half-runs is worse than one
527
+ # that never runs.
528
+ sys.stderr.write(f"[fleetproof] {where}: 'cmd' must be a single line; skipped\n")
529
+ continue
530
+ if cid in seen:
531
+ continue
532
+ seen.add(cid)
533
+ out.append(Check(id=cid, run=cmd, expect={"kind": "exit0"}, block=True,
534
+ description="dispatch-manifest check"))
535
+ return out
536
+
537
+
471
538
  def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
472
539
  """The per-subagent gate. Returns ``(decision_or_None, exit_code)``.
473
540
 
@@ -481,13 +548,17 @@ def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
481
548
  3. If the spec changed since this dispatch was pinned, block. At lane tier and
482
549
  deeper the pinned spec is the contract; grading against a spec the agent
483
550
  could have edited mid-flight is not verification.
484
- 4. Grade the agent's own tier of the spec. Blocking failure -> ``contradicted``
485
- and block (the agent gets its turn back, and the retry re-reports onto this
486
- same dispatch). Otherwise -> ``verified`` and terminate.
487
-
488
- When the agent's tier selects no checks, no verdict is recorded at all: the
489
- dispatch is terminated still-ungraded, because an absent grade must never read
490
- as a passing grade.
551
+ 4. Grade the agent's own tier of the spec, unioned with the checks declared
552
+ on this dispatch's own manifest (blocking, expect-exit0). Blocking
553
+ failure -> ``contradicted`` and block (the agent gets its turn back, and
554
+ the retry re-reports onto this same dispatch). Otherwise -> ``verified``
555
+ and terminate.
556
+
557
+ When nothing is runnable — the tier selects no repo checks *and* the
558
+ manifest declares none — no verdict is recorded at all: the dispatch is
559
+ terminated still-ungraded, because an absent grade must never read as a
560
+ passing grade. A manifest-bearing dispatch can therefore always grade,
561
+ which is the L21 fix: its checks no longer need to live in ``checks.json``.
491
562
  """
492
563
  session_id = os.environ.get(SESSION_ID_ENV)
493
564
  agent_id, agent_type = _agent_fields(payload)
@@ -552,7 +623,14 @@ def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
552
623
  # not manufacture a verdict out of the absence of checks.
553
624
  checks = []
554
625
  selected = select_checks(checks, dispatch.tier) if checks else []
555
- if not selected:
626
+ # Union with the dispatch's own manifest checks, repo spec first. A manifest
627
+ # check whose id collides with a selected repo check is dropped: the repo
628
+ # spec is the more attested source, and two commands under one id would make
629
+ # the executed-id set (which coverage joins on) ambiguous.
630
+ selected_ids = {c.id for c in selected}
631
+ runnable = selected + [c for c in _manifest_checks(dispatch)
632
+ if c.id not in selected_ids]
633
+ if not runnable:
556
634
  _try_close(dispatch.run_id)
557
635
  # The grading *happened* and selected nothing — recorded as an empty
558
636
  # CheckReport so the telemetry layer can tell "checked nothing on
@@ -564,7 +642,7 @@ def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
564
642
  )
565
643
  return None, 0
566
644
 
567
- report = run_checks(selected, record_to_log=True, tier=dispatch.tier)
645
+ report = run_checks(runnable, record_to_log=True, tier=dispatch.tier)
568
646
  if report.blocking_failures:
569
647
  record_verdict(
570
648
  dispatch.run_id,
@@ -572,7 +650,7 @@ def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
572
650
  detail="; ".join(r.id for r in report.blocking_failures),
573
651
  by=VERDICT_BY,
574
652
  )
575
- _try_build_telemetry(dispatch.run_id, check_report=report, checks=selected)
653
+ _try_build_telemetry(dispatch.run_id, check_report=report, checks=runnable)
576
654
  return _subagent_block(_failure_reason(report), _evidence_context(report)), 0
577
655
 
578
656
  record_verdict(
@@ -584,7 +662,7 @@ def subagent_stop(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, int]:
584
662
  _try_close(dispatch.run_id)
585
663
  # Built after the close, so the outcome class derives from a finished
586
664
  # lifecycle rather than a snapshot mid-transition.
587
- _try_build_telemetry(dispatch.run_id, check_report=report, checks=selected)
665
+ _try_build_telemetry(dispatch.run_id, check_report=report, checks=runnable)
588
666
  return None, 0
589
667
 
590
668
 
@@ -52,6 +52,8 @@ Public API:
52
52
  list_dispatches(*, session_id, open_only, non_terminal_only) -> list[DispatchRecord]
53
53
  find_dispatch_by_agent(session_id, agent_id) -> DispatchRecord | None
54
54
  derive_manifest(prompt) -> dict
55
+ write_intent(agent_type, prompt, *, manifest, tier) -> Path
56
+ consume_intent(agent_type) -> (fields_or_None, notes)
55
57
  """
56
58
 
57
59
  from __future__ import annotations
@@ -163,6 +165,23 @@ EMPTY_MANIFEST_KEYS = ("deliverables", "allowed_paths", "checks")
163
165
  DERIVED_V1_NO_MANIFEST = "derived-v1: no structured manifest in prompt"
164
166
  DERIVED_V1_MALFORMED = "derived-v1: embedded manifest was malformed and ignored"
165
167
 
168
+ # Where dispatch-intent sidecars live, next to the runs directory. An intent is
169
+ # the dispatcher's declaration of what it is about to spawn — the harness does
170
+ # not put the spawn prompt in the SubagentStart payload, so the sidecar is the
171
+ # only way a real prompt (and manifest, and tier) reaches the captured dispatch.
172
+ INTENTS_DIRNAME = "intents"
173
+
174
+ INTENT_MANIFEST_MALFORMED = (
175
+ "intent-sidecar: declared manifest was malformed and ignored; "
176
+ "derived from the intent prompt instead"
177
+ )
178
+
179
+ # What may name an intent file. The agent_type on the consume side comes out of
180
+ # the harness payload; building a path from a payload-controlled string is how
181
+ # a hook gets walked out of its own directory, so anything that is not a plain
182
+ # name gets no sidecar lookup at all.
183
+ _INTENT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
184
+
166
185
 
167
186
  class LedgerError(Exception):
168
187
  """Raised on an illegal state transition or an unusable dispatch/report."""
@@ -544,6 +563,128 @@ def derive_manifest(prompt: str) -> dict[str, Any]:
544
563
  return empty_manifest(DERIVED_V1_MALFORMED if saw_malformed else DERIVED_V1_NO_MANIFEST)
545
564
 
546
565
 
566
+ # === Dispatch-intent sidecar ===
567
+
568
+ def intents_dir() -> Path:
569
+ """The ``.fleetproof/intents/`` directory, resolved beside the runs dir."""
570
+ return runs_dir().parent / INTENTS_DIRNAME
571
+
572
+
573
+ def intent_path(agent_type: str | None) -> Path | None:
574
+ """The sidecar path for ``agent_type``, or None when it cannot name a file."""
575
+ if not agent_type or not _INTENT_NAME_RE.match(agent_type):
576
+ return None
577
+ return intents_dir() / f"{agent_type}.json"
578
+
579
+
580
+ def write_intent(
581
+ agent_type: str,
582
+ prompt: str,
583
+ *,
584
+ manifest: dict[str, Any] | None = None,
585
+ tier: str | None = None,
586
+ ) -> Path:
587
+ """Write the dispatch-intent sidecar for the next spawn of ``agent_type``.
588
+
589
+ The dispatcher calls this (via ``fleetproof dispatch intent``) *before*
590
+ spawning, so the SubagentStart capture finds the real prompt waiting instead
591
+ of recording a placeholder. Overwrites an existing intent for the same agent
592
+ type — the newest declaration wins, matching how a re-issued dispatch prompt
593
+ supersedes the one it replaces. Validation is strict here, on the write
594
+ side, where an error still has someone to land on; the consume side (a hook
595
+ that must not crash a spawn) degrades instead.
596
+ """
597
+ path = intent_path(agent_type)
598
+ if path is None:
599
+ raise LedgerError(
600
+ f"Agent type {agent_type!r} cannot name an intent file; use a plain "
601
+ "name (letters, digits, dot, dash, underscore)."
602
+ )
603
+ if not isinstance(prompt, str) or not prompt.strip():
604
+ raise LedgerError("An intent needs a prompt; refusing to record an empty one.")
605
+ intent: dict[str, Any] = {
606
+ "agent_type": agent_type,
607
+ "prompt": prompt,
608
+ "created_at": _now_iso(),
609
+ }
610
+ if manifest is not None:
611
+ try:
612
+ intent["manifest"] = _coerce_manifest(manifest)
613
+ except ValueError as e:
614
+ raise LedgerError(f"Unusable manifest: {e}") from e
615
+ if tier is not None:
616
+ intent["tier"] = _validate_tier(tier)
617
+ path.parent.mkdir(parents=True, exist_ok=True)
618
+ _write_json(path, intent)
619
+ return path
620
+
621
+
622
+ def consume_intent(agent_type: str | None) -> tuple[dict[str, Any] | None, list[str]]:
623
+ """Read, validate, and DELETE the intent sidecar for one spawning agent.
624
+
625
+ Returns ``(fields, notes)``. ``fields`` is ``{"prompt", "manifest", "tier"}``
626
+ — manifest coerced (or None), tier validated (or None) — or None when no
627
+ usable intent exists. ``notes`` are human-readable degradation notes for the
628
+ caller to surface; an intent that half-worked must be visible, not silent.
629
+
630
+ Consumption is unconditional once a matching file is found: one intent, one
631
+ spawn, even when the intent turns out malformed. A malformed sidecar left in
632
+ place would attach itself to the *next* spawn of the same agent type, which
633
+ misattributes a prompt — worse than losing it, and the note says what was
634
+ lost. The one exception is a file that cannot be deleted: an undeletable
635
+ sidecar would replay onto every later spawn, so it is not trusted either.
636
+ """
637
+ notes: list[str] = []
638
+ path = intent_path(agent_type)
639
+ if path is None or not path.exists():
640
+ return None, notes
641
+ raw = _read_json(path)
642
+ try:
643
+ path.unlink()
644
+ except OSError as e:
645
+ notes.append(f"could not delete intent file {path}: {e}; ignoring it")
646
+ return None, notes
647
+ if raw is None:
648
+ notes.append(
649
+ f"intent file {path.name} was unreadable or not a JSON object; "
650
+ "capturing with the placeholder prompt instead"
651
+ )
652
+ return None, notes
653
+ prompt = raw.get("prompt")
654
+ if not isinstance(prompt, str) or not prompt.strip():
655
+ notes.append(
656
+ f"intent file {path.name} has no usable prompt; "
657
+ "capturing with the placeholder prompt instead"
658
+ )
659
+ return None, notes
660
+ fields: dict[str, Any] = {"prompt": prompt, "manifest": None, "tier": None}
661
+ raw_manifest = raw.get("manifest")
662
+ if raw_manifest is not None:
663
+ try:
664
+ fields["manifest"] = _coerce_manifest(raw_manifest)
665
+ except ValueError as e:
666
+ # Degrade to derive-from-prompt, with the failure written into the
667
+ # manifest notes so it survives on the dispatch record itself.
668
+ notes.append(
669
+ f"intent manifest for {agent_type!r} was malformed ({e}); "
670
+ "deriving from the intent prompt instead"
671
+ )
672
+ derived = derive_manifest(prompt)
673
+ base = derived.get("notes") or ""
674
+ derived["notes"] = (f"{base}; " if base else "") + INTENT_MANIFEST_MALFORMED
675
+ fields["manifest"] = derived
676
+ raw_tier = raw.get("tier")
677
+ if raw_tier is not None:
678
+ if isinstance(raw_tier, str) and raw_tier in VALID_TIERS:
679
+ fields["tier"] = raw_tier
680
+ else:
681
+ notes.append(
682
+ f"intent tier {raw_tier!r} is not one of {sorted(VALID_TIERS)}; "
683
+ "using the captured-subagent default"
684
+ )
685
+ return fields, notes
686
+
687
+
547
688
  # === Creating a dispatch ===
548
689
 
549
690
  def _now_iso() -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fleetproof
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Independent, out-of-band verification for agent fleets.
5
5
  Author: Gathrazio
6
6
  License: MIT
@@ -161,10 +161,12 @@ SubagentStop is the gate. Each step is its own reason to refuse the stop:
161
161
  2. Pin drift. Every dispatch pins the SHA-256 of the check spec that was in force
162
162
  when the work was ordered. If the spec changed mid-flight, the stop is blocked
163
163
  rather than graded against a spec the agent could have edited itself.
164
- 3. That agent's tier of the spec. A blocking failure records `contradicted` and
165
- blocks; a pass records `verified` and closes the dispatch. If the tier selects
166
- no checks at all, no verdict is recorded — an absent grade must never read as a
167
- passing one.
164
+ 3. That agent's tier of the spec, unioned with any checks declared on the
165
+ dispatch's own manifest (`{"id", "cmd"}` entries — blocking, expect-exit0).
166
+ A blocking failure records `contradicted` and blocks; a pass records
167
+ `verified` and closes the dispatch. If nothing is runnable — the tier selects
168
+ no repo checks and the manifest declares none — no verdict is recorded: an
169
+ absent grade must never read as a passing one.
168
170
 
169
171
  The Stop hook keeps its v0.1 job and adds a ledger sweep. A dispatch that reported
170
172
  and then never terminated blocks the bridge from stopping: something started
@@ -179,6 +181,10 @@ fleetproof dispatch new --prompt "..." # --prompt-file for a real, long o
179
181
  fleetproof dispatch report <run-id> --report report.json
180
182
  fleetproof dispatch close <run-id>
181
183
 
184
+ fleetproof dispatch intent --agent recon --prompt-file p.md \
185
+ [--manifest m.json] [--tier lane] # declare the NEXT spawn of an agent
186
+ # type; the start capture consumes it
187
+
182
188
  fleetproof fleet # the board, newest first
183
189
  fleetproof fleet --open # only what has not terminated
184
190
  fleetproof fleet --format json # full records, for an agent to read
@@ -194,12 +200,16 @@ transition trail, and which process wrote each transition.
194
200
 
195
201
  ### What the ledger does not do yet
196
202
 
197
- - The spawn prompt is in neither subagent hook payload. A captured dispatch records
198
- a placeholder saying so, rather than inventing a prompt it never saw. To get the
199
- real prompt on the record, dispatch through `fleetproof dispatch new`.
200
- - Captured subagents are recorded as `lane` tier. Real nesting depth is not visible
201
- from the payload — there is no parent-agent field — so declaring one beats
202
- inferring it wrongly. What that costs in practice is a pilot question.
203
+ - The spawn prompt is in neither subagent hook payload, so an undeclared spawn
204
+ still records a placeholder saying so, rather than inventing a prompt it never
205
+ saw. To get the real prompt (and manifest, and tier) onto a captured dispatch,
206
+ write an intent sidecar first: `fleetproof dispatch intent --agent <type>
207
+ --prompt-file <file>` writes `.fleetproof/intents/<agent_type>.json`, and the
208
+ next SubagentStart of that agent type consumes it — one intent, one spawn.
209
+ - Captured subagents without a declared intent tier are recorded as `lane` tier.
210
+ Real nesting depth is not visible from the payload — there is no parent-agent
211
+ field — so declaring one beats inferring it wrongly. What that costs in
212
+ practice is a pilot question.
203
213
  - An agent killed mid-turn can reach SubagentStop with an empty final message,
204
214
  which report-before-idle blocks. Blocking the stop of an agent that is already
205
215
  gone is not useful; how often it happens is under observation.
@@ -267,7 +277,11 @@ operator-attested. The checker writes verdicts from a separate process, and v0.3
267
277
  chains a rolling hash over the records as they're written (`anchor` gives you a
268
278
  head you can sign or store elsewhere) — but a local ledger on a shared filesystem
269
279
  is not tamper-proof against everything that can write to it, and FleetProof will
270
- not pretend otherwise.
280
+ not pretend otherwise. The dispatch-intent sidecar (0.3.1) sits in the same
281
+ trust class as `checks.json` itself: operator-attested, exactly as forgeable as
282
+ the rest of `.fleetproof/`, no worse — anything that can write the repo can
283
+ write an intent, and the manifest checks it carries run as shell commands at
284
+ the stop gate the same way repo checks do.
271
285
 
272
286
  ## Install (each line is one command in Claude Code)
273
287
 
@@ -131,6 +131,61 @@ def test_dispatch_new_bad_manifest_file(cli_runs, tmp_path, capsys):
131
131
  str(tmp_path / "absent.json")]) == 2
132
132
 
133
133
 
134
+ def test_dispatch_intent_writes_sidecar_and_prints_path(cli_runs, tmp_path, capsys):
135
+ pf = tmp_path / "prompt.md"
136
+ pf.write_text("do the real work\nwith a real prompt", encoding="utf-8")
137
+ mf = tmp_path / "manifest.json"
138
+ mf.write_text(json.dumps({"manifest": {
139
+ "deliverables": ["a"],
140
+ "checks": [{"id": "c1", "cmd": "true"}],
141
+ "check_map": {"a": ["c1"]},
142
+ }}), encoding="utf-8")
143
+
144
+ assert main(["dispatch", "intent", "--agent", "recon", "--prompt-file", str(pf),
145
+ "--manifest", str(mf), "--tier", "lane"]) == 0
146
+ path = Path(capsys.readouterr().out.strip())
147
+ assert path.exists()
148
+ assert path.name == "recon.json"
149
+
150
+ intent = json.loads(path.read_text(encoding="utf-8"))
151
+ assert intent["agent_type"] == "recon"
152
+ assert intent["prompt"] == "do the real work\nwith a real prompt"
153
+ assert intent["manifest"]["check_map"] == {"a": ["c1"]}
154
+ assert intent["tier"] == "lane"
155
+ assert intent["created_at"]
156
+
157
+
158
+ def test_dispatch_intent_json_format(cli_runs, tmp_path, capsys):
159
+ pf = tmp_path / "prompt.md"
160
+ pf.write_text("work", encoding="utf-8")
161
+ assert main(["dispatch", "intent", "--agent", "recon", "--prompt-file", str(pf),
162
+ "--format", "json"]) == 0
163
+ out = json.loads(capsys.readouterr().out)
164
+ assert out["ok"] is True
165
+ assert Path(out["intent_path"]).exists()
166
+
167
+
168
+ def test_dispatch_intent_rejects_bad_inputs(cli_runs, tmp_path, capsys):
169
+ pf = tmp_path / "prompt.md"
170
+ pf.write_text("work", encoding="utf-8")
171
+ # Missing prompt file: an intent with no prompt is the placeholder problem
172
+ # the sidecar exists to fix, so it fails at write time.
173
+ assert main(["dispatch", "intent", "--agent", "recon",
174
+ "--prompt-file", str(tmp_path / "absent.md")]) == 2
175
+ # Unparseable manifest file vs. parseable-but-unusable manifest shape.
176
+ bad = tmp_path / "bad.json"
177
+ bad.write_text("{not json", encoding="utf-8")
178
+ assert main(["dispatch", "intent", "--agent", "recon", "--prompt-file", str(pf),
179
+ "--manifest", str(bad)]) == 2
180
+ unusable = tmp_path / "unusable.json"
181
+ unusable.write_text(json.dumps({"deliverables": "not-an-array"}), encoding="utf-8")
182
+ assert main(["dispatch", "intent", "--agent", "recon", "--prompt-file", str(pf),
183
+ "--manifest", str(unusable)]) == 1
184
+ # A path-shaped agent type must never become a path.
185
+ assert main(["dispatch", "intent", "--agent", "../evil",
186
+ "--prompt-file", str(pf)]) == 1
187
+
188
+
134
189
  def test_dispatch_report_and_close(cli_runs, tmp_path, capsys):
135
190
  run_id = _dispatch_new(capsys)
136
191
  rf = tmp_path / "report.json"
@@ -622,6 +622,255 @@ def test_subagent_shims_run_via_the_plugin_root_fallback(tmp_path):
622
622
  assert "report-before-idle" in decision["reason"]
623
623
 
624
624
 
625
+ # === dispatch-intent sidecar (SubagentStart) ===
626
+
627
+ _INTENT_PROMPT = "Refactor the widget module and prove it with the manifest checks."
628
+
629
+
630
+ def _intent_manifest(cmd_exit=0):
631
+ cmd = f'"{sys.executable}" -c "raise SystemExit({cmd_exit})"'
632
+ return {
633
+ "deliverables": ["the widget refactor"],
634
+ "checks": [{"id": "m-widget", "cmd": cmd}],
635
+ "check_map": {"the widget refactor": ["m-widget"]},
636
+ "notes": "declared by the dispatcher",
637
+ }
638
+
639
+
640
+ def test_subagent_start_consumes_intent_sidecar(tmp_path, monkeypatch):
641
+ # The whole point of the sidecar: the real prompt, manifest, and tier land
642
+ # on the captured dispatch, and the intent file is gone afterwards.
643
+ from fleetproof.hookgate import subagent_start_main
644
+ from fleetproof.ledger import intent_path, list_dispatches, write_intent
645
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
646
+ path = write_intent("tester", _INTENT_PROMPT,
647
+ manifest=_intent_manifest(), tier="leaf")
648
+ assert path.exists()
649
+
650
+ _feed(monkeypatch, _start_payload())
651
+ assert subagent_start_main() == 0
652
+
653
+ d = list_dispatches()[0]
654
+ assert d.prompt == _INTENT_PROMPT
655
+ assert d.tier == "leaf"
656
+ assert d.tier_source == "declared"
657
+ assert d.manifest["deliverables"] == ["the widget refactor"]
658
+ assert d.manifest["check_map"] == {"the widget refactor": ["m-widget"]}
659
+ assert d.agent["capture"] == "start"
660
+ # Consumed: one intent, one spawn.
661
+ assert not path.exists()
662
+ assert intent_path("tester") is not None # the name itself stays mappable
663
+
664
+
665
+ def test_second_spawn_after_consumption_gets_the_placeholder(tmp_path, monkeypatch):
666
+ from fleetproof.hookgate import subagent_start_main
667
+ from fleetproof.ledger import list_dispatches, write_intent
668
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
669
+ write_intent("tester", _INTENT_PROMPT)
670
+
671
+ _feed(monkeypatch, _start_payload(agent_id="agent-1"))
672
+ subagent_start_main()
673
+ _feed(monkeypatch, _start_payload(agent_id="agent-2"))
674
+ subagent_start_main()
675
+
676
+ # Two spawns in the same second sort unpredictably; key on the agent id.
677
+ by_agent = {d.agent_id: d for d in list_dispatches()}
678
+ assert by_agent["agent-1"].prompt == _INTENT_PROMPT
679
+ assert "[uncaptured]" in by_agent["agent-2"].prompt
680
+ assert by_agent["agent-2"].tier == "lane"
681
+
682
+
683
+ def test_intent_for_a_different_agent_type_is_left_alone(tmp_path, monkeypatch):
684
+ from fleetproof.hookgate import subagent_start_main
685
+ from fleetproof.ledger import intent_path, list_dispatches, write_intent
686
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
687
+ write_intent("someone-else", _INTENT_PROMPT)
688
+
689
+ _feed(monkeypatch, _start_payload(agent_type="tester"))
690
+ assert subagent_start_main() == 0
691
+
692
+ assert "[uncaptured]" in list_dispatches()[0].prompt
693
+ assert intent_path("someone-else").exists() # not consumed by the wrong spawn
694
+
695
+
696
+ def test_malformed_intent_json_degrades_to_placeholder(tmp_path, monkeypatch, capsys):
697
+ # The capture must survive a hand-mangled sidecar: placeholder path, loud
698
+ # stderr note, and the bad file consumed so it cannot poison the next spawn.
699
+ from fleetproof.hookgate import subagent_start_main
700
+ from fleetproof.ledger import intent_path, intents_dir, list_dispatches
701
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
702
+ intents_dir().mkdir(parents=True, exist_ok=True)
703
+ (intents_dir() / "tester.json").write_text("not json at all", encoding="utf-8")
704
+
705
+ _feed(monkeypatch, _start_payload())
706
+ assert subagent_start_main() == 0
707
+
708
+ d = list_dispatches()[0]
709
+ assert "[uncaptured]" in d.prompt
710
+ assert "intent file tester.json" in capsys.readouterr().err
711
+ assert not intent_path("tester").exists()
712
+
713
+
714
+ def test_malformed_manifest_in_a_valid_intent_degrades_with_a_note(
715
+ tmp_path, monkeypatch, capsys):
716
+ # A valid intent whose manifest is unusable keeps its real prompt; the
717
+ # manifest degrades to derive-from-prompt with the failure written into the
718
+ # manifest notes, where it survives on the dispatch record itself.
719
+ from fleetproof.hookgate import subagent_start_main
720
+ from fleetproof.ledger import INTENT_MANIFEST_MALFORMED, intents_dir, list_dispatches
721
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
722
+ intents_dir().mkdir(parents=True, exist_ok=True)
723
+ (intents_dir() / "tester.json").write_text(json.dumps({
724
+ "agent_type": "tester",
725
+ "prompt": _INTENT_PROMPT,
726
+ "manifest": {"deliverables": "not-an-array"},
727
+ }), encoding="utf-8")
728
+
729
+ _feed(monkeypatch, _start_payload())
730
+ assert subagent_start_main() == 0
731
+
732
+ d = list_dispatches()[0]
733
+ assert d.prompt == _INTENT_PROMPT
734
+ assert d.manifest["deliverables"] == []
735
+ assert INTENT_MANIFEST_MALFORMED in d.manifest["notes"]
736
+ assert "malformed" in capsys.readouterr().err
737
+
738
+
739
+ def test_invalid_intent_tier_falls_back_to_the_captured_default(
740
+ tmp_path, monkeypatch, capsys):
741
+ from fleetproof.hookgate import subagent_start_main
742
+ from fleetproof.ledger import intents_dir, list_dispatches
743
+ _setup_project(tmp_path, [_LANE_PASS], monkeypatch)
744
+ intents_dir().mkdir(parents=True, exist_ok=True)
745
+ (intents_dir() / "tester.json").write_text(json.dumps({
746
+ "agent_type": "tester", "prompt": _INTENT_PROMPT, "tier": "captain",
747
+ }), encoding="utf-8")
748
+
749
+ _feed(monkeypatch, _start_payload())
750
+ assert subagent_start_main() == 0
751
+
752
+ d = list_dispatches()[0]
753
+ assert d.prompt == _INTENT_PROMPT
754
+ assert d.tier == "lane"
755
+ assert "captain" in capsys.readouterr().err
756
+
757
+
758
+ # === manifest checks in the stop gate (SubagentStop) ===
759
+
760
+ def _era_config(tmp_path: Path) -> None:
761
+ """Telemetry-era cutover in force, so the gate builds telemetry.json."""
762
+ (tmp_path / ".fleetproof" / "config.json").write_text(
763
+ json.dumps({"telemetry_era": "2026-01-01"}), encoding="utf-8")
764
+
765
+
766
+ def _spawn_with_intent(tmp_path, monkeypatch, manifest, spec=None):
767
+ from fleetproof.hookgate import subagent_start_main
768
+ from fleetproof.ledger import write_intent
769
+ _setup_project(tmp_path, spec if spec is not None else [_LEAF_ONLY], monkeypatch)
770
+ _era_config(tmp_path)
771
+ write_intent("tester", _INTENT_PROMPT, manifest=manifest, tier="lane")
772
+ _feed(monkeypatch, _start_payload())
773
+ subagent_start_main()
774
+
775
+
776
+ def test_manifest_checks_grade_a_dispatch_the_spec_ignores(tmp_path, monkeypatch, capsys):
777
+ # The L21 fix, end to end: the repo spec has nothing at lane tier, so before
778
+ # 0.3.1 this dispatch terminated ungraded. Its own manifest checks now run
779
+ # as blocking, the verdict lands, and coverage joins through check_map.
780
+ from fleetproof.hookgate import subagent_stop_main
781
+ from fleetproof.ledger import list_dispatches
782
+ from fleetproof.telemetry import load_telemetry
783
+ _spawn_with_intent(tmp_path, monkeypatch, _intent_manifest(cmd_exit=0))
784
+
785
+ _feed(monkeypatch, _stop_payload(message="Refactored and verified."))
786
+ assert subagent_stop_main() == 0
787
+ assert capsys.readouterr().out == "" # allowed to stop
788
+
789
+ d = list_dispatches()[0]
790
+ assert d.verdict == "verified"
791
+ assert d.state == "terminated"
792
+ telemetry = load_telemetry(d.run_id)
793
+ assert telemetry["outcome.class"] == "verified"
794
+ assert telemetry["outcome.verifier"]["checks_run"] == 1
795
+ assert telemetry["outcome.coverage"] > 0
796
+
797
+
798
+ def test_failing_manifest_check_contradicts_and_blocks(tmp_path, monkeypatch, capsys):
799
+ from fleetproof.hookgate import subagent_stop_main
800
+ from fleetproof.ledger import list_dispatches
801
+ _spawn_with_intent(tmp_path, monkeypatch, _intent_manifest(cmd_exit=1))
802
+
803
+ _feed(monkeypatch, _stop_payload(message="Refactored, honest."))
804
+ assert subagent_stop_main() == 0
805
+ decision = json.loads(capsys.readouterr().out)
806
+ assert decision["decision"] == "block"
807
+ assert "m-widget" in decision["reason"]
808
+
809
+ d = list_dispatches()[0]
810
+ assert d.state == "contradicted"
811
+ assert d.is_terminal is False # the retry lands on this same dispatch
812
+
813
+
814
+ def test_manifest_checks_union_with_tier_selected_repo_checks(tmp_path, monkeypatch, capsys):
815
+ # Both sources grade the same stop: the lane-tier repo check and the
816
+ # dispatch's own manifest check each execute exactly once.
817
+ from fleetproof.hookgate import subagent_stop_main
818
+ from fleetproof.ledger import list_dispatches
819
+ _spawn_with_intent(tmp_path, monkeypatch, _intent_manifest(cmd_exit=0),
820
+ spec=[_LANE_PASS])
821
+
822
+ _feed(monkeypatch, _stop_payload(message="Done, both ways."))
823
+ assert subagent_stop_main() == 0
824
+ assert capsys.readouterr().out == ""
825
+
826
+ assert _graded_check_ids() == ["lane-ok", "m-widget"]
827
+ assert list_dispatches()[0].verdict == "verified"
828
+
829
+
830
+ def test_manifest_check_colliding_with_a_repo_check_id_defers_to_the_spec(
831
+ tmp_path, monkeypatch, capsys):
832
+ # One id, one command: the repo spec is the more attested source, so a
833
+ # manifest check reusing a selected repo check's id is dropped rather than
834
+ # run as a second command under the same name.
835
+ from fleetproof.hookgate import subagent_stop_main
836
+ from fleetproof.ledger import list_dispatches
837
+ manifest = {
838
+ "checks": [{"id": "lane-ok",
839
+ "cmd": f'"{sys.executable}" -c "raise SystemExit(1)"'}],
840
+ }
841
+ _spawn_with_intent(tmp_path, monkeypatch, manifest, spec=[_LANE_PASS])
842
+
843
+ _feed(monkeypatch, _stop_payload(message="Done."))
844
+ assert subagent_stop_main() == 0
845
+ assert capsys.readouterr().out == "" # the repo's lane-ok passes; no block
846
+
847
+ assert _graded_check_ids() == ["lane-ok"]
848
+ assert list_dispatches()[0].verdict == "verified"
849
+
850
+
851
+ def test_malformed_manifest_check_entries_are_skipped_loudly(
852
+ tmp_path, monkeypatch, capsys):
853
+ # Junk entries must neither run half-parsed nor take down the gate — and a
854
+ # skipped check never counts as executed, so it can never read as a pass.
855
+ from fleetproof.hookgate import subagent_stop_main
856
+ from fleetproof.ledger import list_dispatches
857
+ manifest = _intent_manifest(cmd_exit=0)
858
+ manifest["checks"] = [
859
+ "not-an-object",
860
+ {"id": "no-cmd"},
861
+ {"id": "multi-line", "cmd": "echo a\necho b"},
862
+ ] + manifest["checks"]
863
+ _spawn_with_intent(tmp_path, monkeypatch, manifest)
864
+
865
+ _feed(monkeypatch, _stop_payload(message="Done."))
866
+ assert subagent_stop_main() == 0
867
+ err = capsys.readouterr().err
868
+ assert "skipped" in err
869
+
870
+ assert _graded_check_ids() == ["m-widget"] # only the well-formed entry ran
871
+ assert list_dispatches()[0].verdict == "verified"
872
+
873
+
625
874
  def test_read_hook_input_tolerates_utf8_bom(monkeypatch):
626
875
  # Some shells prepend a BOM when piping; losing the payload would silently
627
876
  # lose session grouping and drift detection.
File without changes
File without changes