mandrel 2.58.0 → 2.59.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 +6 -3
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- 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/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- 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/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/templates/decomposer-prompts.js +21 -18
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/plan-reference.md +18 -7
- package/.agents/workflows/mandrel-plan.md +14 -13
- package/README.md +3 -3
- package/docs/CHANGELOG.md +8 -0
- package/lib/cli/registry.js +45 -25
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- package/.agents/skills/core/scope-triage/SKILL.md +0 -48
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escomplex-kernel.js — the complexity kernel's parse and dispatch layers,
|
|
3
|
+
* in-repo.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists
|
|
6
|
+
*
|
|
7
|
+
* `typhonjs-escomplex` was a thin shell around four packages that do all the
|
|
8
|
+
* actual work. The shell contributed two things: a parser front-end
|
|
9
|
+
* (`@typhonjs/babel-parser`, a ~100-LOC shim over `@babel/parser`) and a
|
|
10
|
+
* generic plugin bus (`typhonjs-plugin-manager`, used as a hardcoded
|
|
11
|
+
* two-plugin synchronous dispatcher). Nine packages of plumbing hang off
|
|
12
|
+
* those two, and none of it computes anything.
|
|
13
|
+
*
|
|
14
|
+
* What this does **not** do is remove `core-js@2`. Four of the retained
|
|
15
|
+
* metric-core packages `require('babel-runtime/core-js/*')` themselves, so it
|
|
16
|
+
* is load-bearing for the code that stays; a change that claimed otherwise
|
|
17
|
+
* would be unshippable. What it buys instead is an honest closure —
|
|
18
|
+
* `babel-runtime` is required by those packages and declared by none of them,
|
|
19
|
+
* so today it resolves only because the removed plumbing hoists it. Declaring
|
|
20
|
+
* it turns an accident into a contract.
|
|
21
|
+
*
|
|
22
|
+
* This module reimplements exactly those two layers over the retained metric
|
|
23
|
+
* core — `typhonjs-escomplex-commons`, `escomplex-plugin-metrics-module`,
|
|
24
|
+
* `escomplex-plugin-syntax-babylon`, `typhonjs-ast-walker` — which compute
|
|
25
|
+
* every score. Nothing here computes a metric; the scores come from the same
|
|
26
|
+
* packages as before, which is why they do not move.
|
|
27
|
+
*
|
|
28
|
+
* ## The equivalence contract
|
|
29
|
+
*
|
|
30
|
+
* Reproducing the displaced shell's *behaviour* means reproducing three
|
|
31
|
+
* details it never documented:
|
|
32
|
+
*
|
|
33
|
+
* 1. **The parser's fixed plugin list**, verbatim and in order, with
|
|
34
|
+
* `sourceType: 'unambiguous'` — see `PARSER_PLUGINS` below. The list is
|
|
35
|
+
* what makes a `.ts` file, a decorator or a pipeline operator parse at
|
|
36
|
+
* all, and `unambiguous` is what lets a CommonJS script and an ES module
|
|
37
|
+
* both score.
|
|
38
|
+
* 2. **Both plugin instances, in registration order** — syntax first, then
|
|
39
|
+
* metrics. The metrics plugin reads trait tables the syntax plugin put on
|
|
40
|
+
* the event.
|
|
41
|
+
* 3. **One mutable event object per dispatch**, threaded through every plugin
|
|
42
|
+
* in turn, with the caller reading the mutations back off it. The displaced
|
|
43
|
+
* bus also stamped `$$plugin_invoke_count` / `$$plugin_invoke_names` onto
|
|
44
|
+
* every event's data; no plugin and no report reads them, so they are not
|
|
45
|
+
* reproduced.
|
|
46
|
+
*
|
|
47
|
+
* Equivalence is not asserted by reasoning: `tests/lib/escomplex-kernel.test.js`
|
|
48
|
+
* replays a corpus captured under the displaced kernel *before* it left the
|
|
49
|
+
* tree (`tests/fixtures/escomplex-kernel-parity/`), because afterwards there is
|
|
50
|
+
* nothing left to compare against.
|
|
51
|
+
*
|
|
52
|
+
* ## The one uncontrolled input
|
|
53
|
+
*
|
|
54
|
+
* `.agents/` materializes into a consumer's repository root, so `@babel/parser`
|
|
55
|
+
* resolves from **their** `node_modules`. The preflight guard checks presence,
|
|
56
|
+
* not range, and `@babel/parser@8` is GA and rejects several names in the
|
|
57
|
+
* fixed plugin list. A manifest range documents the requirement; it does not
|
|
58
|
+
* enforce it. So the resolved major is asserted here, at load, with an error
|
|
59
|
+
* that names the problem — rather than surfacing as an opaque plugin-list
|
|
60
|
+
* syntax error partway through a scan.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
// Every metric-core package is reached by a STATIC import specifier with an
|
|
64
|
+
// explicit `.js` extension. That is not style: `tests/scripts/
|
|
65
|
+
// runtime-deps-drift.test.js` scans for literal `import`/`require` callees, so
|
|
66
|
+
// a package reached through an aliased `createRequire` would be a runtime
|
|
67
|
+
// dependency that is neither declared nor preflighted. The extensions are
|
|
68
|
+
// mandatory because `typhonjs-escomplex-commons` ships an empty
|
|
69
|
+
// `package.json` — no `main`, no `exports` — so a deep path is the only door.
|
|
70
|
+
import { parse as babelParse } from '@babel/parser';
|
|
71
|
+
import PluginMetricsModule from 'escomplex-plugin-metrics-module/dist/PluginMetricsModule.js';
|
|
72
|
+
import PluginSyntaxBabylon from 'escomplex-plugin-syntax-babylon/dist/PluginSyntaxBabylon.js';
|
|
73
|
+
import ASTWalker from 'typhonjs-ast-walker/dist/ASTWalker.js';
|
|
74
|
+
import ModuleScopeControl from 'typhonjs-escomplex-commons/dist/module/report/control/ModuleScopeControl.js';
|
|
75
|
+
import ModuleReport from 'typhonjs-escomplex-commons/dist/module/report/ModuleReport.js';
|
|
76
|
+
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
77
|
+
import { describeParserMajorError } from './runtime-deps/parser-major.js';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The displaced parser shim's plugin list, verbatim and in order.
|
|
81
|
+
*
|
|
82
|
+
* Order is preserved because it is cheap to preserve, not because a
|
|
83
|
+
* reordering is known to matter. The two entries with options
|
|
84
|
+
* (`decorators`, `pipelineOperator`) carry the shim's exact settings —
|
|
85
|
+
* `decoratorsBeforeExport: false` and the `minimal` pipeline proposal — which
|
|
86
|
+
* decide whether decorated classes and `|>` parse. Re-cloned per parse so a
|
|
87
|
+
* parser that mutated its options could not poison a later call.
|
|
88
|
+
*/
|
|
89
|
+
const PARSER_PLUGINS = [
|
|
90
|
+
'asyncGenerators',
|
|
91
|
+
'bigInt',
|
|
92
|
+
'classProperties',
|
|
93
|
+
'classPrivateProperties',
|
|
94
|
+
'classPrivateMethods',
|
|
95
|
+
['decorators', { decoratorsBeforeExport: false }],
|
|
96
|
+
'doExpressions',
|
|
97
|
+
'dynamicImport',
|
|
98
|
+
'exportDefaultFrom',
|
|
99
|
+
'exportNamespaceFrom',
|
|
100
|
+
'functionBind',
|
|
101
|
+
'functionSent',
|
|
102
|
+
'importMeta',
|
|
103
|
+
'jsx',
|
|
104
|
+
'logicalAssignment',
|
|
105
|
+
'nullishCoalescingOperator',
|
|
106
|
+
'numericSeparator',
|
|
107
|
+
'objectRestSpread',
|
|
108
|
+
'optionalCatchBinding',
|
|
109
|
+
'optionalChaining',
|
|
110
|
+
['pipelineOperator', { proposal: 'minimal' }],
|
|
111
|
+
'throwExpressions',
|
|
112
|
+
'typescript',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The two plugins, in registration order: syntax populates the trait tables
|
|
117
|
+
* the metrics plugin then reads. Neither defines `onPluginLoad`, and the
|
|
118
|
+
* displaced bus was constructed without an eventbus, so there is no plugin
|
|
119
|
+
* lifecycle or eventbus coupling to reproduce — only this list.
|
|
120
|
+
*/
|
|
121
|
+
const PLUGINS = [
|
|
122
|
+
['escomplex-plugin-syntax-babylon', new PluginSyntaxBabylon()],
|
|
123
|
+
['escomplex-plugin-metrics-module', new PluginMetricsModule()],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// The kernel's code generator predates the Babel AST its own parser emits, so
|
|
127
|
+
// ordinary modern syntax aborts a WHOLE module — see `escomplex-ast-compat.js`
|
|
128
|
+
// for the defect and the upstream status. Installing at the kernel rather than
|
|
129
|
+
// at each caller makes the next scoring entrypoint correct by construction.
|
|
130
|
+
installAstCompat();
|
|
131
|
+
|
|
132
|
+
const parserProblem = describeParserMajorError();
|
|
133
|
+
if (parserProblem !== null) {
|
|
134
|
+
throw new Error(`[escomplex-kernel] ${parserProblem}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Run one synchronous plugin dispatch.
|
|
139
|
+
*
|
|
140
|
+
* Reproduces the displaced bus's `invokeSyncEvents` for the degenerate shape
|
|
141
|
+
* this kernel uses: no `copyProps` (the shell passed `void 0` at every call
|
|
142
|
+
* site, so the merge base was always `{}`), no eventbus, and the caller
|
|
143
|
+
* reading its results back off the same mutated `data` object every plugin
|
|
144
|
+
* saw.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} method Plugin method name, e.g. `onEnterNode`.
|
|
147
|
+
* @param {object} passthru Properties placed on the event's `data`.
|
|
148
|
+
* @returns {object} The event `data`, after every plugin has mutated it.
|
|
149
|
+
*/
|
|
150
|
+
function dispatch(method, passthru) {
|
|
151
|
+
const event = {
|
|
152
|
+
data: { ...passthru },
|
|
153
|
+
extra: undefined,
|
|
154
|
+
eventbus: undefined,
|
|
155
|
+
pluginName: undefined,
|
|
156
|
+
pluginOptions: undefined,
|
|
157
|
+
};
|
|
158
|
+
for (const [name, instance] of PLUGINS) {
|
|
159
|
+
if (typeof instance[method] !== 'function') continue;
|
|
160
|
+
event.pluginName = name;
|
|
161
|
+
instance[method](event);
|
|
162
|
+
}
|
|
163
|
+
return event.data;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The `ignoreKeys` a syntax trait wants withheld from the walker, if any.
|
|
168
|
+
*
|
|
169
|
+
* @param {object|undefined} syntax The trait entry for this node type.
|
|
170
|
+
* @param {object} node
|
|
171
|
+
* @param {object} parent
|
|
172
|
+
* @returns {string[]}
|
|
173
|
+
*/
|
|
174
|
+
function traitIgnoreKeys(syntax, node, parent) {
|
|
175
|
+
return typeof syntax === 'object' && syntax?.ignoreKeys
|
|
176
|
+
? syntax.ignoreKeys.valueOf(node, parent)
|
|
177
|
+
: [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The new scope a syntax trait opens at this node, if any.
|
|
182
|
+
*
|
|
183
|
+
* @param {object|undefined} syntax The trait entry for this node type.
|
|
184
|
+
* @param {object} node
|
|
185
|
+
* @param {object} parent
|
|
186
|
+
* @returns {object|null}
|
|
187
|
+
*/
|
|
188
|
+
function traitNewScope(syntax, node, parent) {
|
|
189
|
+
if (typeof syntax !== 'object' || !syntax?.newScope) return null;
|
|
190
|
+
return syntax.newScope.valueOf(node, parent) ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the walker visitor for one module traversal.
|
|
195
|
+
*
|
|
196
|
+
* Split out of {@link analyzeModule} so the enter/exit symmetry is readable
|
|
197
|
+
* side by side: each resolves the trait's scope, brackets the `scopeControl`
|
|
198
|
+
* mutation with a pre/post dispatch, and straddles it with the node dispatch
|
|
199
|
+
* in opposite order on the way in and out.
|
|
200
|
+
*
|
|
201
|
+
* The two event shapes are not interchangeable, and the difference is the
|
|
202
|
+
* displaced shell's, not a simplification available here: node events carry
|
|
203
|
+
* `syntaxes` and scope events do not, and a scope event names its scope
|
|
204
|
+
* `newScope` on the way in but `scope` on the way out.
|
|
205
|
+
*
|
|
206
|
+
* @param {{
|
|
207
|
+
* moduleReport: object,
|
|
208
|
+
* scopeControl: object,
|
|
209
|
+
* syntaxes: Record<string, object>,
|
|
210
|
+
* settings: object,
|
|
211
|
+
* }} context
|
|
212
|
+
* @returns {{enterNode: Function, exitNode: Function}}
|
|
213
|
+
*/
|
|
214
|
+
function buildVisitor({ moduleReport, scopeControl, syntaxes, settings }) {
|
|
215
|
+
const nodeBase = { moduleReport, scopeControl, syntaxes, settings };
|
|
216
|
+
const scopeBase = { moduleReport, scopeControl, settings };
|
|
217
|
+
return {
|
|
218
|
+
enterNode(node, parent) {
|
|
219
|
+
const syntax = syntaxes[node.type];
|
|
220
|
+
const event = dispatch('onEnterNode', {
|
|
221
|
+
...nodeBase,
|
|
222
|
+
ignoreKeys: traitIgnoreKeys(syntax, node, parent),
|
|
223
|
+
node,
|
|
224
|
+
parent,
|
|
225
|
+
});
|
|
226
|
+
const ignoreKeys = event !== null ? event.ignoreKeys : [];
|
|
227
|
+
const newScope = traitNewScope(syntax, node, parent);
|
|
228
|
+
if (newScope) {
|
|
229
|
+
const scoped = { ...scopeBase, newScope, node, parent };
|
|
230
|
+
dispatch('onModulePreScopeCreated', scoped);
|
|
231
|
+
scopeControl.createScope(newScope);
|
|
232
|
+
dispatch('onModulePostScopeCreated', scoped);
|
|
233
|
+
}
|
|
234
|
+
return ignoreKeys;
|
|
235
|
+
},
|
|
236
|
+
exitNode(node, parent) {
|
|
237
|
+
const syntax = syntaxes[node.type];
|
|
238
|
+
const newScope = traitNewScope(syntax, node, parent);
|
|
239
|
+
if (newScope) {
|
|
240
|
+
const scoped = { ...scopeBase, scope: newScope, node, parent };
|
|
241
|
+
dispatch('onModulePreScopePopped', scoped);
|
|
242
|
+
scopeControl.popScope(newScope);
|
|
243
|
+
dispatch('onModulePostScopePopped', scoped);
|
|
244
|
+
}
|
|
245
|
+
dispatch('onExitNode', { ...nodeBase, node, parent });
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse and score one module.
|
|
252
|
+
*
|
|
253
|
+
* Drop-in replacement for the displaced `escomplex.analyzeModule(source)`:
|
|
254
|
+
* same report object, same `finalize()` shape, same thrown errors for source
|
|
255
|
+
* the kernel cannot handle.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} source JavaScript (or TypeScript) source text.
|
|
258
|
+
* @param {object} [options] Passed to the plugins' `onConfigure`, as before.
|
|
259
|
+
* @returns {object} The finalized module report.
|
|
260
|
+
* @throws {SyntaxError} Propagated from the parser, as before.
|
|
261
|
+
*/
|
|
262
|
+
export function analyzeModule(source, options = {}) {
|
|
263
|
+
const ast = babelParse(source, {
|
|
264
|
+
plugins: structuredClone(PARSER_PLUGINS),
|
|
265
|
+
sourceType: 'unambiguous',
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const settings = dispatch('onConfigure', { options, settings: {} }).settings;
|
|
269
|
+
Object.freeze(settings);
|
|
270
|
+
const syntaxes = dispatch('onLoadSyntax', {
|
|
271
|
+
settings,
|
|
272
|
+
syntaxes: {},
|
|
273
|
+
}).syntaxes;
|
|
274
|
+
|
|
275
|
+
const moduleReport = new ModuleReport(
|
|
276
|
+
ast.loc.start.line,
|
|
277
|
+
ast.loc.end.line,
|
|
278
|
+
settings,
|
|
279
|
+
);
|
|
280
|
+
dispatch('onModuleStart', { ast, moduleReport, syntaxes, settings });
|
|
281
|
+
|
|
282
|
+
const scopeControl = new ModuleScopeControl(moduleReport);
|
|
283
|
+
new ASTWalker().traverse(
|
|
284
|
+
ast,
|
|
285
|
+
buildVisitor({ moduleReport, scopeControl, syntaxes, settings }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
for (const phase of [
|
|
289
|
+
'onModuleCalculate',
|
|
290
|
+
'onModuleAverage',
|
|
291
|
+
'onModulePostAverage',
|
|
292
|
+
'onModuleEnd',
|
|
293
|
+
]) {
|
|
294
|
+
dispatch(phase, { moduleReport, syntaxes, settings });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return moduleReport.finalize();
|
|
298
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import escomplex from 'typhonjs-escomplex';
|
|
3
2
|
import { install as installAstCompat } from './escomplex-ast-compat.js';
|
|
3
|
+
import { analyzeModule } from './escomplex-kernel.js';
|
|
4
4
|
import { transpileIfNeeded } from './transpile.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -45,7 +45,7 @@ const UNSCORABLE = 0;
|
|
|
45
45
|
*/
|
|
46
46
|
export function scoreSource(sourceCode) {
|
|
47
47
|
try {
|
|
48
|
-
const score =
|
|
48
|
+
const score = analyzeModule(sourceCode)?.maintainability;
|
|
49
49
|
return Number.isFinite(score)
|
|
50
50
|
? { score, unscorable: false, reason: null }
|
|
51
51
|
: unscorable(`kernel returned a non-finite index (${String(score)})`);
|
|
@@ -141,7 +141,7 @@ export function scoreFile(filePath) {
|
|
|
141
141
|
*/
|
|
142
142
|
export function calculateReport(sourceCode) {
|
|
143
143
|
try {
|
|
144
|
-
const result =
|
|
144
|
+
const result = analyzeModule(sourceCode);
|
|
145
145
|
const methods = (result.methods ?? []).map((m) => ({
|
|
146
146
|
name: m.name,
|
|
147
147
|
maintainability:
|
|
@@ -31,10 +31,6 @@ import {
|
|
|
31
31
|
renderStorySplitRules,
|
|
32
32
|
ticketsModePromptField,
|
|
33
33
|
} from '../templates/decomposer-prompts.js';
|
|
34
|
-
import {
|
|
35
|
-
renderAcceptanceSpecSystemPrompt,
|
|
36
|
-
renderTechSpecSystemPrompt,
|
|
37
|
-
} from '../templates/spec-author-prompts.js';
|
|
38
34
|
import { concurrentMap, FANOUT_CONCURRENCY } from '../util/concurrent-map.js';
|
|
39
35
|
import { buildComplexitySignals } from './complexity-gate.js';
|
|
40
36
|
import { findDependencyCandidates } from './dependency-candidates.js';
|
|
@@ -329,6 +325,17 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
|
329
325
|
title: 'Fill: short descriptive title',
|
|
330
326
|
body: {
|
|
331
327
|
goal: 'Fill: one sentence stating why this Story exists.',
|
|
328
|
+
// A filled, multi-checkpoint example rather than a placeholder
|
|
329
|
+
// (Story #5332): `## Slicing` is how one cohesive sweep stays one
|
|
330
|
+
// Story, so the skeleton has to show an author what a checkpoint
|
|
331
|
+
// list looks like. Each line is a stage of the work — a commit
|
|
332
|
+
// boundary inside one session — never a restatement of an
|
|
333
|
+
// acceptance item, which states what is true once the Story lands.
|
|
334
|
+
slicing:
|
|
335
|
+
'1. Re-anchor the shared constant and its consumers.\n' +
|
|
336
|
+
'2. Move the gate ahead of the first write and arm the refusal.\n' +
|
|
337
|
+
'3. Delete the superseded module, its test and its flag.\n' +
|
|
338
|
+
'4. Regenerate the affected baselines; run the full gate chain.',
|
|
332
339
|
spec:
|
|
333
340
|
'Optional — contract and invariants only: interfaces, status ' +
|
|
334
341
|
'codes, security invariants, and load-bearing constraints with ' +
|
|
@@ -340,7 +347,7 @@ export function renderStoriesTemplate({ complexitySignals = null } = {}) {
|
|
|
340
347
|
non_goals: [],
|
|
341
348
|
},
|
|
342
349
|
acceptance: [
|
|
343
|
-
'Fill: an outcome a PR reviewer can confirm from the diff and the verify output
|
|
350
|
+
'Fill: an outcome a PR reviewer can confirm from the diff and the verify output — as many as the capability has, no target and no ceiling',
|
|
344
351
|
],
|
|
345
352
|
verify: [
|
|
346
353
|
'Fill: exact command or test path — the mechanical check the acceptance item rests on',
|
|
@@ -367,25 +374,23 @@ function countEnumeratedItems(text) {
|
|
|
367
374
|
}
|
|
368
375
|
|
|
369
376
|
/**
|
|
370
|
-
* Delta-shaped change-request verbs —
|
|
371
|
-
*
|
|
372
|
-
* footprint stays inside Story width.
|
|
377
|
+
* Delta-shaped change-request verbs — a change request naming one of these
|
|
378
|
+
* stays a Story by default when the footprint stays inside Story width.
|
|
373
379
|
*/
|
|
374
380
|
const DELTA_VERB_RE =
|
|
375
381
|
/\b(fix(?:es)?|tweak(?:s)?|extend(?:s)?|update(?:s)?|adjust(?:s)?|rename(?:s)?|correct(?:s)?|patch(?:es)?|bug|regression|flaky)\b/i;
|
|
376
382
|
|
|
377
383
|
/**
|
|
378
|
-
* Deterministic, CLI-applied scope
|
|
379
|
-
* (#4496 fix 6). Embedding
|
|
380
|
-
*
|
|
381
|
-
* from the headless path; the attended path keeps the skill-based judgment.
|
|
384
|
+
* Deterministic, CLI-applied scope signal over a raw `--seed` text
|
|
385
|
+
* (#4496 fix 6). Embedding it in the `--seed` envelope keeps the headless
|
|
386
|
+
* path from needing a judgment pass of its own.
|
|
382
387
|
*
|
|
383
|
-
* The heuristics anchor to the
|
|
388
|
+
* The heuristics anchor to the granularity SSOT —
|
|
384
389
|
* `DELIVERABLE_GRANULARITY_GUIDANCE` in `ticket-validator-sizing.js` (one
|
|
385
390
|
* Story = one coherent capability slice; multiple independent capabilities =
|
|
386
|
-
* an Epic) — and to the
|
|
387
|
-
*
|
|
388
|
-
*
|
|
391
|
+
* an Epic) — and to the change-request delta rubric above. The verdict is
|
|
392
|
+
* **advisory**: being wrong in the `epic` direction is cheap, and
|
|
393
|
+
* `borderline` is a first-class output, not a forced call.
|
|
389
394
|
*
|
|
390
395
|
* @param {{ seedText?: string }} args
|
|
391
396
|
* @returns {{ verdict: 'epic'|'story'|'borderline', reasons: string[], advisory: true, appliedBy: 'cli' }}
|
|
@@ -634,12 +639,15 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
|
|
|
634
639
|
|
|
635
640
|
/**
|
|
636
641
|
* Render the authoring system prompts the collapsed pipeline's single
|
|
637
|
-
* authoring pass consumes
|
|
638
|
-
* `lib/templates/
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
642
|
+
* authoring pass consumes: the N=1 core from
|
|
643
|
+
* `lib/templates/decomposer-prompts.js`, with the schedule rules and the
|
|
644
|
+
* collision refusal a planner reads only when the default-single split policy
|
|
645
|
+
* clears carried separately as `storySplitRules` (Story #5312).
|
|
646
|
+
*
|
|
647
|
+
* Story #5332 deleted the `spec` / `acceptance` fields with the module that
|
|
648
|
+
* rendered them: nothing read either, and both contradicted the current
|
|
649
|
+
* contract — one asserting Spec budgets that no longer exist, the other
|
|
650
|
+
* demanding the verify tier suffixes tickets mode now strips.
|
|
643
651
|
*
|
|
644
652
|
* `storyTicketsRules` is the one mode-conditional field (Story #5323): it
|
|
645
653
|
* only means anything when the seed is an existing ticket, and an envelope
|
|
@@ -647,12 +655,10 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
|
|
|
647
655
|
* ticket that a `--seed` run does not have.
|
|
648
656
|
*
|
|
649
657
|
* @param {{ mode?: string }} [args]
|
|
650
|
-
* @returns {{
|
|
658
|
+
* @returns {{ story: string, storySplitRules: string, storyTicketsRules?: string }}
|
|
651
659
|
*/
|
|
652
660
|
export function buildSystemPrompts({ mode } = {}) {
|
|
653
661
|
return {
|
|
654
|
-
spec: renderTechSpecSystemPrompt(),
|
|
655
|
-
acceptance: renderAcceptanceSpecSystemPrompt(),
|
|
656
662
|
story: renderStoryAuthorCore(),
|
|
657
663
|
storySplitRules: renderStorySplitRules(),
|
|
658
664
|
...ticketsModePromptField(mode),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 1. `changes[]` repair + ticket validator + file-assumption + DAG
|
|
9
9
|
* 2. Draft reachability (named soft failure, exit 3)
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. Same-wave collision refusal (`assertNoWaveCollisions`) + spec fold
|
|
11
11
|
* 4. Create Story issues (`type::story` + sanitized authored labels —
|
|
12
12
|
* deliberately NOT `agent::ready`), resumably via a plan fingerprint
|
|
13
13
|
* 5. Upsert `story-plan-state` on every created Story; upsert `plan-summary`
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
PLAN_SUMMARY_COMMENT_TYPE,
|
|
82
82
|
} from './summary.js';
|
|
83
83
|
import { closeSupersededTickets } from './supersede-ops.js';
|
|
84
|
-
import {
|
|
84
|
+
import { assertNoWaveCollisions } from './wave-collision-gate.js';
|
|
85
85
|
|
|
86
86
|
/** Checkpoint schema version written on each Story's story-plan-state. */
|
|
87
87
|
const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
|
|
@@ -543,7 +543,6 @@ function logPersistEpilogue({
|
|
|
543
543
|
* artifacts: {
|
|
544
544
|
* stories: Array<object>,
|
|
545
545
|
* techSpecContent?: string|null,
|
|
546
|
-
* planAcceptance?: string[]|null,
|
|
547
546
|
* planContextEnvelope?: object|null,
|
|
548
547
|
* },
|
|
549
548
|
* config?: object,
|
|
@@ -569,7 +568,6 @@ export async function runPlanPersist({
|
|
|
569
568
|
const {
|
|
570
569
|
stories: rawStories = null,
|
|
571
570
|
techSpecContent = null,
|
|
572
|
-
planAcceptance = null,
|
|
573
571
|
planContextEnvelope = null,
|
|
574
572
|
} = artifacts ?? {};
|
|
575
573
|
const {
|
|
@@ -623,11 +621,10 @@ export async function runPlanPersist({
|
|
|
623
621
|
epicId: opts.adoptEpicId ?? null,
|
|
624
622
|
});
|
|
625
623
|
|
|
626
|
-
//
|
|
624
|
+
// Inline Spec fold (Specs stay inline, never under docs/).
|
|
627
625
|
const seedContent = planContextEnvelope?.seed?.content ?? '';
|
|
628
626
|
const { stories: assembled } = assemblePlanStories(rawStories, {
|
|
629
627
|
sharedSpec: techSpecContent,
|
|
630
|
-
planAcceptance: planAcceptance ?? undefined,
|
|
631
628
|
sourceTicketIds,
|
|
632
629
|
// The seed this plan was authored from: an audit sweep's Single-plan seed
|
|
633
630
|
// carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
|
|
@@ -654,6 +651,31 @@ export async function runPlanPersist({
|
|
|
654
651
|
rawFindings: validated.findings,
|
|
655
652
|
});
|
|
656
653
|
|
|
654
|
+
// Story #5332 — the split gate, ahead of the first create. `buildWaveTable`
|
|
655
|
+
// needs only `{slug, title, depends_on}` and the prediction needs only the
|
|
656
|
+
// assembled bodies, so both can run before anything is written; they used
|
|
657
|
+
// to sit *after* creation, which is why the collision table could only ever
|
|
658
|
+
// be a receipt for a plan already live. The same computed value is handed
|
|
659
|
+
// to the summary rendering below rather than recomputed, so the refusal and
|
|
660
|
+
// the receipt can never disagree.
|
|
661
|
+
const waveTable = buildWaveTable(
|
|
662
|
+
stories.map((s) => ({
|
|
663
|
+
slug: s.slug,
|
|
664
|
+
title: s.title,
|
|
665
|
+
depends_on: s.depends_on,
|
|
666
|
+
})),
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
// Story #5265: the table says which Stories share an order; the dispatcher
|
|
670
|
+
// decides which of those actually run together. Run its own predicate over
|
|
671
|
+
// the assembled bodies — the exact artifact the tick will read back off
|
|
672
|
+
// GitHub — so an N>1 draft it would serialize is refused here, and the
|
|
673
|
+
// summary comment names the serialisation instead of promising parallelism
|
|
674
|
+
// the next tick refuses. One enumeration feeds both.
|
|
675
|
+
const waveCollisions = assertNoWaveCollisions(waveTable, stories, {
|
|
676
|
+
tempRoot: getPaths(config).tempRoot,
|
|
677
|
+
});
|
|
678
|
+
|
|
657
679
|
const { created, planRunLabel, planRunLabelApplied } =
|
|
658
680
|
await createStoryIssues({
|
|
659
681
|
provider,
|
|
@@ -666,24 +688,6 @@ export async function runPlanPersist({
|
|
|
666
688
|
recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
|
|
667
689
|
|
|
668
690
|
const primary = created[0];
|
|
669
|
-
const waveTable = buildWaveTable(
|
|
670
|
-
stories.map((s) => ({
|
|
671
|
-
slug: s.slug,
|
|
672
|
-
title: s.title,
|
|
673
|
-
depends_on: s.depends_on,
|
|
674
|
-
})),
|
|
675
|
-
);
|
|
676
|
-
|
|
677
|
-
// Story #5265: the table says which Stories share an order; the dispatcher
|
|
678
|
-
// decides which of those actually run together, and it decides on the
|
|
679
|
-
// evidence-widened footprint. Run its own predicate over the assembled
|
|
680
|
-
// bodies — the exact artifact the tick will read back off GitHub — so the
|
|
681
|
-
// comment names the serialisation instead of promising parallelism the
|
|
682
|
-
// next tick refuses. `tempRoot` is threaded for the same reason the tick
|
|
683
|
-
// threads it: the scrape must ignore this project's scratch root.
|
|
684
|
-
const waveCollisions = predictWaveSerialisation(waveTable, stories, {
|
|
685
|
-
tempRoot: getPaths(config).tempRoot,
|
|
686
|
-
});
|
|
687
691
|
|
|
688
692
|
// Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
|
|
689
693
|
// called with `config` first, so the ledger path resolver received the
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Under the Story collapse (`docs/roadmap.md` § Stage 3), `/mandrel-plan` persists
|
|
5
5
|
* zero-or-more Story issues directly — no Epic parent, no reconciler tree,
|
|
6
6
|
* no `deliveryShape` mode matrix. Default is **one Story**; N>1 is gated by
|
|
7
|
-
* the
|
|
7
|
+
* the supersede partition check.
|
|
8
8
|
*
|
|
9
9
|
* Each Story body is the single executable document: Tech Spec stays inline
|
|
10
10
|
* under `## Spec`, at whatever length the work needs (Story #5312 deleted
|
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
concurrentMap,
|
|
35
35
|
FANOUT_CONCURRENCY,
|
|
36
36
|
} from '../../util/concurrent-map.js';
|
|
37
|
-
import { assertAcceptancePartition } from '../split-policy-validator.js';
|
|
38
37
|
import {
|
|
39
38
|
externalDependencyId,
|
|
40
39
|
isExternalDependencyRef,
|
|
@@ -514,15 +513,18 @@ function assertSharedSpecAllowed(tickets, sharedSpec) {
|
|
|
514
513
|
|
|
515
514
|
/**
|
|
516
515
|
* Assemble markdown bodies for every Story: normalize → fold spec →
|
|
517
|
-
*
|
|
516
|
+
* assertSupersedePartition → serialize.
|
|
518
517
|
*
|
|
519
|
-
*
|
|
520
|
-
* plan never leaves Stories live against an inconsistent tracker.
|
|
518
|
+
* The partition check runs **before** any GitHub write so a mis-authored
|
|
519
|
+
* plan never leaves Stories live against an inconsistent tracker. Story #5332
|
|
520
|
+
* retired the acceptance partition that used to run beside it: it refused
|
|
521
|
+
* only byte-identical acceptance text across siblings, and the split gate is
|
|
522
|
+
* now `assertNoWaveCollisions` in `run-plan-persist.js`, ahead of the first
|
|
523
|
+
* create.
|
|
521
524
|
*
|
|
522
525
|
* @param {object[]} tickets
|
|
523
526
|
* @param {object} [opts]
|
|
524
527
|
* @param {string|null} [opts.sharedSpec]
|
|
525
|
-
* @param {string[]} [opts.planAcceptance]
|
|
526
528
|
* @param {number[]} [opts.sourceTicketIds] Ids passed to `/mandrel-plan --tickets`.
|
|
527
529
|
* @returns {{ stories: Array<{ slug: string, title: string, body: string, acceptance: string[], depends_on: string[], supersedes: Array<{ id: number, note: string|null }> }> }}
|
|
528
530
|
*/
|
|
@@ -539,9 +541,6 @@ export function assemblePlanStories(tickets, opts = {}) {
|
|
|
539
541
|
(ticket) => assembleOnePlanStory(ticket, opts).story,
|
|
540
542
|
);
|
|
541
543
|
|
|
542
|
-
assertAcceptancePartition(stories, {
|
|
543
|
-
planAcceptance: opts.planAcceptance,
|
|
544
|
-
});
|
|
545
544
|
assertSupersedePartition(stories, opts.sourceTicketIds ?? []);
|
|
546
545
|
|
|
547
546
|
return { stories };
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Two halves, deliberately separated by the `createIssue` boundary:
|
|
17
17
|
*
|
|
18
18
|
* 1. **`assertSupersedePartition`** — a plan-time, fail-closed check that
|
|
19
|
-
* runs *before* any GitHub write.
|
|
19
|
+
* runs *before* any GitHub write. Same fail-closed shape as the
|
|
20
20
|
* every id passed to `--tickets` must be claimed by exactly one Story,
|
|
21
21
|
* and no Story may claim an id that was not a source ticket. A partial
|
|
22
22
|
* supersede map is a planning error, not something to paper over at
|