mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -9,7 +9,10 @@
9
9
  * @module lib/orchestration/story-follow-ups
10
10
  */
11
11
 
12
- import { signalsFile } from '../config/temp-paths.js';
12
+ import { mkdir, writeFile } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { orchestrationLogDir, signalsFile } from '../config/temp-paths.js';
13
16
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
14
17
  import {
15
18
  DEFAULT_FRAMEWORK_REPO,
@@ -30,6 +33,76 @@ import { upsertStructuredComment } from './ticketing.js';
30
33
 
31
34
  const FOLLOW_UPS_COMMENT_TYPE = 'follow-ups';
32
35
 
36
+ /**
37
+ * Publish a rendered roll-up — as a `follow-ups` comment when the run actually
38
+ * filed something, and as a run artifact under the temp root when it did not.
39
+ *
40
+ * Story #5341 made the filing the condition. The comment had been
41
+ * unconditional, and the corpus it rendered is dominated by noise: #5324's
42
+ * roll-up carried 116 signals and filed nothing, and #4653 / #4833 / #4834 /
43
+ * #4836 record filings that were false or leaked from fixtures. A comment that
44
+ * says "nothing to do" on every Story trains its readers to skip the one that
45
+ * does not — while the roll-up itself is still written, so nothing is lost,
46
+ * only moved off the ticket.
47
+ *
48
+ * @param {object} args
49
+ * @param {number} args.anchorId — the ticket the roll-up belongs to.
50
+ * @param {string} args.body — the rendered roll-up.
51
+ * @param {number} args.filedCount — issues this roll-up actually filed.
52
+ * @param {object} args.provider
53
+ * @param {object} [args.config]
54
+ * @returns {Promise<{ posted: boolean, artifactPath: string|null, summary: string }>}
55
+ */
56
+ export async function publishFollowUpsRollup({
57
+ anchorId,
58
+ body,
59
+ filedCount,
60
+ provider,
61
+ config,
62
+ }) {
63
+ return filedCount > 0
64
+ ? postFollowUpsComment({ anchorId, body, filedCount, provider })
65
+ : parkFollowUpsRollup({ anchorId, body, config });
66
+ }
67
+
68
+ /**
69
+ * Post the roll-up as the `follow-ups` structured comment.
70
+ *
71
+ * @param {object} args
72
+ * @returns {Promise<{ posted: true, artifactPath: null, summary: string }>}
73
+ */
74
+ async function postFollowUpsComment({ anchorId, body, filedCount, provider }) {
75
+ await upsertStructuredComment(
76
+ provider,
77
+ anchorId,
78
+ FOLLOW_UPS_COMMENT_TYPE,
79
+ body,
80
+ );
81
+ return {
82
+ posted: true,
83
+ artifactPath: null,
84
+ summary: `Captured follow-ups for #${anchorId} (filed=${filedCount}).`,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Write the roll-up to the run artifacts under the temp root.
90
+ *
91
+ * @param {object} args
92
+ * @returns {Promise<{ posted: false, artifactPath: string, summary: string }>}
93
+ */
94
+ async function parkFollowUpsRollup({ anchorId, body, config }) {
95
+ const dir = orchestrationLogDir(config);
96
+ const artifactPath = path.join(dir, `follow-ups-rollup-${anchorId}.md`);
97
+ await mkdir(dir, { recursive: true });
98
+ await writeFile(artifactPath, `${body}\n`, 'utf8');
99
+ return {
100
+ posted: false,
101
+ artifactPath,
102
+ summary: `No follow-ups filed for #${anchorId} — roll-up kept at ${artifactPath}.`,
103
+ };
104
+ }
105
+
33
106
  /** Milliseconds in one day — the unit `frictionWindowDays` is expressed in. */
34
107
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
35
108
 
@@ -708,6 +781,52 @@ export function buildFollowUpsCommentBody({
708
781
  return lines.join('\n');
709
782
  }
710
783
 
784
+ /**
785
+ * Compose the routed proposals for one Story's friction corpus.
786
+ *
787
+ * @param {number} sid
788
+ * @param {Array<object>} signals
789
+ * @param {object} [config]
790
+ * @returns {object}
791
+ */
792
+ function composeStoryProposals(sid, signals, config) {
793
+ const repos = resolveFollowUpRepos(config);
794
+ return composeRoutedProposals({
795
+ anchorId: sid,
796
+ anchorKind: 'story',
797
+ frameworkRepo: repos.frameworkRepo,
798
+ consumerRepo: repos.consumerRepo,
799
+ signals,
800
+ // Derived, not hardcoded `[]` (Story #4649). This is the escape hatch
801
+ // the retired story-scope threshold carve-out was standing in for: a
802
+ // Story still parked at `agent::blocked` files at a single occurrence,
803
+ // while one that blocked and self-resolved nets out entirely.
804
+ unresolvedBlockedEvents: deriveUnresolvedBlockedEvents(signals),
805
+ });
806
+ }
807
+
808
+ /**
809
+ * Hand one Story's routed proposals to the graduator.
810
+ *
811
+ * @param {object} args
812
+ * @returns {Promise<object>}
813
+ */
814
+ function fileStoryProposals({ sid, proposals, provider, config, cwd }) {
815
+ const repos = resolveFollowUpRepos(config);
816
+ return graduateRetroProposals({
817
+ epicId: sid,
818
+ provider,
819
+ config,
820
+ currentRepo: repos.currentRepo,
821
+ // The resolved bucket object, not a re-split of the slug: routing is
822
+ // decided once in `github/framework-repo.js`.
823
+ frameworkRepo: repos.repos.framework,
824
+ platformRepo: repos.repos.platform,
825
+ routedProposals: proposals,
826
+ cwd,
827
+ });
828
+ }
829
+
711
830
  /**
712
831
  * Capture and persist Story follow-ups. Never throws — the land must not
713
832
  * fail because follow-up filing flaked.
@@ -743,29 +862,12 @@ export async function captureStoryFollowUps({
743
862
  }
744
863
  try {
745
864
  const signals = await gatherStoryFrictionSignals(sid, config);
746
- const repos = resolveFollowUpRepos(config);
747
- const proposals = composeRoutedProposals({
748
- anchorId: sid,
749
- anchorKind: 'story',
750
- frameworkRepo: repos.frameworkRepo,
751
- consumerRepo: repos.consumerRepo,
752
- signals,
753
- // Derived, not hardcoded `[]` (Story #4649). This is the escape hatch
754
- // the retired story-scope threshold carve-out was standing in for: a
755
- // Story still parked at `agent::blocked` files at a single occurrence,
756
- // while one that blocked and self-resolved nets out entirely.
757
- unresolvedBlockedEvents: deriveUnresolvedBlockedEvents(signals),
758
- });
759
- const graduated = await graduateRetroProposals({
760
- epicId: sid,
865
+ const proposals = composeStoryProposals(sid, signals, config);
866
+ const graduated = await fileStoryProposals({
867
+ sid,
868
+ proposals,
761
869
  provider,
762
870
  config,
763
- currentRepo: repos.currentRepo,
764
- // The resolved bucket object, not a re-split of the slug: routing is
765
- // decided once in `github/framework-repo.js`.
766
- frameworkRepo: repos.repos.framework,
767
- platformRepo: repos.repos.platform,
768
- routedProposals: proposals,
769
871
  cwd,
770
872
  });
771
873
  const body = buildFollowUpsCommentBody({
@@ -775,30 +877,45 @@ export async function captureStoryFollowUps({
775
877
  signalCount: signals.length,
776
878
  categories: summarizeSignalCategories(signals),
777
879
  });
778
- await upsertStructuredComment(provider, sid, FOLLOW_UPS_COMMENT_TYPE, body);
779
- progress?.(
780
- 'FOLLOW-UPS',
781
- `Captured follow-ups for Story #${sid} (filed=${graduated.filed?.length ?? 0}).`,
782
- );
880
+ const filedCount = graduated.filed?.length ?? 0;
881
+ const published = await publishFollowUpsRollup({
882
+ anchorId: sid,
883
+ body,
884
+ filedCount,
885
+ provider,
886
+ config,
887
+ });
888
+ progress?.('FOLLOW-UPS', published.summary);
783
889
  return {
784
890
  ok: true,
785
891
  storyId: sid,
786
892
  proposals,
787
893
  graduated,
788
894
  signalCount: signals.length,
895
+ commentPosted: published.posted,
896
+ artifactPath: published.artifactPath,
789
897
  };
790
898
  } catch (err) {
791
- Logger.warn(
792
- `[story-follow-ups] capture failed for #${sid}: ${err?.message ?? err}`,
793
- );
794
- progress?.(
795
- 'FOLLOW-UPS',
796
- `⚠️ Follow-up capture failed (close continues): ${err?.message ?? err}`,
797
- );
798
- return {
799
- ok: false,
800
- reason: 'capture-failed',
801
- error: String(err?.message ?? err),
802
- };
899
+ return reportCaptureFailure(sid, err, progress);
803
900
  }
804
901
  }
902
+
903
+ /**
904
+ * Report a failed capture without failing the close around it — follow-up
905
+ * capture is a reporting step, so a provider or filesystem fault degrades the
906
+ * report and never the land.
907
+ *
908
+ * @param {number} sid
909
+ * @param {unknown} err
910
+ * @param {((tag: string, msg: string) => void)} [progress]
911
+ * @returns {{ ok: false, reason: string, error: string }}
912
+ */
913
+ function reportCaptureFailure(sid, err, progress) {
914
+ const detail = String(err?.message ?? err);
915
+ Logger.warn(`[story-follow-ups] capture failed for #${sid}: ${detail}`);
916
+ progress?.(
917
+ 'FOLLOW-UPS',
918
+ `⚠️ Follow-up capture failed (close continues): ${detail}`,
919
+ );
920
+ return { ok: false, reason: 'capture-failed', error: detail };
921
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * story-init-envelope.js — read back the envelope `single-story-init.js`
3
+ * writes for one Story.
4
+ *
5
+ * Init routes its full result object to
6
+ * `<tempRoot>/orchestration/story-init-result-<id>.log` through
7
+ * `emitTerseResult`, wrapped in the same `--- STORY INIT RESULT ---` markers
8
+ * the legacy inline dump used. Story #5343 retired the `story-init` ticket
9
+ * comment that duplicated it, which makes this file the durable record of
10
+ * what a run was seeded with — most load-bearingly the `runScopedConfig` pin
11
+ * `run-scoped-config.js` compares against at close.
12
+ *
13
+ * Total and non-throwing: a missing, unreadable or unparseable envelope
14
+ * resolves to `null`, and the caller reports the degrade. Reading it is
15
+ * always best-effort — the temp tree is reapable by design.
16
+ *
17
+ * @module lib/orchestration/story-init-envelope
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ import { orchestrationLogDir } from '../config/temp-paths.js';
24
+
25
+ /**
26
+ * Read one Story's init envelope, or `null` when there is none to read.
27
+ *
28
+ * @param {{
29
+ * storyId: number|string,
30
+ * config?: object,
31
+ * readFileFn?: typeof readFileSync,
32
+ * }} args
33
+ * @returns {object|null} the parsed init result, or `null`.
34
+ */
35
+ function readStoryInitEnvelope({ storyId, config, readFileFn = readFileSync }) {
36
+ const file = path.join(
37
+ orchestrationLogDir(config),
38
+ `story-init-result-${storyId}.log`,
39
+ );
40
+ try {
41
+ // The log brackets the pretty JSON with human markers, so take the
42
+ // outermost brace span rather than parsing the whole file.
43
+ const fence = /\{[\s\S]*\}/.exec(readFileFn(file, 'utf8'));
44
+ const payload = fence ? JSON.parse(fence[0]) : null;
45
+ return payload && typeof payload === 'object' ? payload : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The `runScopedConfig` pin off one Story's init envelope, or `null` when the
53
+ * envelope is absent or carries none. The write half is
54
+ * `run-scoped-config.js#pinRunScopedConfig`, which `single-story-init.js`
55
+ * records on the envelope this reads back.
56
+ *
57
+ * @param {{
58
+ * storyId: number|string,
59
+ * config?: object,
60
+ * readEnvelopeFn?: typeof readStoryInitEnvelope,
61
+ * }} args
62
+ * @returns {Record<string, unknown>|null}
63
+ */
64
+ export function readRunScopedPin({
65
+ storyId,
66
+ config,
67
+ readEnvelopeFn = readStoryInitEnvelope,
68
+ }) {
69
+ const pin = readEnvelopeFn({ storyId, config })?.runScopedConfig;
70
+ return pin && typeof pin === 'object' ? pin : null;
71
+ }
@@ -19,9 +19,11 @@
19
19
  * the 2-tier hierarchy (Epic → Story).
20
20
  *
21
21
  * Required after parse/normalize: a non-empty `goal`, and non-empty
22
- * `changes`, `acceptance`, and `verify` arrays — and `changes` items must
23
- * name at least one path-shaped token so vague verbs ("clean up",
24
- * "refactor") can't slip through.
22
+ * `changes` and `acceptance` arrays — and `changes` items must name at least
23
+ * one path-shaped token so vague verbs ("clean up", "refactor") can't slip
24
+ * through. Story #5342 dropped the non-empty `verify` requirement: an empty
25
+ * `verify[]` is a dry-run warning the ticket validator raises, not a
26
+ * refusal.
25
27
  *
26
28
  * `acceptance` / `verify` are the **top-level machine contract** (Story
27
29
  * #4541). The decomposer prompt tells authors to write those lists once at
@@ -43,8 +45,8 @@
43
45
  *
44
46
  * `body.verify` entries are commands, nothing more: Story #5312 deleted the
45
47
  * `(<tier>)` suffix, the `manual:<reason>` escape and the repair pass that
46
- * appended the suffix for the author. The only verify rule left is that the
47
- * list is non-empty.
48
+ * appended the suffix for the author, and Story #5342 deleted the last rule
49
+ * — the non-empty check. This validator no longer scores `verify` at all.
48
50
  *
49
51
  * The errors are batched and surfaced as a single thrown Error so the
50
52
  * planner can see every offending slug in one pass instead of fixing one
@@ -69,7 +71,7 @@ import { FILE_ASSUMPTION_VALUES } from './file-assumption-enum.js';
69
71
  * Story body to a markdown string, so a *string* body is NOT skipped here
70
72
  * (Story #3906) — `validateTaskBodyShape` parses it back into structured
71
73
  * form via `parseStoryBody` before applying the section rules. This is what
72
- * makes the non-empty-verify / vague-verb / non-empty-goal checks actually fire
74
+ * makes the vague-verb / non-empty-goal checks actually fire
73
75
  * on real plans. Features (and everything else) use narrative string bodies
74
76
  * and are skipped by the `type !== 'story'` guard.
75
77
  *
@@ -185,7 +187,6 @@ export function validateTaskBodyShape(ticket) {
185
187
  }
186
188
  errors.push(...collectChangesErrors(prefix, body.changes));
187
189
  errors.push(...collectAcceptanceErrors(prefix, body.acceptance));
188
- errors.push(...collectVerifyErrors(prefix, body.verify));
189
190
  errors.push(...collectReferencesErrors(prefix, body.references));
190
191
  return errors;
191
192
  }
@@ -306,16 +307,6 @@ function collectAcceptanceErrors(prefix, rawAcceptance) {
306
307
  * @param {unknown} rawVerify
307
308
  * @returns {string[]}
308
309
  */
309
- function collectVerifyErrors(prefix, rawVerify) {
310
- const verify = Array.isArray(rawVerify) ? rawVerify : [];
311
- if (verify.length === 0) {
312
- return [
313
- `${prefix}: verify must list at least one entry — author it at the ticket's top level (preferred) or in the body's ## Verify section.`,
314
- ];
315
- }
316
- return [];
317
- }
318
-
319
310
  /**
320
311
  * Validate every 2-tier Story in `tickets` whose `body` is a structured
321
312
  * object. Returns an array of error strings (one per offending slug); empty
@@ -3,7 +3,6 @@ import { normalizeOwnedProvenance } from '../findings/provenance-field.js';
3
3
  import { detectCycle } from '../Graph.js';
4
4
  import { gitSpawn } from '../git-utils.js';
5
5
 
6
- import { Logger } from '../Logger.js';
7
6
  import { validateStoryFileAssumptions } from './file-assumptions.js';
8
7
  import { isExternalDependencyRef } from './plan-persist/external-deps.js';
9
8
  import {
@@ -41,38 +40,6 @@ function collectPathsFromText(text, paths) {
41
40
  }
42
41
  }
43
42
 
44
- /**
45
- * Resolve every acceptance line a Story declares, across both authoring
46
- * shapes (Story #4541).
47
- *
48
- * The canonical shape is a **serialized string body** with the criteria at
49
- * the ticket's **top level** — the machine contract persist syncs into the
50
- * body. `validateAcceptanceSubjectPrefix` used to read `body.acceptance` on
51
- * an object body only, so on every real plan it scanned nothing and the gate
52
- * silently passed. Union both sources (deduplicated) so the gate fires on
53
- * whichever surface the author used.
54
- *
55
- * @param {object} story
56
- * @returns {string[]}
57
- */
58
- function resolveAcceptanceLines(story) {
59
- const lines = new Set();
60
- if (Array.isArray(story?.acceptance)) {
61
- for (const item of story.acceptance) lines.add(String(item ?? ''));
62
- }
63
- const body = story?.body;
64
- let bodyAcceptance = null;
65
- if (typeof body === 'string' && body.trim().length > 0) {
66
- bodyAcceptance = parseStoryBodyOrThrow(story).acceptance;
67
- } else if (body !== null && typeof body === 'object') {
68
- bodyAcceptance = body.acceptance;
69
- }
70
- if (Array.isArray(bodyAcceptance)) {
71
- for (const item of bodyAcceptance) lines.add(String(item ?? ''));
72
- }
73
- return [...lines];
74
- }
75
-
76
43
  function collectTaskPathReferences(task) {
77
44
  const paths = new Set();
78
45
  const body = task.body;
@@ -283,117 +250,6 @@ export function validateAcFreshness({
283
250
  return misses.map((m) => renderMissLine(m, baseBranchRef));
284
251
  }
285
252
 
286
- /**
287
- * Allowed leading Conventional-Commits types. Mirrors the `changelog-sections`
288
- * keys in `release-please-config.json` and the `type-enum` list in
289
- * `commitlint.config.js`. When a planner LLM prescribes a commit subject in a
290
- * Task acceptance item via the "Commit subject begins with '<prefix>:'" form,
291
- * the captured prefix must reduce to one of these types (optionally followed
292
- * by a `(scope)` qualifier) — anything else fails commitlint locally and
293
- * release-please's changelog parser on `main`, so the decompose is rejected
294
- * before the Story branch is ever cut.
295
- *
296
- * Epic #2501 introduced this guard after the legacy `baseline-refresh`
297
- * leading-token prescription created a wave of commit-msg hook failures
298
- * across story-deliver sub-agents. See
299
- * `.agents/skills/core/gates-and-baselines/SKILL.md` for the canonical refresh
300
- * shape (Conventional-Commits subject + `baseline-refresh: true` body
301
- * trailer).
302
- */
303
- const ALLOWED_COMMIT_TYPES = new Set([
304
- 'feat',
305
- 'fix',
306
- 'chore',
307
- 'refactor',
308
- 'perf',
309
- 'docs',
310
- 'style',
311
- 'test',
312
- 'build',
313
- 'ci',
314
- 'revert',
315
- ]);
316
-
317
- /**
318
- * Regex matching the canonical "Commit subject begins with '<prefix>:'"
319
- * prescription shape the planner emits in `body.acceptance[]` entries.
320
- * The leading quote is captured loosely (single, double, or backtick) so the
321
- * three quoting styles the decomposer LLM has historically emitted all
322
- * match. The captured group is the prefix token *without* the trailing
323
- * colon — callers normalize by stripping an optional `(scope)` qualifier
324
- * before comparing against the allowed-types set.
325
- */
326
- const SUBJECT_PREFIX_RE = /Commit subject begins with ['"`]([^'"`]+):['"`]/g;
327
-
328
- /**
329
- * Scan every Story's `body.acceptance[]` for "Commit subject begins with
330
- * '<prefix>:'" prescriptions and reject the decompose when any captured
331
- * prefix is not a valid Conventional-Commits type.
332
- *
333
- * A captured prefix of the form `chore(baselines)` is accepted — the
334
- * leading `chore` is in the allowed-types set, and the `(scope)` qualifier
335
- * is the standard Conventional-Commits scope shape. A captured prefix of
336
- * the form `baseline-refresh` is rejected because no Conventional-Commits
337
- * type starts with that token.
338
- *
339
- * Only acceptance criteria are scanned; `body.goal` / `body.verify` /
340
- * `body.changes` are not commit-subject prescriptions by convention and
341
- * scanning them would surface false positives from prose that happens to
342
- * quote a forbidden prefix while explaining why it's forbidden.
343
- *
344
- * Both authoring shapes are covered (Story #4541): the canonical top-level
345
- * `acceptance[]` on a serialized string body, and the pre-serialize
346
- * `body.acceptance[]` object shape. Scanning only the latter made the gate
347
- * inert on every real plan.
348
- *
349
- * @param {object} opts
350
- * @param {object[]} opts.tickets - Validated ticket hierarchy.
351
- * @throws {ValidationError} when one or more Story acceptance items
352
- * prescribe a forbidden subject prefix. The error carries
353
- * `code: 'forbidden-subject-prefix'` and a `violations[]` payload
354
- * listing each `{ slug, prefix, line }` so the decompose loop can
355
- * surface the exact offending text to the operator.
356
- */
357
- export function validateAcceptanceSubjectPrefix({ tickets }) {
358
- const violations = [];
359
- const stories = (tickets ?? []).filter((t) => t.type === 'story');
360
- for (const story of stories) {
361
- for (const line of resolveAcceptanceLines(story)) {
362
- // Reset the global regex between iterations.
363
- SUBJECT_PREFIX_RE.lastIndex = 0;
364
- let match = SUBJECT_PREFIX_RE.exec(line);
365
- while (match !== null) {
366
- const rawPrefix = match[1];
367
- // Strip an optional `(scope)` qualifier — `chore(baselines)` reduces
368
- // to `chore` for the allowed-types check.
369
- const type = rawPrefix.replace(/\(.*\)$/, '').trim();
370
- if (!ALLOWED_COMMIT_TYPES.has(type)) {
371
- violations.push({
372
- slug: story.slug ?? '<unknown>',
373
- prefix: rawPrefix,
374
- line,
375
- });
376
- }
377
- match = SUBJECT_PREFIX_RE.exec(line);
378
- }
379
- }
380
- }
381
- if (violations.length === 0) return;
382
- const allowed = [...ALLOWED_COMMIT_TYPES].join('|');
383
- const lines = violations
384
- .map(
385
- (v) =>
386
- ` - "${v.slug}" → forbidden subject prefix "${v.prefix}:" in acceptance item: ${v.line}`,
387
- )
388
- .join('\n');
389
- const err = new ValidationError(
390
- `Cross-Validation Failed: ${violations.length} Story acceptance item(s) prescribe a non-Conventional-Commits subject prefix:\n${lines}\n\nAllowed leading types: ${allowed}. Use a Conventional-Commits subject (e.g. "chore(baselines): refresh ...") and a body trailer (e.g. "baseline-refresh: true") for machine-readable markers. See Epic #2501.`,
391
- { violations },
392
- );
393
- err.code = 'forbidden-subject-prefix';
394
- throw err;
395
- }
396
-
397
253
  /**
398
254
  * Render one missing-path warning with a remediation hint pointing at the
399
255
  * Story's `changes[]`. For `tests/**` paths we suggest the explicit
@@ -482,44 +338,55 @@ function assertAllTicketsAreStories({ tickets, stories }) {
482
338
  }
483
339
 
484
340
  /**
485
- * Return true when a Story object carries inline acceptance + verify
486
- * arrays — the inline-contract shape (Epic #3078) where the Story is itself the
487
- * implementation unit and acceptance / verify live on the Story body
488
- * rather than in child Task tickets.
341
+ * Return true when a Story carries a non-empty top-level `acceptance[]` —
342
+ * the inline-contract shape (Epic #3078) where the Story is itself the
343
+ * implementation unit and its criteria live on the Story rather than in
344
+ * child Task tickets.
489
345
  *
490
- * Both arrays must be present, be actual arrays, and contain at least
491
- * one entry. Either alone is insufficient — a Story with only
492
- * `acceptance[]` (no `verify[]`) cannot be implemented without a
493
- * verification handle, and a Story with only `verify[]` (no
494
- * `acceptance[]`) carries no observable criterion. Requiring both is the
495
- * inline-contract invariant every Story must satisfy.
346
+ * Story #5342 narrowed the invariant to `acceptance[]` alone. A Story with
347
+ * no observable criterion is genuinely unimplementable and nothing
348
+ * downstream can recover it; an empty `verify[]` only means the deliverer
349
+ * picks the commands, which the close gate chain runs regardless — so that
350
+ * half is a warning ({@link collectMissingVerifyWarnings}), not a refusal.
496
351
  */
497
- function hasInlineAcceptanceAndVerify(story) {
352
+ function hasInlineAcceptance(story) {
498
353
  if (story === null || typeof story !== 'object') return false;
499
- const { acceptance, verify } = story;
500
- return (
501
- Array.isArray(acceptance) &&
502
- acceptance.length > 0 &&
503
- Array.isArray(verify) &&
504
- verify.length > 0
505
- );
354
+ const { acceptance } = story;
355
+ return Array.isArray(acceptance) && acceptance.length > 0;
506
356
  }
507
357
 
508
358
  function assertEveryStoryHasInlineContract({ stories }) {
509
- // Every Story is its own implementation
510
- // unit and MUST carry a non-empty inline contract — top-level
511
- // `acceptance[]` AND `verify[]`. A Story missing either is the legacy
512
- // 4-tier shape that expected child Tasks; there is no Task tier any
513
- // more, so such a Story is unimplementable and the decompose is
514
- // rejected outright.
515
- const missing = stories.filter((s) => !hasInlineAcceptanceAndVerify(s));
359
+ const missing = stories.filter((s) => !hasInlineAcceptance(s));
516
360
  if (missing.length === 0) return;
517
361
  const list = missing.map((s) => `"${s.title}" (${s.slug})`).join(', ');
518
362
  throw new Error(
519
- `Cross-Validation Failed: ${missing.length} Story/Stories lack an inline acceptance + verify contract: ${list}. Every Story must carry non-empty top-level acceptance[] and verify[].`,
363
+ `Cross-Validation Failed: ${missing.length} Story/Stories lack an inline acceptance contract: ${list}. Every Story must carry a non-empty top-level acceptance[] — the outcomes a PR reviewer confirms once it lands.`,
520
364
  );
521
365
  }
522
366
 
367
+ /**
368
+ * One warning per Story with no `verify[]` entry (Story #5342).
369
+ *
370
+ * Demoted from the hard refusal above: an absent verify list costs the
371
+ * acceptance critic its cheapest evidence, which is worth saying on the
372
+ * dry-run, but it never makes the Story unimplementable — the deliverer
373
+ * derives the commands and the close gate chain runs either way.
374
+ *
375
+ * @param {object[]} stories
376
+ * @returns {string[]}
377
+ */
378
+ function collectMissingVerifyWarnings(stories) {
379
+ return (stories ?? [])
380
+ .filter((s) => !Array.isArray(s?.verify) || s.verify.length === 0)
381
+ .map(
382
+ (s) =>
383
+ `Story "${s.slug ?? s.title ?? '<unknown>'}" lists no verify[] entry — ` +
384
+ 'the deliverer and the acceptance critic have no mechanical check to ' +
385
+ 'read as evidence. Add the exact command or test path unless the ' +
386
+ 'Story genuinely has none.',
387
+ );
388
+ }
389
+
523
390
  /**
524
391
  * Shape-check the optional per-Story `provenance` field (Story #5045).
525
392
  *
@@ -619,19 +486,12 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
619
486
  assertAcyclic(slugAdjacency);
620
487
 
621
488
  // Story #4541 — refuse an unparseable Story body up front, with a named
622
- // error pointing at the offending section + entry. Must precede both the
623
- // subject-prefix scan and the freshness gate: each parses the body, and
624
- // the freshness gate's net-new whitelist comes from `body.changes`, so a
625
- // malformed body used to surface as a stale-path miss naming the paths the
626
- // Story had legitimately declared.
489
+ // error pointing at the offending section + entry. Must precede the
490
+ // freshness gate: it parses the body, and its net-new whitelist comes from
491
+ // `body.changes`, so a malformed body used to surface as a stale-path miss
492
+ // naming the paths the Story had legitimately declared.
627
493
  assertStoryBodiesParse({ tickets });
628
494
 
629
- // Reject any Task acceptance item that prescribes a non-Conventional-Commits
630
- // subject prefix (e.g. legacy "Commit subject begins with 'baseline-refresh:'"
631
- // from pre-Epic-#2501 planner output). Runs before the freshness gate so
632
- // the failure mode is reported up-front rather than after a git probe.
633
- validateAcceptanceSubjectPrefix({ tickets });
634
-
635
495
  // Hoist a single memoized (ref, path) → boolean probe shared across both
636
496
  // git-probe gates below. Without this, `validateAcFreshness` and
637
497
  // `validateStoryFileAssumptions` each maintain an independent cache, so a
@@ -643,7 +503,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
643
503
  ? makeMemoizedGitRunner(opts.gitRunner ?? defaultGitRunner)
644
504
  : null;
645
505
 
646
- const warnings = [];
506
+ const warnings = [...collectMissingVerifyWarnings(stories)];
647
507
  // Story #5312: a goal / acceptance / verify path absent at base is a
648
508
  // warning the dry-run lists, not a refusal. Skipped when the caller omits
649
509
  // `baseBranchRef` so unit tests keep their semantics; production
@@ -702,5 +562,6 @@ export const _internal = {
702
562
  assertNoUnknownDeps,
703
563
  assertAcyclic,
704
564
  attachFindingsAndErrors,
705
- hasInlineAcceptanceAndVerify,
565
+ hasInlineAcceptance,
566
+ collectMissingVerifyWarnings,
706
567
  };