mandrel 2.57.0 → 2.58.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 (24) hide show
  1. package/.agents/agents/story-worker.md +12 -11
  2. package/.agents/scripts/evidence-gate.js +17 -1
  3. package/.agents/scripts/lib/orchestration/code-review.js +7 -3
  4. package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
  5. package/.agents/scripts/lib/orchestration/plan-context.js +11 -3
  6. package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
  7. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +6 -1
  8. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +14 -9
  9. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +17 -7
  10. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +15 -5
  11. package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
  12. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
  13. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
  14. package/.agents/scripts/lib/story-body/story-body.js +36 -2
  15. package/.agents/scripts/lib/templates/decomposer-prompts.js +53 -4
  16. package/.agents/scripts/lib/test-run-credit.js +23 -12
  17. package/.agents/workflows/helpers/deliver-digest.md +22 -15
  18. package/.agents/workflows/helpers/deliver-story-reference.md +31 -11
  19. package/.agents/workflows/helpers/deliver-story.md +6 -5
  20. package/.agents/workflows/helpers/plan-reference.md +35 -6
  21. package/.agents/workflows/mandrel-plan.md +6 -2
  22. package/docs/CHANGELOG.md +13 -0
  23. package/lib/cli/registry.js +98 -2
  24. package/package.json +1 -1
@@ -83,21 +83,22 @@ Do **not** re-read every file in `project.docsContextFiles`. Read the
83
83
  digest your caller passes, then pull files on demand at the lines it
84
84
  names. A null digest path means no docs mandate.
85
85
 
86
- ## Close gates — one full-suite run
86
+ ## Close gates — one credited run
87
87
 
88
88
  `single-story-close.js` runs the canonical close-validation chain
89
89
  (**typecheck, lint, test, format, maintainability, coverage, crap**) and is
90
90
  the authoritative gate — do not pre-run it. The **one** exception is the
91
- full suite: after the self-eval loop's last fix commit, run `npm test`
92
- exactly once in `<workCwd>`. A green full run on `story-<storyId>`
93
- deposits the `test` credit close reads, keyed on the tree; a later commit
94
- voids it, and close captures coverage itself when the CRAP gate needs an
95
- artifact. If the suite outruns the host's sync Bash ceiling, dispatch it in
96
- the **background** — its completion notification re-invokes you. Never
97
- spawn a task to poll or `sleep`-loop against it; a waiter with a wrong
98
- condition outlives the agent. An exit code is never evidence a gate did
99
- work — the runner's **output** is: it prints whether it deposited credit,
100
- and a run off the Story branch or of a partial tier deposits nothing and
91
+ full suite: after the self-eval loop's last fix commit, run it once in
92
+ `<workCwd>` through the depositor — `evidence-gate.js --standalone
93
+ --scope-id <storyId> --gate test --worktree <workCwd> -- npm test`. It runs
94
+ whatever `npm test` resolves to and stamps that, so the credit is earned on
95
+ any runner; a later commit voids it, and close captures coverage itself when
96
+ the CRAP gate needs an artifact. If the suite outruns the host's sync Bash
97
+ ceiling, dispatch it in the **background** — its completion notification
98
+ re-invokes you. Never spawn a task to poll or `sleep`-loop against it; a
99
+ waiter with a wrong condition outlives the agent. An exit code is never
100
+ evidence a gate did work — the **output** is: a bare `npm test` deposits
101
+ nothing unless it routes through mandrel's own runner, the only shape that
101
102
  says so. Redraft rounds run the scoped projects for the roots you changed
102
103
  plus `verify[]`, not the whole suite. Share `lint` / `typecheck` evidence
103
104
  with close via `evidence-gate.js`; never stamp coverage / CRAP fresh any
@@ -269,7 +269,7 @@ runAsCli(import.meta.url, main, {
269
269
  source: 'evidence-gate',
270
270
  usage: {
271
271
  invocation:
272
- 'node .agents/scripts/evidence-gate.js --scope-id <id> --gate <name> [--standalone] [--no-evidence] [--cwd <path>] [--worktree <path>]',
272
+ 'node .agents/scripts/evidence-gate.js --scope-id <id> --gate <name> [--standalone] [--no-evidence] [--cwd <path>] [--worktree <path>] -- <cmd> [args...]',
273
273
  summary:
274
274
  'Run one named gate, reusing a prior evidence stamp for the same HEAD instead of re-running it.',
275
275
  flags: [
@@ -282,6 +282,22 @@ runAsCli(import.meta.url, main, {
282
282
  ],
283
283
  ['--cwd <path>', 'Repository root (default: project root).'],
284
284
  ['--worktree <path>', 'Worktree the gate runs in.'],
285
+ [
286
+ '-- <cmd> [args...]',
287
+ 'Required. The command this gate runs, passed through verbatim (never via a shell).',
288
+ ],
289
+ ],
290
+ notes: [
291
+ [
292
+ 'Everything after the first `--` is the gate. The stamp therefore describes',
293
+ 'what actually ran, whatever that is — which is how a project on any test',
294
+ 'runner earns the close `test` credit:',
295
+ '',
296
+ ' node .agents/scripts/evidence-gate.js --standalone --scope-id 4250 \\',
297
+ ' --gate lint --worktree .worktrees/story-4250 -- npm run lint',
298
+ ' node .agents/scripts/evidence-gate.js --standalone --scope-id 4250 \\',
299
+ ' --gate test --worktree .worktrees/story-4250 -- npm test',
300
+ ].join('\n'),
285
301
  ],
286
302
  },
287
303
  });
@@ -42,6 +42,7 @@
42
42
  import { hasSurvivingCritical } from '../audit-suite/findings.js';
43
43
  import { resolveConfig } from '../config-resolver.js';
44
44
  import { computeChangeSet } from './change-set.js';
45
+ import { remoteBaseRef } from './review-base-ref.js';
45
46
  import { deriveChangeLevel, resolveDepth } from './review-depth.js';
46
47
  import {
47
48
  collectProviderDegradations,
@@ -70,11 +71,14 @@ import { upsertStructuredComment } from './ticketing.js';
70
71
  */
71
72
 
72
73
  /**
73
- * Resolve the project base branch fallback used when a caller omits
74
- * `baseRef`.
74
+ * Resolve the base ref used when a caller omits `baseRef`. Remote-qualified,
75
+ * never the bare branch name — a caller that does not name a base must not
76
+ * silently inherit the local ref's drift (Story #5325). An unfetched remote
77
+ * then yields an unenumerable diff, which every downstream consumer already
78
+ * fails safe on, instead of a confidently-wrong wide one.
75
79
  */
76
80
  function resolveConfigBase(config) {
77
- return config?.project?.baseBranch ?? 'main';
81
+ return remoteBaseRef(config?.project?.baseBranch ?? 'main');
78
82
  }
79
83
 
80
84
  /** Positive-integer override, else the supplied default. */
@@ -0,0 +1,137 @@
1
+ /**
2
+ * pinned-identifier-lint.js — the `pinned-identifier` advisory lint over a
3
+ * draft Story's `acceptance[]` (Story #5323).
4
+ *
5
+ * `acceptance[]` is the Story's **binding** contract and `changes[]` only an
6
+ * advisory sketch the deliverer may revise, so an acceptance item naming an
7
+ * internal symbol pins something the executor is free to rename out from
8
+ * under it. The story-author prompt has always said so; nothing surfaced a
9
+ * violation, which left the rule enforced only by the authoring model
10
+ * remembering it.
11
+ *
12
+ * The classifier lives in its own module because its vocabulary is its own:
13
+ * four token grammars and a call-suffix strip that the sibling prose lint in
14
+ * `plan-text-hygiene.js` shares nothing with.
15
+ *
16
+ * Advisory by contract: findings are deterministic text for the persist
17
+ * dry-run's warning list. They never gate persist and spawn nothing.
18
+ *
19
+ * Pure, synchronous, no I/O.
20
+ *
21
+ * @module lib/orchestration/pinned-identifier-lint
22
+ */
23
+
24
+ /**
25
+ * An inline code span carrying one of these is naming something other than a
26
+ * source identifier, and is never a pinned identifier:
27
+ *
28
+ * - `/` — a file path or a glob (`src/app.js`, `tests/x/*.spec.ts`);
29
+ * - `.` — a dotted path, a filename, or a config key
30
+ * (`delivery.routing.closeAndLand`, `story-body.js`);
31
+ * - `:` — a label (`agent::ready`, `type::story`);
32
+ * - `-` — a kebab token: a `data-testid` value, a slug, a package name, or
33
+ * a CLI flag (`--dry-run`);
34
+ * - whitespace — an argv shape, so a command (`npm run lint`);
35
+ * - `[` / `]` — a field reference (`acceptance[]`).
36
+ */
37
+ const NON_IDENTIFIER_MARKERS = /[/.:\-\s[\]]/;
38
+
39
+ /** A bare source identifier, with an optional call suffix stripped. */
40
+ const BARE_IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
41
+
42
+ /**
43
+ * An UPPER_SNAKE token. Exempt by contract: an environment variable and an
44
+ * internal constant are the same shape, and warning on every `DATABASE_URL`
45
+ * to catch the occasional pinned constant trades a real class of false
46
+ * positives for a marginal gain.
47
+ */
48
+ const UPPER_SNAKE_RE = /^[A-Z0-9_$]+$/;
49
+
50
+ /**
51
+ * A case transition (`staffCan`, `MyCalendarBoard`, `TimeGrid`) — the
52
+ * signature that separates a source identifier from a prose word a criterion
53
+ * legitimately quotes (`landed`, `pending`, `main`).
54
+ */
55
+ const CASE_TRANSITION_RE = /[a-z][A-Z]/;
56
+
57
+ /** The remedy, either side of the identifiers a finding names. */
58
+ const MESSAGE_HEAD = 'Acceptance item pins the internal identifier(s) ';
59
+ const MESSAGE_TAIL =
60
+ '; `changes[]` is an advisory sketch the deliverer may reshape, so assert ' +
61
+ 'the observable behaviour instead of the symbol that implements it.';
62
+
63
+ /**
64
+ * Collect the inline code spans of one acceptance item.
65
+ *
66
+ * @param {string} item
67
+ * @returns {string[]}
68
+ */
69
+ function codeSpans(item) {
70
+ return [...String(item ?? '').matchAll(/`([^`\n]+)`/g)].map((m) => m[1]);
71
+ }
72
+
73
+ /**
74
+ * Decide whether one code span pins a source identifier: a bare token
75
+ * carrying a case transition, with no separator that would make it a path, a
76
+ * label, a kebab token, a flag or a command, and not an UPPER_SNAKE name.
77
+ *
78
+ * Deliberately conservative in one direction only: a false positive costs a
79
+ * warning line the author dismisses, while a false negative on a path, a
80
+ * testid or a command would train the author to ignore the lint.
81
+ *
82
+ * @param {string} span
83
+ * @returns {boolean}
84
+ */
85
+ function isPinnedIdentifier(span) {
86
+ const token = span.trim().replace(/\(\s*\)$/, '');
87
+ return (
88
+ !NON_IDENTIFIER_MARKERS.test(token) &&
89
+ BARE_IDENTIFIER_RE.test(token) &&
90
+ !UPPER_SNAKE_RE.test(token) &&
91
+ CASE_TRANSITION_RE.test(token)
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The authored `acceptance[]` of one draft Story.
97
+ *
98
+ * It is authored at the ticket's top level — the machine contract the
99
+ * validators read — and synced into the body only at assembly, so the top
100
+ * level wins; the parsed body covers a draft that carries it inline.
101
+ *
102
+ * @param {object} story The raw draft ticket.
103
+ * @param {object} body Its parsed body.
104
+ * @returns {string[]}
105
+ */
106
+ function resolveAcceptance(story, body) {
107
+ if (Array.isArray(story?.acceptance)) return story.acceptance;
108
+ return Array.isArray(body?.acceptance) ? body.acceptance : [];
109
+ }
110
+
111
+ /**
112
+ * Evaluate the lint over one draft Story — one finding per offending
113
+ * acceptance item, naming every identifier it pinned.
114
+ *
115
+ * @param {object} story The raw draft ticket.
116
+ * @param {object} body Its parsed body.
117
+ * @param {string} slug
118
+ * @param {(text: string) => string} excerpt Evidence truncator, shared with
119
+ * the sibling lints so every finding excerpts alike.
120
+ * @returns {Array<{ kind: 'pinned-identifier', slug: string, evidence: string, message: string }>}
121
+ */
122
+ export function findPinnedIdentifiers(story, body, slug, excerpt) {
123
+ const acceptance = resolveAcceptance(story, body);
124
+ const findings = [];
125
+ for (const item of acceptance) {
126
+ const pinned = codeSpans(item).filter(isPinnedIdentifier);
127
+ if (pinned.length === 0) continue;
128
+ const named = pinned.map((name) => `\`${name}\``).join(', ');
129
+ findings.push({
130
+ kind: 'pinned-identifier',
131
+ slug,
132
+ evidence: excerpt(String(item ?? '')),
133
+ message: `${MESSAGE_HEAD}${named}${MESSAGE_TAIL}`,
134
+ });
135
+ }
136
+ return findings;
137
+ }
@@ -29,6 +29,7 @@ import { parse as parseStoryBody } from '../story-body/story-body.js';
29
29
  import {
30
30
  renderStoryAuthorCore,
31
31
  renderStorySplitRules,
32
+ ticketsModePromptField,
32
33
  } from '../templates/decomposer-prompts.js';
33
34
  import {
34
35
  renderAcceptanceSpecSystemPrompt,
@@ -640,14 +641,21 @@ function withAdvisorySignals(complexitySignals, { config, cwd } = {}) {
640
641
  * rules a planner reads only when the default-single split policy clears
641
642
  * carried separately as `storySplitRules` (Story #5312).
642
643
  *
643
- * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string }}
644
+ * `storyTicketsRules` is the one mode-conditional field (Story #5323): it
645
+ * only means anything when the seed is an existing ticket, and an envelope
646
+ * that carries it in every mode teaches the author to look for a source
647
+ * ticket that a `--seed` run does not have.
648
+ *
649
+ * @param {{ mode?: string }} [args]
650
+ * @returns {{ spec: string, acceptance: string, story: string, storySplitRules: string, storyTicketsRules?: string }}
644
651
  */
645
- export function buildSystemPrompts() {
652
+ export function buildSystemPrompts({ mode } = {}) {
646
653
  return {
647
654
  spec: renderTechSpecSystemPrompt(),
648
655
  acceptance: renderAcceptanceSpecSystemPrompt(),
649
656
  story: renderStoryAuthorCore(),
650
657
  storySplitRules: renderStorySplitRules(),
658
+ ...ticketsModePromptField(mode),
651
659
  };
652
660
  }
653
661
 
@@ -1007,7 +1015,7 @@ async function buildTicketsModeEnvelope({
1007
1015
  memoryPoolAdvisory: authoring.memoryPoolAdvisory,
1008
1016
  priorFeedback: authoring.priorFeedback,
1009
1017
  ticketSchema: TICKET_SCHEMA_DESCRIPTOR,
1010
- systemPrompts: buildSystemPrompts(),
1018
+ systemPrompts: buildSystemPrompts({ mode: 'tickets' }),
1011
1019
  planState: null,
1012
1020
  planProfile:
1013
1021
  ticketIds.length === 1 ? 'story-default' : 'story-from-tickets',
@@ -0,0 +1,107 @@
1
+ /**
2
+ * acceptance-handle-repair.js — repair-before-judging for the `AC-<n>:`
3
+ * presentation handle on authored acceptance items (Story #5323).
4
+ *
5
+ * The handle belongs to the body renderer, which numbers each checkbox from
6
+ * its position in `acceptance[]` (`story-body.js`). An author planning from
7
+ * an existing ticket reads that rendered body as a template and carries the
8
+ * handle forward, so the persisted checkbox reads `- [ ] AC-1: AC-1: …`; a
9
+ * lettered handle copied out of a hand-edited source (`AC-14a:`) misnumbers
10
+ * the rest of the list against its own text.
11
+ *
12
+ * The correction is mechanical and total — strip the handle the renderer
13
+ * will re-apply — so this module applies it and **reports** it, exactly as
14
+ * `changes-repair.js` does for the `{ path, assumption }` formality. Charging
15
+ * the author a re-draft round to paste back text the strip already produced
16
+ * buys nothing.
17
+ *
18
+ * It lives beside the validator rather than inside it for the same reason
19
+ * `changes-repair.js` does: the validator's job is to judge, and mixing a
20
+ * mutating repair pass into a module of pure collectors muddies both.
21
+ * `persist-helpers.js#validateTickets` calls this first, then the validators.
22
+ *
23
+ * @module lib/orchestration/plan-persist/acceptance-handle-repair
24
+ */
25
+
26
+ import { stripAcceptanceHandle } from '../../story-body/story-body.js';
27
+ import { renderChangeRepair } from './changes-repair.js';
28
+
29
+ /**
30
+ * Strip the handle off one surface's `acceptance[]`, recording each distinct
31
+ * strip on `repairs`.
32
+ *
33
+ * @param {object} surface An object that may carry an `acceptance` array.
34
+ * @param {string} slug
35
+ * @param {Set<string>} reported Items already reported for this ticket.
36
+ * @param {object[]} repairs Accumulator, mutated.
37
+ * @returns {void}
38
+ */
39
+ function repairSurface(surface, slug, reported, repairs) {
40
+ if (!Array.isArray(surface.acceptance)) return;
41
+ surface.acceptance = surface.acceptance.map((item) => {
42
+ const { text, stripped } = stripAcceptanceHandle(item);
43
+ if (!stripped) return item;
44
+ const from = String(item ?? '');
45
+ if (!reported.has(from)) {
46
+ reported.add(from);
47
+ repairs.push({ kind: 'acceptance-handle', slug, from, to: text });
48
+ }
49
+ return text;
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Strip the presentation `AC-<n>:` handle off every authored `acceptance[]`
55
+ * item, on both surfaces that can carry one: the ticket's top-level array
56
+ * (the machine contract) and a structured body's.
57
+ *
58
+ * A serialized **string** body needs no pass of its own — `parse()` strips
59
+ * the handle with the same grammar this does, so the two surfaces converge
60
+ * on one list whichever carried the handle, and the contract sync (which
61
+ * fails closed on a disagreement) sees them agree.
62
+ *
63
+ * Mutates `tickets` in place — the persist pipeline threads this same array
64
+ * on to assembly. Total: a non-array argument and non-Story tickets are
65
+ * no-ops.
66
+ *
67
+ * @param {object[]} tickets
68
+ * @returns {Array<{ kind: 'acceptance-handle', slug: string, from: string, to: string }>}
69
+ */
70
+ export function normalizeAcceptanceHandles(tickets) {
71
+ const repairs = [];
72
+ for (const ticket of Array.isArray(tickets) ? tickets : []) {
73
+ if (!ticket || typeof ticket !== 'object' || ticket.type !== 'story') {
74
+ continue;
75
+ }
76
+ const slug = ticket.slug ?? ticket.title ?? '<unknown>';
77
+ // A ticket normally carries the same list on both surfaces, so report
78
+ // each distinct item once — the operator reads one correction, not two.
79
+ const reported = new Set();
80
+ repairSurface(ticket, slug, reported, repairs);
81
+ const body = ticket.body;
82
+ if (body && typeof body === 'object') {
83
+ repairSurface(body, slug, reported, repairs);
84
+ }
85
+ }
86
+ return repairs;
87
+ }
88
+
89
+ /**
90
+ * Render one entry of the dry-run's mixed repair list.
91
+ *
92
+ * The list carries two kinds — a `changes[]` entry repaired by probing base,
93
+ * and an `acceptance[]` item whose handle was normalised off — and both are
94
+ * mechanical corrections the run applied on the author's behalf, so both
95
+ * belong on the one list the operator reads. This module owns the dispatch
96
+ * because it owns the newer kind: it renders its own line and delegates
97
+ * every other kind to `changes-repair.js`, so neither producer has to know
98
+ * the other's shape.
99
+ *
100
+ * @param {{ kind?: string, slug: string, from: string, to?: string }} repair
101
+ * @returns {string}
102
+ */
103
+ export function renderRepair(repair) {
104
+ if (repair.kind !== 'acceptance-handle') return renderChangeRepair(repair);
105
+ const { slug, from, to } = repair;
106
+ return `Story "${slug}": acceptance[] item "${from}" carried an AC-<n> handle — normalised to "${to}"; the body renderer numbers the checkboxes.`;
107
+ }
@@ -261,7 +261,12 @@ function repairTicket(ticket, existsAtBase) {
261
261
  }
262
262
 
263
263
  /**
264
- * Render one repair as the dry-run line the operator reads.
264
+ * Render one `changes[]` repair as the dry-run line the operator reads.
265
+ *
266
+ * The dry-run's repair list is mixed — an `acceptance[]` handle strip is
267
+ * reported on it too — but the dispatch across kinds lives in
268
+ * [`acceptance-handle-repair.js`](acceptance-handle-repair.js)`#renderRepair`,
269
+ * which delegates here for this kind. Each producer owns its own line.
265
270
  *
266
271
  * @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
267
272
  * @returns {string}
@@ -10,9 +10,10 @@
10
10
  * checkout on CI has no local `main`), else nothing — a shallow checkout
11
11
  * with no base at all skips the probes instead of reading every path as
12
12
  * absent.
13
- * - `validateTickets(tickets, config, opts)` — repairs the mechanical
14
- * `changes[]` formalities against the base branch, then runs the
15
- * cross-link, freshness, and task-body validators in one pass.
13
+ * - `validateTickets(tickets, config, opts)` — normalises the authored
14
+ * `acceptance[]` handles and repairs the mechanical `changes[]`
15
+ * formalities against the base branch, then runs the cross-link,
16
+ * freshness, and task-body validators in one pass.
16
17
  *
17
18
  * Story #5312 deleted the fan-out probe that lived here: the delete
18
19
  * blast-radius count never refused a real plan, and the `git grep` it paid
@@ -24,6 +25,7 @@
24
25
  import { gitSpawn } from '../../git-utils.js';
25
26
  import { validateTaskBodies } from '../task-body-validator.js';
26
27
  import { validateAndNormalizeTickets } from '../ticket-validator.js';
28
+ import { normalizeAcceptanceHandles } from './acceptance-handle-repair.js';
27
29
  import { repairChangeEntries } from './changes-repair.js';
28
30
 
29
31
  /**
@@ -167,13 +169,16 @@ function defineHidden(validated, extras) {
167
169
  export function validateTickets(tickets, config, opts = {}) {
168
170
  const baseBranch = resolveBaseBranchRef(config);
169
171
  const baseBranchRef = resolveProbeRef({ baseBranch, cwd: opts.cwd });
170
- const repairs = repairChangeEntries(tickets, {
171
- existsAtBase: makeExistsAtBase({
172
- baseBranchRef,
173
- cwd: opts.cwd,
174
- gitRunner: opts.gitRunner,
172
+ const repairs = [
173
+ ...normalizeAcceptanceHandles(tickets),
174
+ ...repairChangeEntries(tickets, {
175
+ existsAtBase: makeExistsAtBase({
176
+ baseBranchRef,
177
+ cwd: opts.cwd,
178
+ gitRunner: opts.gitRunner,
179
+ }),
175
180
  }),
176
- });
181
+ ];
177
182
  const validated = validateAndNormalizeTickets(tickets, {
178
183
  baseBranchRef: baseBranchRef ?? undefined,
179
184
  gitRunner: opts.gitRunner,
@@ -62,8 +62,8 @@ import {
62
62
  conflictFindingKey,
63
63
  } from '../ticket-validator-conflicts.js';
64
64
  import { upsertStructuredComment } from '../ticketing.js';
65
+ import { renderRepair } from './acceptance-handle-repair.js';
65
66
  import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
66
- import { renderChangeRepair } from './changes-repair.js';
67
67
  import {
68
68
  resolveContainerEpic,
69
69
  resolveCrossPlanLinks,
@@ -146,7 +146,7 @@ function enforceTicketValidation(validated) {
146
146
  );
147
147
  }
148
148
  const warnings = [
149
- ...(validated.repairs ?? []).map((repair) => renderChangeRepair(repair)),
149
+ ...(validated.repairs ?? []).map(renderRepair),
150
150
  ...(validated.warnings ?? []),
151
151
  ];
152
152
  return {
@@ -170,18 +170,28 @@ function freshnessCounts(probeRef, warnings) {
170
170
  return { stale: warnings.length, ambiguous: 0 };
171
171
  }
172
172
 
173
+ /** How each text-hygiene finding kind names itself on the warning list. */
174
+ const TEXT_HYGIENE_LABELS = {
175
+ 'open-question': 'open question in body',
176
+ 'pinned-identifier': 'pinned identifier in acceptance[]',
177
+ };
178
+
173
179
  /**
174
- * The `open-question` lint over the draft bodies (Story #5312) — an
180
+ * The advisory text-hygiene lints over the draft (Story #5312, #5323) — an
175
181
  * operator-directed question persisted into a Story a non-interactive
176
- * sub-agent executes. A warning the dry-run lists, never a refusal.
182
+ * sub-agent executes, and an acceptance item pinning an internal symbol the
183
+ * advisory `changes[]` may reshape. Warnings the dry-run lists, never
184
+ * refusals.
177
185
  *
178
186
  * @param {object[]} rawStories
179
187
  * @returns {string[]}
180
188
  */
181
- function collectOpenQuestionWarnings(rawStories) {
189
+ function collectTextHygieneWarnings(rawStories) {
182
190
  return evaluateTextHygiene({ draftStories: rawStories }).findings.map(
183
191
  (finding) =>
184
- `Story "${finding.slug}": open question in body — "${finding.evidence}". ${finding.message}`,
192
+ `Story "${finding.slug}": ${
193
+ TEXT_HYGIENE_LABELS[finding.kind] ?? finding.kind
194
+ } — "${finding.evidence}". ${finding.message}`,
185
195
  );
186
196
  }
187
197
 
@@ -595,7 +605,7 @@ export async function runPlanPersist({
595
605
  enforceTicketValidation(validated);
596
606
  const warnings = [
597
607
  ...validationWarnings,
598
- ...collectOpenQuestionWarnings(rawStories),
608
+ ...collectTextHygieneWarnings(rawStories),
599
609
  ];
600
610
  logWarnings(warnings);
601
611
 
@@ -1,6 +1,7 @@
1
1
  /**
2
- * plan-text-hygiene.js — the `open-question` lint over draft Story bodies
3
- * (Story #4599; narrowed to one lint by Story #5312).
2
+ * plan-text-hygiene.js — the advisory draft-Story lints: `open-question`
3
+ * over body prose (Story #4599; narrowed to one lint by Story #5312) and
4
+ * `pinned-identifier` over `acceptance[]` (Story #5323).
4
5
  *
5
6
  * A Story is executed by a non-interactive sub-agent, so an operator-directed
6
7
  * open question persisted into its body ("Flag if…", "TBD", "confirm with the
@@ -12,6 +13,11 @@
12
13
  * lints with the critic gate that surfaced them: both scored prose shape the
13
14
  * authoring model already judges, and neither ever changed a persisted body.
14
15
  *
16
+ * `pinned-identifier` is the one lint that scores the **binding** half of the
17
+ * ticket; its classifier lives in
18
+ * [`pinned-identifier-lint.js`](pinned-identifier-lint.js), which shares no
19
+ * vocabulary with the prose heuristic here.
20
+ *
15
21
  * Advisory by contract: findings are deterministic text for the dry-run's
16
22
  * warning list. They never gate persist and spawn nothing.
17
23
  *
@@ -24,6 +30,7 @@
24
30
  */
25
31
 
26
32
  import { parse } from '../story-body/story-body.js';
33
+ import { findPinnedIdentifiers } from './pinned-identifier-lint.js';
27
34
 
28
35
  /** Truncation length for the `evidence` excerpt on each finding. */
29
36
  const EVIDENCE_MAX_CHARS = 160;
@@ -41,7 +48,7 @@ const OPEN_QUESTION_MARKERS = [
41
48
 
42
49
  /**
43
50
  * @typedef {Object} TextHygieneFinding
44
- * @property {'open-question'} kind
51
+ * @property {'open-question'|'pinned-identifier'} kind
45
52
  * @property {string} slug - The draft Story's slug ('' when absent).
46
53
  * @property {string} evidence - Excerpt of the offending text.
47
54
  * @property {string} message - Human-readable, re-author-actionable text.
@@ -123,7 +130,7 @@ function findOpenQuestions(prose, slug) {
123
130
  }
124
131
 
125
132
  /**
126
- * Evaluate the open-question lint over a draft Story array.
133
+ * Evaluate the advisory lints over a draft Story array.
127
134
  *
128
135
  * @param {{ draftStories?: Array<object>|null }} args - The draft
129
136
  * `stories.json` array (raw Story objects with top-level `slug` /
@@ -146,7 +153,10 @@ export function evaluateTextHygiene({ draftStories = null } = {}) {
146
153
  }
147
154
  const goal = typeof body.goal === 'string' ? body.goal : '';
148
155
  const spec = typeof body.spec === 'string' ? body.spec : '';
149
- findings.push(...findOpenQuestions([goal, spec].join('\n'), slug));
156
+ findings.push(
157
+ ...findOpenQuestions([goal, spec].join('\n'), slug),
158
+ ...findPinnedIdentifiers(story, body, slug, excerpt),
159
+ );
150
160
  }
151
161
  return { findings };
152
162
  }