mandrel 1.88.0 → 1.90.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 (145) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * standalone-feedback-rollup.js — end-of-run feedback rollup for the
5
+ * `/deliver` standalone multi-Story path (Epic #4406 / Story #4416).
6
+ *
7
+ * The standalone delivery path (`helpers/deliver-stories`) drives one or
8
+ * more Epic-free Stories to green, and each per-Story sub-agent appends
9
+ * `friction` records to its **standalone** signals stream at
10
+ * `temp/standalone/stories/story-<sid>/signals.ndjson` (written via
11
+ * `appendSignal({ epicId: null, storyId })`). Before this rollup that
12
+ * tree was write-only — nothing ever read it back. This CLI closes the
13
+ * gap: it scans the standalone streams for the delivered Story set and
14
+ * emits a per-category friction summary that `deliver-stories.md`
15
+ * surfaces in its Phase 3 run summary.
16
+ *
17
+ * Usage:
18
+ * node standalone-feedback-rollup.js --stories <id,...>
19
+ *
20
+ * Contract:
21
+ * - Stream paths resolve **exclusively** through the `temp-paths`
22
+ * helpers via `forEachLine(null, storyId, cb, config)` — the `null`
23
+ * Epic sentinel routes to the standalone tree. No hand-built path
24
+ * strings.
25
+ * - Aggregates `friction` records by their **top-level** `category`
26
+ * (Epic #4406 canonical envelope shape); a record with no category
27
+ * buckets under `Unknown`.
28
+ * - **Never fails a run.** A missing or empty stream degrades to an
29
+ * empty contribution; the CLI always prints a JSON summary and
30
+ * exits 0. Observability MUST NOT take down the delivering flow.
31
+ *
32
+ * Output (stdout, one JSON object):
33
+ * {
34
+ * "kind": "standalone-feedback-rollup",
35
+ * "stories": [<id>, ...],
36
+ * "totalFriction": <int>,
37
+ * "byCategory": { "<category>": <count>, ... },
38
+ * "perStory": { "<id>": { "friction": <int>, "missing": <bool> }, ... }
39
+ * }
40
+ *
41
+ * @see .agents/scripts/lib/observability/signals-writer.js (forEachLine, appendSignal)
42
+ * @see .agents/scripts/lib/config/temp-paths.js (signalsFile — null Epic sentinel)
43
+ * @see .agents/workflows/helpers/deliver-stories.md (Phase 3 summary wiring)
44
+ */
45
+
46
+ import { runAsCli } from './lib/cli-utils.js';
47
+ import { resolveConfig } from './lib/config-resolver.js';
48
+ import { forEachLine } from './lib/observability/signals-writer.js';
49
+
50
+ const FRICTION_KIND = 'friction';
51
+ const UNKNOWN_CATEGORY = 'Unknown';
52
+
53
+ /**
54
+ * Parse `--stories <id,...>` into an ordered, de-duplicated array of
55
+ * positive-integer Story IDs. Accepts a single comma-separated value or
56
+ * repeated `--stories` flags. Non-integer / non-positive tokens are
57
+ * rejected so a typo surfaces as an input error rather than a silently
58
+ * empty rollup.
59
+ *
60
+ * @param {string[]} args
61
+ * @returns {{ stories: number[] }}
62
+ */
63
+ export function parseArguments(args) {
64
+ const raw = [];
65
+ for (let i = 0; i < args.length; i++) {
66
+ if (args[i] === '--stories') {
67
+ const value = args[++i];
68
+ if (typeof value === 'string') {
69
+ raw.push(...value.split(','));
70
+ }
71
+ }
72
+ }
73
+
74
+ const seen = new Set();
75
+ const stories = [];
76
+ for (const token of raw) {
77
+ const trimmed = token.trim();
78
+ if (trimmed.length === 0) continue;
79
+ const id = Number(trimmed);
80
+ if (!Number.isInteger(id) || id <= 0) {
81
+ throw new Error(
82
+ `--stories expects comma-separated positive integers; got ${JSON.stringify(trimmed)}`,
83
+ );
84
+ }
85
+ if (seen.has(id)) continue;
86
+ seen.add(id);
87
+ stories.push(id);
88
+ }
89
+
90
+ if (stories.length === 0) {
91
+ throw new Error(
92
+ 'Usage: node standalone-feedback-rollup.js --stories <id,...>',
93
+ );
94
+ }
95
+
96
+ return { stories };
97
+ }
98
+
99
+ /**
100
+ * Read one standalone Story's signals stream and aggregate its `friction`
101
+ * records by top-level `category`, mutating the shared `byCategory` tally.
102
+ *
103
+ * Degrades to a zero contribution on a missing / empty / unreadable
104
+ * stream — `forEachLine` already swallows fs + JSON faults and reports a
105
+ * missing file via `{ missing: true }` rather than throwing.
106
+ *
107
+ * @param {number} storyId
108
+ * @param {Record<string, number>} byCategory Mutated in place.
109
+ * @param {object} config
110
+ * @returns {Promise<{ friction: number, missing: boolean }>}
111
+ */
112
+ async function rollupStory(storyId, byCategory, config) {
113
+ let friction = 0;
114
+ // `epicId: null` routes forEachLine → signalsFile(null, storyId, config)
115
+ // → the standalone tree. Never a hand-built path string.
116
+ const result = await forEachLine(
117
+ null,
118
+ storyId,
119
+ (record) => {
120
+ if (
121
+ record === null ||
122
+ typeof record !== 'object' ||
123
+ record.kind !== FRICTION_KIND
124
+ ) {
125
+ return;
126
+ }
127
+ const category =
128
+ typeof record.category === 'string' && record.category.length > 0
129
+ ? record.category
130
+ : UNKNOWN_CATEGORY;
131
+ byCategory[category] = (byCategory[category] ?? 0) + 1;
132
+ friction += 1;
133
+ },
134
+ config,
135
+ );
136
+
137
+ return { friction, missing: Boolean(result?.missing) };
138
+ }
139
+
140
+ /**
141
+ * Aggregate friction across every named standalone Story stream.
142
+ *
143
+ * @param {number[]} stories
144
+ * @param {object} [config]
145
+ * @returns {Promise<{
146
+ * kind: string,
147
+ * stories: number[],
148
+ * totalFriction: number,
149
+ * byCategory: Record<string, number>,
150
+ * perStory: Record<string, { friction: number, missing: boolean }>,
151
+ * }>}
152
+ */
153
+ export async function buildRollup(stories, config) {
154
+ const byCategory = {};
155
+ const perStory = {};
156
+ let totalFriction = 0;
157
+
158
+ for (const storyId of stories) {
159
+ const { friction, missing } = await rollupStory(
160
+ storyId,
161
+ byCategory,
162
+ config,
163
+ );
164
+ perStory[storyId] = { friction, missing };
165
+ totalFriction += friction;
166
+ }
167
+
168
+ return {
169
+ kind: 'standalone-feedback-rollup',
170
+ stories,
171
+ totalFriction,
172
+ byCategory,
173
+ perStory,
174
+ };
175
+ }
176
+
177
+ export async function main(args = process.argv.slice(2)) {
178
+ const { stories } = parseArguments(args);
179
+ const config = resolveConfig();
180
+ const summary = await buildRollup(stories, config);
181
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
182
+ return 0;
183
+ }
184
+
185
+ runAsCli(import.meta.url, main, {
186
+ source: 'StandaloneFeedbackRollup',
187
+ propagateExitCode: true,
188
+ });
@@ -10,6 +10,9 @@
10
10
  * envelope. Pipeline shape:
11
11
  *
12
12
  * 1. parse + resolveCloseInputs (lib/orchestration/story-close/close-inputs.js)
13
+ * 1.5. already-done no-op guard (inline below) — a Story already at
14
+ * `agent::done` AND closed short-circuits here so no phase below it
15
+ * ever flips the label away from `agent::done` or reopens the issue.
13
16
  * 2. preflight (phases/preflight.js)
14
17
  * 3. state-flip → closing (inline helper below)
15
18
  * 4. capture starting branch (phases/branch-restore.js)
@@ -152,6 +155,51 @@ export async function runStoryClose({
152
155
  storyBranch,
153
156
  } = resolved;
154
157
 
158
+ // Phase 0 — idempotency guard. A re-run against a Story that is already
159
+ // fully closed must be a safe no-op. Without this guard, Phase 3 below
160
+ // (`transitionToClosing`) unconditionally flips the label to
161
+ // `agent::closing` — and `transitionTicketState`
162
+ // (lib/orchestration/ticketing/transition.js) unconditionally sends
163
+ // `state: 'open'` for any non-`agent::done` target — so re-running close
164
+ // against an already-closed Story reopens the GitHub issue and strips
165
+ // `agent::done` *before* the post-merge pipeline even starts. If the
166
+ // pipeline then throws or a phase silently no-ops (the merge is already
167
+ // fully reaped, so there is nothing left to redo), the Story is left
168
+ // regressed at `agent::closing`/OPEN instead of a safe no-op.
169
+ //
170
+ // The "already fully closed" test mirrors the deepest-level guard in the
171
+ // post-merge `ticketClosurePhase` (post-merge/phases/ticket-closure.js):
172
+ // label `agent::done` AND issue `state === 'closed'`, an AND — never a
173
+ // state-alone check. The `state === 'closed'` disjunct is deliberately
174
+ // excluded per the hard-won reasoning in
175
+ // `lib/single-story/confirm-merge.js`: a Story can arrive here closed at
176
+ // the GitHub layer (a `Closes #<id>` PR footer, or a prior close that set
177
+ // `state: closed` but was killed before the label flip landed) while its
178
+ // label is legitimately still `agent::closing`. Treating that as
179
+ // already-done would skip the very `closing → done` re-assertion the
180
+ // re-run exists to perform, stranding the label. Requiring BOTH signals
181
+ // no-ops only a genuinely finished Story.
182
+ const alreadyDone =
183
+ Array.isArray(story?.labels) &&
184
+ story.labels.includes(STATE_LABELS.DONE) &&
185
+ story?.state === 'closed';
186
+ if (alreadyDone) {
187
+ progress(
188
+ 'SKIP',
189
+ `Story #${storyId} is already ${STATE_LABELS.DONE} and closed — no-op re-run.`,
190
+ );
191
+ return {
192
+ success: true,
193
+ result: {
194
+ storyId,
195
+ epicId,
196
+ action: 'noop',
197
+ reason: 'already-done',
198
+ merged: true,
199
+ },
200
+ };
201
+ }
202
+
155
203
  const notifyFn = (ticketId, payload, opts = {}) =>
156
204
  notify(ticketId, payload, { config, provider, ...opts });
157
205
 
@@ -10,9 +10,11 @@
10
10
  *
11
11
  * 1. `--emit-context` mode — given a `--idea`/`--from-notes` seed,
12
12
  * build the context envelope (seed, refine heuristic, persona,
13
- * body template, duplicate candidates, tech-stack summary) and
14
- * print it as JSON on stdout. Logs route to stderr so the
15
- * envelope is byte-clean for `JSON.parse`.
13
+ * body template, duplicate candidates, tech-stack summary, and a
14
+ * corpus-aware `corpusContext` — the docs digest plus relevant
15
+ * existing-Epic Tech Spec excerpts, Story #4432) and print it as
16
+ * JSON on stdout. Logs route to stderr so the envelope is
17
+ * byte-clean for `JSON.parse`.
16
18
  * 2. Persist mode — given a `--body <file>` authored by the host
17
19
  * LLM after operator confirmation, validate the shape and persist
18
20
  * via `provider.createIssue` (which also adds the new Story to
@@ -30,12 +32,14 @@
30
32
  */
31
33
 
32
34
  import { readFile } from 'node:fs/promises';
35
+ import path from 'node:path';
33
36
  import { parseArgs } from 'node:util';
34
37
  import { runAsCli } from './lib/cli-utils.js';
35
38
  import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
36
39
  import { exec as ghExec } from './lib/gh-exec.js';
37
40
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
38
41
  import { TYPE_LABELS } from './lib/label-constants.js';
42
+ import { buildCorpusContext } from './lib/planning-corpus.js';
39
43
  import { createProvider } from './lib/provider-factory.js';
40
44
  import {
41
45
  buildContextEnvelope,
@@ -130,7 +134,17 @@ export function extractTitle(body) {
130
134
  return m ? m[1].trim() : 'Untitled standalone Story';
131
135
  }
132
136
 
133
- async function runEmitContext({ values, provider, projectRoot }) {
137
+ async function runEmitContext({
138
+ values,
139
+ provider,
140
+ projectRoot,
141
+ config,
142
+ // Injectable stdout port so unit tests can capture the emitted envelope
143
+ // without stubbing the process-global stream (mirrors the `runPersist`
144
+ // pattern above — raw stdout writes corrupt the `node --test` runner's
145
+ // structured report stream).
146
+ write = (s) => process.stdout.write(s),
147
+ }) {
134
148
  const seed = await resolveSeed({
135
149
  idea: values.idea,
136
150
  fromNotes: values['from-notes'],
@@ -139,11 +153,29 @@ async function runEmitContext({ values, provider, projectRoot }) {
139
153
  const refine = shouldRefine({ seed, override });
140
154
  const persona = values.persona ?? 'engineer';
141
155
 
142
- const [bodyTemplate, openStories, techStack] = await Promise.all([
143
- loadBodyTemplate(projectRoot),
144
- fetchOpenStories(provider),
145
- readTechStackSummary(projectRoot),
146
- ]);
156
+ // Corpus lookup uses the raw (un-defaulted) docsContextFiles list, same
157
+ // as the `/deliver` per-Epic digest builder: `config.project` fills in
158
+ // the framework's default four-file set even when the operator
159
+ // configured nothing, so a null-vs-configured distinction requires
160
+ // reading `config.raw` directly.
161
+ const docsContextFiles = config?.raw?.project?.docsContextFiles ?? [];
162
+ // Resolve docsRoot against PROJECT_ROOT (not process.cwd()) so the
163
+ // corpus digest reads the project's actual docs directory regardless
164
+ // of the directory this CLI happens to be invoked from — matching the
165
+ // sibling resolution pattern in
166
+ // epic-plan-spec/phases/authoring-context.js.
167
+ const docsRoot = path.resolve(
168
+ PROJECT_ROOT,
169
+ config?.project?.paths?.docsRoot ?? 'docs',
170
+ );
171
+
172
+ const [bodyTemplate, openStories, techStack, corpusContext] =
173
+ await Promise.all([
174
+ loadBodyTemplate(projectRoot),
175
+ fetchOpenStories(provider),
176
+ readTechStackSummary(projectRoot),
177
+ buildCorpusContext({ seed, provider, docsContextFiles, docsRoot }),
178
+ ]);
147
179
 
148
180
  const duplicateCandidates = rankDuplicateCandidates({
149
181
  seed,
@@ -157,12 +189,13 @@ async function runEmitContext({ values, provider, projectRoot }) {
157
189
  bodyTemplate,
158
190
  duplicateCandidates,
159
191
  techStack,
192
+ corpusContext,
160
193
  });
161
194
 
162
195
  const json = values.pretty
163
196
  ? JSON.stringify(envelope, null, 2)
164
197
  : JSON.stringify(envelope);
165
- process.stdout.write(`${json}\n`);
198
+ write(`${json}\n`);
166
199
  }
167
200
 
168
201
  async function runPersist({
@@ -267,7 +300,7 @@ async function main() {
267
300
  // unconditionally parseable by `JSON.parse`. Mirrors the contract
268
301
  // `epic-plan-spec.js` enforces for its own --emit-context mode.
269
302
  routeAllOutputToStderr();
270
- return runEmitContext({ values, provider, projectRoot });
303
+ return runEmitContext({ values, provider, projectRoot, config });
271
304
  }
272
305
 
273
306
  return runPersist({
@@ -281,4 +314,10 @@ runAsCli(import.meta.url, main, { source: 'story-plan' });
281
314
 
282
315
  // Test surface — exported so unit tests can drive the helpers
283
316
  // without importing the CLI side.
284
- export { fetchOpenStories, renderGhArgv, runPersist };
317
+ export {
318
+ fetchOpenStories,
319
+ renderGhArgv,
320
+ resolveSeed,
321
+ runEmitContext,
322
+ runPersist,
323
+ };
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /* node:coverage ignore file -- pre-push docs-freshness gate; pure git-mtime walk with no testable branching beyond filesystem state */
3
2
 
4
3
  /**
5
4
  * .agents/scripts/validate-docs-freshness.js — Documentation Freshness Gate
@@ -10,18 +9,28 @@
10
9
  *
11
10
  * 1. `git log --all --grep="#<epicId>" -- <file>` returns a commit —
12
11
  * the Epic ID was referenced in a commit message that touched the
13
- * file.
14
- * 2. The file's current body contains `#<epicId>` — a human annotation
15
- * (e.g., a CHANGELOG entry) explicitly ties the change to this Epic.
12
+ * file. This is the pass path for **every** doc.
13
+ * 2. The file's current body contains `#<epicId>` — but this
14
+ * body-annotation path is accepted **only for changelog-class files**
15
+ * (basename matches `/changelog/i`), where an appended release note
16
+ * keyed to the Epic is the legitimate, expected update. Any other doc
17
+ * (architecture, decisions, README, …) MUST pass via condition 1: the
18
+ * living doc has to be **rewritten in an Epic-referencing commit**, not
19
+ * merely annotated with `#<epicId>`.
16
20
  *
17
21
  * The prior gate accepted any diff against the base branch — a stray
18
22
  * whitespace edit or a one-line unrelated cleanup passed, defeating the
19
23
  * purpose of the check. Requiring an Epic-ID reference makes "did you
20
24
  * update the docs for this Epic?" a falsifiable question instead of a
21
- * checkbox.
25
+ * checkbox. The changelog-only restriction on condition 2 closes the
26
+ * follow-on perverse incentive: without it, the gate rewarded appending
27
+ * `#<epicId>` history into living docs (manufacturing fake provenance) to
28
+ * satisfy the check. Restricting the annotation path to changelog files
29
+ * makes the gate ask "was this doc rewritten for the Epic?" rather than
30
+ * "does it mention the Epic?".
22
31
  *
23
32
  * Usage:
24
- * node .agents/scripts/validate-docs-freshness.js --epic <EPIC_ID> [--base main] [--docs <comma-separated>] [--json]
33
+ * node .agents/scripts/validate-docs-freshness.js --epic <EPIC_ID> [--docs <comma-separated>] [--json]
25
34
  *
26
35
  * `--json` emits a single JSON object on stdout with
27
36
  * { ok, epicId, results: [{ file, pass, reason }, ...] }
@@ -76,6 +85,20 @@ export function resolveDocList(config) {
76
85
  return Array.from(new Set(resolved));
77
86
  }
78
87
 
88
+ /**
89
+ * A doc is "changelog-class" when its basename matches `/changelog/i`
90
+ * (e.g. `CHANGELOG.md`, `docs/CHANGELOG.md`, `changelog.mdx`). Only these
91
+ * files may satisfy the freshness gate via a body annotation (pass
92
+ * condition 2); every other doc must pass via an Epic-referencing commit
93
+ * (pass condition 1).
94
+ *
95
+ * @param {string} file
96
+ * @returns {boolean}
97
+ */
98
+ export function isChangelogClass(file) {
99
+ return /changelog/i.test(path.basename(file));
100
+ }
101
+
79
102
  function epicRefMatcher(epicId) {
80
103
  // Match `#N` as a standalone token. `(?!\d)` prevents `#10` from
81
104
  // satisfying a search for `#1` — a subtle bug the prior diff-only gate
@@ -83,6 +106,8 @@ function epicRefMatcher(epicId) {
83
106
  return new RegExp(`#${epicId}(?!\\d)`);
84
107
  }
85
108
 
109
+ /* node:coverage disable -- real `git log` shell-out; exercised via the
110
+ injectable `commitsForFile` seam in runFreshnessGate, not directly. */
86
111
  function commitsMentioningEpic(docPath, epicId, cwd = PROJECT_ROOT) {
87
112
  const res = gitSpawn(
88
113
  cwd,
@@ -99,6 +124,7 @@ function commitsMentioningEpic(docPath, epicId, cwd = PROJECT_ROOT) {
99
124
  .map((s) => s.trim())
100
125
  .filter(Boolean);
101
126
  }
127
+ /* node:coverage enable */
102
128
 
103
129
  function fileBodyMentionsEpic(
104
130
  docPath,
@@ -145,17 +171,29 @@ export function runFreshnessGate({
145
171
  reason: `${commits.length} commit(s) reference Epic #${epicId}`,
146
172
  };
147
173
  }
148
- if (fileBodyMentionsEpic(file, epicId, cwd, readFileImpl)) {
174
+ // Pass condition 2 (body annotation) is restricted to changelog-class
175
+ // files. For every other doc, an appended `#<epicId>` no longer passes —
176
+ // the living doc must be rewritten in an Epic-referencing commit.
177
+ const changelogClass = isChangelogClass(file);
178
+ if (
179
+ changelogClass &&
180
+ fileBodyMentionsEpic(file, epicId, cwd, readFileImpl)
181
+ ) {
149
182
  return {
150
183
  file,
151
184
  pass: true,
152
- reason: `body mentions #${epicId}`,
185
+ reason: `changelog body annotation references #${epicId}`,
153
186
  };
154
187
  }
155
188
  return {
156
189
  file,
157
190
  pass: false,
158
- reason: `no commit message or body reference to #${epicId}`,
191
+ reason: changelogClass
192
+ ? `no commit message or changelog body reference to #${epicId}`
193
+ : `${file} was not rewritten in an Epic-referencing commit for #${epicId} — ` +
194
+ `living docs must be REWRITTEN in a commit whose message references ` +
195
+ `#${epicId} (not annotated with #${epicId}); the body-annotation path ` +
196
+ `passes only for changelog-class files`,
159
197
  };
160
198
  });
161
199
  return { ok: results.every((r) => r.pass), results };
@@ -172,7 +210,6 @@ export function parseFreshnessArgs(argv) {
172
210
  args: argv,
173
211
  options: {
174
212
  epic: { type: 'string' },
175
- base: { type: 'string' },
176
213
  docs: { type: 'string' },
177
214
  json: { type: 'boolean', default: false },
178
215
  },
@@ -196,12 +233,25 @@ export function renderFreshnessLine(result) {
196
233
  return `[docs-freshness] ${result.pass ? '✅' : '❌'} ${result.file} — ${result.reason}`;
197
234
  }
198
235
 
199
- /** Pure: build the failure message for the operator. */
200
- export function renderFreshnessFailureMessage(epicId) {
236
+ /**
237
+ * Pure: build the failure message for the operator. Names the failing
238
+ * file(s) and states the rewrite-not-append contract explicitly.
239
+ *
240
+ * @param {number} epicId
241
+ * @param {Array<{ file: string, pass: boolean }>} [results]
242
+ */
243
+ export function renderFreshnessFailureMessage(epicId, results = []) {
244
+ const failing = results.filter((r) => !r.pass).map((r) => r.file);
245
+ const fileList = failing.length > 0 ? failing.join(', ') : '(see rows above)';
201
246
  return (
202
247
  `[docs-freshness] ❌ Documentation freshness gate FAILED for Epic #${epicId}.\n\n` +
203
- `Update each failing file so its commit message or body references #${epicId}, ` +
204
- `then re-run /deliver.`
248
+ `Failing file(s): ${fileList}\n\n` +
249
+ `Living docs satisfy this gate by being REWRITTEN in an Epic-referencing ` +
250
+ `commit — a commit whose message references #${epicId} and touches the ` +
251
+ `file — NOT by appending a #${epicId} annotation to the body. The ` +
252
+ `body-annotation path passes ONLY for changelog-class files (basename ` +
253
+ `matches /changelog/i). Rewrite each failing file for the Epic, then ` +
254
+ `re-run /deliver.`
205
255
  );
206
256
  }
207
257
 
@@ -210,6 +260,9 @@ export function renderFreshnessSuccessMessage(epicId, count) {
210
260
  return `[docs-freshness] ✅ All ${count} doc(s) reference Epic #${epicId}.`;
211
261
  }
212
262
 
263
+ /* node:coverage disable -- process I/O + real config/git wiring (stdout,
264
+ process.exit, resolveConfig, runAsCli); the pure logic these thin wrappers
265
+ call is covered directly above. */
213
266
  function reportEmptyDocs(epicId, json) {
214
267
  if (json) {
215
268
  process.stdout.write(
@@ -234,7 +287,7 @@ function reportGateOutcome({ epicId, json, ok, results }) {
234
287
  Logger.info(renderFreshnessSuccessMessage(epicId, results.length));
235
288
  return;
236
289
  }
237
- Logger.error(renderFreshnessFailureMessage(epicId));
290
+ Logger.error(renderFreshnessFailureMessage(epicId, results));
238
291
  process.exit(1);
239
292
  }
240
293
 
@@ -257,3 +310,4 @@ async function main() {
257
310
  }
258
311
 
259
312
  runAsCli(import.meta.url, main, { source: 'validate-docs-freshness' });
313
+ /* node:coverage enable */
@@ -316,6 +316,64 @@ For shipped features:
316
316
  - Task list now loads 50 items per page (was 20) for better UX (#126)
317
317
  ```
318
318
 
319
+ ## Pruning & Archiving
320
+
321
+ Living docs accrete history — dated changelog entries, closed decision-log
322
+ rows, completed rollout checklists, resolved runbook incidents. Left
323
+ unpruned, that verbatim history crowds out the live guidance a reader (human
324
+ or agent) actually needs, and every task that loads the doc re-pays the cost.
325
+ The fix is to **archive, don't delete**: relocate the cold history so the live
326
+ doc stays lean while the record stays recoverable.
327
+
328
+ ### The archive-don't-delete rule
329
+
330
+ **History is preserved by _moving_ it, never by deleting it.** Pruning a doc
331
+ never destroys its past — the verbatim content is relocated to a dated archive
332
+ file under version control, so the full record remains diffable and
333
+ recoverable. Deleting history outright (even with "git has it") is the
334
+ anti-pattern this convention exists to prevent: the archive is discoverable
335
+ from the live doc, a buried git revision is not.
336
+
337
+ ### How to prune a doc
338
+
339
+ 1. **Extract the still-live signal first — before you archive anything.**
340
+ Gotchas, traps, and hard-won caveats buried in the history are the most
341
+ valuable lines in the doc. Lift them into the live doc's standing guidance
342
+ (a "Known gotchas" list, an inline warning, or an ADR) **before** the
343
+ history moves. Archiving first risks stranding a live trap in a cold file
344
+ nobody rereads.
345
+ 2. **Move the verbatim history to a dated archive file.** Relocate the cold
346
+ content — untouched, word-for-word — to
347
+ `docs/archive/<name>-<YYYY-MM>.md`, where `<name>` is the source doc's base
348
+ name and `<YYYY-MM>` is the archive date (e.g. `docs/archive/changelog-2025-01.md`,
349
+ `docs/archive/decisions-2024-11.md`). The archive is an exact copy of what
350
+ was live; do not summarize or rewrite it in the move.
351
+ 3. **Collapse completed checklists to a one-line summary.** A finished
352
+ checklist (a rollout runbook, a migration plan, a release gate) does not
353
+ need to keep every ticked box in the live doc. Replace it with a single
354
+ line recording the outcome and date — e.g.
355
+ `Auth-migration rollout — completed 2025-01-18, all 12 steps green` — and
356
+ let the archived copy carry the full detail.
357
+ 4. **Leave a one-line pointer behind.** Every archived doc leaves exactly one
358
+ line in the live doc pointing at where its history went, so the record is
359
+ never orphaned — e.g.
360
+ `Older entries archived to docs/archive/changelog-2024.md`. The pointer is
361
+ what makes "moved, not deleted" true from the reader's vantage point.
362
+
363
+ ### When to prune
364
+
365
+ - A changelog, decision log, or runbook has grown long enough that the live
366
+ entries are hard to find among the historical ones.
367
+ - A checklist or rollout plan is fully complete and its step-by-step detail is
368
+ now reference-only.
369
+ - A doc reloaded into agent context on many tasks carries more cold history
370
+ than live guidance.
371
+
372
+ Do **not** prune ADRs by archiving — an ADR that no longer holds is
373
+ **superseded** in place (see [ADR Lifecycle](#adr-lifecycle)), keeping the
374
+ numbered chain intact. Archiving is for the accreted history of living docs,
375
+ not for the immutable decision record.
376
+
319
377
  ## Documentation for Agents
320
378
 
321
379
  Special consideration for AI agent context:
@@ -17,7 +17,8 @@ allowed_tools:
17
17
 
18
18
  - Run only after `epic-plan-decompose.js --emit-context` has written `temp/epic-<Epic_ID>/decomposer-context.json`; fail loudly if the file is missing.
19
19
  - Emit exactly one artifact: `temp/epic-<Epic_ID>/tickets.json` (a JSON array). Do not write anywhere else, and never call the GitHub API from this Skill — persistence belongs to the script.
20
- - Output is JSON only — no prose, no Markdown fence. The downstream validator (`lib/orchestration/ticket-validator.js`) is the authoritative gate; re-author rather than hand-patching when it rejects.
20
+ - Output is JSON only — no prose, no Markdown fence. The downstream validator (`lib/orchestration/ticket-validator.js`) is the authoritative gate.
21
+ - **Re-emit rule (amend, don't regenerate — Story #4431).** On a re-emit — the validator rejecting the draft, or a Phase 8.3/8.4/8.5 critic flagging specific Stories — apply **targeted edits** to the existing `temp/epic-<Epic_ID>/tickets.json`, fixing only the rejected or flagged Stories, rather than re-authoring the whole array from scratch. `helpers/plan-epic.md` bounds each critic's feedback to **one refinement pass** — apply the edit and re-run the downstream step once; do not loop.
21
22
  - Treat **`maxTickets`** from the context envelope as a **reviewability budget**, not a hard authoring cap (Story #2798). Merge narrow, single-module Stories into their capability first; if the plan genuinely needs more, emit the full plan and add a compact `over_budget_rationale` note inside the first Story's `## Goal` section explaining why the plan exceeds the budget. Operator persistence then requires the explicit `--allow-over-budget` override on `epic-plan-decompose.js`; without it the persist step rejects the over-budget array. Never truncate the JSON array to fit.
22
23
  - Honour the 2-tier hierarchy: every ticket is a **Story** attached directly to the Epic. Stories carry the implementation scope inline; no Feature and no lower ticket tier exists. Thematic grouping is prose in the Epic body / Tech Spec, never a ticket.
23
24
  - **Decompose at deliverable granularity, not module/task level.** A Story is a capability slice a frontier model delivers and self-verifies in one pass — a shippable slice a reviewer would accept as a single PR — not a single module or file. See the STORY SIZING section for the full guidance and the single-consumer merge rule.
@@ -435,5 +436,6 @@ any logical ordering requirement via Story-level `depends_on`.
435
436
  loudly. Instruct the caller to run `--emit-context` first.
436
437
  - The validator
437
438
  ([`lib/orchestration/ticket-validator.js`](../../../scripts/lib/orchestration/ticket-validator.js))
438
- is the authoritative gate. Re-author when it rejects rather than
439
- patching tickets by hand.
439
+ is the authoritative gate. On rejection, apply the re-emit rule above —
440
+ a targeted edit to the existing `tickets.json` fixing only what was
441
+ rejected — rather than re-authoring the array from scratch.