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.
- package/CLAUDE.md +13 -1
- package/README.md +16 -13
- package/agents/builder.md +12 -20
- package/agents/plan-checker.md +18 -0
- package/agents/planner.md +9 -0
- package/agents/verifier.md +62 -0
- package/bin/agent-runs.js +233 -0
- package/bin/cli.js +225 -21
- package/bin/install.js +25 -5
- package/bin/plan-contract.js +220 -0
- package/bin/slop-detect.mjs +357 -0
- package/bin/state.js +199 -10
- package/docs/agent-runs.md +273 -0
- package/docs/erp-contract.md +5 -0
- package/docs/plan-contract.md +321 -0
- package/hooks/auto-update.js +3 -7
- package/hooks/pre-compact.js +22 -11
- package/hooks/pre-deploy-gate.js +16 -2
- package/hooks/pre-push.js +22 -2
- package/hooks/stop-session-log.js +1 -1
- package/package.json +8 -2
- package/rules/design-brand.md +110 -0
- package/rules/design-laws.md +144 -0
- package/rules/design-product.md +110 -0
- package/rules/design-rubric.md +153 -0
- package/skills/qualia-build/SKILL.md +5 -5
- package/skills/qualia-flush/SKILL.md +1 -1
- package/skills/qualia-new/SKILL.md +40 -3
- package/skills/qualia-polish/SKILL.md +180 -136
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +25 -5
- package/skills/qualia-ship/SKILL.md +12 -10
- package/skills/zoho-workflow/SKILL.md +64 -0
- package/templates/DESIGN.md +229 -435
- package/templates/PRODUCT.md +95 -0
- package/templates/help.html +13 -7
- package/tests/bin.test.sh +6 -3
- package/tests/hooks.test.sh +9 -20
- package/tests/lib.test.sh +217 -0
- package/tests/runner.js +96 -75
- package/tests/state.test.sh +4 -3
- 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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
1301
|
-
//
|
|
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
|
-
|
|
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
|
}
|