@event4u/agent-config 14.8.0 → 14.9.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 (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +165 -237
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/pr/merge.md +9 -5
  6. package/dist/agent-src/commands/review/changes.md +95 -14
  7. package/dist/agent-src/commands/roadmap/process-full.md +2 -2
  8. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
  10. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  11. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  12. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  13. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  14. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  15. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  16. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  17. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  18. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/agent-src/templates/roadmaps.md +8 -0
  21. package/dist/cli/registry.js +2 -0
  22. package/dist/cli/registry.js.map +1 -1
  23. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  24. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  25. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  26. package/dist/cli-delegate/cmd_conformance.js +89 -13
  27. package/dist/cli-delegate/cmd_doctor.js +3 -3
  28. package/dist/cli-delegate/cmd_handoff.js +2 -2
  29. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  30. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  31. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  32. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +37 -15
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +3 -3
  37. package/dist/discovery/dormancy-report.md +1 -1
  38. package/dist/discovery/orphan-report.md +1 -1
  39. package/dist/discovery/packs.json +6 -5
  40. package/dist/discovery/trust-report.md +2 -2
  41. package/dist/discovery/workspaces.json +5 -4
  42. package/dist/hooks/dispatch.js +562 -204
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/dist/mcp/server.json +2 -2
  45. package/dist/mcp/server.mjs +808 -331
  46. package/dist/scripts/_lib/claude_plugin.js +162 -0
  47. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  48. package/dist/scripts/_lib/environment_detector.js +397 -0
  49. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  50. package/dist/scripts/_lib/spawn_env.js +129 -0
  51. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  52. package/dist/scripts/ai_council/modes.js +199 -0
  53. package/dist/scripts/ai_council/modes.js.map +1 -0
  54. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  55. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  56. package/dist/server/routes/wizard.js +3 -2
  57. package/dist/server/routes/wizard.js.map +1 -1
  58. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  59. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  62. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  63. package/package.json +7 -3
  64. package/src/config/estate-count-budget.json +13 -744
  65. package/src/config/evaluator-budgets.json +4 -4
  66. package/src/config/gate-coverage.yml +247 -9
  67. package/src/config/gate-violation-baselines.json +72 -72
  68. package/src/config/publish-surface.json +60 -0
  69. package/src/config/requirements-trace-budget.json +32 -0
  70. package/src/config/rule-stub-ceilings.json +8 -1
  71. package/src/scripts/_dispatch.bash +7 -0
  72. package/src/scripts/_lib/base_tree.ts +175 -0
  73. package/src/scripts/_lib/billing_grant.ts +165 -0
  74. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  75. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  76. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  77. package/src/scripts/_lib/host_capability.ts +59 -5
  78. package/src/scripts/_lib/quota_parked.ts +133 -0
  79. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  80. package/src/scripts/_lib/review_telemetry.ts +196 -0
  81. package/src/scripts/_lib/subagent_response.ts +92 -0
  82. package/src/scripts/adr/regenerate_index.ts +14 -7
  83. package/src/scripts/ai_council/clients.ts +12 -1
  84. package/src/scripts/ai_council/config.ts +72 -115
  85. package/src/scripts/ai_council/fallback_config.ts +19 -7
  86. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  87. package/src/scripts/ai_council/orchestrator.ts +2 -2
  88. package/src/scripts/ai_council/py_format.ts +124 -0
  89. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  90. package/src/scripts/audit_adr_coverage.ts +33 -6
  91. package/src/scripts/check_council_pin_staleness.ts +300 -0
  92. package/src/scripts/check_estate_count.ts +420 -293
  93. package/src/scripts/check_gate_coverage.ts +91 -0
  94. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  95. package/src/scripts/check_pack_size.ts +234 -1
  96. package/src/scripts/check_publish_surface.ts +297 -0
  97. package/src/scripts/check_requirements_trace.ts +378 -0
  98. package/src/scripts/check_secret_leak.ts +166 -12
  99. package/src/scripts/code_graph/cli.ts +44 -5
  100. package/src/scripts/code_graph/extract.ts +87 -0
  101. package/src/scripts/council_cli.ts +12 -16
  102. package/src/scripts/detect_ai_tells.ts +87 -0
  103. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  104. package/src/scripts/generate_capabilities_index.ts +41 -0
  105. package/src/scripts/git_authorization_hook.ts +54 -4
  106. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  107. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  108. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  109. package/src/scripts/injection_scan_hook.ts +120 -10
  110. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  111. package/src/scripts/lint_workflow_security.ts +53 -10
  112. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  113. package/src/scripts/report_envelope_rate.ts +166 -0
  114. package/src/scripts/review_axis_report.ts +218 -0
  115. package/src/scripts/run_supervise.ts +46 -1
  116. package/src/scripts/schemas/evals.schema.json +5 -0
  117. package/src/scripts/sync_pr_branch.ts +14 -7
  118. package/dist/cli/commands/commands.candidates.test.js +0 -257
  119. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  120. package/dist/cli/commands/profiles.test.js +0 -88
  121. package/dist/cli/commands/profiles.test.js.map +0 -1
  122. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  123. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  124. package/dist/cli/configRoot.test.js +0 -97
  125. package/dist/cli/configRoot.test.js.map +0 -1
  126. package/dist/cli/mcp/content.test.js +0 -54
  127. package/dist/cli/mcp/content.test.js.map +0 -1
  128. package/dist/cli/mcp/dispatch.test.js +0 -108
  129. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  130. package/dist/server/writeRoot.test.js +0 -91
  131. package/dist/server/writeRoot.test.js.map +0 -1
  132. package/dist/shared/capabilities.test.js +0 -39
  133. package/dist/shared/capabilities.test.js.map +0 -1
  134. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  135. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  136. /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
@@ -77,8 +77,47 @@ function flag(argv: string[], name: string): string | null {
77
77
  return i >= 0 && i + 1 < argv.length ? (argv[i + 1] as string) : null;
78
78
  }
79
79
 
80
+ /**
81
+ * Resolve the repository to operate on. `--root` > `AGENT_CONFIG_PROJECT_ROOT` >
82
+ * this module's own tree.
83
+ *
84
+ * The middle rung is the whole point, and its absence was a confused deputy:
85
+ * `_dispatch.bash` parses `--root` GLOBALLY, strips it from argv, and exports
86
+ * `AGENT_CONFIG_PROJECT_ROOT` (`:1655`) — then this engine read neither and fell
87
+ * back to its own module path. So `agent-config code-graph build --root <other
88
+ * repo>` silently indexed THIS package. The dispatcher made the decision and the
89
+ * engine substituted its own, which is why every consumer-facing invocation was
90
+ * wrong about which repository it had just read.
91
+ *
92
+ * `realpathSync` rather than `resolve` alone: a symlinked root must normalise to
93
+ * what will actually be walked, or the path a caller sees in the output is not
94
+ * the path that was indexed. Fail-loud on a root that does not exist — a silent
95
+ * fallback to the package tree is the defect this function exists to remove, and
96
+ * reintroducing it for a typo'd path would be the same bug wearing a smaller
97
+ * hat.
98
+ *
99
+ * Deliberately NOT touching `DEFAULT_CACHE`, which is anchored to `REPO_ROOT`
100
+ * for the same reason and is a separate decision: it is a WRITE path, with its
101
+ * own ownership, cleanup, concurrency and multi-repo namespacing questions.
102
+ * Council 2026-08-22, both seats, explicitly out of scope here.
103
+ */
104
+ function resolveRoot(argv: string[]): string {
105
+ const explicit = flag(argv, '--root');
106
+ const fromDispatcher = process.env['AGENT_CONFIG_PROJECT_ROOT'];
107
+ const candidate =
108
+ explicit ?? (fromDispatcher !== undefined && fromDispatcher !== '' ? fromDispatcher : REPO_ROOT);
109
+ try {
110
+ return fs.realpathSync(path.resolve(candidate));
111
+ } catch {
112
+ // Returned unresolved on purpose: every caller checks existence and
113
+ // prints the path it was given, which is more useful in the error than
114
+ // a normalised form of a path that does not exist.
115
+ return path.resolve(candidate);
116
+ }
117
+ }
118
+
80
119
  async function cmdBuild(argv: string[]): Promise<number> {
81
- const root = flag(argv, '--root') ?? REPO_ROOT;
120
+ const root = resolveRoot(argv);
82
121
  const out = argv.includes('--out') ? flag(argv, '--out') : DEFAULT_CACHE;
83
122
  if (!fs.existsSync(root)) {
84
123
  process.stderr.write(`code-graph: root not found: ${root}\n`);
@@ -122,7 +161,7 @@ function cmdValidate(argv: string[]): number {
122
161
 
123
162
  function resolveGraph(argv: string[]): { g: LoadedGraph; note: string } | { err: string } {
124
163
  const explicit = flag(argv, '--graph');
125
- const root = flag(argv, '--root') ?? REPO_ROOT;
164
+ const root = resolveRoot(argv);
126
165
  if (explicit) {
127
166
  if (!fs.existsSync(explicit)) return { err: `graph not found: ${explicit}` };
128
167
  return { g: loadGraph(explicit, path.relative(REPO_ROOT, explicit)), note: '' };
@@ -179,7 +218,7 @@ function cmdQuery(kind: 'query' | 'explain' | 'affected' | 'path', argv: string[
179
218
  const positional = argv.filter((a) => !a.startsWith('--') && !isFlagValue(argv, a));
180
219
  const budget = budgetOf(argv);
181
220
  if (kind === 'affected' && flag(argv, '--since')) {
182
- const root = flag(argv, '--root') ?? REPO_ROOT;
221
+ const root = resolveRoot(argv);
183
222
  const seeds = changedNodeSeeds(r.g, root, flag(argv, '--since') as string);
184
223
  const merged: QueryResult = { source: r.g.source, seeds: [], lines: [], truncated: false, recommended_reads: [] };
185
224
  const perSeed: QueryResult[] = [];
@@ -216,7 +255,7 @@ function isFlagValue(argv: string[], token: string): boolean {
216
255
  }
217
256
 
218
257
  function cmdDetect(argv: string[]): number {
219
- const root = flag(argv, '--root') ?? REPO_ROOT;
258
+ const root = resolveRoot(argv);
220
259
  if (flag(argv, '--format') === 'json') {
221
260
  // Machine-readable three-state verdict (Phase 2). Existing human-text
222
261
  // output below is unchanged for existing consumers.
@@ -316,7 +355,7 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
316
355
  }
317
356
 
318
357
  function cmdRefresh(argv: string[]): number {
319
- const root = flag(argv, '--root') ?? REPO_ROOT;
358
+ const root = resolveRoot(argv);
320
359
  const out = flag(argv, '--out') ?? DEFAULT_CACHE;
321
360
  const budgetSeconds = budgetSecondsOf(argv);
322
361
  if (!fs.existsSync(root)) {
@@ -306,6 +306,93 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
306
306
  if (body) for (const c of namedChildren(body)) walk(c, classId, id);
307
307
  return;
308
308
  }
309
+ // Modern TS declares most of its functions as bindings, not as
310
+ // `function` statements. Before this case the extractor produced
311
+ // NO node for `export const f = () => {}` or
312
+ // `const g = function () {}`, which is the measured cause of the
313
+ // 170-vs-13,428 TS/PHP symbol gap: the six handled kinds simply do
314
+ // not cover the dominant declaration form.
315
+ //
316
+ // Scoped deliberately to bindings whose value IS a function. A
317
+ // `const x = 3` is data, not a symbol this graph answers questions
318
+ // about, and emitting a node per constant would inflate the count
319
+ // without improving recall — which is the cosmetic-improvement
320
+ // failure the phase falsifier exists to catch.
321
+ case 'lexical_declaration':
322
+ case 'variable_declaration': {
323
+ for (const d of namedChildren(n)) {
324
+ if (d.type !== 'variable_declarator') continue;
325
+ const value = d.childForFieldName('value');
326
+ if (!value || (value.type !== 'arrow_function' && value.type !== 'function_expression')) {
327
+ continue;
328
+ }
329
+ const nm = nameField(d) ?? '<anon>';
330
+ const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
331
+ out.nodes.push({
332
+ id,
333
+ label: nm,
334
+ kind: classId ? 'method' : 'function',
335
+ source_file: file,
336
+ source_location: loc(d),
337
+ });
338
+ out.rawEdges.push({
339
+ sourceId: classId ?? fileId,
340
+ relation: 'member',
341
+ targetName: nm,
342
+ confidenceHint: 'EXTRACTED',
343
+ });
344
+ // Walk the body under the BINDING's scope, so a call inside
345
+ // an arrow function attributes to that function rather than
346
+ // to the enclosing file — the same treatment
347
+ // `function_declaration` gets.
348
+ //
349
+ // `walk(body)` and not `namedChildren(body)`: an
350
+ // expression-bodied arrow (`() => helper()`) has the CALL
351
+ // itself as its body, so descending one level first steps
352
+ // straight past it and the edge is lost. The sibling cases
353
+ // can iterate children because a `statement_block` is never
354
+ // the interesting node; here it often is.
355
+ const body = value.childForFieldName('body');
356
+ if (body) walk(body, classId, id);
357
+ }
358
+ // Non-function declarators still need walking for their calls.
359
+ for (const d of namedChildren(n)) {
360
+ if (d.type !== 'variable_declarator') continue;
361
+ const value = d.childForFieldName('value');
362
+ if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
363
+ continue;
364
+ }
365
+ walk(d, classId, scopeId);
366
+ }
367
+ return;
368
+ }
369
+ // `class C { m = () => 1 }` — a class property holding a function.
370
+ // Emitted as a `method`, the same kind `method_definition` emits,
371
+ // because it is one to every caller.
372
+ case 'public_field_definition': {
373
+ const value = n.childForFieldName('value');
374
+ const nm = nameField(n) ?? '<anon>';
375
+ if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
376
+ const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
377
+ out.nodes.push({
378
+ id,
379
+ label: nm,
380
+ kind: 'method',
381
+ source_file: file,
382
+ source_location: loc(n),
383
+ });
384
+ out.rawEdges.push({
385
+ sourceId: classId ?? fileId,
386
+ relation: 'member',
387
+ targetName: nm,
388
+ confidenceHint: 'EXTRACTED',
389
+ });
390
+ const body = value.childForFieldName('body');
391
+ if (body) walk(body, classId, id);
392
+ return;
393
+ }
394
+ break;
395
+ }
309
396
  case 'new_expression': {
310
397
  const ctor = n.childForFieldName('constructor');
311
398
  if (ctor && (ctor.type === 'identifier' || ctor.type === 'type_identifier' || ctor.type === 'member_expression'))
@@ -106,7 +106,8 @@ import {
106
106
  renderPostureLines,
107
107
  type FallbackPosture,
108
108
  } from './_lib/council_fallback_posture.js';
109
- import { buildFallbackOptions } from './_lib/council_fallback_wiring.js';
109
+ import { wireCouncilFallback } from './_lib/council_fallback_wiring.js';
110
+ import { BILLING_SUBCOMMANDS, billingArgSpec, handleBillingCommand, printBillingGate } from './_lib/billing_grant_cli.js';
110
111
  import {
111
112
  type ClassificationResult,
112
113
  type SizeFitVerdict,
@@ -880,19 +881,11 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
880
881
  'prints the resolved path.',
881
882
  );
882
883
  }
883
- if (fallback_out !== null) {
884
- // Absent/malformed block means `false`, never a throw. Why quota
885
- // fall-through is opt-in: `FallbackPolicy.apiOnQuota`.
886
- const fallback_cfg = _isDict(ai['fallback']) ? (ai['fallback'] as Dict) : {};
887
- fallback_out.options = buildFallbackOptions({
888
- apiOnQuota: _pyBool(fallback_cfg['api_on_quota']),
889
- hasApiRung: (p) => _API_PROVIDERS.has(p),
890
- memberConfig: (p) => ((members_cfg[p] as Dict) || {}) as Dict,
891
- modelOverride: (p) => (overrides[p] as string | undefined) ?? null,
892
- constructApi: _construct_api_member,
893
- emit: appendEvent,
894
- });
895
- }
884
+ wireCouncilFallback(fallback_out, ai, {
885
+ repoRoot: REPO_ROOT, isDict: _isDict, membersCfg: members_cfg, overrides,
886
+ hasApiRung: (p) => _API_PROVIDERS.has(p),
887
+ constructApi: _construct_api_member, emit: appendEvent,
888
+ });
896
889
  return members;
897
890
  }
898
891
 
@@ -2668,6 +2661,7 @@ function cmd_run(
2668
2661
  quorum_out.result = post_run.quorum;
2669
2662
  skipped.push(...post_run.absent);
2670
2663
  recordRoundObservations(REPO_ROOT, members, responses, classifyCliFailure);
2664
+ printBillingGate(fallback_out.options?.parked ?? [], { repoRoot: REPO_ROOT, stdout: _stdout, stderr: _stderr });
2671
2665
  // Attendance telemetry for the reading that actually decided the pass.
2672
2666
  // `post_run.absent` is the mid-flight set only; the construction-time
2673
2667
  // skips already went out under `phase: 'pre_run'` from `build_members`,
@@ -3509,7 +3503,7 @@ interface Args {
3509
3503
  json: boolean;
3510
3504
  }
3511
3505
 
3512
- const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report', 'status'];
3506
+ const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report', 'status', ...BILLING_SUBCOMMANDS];
3513
3507
 
3514
3508
  const _TOP_USAGE =
3515
3509
  `usage: ${_PROG} [-h]\n` +
@@ -3698,7 +3692,7 @@ function _specsFor(cmd: string): { positionals: string[]; opts: OptSpec[]; requi
3698
3692
  requiredOpts: [],
3699
3693
  };
3700
3694
  default:
3701
- return { positionals: [], opts: [], requiredOpts: [] };
3695
+ return billingArgSpec(cmd) ?? { positionals: [], opts: [], requiredOpts: [] };
3702
3696
  }
3703
3697
  }
3704
3698
 
@@ -3902,6 +3896,8 @@ function main(argv: string[] | null = null): number {
3902
3896
  if (args.cmd === 'status') {
3903
3897
  return cmd_status(args);
3904
3898
  }
3899
+ const billing = handleBillingCommand(String(args.cmd), _getattr(args, 'run_id', null), { repoRoot: REPO_ROOT, stdout: _stdout, stderr: _stderr });
3900
+ if (billing !== null) return billing;
3905
3901
  } catch (exc) {
3906
3902
  if (exc instanceof CouncilDisabledError) {
3907
3903
  _stderr(`❌ council:${args.cmd}: ${exc.message}\n`);
@@ -111,6 +111,93 @@ export function scanHiddenUnicode(text: string): HiddenUnicodeFinding[] {
111
111
  }));
112
112
  }
113
113
 
114
+ /** One codepoint the strip considered, and what it decided. */
115
+ export interface CarrierRecord {
116
+ /** `U+XXXX` form, so a record is readable without decoding. */
117
+ codepoint: string;
118
+ /** The class `_classify` returned — never a second class list. */
119
+ cls: string;
120
+ /** Codepoint index (not UTF-16 index) in the input. */
121
+ offset: number;
122
+ disposition: "removed" | "preserved";
123
+ /** Why it was preserved. `null` when removed. */
124
+ reason: string | null;
125
+ }
126
+
127
+ export interface CarrierStripResult {
128
+ out: string;
129
+ removed: number;
130
+ preserved: number;
131
+ records: CarrierRecord[];
132
+ }
133
+
134
+ /**
135
+ * Remove hidden-Unicode carriers from OUTPUT prose — context-aware, opt-in.
136
+ *
137
+ * ## Why this is not `_sanitize`
138
+ *
139
+ * `lint_hidden_unicode._sanitize` drops every `_classify`-flagged codepoint
140
+ * unconditionally and then applies `NFKC`. That is correct for its own callers —
141
+ * it repairs a source FILE — and it is wrong for a deliverable, because
142
+ * `_ZERO_WIDTH` contains `0x200C` and `0x200D`. A blind pass therefore destroys
143
+ * emoji ZWJ sequences and complex-script joiners, which are legitimate content.
144
+ * This function keeps `_classify` as the single source of truth for *what is a
145
+ * candidate* and adds exactly one predicate for *whether to remove it*.
146
+ *
147
+ * ## The predicate
148
+ *
149
+ * A flagged codepoint is removed only when the codepoints on BOTH sides are
150
+ * ASCII (`< 0x80`) or absent. A carrier between two ASCII characters is doing
151
+ * no linguistic work; one adjacent to any non-ASCII character might be a
152
+ * joiner, so it stays.
153
+ *
154
+ * Deliberately conservative, and the asymmetry is the point: the failure mode
155
+ * is a preserved carrier, never a corrupted word. That makes this a HYGIENE
156
+ * pass and not a security control — the injection vector is covered on the
157
+ * INGESTION side by `scanHiddenUnicode`, whose contract is to warn and never
158
+ * strip, and which this function does not touch.
159
+ *
160
+ * Not a detector-evasion tool: it removes invisible characters and alters no
161
+ * visible prose. That exclusion is permanent.
162
+ */
163
+ export function stripCarrierUnicode(text: string): CarrierStripResult {
164
+ const cps = [...text].map((ch) => ch.codePointAt(0) ?? 0);
165
+ const kept: string[] = [];
166
+ const records: CarrierRecord[] = [];
167
+ let removed = 0;
168
+ let preserved = 0;
169
+
170
+ for (let i = 0; i < cps.length; i += 1) {
171
+ const cp = cps[i] as number;
172
+ const cls = _classify(cp);
173
+ if (cls === null) {
174
+ kept.push(String.fromCodePoint(cp));
175
+ continue;
176
+ }
177
+ // `undefined` at either end is "absent", which counts as ASCII-side: a
178
+ // carrier at a boundary has no neighbour to be joining.
179
+ const before = cps[i - 1];
180
+ const after = cps[i + 1];
181
+ const asciiSide = (n: number | undefined): boolean => n === undefined || n < 0x80;
182
+ const strip = asciiSide(before) && asciiSide(after);
183
+ records.push({
184
+ codepoint: `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`,
185
+ cls,
186
+ offset: i,
187
+ disposition: strip ? "removed" : "preserved",
188
+ reason: strip ? null : "adjacent to a non-ASCII codepoint — may be a joiner",
189
+ });
190
+ if (strip) {
191
+ removed += 1;
192
+ } else {
193
+ preserved += 1;
194
+ kept.push(String.fromCodePoint(cp));
195
+ }
196
+ }
197
+
198
+ return { out: kept.join(""), removed, preserved, records };
199
+ }
200
+
114
201
  interface Thresholds {
115
202
  maxHard: number;
116
203
  maxScore: number;
@@ -698,6 +698,44 @@ function isoSeconds(d: Date): string {
698
698
  return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
699
699
  }
700
700
 
701
+ /**
702
+ * The return-envelope POINTER — one line-shaped field, delivered on the
703
+ * dominant dispatch path.
704
+ *
705
+ * ## Why a pointer and not the contract
706
+ *
707
+ * 240 chars is not a round number: it is `MAX_RESPONSE_LINE_CHARS`
708
+ * (`_lib/subagent_response.ts`), so the pointer is one line-shaped field by the
709
+ * contract's own measure. Inlining the shape would put a fourth copy of it in
710
+ * the tree, and a fourth copy is how the contract came to have three
711
+ * inconsistent states.
712
+ *
713
+ * ## Why THIS dispatcher
714
+ *
715
+ * `road-to-subagent-envelope-adoption` step 1.1 says the dominant path, "and it
716
+ * does not fan out to both". The dominant path is **not answerable from the
717
+ * ledger** — 1,725 of 1,845 post-split stops carry a null `agent_type`, which is
718
+ * the 8 % start↔stop join rate recorded elsewhere, so no stop can be attributed
719
+ * to a dispatcher. Decided on invocation surface instead: this file is wired
720
+ * into `/create-pr` and read by three gates (`check_completion_review`,
721
+ * `lint_evidence_artifacts`, `probe_review_binding_drift`), while
722
+ * `team_dispatch` sits behind an ai_team host flag and one `/team delegate`
723
+ * command. It is also the leg with ZERO prior contract hits, so the dominant
724
+ * path and the absent one are the same file.
725
+ *
726
+ * ## What it does not change
727
+ *
728
+ * The findings table stays the artefact — the gates read the file, not the
729
+ * envelope. Per the four-boundary separation in the response contract, this
730
+ * touches the DELIVERY boundary only: what the final message is. It adds no
731
+ * lifecycle status and does not alter the body schema.
732
+ */
733
+ export const RETURN_ENVELOPE_POINTER =
734
+ 'Final message = the return envelope and nothing else: ' +
735
+ '{summary, handoff, confidence, findings, risks}. Shape + the ' +
736
+ 'write-to-disk-first rule: contexts/execution/subagent-response-contract.md. ' +
737
+ 'The findings table stays a file.';
738
+
701
739
  function reviewerPrompt(args: {
702
740
  slug: string;
703
741
  headSha: string;
@@ -781,6 +819,10 @@ function reviewerPrompt(args: {
781
819
  `**Honest-null:** 0 findings, scope ${args.scopeHash}, reviewed <YYYY-MM-DD>`,
782
820
  '```',
783
821
  '',
822
+ '## Return channel',
823
+ '',
824
+ RETURN_ENVELOPE_POINTER,
825
+ '',
784
826
  ].join('\n');
785
827
  }
786
828
 
@@ -268,6 +268,45 @@ function _getStr(d: Dict, key: string, dflt: string): string {
268
268
  return String(v);
269
269
  }
270
270
 
271
+ /**
272
+ * What the runtime hooks actually DO, in four states and no others.
273
+ *
274
+ * The four: `detects` (it looks and reports nothing a host reads), `warns` (it
275
+ * reports through the host, and the host may ignore it), `blocks` (it can
276
+ * refuse), and `not-enforceable-on-host` (the slot does not exist there).
277
+ *
278
+ * WHY THIS BLOCK EXISTS. `CAPABILITIES.yaml` carried no prompt-injection entry
279
+ * at all, and saying nothing is a different defect from saying too much: a
280
+ * reader looking for injection coverage found absence, which reads as "not
281
+ * covered" rather than as "a warn-only scanner exists and ships off". This is
282
+ * the constructive half of a source proposal whose other half — "remove claims
283
+ * that equate a warn-hook with prevention" — had nothing to remove.
284
+ *
285
+ * `blocks` is never legal for the injection scanner on ANY host, and the value
286
+ * is DERIVED from `hook_manifest.yaml` rather than typed here: `fail_closed:
287
+ * false` plus `severity: advisory` is what makes it `warns`, so a manifest edit
288
+ * that gave it teeth would have to change this row too. A hand-written `blocks`
289
+ * would be the coverage inflation `untrusted-input-defense` refuses, one file
290
+ * over.
291
+ */
292
+ function _runtime_enforcement_lines(): string[] {
293
+ return [
294
+ 'runtime_enforcement:',
295
+ ' # Four states, and no others: detects | warns | blocks | not-enforceable-on-host.',
296
+ ' # Derived from src/scripts/hook_manifest.yaml (fail_closed + severity), never',
297
+ ' # hand-written — a manifest row that gained teeth would have to change this one.',
298
+ ' - hook: injection-scan',
299
+ ' slot: post_tool_use',
300
+ ' state: warns',
301
+ ' default: off',
302
+ ' blocks_legal_on_any_host: false',
303
+ ' reason: ' +
304
+ _scalar(
305
+ 'fail_closed: false + severity: advisory in hook_manifest.yaml — it reports through the host and cannot refuse, so it warns and never blocks. Ships enabled: false; the detector was measured at 99.00% recall and a 0.85% false-positive rate over internal/bench/corpora/encoding-channels/ before any default flip is even discussable.<!-- claim:injection-scan-corpus-rates -->',
306
+ ),
307
+ ];
308
+ }
309
+
271
310
  export function build(): string {
272
311
  const packs = _load_packs();
273
312
  const skillMap = _skill_packs();
@@ -360,6 +399,8 @@ export function build(): string {
360
399
  }
361
400
  }
362
401
  lines.push('');
402
+ lines.push(..._runtime_enforcement_lines());
403
+ lines.push('');
363
404
  lines.push('gaps:');
364
405
  if (gaps.length > 0) {
365
406
  const sortedGaps = [...gaps].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -153,6 +153,19 @@ export type GitOp =
153
153
  | "branch"
154
154
  | "pr-create"
155
155
  | "pr-merge"
156
+ /**
157
+ * Enabling auto-merge. Its OWN op, not a plain merge and not nothing.
158
+ *
159
+ * Irreversible in the same sense a merge is: it commits the outcome to a
160
+ * condition the agent does not control, and the merge then happens with
161
+ * nobody in the loop. Before this split, `gh pr merge --auto` classified
162
+ * identically to a plain merge (the pattern is `\bgh\s+pr\s+merge\b`, and
163
+ * `--auto` is invisible to it), and the GraphQL mutation classified as
164
+ * NOTHING — `ghApiWrite()` needs a REST path and a write method, and
165
+ * `gh api graphql -f query='mutation{enablePullRequestAutoMerge...}'` has
166
+ * neither.
167
+ */
168
+ | "pr-merge-auto"
156
169
  | "tag"
157
170
  | "release"
158
171
  | "publish";
@@ -163,6 +176,7 @@ export const ALL_OPS: readonly GitOp[] = [
163
176
  "branch",
164
177
  "pr-create",
165
178
  "pr-merge",
179
+ "pr-merge-auto",
166
180
  "tag",
167
181
  "release",
168
182
  "publish",
@@ -185,8 +199,29 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
185
199
  op: "pr-create",
186
200
  re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|(er)?(ö|oe)ffne|open|create|raise|aufmachen)\b[^.\n]{0,30}\b(pr|pull[- ]request)\b|\b(pr|pull[- ]request)\b[^.\n]{0,20}\b(erstellen|aufmachen|anlegen|(er)?(ö|oe)ffnen)\b/i,
187
201
  },
188
- // `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit".
189
- { op: "pr-merge", re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i },
202
+ // `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit",
203
+ // and never under a NEGATION.
204
+ //
205
+ // The negation half closes a measured defect: probed directly against
206
+ // `classifyAuthorization`, "nicht mergen", "don't merge this" and
207
+ // "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
208
+ // MERGE authorized a merge. The noun-sense lookahead was never a negator.
209
+ //
210
+ // The lookBEHIND is line-scoped by `[^.!?\n]` on purpose, the same scoping
211
+ // `turn_end_gate_hook.ts` uses for its own negation exclusion (`:330`) and
212
+ // its negated-claim window (`:490-493`). A sentence boundary ends a
213
+ // negation's reach: "Do not push. Merge PR #12." is two instructions, and a
214
+ // guard that let the first suppress the second would silently stop
215
+ // authorizing merges the user DID order — the failure mode that is worse
216
+ // than the defect, because nothing happens and nothing says why.
217
+ //
218
+ // Reusing that vocabulary rather than inventing a third is deliberate: two
219
+ // negation vocabularies in one tree drift, and the drift is invisible until
220
+ // a prompt lands in the gap between them.
221
+ {
222
+ op: "pr-merge",
223
+ re: /(?<!\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b[^.!?\n]{0,30})\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
224
+ },
190
225
  // A tag is an ACTION here — bare "Tag" is the German word for day, and
191
226
  // "Version" is an ordinary noun. Both authorized a BLOCK op before this.
192
227
  { op: "tag", re: /\b(tagge(n|st)?|tag\s+(setzen|anlegen|erstellen)|git\s+tag|--tags|--follow-tags)\b/i },
@@ -267,10 +302,20 @@ export function isAffirmative(prose: string): boolean {
267
302
  }
268
303
 
269
304
  /** Executable commands a user may paste, mapped to the op they authorize. */
270
- const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
305
+ const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp | null; re: RegExp }> = [
271
306
  { op: "publish", re: /\bnpm\s+publish\b/i },
272
307
  { op: "tag", re: /\bgit\s+push\s+[^\n]*--tags\b|\bgit\s+tag\s+-a\b/i },
273
308
  { op: "release", re: /\bgh\s+release\s+create\b/i },
309
+ // ORDER IS LOAD-BEARING: the auto variants precede the plain merge, because
310
+ // the first match wins and `gh pr merge 12 --auto` also matches the plain
311
+ // pattern. Same rule the block-side table states for `git push --tags`.
312
+ //
313
+ // The DE-ESCALATING forms come first of all, and they map to no op at all:
314
+ // `--disable-auto` and `disablePullRequestAutoMerge` turn the capability OFF.
315
+ // Requiring merge authorization to switch auto-merge off is a live deadlock,
316
+ // and it is why this cannot ship after the split rather than with it.
317
+ { op: null, re: /\bgh\s+pr\s+merge\b[^\n]*--disable-auto\b|\bdisablePullRequestAutoMerge\b/i },
318
+ { op: "pr-merge-auto", re: /\bgh\s+pr\s+merge\b[^\n]*--auto\b|\benablePullRequestAutoMerge\b/i },
274
319
  { op: "pr-merge", re: /\bgh\s+pr\s+merge\b/i },
275
320
  { op: "pr-create", re: /\bgh\s+pr\s+create\b/i },
276
321
  { op: "push", re: /\bgit\s+push\b/i },
@@ -355,10 +400,15 @@ export function classifyAuthorization(prompt: string): {
355
400
  }
356
401
  for (const line of _commandLines(fence)) {
357
402
  for (const { op, re } of PASTED_COMMANDS) {
358
- if (re.test(line)) {
403
+ if (!re.test(line)) continue;
404
+ // FIRST MATCH WINS, and the table is ordered most-specific-first. A
405
+ // de-escalating form carries `op: null` — it matched, so no later
406
+ // pattern may claim the line, and it authorizes nothing.
407
+ if (op !== null) {
359
408
  authorized.add(op);
360
409
  evidence[op] = `pasted command: "${line.slice(0, 80)}"`;
361
410
  }
411
+ break;
362
412
  }
363
413
  }
364
414
  }
@@ -92,6 +92,9 @@ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
92
92
  "tag",
93
93
  "release",
94
94
  "pr-merge",
95
+ // Enabling auto-merge commits the outcome to a condition the agent does not
96
+ // control, so it is blocked on the same terms as the merge it schedules.
97
+ "pr-merge-auto",
95
98
  ]);
96
99
 
97
100
  /** Recoverable operations — warned without a this-turn authorization. */
@@ -162,10 +165,25 @@ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
162
165
  op: "tag",
163
166
  re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--tags|--follow-tags|refs\\/tags\\/)`, "i"),
164
167
  },
168
+ // ORDER IS LOAD-BEARING, per this table's own rule that the most specific
169
+ // pattern wins: `gh pr merge 12 --auto` matches the plain merge pattern too.
170
+ //
171
+ // The GraphQL mutation needs its OWN pattern rather than a widening of
172
+ // `ghApiWrite()`. That helper requires BOTH a REST-shaped path and an
173
+ // explicit write method, and `gh api graphql -f query='mutation{...}'` has
174
+ // neither — widening it to reach this would loosen every other op that uses
175
+ // it, which is a bigger change than the defect.
176
+ {
177
+ op: "pr-merge-auto",
178
+ re: new RegExp(
179
+ `${P}gh\\s+pr\\s+merge\\b(?=[^\\n]*--auto\\b)|${P}gh\\s+api\\b(?=[^\\n]*enablePullRequestAutoMerge\\b)`,
180
+ "i",
181
+ ),
182
+ },
165
183
  {
166
184
  op: "pr-merge",
167
185
  re: new RegExp(
168
- `${P}gh\\s+pr\\s+merge\\b|${ghApiWrite("\\/pulls\\/\\d+\\/merge\\b").source}`,
186
+ `${P}gh\\s+pr\\s+merge\\b(?![^\\n]*--(auto|disable-auto)\\b)|${ghApiWrite("\\/pulls\\/\\d+\\/merge\\b").source}`,
169
187
  "i",
170
188
  ),
171
189
  },
@@ -129,6 +129,7 @@
129
129
  */
130
130
 
131
131
  import * as fs from 'node:fs';
132
+ import { revokeBillingGrant } from '../_lib/billing_grant.js';
132
133
  import * as path from 'node:path';
133
134
  import { fileURLToPath, pathToFileURL } from 'node:url';
134
135
 
@@ -599,12 +600,13 @@ function writeState(file: string, state: RunState): boolean {
599
600
  * stamp with it. Adoption is a fact this read already established; it is passed in
600
601
  * rather than re-derived from a condition that cannot express it.
601
602
  */
602
- function clearRunState(
603
+ export function clearRunState(
603
604
  workspaceRoot: string,
604
605
  runId: string,
605
606
  keyedFile: string,
606
607
  adoptedLegacy: boolean,
607
608
  ): void {
609
+ revokeBillingGrant(workspaceRoot, runId); // the run IS the grant's TTL
608
610
  try {
609
611
  fs.rmSync(keyedFile, { force: true });
610
612
  } catch {