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.
Files changed (56) hide show
  1. package/CLAUDE.md +23 -11
  2. package/README.md +96 -51
  3. package/agents/builder.md +25 -14
  4. package/agents/plan-checker.md +29 -16
  5. package/agents/planner.md +33 -24
  6. package/agents/research-synthesizer.md +25 -12
  7. package/agents/roadmapper.md +89 -84
  8. package/agents/verifier.md +11 -2
  9. package/bin/cli.js +18 -13
  10. package/bin/install.js +34 -45
  11. package/bin/qualia-ui.js +267 -1
  12. package/bin/state.js +164 -12
  13. package/bin/statusline.js +4 -1
  14. package/docs/erp-contract.md +12 -0
  15. package/guide.md +85 -22
  16. package/hooks/migration-guard.js +23 -9
  17. package/hooks/pre-compact.js +39 -11
  18. package/hooks/pre-deploy-gate.js +3 -4
  19. package/hooks/pre-push.js +6 -3
  20. package/hooks/session-start.js +8 -8
  21. package/package.json +1 -1
  22. package/rules/frontend.md +5 -13
  23. package/skills/qualia/SKILL.md +8 -1
  24. package/skills/qualia-build/SKILL.md +49 -4
  25. package/skills/qualia-debug/SKILL.md +6 -0
  26. package/skills/qualia-design/SKILL.md +9 -1
  27. package/skills/qualia-discuss/SKILL.md +6 -0
  28. package/skills/qualia-handoff/SKILL.md +92 -12
  29. package/skills/qualia-help/SKILL.md +18 -4
  30. package/skills/qualia-idk/SKILL.md +166 -0
  31. package/skills/qualia-learn/SKILL.md +6 -0
  32. package/skills/qualia-map/SKILL.md +7 -0
  33. package/skills/qualia-milestone/SKILL.md +128 -79
  34. package/skills/qualia-new/SKILL.md +163 -230
  35. package/skills/qualia-optimize/SKILL.md +8 -0
  36. package/skills/qualia-pause/SKILL.md +5 -0
  37. package/skills/qualia-plan/SKILL.md +25 -10
  38. package/skills/qualia-polish/SKILL.md +8 -0
  39. package/skills/qualia-quick/SKILL.md +7 -0
  40. package/skills/qualia-report/SKILL.md +17 -0
  41. package/skills/qualia-research/SKILL.md +7 -0
  42. package/skills/qualia-resume/SKILL.md +3 -0
  43. package/skills/qualia-review/SKILL.md +7 -0
  44. package/skills/qualia-ship/SKILL.md +5 -0
  45. package/skills/qualia-skill-new/SKILL.md +6 -0
  46. package/skills/qualia-task/SKILL.md +8 -1
  47. package/skills/qualia-test/SKILL.md +7 -0
  48. package/skills/qualia-verify/SKILL.md +65 -3
  49. package/templates/help.html +4 -4
  50. package/templates/journey.md +113 -0
  51. package/templates/plan.md +56 -11
  52. package/templates/requirements.md +82 -22
  53. package/templates/roadmap.md +41 -14
  54. package/templates/tracking.json +2 -0
  55. package/tests/hooks.test.sh +5 -5
  56. 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
- // Spin-wait briefly. State ops are fast; conflicts rare.
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 block the user.
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
- if (doneWhenCount < taskHeaders.length)
350
- return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
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 STATE.md on failure (atomic so the revert itself is safe)
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
- if (doneWhenCount < taskCount) {
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 ${doneWhenCount} 'Done when:' entries`
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
- const dirKey = DIR.replace(/\//g, "-");
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");
@@ -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. |