@gobing-ai/knowledge-kit 0.0.13 → 0.0.14

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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/index.js +20 -0
  3. package/package.json +1 -1
  4. package/plugins/generations/content-gen/dist/index.js +20 -0
  5. package/plugins/generations/core-facts-gen/dist/index.js +20 -0
  6. package/plugins/generations/daily-article-gen/dist/index.js +29 -1
  7. package/plugins/generations/daily-article-gen/src/index.ts +13 -1
  8. package/plugins/generations/dailynews-gen/dist/index.js +20 -0
  9. package/plugins/generations/episode-plan-gen/dist/index.js +24 -0
  10. package/plugins/generations/episode-plan-gen/src/index.ts +11 -0
  11. package/plugins/generations/image-gen/dist/index.js +2296 -53
  12. package/plugins/generations/image-gen/src/providers/agnes.ts +110 -0
  13. package/plugins/generations/image-gen/src/providers/azure.ts +153 -0
  14. package/plugins/generations/image-gen/src/providers/codex-cli.ts +170 -0
  15. package/plugins/generations/image-gen/src/providers/dashscope.ts +485 -0
  16. package/plugins/generations/image-gen/src/providers/google.ts +268 -0
  17. package/plugins/generations/image-gen/src/providers/huggingface.ts +59 -0
  18. package/plugins/generations/image-gen/src/providers/jimeng.ts +259 -0
  19. package/plugins/generations/image-gen/src/providers/minimax.ts +171 -0
  20. package/plugins/generations/image-gen/src/providers/openai.ts +319 -0
  21. package/plugins/generations/image-gen/src/providers/openrouter.ts +257 -0
  22. package/plugins/generations/image-gen/src/providers/refs.ts +24 -0
  23. package/plugins/generations/image-gen/src/providers/replicate.ts +279 -0
  24. package/plugins/generations/image-gen/src/providers/seedream.ts +1 -1
  25. package/plugins/generations/image-gen/src/providers/types.ts +52 -51
  26. package/plugins/generations/image-gen/src/providers/zai.ts +237 -0
  27. package/plugins/generations/news-report-gen/dist/index.js +22193 -0
  28. package/plugins/generations/news-report-gen/package.json +17 -0
  29. package/plugins/generations/news-report-gen/plugin.json +7 -0
  30. package/plugins/generations/news-report-gen/src/index.ts +308 -0
  31. package/plugins/generations/news-report-gen/tsconfig.json +4 -0
  32. package/plugins/generations/omni-voice-gen/Makefile +14 -0
  33. package/plugins/generations/omni-voice-gen/README.md +112 -0
  34. package/plugins/generations/omni-voice-gen/bin/omni-voice-gen +2 -0
  35. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen-prr8skpb. +2 -0
  36. package/plugins/generations/omni-voice-gen/dist/omni-voice-gen.js +6 -0
  37. package/plugins/generations/omni-voice-gen/plugin.json +6 -0
  38. package/plugins/generations/omni-voice-gen/profiles.json +12 -0
  39. package/plugins/generations/omni-voice-gen/pyproject.toml +25 -0
  40. package/plugins/generations/omni-voice-gen/scripts/coverage_gate.py +74 -0
  41. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__init__.py +1 -0
  42. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/__main__.py +39 -0
  43. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/audio.py +190 -0
  44. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/backend.py +150 -0
  45. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/contract.py +76 -0
  46. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/mp3.py +60 -0
  47. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/pipeline.py +289 -0
  48. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/profiles.py +100 -0
  49. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/qc.py +234 -0
  50. package/plugins/generations/omni-voice-gen/src/omni_voice_gen/voicescript.py +352 -0
  51. package/plugins/generations/omni-voice-gen/uv.lock +3510 -0
  52. package/plugins/generations/voice-gen/dist/index.js +30 -2
  53. package/plugins/generations/voice-gen/src/index.ts +16 -1
  54. package/plugins/generations/voice-gen/src/voicebox-client.ts +3 -1
  55. package/plugins/ingestions/aihot-ingest/dist/index.js +20 -0
  56. package/plugins/ingestions/horizon-ingest/dist/index.js +20 -0
  57. package/plugins/ingestions/last30days-ingest/dist/index.js +20 -0
  58. package/plugins/ingestions/web-search/dist/index.js +20 -0
  59. package/plugins/kk/commands/image-extract.md +40 -0
  60. package/plugins/kk/commands/image-generate.md +18 -1
  61. package/plugins/kk/plugin.json +1 -1
  62. package/plugins/kk/skills/image-authoring/SKILL.md +10 -3
  63. package/plugins/kk/skills/image-authoring/references/format-drafting.md +57 -0
  64. package/plugins/kk/skills/image-authoring/references/style-extraction.md +14 -9
  65. package/plugins/kk/workflows/kk-daily-ai-voice.yaml +130 -30
  66. package/plugins/publishings/emdash-pub/dist/index.js +20 -0
  67. package/plugins/publishings/podcast-pub/dist/index.js +57 -3
  68. package/plugins/publishings/podcast-pub/src/index.ts +18 -2
  69. package/plugins/publishings/podcast-pub/src/show-notes.ts +56 -9
  70. package/plugins/publishings/qiita-pub/dist/index.js +20 -0
  71. package/plugins/publishings/surfdash-pub/dist/index.js +96 -6
  72. package/plugins/publishings/surfdash-pub/src/index.ts +109 -9
  73. package/plugins/publishings/zenn-pub/dist/index.js +20 -0
  74. package/plugins/sp/scripts/batch-preflight.mjs +346 -0
  75. package/plugins/sp/scripts/batch-preflight.ts +459 -0
  76. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +615 -0
  77. package/plugins/sp/scripts/daily-summary/daily-summary.ts +846 -0
  78. package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
  79. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +223 -0
  80. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +367 -0
  81. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +132 -0
  82. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +169 -0
  83. package/plugins/sp/scripts/feature-dev-precheck.mjs +171 -0
  84. package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
  85. package/plugins/sp/scripts/feature-sync-bounded.mjs +285 -0
  86. package/plugins/sp/scripts/feature-sync-bounded.ts +478 -0
  87. package/plugins/sp/scripts/history-anatomy-cache.mjs +902 -0
  88. package/plugins/sp/scripts/history-anatomy-cache.ts +1028 -0
  89. package/plugins/sp/scripts/idea-handoff.mjs +22 -0
  90. package/plugins/sp/scripts/idea-handoff.ts +44 -0
  91. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  92. package/plugins/sp/scripts/inline-run-setup.ts +198 -0
  93. package/plugins/sp/scripts/pr-reviewing.mjs +769 -0
  94. package/plugins/sp/scripts/pr-reviewing.ts +925 -0
  95. package/plugins/sp/scripts/quality-gate.mjs +179 -0
  96. package/plugins/sp/scripts/quality-gate.ts +217 -0
  97. package/plugins/sp/scripts/script-contract-check.ts +319 -0
  98. package/plugins/sp/scripts/stage-registry-adapter.ts +1533 -0
  99. package/plugins/sp/scripts/surface-drift-inventory.ts +929 -0
  100. package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
  101. package/plugins/sp/scripts/task-size-precheck.ts +175 -0
  102. package/plugins/sp/scripts/transition-shim-check.ts +238 -0
  103. package/plugins/sp/scripts/validate-commands.ts +689 -0
  104. package/plugins/sp/scripts/validate-flag-contracts.ts +878 -0
  105. package/plugins/sp/scripts/verify-answer-lint.ts +530 -0
  106. package/plugins/sp/scripts/workflow-step-profile.mjs +316 -0
  107. package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
  108. package/plugins/sp/scripts/wrapup-steps.mjs +373 -0
  109. package/plugins/sp/scripts/wrapup-steps.ts +466 -0
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * verify-answer-lint — deterministic pre-verdict answer lint (0726 R3).
4
+ *
5
+ * Runs AFTER the verify agent exits and BEFORE `spur task verdict --from-answer`.
6
+ * The verifier owns the answer file (`.spur/run/<wbs>-verify-answer.txt`): it creates
7
+ * it with `Verdict: PARTIAL`, appends one complete requirement/AC row at a time, and
8
+ * only replaces the first verdict line once every row is certified. Because the file
9
+ * is now append-progress instead of a single captured blob, malformed rows can reach
10
+ * the verdict step — this lint rejects each invalid class with a row-level message:
11
+ *
12
+ * - missing, duplicate, or unknown requirement IDs (vs the task's Requirements)
13
+ * - AC IDs that do not exactly match the task's AC checklist label or a linked
14
+ * feature scenario title
15
+ * - status / evidence-type values the verdict parser would drop
16
+ * - empty evidence
17
+ *
18
+ * Compound evidence types (`test + command`) stay valid — normalization mirrors
19
+ * `packages/app/src/services/task-verdict.ts` exactly, so anything this lint accepts
20
+ * is also accepted by `spur task verdict --from-answer` (and vice versa).
21
+ *
22
+ * Exits non-zero on any finding, with bounded diagnostics (first 10). Writes nothing.
23
+ *
24
+ * Ships with the plugin to arbitrary projects; node-builtin only — no workspace imports.
25
+ *
26
+ * Usage:
27
+ * bun plugins/sp/scripts/verify-answer-lint.ts <wbs> --answer <path> [--spur-bin <path>]
28
+ *
29
+ * Env: SPUR_BIN
30
+ */
31
+
32
+ import { execFileSync } from 'node:child_process';
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ // ─── CLI (same spur-bin chain as task-evidence-precheck.ts) ─────────────────
37
+
38
+ function usage(): never {
39
+ console.error('Usage: bun plugins/sp/scripts/verify-answer-lint.ts <wbs> --answer <path> [--spur-bin <path>]');
40
+ process.exit(1);
41
+ }
42
+
43
+ function defaultSpurBin(): string {
44
+ if (process.env.SPUR_BIN) return process.env.SPUR_BIN;
45
+ const local = fileURLToPath(new URL('../../../apps/cli/src/index.ts', import.meta.url));
46
+ if (existsSync(local)) return `bun ${local}`;
47
+ return 'spur';
48
+ }
49
+
50
+ function parseArgs(argv: string[]): { wbs: string; answer: string; spurBin: string } {
51
+ let spurBin = defaultSpurBin();
52
+ let wbs = '';
53
+ let answer = '';
54
+ let i = 0;
55
+ while (i < argv.length) {
56
+ const arg = argv[i];
57
+ if (arg === '--spur-bin') {
58
+ spurBin = argv[i + 1] ?? defaultSpurBin();
59
+ i += 2;
60
+ } else if (arg === '--answer') {
61
+ answer = argv[i + 1] ?? '';
62
+ i += 2;
63
+ } else if (!arg.startsWith('--')) {
64
+ wbs = arg;
65
+ i++;
66
+ } else {
67
+ i++;
68
+ }
69
+ }
70
+ if (!wbs || !answer) usage();
71
+ return { wbs, answer, spurBin };
72
+ }
73
+
74
+ function runSpur(spurBin: string, args: string[]): string {
75
+ const [file = 'spur', ...lead] = spurBin.split(/\s+/).filter(Boolean);
76
+ return execFileSync(file, [...lead, ...args], {
77
+ encoding: 'utf-8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ });
80
+ }
81
+
82
+ // ─── Answer parsing — mirrors packages/app/src/services/task-verdict.ts ─────
83
+
84
+ interface ReqRow {
85
+ id: string;
86
+ status: string;
87
+ evidence: string;
88
+ line: number;
89
+ }
90
+ interface AcRow {
91
+ id: string;
92
+ status: string;
93
+ evidenceType: string;
94
+ evidence: string;
95
+ line: number;
96
+ }
97
+
98
+ function splitTableCells(line: string): string[] {
99
+ return line
100
+ .split(/(?<!\\)\|/)
101
+ .map((c) => c.replace(/\\\|/g, '|').trim())
102
+ .filter(Boolean);
103
+ }
104
+
105
+ function normalizeReqStatus(raw: string): string | null {
106
+ if (/\bMET\b/.test(raw)) return 'MET';
107
+ if (/\bPARTIAL\b/.test(raw)) return 'PARTIAL';
108
+ if (/\bUNMET\b/.test(raw)) return 'UNMET';
109
+ return null;
110
+ }
111
+
112
+ function normalizeAcStatus(raw: string): string | null {
113
+ if (/\bMET\b/.test(raw)) return 'MET';
114
+ if (/\bPARTIAL\b/.test(raw)) return 'PARTIAL';
115
+ if (/\bUNMET\b/.test(raw)) return 'UNMET';
116
+ if (/\bN\/A\b/.test(raw) || /\bNA\b/.test(raw)) return 'N/A';
117
+ return null;
118
+ }
119
+
120
+ function normalizeEvidenceTypeToken(normalized: string): string | null {
121
+ if (normalized === 'test') return 'test';
122
+ if (normalized === 'command') return 'command';
123
+ if (
124
+ normalized === 'static-ref' ||
125
+ normalized === 'static' ||
126
+ normalized === 'doc' ||
127
+ normalized === 'docs' ||
128
+ normalized === 'documentation'
129
+ ) {
130
+ return 'static-ref';
131
+ }
132
+ if (normalized === 'manual-review' || normalized === 'manual') return 'manual-review';
133
+ if (normalized === 'llm-judge' || normalized === 'judge') return 'llm-judge';
134
+ if (normalized === 'n/a' || normalized === 'na') return 'n/a';
135
+ return null;
136
+ }
137
+
138
+ const EVIDENCE_TYPE_PRECEDENCE = ['test', 'command', 'static-ref', 'manual-review', 'llm-judge', 'n/a'] as const;
139
+
140
+ function normalizeEvidenceType(raw: string): string | null {
141
+ const normalized = raw.toLowerCase().trim();
142
+ const single = normalizeEvidenceTypeToken(normalized);
143
+ if (single !== null) return single;
144
+ const parts = normalized.split(/[+,/]/).filter((p) => p.trim());
145
+ const tokens = parts.map((part) => normalizeEvidenceTypeToken(part.trim())).filter((t) => t !== null);
146
+ if (tokens.length < 2 || tokens.length !== parts.length) return null;
147
+ return EVIDENCE_TYPE_PRECEDENCE.find((candidate) => tokens.includes(candidate)) ?? null;
148
+ }
149
+
150
+ interface AnswerTables {
151
+ verdict: { value: string; line: number } | null;
152
+ reqs: ReqRow[];
153
+ acs: AcRow[];
154
+ }
155
+
156
+ function parseAnswer(text: string): AnswerTables {
157
+ const out: AnswerTables = { verdict: null, reqs: [], acs: [] };
158
+ const lines = text.split('\n');
159
+ let reqTable = false;
160
+ let acTable = false;
161
+
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const trimmed = lines[i]?.trim() ?? '';
164
+ const lineNo = i + 1;
165
+
166
+ // A markdown heading closes whichever table is open (mirrors the verdict parser).
167
+ if ((reqTable || acTable) && /^#{1,6}\s/.test(trimmed)) {
168
+ reqTable = false;
169
+ acTable = false;
170
+ continue;
171
+ }
172
+ if (!trimmed.startsWith('|')) continue;
173
+ const cells = splitTableCells(trimmed);
174
+ if (/^[-:]+$/.test(cells[0] ?? '')) continue;
175
+
176
+ const h0 = (cells[0] ?? '').toLowerCase();
177
+ const h1 = (cells[1] ?? '').toLowerCase();
178
+
179
+ // Requirement header: `| Req | Status | Evidence |` (id-like first cell + status column).
180
+ if (!reqTable && !acTable && cells.length >= 2) {
181
+ const idLike = h0.includes('req') || h0 === 'requirement' || h0 === 'r#' || h0 === 'r' || /^r\d+$/.test(h0);
182
+ if (idLike && (h1.includes('status') || h1 === 'verdict')) {
183
+ reqTable = true;
184
+ continue;
185
+ }
186
+ if (
187
+ (h0 === 'ac' || h0.includes('acceptance')) &&
188
+ h1.includes('status') &&
189
+ (cells[2] ?? '').toLowerCase().includes('evidence')
190
+ ) {
191
+ acTable = true;
192
+ continue;
193
+ }
194
+ }
195
+
196
+ if (reqTable && cells.length >= 2) {
197
+ // An AC header following the requirement table closes it (mirrors the parser).
198
+ if ((h0 === 'ac' || h0.includes('acceptance')) && h1.includes('status')) {
199
+ reqTable = false;
200
+ acTable = true;
201
+ continue;
202
+ }
203
+ out.reqs.push({
204
+ id: cells[0] ?? '',
205
+ status: (cells[1] ?? '').toUpperCase(),
206
+ evidence: cells[2] ?? '',
207
+ line: lineNo,
208
+ });
209
+ continue;
210
+ }
211
+ if (acTable && cells.length >= 3) {
212
+ out.acs.push({
213
+ id: cells[0] ?? '',
214
+ status: cells[1] ?? '',
215
+ evidenceType: cells[2] ?? '',
216
+ evidence: cells[3] ?? '',
217
+ line: lineNo,
218
+ });
219
+ }
220
+ }
221
+
222
+ const verdictMatches = [...text.matchAll(/^\s*Verdict:\s*(\S+)\s*$/gim)];
223
+ if (verdictMatches.length === 1) {
224
+ out.verdict = {
225
+ value: verdictMatches[0]?.[1] ?? '',
226
+ line: text.slice(0, verdictMatches[0]?.index ?? 0).split('\n').length,
227
+ };
228
+ }
229
+ return out;
230
+ }
231
+
232
+ // ─── Task-side identity extraction ───────────────────────────────────────────
233
+
234
+ function sectionBetween(text: string, heading: string): string {
235
+ const marker = new RegExp(`^#{1,6}\\s+${heading}\\s*$`, 'im');
236
+ const match = marker.exec(text);
237
+ if (!match || match.index === undefined) return '';
238
+ const rest = text.slice(match.index);
239
+ const lineEnd = rest.indexOf('\n');
240
+ const afterHeading = lineEnd === -1 ? '' : rest.slice(lineEnd + 1);
241
+ const next = afterHeading.search(/^#{1,6}\s/m);
242
+ return next === -1 ? afterHeading : afterHeading.slice(0, next);
243
+ }
244
+
245
+ function extractRequirementIds(taskContent: string): string[] {
246
+ const section = sectionBetween(taskContent, 'Requirements');
247
+ const ids = new Set<string>();
248
+ // Corpus forms: bold-wrapped (`**R1. Title.**`, bare `**R1**`); right after a list
249
+ // marker with optional checkbox (`- [ ] R1.` — the dominant corpus form, `- R1. Title.`,
250
+ // `- R1:`) or a bare checkbox with the marker omitted (`[x] R1.`); line-start (`R1:`).
251
+ // The marker and the checkbox are never both optional — that would match bare prose
252
+ // (`R1 is …`) and fabricate declarations. Sub-IDs (`R1.1`) match in every form.
253
+ for (const m of section.matchAll(/\*\*(R\d+(?:\.\d+)*)\b/g)) ids.add(m[1] ?? '');
254
+ for (const m of section.matchAll(/^(?:[-*]\s+(?:\[[ xX]\]\s+)?|\[[ xX]\]\s+)(R\d+(?:\.\d+)*)/gm))
255
+ ids.add(m[1] ?? '');
256
+ for (const m of section.matchAll(/^(R\d+(?:\.\d+)*)\s*[.:]/gm)) ids.add(m[1] ?? '');
257
+ return [...ids];
258
+ }
259
+
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) plus the task-side bold-trajectory paragraph
286
+ * (0817 R3), without importing that private matcher or adopting
287
+ * its permissive trailing-Gherkin fallback (0804 R4). Comparison is
288
+ * case/quote/whitespace-insensitive. A paraphrase normalizes differently
289
+ * and still fails.
290
+ */
291
+ function normalizeAcTitle(title: string): string {
292
+ return (
293
+ stripAcWrappers(title)
294
+ .replace(/^R\d+\s*[:\-—]?\s*/, '')
295
+ .toLowerCase()
296
+ // Exact pre-refactor removal set (0809 R5): ASCII apostrophe + the four curly
297
+ // quotes. Escaped form keeps U+0027 visible next to lookalike curly glyphs;
298
+ // U+02BC stays a meaningful character, never removable punctuation.
299
+ .replace(/[\u0027\u2018\u2019\u201c\u201d]/g, '')
300
+ .replace(/\s+/g, ' ')
301
+ .trim()
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Declared AC identities keyed by canonical normalized title, plus the two
307
+ * AC-N ordinal sources (task scenario list and linked-feature scenario list).
308
+ * A checklist-declared spelling always wins over the positional alias.
309
+ */
310
+ interface AcIdentityIndex {
311
+ /** normalized canonical title → a declared spelling (label, token, title, or bold trajectory). */
312
+ readonly byTitle: Map<string, string>;
313
+ /** AC-N → task scenario title at that 1-based ordinal. */
314
+ readonly taskScenarios: string[];
315
+ /** AC-N → feature scenario title at that 1-based ordinal. */
316
+ readonly featureScenarios: string[];
317
+ }
318
+
319
+ function buildAcIdentityIndex(taskContent: string, featureContent: string | null): AcIdentityIndex {
320
+ const byTitle = new Map<string, string>();
321
+ // Not named `declare`: Bun's TS transpiler treats a call to an identifier
322
+ // named `declare` as an ambient-declaration modifier and drops it.
323
+ const declareIdentity = (spelling: string): void => {
324
+ const key = normalizeAcTitle(spelling);
325
+ if (key !== '' && !byTitle.has(key)) byTitle.set(key, spelling);
326
+ };
327
+ const section = sectionBetween(taskContent, 'Acceptance Criteria');
328
+ for (const m of section.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?(.+?)\s*(?::|$)/gm)) {
329
+ const label = (m[1] ?? '').trim();
330
+ if (!label) continue;
331
+ declareIdentity(label);
332
+ const leading = label.split(/\s+/)[0] ?? '';
333
+ if (leading && leading !== label) declareIdentity(leading);
334
+ }
335
+ // Bold-trajectory form (task 0817 R3): answers may cite an AC by a bare
336
+ // `**AC id**` paragraph (house style for long/complex ids). Only whole-line
337
+ // bold spans count — the line-anchored lazy regex rejects lines with two
338
+ // spans, keeping interpolated bold text out of the index. The head (text
339
+ // before the first colon) is declared like a checklist label, mirroring
340
+ // the `(?::|$)` label extraction above.
341
+ for (const m of section.matchAll(/^\*\*(.+?)\*\*\s*$/gm)) {
342
+ const inner = (m[1] ?? '').trim();
343
+ if (!inner) continue;
344
+ declareIdentity(inner);
345
+ const head = inner.split(':')[0]?.trim() ?? '';
346
+ if (head && head !== inner) declareIdentity(head);
347
+ }
348
+ const scenarioTitles = (content: string): string[] =>
349
+ [...content.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)].map((m) => (m[1] ?? '').trim()).filter((t) => t !== '');
350
+ const taskScenarios = scenarioTitles(sectionBetween(taskContent, 'Acceptance Criteria'));
351
+ const featureScenarios = featureContent !== null ? scenarioTitles(featureContent) : [];
352
+ for (const title of [...taskScenarios, ...featureScenarios]) declareIdentity(title);
353
+ return { byTitle, taskScenarios, featureScenarios };
354
+ }
355
+
356
+ /** Resolution outcome for one AC row id. */
357
+ type AcIdentityResolution = { ok: true; canonical: string } | { ok: false; error: string };
358
+
359
+ /**
360
+ * Resolve an answer-file AC row id to one canonical task identity (0804 R4):
361
+ * exact/declared title forms first, then the documented `AC-N` positional
362
+ * alias — accepted only against a real scenario ordinal, and refused with an
363
+ * actionable diagnostic when task and feature ordinals disagree. Undeclared
364
+ * `ACn` tokens, paraphrases and invented ordinals never resolve.
365
+ * `ACn` tokens, paraphrases and invented ordinals never resolve.
366
+ *
367
+ * Declared forms recognized in the index include the bold-trajectory
368
+ * `**AC id**` paragraph (task 0817 R3); the AC-N failure hint names it.
369
+ */
370
+ function resolveAcIdentity(rowId: string, index: AcIdentityIndex): AcIdentityResolution {
371
+ const canonical = index.byTitle.get(normalizeAcTitle(rowId));
372
+ if (canonical !== undefined) return { ok: true, canonical };
373
+ // Strip the tolerated wrappers, then try the documented `AC-N` alias.
374
+ const stripped = stripAcWrappers(rowId);
375
+ const ordinal = /^AC-(\d+)$/i.exec(stripped);
376
+ if (ordinal !== null) {
377
+ const n = Number(ordinal[1]);
378
+ const taskTitle = index.taskScenarios[n - 1];
379
+ const featureTitle = index.featureScenarios[n - 1];
380
+ const candidates = [...new Set([taskTitle, featureTitle].filter((t): t is string => t !== undefined))];
381
+ if (candidates.length === 0) {
382
+ return {
383
+ ok: false,
384
+ error:
385
+ `AC id "${rowId}" uses the AC-${n} positional alias but no scenario exists at that ordinal ` +
386
+ '(task scenario list and linked-feature scenario list) — cite the exact scenario title, checklist label, or a bare `**AC id**` paragraph',
387
+ };
388
+ }
389
+ if (candidates.length > 1) {
390
+ return {
391
+ ok: false,
392
+ error:
393
+ `AC id "${rowId}" is ambiguous: task AC #${n} ("${taskTitle}") and feature scenario #${n} ` +
394
+ `("${featureTitle}") are different scenarios with different ordering — cite the exact title`,
395
+ };
396
+ }
397
+ const resolved = index.byTitle.get(normalizeAcTitle(candidates[0] ?? ''));
398
+ if (resolved !== undefined) return { ok: true, canonical: resolved };
399
+ }
400
+ return { ok: false, error: '' };
401
+ }
402
+
403
+ // ─── Main ────────────────────────────────────────────────────────────────────
404
+
405
+ function main(): void {
406
+ const { wbs, answer, spurBin } = parseArgs(process.argv.slice(2));
407
+ const findings: string[] = [];
408
+ const add = (msg: string): void => {
409
+ if (findings.length < 10) findings.push(msg);
410
+ };
411
+
412
+ if (!existsSync(answer)) {
413
+ console.error(`verify-answer-lint: FAIL — answer file not found: ${answer}`);
414
+ process.exit(1);
415
+ }
416
+ const raw = readFileSync(answer, 'utf8');
417
+ if (!raw.trim()) {
418
+ console.error(`verify-answer-lint: FAIL — answer file is empty: ${answer}`);
419
+ process.exit(1);
420
+ }
421
+
422
+ const tables = parseAnswer(raw);
423
+ if (tables.verdict === null) {
424
+ add('no `Verdict:` line (expected exactly one `Verdict: PASS|PARTIAL|FAIL` line)');
425
+ } else if (!/^(PASS|PARTIAL|FAIL)$/i.test(tables.verdict.value)) {
426
+ add(`line ${tables.verdict.line}: invalid Verdict value "${tables.verdict.value}" (PASS | PARTIAL | FAIL)`);
427
+ }
428
+
429
+ let taskContent = '';
430
+ let featureId = '';
431
+ try {
432
+ const task = JSON.parse(runSpur(spurBin, ['task', 'show', wbs, '--json'])) as {
433
+ content?: string;
434
+ body?: string;
435
+ feature_id?: string;
436
+ frontmatter?: { feature_id?: string };
437
+ };
438
+ taskContent = task.content ?? task.body ?? '';
439
+ featureId = task.feature_id ?? task.frontmatter?.feature_id ?? '';
440
+ } catch {
441
+ console.error(`verify-answer-lint: FAIL — could not fetch task ${wbs} via ${spurBin}`);
442
+ process.exit(1);
443
+ }
444
+ if (!taskContent) {
445
+ console.error(`verify-answer-lint: FAIL — task ${wbs} returned no content via ${spurBin}`);
446
+ process.exit(1);
447
+ }
448
+
449
+ let featureContent: string | null = null;
450
+ if (featureId) {
451
+ try {
452
+ const feature = JSON.parse(runSpur(spurBin, ['feature', 'show', featureId, '--json'])) as {
453
+ content?: string;
454
+ };
455
+ featureContent = feature.content ?? '';
456
+ } catch {
457
+ featureContent = null; // checklist labels still apply; scenario titles unavailable
458
+ }
459
+ }
460
+
461
+ const reqIds = extractRequirementIds(taskContent);
462
+ const acIndex = buildAcIdentityIndex(taskContent, featureContent);
463
+
464
+ // Requirement rows: completeness, no unknowns, no duplicates, valid status, non-empty evidence.
465
+ const seenReq = new Set<string>();
466
+ for (const row of tables.reqs) {
467
+ if (!reqIds.includes(row.id))
468
+ add(`line ${row.line}: unknown requirement ID "${row.id}" (task declares: ${reqIds.join(', ') || 'none'})`);
469
+ else if (seenReq.has(row.id)) add(`line ${row.line}: duplicate requirement row "${row.id}"`);
470
+ seenReq.add(row.id);
471
+ if (normalizeReqStatus(row.status) === null)
472
+ add(`line ${row.line}: "${row.id}" invalid status "${row.status}" (MET | PARTIAL | UNMET)`);
473
+ if (!row.evidence.trim()) add(`line ${row.line}: "${row.id}" has empty evidence`);
474
+ }
475
+ for (const id of reqIds) {
476
+ if (!seenReq.has(id)) add(`missing requirement row for "${id}"`);
477
+ }
478
+
479
+ // AC rows: identity must resolve to ONE canonical task AC identity — a
480
+ // checklist label/token or a scenario title in any ac-style-guide form
481
+ // (exact/bare title, `Scenario:` prefix, bracket tags, declared AC-N
482
+ // alias; 0804 R4). Alias-equivalent spellings of the same identity are
483
+ // duplicates even when the raw strings differ. Status and evidence type
484
+ // must normalize; evidence non-empty. AC completeness is the verifier's
485
+ // authoring contract, not a lint rejection class (0726 R3).
486
+ const seenAc = new Map<string, string>(); // canonical key → first raw row id
487
+ for (const row of tables.acs) {
488
+ const resolution = resolveAcIdentity(row.id, acIndex);
489
+ const canonicalKey = resolution.ok ? normalizeAcTitle(resolution.canonical) : null;
490
+ if (!resolution.ok) {
491
+ if (resolution.error !== '') add(`line ${row.line}: ${resolution.error}`);
492
+ else
493
+ add(
494
+ `line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title ` +
495
+ '(accepted forms: exact title, bare title, `Scenario:` prefix, bracket tags, declared AC-N alias)',
496
+ );
497
+ } else if (canonicalKey !== null && seenAc.has(canonicalKey)) {
498
+ const first = seenAc.get(canonicalKey) ?? '';
499
+ add(
500
+ `line ${row.line}: duplicate AC row "${row.id.slice(0, 60)}" — alias-equivalent to "${first.slice(0, 60)}"`,
501
+ );
502
+ }
503
+ if (canonicalKey !== null && !seenAc.has(canonicalKey)) seenAc.set(canonicalKey, row.id);
504
+ if (normalizeAcStatus(row.status) === null)
505
+ add(`line ${row.line}: invalid AC status "${row.status}" (MET | PARTIAL | UNMET | N/A)`);
506
+ if (normalizeEvidenceType(row.evidenceType) === null)
507
+ add(
508
+ `line ${row.line}: invalid evidence type "${row.evidenceType}" (test | command | static-ref | manual-review | llm-judge | n/a, or a + compound)`,
509
+ );
510
+ if (!row.evidence.trim()) add(`line ${row.line}: AC "${row.id.slice(0, 40)}" has empty evidence`);
511
+ }
512
+
513
+ if (findings.length > 0) {
514
+ console.error(
515
+ `verify-answer-lint: FAIL — ${findings.length}${findings.length >= 10 ? '+' : ''} finding(s) in ${answer}`,
516
+ );
517
+ for (const f of findings) console.error(` ${f}`);
518
+ process.exit(1);
519
+ }
520
+
521
+ const reqCount = tables.reqs.length;
522
+ const acCount = tables.acs.length;
523
+ console.error(
524
+ `verify-answer-lint: PASS — ${reqCount} requirement row(s), ${acCount} AC row(s), verdict ${tables.verdict?.value ?? '?'}`,
525
+ );
526
+ process.exit(0);
527
+ }
528
+
529
+ // CLI entry (guarded so the helpers stay importable for focused tests).
530
+ if (import.meta.main) main();