@event4u/agent-config 13.0.0 → 14.0.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 (118) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +160 -333
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +224 -104
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2018 -1320
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1652 -892
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +2 -2
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gh_transient.ts +80 -0
  75. package/src/scripts/_lib/release_material.ts +16 -5
  76. package/src/scripts/_lib/review_independence.ts +111 -0
  77. package/src/scripts/_lib/session_register.ts +14 -0
  78. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  79. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  80. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  81. package/src/scripts/ai_council/_py_json.ts +96 -0
  82. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  83. package/src/scripts/ai_council/clients.ts +80 -143
  84. package/src/scripts/ai_council/config.ts +25 -11
  85. package/src/scripts/ai_council/probe_store.ts +196 -0
  86. package/src/scripts/ai_council/qualification.ts +425 -0
  87. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  88. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  89. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  90. package/src/scripts/capture_skill_catalogue.ts +264 -4
  91. package/src/scripts/check_evaluator_schema.ts +249 -0
  92. package/src/scripts/check_pr_ci_current.ts +409 -0
  93. package/src/scripts/check_review_schema.ts +195 -0
  94. package/src/scripts/check_rule_activation_census.ts +451 -0
  95. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  96. package/src/scripts/condense.ts +1 -1
  97. package/src/scripts/council_cli.ts +141 -191
  98. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  99. package/src/scripts/hook_manifest.yaml +72 -5
  100. package/src/scripts/hooks/concern_registry.ts +4 -0
  101. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  102. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  103. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  105. package/src/scripts/hooks_doctor.ts +112 -0
  106. package/src/scripts/interruption_report.ts +443 -0
  107. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  108. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  109. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  110. package/src/scripts/release.ts +38 -53
  111. package/src/scripts/rule_activation_census.ts +292 -0
  112. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  113. package/src/scripts/schemas/review-findings.schema.json +31 -0
  114. package/src/scripts/self_review_gate.ts +6 -0
  115. package/src/scripts/session_register_hook.ts +30 -1
  116. package/src/scripts/sync_pr_branch.ts +271 -0
  117. package/src/scripts/test_council_qualification.ts +213 -0
  118. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -170,6 +170,35 @@ contingency.
170
170
  subagent's output is never auto-persisted into memory — promotion is
171
171
  always the human-gated flow (ADR-098 floor). Auto-surface, never
172
172
  auto-write.
173
+ - **(f) Return shape — text-only final message, disk copy written first.** Two
174
+ clauses, and they ride **here**, at spawn, rather than in a reusable agent
175
+ definition: a delivery duty stated in an agent definition measured 0/2 against
176
+ 1/1 for the identical duty carried in the dispatch prompt (practitioner
177
+ report, n small), which is the same verbatim-at-spawn discipline as (a)–(e).
178
+ - **The final message is a single text-only envelope — never end on a tool
179
+ call.** Measured on host 2.1.229 with a matched control dispatched in the
180
+ same turn: the arm told to emit its report and then make one final
181
+ `echo done` call returned **`(no output)`** to its parent after 3 tool uses
182
+ and 18,242 tokens, while the control ending on assistant text returned the
183
+ complete report. The work was paid for in full and discarded in full
184
+ ([`subagent-lifecycle-phase0-return-channel.md`](../../../../agents/evidence/investigations/subagent-lifecycle-phase0-return-channel.md)
185
+ § F1).
186
+ - **Write the envelope to the runtime artifact dir before emitting it.** Disk
187
+ is the durable channel, the message the fast one — same envelope, so the
188
+ orchestrator still ingests exactly one shape
189
+ ([response contract § Durable copy](subagent-response-contract.md#durable-copy--the-envelope-on-disk-before-the-message)).
190
+
191
+ **No carve-out for verifier dispatches.** An async verifier — a subagent that
192
+ screenshots, grades, and reports back — rides this identical return channel,
193
+ and one that grades and never delivers is two operator symptoms in a single
194
+ run: work that looks endless from the orchestrator's side, and work that
195
+ finished without returning. Rule (f) binds it like any other worker.
196
+
197
+ **Honest scope.** Nothing enforces (f). It is prompt-carried text, so a worker
198
+ that ignores it fails exactly as before — the clause buys a stated duty and a
199
+ known-good path to look in, not a guarantee. The deterministic half needs a
200
+ `subagent_stop` concern that reads the disk copy, which is planned and not
201
+ shipped.
173
202
 
174
203
  ## Hand-off worked examples — step N output feeds step N+1
175
204
 
@@ -32,6 +32,39 @@ A suggestion block emits options. It does **not** start a command flow.
32
32
  The "run as-is" option is **always present**, **always last**, never
33
33
  removed.
34
34
 
35
+ ### The single HIGH-tier carve-out — a duplicate confirmation, never a skipped one
36
+
37
+ ```
38
+ HIGH TIER REMOVES A SECOND CONFIRMATION. IT NEVER REMOVES THE FIRST.
39
+ A COMMAND THAT WOULD BEGIN ACTING ON ARRIVAL IS NEVER HIGH TIER.
40
+ TWO CANDIDATES ABOVE THE FLOOR IS MEDIUM. A SUBSTRING HIT IS MEDIUM.
41
+ NO DETERMINISTIC UNIQUE SIGNAL → THE BLOCK, ALWAYS.
42
+ ```
43
+
44
+ Added by road-to-user-out-of-the-loop Phase 1. It is narrow on purpose, and the
45
+ narrowness is the argument: the law above exists so the user is never carried
46
+ somewhere they did not choose, and a routed command whose **own** contract
47
+ screen is the next thing they see has not carried them anywhere — it has
48
+ replaced one options block with one basis line and left the real decision
49
+ exactly where it was.
50
+
51
+ All three conditions in
52
+ [`command-suggestion-flow § Tier matrix`](../contexts/contracts/command-suggestion-flow.md)
53
+ must hold: a deterministic unique signal, no second candidate above the floor,
54
+ and a routed command that shows its own confirmation. Condition 3 is the
55
+ load-bearing one — without it this clause would authorise auto-execution, which
56
+ is the thing the Iron Law forbids and this carve-out does not touch.
57
+
58
+ Everything else on this page is unchanged. MEDIUM and LOW still emit the block
59
+ with "run as-is" always present and always last; the subordination list below
60
+ still makes suggestion junior to every rule on it; and a Hard-Floor action stays
61
+ gated by [`non-destructive-by-default`](non-destructive-by-default.md) however
62
+ it was reached.
63
+
64
+ **Honest status:** the kill criterion (>5 % mis-routes over 50 auto-routes
65
+ reverts to block-always) has **no instrument yet**. The rate is unmeasured, not
66
+ low. A mis-route is a reportable defect until it can be counted.
67
+
35
68
  ## Subordination — when to stay silent
36
69
 
37
70
  The suggestion rule is **junior** to:
@@ -14,8 +14,13 @@ triggers:
14
14
  - file_pattern: "*.tsx"
15
15
  - file_pattern: "*.jsx"
16
16
  - file_pattern: "*.blade.php"
17
- - keyword: "component"
18
- - keyword: "design token"
17
+ # `keyword: "component"` and `keyword: "design token"` removed 2026-08-17 as an
18
+ # AUTHORING decision — road-to-mixed-trigger-activation-cost Phase 2, amendment
19
+ # option (b), narrowed to the two path-dominant per-edit rules. This is the
20
+ # escape the mixed-triggers guard's own comment names: with no non-path trigger
21
+ # left the emitter restores `paths:`, so the rule loads on UI file contact
22
+ # instead of in every session. The obligation is unchanged — it binds when a UI
23
+ # file is touched, which is the only place a UI edit can happen.
19
24
  routes_to:
20
25
  - "skill:design-review"
21
26
  - "skill:accessibility-auditor"
@@ -24,8 +29,12 @@ packs: [frontend-design]
24
29
  enforced_by:
25
30
  - "none"
26
31
  collision_ok:
27
- "component": "a written component is reviewed before it is called done — the write-side half"
28
- "design token": "same surface, opposite side: ui-audit-gate inventories tokens before the write, this reviews what the write emitted"
32
+ # The "component" and "design token" entries were removed with their triggers
33
+ # (2026-08-17): a collision note for a trigger that no longer exists is an
34
+ # own-orphan, and `minimal-safe-diff` § Own-orphan cleanup requires the diff that
35
+ # stopped referencing them to remove them too. The path-keyed notes below are
36
+ # unchanged, and they still carry the real point: both halves of the UI loop fire
37
+ # on the same paths.
29
38
  "resources/views/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
30
39
  "resources/js/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
31
40
  "components/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
@@ -109,10 +118,18 @@ design consultation latched this session.
109
118
 
110
119
  It runs **parallel** to this rule rather than consuming it. Nothing in the tree
111
120
  reads this frontmatter at session time; the concern decides what counts as a UI
112
- surface from `src/scripts/_lib/ui_surface.ts`, and the `keyword:` triggers here
113
- still have no runtime consumer. A test — not a code path — keeps the two from
114
- drifting: `ui_rule_triggers.test.ts` asserts every `file_pattern` declared here
115
- is accepted by that predicate.
121
+ surface from `src/scripts/_lib/ui_surface.ts`. A test — not a code path — keeps
122
+ the two from drifting: `ui_rule_triggers.test.ts` asserts every `file_pattern`
123
+ declared here is accepted by that predicate.
124
+
125
+ **This rule carries no `keyword:` trigger any more (2026-08-17).** It used to,
126
+ and the observation those keywords had no runtime consumer is what made removing
127
+ them cheap: no host activation surface reads a keyword — Cursor and Windsurf
128
+ activate on `globs` plus the description, Claude on `paths:`. What the keywords
129
+ did do was expensive, and only on Claude: one non-path trigger makes the emitter
130
+ write **no** `paths:` at all, which is why this rule's pre-inventory-and-review
131
+ obligation stood in every session from 12.1.0 until the removal. It is
132
+ path-scoped again — see `road-to-mixed-trigger-activation-cost` Phase 2.
116
133
 
117
134
  Warn-only, capped at two nudges per session, default-OFF, and bound only in the
118
135
  three `platforms:` rows that carry a `pre_tool_use` key — augment, claude,
@@ -14,8 +14,13 @@ triggers:
14
14
  - file_pattern: "*.tsx"
15
15
  - file_pattern: "*.jsx"
16
16
  - file_pattern: "*.blade.php"
17
- - keyword: "component"
18
- - keyword: "design token"
17
+ # `keyword: "component"` and `keyword: "design token"` removed 2026-08-17 as an
18
+ # AUTHORING decision — road-to-mixed-trigger-activation-cost Phase 2, amendment
19
+ # option (b), narrowed to the two path-dominant per-edit rules. This is the
20
+ # escape the mixed-triggers guard's own comment names: with no non-path trigger
21
+ # left the emitter restores `paths:`, so the rule loads on UI file contact
22
+ # instead of in every session. The obligation is unchanged — an audit is owed
23
+ # before a component is written, and a component is written in a file.
19
24
  routes_to:
20
25
  - "skill:existing-ui-audit"
21
26
  - "skill:iconography"
@@ -25,8 +30,11 @@ packs: [frontend-design]
25
30
  enforced_by:
26
31
  - "none"
27
32
  collision_ok:
28
- "component": "no new component without audit findings — its core subject"
29
- "design token": "same surface, opposite side: this inventories tokens before the write, design-review-after-ui-write reviews what the write emitted"
33
+ # The "component" and "design token" entries were removed with their triggers
34
+ # (2026-08-17): a collision note for a trigger that no longer exists is an
35
+ # own-orphan, and `minimal-safe-diff` § Own-orphan cleanup requires the diff that
36
+ # stopped referencing them to remove them too. The path-keyed notes below are
37
+ # unchanged — both halves of the UI loop still fire on the same paths.
30
38
  "resources/views/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
31
39
  "resources/js/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
32
40
  "components/": "both halves of one UI loop fire on the same path — audit before, review after; neither substitutes for the other"
@@ -114,10 +122,19 @@ consultation latched this session.
114
122
  It is a **parallel** mechanism, not a consumer of the triggers above — the
115
123
  distinction matters and the earlier wording here got it wrong. The concern
116
124
  decides "is this a UI surface" from `src/scripts/_lib/ui_surface.ts`; nothing
117
- in the tree parses this frontmatter at session time, so the `keyword:` triggers
118
- still have no runtime consumer at all, and the two sets can drift. What holds
119
- them together is a test, not a dependency: `ui_rule_triggers.test.ts` asserts
120
- every `file_pattern` declared here is accepted by that predicate. The predicate
125
+ in the tree parses this frontmatter at session time, and the two sets can drift.
126
+ What holds them together is a test, not a dependency:
127
+ `ui_rule_triggers.test.ts` asserts every `file_pattern` declared here is
128
+ accepted by that predicate.
129
+
130
+ **This rule carries no `keyword:` trigger any more (2026-08-17).** It used to,
131
+ and the observation those keywords had no runtime consumer is what made removing
132
+ them cheap: no host activation surface reads a keyword — Cursor and Windsurf
133
+ activate on `globs` plus the description, Claude on `paths:`. What the keywords
134
+ did do was expensive, and only on Claude: one non-path trigger makes the emitter
135
+ write **no** `paths:` at all, which is why this rule loaded in every session from
136
+ 12.1.0 until the removal. It is path-scoped again, so the obligation now arrives
137
+ on UI file contact — see `road-to-mixed-trigger-activation-cost` Phase 2. The predicate
121
138
  is deliberately wider (it also covers `.css`, `.scss`, `.astro`), because a
122
139
  measurement denominator and a routing trigger are not the same population.
123
140
 
@@ -0,0 +1,395 @@
1
+ /**
2
+ * `agent-config gates --execute <id>` — the acting half of the gate layer.
3
+ *
4
+ * `gates` renders decisions. This resolves the ones that were never decisions:
5
+ * a class-0 gate is a command whose OUTPUT IS THE UNBLOCK, and leaving it for a
6
+ * human to type is the couriering `road-to-gate-autonomy` exists to remove.
7
+ *
8
+ * ## Deliberately thin, on the sweep's own number
9
+ *
10
+ * Step 1.2 pre-registered that at least 40 % of open blockers would be class 0
11
+ * or 1 and measured **24.5 %** (`agents/evidence/analysis/gate-class-sweep-2026-08-17.md`).
12
+ * The roadmap's honest-null clause then fixes this module's size: it ships for
13
+ * the six class-0 entries and the render path, and it is not the lever that
14
+ * drains the estate. Building a general execution engine against a premise the
15
+ * evidence weakened would be the over-build the null exists to prevent.
16
+ *
17
+ * ## What it will not do
18
+ *
19
+ * - **No sweep.** One blocker per invocation, named explicitly. A blanket
20
+ * `--execute` over the estate would run N authored commands on one keypress,
21
+ * and the blast radius of a misclassification would be the whole tree
22
+ * instead of one entry.
23
+ * - **No resolve on failure.** A non-zero exit reports and changes nothing.
24
+ * A gate marked resolved by a command that failed is worse than an open one.
25
+ * - **No invented ledger.** Class 1 needs a standing budget, and the shape of
26
+ * that budget is an OPEN maintainer decision (`b-gate-budget-preauth`).
27
+ * Until it is taken there is no ledger, so class 1 takes the
28
+ * render-instead-of-run path — which is the behaviour the blocker itself
29
+ * prescribes for a missing ledger, not a stub of the decision.
30
+ * - **No class-3 change.** The class-3 branch executes nothing and writes
31
+ * nothing, so `agent-config gates` renders a class-3 entry exactly as it did
32
+ * before this module existed. `--execute` on one returns a one-line notice
33
+ * and points back at that renderer; it does not call it.
34
+ * - **No run without `--confirm`.** `Run:` is arbitrary shell read out of a
35
+ * markdown field. Without the flag the command is echoed and refused, so
36
+ * the operator sees the exact string first — the this-turn, names-the-object
37
+ * confirmation `non-destructive-by-default` requires. A command carrying a
38
+ * Hard-Floor action is refused even WITH the flag: class 0 means reversible,
39
+ * so such an entry is misclassified rather than merely dangerous.
40
+ */
41
+
42
+ import * as fs from 'node:fs';
43
+ import * as path from 'node:path';
44
+ import { spawnSync } from 'node:child_process';
45
+
46
+ import { collect, blocker_class, type Blocker } from './update_roadmap_progress.js';
47
+
48
+ /**
49
+ * Where a class-1 receipt ledger would live once the budget decision is taken.
50
+ *
51
+ * Named here so the absent-ledger branch has something concrete to report, and
52
+ * deliberately NOT created: the path is a consequence of the decision, and
53
+ * writing it now would answer a question that is still the maintainer's.
54
+ */
55
+ const LEDGER_REL = path.join('agents', 'runtime', 'state', 'gate-budget-ledger.jsonl');
56
+
57
+ type Outcome = 'resolved' | 'failed' | 'rendered' | 'not-found';
58
+
59
+ interface ExecuteResult {
60
+ outcome: Outcome;
61
+ /** Human-facing report — always populated, always the whole answer. */
62
+ report: string;
63
+ /** Exit code for the CLI: 0 unless the gate was named and could not run. */
64
+ code: number;
65
+ }
66
+
67
+ interface Located {
68
+ blocker: Blocker;
69
+ /** Absolute path of the roadmap holding it. */
70
+ file: string;
71
+ rel: string;
72
+ }
73
+
74
+ function locate(roadmapRoot: string, id: string): Located | null {
75
+ for (const r of collect(roadmapRoot)) {
76
+ for (const b of r.open_blockers) {
77
+ if (b.id === id) {
78
+ // `r.path` is the absolute path the collector already resolved.
79
+ // Rebuilding it from `basename(r.rel)` discarded the directory,
80
+ // so a roadmap in any subdirectory the collector does not
81
+ // exclude was either written to a same-named top-level file or
82
+ // threw — and either way AFTER the authored command had run.
83
+ return { blocker: b, file: r.path, rel: r.rel };
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Commands a class-0 gate may never carry, whatever the entry claims.
92
+ *
93
+ * Class 0 is defined as deterministic, free and **reversible**. A command that
94
+ * pushes, merges, deploys, or deletes is none of those, so its presence is a
95
+ * misclassification — and the right answer to a misclassification is refusal,
96
+ * not a confirmation prompt that makes the operator the last line of defence.
97
+ * These are Hard-Floor actions under `non-destructive-by-default`, which no
98
+ * setting, roadmap or flag lifts.
99
+ *
100
+ * A denylist is the wrong shape for a *security boundary* and the right shape
101
+ * for a *classification check*, which is what this is: the tree's roadmap files
102
+ * are reviewed content, so this catches the authoring mistake rather than an
103
+ * attacker. The blast-radius control is `--confirm` below, not this list.
104
+ */
105
+ const HARD_FLOOR_RE =
106
+ /(^|[;&|]\s*)(git\s+(?:push|merge|reset\s+--hard|rebase)\b|gh\s+(?:pr\s+merge|release)\b|terraform\s+apply\b|kubectl\s+apply\b|npm\s+publish\b|rm\s+-[rRf]+|DROP\s+TABLE\b|TRUNCATE\b)/i;
107
+
108
+ function hardFloorReason(command: string): string | null {
109
+ const m = HARD_FLOOR_RE.exec(command);
110
+ return m ? (m[2] as string).trim() : null;
111
+ }
112
+
113
+ /** Strip the backticks an authored `Run:` field carries. */
114
+ function commandOf(b: Blocker): string {
115
+ const raw = b.run.trim();
116
+ const fenced = /^`([^`]+)`$/.exec(raw);
117
+ return (fenced ? (fenced[1] as string) : raw).trim();
118
+ }
119
+
120
+ /**
121
+ * The class-2 consent line — one question, its recommendation, its default.
122
+ *
123
+ * This is a FEED, not a surface: `road-to-user-out-of-the-loop` Phase 1 owns
124
+ * the decision sheet, and this roadmap only hands it a line. Keeping the shape
125
+ * to one line is the contract — a class-2 gate that cannot state a one-line
126
+ * question and a default is reclassified to 3, never verbosified.
127
+ */
128
+ function consentLine(b: Blocker, why?: string): string {
129
+ const head = why ? `${b.id} — ${why}` : b.id;
130
+ const rec = b.recommendation.trim();
131
+ const q = b.question.trim();
132
+ const lines = [`CONSENT · ${head}`];
133
+ // An entry with no `Question:` has no question, and borrowing `Blocks:`
134
+ // silently put a "what this holds up" sentence in the "what is being
135
+ // decided" slot — two different facts. Say which one is on screen.
136
+ lines.push(
137
+ q !== ''
138
+ ? ` Question: ${q}`
139
+ : ` Question: (none authored — this is the Blocks line) ${b.blocks.trim()}`,
140
+ );
141
+ lines.push(
142
+ ` Recommendation: ${rec || '(none recorded — ask for one before deciding)'}`,
143
+ );
144
+ // The default is derived, not constant: an entry carrying a recommendation
145
+ // has one, an entry without one has nothing to default TO, and printing
146
+ // the same sentence for both told the reader nothing either way.
147
+ lines.push(
148
+ rec !== ''
149
+ ? ' Default if you say nothing: the recommendation above is NOT applied; the gate stays open.'
150
+ : ' Default if you say nothing: the gate stays open, and no recommendation exists to fall back on.',
151
+ );
152
+ // Risk 5 of the roadmap, made observable rather than merely written down:
153
+ // the defect being fixed is reading load, and a mechanism that emits
154
+ // paragraphs recreates it under a new name. The remedy the taxonomy names
155
+ // is reclassification to 3, never a longer line — so the overflow is
156
+ // reported at the entry rather than silently rendered.
157
+ if (rec.length > PARAGRAPH_CHARS) {
158
+ lines.push(
159
+ ` note: this recommendation is ${rec.length} chars — a class-2 gate that ` +
160
+ 'cannot state a one-line question and a default is reclassified to 3, ' +
161
+ 'not verbosified.',
162
+ );
163
+ }
164
+ return lines.join('\n') + '\n';
165
+ }
166
+
167
+ /**
168
+ * Where "one line" stops being one line.
169
+ *
170
+ * A stated default, not a measured optimum: two full terminal lines at the 78
171
+ * column the rest of this command wraps to. *Revisit-if* a real decision sheet
172
+ * lands and its own width contradicts this.
173
+ */
174
+ const PARAGRAPH_CHARS = 156;
175
+
176
+ /**
177
+ * Append the run's evidence under the blocker and flip its status.
178
+ *
179
+ * In-file, at the blocker, which is the done-note discipline every phase in
180
+ * this tree already uses — an unblock recorded anywhere else is an unblock the
181
+ * next reader of the roadmap cannot see.
182
+ */
183
+ function appendEvidence(file: string, id: string, command: string, output: string, when: string): boolean {
184
+ const text = fs.readFileSync(file, 'utf-8');
185
+ // The id is regex-escaped: ids are parsed with `(.+?)`, so a metacharacter
186
+ // is legal in the tree and an unescaped interpolation threw an uncaught
187
+ // SyntaxError — after the authored command had already run.
188
+ const head = new RegExp(`^###[ \\t]+blocker:[ \\t]*${escapeRe(id)}[ \\t]*$`, 'im');
189
+ const m = head.exec(text);
190
+ if (!m) {
191
+ return false;
192
+ }
193
+ const bodyStart = m.index + m[0].length;
194
+ // The body ends at the next blocker OR the next `##` section. Bounding it
195
+ // only at the next `### blocker:` made the LAST blocker's body run to the
196
+ // end of the file — the common shape, since `## Blockers` is followed by
197
+ // `## Risk Register` — so the evidence bullet landed after the risk table
198
+ // instead of under the blocker, which is the opposite of what this
199
+ // function's whole point is.
200
+ const rest = text.slice(bodyStart);
201
+ const nextHead = /^###[ \t]+blocker:/im.exec(rest);
202
+ const nextSection = /^##[ \t]+\S/m.exec(rest);
203
+ const ends = [nextHead?.index, nextSection?.index].filter((i): i is number => i !== undefined);
204
+ const bodyEnd = ends.length ? bodyStart + Math.min(...ends) : text.length;
205
+ const body = text.slice(bodyStart, bodyEnd);
206
+
207
+ const statusRe = /^-[ \t]*\*\*Status:\*\*[ \t]*.*$/im;
208
+ if (!statusRe.test(body)) {
209
+ return false;
210
+ }
211
+ const firstLine = output.split(/\r?\n/).find((l) => l.trim() !== '') ?? '(no output)';
212
+ const flipped = body.replace(
213
+ statusRe,
214
+ `- **Status:** resolved ${when} — class-0 auto-run`,
215
+ );
216
+ const evidence =
217
+ `- **Unblock evidence (${when}):** ran \`${command}\` — exit 0. ` +
218
+ `First line of output: ${firstLine.trim()}\n`;
219
+ const withEvidence = flipped.replace(/\s*$/, '\n') + evidence;
220
+ fs.writeFileSync(file, text.slice(0, bodyStart) + withEvidence + text.slice(bodyEnd), 'utf-8');
221
+ return true;
222
+ }
223
+
224
+ /** `YYYY-MM-DD` for the evidence note. Injected so tests are deterministic. */
225
+ function today(now: Date): string {
226
+ return now.toISOString().slice(0, 10);
227
+ }
228
+
229
+ function escapeRe(s: string): string {
230
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
231
+ }
232
+
233
+ interface ExecuteOptions {
234
+ /**
235
+ * Actually run the class-0 command.
236
+ *
237
+ * Default false, and that default is the control. `Run:` is an arbitrary
238
+ * shell string read out of a markdown file; without this flag the command
239
+ * is ECHOED and refused, so the operator sees exactly what would run before
240
+ * anything runs. `non-destructive-by-default` requires the confirmation to
241
+ * be this-turn and to name the exact object, and the echoed command is that
242
+ * object.
243
+ */
244
+ confirm?: boolean;
245
+ }
246
+
247
+ function execute(
248
+ roadmapRoot: string,
249
+ id: string,
250
+ now: Date,
251
+ opts: ExecuteOptions = {},
252
+ ): ExecuteResult {
253
+ const found = locate(roadmapRoot, id);
254
+ if (!found) {
255
+ return {
256
+ outcome: 'not-found',
257
+ report: `No open blocker with id '${id}'. Run \`agent-config gates --all\` for the list.\n`,
258
+ code: 1,
259
+ };
260
+ }
261
+ const { blocker, file, rel } = found;
262
+ const cls = blocker_class(blocker);
263
+
264
+ if (cls === '3') {
265
+ // Byte-identical to today by construction: nothing is executed and
266
+ // nothing is written. The gate is rendered by the caller.
267
+ return {
268
+ outcome: 'rendered',
269
+ report: `${id} is class 3 — human-only. Nothing to execute; see \`agent-config gates\`.\n`,
270
+ code: 0,
271
+ };
272
+ }
273
+ if (cls === '2') {
274
+ return { outcome: 'rendered', report: consentLine(blocker), code: 0 };
275
+ }
276
+ if (cls === '1') {
277
+ const ledger = path.join(path.dirname(path.dirname(roadmapRoot)), LEDGER_REL);
278
+ if (!fs.existsSync(ledger)) {
279
+ // The blocker's own prescription for a missing ledger: render the
280
+ // consent line INSTEAD of running. Not a degraded mode — the
281
+ // budget shape is an open decision and this is what open means.
282
+ return {
283
+ outcome: 'rendered',
284
+ report: consentLine(
285
+ blocker,
286
+ 'class 1 with no budget ledger, so it renders instead of running',
287
+ ),
288
+ code: 0,
289
+ };
290
+ }
291
+ return {
292
+ outcome: 'rendered',
293
+ report:
294
+ `${id} is class 1 and a ledger exists at ${LEDGER_REL}, but spend execution ` +
295
+ 'is gated on `b-gate-budget-preauth`, which is open. Rendering instead of running.\n',
296
+ code: 0,
297
+ };
298
+ }
299
+
300
+ const command = commandOf(blocker);
301
+ if (command === '') {
302
+ // Unreachable through a linted tree — `lint_roadmap_blockers` fails a
303
+ // class-0 entry with no `Run:` — but reachable through an unlinted one,
304
+ // and guessing a command here is the one thing that must not happen.
305
+ return {
306
+ outcome: 'failed',
307
+ report: `${id} is class 0 but carries no **Run:** command. Nothing was executed.\n`,
308
+ code: 1,
309
+ };
310
+ }
311
+
312
+ const floor = hardFloorReason(command);
313
+ if (floor !== null) {
314
+ // Refused outright, not offered for confirmation. Class 0 is defined
315
+ // as reversible; a push, merge, deploy or delete is not, so this is a
316
+ // misclassified entry and the fix is to re-author the class — making
317
+ // the operator wave it through would move a Hard Floor onto a keypress.
318
+ return {
319
+ outcome: 'failed',
320
+ report:
321
+ `${id} is class 0 but its **Run:** command contains \`${floor}\`, which is a ` +
322
+ 'Hard-Floor action under non-destructive-by-default. Class 0 means ' +
323
+ 'deterministic, free and REVERSIBLE, so this entry is misclassified. ' +
324
+ 'Nothing was executed; re-author the class.\n',
325
+ code: 1,
326
+ };
327
+ }
328
+
329
+ if (opts.confirm !== true) {
330
+ // Echo-before-run. The command is arbitrary shell read out of a
331
+ // markdown field, so the operator sees the exact string before it runs
332
+ // and the confirmation is this-turn rather than implied by the id.
333
+ return {
334
+ outcome: 'rendered',
335
+ report:
336
+ `${id} is class 0 and would run, from the repo root:\n\n` +
337
+ ` ${command}\n\n` +
338
+ 'Nothing has been executed. Re-run with `--confirm` to execute it and ' +
339
+ 'record the evidence at the blocker.\n',
340
+ code: 0,
341
+ };
342
+ }
343
+
344
+ const r = spawnSync(command, {
345
+ shell: true,
346
+ encoding: 'utf-8',
347
+ cwd: path.dirname(path.dirname(roadmapRoot)),
348
+ timeout: 120_000,
349
+ });
350
+ const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
351
+ if (r.error !== undefined || r.status !== 0) {
352
+ // `r.error` is read, not just the status: a spawn failure or a
353
+ // maxBuffer overflow leaves status null with empty output, and
354
+ // "exited null" with no diagnostic is illegible on the one path in
355
+ // this command that writes.
356
+ const why =
357
+ r.error !== undefined
358
+ ? `could not run (${r.error.message})`
359
+ : `exited ${r.status ?? 'null'}`;
360
+ return {
361
+ outcome: 'failed',
362
+ report:
363
+ `${id}: \`${command}\` ${why}. The gate is unchanged — ` +
364
+ 'a blocker resolved by a failed command is worse than an open one.\n' +
365
+ output.split(/\r?\n/).slice(0, 10).join('\n') +
366
+ '\n',
367
+ code: 1,
368
+ };
369
+ }
370
+ const when = today(now);
371
+ if (!appendEvidence(file, id, command, output, when)) {
372
+ return {
373
+ outcome: 'failed',
374
+ report:
375
+ `${id}: \`${command}\` succeeded, but its entry in ${rel} could not be ` +
376
+ 'rewritten (no Status field found). Nothing was changed.\n',
377
+ code: 1,
378
+ };
379
+ }
380
+ return {
381
+ outcome: 'resolved',
382
+ report:
383
+ `${id} resolved. Ran \`${command}\` (exit 0); evidence appended in ${rel} ` +
384
+ `and the status flipped to resolved ${when}.\n` +
385
+ // The dashboard is derived from the file this just rewrote, so it
386
+ // is now stale. Saying so is the same follow-up `renderResumed`
387
+ // already prints for its own file-move suggestion.
388
+ 'Run `agent-config roadmap:progress` — the dashboard is derived from the ' +
389
+ 'file this rewrote and is now stale.\n',
390
+ code: 0,
391
+ };
392
+ }
393
+
394
+ export { execute, consentLine, commandOf, locate, appendEvidence, LEDGER_REL };
395
+ export type { ExecuteResult, Outcome };