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.
Files changed (73) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -1
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +44 -1
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. 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 and degrade to a skipped envelope.
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, null, 2)}\n${TERMINAL_END_MARKER}\n`,
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.