qualia-framework 3.6.0 → 4.0.3
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 +23 -11
- package/README.md +96 -51
- package/agents/builder.md +25 -14
- package/agents/plan-checker.md +29 -16
- package/agents/planner.md +33 -24
- package/agents/research-synthesizer.md +25 -12
- package/agents/roadmapper.md +89 -84
- package/agents/verifier.md +11 -2
- package/bin/cli.js +18 -13
- package/bin/install.js +34 -45
- package/bin/qualia-ui.js +267 -1
- package/bin/state.js +164 -12
- package/bin/statusline.js +4 -1
- package/docs/erp-contract.md +12 -0
- package/guide.md +85 -22
- package/hooks/migration-guard.js +23 -9
- package/hooks/pre-compact.js +39 -11
- package/hooks/pre-deploy-gate.js +3 -4
- package/hooks/pre-push.js +6 -3
- package/hooks/session-start.js +8 -8
- package/package.json +1 -1
- package/rules/frontend.md +5 -13
- package/skills/qualia/SKILL.md +8 -1
- package/skills/qualia-build/SKILL.md +49 -4
- package/skills/qualia-debug/SKILL.md +6 -0
- package/skills/qualia-design/SKILL.md +9 -1
- package/skills/qualia-discuss/SKILL.md +6 -0
- package/skills/qualia-handoff/SKILL.md +92 -12
- package/skills/qualia-help/SKILL.md +18 -4
- package/skills/qualia-idk/SKILL.md +166 -0
- package/skills/qualia-learn/SKILL.md +6 -0
- package/skills/qualia-map/SKILL.md +7 -0
- package/skills/qualia-milestone/SKILL.md +128 -79
- package/skills/qualia-new/SKILL.md +163 -230
- package/skills/qualia-optimize/SKILL.md +8 -0
- package/skills/qualia-pause/SKILL.md +5 -0
- package/skills/qualia-plan/SKILL.md +25 -10
- package/skills/qualia-polish/SKILL.md +8 -0
- package/skills/qualia-quick/SKILL.md +7 -0
- package/skills/qualia-report/SKILL.md +17 -0
- package/skills/qualia-research/SKILL.md +7 -0
- package/skills/qualia-resume/SKILL.md +3 -0
- package/skills/qualia-review/SKILL.md +7 -0
- package/skills/qualia-ship/SKILL.md +5 -0
- package/skills/qualia-skill-new/SKILL.md +6 -0
- package/skills/qualia-task/SKILL.md +8 -1
- package/skills/qualia-test/SKILL.md +7 -0
- package/skills/qualia-verify/SKILL.md +65 -3
- package/templates/help.html +4 -4
- package/templates/journey.md +113 -0
- package/templates/plan.md +56 -11
- package/templates/requirements.md +82 -22
- package/templates/roadmap.md +41 -14
- package/templates/tracking.json +2 -0
- package/tests/hooks.test.sh +5 -5
- package/tests/runner.js +381 -7
package/bin/qualia-ui.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// next <command> — "Run: /qualia-X" footer
|
|
18
18
|
// end <status> [next-command] — closing banner with optional next
|
|
19
19
|
// update <current> <latest> — sticky framework update banner
|
|
20
|
+
// plan-summary <path/to/plan.md> — story-file dashboard for a plan
|
|
21
|
+
// journey-tree [path/to/JOURNEY.md] — ladder view of all milestones, current highlighted
|
|
22
|
+
// milestone-complete <num> <name> <next> — celebration banner on milestone close
|
|
20
23
|
|
|
21
24
|
const fs = require("fs");
|
|
22
25
|
const path = require("path");
|
|
@@ -63,6 +66,11 @@ const ACTIONS = {
|
|
|
63
66
|
welcome: { label: "WELCOME", glyph: "⬢" },
|
|
64
67
|
test: { label: "TESTING", glyph: "⊡" },
|
|
65
68
|
analytics: { label: "ANALYTICS", glyph: "◈" },
|
|
69
|
+
milestone: { label: "MILESTONE", glyph: "◆" },
|
|
70
|
+
journey: { label: "JOURNEY", glyph: "◯" },
|
|
71
|
+
auto: { label: "AUTO MODE", glyph: "⚡" },
|
|
72
|
+
research: { label: "RESEARCH", glyph: "◱" },
|
|
73
|
+
roadmap: { label: "ROADMAP", glyph: "◐" },
|
|
66
74
|
};
|
|
67
75
|
|
|
68
76
|
// ─── State Reading ───────────────────────────────────────
|
|
@@ -259,6 +267,261 @@ function cmdEnd(status, nextCmd) {
|
|
|
259
267
|
console.log("");
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
// ─── Journey Tree (the North Star visualization) ────────
|
|
271
|
+
// Renders JOURNEY.md as an ASCII ladder with the current milestone highlighted.
|
|
272
|
+
// Called after /qualia-new to show the full arc, and by /qualia (router) to
|
|
273
|
+
// orient the user on "you are here".
|
|
274
|
+
function cmdJourneyTree(journeyPath) {
|
|
275
|
+
const p = journeyPath || ".planning/JOURNEY.md";
|
|
276
|
+
let content = "";
|
|
277
|
+
try {
|
|
278
|
+
content = fs.readFileSync(p, "utf8");
|
|
279
|
+
} catch {
|
|
280
|
+
console.log(` ${DIM}No JOURNEY.md at ${p}${RESET}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const state = readState();
|
|
285
|
+
const currentMilestone = state && state.ok ? (state.milestone || 1) : 1;
|
|
286
|
+
|
|
287
|
+
// Parse milestone blocks: "## Milestone N · Name" or "## Milestone N · Handoff"
|
|
288
|
+
const milestoneRe = /^## Milestone (\d+)\s*·\s*(.+?)\s*(?:\[[^\]]*\])?\r?$/gm;
|
|
289
|
+
const milestones = [];
|
|
290
|
+
let m;
|
|
291
|
+
while ((m = milestoneRe.exec(content)) !== null) {
|
|
292
|
+
const num = parseInt(m[1]);
|
|
293
|
+
const name = m[2].trim();
|
|
294
|
+
// Extract the section body to pull Why-now and phases
|
|
295
|
+
const startIdx = m.index + m[0].length;
|
|
296
|
+
const nextMatch = milestoneRe.exec(content);
|
|
297
|
+
const endIdx = nextMatch ? nextMatch.index : content.length;
|
|
298
|
+
milestoneRe.lastIndex = startIdx; // rewind for next iteration
|
|
299
|
+
const body = content.slice(startIdx, endIdx);
|
|
300
|
+
|
|
301
|
+
const whyMatch = body.match(/\*\*Why now:\*\*\s*(.+?)\r?$/m);
|
|
302
|
+
const why = whyMatch ? whyMatch[1].trim() : "";
|
|
303
|
+
|
|
304
|
+
const phaseNames = [];
|
|
305
|
+
const phaseRe = /^\d+\.\s+\*\*([^*]+)\*\*/gm;
|
|
306
|
+
let pm;
|
|
307
|
+
while ((pm = phaseRe.exec(body)) !== null) {
|
|
308
|
+
phaseNames.push(pm[1].trim());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
milestones.push({ num, name, why, phaseNames });
|
|
312
|
+
if (nextMatch) milestoneRe.lastIndex = nextMatch.index;
|
|
313
|
+
else break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (milestones.length === 0) {
|
|
317
|
+
console.log(` ${DIM}JOURNEY.md has no milestones to render${RESET}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Project name from frontmatter if present
|
|
322
|
+
const projMatch = content.match(/^project:\s*"?(.+?)"?\s*$/m);
|
|
323
|
+
const projName = projMatch ? projMatch[1] : projectName();
|
|
324
|
+
|
|
325
|
+
console.log("");
|
|
326
|
+
console.log(` ${TEAL}${BOLD}◯${RESET} ${WHITE}${BOLD}JOURNEY${RESET} ${DIM}▸${RESET} ${WHITE}${projName}${RESET}`);
|
|
327
|
+
console.log(` ${RULE_DIM}`);
|
|
328
|
+
console.log(` ${DIM}${milestones.length} milestones · currently at M${currentMilestone}${RESET}`);
|
|
329
|
+
console.log("");
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < milestones.length; i++) {
|
|
332
|
+
const ms = milestones[i];
|
|
333
|
+
const isCurrent = ms.num === currentMilestone;
|
|
334
|
+
const isPast = ms.num < currentMilestone;
|
|
335
|
+
const isFuture = ms.num > currentMilestone;
|
|
336
|
+
const isHandoff = /handoff/i.test(ms.name);
|
|
337
|
+
|
|
338
|
+
let marker;
|
|
339
|
+
let labelColor;
|
|
340
|
+
let connector = "│";
|
|
341
|
+
|
|
342
|
+
if (isPast) {
|
|
343
|
+
marker = `${GREEN}●${RESET}`;
|
|
344
|
+
labelColor = DIM;
|
|
345
|
+
} else if (isCurrent) {
|
|
346
|
+
marker = `${TEAL}${BOLD}◆${RESET}`;
|
|
347
|
+
labelColor = TEAL + BOLD;
|
|
348
|
+
} else {
|
|
349
|
+
marker = `${DIM2}○${RESET}`;
|
|
350
|
+
labelColor = WHITE;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const tag = isCurrent
|
|
354
|
+
? ` ${TEAL}${BOLD}[CURRENT]${RESET}`
|
|
355
|
+
: isPast
|
|
356
|
+
? ` ${GREEN}[shipped]${RESET}`
|
|
357
|
+
: isHandoff
|
|
358
|
+
? ` ${DIM2}[FINAL]${RESET}`
|
|
359
|
+
: "";
|
|
360
|
+
|
|
361
|
+
console.log(` ${marker} ${labelColor}M${ms.num} · ${ms.name}${RESET}${tag}`);
|
|
362
|
+
|
|
363
|
+
if (ms.why && (isCurrent || isFuture)) {
|
|
364
|
+
const shortWhy = ms.why.length > 80 ? ms.why.slice(0, 77) + "…" : ms.why;
|
|
365
|
+
console.log(` ${DIM2}│${RESET} ${DIM}${shortWhy}${RESET}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (ms.phaseNames.length > 0 && (isCurrent || isHandoff)) {
|
|
369
|
+
const phaseList = ms.phaseNames.slice(0, 4).join(` ${DIM2}→${RESET} ${DIM}`);
|
|
370
|
+
console.log(` ${DIM2}│${RESET} ${DIM}${phaseList}${DIM}${ms.phaseNames.length > 4 ? ` +${ms.phaseNames.length - 4}` : ""}${RESET}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Connector between milestones (skip after last)
|
|
374
|
+
if (i < milestones.length - 1) {
|
|
375
|
+
console.log(` ${DIM2}│${RESET}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
console.log("");
|
|
379
|
+
console.log(` ${RULE_DIM}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Milestone Complete (celebration banner) ─────────────
|
|
383
|
+
// Shown at milestone-boundary in auto mode, and by /qualia-milestone manually.
|
|
384
|
+
function cmdMilestoneComplete(num, name, nextName) {
|
|
385
|
+
console.log("");
|
|
386
|
+
console.log(` ${GREEN}${BOLD}◆${RESET} ${WHITE}${BOLD}MILESTONE ${num} SHIPPED${RESET} ${DIM}·${RESET} ${TEAL}${name || ""}${RESET}`);
|
|
387
|
+
console.log(` ${RULE_DIM}`);
|
|
388
|
+
if (nextName) {
|
|
389
|
+
if (/handoff/i.test(nextName)) {
|
|
390
|
+
console.log(` ${DIM}Next${RESET} ${TEAL}${BOLD}${nextName}${RESET} ${DIM}· the final milestone${RESET}`);
|
|
391
|
+
} else {
|
|
392
|
+
console.log(` ${DIM}Next${RESET} ${WHITE}${nextName}${RESET}`);
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
console.log(` ${GREEN}${BOLD}PROJECT COMPLETE${RESET} ${DIM}· last milestone reached${RESET}`);
|
|
396
|
+
}
|
|
397
|
+
console.log(` ${RULE_DIM}`);
|
|
398
|
+
console.log("");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ─── Plan Summary (story-file dashboard) ─────────────────
|
|
402
|
+
// Renders a polished overview of a plan file: phase goal, tasks grouped by wave,
|
|
403
|
+
// persona chips, dependency lines, AC count, validation count. Called by
|
|
404
|
+
// /qualia-plan after the planner and plan-checker finish.
|
|
405
|
+
function cmdPlanSummary(planPath) {
|
|
406
|
+
if (!planPath) {
|
|
407
|
+
console.error("Usage: qualia-ui.js plan-summary <path-to-plan.md>");
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
let content = "";
|
|
411
|
+
try {
|
|
412
|
+
content = fs.readFileSync(planPath, "utf8");
|
|
413
|
+
} catch (e) {
|
|
414
|
+
console.error(`Cannot read plan: ${e.message}`);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ─ Parse frontmatter + phase header ─
|
|
419
|
+
const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
|
420
|
+
const fm = {};
|
|
421
|
+
if (fmMatch) {
|
|
422
|
+
for (const line of fmMatch[1].split("\n")) {
|
|
423
|
+
const m = line.match(/^(\w+):\s*(.+?)\s*$/);
|
|
424
|
+
if (m) fm[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const phaseNum = fm.phase || "?";
|
|
428
|
+
const phaseGoal = fm.goal || "";
|
|
429
|
+
const phaseTitleMatch = content.match(/^# Phase \d+:?\s*(.+?)\r?$/m);
|
|
430
|
+
const phaseTitle = phaseTitleMatch ? phaseTitleMatch[1].trim() : `Phase ${phaseNum}`;
|
|
431
|
+
const whyPhaseMatch = content.match(/^\*\*Why this phase:\*\*\s*(.+?)\r?$/m);
|
|
432
|
+
const whyPhase = whyPhaseMatch ? whyPhaseMatch[1].trim() : "";
|
|
433
|
+
|
|
434
|
+
// ─ Parse tasks ─
|
|
435
|
+
const taskBlocks = content.split(/^(?=## Task \d+)/m).filter((b) => /^## Task \d+/.test(b));
|
|
436
|
+
const tasks = taskBlocks.map((block) => {
|
|
437
|
+
const titleMatch = block.match(/^## Task (\d+)\s*—\s*(.+?)\r?$/m);
|
|
438
|
+
const wave = parseInt((block.match(/\*\*Wave:\*\*\s*(\d+)/) || [])[1]) || 1;
|
|
439
|
+
const persona = ((block.match(/\*\*Persona:\*\*\s*(.+?)\r?$/m) || [])[1] || "").trim();
|
|
440
|
+
const deps = ((block.match(/\*\*Depends on:\*\*\s*(.+?)\r?$/m) || [])[1] || "").trim();
|
|
441
|
+
const why = ((block.match(/\*\*Why:\*\*\s*([\s\S]+?)(?=\r?\n\*\*|\r?\n##|$)/) || [])[1] || "").trim().replace(/\s+/g, " ");
|
|
442
|
+
const acBlock = (block.match(/\*\*Acceptance Criteria:\*\*\s*([\s\S]+?)(?=\r?\n\*\*|\r?\n##|$)/) || [])[1] || "";
|
|
443
|
+
const acCount = (acBlock.match(/^[-*]\s+/gm) || []).length;
|
|
444
|
+
const validationBlock = (block.match(/\*\*Validation:\*\*[^\n]*\n([\s\S]+?)(?=\r?\n\*\*|\r?\n##|$)/) || [])[1] || "";
|
|
445
|
+
const validationCount = (validationBlock.match(/^[-*]\s+/gm) || []).length;
|
|
446
|
+
return {
|
|
447
|
+
num: titleMatch ? parseInt(titleMatch[1]) : 0,
|
|
448
|
+
title: titleMatch ? titleMatch[2].trim() : "",
|
|
449
|
+
wave,
|
|
450
|
+
persona: (() => {
|
|
451
|
+
// Strip placeholder syntax ({...}), then only accept the known set
|
|
452
|
+
const cleaned = persona.replace(/[{}]/g, "").trim().toLowerCase();
|
|
453
|
+
const valid = ["security", "architect", "ux", "frontend", "backend", "performance"];
|
|
454
|
+
return valid.includes(cleaned) ? cleaned : "";
|
|
455
|
+
})(),
|
|
456
|
+
deps,
|
|
457
|
+
why,
|
|
458
|
+
acCount,
|
|
459
|
+
validationCount,
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const contractCount = (content.match(/^### Contract for Task \d+/gm) || []).length;
|
|
464
|
+
const totalWaves = tasks.length > 0 ? Math.max(...tasks.map((t) => t.wave)) : 0;
|
|
465
|
+
|
|
466
|
+
// ─ Render ─
|
|
467
|
+
console.log("");
|
|
468
|
+
console.log(` ${TEAL}${BOLD}▣${RESET} ${WHITE}${BOLD}PLAN${RESET} ${DIM}▸${RESET} ${WHITE}Phase ${phaseNum} — ${phaseTitle}${RESET}`);
|
|
469
|
+
console.log(` ${RULE_DIM}`);
|
|
470
|
+
if (phaseGoal) {
|
|
471
|
+
console.log(` ${DIM}Goal${RESET} ${WHITE}${phaseGoal}${RESET}`);
|
|
472
|
+
}
|
|
473
|
+
if (whyPhase) {
|
|
474
|
+
console.log(` ${DIM}Why${RESET} ${WHITE}${whyPhase}${RESET}`);
|
|
475
|
+
}
|
|
476
|
+
console.log(` ${DIM}Shape${RESET} ${TEAL}${tasks.length}${RESET} ${DIM}tasks${RESET} ${DIM}·${RESET} ${TEAL}${totalWaves}${RESET} ${DIM}waves${RESET} ${DIM}·${RESET} ${TEAL}${contractCount}${RESET} ${DIM}contracts${RESET}`);
|
|
477
|
+
console.log(` ${RULE_DIM}`);
|
|
478
|
+
|
|
479
|
+
// Persona palette
|
|
480
|
+
const personaColors = {
|
|
481
|
+
security: RED,
|
|
482
|
+
architect: BLUE,
|
|
483
|
+
ux: "\x1b[38;2;255;182;193m",
|
|
484
|
+
frontend: TEAL,
|
|
485
|
+
backend: "\x1b[38;2;186;85;211m",
|
|
486
|
+
performance: YELLOW,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
for (let w = 1; w <= totalWaves; w++) {
|
|
490
|
+
const waveTasks = tasks.filter((t) => t.wave === w);
|
|
491
|
+
if (!waveTasks.length) continue;
|
|
492
|
+
console.log("");
|
|
493
|
+
console.log(` ${TEAL}»${RESET} ${WHITE}${BOLD}Wave ${w}${RESET} ${DIM}(${waveTasks.length} ${waveTasks.length === 1 ? "task" : "tasks"}, parallel)${RESET}`);
|
|
494
|
+
for (const t of waveTasks) {
|
|
495
|
+
const personaChip = t.persona
|
|
496
|
+
? ` ${(personaColors[t.persona] || DIM)}[${t.persona}]${RESET}`
|
|
497
|
+
: "";
|
|
498
|
+
// Only show the dep chip if it names a real task reference.
|
|
499
|
+
// Suppress blanks, "none", and template placeholders like "{none | Task N}".
|
|
500
|
+
const depsClean = (t.deps || "").trim();
|
|
501
|
+
const depsIsReal =
|
|
502
|
+
depsClean &&
|
|
503
|
+
!/^none$/i.test(depsClean) &&
|
|
504
|
+
!/[{}]/.test(depsClean);
|
|
505
|
+
const depChip = depsIsReal ? ` ${DIM}← ${depsClean}${RESET}` : "";
|
|
506
|
+
console.log(` ${DIM}${t.num}.${RESET} ${WHITE}${t.title}${RESET}${personaChip}${depChip}`);
|
|
507
|
+
// Suppress placeholder Why text (contains {} braces) to keep the
|
|
508
|
+
// dashboard clean when the planner hasn't filled it in yet.
|
|
509
|
+
if (t.why && !/[{}]/.test(t.why)) {
|
|
510
|
+
const shortWhy = t.why.length > 90 ? t.why.slice(0, 87) + "…" : t.why;
|
|
511
|
+
console.log(` ${DIM}${shortWhy}${RESET}`);
|
|
512
|
+
}
|
|
513
|
+
const metrics = [];
|
|
514
|
+
if (t.acCount > 0) metrics.push(`${TEAL}${t.acCount}${RESET} ${DIM}AC${RESET}`);
|
|
515
|
+
if (t.validationCount > 0) metrics.push(`${TEAL}${t.validationCount}${RESET} ${DIM}checks${RESET}`);
|
|
516
|
+
if (metrics.length) {
|
|
517
|
+
console.log(` ${metrics.join(` ${DIM}·${RESET} `)}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
console.log("");
|
|
522
|
+
console.log(` ${RULE_DIM}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
262
525
|
function cmdUpdate(current, latest) {
|
|
263
526
|
if (!current || !latest) return;
|
|
264
527
|
console.log("");
|
|
@@ -291,9 +554,12 @@ switch (cmd) {
|
|
|
291
554
|
case "next": cmdNext(rest.join(" ")); break;
|
|
292
555
|
case "end": cmdEnd(rest[0], rest.slice(1).join(" ")); break;
|
|
293
556
|
case "update": cmdUpdate(rest[0], rest[1]); break;
|
|
557
|
+
case "plan-summary": cmdPlanSummary(rest[0]); break;
|
|
558
|
+
case "journey-tree": cmdJourneyTree(rest[0]); break;
|
|
559
|
+
case "milestone-complete": cmdMilestoneComplete(rest[0], rest[1], rest.slice(2).join(" ")); break;
|
|
294
560
|
default:
|
|
295
561
|
console.error(
|
|
296
|
-
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update> [args]`
|
|
562
|
+
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update|plan-summary|journey-tree|milestone-complete> [args]`
|
|
297
563
|
);
|
|
298
564
|
process.exit(1);
|
|
299
565
|
}
|
package/bin/state.js
CHANGED
|
@@ -9,6 +9,7 @@ const PLANNING = ".planning";
|
|
|
9
9
|
const STATE_FILE = path.join(PLANNING, "STATE.md");
|
|
10
10
|
const TRACKING_FILE = path.join(PLANNING, "tracking.json");
|
|
11
11
|
const LOCK_FILE = path.join(PLANNING, ".state.lock");
|
|
12
|
+
const JOURNAL_FILE = path.join(PLANNING, ".state.journal");
|
|
12
13
|
|
|
13
14
|
// ─── Atomic write (tmp + rename) ─────────────────────────
|
|
14
15
|
// Prevents half-written files when SIGINT, OOM, or AV scanners
|
|
@@ -26,10 +27,60 @@ function atomicWrite(file, content) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// ─── Write-ahead journal (two-file crash recovery) ──────
|
|
31
|
+
// STATE.md + tracking.json are written back-to-back. A SIGKILL or power
|
|
32
|
+
// loss between the two renames can leave the pair inconsistent. The
|
|
33
|
+
// journal captures the pre-write snapshot; if a journal is still present
|
|
34
|
+
// on next startup, we know the previous mutator crashed mid-write and
|
|
35
|
+
// restore both files to the pre-transaction state.
|
|
36
|
+
function writeJournal(preState, preTracking) {
|
|
37
|
+
if (!fs.existsSync(PLANNING)) return;
|
|
38
|
+
const payload = JSON.stringify({
|
|
39
|
+
ts: new Date().toISOString(),
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
state: preState != null ? preState : null,
|
|
42
|
+
tracking: preTracking != null ? preTracking : null,
|
|
43
|
+
});
|
|
44
|
+
atomicWrite(JOURNAL_FILE, payload);
|
|
45
|
+
}
|
|
46
|
+
function clearJournal() {
|
|
47
|
+
try { fs.unlinkSync(JOURNAL_FILE); } catch {}
|
|
48
|
+
}
|
|
49
|
+
function recoverFromJournal() {
|
|
50
|
+
if (!fs.existsSync(JOURNAL_FILE)) return false;
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(JOURNAL_FILE, "utf8");
|
|
53
|
+
const j = JSON.parse(raw);
|
|
54
|
+
if (j.state != null) atomicWrite(STATE_FILE, j.state);
|
|
55
|
+
if (j.tracking != null) atomicWrite(TRACKING_FILE, j.tracking);
|
|
56
|
+
clearJournal();
|
|
57
|
+
try { _trace("state-journal", "recover", { from_pid: j.pid, journal_ts: j.ts }); } catch {}
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
// Corrupt journal — best effort: remove so we don't loop on recovery.
|
|
61
|
+
clearJournal();
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
// ─── Exclusive lock ──────────────────────────────────────
|
|
30
67
|
// Prevents two concurrent state.js mutations from racing on the dual
|
|
31
68
|
// STATE.md + tracking.json write. Read commands (check, validate-plan)
|
|
32
69
|
// don't take the lock — only mutators do.
|
|
70
|
+
|
|
71
|
+
// Synchronous sleep without CPU spin. Atomics.wait on a zero-initialized
|
|
72
|
+
// SharedArrayBuffer blocks until the timeout elapses and yields the CPU.
|
|
73
|
+
function sleepSync(ms) {
|
|
74
|
+
try {
|
|
75
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
76
|
+
} catch {
|
|
77
|
+
// SharedArrayBuffer unavailable (extremely old runtimes) — last-resort
|
|
78
|
+
// tight loop, bounded to the requested duration.
|
|
79
|
+
const t = Date.now() + ms;
|
|
80
|
+
while (Date.now() < t) {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
function acquireLock(timeoutMs = 5000) {
|
|
34
85
|
if (!fs.existsSync(PLANNING)) return null; // nothing to lock yet
|
|
35
86
|
const start = Date.now();
|
|
@@ -50,12 +101,13 @@ function acquireLock(timeoutMs = 5000) {
|
|
|
50
101
|
continue;
|
|
51
102
|
}
|
|
52
103
|
} catch {}
|
|
53
|
-
|
|
54
|
-
const t = Date.now() + 50;
|
|
55
|
-
while (Date.now() < t) {}
|
|
104
|
+
sleepSync(50);
|
|
56
105
|
}
|
|
57
106
|
}
|
|
58
|
-
// Couldn't acquire — proceed unlocked rather than
|
|
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 {}
|
|
59
111
|
return null;
|
|
60
112
|
}
|
|
61
113
|
|
|
@@ -140,6 +192,8 @@ function writeTracking(t) {
|
|
|
140
192
|
function ensureLifetime(t) {
|
|
141
193
|
if (!t) return t;
|
|
142
194
|
if (typeof t.milestone !== "number") t.milestone = 1;
|
|
195
|
+
if (typeof t.milestone_name !== "string") t.milestone_name = "";
|
|
196
|
+
if (!Array.isArray(t.milestones)) t.milestones = [];
|
|
143
197
|
if (!t.lifetime || typeof t.lifetime !== "object") {
|
|
144
198
|
t.lifetime = {
|
|
145
199
|
tasks_completed: 0,
|
|
@@ -345,9 +399,13 @@ function checkPreconditions(current, target, opts) {
|
|
|
345
399
|
const taskHeaders = planContent.match(/^## Task \d+/gm);
|
|
346
400
|
if (!taskHeaders || taskHeaders.length === 0)
|
|
347
401
|
return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
|
|
402
|
+
// Accept either legacy "**Done when:**" or story-file "**Acceptance Criteria:**"
|
|
403
|
+
// so old in-flight plans don't break on upgrade.
|
|
348
404
|
const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
const acCount = (planContent.match(/\*\*Acceptance Criteria:\*\*/g) || []).length;
|
|
406
|
+
const anchors = doneWhenCount + acCount;
|
|
407
|
+
if (anchors < taskHeaders.length)
|
|
408
|
+
return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`);
|
|
351
409
|
}
|
|
352
410
|
|
|
353
411
|
if (target === "verified") {
|
|
@@ -436,6 +494,8 @@ function cmdCheck(opts) {
|
|
|
436
494
|
status: s.status,
|
|
437
495
|
assigned_to: s.assigned_to,
|
|
438
496
|
milestone: t.milestone || 1,
|
|
497
|
+
milestone_name: t.milestone_name || "",
|
|
498
|
+
milestones: t.milestones || [],
|
|
439
499
|
lifetime: t.lifetime,
|
|
440
500
|
verification: t.verification || "pending",
|
|
441
501
|
gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
|
|
@@ -550,6 +610,7 @@ function cmdTransition(opts) {
|
|
|
550
610
|
t.tasks_done = parseInt(opts.tasks_done) || 0;
|
|
551
611
|
t.tasks_total = parseInt(opts.tasks_total) || 0;
|
|
552
612
|
t.wave = parseInt(opts.wave) || 0;
|
|
613
|
+
t.build_count = (parseInt(t.build_count) || 0) + 1;
|
|
553
614
|
s.last_activity = `Phase ${phase} built (${t.tasks_done}/${t.tasks_total} tasks)`;
|
|
554
615
|
if (s.phases[phase - 1]) s.phases[phase - 1].status = "built";
|
|
555
616
|
}
|
|
@@ -601,16 +662,28 @@ function cmdTransition(opts) {
|
|
|
601
662
|
|
|
602
663
|
if (target === "shipped") {
|
|
603
664
|
t.deployed_url = opts.deployed_url || "";
|
|
665
|
+
t.deploy_count = (parseInt(t.deploy_count) || 0) + 1;
|
|
604
666
|
}
|
|
605
667
|
|
|
606
|
-
// Write both files
|
|
668
|
+
// Write both files. We write a journal snapshot of the pre-transition
|
|
669
|
+
// STATE.md + tracking.json first; if the process dies between the two
|
|
670
|
+
// real writes, the next invocation will see the journal and restore both
|
|
671
|
+
// files to the pre-transition state. Each individual write is torn-write
|
|
672
|
+
// safe (tmp + rename); the journal closes the gap between the two.
|
|
607
673
|
const backupState = readState();
|
|
674
|
+
const backupTracking = (() => {
|
|
675
|
+
try { return fs.readFileSync(TRACKING_FILE, "utf8"); } catch { return null; }
|
|
676
|
+
})();
|
|
608
677
|
try {
|
|
678
|
+
writeJournal(backupState, backupTracking);
|
|
609
679
|
writeStateMd(s);
|
|
610
680
|
writeTracking(t);
|
|
681
|
+
clearJournal();
|
|
611
682
|
} catch (e) {
|
|
612
|
-
// Revert
|
|
613
|
-
if (backupState) atomicWrite(STATE_FILE, backupState);
|
|
683
|
+
// Revert whichever file is out of sync with pre-transition state.
|
|
684
|
+
try { if (backupState) atomicWrite(STATE_FILE, backupState); } catch {}
|
|
685
|
+
try { if (backupTracking) atomicWrite(TRACKING_FILE, backupTracking); } catch {}
|
|
686
|
+
clearJournal();
|
|
614
687
|
return output(fail("WRITE_ERROR", e.message));
|
|
615
688
|
}
|
|
616
689
|
|
|
@@ -712,6 +785,9 @@ function cmdInit(opts) {
|
|
|
712
785
|
? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
|
|
713
786
|
: { ...defaultLifetime };
|
|
714
787
|
|
|
788
|
+
// Preserve milestones array across re-init (v4: milestone summaries for ERP tree).
|
|
789
|
+
const prevMilestones = (prevLife && Array.isArray(prevLife.milestones)) ? prevLife.milestones : [];
|
|
790
|
+
|
|
715
791
|
// Build tracking — current-phase fields reset, lifetime + identity preserved
|
|
716
792
|
const t = {
|
|
717
793
|
project: opts.project,
|
|
@@ -722,6 +798,8 @@ function cmdInit(opts) {
|
|
|
722
798
|
project_id: opts.project_id || (prevLife ? prevLife.project_id || "" : ""),
|
|
723
799
|
git_remote: opts.git_remote || (prevLife ? prevLife.git_remote || "" : ""),
|
|
724
800
|
milestone: prevLife ? prevLife.milestone : 1,
|
|
801
|
+
milestone_name: opts.milestone_name || (prevLife ? prevLife.milestone_name || "" : ""),
|
|
802
|
+
milestones: prevMilestones,
|
|
725
803
|
phase: 1,
|
|
726
804
|
phase_name: phases[0].name,
|
|
727
805
|
total_phases: totalPhases,
|
|
@@ -854,12 +932,15 @@ function cmdValidatePlan(opts) {
|
|
|
854
932
|
errors.push("No task headers found (expected '## Task N — title')");
|
|
855
933
|
}
|
|
856
934
|
|
|
857
|
-
// Check "Done when" exists for each task
|
|
935
|
+
// Check "Done when" OR "Acceptance Criteria" anchor exists for each task
|
|
936
|
+
// (story-file format uses Acceptance Criteria; legacy format uses Done when)
|
|
858
937
|
const taskCount = taskHeaders ? taskHeaders.length : 0;
|
|
859
938
|
const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
|
|
860
|
-
|
|
939
|
+
const acCount = (content.match(/\*\*Acceptance Criteria:\*\*/g) || []).length;
|
|
940
|
+
const anchors = doneWhenCount + acCount;
|
|
941
|
+
if (anchors < taskCount) {
|
|
861
942
|
errors.push(
|
|
862
|
-
`${taskCount} tasks but only ${
|
|
943
|
+
`${taskCount} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`
|
|
863
944
|
);
|
|
864
945
|
}
|
|
865
946
|
|
|
@@ -958,6 +1039,7 @@ function cmdValidatePlan(opts) {
|
|
|
958
1039
|
phase,
|
|
959
1040
|
task_count: taskCount,
|
|
960
1041
|
done_when_count: doneWhenCount,
|
|
1042
|
+
ac_count: acCount,
|
|
961
1043
|
contract_count: contractCount,
|
|
962
1044
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
963
1045
|
});
|
|
@@ -990,10 +1072,76 @@ function cmdCloseMilestone(opts) {
|
|
|
990
1072
|
);
|
|
991
1073
|
}
|
|
992
1074
|
|
|
1075
|
+
// ─── v4 guard rails ─────────────────────────────────────
|
|
1076
|
+
// A milestone is only closable if it actually acted like one:
|
|
1077
|
+
// (a) all its phases are verified/polished/completed, AND
|
|
1078
|
+
// (b) it had ≥ 2 phases (so a 1-phase "milestone" is forced back to being a phase).
|
|
1079
|
+
// Both guards are bypassable with --force for retroactive bookkeeping.
|
|
1080
|
+
if (!opts.force) {
|
|
1081
|
+
const totalPhases = parseInt(t.total_phases) || s.phases.length || 0;
|
|
1082
|
+
if (totalPhases < 2) {
|
|
1083
|
+
return output(
|
|
1084
|
+
fail(
|
|
1085
|
+
"MILESTONE_TOO_SMALL",
|
|
1086
|
+
`Milestone ${closedMilestone} has only ${totalPhases} phase(s). A milestone needs ≥ 2 phases OR must be a shipped release gate. Use --force if this is intentional (e.g. a preview/demo milestone).`
|
|
1087
|
+
)
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
const unfinished = s.phases.filter((p) => {
|
|
1091
|
+
const st = (p.status || "").toLowerCase();
|
|
1092
|
+
return !(st === "verified" || st === "polished" || st === "completed" || st === "complete");
|
|
1093
|
+
});
|
|
1094
|
+
if (unfinished.length > 0) {
|
|
1095
|
+
return output(
|
|
1096
|
+
fail(
|
|
1097
|
+
"MILESTONE_NOT_READY",
|
|
1098
|
+
`Milestone ${closedMilestone} has ${unfinished.length} unfinished phase(s): ${unfinished.map((p) => `${p.num}:${p.name}`).join(", ")}. Verify them first, or use --force.`
|
|
1099
|
+
)
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ─── Append a summary to milestones[] so the ERP can render the tree ──
|
|
1105
|
+
// This is the minimal metadata needed to reconstruct "milestone N of the
|
|
1106
|
+
// project contained these phases" without replaying git history.
|
|
1107
|
+
const phasesCompleted = s.phases.filter((p) => {
|
|
1108
|
+
const st = (p.status || "").toLowerCase();
|
|
1109
|
+
return st === "verified" || st === "polished" || st === "completed" || st === "complete";
|
|
1110
|
+
}).length;
|
|
1111
|
+
// tasks_completed for THIS milestone = lifetime.tasks_completed minus the
|
|
1112
|
+
// sum of tasks already counted in prior milestones[] entries. This gives
|
|
1113
|
+
// the correct per-milestone count even though `t.tasks_done` only reflects
|
|
1114
|
+
// the current phase, not the cumulative milestone total.
|
|
1115
|
+
const priorMilestoneTasks = Array.isArray(t.milestones)
|
|
1116
|
+
? t.milestones.reduce((sum, m) => sum + (parseInt(m && m.tasks_completed) || 0), 0)
|
|
1117
|
+
: 0;
|
|
1118
|
+
const tasksCompletedThisMilestone = Math.max(
|
|
1119
|
+
0,
|
|
1120
|
+
(parseInt(t.lifetime && t.lifetime.tasks_completed) || 0) - priorMilestoneTasks
|
|
1121
|
+
);
|
|
1122
|
+
const summary = {
|
|
1123
|
+
num: closedMilestone,
|
|
1124
|
+
name: t.milestone_name || `Milestone ${closedMilestone}`,
|
|
1125
|
+
total_phases: parseInt(t.total_phases) || s.phases.length || 0,
|
|
1126
|
+
phases_completed: phasesCompleted,
|
|
1127
|
+
tasks_completed: tasksCompletedThisMilestone,
|
|
1128
|
+
shipped_url: t.deployed_url || "",
|
|
1129
|
+
closed_at: new Date().toISOString(),
|
|
1130
|
+
};
|
|
1131
|
+
t.milestones = Array.isArray(t.milestones) ? t.milestones : [];
|
|
1132
|
+
// Idempotency: don't duplicate if the same milestone number is already logged.
|
|
1133
|
+
const existing = t.milestones.findIndex((m) => m && m.num === closedMilestone);
|
|
1134
|
+
if (existing >= 0) {
|
|
1135
|
+
t.milestones[existing] = summary;
|
|
1136
|
+
} else {
|
|
1137
|
+
t.milestones.push(summary);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
993
1140
|
t.lifetime.milestones_completed += 1;
|
|
994
1141
|
t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
|
|
995
1142
|
t.lifetime.last_closed_milestone = closedMilestone;
|
|
996
1143
|
t.milestone = closedMilestone + 1;
|
|
1144
|
+
t.milestone_name = ""; // cleared; /qualia-milestone reads next one from JOURNEY.md
|
|
997
1145
|
t.last_updated = new Date().toISOString();
|
|
998
1146
|
|
|
999
1147
|
writeTracking(t);
|
|
@@ -1108,6 +1256,10 @@ const opts = parseArgs(rest);
|
|
|
1108
1256
|
const READ_ONLY = new Set(["check", "validate-plan"]);
|
|
1109
1257
|
let __lock = null;
|
|
1110
1258
|
if (!READ_ONLY.has(cmd)) {
|
|
1259
|
+
// Before acquiring the lock, recover from any journal left by a crashed
|
|
1260
|
+
// previous mutator. Runs for mutators only; read commands should still
|
|
1261
|
+
// return the actual on-disk state even if it's mid-recovery.
|
|
1262
|
+
try { recoverFromJournal(); } catch {}
|
|
1111
1263
|
__lock = acquireLock();
|
|
1112
1264
|
process.on("exit", () => releaseLock(__lock));
|
|
1113
1265
|
process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
|
package/bin/statusline.js
CHANGED
|
@@ -175,7 +175,10 @@ try {
|
|
|
175
175
|
// ─── Memory count ────────────────────────────────────────
|
|
176
176
|
let MEMORY_COUNT = 0;
|
|
177
177
|
try {
|
|
178
|
-
|
|
178
|
+
// Claude Code uses a hyphenated encoding of the project directory. Replace
|
|
179
|
+
// BOTH forward and backward slashes so Windows installs (where DIR contains
|
|
180
|
+
// `\`) get a correct key and the memory count renders.
|
|
181
|
+
const dirKey = DIR.replace(/[\/\\]/g, "-");
|
|
179
182
|
const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
|
|
180
183
|
if (fs.existsSync(memDir)) {
|
|
181
184
|
const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
|
package/docs/erp-contract.md
CHANGED
|
@@ -39,6 +39,16 @@ Content-Type: application/json
|
|
|
39
39
|
"git_remote": "github.com/QualiasolutionsCY/acme-portal",
|
|
40
40
|
"client": "Client Name",
|
|
41
41
|
"milestone": 2,
|
|
42
|
+
"milestone_name": "Core Product",
|
|
43
|
+
"milestones": [
|
|
44
|
+
{
|
|
45
|
+
"num": 1,
|
|
46
|
+
"name": "Foundation",
|
|
47
|
+
"closed_at": "2026-04-10T18:00:00Z",
|
|
48
|
+
"phases_completed": 3,
|
|
49
|
+
"tasks_completed": 12
|
|
50
|
+
}
|
|
51
|
+
],
|
|
42
52
|
"phase": 2,
|
|
43
53
|
"phase_name": "Authentication & Dashboard",
|
|
44
54
|
"total_phases": 4,
|
|
@@ -175,6 +185,8 @@ Authorization: Bearer <api-key>
|
|
|
175
185
|
| submitted_by | string | yes | Team member name |
|
|
176
186
|
| submitted_at | string | yes | ISO 8601 timestamp |
|
|
177
187
|
| milestone | number | recommended | Current milestone number (1-indexed) |
|
|
188
|
+
| milestone_name | string | recommended (v4+) | Human name of the current milestone — from JOURNEY.md / tracking.json |
|
|
189
|
+
| milestones | array | recommended (v4+) | Array of closed milestone summaries: `{num, name, closed_at, phases_completed, tasks_completed}`. Renders the journey tree on the ERP. |
|
|
178
190
|
| lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases, last_closed_milestone |
|
|
179
191
|
| project_id | string | recommended (v3.6+) | Stable per-project identifier — preferred dedupe key over `project` slug. Survives directory renames. |
|
|
180
192
|
| team_id | string | recommended (v3.6+) | Installation's team identifier. Composite `(team_id, project_id)` is the canonical project key. |
|