@event4u/agent-config 9.31.0 → 9.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +51 -0
  3. package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
  4. package/dist/agent-src/rules/delegation-policy.md +11 -1
  5. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  6. package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  7. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  8. package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  9. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  10. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  11. package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
  12. package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  13. package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
  14. package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-542DZR4P.js} +117 -147
  15. package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
  16. package/dist/cli-delegate/cmd_conformance.js +81 -30
  17. package/dist/cli-delegate/cmd_doctor.js +2 -1
  18. package/dist/cli-delegate/cmd_handoff.js +166 -57
  19. package/dist/cli-delegate/cmd_session_recycle.js +17 -2
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +3 -3
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +1 -1
  24. package/dist/discovery/orphan-report.md +1 -1
  25. package/dist/discovery/packs.json +2 -2
  26. package/dist/discovery/trust-report.md +1 -1
  27. package/dist/discovery/workspaces.json +2 -2
  28. package/dist/hooks/dispatch.js +1118 -690
  29. package/dist/mcp/registry-manifest.json +1 -1
  30. package/dist/mcp/server.json +2 -2
  31. package/dist/mcp/server.mjs +79 -29
  32. package/package.json +1 -1
  33. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  34. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  35. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  36. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  37. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  38. package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
  39. package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  40. package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
  41. package/src/scripts/_cli/handoff_generate.ts +36 -1
  42. package/src/scripts/_cli/handoff_sessions.ts +140 -9
  43. package/src/scripts/_lib/carrier_divergence.ts +98 -27
  44. package/src/scripts/_lib/envelope_grounding.ts +206 -0
  45. package/src/scripts/_lib/host_capability.ts +79 -0
  46. package/src/scripts/_lib/session_eol.ts +85 -0
  47. package/src/scripts/_lib/subagent_capsule.ts +256 -1
  48. package/src/scripts/check_secret_leak.test.ts +43 -0
  49. package/src/scripts/check_secret_leak.ts +12 -0
  50. package/src/scripts/handoff_context_hook.ts +66 -18
  51. package/src/scripts/hooks/session_eol_hook.ts +12 -12
  52. package/src/scripts/report_carrier_divergence.ts +94 -29
  53. package/src/scripts/report_conformance_funnel.ts +11 -4
  54. package/src/scripts/routing_doctor.ts +25 -2
  55. package/src/scripts/schemas/dod.schema.json +45 -0
  56. package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Bootstrap shim for the event4u agent-config suite. This plugin ships ONLY the dispatcher hooks plus one install-pointer skill — ALL content (skills, rules, commands) is installed via `npx -y @event4u/agent-config init`, which writes the file projection AND registers the same hooks in ~/.claude/settings.json. DEPRECATED as a content channel; kept so the marketplace listing stays discoverable and existing installs keep working hooks.",
9
- "version": "9.31.0",
9
+ "version": "9.32.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -321,6 +321,57 @@ Entry-shape contract: [`docs/contracts/CHANGELOG-conventions.md`](docs/contracts
321
321
  > that forces a new era split (`# Era: 9.32.x`, etc.) — see
322
322
  > [`docs/contracts/CHANGELOG-conventions.md § Era splits`](docs/contracts/CHANGELOG-conventions.md).
323
323
 
324
+ ## [9.32.0](https://github.com/event4u-app/agent-config/compare/9.31.0...9.32.0) (2026-08-10)
325
+
326
+ ### Release highlights
327
+
328
+ <!-- Curated head: fill before merge, keep it under 10 lines, and leave `_none_` where it is genuinely the answer. The generated log below is unchanged. -->
329
+ - **Behaviour changes:** _auto-derived, rewrite before merge:_ rule/schema diffs, breaking commits or removed public surface in 1f01490, e05de77.
330
+ - **Default changes + migration:** _none_
331
+ - **Security and correctness:** _none_
332
+ - **Honest nulls:** _none_
333
+ - **Known limitations:** _none_
334
+
335
+ ### Features
336
+
337
+ * **work-engine:** route both red-check lanes through the self-fix loop ([10c8f7e](https://github.com/event4u-app/agent-config/commit/10c8f7eafbf21fc03a5f301163c941a20df4a885))
338
+ * **work-engine:** bounded self-fix loop primitive + executable dod schema ([1f01490](https://github.com/event4u-app/agent-config/commit/1f0149035c1a27729b729dab1dedd861ccdf9812))
339
+ * **envelope:** drift anchor, scripted grounding, verbatim error strings ([6c9187e](https://github.com/event4u-app/agent-config/commit/6c9187e30f41c87553764759096a3f7f4d19a126))
340
+ * **envelope:** successor tailoring, mandatory failed_approaches, data-never-instruction boundary ([b03beac](https://github.com/event4u-app/agent-config/commit/b03beac1ad1325253c89a63eac9abc9f051269d3))
341
+ * **orchestration:** report per-field provenance for host capabilities ([f4958de](https://github.com/event4u-app/agent-config/commit/f4958decf4a70b445a38747bff768ab449877d52))
342
+
343
+ ### Bug Fixes
344
+
345
+ * **gates:** stop the R2 review-input copy from laundering fixture secrets ([c63d350](https://github.com/event4u-app/agent-config/commit/c63d3502013e7bc261096d02e2c102a52e2f1ec7))
346
+ * **golden:** teach the replay the delegated bad-verdict surface, re-lock baselines ([1ff480c](https://github.com/event4u-app/agent-config/commit/1ff480c6adeb88af32198a19d41838e2f9f5dcc0))
347
+ * **envelope:** close the eight R2 review findings ([b0d153c](https://github.com/event4u-app/agent-config/commit/b0d153cb3084dac2a20093b73d28a3ce3c12324c))
348
+ * **tests:** type the self-fix state helpers against the wire types ([60d2724](https://github.com/event4u-app/agent-config/commit/60d272419b07b5abea1ce6ffa430faf127fcde4a))
349
+ * **handoff:** stop offering the caller its own empty session ([b72f772](https://github.com/event4u-app/agent-config/commit/b72f772a03207aa4feeded3acd5b68823e3fd79c))
350
+ * **tests:** narrow regex captures in the capability parity block ([6f27615](https://github.com/event4u-app/agent-config/commit/6f276154b83781404d6551113c6d43e5cf040423))
351
+ * **carrier:** a paths: disagreement is a delivery defect, not inert metadata ([318fb81](https://github.com/event4u-app/agent-config/commit/318fb818eb543f27fc4c3b138ce762dc8c9e9640))
352
+
353
+ ### Documentation
354
+
355
+ * **review:** re-bind the findings artefact to the post-fix scope ([7a9bbbb](https://github.com/event4u-app/agent-config/commit/7a9bbbb2d7f4f04786771914bf7b3b68aba23e10))
356
+ * **review:** record the R2 completion-review findings before fixing them ([27322e8](https://github.com/event4u-app/agent-config/commit/27322e876c76f96d6a67131f9e96a1759290a8f5))
357
+ * **roadmap:** P2.2 build half shipped, its threshold unevaluated with a blocker ([1e478fd](https://github.com/event4u-app/agent-config/commit/1e478fdc317fdf12df001c1b98ef264df54a3742))
358
+ * **contracts:** the flow contract documents the self-fix loop and the dod slot ([0ebaaba](https://github.com/event4u-app/agent-config/commit/0ebaabae9e12c75446efefc5be6f6b33b9d0435f))
359
+ * **roadmap:** capability-answerability Phase 1 closed, blocker narrowed to one question ([c37af04](https://github.com/event4u-app/agent-config/commit/c37af045ecbf62b31e6878befdc88d763498d7a9))
360
+ * **rules:** delegation-policy names the capability provenance check ([e05de77](https://github.com/event4u-app/agent-config/commit/e05de771d74e8284e2e64e91e33be66b1bd15e23))
361
+ * **contracts:** the host-capability contract documents six fields, not five ([566b984](https://github.com/event4u-app/agent-config/commit/566b984681d1a1a5ef76643fa6f90f297c284e81))
362
+
363
+ ### Tests
364
+
365
+ * **work-engine:** pin the loop contract and update the changed halt shapes ([eb88aa6](https://github.com/event4u-app/agent-config/commit/eb88aa695121a01c0c1e10de9804f1df3424c334))
366
+
367
+ ### Other
368
+
369
+ * **carrier:** re-bind to the post-merge scope — the fixed point ([884104c](https://github.com/event4u-app/agent-config/commit/884104c15d8257579a320932c671b489d96e7b06))
370
+ * **carrier:** re-bind the R2 round to the shipping content, all 6 terminal ([9bbfbc3](https://github.com/event4u-app/agent-config/commit/9bbfbc35388f54c77fdae27315025843a6ea79f5))
371
+ * **carrier:** R2 completion review round 1 — 6 findings, all open ([75be940](https://github.com/event4u-app/agent-config/commit/75be940f4f9bcbf71e1f1248467637fc51c6b63b))
372
+
373
+ Tests: 12818 (+89 since 9.31.0)
374
+
324
375
  ## [9.31.0](https://github.com/event4u-app/agent-config/compare/9.30.0...9.31.0) (2026-08-10)
325
376
 
326
377
  ### Release highlights
@@ -16,7 +16,8 @@ A single JSON object, `schema_version: 1`:
16
16
  "parallel_spawn": false,
17
17
  "status_polling": false,
18
18
  "separate_quota_pool": false,
19
- "agent_teams": false
19
+ "agent_teams": false,
20
+ "worker_respawn": false
20
21
  }
21
22
  ```
22
23
 
@@ -64,11 +65,41 @@ to in-session execution rather than attempting an unsupported primitive.
64
65
  | `status_polling` | bool | Monitoring running subagents (progress / completion checks). `false` → fire-and-collect only, no mid-run polling. |
65
66
  | `separate_quota_pool` | bool | Quota-arbitrage bonus (Phase 2) — subagents draw from a distinct quota pool than the session. `false` → assume shared quota. |
66
67
  | `agent_teams` | bool | Claude Code's experimental multi-instance Agent Teams primitive (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`). `false` → the [judgment ladder](auto-dispatch-classification.md#judgment-ladder-phase-2--road-to-always-on-orchestration)'s rung 3 (team) degrades to parallel subagents, recorded as `degraded_from: 3`. |
68
+ | `worker_respawn` | bool | The host can kill a running worker and spawn a fresh one that continues the SAME task mid-flight. `false` on every host today, deliberately — set `true` only once OBSERVED, never by inference from spawning and killing existing separately. `false` → degrade to stop-loss behaviour, loudly. |
67
69
 
68
70
  A field being `true` is a **precondition**, not a mandate: `parallel_spawn:
69
71
  true` permits concurrency but the dispatch cap still applies (see
70
72
  `subagents.max_parallel` in subagent-configuration.md).
71
73
 
74
+ ## Provenance — "probe" is a misnomer for five of the six fields
75
+
76
+ ```
77
+ NEVER READ A false FIELD AS "THIS HOST CANNOT DO IT".
78
+ A REGISTRY ROW IS A COMMITTED OBSERVATION, NOT A LIVE CHECK.
79
+ A default FIELD MEANS NOBODY ANSWERED — RENDERED AS false BECAUSE THAT IS THE
80
+ SAFE DEGRADATION, NOT BECAUSE ANYTHING WAS MEASURED.
81
+ ```
82
+
83
+ The six booleans look alike and are not. `HOST_CAPABILITY_REGISTRY` holds
84
+ exactly one row today, so on every other host **all six** fields are the safe
85
+ default — and `agent_teams` is the only field any live check touches. The name
86
+ `probeHostCapabilities` says "detected" about values that were mostly asserted
87
+ at authoring time.
88
+
89
+ `describeHostCapabilities(hostId)` returns the same manifest plus a
90
+ `sources` map naming, per field, which of the three answered:
91
+
92
+ | Source | Means | Re-checked at run time |
93
+ |---|---|---|
94
+ | `registry` | this repo observed the capability on this host once and committed it to the table | no |
95
+ | `live-probe` | established in THIS process, from the environment | yes |
96
+ | `default` | nothing answered; `SAFE_DEFAULT` applied | n/a |
97
+
98
+ `agent-config routing:doctor [--platform <host>]` prints it. Read the
99
+ provenance before treating a `false` as a host limitation: on an unrecognized
100
+ host every field is `default`, which is an absence of knowledge and not a
101
+ measurement.
102
+
72
103
  ## Related
73
104
 
74
105
  - [`subagent-configuration`](../subagent-configuration.md) — the `subagents.*`
@@ -105,7 +105,17 @@ plus this telemetry). The
105
105
  capability gate itself resolves from the committed host registry merged with a
106
106
  live environment probe in `src/scripts/_lib/host_capability.ts`
107
107
  (`probeHostCapabilities` — capability is a fact about the host, never a
108
- settings override). What stays
108
+ settings override).
109
+
110
+ **Never read a `false` capability as a host limitation without checking where it
111
+ came from.** The registry holds one row, so on every other host all six fields
112
+ are the all-false safe default — which records that *nobody answered*, not that
113
+ the host cannot spawn. `agent-config routing:doctor [--platform <host>]` prints
114
+ the value **and** its provenance per field (`registry` = a committed observation
115
+ this repo made once · `live-probe` = established in this process · `default` =
116
+ no answer). Run it before concluding delegation is unavailable here; the
117
+ same-shaped wrong guess about the council is the incident
118
+ [`council-availability`](council-availability.md) exists for. What stays
109
119
  model-carried, honestly: the decomposition itself, the per-return
110
120
  verification, and every dispatch on hosts without hook slots. The nudges are
111
121
  advisory by design — whether they change behaviour is measured by the
@@ -39,7 +39,7 @@ schema_version: 1
39
39
  # CI guard: a release bump of `package.json` must update this value
40
40
  # in lockstep — see scripts/check_template_pin_drift.ts (road-to-
41
41
  # portable-runtime-and-update-check P3.3).
42
- agent_config_version: "9.31.0"
42
+ agent_config_version: "9.32.0"
43
43
 
44
44
  # --- Project identity ---
45
45
  project:
@@ -96,6 +96,7 @@ export class DeliveryState {
96
96
  contract: Record<string, Any> | null;
97
97
  stitch: Record<string, Any> | null;
98
98
  stack: Record<string, Any> | null;
99
+ self_fix: Record<string, Any> | null;
99
100
 
100
101
  constructor(args: {
101
102
  ticket: Record<string, Any>;
@@ -117,6 +118,7 @@ export class DeliveryState {
117
118
  contract?: Record<string, Any> | null;
118
119
  stitch?: Record<string, Any> | null;
119
120
  stack?: Record<string, Any> | null;
121
+ self_fix?: Record<string, Any> | null;
120
122
  }) {
121
123
  this.ticket = args.ticket;
122
124
  this.persona = args.persona ?? 'senior-engineer';
@@ -137,6 +139,10 @@ export class DeliveryState {
137
139
  this.contract = args.contract ?? null;
138
140
  this.stitch = args.stitch ?? null;
139
141
  this.stack = args.stack ?? null;
142
+ // Per-lane bounded-loop counters (`{test: {attempts, signatures[]}}`).
143
+ // `null` means no red check has been seen yet — distinct from `{}`,
144
+ // which means a lane record was created and then emptied.
145
+ this.self_fix = args.self_fix ?? null;
140
146
  }
141
147
  }
142
148
 
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Bounded self-fix loop — the shared primitive behind the red-check lanes.
3
+ *
4
+ * The gap this closes, verified in code before it was built: `test` and
5
+ * `verify` both halt to `Outcome.BLOCKED` the instant their recorded verdict
6
+ * is not `success`, and neither carries an attempt counter. The halt surface
7
+ * they emit is a *user* question block with no `@agent-directive:` line, so a
8
+ * red check the agent could have fixed unaided still costs a user round-trip.
9
+ *
10
+ * What this module adds is a counter and two floors — nothing else. It
11
+ * deliberately adds **no critic**: the verdict is already a deterministic
12
+ * value the engine holds in hand, so there is no judgement for a second model
13
+ * to make. That distinction is why the TERMINAL honest null on
14
+ * `recursive-verification` (ADR-106: capability 87% vs 87%, McNemar p=1.0)
15
+ * does not bind here — the measured arm added a critic to decide whether an
16
+ * attempt was good enough, and its killer finding was that cost scaled with
17
+ * *all* tasks while benefit sat in the ~28% tail. A red-check retry fires only
18
+ * on a red: zero cost on the passing majority.
19
+ *
20
+ * The null does bind one thing, and it is honoured: the falsification shape.
21
+ * "The loop is redundant" must remain discoverable, so the loop never converts
22
+ * a red into a green. Every exit it owns is `PARTIAL` with the red verdict
23
+ * still on the surface.
24
+ *
25
+ * Two floors, neither invented here:
26
+ *
27
+ * - **Ceiling** — {@link SELF_FIX_CEILING} attempts per lane, which is the
28
+ * N=3 validation-loop budget from the `autonomous-execution` rule, applied
29
+ * per validation target (that rule resets its counter on a different
30
+ * target, so the counters below are per-lane rather than per-run).
31
+ * - **No-progress** — two consecutive attempts producing an identical verdict
32
+ * signature stop the loop immediately, budget remaining or not. This is the
33
+ * floor the `recursive-verification` skill already stated in prose ("two
34
+ * consecutive attempts score identical on the deterministic scorer; further
35
+ * depth cannot help, so stop"), adopted as code rather than left as advice,
36
+ * and it matches `context-hygiene`'s "same failure signature twice → stop
37
+ * and pivot now".
38
+ *
39
+ * No-progress is checked BEFORE the ceiling on purpose: an unchanged signature
40
+ * means the remaining budget cannot help, so spending it would be the exact
41
+ * wasted-attempt pattern both rules name.
42
+ */
43
+
44
+ import type { DeliveryState } from '../../delivery_state.js';
45
+ import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
46
+
47
+ /**
48
+ * Attempts allowed per lane before the loop exits PARTIAL.
49
+ *
50
+ * Deliberately the `autonomous-execution` N=3 budget rather than a fresh
51
+ * number: a second, differently-sized retry ceiling in the same package would
52
+ * be a rule the tree contradicts.
53
+ */
54
+ export const SELF_FIX_CEILING = 3;
55
+
56
+ /** Lanes that own a bounded loop. One counter each — they are distinct targets. */
57
+ export const SELF_FIX_LANES = ['test', 'verify'] as const;
58
+
59
+ export type SelfFixLane = (typeof SELF_FIX_LANES)[number];
60
+
61
+ /**
62
+ * Keys excluded from the verdict signature at every depth.
63
+ *
64
+ * Without this the no-progress floor could never fire: a test verdict carries
65
+ * `duration_ms`, which changes on every run, so two byte-identical failures
66
+ * would sign differently and the loop would burn its whole budget on a target
67
+ * it was making no progress against.
68
+ */
69
+ export const VOLATILE_KEYS: ReadonlyArray<string> = [
70
+ 'duration_ms',
71
+ 'elapsed_ms',
72
+ 'timestamp',
73
+ 'started_at',
74
+ 'finished_at',
75
+ 'ran_at',
76
+ ];
77
+
78
+ /** Why the loop stopped, or that it has budget left. */
79
+ export type SelfFixKind = 'retry' | 'exhausted' | 'no_progress';
80
+
81
+ /** Verdict of {@link decide} — pure, so the caller owns every mutation. */
82
+ export interface SelfFixDecision {
83
+ kind: SelfFixKind;
84
+ /** Attempts already spent on this lane before this decision. */
85
+ attempts: number;
86
+ /** 1-based number of the attempt a `retry` authorises. */
87
+ next_attempt: number;
88
+ ceiling: number;
89
+ signature: string;
90
+ }
91
+
92
+ /**
93
+ * Canonical, volatile-free signature of a recorded verdict.
94
+ *
95
+ * Two failures are "the same failure" when this string matches. Object keys
96
+ * are sorted so key order in the recorded state cannot masquerade as progress.
97
+ */
98
+ export function verdict_signature(lane: string, verdict_payload: Any): string {
99
+ return `${lane}:${_canonical(verdict_payload)}`;
100
+ }
101
+
102
+ /** Read the per-lane counter without mutating anything. */
103
+ export function decide(state: DeliveryState, lane: string, signature: string): SelfFixDecision {
104
+ const lane_state = _lane_state(state, lane);
105
+ const attempts = _nonNegativeInt(lane_state['attempts']);
106
+ const signatures = Array.isArray(lane_state['signatures'])
107
+ ? (lane_state['signatures'] as Any[]).filter((s): s is string => typeof s === 'string')
108
+ : [];
109
+ const previous = signatures.length > 0 ? signatures[signatures.length - 1] : null;
110
+
111
+ const base = {
112
+ attempts,
113
+ next_attempt: attempts + 1,
114
+ ceiling: SELF_FIX_CEILING,
115
+ signature,
116
+ };
117
+
118
+ // No-progress before the ceiling: an unchanged signature means the
119
+ // remaining budget cannot move this target, so spending it is waste.
120
+ if (previous !== null && previous === signature) {
121
+ return { kind: 'no_progress', ...base };
122
+ }
123
+ if (attempts >= SELF_FIX_CEILING) {
124
+ return { kind: 'exhausted', ...base };
125
+ }
126
+ return { kind: 'retry', ...base };
127
+ }
128
+
129
+ /**
130
+ * Spend one attempt on `lane` and remember its signature.
131
+ *
132
+ * Mutates `state.self_fix` in place, which is how the counter survives the
133
+ * halt: the dispatcher persists the state it was handed, so the next
134
+ * invocation reads the incremented value. Callers must invoke this exactly
135
+ * once per `retry` decision — recording on an exit would inflate the count.
136
+ */
137
+ export function record_attempt(state: DeliveryState, lane: string, signature: string): void {
138
+ const container = _pyTruthy(state.self_fix) ? (state.self_fix as Record<string, Any>) : {};
139
+ const lane_state = _lane_state(state, lane);
140
+ const signatures = Array.isArray(lane_state['signatures'])
141
+ ? (lane_state['signatures'] as Any[]).filter((s): s is string => typeof s === 'string')
142
+ : [];
143
+ container[lane] = {
144
+ attempts: _nonNegativeInt(lane_state['attempts']) + 1,
145
+ signatures: [...signatures, signature],
146
+ };
147
+ state.self_fix = container;
148
+ }
149
+
150
+ /** DoD entries the agent has not recorded as proven. Never inferred. */
151
+ export function unmet_dod(state: DeliveryState): Array<Record<string, Any>> {
152
+ const dod = (state.ticket ?? {})['dod'];
153
+ if (!Array.isArray(dod)) {
154
+ return [];
155
+ }
156
+ return (dod as Any[]).filter(
157
+ (item): item is Record<string, Any> => _isPlainObject(item) && item['proven'] !== true,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * BLOCKED halt that delegates the fix instead of asking the user.
163
+ *
164
+ * The `@agent-directive:` first line is what makes this an agent round-trip
165
+ * rather than a user one — the same delegation shape `run-tests` and
166
+ * `review-changes` already use. The numbered options stay, because an
167
+ * orchestrator that does not honour directives must still leave the human
168
+ * something to act on.
169
+ */
170
+ export function retry_halt(args: {
171
+ lane: string;
172
+ ticket_id: string;
173
+ verdict: Any;
174
+ decision: SelfFixDecision;
175
+ fix_hint: string;
176
+ rerun_directive: string;
177
+ }): StepResult {
178
+ const { lane, ticket_id, verdict, decision, fix_hint, rerun_directive } = args;
179
+ return new StepResult({
180
+ outcome: Outcome.BLOCKED,
181
+ questions: [
182
+ agent_directive('fix-failing-checks', {
183
+ ticket: ticket_id,
184
+ lane,
185
+ attempt: decision.next_attempt,
186
+ ceiling: decision.ceiling,
187
+ }),
188
+ `> Ticket ${ticket_id} — \`${lane}\` reported \`${String(verdict)}\`. ` +
189
+ `Self-fix attempt ${decision.next_attempt} of ${decision.ceiling}.`,
190
+ `> The verdict is deterministic, so the fix is delegated rather than asked: ${fix_hint}`,
191
+ `> 1. Continue — fix the failures and re-run \`${rerun_directive}\``,
192
+ '> 2. Abort — stop this cycle and hand the failures back',
193
+ ],
194
+ message:
195
+ `Ticket ${ticket_id} ${lane} verdict was \`${String(verdict)}\`; ` +
196
+ `self-fix attempt ${decision.next_attempt}/${decision.ceiling} delegated.`,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * PARTIAL exit — the loop stopped and the check is STILL RED.
202
+ *
203
+ * `PARTIAL` rather than `BLOCKED` because attempts were spent and changes may
204
+ * have landed, and emphatically not `SUCCESS`: the risk this loop carries is
205
+ * that it converts a visible red into an invisible one, which is worse than
206
+ * the round-trip it removes. The dispatcher halts on PARTIAL exactly as it
207
+ * halts on BLOCKED, and the red verdict is the second line of the surface.
208
+ */
209
+ export function partial_exit(args: {
210
+ lane: string;
211
+ ticket_id: string;
212
+ verdict: Any;
213
+ decision: SelfFixDecision;
214
+ unmet: Array<Record<string, Any>>;
215
+ rerun_directive: string;
216
+ }): StepResult {
217
+ const { lane, ticket_id, verdict, decision, unmet, rerun_directive } = args;
218
+ const reason =
219
+ decision.kind === 'no_progress'
220
+ ? `two consecutive attempts produced an identical \`${lane}\` verdict, ` +
221
+ 'so further attempts cannot help'
222
+ : `the ${decision.ceiling}-attempt budget for \`${lane}\` is spent`;
223
+ const dod_lines =
224
+ unmet.length > 0
225
+ ? [
226
+ '> Definition-of-done items still unproven:',
227
+ ...unmet.map((item) => {
228
+ const id = typeof item['id'] === 'string' ? item['id'] : '(no id)';
229
+ const check = typeof item['check'] === 'string' ? item['check'] : '(no check)';
230
+ return `> - \`${id}\` — \`${check}\``;
231
+ }),
232
+ ]
233
+ : [];
234
+ return new StepResult({
235
+ outcome: Outcome.PARTIAL,
236
+ questions: [
237
+ `> Ticket ${ticket_id} — self-fix stopped after ` +
238
+ `${decision.attempts} attempt(s): ${reason}.`,
239
+ `> \`${lane}\` is STILL \`${String(verdict)}\`. Nothing here claims completion.`,
240
+ ...dod_lines,
241
+ `> 1. I'll take it from here — hand me the failures and re-run \`${rerun_directive}\``,
242
+ '> 2. Continue anyway — override on a red verdict (NOT recommended)',
243
+ '> 3. Abort',
244
+ ],
245
+ message:
246
+ `Ticket ${ticket_id} ${lane} verdict is \`${String(verdict)}\` after ` +
247
+ `${decision.attempts} self-fix attempt(s) (${decision.kind}); exiting PARTIAL.`,
248
+ });
249
+ }
250
+
251
+ /** Per-lane record, defaulted without writing anything back. */
252
+ function _lane_state(state: DeliveryState, lane: string): Record<string, Any> {
253
+ const container = _pyTruthy(state.self_fix) ? (state.self_fix as Record<string, Any>) : {};
254
+ const lane_state = container[lane];
255
+ return _isPlainObject(lane_state) ? lane_state : {};
256
+ }
257
+
258
+ /** Ints only; a bool, float, negative, or non-number resets to 0. */
259
+ function _nonNegativeInt(value: Any): number {
260
+ if (typeof value !== 'number' || typeof value === 'boolean') {
261
+ return 0;
262
+ }
263
+ if (!Number.isInteger(value) || value < 0) {
264
+ return 0;
265
+ }
266
+ return value;
267
+ }
268
+
269
+ /**
270
+ * Deterministic JSON rendering with sorted keys and volatile keys removed.
271
+ *
272
+ * Hand-rolled rather than `JSON.stringify`: the replacer form cannot sort
273
+ * object keys, and key order is exactly what must not leak into the signature.
274
+ */
275
+ function _canonical(value: Any): string {
276
+ if (value === null || value === undefined) {
277
+ return 'null';
278
+ }
279
+ if (typeof value === 'boolean' || typeof value === 'number') {
280
+ return String(value);
281
+ }
282
+ if (typeof value === 'string') {
283
+ return JSON.stringify(value);
284
+ }
285
+ if (Array.isArray(value)) {
286
+ return '[' + value.map((v) => _canonical(v)).join(',') + ']';
287
+ }
288
+ if (_isPlainObject(value)) {
289
+ const keys = Object.keys(value)
290
+ .filter((k) => !VOLATILE_KEYS.includes(k))
291
+ .sort();
292
+ return '{' + keys.map((k) => `${JSON.stringify(k)}:${_canonical(value[k])}`).join(',') + '}';
293
+ }
294
+ return JSON.stringify(String(value));
295
+ }
296
+
297
+ /** Python truthiness for the container kinds this module reads. */
298
+ function _pyTruthy(value: Any): boolean {
299
+ if (value === null || value === undefined || value === false) {
300
+ return false;
301
+ }
302
+ if (value === true) {
303
+ return true;
304
+ }
305
+ if (typeof value === 'number') {
306
+ return value !== 0;
307
+ }
308
+ if (typeof value === 'string') {
309
+ return value.length !== 0;
310
+ }
311
+ if (Array.isArray(value)) {
312
+ return value.length !== 0;
313
+ }
314
+ if (value instanceof Set || value instanceof Map) {
315
+ return value.size !== 0;
316
+ }
317
+ if (typeof value === 'object') {
318
+ return Object.keys(value as Record<string, Any>).length !== 0;
319
+ }
320
+ return true;
321
+ }
322
+
323
+ /** True for a dict-like value (mirrors Python `isinstance(x, dict)`). */
324
+ function _isPlainObject(value: Any): value is Record<string, Any> {
325
+ return (
326
+ typeof value === 'object' &&
327
+ value !== null &&
328
+ !Array.isArray(value) &&
329
+ !(value instanceof Set) &&
330
+ !(value instanceof Map)
331
+ );
332
+ }
@@ -7,7 +7,9 @@
7
7
  * part of the contract).
8
8
  *
9
9
  * The step never calls an LLM. It inspects `state.ticket` (which carries
10
- * `input.data` after the CLI projection) and routes on shape:
10
+ * `input.data` after the CLI projection), shape-checks the optional
11
+ * `dod[]` slot (the executable definition-of-done —
12
+ * `src/scripts/schemas/dod.schema.json`), and routes on shape:
11
13
  *
12
14
  * - **Ticket envelope** (`id`, `title`, `acceptance_criteria`) — the
13
15
  * R1 path. Validates the minimum viable shape and either returns
@@ -84,6 +86,15 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
84
86
  'user answers one clarifying question; the agent ' +
85
87
  're-runs `refine-prompt` against the refreshed prompt',
86
88
  },
89
+ {
90
+ code: 'malformed_dod',
91
+ trigger:
92
+ '`dod` key present but not a list, or an entry missing a ' +
93
+ 'non-empty `id` / `check` string',
94
+ resolution:
95
+ 'fix the `dod[]` entries against `src/scripts/schemas/dod.schema.json`, ' +
96
+ 'or drop the key — the slot is optional',
97
+ },
87
98
  {
88
99
  code: 'prompt_ui_intent',
89
100
  trigger:
@@ -99,6 +110,27 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
99
110
  /** Route on envelope shape: ticket path or prompt path. */
100
111
  export function run(state: DeliveryState): StepResult {
101
112
  const data = state.ticket || {};
113
+
114
+ // The `dod[]` slot is shape-checked on BOTH paths before either routes, so
115
+ // a malformed definition-of-done cannot reach the self-fix loop that
116
+ // reports it. Absent key → no check, no behaviour change.
117
+ const dod_issues = diagnose_dod(data);
118
+ if (dod_issues.length > 0) {
119
+ const dod_ticket_id = _pyTruthy(data.id) ? _pyStr(data.id) : '(no id)';
120
+ return new StepResult({
121
+ outcome: Outcome.BLOCKED,
122
+ questions: [
123
+ `> Ticket ${dod_ticket_id} — the \`dod[]\` slot is malformed: ` + dod_issues.join('; ') + '.',
124
+ '> Every entry needs a non-empty `id` and a non-empty `check` ' +
125
+ '(the command that proves it) — prose there cannot be run.',
126
+ '> 1. Fix the `dod[]` entries and re-invoke',
127
+ '> 2. Drop the `dod` key — the slot is optional',
128
+ '> 3. Abort',
129
+ ],
130
+ message: `Ticket ${dod_ticket_id} has a malformed dod[] slot: ` + dod_issues.join('; '),
131
+ });
132
+ }
133
+
102
134
  if (_is_prompt_envelope(data)) {
103
135
  return _run_prompt(state, data);
104
136
  }
@@ -117,6 +149,53 @@ export function run(state: DeliveryState): StepResult {
117
149
  });
118
150
  }
119
151
 
152
+ /**
153
+ * Report what is wrong with an envelope's `dod[]` slot; `[]` when it is fine.
154
+ *
155
+ * Exported so the shape is testable on its own and so a caller can ask the
156
+ * question without triggering a halt. An absent key is valid — the slot is
157
+ * optional and omitting it means the author never declared a machine-checkable
158
+ * done-condition, which is different from declaring an empty one.
159
+ *
160
+ * `check` is required to be non-empty for the reason the schema states: the
161
+ * dispatcher spawns no subprocesses, so `check` is handed to an agent to run,
162
+ * and an agent cannot run a sentence.
163
+ */
164
+ export function diagnose_dod(data: Record<string, Any>): string[] {
165
+ if (!_isPlainObject(data) || !('dod' in data)) {
166
+ return [];
167
+ }
168
+ const dod = data.dod;
169
+ if (!Array.isArray(dod)) {
170
+ return [`\`dod\` must be a list, got ${_pyTypeName(dod)}`];
171
+ }
172
+ const issues: string[] = [];
173
+ const seen = new Set<string>();
174
+ dod.forEach((entry, idx) => {
175
+ const position = idx + 1;
176
+ if (!_isPlainObject(entry)) {
177
+ issues.push(`entry ${position} must be an object, got ${_pyTypeName(entry)}`);
178
+ return;
179
+ }
180
+ const id = entry.id;
181
+ if (typeof id !== 'string' || !id.trim()) {
182
+ issues.push(`entry ${position} has no \`id\``);
183
+ } else if (seen.has(id)) {
184
+ issues.push(`entry ${position} repeats id \`${id}\``);
185
+ } else {
186
+ seen.add(id);
187
+ }
188
+ const check = entry.check;
189
+ if (typeof check !== 'string' || !check.trim()) {
190
+ issues.push(`entry ${position} has no \`check\` command`);
191
+ }
192
+ if ('proven' in entry && typeof entry.proven !== 'boolean') {
193
+ issues.push(`entry ${position} has a non-boolean \`proven\``);
194
+ }
195
+ });
196
+ return issues;
197
+ }
198
+
120
199
  /**
121
200
  * True when `state.ticket` carries a prompt envelope.
122
201
  *
@@ -442,6 +521,32 @@ function _pyTruthy(value: unknown): boolean {
442
521
  return true;
443
522
  }
444
523
 
524
+ /** Python `type(x).__name__` for the value kinds the dod diagnose path sees. */
525
+ function _pyTypeName(value: unknown): string {
526
+ if (value === null || value === undefined) {
527
+ return 'NoneType';
528
+ }
529
+ if (typeof value === 'boolean') {
530
+ return 'bool';
531
+ }
532
+ if (typeof value === 'number') {
533
+ return Number.isInteger(value) ? 'int' : 'float';
534
+ }
535
+ if (typeof value === 'string') {
536
+ return 'str';
537
+ }
538
+ if (Array.isArray(value)) {
539
+ return 'list';
540
+ }
541
+ if (value instanceof Set) {
542
+ return 'set';
543
+ }
544
+ if (value instanceof Map || _isPlainObject(value)) {
545
+ return 'dict';
546
+ }
547
+ return typeof value;
548
+ }
549
+
445
550
  /** True for a dict-like value (mirrors Python `isinstance(x, dict)`). */
446
551
  function _isPlainObject(value: unknown): value is Record<string, unknown> {
447
552
  return (