@gobing-ai/spur 0.3.76 → 0.3.77

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.
@@ -7,7 +7,7 @@
7
7
  "plugins": [
8
8
  {
9
9
  "name": "sp",
10
- "version": "0.3.76",
10
+ "version": "0.3.77",
11
11
  "source": "./plugins/sp"
12
12
  }
13
13
  ]
@@ -36,6 +36,10 @@
36
36
  "contract": "standard",
37
37
  "twin": "history-anatomy-cache.mjs"
38
38
  },
39
+ {
40
+ "rel": "inline-run-setup.ts",
41
+ "contract": "repo-only"
42
+ },
39
43
  {
40
44
  "rel": "inline-pipeline-parity-check.ts",
41
45
  "contract": "repo-only"
@@ -71,6 +71,7 @@ rules:
71
71
  - "packages/config/src/loader.ts" # sync resolveConfigFile() / schema specifier lookup
72
72
  - "packages/config/src/executor-update.ts" # atomic single-entry config update (task 0797): O_EXCL lock, fsync'd tmp+rename commit, lstat symlink refusal, mode preservation — ts-runtime FileSystem seam has no lstat/fsync/chmod/O_EXCL
73
73
  - "packages/app/src/services/project-start.ts" # sync CLI binary path checks before daemon spawn
74
+ - "apps/cli/src/commands/serve.ts" # sync resolveServeCwd() directory validation before server startup (task 0805 R2); mirrors task.ts sync-template exemption
74
75
 
75
76
  # Synchronous template & workflow resolvers:
76
77
  - "apps/cli/src/commands/task.ts" # sync loadTemplateContent/Bodies callbacks
@@ -270,12 +270,20 @@ states:
270
270
  # WorkflowAppService.run(); the wbs fallback keeps a driver-less invocation from writing to
271
271
  # a bare "-route-reason.txt". The log line carries the run id for the same reason — an
272
272
  # unattributed append is log scraping, which R5 explicitly rejects as evidence.
273
+ # 0804 R8: the run id must be a single safe filename component before it
274
+ # becomes REASON_FILE — unresolved interpolation ($ { } vars.), path
275
+ # separators and dot traversal are a failed action (nonzero, no artifact).
276
+ # NOTE: no `#` comments inside the folded scalar — `>-` joins all same-indent
277
+ # lines into ONE shell line, so an in-scalar `#` comments out the rest of the
278
+ # command (the original R8 draft broke exactly this way; the case statement
279
+ # must stay one line).
273
280
  - kind: shell
274
281
  options:
275
282
  command: >-
276
283
  mkdir -p .spur/run .spur/memory &&
277
284
  RUN_ID="$__runId" &&
278
285
  if [ -z "$RUN_ID" ]; then RUN_ID="pipeline-$wbs"; fi &&
286
+ case "$RUN_ID" in *'$'*|*'{'*|*'}'*|*vars.*|*/*|*'\'*|*..*) echo "route-reason: refusing unsafe run id: $RUN_ID" >&2; exit 1 ;; *) : ;; esac &&
279
287
  REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
280
288
  if [ "$mode" = "fast" ]; then
281
289
  echo "fast:evidence complete+consistent" > "$REASON_FILE";
@@ -335,7 +343,7 @@ states:
335
343
  RETRY_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
336
344
  printf '%s\n' "$RETRY_OUTPUT";
337
345
  if [ "$RETRY_RC" -eq 0 ]; then return 0; fi;
338
- if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked'; then return "$RETRY_RC"; fi;
346
+ if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked|SQLite database .*is busy|SQLITE_BUSY'; then return "$RETRY_RC"; fi;
339
347
  sleep 2;
340
348
  RETRY_SECOND_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
341
349
  printf '%s\n' "$RETRY_SECOND_OUTPUT";
@@ -431,7 +439,7 @@ states:
431
439
  ATTEMPT_LOG="$LOG_FILE.attempt-$gate_attempt";
432
440
  sh -c "$qualityGateCmd" > "$ATTEMPT_LOG" 2>&1; gate_rc=$?;
433
441
  gate_locked=0;
434
- grep -q 'SQLiteError: database is locked' "$ATTEMPT_LOG" && gate_locked=1;
442
+ grep -Eq 'SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY' "$ATTEMPT_LOG" && gate_locked=1;
435
443
  cat "$ATTEMPT_LOG" >> "$LOG_FILE";
436
444
  rm -f "$ATTEMPT_LOG";
437
445
  if [ "$gate_rc" -eq 0 ] || [ "$gate_locked" -ne 1 ] || [ "$gate_attempt" -ge 5 ]; then break; fi;
@@ -547,7 +555,7 @@ states:
547
555
  ATTEMPT_LOG="$LOG_FILE.attempt-$gate_attempt";
548
556
  sh -c "$qualityGateCmd" > "$ATTEMPT_LOG" 2>&1; gate_rc=$?;
549
557
  gate_locked=0;
550
- grep -q 'SQLiteError: database is locked' "$ATTEMPT_LOG" && gate_locked=1;
558
+ grep -Eq 'SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY' "$ATTEMPT_LOG" && gate_locked=1;
551
559
  cat "$ATTEMPT_LOG" >> "$LOG_FILE";
552
560
  rm -f "$ATTEMPT_LOG";
553
561
  if [ "$gate_rc" -eq 0 ] || [ "$gate_locked" -ne 1 ] || [ "$gate_attempt" -ge 5 ]; then break; fi;
@@ -747,7 +755,7 @@ states:
747
755
  RETRY_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
748
756
  printf '%s\n' "$RETRY_OUTPUT";
749
757
  if [ "$RETRY_RC" -eq 0 ]; then return 0; fi;
750
- if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked'; then return "$RETRY_RC"; fi;
758
+ if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked|SQLite database .*is busy|SQLITE_BUSY'; then return "$RETRY_RC"; fi;
751
759
  sleep 2;
752
760
  RETRY_SECOND_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
753
761
  printf '%s\n' "$RETRY_SECOND_OUTPUT";
@@ -794,7 +802,7 @@ states:
794
802
  RETRY_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
795
803
  printf '%s\n' "$RETRY_OUTPUT";
796
804
  if [ "$RETRY_RC" -eq 0 ]; then return 0; fi;
797
- if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked'; then return "$RETRY_RC"; fi;
805
+ if ! printf '%s' "$RETRY_OUTPUT" | grep -Eq 'ENOENT|EBUSY|ENOTEMPTY|database is locked|SQLite database .*is busy|SQLITE_BUSY'; then return "$RETRY_RC"; fi;
798
806
  sleep 2;
799
807
  RETRY_SECOND_OUTPUT=$("$@" 2>&1); RETRY_RC=$?;
800
808
  printf '%s\n' "$RETRY_SECOND_OUTPUT";
@@ -1016,8 +1024,13 @@ transitions:
1016
1024
  guard:
1017
1025
  kind: shell
1018
1026
  options:
1027
+ # 0804 R6: `--as done` projects the check onto the done target so open
1028
+ # Plan/AC checkboxes fail HERE — before the done action runs — mirroring
1029
+ # the CLI's own target-aware `--no-lifecycle` backstop (F92 R3). No `#`
1030
+ # comments inside the folded scalar: `>-` joins same-indent lines into
1031
+ # one shell line.
1019
1032
  command: >-
1020
- $spurBin task check $wbs &&
1033
+ $spurBin task check $wbs --as done &&
1021
1034
  test "$(jq -r .verdict .spur/run/$wbs-verdict.json 2>/dev/null)" = PASS &&
1022
1035
  test "$(jq -r '.proof.digest // ""' .spur/run/$wbs-verdict.json 2>/dev/null)" = "$proofDigest"
1023
1036
  - from: record
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
5
5
  "keywords": [
6
6
  "spur",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * inline-run-setup — authoritative inline full-pipeline run identity (task 0804 R1).
4
+ *
5
+ * Unlike the subprocess path (`spur workflow run`), the interactive inline driver
6
+ * allocated a run id but never persisted an authoritative `runs` row, so bound
7
+ * `run.artifact` registration (0785 R3) correctly refused every inline record. This
8
+ * script is the thin delegate the driver now runs at Run setup: it resolves the spur
9
+ * repo checkout from the SPUR_BIN chain, imports the real app service
10
+ * (`createOrAttachInlineRun` / `openInlineRunProjectDb` from packages/app), and lets it
11
+ * resolve the SAME project-or-bundled definition the engine would launch, compute the
12
+ * canonical definition digest with the exported hash machinery, and create-or-attach the
13
+ * run row through the existing engine persistence adapter.
14
+ *
15
+ * The script itself contains NO direct SQL, NO second hasher and NO persistence policy —
16
+ * every rule lives in packages/app (0804 D1). On a bundle-only install there is no repo
17
+ * checkout to import the app service from, so the setup fails closed with actionable
18
+ * remediation guidance (point SPUR_BIN at a repo checkout); it never falls back to an
19
+ * unbound run (0804 R1 failure policy).
20
+ *
21
+ * Outcome JSON is written to `.spur/run/<run-id>-inline-setup.json` so the driver can
22
+ * seed the inline var overlay (`__runId`, `__definitionDigest`) that proof capture and
23
+ * bound registration verify against. Exit 0 = authoritative identity ready (created or
24
+ * idempotently attached); exit 1 = fail closed, the driver must stop.
25
+ *
26
+ * Repo-only script (ADR-065): it imports the app workspace source, so it runs under bun
27
+ * against a monorepo checkout only — the same posture as task-size-precheck.ts and
28
+ * task-evidence-precheck.ts.
29
+ *
30
+ * Usage:
31
+ * bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]
32
+ *
33
+ * Env: SPUR_BIN
34
+ */
35
+
36
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
37
+ import { dirname, join, resolve } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ /** Outcome document written to `.spur/run/<run-id>-inline-setup.json`. */
41
+ interface SetupOutcome {
42
+ readonly ok: boolean;
43
+ readonly runId: string;
44
+ readonly attached?: boolean;
45
+ readonly definitionDigest?: string;
46
+ readonly workflowName?: string;
47
+ readonly workflowVersion?: string | null;
48
+ readonly resolvedPath?: string;
49
+ readonly layer?: string;
50
+ readonly workdir?: string;
51
+ readonly status?: string;
52
+ readonly error?: string;
53
+ }
54
+
55
+ function usage(): never {
56
+ console.error(
57
+ 'Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]',
58
+ );
59
+ process.exit(1);
60
+ }
61
+
62
+ /**
63
+ * The run id becomes a filename under `.spur/run/` (`<run-id>-inline-setup.json`), so it must be a
64
+ * single safe filename component before anything is written — the same guard class the
65
+ * task-pipeline.yaml route-reason action applies to `$__runId` (task 0804 R8). The allowlist
66
+ * refuses path separators, dot traversal (leading `.`), unresolved interpolation (`$`/`{`/`}`) and
67
+ * every other shell/unspecified metachar; valid UUID/timestamp-slug ids pass.
68
+ */
69
+ const SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70
+
71
+ function refuseUnsafeRunId(runId: string): never {
72
+ // Refuse BEFORE any outcome write: an unsafe id must never reach
73
+ // `.spur/run/<run-id>-inline-setup.json` (no traversal, no unintended file).
74
+ console.error(`inline-run-setup: refusing unsafe run id: ${runId}`);
75
+ console.error(
76
+ ' The run id must be a single safe filename component (alphanumeric/._-, no leading dot, ' +
77
+ 'no path separators, interpolation or traversal; same class as the task-pipeline ' +
78
+ 'route-reason guard, task 0804 R8). Allocate a fresh run id (uuid or timestamp slug) and retry.',
79
+ );
80
+ process.exit(1);
81
+ }
82
+
83
+ /**
84
+ * Resolve the spur repo checkout the same way the other prechecks resolve the CLI
85
+ * (--spur-bin > SPUR_BIN > monorepo-local CLI entry > PATH `spur`), then derive the repo
86
+ * root from the resolved main module. `bun <repo>/apps/cli/src/index.ts` → repo root is
87
+ * three levels up. A bundle-only install (`spur` on PATH, a bundled `spur.js`, or a spur
88
+ * binary without the app workspace) has no app entry to import — the caller fails that
89
+ * closed with remediation guidance.
90
+ */
91
+ function resolveAppEntry(
92
+ spurBin: string,
93
+ ): { entry: string; repoRoot: string } | { entry: null; repoRoot: null; chain: string } {
94
+ let candidates: string[] = [];
95
+ if (spurBin !== '') {
96
+ candidates = [spurBin];
97
+ } else {
98
+ // scripts/ -> plugins/sp/ -> <repo>/apps/cli/src/index.ts (fileURLToPath — raw
99
+ // pathname breaks on %-encoded paths, e.g. spaces in the checkout directory).
100
+ candidates = [fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url))];
101
+ }
102
+ for (const candidate of candidates) {
103
+ const tokens = candidate.split(/\s+/).filter(Boolean);
104
+ // The main module is the last path-like token (tolerates `bun <path>` /
105
+ // `bun run <path>` lead tokens). Only a TypeScript source entry proves a repo
106
+ // checkout; a bundled `spur.js` or a bare `spur` binary does not.
107
+ const mainModule = [...tokens].reverse().find((t) => t.endsWith('.ts'));
108
+ if (mainModule === undefined || !existsSync(mainModule)) continue;
109
+ // <repo>/apps/cli/src/index.ts → repo root; then require the app package source.
110
+ const srcDir = dirname(mainModule);
111
+ const repoRoot = resolve(srcDir, '..', '..', '..');
112
+ const appEntry = join(repoRoot, 'packages', 'app', 'src', 'index.ts');
113
+ if (existsSync(appEntry)) return { entry: appEntry, repoRoot };
114
+ return { entry: null, repoRoot: null, chain: `${candidate} (no ${appEntry})` };
115
+ }
116
+ return { entry: null, repoRoot: null, chain: spurBin === '' ? 'PATH spur (bundle-only install)' : spurBin };
117
+ }
118
+
119
+ function writeOutcome(runId: string, outcome: SetupOutcome): void {
120
+ const runDir = join(process.cwd(), '.spur', 'run');
121
+ if (!existsSync(runDir)) mkdirSync(runDir, { recursive: true });
122
+ writeFileSync(join(runDir, `${runId}-inline-setup.json`), `${JSON.stringify(outcome, null, 4)}\n`);
123
+ }
124
+
125
+ async function main(): Promise<void> {
126
+ let runId = '';
127
+ let file = '';
128
+ let spurBin = process.env.SPUR_BIN ?? '';
129
+ const argv = process.argv.slice(2);
130
+ for (let i = 0; i < argv.length; i++) {
131
+ if (argv[i] === '--run-id') runId = argv[++i] ?? '';
132
+ else if (argv[i] === '--file') file = argv[++i] ?? '';
133
+ else if (argv[i] === '--spur-bin') spurBin = argv[++i] ?? spurBin;
134
+ }
135
+ if (runId.trim() === '' || file.trim() === '') usage();
136
+ if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
137
+
138
+ const { entry, repoRoot, chain } = resolveAppEntry(spurBin);
139
+ if (entry === null || repoRoot === null) {
140
+ const outcome: SetupOutcome = {
141
+ ok: false,
142
+ runId,
143
+ error:
144
+ `inline run setup failed closed: no monorepo checkout of spur is reachable via ${chain}. ` +
145
+ 'The authoritative run identity must be persisted by the app service ' +
146
+ '(packages/app/src/services/inline-run-setup.ts); a bundle-only install cannot do this. ' +
147
+ 'Remediation: point SPUR_BIN at a repo checkout, e.g. ' +
148
+ 'SPUR_BIN="bun /path/to/spur/apps/cli/src/index.ts". The pipeline must not run unbound.',
149
+ };
150
+ writeOutcome(runId, outcome);
151
+ console.error(`inline-run-setup: FAIL for run ${runId}`);
152
+ console.error(` ${outcome.error}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ // Dynamic import by absolute path: the app source graph resolves its own workspace
157
+ // dependencies from the repo checkout, never from this plugin script's location.
158
+ const app = (await import(entry)) as {
159
+ createOrAttachInlineRun: (input: {
160
+ workdir: string;
161
+ getDb: () => Promise<unknown>;
162
+ file: string;
163
+ runId: string;
164
+ }) => Promise<SetupOutcome & { ok: boolean }>;
165
+ openInlineRunProjectDb: (workdir: string) => Promise<{ adapter: unknown; close: () => void }>;
166
+ };
167
+
168
+ const workdir = process.cwd();
169
+ const projectDb = await app.openInlineRunProjectDb(workdir);
170
+ let exitCode = 0;
171
+ try {
172
+ const result = await app.createOrAttachInlineRun({
173
+ workdir,
174
+ getDb: async () => projectDb.adapter,
175
+ file,
176
+ runId,
177
+ });
178
+ writeOutcome(runId, result);
179
+ if (!result.ok) {
180
+ console.error(`inline-run-setup: FAIL for run ${runId}`);
181
+ console.error(` ${result.error}`);
182
+ exitCode = 1;
183
+ } else {
184
+ console.error(
185
+ `inline-run-setup: ${result.attached ? 'attached' : 'created'} run ${runId} ` +
186
+ `(${result.workflowName}, layer ${result.layer}, digest ${result.definitionDigest}, status ${result.status})`,
187
+ );
188
+ }
189
+ } finally {
190
+ projectDb.close();
191
+ }
192
+ process.exit(exitCode);
193
+ }
194
+
195
+ main().catch((e: unknown) => {
196
+ console.error(`inline-run-setup: FAIL — ${e instanceof Error ? e.message : String(e)}`);
197
+ process.exit(1);
198
+ });
@@ -257,29 +257,124 @@ function extractRequirementIds(taskContent: string): string[] {
257
257
  return [...ids];
258
258
  }
259
259
 
260
- function extractAcIdentities(taskContent: string, featureContent: string | null): string[] {
261
- const identities = new Set<string>();
260
+ // ─── Canonical AC identity resolution (task 0804 R4) ─────────────────────────
261
+
262
+ /**
263
+ * Strip the tolerated AC-id wrappers (bracket tags, `Scenario:` prefix) until
264
+ * fixpoint — the shared first step of `normalizeAcTitle` and the `AC-N` alias
265
+ * path in `resolveAcIdentity`.
266
+ */
267
+ function stripAcWrappers(title: string): string {
268
+ let out = title.trim();
269
+ let prev: string;
270
+ do {
271
+ prev = out;
272
+ out = out
273
+ .replace(/^\[[^\]]*\]\s*/, '')
274
+ .replace(/\s*\[[^\]]*\]\s*$/, '')
275
+ .replace(/^Scenario:\s*/i, '')
276
+ .trim();
277
+ } while (out !== prev);
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * Normalize an AC identity to its canonical key, mirroring the documented
283
+ * matching behavior of feature-check `rowMatchesScenario` + ac-style-guide
284
+ * "Four accepted id forms" (exact/bare title, `Scenario:` prefix, bracket
285
+ * tags, `AC-N` ordinal) without importing that private matcher or adopting
286
+ * its permissive trailing-Gherkin fallback (0804 R4). Comparison is
287
+ * case/quote/whitespace-insensitive. A paraphrase normalizes differently
288
+ * and still fails.
289
+ */
290
+ function normalizeAcTitle(title: string): string {
291
+ return stripAcWrappers(title)
292
+ .replace(/^R\d+\s*[:\-—]?\s*/, '')
293
+ .toLowerCase()
294
+ .replace(/[ʼ‘’“”]/g, '')
295
+ .replace(/\s+/g, ' ')
296
+ .trim();
297
+ }
298
+
299
+ /**
300
+ * Declared AC identities keyed by canonical normalized title, plus the two
301
+ * AC-N ordinal sources (task scenario list and linked-feature scenario list).
302
+ * A checklist-declared spelling always wins over the positional alias.
303
+ */
304
+ interface AcIdentityIndex {
305
+ /** normalized canonical title → a declared spelling (label, token, or title). */
306
+ readonly byTitle: Map<string, string>;
307
+ /** AC-N → task scenario title at that 1-based ordinal. */
308
+ readonly taskScenarios: string[];
309
+ /** AC-N → feature scenario title at that 1-based ordinal. */
310
+ readonly featureScenarios: string[];
311
+ }
312
+
313
+ function buildAcIdentityIndex(taskContent: string, featureContent: string | null): AcIdentityIndex {
314
+ const byTitle = new Map<string, string>();
315
+ // Not named `declare`: Bun's TS transpiler treats a call to an identifier
316
+ // named `declare` as an ambient-declaration modifier and drops it.
317
+ const declareIdentity = (spelling: string): void => {
318
+ const key = normalizeAcTitle(spelling);
319
+ if (key !== '' && !byTitle.has(key)) byTitle.set(key, spelling);
320
+ };
262
321
  const section = sectionBetween(taskContent, 'Acceptance Criteria');
263
- // Checkbox labels (`- [x] AC1 (R1): …`, 0726) and plain bullets (`- AC1: Given …`,
264
- // 0713/0727) both yield the label text up to `:` plus its leading token.
265
322
  for (const m of section.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?(.+?)\s*(?::|$)/gm)) {
266
323
  const label = (m[1] ?? '').trim();
267
324
  if (!label) continue;
268
- identities.add(label);
325
+ declareIdentity(label);
269
326
  const leading = label.split(/\s+/)[0] ?? '';
270
- if (leading && leading !== label) identities.add(leading);
327
+ if (leading && leading !== label) declareIdentity(leading);
271
328
  }
272
- for (const m of section.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)) {
273
- const title = (m[1] ?? '').trim();
274
- if (title) identities.add(title);
275
- }
276
- if (featureContent !== null) {
277
- for (const m of featureContent.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)) {
278
- const title = (m[1] ?? '').trim();
279
- if (title) identities.add(title);
329
+ const scenarioTitles = (content: string): string[] =>
330
+ [...content.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)].map((m) => (m[1] ?? '').trim()).filter((t) => t !== '');
331
+ const taskScenarios = scenarioTitles(sectionBetween(taskContent, 'Acceptance Criteria'));
332
+ const featureScenarios = featureContent !== null ? scenarioTitles(featureContent) : [];
333
+ for (const title of [...taskScenarios, ...featureScenarios]) declareIdentity(title);
334
+ return { byTitle, taskScenarios, featureScenarios };
335
+ }
336
+
337
+ /** Resolution outcome for one AC row id. */
338
+ type AcIdentityResolution = { ok: true; canonical: string } | { ok: false; error: string };
339
+
340
+ /**
341
+ * Resolve an answer-file AC row id to one canonical task identity (0804 R4):
342
+ * exact/declared title forms first, then the documented `AC-N` positional
343
+ * alias — accepted only against a real scenario ordinal, and refused with an
344
+ * actionable diagnostic when task and feature ordinals disagree. Undeclared
345
+ * `ACn` tokens, paraphrases and invented ordinals never resolve.
346
+ */
347
+ function resolveAcIdentity(rowId: string, index: AcIdentityIndex): AcIdentityResolution {
348
+ const canonical = index.byTitle.get(normalizeAcTitle(rowId));
349
+ if (canonical !== undefined) return { ok: true, canonical };
350
+ // Strip the tolerated wrappers, then try the documented `AC-N` alias.
351
+ const stripped = stripAcWrappers(rowId);
352
+ const ordinal = /^AC-(\d+)$/i.exec(stripped);
353
+ if (ordinal !== null) {
354
+ const n = Number(ordinal[1]);
355
+ const taskTitle = index.taskScenarios[n - 1];
356
+ const featureTitle = index.featureScenarios[n - 1];
357
+ const candidates = [...new Set([taskTitle, featureTitle].filter((t): t is string => t !== undefined))];
358
+ if (candidates.length === 0) {
359
+ return {
360
+ ok: false,
361
+ error:
362
+ `AC id "${rowId}" uses the AC-${n} positional alias but no scenario exists at that ordinal ` +
363
+ '(task scenario list and linked-feature scenario list) — cite the exact scenario title or checklist label',
364
+ };
365
+ }
366
+ if (candidates.length > 1) {
367
+ return {
368
+ ok: false,
369
+ error:
370
+ `AC id "${rowId}" is ambiguous: task AC #${n} ("${taskTitle}") and feature scenario #${n} ` +
371
+ `("${featureTitle}") are different scenarios with different ordering — cite the exact title`,
372
+ };
280
373
  }
374
+ const resolved = index.byTitle.get(normalizeAcTitle(candidates[0] ?? ''));
375
+ if (resolved !== undefined) return { ok: true, canonical: resolved };
281
376
  }
282
- return [...identities];
377
+ return { ok: false, error: '' };
283
378
  }
284
379
 
285
380
  // ─── Main ────────────────────────────────────────────────────────────────────
@@ -341,7 +436,7 @@ function main(): void {
341
436
  }
342
437
 
343
438
  const reqIds = extractRequirementIds(taskContent);
344
- const acIdentities = extractAcIdentities(taskContent, featureContent);
439
+ const acIndex = buildAcIdentityIndex(taskContent, featureContent);
345
440
 
346
441
  // Requirement rows: completeness, no unknowns, no duplicates, valid status, non-empty evidence.
347
442
  const seenReq = new Set<string>();
@@ -358,19 +453,31 @@ function main(): void {
358
453
  if (!seenReq.has(id)) add(`missing requirement row for "${id}"`);
359
454
  }
360
455
 
361
- // AC rows: identity must exactly match a checklist label/token or a scenario title;
362
- // status and evidence type must normalize; evidence non-empty. AC completeness is the
363
- // verifier's authoring contract, not a lint rejection class (0726 R3).
364
- const seenAc = new Set<string>();
456
+ // AC rows: identity must resolve to ONE canonical task AC identity a
457
+ // checklist label/token or a scenario title in any ac-style-guide form
458
+ // (exact/bare title, `Scenario:` prefix, bracket tags, declared AC-N
459
+ // alias; 0804 R4). Alias-equivalent spellings of the same identity are
460
+ // duplicates even when the raw strings differ. Status and evidence type
461
+ // must normalize; evidence non-empty. AC completeness is the verifier's
462
+ // authoring contract, not a lint rejection class (0726 R3).
463
+ const seenAc = new Map<string, string>(); // canonical key → first raw row id
365
464
  for (const row of tables.acs) {
366
- if (!acIdentities.includes(row.id)) {
465
+ const resolution = resolveAcIdentity(row.id, acIndex);
466
+ const canonicalKey = resolution.ok ? normalizeAcTitle(resolution.canonical) : null;
467
+ if (!resolution.ok) {
468
+ if (resolution.error !== '') add(`line ${row.line}: ${resolution.error}`);
469
+ else
470
+ add(
471
+ `line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title ` +
472
+ '(accepted forms: exact title, bare title, `Scenario:` prefix, bracket tags, declared AC-N alias)',
473
+ );
474
+ } else if (canonicalKey !== null && seenAc.has(canonicalKey)) {
475
+ const first = seenAc.get(canonicalKey) ?? '';
367
476
  add(
368
- `line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title`,
477
+ `line ${row.line}: duplicate AC row "${row.id.slice(0, 60)}" alias-equivalent to "${first.slice(0, 60)}"`,
369
478
  );
370
- } else if (seenAc.has(row.id)) {
371
- add(`line ${row.line}: duplicate AC row "${row.id.slice(0, 60)}"`);
372
479
  }
373
- seenAc.add(row.id);
480
+ if (canonicalKey !== null && !seenAc.has(canonicalKey)) seenAc.set(canonicalKey, row.id);
374
481
  if (normalizeAcStatus(row.status) === null)
375
482
  add(`line ${row.line}: invalid AC status "${row.status}" (MET | PARTIAL | UNMET | N/A)`);
376
483
  if (normalizeEvidenceType(row.evidenceType) === null)
@@ -396,4 +503,5 @@ function main(): void {
396
503
  process.exit(0);
397
504
  }
398
505
 
399
- main();
506
+ // CLI entry (guarded so the helpers stay importable for focused tests).
507
+ if (import.meta.main) main();
@@ -117,8 +117,8 @@ assign a per-requirement status:
117
117
  | **PARTIAL** | Evidence for part of the requirement only |
118
118
  | **UNMET** | No implementation evidence found |
119
119
 
120
- Record the evidence string (repo-relative path `file:line`, e.g. `packages/app/src/services/task-check.ts:42`, command, or test name) per requirement — this is what lands
121
- in `## Testing`.
120
+ Record the evidence string (repo-relative `file:line`, command, or test name) per requirement —
121
+ this lands in `## Testing`.
122
122
 
123
123
  **Line-anchor verification (anti-stale-citation rule).** Every `file:line` evidence citation
124
124
  written into the Testing table MUST be re-read at the cited lines this run, and the re-read content
@@ -142,6 +142,8 @@ classifies it as external and never raises `L4.stale-line-anchor` for it (R1). D
142
142
  that lives in this repo — in-repo evidence MUST use the repo-relative backtick form
143
143
  `` `path:line` `` / `` `path:start-end` ``, and citing it in the external form still reports (R2).
144
144
 
145
+ **Concrete anchors (0804 R9):** cite existing `file:line`s, never globs — `references/verdict-schema.md`.
146
+
145
147
  ### Step 5 — Acceptance Criteria guard
146
148
 
147
149
  If the task has a non-empty Acceptance Criteria section, evaluate every checklist item and every
@@ -110,12 +110,21 @@ For answer files, emit a matching parseable table:
110
110
  `Verdict: PARTIAL` first, append one complete row at a time, and replace the first verdict line only
111
111
  after every row is certified. `verify-answer-lint.ts` gates the file before `spur task verdict
112
112
  --from-answer` and rejects, with row-level diagnostics: missing/duplicate/unknown requirement IDs,
113
- AC ids that do not exactly match a task AC checklist label (or its leading token, e.g. `AC1`) or a
114
- linked feature scenario title, invalid status (`MET | PARTIAL | UNMET` for requirements;
113
+ AC ids that do not resolve to one accepted identity — a task AC checklist label or its declared
114
+ `AC-N`/checklist-token alias, or a linked feature scenario title, in the ac-style-guide forms
115
+ (exact/bare title, `Scenario:` prefix, bracket tags, `AC-N`); paraphrases and ambiguous aliases
116
+ fail — invalid status (`MET | PARTIAL | UNMET` for requirements;
115
117
  `N/A` additionally allowed for AC), invalid evidence type (`test | command | static-ref |
116
118
  manual-review | llm-judge | n/a`, or a `+` compound), and empty evidence. Interrupted runs keep the
117
119
  rows that pass the lint and complete only the missing IDs on retry.
118
120
 
121
+ **Concrete anchors only (task 0804 R9).** An evidence anchor must be a concrete existing `file:line`
122
+ (or `file:start-end`) path. A glob or directory summary (`src/services/*.ts`, `the retry
123
+ classifiers in task-pipeline.yaml`) is not an anchor: expand it into the specific cited files/ranges
124
+ the run actually verified. Since 0804 R9 the checker ignores complete parsed citation spans before
125
+ scanning for subjects, so a citation's filename (including snake_case paths) can never become a
126
+ false subject — a real absent symbol, nonexistent file, or invalid range still reports.
127
+
119
128
  ## Checks evidence
120
129
 
121
130
  Wave C verification can emit the following additive `checks[]` rows: