mandrel 2.25.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -1,57 +1,97 @@
1
+ /**
2
+ * update-duplication-baseline.js — manual refresh CLI for the
3
+ * code-duplication (DRY) baseline (Story #3664).
4
+ *
5
+ * Story #4944: this CLI is now a thin wrapper around
6
+ * `refreshBaseline({ kind: 'duplication' })` from
7
+ * `.agents/scripts/lib/baselines/refresh-service.js`, completing the Epic
8
+ * #2173 migration — duplication was the last kind still assembling its own
9
+ * writer call via the legacy `buildWriterScopeArgs` path. Scope resolution,
10
+ * prior-envelope reading, epsilon damping, envelope assembly, and
11
+ * persistence all flow through the unified service.
12
+ *
13
+ * The migration is what makes this CLI's documented surface true. Before it,
14
+ * `--full-scope` was advertised and parsed by nobody, and the no-flag default
15
+ * was a full rewrite rather than the diff-scoped refresh the usage text
16
+ * described.
17
+ *
18
+ * Surface:
19
+ *
20
+ * - `--baseline <path>`: write somewhere other than the configured
21
+ * `delivery.quality.gates.duplication.baselinePath`.
22
+ * - `--diff-scope <ref>`: scope the refresh to files changed between
23
+ * `<ref>` and HEAD. Out-of-scope rows are preserved verbatim from the
24
+ * prior on-disk baseline.
25
+ * - `--full-scope`: regenerate every row (no out-of-scope merge).
26
+ * - With no scope flag: scope is derived from `git diff --name-only
27
+ * origin/main..HEAD` (the service's default `baseRef..headRef`), matching
28
+ * `update-crap-baseline.js` / `update-maintainability-baseline.js` /
29
+ * `update-coverage-baseline.js`.
30
+ *
31
+ * **Scope is a write-side filter here, not a scan-side one.** jscpd detects
32
+ * clones pairwise, so the scan always covers the whole target tree even in
33
+ * diff mode — see `buildDefaultDuplicationScorer` in the refresh service for
34
+ * why narrowing the scan would drop clones between a changed file and an
35
+ * unchanged one. What a scope flag narrows is which rows the refresh is
36
+ * allowed to rewrite.
37
+ *
38
+ * Exits non-zero only when the scanner itself crashes. An empty result (no
39
+ * detected clones) still writes an envelope with `rows: []` so downstream
40
+ * `check-baselines` can tell "intentional empty baseline" apart from "no
41
+ * baseline yet".
42
+ */
43
+
1
44
  // Fail-fast if the framework's runtime deps are not installed — must be the
2
45
  // first import so the check runs before any third-party-importing sibling
3
46
  // module is evaluated (Story #3432).
4
47
  import './lib/runtime-deps/ensure-installed.js';
5
- import { createRequire } from 'node:module';
6
48
  import path from 'node:path';
7
- import { buildWriterScopeArgs } from './lib/baselines/diff-scope-cli.js';
8
- import { scanDuplication } from './lib/baselines/duplication-scanner.js';
9
- import { write, writeFile } from './lib/baselines/writer.js';
49
+ import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
50
+ import { refreshBaseline } from './lib/baselines/refresh-service.js';
51
+ import { runAsCli } from './lib/cli-utils.js';
10
52
  import { getBaselineEpsilon } from './lib/config/quality.js';
11
53
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
12
54
  import { Logger } from './lib/Logger.js';
13
55
 
14
56
  /**
15
- * CLI: scan score save the code-duplication (DRY) baseline (Story #3664).
16
- *
17
- * Writes the canonical duplication baseline at the path resolved from
18
- * `delivery.quality.gates.duplication.baselinePath` (default
19
- * `baselines/duplication.json`), or the path supplied via `--baseline <path>`.
20
- * Output is a deterministic, kernel-stamped envelope produced by the shared
21
- * writer — every row path is canonicalised, the per-kind rollup math runs,
22
- * and the envelope is schema-validated before persisting.
23
- *
24
- * Mirrors `update-crap-baseline.js`: thin CLI shell + shared writer funnel.
25
- * The duplication scan delegates to jscpd's `detectClones` (a pure clone
26
- * detector with no test coupling), wrapped by `scanDuplication` so the
27
- * parse→envelope path is unit-testable with the scanner mocked.
28
- *
29
- * Exits non-zero only when the scanner itself crashes. An empty result (no
30
- * detected clones) still writes an envelope with `rows: []` so downstream
31
- * `check-baselines` can tell "intentional empty baseline" apart from "no
32
- * baseline yet".
57
+ * Usage block for `--help`. This CLI *writes* on invocation, so the help
58
+ * branch must short-circuit before `main` runs rather than inside it —
59
+ * `runAsCli` answers help first, which makes "a usage probe never mutates a
60
+ * baseline" structural instead of a check `main` has to remember.
33
61
  */
34
-
35
- const require = createRequire(import.meta.url);
62
+ const USAGE = {
63
+ invocation:
64
+ 'node .agents/scripts/update-duplication-baseline.js [--baseline <path>] [--full-scope | --diff-scope <ref>]',
65
+ summary:
66
+ 'Scan → score → write the code-duplication (DRY) baseline. With no scope flag the refresh is scoped to the files changed in `origin/main..HEAD`; out-of-scope rows are preserved verbatim.',
67
+ flags: [
68
+ [
69
+ '--baseline <path>',
70
+ 'Write to this path instead of `delivery.quality.gates.duplication.baselinePath`.',
71
+ ],
72
+ [
73
+ '--full-scope',
74
+ 'Rescan every file in every target dir (no out-of-scope merge).',
75
+ ],
76
+ [
77
+ '--diff-scope <ref>',
78
+ 'Scope the refresh to files changed between <ref> and HEAD. Incompatible with --full-scope.',
79
+ ],
80
+ ],
81
+ notes: [
82
+ 'Backed by jscpd; the scan reads the working tree and runs no test suite.',
83
+ 'Clone detection is pairwise, so the scan always covers the whole target tree — a scope flag narrows which rows are rewritten, not what is scanned.',
84
+ ],
85
+ };
36
86
 
37
87
  /**
38
- * Resolve jscpd's `detectClones` lazily. The jscpd ESM entrypoint has a
39
- * broken transitive `colors/safe` specifier under Node's strict ESM
40
- * resolver, so we load the CJS build via `createRequire`. Isolated here so
41
- * the rest of the module stays import-pure and testable.
88
+ * Parse `--baseline <path>` the one flag this CLI does not share with its
89
+ * siblings. The scope flags are parsed by the shared helpers so their
90
+ * contract stays identical across the four update CLIs.
42
91
  *
43
- * @returns {(opts: object) => Promise<Array<object>>}
92
+ * @param {string[]} argv
93
+ * @returns {{ baselinePath: string | undefined }}
44
94
  */
45
- function resolveDetectClones() {
46
- const jscpd = require('jscpd');
47
- if (typeof jscpd.detectClones !== 'function') {
48
- throw new Error(
49
- "[Duplication] jscpd.detectClones is not available — run 'npm install'",
50
- );
51
- }
52
- return jscpd.detectClones;
53
- }
54
-
55
95
  function parseCliArgs(argv = process.argv.slice(2)) {
56
96
  const out = { baselinePath: undefined };
57
97
  for (let i = 0; i < argv.length; i += 1) {
@@ -63,6 +103,16 @@ function parseCliArgs(argv = process.argv.slice(2)) {
63
103
  return out;
64
104
  }
65
105
 
106
+ /**
107
+ * Parse `--full-scope` (boolean opt-out flag).
108
+ *
109
+ * @param {string[]} argv
110
+ * @returns {boolean}
111
+ */
112
+ function parseFullScopeFlag(argv = []) {
113
+ return argv.includes('--full-scope');
114
+ }
115
+
66
116
  /**
67
117
  * Resolve the duplication gate block from the merged quality config. The
68
118
  * flattened legacy bag does not expose a `duplication` accessor (the kind
@@ -76,59 +126,135 @@ function resolveDuplicationGate(config) {
76
126
  return gates.duplication ?? {};
77
127
  }
78
128
 
79
- async function main() {
80
- const args = parseCliArgs();
81
- const config = resolveConfig();
82
- const gate = resolveDuplicationGate(config);
83
- const targetDirs = Array.isArray(gate.targetDirs) ? gate.targetDirs : [];
84
- const ignoreGlobs = Array.isArray(gate.ignoreGlobs) ? gate.ignoreGlobs : [];
85
- const baselinePath =
86
- args.baselinePath ?? gate.baselinePath ?? 'baselines/duplication.json';
87
-
88
- Logger.info('[Duplication] Updating baseline...');
89
- Logger.info(`[Duplication] Target dirs: ${targetDirs.join(', ')}`);
90
-
91
- const rows = await scanDuplication({
92
- targetDirs,
93
- cwd: process.cwd(),
94
- ignoreGlobs,
95
- detect: resolveDetectClones(),
96
- });
129
+ /**
130
+ * Resolve the mutually-exclusive scope selection from argv.
131
+ *
132
+ * Split out of `main` deliberately: nothing in this file is reachable from a
133
+ * test (the CLI is only ever spawned with `--help`, which `runAsCli`
134
+ * short-circuits), so every function here scores CRAP at zero coverage —
135
+ * `c² + c`. A single `main` carrying all the branching lands at c=8 → 72,
136
+ * well over the 30 ceiling. Small single-purpose helpers keep each row far
137
+ * below it without hiding any logic.
138
+ *
139
+ * @param {string[]} argv
140
+ * @returns {{ fullScope: boolean, diffScopeRef: string | null }}
141
+ * @throws {Error} when both scope flags are supplied.
142
+ */
143
+ function resolveScopeSelection(argv) {
144
+ const diffScopeRef = parseDiffScopeFlag(argv);
145
+ const fullScope = parseFullScopeFlag(argv);
146
+ if (fullScope && diffScopeRef !== null) {
147
+ throw new Error(
148
+ '[Duplication] --full-scope is incompatible with --diff-scope; pick one',
149
+ );
150
+ }
151
+ return { fullScope, diffScopeRef };
152
+ }
97
153
 
98
- const absBaselinePath = path.isAbsolute(baselinePath)
154
+ /**
155
+ * Resolve the absolute path the refreshed envelope is written to:
156
+ * `--baseline <path>` wins, then the configured gate path, then the
157
+ * framework default.
158
+ *
159
+ * @param {string[]} argv
160
+ * @param {object} gate resolved duplication gate block
161
+ * @returns {string} absolute path
162
+ */
163
+ function resolveAbsBaselinePath(argv, gate) {
164
+ const baselinePath =
165
+ parseCliArgs(argv).baselinePath ??
166
+ gate.baselinePath ??
167
+ 'baselines/duplication.json';
168
+ return path.isAbsolute(baselinePath)
99
169
  ? baselinePath
100
170
  : path.resolve(process.cwd(), baselinePath);
171
+ }
101
172
 
102
- // Route through the shared writer: canonicalise paths, run the per-kind
103
- // rollup, stamp `$schema` / `kernelVersion` / `generatedAt`, and validate
104
- // against the duplication schema before persisting. Epsilon is applied by
105
- // default so unchanged code with stale env produces a zero-row diff.
106
- const scopeArgs = buildWriterScopeArgs({
107
- kind: 'duplication',
108
- absBaselinePath,
109
- epsilon: getBaselineEpsilon('duplication', config),
110
- logger: Logger,
111
- logTag: '[Duplication]',
112
- });
113
- const envelope = write({
173
+ /**
174
+ * Announce which scope the refresh resolved to, so an operator reading the
175
+ * log can tell a narrowed refresh from a full rewrite without re-deriving it.
176
+ *
177
+ * @param {{ fullScope: boolean, diffScopeRef: string | null }} selection
178
+ */
179
+ function logScopeDecision({ fullScope, diffScopeRef }) {
180
+ if (fullScope) {
181
+ Logger.info(
182
+ '[Duplication] --full-scope: regenerating every row (out-of-scope merge disabled).',
183
+ );
184
+ } else if (diffScopeRef) {
185
+ Logger.info(
186
+ `[Duplication] --diff-scope ${diffScopeRef}: narrowing to changed files; out-of-scope rows preserved verbatim.`,
187
+ );
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Assemble the `refreshBaseline` option bag for the resolved selection.
193
+ *
194
+ * No `scorer` is injected — the service resolves the canonical default
195
+ * duplication scorer, which reads `gates.duplication.targetDirs` /
196
+ * `ignoreGlobs` off the same resolved config this CLI reads.
197
+ *
198
+ * @param {{ fullScope: boolean, diffScopeRef: string | null, absBaselinePath: string, epsilon: number }} args
199
+ * @returns {object} options for `refreshBaseline`
200
+ */
201
+ function buildRefreshOpts({
202
+ fullScope,
203
+ diffScopeRef,
204
+ absBaselinePath,
205
+ epsilon,
206
+ }) {
207
+ const refreshOpts = {
114
208
  kind: 'duplication',
115
- rows,
116
- ...scopeArgs,
117
- });
118
- writeFile(absBaselinePath, envelope);
209
+ writePath: absBaselinePath,
210
+ epsilon,
211
+ };
212
+ if (fullScope) {
213
+ refreshOpts.fullScope = true;
214
+ } else if (diffScopeRef) {
215
+ // The CLI's documented `--diff-scope <ref>` semantics are `<ref>...HEAD`
216
+ // (three-dot). The service derives via two-dot `baseRef..headRef`; pass
217
+ // the ref as `baseRef` so the derivation runs through the same execFile
218
+ // seam the sibling CLIs use.
219
+ refreshOpts.baseRef = diffScopeRef;
220
+ }
221
+ // No flag → scopeFiles=null + fullScope=false → service derives the diff
222
+ // via `origin/main..HEAD` (its default baseRef/headRef).
223
+ return refreshOpts;
224
+ }
119
225
 
120
- Logger.info(
121
- `[Duplication] Scanned ${rows.length} file(s) with detected duplication; wrote ${envelope.rows.length} row(s).`,
226
+ async function main() {
227
+ const argv = process.argv.slice(2);
228
+ const selection = resolveScopeSelection(argv);
229
+ const config = resolveConfig();
230
+ const absBaselinePath = resolveAbsBaselinePath(
231
+ argv,
232
+ resolveDuplicationGate(config),
122
233
  );
234
+
235
+ Logger.info('[Duplication] Updating baseline...');
236
+ logScopeDecision(selection);
237
+
238
+ const result = await refreshBaseline(
239
+ buildRefreshOpts({
240
+ ...selection,
241
+ absBaselinePath,
242
+ epsilon: getBaselineEpsilon('duplication', config),
243
+ }),
244
+ );
245
+
123
246
  Logger.info(
124
- `[Duplication] ✅ Baseline updated (kernelVersion=${envelope.kernelVersion}). Wrote to ${absBaselinePath}.`,
247
+ `[Duplication] ✅ Baseline updated (kernelVersion=${result.envelope.kernelVersion}, wrote=${result.wrote}, scope=${result.scope.mode}, rows=${result.envelope.rows.length}). Wrote to ${absBaselinePath}.`,
125
248
  );
126
249
  }
127
250
 
128
- // cli-opt-out: top-level main().catch predates runAsCli; never imported elsewhere so the auto-run risk is moot.
129
- main().catch((err) => {
130
- Logger.error(
131
- `[Duplication] ❌ Fatal error: ${err?.stack ?? err?.message ?? err}`,
132
- );
133
- process.exit(1);
251
+ runAsCli(import.meta.url, main, {
252
+ source: 'duplication-baseline',
253
+ usage: USAGE,
254
+ onError: (err) => {
255
+ Logger.error(
256
+ `[Duplication] ❌ Fatal error: ${err?.stack ?? err?.message ?? err}`,
257
+ );
258
+ process.exitCode = 1;
259
+ },
134
260
  });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* node:coverage ignore file -- top-level docs-freshness gate shell; the tested logic lives in lib/docs-freshness.js */
2
3
 
3
4
  /**
4
5
  * .agents/scripts/validate-docs-freshness.js — Documentation Freshness Gate
@@ -15,6 +15,7 @@ allowed_tools:
15
15
  ## Policy Capsule
16
16
 
17
17
  - Invoke via the wrapping CLI `node .agents/scripts/diagnose-friction.js --story <id> [--epic <id>] --cmd <command args...>`; this is the single supported entry point.
18
+ - `--cmd` takes the command as **separate argv words** — the CLI spawns them with no shell. Quoting the whole command as one string (`--cmd "npm run lint"`) makes the entire string the executable name, so the spawn fails ENOENT. That is a usage error in your own invocation, not friction: the CLI refuses it with a usage message and appends **no** ledger row. Re-run it unquoted.
18
19
  - Pass the wrapped command's stdout and stderr through **unchanged** — never reformat, redact, or buffer in a way that loses the original failure shape.
19
20
  - Never mutate the wrapped command's exit code. The Skill observes; the caller decides whether the failure is fatal.
20
21
  - Operate as **best-effort observation**: a write failure on the signals stream MUST NOT halt the runner. A missing signal is preferable to a stalled wave.
@@ -39,7 +40,9 @@ that want to dispatch via the Skill tool rather than spawn the CLI.
39
40
 
40
41
  ## Inputs
41
42
 
42
- - `--cmd <command args...>` — the command to invoke and observe.
43
+ - `--cmd <command args...>` — the command to invoke and observe, passed as
44
+ separate argv words. A single whitespace-containing argument is rejected as
45
+ a quoting mistake before anything is spawned or recorded.
43
46
  - `--story <id>` / `--epic <id>` (optional) — when resolved, the Skill
44
47
  appends a `friction` signal to
45
48
  `temp/run-<eid>/stories/story-<sid>/signals.ndjson` on non-zero exit
@@ -18,7 +18,7 @@ allowed_tools:
18
18
  - **No gate may be skipped.** Failing lint means fix lint, not disable the rule; a failing test means fix the code, not `.skip` or delete the test. Gates are ordered shift-left so cheap checks fail first, and CI failure output is fed back verbatim with the directive to reproduce and fix locally before re-pushing.
19
19
  - **Introducing a gate that asserts on pre-existing state** (doc-drift, lint-vocabulary, dependency-cycle, missing-coverage) MUST land green at merge: either advisory-first (report-only until the backlog is burned down) or with the populated baseline committed in the same change that turns the gate on. Never wire a gate into `requiredChecks` that lands red on latent findings nobody authored.
20
20
  - **Refresh a baseline only when the change is deliberate** — a rename/move, an operator-approved complexity bump, a signed-off perf delta, an intentional API-surface change. Never refresh to paper over an unintentional regression; fix the regression instead.
21
- - Run the kind-specific refresh (`npm run crap:update` / `npm run maintainability:update`; dead-exports and lighthouse have no npm script — regenerate the rows and edit `baselines/dead-exports*.json` / `baselines/lighthouse.json` directly) on the **Story branch**, not on `main`.
21
+ - **Pick the refresh that matches why you are refreshing** — the two are different scopes, and the wrong one silently no-ops. `npm run <kind>:update` is **diff-scoped**: it re-scores only files changed in `origin/main..HEAD` and preserves every other row verbatim. That is what you want after changing code. `npm run <kind>:reanchor` is the same script with `--full-scope`: it re-scores every file in every target dir. That is what you want when the baseline is **stale** — a scorer-version bump, a coverage-shape change, accumulated drift — because a diff-scoped run would leave the stale rows exactly as they were and the staleness finding would re-fire. Kinds: `coverage`, `crap`, `maintainability`, `duplication`; dead-exports and lighthouse have no npm script — regenerate the rows and edit `baselines/dead-exports*.json` / `baselines/lighthouse.json` directly. Run either on the **Story branch**, not on `main`.
22
22
  - Verify the refresh diff is scoped to the relevant `baselines/<kind>.json` (plus cosmetic `package-lock.json` churn only). If unrelated files appear, STOP — the refresh is contaminated. Stage baseline files **explicitly** (`git add baselines/<kind>.json`); never `git add -A` in a refresh commit.
23
23
  - Commit-subject contract: a **Conventional-Commits** subject `chore(baselines): refresh <kind> snapshot for <reason>` — never an ad-hoc leading token like `baseline-refresh:` (commitlint and the planner validator reject it). The body is **mandatory** and non-empty: what changed, why the new floor is correct, and the Story that triggered it.
24
24
  - Add the machine-readable trailer `baseline-refresh: true` (git-trailer `Key: value` style) and `Story: #<storyId>` to the body whenever observability classification matters. Never pass `--no-verify`; the `commit-msg` hook (commitlint) MUST run and pass.
@@ -95,18 +95,24 @@ own as the parseable marker for any future reader.)
95
95
 
96
96
  ### Procedure
97
97
 
98
- | Kind | Update command |
99
- | --------------- | -------------------------------- |
100
- | CRAP | `npm run crap:update` |
101
- | Maintainability | `npm run maintainability:update` |
102
- | Dead-exports | edit `baselines/dead-exports.json` / `baselines/dead-exports-production.json` (rows are `(file, symbol)`; `check-dead-exports.js --json` prints the current rows) |
103
- | Lighthouse | edit `baselines/lighthouse.json` |
104
-
105
- 1. **Run the matching update command** on the Story branch (HEAD must already be
106
- the Story branch, not `main`).
98
+ | Kind | After a code change (diff-scoped) | Stale baseline (full re-anchor) |
99
+ | --------------- | ---------------------------------- | ------------------------------------ |
100
+ | CRAP | `npm run crap:update` | `npm run crap:reanchor` |
101
+ | Maintainability | `npm run maintainability:update` | `npm run maintainability:reanchor` |
102
+ | Coverage | `npm run coverage:update` | `npm run coverage:reanchor` |
103
+ | Duplication | `npm run duplication:update` | `npm run duplication:reanchor` |
104
+ | Dead-exports | edit `baselines/dead-exports.json` / `baselines/dead-exports-production.json` (rows are `(file, symbol)`; `check-dead-exports.js --json` prints the current rows) | same — no scope flag applies |
105
+ | Lighthouse | edit `baselines/lighthouse.json` | same no scope flag applies |
106
+
107
+ 1. **Run the matching command for your reason** — left column after a code
108
+ change, right column for a stale baseline — on the Story branch (HEAD must
109
+ already be the Story branch, not `main`).
107
110
  2. **Verify the diff is scoped** to the relevant `baselines/<kind>.json` (and
108
111
  possibly cosmetic `package-lock.json` churn). Unrelated files → STOP, the
109
- refresh is contaminated.
112
+ refresh is contaminated. Judge contamination by **which files** changed, not
113
+ how many rows moved inside the baseline: a `:reanchor` legitimately rewrites
114
+ most of its own file, so say in the commit body that the breadth is a
115
+ re-anchor and not a mass regression.
110
116
  3. **Author the commit.** Stage the baseline file(s) explicitly, then commit with
111
117
  the Conventional-Commits subject + body trailer above:
112
118
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-07-21T13:53:23.232Z",
2
+ "generatedAt": "2026-08-02T11:35:53.522Z",
3
3
  "generator": "generate-skills-index.js@1",
4
4
  "skills": [
5
5
  {
@@ -48,7 +48,7 @@
48
48
  "category": "core",
49
49
  "path": ".agents/skills/core/diagnose-friction/SKILL.md",
50
50
  "description": "Wrap a shell command with diagnostic capture. On failure, print static suggestions and append a structured `friction` record to the per-Story signals.ndjson stream. Use whenever a script in the orchestration loop invokes a tool whose failure shape we want the analyzer to attribute.",
51
- "policyCapsuleBullets": 8,
51
+ "policyCapsuleBullets": 9,
52
52
  "allowedTools": ["Bash", "Read"],
53
53
  "vendor": null
54
54
  },