mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,156 @@
1
+ /**
2
+ * import-graph.js — the shared static-import graph seam (Story #4902).
3
+ *
4
+ * Extracted verbatim from `check-arch-cycles.js`, which owned the only
5
+ * import-graph builder in the repository and kept it private to its own
6
+ * cycle ratchet. A second consumer now needs the same graph for a very
7
+ * different question — `audit-baselines.js` ranks hotspot files by import
8
+ * in-degree — and re-deriving "which module imports which" a second time
9
+ * would guarantee the two answers drift.
10
+ *
11
+ * Note this is a **module** graph, not the task/DAG graph in `lib/Graph.js`;
12
+ * the two are unrelated despite the shared word.
13
+ *
14
+ * The extraction is behaviour-preserving: `check-arch-cycles.js` imports
15
+ * these helpers and re-exports them, so its public surface (and the ratchet's
16
+ * output) is unchanged.
17
+ *
18
+ * @module lib/import-graph
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+
24
+ /**
25
+ * Default scan roots making up the project's distributed surface — the
26
+ * directories published to npm via `package.json` `files[]`. Resolving
27
+ * them into one graph (relativized against the repo root) means a cycle
28
+ * crossing two roots is visible to consumers of the graph.
29
+ *
30
+ * @type {string[]}
31
+ */
32
+ export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
33
+
34
+ /**
35
+ * Recursively collect `.js` files under `rootDir`, skipping
36
+ * `node_modules`. Returns absolute paths, sorted for determinism.
37
+ *
38
+ * @param {string} rootDir
39
+ * @returns {string[]}
40
+ */
41
+ export function collectJsFiles(rootDir) {
42
+ const out = [];
43
+ const walk = (dir) => {
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ } catch {
48
+ return;
49
+ }
50
+ for (const entry of entries) {
51
+ if (entry.name === 'node_modules') continue;
52
+ const full = path.join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ walk(full);
55
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
56
+ out.push(full);
57
+ }
58
+ }
59
+ };
60
+ walk(rootDir);
61
+ return out.sort();
62
+ }
63
+
64
+ const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
65
+
66
+ /**
67
+ * Pure helper: extract relative static-import specifiers from source text.
68
+ *
69
+ * @param {string} source
70
+ * @returns {string[]}
71
+ */
72
+ export function parseRelativeImports(source) {
73
+ const specs = [];
74
+ for (const m of source.matchAll(IMPORT_RE)) {
75
+ specs.push(m[1]);
76
+ }
77
+ return specs;
78
+ }
79
+
80
+ /**
81
+ * Build a directed import graph over the given files. Node identity is the
82
+ * file path relative to `rootDir`, posix-separated, so the graph (and any
83
+ * cycles found in it) serializes identically across platforms. Edges that
84
+ * resolve outside the scanned file set are dropped.
85
+ *
86
+ * @param {string[]} files absolute paths
87
+ * @param {string} rootDir
88
+ * @param {{ readFile?: (p: string) => string }} [opts]
89
+ * @returns {Map<string, string[]>}
90
+ */
91
+ export function buildGraph(files, rootDir, { readFile } = {}) {
92
+ const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
93
+ const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
94
+ const idSet = new Set(files.map(toId));
95
+ const graph = new Map();
96
+ for (const file of files) {
97
+ const id = toId(file);
98
+ let source;
99
+ try {
100
+ source = read(file);
101
+ } catch {
102
+ graph.set(id, []);
103
+ continue;
104
+ }
105
+ const edges = [];
106
+ for (const spec of parseRelativeImports(source)) {
107
+ const target = path
108
+ .relative(rootDir, path.resolve(path.dirname(file), spec))
109
+ .split(path.sep)
110
+ .join('/');
111
+ if (idSet.has(target) && target !== id) edges.push(target);
112
+ }
113
+ graph.set(id, [...new Set(edges)].sort());
114
+ }
115
+ return graph;
116
+ }
117
+
118
+ /**
119
+ * Build the whole-repository import graph by scanning the roots that exist
120
+ * under `cwd`. Returns `null` when none of the roots is present — the
121
+ * "no resolvable import graph" degradation every consumer must tolerate
122
+ * rather than treating an absent graph as a graph with no edges.
123
+ *
124
+ * @param {string} cwd repository root the ids are relativized against
125
+ * @param {{ roots?: string[] }} [opts]
126
+ * @returns {Map<string, string[]> | null}
127
+ */
128
+ export function resolveRepoGraph(cwd, { roots = DEFAULT_ROOTS } = {}) {
129
+ const present = roots
130
+ .map((dir) => path.resolve(cwd, dir))
131
+ .filter((dir) => fs.existsSync(dir));
132
+ if (present.length === 0) return null;
133
+ const files = present.flatMap((dir) => collectJsFiles(dir));
134
+ if (files.length === 0) return null;
135
+ return buildGraph(files, path.resolve(cwd));
136
+ }
137
+
138
+ /**
139
+ * Count inbound edges per node. Nodes with no inbound edge are present in
140
+ * the result with a count of 0, so callers never have to distinguish
141
+ * "unknown module" from "module nothing imports".
142
+ *
143
+ * @param {Map<string, string[]> | null} graph
144
+ * @returns {Map<string, number>} empty when `graph` is null
145
+ */
146
+ export function computeInDegree(graph) {
147
+ const degrees = new Map();
148
+ if (!graph) return degrees;
149
+ for (const node of graph.keys()) degrees.set(node, 0);
150
+ for (const edges of graph.values()) {
151
+ for (const target of edges) {
152
+ degrees.set(target, (degrees.get(target) ?? 0) + 1);
153
+ }
154
+ }
155
+ return degrees;
156
+ }
@@ -111,6 +111,22 @@ export const META_LABELS = {
111
111
  CONSUMER_IMPROVEMENT: 'meta::consumer-improvement',
112
112
  };
113
113
 
114
+ /**
115
+ * Risk-axis labels. Planning/audit metadata only — `risk::high` carries no
116
+ * runtime behaviour (the single runtime pause point is `agent::blocked`, see
117
+ * `.agents/instructions.md` § 1.J); it marks a ticket a human should look at
118
+ * first. Applied by the audit filer to a Story whose merge contains a Critical
119
+ * finding.
120
+ *
121
+ * Named here because it was a bare `'risk::high'` string literal in
122
+ * `audit-to-stories/build-story-body.js` and defined by no taxonomy at all —
123
+ * neither `LABEL_TAXONOMY` nor the audit bootstrap created it, so the audit
124
+ * filer generated a label the repo did not have (Story #4877).
125
+ */
126
+ export const RISK_LABELS = {
127
+ HIGH: 'risk::high',
128
+ };
129
+
114
130
  /**
115
131
  * Planning-axis labels (Epic #2880 F7). Currently scoped to the
116
132
  * `planning::healthcheck-waived` operator-applied waiver — a historical
@@ -147,6 +163,7 @@ export const PLANNING_HEALTHCHECK_WAIVED = 'planning::healthcheck-waived';
147
163
  */
148
164
  export const LABEL_COLORS = {
149
165
  TYPE: '#7057FF',
166
+ RISK_HIGH: '#B60205',
150
167
  AGENT: '#0E8A16',
151
168
  STATUS_BLOCKED: '#D93F0B',
152
169
  ACCEPTANCE: '#FBCA04',
@@ -534,18 +534,33 @@ function frictionForTerminal(envelope) {
534
534
  * Emit the friction record (if any) implied by a terminal envelope.
535
535
  * Best-effort; never throws.
536
536
  *
537
+ * `tool` is the CALLER's name, threaded in rather than assumed: TWO CLIs emit
538
+ * terminal envelopes — `single-story-close.js` (plus its runner) and
539
+ * `single-story-confirm-merge.js` — and `retro-proposals.js` reads
540
+ * `emitter.tool` (via {@link normalizeGatheredSignal}) to name the surface a
541
+ * candidate came from, so hard-coding the close name here attributed every
542
+ * confirm-merge record to a CLI that never ran and misdirected the follow-up.
543
+ * Both callers already declare their name once via `runAsCli({ source })`;
544
+ * pass that same string. The default keeps the close paths — the original
545
+ * callers, which pass none — emitting exactly what they always did.
546
+ *
537
547
  * @param {object} args
538
548
  * @param {object} args.envelope
549
+ * @param {string} [args.tool] Emitting surface (default `single-story-close`).
539
550
  * @param {object} [args.config]
540
551
  * @returns {Promise<boolean>} true when a record was appended.
541
552
  */
542
- export async function emitTerminalFriction({ envelope, config } = {}) {
553
+ export async function emitTerminalFriction({
554
+ envelope,
555
+ tool = 'single-story-close',
556
+ config,
557
+ } = {}) {
543
558
  const verdict = frictionForTerminal(envelope);
544
559
  if (!verdict) return false;
545
560
  return emitRuntimeFriction({
546
561
  storyId: envelope?.storyId,
547
562
  category: verdict.category,
548
- tool: 'single-story-close',
563
+ tool,
549
564
  details: verdict.details,
550
565
  config,
551
566
  });
@@ -17,6 +17,9 @@
17
17
  * - `.agentrc.json`
18
18
  * - `.claude/`
19
19
  * - `node .agents/scripts/`
20
+ * …or (Story #4916) names one of the CLI entry points shipped at the
21
+ * top level of `.agents/scripts/` by **bare basename**, so recognition
22
+ * does not depend on how the caller spelled the reference.
20
23
  * - Anything else (or empty input) defaults to `"consumer"`. The default
21
24
  * is intentional — most friction comes from consumer code touching
22
25
  * framework tooling, and we'd rather under-tag than mis-route a
@@ -47,6 +50,133 @@ const FRAMEWORK_PREFIXES = Object.freeze([
47
50
  'node .agents/scripts/',
48
51
  ]);
49
52
 
53
+ /**
54
+ * Basenames of the CLI entry points shipped at the top level of
55
+ * `.agents/scripts/` (Story #4916).
56
+ *
57
+ * The prefix scan above recognises a framework script only when the caller
58
+ * spelled a path — `node .agents/scripts/acceptance-eval.js` classifies
59
+ * `framework`, while the same script named bare (`acceptance-eval.js --story
60
+ * 4901`) matched no prefix and fell through to `consumer`, so a real framework
61
+ * defect was routed as consumer-actionable and discarded. Recognition must not
62
+ * depend on how the caller spelled the reference.
63
+ *
64
+ * Kept as **static data**, not a directory read: `classifySignalSource` is
65
+ * called once per signal and MUST stay pure — no filesystem I/O at classify
66
+ * time. The sync test in
67
+ * `tests/lib/observability/source-classifier.test.js` reads the real directory
68
+ * and fails when a script is added, renamed, or removed without this list
69
+ * being updated, so the set cannot go stale and silently restore the blind
70
+ * spot.
71
+ *
72
+ * Scoped to the top level deliberately: those are the entry points a command
73
+ * line names. Library modules under `.agents/scripts/lib/` carry generic
74
+ * basenames (`index.js`, `config.js`) that a consumer command could plausibly
75
+ * name, and widening to them would trade one mis-route for another.
76
+ *
77
+ * @type {readonly string[]}
78
+ */
79
+ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
80
+ 'acceptance-eval.js',
81
+ 'agents-bootstrap-github.js',
82
+ 'apply-quality-bootstrap.js',
83
+ 'audit-baselines.js',
84
+ 'audit-labels-bootstrap.js',
85
+ 'audit-to-stories.js',
86
+ 'boot-sweep.js',
87
+ 'bootstrap.js',
88
+ 'check-action-pinning.js',
89
+ 'check-arch-cycles.js',
90
+ 'check-baseline-drift.js',
91
+ 'check-baselines.js',
92
+ 'check-context-budget.js',
93
+ 'check-cyclomatic.js',
94
+ 'check-dead-exports.js',
95
+ 'check-doc-links.js',
96
+ 'check-gherkin-placeholders.js',
97
+ 'check-lifecycle-doc-drift.js',
98
+ 'check-lifecycle-lint.js',
99
+ 'check-schema-references.js',
100
+ 'check-test-temp-hygiene.js',
101
+ 'check-windows-git-perf.js',
102
+ 'check-workflow-citations.js',
103
+ 'check-workflow-cli-lint.js',
104
+ 'check-workflow-timeouts.js',
105
+ 'cleanup-repo-test-temp.js',
106
+ 'coverage-capture.js',
107
+ 'deliver-light.js',
108
+ 'deliver-recover.js',
109
+ 'diagnose-friction.js',
110
+ 'diagnose.js',
111
+ 'drain-pending-cleanup.js',
112
+ 'evidence-gate.js',
113
+ 'generate-config-docs.js',
114
+ 'generate-lens-checklists.js',
115
+ 'generate-lifecycle-docs.js',
116
+ 'generate-skills-index.js',
117
+ 'generate-workflows-doc.js',
118
+ 'git-cleanup.js',
119
+ 'install-matrix-assert.js',
120
+ 'lint-baseline.js',
121
+ 'lint-issue-body.js',
122
+ 'lint-label-vocabulary.js',
123
+ 'mandrel-update-preflight.js',
124
+ 'nav-registry-diff.js',
125
+ 'notify.js',
126
+ 'plan-context.js',
127
+ 'plan-critics.js',
128
+ 'plan-persist.js',
129
+ 'plan-run-epilogue.js',
130
+ 'post-structured-comment.js',
131
+ 'pr-watch-with-update.js',
132
+ 'provision-git-hooks.js',
133
+ 'quality-preview.js',
134
+ 'quality-watch.js',
135
+ 'resolve-doc-tiers.js',
136
+ 'resolve-stories.js',
137
+ 'resync-status-column.js',
138
+ 'run-coverage.js',
139
+ 'run-lint.js',
140
+ 'run-test-profile.js',
141
+ 'run-tests.js',
142
+ 'run-verify.js',
143
+ 'signals-view.js',
144
+ 'single-story-close.js',
145
+ 'single-story-confirm-merge.js',
146
+ 'single-story-init.js',
147
+ 'stories-wave-tick.js',
148
+ 'story-plan.js',
149
+ 'sync-agentrc.js',
150
+ 'sync-branch-from-base.js',
151
+ 'sync-claude-agents.js',
152
+ 'sync-claude-commands.js',
153
+ 'test-isolate.js',
154
+ 'test-wrapper.js',
155
+ 'update-coverage-baseline.js',
156
+ 'update-crap-baseline.js',
157
+ 'update-duplication-baseline.js',
158
+ 'update-maintainability-baseline.js',
159
+ 'update-ticket-state.js',
160
+ 'validate-docs-freshness.js',
161
+ 'validate-skills.js',
162
+ ]);
163
+
164
+ /**
165
+ * Membership index over {@link FRAMEWORK_SCRIPT_BASENAMES}, built once at
166
+ * module load so the per-signal scan is a hash lookup.
167
+ *
168
+ * @type {ReadonlySet<string>}
169
+ */
170
+ const FRAMEWORK_SCRIPT_BASENAME_SET = new Set(FRAMEWORK_SCRIPT_BASENAMES);
171
+
172
+ /**
173
+ * Characters a shell-ish command line can wrap a token in. Stripped from both
174
+ * ends before the membership test so `"acceptance-eval.js",` still resolves.
175
+ *
176
+ * @type {RegExp}
177
+ */
178
+ const TOKEN_TRIM = /^[\s'"`(,;:]+|[\s'"`),;:]+$/g;
179
+
50
180
  /**
51
181
  * Normalise a value to a string for prefix scanning. Anything that is not
52
182
  * a string (undefined, null, numbers, objects) becomes the empty string,
@@ -78,6 +208,44 @@ function containsFrameworkPrefix(haystack) {
78
208
  return false;
79
209
  }
80
210
 
211
+ /**
212
+ * Return true when `haystack` names a top-level framework script by **bare
213
+ * basename** — no path segment at all (Story #4916).
214
+ *
215
+ * Whitespace-tokenised and matched whole: only a token that *equals* a known
216
+ * basename counts. A token carrying any path segment is deliberately left to
217
+ * {@link containsFrameworkPrefix} — `./tools/notify.js` is the consumer's own
218
+ * script and must stay `consumer`, while `.agents/scripts/notify.js` already
219
+ * matches a prefix. Likewise `my-notify.js` is not `notify.js`.
220
+ *
221
+ * @param {string} haystack
222
+ * @returns {boolean}
223
+ */
224
+ function containsFrameworkScriptBasename(haystack) {
225
+ if (haystack.length === 0) return false;
226
+ for (const rawToken of haystack.split(/\s+/)) {
227
+ const token = rawToken.replace(TOKEN_TRIM, '');
228
+ if (FRAMEWORK_SCRIPT_BASENAME_SET.has(token)) return true;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * True when `haystack` names the framework's own surface, by either
235
+ * recognition route: a path prefix, or a bare framework-script basename.
236
+ *
237
+ * Purely additive over {@link containsFrameworkPrefix} — every string that
238
+ * matched a prefix still matches here, so widening can only ever move a
239
+ * classification from `consumer` to `framework`, never the reverse.
240
+ *
241
+ * @param {string} haystack
242
+ * @returns {boolean}
243
+ */
244
+ function namesFrameworkSurface(haystack) {
245
+ if (containsFrameworkPrefix(haystack)) return true;
246
+ return containsFrameworkScriptBasename(haystack);
247
+ }
248
+
81
249
  /**
82
250
  * Classify a friction signal as `"framework"` or `"consumer"`.
83
251
  *
@@ -92,6 +260,10 @@ function containsFrameworkPrefix(haystack) {
92
260
  * `node .agents/scripts/single-story-init.js` — that's framework friction even
93
261
  * though the failing test path lives under the consumer.
94
262
  *
263
+ * Either input matches on a framework path prefix **or** on a bare framework
264
+ * script basename (Story #4916), so `single-story-close.js --story 4906` is
265
+ * recognised exactly like its fully-pathed spelling.
266
+ *
95
267
  * @param {unknown} failingPath The path of the file or directory the
96
268
  * signal blames (e.g. `"tests/foo.test.js"`).
97
269
  * @param {unknown} command The command line the signal blames
@@ -101,8 +273,8 @@ function containsFrameworkPrefix(haystack) {
101
273
  export function classifyPathSource(failingPath, command) {
102
274
  const path = toScanString(failingPath);
103
275
  const cmd = toScanString(command);
104
- if (containsFrameworkPrefix(path)) return 'framework';
105
- if (containsFrameworkPrefix(cmd)) return 'framework';
276
+ if (namesFrameworkSurface(path)) return 'framework';
277
+ if (namesFrameworkSurface(cmd)) return 'framework';
106
278
  return 'consumer';
107
279
  }
108
280
 
@@ -229,5 +401,6 @@ export function classifySignalSource(record) {
229
401
 
230
402
  export const __testing = Object.freeze({
231
403
  FRAMEWORK_PREFIXES,
404
+ FRAMEWORK_SCRIPT_BASENAMES,
232
405
  TOOL_DEGRADED_CATEGORY,
233
406
  });
@@ -17,6 +17,20 @@
17
17
  * from the cap enforcement path — a critic that always reports `round: 1`
18
18
  * can no longer defeat the bounded-loop guarantee.
19
19
  *
20
+ * ## Reading a verdict is not a round (Story #4874)
21
+ *
22
+ * Counting signals made *observation* costly: re-running the gate over an
23
+ * already-scored verdict — a resumed sub-agent re-reading its own verdict,
24
+ * an operator re-checking why the loop said `redraft` — appended another
25
+ * signal and advanced the derived round, so a `redraft` could escalate to
26
+ * `block` with no work in between. Every appended signal therefore carries
27
+ * a `details.verdictFingerprint` content-addressing the verdict it scored,
28
+ * and {@link resolveAcceptanceEvalRound} replays the round already recorded
29
+ * for that fingerprint instead of minting a new one. A replay is observably
30
+ * free: the round does not advance and no signal is appended. New work
31
+ * produces different verdict content, hence a new fingerprint, hence a
32
+ * genuine round.
33
+ *
20
34
  * ## The three terminal actions
21
35
  *
22
36
  * - `proceed` — every criterion is `met`. The Story may flip to
@@ -39,6 +53,7 @@
39
53
  * possible action is `block`.
40
54
  */
41
55
 
56
+ import { createHash } from 'node:crypto';
42
57
  import { readFileSync } from 'node:fs';
43
58
 
44
59
  import { runArtifactPath, signalsFile } from '../config/temp-paths.js';
@@ -112,7 +127,7 @@ function partitionCriteria(criteria) {
112
127
  * The resolved (already-clamped) redraft ceiling from
113
128
  * `getAcceptanceEval(config).maxRounds`.
114
129
  * @param {number} [args.round]
115
- * The current round number, derived via `deriveAcceptanceEvalRound`.
130
+ * The current round number, derived via `resolveAcceptanceEvalRound`.
116
131
  * Defaults to 1 when absent or invalid.
117
132
  * @returns {{
118
133
  * decision: 'proceed' | 'redraft' | 'block',
@@ -172,6 +187,7 @@ export function buildAcceptanceEvalSignal({
172
187
  outcome,
173
188
  phase = 'implement',
174
189
  clusterId = null,
190
+ verdictFingerprint = null,
175
191
  }) {
176
192
  return {
177
193
  kind: 'acceptance-eval',
@@ -186,6 +202,13 @@ export function buildAcceptanceEvalSignal({
186
202
  phase,
187
203
  emitter: { tool: 'acceptance-eval.js' },
188
204
  details: {
205
+ // Content address of the verdict this signal scored. The replay guard
206
+ // in `resolveAcceptanceEvalRound` matches on it, so a re-read of an
207
+ // already-scored verdict reuses its round instead of minting one.
208
+ ...(typeof verdictFingerprint === 'string' &&
209
+ verdictFingerprint.length > 0
210
+ ? { verdictFingerprint }
211
+ : {}),
189
212
  decision: outcome.decision,
190
213
  round: outcome.round,
191
214
  cap: outcome.cap,
@@ -201,29 +224,29 @@ export function buildAcceptanceEvalSignal({
201
224
  }
202
225
 
203
226
  /**
204
- * Derive the current acceptance-eval round for a Story by counting the
205
- * `acceptance-eval` signals already appended to the Story's
206
- * `signals.ndjson` (Story #4019). Round = prior-signal count + 1, so the
207
- * first run reports round 1 and each completed round (which appends one
208
- * signal via `acceptance-eval.js`) advances the derived round by one.
227
+ * Read the `acceptance-eval` records already appended to the Story's (or AC
228
+ * cluster's) `signals.ndjson`, in append order the prior rounds
229
+ * {@link resolveAcceptanceEvalRound} counts from (Story #4019).
209
230
  *
210
- * The derivation is restart-safe: the ledger lives on disk, so a subagent
211
- * that dies mid-loop and restarts still observes every prior round. A
212
- * missing or malformed ledger degrades to round 1 (no prior rounds), and
213
- * malformed lines are skipped — observability corruption never wedges the
214
- * gate.
231
+ * The read is restart-safe: the ledger lives on disk, so a subagent that
232
+ * dies mid-loop and restarts still observes every prior round. A missing or
233
+ * unreadable ledger degrades to "no prior rounds" and malformed lines are
234
+ * skipped — observability corruption never wedges the gate.
215
235
  *
216
236
  * @param {object} args
217
237
  * @param {number|null} args.epicId Parent Epic ID, or `null` for a
218
238
  * standalone Story (routes to `<tempRoot>/standalone/stories/...`).
219
239
  * @param {number} args.storyId
240
+ * @param {string|null} [args.clusterId] AC-cluster id (Epic #4475 M4-B).
220
241
  * @param {object} [args.config] Resolved config (tempRoot resolution).
221
242
  * @param {(p: string) => string} [args.readFile] Injectable reader (tests).
222
243
  * @param {(eid: number|null, sid: number, config?: object) => string} [args.signalsPathResolver]
223
244
  * Injectable path resolver (tests). Defaults to `signalsFile`.
224
- * @returns {number} The 1-based current round.
245
+ * @param {(eid: number, config?: object) => string} [args.epicSignalsPathResolver]
246
+ * Injectable epic-stream path resolver (tests).
247
+ * @returns {object[]} The matching records, oldest first.
225
248
  */
226
- export function deriveAcceptanceEvalRound({
249
+ function readPriorAcceptanceEvalRecords({
227
250
  epicId,
228
251
  storyId,
229
252
  clusterId = null,
@@ -249,10 +272,10 @@ export function deriveAcceptanceEvalRound({
249
272
  : readFile(signalsPathResolver(epicId ?? null, storyId, config));
250
273
  } catch (_err) {
251
274
  // No ledger yet → no prior rounds.
252
- return 1;
275
+ return [];
253
276
  }
254
277
 
255
- let priorRounds = 0;
278
+ const records = [];
256
279
  for (const line of String(text).split('\n')) {
257
280
  const trimmed = line.trim();
258
281
  if (trimmed === '') continue;
@@ -269,7 +292,77 @@ export function deriveAcceptanceEvalRound({
269
292
  } else if (record.storyId !== storyId) {
270
293
  continue;
271
294
  }
272
- priorRounds += 1;
295
+ records.push(record);
273
296
  }
274
- return priorRounds + 1;
297
+ return records;
298
+ }
299
+
300
+ /**
301
+ * Content-address a verdict so re-scoring the same verdict is recognisable
302
+ * as a re-read rather than a new round (Story #4874).
303
+ *
304
+ * The fingerprint covers exactly what the decision depends on — the ordered
305
+ * per-criterion `index` / `criterion` / `verdict` / `evidence` tuples — and
306
+ * deliberately nothing else: the verdict's self-reported `round`, its
307
+ * timestamp, and any authoring scratch must not make an unchanged
308
+ * evaluation look like a new one. Conversely, real rework changes at least
309
+ * one criterion's verdict or its evidence, so a genuine re-evaluation
310
+ * always fingerprints differently.
311
+ *
312
+ * @param {{ criteria?: Array<object> }} verdict
313
+ * @returns {string} 16 hex chars of a SHA-256 over the canonical form.
314
+ */
315
+ export function computeVerdictFingerprint(verdict) {
316
+ const criteria = Array.isArray(verdict?.criteria) ? verdict.criteria : [];
317
+ const canonical = criteria.map((c) => [
318
+ Number.isInteger(c?.index) ? c.index : null,
319
+ typeof c?.criterion === 'string' ? c.criterion : '',
320
+ typeof c?.verdict === 'string' ? c.verdict : '',
321
+ typeof c?.evidence === 'string' ? c.evidence : '',
322
+ ]);
323
+ return createHash('sha256')
324
+ .update(JSON.stringify(canonical))
325
+ .digest('hex')
326
+ .slice(0, 16);
327
+ }
328
+
329
+ /**
330
+ * Resolve the round a verdict should be scored under, distinguishing a
331
+ * genuine evaluation from a re-read of an already-scored verdict
332
+ * (Story #4874). A genuine round is `prior-signal count + 1`, so the first
333
+ * run reports round 1 and each completed round advances it by one.
334
+ *
335
+ * When the ledger already carries an `acceptance-eval` signal whose
336
+ * `details.verdictFingerprint` matches, this invocation is a **replay**: it
337
+ * reports that signal's round and tells the caller not to append a second
338
+ * one, so reading is observably free and cannot escalate a `redraft` into a
339
+ * `block`. Otherwise it is a genuine round and the counter advances exactly
340
+ * as the pre-#4874 count-based derivation always did.
341
+ *
342
+ * Signals written before this field existed carry no fingerprint; they can
343
+ * never match, so legacy ledgers keep their count-based behaviour.
344
+ *
345
+ * @param {object} args — {@link readPriorAcceptanceEvalRecords}'s arguments
346
+ * plus:
347
+ * @param {string} args.verdictFingerprint
348
+ * @returns {{ round: number, replay: boolean }}
349
+ */
350
+ export function resolveAcceptanceEvalRound(args) {
351
+ const { verdictFingerprint } = args;
352
+ const records = readPriorAcceptanceEvalRecords(args);
353
+ const priorIndex =
354
+ typeof verdictFingerprint === 'string' && verdictFingerprint.length > 0
355
+ ? records.findIndex(
356
+ (r) => r?.details?.verdictFingerprint === verdictFingerprint,
357
+ )
358
+ : -1;
359
+ if (priorIndex === -1) {
360
+ return { round: records.length + 1, replay: false };
361
+ }
362
+ const recorded = records[priorIndex]?.details?.round;
363
+ return {
364
+ round:
365
+ Number.isInteger(recorded) && recorded >= 1 ? recorded : priorIndex + 1,
366
+ replay: true,
367
+ };
275
368
  }
@@ -78,7 +78,7 @@
78
78
  * @typedef {'fresh'|'inline'} CeremonyMode
79
79
  * @typedef {'fresh-critic'|'inline-self-eval'} VerdictOwner
80
80
  * @typedef {import('./review-depth.js').ChangeLevel} ChangeLevel
81
- * @typedef {'minimal'|'standard'|'strict'} CeremonyProfile
81
+ * @typedef {(typeof CEREMONY_PROFILES)[number]} CeremonyProfile
82
82
  */
83
83
 
84
84
  /**
@@ -93,12 +93,18 @@ export function verdictOwnerForMode(mode) {
93
93
  return mode === 'fresh' ? 'fresh-critic' : 'inline-self-eval';
94
94
  }
95
95
 
96
- /** @type {readonly CeremonyProfile[]} */
97
- export const CEREMONY_PROFILES = Object.freeze([
98
- 'minimal',
99
- 'standard',
100
- 'strict',
101
- ]);
96
+ /**
97
+ * The ceremony-profile vocabulary — the **single** place the three profile
98
+ * names are written. `normalizeCeremonyProfile` is its reader and the
99
+ * `CeremonyProfile` typedef is derived from it, so adding a profile is a
100
+ * one-line change here (Story #4926).
101
+ *
102
+ * @type {readonly ['minimal', 'standard', 'strict']}
103
+ */
104
+ const CEREMONY_PROFILES = Object.freeze(['minimal', 'standard', 'strict']);
105
+
106
+ /** The profile an absent or unrecognized value degrades to. */
107
+ const DEFAULT_CEREMONY_PROFILE = 'standard';
102
108
 
103
109
  /**
104
110
  * Normalize an operator/config ceremony profile. Unknown values degrade to
@@ -107,11 +113,10 @@ export const CEREMONY_PROFILES = Object.freeze([
107
113
  * @param {unknown} value
108
114
  * @returns {CeremonyProfile}
109
115
  */
110
- export function normalizeCeremonyProfile(value) {
111
- if (value === 'minimal' || value === 'standard' || value === 'strict') {
112
- return value;
113
- }
114
- return 'standard';
116
+ function normalizeCeremonyProfile(value) {
117
+ return CEREMONY_PROFILES.includes(/** @type {CeremonyProfile} */ (value))
118
+ ? /** @type {CeremonyProfile} */ (value)
119
+ : DEFAULT_CEREMONY_PROFILE;
115
120
  }
116
121
 
117
122
  /**