mandrel 2.1.0 → 2.3.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 (42) hide show
  1. package/.agents/agents/acceptance-critic.md +11 -2
  2. package/.agents/agents/story-worker.md +4 -2
  3. package/.agents/docs/SDLC.md +11 -4
  4. package/.agents/docs/configuration.md +1 -1
  5. package/.agents/docs/quality-gates.md +3 -3
  6. package/.agents/rules/gherkin-standards.md +10 -0
  7. package/.agents/schemas/acceptance-eval-verdict.schema.json +2 -2
  8. package/.agents/schemas/agentrc.schema.json +1 -1
  9. package/.agents/scripts/acceptance-eval.js +2 -2
  10. package/.agents/scripts/lib/config/acceptance-eval.js +2 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  12. package/.agents/scripts/lib/orchestration/change-set.js +103 -0
  13. package/.agents/scripts/lib/orchestration/code-review.js +24 -35
  14. package/.agents/scripts/lib/orchestration/plan-context.js +2 -9
  15. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +17 -16
  16. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +28 -15
  17. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +0 -25
  18. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +230 -0
  19. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -2
  20. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +97 -255
  22. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +191 -0
  23. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +120 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +75 -8
  25. package/.agents/scripts/lib/templates/decomposer-prompts.js +8 -13
  26. package/.agents/scripts/lib/wave-runner/live-probe.js +315 -0
  27. package/.agents/scripts/plan-context.js +0 -1
  28. package/.agents/scripts/plan-critics.js +203 -0
  29. package/.agents/scripts/quality-preview.js +13 -6
  30. package/.agents/scripts/stories-wave-tick.js +307 -55
  31. package/.agents/workflows/deliver.md +50 -15
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +14 -5
  33. package/.agents/workflows/helpers/code-quality-guardrails.md +7 -4
  34. package/.agents/workflows/helpers/code-review.md +2 -2
  35. package/.agents/workflows/helpers/deliver-story.md +22 -6
  36. package/.agents/workflows/plan.md +55 -0
  37. package/bin/mandrel.js +0 -0
  38. package/docs/CHANGELOG.md +30 -0
  39. package/lib/cli/update.js +83 -34
  40. package/lib/migrations/index.js +6 -1
  41. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +154 -0
  42. 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
+ }
@@ -205,7 +205,6 @@ async function main() {
205
205
  {
206
206
  cli: 'plan-context',
207
207
  mode,
208
- epicId: null,
209
208
  config,
210
209
  },
211
210
  () =>
@@ -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] === '--changed-since') {
44
- const next = argv[i + 1];
45
- if (next && !next.startsWith('--')) return next;
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 null;
56
+ return resolved;
50
57
  }
51
58
 
52
59
  /**