@event4u/agent-config 9.30.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +60 -292
  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-IYTGIG2Y.js → chunk-542DZR4P.js} +118 -148
  15. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  16. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  17. package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
  18. package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
  19. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  20. package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
  21. package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
  22. package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
  23. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  24. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  25. package/dist/cli-delegate/cmd_conformance.js +154 -41
  26. package/dist/cli-delegate/cmd_doctor.js +4 -3
  27. package/dist/cli-delegate/cmd_explain.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +4 -4
  29. package/dist/cli-delegate/cmd_handoff.js +168 -59
  30. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  31. package/dist/cli-delegate/cmd_migrate.js +2 -2
  32. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  33. package/dist/cli-delegate/cmd_prune.js +4 -4
  34. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  35. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  36. package/dist/cli-delegate/cmd_session_recycle.js +19 -4
  37. package/dist/cli-delegate/cmd_settings_get.js +2 -55
  38. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +4 -4
  41. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  42. package/dist/cli-delegate/cmd_update.js +1 -1
  43. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +4 -4
  45. package/dist/cli-delegate/cmd_versions.js +1 -1
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +3 -3
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +1 -1
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +2 -2
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +2 -2
  54. package/dist/hooks/dispatch.js +1411 -810
  55. package/dist/install/install.mjs +121 -16
  56. package/dist/mcp/registry-manifest.json +1 -1
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +365 -143
  59. package/dist/scripts/_lib/agent_settings.js +111 -2
  60. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  61. package/dist/server/schemas/settings.js +2 -2
  62. package/dist/server/schemas/settings.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  67. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  68. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  69. package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
  70. package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  71. package/src/config/pack-size-budget.json +3 -2
  72. package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
  73. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  74. package/src/scripts/_cli/handoff_generate.ts +36 -1
  75. package/src/scripts/_cli/handoff_sessions.ts +140 -9
  76. package/src/scripts/_lib/agent_settings.ts +119 -2
  77. package/src/scripts/_lib/carrier_divergence.ts +120 -2
  78. package/src/scripts/_lib/envelope_grounding.ts +206 -0
  79. package/src/scripts/_lib/host_capability.ts +79 -0
  80. package/src/scripts/_lib/session_eol.ts +85 -0
  81. package/src/scripts/_lib/subagent_capsule.ts +256 -1
  82. package/src/scripts/check_secret_leak.test.ts +43 -0
  83. package/src/scripts/check_secret_leak.ts +12 -0
  84. package/src/scripts/handoff_context_hook.ts +66 -18
  85. package/src/scripts/hooks/session_eol_hook.ts +12 -12
  86. package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
  87. package/src/scripts/language_mirror_hook.ts +69 -16
  88. package/src/scripts/measure_turn_end_gate.ts +129 -51
  89. package/src/scripts/report_carrier_divergence.ts +143 -30
  90. package/src/scripts/report_conformance_funnel.ts +18 -4
  91. package/src/scripts/routing_doctor.ts +25 -2
  92. package/src/scripts/schemas/dod.schema.json +45 -0
  93. package/src/server/schemas/settings.ts +2 -2
  94. package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
@@ -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
  /**
@@ -7,9 +7,10 @@
7
7
  "measurement_conditions": "npm pack --dry-run --json --ignore-scripts, run at the repo root on a clean checkout. `--ignore-scripts` means NO build runs, so dist/cli, dist/ui, dist/mcp and dist/hooks are absent and the figure covers only the TRACKED payload (src/scripts/, dist/agent-src/, dist/install/, docs/guidelines/, templates). That is deliberate: this gate is always-on for every PR, and the tracked content tree is exactly the surface that grows by accretion. The built artifact is ~5.4 MB larger and is gated separately, at release time, by evaluator-budgets.unpacked_size_mb.",
8
8
  "budgets": {
9
9
  "packed_size_mb": {
10
- "max": 6.9,
11
- "last_measured": 6.398,
10
+ "max": 7.8,
11
+ "last_measured": 7.238,
12
12
  "method": "npm pack --dry-run --json --ignore-scripts → dist.size / 1e6 (the compressed tarball a consumer actually downloads)",
13
+ "baseline_note_2026_08_10": "6.9 → 7.8 re-baselined 2026-08-10. WHICH OF THE TWO THIS IS, stated because the guard asks: a RATCHET RESET, not the config bent around a failing check — and the discriminator is that the payload is real and needed rather than that the check was inconvenient. The 44 KB is two source files a shipped instruction depends on, the cheaper alternative was examined and rejected on that ground (below), the new cap carries ~8% headroom over the higher of two disagreeing measurements rather than clearing by a hair, and the raise was put to the maintainer as a decision instead of applied to make a red go away. A reader who disagrees has everything needed to reverse it: the measurement, its method, the trip cause, and the rejected option. Maintainer-decided in the PR that tripped it, surfaced rather than smuggled — the same standing this file's `owner: maintainer` requires. MEASURED TWICE, and the two do not agree: CI reported 6.907 on this exact commit, a local run under the documented conditions reports 7.238. The 331 KB gap is UNEXPLAINED — the tree matches the pushed commit and only this file differs — so the cap is set over the HIGHER figure rather than the convenient one, and the discrepancy is recorded here as an open question instead of resolved by assumption. A third party reproducing this should expect either number and should not treat the lower one as authoritative. Separately, and this part IS understood: `npm pack --dry-run --ignore-scripts` in a worktree that has been BUILT reads 8.45, because the flag assumes dist/cli, dist/ui, dist/mcp and dist/hooks are absent and a built tree has them. That figure is not comparable to this budget and must never be written here — recorded because it is a trap the documented method invites. Trip cause: road-to-conformance-round5 Phase 3 added src/scripts/hooks/turn_end_gate_hook.ts (32 KB) and src/scripts/measure_turn_end_gate.ts (12 KB) in packed paths plus schema/template/contract growth, landing 7 KB over a 6.9 cap. Max set to 7.8 (~8% headroom over 7.238, and ~13% over CI's 6.907) for the reason the 2026-08-05 note already gives: a cap pinned just above the measurement reds on the next commit and teaches the reader to raise it again. ALTERNATIVE EXAMINED AND REJECTED, so it is not re-proposed: dropping measure_turn_end_gate.ts from the pack would have cleared the cap with room, but the settings description added in the same change instructs CONSUMERS to run that script against their own transcript store before arming the gate — excluding it would break a shipped instruction.",
13
14
  "baseline_note": "5.903 → 6.398 re-baselined 2026-08-05. This is a RE-BASELINE, not a carve-out for the change that tripped it: measured on the tracked tree, `origin/main` alone was already at 6.372 of a 6.4 cap, so 28 KB of headroom remained after months of accretion and the next legitimate addition of any size was going to cross it. The addition here is 26 KB — docs/contracts/settings-classes.md (the A/B/C fence, read at RUNTIME by the settings writer, so it must ship) plus src/server/io/yamlIO.ts (imported by that writer to preserve the user's comments). CI measures ~4 KB above the local figure because it regenerates dist/agent-src first. Max set to 6.9 rather than to a number that merely clears: a cap pinned 2 KB above the measurement reds on the next commit and teaches the reader to raise it again, which is how a budget becomes a formality. That restores the ~8% headroom the 5.903/6.4 pair originally carried. The `owner: maintainer` on this file stands — this raise is surfaced in the PR for that decision, not smuggled."
14
15
  }
15
16
  },
@@ -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 {
@@ -132,6 +132,19 @@ export function build(project_root: string): Record<string, unknown> | null {
132
132
  rule_loading_tier = null;
133
133
  }
134
134
  let rule_loading_tier_source = _pyTruthy(rule_loading_tier) ? 'user' : 'default';
135
+ // The one read-site divergence from the template that is NOT a
136
+ // `settingsCarveOut` row, so it is explained here instead
137
+ // (`road-to-scripts-settings-defaults` Phase 1, § 2 of the Evidence Report).
138
+ // The template ships `rule_loading_tier: __RULE_LOADING_TIER__` — an
139
+ // installer placeholder, not a value — so the template-defaults layer in
140
+ // `load_agent_settings` deliberately does not inject it and an absent key
141
+ // arrives here as `undefined`. `balanced` is therefore this reader's own
142
+ // default, matching what the installer substitutes.
143
+ // The second arm is the deliberate part: a settings file that still
144
+ // literally contains the placeholder is a BROKEN install, and this reports
145
+ // it as `source: default` — i.e. the trace looks healthy. Surfacing it
146
+ // instead would be a reporting change beyond this roadmap's scope; it is
147
+ // recorded rather than silently kept.
135
148
  if (!_pyTruthy(rule_loading_tier) || rule_loading_tier === '__RULE_LOADING_TIER__') {
136
149
  rule_loading_tier = _DEFAULT_RULE_LOADING_TIER;
137
150
  rule_loading_tier_source = 'default';
@@ -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]`);
@@ -19,6 +19,11 @@
19
19
  * De-dup: native-store sessions whose platform id maps (via
20
20
  * `derive_session_tag`) to a chat-history bucket are dropped — the
21
21
  * chat-history entry wins.
22
+ *
23
+ * Two exclusions then apply to the merged list (road-to-cost-parity-3
24
+ * Phase 1): the ISSUING session, by exact id and never heuristically, and
25
+ * every candidate holding nothing worth resuming (`is_substantive`). Both
26
+ * fail OPEN — an unknown lists.
22
27
  */
23
28
 
24
29
  import * as fs from 'node:fs';
@@ -26,10 +31,35 @@ import * as os from 'node:os';
26
31
  import * as path from 'node:path';
27
32
 
28
33
  import { derive_session_tag, list_sessions, read_entries } from '../chat_history.js';
34
+ import { eolSessionKey, readEolCounters, type StoredEolCounters } from '../_lib/session_eol.js';
29
35
 
30
36
  export const DEFAULT_CAP = 15;
37
+ /**
38
+ * Per-source over-fetch factor. The exclusions run after the per-source cap,
39
+ * so fetching exactly `cap` lets a filtered candidate consume a slot no
40
+ * eligible session can reclaim. 3× is a backfill margin, not a measurement:
41
+ * on the observed store 10 of 217 sessions are filtered (~5%), so 3× covers
42
+ * that comfortably while keeping the per-file scan bounded.
43
+ */
44
+ const FETCH_OVERSHOOT = 3;
31
45
  const SUMMARY_SNIPPET_CHARS = 120;
32
46
 
47
+ /**
48
+ * Committed substantive-content floor, in parsed transcript tokens (billable
49
+ * input of the last main-chain assistant record, per `cc_transcript.ts`) — the
50
+ * OR-arm for a session that produced real discussion without ever calling a
51
+ * tool.
52
+ *
53
+ * Derivation (`agents/evidence/analysis/handoff-substantive-threshold.md`,
54
+ * 217 sessions of the local Claude store, 2026-08-10): 206 of the 207 sessions
55
+ * carrying an assistant record also carry a `tool_use` block, so this arm
56
+ * serves the ~0.5 % tail and the hosts whose transcripts log no tool blocks at
57
+ * all. 10 000 sits **25× below** the p10 of real working sessions (254 939),
58
+ * so it cannot hide one, and above a single trivial exchange. Changing it is a
59
+ * PR citing evidence, never a drive-by edit.
60
+ */
61
+ export const SUBSTANTIVE_TOKEN_FLOOR = 10_000;
62
+
33
63
  export type HandoffSource = 'chat-history' | 'claude-transcript' | 'codex-session';
34
64
 
35
65
  export interface HandoffSession {
@@ -55,6 +85,21 @@ export interface ListOptions {
55
85
  codexSessionsRoot?: string;
56
86
  /** Max sessions per source AND in the merged result. Default 15. */
57
87
  cap?: number;
88
+ /**
89
+ * The ISSUING session's id — excluded from the result unconditionally.
90
+ *
91
+ * Default: `AGENT_SESSION_ID` (this package's own envelope convention),
92
+ * else `CLAUDE_CODE_SESSION_ID`. The second name is **measured, not
93
+ * guessed**: a live Claude Code session (host 2026-08-10) exports
94
+ * `CLAUDE_CODE_SESSION_ID` into every Bash tool call and its value is the
95
+ * session uuid that also names the transcript file — a plausible-looking
96
+ * `CLAUDE_SESSION_ID` is not exported and would leave this filter inert.
97
+ *
98
+ * Pass `null` to disable (a caller that genuinely is not a session).
99
+ */
100
+ selfSessionId?: string | null;
101
+ /** Workspace root holding `agents/runtime/state/session-eol/`. Default: `cwd`. */
102
+ workspaceRoot?: string;
58
103
  }
59
104
 
60
105
  /** Mirrors the Claude Code project-dir naming: `/` and `.` → `-`. */
@@ -408,28 +453,114 @@ function _sort_key(s: HandoffSession): string {
408
453
  return s.endedAt ?? s.startedAt ?? '';
409
454
  }
410
455
 
456
+ /**
457
+ * Does this session hold anything worth resuming?
458
+ *
459
+ * `≥ 1 assistant turn AND (≥ 1 tool call OR parsed tokens ≥ the committed
460
+ * floor)`, read from the counts-only session-eol state.
461
+ *
462
+ * **Fail-open, deliberately** (Phase 1.2): absent, unreadable or mis-shaped
463
+ * state LISTS the candidate rather than filtering it, and a `tool_calls` key
464
+ * missing from a state file written before that counter existed reads as
465
+ * *unknown*, never as zero. A wrongly listed candidate is noise the user
466
+ * scrolls past; a wrongly hidden one is data loss they cannot even see.
467
+ */
468
+ export function is_substantive(counters: StoredEolCounters | null): boolean {
469
+ if (counters === null) return true;
470
+ // Every "unknown" below is a NUMBER test, not an `=== undefined` test.
471
+ // Absent, null and NaN are all unknown, and each one reaches this code by
472
+ // a real path: a state file written before the counter existed is absent,
473
+ // `JSON.stringify` turns NaN into null on the way to disk, and a
474
+ // half-written file yields a parseable object with missing keys. Reading
475
+ // any of them as "counted zero" hides a session that did real work.
476
+ if (!Number.isFinite(counters.assistant_records)) return true;
477
+ if ((counters.assistant_records as number) < 1) return false;
478
+ if (!Number.isFinite(counters.tool_calls)) return true;
479
+ if ((counters.tool_calls as number) >= 1) return true;
480
+ if (!Number.isFinite(counters.final_context_tokens)) return true;
481
+ return (counters.final_context_tokens as number) >= SUBSTANTIVE_TOKEN_FLOOR;
482
+ }
483
+
484
+ function _derive_tag(id: string): string | null {
485
+ try {
486
+ return derive_session_tag(id);
487
+ } catch {
488
+ return null;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The state file is keyed on the RAW session id (or transcript path) the hook
494
+ * saw. A chat-history bucket carries only the derived tag, so the native scan
495
+ * — which holds both — supplies the raw identity for those buckets.
496
+ */
497
+ function _counters_for(
498
+ session: HandoffSession,
499
+ byTag: Map<string, HandoffSession>,
500
+ workspaceRoot: string,
501
+ ): StoredEolCounters | null {
502
+ const native = session.source === 'chat-history' ? byTag.get(session.id) : session;
503
+ const candidates = [
504
+ ...(session.source === 'chat-history' ? [] : [session.id]),
505
+ native?.id,
506
+ native?.transcriptPath,
507
+ ];
508
+ for (const candidate of candidates) {
509
+ if (!candidate) continue;
510
+ const counters = readEolCounters(workspaceRoot, eolSessionKey(candidate));
511
+ if (counters !== null) return counters;
512
+ }
513
+ return null;
514
+ }
515
+
411
516
  /**
412
517
  * Unified newest-first session list. Native-store sessions whose platform
413
518
  * id maps to an existing chat-history bucket are dropped (chat-history is
414
519
  * the primary source).
520
+ *
521
+ * Two exclusions apply to the merged list: the ISSUING session, by exact id
522
+ * (never heuristically — a heuristic here hides a session the user wanted),
523
+ * and every candidate `is_substantive` rejects.
415
524
  */
416
525
  export function list_handoff_sessions(opts: ListOptions = {}): HandoffSession[] {
417
526
  const cap = opts.cap ?? DEFAULT_CAP;
527
+ const workspaceRoot = opts.workspaceRoot ?? opts.cwd ?? process.cwd();
528
+ const selfId =
529
+ opts.selfSessionId === null
530
+ ? null
531
+ : (opts.selfSessionId ??
532
+ process.env.AGENT_SESSION_ID ??
533
+ process.env.CLAUDE_CODE_SESSION_ID ??
534
+ null);
535
+ const selfTag = selfId ? _derive_tag(selfId) : null;
536
+
418
537
  const primary = _chat_history_sessions(opts);
419
538
  const knownTags = new Set(primary.map((s) => s.id));
420
539
 
421
- const native = [
422
- ..._claude_transcript_sessions(opts, cap),
423
- ..._codex_sessions(opts, cap),
424
- ].filter((s) => {
425
- try {
426
- return !knownTags.has(derive_session_tag(s.id));
427
- } catch {
428
- return true;
429
- }
540
+ // Over-fetch per source, because the two filters below run AFTER it and
541
+ // the issuing session is by construction among the newest — with a
542
+ // per-source cap of exactly `cap`, the caller always burns a slot and
543
+ // every filtered empty session burns another, so a substantive session
544
+ // just outside the window is silently dropped instead of backfilled.
545
+ // That is the hidden-session direction Phase 1.2 calls data loss.
546
+ const fetchCap = cap * FETCH_OVERSHOOT;
547
+ const nativeAll = [
548
+ ..._claude_transcript_sessions(opts, fetchCap),
549
+ ..._codex_sessions(opts, fetchCap),
550
+ ];
551
+ const byTag = new Map<string, HandoffSession>();
552
+ for (const s of nativeAll) {
553
+ const tag = _derive_tag(s.id);
554
+ if (tag) byTag.set(tag, s);
555
+ }
556
+ const native = nativeAll.filter((s) => {
557
+ const tag = _derive_tag(s.id);
558
+ return tag === null || !knownTags.has(tag);
430
559
  });
431
560
 
432
561
  return [...primary, ...native]
562
+ .filter((s) => s.id !== selfId && (selfTag === null || s.id !== selfTag))
563
+ .filter((s) => is_substantive(_counters_for(s, byTag, workspaceRoot)))
433
564
  .sort((a, b) => (_sort_key(a) < _sort_key(b) ? 1 : _sort_key(a) > _sort_key(b) ? -1 : 0))
434
565
  .slice(0, cap);
435
566
  }
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Resolution order (deepest wins; user-global is whitelist-filtered only):
11
11
  *
12
+ * N+1. `src/config/agent-settings.template.yml` (shipped defaults; base)
12
13
  * N. `~/.event4u/agent-config/agent-settings.yml` (user-global; whitelist only)
13
14
  * N-1. `<repo-root>/.agent-settings.yml` (project-wide; all keys)
14
15
  * N-2. `<intermediate-dir>/.agent-settings.yml` (subsystem-scoped; all keys)
@@ -36,13 +37,22 @@
36
37
  * - YAML parse failure / unreadable file → defaults, logged at WARNING.
37
38
  * - Missing files → defaults.
38
39
  * - No file is ever created or written by this module.
40
+ *
41
+ * The template-defaults base ({@link template_defaults}) gives this path the
42
+ * guarantee the server read path already had: a key absent from every layer
43
+ * resolves to its shipped default rather than `undefined`. Two sets are
44
+ * excluded from it — the `settingsCarveOut` keys, whose absence deliberately
45
+ * means something else, and placeholder-valued leaves the installer fills.
46
+ * An unreadable template degrades to the pre-existing empty-defaults behaviour.
39
47
  */
40
48
  import { createRequire } from 'node:module';
41
49
  import * as fs from 'node:fs';
42
50
  import * as os from 'node:os';
43
51
  import * as path from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
44
53
 
45
54
  import * as user_global_paths from './user_global_paths.js';
55
+ import { carveOutKeys } from '../../shared/settingsCarveOut.js';
46
56
  import type * as YamlModule from 'yaml';
47
57
 
48
58
  // ESM has no `require`; `createRequire(import.meta.url)` restores it so the
@@ -286,7 +296,106 @@ export const MERGEABLE_KEYS: readonly string[] = [
286
296
  'knowledge.global_sharing.freshness.stale_after_days',
287
297
  ];
288
298
 
289
- const _DEFAULTS: SettingsDict = {};
299
+ /** The shipped template, relative to the package root. */
300
+ const TEMPLATE_RELATIVE = path.join('src', 'config', 'agent-settings.template.yml');
301
+
302
+ /**
303
+ * Package root: `src/scripts/_lib/agent_settings.ts` → three levels up.
304
+ * Same idiom as `cli_wrapper.ts:28`, and it holds in an installed consumer
305
+ * too because `src/config/` ships (`package.json` `files[]`).
306
+ */
307
+ const _PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
308
+
309
+ /** Default template path — overridable per call for tests. */
310
+ export function default_template_path(): string {
311
+ return path.join(_PACKAGE_ROOT, TEMPLATE_RELATIVE);
312
+ }
313
+
314
+ /**
315
+ * A leaf whose template value is still an installer placeholder
316
+ * (`__RULE_LOADING_TIER__`, `__CHAT_HISTORY_FREQUENCY__`, …). The installer
317
+ * substitutes these from the chosen profile preset; the template alone does
318
+ * not carry a usable default, so the defaults layer must not inject one.
319
+ *
320
+ * Matched by shape rather than against `TEMPLATE_PLACEHOLDER_DEFAULTS`
321
+ * (`src/server/io/yamlIO.ts:139`) on purpose: the scripts family does not
322
+ * import from the server family, and a duplicated map would be one more thing
323
+ * that can drift. A new placeholder is covered the day it is added.
324
+ */
325
+ function _is_placeholder(value: SettingsValue): boolean {
326
+ return typeof value === 'string' && /^__[A-Z0-9_]+__$/.test(value);
327
+ }
328
+
329
+ /**
330
+ * Prune a parsed template into the tree the defaults layer may contribute:
331
+ * every placeholder-valued leaf and every carve-out key removed, and any
332
+ * branch that empties out as a result dropped with them.
333
+ */
334
+ function _prune_template(tree: SettingsDict, excluded: ReadonlySet<string>, prefix = ''): SettingsDict {
335
+ const out: SettingsDict = {};
336
+ for (const [key, value] of Object.entries(tree)) {
337
+ const dotted = prefix === '' ? key : `${prefix}.${key}`;
338
+ if (excluded.has(dotted) || _is_placeholder(value)) {
339
+ continue;
340
+ }
341
+ if (_is_plain_dict(value)) {
342
+ const child = _prune_template(value as SettingsDict, excluded, dotted);
343
+ if (Object.keys(child).length > 0) {
344
+ out[key] = child;
345
+ }
346
+ continue;
347
+ }
348
+ out[key] = value;
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /** Parsed-and-pruned template, memoised for the default path only. */
354
+ let _template_defaults_cache: SettingsDict | null = null;
355
+
356
+ /**
357
+ * The template-defaults layer — the base every real settings layer sits on.
358
+ *
359
+ * Gives the scripts read path the guarantee the server read path already has:
360
+ * a key absent from every layer resolves to its shipped default instead of
361
+ * `undefined`. Two exclusions, both load-bearing:
362
+ *
363
+ * - **Carve-out keys** (`src/shared/settingsCarveOut.ts`). For these an absent
364
+ * key deliberately resolves to something other than the template default,
365
+ * and injecting the default here would flip existing installs — precisely
366
+ * the silent narrowing that module exists to prevent (`settingsCarveOut.ts:18-21`).
367
+ * - **Placeholder-valued leaves**, which carry no default until the installer
368
+ * substitutes them.
369
+ *
370
+ * Tolerant like the rest of this module: an unreadable or malformed template
371
+ * yields `{}`, i.e. exactly the pre-existing no-defaults behaviour.
372
+ *
373
+ * The result is a full `_deepcopy`, never `_deep_copy_defaults`. The latter is
374
+ * built on `_deep_merge`, which assigns a nested value by REFERENCE whenever
375
+ * the destination has no dict at that key — so its "copy" of a fresh `{}`
376
+ * shares every sub-tree with the source. The caller then merges the real
377
+ * layers into that shared sub-tree and silently rewrites the cache: the first
378
+ * settings file read in a process would become the defaults every later read
379
+ * sees. The predecessor `_DEFAULTS` was `{}`, so the hazard existed but had
380
+ * nothing to poison; a populated base makes it live.
381
+ */
382
+ export function template_defaults(template_path?: string): SettingsDict {
383
+ const use_cache = template_path === undefined;
384
+ if (use_cache && _template_defaults_cache !== null) {
385
+ return _deepcopy(_template_defaults_cache);
386
+ }
387
+ const parsed = _read_yaml(template_path ?? default_template_path()) ?? {};
388
+ const pruned = _prune_template(parsed, new Set(carveOutKeys()));
389
+ if (use_cache) {
390
+ _template_defaults_cache = pruned;
391
+ }
392
+ return _deepcopy(pruned);
393
+ }
394
+
395
+ /** Drop the memoised template — tests only; production reads it once per process. */
396
+ export function _reset_template_defaults_cache(): void {
397
+ _template_defaults_cache = null;
398
+ }
290
399
 
291
400
  /**
292
401
  * Defaults applied by `get_modules_config` when a key is absent from both
@@ -730,12 +839,17 @@ export function load_agent_settings(
730
839
  user_global_path?: string | null;
731
840
  verbose?: boolean;
732
841
  cwd?: string | null;
842
+ template_path?: string | null;
733
843
  } = {},
734
844
  ): SettingsDict {
735
845
  const project_path = options.project_path ?? null;
736
846
  const user_global_path = options.user_global_path ?? null;
737
847
  const verbose = options.verbose ?? false;
738
848
  const cwd = options.cwd ?? null;
849
+ // Same caller-override contract as `project_path` / `user_global_path`:
850
+ // a test or tool that pins every other input can pin the defaults base
851
+ // too, and point it at a nonexistent file to isolate the cascade.
852
+ const template_path = options.template_path ?? null;
739
853
 
740
854
  // An explicit path is a caller override (tests, tooling) and stays single;
741
855
  // otherwise every user-global file merges in precedence order.
@@ -754,7 +868,10 @@ export function load_agent_settings(
754
868
 
755
869
  const cascade = _resolve_cascade_paths(cwd, project_path);
756
870
 
757
- const merged: SettingsDict = _deep_copy_defaults(_DEFAULTS);
871
+ // Template defaults sit BELOW every real layer — never above one. The
872
+ // precedence here is the inverse of the server family's, so the order of
873
+ // these three statements is the whole contract.
874
+ const merged: SettingsDict = template_defaults(template_path ?? undefined);
758
875
  _deep_merge(merged, user_global_filtered);
759
876
  for (const p of cascade) {
760
877
  const layer = _read_yaml(p) ?? {};