@event4u/agent-config 9.29.0 → 9.31.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -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) ?? {};
@@ -81,6 +81,53 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
81
81
  return 'body-diff';
82
82
  }
83
83
 
84
+ /**
85
+ * Drop a leading YAML frontmatter block, returning the prose the host actually
86
+ * delivers.
87
+ *
88
+ * A file with no `---` fence, or an unterminated one, is returned unchanged —
89
+ * the same tolerance `report_carrier_divergence`'s own `type:` reader applies,
90
+ * because a malformed fence must not silently swallow a rule's whole text.
91
+ */
92
+ export function stripFrontmatter(text: string): string {
93
+ if (!text.startsWith('---')) return text;
94
+ const end = text.indexOf('\n---', 3);
95
+ if (end === -1) return text;
96
+ const afterFence = text.indexOf('\n', end + 1);
97
+ return afterFence === -1 ? '' : text.slice(afterFence + 1);
98
+ }
99
+
100
+ /**
101
+ * Do the two copies carry the same governed PROSE, ignoring frontmatter?
102
+ *
103
+ * WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
104
+ * --------------------------------------------------------------
105
+ * `comparePair` answers *can a byte-identity dedup skip this pair* — it is the
106
+ * dedup predicate, and a frontmatter difference is a real byte difference, so
107
+ * folding this in would relax the predicate that
108
+ * `agents/settings/contexts/dedup-reachability-refusal.md` deliberately keeps
109
+ * strict. `measure_scope_dedup` asks exactly that question and its arithmetic
110
+ * stays untouched.
111
+ *
112
+ * This answers the different, reader-facing question `report_carrier_divergence`
113
+ * asks: *must a human act on this pair*. Measured 2026-08-10 over the live
114
+ * carriers at commit `a5b2f4cb7`: 109 shared rules, **0** byte-identical, **109**
115
+ * classified `body-diff` — and after this strip, **0** of the 109 differ in
116
+ * prose. The whole divergence was the metadata block. Reporting that as body
117
+ * divergence manufactures the one class the report tells a reader to act on,
118
+ * which is precisely the failure `compareCarriers` already refuses to commit for
119
+ * an unreadable copy.
120
+ *
121
+ * Edge trimming only, on purpose: the frontmatter fence leaves the two sides one
122
+ * leading newline apart, and that is not a content difference. Internal
123
+ * whitespace and line endings are NOT normalized — a CRLF-vs-LF body stays a
124
+ * prose difference, so this stays no softer than the dedup predicate on the text
125
+ * the host reads.
126
+ */
127
+ export function proseEqual(a: string, b: string): boolean {
128
+ return stripFrontmatter(a).trim() === stripFrontmatter(b).trim();
129
+ }
130
+
84
131
  export interface RuleDirCensus {
85
132
  files: number;
86
133
  /**
@@ -37,14 +37,18 @@ import {
37
37
  } from './auto_dispatch.js';
38
38
 
39
39
  /** The five dispatch rungs, or `null` for the silent ∅ (in-session / ask). */
40
- export type LadderRung = 0 | 1 | 2 | 3 | 4 | null;
40
+ export type LadderRung = 0 | 0.5 | 1 | 2 | 3 | 4 | null;
41
41
 
42
42
  export type LadderVerdict =
43
43
  | 'script' // rung 0 — deterministic transform/primitive, no spawn at all
44
44
  | 'subagent' // rung 1/2 — one, or several downshifted, subagent dispatch(es)
45
45
  | 'team' // rung 3 — communicating slices, dispatched via the host teams primitive
46
46
  | 'council' // rung 4 — contested judgment
47
- | 'ask' // ∅ — ambiguous; a VERDICT to the user, never a speculative spawn
47
+ // 'ask' carries TWO shapes, split by rung (road-to-token-economy-dispatch
48
+ // Phase 4): rung 0.5 = a bounded question routed to ONE completion via
49
+ // `ask_transport` (no session, no tools, hard-capped — never the user);
50
+ // rung null = ambiguity, a VERDICT to the user, never a speculative spawn.
51
+ | 'ask'
48
52
  | 'in-session'; // ∅ — halted, no host primitive, trivial, approval-required, or recursive guard
49
53
 
50
54
  export interface LadderResult {
@@ -279,6 +283,62 @@ export function detectContestedJudgment(text: string): { matched: boolean; reaso
279
283
  * `point-of-action-carrier` blocker already names as its pre-registered
280
284
  * null ("no discriminator" is publishable and does not block this roadmap).
281
285
  */
286
+ /**
287
+ * Rung-0.5 signal — a single bounded question (token-economy-dispatch 4.2).
288
+ *
289
+ * Deliberately conservative, regex-only (the F3-lite discipline): the cost
290
+ * of a false NEGATIVE is one avoidable spawn; the cost of a false POSITIVE
291
+ * is an ask that returns INSUFFICIENT_CONTEXT and gets escalated — which the
292
+ * registered `ask_economy` kill criterion counts against the rung. So every
293
+ * ambiguity falls through (no match), and four hard exclusions apply:
294
+ *
295
+ * 1. Multi-line prompts — a bounded question is one sentence.
296
+ * 2. Length > 240 chars — beyond that it is a briefing, not a question.
297
+ * 3. Imperative work verbs (fix/implement/write/…) — those are tasks.
298
+ * 4. No interrogative shape — must lead with an interrogative (EN/DE) and
299
+ * end with `?`, OR be an explicit classify/categorize instruction
300
+ * (the one imperative the roadmap names as ask-shaped).
301
+ */
302
+ const _QUESTION_IMPERATIVE_RE =
303
+ /\b(fix|implement|refactor|build|create|write|add|update|delete|remove|rename|migrate|install|deploy|commit|push|merge|run|execute|generate|erstelle|baue|implementiere|schreibe|behebe)\b/i;
304
+ const _QUESTION_LEAD_RE =
305
+ /^(is|are|does|do|did|can|could|should|would|will|which|what|why|how|when|where|who|whose|ist|sind|hat|haben|kann|welche[rs]?|was|warum|wie|wann|wo|wer)\b/i;
306
+ const _CLASSIFY_LEAD_RE = /^(classify|categori[sz]e|klassifiziere)\b/i;
307
+ const _QUESTION_MAX_LEN = 240;
308
+
309
+ /** A question that names a file/repo object or a concrete path needs TOOLS —
310
+ * a blind completion cannot read it, so it is never ask-shaped. */
311
+ const _NEEDS_TOOLS_RE =
312
+ /\b(this|the|that|my|our)\s+(?:[\w-]+\s+){0,2}?(file|files|codebase|repo|repository|transcript|log|logs|diff|document|branch|pr|route|routes|schema|module|class|function|endpoint|test|tests)\b|[\w.-]+\/[\w.-]+|\b[\w-]+\.(ts|js|py|php|md|json|yaml|yml|sh|go|rs)\b/i;
313
+
314
+ export function detectBoundedQuestion(taskText: string): { matched: boolean; reason: string } {
315
+ const text = taskText.trim();
316
+ if (!text || text.length > _QUESTION_MAX_LEN || text.includes('\n')) {
317
+ return { matched: false, reason: 'not-bounded' };
318
+ }
319
+ if (_NEEDS_TOOLS_RE.test(text)) {
320
+ return { matched: false, reason: 'references-tool-requiring-objects' };
321
+ }
322
+ // Classify-lead first: its PAYLOAD may quote work verbs ("classify this
323
+ // error: ETIMEDOUT on push") without being a work instruction itself.
324
+ if (_CLASSIFY_LEAD_RE.test(text)) {
325
+ return {
326
+ matched: true,
327
+ reason: 'bounded-question (classification) — rung-0.5 single completion via ask_transport, no session',
328
+ };
329
+ }
330
+ if (_QUESTION_IMPERATIVE_RE.test(text)) {
331
+ return { matched: false, reason: 'imperative-work-verb' };
332
+ }
333
+ if (_QUESTION_LEAD_RE.test(text) && text.endsWith('?')) {
334
+ return {
335
+ matched: true,
336
+ reason: 'bounded-question — rung-0.5 single completion via ask_transport, no session',
337
+ };
338
+ }
339
+ return { matched: false, reason: 'no-interrogative-shape' };
340
+ }
341
+
282
342
  export function classifyLadder(inp: LadderInputs): LadderResult {
283
343
  if (inp.interactiveApprovalRequired === true) {
284
344
  return {
@@ -362,6 +422,18 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
362
422
  // regex and dispatched a subagent anyway, silently bypassing the floor
363
423
  // `classifyTask` had just enforced one line above.
364
424
  if (inp.signals.size_estimate <= SIZE_FLOOR) {
425
+ // Rung 0.5 (token-economy-dispatch 4.2) — a SELF-CONTAINED bounded
426
+ // question below the floor is the one below-spawn shape with its own
427
+ // routing verdict: one completion via ask_transport (no session, no
428
+ // tools, hard-capped). This is an ORCHESTRATOR-facing routing datum —
429
+ // the delegation-nudge carrier deliberately stays silent on it (a
430
+ // user's question is answered in-session at zero marginal cost; see
431
+ // classifyPrompt). Questions referencing files/paths are excluded by
432
+ // the detector: a completion without tools cannot read them.
433
+ const question = detectBoundedQuestion(inp.taskText);
434
+ if (question.matched) {
435
+ return { rung: 0.5, verdict: 'ask', reason: question.reason };
436
+ }
365
437
  return {
366
438
  rung: null,
367
439
  verdict: classification.action === 'ask' ? 'ask' : 'in-session',
@@ -380,3 +452,183 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
380
452
  reason: classification.reason,
381
453
  };
382
454
  }
455
+
456
+ // ── Explanation trail (additive — Phase 4.1, road-to-feedback-9-29) ──────
457
+ //
458
+ // `classifyLadder` short-circuits on the first rung that resolves, so its
459
+ // minimal `{rung, verdict, reason}` return cannot answer "why NOT team /
460
+ // why NOT council / why no spawn". This layer answers exactly that without
461
+ // touching the minimal shape current callers depend on: the result is taken
462
+ // from `classifyLadder` itself (never re-derived, so the two can never
463
+ // disagree on the headline), and the per-rung trail is reconstructed from
464
+ // the SAME exported pure detectors and gate fields the resolver reads, in
465
+ // the same fixed priority order.
466
+
467
+ export type RungStatus = 'taken' | 'rejected' | 'not-reached';
468
+
469
+ export interface RungEvaluation {
470
+ rung: 0 | 0.5 | 1 | 2 | 3 | 4;
471
+ /** The verdict this rung resolves to when taken. */
472
+ resolves_to: 'script' | 'ask' | 'subagent' | 'team' | 'council';
473
+ status: RungStatus;
474
+ reason: string;
475
+ }
476
+
477
+ export interface LadderExplanation {
478
+ /** The authoritative resolution — exactly `classifyLadder(inp)`. */
479
+ result: LadderResult;
480
+ /**
481
+ * Per-rung trail in the resolver's fixed priority order: 0, 4, 3, 2, 0.5, 1.
482
+ *
483
+ * 0.5 sits where the resolver checks it — inside the size-floor branch,
484
+ * after rung 2's verdict and before rung 1 — not where its number would
485
+ * sort. The order IS the contract, so the trail follows the walk.
486
+ */
487
+ trail: RungEvaluation[];
488
+ /** Present only when `result.rung` is null (∅) — the why-no-spawn reason. */
489
+ no_spawn_reason?: string;
490
+ }
491
+
492
+ /**
493
+ * Explain one ladder resolution: the rung taken, every rung genuinely
494
+ * evaluated-and-rejected (with the detector's own reason), and rungs the
495
+ * short-circuit never reached (marked `not-reached`, never given a
496
+ * fabricated rejection reason — a lower rung's detector might well have
497
+ * matched had it been consulted).
498
+ */
499
+ export function explainLadder(inp: LadderInputs): LadderExplanation {
500
+ const result = classifyLadder(inp);
501
+ const trail: RungEvaluation[] = [];
502
+ const approval = inp.interactiveApprovalRequired === true;
503
+
504
+ // Rung 0 — evaluated first, exempt from the guards below.
505
+ if (result.rung === 0) {
506
+ trail.push({ rung: 0, resolves_to: 'script', status: 'taken', reason: result.reason });
507
+ } else if (approval) {
508
+ trail.push({
509
+ rung: 0,
510
+ resolves_to: 'script',
511
+ status: 'not-reached',
512
+ reason: 'interactive-approval-required resolves before every rung',
513
+ });
514
+ } else {
515
+ const lookup = classifyLookup(inp.taskText);
516
+ const mechanical = detectMechanicalTransform(inp.taskText);
517
+ trail.push({ rung: 0, resolves_to: 'script', status: 'rejected', reason: `${lookup.reason}; ${mechanical.reason}` });
518
+ }
519
+
520
+ // A gate that resolved ∅ before rung 4 blocks rungs 4-1 wholesale; a
521
+ // resolution at rung 0 means they were never consulted either.
522
+ const gate: string | null = approval
523
+ ? 'interactive-approval-required — caller-stated, resolves ∅'
524
+ : result.rung === 0
525
+ ? 'resolved at rung 0'
526
+ : inp.insideSubagentSession === true
527
+ ? 'recursive-dispatch guard — running inside a subagent/teammate session'
528
+ : inp.activation.halted
529
+ ? 'emergency.orchestration_halt is set'
530
+ : !inp.activation.subagent_spawn
531
+ ? 'host has no subagent_spawn primitive'
532
+ : null;
533
+
534
+ const notReached = (why: string): string => `not evaluated — ${why}`;
535
+
536
+ // Rung 4 — council.
537
+ if (gate !== null) {
538
+ trail.push({ rung: 4, resolves_to: 'council', status: 'not-reached', reason: notReached(gate) });
539
+ } else if (result.rung === 4) {
540
+ trail.push({ rung: 4, resolves_to: 'council', status: 'taken', reason: result.reason });
541
+ } else {
542
+ trail.push({ rung: 4, resolves_to: 'council', status: 'rejected', reason: detectContestedJudgment(inp.taskText).reason });
543
+ }
544
+
545
+ // Rung 3 — team.
546
+ if (gate !== null) {
547
+ trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached(gate) });
548
+ } else if (result.rung === 4) {
549
+ trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached('resolved at rung 4') });
550
+ } else if (result.rung === 3) {
551
+ trail.push({ rung: 3, resolves_to: 'team', status: 'taken', reason: result.reason });
552
+ } else {
553
+ const communication = detectCommunicationNeed(inp.taskText);
554
+ const reason =
555
+ communication.matched && result.degraded_from === 3
556
+ ? `${communication.reason} — but host reports agent_teams: false, degraded (see resolved rung)`
557
+ : communication.reason;
558
+ trail.push({ rung: 3, resolves_to: 'team', status: 'rejected', reason });
559
+ }
560
+
561
+ // Rungs 2 and 1 — classifyTask's verdict, then the single-slice check.
562
+ const resolvedAbove = result.rung === 4 || result.rung === 3;
563
+ if (gate !== null || resolvedAbove) {
564
+ const why = gate ?? `resolved at rung ${result.rung}`;
565
+ trail.push({ rung: 2, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
566
+ trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached(why) });
567
+ trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
568
+ } else {
569
+ const classification = classifyTask(inp.signals, inp.activation);
570
+ if (result.rung === 2) {
571
+ trail.push({ rung: 2, resolves_to: 'subagent', status: 'taken', reason: result.reason });
572
+ trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached('resolved at rung 2') });
573
+ trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached('resolved at rung 2') });
574
+ } else {
575
+ trail.push({ rung: 2, resolves_to: 'subagent', status: 'rejected', reason: classification.reason });
576
+ if (result.rung === 1) {
577
+ trail.push({
578
+ rung: 0.5,
579
+ resolves_to: 'ask',
580
+ status: 'not-reached',
581
+ reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
582
+ });
583
+ trail.push({ rung: 1, resolves_to: 'subagent', status: 'taken', reason: result.reason });
584
+ } else if (result.degraded_from === 3) {
585
+ // The communication branch returns from INSIDE rung 3's handling
586
+ // (its `classifyTask` fallback is the rung-2 attempt), so it exits
587
+ // before `detectBoundedReadHeavySlice` is ever called. Reporting
588
+ // rung 1 as `rejected` here quoted that detector's reason — and
589
+ // when the detector MATCHES, that reason describes a match, so
590
+ // the trail read "rejected: one lite-tier subagent instead of
591
+ // session-context burn". A maintainer asking why nothing spawned
592
+ // was told rung 1 declined, hiding the missing agent_teams
593
+ // capability that actually ended the walk.
594
+ const skipped = notReached('the rung-3 communication branch resolved before it was consulted');
595
+ trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: skipped });
596
+ trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: skipped });
597
+ } else if (inp.signals.size_estimate <= SIZE_FLOOR) {
598
+ // Rung 0.5 lives INSIDE the resolver's floor branch, between rung
599
+ // 2 and rung 1, so it is reachable only here. Emitting it in that
600
+ // position keeps the trail's order the resolver's order; omitting
601
+ // it (as this trail did before the rung landed) would report a
602
+ // walk that skipped a rung the resolver actually consulted.
603
+ if (result.rung === 0.5) {
604
+ trail.push({ rung: 0.5, resolves_to: 'ask', status: 'taken', reason: result.reason });
605
+ } else {
606
+ trail.push({
607
+ rung: 0.5,
608
+ resolves_to: 'ask',
609
+ status: 'rejected',
610
+ reason: detectBoundedQuestion(inp.taskText).reason,
611
+ });
612
+ }
613
+ trail.push({
614
+ rung: 1,
615
+ resolves_to: 'subagent',
616
+ status: 'rejected',
617
+ reason: `task below size floor (${inp.signals.size_estimate} <= ${SIZE_FLOOR}) — rung 1 never bypasses the floor`,
618
+ });
619
+ } else {
620
+ // Above the floor the resolver never enters the branch rung 0.5
621
+ // lives in, so it was not consulted — not declined.
622
+ trail.push({
623
+ rung: 0.5,
624
+ resolves_to: 'ask',
625
+ status: 'not-reached',
626
+ reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
627
+ });
628
+ trail.push({ rung: 1, resolves_to: 'subagent', status: 'rejected', reason: detectBoundedReadHeavySlice(inp.taskText).reason });
629
+ }
630
+ }
631
+ }
632
+
633
+ return result.rung === null ? { result, trail, no_spawn_reason: result.reason } : { result, trail };
634
+ }
@@ -19,8 +19,10 @@
19
19
  import type { LookupClass } from './auto_dispatch.js';
20
20
 
21
21
  export type DispatchOutcome = 'DONE' | 'DONE_WITH_CONCERNS' | 'NEEDS_CONTEXT' | 'BLOCKED' | 'killed';
22
- /** Which route the lookup-class rung took (lean-init L0). */
23
- export type RouteTaken = 'primitive' | 'subagent';
22
+ /** Which route the rung took: deterministic primitive (lean-init L0),
23
+ * single-completion ask (token-economy-dispatch rung 0.5), or a full
24
+ * subagent spawn. */
25
+ export type RouteTaken = 'primitive' | 'subagent' | 'ask';
24
26
  export type VerifyMode = 'deterministic' | 'judge' | 'none';
25
27
  export type Provenance = 'measured' | 'estimated';
26
28
  export type TierChosen = 'lite' | 'medium' | 'high';
@@ -98,6 +100,23 @@ export interface RecordInput {
98
100
  /** Rules the worker actually applied/cited (count of the envelope's
99
101
  * `rules_applied`-equivalent on the worker side). Counts only. */
100
102
  rules_used?: number | null | undefined;
103
+ // ── dispatch-economy additive fields (road-to-token-economy-dispatch
104
+ // Phase 1.1). `init_tokens` above (lean-init) is the spawn-payload
105
+ // half; `work_tokens` is the delta from first worker turn to envelope
106
+ // close. Both are counts; the pair's provenance rides
107
+ // `floor_provenance` exactly like `token_delta_provenance` tags
108
+ // `token_delta`. ──
109
+ /** Tokens the worker consumed AFTER init (delta to envelope close). */
110
+ work_tokens?: number | null | undefined;
111
+ /** Chars the dispatch RETURNED into the orchestrator context (serialized
112
+ * tool-result length on sync completions) — the Phase 6.3 detector for
113
+ * "isolation win refunded through the return channel". A count, never
114
+ * content. */
115
+ return_channel_chars?: number | null | undefined;
116
+ /** Provenance of the init/work pair: 'measured' (transcript ledger via
117
+ * cc_transcript) or 'estimated'. Defaults to 'estimated' when either
118
+ * field is present without a tag. */
119
+ floor_provenance?: Provenance | null | undefined;
101
120
  // ── capsule shadow-measurement fields (road-to-worker-generation-recycling
102
121
  // Phase 1.2). SHADOW ONLY: the worker still runs to stop-loss, nothing
103
122
  // reads a capsule. All counts / enums — a capsule's CONTENT never
@@ -138,7 +157,7 @@ const TIER_SOURCES: readonly TierSource[] = ['static', 'inferred', 'inherit'];
138
157
  const BANDS: readonly Band[] = ['low', 'medium', 'high'];
139
158
  const PHASES: readonly LinePhase[] = ['refine', 'memory', 'analyze', 'plan', 'implement', 'test', 'verify', 'report'];
140
159
  const LOOKUP_CLASSES: readonly LookupClass[] = ['definition', 'references', 'string-existence', 'report-run'];
141
- const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent'];
160
+ const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent', 'ask'];
142
161
  const TRIGGER_ARMS: readonly TriggerArm[] = ['watermark', 'saturation', 'tie'];
143
162
  /** Hex-only payload hash — a hash can never smuggle content (privacy by construction). */
144
163
  const PAYLOAD_HASH_RE = /^[a-f0-9]{8,64}$/i;
@@ -164,13 +183,15 @@ function isInt(n: unknown): n is number {
164
183
  export function buildOrchestrationLine(input: RecordInput): BuiltLine {
165
184
  const errors: string[] = [];
166
185
 
167
- // A lookup-class primitive route is the ONE recordable zero-spawn event
168
- // (lean-init L0): the routing decision itself is the datum the
169
- // cost-reduction claim reads. Everything else with spawn_count 0 stays
170
- // unrecordable in-session work.
171
- const zeroSpawnPrimitive = input.route_taken === 'primitive' && input.spawn_count === 0;
172
- if (!zeroSpawnPrimitive && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
173
- errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive lookup routes record with spawn_count 0)');
186
+ // A lookup-class primitive route (lean-init L0) and a rung-0.5 ask route
187
+ // (token-economy-dispatch Phase 4) are the TWO recordable zero-spawn
188
+ // events: the routing decision itself is the datum the cost-reduction
189
+ // claim reads. Everything else with spawn_count 0 stays unrecordable
190
+ // in-session work.
191
+ const zeroSpawnRoute =
192
+ (input.route_taken === 'primitive' || input.route_taken === 'ask') && input.spawn_count === 0;
193
+ if (!zeroSpawnRoute && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
194
+ errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive|ask routes record with spawn_count 0)');
174
195
  }
175
196
  if (!isInt(input.token_delta)) errors.push('token_delta must be an integer (negative = net saved vs the in-session baseline)');
176
197
  if (input.task_size_estimate !== undefined && (!isInt(input.task_size_estimate) || input.task_size_estimate < 0)) {
@@ -239,9 +260,14 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
239
260
  for (const [key, v] of [
240
261
  ['rules_carried', input.rules_carried],
241
262
  ['rules_used', input.rules_used],
263
+ ['work_tokens', input.work_tokens],
264
+ ['return_channel_chars', input.return_channel_chars],
242
265
  ] as const) {
243
266
  if (v != null && (!isInt(v) || v < 0)) errors.push(`${key} must be a non-negative integer count`);
244
267
  }
268
+ if (input.floor_provenance != null && !PROVENANCES.includes(input.floor_provenance)) {
269
+ errors.push(`floor_provenance must be one of ${PROVENANCES.join(' | ')} (or omitted)`);
270
+ }
245
271
  if (
246
272
  input.rules_carried != null &&
247
273
  input.rules_used != null &&
@@ -298,6 +324,11 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
298
324
  origin: input.origin ?? null,
299
325
  rules_carried: input.rules_carried ?? null,
300
326
  rules_used: input.rules_used ?? null,
327
+ // dispatch-economy additive fields — readers ignore unknowns per audit-log-v1
328
+ work_tokens: input.work_tokens ?? null,
329
+ return_channel_chars: input.return_channel_chars ?? null,
330
+ floor_provenance:
331
+ input.floor_provenance ?? (input.init_tokens != null || input.work_tokens != null ? 'estimated' : null),
301
332
  // capsule shadow-measurement — readers ignore unknowns per audit-log-v1
302
333
  capsule_emitted: input.capsule_emitted ?? null,
303
334
  capsule_entries: input.capsule_entries ?? null,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Recycle-envelope filesystem contract (road-to-token-economy-recycling
3
+ * Phase 2.2/2.3) — the ONE place the producer (`session:recycle`) and the
4
+ * consumer (`handoff_context_hook`) agree on where the envelope lives and
5
+ * when it is stale. A second copy of any of these values is the drift seam
6
+ * Risk 6 warns about.
7
+ */
8
+ import * as path from 'node:path';
9
+
10
+ /** Where `session:recycle` writes the pending envelope (gitignored runtime state). */
11
+ export const RECYCLE_ENVELOPE_REL = path.join('agents', 'runtime', 'state', 'recycle-envelope.json');
12
+
13
+ /**
14
+ * Where the consumer MOVES the envelope on consumption — moved, not copied,
15
+ * so a consumed or rejected envelope can never leak into a later session,
16
+ * while the last one stays inspectable for debugging.
17
+ */
18
+ export const RECYCLE_CONSUMED_REL = path.join(
19
+ 'agents',
20
+ 'runtime',
21
+ 'state',
22
+ 'recycle-envelope.consumed.json',
23
+ );
24
+
25
+ /** Staleness guard — mirrors the handoff file's 48 h discipline. */
26
+ export const RECYCLE_MAX_AGE_HOURS = 48;
27
+
28
+ /**
29
+ * Producer-side size cap on the serialized envelope. The schema's per-field
30
+ * caps make a transcript-shaped envelope invalid; this cap keeps even a
31
+ * legal envelope inside the `handoff-context` injection budget
32
+ * (`src/config/hook-token-budget.json`: 8192 B for the concern) with room
33
+ * for the wrapper block and a co-pending handoff.
34
+ */
35
+ export const RECYCLE_ENVELOPE_MAX_BYTES = 6144;