@massa-ai/cursor-plugin 1.21.0 → 1.22.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 (41) hide show
  1. package/.cursor-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/skills/massa-ai/references/evidence-gate.md +1 -1
  4. package/skills/massa-ai/references/hook-enforcement.md +2 -2
  5. package/skills/massa-ai/references/implementation-delivery.md +3 -3
  6. package/skills/massa-ai/references/lessons.md +9 -10
  7. package/skills/massa-ai/references/mcp-tools.md +1 -1
  8. package/skills/massa-ai/references/project-context.md +1 -1
  9. package/skills/massa-ai/references/spec-driven/artifact-store.md +7 -8
  10. package/skills/massa-ai/references/spec-driven/design.md +1 -1
  11. package/skills/massa-ai/references/spec-driven/execute.md +5 -5
  12. package/skills/massa-ai/references/spec-driven/specify.md +6 -6
  13. package/skills/massa-ai/references/spec-driven/sub-agents.md +1 -1
  14. package/skills/massa-ai/references/spec-driven/tasks.md +2 -2
  15. package/skills/massa-ai/references/spec-driven/validate.md +3 -3
  16. package/skills/massa-ai/scripts/check_commit.ts +231 -0
  17. package/skills/massa-ai/scripts/check_specs_delivered.ts +209 -0
  18. package/skills/massa-ai/scripts/lessons.ts +907 -0
  19. package/skills/massa-ai/scripts/validate_spec.ts +413 -0
  20. package/skills/massa-ai/scripts/validate_state.ts +276 -0
  21. package/skills/massa-ai/scripts/validate_tasks.ts +498 -0
  22. package/skills/massa-ai/workflows/architecture/architecture-fix.md +1 -1
  23. package/skills/massa-ai/workflows/bugs/bugs-fix.md +1 -1
  24. package/skills/massa-ai/workflows/code-quality/code-quality-fix.md +1 -1
  25. package/skills/massa-ai/workflows/debug.md +1 -1
  26. package/skills/massa-ai/workflows/feature.md +1 -1
  27. package/skills/massa-ai/workflows/general.md +2 -2
  28. package/skills/massa-ai/workflows/implementation/implementation-fix.md +1 -1
  29. package/skills/massa-ai/workflows/maestro/maestro-fix.md +1 -1
  30. package/skills/massa-ai/workflows/mobile-figma/mobile-figma-fix.md +1 -1
  31. package/skills/massa-ai/workflows/refactor.md +1 -1
  32. package/skills/massa-ai/workflows/requirements/requirements-fix.md +1 -1
  33. package/skills/massa-ai/workflows/security/security-fix.md +1 -1
  34. package/skills/massa-ai/workflows/spec-driven.md +3 -3
  35. package/skills/massa-ai/workflows/tests/tests-fix.md +1 -1
  36. package/skills/massa-ai/scripts/check_commit.py +0 -128
  37. package/skills/massa-ai/scripts/check_specs_delivered.py +0 -137
  38. package/skills/massa-ai/scripts/lessons.py +0 -630
  39. package/skills/massa-ai/scripts/validate_spec.py +0 -272
  40. package/skills/massa-ai/scripts/validate_state.py +0 -183
  41. package/skills/massa-ai/scripts/validate_tasks.py +0 -302
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * validate_spec.ts - deterministic closure-gate checks for a feature spec.md.
4
+ *
5
+ * Turns the Requirement Closure Gate (Specify phase) into a checkable pass/fail
6
+ * run BEFORE a spec is presented for confirmation, instead of trusting the model
7
+ * to remember the checks. Bun builtins only, zero dependencies. Operates only
8
+ * on the spec.md markdown artifact - never on the target codebase - so it stays
9
+ * stack-agnostic and tool-agnostic.
10
+ *
11
+ * What it checks (heuristic markdown inspection, not a full parser):
12
+ * ERROR - a required section is missing
13
+ * ERROR - an acceptance criterion has no SHALL (not testable / not EARS-shaped)
14
+ * ERROR - an Assumptions row has an empty "Chosen default" or "Rationale" cell
15
+ * ERROR - a Requirement Traceability row has a malformed ID
16
+ * WARN - an AC has SHALL but no recognizable EARS lead keyword
17
+ * WARN - template placeholder rows are still present (spec not filled in)
18
+ * WARN - open questions are not explicitly resolved
19
+ *
20
+ * Usage:
21
+ * bun skills/massa-ai/scripts/validate_spec.ts [target] [--root DIR] [--strict]
22
+ *
23
+ * Invoke with the repo-root-relative script path shown above (matches
24
+ * lessons.ts's convention), not a project-local copy.
25
+ * target Path to a spec.md, a feature directory, or a project root.
26
+ * Omitted -> auto-detect the single feature under <root>/.specs/features/.
27
+ * --root Project root that contains .specs/ (default: current dir).
28
+ * --strict Treat warnings as errors.
29
+ *
30
+ * Exit codes: 0 pass, 1 errors found (or warnings under --strict), 2 usage error.
31
+ */
32
+
33
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
34
+
35
+ const REQUIRED_SECTIONS = [
36
+ "Problem Statement",
37
+ "Out of Scope",
38
+ "Assumptions & Open Questions",
39
+ "User Stories",
40
+ "Requirement Traceability",
41
+ ];
42
+
43
+ const ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
44
+ const PLACEHOLDER_RE = /^\s*\[.+\]\s*$/;
45
+ // Defined for parity with the Python source (STATUS_VALUES); unused there too.
46
+ const STATUS_VALUES = new Set(["pending", "in design", "in tasks", "implementing", "verified"]);
47
+ void STATUS_VALUES;
48
+
49
+ /**
50
+ * Mirrors Python's os.path.join(): unlike node:path's join(), it does NOT
51
+ * normalize away a leading "." segment (os.path.join(".", "a") === "./a",
52
+ * node's join(".", "a") === "a") - divergence risk since this script's
53
+ * default --root "." is never abspath()'d, so a joined path can be printed
54
+ * or matched literally with the leading "./" intact.
55
+ */
56
+ function pyJoin(...parts: string[]): string {
57
+ let result = parts[0] ?? "";
58
+ for (let i = 1; i < parts.length; i++) {
59
+ const part = parts[i]!;
60
+ if (part.startsWith("/")) {
61
+ result = part;
62
+ } else if (result === "" || result.endsWith("/")) {
63
+ result += part;
64
+ } else {
65
+ result += `/${part}`;
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function isFile(p: string): boolean {
72
+ return existsSync(p) && statSync(p).isFile();
73
+ }
74
+
75
+ function isDir(p: string): boolean {
76
+ return existsSync(p) && statSync(p).isDirectory();
77
+ }
78
+
79
+ /** Mirrors Python's str.splitlines(): universal newline split, no trailing empty element. */
80
+ function splitLines(text: string): string[] {
81
+ if (text === "") return [];
82
+ const result: string[] = [];
83
+ const lineBreakRe = /\r\n|\r|\n/g;
84
+ let start = 0;
85
+ let match: RegExpExecArray | null;
86
+ while ((match = lineBreakRe.exec(text)) !== null) {
87
+ result.push(text.slice(start, match.index));
88
+ start = match.index + match[0].length;
89
+ }
90
+ if (start < text.length) {
91
+ result.push(text.slice(start));
92
+ }
93
+ return result;
94
+ }
95
+
96
+ function escapeRegExp(s: string): string {
97
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
98
+ }
99
+
100
+ function autodetect(root: string): string | null {
101
+ const base = pyJoin(root, ".specs", "features");
102
+ if (!isDir(base)) return null;
103
+ const features = readdirSync(base)
104
+ .filter((d) => isFile(pyJoin(base, d, "spec.md")))
105
+ .sort();
106
+ if (features.length === 1) return pyJoin(base, features[0]!, "spec.md");
107
+ if (features.length === 0) return null;
108
+ // Ambiguous: signal the caller with the list (mirrors Python's
109
+ // `raise SystemExit(str)`, which prints the string to stderr and exits 1).
110
+ const joined = features.map((f) => pyJoin(base, f, "spec.md")).join("\n ");
111
+ console.error(`validate_spec: multiple features found; pass one explicitly:\n ${joined}`);
112
+ process.exit(1);
113
+ }
114
+
115
+ /** Return the path to a spec.md from a file, dir, or auto-detect. */
116
+ function resolveSpec(target: string | null, root: string): string | null {
117
+ if (target) {
118
+ if (isFile(target)) return target;
119
+ if (isDir(target)) {
120
+ const cand = pyJoin(target, "spec.md");
121
+ if (isFile(cand)) return cand;
122
+ // maybe it's a project root
123
+ return autodetect(target);
124
+ }
125
+ // Not a path: treat as a feature name under <root>/.specs/features/<name>/
126
+ const cand = pyJoin(root, ".specs", "features", target, "spec.md");
127
+ if (isFile(cand)) return cand;
128
+ return null;
129
+ }
130
+ return autodetect(root);
131
+ }
132
+
133
+ function splitRow(line: string): string[] {
134
+ const stripped = line.trim().replace(/^\|+/, "").replace(/\|+$/, "");
135
+ return stripped.split("|").map((c) => c.trim());
136
+ }
137
+
138
+ function isSeparator(line: string): boolean {
139
+ return /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && line.includes("-");
140
+ }
141
+
142
+ /** Return [start, end) line indices for a `## name` section body. */
143
+ function sectionBounds(lines: string[], name: string): [number, number] | null {
144
+ const headingRe = new RegExp(`^#{1,3}\\s+${escapeRegExp(name)}\\s*$`);
145
+ let start: number | null = null;
146
+ for (let i = 0; i < lines.length; i++) {
147
+ if (headingRe.test(lines[i]!.trim())) {
148
+ start = i + 1;
149
+ break;
150
+ }
151
+ }
152
+ if (start === null) return null;
153
+ let end = lines.length;
154
+ const nextHeadingRe = /^#{1,3}\s+\S/;
155
+ for (let j = start; j < lines.length; j++) {
156
+ if (nextHeadingRe.test(lines[j]!)) {
157
+ end = j;
158
+ break;
159
+ }
160
+ }
161
+ return [start, end];
162
+ }
163
+
164
+ const EARS_PATTERN: Record<string, string> = {
165
+ WHILE: "state-driven",
166
+ WHEN: "event-driven",
167
+ "IF/THEN": "unwanted-behavior",
168
+ WHERE: "optional-feature",
169
+ };
170
+
171
+ /** Return [ok, note]. ok requires a SHALL; note records the EARS pattern. */
172
+ function classifyEars(text: string): [boolean, string] {
173
+ const t = text.trim();
174
+ const low = t.toLowerCase();
175
+ const hasShall = /\bshall\b/.test(low);
176
+ if (!hasShall) {
177
+ return [false, "no SHALL"];
178
+ }
179
+ const kws: string[] = [];
180
+ if (/\bwhile\b/.test(low)) kws.push("WHILE");
181
+ if (/\bwhen\b/.test(low)) kws.push("WHEN");
182
+ if (/^\s*if\b/.test(low) || /\bif\b.*\bthen\b/.test(low)) kws.push("IF/THEN");
183
+ if (/\bwhere\b/.test(low)) kws.push("WHERE");
184
+ if (kws.length >= 2) {
185
+ return [true, `complex (${kws.join("+")})`];
186
+ }
187
+ if (kws.length) {
188
+ return [true, EARS_PATTERN[kws[0]!]!];
189
+ }
190
+ if (/^\s*the\b/.test(low)) {
191
+ return [true, "ubiquitous"];
192
+ }
193
+ return [true, "warn: SHALL present but no EARS lead keyword"];
194
+ }
195
+
196
+ function check(specPath: string): { errors: string[]; warnings: string[] } {
197
+ const text = readFileSync(specPath, "utf-8");
198
+ const lines = splitLines(text);
199
+ const errors: string[] = [];
200
+ const warnings: string[] = [];
201
+
202
+ // 1. Required sections.
203
+ for (const name of REQUIRED_SECTIONS) {
204
+ if (sectionBounds(lines, name) === null) {
205
+ errors.push(`missing required section: ## ${name}`);
206
+ }
207
+ }
208
+
209
+ // 2. Acceptance criteria are EARS-shaped (have a SHALL).
210
+ //
211
+ // massa-ai patch (beyond D1): upstream terminated the AC scan on the FIRST
212
+ // blank line after the "**Acceptance Criteria**:" header, before any item
213
+ // was ever read - massa-ai's (and TLC's own) template puts a blank line
214
+ // between the header and the numbered list, so the SHALL check was a
215
+ // silent no-op against every realistically-formatted spec. Track whether
216
+ // an item has been seen and only let a blank line end the block once it
217
+ // has, so leading blank lines are skipped instead of ending the scan.
218
+ let inAc = false;
219
+ let seenItem = false;
220
+ const acHeaderRe = /^\*{0,2}Acceptance Criteria\*{0,2}\s*:?\s*$/;
221
+ const acItemRe = /^\s*\d+\.\s+(.*)$/;
222
+ const headingRe = /^#{1,3}\s/;
223
+ for (let idx = 0; idx < lines.length; idx++) {
224
+ const i = idx + 1;
225
+ const ln = lines[idx]!;
226
+ const stripped = ln.trim();
227
+ if (acHeaderRe.test(stripped)) {
228
+ inAc = true;
229
+ seenItem = false;
230
+ continue;
231
+ }
232
+ if (inAc) {
233
+ const m = acItemRe.exec(ln);
234
+ if (m) {
235
+ seenItem = true;
236
+ const item = m[1]!.trim();
237
+ if (PLACEHOLDER_RE.test(item)) {
238
+ continue; // untouched template row
239
+ }
240
+ const [ok, note] = classifyEars(item);
241
+ if (!ok) {
242
+ errors.push(`L${i}: acceptance criterion has no SHALL (not testable): ${item.slice(0, 70)}`);
243
+ } else if (note.startsWith("warn")) {
244
+ warnings.push(
245
+ `L${i}: AC has SHALL but no EARS keyword (WHEN/WHILE/WHERE/IF or ubiquitous 'The … shall'): ${item.slice(0, 60)}`,
246
+ );
247
+ }
248
+ } else if (headingRe.test(ln) || stripped.startsWith("**") || (stripped === "" && seenItem)) {
249
+ inAc = false;
250
+ }
251
+ }
252
+ }
253
+
254
+ // 3. Assumptions table cells filled.
255
+ const assumptionsBounds = sectionBounds(lines, "Assumptions & Open Questions");
256
+ if (assumptionsBounds) {
257
+ const [bs, be] = assumptionsBounds;
258
+ const rows: string[] = [];
259
+ for (let i = bs; i < be; i++) {
260
+ if (lines[i]!.trim().startsWith("|")) rows.push(lines[i]!);
261
+ }
262
+ let data = rows.filter((r) => !isSeparator(r));
263
+ // drop the header row (first table row)
264
+ if (data.length) data = data.slice(1);
265
+ let templateSeen = false;
266
+ for (const r of data) {
267
+ const cells = splitRow(r);
268
+ if (cells.length < 3) continue;
269
+ const assumption = cells[0]!;
270
+ const chosen = cells[1]!;
271
+ const rationale = cells[2]!;
272
+ if (PLACEHOLDER_RE.test(assumption) && PLACEHOLDER_RE.test(chosen)) {
273
+ templateSeen = true;
274
+ continue;
275
+ }
276
+ if (!chosen || PLACEHOLDER_RE.test(chosen)) {
277
+ errors.push(`assumption '${assumption.slice(0, 40)}' has empty 'Chosen default'`);
278
+ }
279
+ if (!rationale || PLACEHOLDER_RE.test(rationale)) {
280
+ errors.push(`assumption '${assumption.slice(0, 40)}' has empty 'Rationale'`);
281
+ }
282
+ }
283
+ if (templateSeen) {
284
+ warnings.push("Assumptions table still contains template placeholder rows");
285
+ }
286
+ // open questions line
287
+ const oq: string[] = [];
288
+ for (let i = bs; i < be; i++) {
289
+ if (lines[i]!.toLowerCase().includes("open questions")) oq.push(lines[i]!);
290
+ }
291
+ const oqClean = oq
292
+ .join(" ")
293
+ .replace(/[*_]/g, "")
294
+ .toLowerCase();
295
+ if (!oq.length) {
296
+ warnings.push("no 'Open questions:' line in Assumptions section");
297
+ } else if (!/open questions.*:\s*none/.test(oqClean)) {
298
+ warnings.push("open questions do not read as resolved ('Open questions: none')");
299
+ }
300
+ }
301
+
302
+ // 4. Requirement traceability IDs.
303
+ const traceabilityBounds = sectionBounds(lines, "Requirement Traceability");
304
+ if (traceabilityBounds) {
305
+ const [bs, be] = traceabilityBounds;
306
+ const rows: string[] = [];
307
+ for (let i = bs; i < be; i++) {
308
+ if (lines[i]!.trim().startsWith("|")) rows.push(lines[i]!);
309
+ }
310
+ let data = rows.filter((r) => !isSeparator(r));
311
+ if (data.length) data = data.slice(1);
312
+ let templateSeen = false;
313
+ let realIds = 0;
314
+ for (const r of data) {
315
+ const cells = splitRow(r);
316
+ if (!cells.length) continue;
317
+ const rid = cells[0]!;
318
+ if (PLACEHOLDER_RE.test(rid) || rid.includes("[")) {
319
+ templateSeen = true;
320
+ continue;
321
+ }
322
+ if (!rid) continue;
323
+ if (!ID_RE.test(rid)) {
324
+ errors.push(`malformed requirement ID: '${rid}' (expected e.g. AUTH-01)`);
325
+ } else {
326
+ realIds++;
327
+ }
328
+ }
329
+ if (templateSeen && realIds === 0) {
330
+ warnings.push("Requirement Traceability has only template rows (no real IDs yet)");
331
+ }
332
+ }
333
+
334
+ return { errors, warnings };
335
+ }
336
+
337
+ const USAGE = "usage: validate_spec.ts [-h] [--root ROOT] [--strict] [target]";
338
+ const HELP = `${USAGE}
339
+
340
+ Closure-gate checks for a feature spec.md.
341
+
342
+ positional arguments:
343
+ target Path to a spec.md, a feature directory, or a project root
344
+
345
+ options:
346
+ -h, --help show this help message and exit
347
+ --root ROOT
348
+ --strict`;
349
+
350
+ interface Args {
351
+ target: string | null;
352
+ root: string;
353
+ strict: boolean;
354
+ }
355
+
356
+ function printUsageError(msg: string): void {
357
+ process.stderr.write(`${USAGE}\nvalidate_spec.ts: error: ${msg}\n`);
358
+ }
359
+
360
+ function parseArgs(argv: string[]): Args | null {
361
+ let root = ".";
362
+ let strict = false;
363
+ const positionals: string[] = [];
364
+ for (let i = 0; i < argv.length; i++) {
365
+ const a = argv[i]!;
366
+ if (a === "--root") {
367
+ if (i + 1 >= argv.length) {
368
+ printUsageError("argument --root: expected one argument");
369
+ return null;
370
+ }
371
+ root = argv[++i]!;
372
+ } else if (a.startsWith("--root=")) {
373
+ root = a.slice("--root=".length);
374
+ } else if (a === "--strict") {
375
+ strict = true;
376
+ } else if (a === "-h" || a === "--help") {
377
+ console.log(HELP);
378
+ process.exit(0);
379
+ } else if (a.startsWith("-") && a !== "-") {
380
+ printUsageError(`unrecognized arguments: ${a}`);
381
+ return null;
382
+ } else {
383
+ positionals.push(a);
384
+ }
385
+ }
386
+ if (positionals.length > 1) {
387
+ printUsageError(`unrecognized arguments: ${positionals.slice(1).join(" ")}`);
388
+ return null;
389
+ }
390
+ return { target: positionals[0] ?? null, root, strict };
391
+ }
392
+
393
+ function main(argv: string[]): number {
394
+ const args = parseArgs(argv);
395
+ if (args === null) return 2;
396
+
397
+ const spec = resolveSpec(args.target, args.root);
398
+ if (!spec) {
399
+ console.error("validate_spec: could not locate a spec.md. Pass a path or run from the project root.");
400
+ return 2;
401
+ }
402
+
403
+ const { errors, warnings } = check(spec);
404
+ for (const w of warnings) console.log(` WARN ${w}`);
405
+ for (const e of errors) console.log(` ERROR ${e}`);
406
+ const fail = errors.length > 0 || (warnings.length > 0 && args.strict);
407
+ console.log(`\nvalidate_spec: ${errors.length} error(s), ${warnings.length} warning(s) in ${spec}`);
408
+ return fail ? 1 : 0;
409
+ }
410
+
411
+ if (import.meta.main) {
412
+ process.exit(main(process.argv.slice(2)));
413
+ }
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * validate_state.ts - deterministic completion gate for a feature.
4
+ *
5
+ * The skill's strongest invariant is "the Verifier is always-on, never prompted;
6
+ * Execute is not done until validation.md reports PASS." That is prose the model
7
+ * must remember. This turns it into a checkable pass/fail the closing step runs
8
+ * automatically, so declaring a feature done without a real Verifier report fails
9
+ * loudly instead of slipping through.
10
+ *
11
+ * It does NOT merely check that validation.md exists - a report that exists but is
12
+ * empty, still holds the template placeholder, or has no evidence would pass a
13
+ * shallow existence check while proving nothing. This gate requires a real,
14
+ * filled verdict plus at least one file:line evidence citation.
15
+ *
16
+ * Operates only on the .specs/ markdown artifacts (stack- and tool-agnostic). Bun
17
+ * builtins only, no new dependencies. Run from the project root (the dir that
18
+ * contains .specs), or pass --root. Meant to be invoked by the skill as the
19
+ * closing gate of Execute, the same way lessons.ts is invoked at distillation -
20
+ * not a manual step.
21
+ *
22
+ * Usage:
23
+ * bun skills/massa-ai/scripts/validate_state.ts [feature]
24
+ * bun skills/massa-ai/scripts/validate_state.ts
25
+ *
26
+ * Invoke with the repo-root-relative script path shown above (matches
27
+ * lessons.ts's convention), not a project-local copy. Pass --root when cwd is
28
+ * not the project that contains .specs/.
29
+ *
30
+ * Exit codes: 0 ok, 1 a completed feature is missing a real PASS report,
31
+ * 2 usage error.
32
+ */
33
+
34
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
35
+ import { basename, join, resolve } from "node:path";
36
+
37
+ // A file:line citation: a path with an extension, then :<line>. e.g. src/a.ts:42
38
+ const EVIDENCE_RE = /[\w./-]+\.[A-Za-z0-9]+:\d+/;
39
+
40
+ const SUMMARY_HEADING_RE = /^#{1,4}\s*summary\b/i;
41
+ const NEXT_HEADING_RE = /^#{1,4}\s/;
42
+ const RESULT_RE = /\*{0,2}result\*{0,2}\s*:/i;
43
+ const VALIDATION_HEADING_RE = /^#{1,4}\s*validation\b/i;
44
+ const TASK_HEADING_RE = /^#{2,4}\s+T\d+\s*:/m;
45
+ const UNCHECKED_BOX_RE = /^\s*-\s*\[\s\]/m;
46
+
47
+ /** Mirrors Python's str.splitlines(): universal newline split, no trailing empty element. */
48
+ function splitLines(text: string): string[] {
49
+ if (text === "") return [];
50
+ const result: string[] = [];
51
+ const lineBreakRe = /\r\n|\r|\n/g;
52
+ let start = 0;
53
+ let match: RegExpExecArray | null;
54
+ while ((match = lineBreakRe.exec(text)) !== null) {
55
+ result.push(text.slice(start, match.index));
56
+ start = match.index + match[0].length;
57
+ }
58
+ if (start < text.length) {
59
+ result.push(text.slice(start));
60
+ }
61
+ return result;
62
+ }
63
+
64
+ function isDir(p: string): boolean {
65
+ return existsSync(p) && statSync(p).isDirectory();
66
+ }
67
+
68
+ function featureDirs(root: string): { base: string; dirs: string[] } {
69
+ const base = join(root, ".specs", "features");
70
+ if (!isDir(base)) {
71
+ return { base, dirs: [] };
72
+ }
73
+ const dirs = readdirSync(base)
74
+ .filter((d) => isDir(join(base, d)))
75
+ .sort();
76
+ return { base, dirs };
77
+ }
78
+
79
+ type Verdict = "pass" | "fail" | "unfilled" | null;
80
+
81
+ /** Return 'pass', 'fail', 'unfilled', or null from a validation report. */
82
+ function verdictOf(text: string): Verdict {
83
+ const lines = splitLines(text);
84
+ // Scope to the '## Summary' section when it carries its own Result line:
85
+ // the Discrimination Sensor's per-mutation `**Result**:` sub-line elsewhere
86
+ // in the report can carry the opposite word (sensor PASS, overall FAIL) and
87
+ // must not collide with the report verdict.
88
+ const summaryLines: string[] = [];
89
+ let inSummary = false;
90
+ for (const ln of lines) {
91
+ const stripped = ln.trim();
92
+ if (SUMMARY_HEADING_RE.test(stripped)) {
93
+ inSummary = true;
94
+ continue;
95
+ }
96
+ if (inSummary && NEXT_HEADING_RE.test(stripped)) {
97
+ break;
98
+ }
99
+ if (inSummary) {
100
+ summaryLines.push(ln);
101
+ }
102
+ }
103
+ const scope = summaryLines.some((ln) => RESULT_RE.test(ln.trim())) ? summaryLines : lines;
104
+ // Look at the '## Validation' heading first, then a '**Result**' line.
105
+ const candidates = scope.filter((ln) => {
106
+ const s = ln.trim();
107
+ return VALIDATION_HEADING_RE.test(s) || RESULT_RE.test(s);
108
+ });
109
+ const hay = candidates.length ? candidates.join(" ") : scope.join("\n");
110
+ const hasPass = /\bPASS\b/.test(hay);
111
+ const hasFail = /\bFAIL\b/.test(hay);
112
+ if (hasPass && hasFail) {
113
+ // Both present on the verdict line = unfilled template "[PASS | FAIL]".
114
+ return "unfilled";
115
+ }
116
+ if (hasPass) return "pass";
117
+ if (hasFail) return "fail";
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Conservative completeness heuristic for the cross-check mode.
123
+ *
124
+ * A feature 'appears complete' if it already has a validation.md, or if it has
125
+ * a tasks.md with at least one task and no unchecked '- [ ]' boxes left. When
126
+ * the signal is ambiguous (no tasks.md, Tasks phase skipped), returns false so
127
+ * an in-flight feature is never falsely flagged.
128
+ */
129
+ function appearsComplete(fdir: string): boolean {
130
+ if (existsSync(join(fdir, "validation.md"))) {
131
+ return true;
132
+ }
133
+ const tasks = join(fdir, "tasks.md");
134
+ if (!existsSync(tasks)) {
135
+ return false;
136
+ }
137
+ const body = readFileSync(tasks, "utf-8");
138
+ if (!TASK_HEADING_RE.test(body)) {
139
+ return false;
140
+ }
141
+ if (UNCHECKED_BOX_RE.test(body)) {
142
+ return false; // unchecked box remains -> still in progress
143
+ }
144
+ return true;
145
+ }
146
+
147
+ /** Return list of error strings for one feature (empty = pass). */
148
+ function checkFeature(fdir: string, name: string): string[] {
149
+ const errors: string[] = [];
150
+ const vpath = join(fdir, "validation.md");
151
+ if (!existsSync(vpath)) {
152
+ errors.push(
153
+ `${name}: no validation.md - Execute is not done until the Verifier writes it (author != verifier). Dispatch validation before marking done.`,
154
+ );
155
+ return errors;
156
+ }
157
+ const text = readFileSync(vpath, "utf-8");
158
+ const verdict = verdictOf(text);
159
+ if (verdict === null) {
160
+ errors.push(`${name}: validation.md has no PASS/FAIL verdict (a prose-only report does not count)`);
161
+ } else if (verdict === "unfilled") {
162
+ errors.push(`${name}: validation.md verdict is still the template placeholder '[PASS | FAIL]' - not filled`);
163
+ } else if (verdict === "fail") {
164
+ errors.push(
165
+ `${name}: validation.md verdict is FAIL - route the ranked gaps to fix tasks, then re-verify (feature is not done)`,
166
+ );
167
+ }
168
+ if (verdict === "pass" && !EVIDENCE_RE.test(text)) {
169
+ errors.push(`${name}: validation.md is PASS but cites no file:line evidence - evidence-or-zero not satisfied`);
170
+ }
171
+ return errors;
172
+ }
173
+
174
+ function resolveTargets(root: string, feature: string | null): [string, string][] {
175
+ const { base, dirs } = featureDirs(root);
176
+ if (!isDir(base)) {
177
+ console.log(`validate_state: no ${base} directory - nothing to check.`);
178
+ return [];
179
+ }
180
+ if (feature) {
181
+ const fdir = isDir(feature) ? feature : join(base, feature);
182
+ if (!isDir(fdir)) {
183
+ console.error(`validate_state: feature not found: ${feature}`);
184
+ process.exit(2);
185
+ }
186
+ return [[fdir, basename(fdir.replace(/\/+$/, ""))]];
187
+ }
188
+ if (dirs.length === 1) {
189
+ return [[join(base, dirs[0]!), dirs[0]!]];
190
+ }
191
+ if (!dirs.length) {
192
+ console.log("validate_state: no features under .specs/features/ - nothing to check.");
193
+ return [];
194
+ }
195
+ // Cross-check mode: only features that appear complete.
196
+ const picked: [string, string][] = dirs
197
+ .filter((d) => appearsComplete(join(base, d)))
198
+ .map((d) => [join(base, d), d]);
199
+ if (!picked.length) {
200
+ console.log("validate_state: no completed feature detected (all in progress) - nothing to gate.");
201
+ }
202
+ return picked;
203
+ }
204
+
205
+ const USAGE = "usage: validate_state.ts [-h] [--root ROOT] [feature]";
206
+ const HELP = `${USAGE}
207
+
208
+ Deterministic completion gate: a done feature must have a real PASS validation report.
209
+
210
+ positional arguments:
211
+ feature Feature dir or name (default: sole feature, else cross-check all completed)
212
+
213
+ options:
214
+ -h, --help show this help message and exit
215
+ --root ROOT Project root containing .specs/ (default: current dir)`;
216
+
217
+ interface Args {
218
+ feature: string | null;
219
+ root: string;
220
+ }
221
+
222
+ function printUsageError(msg: string): void {
223
+ process.stderr.write(`${USAGE}\nvalidate_state.ts: error: ${msg}\n`);
224
+ }
225
+
226
+ function parseArgs(argv: string[]): Args | null {
227
+ let root = ".";
228
+ const positionals: string[] = [];
229
+ for (let i = 0; i < argv.length; i++) {
230
+ const a = argv[i]!;
231
+ if (a === "--root") {
232
+ if (i + 1 >= argv.length) {
233
+ printUsageError("argument --root: expected one argument");
234
+ return null;
235
+ }
236
+ root = argv[++i]!;
237
+ } else if (a.startsWith("--root=")) {
238
+ root = a.slice("--root=".length);
239
+ } else if (a === "-h" || a === "--help") {
240
+ console.log(HELP);
241
+ process.exit(0);
242
+ } else if (a.startsWith("-") && a !== "-") {
243
+ printUsageError(`unrecognized arguments: ${a}`);
244
+ return null;
245
+ } else {
246
+ positionals.push(a);
247
+ }
248
+ }
249
+ if (positionals.length > 1) {
250
+ printUsageError(`unrecognized arguments: ${positionals.slice(1).join(" ")}`);
251
+ return null;
252
+ }
253
+ return { feature: positionals[0] ?? null, root };
254
+ }
255
+
256
+ function main(argv: string[]): number {
257
+ const args = parseArgs(argv);
258
+ if (args === null) return 2;
259
+ const root = resolve(args.root);
260
+
261
+ const targets = resolveTargets(root, args.feature);
262
+ const allErrors: string[] = [];
263
+ for (const [fdir, name] of targets) {
264
+ allErrors.push(...checkFeature(fdir, name));
265
+ }
266
+
267
+ for (const e of allErrors) console.log(` ERROR ${e}`);
268
+ const n = allErrors.length;
269
+ const checked = targets.map(([, name]) => name).join(", ") || "(none)";
270
+ console.log(`\nvalidate_state: ${n} error(s) across [${checked}]`);
271
+ return n ? 1 : 0;
272
+ }
273
+
274
+ if (import.meta.main) {
275
+ process.exit(main(process.argv.slice(2)));
276
+ }