qualia-framework 4.3.0 → 4.5.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 (42) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +16 -13
  3. package/agents/builder.md +12 -20
  4. package/agents/plan-checker.md +18 -0
  5. package/agents/planner.md +9 -0
  6. package/agents/verifier.md +62 -0
  7. package/bin/agent-runs.js +233 -0
  8. package/bin/cli.js +225 -21
  9. package/bin/install.js +25 -5
  10. package/bin/plan-contract.js +220 -0
  11. package/bin/slop-detect.mjs +357 -0
  12. package/bin/state.js +199 -10
  13. package/docs/agent-runs.md +273 -0
  14. package/docs/erp-contract.md +5 -0
  15. package/docs/plan-contract.md +321 -0
  16. package/hooks/auto-update.js +3 -7
  17. package/hooks/pre-compact.js +22 -11
  18. package/hooks/pre-deploy-gate.js +16 -2
  19. package/hooks/pre-push.js +22 -2
  20. package/hooks/stop-session-log.js +1 -1
  21. package/package.json +8 -2
  22. package/rules/design-brand.md +110 -0
  23. package/rules/design-laws.md +144 -0
  24. package/rules/design-product.md +110 -0
  25. package/rules/design-rubric.md +153 -0
  26. package/skills/qualia-build/SKILL.md +5 -5
  27. package/skills/qualia-flush/SKILL.md +1 -1
  28. package/skills/qualia-new/SKILL.md +40 -3
  29. package/skills/qualia-polish/SKILL.md +180 -136
  30. package/skills/qualia-quick/SKILL.md +1 -1
  31. package/skills/qualia-report/SKILL.md +25 -5
  32. package/skills/qualia-ship/SKILL.md +12 -10
  33. package/skills/zoho-workflow/SKILL.md +64 -0
  34. package/templates/DESIGN.md +229 -435
  35. package/templates/PRODUCT.md +95 -0
  36. package/templates/help.html +13 -7
  37. package/tests/bin.test.sh +6 -3
  38. package/tests/hooks.test.sh +9 -20
  39. package/tests/lib.test.sh +217 -0
  40. package/tests/runner.js +96 -75
  41. package/tests/state.test.sh +4 -3
  42. package/skills/qualia-design/SKILL.md +0 -169
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * slop-detect — Standalone anti-pattern scanner for Qualia projects.
4
+ *
5
+ * Usage:
6
+ * node bin/slop-detect.mjs # scan whole repo (default globs)
7
+ * node bin/slop-detect.mjs path/to/file.tsx # scan one file
8
+ * node bin/slop-detect.mjs src/components/ # scan a directory
9
+ * node bin/slop-detect.mjs --json # machine-readable output
10
+ * node bin/slop-detect.mjs --severity=critical # only critical findings
11
+ *
12
+ * Exit codes:
13
+ * 0 no critical findings
14
+ * 1 one or more critical findings
15
+ * 2 invocation error
16
+ *
17
+ * Builder agents call this BEFORE commit. A non-zero exit blocks the commit.
18
+ */
19
+
20
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
21
+ import { join, extname, relative, resolve } from "node:path";
22
+ import { argv, exit, cwd } from "node:process";
23
+
24
+ // ── Severity rubric (from rules/grounding.md) ─────────────────────────
25
+ const CRITICAL = "critical";
26
+ const HIGH = "high";
27
+ const MEDIUM = "medium";
28
+ const LOW = "low";
29
+
30
+ // ── Anti-patterns ─────────────────────────────────────────────────────
31
+ // Each rule: { id, severity, label, fileGlob, pattern, allow?, fix }
32
+ const RULES = [
33
+ // ── CRITICAL: absolute bans (block commit) ──────────────────────────
34
+ {
35
+ id: "ABS-FONT",
36
+ severity: CRITICAL,
37
+ label: "Banned font (Inter/Roboto/Arial/system-ui/Space Grotesk)",
38
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
39
+ pattern: /(font-family|fontFamily)[^;{,]*(['"`])(Inter|Roboto|Arial|Helvetica|system-ui|Space\s*Grotesk)\b/i,
40
+ allow: /Inter\s*Display|Inter\s*Tight/,
41
+ fix: "Replace with a distinctive font (Fraunces, Geist, Söhne, JetBrains Mono, etc). See DESIGN.md §3.",
42
+ },
43
+ {
44
+ id: "ABS-PURPLE-GRAD",
45
+ severity: CRITICAL,
46
+ label: "Purple-blue gradient (the #1 AI-design tell)",
47
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
48
+ pattern: /(from-(blue|indigo|violet)-\d+\s+to-(purple|violet|fuchsia|pink)-\d+|from-(purple|violet|fuchsia|pink)-\d+\s+to-(blue|indigo|violet)-\d+|linear-gradient[^;]*(blue|indigo)[^;]*(purple|violet|fuchsia)|linear-gradient[^;]*(purple|violet|fuchsia)[^;]*(blue|indigo))/i,
49
+ fix: "Use one solid brand accent color. Emphasis via weight or size, not gradient text/bg.",
50
+ },
51
+ {
52
+ id: "ABS-GRADIENT-TEXT",
53
+ severity: CRITICAL,
54
+ label: "Gradient text (background-clip: text)",
55
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
56
+ pattern: /(background-clip\s*:\s*text|bg-clip-text|-webkit-background-clip\s*:\s*text)/i,
57
+ fix: "Decorative, never meaningful. Use a single solid color. Emphasis via weight or size.",
58
+ },
59
+ {
60
+ id: "ABS-PURE-BLACK-WHITE",
61
+ severity: CRITICAL,
62
+ label: "Pure #000 or #fff (untuned, generic)",
63
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
64
+ pattern: /#(000000|FFFFFF|000|FFF)\b/,
65
+ allow: /shadow|outline|currentColor|var\(/i,
66
+ fix: "Tint every neutral toward brand hue. Use OKLCH with chroma 0.005-0.015. See design-laws.md §1.",
67
+ },
68
+ {
69
+ id: "ABS-HEX-IN-JSX",
70
+ severity: CRITICAL,
71
+ label: "Hardcoded hex color in JSX/TSX (use design tokens)",
72
+ fileGlob: /\.(tsx|jsx)$/,
73
+ pattern: /style=\{[^}]*['"]#[0-9a-fA-F]{3,8}['"]/,
74
+ fix: "Move color to a CSS custom property in DESIGN.md tokens. Reference via var(--name).",
75
+ },
76
+ {
77
+ id: "ABS-SIDE-STRIPE",
78
+ severity: CRITICAL,
79
+ label: "Side-stripe border (decorative border-left ≥2px)",
80
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
81
+ pattern: /border-(left|right)\s*:\s*\d+(px|rem)\s+solid|border-l-(2|4|8)|border-r-(2|4|8)/,
82
+ allow: /focus|active|outline/,
83
+ fix: "Use full borders, background tints, leading icons, or nothing. See design-laws.md §8.",
84
+ },
85
+
86
+ // ── HIGH: strong tells ───────────────────────────────────────────────
87
+ {
88
+ id: "HI-MAX-W-CAP",
89
+ severity: HIGH,
90
+ label: "Hardcoded max-width container (no fluid full-width)",
91
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
92
+ pattern: /(max-w-7xl|max-w-\[1200|max-w-\[1280|max-w-\[1440|max-width\s*:\s*1200|max-width\s*:\s*1280)/,
93
+ fix: "Use fluid padding: clamp(1rem, 5vw, 4rem). Cap content via max-width: 65ch on prose only.",
94
+ },
95
+ {
96
+ id: "HI-OUTLINE-NONE",
97
+ severity: HIGH,
98
+ label: "outline:none without focus replacement (a11y violation)",
99
+ fileGlob: /\.(tsx|jsx|ts|js|css|scss)$/,
100
+ pattern: /outline\s*:\s*none|outline-none/,
101
+ allow: /focus-visible|focus:ring|focus:outline/,
102
+ fix: "Replace with a visible focus ring: 2px offset, contrasting color. See design-laws.md §accessibility.",
103
+ },
104
+ {
105
+ id: "HI-IMG-NO-ALT",
106
+ severity: HIGH,
107
+ label: "<img> without alt attribute",
108
+ fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
109
+ pattern: /<img\s+(?![^>]*\balt=)[^>]*>/,
110
+ fix: "Every image needs alt text. Decorative: alt=\"\" + aria-hidden=\"true\". Meaningful: describe it.",
111
+ },
112
+ {
113
+ id: "HI-GENERIC-CTA",
114
+ severity: HIGH,
115
+ label: "Generic CTA copy (Get Started / Learn More / Click Here)",
116
+ fileGlob: /\.(tsx|jsx|html|svelte|vue|astro|md)$/,
117
+ pattern: />\s*(Get Started|Learn More|Click Here|Welcome to|Read More|Find Out More)\s*</i,
118
+ fix: "Name the action: 'Download invoice', 'Continue setup', 'Try the demo'.",
119
+ },
120
+ {
121
+ id: "HI-EM-DASH",
122
+ severity: HIGH,
123
+ label: "Em dash in user-facing copy (— or '--')",
124
+ // Scope: shipped UI files only. Markdown / docs prose is allowed em-dashes.
125
+ fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
126
+ pattern: />\s*[^<]*[—][^<]*</,
127
+ fix: "Use commas, colons, semicolons, periods, or parentheses. See design-laws.md §7.",
128
+ },
129
+
130
+ // ── MEDIUM: noisy signals ────────────────────────────────────────────
131
+ {
132
+ id: "MED-CARD-GRID-3",
133
+ severity: MEDIUM,
134
+ label: "Three/four-column card grid (likely identical-card slop)",
135
+ fileGlob: /\.(tsx|jsx|html|svelte|vue|astro)$/,
136
+ pattern: /(grid-cols-3|grid-cols-4|grid-template-columns\s*:\s*repeat\(\s*[34]\s*,)/,
137
+ fix: "Vary card sizes and content shapes. Identical cards in a grid is the AI default hero pattern.",
138
+ },
139
+ {
140
+ id: "MED-GLASSMORPHISM",
141
+ severity: MEDIUM,
142
+ label: "Glassmorphism (backdrop-blur on multiple surfaces — likely default)",
143
+ fileGlob: /\.(tsx|jsx|css|scss)$/,
144
+ pattern: /(backdrop-blur|backdrop-filter\s*:\s*blur)/,
145
+ fix: "Glass effects are rare and purposeful, or nothing. Don't use them as default decoration.",
146
+ },
147
+ {
148
+ id: "MED-CONTAINER-DEPTH",
149
+ severity: MEDIUM,
150
+ label: "Possible container-depth >2 (card on card on pill)",
151
+ fileGlob: /\.(tsx|jsx)$/,
152
+ pattern: /<div[^>]*className="[^"]*\b(card|panel|surface)[^"]*"[^>]*>\s*<div[^>]*className="[^"]*\b(card|panel|surface)/,
153
+ fix: "Container depth max 2. Flatten nested cards into a single container with content.",
154
+ },
155
+ {
156
+ id: "MED-ANIMATE-LAYOUT",
157
+ severity: MEDIUM,
158
+ label: "Animating layout properties (causes reflow, jank)",
159
+ fileGlob: /\.(tsx|jsx|css|scss)$/,
160
+ pattern: /transition\s*:\s*(width|height|top|left|right|bottom|margin|padding)\s/,
161
+ fix: "Animate transform and opacity only. Layout properties trigger reflow.",
162
+ },
163
+ {
164
+ id: "MED-BOUNCE-EASING",
165
+ severity: MEDIUM,
166
+ label: "Bounce/elastic easing (banned per design-laws.md §6)",
167
+ fileGlob: /\.(tsx|jsx|css|scss|js|ts)$/,
168
+ pattern: /(bounce|elastic|backIn|backOut|cubic-bezier\([^)]*1\.\d+)/i,
169
+ fix: "Ease out with exponential curves: cubic-bezier(0.22, 1, 0.36, 1) or (0.16, 1, 0.3, 1).",
170
+ },
171
+
172
+ // ── LOW: cleanup ─────────────────────────────────────────────────────
173
+ {
174
+ id: "LOW-CONSOLE-LOG",
175
+ severity: LOW,
176
+ label: "console.log in production code",
177
+ fileGlob: /\.(tsx|jsx|ts|js)$/,
178
+ pattern: /console\.(log|debug)\(/,
179
+ allow: /\/\/\s*(debug|todo)|test\.|spec\./i,
180
+ fix: "Remove or replace with a proper logger.",
181
+ },
182
+ ];
183
+
184
+ // ── File walker ───────────────────────────────────────────────────────
185
+ const SKIP_DIRS = new Set([
186
+ "node_modules", ".next", "dist", "build", ".git", ".turbo",
187
+ "coverage", ".cache", "out", ".vercel", ".vscode", ".idea",
188
+ ".planning", ".qa-screenshots",
189
+ ]);
190
+
191
+ function* walk(dir) {
192
+ let entries;
193
+ try { entries = readdirSync(dir); } catch { return; }
194
+ for (const name of entries) {
195
+ if (name.startsWith(".") && !["src", "app", "components", "lib"].includes(name)) {
196
+ // skip dotfiles except known source dirs
197
+ if (SKIP_DIRS.has(name)) continue;
198
+ }
199
+ const path = join(dir, name);
200
+ let st;
201
+ try { st = statSync(path); } catch { continue; }
202
+ if (st.isDirectory()) {
203
+ if (SKIP_DIRS.has(name)) continue;
204
+ yield* walk(path);
205
+ } else if (st.isFile()) {
206
+ yield path;
207
+ }
208
+ }
209
+ }
210
+
211
+ // ── Scanner ───────────────────────────────────────────────────────────
212
+ function scanFile(path) {
213
+ const findings = [];
214
+ let content;
215
+ try { content = readFileSync(path, "utf8"); } catch { return findings; }
216
+ const lines = content.split("\n");
217
+
218
+ for (const rule of RULES) {
219
+ if (!rule.fileGlob.test(path)) continue;
220
+ for (let i = 0; i < lines.length; i++) {
221
+ const line = lines[i];
222
+ if (!rule.pattern.test(line)) continue;
223
+ if (rule.allow && rule.allow.test(line)) continue;
224
+ findings.push({
225
+ rule: rule.id,
226
+ severity: rule.severity,
227
+ label: rule.label,
228
+ file: path,
229
+ line: i + 1,
230
+ snippet: line.trim().slice(0, 120),
231
+ fix: rule.fix,
232
+ });
233
+ }
234
+ }
235
+ return findings;
236
+ }
237
+
238
+ // ── CLI ───────────────────────────────────────────────────────────────
239
+ function parseArgs(argv) {
240
+ const args = { paths: [], json: false, severity: null, help: false };
241
+ for (const a of argv.slice(2)) {
242
+ if (a === "--json") args.json = true;
243
+ else if (a === "--help" || a === "-h") args.help = true;
244
+ else if (a.startsWith("--severity=")) args.severity = a.split("=")[1];
245
+ else if (a.startsWith("--")) {
246
+ console.error(`Unknown flag: ${a}`);
247
+ exit(2);
248
+ } else args.paths.push(a);
249
+ }
250
+ return args;
251
+ }
252
+
253
+ function help() {
254
+ console.log(`slop-detect — Qualia anti-pattern scanner
255
+
256
+ Usage:
257
+ slop-detect [path ...] [--json] [--severity=critical|high|medium|low]
258
+
259
+ Examples:
260
+ slop-detect # scan whole repo
261
+ slop-detect src/components/Button.tsx # scan one file
262
+ slop-detect app/ # scan a directory
263
+ slop-detect --severity=critical # only critical findings
264
+ slop-detect --json > slop.json # machine-readable
265
+
266
+ Exit codes:
267
+ 0 no critical findings
268
+ 1 one or more critical findings
269
+ 2 invocation error
270
+ `);
271
+ }
272
+
273
+ function severityOrder(s) { return { critical: 4, high: 3, medium: 2, low: 1 }[s] || 0; }
274
+ function severityColor(s) { return { critical: "\x1b[31m", high: "\x1b[33m", medium: "\x1b[36m", low: "\x1b[37m" }[s] || ""; }
275
+ const RESET = "\x1b[0m";
276
+ const DIM = "\x1b[2m";
277
+ const BOLD = "\x1b[1m";
278
+
279
+ function main() {
280
+ const args = parseArgs(argv);
281
+ if (args.help) { help(); exit(0); }
282
+
283
+ const targets = args.paths.length ? args.paths.map(p => resolve(p)) : ["app", "components", "src", "lib", "pages"]
284
+ .map(d => resolve(cwd(), d))
285
+ .filter(d => existsSync(d));
286
+
287
+ if (targets.length === 0) targets.push(resolve(cwd()));
288
+
289
+ // Collect files
290
+ const files = [];
291
+ for (const t of targets) {
292
+ let st;
293
+ try { st = statSync(t); } catch {
294
+ console.error(`Path does not exist: ${t}`);
295
+ exit(2);
296
+ }
297
+ if (st.isFile()) files.push(t);
298
+ else for (const f of walk(t)) files.push(f);
299
+ }
300
+
301
+ // Scan
302
+ const findings = [];
303
+ for (const f of files) findings.push(...scanFile(f));
304
+
305
+ // Filter by severity
306
+ const minSev = args.severity ? severityOrder(args.severity) : 1;
307
+ const filtered = findings.filter(f => severityOrder(f.severity) >= minSev);
308
+
309
+ // Output
310
+ if (args.json) {
311
+ console.log(JSON.stringify({
312
+ scanned_files: files.length,
313
+ total_findings: filtered.length,
314
+ by_severity: {
315
+ critical: filtered.filter(f => f.severity === CRITICAL).length,
316
+ high: filtered.filter(f => f.severity === HIGH).length,
317
+ medium: filtered.filter(f => f.severity === MEDIUM).length,
318
+ low: filtered.filter(f => f.severity === LOW).length,
319
+ },
320
+ findings: filtered,
321
+ }, null, 2));
322
+ } else {
323
+ if (filtered.length === 0) {
324
+ console.log(`${BOLD}\x1b[32m✓ no slop detected${RESET} (${files.length} files scanned)`);
325
+ } else {
326
+ // Group by severity, sorted highest first
327
+ const bySev = {};
328
+ for (const f of filtered) (bySev[f.severity] ||= []).push(f);
329
+ const order = ["critical", "high", "medium", "low"];
330
+ for (const sev of order) {
331
+ const items = bySev[sev];
332
+ if (!items) continue;
333
+ const color = severityColor(sev);
334
+ console.log(`\n${color}${BOLD}${sev.toUpperCase()}${RESET} ${items.length} finding${items.length === 1 ? "" : "s"}`);
335
+ console.log(`${DIM}${"─".repeat(60)}${RESET}`);
336
+ for (const f of items) {
337
+ const rel = relative(cwd(), f.file);
338
+ console.log(`${color}●${RESET} ${BOLD}${rel}:${f.line}${RESET} ${DIM}[${f.rule}]${RESET}`);
339
+ console.log(` ${f.label}`);
340
+ console.log(` ${DIM}${f.snippet}${RESET}`);
341
+ console.log(` ${color}→${RESET} ${f.fix}`);
342
+ console.log();
343
+ }
344
+ }
345
+ const crit = (bySev.critical || []).length;
346
+ const total = filtered.length;
347
+ console.log(`${BOLD}${total}${RESET} total · ${BOLD}${crit}${RESET} critical · ${files.length} files scanned`);
348
+ if (crit > 0) console.log(`${severityColor("critical")}${BOLD}commit blocked${RESET} — fix critical findings first`);
349
+ }
350
+ }
351
+
352
+ // Exit code
353
+ const criticalCount = filtered.filter(f => f.severity === CRITICAL).length;
354
+ exit(criticalCount > 0 ? 1 : 0);
355
+ }
356
+
357
+ main();
package/bin/state.js CHANGED
@@ -104,11 +104,14 @@ function acquireLock(timeoutMs = 5000) {
104
104
  sleepSync(50);
105
105
  }
106
106
  }
107
- // Couldn't acquire inside the budget — proceed unlocked rather than
108
- // hard-block the user. Surface this in analytics so repeated contention
109
- // is visible instead of silent.
110
- try { _trace("state-lock", "fallthrough", { waited_ms: Date.now() - start }); } catch {}
111
- return null;
107
+ const waited = Date.now() - start;
108
+ try { _trace("state-lock", "timeout", { waited_ms: waited }); } catch {}
109
+ const err = new Error(
110
+ `Could not acquire ${LOCK_FILE} after ${waited}ms. Another state mutation may still be running.`
111
+ );
112
+ err.code = "STATE_LOCK_TIMEOUT";
113
+ err.waited_ms = waited;
114
+ throw err;
112
115
  }
113
116
 
114
117
  function releaseLock(lock) {
@@ -1264,6 +1267,186 @@ function cmdBackfillLifetime(opts) {
1264
1267
  });
1265
1268
  }
1266
1269
 
1270
+ // ─── Backfill Milestones from JOURNEY.md ─────────────────
1271
+ // Reconstructs the milestones[] array + lifetime counters from the
1272
+ // "Milestone arc" table in .planning/JOURNEY.md. Required when a project
1273
+ // pre-dates v4 milestone bookkeeping (or had its tracking.json reset)
1274
+ // but JOURNEY.md captures the historical arc. Idempotent — only adds
1275
+ // missing milestones or overwrites entries flagged backfilled:true.
1276
+ //
1277
+ // JOURNEY.md table format expected:
1278
+ // | # | Milestone | Status | Phases | Closed |
1279
+ // | 1 | Name | CLOSED | 1–13 | YYYY-MM-DD |
1280
+ // | 5 | Name | OPEN | rolling| — |
1281
+ //
1282
+ // Phase counting handles ranges (`1–13` → 13), comma lists (`14, 15, 16.1–16.6` → 8),
1283
+ // and "rolling" / "—" / "-" → 0.
1284
+ function cmdBackfillMilestones(opts) {
1285
+ const t = readTracking();
1286
+ if (!t) return output(fail("NO_PROJECT", "No .planning/ found."));
1287
+ ensureLifetime(t);
1288
+
1289
+ const journeyPath = path.join(PLANNING, "JOURNEY.md");
1290
+ if (!fs.existsSync(journeyPath)) {
1291
+ return output(fail("NO_JOURNEY", "JOURNEY.md not found. Cannot backfill without milestone history source."));
1292
+ }
1293
+
1294
+ const content = fs.readFileSync(journeyPath, "utf8");
1295
+
1296
+ // Parse the milestone arc table. Each row must have 5 columns:
1297
+ // num | name | status | phases | closed
1298
+ const rows = [];
1299
+ const lines = content.split(/\r?\n/);
1300
+ for (const line of lines) {
1301
+ const m = line.match(
1302
+ /^\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*(CLOSED|OPEN|CURRENT)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/i
1303
+ );
1304
+ if (m) {
1305
+ rows.push({
1306
+ num: parseInt(m[1], 10),
1307
+ name: m[2].trim(),
1308
+ status: m[3].trim().toUpperCase(),
1309
+ phasesStr: m[4].trim(),
1310
+ closedStr: m[5].trim(),
1311
+ });
1312
+ }
1313
+ }
1314
+
1315
+ if (rows.length === 0) {
1316
+ return output(
1317
+ fail(
1318
+ "NO_MILESTONE_TABLE",
1319
+ "No milestone arc table found in JOURNEY.md. Expected `| # | Milestone | Status | Phases | Closed |` header followed by rows."
1320
+ )
1321
+ );
1322
+ }
1323
+
1324
+ // Count phases from a phasesStr.
1325
+ // "1–13" → 13. "14, 15, 16.1–16.6" → 8. "19–25" → 7. "rolling" / "—" / "-" → 0.
1326
+ const countPhases = (s) => {
1327
+ if (!s) return 0;
1328
+ const lower = s.toLowerCase();
1329
+ if (lower === "—" || lower === "-" || lower === "rolling" || lower === "n/a") return 0;
1330
+ let count = 0;
1331
+ for (const seg of s.split(",")) {
1332
+ const trimmed = seg.trim();
1333
+ if (!trimmed || trimmed === "—" || trimmed === "-") continue;
1334
+ // Match X–Y or X-Y where X/Y can be "13" or "16.6"
1335
+ const range = trimmed.match(/^(\d+(?:\.\d+)?)\s*[–-]\s*(\d+(?:\.\d+)?)$/);
1336
+ if (range) {
1337
+ const startStr = range[1];
1338
+ const endStr = range[2];
1339
+ // Sub-phase range like "16.1–16.6" → count by sub-index difference + 1
1340
+ if (startStr.includes(".") && endStr.includes(".")) {
1341
+ const startSub = parseInt(startStr.split(".")[1], 10);
1342
+ const endSub = parseInt(endStr.split(".")[1], 10);
1343
+ count += Math.max(0, endSub - startSub + 1);
1344
+ } else {
1345
+ const start = parseInt(startStr, 10);
1346
+ const end = parseInt(endStr, 10);
1347
+ count += Math.max(0, end - start + 1);
1348
+ }
1349
+ } else {
1350
+ // Single phase like "14" or "17.1"
1351
+ count += 1;
1352
+ }
1353
+ }
1354
+ return count;
1355
+ };
1356
+
1357
+ const closed = rows.filter((r) => r.status === "CLOSED").sort((a, b) => a.num - b.num);
1358
+ const openRow = rows.find((r) => r.status === "OPEN" || r.status === "CURRENT");
1359
+
1360
+ t.milestones = Array.isArray(t.milestones) ? t.milestones : [];
1361
+ let added = 0;
1362
+ let updated = 0;
1363
+ let totalClosedPhases = 0;
1364
+ const closedSummaries = [];
1365
+
1366
+ for (const row of closed) {
1367
+ const phaseCount = countPhases(row.phasesStr);
1368
+ totalClosedPhases += phaseCount;
1369
+
1370
+ const dateMatch = row.closedStr.match(/\d{4}-\d{2}-\d{2}/);
1371
+ const closedAt = dateMatch ? `${dateMatch[0]}T00:00:00.000Z` : "";
1372
+
1373
+ const summary = {
1374
+ num: row.num,
1375
+ name: row.name,
1376
+ total_phases: phaseCount,
1377
+ phases_completed: phaseCount,
1378
+ tasks_completed: 0, // unknown for historical backfill
1379
+ shipped_url: "",
1380
+ closed_at: closedAt,
1381
+ backfilled: true,
1382
+ };
1383
+ closedSummaries.push({ num: row.num, name: row.name, phases: phaseCount });
1384
+
1385
+ const existing = t.milestones.findIndex((mm) => mm && mm.num === row.num);
1386
+ if (existing >= 0) {
1387
+ // Don't override entries that came from real /qualia-milestone close
1388
+ // (they have richer data). Only overwrite previously-backfilled entries.
1389
+ if (t.milestones[existing].backfilled) {
1390
+ t.milestones[existing] = summary;
1391
+ updated++;
1392
+ }
1393
+ } else {
1394
+ t.milestones.push(summary);
1395
+ added++;
1396
+ }
1397
+ }
1398
+
1399
+ // Stable order by milestone number.
1400
+ t.milestones.sort((a, b) => (a.num || 0) - (b.num || 0));
1401
+
1402
+ const lastClosed = closed.length > 0 ? closed[closed.length - 1].num : 0;
1403
+
1404
+ // Math.max — never reduce lifetime counters. Preserves real /qualia-milestone
1405
+ // history if a project was partly closed properly and partly backfilled.
1406
+ t.lifetime.milestones_completed = Math.max(
1407
+ t.lifetime.milestones_completed || 0,
1408
+ closed.length
1409
+ );
1410
+ t.lifetime.last_closed_milestone = Math.max(
1411
+ t.lifetime.last_closed_milestone || 0,
1412
+ lastClosed
1413
+ );
1414
+ t.lifetime.total_phases = Math.max(
1415
+ t.lifetime.total_phases || 0,
1416
+ totalClosedPhases
1417
+ );
1418
+
1419
+ // Set current milestone — prefer the OPEN row; otherwise next-after-last-closed.
1420
+ if (openRow) {
1421
+ t.milestone = openRow.num;
1422
+ t.milestone_name = openRow.name;
1423
+ } else if (lastClosed > 0) {
1424
+ t.milestone = lastClosed + 1;
1425
+ t.milestone_name = readNextMilestoneNameFromJourney(t.milestone);
1426
+ }
1427
+
1428
+ t.last_updated = new Date().toISOString();
1429
+ writeTracking(t);
1430
+
1431
+ _trace("backfill-milestones", "allow", {
1432
+ added,
1433
+ updated,
1434
+ closed_count: closed.length,
1435
+ total_phases: totalClosedPhases,
1436
+ lifetime: t.lifetime,
1437
+ });
1438
+
1439
+ output({
1440
+ ok: true,
1441
+ action: "backfill-milestones",
1442
+ added,
1443
+ updated,
1444
+ closed: closedSummaries,
1445
+ open_milestone: openRow ? { num: openRow.num, name: openRow.name } : null,
1446
+ lifetime: t.lifetime,
1447
+ });
1448
+ }
1449
+
1267
1450
  // ─── Next Report ID ──────────────────────────────────────
1268
1451
  // Increments report_seq and returns the next QS-REPORT-NN id. Per-project
1269
1452
  // counter (lives in tracking.json). /qualia-report calls this to tag each
@@ -1297,9 +1480,8 @@ const opts = parseArgs(rest);
1297
1480
 
1298
1481
  // Mutators must hold the .planning/.state.lock for the duration of their
1299
1482
  // dual STATE.md + tracking.json writes. Read commands (check, validate-plan)
1300
- // don't need the lock. The lock is best-effort: if it can't be acquired
1301
- // inside acquireLock's timeout, the command proceeds anyway we'd rather
1302
- // risk a rare race than hard-block the user.
1483
+ // don't need the lock. A lock timeout is a hard failure for mutators; racing
1484
+ // state writes are worse than asking the user to retry.
1303
1485
  const READ_ONLY = new Set(["check", "validate-plan"]);
1304
1486
  let __lock = null;
1305
1487
  if (!READ_ONLY.has(cmd)) {
@@ -1307,7 +1489,11 @@ if (!READ_ONLY.has(cmd)) {
1307
1489
  // previous mutator. Runs for mutators only; read commands should still
1308
1490
  // return the actual on-disk state even if it's mid-recovery.
1309
1491
  try { recoverFromJournal(); } catch {}
1310
- __lock = acquireLock();
1492
+ try {
1493
+ __lock = acquireLock();
1494
+ } catch (err) {
1495
+ output(fail(err.code || "STATE_LOCK_ERROR", err.message));
1496
+ }
1311
1497
  process.on("exit", () => releaseLock(__lock));
1312
1498
  process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
1313
1499
  process.on("SIGTERM", () => { releaseLock(__lock); process.exit(143); });
@@ -1336,6 +1522,9 @@ try {
1336
1522
  case "backfill-lifetime":
1337
1523
  cmdBackfillLifetime(opts);
1338
1524
  break;
1525
+ case "backfill-milestones":
1526
+ cmdBackfillMilestones(opts);
1527
+ break;
1339
1528
  case "next-report-id":
1340
1529
  cmdNextReportId(opts);
1341
1530
  break;
@@ -1343,7 +1532,7 @@ try {
1343
1532
  output(
1344
1533
  fail(
1345
1534
  "UNKNOWN_COMMAND",
1346
- `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|next-report-id> [--options]`
1535
+ `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|backfill-milestones|next-report-id> [--options]`
1347
1536
  )
1348
1537
  );
1349
1538
  }