mandrel 2.1.0 → 2.2.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/agents/acceptance-critic.md +11 -2
- package/.agents/agents/story-worker.md +4 -2
- package/.agents/docs/SDLC.md +11 -4
- package/.agents/docs/configuration.md +1 -1
- package/.agents/docs/quality-gates.md +3 -3
- package/.agents/rules/gherkin-standards.md +10 -0
- package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
- package/.agents/scripts/lib/orchestration/change-set.js +103 -0
- package/.agents/scripts/lib/orchestration/code-review.js +24 -35
- package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
- package/.agents/scripts/lib/story-body/story-body.js +75 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
- package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
- package/.agents/scripts/plan-context.js +0 -1
- package/.agents/scripts/plan-critics.js +203 -0
- package/.agents/scripts/quality-preview.js +13 -6
- package/.agents/scripts/stories-wave-tick.js +307 -55
- package/.agents/workflows/deliver.md +50 -15
- package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
- package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
- package/.agents/workflows/helpers/code-review.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +22 -6
- package/.agents/workflows/plan.md +55 -0
- package/docs/CHANGELOG.md +22 -0
- package/lib/migrations/index.js +6 -1
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
- package/package.json +2 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/wave-runner/live-probe.js — the state-probing adapter that feeds the
|
|
3
|
+
* ready-set kernel from live GitHub state.
|
|
4
|
+
*
|
|
5
|
+
* `selectReadySet` (`./ready-set.js`) is deliberately a pure, side-effect-free
|
|
6
|
+
* kernel: callers hand it the live Story records, the done set, and the
|
|
7
|
+
* in-flight count, and it decides. Until now the only adapter was the
|
|
8
|
+
* flag-driven one (`stories-wave-tick.js --dag/--done/--in-flight`), which
|
|
9
|
+
* pushed the *gathering* of those inputs onto the caller — in practice onto
|
|
10
|
+
* the host LLM following `/deliver`'s prose, re-seeding `--done` and counting
|
|
11
|
+
* `--in-flight` by hand every beat. That is hand-maintained accounting on the
|
|
12
|
+
* one correctness-critical path where a mistake silently wedges a run (a
|
|
13
|
+
* dropped foreign blocker) or double-dispatches a Story (a miscounted slot).
|
|
14
|
+
*
|
|
15
|
+
* This module closes that gap by **probing** the same facts the host was
|
|
16
|
+
* transcribing:
|
|
17
|
+
*
|
|
18
|
+
* - **done** — an `agent::done` label OR a closed issue, the same predicate
|
|
19
|
+
* `classifyStory` already applies, evaluated over live state rather than a
|
|
20
|
+
* `--done` CSV the caller maintained across beats. Foreign blockers
|
|
21
|
+
* (outside the delivered set) are resolved too, which is what makes
|
|
22
|
+
* cross-run delivery work: a blocker that merged weeks ago in another run
|
|
23
|
+
* is simply done.
|
|
24
|
+
* - **in-flight** — derived from live `agent::executing` / `agent::closing`
|
|
25
|
+
* labels, **unioned with the ids the host says it has dispatched**
|
|
26
|
+
* (`--dispatched`). The label alone is not sufficient: the kernel's
|
|
27
|
+
* contract counts "executing / closing / dispatched-not-yet-labelled" as
|
|
28
|
+
* in-flight, and `single-story-init.js` flips `agent::executing` at step 6
|
|
29
|
+
* of 6 — *after* a 3–6 minute worktree install. For that whole window a
|
|
30
|
+
* dispatched Story still reads `agent::ready`, so a label-only derivation
|
|
31
|
+
* re-emits it in the next beat's `ready[]` and the host dispatches it a
|
|
32
|
+
* second time onto the same branch and worktree (Story #4601).
|
|
33
|
+
* - **blocked** — the ids carrying `agent::blocked`. `classifyStory` has
|
|
34
|
+
* always returned this class; nothing consumed it, so a blocked Story was
|
|
35
|
+
* neither done, ready, nor in-flight and the beat reported a permanent
|
|
36
|
+
* "waiting" (Story #4601).
|
|
37
|
+
*
|
|
38
|
+
* It is an **adapter, not a kernel change**: it gathers inputs and hands them
|
|
39
|
+
* to `selectReadySet` unchanged. The kernel stays pure and flag-driven, and
|
|
40
|
+
* the legacy flag mode stays byte-compatible.
|
|
41
|
+
*
|
|
42
|
+
* The graph resolution is **not** reimplemented here — it reuses
|
|
43
|
+
* `resolve-stories.js`'s machinery wholesale (body `depends_on` ∪ native
|
|
44
|
+
* `blocked_by` edges, foreign-blocker resolution, `files[]` footprints), so
|
|
45
|
+
* the probe and `/deliver`'s step-1 resolution cannot disagree about what
|
|
46
|
+
* depends on what.
|
|
47
|
+
*
|
|
48
|
+
* @module lib/wave-runner/live-probe
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
fetchStories,
|
|
53
|
+
readNativeEdges,
|
|
54
|
+
resolveForeignDone,
|
|
55
|
+
resolveStoriesProvider,
|
|
56
|
+
} from '../../resolve-stories.js';
|
|
57
|
+
import { AGENT_LABELS } from '../label-constants.js';
|
|
58
|
+
import { buildStoriesEnvelope } from '../orchestration/resolve-stories.js';
|
|
59
|
+
import { classifyStory, storyIdOf } from './ready-set.js';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Identify the Stories that currently occupy a dispatch slot, as an id set.
|
|
63
|
+
*
|
|
64
|
+
* Two sources, unioned — which is exactly the kernel's stated contract
|
|
65
|
+
* ("executing / closing / dispatched-not-yet-labelled"):
|
|
66
|
+
*
|
|
67
|
+
* 1. **Live labels.** `classifyStory` folds `agent::executing` and
|
|
68
|
+
* `agent::closing` into one `executing` class — both are in-flight and
|
|
69
|
+
* neither may be re-dispatched.
|
|
70
|
+
* 2. **`dispatched`** — ids the host has spawned. This closes the init
|
|
71
|
+
* window: `single-story-init.js` flips `agent::executing` last, after a
|
|
72
|
+
* 3–6 minute install, so between spawn and flip a dispatched Story reads
|
|
73
|
+
* `agent::ready` and a label-only derivation hands it back as ready.
|
|
74
|
+
*
|
|
75
|
+
* `dispatched` is deliberately **not** the `--done`-style accounting probe
|
|
76
|
+
* mode retired. Three properties keep it from becoming one:
|
|
77
|
+
*
|
|
78
|
+
* - **It is a set union, not a counter.** Re-passing an id that has since
|
|
79
|
+
* picked up its `agent::executing` label cannot double-count a slot.
|
|
80
|
+
* - **Live state overrules the claim.** An id the host still lists but that
|
|
81
|
+
* now classifies `done` (or `blocked`) is dropped, so a stale entry can
|
|
82
|
+
* never occupy a slot forever and starve the run.
|
|
83
|
+
* - **Therefore the host's correct strategy is monotonic append**: pass
|
|
84
|
+
* every id you have dispatched this run and never reason about removing
|
|
85
|
+
* one. There is no drop-a-slot decision to get wrong — the probe subtracts
|
|
86
|
+
* reality from the claim. Forgetting an id degrades to the pre-#4601
|
|
87
|
+
* behaviour rather than to something worse.
|
|
88
|
+
*
|
|
89
|
+
* Ids outside the probed set are ignored: they are not part of this run and
|
|
90
|
+
* must not consume its cap.
|
|
91
|
+
*
|
|
92
|
+
* @param {Array<{id?: number, number?: number, labels?: string[], state?: string}>} storyRecords
|
|
93
|
+
* @param {Iterable<number>} [dispatched] Ids the host has spawned.
|
|
94
|
+
* @returns {Set<number>} In-flight Story ids.
|
|
95
|
+
*/
|
|
96
|
+
function deriveInFlightIds(storyRecords, dispatched = []) {
|
|
97
|
+
const claimed = new Set(dispatched);
|
|
98
|
+
const inFlight = new Set();
|
|
99
|
+
for (const rec of storyRecords) {
|
|
100
|
+
const id = storyIdOf(rec);
|
|
101
|
+
if (id === null) continue;
|
|
102
|
+
const cls = classifyStory(rec);
|
|
103
|
+
if (cls === 'executing' || (claimed.has(id) && cls === 'ready')) {
|
|
104
|
+
inFlight.add(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return inFlight;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The ids carrying `agent::blocked`.
|
|
112
|
+
*
|
|
113
|
+
* `classifyStory` has always returned a `blocked` class, but no adapter
|
|
114
|
+
* consumed it: a blocked Story was never done, never ready, and never counted
|
|
115
|
+
* in-flight, so `detectWedge` dropped it (its "undone work with no unmet
|
|
116
|
+
* blockers would have been dispatched" invariant is precisely what probe mode
|
|
117
|
+
* broke) and the beat reported exit 0 / `ready: []` / `wedged: null` forever.
|
|
118
|
+
* `/deliver` reads that as "waiting", so the `agent::blocked` HITL pause — the
|
|
119
|
+
* one runtime gate in the protocol — was never surfaced to the operator.
|
|
120
|
+
*
|
|
121
|
+
* @param {Array<{id?: number, number?: number, labels?: string[], state?: string}>} storyRecords
|
|
122
|
+
* @returns {number[]} Blocked Story ids, ascending.
|
|
123
|
+
*/
|
|
124
|
+
function deriveBlockedIds(storyRecords) {
|
|
125
|
+
return storyRecords
|
|
126
|
+
.filter((rec) => classifyStory(rec) === 'blocked')
|
|
127
|
+
.map((rec) => storyIdOf(rec))
|
|
128
|
+
.filter((id) => id !== null)
|
|
129
|
+
.sort((a, b) => a - b);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the provider + repo coordinates the probe reads through.
|
|
134
|
+
*
|
|
135
|
+
* Shares `resolve-stories.js`'s provider seam, so probe mode authenticates and
|
|
136
|
+
* targets exactly the same repo `/deliver`'s resolution step does. Tests
|
|
137
|
+
* inject a stub provider instead of calling this.
|
|
138
|
+
*
|
|
139
|
+
* @param {object} [deps]
|
|
140
|
+
* @param {Function} [deps.resolveProvider] Injection seam for tests.
|
|
141
|
+
* @returns {{ provider: object, owner: string|undefined, repo: string|undefined }}
|
|
142
|
+
*/
|
|
143
|
+
export function createProbeContext({
|
|
144
|
+
resolveProvider = resolveStoriesProvider,
|
|
145
|
+
} = {}) {
|
|
146
|
+
const { provider, config } = resolveProvider();
|
|
147
|
+
return { provider, owner: config?.github?.owner, repo: config?.github?.repo };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Probe live state for a set of Story ids and return the exact inputs
|
|
152
|
+
* `selectReadySet` consumes.
|
|
153
|
+
*
|
|
154
|
+
* Mirrors `resolve-stories.js`'s two-pass envelope build: a provisional pass
|
|
155
|
+
* yields the DAG whose foreign dependency ids are then resolved against live
|
|
156
|
+
* issue state, and the second pass folds those satisfied foreign blockers into
|
|
157
|
+
* `done[]`. Skipping that pass would withhold any Story whose blocker landed
|
|
158
|
+
* outside the delivered set — the cross-run wedge the resolver exists to fix.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} args
|
|
161
|
+
* @param {number[]} args.ids Story ids in the run.
|
|
162
|
+
* @param {object} args.provider GitHub provider (stubbed in tests).
|
|
163
|
+
* @param {string} [args.owner]
|
|
164
|
+
* @param {string} [args.repo]
|
|
165
|
+
* @param {boolean} [args.native=true] Read native `blocked_by` edges.
|
|
166
|
+
* @param {number[]} [args.dispatched=[]] Ids the host has spawned but may not
|
|
167
|
+
* yet have observed labelled `agent::executing` (see `deriveInFlightIds`).
|
|
168
|
+
* @param {(msg: string) => void} [args.warn]
|
|
169
|
+
* Each returned node carries its **live labels**. That is load-bearing, not
|
|
170
|
+
* decoration: `selectReadySet` classifies from labels, so a node stripped of
|
|
171
|
+
* them reads as `ready` and an `agent::executing` Story gets re-dispatched
|
|
172
|
+
* onto a second branch while its first run is still going. The resolver's DAG
|
|
173
|
+
* projection (`{id, dependsOn, files}`) drops labels because flag mode's
|
|
174
|
+
* caller tracked in-flight itself; probe mode must put them back.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<{
|
|
177
|
+
* nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
|
|
178
|
+
* doneIds: Set<number>,
|
|
179
|
+
* inFlight: number,
|
|
180
|
+
* blockedIds: number[]
|
|
181
|
+
* }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function probeLiveState({
|
|
184
|
+
ids,
|
|
185
|
+
provider,
|
|
186
|
+
owner,
|
|
187
|
+
repo,
|
|
188
|
+
native = true,
|
|
189
|
+
dispatched = [],
|
|
190
|
+
warn,
|
|
191
|
+
}) {
|
|
192
|
+
const stories = await fetchStories(provider, ids);
|
|
193
|
+
const nativeEdges = native
|
|
194
|
+
? await readNativeEdges({ provider, stories, owner, repo })
|
|
195
|
+
: new Map();
|
|
196
|
+
|
|
197
|
+
const provisional = buildStoriesEnvelope({ stories, nativeEdges, warn });
|
|
198
|
+
const foreignDone = await resolveForeignDone({
|
|
199
|
+
provider,
|
|
200
|
+
dag: provisional.dag,
|
|
201
|
+
inSetIds: new Set(stories.map((s) => s.id)),
|
|
202
|
+
});
|
|
203
|
+
const envelope = buildStoriesEnvelope({
|
|
204
|
+
stories,
|
|
205
|
+
nativeEdges,
|
|
206
|
+
foreignDone,
|
|
207
|
+
warn: () => {},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
|
|
211
|
+
const inFlightIds = deriveInFlightIds(stories, dispatched);
|
|
212
|
+
return {
|
|
213
|
+
nodes: envelope.dag.map((node) => ({
|
|
214
|
+
...node,
|
|
215
|
+
labels: projectInFlightLabels(
|
|
216
|
+
labelsById.get(node.id) ?? [],
|
|
217
|
+
inFlightIds.has(node.id),
|
|
218
|
+
),
|
|
219
|
+
})),
|
|
220
|
+
doneIds: new Set(envelope.done),
|
|
221
|
+
inFlight: inFlightIds.size,
|
|
222
|
+
blockedIds: deriveBlockedIds(stories),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Project the in-flight fact onto a node's labels, synthesizing
|
|
228
|
+
* `agent::executing` for a Story that is dispatched but not yet labelled.
|
|
229
|
+
*
|
|
230
|
+
* This is the load-bearing half of the dispatch-window fix, and it is why
|
|
231
|
+
* `inFlight` alone is not enough. The two inputs do **different** jobs inside
|
|
232
|
+
* `selectReadySet`:
|
|
233
|
+
*
|
|
234
|
+
* - `inFlight` is only a **count**. It reserves capacity (`slots = cap −
|
|
235
|
+
* inFlight`) and nothing more.
|
|
236
|
+
* - **Eligibility is decided per-record by `classifyStory`**, from labels.
|
|
237
|
+
*
|
|
238
|
+
* So a dispatched-but-unlabelled Story counted only via `inFlight` still
|
|
239
|
+
* classifies `ready`, stays eligible, and — whenever a slot remains — is
|
|
240
|
+
* admitted to the very same beat that reserved a slot for it. It would be
|
|
241
|
+
* re-dispatched onto its own live branch, with the miscount merely reshaped
|
|
242
|
+
* rather than fixed. Handing the kernel the label makes it apply the rule it
|
|
243
|
+
* already has, and keeps the kernel itself untouched: the adapter's job is to
|
|
244
|
+
* supply the input the kernel's contract ("executing / closing / dispatched-
|
|
245
|
+
* not-yet-labelled") already specifies.
|
|
246
|
+
*
|
|
247
|
+
* @param {string[]} labels The Story's live labels.
|
|
248
|
+
* @param {boolean} inFlight Whether the Story occupies a dispatch slot.
|
|
249
|
+
* @returns {string[]} Labels, with `agent::executing` added when needed.
|
|
250
|
+
*/
|
|
251
|
+
function projectInFlightLabels(labels, inFlight) {
|
|
252
|
+
if (!inFlight || classifyStory({ labels }) === 'executing') return labels;
|
|
253
|
+
return [...labels, AGENT_LABELS.EXECUTING];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Validate the mode-selecting flags, keeping probe mode and the legacy
|
|
258
|
+
* flag mode mutually exclusive.
|
|
259
|
+
*
|
|
260
|
+
* The exclusion is not pedantry: `--probe-live` derives `done` and `in-flight`
|
|
261
|
+
* from live state, so honouring a caller-supplied `--done` alongside it would
|
|
262
|
+
* silently reintroduce the hand-maintained accounting probe mode exists to
|
|
263
|
+
* retire — and quietly disagree with reality when the two differ.
|
|
264
|
+
*
|
|
265
|
+
* `--dispatched` is the deliberate exception, and it is **additive rather than
|
|
266
|
+
* authoritative**: it does not replace the derived in-flight set, it is unioned
|
|
267
|
+
* into it and then filtered by live state (see `deriveInFlightIds`). It carries
|
|
268
|
+
* the one fact the host knows and GitHub does not yet — "I spawned this id, the
|
|
269
|
+
* label has not appeared yet" — so it cannot disagree with reality the way an
|
|
270
|
+
* authoritative `--in-flight <n>` could. `--in-flight` therefore stays excluded.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} flags
|
|
273
|
+
* @param {boolean} [flags.probeLive]
|
|
274
|
+
* @param {string} [flags.stories]
|
|
275
|
+
* @param {string} [flags.dag]
|
|
276
|
+
* @param {string} [flags.dagFile]
|
|
277
|
+
* @param {string} [flags.done]
|
|
278
|
+
* @param {string} [flags.inFlight]
|
|
279
|
+
* @param {string} [flags.dispatched]
|
|
280
|
+
* @returns {string|null} An error message, or `null` when the flags are valid.
|
|
281
|
+
*/
|
|
282
|
+
export function validateProbeFlags({
|
|
283
|
+
probeLive,
|
|
284
|
+
stories,
|
|
285
|
+
dag,
|
|
286
|
+
dagFile,
|
|
287
|
+
done,
|
|
288
|
+
inFlight,
|
|
289
|
+
dispatched,
|
|
290
|
+
} = {}) {
|
|
291
|
+
if (!probeLive) {
|
|
292
|
+
if (dispatched != null) {
|
|
293
|
+
return '--dispatched requires --probe-live (it augments the live-derived in-flight set; flag mode uses --in-flight <n>)';
|
|
294
|
+
}
|
|
295
|
+
return stories
|
|
296
|
+
? '--stories requires --probe-live (it names the run to probe from live state)'
|
|
297
|
+
: null;
|
|
298
|
+
}
|
|
299
|
+
const conflicting = [
|
|
300
|
+
dag ? '--dag' : null,
|
|
301
|
+
dagFile ? '--dag-file' : null,
|
|
302
|
+
done != null ? '--done' : null,
|
|
303
|
+
inFlight != null ? '--in-flight' : null,
|
|
304
|
+
].filter(Boolean);
|
|
305
|
+
if (conflicting.length > 0) {
|
|
306
|
+
return (
|
|
307
|
+
`--probe-live is mutually exclusive with ${conflicting.join(', ')}: it resolves the graph ` +
|
|
308
|
+
`and derives done / in-flight from live state. Drop the flag(s), or use the legacy flag mode.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (!stories) {
|
|
312
|
+
return '--probe-live requires --stories <csv> of Story ids';
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* plan-critics.js — the /plan critic-dispatch verdict CLI (Story #4592).
|
|
5
|
+
*
|
|
6
|
+
* `/plan` step 2.5 (between Author and Persist) runs this against the draft
|
|
7
|
+
* `stories.json`. It evaluates the consolidation + pre-mortem dispatch
|
|
8
|
+
* conditions and prints the verdict as JSON on stdout so the workflow can
|
|
9
|
+
* act on it — dispatching a fresh-context critic sub-agent and folding its
|
|
10
|
+
* findings into a re-author round **before** the plan is persisted.
|
|
11
|
+
*
|
|
12
|
+
* Why here and nowhere else. The evaluation used to run inside
|
|
13
|
+
* `run-plan-persist.js`, after authoring was finished and immediately before
|
|
14
|
+
* `createStoryIssues` — the one point in the flow where nothing can act on a
|
|
15
|
+
* `dispatch: true` verdict, because the artifacts are about to become live
|
|
16
|
+
* issues. It logged the verdict and moved on. This CLI is now the **single**
|
|
17
|
+
* evaluation point, sited where a re-author loop actually exists.
|
|
18
|
+
*
|
|
19
|
+
* Advisory by contract: a `dispatch: true` verdict routes work to the
|
|
20
|
+
* workflow, it does not gate the run. This CLI exits 0 on any verdict; only a
|
|
21
|
+
* usage/IO error is a failure. Every `dispatch: false` decision is recorded to
|
|
22
|
+
* the plan-metrics ledger (`appendCriticSkip`) so under-firing stays auditable.
|
|
23
|
+
*
|
|
24
|
+
* CLI:
|
|
25
|
+
* --stories <file> Required. The draft Story ticket array (JSON).
|
|
26
|
+
* --tech-spec <file> Optional. Shared Tech Spec carrying the
|
|
27
|
+
* `## Delivery Slicing` table the consolidation
|
|
28
|
+
* precondition reads.
|
|
29
|
+
*
|
|
30
|
+
* stdout is reserved for the verdict JSON (Story #2278 discipline):
|
|
31
|
+
*
|
|
32
|
+
* {
|
|
33
|
+
* "consolidation": { "critic": "consolidation", "dispatch": false, "reasons": [...] },
|
|
34
|
+
* "premortem": { "critic": "pre-mortem", "dispatch": true, "reasons": [...] },
|
|
35
|
+
* "textHygiene": { "critic": "text-hygiene", "findings": [...] }
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* `textHygiene` (Story #4599) is advisory-only: deterministic body lints with
|
|
39
|
+
* no dispatch semantics — its findings fold into the re-author round.
|
|
40
|
+
*
|
|
41
|
+
* Human-readable log lines go to stderr, matching the sibling `plan-persist`.
|
|
42
|
+
*
|
|
43
|
+
* Exit codes: 0 success (any verdict); 1 usage/IO error.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { readFile } from 'node:fs/promises';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { parseArgs } from 'node:util';
|
|
49
|
+
|
|
50
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
51
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
52
|
+
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
53
|
+
import { evaluatePlanCritics } from './lib/orchestration/plan-critics-evaluate.js';
|
|
54
|
+
import { appendCriticSkip } from './lib/orchestration/plan-metrics.js';
|
|
55
|
+
|
|
56
|
+
const CLI_OPTIONS = {
|
|
57
|
+
stories: { type: 'string' },
|
|
58
|
+
'tech-spec': { type: 'string' },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const USAGE = 'Usage: plan-critics.js --stories <file> [--tech-spec <file>]';
|
|
62
|
+
|
|
63
|
+
/** The `cli` discriminator every ledger record from this surface carries. */
|
|
64
|
+
export const PLAN_CRITICS_CLI = 'plan-critics';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the draft artifacts the critics evaluate.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ storiesPath: string, techSpecPath?: string|null }} paths
|
|
70
|
+
* @returns {Promise<{ tickets: object[], techSpecContent: string }>}
|
|
71
|
+
*/
|
|
72
|
+
export async function loadCriticArtifacts({
|
|
73
|
+
storiesPath,
|
|
74
|
+
techSpecPath = null,
|
|
75
|
+
}) {
|
|
76
|
+
const raw = await readFile(storiesPath, 'utf8');
|
|
77
|
+
let tickets;
|
|
78
|
+
try {
|
|
79
|
+
tickets = JSON.parse(raw);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Failed to parse stories file "${storiesPath}" as JSON: ${err.message}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(tickets)) {
|
|
86
|
+
throw new Error(`Stories file "${storiesPath}" must contain a JSON array.`);
|
|
87
|
+
}
|
|
88
|
+
const techSpecContent = techSpecPath
|
|
89
|
+
? await readFile(techSpecPath, 'utf8')
|
|
90
|
+
: '';
|
|
91
|
+
return { tickets, techSpecContent };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Log each decision and record every skip on the plan-metrics ledger. The
|
|
96
|
+
* ledger write is best-effort by `appendCriticSkip`'s own contract — it can
|
|
97
|
+
* never fail the plan step.
|
|
98
|
+
*
|
|
99
|
+
* @param {{ consolidation: object, premortem: object, textHygiene?: object }} verdict
|
|
100
|
+
* @param {object} config
|
|
101
|
+
* @param {{ append?: typeof appendCriticSkip }} [deps]
|
|
102
|
+
* @returns {Promise<void>}
|
|
103
|
+
*/
|
|
104
|
+
export async function recordCriticSkips(
|
|
105
|
+
verdict,
|
|
106
|
+
config,
|
|
107
|
+
{ append = appendCriticSkip } = {},
|
|
108
|
+
) {
|
|
109
|
+
for (const decision of [verdict.consolidation, verdict.premortem]) {
|
|
110
|
+
Logger.info(
|
|
111
|
+
`[plan-critics] critic ${decision.critic}: ` +
|
|
112
|
+
`${decision.dispatch ? 'dispatch' : 'skip'} — ` +
|
|
113
|
+
decision.reasons.join('; '),
|
|
114
|
+
);
|
|
115
|
+
if (!decision.dispatch) {
|
|
116
|
+
await append(
|
|
117
|
+
{
|
|
118
|
+
critic: decision.critic,
|
|
119
|
+
reasons: decision.reasons,
|
|
120
|
+
cli: PLAN_CRITICS_CLI,
|
|
121
|
+
},
|
|
122
|
+
config,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Text hygiene (Story #4599) is advisory-only — no dispatch semantics, so
|
|
128
|
+
// "skip" here means "zero findings". Recording that keeps the lint's
|
|
129
|
+
// fire/skip accounting on the same ledger as the dispatching critics.
|
|
130
|
+
const hygiene = verdict.textHygiene;
|
|
131
|
+
if (hygiene) {
|
|
132
|
+
const count = hygiene.findings.length;
|
|
133
|
+
Logger.info(
|
|
134
|
+
`[plan-critics] critic ${hygiene.critic}: ${count} finding(s) (advisory).`,
|
|
135
|
+
);
|
|
136
|
+
if (count === 0) {
|
|
137
|
+
await append(
|
|
138
|
+
{
|
|
139
|
+
critic: hygiene.critic,
|
|
140
|
+
reasons: ['No text-hygiene findings over the draft stories.'],
|
|
141
|
+
cli: PLAN_CRITICS_CLI,
|
|
142
|
+
},
|
|
143
|
+
config,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load the artifacts, evaluate both critics, record the skips, and return the
|
|
151
|
+
* verdict. Exported as the CLI's whole body so tests drive it in-process with
|
|
152
|
+
* an explicit config and ledger seam.
|
|
153
|
+
*
|
|
154
|
+
* @param {{
|
|
155
|
+
* storiesPath: string,
|
|
156
|
+
* techSpecPath?: string|null,
|
|
157
|
+
* config?: object,
|
|
158
|
+
* append?: typeof appendCriticSkip,
|
|
159
|
+
* }} args
|
|
160
|
+
* @returns {Promise<{ consolidation: object, premortem: object, textHygiene: object }>}
|
|
161
|
+
*/
|
|
162
|
+
export async function evaluateCriticArtifacts({
|
|
163
|
+
storiesPath,
|
|
164
|
+
techSpecPath = null,
|
|
165
|
+
config = {},
|
|
166
|
+
append = appendCriticSkip,
|
|
167
|
+
}) {
|
|
168
|
+
const { tickets, techSpecContent } = await loadCriticArtifacts({
|
|
169
|
+
storiesPath,
|
|
170
|
+
techSpecPath,
|
|
171
|
+
});
|
|
172
|
+
const verdict = evaluatePlanCritics({ techSpecContent, tickets, config });
|
|
173
|
+
await recordCriticSkips(verdict, config, { append });
|
|
174
|
+
return verdict;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function main() {
|
|
178
|
+
const { values } = parseArgs({ options: CLI_OPTIONS });
|
|
179
|
+
|
|
180
|
+
if (!values.stories) {
|
|
181
|
+
throw new Error(USAGE);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// stdout is reserved for the verdict JSON — flip every Logger sink that
|
|
185
|
+
// could land on stdout to stderr before any evaluation runs.
|
|
186
|
+
routeAllOutputToStderr();
|
|
187
|
+
|
|
188
|
+
const verdict = await evaluateCriticArtifacts({
|
|
189
|
+
storiesPath: path.resolve(values.stories),
|
|
190
|
+
techSpecPath: values['tech-spec']
|
|
191
|
+
? path.resolve(values['tech-spec'])
|
|
192
|
+
: null,
|
|
193
|
+
config: resolveConfig(),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
runAsCli(import.meta.url, main, {
|
|
201
|
+
source: 'plan-critics',
|
|
202
|
+
propagateExitCode: true,
|
|
203
|
+
});
|
|
@@ -35,18 +35,25 @@ import {
|
|
|
35
35
|
* present without a value. Returns `null` when the flag is absent so callers
|
|
36
36
|
* can fall through to the gate scripts' own diff defaults.
|
|
37
37
|
*
|
|
38
|
+
* **Last occurrence wins** (Story #4603). `npm run <alias> -- --changed-since <base>`
|
|
39
|
+
* appends the operator's flag *after* any flag baked into the npm script, so a
|
|
40
|
+
* first-wins scan silently discarded the operator's base and compared against
|
|
41
|
+
* the script's hardcoded one instead — reporting a false green for a branch the
|
|
42
|
+
* gate had never actually scored. Last-wins matches the convention every
|
|
43
|
+
* mainstream CLI parser follows for repeated scalar flags, and makes the
|
|
44
|
+
* npm-alias passthrough behave the way its callers already assume.
|
|
45
|
+
*
|
|
38
46
|
* @param {string[]} argv
|
|
39
47
|
* @returns {string | null}
|
|
40
48
|
*/
|
|
41
49
|
export function parseChangedSinceArg(argv) {
|
|
50
|
+
let resolved = null;
|
|
42
51
|
for (let i = 0; i < argv.length; i += 1) {
|
|
43
|
-
if (argv[i]
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return 'HEAD';
|
|
47
|
-
}
|
|
52
|
+
if (argv[i] !== '--changed-since') continue;
|
|
53
|
+
const next = argv[i + 1];
|
|
54
|
+
resolved = next && !next.startsWith('--') ? next : 'HEAD';
|
|
48
55
|
}
|
|
49
|
-
return
|
|
56
|
+
return resolved;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
/**
|