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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- 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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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({
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 {
|
|
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>` —
|
|
15
|
-
*
|
|
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> [--
|
|
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
|
-
|
|
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
|
|
185
|
+
reason: `changelog body annotation references #${epicId}`,
|
|
153
186
|
};
|
|
154
187
|
}
|
|
155
188
|
return {
|
|
156
189
|
file,
|
|
157
190
|
pass: false,
|
|
158
|
-
reason:
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
-
`
|
|
204
|
-
`
|
|
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
|
|
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.
|
|
439
|
-
|
|
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.
|