mandrel 2.25.0 → 2.26.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/agents/acceptance-critic.md +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -11
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -31,15 +31,13 @@ import {
|
|
|
31
31
|
renderAcceptanceSpecSystemPrompt,
|
|
32
32
|
renderTechSpecSystemPrompt,
|
|
33
33
|
} from '../templates/spec-author-prompts.js';
|
|
34
|
-
import { concurrentMap } from '../util/concurrent-map.js';
|
|
34
|
+
import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
|
|
35
35
|
import { buildComplexitySignals } from './complexity-gate.js';
|
|
36
36
|
import { parseDeliverySlicingTable } from './consolidation-precondition.js';
|
|
37
37
|
import { buildDocsDigest } from './docs-digest.js';
|
|
38
38
|
import { buildAuthoringContext } from './planning/authoring-context.js';
|
|
39
39
|
import { buildDecomposerSystemPrompt } from './planning/decomposer-context.js';
|
|
40
40
|
|
|
41
|
-
/** Bounded concurrency for `--tickets` source-ticket hydration. */
|
|
42
|
-
const SOURCE_TICKET_FETCH_CONCURRENCY = 4;
|
|
43
41
|
/**
|
|
44
42
|
* Envelope byte ceiling (regression guard for the design's named PR2 risk:
|
|
45
43
|
* two envelopes → one bigger one). This is the **only** live bound on
|
|
@@ -229,7 +227,8 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
|
229
227
|
'codes, security invariants, and load-bearing constraints with ' +
|
|
230
228
|
'their why. Implementation choices belong to the deliverer unless ' +
|
|
231
229
|
'load-bearing. No per-file behavior paragraphs, no current-state ' +
|
|
232
|
-
'narration.
|
|
230
|
+
'narration. Aim for ~250 words; an advisory warning fires past 350, ' +
|
|
231
|
+
'and it never fails the persist. ' +
|
|
233
232
|
'Delete this field when acceptance[] carries the whole contract.',
|
|
234
233
|
changes: buildTemplateChanges(complexitySignals),
|
|
235
234
|
non_goals: [],
|
|
@@ -813,6 +812,84 @@ async function searchStoryDuplicates({
|
|
|
813
812
|
}
|
|
814
813
|
}
|
|
815
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Gather the three independent envelope inputs — the open-Story duplicate
|
|
817
|
+
* search, the folded authoring context, and the inline docs digest — under
|
|
818
|
+
* bounded concurrency (Story #4952).
|
|
819
|
+
*
|
|
820
|
+
* None of the three reads a value the others produce, so the result is a pure
|
|
821
|
+
* function of `seed` and the injected config: the assembled envelope is
|
|
822
|
+
* **byte-identical** to the serial build for the same inputs, whichever order
|
|
823
|
+
* the three happen to settle in. `concurrentMap` preserves input order, so the
|
|
824
|
+
* destructuring below is positional and stable.
|
|
825
|
+
*
|
|
826
|
+
* `docsContextFiles` is emptied for the `buildAuthoringContext` call: the
|
|
827
|
+
* per-plan digest-file path needs a plan id that does not exist yet — the
|
|
828
|
+
* inline digest gathered alongside it replaces that pointer.
|
|
829
|
+
*
|
|
830
|
+
* @param {{
|
|
831
|
+
* seed: string,
|
|
832
|
+
* epicTitle: string,
|
|
833
|
+
* excludeIds?: Iterable<number|string>,
|
|
834
|
+
* provider: object,
|
|
835
|
+
* config: object,
|
|
836
|
+
* settings: object,
|
|
837
|
+
* cwd?: string,
|
|
838
|
+
* }} args
|
|
839
|
+
* @returns {Promise<{
|
|
840
|
+
* duplicates: Array<object>,
|
|
841
|
+
* authoring: object,
|
|
842
|
+
* docsContext: { mode: 'digest-inline', digest: string }|null,
|
|
843
|
+
* }>}
|
|
844
|
+
*/
|
|
845
|
+
async function gatherEnvelopeInputs({
|
|
846
|
+
seed,
|
|
847
|
+
epicTitle,
|
|
848
|
+
excludeIds = [],
|
|
849
|
+
provider,
|
|
850
|
+
config,
|
|
851
|
+
settings,
|
|
852
|
+
cwd,
|
|
853
|
+
}) {
|
|
854
|
+
const paths = settings?.paths ?? {};
|
|
855
|
+
const [duplicates, authoring, inlineDigest] = await concurrentMap(
|
|
856
|
+
[
|
|
857
|
+
() => searchStoryDuplicates({ seed, provider, config, excludeIds }),
|
|
858
|
+
() =>
|
|
859
|
+
buildAuthoringContext(
|
|
860
|
+
0,
|
|
861
|
+
/* provider (unused behind the prefetch seam) */ {},
|
|
862
|
+
{ ...settings, docsContextFiles: [] },
|
|
863
|
+
{
|
|
864
|
+
epic: { id: 0, title: epicTitle, body: seed },
|
|
865
|
+
github: config.github ?? null,
|
|
866
|
+
cwd,
|
|
867
|
+
},
|
|
868
|
+
),
|
|
869
|
+
() =>
|
|
870
|
+
buildDocsDigest({
|
|
871
|
+
docsContextFiles: settings?.docsContextFiles,
|
|
872
|
+
docsRoot: paths.docsRoot,
|
|
873
|
+
}),
|
|
874
|
+
],
|
|
875
|
+
(gather) => gather(),
|
|
876
|
+
// The per-mode envelope gathers (Story #4952): the duplicate search, the
|
|
877
|
+
// authoring-context fold and the docs digest have no data dependency on
|
|
878
|
+
// one another, so their serialization was incidental and `/plan` paid it
|
|
879
|
+
// with the operator waiting at Gate #1.
|
|
880
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
duplicates,
|
|
885
|
+
authoring,
|
|
886
|
+
docsContext:
|
|
887
|
+
inlineDigest == null
|
|
888
|
+
? null
|
|
889
|
+
: { mode: 'digest-inline', digest: inlineDigest },
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
816
893
|
/**
|
|
817
894
|
* Build the seed-file (ideation) envelope. No parent ticket
|
|
818
895
|
* exists yet — creation moves to the persist half — so the open-Story
|
|
@@ -836,37 +913,17 @@ async function buildSeedFileModeEnvelope({
|
|
|
836
913
|
);
|
|
837
914
|
}
|
|
838
915
|
|
|
839
|
-
|
|
916
|
+
// Dup search, the authoring-context fold grounded in the seed prose, and the
|
|
917
|
+
// inline docs digest are independent — gathered concurrently (Story #4952).
|
|
918
|
+
const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
|
|
840
919
|
seed: content,
|
|
920
|
+
epicTitle: seedFilePath ?? 'seed',
|
|
841
921
|
provider,
|
|
842
922
|
config,
|
|
923
|
+
settings,
|
|
924
|
+
cwd,
|
|
843
925
|
});
|
|
844
926
|
|
|
845
|
-
// Fold the authoring-context builders grounded in the seed prose.
|
|
846
|
-
// `docsContextFiles` is emptied for this call: the per-plan digest-file
|
|
847
|
-
// path needs a plan id that does not exist yet — the inline digest
|
|
848
|
-
// below replaces it.
|
|
849
|
-
const authoring = await buildAuthoringContext(
|
|
850
|
-
0,
|
|
851
|
-
/* provider (unused behind the prefetch seam) */ {},
|
|
852
|
-
{ ...settings, docsContextFiles: [] },
|
|
853
|
-
{
|
|
854
|
-
epic: { id: 0, title: seedFilePath ?? 'seed', body: content },
|
|
855
|
-
github: config.github ?? null,
|
|
856
|
-
cwd,
|
|
857
|
-
},
|
|
858
|
-
);
|
|
859
|
-
|
|
860
|
-
const paths = settings?.paths ?? {};
|
|
861
|
-
const inlineDigest = await buildDocsDigest({
|
|
862
|
-
docsContextFiles: settings?.docsContextFiles,
|
|
863
|
-
docsRoot: paths.docsRoot,
|
|
864
|
-
});
|
|
865
|
-
const docsContext =
|
|
866
|
-
inlineDigest == null
|
|
867
|
-
? null
|
|
868
|
-
: { mode: 'digest-inline', digest: inlineDigest };
|
|
869
|
-
|
|
870
927
|
const limits = getLimits(config);
|
|
871
928
|
const heuristics = resolveRiskHeuristics(config);
|
|
872
929
|
|
|
@@ -892,7 +949,7 @@ async function buildSeedFileModeEnvelope({
|
|
|
892
949
|
docsContext,
|
|
893
950
|
bddRunner: authoring.bddRunner,
|
|
894
951
|
bddScenarios: authoring.bddScenarios,
|
|
895
|
-
|
|
952
|
+
memoryPoolAdvisory: authoring.memoryPoolAdvisory,
|
|
896
953
|
priorFeedback: authoring.priorFeedback,
|
|
897
954
|
ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
|
|
898
955
|
maxTickets: limits.maxTickets,
|
|
@@ -975,7 +1032,8 @@ async function fetchSourceTickets(ticketIds, provider) {
|
|
|
975
1032
|
state: ticket.state ?? undefined,
|
|
976
1033
|
};
|
|
977
1034
|
},
|
|
978
|
-
|
|
1035
|
+
// `--tickets` source-ticket hydration: one independent read per id.
|
|
1036
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
979
1037
|
);
|
|
980
1038
|
}
|
|
981
1039
|
|
|
@@ -1001,37 +1059,18 @@ async function buildTicketsModeEnvelope({
|
|
|
1001
1059
|
.map((t) => `# ${t.title}\n\n${t.body}`)
|
|
1002
1060
|
.join('\n\n---\n\n');
|
|
1003
1061
|
|
|
1004
|
-
|
|
1062
|
+
// Same three independent gathers as seed-file mode, concurrent under the
|
|
1063
|
+
// same bound (Story #4952); only the source-ticket hydration above is a
|
|
1064
|
+
// genuine data dependency, because `seed` is derived from it.
|
|
1065
|
+
const { duplicates, authoring, docsContext } = await gatherEnvelopeInputs({
|
|
1005
1066
|
seed,
|
|
1067
|
+
epicTitle: sourceTickets[0]?.title ?? 'tickets',
|
|
1068
|
+
excludeIds: ticketIds,
|
|
1006
1069
|
provider,
|
|
1007
1070
|
config,
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const authoring = await buildAuthoringContext(
|
|
1012
|
-
0,
|
|
1013
|
-
{},
|
|
1014
|
-
{ ...settings, docsContextFiles: [] },
|
|
1015
|
-
{
|
|
1016
|
-
epic: {
|
|
1017
|
-
id: 0,
|
|
1018
|
-
title: sourceTickets[0]?.title ?? 'tickets',
|
|
1019
|
-
body: seed,
|
|
1020
|
-
},
|
|
1021
|
-
github: config.github ?? null,
|
|
1022
|
-
cwd,
|
|
1023
|
-
},
|
|
1024
|
-
);
|
|
1025
|
-
|
|
1026
|
-
const paths = settings?.paths ?? {};
|
|
1027
|
-
const inlineDigest = await buildDocsDigest({
|
|
1028
|
-
docsContextFiles: settings?.docsContextFiles,
|
|
1029
|
-
docsRoot: paths.docsRoot,
|
|
1071
|
+
settings,
|
|
1072
|
+
cwd,
|
|
1030
1073
|
});
|
|
1031
|
-
const docsContext =
|
|
1032
|
-
inlineDigest == null
|
|
1033
|
-
? null
|
|
1034
|
-
: { mode: 'digest-inline', digest: inlineDigest };
|
|
1035
1074
|
|
|
1036
1075
|
const limits = getLimits(config);
|
|
1037
1076
|
const heuristics = resolveRiskHeuristics(config);
|
|
@@ -1053,7 +1092,7 @@ async function buildTicketsModeEnvelope({
|
|
|
1053
1092
|
docsContext,
|
|
1054
1093
|
bddRunner: authoring.bddRunner,
|
|
1055
1094
|
bddScenarios: authoring.bddScenarios,
|
|
1056
|
-
|
|
1095
|
+
memoryPoolAdvisory: authoring.memoryPoolAdvisory,
|
|
1057
1096
|
priorFeedback: authoring.priorFeedback,
|
|
1058
1097
|
ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
|
|
1059
1098
|
maxTickets: limits.maxTickets,
|
|
@@ -1147,12 +1186,26 @@ async function buildAmendmentModeEnvelope({
|
|
|
1147
1186
|
|
|
1148
1187
|
const heuristics = resolveRiskHeuristics(config);
|
|
1149
1188
|
const limits = getLimits(config);
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1189
|
+
// Story #4952 — this builder's independent-gather set has exactly one
|
|
1190
|
+
// member. `provider.getTicket` above is a hard data dependency (the prior
|
|
1191
|
+
// body IS the seed), and the mode deliberately carries no authoring-context
|
|
1192
|
+
// fold and no docs digest — the prior artifacts are the grounding. It still
|
|
1193
|
+
// goes through the same bounded gather as the other two builders so one file
|
|
1194
|
+
// does not carry two ways of gathering independent envelope inputs.
|
|
1195
|
+
const [duplicates] = await concurrentMap(
|
|
1196
|
+
[
|
|
1197
|
+
() =>
|
|
1198
|
+
searchStoryDuplicates({
|
|
1199
|
+
seed: priorBody,
|
|
1200
|
+
provider,
|
|
1201
|
+
config,
|
|
1202
|
+
excludeIds: [amendsId],
|
|
1203
|
+
}),
|
|
1204
|
+
],
|
|
1205
|
+
(gather) => gather(),
|
|
1206
|
+
// Same independent-gather fan-out as the seed-mode envelope above.
|
|
1207
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
1208
|
+
);
|
|
1156
1209
|
|
|
1157
1210
|
return {
|
|
1158
1211
|
mode: 'amends',
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Logger } from '../../Logger.js';
|
|
9
9
|
import {
|
|
10
|
+
CONFLICT_KINDS,
|
|
10
11
|
renderFanOutEvidence,
|
|
11
12
|
renderFanOutRemedy,
|
|
12
13
|
renderHardConflictError,
|
|
@@ -54,6 +55,15 @@ export function enforceFanOutGate(
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
58
|
+
* Report every soft finding the validator produced, each under its own kind.
|
|
59
|
+
*
|
|
60
|
+
* Only the {@link CONFLICT_KINDS} are cross-Story conflicts. The advisory
|
|
61
|
+
* kinds — `spec-word-budget` and the sizing findings — are single-Story
|
|
62
|
+
* nudges, and announcing them as conflicts overstated them and taught readers
|
|
63
|
+
* to discount the whole channel (Story #4907). `fan-out-warning` is excluded
|
|
64
|
+
* throughout: {@link enforceFanOutGate} owns it and has already either thrown
|
|
65
|
+
* or logged the override.
|
|
66
|
+
*
|
|
57
67
|
* @param {object[]} findings
|
|
58
68
|
* @param {string} [tag]
|
|
59
69
|
*/
|
|
@@ -62,10 +72,26 @@ export function surfaceSoftConflictFindings(findings, tag = 'plan-persist') {
|
|
|
62
72
|
(f) => f?.severity === 'soft' && f?.kind !== 'fan-out-warning',
|
|
63
73
|
);
|
|
64
74
|
if (soft.length === 0) return;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
const conflicts = soft.filter((f) => CONFLICT_KINDS.has(f?.kind));
|
|
76
|
+
const advisories = soft.filter((f) => !CONFLICT_KINDS.has(f?.kind));
|
|
77
|
+
if (conflicts.length > 0) {
|
|
78
|
+
Logger.warn(
|
|
79
|
+
`[${tag}] ${conflicts.length} soft cross-Story conflict finding(s) — review before approving the plan:`,
|
|
80
|
+
);
|
|
81
|
+
for (const finding of conflicts) {
|
|
82
|
+
Logger.warn(
|
|
83
|
+
`[${tag}] soft conflict: ${renderHardConflictError(finding)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (advisories.length > 0) {
|
|
88
|
+
Logger.warn(
|
|
89
|
+
`[${tag}] ${advisories.length} advisory finding(s) — the persist proceeds:`,
|
|
90
|
+
);
|
|
91
|
+
for (const finding of advisories) {
|
|
92
|
+
Logger.warn(
|
|
93
|
+
`[${tag}] advisory (${finding.kind}): ${renderHardConflictError(finding)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
70
96
|
}
|
|
71
97
|
}
|
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { rm } from 'node:fs/promises';
|
|
41
|
-
import path from 'node:path';
|
|
42
41
|
import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
|
|
43
42
|
import { gitSpawn } from '../../git-utils.js';
|
|
44
43
|
import { Logger } from '../../Logger.js';
|
|
45
44
|
import { sweepTempRetention } from '../../temp-retention.js';
|
|
45
|
+
import {
|
|
46
|
+
concurrentMap,
|
|
47
|
+
FANOUT_CONCURRENCY,
|
|
48
|
+
} from '../../util/concurrent-map.js';
|
|
46
49
|
import {
|
|
47
50
|
deriveStoryShape,
|
|
48
51
|
LITE_ROUTE_LABEL,
|
|
@@ -410,6 +413,198 @@ export async function reapStalePlanDirs({
|
|
|
410
413
|
return { reaped: result.purged.map((entry) => entry.path) };
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Fail closed on a payload that cannot be persisted, and warn on an
|
|
418
|
+
* explicitly-authorized over-budget one. Extracted from `runPlanPersist`
|
|
419
|
+
* (Story #4926) so the entry point carries the flow, not the guards.
|
|
420
|
+
*
|
|
421
|
+
* @param {unknown} rawStories
|
|
422
|
+
* @param {{ maxTickets: number, allowOverBudget: boolean }} limits
|
|
423
|
+
* @returns {void}
|
|
424
|
+
* @throws {Error} On an empty payload or an unauthorized over-budget one.
|
|
425
|
+
*/
|
|
426
|
+
function assertPersistablePlan(rawStories, { maxTickets, allowOverBudget }) {
|
|
427
|
+
if (!Array.isArray(rawStories) || rawStories.length === 0) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
'[plan-persist] stories payload must be a non-empty array ' +
|
|
430
|
+
'(--stories <file>). Default is one Story.',
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (rawStories.length <= maxTickets) return;
|
|
434
|
+
if (!allowOverBudget) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
|
|
437
|
+
`budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
Logger.warn(
|
|
441
|
+
`[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
|
|
442
|
+
`Stories vs. budget ${maxTickets} (--allow-over-budget).`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enforce the draft-reachability critic: orphans throw, a skip is ledgered.
|
|
448
|
+
*
|
|
449
|
+
* @param {{ status: string, reasons: string[], orphans?: object[] }} reachability
|
|
450
|
+
* @param {object} config
|
|
451
|
+
* @returns {Promise<void>}
|
|
452
|
+
* @throws {Error} `PLAN_REACHABILITY_ORPHANS` when the draft orphans a Story.
|
|
453
|
+
*/
|
|
454
|
+
async function enforceReachability(reachability, config) {
|
|
455
|
+
if (reachability.status === 'orphans') {
|
|
456
|
+
const err = new Error(renderReachabilityOrphans(reachability));
|
|
457
|
+
err.code = 'PLAN_REACHABILITY_ORPHANS';
|
|
458
|
+
err.orphans = reachability.orphans;
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
|
|
462
|
+
if (reachability.status === 'skipped') {
|
|
463
|
+
await appendCriticSkip(
|
|
464
|
+
{
|
|
465
|
+
critic: 'reachability',
|
|
466
|
+
reasons: reachability.reasons,
|
|
467
|
+
cli: 'plan-persist',
|
|
468
|
+
},
|
|
469
|
+
config,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Write the per-Story checkpoint, upsert the plan-summary comment, and flip
|
|
476
|
+
* every created Story to `agent::ready`. Terminal ordering is load-bearing:
|
|
477
|
+
* `agent::ready` lands last so it can honestly mean "fully persisted"
|
|
478
|
+
* (Story #4541). A dry run performs none of it.
|
|
479
|
+
*
|
|
480
|
+
* **The checkpoints fan out; the phase boundary does not** (Story #4952). The
|
|
481
|
+
* per-Story upserts are independent of one another and run under bounded
|
|
482
|
+
* concurrency, but the `await` on that whole fan-out is what keeps the
|
|
483
|
+
* Story #4541 invariant intact: *every* checkpoint is on its ticket before the
|
|
484
|
+
* first `agent::ready` flip is issued, so `ready` still means "fully
|
|
485
|
+
* persisted" and a `/deliver` that picks a Story up cannot read a null
|
|
486
|
+
* checkpoint. Concurrency inside the phase is safe; overlapping the phases is
|
|
487
|
+
* the race this ordering exists to close.
|
|
488
|
+
*
|
|
489
|
+
* @param {object} args
|
|
490
|
+
* @returns {Promise<void>}
|
|
491
|
+
*/
|
|
492
|
+
async function persistStoryArtifacts({
|
|
493
|
+
provider,
|
|
494
|
+
created,
|
|
495
|
+
primary,
|
|
496
|
+
route,
|
|
497
|
+
summaryBody,
|
|
498
|
+
}) {
|
|
499
|
+
const cohort = created.map((createdStory) => ({
|
|
500
|
+
slug: createdStory.slug,
|
|
501
|
+
id: createdStory.id,
|
|
502
|
+
}));
|
|
503
|
+
await concurrentMap(
|
|
504
|
+
created,
|
|
505
|
+
(story) =>
|
|
506
|
+
writeCheckpointV2(provider, story.id, {
|
|
507
|
+
persist: {
|
|
508
|
+
completedAt: new Date().toISOString(),
|
|
509
|
+
storyCount: created.length,
|
|
510
|
+
primaryStoryId: primary.id,
|
|
511
|
+
stories: cohort,
|
|
512
|
+
},
|
|
513
|
+
// Ledger the authored route verdict — the recorded reason and the
|
|
514
|
+
// per-Story shape evidence, including a shape-refused claim — on plan
|
|
515
|
+
// state (Story #4722). No authored verdict writes no block: absence
|
|
516
|
+
// is the standard full path.
|
|
517
|
+
...(route ? { route } : {}),
|
|
518
|
+
}),
|
|
519
|
+
// The per-Story checkpoint upserts (Story #4952): each targets a
|
|
520
|
+
// different issue and reads nothing another writes, so this loop was
|
|
521
|
+
// serial only by construction — but see {@link persistStoryArtifacts}
|
|
522
|
+
// for the phase ordering that is *not* incidental.
|
|
523
|
+
{ concurrency: FANOUT_CONCURRENCY },
|
|
524
|
+
);
|
|
525
|
+
await upsertStructuredComment(
|
|
526
|
+
provider,
|
|
527
|
+
primary.id,
|
|
528
|
+
PLAN_SUMMARY_COMMENT_TYPE,
|
|
529
|
+
summaryBody,
|
|
530
|
+
);
|
|
531
|
+
await markStoriesReady({ provider, created });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Remove this run's plan directory on terminal success, then sweep the
|
|
536
|
+
* abandoned ones terminal-success cleanup can never reach (Story #4541).
|
|
537
|
+
*
|
|
538
|
+
* @param {{ config: object, planDir: string|null, skipCleanup: boolean }} args
|
|
539
|
+
* @returns {Promise<void>}
|
|
540
|
+
*/
|
|
541
|
+
async function cleanupPlanDirs({ config, planDir, skipCleanup }) {
|
|
542
|
+
if (!skipCleanup && planDir) {
|
|
543
|
+
try {
|
|
544
|
+
await rm(planDir, { recursive: true, force: true });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Log the effective complexity route (Story #4722). Lite is upheld by the
|
|
554
|
+
* shape backstop; anything else reports why it fell back to full.
|
|
555
|
+
*
|
|
556
|
+
* @param {object|null} route
|
|
557
|
+
* @param {boolean} isLiteRoute
|
|
558
|
+
* @returns {void}
|
|
559
|
+
*/
|
|
560
|
+
function logEffectiveRoute(route, isLiteRoute) {
|
|
561
|
+
if (isLiteRoute) {
|
|
562
|
+
Logger.info(
|
|
563
|
+
`[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
|
|
564
|
+
`created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
|
|
565
|
+
`(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
|
|
566
|
+
'the route from each Story body — the label is never the control signal.',
|
|
567
|
+
);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (route) {
|
|
571
|
+
Logger.warn(
|
|
572
|
+
`[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
|
|
573
|
+
'(no route hint label).',
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Log the operator-facing persist epilogue: adoption, the ready primary, the
|
|
580
|
+
* deliver command, and the cohort grouping label.
|
|
581
|
+
*
|
|
582
|
+
* @param {{ created: object[], primary: object, planRunLabel: string }} args
|
|
583
|
+
* @returns {void}
|
|
584
|
+
*/
|
|
585
|
+
function logPersistEpilogue({ created, primary, planRunLabel }) {
|
|
586
|
+
const adopted = created.filter((story) => story.adopted);
|
|
587
|
+
if (adopted.length > 0) {
|
|
588
|
+
Logger.info(
|
|
589
|
+
`[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
|
|
590
|
+
`from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
Logger.info(
|
|
594
|
+
`[plan-persist] Persisted ${created.length} Story(ies)` +
|
|
595
|
+
`; primary #${primary.id} is agent::ready.`,
|
|
596
|
+
);
|
|
597
|
+
Logger.info(
|
|
598
|
+
`[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
|
|
599
|
+
);
|
|
600
|
+
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
601
|
+
// a delivery-resolution input (/deliver stays ids-only, Story #4540).
|
|
602
|
+
Logger.info(
|
|
603
|
+
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
604
|
+
`label:${planRunLabel}`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
413
608
|
/**
|
|
414
609
|
* Execute the flat Story persist end to end.
|
|
415
610
|
*
|
|
@@ -475,26 +670,10 @@ export async function runPlanPersist({
|
|
|
475
670
|
// through the shared standalone ledger (Story #4541).
|
|
476
671
|
const runStartedAt = opts.metricsSince ?? new Date().toISOString();
|
|
477
672
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const maxTickets = getLimits(config).maxTickets;
|
|
486
|
-
if (rawStories.length > maxTickets && !allowOverBudget) {
|
|
487
|
-
throw new Error(
|
|
488
|
-
`[plan-persist] Stories (${rawStories.length}) exceed the reviewability ` +
|
|
489
|
-
`budget (${maxTickets}). Re-scope, or rerun with --allow-over-budget.`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
if (rawStories.length > maxTickets && allowOverBudget) {
|
|
493
|
-
Logger.warn(
|
|
494
|
-
`[plan-persist] Persisting an over-budget plan: ${rawStories.length} ` +
|
|
495
|
-
`Stories vs. budget ${maxTickets} (--allow-over-budget).`,
|
|
496
|
-
);
|
|
497
|
-
}
|
|
673
|
+
assertPersistablePlan(rawStories, {
|
|
674
|
+
maxTickets: getLimits(config).maxTickets,
|
|
675
|
+
allowOverBudget,
|
|
676
|
+
});
|
|
498
677
|
|
|
499
678
|
Logger.info(
|
|
500
679
|
`[plan-persist] Running cross-validation on ${rawStories.length} Story ticket(s)...`,
|
|
@@ -516,23 +695,7 @@ export async function runPlanPersist({
|
|
|
516
695
|
tickets: rawStories,
|
|
517
696
|
config,
|
|
518
697
|
});
|
|
519
|
-
|
|
520
|
-
const err = new Error(renderReachabilityOrphans(reachability));
|
|
521
|
-
err.code = 'PLAN_REACHABILITY_ORPHANS';
|
|
522
|
-
err.orphans = reachability.orphans;
|
|
523
|
-
throw err;
|
|
524
|
-
}
|
|
525
|
-
Logger.info(`[plan-persist] reachability: ${reachability.reasons[0]}`);
|
|
526
|
-
if (reachability.status === 'skipped') {
|
|
527
|
-
await appendCriticSkip(
|
|
528
|
-
{
|
|
529
|
-
critic: 'reachability',
|
|
530
|
-
reasons: reachability.reasons,
|
|
531
|
-
cli: 'plan-persist',
|
|
532
|
-
},
|
|
533
|
-
config,
|
|
534
|
-
);
|
|
535
|
-
}
|
|
698
|
+
await enforceReachability(reachability, config);
|
|
536
699
|
|
|
537
700
|
// Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
|
|
538
701
|
const { stories } = assemblePlanStories(rawStories, {
|
|
@@ -559,19 +722,7 @@ export async function runPlanPersist({
|
|
|
559
722
|
injectedRules,
|
|
560
723
|
});
|
|
561
724
|
const isLiteRoute = route?.route === 'lite';
|
|
562
|
-
|
|
563
|
-
Logger.info(
|
|
564
|
-
`[plan-persist] ceremony-lite route upheld by the shape backstop: ` +
|
|
565
|
-
`created Stories carry the ${LITE_ROUTE_LABEL} hint ` +
|
|
566
|
-
`(recorded reason: ${route.authored.reason}). /deliver re-derives ` +
|
|
567
|
-
'the route from each Story body — the label is never the control signal.',
|
|
568
|
-
);
|
|
569
|
-
} else if (route) {
|
|
570
|
-
Logger.warn(
|
|
571
|
-
`[plan-persist] ${route.reasons.join('; ')} — persisting as full ` +
|
|
572
|
-
'(no route hint label).',
|
|
573
|
-
);
|
|
574
|
-
}
|
|
725
|
+
logEffectiveRoute(route, isLiteRoute);
|
|
575
726
|
|
|
576
727
|
const { created, planRunLabel } = await createStoryIssues({
|
|
577
728
|
provider,
|
|
@@ -616,35 +767,13 @@ export async function runPlanPersist({
|
|
|
616
767
|
});
|
|
617
768
|
|
|
618
769
|
if (!dryRun) {
|
|
619
|
-
|
|
620
|
-
await writeCheckpointV2(provider, story.id, {
|
|
621
|
-
persist: {
|
|
622
|
-
completedAt: new Date().toISOString(),
|
|
623
|
-
storyCount: created.length,
|
|
624
|
-
primaryStoryId: primary.id,
|
|
625
|
-
stories: created.map((createdStory) => ({
|
|
626
|
-
slug: createdStory.slug,
|
|
627
|
-
id: createdStory.id,
|
|
628
|
-
})),
|
|
629
|
-
},
|
|
630
|
-
// Ledger the authored route verdict — the recorded reason and the
|
|
631
|
-
// per-Story shape evidence, including a shape-refused claim — on plan
|
|
632
|
-
// state (Story #4722). No authored verdict writes no block: absence
|
|
633
|
-
// is the standard full path.
|
|
634
|
-
...(route ? { route } : {}),
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
await upsertStructuredComment(
|
|
770
|
+
await persistStoryArtifacts({
|
|
638
771
|
provider,
|
|
639
|
-
|
|
640
|
-
|
|
772
|
+
created,
|
|
773
|
+
primary,
|
|
774
|
+
route,
|
|
641
775
|
summaryBody,
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
// Terminal step: every checkpoint above is now on every Story, so
|
|
645
|
-
// `agent::ready` can honestly mean "fully persisted" (Story #4541).
|
|
646
|
-
// Anything that picks a Story up from here reads a real checkpoint.
|
|
647
|
-
await markStoriesReady({ provider, created });
|
|
776
|
+
});
|
|
648
777
|
}
|
|
649
778
|
|
|
650
779
|
const supersede = await runSupersedePhase({
|
|
@@ -660,37 +789,8 @@ export async function runPlanPersist({
|
|
|
660
789
|
// any) rather than reading as a clean no-op — Story #4554.
|
|
661
790
|
supersede.sourceTicketOrigin = sourceTicketOrigin;
|
|
662
791
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
await rm(planDir, { recursive: true, force: true });
|
|
666
|
-
} catch (err) {
|
|
667
|
-
Logger.warn(`[plan-persist] temp cleanup skipped: ${err.message}`);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
// Terminal-success cleanup only ever removes *this* run's planDir, so
|
|
671
|
-
// abandoned ones accumulated forever. Sweep them (Story #4541).
|
|
672
|
-
await reapStalePlanDirs({ config, keepDir: skipCleanup ? planDir : null });
|
|
673
|
-
|
|
674
|
-
const adopted = created.filter((story) => story.adopted);
|
|
675
|
-
if (adopted.length > 0) {
|
|
676
|
-
Logger.info(
|
|
677
|
-
`[plan-persist] resumed ${adopted.length} of ${created.length} Story(ies) ` +
|
|
678
|
-
`from a previous persist: ${adopted.map((s2) => `#${s2.id}`).join(', ')}.`,
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
Logger.info(
|
|
682
|
-
`[plan-persist] Persisted ${created.length} Story(ies)` +
|
|
683
|
-
`; primary #${primary.id} is agent::ready.`,
|
|
684
|
-
);
|
|
685
|
-
Logger.info(
|
|
686
|
-
`[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
|
|
687
|
-
);
|
|
688
|
-
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
689
|
-
// a delivery-resolution input (/deliver stays ids-only, Story #4540).
|
|
690
|
-
Logger.info(
|
|
691
|
-
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
692
|
-
`label:${planRunLabel}`,
|
|
693
|
-
);
|
|
792
|
+
await cleanupPlanDirs({ config, planDir, skipCleanup });
|
|
793
|
+
logPersistEpilogue({ created, primary, planRunLabel });
|
|
694
794
|
|
|
695
795
|
return {
|
|
696
796
|
stories: created,
|