mandrel 2.24.0 → 2.25.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 (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. package/package.json +1 -1
@@ -191,6 +191,29 @@ export function mergeEnvelopes(miEnvelope, crapEnvelope) {
191
191
  };
192
192
  }
193
193
 
194
+ /**
195
+ * Render the named diagnostics a gate envelope carries, or `null` when it
196
+ * carries none (Story #4866).
197
+ *
198
+ * A diagnostic is what a gate emits *instead of* per-method verdicts when it
199
+ * has established that no verdict it could produce would be meaningful — an
200
+ * incomparable baseline, or a comparison basis whose drifted-row ratio proves
201
+ * the two sides disagree on line coordinates. It must reach the operator
202
+ * verbatim: the gate exits 0, so silence would read as a clean run.
203
+ *
204
+ * @param {Array<{ envelope: { diagnostics?: Array<{name: string, message: string}> } | null }>} results
205
+ * @returns {string | null}
206
+ */
207
+ export function renderDiagnostics(results) {
208
+ const lines = [];
209
+ for (const { envelope } of results ?? []) {
210
+ for (const d of envelope?.diagnostics ?? []) {
211
+ lines.push(`[${d.name}] ${d.message}`);
212
+ }
213
+ }
214
+ return lines.length === 0 ? null : lines.join('\n');
215
+ }
216
+
194
217
  /**
195
218
  * Compute the CLI exit code from a merge result + per-gate exit codes. Pure.
196
219
  *
@@ -320,6 +343,8 @@ export async function runCli({
320
343
  : `scope=diff ref=${ref}\n\n`,
321
344
  );
322
345
  stdout.write(`${renderTable(merged)}\n`);
346
+ const diagnostics = renderDiagnostics([miResult, crapResult]);
347
+ if (diagnostics) stdout.write(`\n${diagnostics}\n`);
323
348
  if (miExit !== 0 || crapExit !== 0) {
324
349
  stderr.write(
325
350
  `\n[quality:preview] gate exits: mi=${miExit} crap=${crapExit}\n`,
@@ -32,6 +32,7 @@ import { spawnSync } from 'node:child_process';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
34
  import { fileURLToPath } from 'node:url';
35
+ import { assertNoReservedIdStreams } from './check-test-temp-hygiene.js';
35
36
  import { cleanupRepoTestTempArtifacts } from './cleanup-repo-test-temp.js';
36
37
  import { runAsCli } from './lib/cli-utils.js';
37
38
  import { buildWebhookSafeTestEnv } from './lib/test-env.js';
@@ -165,6 +166,7 @@ export function runTestSuite({
165
166
  cleanup = cleanupRepoTestTempArtifacts,
166
167
  listTargets = listTestFilesForTier,
167
168
  maxTargetChars = resolveMaxTargetChars(),
169
+ fixtureStreamGuard = assertNoReservedIdStreams,
168
170
  } = {}) {
169
171
  const { tier, rest } = parseTierArgv(argv);
170
172
  const targets = listTargets(tier, cwd);
@@ -192,6 +194,14 @@ export function runTestSuite({
192
194
 
193
195
  cleanup({ repoRoot: cwd });
194
196
 
197
+ // A test that spawns a real CLI at the repository root inherits the real
198
+ // state directory, so its fixture telemetry can land in the operator's live
199
+ // signals tree — where the retro graduator counts it as recurrence evidence
200
+ // and files a ticket citing a fixture Story id. Fail the run that caused it
201
+ // rather than discovering it from that ticket.
202
+ const polluted = fixtureStreamGuard({ cwd });
203
+ if (polluted !== 0 && status === 0) status = polluted;
204
+
195
205
  if (spawnError) {
196
206
  throw spawnError;
197
207
  }
@@ -90,6 +90,7 @@ import { runAsCli } from './lib/cli-utils.js';
90
90
  import { formatCliError } from './lib/error-redactor.js';
91
91
  import { Logger } from './lib/Logger.js';
92
92
  import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
93
+ import { resolveRunScopedConfig } from './lib/orchestration/run-scoped-config.js';
93
94
  import {
94
95
  failedTerminalFor,
95
96
  gatesForFailedPhase,
@@ -123,12 +124,17 @@ export const enableAutoMerge = enableAutoMergeWith;
123
124
  // `gatesForFailedPhase` now lives beside the envelope it feeds
124
125
  // (`single-story-close/failed-terminal.js`); it is re-exported here so the
125
126
  // CLI's public surface is unchanged by that move.
127
+ // `resolveRunScopedConfig` (Story #4891) is the run-scoped config pin the
128
+ // pipeline reads before its first phase; it is part of this CLI's surface for
129
+ // the same reason the sync helpers are — the runner reaches it only through a
130
+ // dynamic import, so this file is where it is statically visible.
126
131
  export {
127
132
  buildStoryReviewCrossRefBody,
128
133
  buildSyncFailureCommentBody,
129
134
  gatesForFailedPhase,
130
135
  handleSyncFailure,
131
136
  parsePrNumber,
137
+ resolveRunScopedConfig,
132
138
  runStoryScopeReview,
133
139
  };
134
140
 
@@ -61,6 +61,7 @@ import {
61
61
  planFastForward,
62
62
  } from './lib/orchestration/git-cleanup/phases/fast-forward.js';
63
63
  import { verifyRemote } from './lib/orchestration/remote-verifier.js';
64
+ import { pinRunScopedConfig } from './lib/orchestration/run-scoped-config.js';
64
65
  import {
65
66
  acquireStoryLease,
66
67
  releaseStoryLease,
@@ -735,6 +736,11 @@ export async function runSingleStoryInit({
735
736
  standalone: true,
736
737
  storyBranch,
737
738
  baseBranch,
739
+ // The write half of the run-scoped config pin. `baseBranch` above is the
740
+ // legacy field close still reads as a fallback; this block is the
741
+ // registry-driven form a second run-scoped key joins without a second
742
+ // mechanism (`lib/orchestration/run-scoped-config.js`).
743
+ runScopedConfig: pinRunScopedConfig(config),
738
744
  storyTitle: story.title,
739
745
  worktreeEnabled: runtime.worktreeEnabled,
740
746
  workCwd,
@@ -805,6 +811,7 @@ export function renderSingleStoryInitComment(result) {
805
811
  standalone: true,
806
812
  storyBranch: result.storyBranch,
807
813
  baseBranch: result.baseBranch,
814
+ runScopedConfig: result.runScopedConfig,
808
815
  worktreeEnabled: result.worktreeEnabled,
809
816
  workCwd: result.workCwd,
810
817
  worktreeCreated: result.worktreeCreated,
@@ -75,7 +75,9 @@
75
75
  * `/deliver` uses — `resolveConfig` + `getRunners` reading
76
76
  * `delivery.deliverRunner.concurrencyCap` (default 3) — so a
77
77
  * `.agentrc.local.json` override is honored. A `--concurrency <n>` CLI flag
78
- * overrides the config-resolved value for that run only. This shares one
78
+ * overrides the config-resolved value for that run only, and the envelope's
79
+ * `capPrecedence` names which source won so the override is never silent
80
+ * (Story #4875). This shares one
79
81
  * deterministic config source (`delivery.deliverRunner.concurrencyCap`) and
80
82
  * one scheduling kernel with every `/deliver` multi-Story invocation.
81
83
  *
@@ -167,7 +169,10 @@ Options:
167
169
  --concurrency <n> Override the per-beat concurrency cap for this run only.
168
170
  Must be a positive integer. When omitted, the cap is
169
171
  resolved from delivery.deliverRunner.concurrencyCap in
170
- .agentrc.json / .agentrc.local.json (default 3).
172
+ .agentrc.json / .agentrc.local.json (default 3). The flag
173
+ WINS over the configured value, and the envelope's
174
+ capPrecedence records that it did — including when the
175
+ request exceeds the configured cap.
171
176
  --done <csv> Comma-separated Story IDs already completed this run.
172
177
  Their dependents become eligible; they are never
173
178
  re-dispatched. Defaults to empty.
@@ -181,6 +186,14 @@ Output envelope:
181
186
  "ready": [101],
182
187
  "totalStories": 2,
183
188
  "concurrencyCap": 3,
189
+ "capPrecedence": {
190
+ "cap": 3,
191
+ "source": "config",
192
+ "configuredCap": 3,
193
+ "requestedCap": null,
194
+ "exceedsConfigured": false,
195
+ "note": "..."
196
+ },
184
197
  "inFlight": 0,
185
198
  "cycleError": null,
186
199
  "wedged": null
@@ -214,6 +227,7 @@ function inputErrorResult(message, concurrencyCap = null, inFlightValue = 0) {
214
227
  ready: [],
215
228
  totalStories: 0,
216
229
  concurrencyCap,
230
+ capPrecedence: null,
217
231
  inFlight: inFlightValue,
218
232
  cycleError: null,
219
233
  wedged: null,
@@ -394,13 +408,69 @@ export function parseConcurrencyOverride(raw) {
394
408
  * `--concurrency`; wins over config.
395
409
  * @returns {number} The resolved positive-integer concurrency cap.
396
410
  */
397
- export function resolveConcurrencyCap({ cwd, config, override } = {}) {
398
- if (override != null) {
399
- return override;
400
- }
411
+ export function resolveConcurrencyCap(opts = {}) {
412
+ return resolveCapPrecedence(opts).cap;
413
+ }
414
+
415
+ /**
416
+ * Resolve the per-beat cap **and the precedence that produced it** (Story
417
+ * #4875).
418
+ *
419
+ * `--concurrency` wins over `delivery.deliverRunner.concurrencyCap`, and that
420
+ * is the intended contract — a flag an operator typed for one run should not be
421
+ * outranked by a checked-in default. What was wrong is that it won *silently*:
422
+ * the envelope reported a single `concurrencyCap` number with no record of
423
+ * which source set it, so a run at 8 when the project configured 3 was
424
+ * indistinguishable from a project configured at 8. A reader could not tell an
425
+ * override from a default, and an override that ran the repo above its own
426
+ * configured ceiling left no trace at all.
427
+ *
428
+ * So the flag still wins, but never quietly: the source is named, the
429
+ * configured value is carried alongside the requested one, and a request that
430
+ * exceeds the configured cap is called out as such. Reporting rather than
431
+ * refusing is deliberate — the configured cap is a project default, not a
432
+ * safety limit, and refusing a deliberate operator escalation would trade a
433
+ * silent override for a silent stall.
434
+ *
435
+ * @param {object} [opts]
436
+ * @param {string} [opts.cwd] Repo root for config resolution.
437
+ * @param {object} [opts.config] Pre-resolved config (test injection).
438
+ * @param {number} [opts.override] Validated positive integer from
439
+ * `--concurrency`.
440
+ * @returns {{
441
+ * cap: number,
442
+ * source: 'flag'|'config',
443
+ * configuredCap: number,
444
+ * requestedCap: number|null,
445
+ * exceedsConfigured: boolean,
446
+ * note: string,
447
+ * }}
448
+ */
449
+ export function resolveCapPrecedence({ cwd, config, override } = {}) {
401
450
  const resolved = config ?? resolveConfig({ cwd });
402
451
  const { deliverRunner } = getRunners(resolved);
403
- return deliverRunner.concurrencyCap;
452
+ const configuredCap = deliverRunner.concurrencyCap;
453
+ if (override == null) {
454
+ return {
455
+ cap: configuredCap,
456
+ source: 'config',
457
+ configuredCap,
458
+ requestedCap: null,
459
+ exceedsConfigured: false,
460
+ note: `cap ${configuredCap} from delivery.deliverRunner.concurrencyCap (no --concurrency given)`,
461
+ };
462
+ }
463
+ const exceedsConfigured = override > configuredCap;
464
+ return {
465
+ cap: override,
466
+ source: 'flag',
467
+ configuredCap,
468
+ requestedCap: override,
469
+ exceedsConfigured,
470
+ note: exceedsConfigured
471
+ ? `cap ${override} from --concurrency, which OVERRIDES and EXCEEDS the configured delivery.deliverRunner.concurrencyCap ${configuredCap} — this run is deliberately above the project default`
472
+ : `cap ${override} from --concurrency, which overrides the configured delivery.deliverRunner.concurrencyCap ${configuredCap}`,
473
+ };
404
474
  }
405
475
 
406
476
  /**
@@ -418,6 +488,8 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
418
488
  * @param {Array<{id: number, dependsOn: number[]}>} nodes
419
489
  * @param {object} args
420
490
  * @param {number} args.concurrencyCap Resolved per-beat concurrency cap.
491
+ * @param {object|null} [args.capPrecedence] The {@link resolveCapPrecedence}
492
+ * record explaining which source set the cap.
421
493
  * @param {Set<number>} [args.doneIds] Story IDs already completed this run.
422
494
  * @param {number} [args.inFlight] Stories already occupying a slot.
423
495
  * @returns {{
@@ -434,7 +506,7 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
434
506
  */
435
507
  export function buildReadySetEnvelope(
436
508
  nodes,
437
- { concurrencyCap, doneIds = new Set(), inFlight = 0 },
509
+ { concurrencyCap, capPrecedence = null, doneIds = new Set(), inFlight = 0 },
438
510
  ) {
439
511
  const totalStories = nodes.length;
440
512
 
@@ -443,6 +515,10 @@ export function buildReadySetEnvelope(
443
515
  ready: [],
444
516
  totalStories,
445
517
  concurrencyCap,
518
+ // Which source set `concurrencyCap`, and whether it outranks the project's
519
+ // configured value (Story #4875). Never omitted on a resolved beat: a
520
+ // missing precedence record is what made a silent override possible.
521
+ capPrecedence,
446
522
  inFlight,
447
523
  cycleError: null,
448
524
  wedged: null,
@@ -484,6 +560,11 @@ export function buildReadySetEnvelope(
484
560
  labels: doneIds.has(node.id) ? [AGENT_LABELS.DONE] : (node.labels ?? []),
485
561
  };
486
562
  if (node.files !== undefined) rec.files = node.files;
563
+ // Probe-mode nodes carry the Story body so the overlap guard can widen a
564
+ // declared footprint from the paths the Story's own text names (Story
565
+ // #4875). Flag-mode nodes carry none — `parseDag` accepts no body — so
566
+ // this is inert there and the legacy contract is unchanged.
567
+ if (typeof node.body === 'string') rec.body = node.body;
487
568
  return rec;
488
569
  });
489
570
 
@@ -600,7 +681,8 @@ export function runStoriesWaveTick({
600
681
  return inputErrorResult(doneError, null, inFlightValue);
601
682
  }
602
683
 
603
- const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
684
+ const capPrecedence = resolveCapPrecedence({ cwd, config, override });
685
+ const concurrencyCap = capPrecedence.cap;
604
686
 
605
687
  let rawJson;
606
688
 
@@ -642,6 +724,7 @@ export function runStoriesWaveTick({
642
724
 
643
725
  return buildReadySetEnvelope(nodes, {
644
726
  concurrencyCap,
727
+ capPrecedence,
645
728
  doneIds,
646
729
  inFlight: inFlightValue,
647
730
  });
@@ -705,7 +788,8 @@ export async function runProbedStoriesWaveTick({
705
788
  return inputErrorResult(dispatchedError);
706
789
  }
707
790
 
708
- const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
791
+ const capPrecedence = resolveCapPrecedence({ cwd, config, override });
792
+ const concurrencyCap = capPrecedence.cap;
709
793
 
710
794
  let probed;
711
795
  try {
@@ -738,6 +822,7 @@ export async function runProbedStoriesWaveTick({
738
822
  } = probed;
739
823
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
740
824
  concurrencyCap,
825
+ capPrecedence,
741
826
  doneIds,
742
827
  inFlight,
743
828
  });
@@ -32,6 +32,7 @@ import {
32
32
  rankDuplicateCandidates,
33
33
  readTechStackSummary,
34
34
  shouldRefine,
35
+ synthesizeContractSections,
35
36
  validateStoryBody,
36
37
  } from './lib/story-plan.js';
37
38
 
@@ -40,7 +41,8 @@ Usage:
40
41
  story-plan.js --emit-context (--seed "<seed>" | --seed-file <file>) \\
41
42
  [--refine | --no-refine] [--pretty]
42
43
 
43
- story-plan.js --body <file> [--dry-run]
44
+ story-plan.js --body <file> [--acceptance <file>] [--verify <file>] \\
45
+ [--dry-run]
44
46
 
45
47
  story-plan.js --help
46
48
 
@@ -48,9 +50,18 @@ Modes:
48
50
  --emit-context Build the host-LLM authoring envelope and print it as
49
51
  JSON on stdout. Use this first; the host LLM authors a
50
52
  draft body using the envelope and the body template.
51
- --body <file> Persist a pre-authored body. Validates shape (required
52
- sections, no Epic: ref, AC checklist non-empty) and
53
- calls \`gh issue create\` with type::story.
53
+ --body <file> Persist a pre-authored body. Validates shape (## Goal
54
+ and ## Changes present, no Epic: ref, an acceptance +
55
+ verify contract) and calls \`gh issue create\` with
56
+ type::story.
57
+ --acceptance <file>
58
+ JSON string[] of the Story's top-level acceptance
59
+ criteria. Authored once here — NOT mirrored into the
60
+ body by hand; the \`## Acceptance\` section is
61
+ synthesized from it, matching the story-author prompt.
62
+ --verify <file> JSON string[] of the Story's top-level verify entries,
63
+ each ending in a (unit|contract|e2e|validate) tier.
64
+ Synthesized into \`## Verify\` the same way.
54
65
  --dry-run With --body: print the body and the gh argv that would
55
66
  be invoked, then exit 0. No GitHub mutations.
56
67
 
@@ -101,8 +112,13 @@ async function fetchOpenStories(provider) {
101
112
  * array; the caller decides whether to execute it (persist) or print
102
113
  * it (--dry-run).
103
114
  */
104
- function renderGhArgv({ title, bodyPath, labels }) {
105
- const argv = ['issue', 'create', '--title', title, '--body-file', bodyPath];
115
+ function renderGhArgv({ title, bodyPath, labels, body = null }) {
116
+ // `--body-file` streams the authored file untouched; an inline `--body` is
117
+ // used only when persist synthesized sections into it (Story #4874), so the
118
+ // file on disk is no longer what gets created.
119
+ const source =
120
+ typeof body === 'string' ? ['--body', body] : ['--body-file', bodyPath];
121
+ const argv = ['issue', 'create', '--title', title, ...source];
106
122
  for (const label of labels) {
107
123
  argv.push('--label', label);
108
124
  }
@@ -180,6 +196,31 @@ async function runEmitContext({
180
196
  write(`${json}\n`);
181
197
  }
182
198
 
199
+ /**
200
+ * Read an optional top-level contract array (`--acceptance` / `--verify`)
201
+ * from a JSON file. Absent flag → an empty list, so the flags stay optional
202
+ * for a body that already carries its own sections.
203
+ *
204
+ * @param {string|undefined} filePath
205
+ * @param {'acceptance'|'verify'} label
206
+ * @returns {Promise<string[]>}
207
+ */
208
+ async function readContractList(filePath, label) {
209
+ if (!filePath) return [];
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(await readFile(filePath, 'utf8'));
213
+ } catch (err) {
214
+ throw new Error(
215
+ `Cannot read --${label} file "${filePath}": ${err.message}`,
216
+ );
217
+ }
218
+ if (!Array.isArray(parsed) || parsed.some((v) => typeof v !== 'string')) {
219
+ throw new Error(`--${label} file "${filePath}" must be a JSON string[].`);
220
+ }
221
+ return parsed;
222
+ }
223
+
183
224
  async function runPersist({
184
225
  values,
185
226
  provider,
@@ -193,17 +234,30 @@ async function runPersist({
193
234
  if (!bodyPath) {
194
235
  throw new Error('--body <file> is required in persist mode.');
195
236
  }
196
- const body = await readFile(bodyPath, 'utf8');
197
- const validation = validateStoryBody(body);
237
+ const authored = await readFile(bodyPath, 'utf8');
238
+ const contract = {
239
+ acceptance: await readContractList(values.acceptance, 'acceptance'),
240
+ verify: await readContractList(values.verify, 'verify'),
241
+ };
242
+ const validation = validateStoryBody(authored, contract);
198
243
  if (!validation.ok) {
199
244
  throw new Error(
200
245
  `Drafted body failed validation:\n - ${validation.errors.join('\n - ')}`,
201
246
  );
202
247
  }
203
248
 
249
+ // Story #4874: the author writes acceptance[] / verify[] once, at the
250
+ // ticket's top level, and persist renders their sections — never the
251
+ // author by hand.
252
+ const body = synthesizeContractSections(authored, contract);
204
253
  const title = extractTitle(body);
205
254
  const labels = [TYPE_LABELS.STORY];
206
- const argv = renderGhArgv({ title, bodyPath, labels });
255
+ const argv = renderGhArgv({
256
+ title,
257
+ bodyPath,
258
+ labels,
259
+ body: body === authored ? null : body,
260
+ });
207
261
 
208
262
  if (dryRun) {
209
263
  Logger.info('--- DRY RUN ---');
@@ -255,6 +309,8 @@ async function main() {
255
309
  seed: { type: 'string' },
256
310
  'seed-file': { type: 'string' },
257
311
  body: { type: 'string' },
312
+ acceptance: { type: 'string' },
313
+ verify: { type: 'string' },
258
314
  refine: { type: 'boolean', default: false },
259
315
  'no-refine': { type: 'boolean', default: false },
260
316
  pretty: { type: 'boolean', default: false },
@@ -5,6 +5,7 @@ import './lib/runtime-deps/ensure-installed.js';
5
5
  import path from 'node:path';
6
6
  import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
7
7
  import { refreshBaseline } from './lib/baselines/refresh-service.js';
8
+ import { runAsCli } from './lib/cli-utils.js';
8
9
  import { getBaselineEpsilon } from './lib/config/quality.js';
9
10
  import {
10
11
  getBaselines,
@@ -44,6 +45,40 @@ import { Logger } from './lib/Logger.js';
44
45
  * "no baseline yet".
45
46
  */
46
47
 
48
+ /**
49
+ * Usage block for `--help` (Story #4872). This CLI *writes* on invocation, so
50
+ * the help branch must short-circuit before `main` runs rather than inside it —
51
+ * `runAsCli` answers help first, which makes "a usage probe never mutates a
52
+ * baseline" structural instead of a check `main` has to remember.
53
+ */
54
+ const USAGE = {
55
+ invocation:
56
+ 'node .agents/scripts/update-crap-baseline.js [--baseline <path>] [--coverage <path>] [--full-scope | --diff-scope <ref>]',
57
+ summary:
58
+ 'Scan → score → write the CRAP baseline. With no scope flag the refresh is scoped to the files changed in `origin/main..HEAD`; out-of-scope rows are preserved verbatim.',
59
+ flags: [
60
+ [
61
+ '--baseline <path>',
62
+ 'Write to this path instead of `delivery.quality.baselines.crap.path`.',
63
+ ],
64
+ [
65
+ '--coverage <path>',
66
+ 'Read coverage from this path (default `coverage/coverage-final.json`).',
67
+ ],
68
+ [
69
+ '--full-scope',
70
+ 'Rescore every file in every target dir (no out-of-scope merge).',
71
+ ],
72
+ [
73
+ '--diff-scope <ref>',
74
+ 'Scope the refresh to files changed between <ref> and HEAD. Incompatible with --full-scope.',
75
+ ],
76
+ ],
77
+ notes: [
78
+ 'Run `npm run test:coverage` first — without a coverage artifact every file is skipped.',
79
+ ],
80
+ };
81
+
47
82
  function parseCliArgs(argv = process.argv.slice(2)) {
48
83
  const out = {
49
84
  baselinePath: undefined,
@@ -183,8 +218,11 @@ async function main() {
183
218
  );
184
219
  }
185
220
 
186
- // cli-opt-out: top-level main().catch predates runAsCli; never imported elsewhere so the auto-run risk is moot.
187
- main().catch((err) => {
188
- Logger.error(`[CRAP] ❌ Fatal error: ${err?.stack ?? err?.message ?? err}`);
189
- process.exit(1);
221
+ runAsCli(import.meta.url, main, {
222
+ source: 'crap-baseline',
223
+ usage: USAGE,
224
+ onError: (err) => {
225
+ Logger.error(`[CRAP] ❌ Fatal error: ${err?.stack ?? err?.message ?? err}`);
226
+ process.exitCode = 1;
227
+ },
190
228
  });
@@ -43,10 +43,34 @@ import './lib/runtime-deps/ensure-installed.js';
43
43
  import path from 'node:path';
44
44
  import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
45
45
  import { refreshBaseline } from './lib/baselines/refresh-service.js';
46
+ import { runAsCli } from './lib/cli-utils.js';
46
47
  import { getBaselineEpsilon } from './lib/config/quality.js';
47
48
  import { getBaselines, resolveConfig } from './lib/config-resolver.js';
48
49
  import { Logger } from './lib/Logger.js';
49
50
 
51
+ /**
52
+ * Usage block for `--help` (Story #4872). This CLI *writes* on invocation, so
53
+ * the help branch must short-circuit before `main` runs rather than inside it —
54
+ * `runAsCli` answers help first, which makes "a usage probe never mutates a
55
+ * baseline" structural instead of a check `main` has to remember.
56
+ */
57
+ const USAGE = {
58
+ invocation:
59
+ 'node .agents/scripts/update-maintainability-baseline.js [--full-scope | --diff-scope <ref>]',
60
+ summary:
61
+ 'Score → write the maintainability baseline. With no scope flag the refresh is scoped to the files changed in `origin/main..HEAD`; out-of-scope rows are preserved verbatim.',
62
+ flags: [
63
+ [
64
+ '--full-scope',
65
+ 'Rescore every file in every target dir (no out-of-scope merge).',
66
+ ],
67
+ [
68
+ '--diff-scope <ref>',
69
+ 'Scope the refresh to files changed between <ref> and HEAD. Incompatible with --full-scope.',
70
+ ],
71
+ ],
72
+ };
73
+
50
74
  /**
51
75
  * Parse `--full-scope` (boolean opt-out flag).
52
76
  *
@@ -119,8 +143,11 @@ async function main() {
119
143
  );
120
144
  }
121
145
 
122
- // cli-opt-out: top-level main().catch predates runAsCli; never imported elsewhere so the auto-run risk is moot.
123
- main().catch((err) => {
124
- Logger.error(`[Maintainability] ❌ Fatal error: ${err.message}`);
125
- process.exit(1);
146
+ runAsCli(import.meta.url, main, {
147
+ source: 'maintainability-baseline',
148
+ usage: USAGE,
149
+ onError: (err) => {
150
+ Logger.error(`[Maintainability] ❌ Fatal error: ${err.message}`);
151
+ process.exitCode = 1;
152
+ },
126
153
  });
@@ -1,17 +1,25 @@
1
1
  # {{title}}
2
2
 
3
- ## Context
3
+ ## Goal
4
4
 
5
- {{context}}
5
+ {{goal}}
6
6
 
7
- ## Acceptance Criteria
7
+ ## Spec
8
8
 
9
- {{acceptanceCriteria}}
9
+ {{spec}}
10
10
 
11
- ## Out of Scope
11
+ ## Changes
12
12
 
13
- {{outOfScope}}
13
+ {{changes}}
14
14
 
15
- ## Notes
15
+ ## Non-Goals
16
16
 
17
- {{notes}}
17
+ {{nonGoals}}
18
+
19
+ <!--
20
+ `acceptance[]` and `verify[]` are the ticket's top-level machine contract.
21
+ Author each list ONCE, at the top level (`--acceptance` / `--verify`); do
22
+ NOT mirror them into this body — persist synthesizes the `## Acceptance`
23
+ and `## Verify` sections from those arrays so the issue still reads as a
24
+ complete executable document.
25
+ -->