@event4u/agent-config 9.29.0 → 9.30.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +108 -7
  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-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
  18. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
  19. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
  20. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
  21. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  22. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  23. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  24. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  25. package/dist/cli-delegate/cmd_conformance.js +2798 -95
  26. package/dist/cli-delegate/cmd_doctor.js +2 -1
  27. package/dist/cli-delegate/cmd_export.js +2 -2
  28. package/dist/cli-delegate/cmd_handoff.js +86 -6
  29. package/dist/cli-delegate/cmd_prune.js +2 -2
  30. package/dist/cli-delegate/cmd_route_audit.js +2 -1
  31. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  32. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  33. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +7 -7
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +2 -2
  44. package/dist/discovery/trust-report.md +1 -1
  45. package/dist/discovery/workspaces.json +2 -2
  46. package/dist/hooks/dispatch.js +1904 -575
  47. package/dist/install/install.mjs +14 -0
  48. package/dist/mcp/registry-manifest.json +2 -2
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +3760 -726
  51. package/dist/server/schemas/settings.js +6 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  54. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  55. package/dist/ui/index.html +1 -1
  56. package/docs/contracts/settings-classes.md +21 -5
  57. package/package.json +1 -1
  58. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  59. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  60. package/src/config/agent-settings.template.yml +36 -0
  61. package/src/config/dispatch-economy-metrics.json +61 -0
  62. package/src/config/evaluator-budgets.json +4 -4
  63. package/src/config/hook-token-budget.json +91 -0
  64. package/src/config/recycle-threshold-budget.json +12 -0
  65. package/src/config/rule-enforcement-baseline.json +86 -0
  66. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  67. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  68. package/src/scripts/_dispatch.bash +23 -0
  69. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  70. package/src/scripts/_lib/orchestration_record.ts +41 -10
  71. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  72. package/src/scripts/_lib/self_repair.ts +194 -0
  73. package/src/scripts/_lib/session_eol.ts +230 -0
  74. package/src/scripts/_lib/session_role.ts +42 -0
  75. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  76. package/src/scripts/_lib/subagent_response.ts +75 -0
  77. package/src/scripts/ai_council/clients.ts +50 -1
  78. package/src/scripts/ask_transport.ts +221 -0
  79. package/src/scripts/bench_hook_injection.ts +234 -0
  80. package/src/scripts/check_adr_frontmatter.ts +84 -40
  81. package/src/scripts/check_branch_freshness.ts +1 -0
  82. package/src/scripts/check_corpus_staleness.ts +18 -4
  83. package/src/scripts/check_detector_corpus.ts +17 -0
  84. package/src/scripts/check_gitignore_freshness.ts +42 -8
  85. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  86. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  87. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  88. package/src/scripts/check_static_layer_stability.ts +154 -0
  89. package/src/scripts/check_suppression_hygiene.ts +48 -0
  90. package/src/scripts/dispatch_economy_report.ts +448 -0
  91. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  92. package/src/scripts/explain_run.ts +206 -4
  93. package/src/scripts/generate_settings_reference.ts +1 -0
  94. package/src/scripts/handoff_context_hook.ts +112 -4
  95. package/src/scripts/hook_manifest.yaml +117 -10
  96. package/src/scripts/hooks/concern_registry.ts +8 -0
  97. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  98. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  99. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  100. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  101. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  102. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  103. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  105. package/src/scripts/install-hooks.sh +1 -1
  106. package/src/scripts/language_mirror_hook.ts +89 -0
  107. package/src/scripts/lint_decision_impact.ts +14 -1
  108. package/src/scripts/lint_hook_manifest.ts +82 -0
  109. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  110. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  111. package/src/scripts/measure_turn_end_gate.ts +194 -0
  112. package/src/scripts/orchestration_record.ts +4 -1
  113. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  114. package/src/scripts/report_conformance_funnel.ts +284 -0
  115. package/src/scripts/report_imperative_density.ts +1 -0
  116. package/src/scripts/routing_doctor.ts +97 -0
  117. package/src/scripts/self_repair_cli.ts +51 -3
  118. package/src/scripts/session_eol_report.ts +261 -0
  119. package/src/server/schemas/settings.ts +14 -0
  120. package/src/templates/compact-instructions.md +40 -0
  121. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  122. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -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;
@@ -28,6 +28,7 @@
28
28
  import { createHash } from 'node:crypto';
29
29
 
30
30
  import { redact_low_impact_entry } from '../ai_council/redact_low_impact_entry.js';
31
+ import { is_kernel_rule } from './kernel_rules.js';
31
32
 
32
33
  /** Where a record came from. */
33
34
  export type DefectSource = 'user-reported' | 'self-detected';
@@ -128,6 +129,199 @@ export function egressBlockedReason(record: DefectRecord, repoRoot?: string | nu
128
129
  return `privacy floor refused the evidence (${kinds}) — record stays local`;
129
130
  }
130
131
 
132
+ // ── may-not-modify deny-list ───────────────────────────────────────
133
+
134
+ /**
135
+ * Surfaces self-repair may never target with a PATCH. Detection and reporting
136
+ * stay untouched — for a record targeting one of these, egress degrades to
137
+ * report-only: an issue (or the local record) is still allowed, the
138
+ * pull-request path refuses with the named reason.
139
+ *
140
+ * The point is structural: the loop that fixes the agent's own defects must
141
+ * not be a channel through which the agent loosens the floors it is bound by.
142
+ * Same posture as `block_kernel_rule_writes` (tool-call-time) and the settings
143
+ * class-C fence (write-time), applied to the one outward vehicle this loop
144
+ * owns. Nothing auto-applies a patch today; this keeps that true for the
145
+ * denied surfaces even when the PR path is otherwise available.
146
+ */
147
+ export type DeniedSurface =
148
+ | 'kernel-rule'
149
+ | 'safety-floor-rule'
150
+ | 'settings-class-c'
151
+ | 'ci-enforcement'
152
+ | 'self-repair-policy';
153
+
154
+ export interface PatchDenial {
155
+ surface: DeniedSurface;
156
+ /** The token in the record that matched. */
157
+ match: string;
158
+ }
159
+
160
+ /**
161
+ * Characters that decorate a token but never appear inside a real path, rule
162
+ * id, or settings key: markdown emphasis / strikethrough, autolink angle
163
+ * brackets, table pipes. They are removed ANYWHERE in the token, not just at
164
+ * its edges, so `src/rules/**scope-control**.md` reduces like `**…**` does.
165
+ */
166
+ const DECORATION_RE = /[*~<>|]/g;
167
+
168
+ /**
169
+ * Edge punctuation a sentence leaves on a token — sentence-final `.`, a `:`
170
+ * label separator, list/reference brackets, and underscore emphasis (`_` is
171
+ * stripped at the EDGES only: it is load-bearing inside `self_repair.ts`,
172
+ * `hook_manifest.yaml`, and every snake_case settings key).
173
+ */
174
+ const EDGE_TRIM_RE = /^[.:[\]_]+|[.:[\]_]+$/g;
175
+
176
+ /**
177
+ * Word-ish / path-ish tokens of a record's text, punctuation-stripped.
178
+ *
179
+ * `suggested_surface` is free-text the agent writes, and this repo's prose is
180
+ * saturated with `**bold**` file paths — so a tokenizer that only splits on
181
+ * whitespace-and-quotes lets ordinary markdown emphasis smuggle a denied
182
+ * surface past the scan (`**src/rules/scope-control.md**` read as ALLOWED
183
+ * while the same path bare read as DENIED). Both the raw token and its
184
+ * un-decorated form are emitted, and the edge trim runs AFTER the decoration
185
+ * strip so an unwrapped `**.github/workflows/ci.yml**` cannot keep a leading
186
+ * dot in front of the anchored `github/workflows/` match.
187
+ */
188
+ function denyTokens(text: string): string[] {
189
+ const out: string[] = [];
190
+ for (const raw of text.split(/[\s,;()!?"'`]+/)) {
191
+ if (raw.length === 0) {
192
+ continue;
193
+ }
194
+ for (const candidate of new Set([raw, raw.replace(DECORATION_RE, '')])) {
195
+ const token = candidate.replace(EDGE_TRIM_RE, '');
196
+ if (token.length > 0) {
197
+ out.push(token);
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /** Basename stem (no `.md`) of a path-or-id token. */
205
+ function stemOf(token: string): string {
206
+ const base = token.replace(/\\/g, '/').split('/').pop() ?? token;
207
+ return base.replace(/\.md$/, '');
208
+ }
209
+
210
+ /** Dotted settings-key shape — `personal.autonomy`, `tokens.rich_skills`, … */
211
+ const DOTTED_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/;
212
+
213
+ /**
214
+ * The surfaces through which enforcement is actually armed or disarmed. A
215
+ * workflow file is only ONE of them, and in this repo it is not even the usual
216
+ * one — a new gate is registered in the taskfiles the workflow calls, and a
217
+ * tool-call-time guard is armed in the hook manifest. Each entry, and why it
218
+ * arms enforcement:
219
+ *
220
+ * - `.github/workflows/` — the CI job definitions; deleting a step removes
221
+ * the gate from every PR. (The leading dot is stripped by `denyTokens`, so
222
+ * the match is on the segment pair, not the dotfile spelling.)
223
+ * - `Taskfile.yml` / `taskfiles/*.yml` — the task graph the workflows invoke
224
+ * (`task ci`, `task ci-fast`). A gate that is not listed here does not run,
225
+ * however green the workflow looks.
226
+ * - `src/scripts/hook_manifest.yaml` — the concern↔slot bindings. Unbinding
227
+ * a concern disarms a PreToolUse guard without touching the guard's code.
228
+ * - `src/scripts/hooks/` — the guard implementations themselves
229
+ * (`block_no_verify`, `block_kernel_rule_writes`, `block_config_weakening`).
230
+ * - `src/scripts/check_*.ts` / `src/scripts/lint_*.ts` — the gate scripts. A
231
+ * loosened threshold or a narrowed scan root disarms the gate in place,
232
+ * which is exactly the "gates that scan nothing exit green" failure.
233
+ *
234
+ * Deliberately NOT the whole of `src/scripts/`: that would deny nearly every
235
+ * legitimate repair this loop exists to file.
236
+ */
237
+ function isCiEnforcementPath(token: string): boolean {
238
+ const t = token.replace(/\\/g, '/');
239
+ if (/(^|\/)github\/workflows\//.test(t)) {
240
+ return true;
241
+ }
242
+ if (/(^|\/)Taskfile(\.[a-z0-9_-]+)?\.ya?ml$/i.test(t)) {
243
+ return true;
244
+ }
245
+ if (/(^|\/)taskfiles\//.test(t)) {
246
+ return true;
247
+ }
248
+ if (/(^|\/)hook_manifest\.ya?ml$/.test(t)) {
249
+ return true;
250
+ }
251
+ if (/(^|\/)scripts\/hooks\//.test(t)) {
252
+ return true;
253
+ }
254
+ return /(^|\/)scripts\/(check|lint)_[a-z0-9_]+\.ts$/.test(t);
255
+ }
256
+
257
+ /**
258
+ * Is this record's fix aimed at a denied surface? Scans the two fields that
259
+ * name what the fix should change (`suggested_surface`) and what was observed
260
+ * (`evidence`). A match errs toward denial on purpose — the cost of a false
261
+ * positive is a report instead of a patch, still egress; the cost of a false
262
+ * negative is a patch channel into a safety floor.
263
+ *
264
+ * The kernel set resolves from `kernel_rules.ts` — the same canonical source
265
+ * `block_kernel_rule_writes` uses — never a copy that can drift. Settings
266
+ * class-C keys are matched against `classCKeys` when the caller can supply
267
+ * them (parsed from `docs/contracts/settings-classes.md`); the contract file
268
+ * itself is denied by path regardless, so an unreadable contract narrows the
269
+ * dynamic check without opening the fence's own definition to patching.
270
+ */
271
+ export function patchDeniedSurface(
272
+ record: DefectFinding,
273
+ opts?: { classCKeys?: ReadonlySet<string> | null },
274
+ ): PatchDenial | null {
275
+ const classCKeys = opts?.classCKeys ?? null;
276
+ for (const token of denyTokens(`${record.suggested_surface}\n${record.evidence}`)) {
277
+ // Checked before `ci-enforcement` so a self-repair file that also lives
278
+ // under an enforcement path (`src/scripts/hooks/self_repair_hook.ts`)
279
+ // reports the more specific surface. Either way it is denied.
280
+ if (/self[-_]repair/i.test(token)) {
281
+ return { surface: 'self-repair-policy', match: token };
282
+ }
283
+ if (isCiEnforcementPath(token)) {
284
+ return { surface: 'ci-enforcement', match: token };
285
+ }
286
+ if (token.includes('settings-classes.md')) {
287
+ return { surface: 'settings-class-c', match: token };
288
+ }
289
+ if (is_kernel_rule(token)) {
290
+ return { surface: 'kernel-rule', match: token };
291
+ }
292
+ if (stemOf(token).endsWith('-safety-floor')) {
293
+ return { surface: 'safety-floor-rule', match: token };
294
+ }
295
+ if (classCKeys !== null && DOTTED_KEY_RE.test(token)) {
296
+ // Exact key first, then every classified ancestor down to the root
297
+ // segment — a class-C key whose value is a MAP has children that
298
+ // never appear under the key's own name.
299
+ const parts = token.split('.');
300
+ for (let end = parts.length; end > 0; end--) {
301
+ if (classCKeys.has(parts.slice(0, end).join('.'))) {
302
+ return { surface: 'settings-class-c', match: token };
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** The named refusal the patch/PR path reports. Null = patch path allowed. */
311
+ export function patchDeniedReason(
312
+ record: DefectFinding,
313
+ opts?: { classCKeys?: ReadonlySet<string> | null },
314
+ ): string | null {
315
+ const denial = patchDeniedSurface(record, opts);
316
+ if (denial === null) {
317
+ return null;
318
+ }
319
+ return (
320
+ `self-repair may not patch a ${denial.surface} surface (matched: ${denial.match}) — ` +
321
+ 'the pull-request path is refused; the record egresses report-only (issue / local record)'
322
+ );
323
+ }
324
+
131
325
  // ── detectors ──────────────────────────────────────────────────────
132
326
 
133
327
  // An agent-directed complaint, not a complaint about the code. Deliberately