mandrel 2.9.0 → 2.11.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/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -25
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +36 -9
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +10 -6
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -16
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +533 -93
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -3
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +153 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +221 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +12 -1
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +84 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/resolve-stories.js +2 -0
- package/.agents/scripts/single-story-confirm-merge.js +65 -5
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/workflows/deliver.md +88 -230
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-reference.md +171 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +223 -0
- package/.agents/workflows/helpers/deliver-story.md +115 -432
- package/.agents/workflows/helpers/plan-reference.md +246 -0
- package/.agents/workflows/plan.md +108 -304
- package/docs/CHANGELOG.md +46 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
- package/package.json +1 -1
|
@@ -13,10 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
evaluateLensDiffFloor,
|
|
17
|
+
resolveLensDiffFloor,
|
|
16
18
|
runAuditSuite,
|
|
17
19
|
selectLocalLenses,
|
|
18
20
|
} from '../../../audit-suite/index.js';
|
|
21
|
+
import { resolveConfig } from '../../../config-resolver.js';
|
|
19
22
|
import { gitSpawn } from '../../../git-utils.js';
|
|
23
|
+
import {
|
|
24
|
+
emitRuntimeFriction,
|
|
25
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
26
|
+
} from '../../../observability/runtime-friction.js';
|
|
20
27
|
import { computeChangeSet } from '../../change-set.js';
|
|
21
28
|
|
|
22
29
|
/**
|
|
@@ -180,10 +187,21 @@ function resolveLensChangeSet({
|
|
|
180
187
|
* this the default review provider dropped the materialized envelope, so the
|
|
181
188
|
* pass was a progress log line with no reader.
|
|
182
189
|
*
|
|
190
|
+
* Story #4699 — the **lens diff-floor**. When the caller supplies a known
|
|
191
|
+
* `changedLineCount` and the diff sits strictly below the configured floor
|
|
192
|
+
* (`delivery.review.lensDiffFloor`, default 40) with zero sensitive-path
|
|
193
|
+
* hits, the pass records the matched roster but skips materialization
|
|
194
|
+
* entirely (`skipped: true` with the lenses retained and a `floorSkip`
|
|
195
|
+
* verdict) — the maker-blind code-review pillar and every hard gate are
|
|
196
|
+
* untouched. An unknown line count, a disabled floor, or a sensitive-path
|
|
197
|
+
* hit all fail open to the full materialization.
|
|
198
|
+
*
|
|
183
199
|
* @param {{
|
|
184
200
|
* baseRef: string,
|
|
185
201
|
* headRef: string,
|
|
186
202
|
* changedFiles?: string[]|null,
|
|
203
|
+
* changedLineCount?: number|null,
|
|
204
|
+
* lensDiffFloor?: number,
|
|
187
205
|
* storyId?: number|string|null,
|
|
188
206
|
* artifactPrefix?: string,
|
|
189
207
|
* progress: (tag: string, msg: string) => void,
|
|
@@ -191,11 +209,15 @@ function resolveLensChangeSet({
|
|
|
191
209
|
* gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
|
|
192
210
|
* selectLocalLensesFn?: typeof selectLocalLenses,
|
|
193
211
|
* runAuditSuiteFn?: typeof runAuditSuite,
|
|
212
|
+
* resolveConfigFn?: typeof resolveConfig,
|
|
213
|
+
* evaluateLensDiffFloorFn?: typeof evaluateLensDiffFloor,
|
|
214
|
+
* emitToolDegradationFn?: typeof emitRuntimeFriction,
|
|
194
215
|
* }} args
|
|
195
216
|
* @returns {Promise<{
|
|
196
217
|
* depth: 'light',
|
|
197
218
|
* lenses: string[],
|
|
198
219
|
* skipped: boolean,
|
|
220
|
+
* floorSkip: object|null,
|
|
199
221
|
* materialized: object|null,
|
|
200
222
|
* artifactPaths: string[],
|
|
201
223
|
* }>}
|
|
@@ -204,6 +226,8 @@ export async function runLocalLensReview({
|
|
|
204
226
|
baseRef,
|
|
205
227
|
headRef,
|
|
206
228
|
changedFiles: injectedChangedFiles,
|
|
229
|
+
changedLineCount = null,
|
|
230
|
+
lensDiffFloor,
|
|
207
231
|
storyId,
|
|
208
232
|
artifactPrefix,
|
|
209
233
|
progress,
|
|
@@ -211,11 +235,15 @@ export async function runLocalLensReview({
|
|
|
211
235
|
gitSpawnFn = gitSpawn,
|
|
212
236
|
selectLocalLensesFn = selectLocalLenses,
|
|
213
237
|
runAuditSuiteFn = runAuditSuite,
|
|
238
|
+
resolveConfigFn = resolveConfig,
|
|
239
|
+
evaluateLensDiffFloorFn = evaluateLensDiffFloor,
|
|
240
|
+
emitToolDegradationFn = emitRuntimeFriction,
|
|
214
241
|
}) {
|
|
215
242
|
const empty = {
|
|
216
243
|
depth: STORY_SCOPE_LENS_DEPTH,
|
|
217
244
|
lenses: [],
|
|
218
245
|
skipped: true,
|
|
246
|
+
floorSkip: null,
|
|
219
247
|
materialized: null,
|
|
220
248
|
artifactPaths: [],
|
|
221
249
|
};
|
|
@@ -234,6 +262,34 @@ export async function runLocalLensReview({
|
|
|
234
262
|
);
|
|
235
263
|
return empty;
|
|
236
264
|
}
|
|
265
|
+
|
|
266
|
+
// Lens diff-floor (Story #4699). Deliberately evaluated AFTER lens
|
|
267
|
+
// selection so a floor-skip still records WHICH lenses it skipped —
|
|
268
|
+
// the findings-yield ledger needs the roster either way.
|
|
269
|
+
const floorVerdict = evaluateLensDiffFloorFn({
|
|
270
|
+
changedFiles,
|
|
271
|
+
changedLineCount,
|
|
272
|
+
floor:
|
|
273
|
+
typeof lensDiffFloor === 'number'
|
|
274
|
+
? lensDiffFloor
|
|
275
|
+
: resolveLensDiffFloor(safeResolveConfig(resolveConfigFn)),
|
|
276
|
+
});
|
|
277
|
+
if (floorVerdict.skip) {
|
|
278
|
+
progress(
|
|
279
|
+
progressTag,
|
|
280
|
+
`Lens diff-floor: ${floorVerdict.changedLineCount} changed line(s) < ` +
|
|
281
|
+
`floor ${floorVerdict.floor} with zero sensitive-path hits — ` +
|
|
282
|
+
`skipping materialization of ${lenses.join(', ')}.`,
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
depth: STORY_SCOPE_LENS_DEPTH,
|
|
286
|
+
lenses,
|
|
287
|
+
skipped: true,
|
|
288
|
+
floorSkip: floorVerdict,
|
|
289
|
+
materialized: null,
|
|
290
|
+
artifactPaths: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
237
293
|
// Scope the artifact filenames to this Story so concurrent closes on a
|
|
238
294
|
// shared audit output dir cannot clobber each other's prompts.
|
|
239
295
|
const effectivePrefix =
|
|
@@ -256,16 +312,48 @@ export async function runLocalLensReview({
|
|
|
256
312
|
depth: STORY_SCOPE_LENS_DEPTH,
|
|
257
313
|
lenses,
|
|
258
314
|
skipped: false,
|
|
315
|
+
floorSkip: floorVerdict,
|
|
259
316
|
materialized,
|
|
260
317
|
artifactPaths,
|
|
261
318
|
};
|
|
262
319
|
} catch (err) {
|
|
263
320
|
// The lens pass is advisory: a git or materialization failure must not
|
|
264
|
-
// fail the close. Log
|
|
321
|
+
// fail the close. Log, route the tool-execution degradation to friction
|
|
322
|
+
// telemetry (Story #4699 — degradations are operational signals, not
|
|
323
|
+
// findings), and degrade to a skipped envelope.
|
|
265
324
|
progress(
|
|
266
325
|
progressTag,
|
|
267
326
|
`⚠️ local lens pass failed (continuing without it): ${err?.message ?? err}`,
|
|
268
327
|
);
|
|
328
|
+
try {
|
|
329
|
+
await emitToolDegradationFn({
|
|
330
|
+
storyId,
|
|
331
|
+
category: RUNTIME_FRICTION_CATEGORIES.TOOL_DEGRADED,
|
|
332
|
+
tool: 'local-lens-review',
|
|
333
|
+
details: {
|
|
334
|
+
surface: 'lens-materialization',
|
|
335
|
+
reason: String(err?.message ?? err).slice(0, 500),
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
} catch {
|
|
339
|
+
// Observability must never fail the close (best-effort contract).
|
|
340
|
+
}
|
|
269
341
|
return empty;
|
|
270
342
|
}
|
|
271
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolve config for the floor read without letting a resolver failure
|
|
347
|
+
* fail the (advisory) lens pass. Module-local: a degraded config simply
|
|
348
|
+
* yields the framework-default floor.
|
|
349
|
+
*
|
|
350
|
+
* @param {typeof resolveConfig} resolveConfigFn
|
|
351
|
+
* @returns {object|undefined}
|
|
352
|
+
*/
|
|
353
|
+
function safeResolveConfig(resolveConfigFn) {
|
|
354
|
+
try {
|
|
355
|
+
return resolveConfigFn();
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* file (Story #3653 established the shared-spine contract).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { countChangedLines } from '../../../audit-suite/index.js';
|
|
12
13
|
import { gitSpawn } from '../../../git-utils.js';
|
|
14
|
+
import { appendFindingsYield } from '../../../observability/metrics-ledger.js';
|
|
13
15
|
import { computeChangeSet } from '../../change-set.js';
|
|
14
16
|
import { runCodeReview } from '../../code-review.js';
|
|
15
17
|
import { runLocalLensReview } from './local-lens-review.js';
|
|
@@ -57,6 +59,8 @@ import { runLocalLensReview } from './local-lens-review.js';
|
|
|
57
59
|
* computeChangeSetFn?: typeof computeChangeSet,
|
|
58
60
|
* runCodeReviewFn?: typeof runCodeReview,
|
|
59
61
|
* runLocalLensReviewFn?: typeof runLocalLensReview,
|
|
62
|
+
* countChangedLinesFn?: typeof countChangedLines,
|
|
63
|
+
* appendFindingsYieldFn?: typeof appendFindingsYield,
|
|
60
64
|
* }} args
|
|
61
65
|
* @returns {Promise<object>} Raw result envelope from `runCodeReview`, augmented
|
|
62
66
|
* with a `localLensReview` field carrying the Story-scope local-lens pass
|
|
@@ -75,6 +79,8 @@ export async function runStoryReviewCore({
|
|
|
75
79
|
computeChangeSetFn = computeChangeSet,
|
|
76
80
|
runCodeReviewFn = runCodeReview,
|
|
77
81
|
runLocalLensReviewFn = runLocalLensReview,
|
|
82
|
+
countChangedLinesFn = countChangedLines,
|
|
83
|
+
appendFindingsYieldFn = appendFindingsYield,
|
|
78
84
|
}) {
|
|
79
85
|
const storyIdNum = Number(storyId);
|
|
80
86
|
|
|
@@ -84,6 +90,16 @@ export async function runStoryReviewCore({
|
|
|
84
90
|
// honour without retrying (Story #4603).
|
|
85
91
|
const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
|
|
86
92
|
|
|
93
|
+
// The one changed-LINE enumeration (Story #4699 — the lens diff-floor's
|
|
94
|
+
// size signal). Probed only when the file enumeration succeeded with a
|
|
95
|
+
// non-empty set: a null/empty set already yields an empty lens roster, so
|
|
96
|
+
// a second git spawn would buy nothing. `null` = count unknown → the
|
|
97
|
+
// floor fails open (no skip).
|
|
98
|
+
const changedLineCount =
|
|
99
|
+
Array.isArray(changeSet.files) && changeSet.files.length > 0
|
|
100
|
+
? countChangedLinesFn({ baseRef, headRef, gitSpawnFn })
|
|
101
|
+
: null;
|
|
102
|
+
|
|
87
103
|
const opts = {
|
|
88
104
|
scope: 'story',
|
|
89
105
|
ticketId: storyIdNum,
|
|
@@ -110,6 +126,7 @@ export async function runStoryReviewCore({
|
|
|
110
126
|
baseRef,
|
|
111
127
|
headRef,
|
|
112
128
|
changedFiles: changeSet.files,
|
|
129
|
+
changedLineCount,
|
|
113
130
|
storyId: storyIdNum,
|
|
114
131
|
progress,
|
|
115
132
|
progressTag,
|
|
@@ -117,5 +134,61 @@ export async function runStoryReviewCore({
|
|
|
117
134
|
});
|
|
118
135
|
|
|
119
136
|
const result = await runCodeReviewFn(opts);
|
|
137
|
+
|
|
138
|
+
// Findings-yield ledger (Story #4699) — record what this close's lens
|
|
139
|
+
// pass produced (or floor-skipped) so the roster can later be tuned on
|
|
140
|
+
// measurement. Best-effort: a ledger failure never fails the review.
|
|
141
|
+
try {
|
|
142
|
+
const yieldEntries = buildLensYieldEntries(localLensReview);
|
|
143
|
+
if (yieldEntries !== null) {
|
|
144
|
+
await appendFindingsYieldFn({
|
|
145
|
+
storyId: storyIdNum,
|
|
146
|
+
cli: 'story-close-review',
|
|
147
|
+
lenses: yieldEntries,
|
|
148
|
+
diffFloor: localLensReview?.floorSkip ?? null,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
progress(
|
|
153
|
+
progressTag,
|
|
154
|
+
`⚠️ findings-yield ledger append failed (continuing): ${err?.message ?? err}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
return { ...result, localLensReview, changeSet };
|
|
121
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fold the lens-pass envelope into per-lens findings-yield entries
|
|
163
|
+
* (Story #4699). One entry per lens in the matched roster: the lens name,
|
|
164
|
+
* the count of materialization findings attributed to it, and whether the
|
|
165
|
+
* diff-floor skipped its materialization. Returns `null` when the roster is
|
|
166
|
+
* empty (nothing ran, nothing skipped — no record to write).
|
|
167
|
+
*
|
|
168
|
+
* Module-local: an implementation detail of {@link runStoryReviewCore},
|
|
169
|
+
* asserted through the appended record's shape rather than imported
|
|
170
|
+
* directly.
|
|
171
|
+
*
|
|
172
|
+
* @param {object|null|undefined} localLensReview
|
|
173
|
+
* @returns {Array<{ lens: string, findings: number, skippedByFloor: boolean }>|null}
|
|
174
|
+
*/
|
|
175
|
+
function buildLensYieldEntries(localLensReview) {
|
|
176
|
+
const lenses = Array.isArray(localLensReview?.lenses)
|
|
177
|
+
? localLensReview.lenses.filter((l) => typeof l === 'string' && l.length)
|
|
178
|
+
: [];
|
|
179
|
+
if (lenses.length === 0) return null;
|
|
180
|
+
const skippedByFloor = localLensReview?.floorSkip?.skip === true;
|
|
181
|
+
const findingsByLens = new Map();
|
|
182
|
+
for (const finding of localLensReview?.materialized?.findings ?? []) {
|
|
183
|
+
if (typeof finding?.audit !== 'string') continue;
|
|
184
|
+
findingsByLens.set(
|
|
185
|
+
finding.audit,
|
|
186
|
+
(findingsByLens.get(finding.audit) ?? 0) + 1,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return lenses.map((lens) => ({
|
|
190
|
+
lens,
|
|
191
|
+
findings: skippedByFloor ? 0 : (findingsByLens.get(lens) ?? 0),
|
|
192
|
+
skippedByFloor,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
@@ -829,6 +829,12 @@ export function renderHardConflictError(finding) {
|
|
|
829
829
|
if (finding.kind === 'missing-bdd-scaffold') {
|
|
830
830
|
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.`;
|
|
831
831
|
}
|
|
832
|
+
// Findings from other passes (sizing, spec-word-budget) carry their own
|
|
833
|
+
// message — render it rather than a shape-blind generic line, so the soft
|
|
834
|
+
// surface (`surfaceSoftConflictFindings`) stays legible for every kind.
|
|
835
|
+
if (typeof finding.message === 'string' && finding.message.length > 0) {
|
|
836
|
+
return finding.message;
|
|
837
|
+
}
|
|
832
838
|
return `Conflict finding ${finding.kind} on path "${finding.path ?? '<unknown>'}".`;
|
|
833
839
|
}
|
|
834
840
|
|
|
@@ -3,11 +3,12 @@ import { detectCycle } from '../Graph.js';
|
|
|
3
3
|
import { gitSpawn } from '../git-utils.js';
|
|
4
4
|
|
|
5
5
|
import { Logger } from '../Logger.js';
|
|
6
|
-
import {
|
|
7
|
-
parse as parseStoryBody,
|
|
8
|
-
StoryBodyParseError,
|
|
9
|
-
} from '../story-body/story-body.js';
|
|
10
6
|
import { validateStoryFileAssumptions } from './file-assumptions.js';
|
|
7
|
+
import { computeSpecBudgetFindings } from './spec-budget.js';
|
|
8
|
+
import {
|
|
9
|
+
assertStoryBodiesParse,
|
|
10
|
+
parseStoryBodyOrThrow,
|
|
11
|
+
} from './story-body-gate.js';
|
|
11
12
|
import {
|
|
12
13
|
computeConflictFindings,
|
|
13
14
|
renderHardConflictError,
|
|
@@ -46,63 +47,6 @@ function collectPathsFromText(text, paths) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
* Parse a Story's serialized markdown body, translating a
|
|
51
|
-
* `StoryBodyParseError` into a `ValidationError` that names the offending
|
|
52
|
-
* **section** and **entry** (Story #4541).
|
|
53
|
-
*
|
|
54
|
-
* `StoryBodyParseError` already carries `field` (the section the parser was
|
|
55
|
-
* reading) and `raw` (the entry text that failed); this lifts both into an
|
|
56
|
-
* operator-legible message and a structured `violation` payload so an
|
|
57
|
-
* authoring loop can point at the exact bullet instead of re-deriving it
|
|
58
|
-
* from a downstream freshness miss.
|
|
59
|
-
*
|
|
60
|
-
* @param {object} story Story whose `body` is a non-empty markdown string.
|
|
61
|
-
* @returns {object} The structured body.
|
|
62
|
-
* @throws {ValidationError} `code: 'story-body-unparseable'`.
|
|
63
|
-
*/
|
|
64
|
-
function parseStoryBodyOrThrow(story) {
|
|
65
|
-
try {
|
|
66
|
-
return parseStoryBody(story.body).body;
|
|
67
|
-
} catch (err) {
|
|
68
|
-
if (!(err instanceof StoryBodyParseError)) throw err;
|
|
69
|
-
const slug = story.slug ?? '<unknown>';
|
|
70
|
-
const section = err.field ?? 'body';
|
|
71
|
-
const entry = err.raw ?? null;
|
|
72
|
-
const entryLine = entry === null ? '' : `\n entry: ${entry}`;
|
|
73
|
-
const violation = { slug, section, entry, reason: err.message };
|
|
74
|
-
const error = new ValidationError(
|
|
75
|
-
`Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
|
|
76
|
-
`the ## ${section} section could not be read: ${err.message}` +
|
|
77
|
-
`${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
|
|
78
|
-
'not a stale path reference.',
|
|
79
|
-
{ violations: [violation] },
|
|
80
|
-
);
|
|
81
|
-
error.code = 'story-body-unparseable';
|
|
82
|
-
error.violations = [violation];
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Refuse the plan when any Story's serialized body cannot be parsed, before
|
|
89
|
-
* either git-probe gate runs (Story #4541). Ordering matters: the freshness
|
|
90
|
-
* gate consults `body.changes` for its net-new whitelist, so an unparseable
|
|
91
|
-
* body used to reach the operator as a freshness miss naming declared paths.
|
|
92
|
-
*
|
|
93
|
-
* @param {{ tickets: object[] }} opts
|
|
94
|
-
* @throws {ValidationError} `code: 'story-body-unparseable'` on the first
|
|
95
|
-
* offending Story.
|
|
96
|
-
*/
|
|
97
|
-
function assertStoryBodiesParse({ tickets }) {
|
|
98
|
-
for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
|
|
99
|
-
if (typeof story.body !== 'string' || story.body.trim().length === 0) {
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
parseStoryBodyOrThrow(story);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
50
|
/**
|
|
107
51
|
* Resolve every acceptance line a Story declares, across both authoring
|
|
108
52
|
* shapes (Story #4541).
|
|
@@ -721,7 +665,19 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
721
665
|
stories,
|
|
722
666
|
policy: opts.conflictPolicy,
|
|
723
667
|
});
|
|
724
|
-
|
|
668
|
+
// Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
|
|
669
|
+
// surfaced as warnings here and via the persist soft-finding channel;
|
|
670
|
+
// never promoted to `errors[]`, so an over-budget Spec cannot fail the
|
|
671
|
+
// persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
|
|
672
|
+
const specBudgetFindings = computeSpecBudgetFindings({ stories });
|
|
673
|
+
for (const finding of specBudgetFindings) {
|
|
674
|
+
Logger.warn(`[ticket-validator] spec-word-budget: ${finding.message}`);
|
|
675
|
+
}
|
|
676
|
+
const findings = [
|
|
677
|
+
...sizingFindings,
|
|
678
|
+
...conflictFindings,
|
|
679
|
+
...specBudgetFindings,
|
|
680
|
+
];
|
|
725
681
|
const CONFLICT_KINDS = new Set([
|
|
726
682
|
'shared-editor',
|
|
727
683
|
'implicit-cross-story-dep',
|
|
@@ -109,7 +109,7 @@ You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown
|
|
|
109
109
|
**Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
|
|
110
110
|
|
|
111
111
|
### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
|
|
112
|
-
\`body\`
|
|
112
|
+
\`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\` / \`wide\` / \`reason_to_exist\` / \`estimated_test_files\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
|
|
113
113
|
|
|
114
114
|
The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
|
|
115
115
|
|
|
@@ -138,10 +138,6 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
138
138
|
- <exact command or test path> (<tier>)
|
|
139
139
|
- ...
|
|
140
140
|
|
|
141
|
-
## References
|
|
142
|
-
- {"path": "<read-only dependency path>", "assumption": "exists"}
|
|
143
|
-
- ...
|
|
144
|
-
|
|
145
141
|
## Non-Goals
|
|
146
142
|
- <a capability or change this Story explicitly does NOT deliver>
|
|
147
143
|
- ...
|
|
@@ -149,7 +145,7 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
149
145
|
#### STORY BODY RULES:
|
|
150
146
|
|
|
151
147
|
- **goal** (in body string): One sentence stating WHY this Story exists.
|
|
152
|
-
- **spec** (optional, in body string as \`## Spec\`): Lean technical approach only. If the Spec is large enough to feel like its own document, the Story is probably too big — split it. Persist keeps Specs inline and rejects over-budget Specs (never writes them under \`docs/\`).
|
|
148
|
+
- **spec** (optional, in body string as \`## Spec\`): Lean technical approach only, at the altitude the SPEC PROSE CONTRACT below fixes — contract and invariants, never implementation narration. If the Spec is large enough to feel like its own document, the Story is probably too big — split it. Persist keeps Specs inline and rejects over-budget Specs (never writes them under \`docs/\`).
|
|
153
149
|
- **slicing** (optional): Ordered intra-session checkpoints for one Story. Not a fan-out table and not a duplicate of Acceptance.
|
|
154
150
|
- **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
|
|
155
151
|
- **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
|
|
@@ -162,6 +158,17 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
162
158
|
- **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
|
|
163
159
|
- **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
|
|
164
160
|
|
|
161
|
+
#### SPEC PROSE CONTRACT — state the contract, not the implementation:
|
|
162
|
+
|
|
163
|
+
The Story is executed by a frontier-model deliverer that reads the codebase itself. Author \`## Spec\` (and Goal prose) at contract level:
|
|
164
|
+
|
|
165
|
+
- **Spec states the contract and invariants**: interfaces, status codes, security invariants, and load-bearing constraints — each with its why. That is the whole job of the Spec.
|
|
166
|
+
- **Implementation choices belong to the deliverer** unless a choice is load-bearing; a load-bearing choice is stated as a constraint (with why it binds), never as a walkthrough of how to code it.
|
|
167
|
+
- **No per-file behavior paragraphs.** The \`## Changes\` list already names the footprint; do not narrate what each file will do.
|
|
168
|
+
- **No current-state narration.** Do not describe how the codebase works today as scene-setting; the deliverer reads the code. The only current-state prose allowed is a claim a decision depends on, opening with \`Current state (verified <date>)\` per the observed-behavior rule above.
|
|
169
|
+
- **Do not author a \`## References\` section.** Read-only context the deliverer needs is discoverable from the contract and the footprint.
|
|
170
|
+
- **Acceptance criteria remain the binding contract** — the Spec constrains and explains; \`acceptance[]\` binds.
|
|
171
|
+
|
|
165
172
|
#### DETERMINISTIC BODY-FORMAT LINTS — author lint-clean by construction:
|
|
166
173
|
|
|
167
174
|
Persist enforces the deterministic body-format rules below and **rejects** an authored body that violates any of them. Author every Story to satisfy all of them on the FIRST draft — each rule is stated example-first so there is nothing to discover by trial-and-error. The two auto-fixable rules (\`changes-path-entry-shape\`, \`verify-tier-suffix\`) also emit the corrected form in the dry-run failure output, but authoring them right up front avoids the round-trip entirely.
|
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { TEST_TEMP_ROOT_ENV } from './config/temp-paths.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-process memo for the created scratch dir, so repeated calls in one
|
|
9
|
+
* runner process (e.g. building env bags for several chunks) share a single
|
|
10
|
+
* scratch tree instead of minting one dir per call.
|
|
11
|
+
*/
|
|
12
|
+
let _createdScratchDir = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test-only: clear the per-process scratch memo so a suite can exercise the
|
|
16
|
+
* creation branch repeatedly in one process.
|
|
17
|
+
*/
|
|
18
|
+
export function _clearTestScratchTempRootCache() {
|
|
19
|
+
_createdScratchDir = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensure an absolute per-process scratch tempRoot is available for the test
|
|
24
|
+
* run and return it (Story #4696).
|
|
25
|
+
*
|
|
26
|
+
* If `baseEnv` already carries an absolute `MANDREL_TEST_TEMP_ROOT` (the
|
|
27
|
+
* common case for a child process that inherits the parent runner's env),
|
|
28
|
+
* that value is reused verbatim so every chunk / worker of a single suite
|
|
29
|
+
* run shares one scratch dir. Otherwise a fresh `os.tmpdir()` directory is
|
|
30
|
+
* created once per process (memoized). Every stream writer that resolves a
|
|
31
|
+
* relative tempRoot then lands under this dir instead of the repo's real
|
|
32
|
+
* `temp/` telemetry tree — the regression that let 99% of friction records
|
|
33
|
+
* be test-fixture pollution.
|
|
34
|
+
*
|
|
35
|
+
* Directly unit-tested via the injectable `mkdtemp` seam in
|
|
36
|
+
* `tests/lib/test-env.test.js` (Story #4711).
|
|
37
|
+
*
|
|
38
|
+
* @param {NodeJS.ProcessEnv} [baseEnv=process.env]
|
|
39
|
+
* @param {{ mkdtemp?: typeof mkdtempSync }} [deps] Injectable for tests.
|
|
40
|
+
* @returns {string} absolute scratch tempRoot
|
|
41
|
+
*/
|
|
42
|
+
export function ensureTestScratchTempRoot(
|
|
43
|
+
baseEnv = process.env,
|
|
44
|
+
{ mkdtemp = mkdtempSync } = {},
|
|
45
|
+
) {
|
|
46
|
+
const existing = baseEnv?.[TEST_TEMP_ROOT_ENV];
|
|
47
|
+
if (
|
|
48
|
+
typeof existing === 'string' &&
|
|
49
|
+
existing.length > 0 &&
|
|
50
|
+
path.isAbsolute(existing)
|
|
51
|
+
) {
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
if (_createdScratchDir === null) {
|
|
55
|
+
_createdScratchDir = mkdtemp(path.join(os.tmpdir(), 'mandrel-test-temp-'));
|
|
56
|
+
}
|
|
57
|
+
return _createdScratchDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
1
60
|
/**
|
|
2
61
|
* Build a webhook-safe child-process environment for test runners.
|
|
3
62
|
*
|
|
@@ -27,6 +86,11 @@
|
|
|
27
86
|
* covered by `cleanGitEnv` in `git-utils.js`; this is the same scrub
|
|
28
87
|
* for test child processes, which may spawn git directly. Tests that
|
|
29
88
|
* need a `GIT_*` variable set it explicitly on their own spawn.
|
|
89
|
+
* - `MANDREL_TEST_TEMP_ROOT` is set to an absolute per-process scratch
|
|
90
|
+
* dir (Story #4696). Any test that reaches a stream writer without
|
|
91
|
+
* injecting its own absolute tempRoot lands under scratch instead of
|
|
92
|
+
* the repo's real `temp/` telemetry tree, so the suite can no longer
|
|
93
|
+
* append fixture records to friction / lifecycle / trace streams.
|
|
30
94
|
*
|
|
31
95
|
* @param {NodeJS.ProcessEnv} baseEnv
|
|
32
96
|
* @returns {NodeJS.ProcessEnv}
|
|
@@ -39,5 +103,6 @@ export function buildWebhookSafeTestEnv(baseEnv = process.env) {
|
|
|
39
103
|
if (env.MANDREL_ALLOW_TEST_WEBHOOKS !== '1') {
|
|
40
104
|
delete env.NOTIFICATION_WEBHOOK_URL;
|
|
41
105
|
}
|
|
106
|
+
env[TEST_TEMP_ROOT_ENV] = ensureTestScratchTempRoot(baseEnv);
|
|
42
107
|
return env;
|
|
43
108
|
}
|
|
@@ -18,18 +18,21 @@
|
|
|
18
18
|
* Stories. Envelope carries `sourceTickets[]`.
|
|
19
19
|
*
|
|
20
20
|
* Flags:
|
|
21
|
-
* --out <path>
|
|
21
|
+
* --out <path> Write the envelope to <path> (parent dirs created).
|
|
22
22
|
* `/plan` points this at `<plan-dir>/plan-context.json`,
|
|
23
23
|
* which is where `plan-persist.js` auto-discovers the
|
|
24
24
|
* `--tickets` source ids from (Story #4554). Without a
|
|
25
25
|
* captured envelope persist cannot know a `--tickets` run
|
|
26
26
|
* happened, and superseding degrades to the
|
|
27
|
-
* `--source-tickets` flag.
|
|
28
|
-
*
|
|
27
|
+
* `--source-tickets` flag. With --out, stdout carries a
|
|
28
|
+
* compact digest naming the artifact instead of the full
|
|
29
|
+
* envelope (Story #4708 script-output contract).
|
|
30
|
+
* --pretty Pretty-print the JSON envelope (no-op with --out).
|
|
29
31
|
*
|
|
30
|
-
* stdout is reserved for
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* stdout is reserved for a single JSON payload (Story #2278 discipline) —
|
|
33
|
+
* the envelope, or the digest when --out captures it:
|
|
34
|
+
* `routeAllOutputToStderr()` runs before any pipeline code so the stream
|
|
35
|
+
* is unconditionally parseable by `JSON.parse`.
|
|
33
36
|
*
|
|
34
37
|
* Exit codes:
|
|
35
38
|
* 0 — envelope emitted.
|
|
@@ -49,7 +52,11 @@ import {
|
|
|
49
52
|
validateOrchestrationConfig,
|
|
50
53
|
} from './lib/config-resolver.js';
|
|
51
54
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
52
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
buildPlanContext,
|
|
57
|
+
renderStoriesTemplate,
|
|
58
|
+
STORIES_TEMPLATE_FILENAME,
|
|
59
|
+
} from './lib/orchestration/plan-context.js';
|
|
53
60
|
import { recordPlanInvocation } from './lib/orchestration/plan-metrics.js';
|
|
54
61
|
import { createProvider } from './lib/provider-factory.js';
|
|
55
62
|
|
|
@@ -111,8 +118,40 @@ export async function emitPlanContext({
|
|
|
111
118
|
const json = pretty
|
|
112
119
|
? JSON.stringify(envelope, null, 2)
|
|
113
120
|
: JSON.stringify(envelope);
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
if (outPath) {
|
|
122
|
+
// Script-output contract (Story #4708, AC-5): the full envelope is a
|
|
123
|
+
// ~40KB artifact that would ride resident in the transcript for every
|
|
124
|
+
// later turn. When it is captured to disk anyway, stdout carries a
|
|
125
|
+
// compact digest naming the artifact instead of the payload itself.
|
|
126
|
+
await writeEnvelopeFile(outPath, json);
|
|
127
|
+
await writeStoriesTemplateFile(outPath, envelope);
|
|
128
|
+
const resolved = path.resolve(outPath);
|
|
129
|
+
const digest = {
|
|
130
|
+
digest: 'plan-context',
|
|
131
|
+
mode: envelope.mode,
|
|
132
|
+
out: resolved,
|
|
133
|
+
storiesTemplate: path.join(
|
|
134
|
+
path.dirname(resolved),
|
|
135
|
+
'stories.template.json',
|
|
136
|
+
),
|
|
137
|
+
bytes: Buffer.byteLength(json, 'utf8'),
|
|
138
|
+
sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
|
|
139
|
+
duplicates: (envelope.duplicates ?? []).length,
|
|
140
|
+
// Advisory only (Story #4722): signals, no route — the planner owns
|
|
141
|
+
// the trivial-vs-standard verdict and persist validates it by shape.
|
|
142
|
+
complexitySignals: envelope.complexitySignals
|
|
143
|
+
? {
|
|
144
|
+
artifactCount: envelope.complexitySignals.artifactCount,
|
|
145
|
+
riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
|
|
146
|
+
sensitivePathClasses:
|
|
147
|
+
envelope.complexitySignals.sensitivePathClasses,
|
|
148
|
+
}
|
|
149
|
+
: null,
|
|
150
|
+
};
|
|
151
|
+
stdout.write(`${JSON.stringify(digest)}\n`);
|
|
152
|
+
} else {
|
|
153
|
+
stdout.write(`${json}\n`);
|
|
154
|
+
}
|
|
116
155
|
return envelope;
|
|
117
156
|
}
|
|
118
157
|
|
|
@@ -140,6 +179,42 @@ async function writeEnvelopeFile(outPath, json) {
|
|
|
140
179
|
Logger.info(`[plan-context] wrote envelope to ${resolved}`);
|
|
141
180
|
}
|
|
142
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Emit the ready-to-fill Story authoring template next to the captured
|
|
184
|
+
* envelope (Story #4707 — one-shot authoring). The planner copies it to
|
|
185
|
+
* `stories.json` and fills the placeholders; no step of the authoring path
|
|
186
|
+
* requires reading `story-body.js` source. Written whenever `--out` is
|
|
187
|
+
* passed, and throwing on failure for the same reason the envelope write
|
|
188
|
+
* does: a silently missing template re-opens the format-discovery loop it
|
|
189
|
+
* exists to close. The envelope's advisory `complexitySignals` are threaded
|
|
190
|
+
* through so the skeleton's `changes[]` arrive pre-resolved to
|
|
191
|
+
* creates-vs-refactors against the repo snapshot (Story #4723).
|
|
192
|
+
*
|
|
193
|
+
* @param {string} outPath The envelope `--out` path; the template lands in
|
|
194
|
+
* the same directory as {@link STORIES_TEMPLATE_FILENAME}.
|
|
195
|
+
* @param {object} [envelope] The emitted plan-context envelope.
|
|
196
|
+
*/
|
|
197
|
+
async function writeStoriesTemplateFile(outPath, envelope = {}) {
|
|
198
|
+
const resolved = path.resolve(
|
|
199
|
+
path.dirname(path.resolve(outPath)),
|
|
200
|
+
STORIES_TEMPLATE_FILENAME,
|
|
201
|
+
);
|
|
202
|
+
try {
|
|
203
|
+
await writeFile(
|
|
204
|
+
resolved,
|
|
205
|
+
renderStoriesTemplate({
|
|
206
|
+
complexitySignals: envelope?.complexitySignals ?? null,
|
|
207
|
+
}),
|
|
208
|
+
'utf8',
|
|
209
|
+
);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`[plan-context] cannot write stories template to ${resolved}: ${err.message}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
Logger.info(`[plan-context] wrote ready-to-fill template to ${resolved}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
143
218
|
async function main() {
|
|
144
219
|
const { values } = parseArgs({
|
|
145
220
|
options: {
|