@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
@@ -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 (
@@ -17,10 +17,12 @@
17
17
  * - Must be a dict.
18
18
  * - Must carry a `verdict` key — one of `success`, `failed`,
19
19
  * or `mixed` (targeted vs full-suite split). Anything else blocks.
20
- * - `failed` or `mixed` verdicts halt with the verdict as part of
21
- * the surfaced message so the user decides whether to continue or
22
- * stop. This follows the `verify-before-complete` rule: a bad test
23
- * outcome must never be silently skipped.
20
+ * - `failed` or `mixed` verdicts enter the bounded self-fix loop
21
+ * (`./_self_fix.ts`): the first attempts are delegated by directive, and an
22
+ * exhausted or non-progressing loop exits `PARTIAL` with the verdict still
23
+ * on the surface so the user decides whether to continue or stop. This
24
+ * follows the `verify-before-complete` rule: a bad test outcome must never
25
+ * be silently skipped, and the loop cannot report one as success.
24
26
  * - Optional keys (`targeted`, `full`, `duration_ms`,
25
27
  * `followups`) feed the delivery report.
26
28
  */
@@ -28,6 +30,7 @@
28
30
  import type { DeliveryState} from '../../delivery_state.js';
29
31
  import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
30
32
  import { resolve_policy } from '../../persona_policy.js';
33
+ import { decide, partial_exit, record_attempt, retry_halt, unmet_dod, verdict_signature } from './_self_fix.js';
31
34
 
32
35
  const _ALLOWED_VERDICTS = ['success', 'failed', 'mixed'] as const;
33
36
 
@@ -53,7 +56,18 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
53
56
  {
54
57
  code: 'bad_test_verdict',
55
58
  trigger: '`state.tests[\'verdict\']` is `failed` or `mixed`',
56
- resolution: 'fix failures and re-run, or abort',
59
+ resolution:
60
+ 'bounded self-fix loop: agent directive `fix-failing-checks` ' +
61
+ 'while attempts remain and the verdict signature keeps changing',
62
+ },
63
+ {
64
+ code: 'self_fix_exhausted',
65
+ trigger:
66
+ 'the self-fix budget for `test` is spent, or two consecutive ' +
67
+ 'attempts produced an identical verdict signature',
68
+ resolution:
69
+ 'PARTIAL honest exit — the red verdict stays on the surface, ' +
70
+ 'the user decides whether to take over, override, or abort',
57
71
  },
58
72
  ];
59
73
 
@@ -156,18 +170,39 @@ function _blocked_on_shape(state: DeliveryState, issues: string[]): StepResult {
156
170
  });
157
171
  }
158
172
 
173
+ /**
174
+ * Route a red test verdict through the bounded self-fix loop.
175
+ *
176
+ * Before this loop existed the function below returned a directive-free
177
+ * BLOCKED — a user question block — so every red cost a user round-trip even
178
+ * when the failure was one the agent could fix unaided. Now the first attempts
179
+ * are delegated and only an exhausted or non-progressing loop reaches the
180
+ * user, as a PARTIAL that still carries the red verdict.
181
+ */
159
182
  function _blocked_on_bad_verdict(state: DeliveryState, verdict: Any): StepResult {
160
183
  const ticketId = _ticketId(state);
161
- return new StepResult({
162
- outcome: Outcome.BLOCKED,
163
- questions: [
164
- `> Ticket ${ticketId} — tests reported \`${String(verdict)}\`. ` +
165
- 'Verification cannot proceed on a non-success verdict.',
166
- '> 1. Fix the failing tests and re-run `run-tests`',
167
- '> 2. Continue anyway — override (NOT recommended)',
168
- '> 3. Abort',
169
- ],
170
- message: `Ticket ${ticketId} test verdict was \`${String(verdict)}\`, not success.`,
184
+ const signature = verdict_signature('test', state.tests);
185
+ const decision = decide(state, 'test', signature);
186
+
187
+ if (decision.kind === 'retry') {
188
+ record_attempt(state, 'test', signature);
189
+ return retry_halt({
190
+ lane: 'test',
191
+ ticket_id: ticketId,
192
+ verdict,
193
+ decision,
194
+ fix_hint: 'read the failing assertions, fix the cause, re-run the same filter.',
195
+ rerun_directive: 'run-tests',
196
+ });
197
+ }
198
+
199
+ return partial_exit({
200
+ lane: 'test',
201
+ ticket_id: ticketId,
202
+ verdict,
203
+ decision,
204
+ unmet: unmet_dod(state),
205
+ rerun_directive: 'run-tests',
171
206
  });
172
207
  }
173
208
 
@@ -18,9 +18,12 @@
18
18
  * `partial`. Matches the `Outcome` vocabulary used everywhere
19
19
  * else in the flow.
20
20
  * - A `success` verdict advances the flow to `report`.
21
- * - A `blocked` or `partial` verdict halts with numbered options
22
- * so the user decides whether to address the findings, override
23
- * (rarely appropriate), or abort.
21
+ * - A `blocked` or `partial` verdict enters the bounded self-fix loop
22
+ * (`./_self_fix.ts`): while attempts remain and the verdict signature
23
+ * keeps changing, the fix is delegated by directive. An exhausted or
24
+ * non-progressing loop exits `PARTIAL` with numbered options so the user
25
+ * decides whether to take over, override (rarely appropriate), or abort.
26
+ * The loop never turns a non-success verdict into `SUCCESS`.
24
27
  * - Optional keys (`confidence`, `findings`, `followups`) feed
25
28
  * the delivery report.
26
29
  */
@@ -28,6 +31,7 @@
28
31
  import type { DeliveryState} from '../../delivery_state.js';
29
32
  import { type Any, Outcome, StepResult, agent_directive } from '../../delivery_state.js';
30
33
  import { resolve_policy } from '../../persona_policy.js';
34
+ import { decide, partial_exit, record_attempt, retry_halt, unmet_dod, verdict_signature } from './_self_fix.js';
31
35
 
32
36
  const _ALLOWED_VERDICTS = ['success', 'blocked', 'partial'] as const;
33
37
 
@@ -52,7 +56,17 @@ export const AMBIGUITIES: ReadonlyArray<Record<string, string>> = [
52
56
  code: 'bad_verify_verdict',
53
57
  trigger: '`state.verify[\'verdict\']` is `blocked` or `partial`',
54
58
  resolution:
55
- 'address findings and re-run `/review-changes` — never bypass ' + '(see `verify-before-complete`)',
59
+ 'bounded self-fix loop: agent directive `fix-failing-checks` ' +
60
+ 'while attempts remain — never bypass ' + '(see `verify-before-complete`)',
61
+ },
62
+ {
63
+ code: 'self_fix_exhausted',
64
+ trigger:
65
+ 'the self-fix budget for `verify` is spent, or two consecutive ' +
66
+ 'attempts produced an identical verdict signature',
67
+ resolution:
68
+ 'PARTIAL honest exit — the non-success verdict stays on the ' +
69
+ 'surface; the user takes over, overrides, or aborts',
56
70
  },
57
71
  ];
58
72
 
@@ -145,19 +159,39 @@ function _blocked_on_shape(state: DeliveryState, issues: string[]): StepResult {
145
159
  });
146
160
  }
147
161
 
162
+ /**
163
+ * Route a red review verdict through the bounded self-fix loop.
164
+ *
165
+ * Same shape as the `test` lane, separate counter: `autonomous-execution`
166
+ * resets its N=3 budget on a different validation target, and a review finding
167
+ * is not a failing test. `verify-before-complete` is unaffected — the loop
168
+ * cannot report completion, and its exhausted exit is PARTIAL with the
169
+ * non-success verdict still on the surface.
170
+ */
148
171
  function _blocked_on_bad_verdict(state: DeliveryState, verdict: Any): StepResult {
149
172
  const ticketId = _ticketId(state);
150
- return new StepResult({
151
- outcome: Outcome.BLOCKED,
152
- questions: [
153
- `> Ticket ${ticketId} — \`review-changes\` reported ` +
154
- `\`${String(verdict)}\`. The delivery report cannot claim completion on a ` +
155
- 'non-success verdict (see `verify-before-complete`).',
156
- '> 1. Address the findings and re-run `review-changes`',
157
- '> 2. Continue anyway — override (NOT recommended)',
158
- '> 3. Abort',
159
- ],
160
- message: `Ticket ${ticketId} verify verdict was \`${String(verdict)}\`, not success.`,
173
+ const signature = verdict_signature('verify', state.verify);
174
+ const decision = decide(state, 'verify', signature);
175
+
176
+ if (decision.kind === 'retry') {
177
+ record_attempt(state, 'verify', signature);
178
+ return retry_halt({
179
+ lane: 'verify',
180
+ ticket_id: ticketId,
181
+ verdict,
182
+ decision,
183
+ fix_hint: 'address the recorded findings, then re-run the four-judge review.',
184
+ rerun_directive: 'review-changes',
185
+ });
186
+ }
187
+
188
+ return partial_exit({
189
+ lane: 'verify',
190
+ ticket_id: ticketId,
191
+ verdict,
192
+ decision,
193
+ unmet: unmet_dod(state),
194
+ rerun_directive: 'review-changes',
161
195
  });
162
196
  }
163
197
 
@@ -220,6 +220,7 @@ export class WorkState {
220
220
  ui_polish: Dict | null;
221
221
  contract: Dict | null;
222
222
  stitch: Dict | null;
223
+ self_fix: Dict | null;
223
224
  halts: Dict[];
224
225
  version: number;
225
226
  persona: string;
@@ -245,6 +246,7 @@ export class WorkState {
245
246
  ui_polish?: Dict | null;
246
247
  contract?: Dict | null;
247
248
  stitch?: Dict | null;
249
+ self_fix?: Dict | null;
248
250
  halts?: Dict[];
249
251
  version?: number;
250
252
  persona?: string;
@@ -269,6 +271,7 @@ export class WorkState {
269
271
  this.ui_polish = opts.ui_polish ?? null;
270
272
  this.contract = opts.contract ?? null;
271
273
  this.stitch = opts.stitch ?? null;
274
+ this.self_fix = opts.self_fix ?? null;
272
275
  this.halts = opts.halts ?? [];
273
276
  this.version = opts.version ?? SCHEMA_VERSION;
274
277
  this.persona = opts.persona ?? 'senior-engineer';
@@ -309,6 +312,7 @@ export function to_dict(state: WorkState): Dict {
309
312
  _validate_ui_polish(state.ui_polish);
310
313
  _validate_contract(state.contract);
311
314
  _validate_stitch(state.stitch);
315
+ _validate_self_fix(state.self_fix);
312
316
  _validate_halts(state.halts);
313
317
  return {
314
318
  version: state.version,
@@ -324,6 +328,7 @@ export function to_dict(state: WorkState): Dict {
324
328
  ui_polish: state.ui_polish,
325
329
  contract: state.contract,
326
330
  stitch: state.stitch,
331
+ self_fix: state.self_fix,
327
332
  halts: [...state.halts],
328
333
  persona: state.persona,
329
334
  memory: state.memory,
@@ -406,6 +411,9 @@ export function from_dict(payload: JsonValue): WorkState {
406
411
  const stitch = payload['stitch'] ?? null;
407
412
  _validate_stitch(stitch);
408
413
 
414
+ const self_fix = payload['self_fix'] ?? null;
415
+ _validate_self_fix(self_fix);
416
+
409
417
  const halts = 'halts' in payload ? payload['halts'] : [];
410
418
  _validate_halts(halts);
411
419
 
@@ -422,6 +430,7 @@ export function from_dict(payload: JsonValue): WorkState {
422
430
  ui_polish: _isDict(ui_polish) ? { ...ui_polish } : null,
423
431
  contract: _isDict(contract) ? { ...contract } : null,
424
432
  stitch: _isDict(stitch) ? { ...stitch } : null,
433
+ self_fix: _isDict(self_fix) ? { ...self_fix } : null,
425
434
  halts: Array.isArray(halts) ? ([...halts] as Dict[]) : [],
426
435
  version: version as number,
427
436
  persona: _get(payload, 'persona', 'senior-engineer') as string,
@@ -866,6 +875,71 @@ function _validate_ui_review(ui_review: JsonValue): void {
866
875
  }
867
876
  }
868
877
 
878
+ /**
879
+ * Reject malformed `self_fix` envelopes; tolerate `null` and `{}`.
880
+ *
881
+ * `null` means no red check has been seen. Once populated, each key is a lane
882
+ * name (`test`, `verify`) whose record may carry `attempts` (a non-negative
883
+ * int) and `signatures` (a list of strings). As with `ui_polish`, the schema
884
+ * enforces only shape: the ceiling and the no-progress floor are the handler's
885
+ * business (`directives/backend/_self_fix.ts`), so a state file cannot decide
886
+ * how many attempts are allowed.
887
+ *
888
+ * `attempts` is validated but NOT bounded here on purpose. A file carrying
889
+ * `attempts: 99` is well-formed and simply exhausted — rejecting it would turn
890
+ * a spent budget into a crash, and the loop's whole point is a clean exit.
891
+ */
892
+ function _validate_self_fix(self_fix: JsonValue): void {
893
+ if (self_fix === null) {
894
+ return;
895
+ }
896
+ if (!_isDict(self_fix)) {
897
+ throw new SchemaError(
898
+ `state.self_fix must be a JSON object or null; ` +
899
+ `got ${pyTypeName(self_fix)}`,
900
+ );
901
+ }
902
+ for (const [lane, record] of Object.entries(self_fix)) {
903
+ if (!_isDict(record)) {
904
+ throw new SchemaError(
905
+ `state.self_fix.${lane} must be a JSON object; ` +
906
+ `got ${pyTypeName(record)}`,
907
+ );
908
+ }
909
+ if ('attempts' in record) {
910
+ const attempts = record['attempts'];
911
+ if (
912
+ typeof attempts !== 'number' ||
913
+ typeof attempts === 'boolean' ||
914
+ !Number.isInteger(attempts) ||
915
+ attempts < 0
916
+ ) {
917
+ throw new SchemaError(
918
+ `state.self_fix.${lane}.attempts must be a non-negative ` +
919
+ `integer when present; got ${pyRepr(attempts)}`,
920
+ );
921
+ }
922
+ }
923
+ if ('signatures' in record) {
924
+ const signatures = record['signatures'];
925
+ if (!Array.isArray(signatures)) {
926
+ throw new SchemaError(
927
+ `state.self_fix.${lane}.signatures must be a list when ` +
928
+ `present; got ${pyTypeName(signatures)}`,
929
+ );
930
+ }
931
+ for (const entry of signatures) {
932
+ if (typeof entry !== 'string') {
933
+ throw new SchemaError(
934
+ `state.self_fix.${lane}.signatures entries must be ` +
935
+ `strings; got ${pyTypeName(entry)}`,
936
+ );
937
+ }
938
+ }
939
+ }
940
+ }
941
+ }
942
+
869
943
  /**
870
944
  * Reject malformed `ui_polish` envelopes; tolerate `null` and `{}`.
871
945
  *
@@ -150,6 +150,7 @@ export function _to_delivery(work: WorkState): DeliveryState {
150
150
  contract: work.contract as Record<string, DeliveryAny> | null,
151
151
  stitch: work.stitch as Record<string, DeliveryAny> | null,
152
152
  stack: work.stack as Record<string, DeliveryAny> | null,
153
+ self_fix: work.self_fix as Record<string, DeliveryAny> | null,
153
154
  });
154
155
  }
155
156
 
@@ -182,6 +183,11 @@ export function _sync_back(work: WorkState, delivery: DeliveryState): void {
182
183
  work.contract = delivery.contract as Dict | null;
183
184
  work.stitch = delivery.stitch as Dict | null;
184
185
  work.stack = delivery.stack as Dict | null;
186
+ // Reassigned (not mutated) by `record_attempt`, so the defensive mirror
187
+ // above is load-bearing here rather than a no-op: without this line the
188
+ // attempt counter would reset on every dispatch and the loop would never
189
+ // reach either floor.
190
+ work.self_fix = delivery.self_fix as Dict | null;
185
191
  }
186
192
 
187
193
  /**
@@ -38,6 +38,7 @@ import {
38
38
  CAPSULE_SCHEMA_VERSION,
39
39
  validateRecycleEnvelope,
40
40
  } from '../_lib/subagent_capsule.js';
41
+ import { collectGrounding } from '../_lib/envelope_grounding.js';
41
42
  import { atomic_write_json } from '../hooks/state_io.js';
42
43
 
43
44
  export interface RecycleResult {
@@ -61,6 +62,9 @@ export function templateEnvelope(): Record<string, unknown> {
61
62
  open_worker_envelopes: [],
62
63
  artifact_paths: ['<path to a deliverable / note / evidence file>'],
63
64
  assumptions: [],
65
+ next_task: '<the ONE task this envelope is written for — select content for it>',
66
+ suggested_skills: ['<skill the successor should invoke>'],
67
+ failed_approaches: ['<tried X, failed because Y — or the single entry "none">'],
64
68
  };
65
69
  }
66
70
 
@@ -123,6 +127,27 @@ export function runSessionRecycle(
123
127
  }
124
128
  if (envelope['workspace'] === undefined) envelope['workspace'] = projectRoot;
125
129
 
130
+ // Scripted grounding (Phase 3.3): the factual fields are READ, never
131
+ // composed. A model-written branch is a claim; this is a reading. The
132
+ // composer cannot override them — a "next_task" it got wrong is a
133
+ // proposal, a "head" it got wrong is a silent stale resume.
134
+ const grounding = collectGrounding(projectRoot);
135
+ // Drop the composer's factual keys UNCONDITIONALLY first. Guarding each
136
+ // assignment on `!== null` would leave a model-composed branch or head
137
+ // standing whenever the git read fails — the consumer would then compare
138
+ // a fabricated anchor against the real tree and produce either false
139
+ // drift or, worse, false silence. An unreadable fact is absent, never
140
+ // inherited from the composer.
141
+ for (const key of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify', 'uncommitted_paths']) {
142
+ delete envelope[key];
143
+ }
144
+ if (grounding.repo_identity !== null) envelope['repo_identity'] = grounding.repo_identity;
145
+ if (grounding.branch !== null) envelope['branch'] = grounding.branch;
146
+ if (grounding.head !== null) envelope['head'] = grounding.head;
147
+ if (grounding.status_summary !== null) envelope['status_summary'] = grounding.status_summary;
148
+ if (grounding.last_verify !== null) envelope['last_verify'] = grounding.last_verify;
149
+ envelope['uncommitted_paths'] = grounding.uncommitted_paths;
150
+
126
151
  const violations = validateRecycleEnvelope(envelope);
127
152
  if (violations.length > 0) {
128
153
  return {
@@ -35,6 +35,19 @@ export const WORD_CAP = 1200;
35
35
  const SNIPPET_CHARS = 200;
36
36
  const DONE_SNIPPET_CHARS = 400;
37
37
  const MAX_ERRORS = 5;
38
+ /**
39
+ * Per-error cap. Above `SNIPPET_CHARS` because an error string is kept
40
+ * verbatim so it stays greppable — but deliberately not far above.
41
+ *
42
+ * The section competes for a fixed word cap whose trim pops the NEWEST entry
43
+ * first, and the newest failure is the one a successor needs. Enlarging each
44
+ * entry therefore makes that pre-existing trim order bite more often. 400 is
45
+ * twice the old truncation — enough to carry a real error line whole — while
46
+ * keeping the five-entry section within roughly a sixth of the cap. The
47
+ * pop()-newest ordering is pre-existing and is not this change's to alter;
48
+ * this bound is what keeps the change from amplifying it.
49
+ */
50
+ const MAX_ERROR_CHARS = 400;
38
51
  const MAX_DECISIONS = 5;
39
52
  const MAX_FILES = 15;
40
53
 
@@ -325,11 +338,23 @@ export function build_handoff(
325
338
  );
326
339
 
327
340
  // Errors + fixes — failure-pattern turns, oldest→newest, last 5.
341
+ //
342
+ // Precision rule (Phase 3.1): the error string is carried VERBATIM. It
343
+ // used to be `_snippet`-ed like every other section, which truncates
344
+ // mid-string — and a truncated error cannot be grepped, matched against a
345
+ // tracker, or recognised when it recurs, which is the only reason to
346
+ // carry it at all. So the FAILURE-matching line is taken whole (capped
347
+ // only against an absurd single line) instead of the turn being cut at an
348
+ // arbitrary offset.
328
349
  const errors = _redact_items(
329
350
  toolish
330
351
  .filter((t) => FAILURE_RE.test(t.text))
331
352
  .slice(-MAX_ERRORS)
332
- .map((t) => _snippet(`${t.tool ? `${t.tool}: ` : ''}${t.text}`, SNIPPET_CHARS)),
353
+ .map((t) => {
354
+ const line = t.text.split('\n').find((l) => FAILURE_RE.test(l))?.trim() ?? t.text.trim();
355
+ const prefix = t.tool ? `${t.tool}: ` : '';
356
+ return `${prefix}${line}`.slice(0, MAX_ERROR_CHARS);
357
+ }),
333
358
  );
334
359
 
335
360
  // Key decisions — decision-pattern assistant turns, last 5.
@@ -396,6 +421,16 @@ export function build_handoff(
396
421
  }
397
422
  for (const s of sections) {
398
423
  lines.push('', `## ${s.title}`, '');
424
+ if (s.title === 'Resume pointer') {
425
+ // Precision rules (Phase 3.1), rendered where the successor
426
+ // reads them rather than filed in a doc nobody opens.
427
+ lines.push(
428
+ '> Precision contract: identify code by signature or `path:line`, never by',
429
+ '> description; quote error strings verbatim; give every resume step its',
430
+ '> expected outcome, so "done" is checkable rather than felt.',
431
+ '',
432
+ );
433
+ }
399
434
  if (s.items.length === 0) {
400
435
  if (s.verbatim && verbatim.withheldLines > 0) {
401
436
  lines.push(`- [${verbatim.withheldLines} line(s) withheld by privacy floor]`);