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.
- {fleetproof-0.3.0/src/fleetproof.egg-info → fleetproof-0.3.1}/PKG-INFO +26 -12
- {fleetproof-0.3.0 → fleetproof-0.3.1}/README.md +25 -11
- {fleetproof-0.3.0 → fleetproof-0.3.1}/pyproject.toml +1 -1
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/__init__.py +1 -1
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/cli.py +62 -1
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/hookgate.py +95 -17
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/ledger.py +141 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1/src/fleetproof.egg-info}/PKG-INFO +26 -12
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_cli.py +55 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_hookgate.py +249 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/LICENSE +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/setup.cfg +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/__main__.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/checker.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/checks.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/config.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/report.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/runlog.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/telemetry.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof/telemetry_export.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/SOURCES.txt +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/dependency_links.txt +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/entry_points.txt +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/requires.txt +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/src/fleetproof.egg-info/top_level.txt +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_adversarial_regressions.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_checker.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_checks.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_ledger.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_report.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_runlog.py +0 -0
- {fleetproof-0.3.0 → fleetproof-0.3.1}/tests/test_telemetry.py +0 -0
- {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.
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
198
|
-
a placeholder saying so, rather than inventing a prompt it never
|
|
199
|
-
real prompt
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
177
|
-
a placeholder saying so, rather than inventing a prompt it never
|
|
178
|
-
real prompt
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
|
@@ -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.
|
|
30
|
-
placeholder prompt and says so, rather than inventing the prompt it did not
|
|
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.
|
|
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
|
-
|
|
399
|
-
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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=
|
|
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.
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
198
|
-
a placeholder saying so, rather than inventing a prompt it never
|
|
199
|
-
real prompt
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|