@gobing-ai/spur 0.3.68 → 0.3.70
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/corpus-baseline.json +0 -18
- package/config/plugin-scripts.json +8 -0
- package/config/workflow-composition-baseline.json +123 -377
- package/config/workflows/basic.yaml +1 -1
- package/config/workflows/docs-pipeline.yaml +1 -1
- package/config/workflows/feature-dev.yaml +1 -1
- package/config/workflows/idea-pipeline.yaml +1 -1
- package/config/workflows/task-pipeline.yaml +62 -20
- package/config/workflows/wayfinder-resolution.yaml +1 -1
- package/config/workflows/wrapup-pipeline.yaml +1 -1
- package/package.json +9 -9
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/surface-drift-inventory.ts +2 -15
- package/plugins/sp/scripts/task-evidence-precheck.ts +181 -0
- package/plugins/sp/scripts/task-size-precheck.ts +21 -79
- package/plugins/sp/scripts/verify-answer-lint.ts +386 -0
- package/plugins/sp/skills/code-verification/SKILL.md +11 -10
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +10 -0
- package/plugins/sp/skills/spur-cli/references/history.md +1 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +2 -1
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +16 -0
- package/spur.js +1275 -292
- package/web/_astro/BoardApp.DHj-03Dp.js +1 -0
- package/web/_astro/{BoardApp.BQFbkeqq.js → BoardApp.DOadeEJV.js} +77 -77
- package/web/_astro/{TaskDetail.Dl2Eaj1w.js → TaskDetail.DKzpkDj5.js} +1 -1
- package/web/_astro/{arc.uG14rp8A.js → arc.Bsa0gprH.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.Dye6uD_x.js → architectureDiagram-3BPJPVTR.LOUZCDBC.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.B9Pkh7Hb.js → blockDiagram-GPEHLZMM.CGee4isG.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.C2x7SC_X.js → c4Diagram-AAUBKEIU.B2CrQJ_O.js} +1 -1
- package/web/_astro/channel.BGL7KSHC.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.D2p4-nWk.js → chunk-2J33WTMH.DJvf8RiP.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.S-6jf33o.js → chunk-4BX2VUAB.B_l35O-Y.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DVXj4Fdh.js → chunk-55IACEB6.DqbxMswU.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.Dz689FMN.js → chunk-727SXJPM.D_7ZGNjE.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.KxYj5TnI.js → chunk-AQP2D5EJ.Cq6yZ0s8.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.itTQyHQB.js → chunk-FMBD7UC4.BWrlUPYi.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.euSrbJf5.js → chunk-ND2GUHAM.BPLaXFZH.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.OWASJRQy.js → chunk-QZHKN3VN.BtmAEAwZ.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.BLvrlpNO.js → classDiagram-4FO5ZUOK.CWycT7Ia.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.BLvrlpNO.js → classDiagram-v2-Q7XG4LA2.CWycT7Ia.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.XBF-rmyD.js → cose-bilkent-S5V4N54A.DyxjSWon.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.DlCx762Z.js → cynefin-OW5HDTMX.DnNfLBpX.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.D17Rshxv.js → dagre-BM42HDAG.BFAvnjKD.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.AhBIVJC8.js → diagram-2AECGRRQ.BAoNCFeB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.C9ximjyC.js → diagram-5GNKFQAL.DiiKe7BP.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.CZb7Ru_9.js → diagram-KO2AKTUF.t_uOQWVP.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BW7LwqoS.js → diagram-LMA3HP47.SB9997cP.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.XC025W0V.js → diagram-OG6HWLK6.Bgsxgf5G.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.CpMXmBDP.js → erDiagram-TEJ5UH35.DO9OAOAc.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.D2ednJWg.js → flowDiagram-I6XJVG4X.CIb41ZyL.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.BjL9FGKO.js → ganttDiagram-6RSMTGT7.Dc8Lk0fV.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.B90g1VGk.js → gitGraphDiagram-PVQCEYII.CpPmMWrl.js} +1 -1
- package/web/_astro/index.C-t8kB0T.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.RqgycKtQ.js → infoDiagram-5YYISTIA.DxBXfQSf.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.Ctn-zt6a.js → ishikawaDiagram-YF4QCWOH.gA6OKtXq.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.DJhT8Ctp.js → journeyDiagram-JHISSGLW.CMEyLMmm.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BY1QdejI.js → kanban-definition-UN3LZRKU.CKXlUNCr.js} +1 -1
- package/web/_astro/{linear.Di7YObSt.js → linear.CpTGdVx3.js} +1 -1
- package/web/_astro/{mermaid.core.CbxtJS3Q.js → mermaid.core.c8SSCsE5.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.CxvR4g_J.js → mindmap-definition-RKZ34NQL.BH59HDw6.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.jNWqnBHH.js → pieDiagram-4H26LBE5.DmgFXkZR.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.BCp12MbA.js → quadrantDiagram-W4KKPZXB.DyfB7N4J.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.Dxhm4TyR.js → requirementDiagram-4Y6WPE33.DosszoFo.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.BtQXp4J9.js → sankeyDiagram-5OEKKPKP.pBkHhvVN.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.BWEM1R_Q.js → sequenceDiagram-3UESZ5HK.BE8tmkNR.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BG3wUkWB.js → stateDiagram-AJRCARHV.C4n7vDiG.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.BLtMeFVP.js → stateDiagram-v2-BHNVJYJU.BpgwYlRy.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.D5fHo0az.js → timeline-definition-PNZ67QCA.DvcVKc-W.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.0DcuMluU.js → vennDiagram-CIIHVFJN.BLOk-UOl.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.BZ-dxgHm.js → wardleyDiagram-YWT4CUSO.CIzB7M2o.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.Bg-XWF7z.js → xychartDiagram-2RQKCTM6.CGC_lZ19.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.CTkqrhWd.js +0 -1
- package/web/_astro/channel.Dsvulp7W.js +0 -1
- package/web/_astro/index.BVXdIsZV.css +0 -1
|
@@ -0,0 +1,386 @@
|
|
|
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
|
+
for (const m of section.matchAll(/\*\*(R\d+(?:\.\d+)*)\s*\./g)) ids.add(m[1] ?? '');
|
|
249
|
+
for (const m of section.matchAll(/\*\*(R\d+(?:\.\d+)*)\*\*/g)) ids.add(m[1] ?? '');
|
|
250
|
+
return [...ids];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function extractAcIdentities(taskContent: string, featureContent: string | null): string[] {
|
|
254
|
+
const identities = new Set<string>();
|
|
255
|
+
const section = sectionBetween(taskContent, 'Acceptance Criteria');
|
|
256
|
+
for (const m of section.matchAll(/^[-*]\s+\[[ x]\]\s+(.+?)\s*(?::|$)/gm)) {
|
|
257
|
+
const label = (m[1] ?? '').trim();
|
|
258
|
+
if (!label) continue;
|
|
259
|
+
identities.add(label);
|
|
260
|
+
const leading = label.split(/\s+/)[0] ?? '';
|
|
261
|
+
if (leading && leading !== label) identities.add(leading);
|
|
262
|
+
}
|
|
263
|
+
if (featureContent !== null) {
|
|
264
|
+
for (const m of featureContent.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)) {
|
|
265
|
+
const title = (m[1] ?? '').trim();
|
|
266
|
+
if (title) identities.add(title);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return [...identities];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
function main(): void {
|
|
275
|
+
const { wbs, answer, spurBin } = parseArgs(process.argv.slice(2));
|
|
276
|
+
const findings: string[] = [];
|
|
277
|
+
const add = (msg: string): void => {
|
|
278
|
+
if (findings.length < 10) findings.push(msg);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (!existsSync(answer)) {
|
|
282
|
+
console.error(`verify-answer-lint: FAIL — answer file not found: ${answer}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const raw = readFileSync(answer, 'utf8');
|
|
286
|
+
if (!raw.trim()) {
|
|
287
|
+
console.error(`verify-answer-lint: FAIL — answer file is empty: ${answer}`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const tables = parseAnswer(raw);
|
|
292
|
+
if (tables.verdict === null) {
|
|
293
|
+
add('no `Verdict:` line (expected exactly one `Verdict: PASS|PARTIAL|FAIL` line)');
|
|
294
|
+
} else if (!/^(PASS|PARTIAL|FAIL)$/i.test(tables.verdict.value)) {
|
|
295
|
+
add(`line ${tables.verdict.line}: invalid Verdict value "${tables.verdict.value}" (PASS | PARTIAL | FAIL)`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let taskContent = '';
|
|
299
|
+
let featureId = '';
|
|
300
|
+
try {
|
|
301
|
+
const task = JSON.parse(runSpur(spurBin, ['task', 'show', wbs, '--json'])) as {
|
|
302
|
+
content?: string;
|
|
303
|
+
body?: string;
|
|
304
|
+
feature_id?: string;
|
|
305
|
+
frontmatter?: { feature_id?: string };
|
|
306
|
+
};
|
|
307
|
+
taskContent = task.content ?? task.body ?? '';
|
|
308
|
+
featureId = task.feature_id ?? task.frontmatter?.feature_id ?? '';
|
|
309
|
+
} catch {
|
|
310
|
+
console.error(`verify-answer-lint: FAIL — could not fetch task ${wbs} via ${spurBin}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
if (!taskContent) {
|
|
314
|
+
console.error(`verify-answer-lint: FAIL — task ${wbs} returned no content via ${spurBin}`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let featureContent: string | null = null;
|
|
319
|
+
if (featureId) {
|
|
320
|
+
try {
|
|
321
|
+
const feature = JSON.parse(runSpur(spurBin, ['feature', 'show', featureId, '--json'])) as {
|
|
322
|
+
content?: string;
|
|
323
|
+
};
|
|
324
|
+
featureContent = feature.content ?? '';
|
|
325
|
+
} catch {
|
|
326
|
+
featureContent = null; // checklist labels still apply; scenario titles unavailable
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const reqIds = extractRequirementIds(taskContent);
|
|
331
|
+
const acIdentities = extractAcIdentities(taskContent, featureContent);
|
|
332
|
+
|
|
333
|
+
// Requirement rows: completeness, no unknowns, no duplicates, valid status, non-empty evidence.
|
|
334
|
+
const seenReq = new Set<string>();
|
|
335
|
+
for (const row of tables.reqs) {
|
|
336
|
+
if (!reqIds.includes(row.id))
|
|
337
|
+
add(`line ${row.line}: unknown requirement ID "${row.id}" (task declares: ${reqIds.join(', ') || 'none'})`);
|
|
338
|
+
else if (seenReq.has(row.id)) add(`line ${row.line}: duplicate requirement row "${row.id}"`);
|
|
339
|
+
seenReq.add(row.id);
|
|
340
|
+
if (normalizeReqStatus(row.status) === null)
|
|
341
|
+
add(`line ${row.line}: "${row.id}" invalid status "${row.status}" (MET | PARTIAL | UNMET)`);
|
|
342
|
+
if (!row.evidence.trim()) add(`line ${row.line}: "${row.id}" has empty evidence`);
|
|
343
|
+
}
|
|
344
|
+
for (const id of reqIds) {
|
|
345
|
+
if (!seenReq.has(id)) add(`missing requirement row for "${id}"`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// AC rows: identity must exactly match a checklist label/token or a scenario title;
|
|
349
|
+
// status and evidence type must normalize; evidence non-empty. AC completeness is the
|
|
350
|
+
// verifier's authoring contract, not a lint rejection class (0726 R3).
|
|
351
|
+
const seenAc = new Set<string>();
|
|
352
|
+
for (const row of tables.acs) {
|
|
353
|
+
if (!acIdentities.includes(row.id)) {
|
|
354
|
+
add(
|
|
355
|
+
`line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title`,
|
|
356
|
+
);
|
|
357
|
+
} else if (seenAc.has(row.id)) {
|
|
358
|
+
add(`line ${row.line}: duplicate AC row "${row.id.slice(0, 60)}"`);
|
|
359
|
+
}
|
|
360
|
+
seenAc.add(row.id);
|
|
361
|
+
if (normalizeAcStatus(row.status) === null)
|
|
362
|
+
add(`line ${row.line}: invalid AC status "${row.status}" (MET | PARTIAL | UNMET | N/A)`);
|
|
363
|
+
if (normalizeEvidenceType(row.evidenceType) === null)
|
|
364
|
+
add(
|
|
365
|
+
`line ${row.line}: invalid evidence type "${row.evidenceType}" (test | command | static-ref | manual-review | llm-judge | n/a, or a + compound)`,
|
|
366
|
+
);
|
|
367
|
+
if (!row.evidence.trim()) add(`line ${row.line}: AC "${row.id.slice(0, 40)}" has empty evidence`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (findings.length > 0) {
|
|
371
|
+
console.error(
|
|
372
|
+
`verify-answer-lint: FAIL — ${findings.length}${findings.length >= 10 ? '+' : ''} finding(s) in ${answer}`,
|
|
373
|
+
);
|
|
374
|
+
for (const f of findings) console.error(` ${f}`);
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const reqCount = tables.reqs.length;
|
|
379
|
+
const acCount = tables.acs.length;
|
|
380
|
+
console.error(
|
|
381
|
+
`verify-answer-lint: PASS — ${reqCount} requirement row(s), ${acCount} AC row(s), verdict ${tables.verdict?.value ?? '?'}`,
|
|
382
|
+
);
|
|
383
|
+
process.exit(0);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
main();
|
|
@@ -247,9 +247,10 @@ completion gate (`PARTIAL`/`FAIL` route the pipeline to `failed`).
|
|
|
247
247
|
### Step 10 — Emit the verdict artifact (the only verify output)
|
|
248
248
|
|
|
249
249
|
Assemble the evidence and **emit the canonical verdict artifact** — verification writes no task
|
|
250
|
-
section (F92 0593 R1). Under the pipeline
|
|
251
|
-
`.spur/run/<wbs>-verify-answer.txt
|
|
252
|
-
`.spur/run/<wbs>-verdict.json`, and
|
|
250
|
+
section (F92 0593 R1). Under the pipeline **you** write
|
|
251
|
+
`.spur/run/<wbs>-verify-answer.txt` (0726 R3: host `expectFile`, never captured/overwritten); a
|
|
252
|
+
deterministic shell step lints it and derives `.spur/run/<wbs>-verdict.json`, and `record`
|
|
253
|
+
transcribes `## Testing` from it.
|
|
253
254
|
|
|
254
255
|
**Standalone** (`/sp:dev-verify` outside the pipeline), write the artifact yourself, then invoke
|
|
255
256
|
the deterministic Testing writer `spur task record` (section authorship never happens here):
|
|
@@ -306,14 +307,14 @@ The per-requirement traceability table MUST use `| Req | Status | Evidence |` (e
|
|
|
306
307
|
The parser is tolerant of these variants (defense-in-depth), but the authoring contract is
|
|
307
308
|
canonical.
|
|
308
309
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
Under the pipeline, the verifier owns the answer file `.spur/run/<wbs>-verify-answer.txt` (0726
|
|
311
|
+
R3): `Verdict: PARTIAL` first, append one row at a time, replace the verdict line only when all
|
|
312
|
+
rows are certified — interruptions leave lintable partial rows; retries fill only missing IDs.
|
|
313
|
+
Host: `expectFile` → `verify-answer-lint.ts` → verdict + `spur task check` (R9). Vocabularies,
|
|
314
|
+
rejection classes, and the AC identity rule: `references/verdict-schema.md`. Sections follow the
|
|
315
|
+
Step 10 contract.
|
|
315
316
|
|
|
316
|
-
**Standalone** (
|
|
317
|
+
**Standalone** (outside the pipeline — no host lint/derive step), write the answer file and
|
|
317
318
|
artifact yourself; shape and field-by-field contract in
|
|
318
319
|
[references/verdict-schema.md](references/verdict-schema.md):
|
|
319
320
|
|
|
@@ -106,6 +106,16 @@ For answer files, emit a matching parseable table:
|
|
|
106
106
|
| Scenario: CLI emits JSON | MET | test | `apps/cli/tests/foo.test.ts:42` |
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
+
**Authoring contract under the pipeline (0726 R3).** The verifier owns the answer file: write
|
|
110
|
+
`Verdict: PARTIAL` first, append one complete row at a time, and replace the first verdict line only
|
|
111
|
+
after every row is certified. `verify-answer-lint.ts` gates the file before `spur task verdict
|
|
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;
|
|
115
|
+
`N/A` additionally allowed for AC), invalid evidence type (`test | command | static-ref |
|
|
116
|
+
manual-review | llm-judge | n/a`, or a `+` compound), and empty evidence. Interrupted runs keep the
|
|
117
|
+
rows that pass the lint and complete only the missing IDs on retry.
|
|
118
|
+
|
|
109
119
|
## Checks evidence
|
|
110
120
|
|
|
111
121
|
Wave C verification can emit the following additive `checks[]` rows:
|
|
@@ -22,6 +22,7 @@ live in `packages/app/src/services/history-service.ts` (`FanOutResult`, `DailyRe
|
|
|
22
22
|
| `analyze` | Aggregate imported rows and write a versioned forensic artifact | `--since <iso>` `--until <iso>` `--source <source>` `--session <id>` `--run <runId>` `--task <wbs>` `--top <n>` `--out <path>` `--json` |
|
|
23
23
|
| `report [path]` | Purely render an existing artifact; default to `latest.json` | `--mode <name>` `--task <wbs>` `--top <n>` `--json` |
|
|
24
24
|
| `daily` | Run import-all → analyze → artifact → 90-day report pruning once | `--since <iso>` `--until <iso>` `--root <path>` `--source-timeout <ms>` `--mode <name>` `--json` |
|
|
25
|
+
| `reset` | Destructively wipe every `history_*` table for a clean re-import; refuses without `--yes` | `--yes` `--json` |
|
|
25
26
|
|
|
26
27
|
Every JSON-capable verb also advertises `--json-envelope`; use the facade's machine-output contract.
|
|
27
28
|
|
|
@@ -139,7 +139,8 @@ resolved in this order; first match wins:
|
|
|
139
139
|
2. **`agent.default`** from `.spur/config.yaml` (project layer, then `~/.config/spur/config.yaml`) —
|
|
140
140
|
`spur workflow run` injects it as the `agent` var when `vars.agent` was not set by the caller.
|
|
141
141
|
3. **YAML literal `agent:` in the pipeline file** — the last-resort fallback declared in the
|
|
142
|
-
workflow YAML
|
|
142
|
+
workflow YAML. Every shipped pipeline declares `agent: "auto"`, so this rung resolves through
|
|
143
|
+
the role/tier ladder instead of pinning an executor name; it fires only when no
|
|
143
144
|
`agent.default` is configured anywhere.
|
|
144
145
|
|
|
145
146
|
`--agent auto` tier-resolves an executor (stage `model_policy` → `agent.default` → tier priority)
|
|
@@ -72,6 +72,16 @@ Entered before `task-pipeline.yaml` `precheck` state runs `spur task check <wbs>
|
|
|
72
72
|
- [ ] The `## Plan` section is an ordered checklist (not prose).
|
|
73
73
|
- [ ] The `## Design` section, if present, does not contradict the parent feature's design.
|
|
74
74
|
- [ ] No `TODO`, `TBD`, or `???` placeholders in Requirements, AC, Design, or Plan.
|
|
75
|
+
- [ ] The evidence-channel precheck (0726 R2) status file is consulted by the
|
|
76
|
+
pipeline guard: `plugins/sp/scripts/task-evidence-precheck.ts` parses the task
|
|
77
|
+
content for an exact `evidence-channel: history_tool_call.args_raw[pi]`
|
|
78
|
+
declaration and, when present, counts live pi rows with `args_raw` on
|
|
79
|
+
`.spur/spur.db` via bun:sqlite. Tasks without a declaration pass without opening
|
|
80
|
+
SQLite; unknown declarations, a missing database/table, and a zero count write
|
|
81
|
+
FAIL. Both precheck guard conjuncts (`precheck-size.status` and
|
|
82
|
+
`precheck-evidence.status`) must read PASS — tasks declaring a live-data
|
|
83
|
+
evidence channel must import real history (safe importer, non-dry-run) before
|
|
84
|
+
implementation begins.
|
|
75
85
|
|
|
76
86
|
## review gate
|
|
77
87
|
|
|
@@ -90,6 +100,12 @@ Entered before `task-pipeline.yaml` `review` state dispatches `sp:code-verificat
|
|
|
90
100
|
|
|
91
101
|
Entered before `task-pipeline.yaml` `verify` state produces a task verdict.
|
|
92
102
|
|
|
103
|
+
- [ ] The verify answer file (`.spur/run/<wbs>-verify-answer.txt`) is lint-clean before
|
|
104
|
+
verdict derivation: `plugins/sp/scripts/verify-answer-lint.ts <wbs>` (0726 R3)
|
|
105
|
+
rejects missing/duplicate/unknown R IDs, AC identities that are not an exact task
|
|
106
|
+
checklist label or linked-feature scenario title, invalid status/evidence-type
|
|
107
|
+
values, and empty evidence on any row. A lint failure fails the verify
|
|
108
|
+
step (fail-closed) before `spur task verdict` runs.
|
|
93
109
|
- [ ] `spur task check <wbs> --strict-core --json` returns PASS.
|
|
94
110
|
- [ ] Every AC scenario has a corresponding verify command that exited 0.
|
|
95
111
|
- [ ] The `## Solution` section is filled (not the placeholder comment).
|