mandrel 2.59.0 → 2.60.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 (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
@@ -77,20 +77,19 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
77
77
  'follow-ups',
78
78
  // v2 plan-run epilogue artifacts (posted on the primary Story)
79
79
  'plan-run-audit-roster',
80
- 'plan-run-sibling-coherence',
81
80
  'epic-run-state',
82
81
  'epic-run-progress',
83
82
  'epic-plan-state',
84
83
  // `parked-follow-ons` retired in Story #5114 with the module that was its
85
- // only writer. A kind the reader still recognises but nothing emits is the
86
- // same dead wiring in a new place.
84
+ // only writer, and `plan-run-sibling-coherence` in Story #5341 with the
85
+ // epilogue step that was its. Story #5367 retired two more on the same
86
+ // rule: `story-init` (its write went away in #5343 — the receipt is read
87
+ // off disk now) and `model-attribution` (its writer went with the per-Task
88
+ // progress writer in #3157, and its reader modules are deleted). A kind the
89
+ // reader still recognises but nothing emits is the same dead wiring in a
90
+ // new place.
87
91
  // Story #566 — per-phase wall-clock summary posted by single-story-close.js.
88
92
  'phase-timings',
89
- // Story #831 — story-init upserts a `story-init` comment that
90
- // surfaces `dependenciesInstalled` (and the underlying installStatus) so
91
- // downstream workflow steps don't have to infer install state from
92
- // node_modules presence.
93
- 'story-init',
94
93
  // Story #2128 — Phase 6 Epic Clarity Gate (CLI retired). Historical
95
94
  // `clarity-gate-update` comments may still exist on older tickets.
96
95
  'clarity-gate-update',
@@ -100,16 +99,6 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
100
99
  // operator can correct drift before Phase 8 decomposes from a stale
101
100
  // spec. Advisory: the run continues regardless of the report contents.
102
101
  'spec-freshness',
103
- // Story #2813 — the per-Task progress writer (since retired under
104
- // #3157) upserted a `model-attribution` comment on a Task ticket at
105
- // the moment it transitioned to `agent::executing`, recording which
106
- // Claude model was actively executing the work. One entry per Task
107
- // (upsert is idempotent across resume re-runs). Story- and Epic-level
108
- // rollups are computed at query time by `rollupModelAttribution` in
109
- // `lib/orchestration/model-attribution.js` — no Story/Epic-scope
110
- // emissions are written. Schema:
111
- // `.agents/schemas/model-attribution.schema.json`.
112
- 'model-attribution',
113
102
  // Story #2894 — `finalize/post-handoff-comment.js` upserts an
114
103
  // `epic-handoff` comment on the Epic at the end of the bus-owned
115
104
  // finalize flow (after `open-or-locate-pr`
@@ -157,13 +146,13 @@ export const STRUCTURED_COMMENT_TYPES = Object.freeze([
157
146
  // `graduator="<name>"` attr so independent graduators do not clobber each
158
147
  // other's comment; re-runs upsert in place.
159
148
  'cross-repo-deferred',
160
- // Epic #4474 (PR3) / v2 Stage 3 — `plan-persist.js` upserts a single
161
- // `plan-summary` comment on the primary Story at terminal persist
162
- // success, carrying risk / routing receipts and the depends_on order
163
- // table. One entry per plan; a re-persist upserts in place.
164
- 'plan-summary',
165
- // v2 Stage 3 — flat Story persist checkpoint on every created Story
166
- // (replaces epic-plan-state for new plans). plan-summary stays primary-only.
149
+ // v2 Stage 3 — the flat Story persist comment on every created Story
150
+ // (replaces epic-plan-state for new plans). Since Story #5343 it is the
151
+ // ONLY comment persist posts: the primary-Story-only `plan-summary` marker
152
+ // was retired and its content — story set, delivery order, deliver command
153
+ // — rides this marker on every Story. Story #5367 deleted the machine
154
+ // checkpoint that used to lead the body; the marker remains, and it is what
155
+ // makes a re-persist upsert in place.
167
156
  'story-plan-state',
168
157
  // Story #4535 — `plan-persist.js` upserts a `superseded-by` comment on
169
158
  // each `/mandrel-plan --tickets` source issue at persist time, naming the single
@@ -22,7 +22,10 @@
22
22
  *
23
23
  * Story #5312 deleted the `verify-tier-suffix` / `verify-manual-reason` lints
24
24
  * with the tier suffix itself: a `verify[]` entry is any command, and the
25
- * `manual:<reason>` escape is gone with the rule it escaped.
25
+ * `manual:<reason>` escape is gone with the rule it escaped. Story #5342
26
+ * deleted `verify-non-empty`: an empty `verify[]` is a dry-run warning, not a
27
+ * refusal, so it is no longer a rejecting lint — this registry carries only
28
+ * the rules that still refuse.
26
29
  *
27
30
  * Import hygiene: this module imports only the cycle-free
28
31
  * `file-assumption-enum.js` leaf. It must NOT import `story-body.js` or
@@ -40,6 +43,53 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
40
43
  */
41
44
  const DEFAULT_SUGGESTED_ASSUMPTION = 'refactors-existing';
42
45
 
46
+ /**
47
+ * The bare-path bullet grammar (Story #5342, widened by Story #5361) — the
48
+ * **one** definition of what a `## Changes` / `## References` bullet has to
49
+ * look like to be a path rather than prose.
50
+ *
51
+ * It lives here, in the cycle-free lint leaf, because two consumers need the
52
+ * identical judgment and neither may import the other: the story-body parser
53
+ * (`story-body.js#parsePathEntry`) and the persist repair pass
54
+ * (`plan-persist/changes-repair.js`). They carried a copy each, and the
55
+ * copies drifted.
56
+ *
57
+ * A bullet is a path when it is a single token git could track: any run of
58
+ * non-whitespace, optionally wrapped in a matched pair of backticks. That
59
+ * admits the shapes the earlier `[\w@*-]*[/.][\w@./*-]+` class refused
60
+ * outright — route-segment paths (`app/[slug]/page.tsx`,
61
+ * `app/(marketing)/page.tsx`, `src/routes/$id.svelte`) and extensionless
62
+ * top-level files (`Makefile`) — while still refusing prose, since
63
+ * whitespace is the one thing that reliably marks a sentence.
64
+ */
65
+ const BARE_PATH_TOKEN_RE = /^(?:`([^\s`]+)`|([^\s`]+))$/;
66
+
67
+ /**
68
+ * Read the bare path token out of a bullet, or `null` when the bullet is not
69
+ * one single token.
70
+ *
71
+ * @param {unknown} raw
72
+ * @returns {string|null} The path, with any wrapping backticks peeled.
73
+ */
74
+ export function matchBarePathToken(raw) {
75
+ if (typeof raw !== 'string') return null;
76
+ const match = raw.trim().match(BARE_PATH_TOKEN_RE);
77
+ return match ? (match[1] ?? match[2]) : null;
78
+ }
79
+
80
+ /**
81
+ * Whether a rejected bullet is prose — it carries whitespace, so no path
82
+ * grammar could ever have admitted it. The counterpart failure is a
83
+ * single token that still names no usable path; the two get different
84
+ * refusals because they need different fixes.
85
+ *
86
+ * @param {unknown} raw
87
+ * @returns {boolean}
88
+ */
89
+ export function isProseBullet(raw) {
90
+ return typeof raw === 'string' && /\s/.test(raw.trim());
91
+ }
92
+
43
93
  // A token that looks like a file path / glob / module id: it carries a `/` or a
44
94
  // `.`-separated segment. Deliberately loose — the suggestion is best-effort, and
45
95
  // a false positive only produces an unhelpful (still-valid) fix-it string.
@@ -114,10 +164,13 @@ export const BODY_FORMAT_LINTS = Object.freeze([
114
164
  {
115
165
  id: 'changes-path-entry-shape',
116
166
  summary:
117
- 'Every `## Changes` / `## References` bullet MUST be a `{ path, assumption }` object (assumption ∈ ' +
118
- `${FILE_ASSUMPTION_VALUES.join(' | ')}); plain path strings are rejected.`,
119
- badExample: '- src/app.js',
120
- goodExample: '- {"path": "src/app.js", "assumption": "refactors-existing"}',
167
+ 'Every `## Changes` / `## References` bullet MUST name a path — a bare ' +
168
+ 'path string is the default form and persist derives its assumption by ' +
169
+ 'probing the base branch. Use the `{ path, assumption }` object ' +
170
+ `(assumption ∈ ${FILE_ASSUMPTION_VALUES.join(' | ')}) only to pin one ` +
171
+ 'yourself; `deletes` always needs it. Prose bullets are rejected.',
172
+ badExample: '- the routing module and its tests',
173
+ goodExample: '- src/app.js',
121
174
  autoFixable: true,
122
175
  },
123
176
  {
@@ -127,13 +180,6 @@ export const BODY_FORMAT_LINTS = Object.freeze([
127
180
  goodExample: '- {"path": "src/app.js", "assumption": "creates"}',
128
181
  autoFixable: false,
129
182
  },
130
- {
131
- id: 'verify-non-empty',
132
- summary: 'A Story MUST list at least one `verify[]` entry.',
133
- badExample: '"verify": []',
134
- goodExample: '"verify": ["npm run validate"]',
135
- autoFixable: false,
136
- },
137
183
  {
138
184
  id: 'acceptance-non-empty',
139
185
  summary:
@@ -41,7 +41,11 @@
41
41
  */
42
42
 
43
43
  import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
44
- import { suggestPathEntryFix } from './body-format-lints.js';
44
+ import {
45
+ isProseBullet,
46
+ matchBarePathToken,
47
+ suggestPathEntryFix,
48
+ } from './body-format-lints.js';
45
49
  import { isFooterSeparator, parseFooterBlockedByRefs } from './footer-block.js';
46
50
 
47
51
  // ---------------------------------------------------------------------------
@@ -161,6 +165,14 @@ function stripListMarker(line) {
161
165
  // issue bodies are never rewritten).
162
166
  const HUMANIZED_PATH_ENTRY_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
163
167
 
168
+ // Bare path bullet (Story #5342): `src/app.js` or `` `src/app.js` `` with no
169
+ // assumption. The assumption is a fact about the base branch, not a thing the
170
+ // author knows better than a probe does, so the default authored form omits
171
+ // it and persist derives it. The grammar itself — a single whitespace-free
172
+ // token, so prose bullets do not match and are still rejected — is
173
+ // `body-format-lints.js#matchBarePathToken`, the one definition the persist
174
+ // repair pass scores against too (Story #5361).
175
+
164
176
  // AC-<n> presentation prefix on acceptance checkboxes (Story #4600). The
165
177
  // numbering is a stable 1-based human handle only — parse() strips it so the
166
178
  // top-level acceptance[] machine contract round-trips byte-identical.
@@ -189,8 +201,10 @@ const META_BLOCK_RE = /<!--\s*meta:[\s\S]*?-->/;
189
201
  /**
190
202
  * Parse a single `changes` / `references` bullet into a `PathEntry`.
191
203
  *
192
- * Accepted markdown shapes (both parsed indefinitely — live issue bodies
204
+ * Accepted markdown shapes (all parsed indefinitely — live issue bodies
193
205
  * are never rewritten):
206
+ * - Bare path (the default authored form since Story #5342):
207
+ * `` `src/x.js` `` or `src/x.js`, parsed with `assumption: null`
194
208
  * - Humanized bullet (canonical serialize() output since Story #4600):
195
209
  * `` `src/x.js` — refactors-existing ``
196
210
  * - Legacy inline-JSON object bullet:
@@ -209,18 +223,65 @@ function parsePathEntry(raw, warnings) {
209
223
  return pathEntryFromObject(raw);
210
224
  }
211
225
 
212
- const str = typeof raw === 'string' ? raw.trim() : String(raw).trim();
226
+ const str = String(raw).trim();
213
227
  if (str.length === 0) return null;
214
228
 
215
229
  const entry = pathEntryFromHumanized(str) ?? pathEntryFromInlineJson(str);
216
230
  if (entry) return entry;
231
+ return pathEntryFromBare(str);
232
+ }
233
+
234
+ /**
235
+ * Parse the bare path bullet — the default authored form since Story #5342 —
236
+ * or refuse the bullet. This is the last shape `parsePathEntry` tries, so it
237
+ * owns the rejection too.
238
+ *
239
+ * `assumption: null` records only what the author said; persist derives the
240
+ * rest by probing the base branch. A `{`-leading string reached here because
241
+ * it failed to parse as the inline JSON object it announced itself as, so it
242
+ * is malformed JSON rather than a path and keeps failing closed.
243
+ *
244
+ * Story #5361: the two failures need different fixes, so they get different
245
+ * refusals — rewrite a sentence as a path, versus fix a token that is not one.
246
+ *
247
+ * @param {string} str
248
+ * @returns {PathEntry}
249
+ */
250
+ function pathEntryFromBare(str) {
251
+ const bare = str.startsWith('{') ? null : matchBarePathToken(str);
252
+ if (bare !== null) return { path: bare, assumption: null };
217
253
 
254
+ const shape = isProseBullet(str)
255
+ ? 'is prose, not a path'
256
+ : 'names no usable path';
218
257
  throw new StoryBodyParseError(
219
- `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
258
+ `changes/references entry ${shape} — a bullet must be a single ` +
259
+ `whitespace-free path token, or a { path, assumption } object: ` +
260
+ `${str.slice(0, 120)}${pathEntryFixIt(str)}`,
220
261
  { field: 'changes', raw: str },
221
262
  );
222
263
  }
223
264
 
265
+ /**
266
+ * Read the assumption a raw entry declares, collapsing the three cases the
267
+ * parser has to tell apart into one value:
268
+ *
269
+ * - a canonical `FILE_ASSUMPTION_VALUES` member — the author pinned one;
270
+ * - `null` — the author named no assumption at all. Since Story #5342 that
271
+ * is the **bare form**, and persist derives the value by probing the base
272
+ * branch rather than the author guessing it;
273
+ * - `undefined` — the author named something that is not an assumption.
274
+ * Distinct from absent on purpose: a typo must fail closed where an
275
+ * omission is the default shape.
276
+ *
277
+ * @param {unknown} raw
278
+ * @returns {string|null|undefined}
279
+ */
280
+ function readAssumption(raw) {
281
+ if (FILE_ASSUMPTION_VALUES.includes(raw)) return raw;
282
+ return raw == null ? null : undefined;
283
+ }
284
+
224
285
  /**
225
286
  * Validate an already-structured `{ path, assumption }` object. Fails closed
226
287
  * on a malformed object.
@@ -229,12 +290,10 @@ function parsePathEntry(raw, warnings) {
229
290
  * @returns {PathEntry}
230
291
  */
231
292
  function pathEntryFromObject(raw) {
232
- if (
233
- typeof raw.path === 'string' &&
234
- raw.path.trim().length > 0 &&
235
- FILE_ASSUMPTION_VALUES.includes(raw.assumption)
236
- ) {
237
- return { path: raw.path.trim(), assumption: raw.assumption };
293
+ const path = typeof raw.path === 'string' ? raw.path.trim() : '';
294
+ const assumption = readAssumption(raw.assumption);
295
+ if (path !== '' && assumption !== undefined) {
296
+ return { path, assumption };
238
297
  }
239
298
  // Malformed object: fail closed.
240
299
  throw new StoryBodyParseError(
@@ -270,8 +329,9 @@ function pathEntryFromHumanized(str) {
270
329
  * Parse the legacy inline-JSON object bullet:
271
330
  * `{ "path": "...", "assumption": "..." }`. Returns `null` when the line is
272
331
  * not a JSON object at all (including a JSON parse failure — the caller then
273
- * rejects the plain-string form); fails closed when it parses to an object
274
- * without valid PathEntry fields.
332
+ * tries the bare-path form and finally rejects the bullet); delegates the
333
+ * field check to {@link pathEntryFromObject}, which is the same judgment on
334
+ * the same shape and fails closed the same way.
275
335
  *
276
336
  * @param {string} str
277
337
  * @returns {PathEntry|null}
@@ -282,21 +342,12 @@ function pathEntryFromInlineJson(str) {
282
342
  try {
283
343
  parsed = JSON.parse(str);
284
344
  } catch {
285
- // JSON parse failed — the caller rejects the plain-string form.
345
+ // JSON parse failed — the caller falls through to the bare-path form.
286
346
  return null;
287
347
  }
288
- if (typeof parsed !== 'object' || parsed === null) return null;
289
- if (
290
- typeof parsed.path === 'string' &&
291
- FILE_ASSUMPTION_VALUES.includes(parsed.assumption)
292
- ) {
293
- return { path: parsed.path.trim(), assumption: parsed.assumption };
294
- }
295
- // Parsed successfully as JSON object but has invalid fields — fail closed.
296
- throw new StoryBodyParseError(
297
- `changes/references entry is a JSON object but not a valid PathEntry: ${str}`,
298
- { field: 'changes', raw: str },
299
- );
348
+ return parsed !== null && typeof parsed === 'object'
349
+ ? pathEntryFromObject(parsed)
350
+ : null;
300
351
  }
301
352
 
302
353
  /**
@@ -789,10 +840,13 @@ const STRUCTURED_FIELD_NORMALIZERS = {
789
840
  */
790
841
  function serializePathEntry(entry) {
791
842
  if (typeof entry === 'string') return entry;
792
- // Canonical object form (Story #4600): render as a human-readable bullet —
793
- // path in backticks, em-dash, assumption. parsePathEntry recognizes this
794
- // shape (and the legacy inline-JSON shape) for round-trip fidelity.
795
- return `\`${entry.path}\` — ${entry.assumption}`;
843
+ // Canonical object form (Story #4600): a human-readable bullet — path in
844
+ // backticks, em-dash, assumption. parsePathEntry recognizes this shape (and
845
+ // the legacy inline-JSON one) for round-trip fidelity. Story #5342: a bare
846
+ // entry — a path the author wrote with no assumption — serializes back bare
847
+ // rather than silently acquiring a derivation they never made; persist
848
+ // fills it in by probing base before it writes a body.
849
+ return [`\`${entry.path}\``, entry.assumption].filter(Boolean).join(' — ');
796
850
  }
797
851
 
798
852
  /**
@@ -117,7 +117,8 @@ The **persisted** \`body\` renders these markdown sections (in order) — you au
117
117
  <optional technical approach at contract level — do NOT restate Goal / Acceptance / Verify>
118
118
 
119
119
  ## Changes
120
- - {"path": "<file path>", "assumption": "creates" | "refactors-existing" | "deletes"}
120
+ - <file path>
121
+ - {"path": "<file path>", "assumption": "deletes"}
121
122
  - ...
122
123
 
123
124
  ## Acceptance <-- synthesized by persist from acceptance[]; do not author
@@ -137,9 +138,9 @@ The **persisted** \`body\` renders these markdown sections (in order) — you au
137
138
  - **goal** (in body string): One sentence stating WHY this Story exists.
138
139
  - **spec** (optional, in body string as \`## Spec\`): The technical approach at the altitude the SPEC PROSE CONTRACT below fixes — contract and invariants, never implementation narration. Write as much as the work needs and no more; persist keeps Specs inline at any length and never writes them under \`docs/\`.
139
140
  - **slicing** (optional): Ordered intra-session checkpoints for one Story, one line each. A checkpoint is a **stage of the work** — a commit boundary the deliverer passes through inside one session, stated as the step it performs. An acceptance item is a **state of the codebase** a PR reviewer confirms once the Story has landed. The same Story therefore carries both: the checkpoints say in what order it is built, \`acceptance[]\` says what must then be true. Never a fan-out table, never a second acceptance list, and never sibling tickets — a broad sweep with many stages is still one Story, sliced here.
140
- - **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. **Name the files the deliverer authors, and omit generated artifacts** — quality baselines, generated test indexes, migration journals, lockfiles and the like are regenerated by the work itself, the refresh is a close-gate concern, and declaring one needlessly reserves a footprint that serializes sibling Stories at dispatch. 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. Persist probes every path against the base branch and repairs a plain-string bullet or a trailing parenthetical into the object form for you; a \`creates\` on an existing path or a \`refactors-existing\` on an absent one is a dry-run warning, and only a \`deletes\` naming an absent path is refused.
141
+ - **changes** (in body string): Each entry is a **bare path string** — the default form; persist derives its assumption by probing the base branch and reports the derivation. Use the object form \`{ path, assumption }\` (\`assumption\` one of \`creates | refactors-existing | deletes\`) only to pin one yourself, and always for a \`deletes\`, which a bare path can never express. **Name the files the deliverer authors, and omit generated artifacts** — quality baselines, generated test indexes, migration journals, lockfiles and the like are regenerated by the work itself, the refresh is a close-gate concern, and declaring one needlessly reserves a footprint that serializes sibling Stories at dispatch. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Pin \`refactors-existing\` for an in-place edit, \`creates\` for a net-new file, \`deletes\` for a removal, whenever the probe would get it wrong. Persist probes every path against the base branch and derives the assumption for you, so a \`creates\` on an existing path or a \`refactors-existing\` on an absent one is a dry-run warning; only a \`deletes\` naming an absent path is refused.
141
142
  - **acceptance** (top-level array on the ticket object): Each item is an **outcome a PR reviewer can confirm from the diff and the verify output** — what is true of the codebase once the Story lands, stated at the altitude of the capability (a command that now exits 0 against a named input, a behavior a named test now asserts, a config that now fails validation on a retired key, a document that now records a decision). State as many outcomes as the capability has and no more — the list has no target, floor or ceiling, and a long one is never a reason to split the Story. Push grep-shaped probes, file-exists checks and exit-code tests down into \`verify[]\`; never pin an internal helper name or a private file path into an acceptance item the advisory \`changes[]\` is free to reshape. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec".
142
- - **verify** (top-level array on the ticket object): The **mechanical checks** — exact commands or test paths the deliverer runs and the acceptance critic consumes as evidence: \`node --test tests/x.test.js\`, \`npm run lint\`, \`npm run validate\`, a scoped grep. Every acceptance item should be confirmable from at least one verify entry's output plus the diff. Stories with zero verify entries fail validation.
143
+ - **verify** (top-level array on the ticket object): The **mechanical checks** — exact commands or test paths the deliverer runs and the acceptance critic consumes as evidence: \`node --test tests/x.test.js\`, \`npm run lint\`, \`npm run validate\`, a scoped grep. Every acceptance item should be confirmable from at least one verify entry's output plus the diff. A Story with no verify entry is warned about, not rejected — but the critic then has nothing to read as evidence, so author the commands.
143
144
  - **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, and the dry-run warns on every one it finds. Triage each unknown by who can resolve it: an AFK-shaped unknown (a fact in docs, a third-party API surface, observable repo behavior) MUST be resolved by your own research before authoring — never restated as an assumption; only a HITL-shaped unknown (a genuine product or architecture call the operator owns) may be restated as a declarative Key Assumption the agent can act on, stating the default chosen (a decision-made-by-default).
144
145
  - **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.
145
146
 
@@ -180,19 +181,10 @@ ${envelopeFloor}
180
181
  - **A remediation sweep over one subsystem is one Story.** A batch of findings in the same subsystem shares one reason to exist — the subsystem is wrong — so it arrives as one Story whose \`## Slicing\` checkpoints carry the stages, not as one Story per finding.
181
182
  - ${singleConsumerRule}
182
183
 
183
- #### UI / TESTID INVARIANCE (per CLAUDE.md safety rule):
184
+ #### UI AND COPY WORK — where the contract is written down:
184
185
 
185
- Every \`changes[]\` entry is a \`{ path, assumption }\` object — a prose bullet there is rejected by the parser, so the testid contract is carried where prose belongs:
186
-
187
- - Stories that touch UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, components folders) MUST carry the testid contract as a top-level \`acceptance[]\` item, one of:
188
- - \`"data-testid invariance: <list of testids that MUST be preserved>"\`, or
189
- - \`"data-testid changes: <old> -> <new>, with the matching tests/e2e/*.spec.ts selector updated"\` — paired with that \`tests/e2e/*.spec.ts\` file in \`changes[]\`, in the same Story or a depends_on Story.
190
- - State the preserved-testid set in \`## Non-Goals\` prose as well when the Story deliberately renames nothing.
191
- - Renaming a testid without the matching e2e edit is FORBIDDEN.
192
-
193
- #### BRAND / COPY / STYLE WORK:
194
-
195
- - Stories that touch user-visible copy, brand assets, or visual style MUST cite the relevant section of \`docs/style-guide.md\` in \`acceptance\` (e.g. \`"acceptance": ["Hero copy matches docs/style-guide.md §3 (voice & tone)"]\`). If \`docs/style-guide.md\` does not exist or has no relevant section, state that explicitly: \`"acceptance": ["docs/style-guide.md absent — copy reviewed against the inline brand brief in the plan seed"]\`. Silence on style sourcing is a smell.
186
+ - A Story touching UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, a components folder) states the \`data-testid\` contract in \`acceptance[]\` per the testid contract in \`.agents/skills/stack/qa/playwright/SKILL.md\`.
187
+ - A Story touching user-visible copy, brand assets or visual style cites the relevant section of \`docs/style-guide.md\` in \`acceptance[]\` when that file exists.
196
188
 
197
189
  CRITICAL: Dependencies should follow execution blockers. There is no parent ticket — never emit a 'parent_slug' field.
198
190
  IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depends_on\` (one Story depends_on another Story's slug). Use this to express execution ordering across the plan.
@@ -101,22 +101,37 @@ import { classifyStory, storyIdOf } from './ready-set.js';
101
101
  * Ids outside the probed set are ignored: they are not part of this run and
102
102
  * must not consume its cap.
103
103
  *
104
+ * The second arm is also reported on its own, as `unlabelled`. A claimed id
105
+ * that still reads `agent::ready` is in flight either because its init is
106
+ * still running or because the spawn that claimed it never started one, and
107
+ * those two are indistinguishable here — but only the second pins the id in
108
+ * the caller's ledger forever. Live state cannot tell them apart; the operator
109
+ * can, so the fact is surfaced rather than acted on (Story #5363).
110
+ *
104
111
  * @param {Array<{id?: number, number?: number, labels?: string[], state?: string}>} storyRecords
105
112
  * @param {Iterable<number>} [dispatched] Ids the host has spawned.
106
- * @returns {Set<number>} In-flight Story ids.
113
+ * @returns {{inFlight: Set<number>, unlabelled: Set<number>}} In-flight Story
114
+ * ids, and the subset of them claimed by the caller that live state still
115
+ * reports as `agent::ready`.
107
116
  */
108
117
  function deriveInFlightIds(storyRecords, dispatched = []) {
109
118
  const claimed = new Set(dispatched);
110
- const inFlight = new Set();
119
+ // Bucketed by live class rather than tested twice. The admission rule is
120
+ // unchanged; it is only that the `ready` bucket IS the claimed-but-unlabelled
121
+ // set, since `ready` is the one class the rule admits on the caller's claim.
122
+ const byClass = { executing: new Set(), ready: new Set() };
111
123
  for (const rec of storyRecords) {
112
124
  const id = storyIdOf(rec);
113
125
  if (id === null) continue;
114
126
  const cls = classifyStory(rec);
115
127
  if (cls === 'executing' || (claimed.has(id) && cls === 'ready')) {
116
- inFlight.add(id);
128
+ byClass[cls].add(id);
117
129
  }
118
130
  }
119
- return inFlight;
131
+ return {
132
+ inFlight: new Set([...byClass.executing, ...byClass.ready]),
133
+ unlabelled: byClass.ready,
134
+ };
120
135
  }
121
136
 
122
137
  /**
@@ -251,6 +266,7 @@ export function createProbeContext({
251
266
  * doneIds: Set<number>,
252
267
  * inFlight: number,
253
268
  * blockedIds: number[],
269
+ * stalledDispatch: number[],
254
270
  * foreignHeld: Array<{id: number, holder: string}>
255
271
  * }>}
256
272
  */
@@ -294,7 +310,10 @@ export async function probeLiveState({
294
310
  // They are consumed in-process by `planReadySet` and never serialized into
295
311
  // the beat envelope, which stays a list of ids.
296
312
  const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
297
- const inFlightIds = deriveInFlightIds(stories, dispatched);
313
+ const { inFlight: inFlightIds, unlabelled } = deriveInFlightIds(
314
+ stories,
315
+ dispatched,
316
+ );
298
317
  // A Story another operator's lease holds occupies a (global) dispatch slot
299
318
  // just like an in-flight one: fold it into the in-flight set so it is both
300
319
  // withheld (via the projected label) and excluded from a false wedge, but
@@ -322,6 +341,13 @@ export async function probeLiveState({
322
341
  doneIds: new Set(envelope.done),
323
342
  inFlight: inFlightIds.size,
324
343
  blockedIds: deriveBlockedIds(stories),
344
+ // Claimed by the caller, still labelled `agent::ready`: a live init window
345
+ // or a spawn that never reached one. Reported, never released here. A
346
+ // foreign lease is its own withhold reason and outranks the claim, so it
347
+ // is subtracted — one Story must not carry two recoveries.
348
+ stalledDispatch: [...unlabelled]
349
+ .filter((id) => !foreignHeld.has(id))
350
+ .sort((a, b) => a - b),
325
351
  foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
326
352
  };
327
353
  }
@@ -36,9 +36,8 @@
36
36
  *
37
37
  * ## Not every `baselines/*.json` is an envelope
38
38
  *
39
- * That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger,
40
- * context-budget and workflow-citations — files with their own shapes and no
41
- * row identity. Anything whose `$schema` is not a known per-kind envelope is
39
+ * That glob also matches arch-cycles, cyclomatic, dead-exports, audit-ledger
40
+ * and context-budget — files with their own shapes and no row identity. Anything whose `$schema` is not a known per-kind envelope is
42
41
  * handed straight back to `git merge-file`, so registering the driver cannot
43
42
  * change their behaviour.
44
43
  */
@@ -164,8 +163,8 @@ function delegateToGit(basePath, oursPath, theirsPath) {
164
163
  * `git merge-file` even though `.gitattributes` routes them here, so the
165
164
  * attribute promised a row merge the driver never performed.
166
165
  *
167
- * Anything else — arch-cycles, audit-ledger, context-budget,
168
- * workflow-citations — resolves `null` and is handed back to git unchanged.
166
+ * Anything else — arch-cycles, audit-ledger, context-budget — resolves `null`
167
+ * and is handed back to git unchanged.
169
168
  *
170
169
  * @param {unknown} ours
171
170
  * @param {unknown} theirs