mandrel 2.8.0 → 2.10.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 -1
- 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 +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- 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/audit-to-stories/dedupe-against-github.js +120 -55
- 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 +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- 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 +60 -0
- 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 +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- 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/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- 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/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -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/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -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
|
+
}
|
|
@@ -263,8 +263,11 @@ export function emitTerminalEnvelope(
|
|
|
263
263
|
envelope,
|
|
264
264
|
{ write = (s) => process.stdout.write(s) } = {},
|
|
265
265
|
) {
|
|
266
|
+
// Story #4685 — compact (not 2-space pretty) JSON. The envelope is a
|
|
267
|
+
// machine contract callers recover with `JSON.parse`, so pretty-printing
|
|
268
|
+
// only adds turn-resident bytes without helping any consumer.
|
|
266
269
|
write(
|
|
267
|
-
`\n${TERMINAL_BEGIN_MARKER}\n${JSON.stringify(envelope
|
|
270
|
+
`\n${TERMINAL_BEGIN_MARKER}\n${JSON.stringify(envelope)}\n${TERMINAL_END_MARKER}\n`,
|
|
268
271
|
);
|
|
269
272
|
}
|
|
270
273
|
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
* at a time.
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
|
+
import {
|
|
56
|
+
suggestPathEntryFix,
|
|
57
|
+
suggestVerifyFix,
|
|
58
|
+
} from '../story-body/body-format-lints.js';
|
|
55
59
|
import {
|
|
56
60
|
parse as parseStoryBody,
|
|
57
61
|
StoryBodyParseError,
|
|
@@ -73,44 +77,6 @@ export const VERIFY_TIER_VALUES = Object.freeze([
|
|
|
73
77
|
'validate',
|
|
74
78
|
]);
|
|
75
79
|
|
|
76
|
-
const PATH_LIKE_RE = /[/.][\w@\-./*]+|\*\*?\/?\*?\.\w+|[a-z][\w-]*\/[\w-./*]+/i;
|
|
77
|
-
const VAGUE_VERBS = [
|
|
78
|
-
'clean up',
|
|
79
|
-
'refactor',
|
|
80
|
-
'improve',
|
|
81
|
-
'polish',
|
|
82
|
-
'tighten',
|
|
83
|
-
'tidy',
|
|
84
|
-
'simplify',
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* @param {string} bullet
|
|
89
|
-
* @returns {boolean}
|
|
90
|
-
*/
|
|
91
|
-
function bulletNamesPath(bullet) {
|
|
92
|
-
const colonIdx = bullet.indexOf(':');
|
|
93
|
-
if (colonIdx <= 0) return PATH_LIKE_RE.test(bullet);
|
|
94
|
-
const head = bullet.slice(0, colonIdx);
|
|
95
|
-
// The conventional shape is "<path>: <verb> <object>" — head is the path.
|
|
96
|
-
return PATH_LIKE_RE.test(head) || PATH_LIKE_RE.test(bullet);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* @param {string} bullet
|
|
101
|
-
* @returns {string|null} reason if the bullet uses a vague verb without a named target, else null.
|
|
102
|
-
*/
|
|
103
|
-
function vagueVerbWithoutTarget(bullet) {
|
|
104
|
-
const lower = bullet.toLowerCase();
|
|
105
|
-
for (const verb of VAGUE_VERBS) {
|
|
106
|
-
if (!lower.includes(verb)) continue;
|
|
107
|
-
if (!bulletNamesPath(bullet)) {
|
|
108
|
-
return verb;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
80
|
/**
|
|
115
81
|
* Predicate: should the validator skip this ticket entirely? Skip when:
|
|
116
82
|
* - it is not a Story (only `type: 'story'` tickets are validated here),
|
|
@@ -297,8 +263,13 @@ function collectChangesErrors(prefix, rawChanges) {
|
|
|
297
263
|
}
|
|
298
264
|
for (const entry of changes) {
|
|
299
265
|
if (typeof entry === 'string') {
|
|
266
|
+
const fix = suggestPathEntryFix(entry);
|
|
267
|
+
const fixIt =
|
|
268
|
+
fix === null
|
|
269
|
+
? ''
|
|
270
|
+
: ` Suggested fix: ${fix} (adjust the assumption to creates|deletes if this is a new file or a removal).`;
|
|
300
271
|
errors.push(
|
|
301
|
-
`${prefix}: body.changes entry must be a { path, assumption } object; plain string bullets are no longer accepted: "${entry}"
|
|
272
|
+
`${prefix}: body.changes entry must be a { path, assumption } object; plain string bullets are no longer accepted: "${entry}".${fixIt}`,
|
|
302
273
|
);
|
|
303
274
|
continue;
|
|
304
275
|
}
|
|
@@ -385,8 +356,10 @@ function collectVerifyErrors(prefix, rawVerify) {
|
|
|
385
356
|
continue;
|
|
386
357
|
}
|
|
387
358
|
if (!VERIFY_TIER_RE.test(v)) {
|
|
359
|
+
const fix = suggestVerifyFix(v);
|
|
360
|
+
const fixIt = fix === null ? '' : ` Suggested fix: "${fix}".`;
|
|
388
361
|
errors.push(
|
|
389
|
-
`${prefix}: body.verify entry must end with a tier in parentheses — one of (${VERIFY_TIER_VALUES.join('|')}). Got: "${v}"
|
|
362
|
+
`${prefix}: body.verify entry must end with a tier in parentheses — one of (${VERIFY_TIER_VALUES.join('|')}). Got: "${v}".${fixIt}`,
|
|
390
363
|
);
|
|
391
364
|
}
|
|
392
365
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* body-format-lints.js — SSOT for the deterministic body-format lints that can
|
|
3
|
+
* REJECT an authored Story body at persist time, plus the mechanical auto-fix
|
|
4
|
+
* inference a failing dry-run surfaces (Story #4684).
|
|
5
|
+
*
|
|
6
|
+
* The problem this closes: deterministic format rules (structured `## Changes`
|
|
7
|
+
* bullet shape, verify-tier suffixes) used to be discovered only as persist
|
|
8
|
+
* dry-run failures — every miss cost a full re-author round-trip at
|
|
9
|
+
* resident-context prices. This module is the single home for two remedies:
|
|
10
|
+
*
|
|
11
|
+
* 1. `BODY_FORMAT_LINTS` — the enumerated rejecting lints, each carrying a
|
|
12
|
+
* concrete good/bad example. `templates/decomposer-prompts.js` renders
|
|
13
|
+
* them into the story-author system prompt so the first draft is
|
|
14
|
+
* lint-clean by construction; a test enumerates the registry against the
|
|
15
|
+
* rendered prompt (Story #4684 AC-1).
|
|
16
|
+
* 2. `suggestPathEntryFix` / `suggestVerifyFix` — the mechanical rewrites.
|
|
17
|
+
* `story-body.js` (`parsePathEntry`) and `task-body-validator.js`
|
|
18
|
+
* (`collectChangesErrors` / `collectVerifyErrors`) call them so a failing
|
|
19
|
+
* lint emits the corrected form ready to paste rather than a bare reject
|
|
20
|
+
* (AC-2).
|
|
21
|
+
*
|
|
22
|
+
* Import hygiene: this module imports only the cycle-free
|
|
23
|
+
* `file-assumption-enum.js` leaf. It must NOT import `story-body.js` or
|
|
24
|
+
* `task-body-validator.js` — both import this module, and either back-edge
|
|
25
|
+
* would introduce a cycle (see the note in `file-assumption-enum.js`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Canonical testing-tier vocabulary an inferred verify suffix may name. Kept in
|
|
32
|
+
* sync with `task-body-validator.js`'s `VERIFY_TIER_VALUES` by a test rather
|
|
33
|
+
* than an import (importing that module here would create a cycle).
|
|
34
|
+
*
|
|
35
|
+
* @type {readonly ['unit','contract','e2e','validate']}
|
|
36
|
+
*/
|
|
37
|
+
const INFERABLE_VERIFY_TIERS = Object.freeze([
|
|
38
|
+
'unit',
|
|
39
|
+
'contract',
|
|
40
|
+
'e2e',
|
|
41
|
+
'validate',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The default assumption a mechanical `## Changes` auto-fix proposes. Most
|
|
46
|
+
* bare-path bullets an author drops are in-place edits, so `refactors-existing`
|
|
47
|
+
* is the safe default — the fix-it text tells the author to switch it to
|
|
48
|
+
* `creates` / `deletes` when the edit is net-new or a removal.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_SUGGESTED_ASSUMPTION = 'refactors-existing';
|
|
51
|
+
|
|
52
|
+
// A token that looks like a file path / glob / module id: it carries a `/` or a
|
|
53
|
+
// `.`-separated segment. Deliberately loose — the suggestion is best-effort, and
|
|
54
|
+
// a false positive only produces an unhelpful (still-valid) fix-it string.
|
|
55
|
+
const PATH_LIKE_RE = /[\w@*-]*[/.][\w@./*-]+/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer the testing tier a bare `verify[]` command implies, when it is
|
|
59
|
+
* unambiguous. Returns `null` when no confident inference is possible (the
|
|
60
|
+
* author must then choose the tier themselves — the lint still fires, just
|
|
61
|
+
* without a fix-it).
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} command
|
|
64
|
+
* @returns {'unit'|'contract'|'e2e'|'validate'|null}
|
|
65
|
+
*/
|
|
66
|
+
function inferVerifyTier(command) {
|
|
67
|
+
if (typeof command !== 'string') return null;
|
|
68
|
+
const c = command.toLowerCase();
|
|
69
|
+
if (/\bvalidate\b/.test(c)) return 'validate';
|
|
70
|
+
if (/playwright|\.spec\.|\be2e\b/.test(c)) return 'e2e';
|
|
71
|
+
if (/\.test\.|node --test|node:test|\bvitest\b|\bjest\b/.test(c)) {
|
|
72
|
+
return 'unit';
|
|
73
|
+
}
|
|
74
|
+
if (/\bcontract\b/.test(c)) return 'contract';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Propose the corrected form of a `verify[]` entry that is missing its tier
|
|
80
|
+
* suffix, when the tier is inferable from the command. Returns `null` when the
|
|
81
|
+
* entry is a `manual:` escape, is empty, or the tier cannot be inferred.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} entry
|
|
84
|
+
* @returns {string|null} e.g. `"npm run validate (validate)"`.
|
|
85
|
+
*/
|
|
86
|
+
export function suggestVerifyFix(entry) {
|
|
87
|
+
if (typeof entry !== 'string') return null;
|
|
88
|
+
const trimmed = entry.trim();
|
|
89
|
+
if (trimmed === '' || trimmed.startsWith('manual:')) return null;
|
|
90
|
+
const tier = inferVerifyTier(trimmed);
|
|
91
|
+
if (tier === null) return null;
|
|
92
|
+
// Drop any trailing (…) — a wrong/partial tier suffix — before appending the
|
|
93
|
+
// inferred one, so `npm test (smoke)` becomes `npm test (unit)` not a double.
|
|
94
|
+
const base = trimmed.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
95
|
+
if (base === '') return null;
|
|
96
|
+
return `${base} (${tier})`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Propose the canonical `{ path, assumption }` object form for a `## Changes` /
|
|
101
|
+
* `## References` bullet an author wrote as a bare path string (or a humanized
|
|
102
|
+
* bullet with a bad assumption). Returns `null` when no path-shaped token can
|
|
103
|
+
* be salvaged.
|
|
104
|
+
*
|
|
105
|
+
* The returned string is inline-JSON that round-trips cleanly back through the
|
|
106
|
+
* story-body parser, so it is paste-ready.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} raw
|
|
109
|
+
* @returns {string|null} e.g. `{"path":"src/app.js","assumption":"refactors-existing"}`.
|
|
110
|
+
*/
|
|
111
|
+
export function suggestPathEntryFix(raw) {
|
|
112
|
+
if (typeof raw !== 'string') return null;
|
|
113
|
+
// Strip a leading markdown bullet marker.
|
|
114
|
+
let s = raw
|
|
115
|
+
.trim()
|
|
116
|
+
.replace(/^[-*]\s+/, '')
|
|
117
|
+
.trim();
|
|
118
|
+
// Take the segment before any humanized "— assumption" tail.
|
|
119
|
+
s = s.split('—')[0].trim();
|
|
120
|
+
// Peel surrounding backticks / quotes.
|
|
121
|
+
s = s
|
|
122
|
+
.replace(/^[`'"]+/, '')
|
|
123
|
+
.replace(/[`'"]+$/, '')
|
|
124
|
+
.trim();
|
|
125
|
+
if (s === '' || !PATH_LIKE_RE.test(s)) return null;
|
|
126
|
+
return JSON.stringify({ path: s, assumption: DEFAULT_SUGGESTED_ASSUMPTION });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A single deterministic body-format lint the persist path enforces.
|
|
131
|
+
*
|
|
132
|
+
* @typedef {object} BodyFormatLint
|
|
133
|
+
* @property {string} id Stable identifier (also the prompt anchor).
|
|
134
|
+
* @property {string} summary One-line statement of the requirement.
|
|
135
|
+
* @property {string} badExample A form the lint rejects.
|
|
136
|
+
* @property {string} goodExample The lint-clean form to author instead.
|
|
137
|
+
* @property {boolean} autoFixable Whether a failing dry-run emits a fix-it.
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The enumerated deterministic lints that can reject an authored Story body at
|
|
142
|
+
* persist time. Each carries a concrete example so the story-author prompt can
|
|
143
|
+
* state the requirement example-first (Story #4684 AC-1). The two `autoFixable`
|
|
144
|
+
* lints are the mechanical rewrites whose dry-run failure carries the corrected
|
|
145
|
+
* form (AC-2).
|
|
146
|
+
*
|
|
147
|
+
* @type {ReadonlyArray<BodyFormatLint>}
|
|
148
|
+
*/
|
|
149
|
+
export const BODY_FORMAT_LINTS = Object.freeze([
|
|
150
|
+
{
|
|
151
|
+
id: 'body-is-string',
|
|
152
|
+
summary:
|
|
153
|
+
'The Story `body` MUST be the serialized markdown string produced by `serialize()`, never a JSON object.',
|
|
154
|
+
badExample: '"body": { "goal": "…" }',
|
|
155
|
+
goodExample: '"body": "## Goal\\n…"',
|
|
156
|
+
autoFixable: false,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'goal-non-empty',
|
|
160
|
+
summary: 'The body MUST open with a non-empty `## Goal` sentence.',
|
|
161
|
+
badExample: '## Goal\n\n## Spec',
|
|
162
|
+
goodExample:
|
|
163
|
+
'## Goal\nExchange short-lived JWTs so sessions survive a restart.',
|
|
164
|
+
autoFixable: false,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'changes-path-entry-shape',
|
|
168
|
+
summary:
|
|
169
|
+
'Every `## Changes` / `## References` bullet MUST be a `{ path, assumption }` object (assumption ∈ ' +
|
|
170
|
+
`${FILE_ASSUMPTION_VALUES.join(' | ')}); plain path strings are rejected.`,
|
|
171
|
+
badExample: '- src/app.js',
|
|
172
|
+
goodExample: '- {"path": "src/app.js", "assumption": "refactors-existing"}',
|
|
173
|
+
autoFixable: true,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'changes-non-empty',
|
|
177
|
+
summary: 'A Story MUST declare at least one `## Changes` bullet.',
|
|
178
|
+
badExample: '## Changes\n\n## Acceptance',
|
|
179
|
+
goodExample: '- {"path": "src/app.js", "assumption": "creates"}',
|
|
180
|
+
autoFixable: false,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'verify-tier-suffix',
|
|
184
|
+
summary:
|
|
185
|
+
'Every `verify[]` entry MUST end with a tier in parentheses — one of ' +
|
|
186
|
+
`(${INFERABLE_VERIFY_TIERS.join(' | ')}) — or be a \`manual:<reason>\` escape.`,
|
|
187
|
+
badExample: 'npm test -- src/app.test.js',
|
|
188
|
+
goodExample: 'npm test -- src/app.test.js (unit)',
|
|
189
|
+
autoFixable: true,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'verify-non-empty',
|
|
193
|
+
summary:
|
|
194
|
+
'A Story MUST list at least one `verify[]` entry (use `manual:<reason>` only when truly unverifiable in isolation).',
|
|
195
|
+
badExample: '"verify": []',
|
|
196
|
+
goodExample: '"verify": ["npm run validate (validate)"]',
|
|
197
|
+
autoFixable: false,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'verify-manual-reason',
|
|
201
|
+
summary:
|
|
202
|
+
'A `manual:` verify entry MUST carry a reason after the colon; a bare `manual:` is rejected.',
|
|
203
|
+
badExample: 'manual:',
|
|
204
|
+
goodExample: 'manual: copy-only edit an auditor eyeballs',
|
|
205
|
+
autoFixable: false,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'acceptance-non-empty',
|
|
209
|
+
summary:
|
|
210
|
+
'A Story MUST list at least one observable `acceptance[]` criterion.',
|
|
211
|
+
badExample: '"acceptance": []',
|
|
212
|
+
goodExample: '"acceptance": ["`npm run build` exits 0"]',
|
|
213
|
+
autoFixable: false,
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
authoredMarkerLine,
|
|
49
49
|
} from '../framework-version.js';
|
|
50
50
|
import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
|
|
51
|
+
import { suggestPathEntryFix } from './body-format-lints.js';
|
|
51
52
|
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Public types (JSDoc only — no runtime schema file)
|
|
@@ -229,7 +230,7 @@ function parsePathEntry(raw, warnings) {
|
|
|
229
230
|
}
|
|
230
231
|
// Recognized the humanized shape but the fields are invalid: fail closed.
|
|
231
232
|
throw new StoryBodyParseError(
|
|
232
|
-
`changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}`,
|
|
233
|
+
`changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
|
|
233
234
|
{ field: 'changes', raw: str },
|
|
234
235
|
);
|
|
235
236
|
}
|
|
@@ -261,11 +262,26 @@ function parsePathEntry(raw, warnings) {
|
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
throw new StoryBodyParseError(
|
|
264
|
-
`changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}`,
|
|
265
|
+
`changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
|
|
265
266
|
{ field: 'changes', raw: str },
|
|
266
267
|
);
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Build the ` Suggested fix: …` suffix for a rejected changes/references
|
|
272
|
+
* bullet, when a `{ path, assumption }` object can be salvaged from it. Returns
|
|
273
|
+
* an empty string when nothing is inferable, so callers can append it
|
|
274
|
+
* unconditionally (Story #4684 — mechanical auto-fix in the failure output).
|
|
275
|
+
*
|
|
276
|
+
* @param {string} raw The rejected bullet text.
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
function pathEntryFixIt(raw) {
|
|
280
|
+
const suggestion = suggestPathEntryFix(raw);
|
|
281
|
+
if (suggestion === null) return '';
|
|
282
|
+
return ` — Suggested fix: ${suggestion} (adjust the assumption to creates|deletes if this is a new file or a removal).`;
|
|
283
|
+
}
|
|
284
|
+
|
|
269
285
|
/**
|
|
270
286
|
* Extract the `blocked by #N` lines from the footer block (text after
|
|
271
287
|
* the last `---` separator). Returns an array of "#N" strings.
|