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.
- package/.agents/README.md +17 -12
- package/.agents/agents/acceptance-critic.md +24 -43
- package/.agents/agents/story-worker.md +18 -19
- package/.agents/docs/SDLC.md +12 -13
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +29 -46
- package/.agents/docs/quality-gates.md +9 -5
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +5 -7
- package/.agents/rules/ci-remediation.md +41 -8
- package/.agents/rules/known-tooling-behavior.md +65 -15
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -11
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
- package/.agents/scripts/README.md +11 -1
- package/.agents/scripts/acceptance-eval.js +25 -27
- package/.agents/scripts/ceremony-derive.js +15 -10
- package/.agents/scripts/check-context-budget.js +148 -228
- package/.agents/scripts/check-schema-references.js +5 -3
- package/.agents/scripts/check-workflow-citations.js +33 -147
- package/.agents/scripts/coverage-capture.js +7 -4
- package/.agents/scripts/deliver-light.js +41 -100
- package/.agents/scripts/deliver-run.js +631 -0
- package/.agents/scripts/file-ci-gap.js +59 -11
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/changed-files.js +30 -0
- package/.agents/scripts/lib/config/delivery-routing.js +5 -4
- package/.agents/scripts/lib/config/explain.js +1 -3
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
- package/.agents/scripts/lib/config-resolver.js +1 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
- package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/doc-tiers.js +4 -2
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/gh-exec.js +160 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
- package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
- package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
- package/.agents/scripts/lib/orchestration/plan-context.js +44 -50
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +104 -119
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +41 -25
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
- package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
- package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
- package/.agents/scripts/lib/story-body/story-body.js +83 -29
- package/.agents/scripts/lib/templates/decomposer-prompts.js +28 -33
- package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
- package/.agents/scripts/merge-baseline.js +4 -5
- package/.agents/scripts/plan-context.js +117 -28
- package/.agents/scripts/plan-persist.js +79 -39
- package/.agents/scripts/plan-run-epilogue.js +11 -8
- package/.agents/scripts/pr-watch-with-update.js +9 -2
- package/.agents/scripts/run-verify.js +13 -6
- package/.agents/scripts/single-story-init.js +7 -57
- package/.agents/scripts/stories-wave-tick.js +160 -26
- package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
- package/.agents/skills/skills.index.json +2 -12
- package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
- package/.agents/workflows/helpers/code-review.md +4 -2
- package/.agents/workflows/helpers/deliver-digest.md +31 -24
- package/.agents/workflows/helpers/deliver-light.md +92 -101
- package/.agents/workflows/helpers/deliver-reference.md +116 -100
- package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
- package/.agents/workflows/helpers/deliver-story.md +17 -18
- package/.agents/workflows/helpers/plan-reference.md +82 -60
- package/.agents/workflows/mandrel-deliver.md +47 -31
- package/.agents/workflows/mandrel-plan.md +32 -30
- package/.agents/workflows/mandrel-update.md +36 -21
- package/README.md +3 -3
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/registry.js +45 -25
- package/lib/cli/update.js +376 -17
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
- package/package.json +8 -2
- package/.agents/schemas/model-attribution.schema.json +0 -53
- package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
- package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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 {
|
|
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
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
47
|
-
*
|
|
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
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
406
|
-
* so on the real payload
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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
|
};
|