mandrel 2.58.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 (124) hide show
  1. package/.agents/README.md +17 -12
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +12 -13
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +9 -5
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +5 -7
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/runtime-deps.json +7 -2
  13. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  14. package/.agents/schemas/agentrc.schema.json +6 -11
  15. package/.agents/schemas/crap-baseline.schema.json +1 -1
  16. package/.agents/schemas/crap-report.schema.json +1 -1
  17. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  18. package/.agents/scripts/README.md +11 -1
  19. package/.agents/scripts/acceptance-eval.js +25 -27
  20. package/.agents/scripts/ceremony-derive.js +15 -10
  21. package/.agents/scripts/check-context-budget.js +148 -228
  22. package/.agents/scripts/check-schema-references.js +5 -3
  23. package/.agents/scripts/check-workflow-citations.js +33 -147
  24. package/.agents/scripts/coverage-capture.js +7 -4
  25. package/.agents/scripts/deliver-light.js +41 -100
  26. package/.agents/scripts/deliver-run.js +631 -0
  27. package/.agents/scripts/file-ci-gap.js +59 -11
  28. package/.agents/scripts/install-matrix-assert.js +48 -3
  29. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  30. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  31. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  32. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  33. package/.agents/scripts/lib/changed-files.js +30 -0
  34. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  35. package/.agents/scripts/lib/config/explain.js +1 -3
  36. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  37. package/.agents/scripts/lib/config-resolver.js +1 -0
  38. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  39. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  40. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  41. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  42. package/.agents/scripts/lib/crap-engine.js +2 -2
  43. package/.agents/scripts/lib/crap-utils.js +21 -5
  44. package/.agents/scripts/lib/doc-tiers.js +4 -2
  45. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  46. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  47. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  48. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  49. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  50. package/.agents/scripts/lib/gh-exec.js +160 -0
  51. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  52. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  53. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  54. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  55. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  56. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  57. package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
  58. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  64. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  65. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  66. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  67. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  68. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  69. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  70. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  71. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  72. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  73. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  74. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  75. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  76. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  77. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  78. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  79. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  80. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  81. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  82. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  83. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  84. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  85. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  86. package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
  87. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  88. package/.agents/scripts/merge-baseline.js +4 -5
  89. package/.agents/scripts/plan-context.js +117 -28
  90. package/.agents/scripts/plan-persist.js +79 -39
  91. package/.agents/scripts/plan-run-epilogue.js +11 -8
  92. package/.agents/scripts/pr-watch-with-update.js +9 -2
  93. package/.agents/scripts/run-verify.js +13 -6
  94. package/.agents/scripts/single-story-init.js +7 -57
  95. package/.agents/scripts/stories-wave-tick.js +160 -26
  96. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  97. package/.agents/skills/skills.index.json +2 -12
  98. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  99. package/.agents/workflows/audit-to-stories.md +14 -11
  100. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  101. package/.agents/workflows/helpers/code-review.md +4 -2
  102. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  103. package/.agents/workflows/helpers/deliver-light.md +92 -101
  104. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  105. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  106. package/.agents/workflows/helpers/deliver-story.md +17 -18
  107. package/.agents/workflows/helpers/plan-reference.md +82 -60
  108. package/.agents/workflows/mandrel-deliver.md +47 -31
  109. package/.agents/workflows/mandrel-plan.md +32 -30
  110. package/.agents/workflows/mandrel-update.md +36 -21
  111. package/README.md +3 -3
  112. package/docs/CHANGELOG.md +43 -0
  113. package/lib/cli/registry.js +45 -25
  114. package/lib/cli/update.js +376 -17
  115. package/lib/migrations/index.js +2 -0
  116. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  117. package/package.json +8 -2
  118. package/.agents/schemas/model-attribution.schema.json +0 -53
  119. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  120. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  121. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  122. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
  123. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  124. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
@@ -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
@@ -8,13 +8,12 @@ import { computeStoryReachability } from './story-reachability.js';
8
8
  * `collectStoryAssumptionEntries` (Story #3302) and the sizing gate's
9
9
  * `resolveStoryBody` (Story #4271).
10
10
  *
11
- * The decomposer emits `body` as the canonical serialized **string**, but
12
- * the conflict passes (`indexConsumers`, `computeMissingBddScaffoldFindings`,
13
- * and the producer path scan in `collectStoryProducerPaths`) historically
14
- * read `story.body` only when it was already an object — so on the
15
- * production string shape the `implicit-cross-story-dep` and
16
- * `missing-bdd-scaffold` findings emitted nothing. Parsing the body once at the entry point and
17
- * threading the normalized Story through every pass restores parity.
11
+ * The decomposer emits `body` as the canonical serialized **string**, but the
12
+ * conflict passes (the producer path scan in `collectStoryProducerPaths`, and
13
+ * the two substring-match advisories Story #5332 retired) historically read
14
+ * `story.body` only when it was already an object — so on the production
15
+ * string shape they emitted nothing. Parsing the body once at the entry point
16
+ * and threading the normalized Story through every pass restores parity.
18
17
  *
19
18
  * `collectStoryAssumptionEntries` already parses string bodies itself, so a
20
19
  * normalized object body round-trips through it unchanged. The returned Story
@@ -76,16 +75,20 @@ function normalizeStoryBody(story) {
76
75
  * @property {string} path Producer path written by ≥2 Stories.
77
76
  * @property {string[]} storySlugs Story slugs in the conflict cluster.
78
77
  *
79
- * @typedef {object} ImplicitCrossStoryDepFinding
80
- * @property {'implicit-cross-story-dep'} kind
81
- * @property {'hard'|'soft'} severity
82
- * @property {string} path Path consumed without a depends_on link.
83
- * @property {{ storySlug: string, taskSlug: string }} producer
84
- * @property {{ storySlug: string, taskSlug: string, sourceField: 'acceptance'|'verify' }} consumer
85
- *
86
- * @typedef {SharedEditorFinding | ImplicitCrossStoryDepFinding} ConflictFinding
78
+ * @typedef {SharedEditorFinding} ConflictFinding
87
79
  */
88
80
 
81
+ /**
82
+ * Story #5332 retired the `implicit-cross-story-dep` and
83
+ * `missing-bdd-scaffold` findings, leaving `shared-editor` as the one
84
+ * conflict kind. Both matched a producer path as a **substring** of a
85
+ * consumer's `acceptance[]` / `verify[]` text — the noise-prone shape the
86
+ * planning-diet ADR (`20260912-5312`) itself calls out — and both had been
87
+ * unreachable on the real payload for most of their life (see
88
+ * {@link computeAssembledConflictFindings}). What they nudged for, ordering a
89
+ * consumer after its producer, the same-wave collision refusal now enforces
90
+ * on declarations rather than guesses at from prose.
91
+
89
92
  /**
90
93
  * Every conflict class is advisory (`'soft'`) since Story #5312: the
91
94
  * `planning.failOnSharedEditors` / `requireExplicitCrossStoryDeps` /
@@ -160,46 +163,6 @@ function indexProducers(stories) {
160
163
  return producers;
161
164
  }
162
165
 
163
- /**
164
- * Build the consumers index — `Array<{path, storySlug, taskSlug, sourceField}>`.
165
- *
166
- * For each Task, scan `body.acceptance` and `body.verify` joined text for
167
- * literal substring occurrences of any known producer path. Only producer
168
- * paths are matched (intersect-then-test), so free-text path-like tokens
169
- * that no one writes never produce false positives.
170
- *
171
- * A Story is not its own consumer — entries whose producer is the same
172
- * Story are skipped to keep the surface focused on cross-Story signal.
173
- */
174
- function indexConsumers(stories, producers) {
175
- const consumers = [];
176
- if (producers.size === 0) return consumers;
177
- const producerPaths = Array.from(producers.keys()).sort(
178
- (a, b) => b.length - a.length,
179
- );
180
- for (const story of stories) {
181
- const body = story.body;
182
- if (!body || typeof body !== 'object') continue;
183
- for (const sourceField of ['acceptance', 'verify']) {
184
- const items = Array.isArray(body[sourceField]) ? body[sourceField] : [];
185
- if (items.length === 0) continue;
186
- const joined = items.map((it) => String(it ?? '')).join('\n');
187
- for (const path of producerPaths) {
188
- if (!joined.includes(path)) continue;
189
- const producerEntries = producers.get(path) ?? [];
190
- if (producerEntries.some((p) => p.taskSlug === story.slug)) continue;
191
- consumers.push({
192
- path,
193
- storySlug: storySlugOf(story),
194
- taskSlug: story.slug,
195
- sourceField,
196
- });
197
- }
198
- }
199
- }
200
- return consumers;
201
- }
202
-
203
166
  function inSameWave(reach, slugA, slugB) {
204
167
  if (slugA === slugB) return false;
205
168
  const a = reach.get(slugA);
@@ -240,133 +203,6 @@ function computeSharedEditorFindings(producers, reach, severity) {
240
203
  return findings;
241
204
  }
242
205
 
243
- /**
244
- * Emit one `implicit-cross-story-dep` finding per consumer entry whose
245
- * producer Story is not transitively reachable from the consumer Story.
246
- *
247
- * Multiple producers per path are possible — the finding pins the *first*
248
- * producer in declaration order (sufficient signal; the operator typically
249
- * fixes the missing `depends_on` by linking to whichever Story they
250
- * recognize). Consumers already covered by a transitive dependency to
251
- * *some* producer are silently allowed even if other producers exist.
252
- */
253
- function computeImplicitDepFindings(consumers, producers, reach, severity) {
254
- const findings = [];
255
- for (const consumer of consumers) {
256
- const producerEntries = producers.get(consumer.path) ?? [];
257
- if (producerEntries.length === 0) continue;
258
- const reachable = reach.get(consumer.storySlug) ?? new Set();
259
- const alreadyDependsOnSome = producerEntries.some(
260
- (p) => p.storySlug === consumer.storySlug || reachable.has(p.storySlug),
261
- );
262
- if (alreadyDependsOnSome) continue;
263
- const producer = producerEntries[0];
264
- findings.push({
265
- kind: 'implicit-cross-story-dep',
266
- severity,
267
- path: consumer.path,
268
- producer: {
269
- storySlug: producer.storySlug,
270
- taskSlug: producer.taskSlug,
271
- },
272
- consumer: {
273
- storySlug: consumer.storySlug,
274
- taskSlug: consumer.taskSlug,
275
- sourceField: consumer.sourceField,
276
- },
277
- });
278
- }
279
- return findings;
280
- }
281
-
282
- /**
283
- * Compute `missing-bdd-scaffold` findings (Story #3857).
284
- *
285
- * The features-first delivery model requires every `.feature` file a Story
286
- * verifies against to already exist when that Story runs. When a Story's
287
- * `verify[]` references a `.feature` path that another Story declares with
288
- * `assumption: "creates"`, the consumer is correct only if the producer
289
- * lands in an *earlier* wave — otherwise the consumer's `verify[]` runs
290
- * against a file that does not yet exist and verification fails mid-delivery.
291
- *
292
- * A finding fires for each consumer/producer pair where:
293
- * - the path ends in `.feature`,
294
- * - a *different* Story declares that path as `assumption: "creates"`, and
295
- * - the consumer Story does not transitively `depends_on` the producer
296
- * (i.e. they share a wave, or the producer runs later).
297
- *
298
- * The finding is advisory (`'soft'`) — it is a nudge to add a `depends_on`
299
- * link to the wave-0 scaffold Story (or to the producing Story), not a hard
300
- * block. The remediation is the same shape as `implicit-cross-story-dep`:
301
- * order the consumer after the producer so the scaffold lands first.
302
- *
303
- * @param {object[]} stories
304
- * @param {Map<string, Set<string>>} reach Transitive predecessor sets.
305
- * @param {'soft'|'hard'} severity
306
- * @returns {object[]} `missing-bdd-scaffold` findings.
307
- */
308
- function computeMissingBddScaffoldFindings(stories, reach, severity) {
309
- // Index every `.feature` path declared `creates` to its producing Story.
310
- // A path may be created by more than one Story (unusual); pin the first in
311
- // declaration order, mirroring the implicit-dep finding's single-producer
312
- // shape.
313
- const featureCreators = new Map(); // path -> storySlug (first creator)
314
- for (const story of stories) {
315
- const body = story?.body;
316
- if (!body || typeof body !== 'object') continue;
317
- const changes = Array.isArray(body.changes) ? body.changes : [];
318
- for (const change of changes) {
319
- if (
320
- change === null ||
321
- typeof change !== 'object' ||
322
- change.assumption !== 'creates' ||
323
- typeof change.path !== 'string' ||
324
- !change.path.endsWith('.feature')
325
- )
326
- continue;
327
- if (!featureCreators.has(change.path)) {
328
- featureCreators.set(change.path, storySlugOf(story));
329
- }
330
- }
331
- }
332
- if (featureCreators.size === 0) return [];
333
-
334
- const creatorPaths = Array.from(featureCreators.keys()).sort(
335
- (a, b) => b.length - a.length,
336
- );
337
- const findings = [];
338
- const seen = new Set(); // dedupe `${consumerSlug}::${path}` pairs
339
- for (const story of stories) {
340
- const body = story?.body;
341
- if (!body || typeof body !== 'object') continue;
342
- const verifyItems = Array.isArray(body.verify) ? body.verify : [];
343
- if (verifyItems.length === 0) continue;
344
- const joined = verifyItems.map((it) => String(it ?? '')).join('\n');
345
- const consumerSlug = storySlugOf(story);
346
- for (const path of creatorPaths) {
347
- if (!joined.includes(path)) continue;
348
- const producerSlug = featureCreators.get(path);
349
- // A Story that creates the file it verifies is fine — no cross-Story gap.
350
- if (producerSlug === consumerSlug) continue;
351
- // Producer already runs in an earlier wave → consumer is correctly
352
- // ordered, scaffold lands first, no finding.
353
- const reachable = reach.get(consumerSlug) ?? new Set();
354
- if (reachable.has(producerSlug)) continue;
355
- const key = `${consumerSlug}::${path}`;
356
- if (seen.has(key)) continue;
357
- seen.add(key);
358
- findings.push({
359
- kind: 'missing-bdd-scaffold',
360
- severity,
361
- path,
362
- producer: { storySlug: producerSlug },
363
- consumer: { storySlug: consumerSlug, sourceField: 'verify' },
364
- });
365
- }
366
- }
367
- return findings;
368
- }
369
-
370
206
  /**
371
207
  * Public entry point. Walks the normalized ticket spec once and returns
372
208
  * the structured cross-Story findings array. Every finding is `'soft'`
@@ -384,13 +220,8 @@ export function computeConflictFindings({ stories } = {}) {
384
220
  // shape across every conflict pass.
385
221
  const storyList = (stories ?? []).map(normalizeStoryBody);
386
222
  const producers = indexProducers(storyList);
387
- const consumers = indexConsumers(storyList, producers);
388
223
  const reach = computeStoryReachability(storyList);
389
- return [
390
- ...computeSharedEditorFindings(producers, reach, SOFT),
391
- ...computeImplicitDepFindings(consumers, producers, reach, SOFT),
392
- ...computeMissingBddScaffoldFindings(storyList, reach, SOFT),
393
- ];
224
+ return computeSharedEditorFindings(producers, reach, SOFT);
394
225
  }
395
226
 
396
227
  /**
@@ -402,11 +233,11 @@ export function computeConflictFindings({ stories } = {}) {
402
233
  * persisted. That is not a cosmetic ordering nit: the canonical authoring shape
403
234
  * carries `acceptance[]` / `verify[]` at the ticket's **top level**, and it is
404
235
  * assembly's `syncContractFieldFromTopLevel` that folds them into the body.
405
- * `indexConsumers` scans `body.acceptance` / `body.verify` for producer paths —
406
- * so on the real payload it scanned two empty arrays, and every
407
- * `implicit-cross-story-dep` and `missing-bdd-scaffold` finding was silently
408
- * unreachable. Running the passes again over the serialized bodies restores
409
- * them.
236
+ * The two retired advisories scanned `body.acceptance` / `body.verify` for
237
+ * producer paths, so on the real payload they scanned two empty arrays and
238
+ * were silently unreachable. Running the passes again over the serialized
239
+ * bodies is what keeps the surviving `shared-editor` pass honest about what
240
+ * persist actually writes.
410
241
  *
411
242
  * @param {{ stories: Array<{ slug: string, title: string, body: string, depends_on?: string[] }> }} args
412
243
  * @returns {ConflictFinding[]}
@@ -457,13 +288,7 @@ export function conflictFindingKey(finding) {
457
288
  * of this list is how readers drift apart, so it is defined exactly once and
458
289
  * imported.
459
290
  */
460
- export const CONFLICT_KINDS = Object.freeze(
461
- new Set([
462
- 'shared-editor',
463
- 'implicit-cross-story-dep',
464
- 'missing-bdd-scaffold',
465
- ]),
466
- );
291
+ export const CONFLICT_KINDS = Object.freeze(new Set(['shared-editor']));
467
292
 
468
293
  /**
469
294
  * Render a conflict finding as a human-readable line. Every finding is soft
@@ -476,12 +301,6 @@ export function renderHardConflictError(finding) {
476
301
  const stories = finding.storySlugs.map((s) => `"${s}"`).join(', ');
477
302
  return `Shared-editor conflict: "${finding.path}" is written by ${finding.storySlugs.length} concurrent Stories (${stories}). Add depends_on chains between them or split the edits into a dedicated late-wave wiring Story.`;
478
303
  }
479
- if (finding.kind === 'implicit-cross-story-dep') {
480
- return `Implicit cross-Story dependency: Story "${finding.consumer.storySlug}" references "${finding.path}" (produced by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but Story "${finding.consumer.storySlug}" has no depends_on link to Story "${finding.producer.storySlug}". Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story or remove the reference.`;
481
- }
482
- if (finding.kind === 'missing-bdd-scaffold') {
483
- return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
484
- }
485
304
  // Findings from other passes carry their own message — render it rather
486
305
  // than a shape-blind generic line, so the soft surface
487
306
  // (`surfaceSoftConflictFindings`) stays legible for every kind.
@@ -496,10 +315,7 @@ export const _internal = {
496
315
  collectStoryProducerPaths,
497
316
  WRITE_IMPLYING_ASSUMPTIONS,
498
317
  indexProducers,
499
- indexConsumers,
500
318
  computeStoryReachability,
501
319
  inSameWave,
502
320
  computeSharedEditorFindings,
503
- computeImplicitDepFindings,
504
- computeMissingBddScaffoldFindings,
505
321
  };