qualia-framework 3.4.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +96 -51
  2. package/agents/builder.md +25 -14
  3. package/agents/plan-checker.md +29 -16
  4. package/agents/planner.md +33 -24
  5. package/agents/research-synthesizer.md +25 -12
  6. package/agents/roadmapper.md +89 -84
  7. package/agents/verifier.md +11 -2
  8. package/bin/cli.js +13 -2
  9. package/bin/install.js +28 -5
  10. package/bin/qualia-ui.js +267 -1
  11. package/bin/state.js +377 -52
  12. package/bin/statusline.js +40 -20
  13. package/docs/erp-contract.md +23 -2
  14. package/guide.md +84 -21
  15. package/hooks/auto-update.js +54 -70
  16. package/hooks/branch-guard.js +64 -6
  17. package/hooks/migration-guard.js +85 -10
  18. package/hooks/pre-compact.js +28 -4
  19. package/hooks/pre-deploy-gate.js +46 -6
  20. package/hooks/pre-push.js +94 -27
  21. package/hooks/session-start.js +6 -0
  22. package/package.json +1 -1
  23. package/skills/qualia/SKILL.md +3 -1
  24. package/skills/qualia-build/SKILL.md +40 -5
  25. package/skills/qualia-handoff/SKILL.md +87 -12
  26. package/skills/qualia-idk/SKILL.md +155 -3
  27. package/skills/qualia-map/SKILL.md +4 -4
  28. package/skills/qualia-milestone/SKILL.md +122 -79
  29. package/skills/qualia-new/SKILL.md +151 -230
  30. package/skills/qualia-optimize/SKILL.md +4 -4
  31. package/skills/qualia-plan/SKILL.md +14 -9
  32. package/skills/qualia-quick/SKILL.md +1 -1
  33. package/skills/qualia-report/SKILL.md +12 -0
  34. package/skills/qualia-verify/SKILL.md +59 -5
  35. package/templates/help.html +98 -31
  36. package/templates/journey.md +113 -0
  37. package/templates/plan.md +56 -11
  38. package/templates/requirements.md +82 -22
  39. package/templates/roadmap.md +41 -14
  40. package/templates/tracking.json +12 -1
  41. package/tests/runner.js +560 -0
  42. package/tests/state.test.sh +40 -0
package/bin/state.js CHANGED
@@ -8,13 +8,98 @@ const path = require("path");
8
8
  const PLANNING = ".planning";
9
9
  const STATE_FILE = path.join(PLANNING, "STATE.md");
10
10
  const TRACKING_FILE = path.join(PLANNING, "tracking.json");
11
+ const LOCK_FILE = path.join(PLANNING, ".state.lock");
12
+
13
+ // ─── Atomic write (tmp + rename) ─────────────────────────
14
+ // Prevents half-written files when SIGINT, OOM, or AV scanners
15
+ // interrupt mid-write. Same-filesystem rename is atomic on POSIX
16
+ // and best-effort atomic on Windows (NTFS replaces in one syscall).
17
+ function atomicWrite(file, content) {
18
+ const tmp = `${file}.tmp.${process.pid}`;
19
+ fs.writeFileSync(tmp, content);
20
+ try {
21
+ fs.renameSync(tmp, file);
22
+ } catch (err) {
23
+ // Cleanup tmp on failure (Windows EBUSY, EPERM, etc.)
24
+ try { fs.unlinkSync(tmp); } catch {}
25
+ throw err;
26
+ }
27
+ }
28
+
29
+ // ─── Exclusive lock ──────────────────────────────────────
30
+ // Prevents two concurrent state.js mutations from racing on the dual
31
+ // STATE.md + tracking.json write. Read commands (check, validate-plan)
32
+ // don't take the lock — only mutators do.
33
+ function acquireLock(timeoutMs = 5000) {
34
+ if (!fs.existsSync(PLANNING)) return null; // nothing to lock yet
35
+ const start = Date.now();
36
+ const ours = `${process.pid}@${new Date().toISOString()}`;
37
+ while (Date.now() - start < timeoutMs) {
38
+ try {
39
+ const fd = fs.openSync(LOCK_FILE, "wx");
40
+ fs.writeSync(fd, ours);
41
+ fs.closeSync(fd);
42
+ return LOCK_FILE;
43
+ } catch (err) {
44
+ if (err.code !== "EEXIST") throw err;
45
+ // Stale lock? If older than 30s, steal it.
46
+ try {
47
+ const stat = fs.statSync(LOCK_FILE);
48
+ if (Date.now() - stat.mtimeMs > 30_000) {
49
+ fs.unlinkSync(LOCK_FILE);
50
+ continue;
51
+ }
52
+ } catch {}
53
+ // Spin-wait briefly. State ops are fast; conflicts rare.
54
+ const t = Date.now() + 50;
55
+ while (Date.now() < t) {}
56
+ }
57
+ }
58
+ // Couldn't acquire — proceed unlocked rather than block the user.
59
+ return null;
60
+ }
61
+
62
+ function releaseLock(lock) {
63
+ if (!lock) return;
64
+ try { fs.unlinkSync(lock); } catch {}
65
+ }
11
66
 
12
67
  // ─── Trace ──────────────────────────────────────────────
13
- function _trace(event, data) {
68
+ // Signature normalized: _trace(event, result, data?). Old callers passed
69
+ // (event, data) with `result` as a string in `data` — that produced
70
+ // nonsense JSONL ({0:"a",1:"l",2:"l",...}). Always use the 3-arg form.
71
+ //
72
+ // Log rotation: trace files older than TRACE_RETENTION_DAYS are pruned
73
+ // on every write. Heavy users used to accumulate unbounded MB/day in
74
+ // ~/.claude/.qualia-traces/. The prune is best-effort and never throws.
75
+ const TRACE_RETENTION_DAYS = 30;
76
+
77
+ function _pruneTraces(traceDir) {
78
+ try {
79
+ const cutoff = Date.now() - TRACE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
80
+ for (const name of fs.readdirSync(traceDir)) {
81
+ if (!name.endsWith(".jsonl")) continue;
82
+ const p = path.join(traceDir, name);
83
+ try {
84
+ const stat = fs.statSync(p);
85
+ if (stat.mtimeMs < cutoff) fs.unlinkSync(p);
86
+ } catch {}
87
+ }
88
+ } catch {}
89
+ }
90
+
91
+ function _trace(event, result, data) {
14
92
  try {
15
93
  const traceDir = path.join(require("os").homedir(), ".claude", ".qualia-traces");
16
94
  if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
17
- const entry = { hook: event, timestamp: new Date().toISOString(), ...data };
95
+ // Prune ~1% of the time (cheap on most invocations, bounded over time).
96
+ if (Math.random() < 0.01) _pruneTraces(traceDir);
97
+ const entry = {
98
+ hook: event,
99
+ result: result || "allow",
100
+ timestamp: new Date().toISOString(),
101
+ ...(data && typeof data === "object" ? data : {}),
102
+ };
18
103
  const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
19
104
  fs.appendFileSync(file, JSON.stringify(entry) + "\n");
20
105
  } catch { /* trace failures must not disrupt state machine */ }
@@ -48,13 +133,15 @@ function readTracking() {
48
133
  }
49
134
 
50
135
  function writeTracking(t) {
51
- fs.writeFileSync(TRACKING_FILE, JSON.stringify(t, null, 2) + "\n");
136
+ atomicWrite(TRACKING_FILE, JSON.stringify(t, null, 2) + "\n");
52
137
  }
53
138
 
54
139
  // Ensure lifetime + milestone fields exist (backward compat for old tracking files)
55
140
  function ensureLifetime(t) {
56
141
  if (!t) return t;
57
142
  if (typeof t.milestone !== "number") t.milestone = 1;
143
+ if (typeof t.milestone_name !== "string") t.milestone_name = "";
144
+ if (!Array.isArray(t.milestones)) t.milestones = [];
58
145
  if (!t.lifetime || typeof t.lifetime !== "object") {
59
146
  t.lifetime = {
60
147
  tasks_completed: 0,
@@ -79,14 +166,17 @@ function parseStateMd(content) {
79
166
  if (!content) return null;
80
167
  const schema_errors = [];
81
168
  const get = (prefix) => {
82
- const m = content.match(new RegExp(`^${prefix}:\\s*(.+)$`, "m"));
169
+ // CRLF tolerance: Windows editors save with `\r\n`. Use `(.+?)\r?$` so
170
+ // the `\r` is consumed, not captured. `.trim()` is still applied as
171
+ // belt-and-suspenders for any other trailing whitespace.
172
+ const m = content.match(new RegExp(`^${prefix}:\\s*(.+?)\\r?$`, "m"));
83
173
  return m ? m[1].trim() : "";
84
174
  };
85
175
  const hasField = (prefix) =>
86
176
  new RegExp(`^${prefix}:\\s*`, "m").test(content);
87
177
 
88
178
  const phaseMatch = content.match(
89
- /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+)$/m
179
+ /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+?)\r?$/m
90
180
  );
91
181
  if (!phaseMatch) {
92
182
  schema_errors.push({
@@ -198,7 +288,7 @@ Last session: ${now}
198
288
  Last worked by: ${s.assigned_to}
199
289
  Resume: ${s.resume || "—"}
200
290
  `;
201
- fs.writeFileSync(STATE_FILE, md);
291
+ atomicWrite(STATE_FILE, md);
202
292
  }
203
293
 
204
294
  // ─── Precondition Checks ─────────────────────────────────
@@ -257,9 +347,13 @@ function checkPreconditions(current, target, opts) {
257
347
  const taskHeaders = planContent.match(/^## Task \d+/gm);
258
348
  if (!taskHeaders || taskHeaders.length === 0)
259
349
  return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
350
+ // Accept either legacy "**Done when:**" or story-file "**Acceptance Criteria:**"
351
+ // so old in-flight plans don't break on upgrade.
260
352
  const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
261
- if (doneWhenCount < taskHeaders.length)
262
- return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
353
+ const acCount = (planContent.match(/\*\*Acceptance Criteria:\*\*/g) || []).length;
354
+ const anchors = doneWhenCount + acCount;
355
+ if (anchors < taskHeaders.length)
356
+ return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`);
263
357
  }
264
358
 
265
359
  if (target === "verified") {
@@ -348,6 +442,8 @@ function cmdCheck(opts) {
348
442
  status: s.status,
349
443
  assigned_to: s.assigned_to,
350
444
  milestone: t.milestone || 1,
445
+ milestone_name: t.milestone_name || "",
446
+ milestones: t.milestones || [],
351
447
  lifetime: t.lifetime,
352
448
  verification: t.verification || "pending",
353
449
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
@@ -462,6 +558,7 @@ function cmdTransition(opts) {
462
558
  t.tasks_done = parseInt(opts.tasks_done) || 0;
463
559
  t.tasks_total = parseInt(opts.tasks_total) || 0;
464
560
  t.wave = parseInt(opts.wave) || 0;
561
+ t.build_count = (parseInt(t.build_count) || 0) + 1;
465
562
  s.last_activity = `Phase ${phase} built (${t.tasks_done}/${t.tasks_total} tasks)`;
466
563
  if (s.phases[phase - 1]) s.phases[phase - 1].status = "built";
467
564
  }
@@ -498,12 +595,22 @@ function cmdTransition(opts) {
498
595
  }
499
596
 
500
597
  if (target === "polished") {
501
- if (s.phases[s.phases.length - 1])
502
- s.phases[s.phases.length - 1].status = "verified";
598
+ // Mark every passed phase as polished (polish is a whole-project pass).
599
+ // Previously only the last roadmap row was touched, and was set to
600
+ // "verified" — which both lost current-phase context and used the wrong
601
+ // status string. Now we use "polished" on every row that's already at
602
+ // verified or polished or completed.
603
+ for (const p of s.phases) {
604
+ const st = (p.status || "").toLowerCase();
605
+ if (st === "verified" || st === "polished" || st === "completed" || st === "complete") {
606
+ p.status = "polished";
607
+ }
608
+ }
503
609
  }
504
610
 
505
611
  if (target === "shipped") {
506
612
  t.deployed_url = opts.deployed_url || "";
613
+ t.deploy_count = (parseInt(t.deploy_count) || 0) + 1;
507
614
  }
508
615
 
509
616
  // Write both files
@@ -512,21 +619,18 @@ function cmdTransition(opts) {
512
619
  writeStateMd(s);
513
620
  writeTracking(t);
514
621
  } catch (e) {
515
- // Revert STATE.md on failure
516
- if (backupState) fs.writeFileSync(STATE_FILE, backupState);
622
+ // Revert STATE.md on failure (atomic so the revert itself is safe)
623
+ if (backupState) atomicWrite(STATE_FILE, backupState);
517
624
  return output(fail("WRITE_ERROR", e.message));
518
625
  }
519
626
 
520
627
  // Skill outcome scoring — log transition for analytics
521
- _trace("state-transition", {
522
- result: "allow",
628
+ _trace("state-transition", "allow", {
523
629
  phase: s.phase,
524
630
  status: s.status,
525
631
  previous_status: prevStatus,
526
632
  verification: t.verification,
527
633
  gap_closure: prevStatus === "verified" && target === "planned",
528
- duration_ms: 0,
529
- extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
530
634
  });
531
635
 
532
636
  output({
@@ -544,6 +648,19 @@ function cmdTransition(opts) {
544
648
  function cmdInit(opts) {
545
649
  if (!opts.project) return output(fail("MISSING_ARG", "--project required"));
546
650
 
651
+ // Refuse to clobber an active project unless --force.
652
+ // Lifetime preservation runs lower in this fn — but current-phase fields
653
+ // (phase, status, wave, tasks_done, tasks_total, gap_cycles) ARE wiped
654
+ // on init, which is a footgun for an in-progress project.
655
+ if (!opts.force && fs.existsSync(STATE_FILE)) {
656
+ return output(
657
+ fail(
658
+ "ALREADY_INITIALIZED",
659
+ "Project already initialized at .planning/STATE.md. Use --force to re-initialize (preserves lifetime, resets current phase)."
660
+ )
661
+ );
662
+ }
663
+
547
664
  // Parse phases
548
665
  let phases = [];
549
666
  if (opts.phases) {
@@ -591,13 +708,35 @@ function cmdInit(opts) {
591
708
  resume: "—",
592
709
  };
593
710
 
594
- // Build tracking current-phase fields reset, lifetime fields preserved
711
+ // Defensive lifetime hydrate: even if `prevLife.lifetime` is partial (an
712
+ // older tracking.json missing some keys), the spread would leave gaps that
713
+ // later `+=` would NaN. Build with safe defaults, then overlay.
714
+ const defaultLifetime = {
715
+ tasks_completed: 0,
716
+ phases_completed: 0,
717
+ milestones_completed: 0,
718
+ total_phases: 0,
719
+ last_closed_milestone: 0,
720
+ };
721
+ const lifetime = prevLife
722
+ ? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
723
+ : { ...defaultLifetime };
724
+
725
+ // Preserve milestones array across re-init (v4: milestone summaries for ERP tree).
726
+ const prevMilestones = (prevLife && Array.isArray(prevLife.milestones)) ? prevLife.milestones : [];
727
+
728
+ // Build tracking — current-phase fields reset, lifetime + identity preserved
595
729
  const t = {
596
730
  project: opts.project,
597
731
  client: opts.client || (prevLife ? prevLife.client : ""),
598
732
  type: opts.type || (prevLife ? prevLife.type : ""),
599
733
  assigned_to: opts.assigned_to || (prevLife ? prevLife.assigned_to : ""),
734
+ team_id: opts.team_id || (prevLife ? prevLife.team_id || "" : ""),
735
+ project_id: opts.project_id || (prevLife ? prevLife.project_id || "" : ""),
736
+ git_remote: opts.git_remote || (prevLife ? prevLife.git_remote || "" : ""),
600
737
  milestone: prevLife ? prevLife.milestone : 1,
738
+ milestone_name: opts.milestone_name || (prevLife ? prevLife.milestone_name || "" : ""),
739
+ milestones: prevMilestones,
601
740
  phase: 1,
602
741
  phase_name: phases[0].name,
603
742
  total_phases: totalPhases,
@@ -608,16 +747,16 @@ function cmdInit(opts) {
608
747
  verification: "pending",
609
748
  gap_cycles: {},
610
749
  blockers: [],
750
+ session_started_at: now,
611
751
  last_updated: now,
752
+ last_pushed_at: prevLife ? prevLife.last_pushed_at || "" : "",
612
753
  last_commit: prevLife ? prevLife.last_commit : "",
754
+ build_count: prevLife ? (prevLife.build_count || 0) : 0,
755
+ deploy_count: prevLife ? (prevLife.deploy_count || 0) : 0,
613
756
  deployed_url: prevLife ? prevLife.deployed_url : "",
614
757
  notes: "",
615
- lifetime: prevLife ? { ...prevLife.lifetime } : {
616
- tasks_completed: 0,
617
- phases_completed: 0,
618
- milestones_completed: 0,
619
- total_phases: 0,
620
- },
758
+ submitted_by: opts.assigned_to || (prevLife ? prevLife.submitted_by || "" : ""),
759
+ lifetime,
621
760
  };
622
761
  // lifetime.total_phases starts at 0 for new projects. It accumulates only via
623
762
  // close-milestone (which adds current total_phases before the next init).
@@ -730,12 +869,15 @@ function cmdValidatePlan(opts) {
730
869
  errors.push("No task headers found (expected '## Task N — title')");
731
870
  }
732
871
 
733
- // Check "Done when" exists for each task
872
+ // Check "Done when" OR "Acceptance Criteria" anchor exists for each task
873
+ // (story-file format uses Acceptance Criteria; legacy format uses Done when)
734
874
  const taskCount = taskHeaders ? taskHeaders.length : 0;
735
875
  const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
736
- if (doneWhenCount < taskCount) {
876
+ const acCount = (content.match(/\*\*Acceptance Criteria:\*\*/g) || []).length;
877
+ const anchors = doneWhenCount + acCount;
878
+ if (anchors < taskCount) {
737
879
  errors.push(
738
- `${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
880
+ `${taskCount} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`
739
881
  );
740
882
  }
741
883
 
@@ -834,12 +976,17 @@ function cmdValidatePlan(opts) {
834
976
  phase,
835
977
  task_count: taskCount,
836
978
  done_when_count: doneWhenCount,
979
+ ac_count: acCount,
837
980
  contract_count: contractCount,
838
981
  warnings: warnings.length > 0 ? warnings : undefined,
839
982
  });
840
983
  }
841
984
 
842
985
  // ─── Close Milestone ─────────────────────────────────────
986
+ // Idempotent: a sentinel `lifetime.last_closed_milestone` records the most
987
+ // recently closed milestone so re-running close-milestone (e.g., after a
988
+ // hiccup) does NOT double-count. To re-close a milestone deliberately, pass
989
+ // --force.
843
990
  function cmdCloseMilestone(opts) {
844
991
  const t = readTracking();
845
992
  const s = parseStateMd(readState());
@@ -849,9 +996,89 @@ function cmdCloseMilestone(opts) {
849
996
  ensureLifetime(t);
850
997
 
851
998
  const closedMilestone = t.milestone || 1;
999
+ if (
1000
+ !opts.force &&
1001
+ typeof t.lifetime.last_closed_milestone === "number" &&
1002
+ t.lifetime.last_closed_milestone >= closedMilestone
1003
+ ) {
1004
+ return output(
1005
+ fail(
1006
+ "ALREADY_CLOSED",
1007
+ `Milestone ${closedMilestone} was already closed (last_closed_milestone=${t.lifetime.last_closed_milestone}). Use --force to close again.`
1008
+ )
1009
+ );
1010
+ }
1011
+
1012
+ // ─── v4 guard rails ─────────────────────────────────────
1013
+ // A milestone is only closable if it actually acted like one:
1014
+ // (a) all its phases are verified/polished/completed, AND
1015
+ // (b) it had ≥ 2 phases (so a 1-phase "milestone" is forced back to being a phase).
1016
+ // Both guards are bypassable with --force for retroactive bookkeeping.
1017
+ if (!opts.force) {
1018
+ const totalPhases = parseInt(t.total_phases) || s.phases.length || 0;
1019
+ if (totalPhases < 2) {
1020
+ return output(
1021
+ fail(
1022
+ "MILESTONE_TOO_SMALL",
1023
+ `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).`
1024
+ )
1025
+ );
1026
+ }
1027
+ const unfinished = s.phases.filter((p) => {
1028
+ const st = (p.status || "").toLowerCase();
1029
+ return !(st === "verified" || st === "polished" || st === "completed" || st === "complete");
1030
+ });
1031
+ if (unfinished.length > 0) {
1032
+ return output(
1033
+ fail(
1034
+ "MILESTONE_NOT_READY",
1035
+ `Milestone ${closedMilestone} has ${unfinished.length} unfinished phase(s): ${unfinished.map((p) => `${p.num}:${p.name}`).join(", ")}. Verify them first, or use --force.`
1036
+ )
1037
+ );
1038
+ }
1039
+ }
1040
+
1041
+ // ─── Append a summary to milestones[] so the ERP can render the tree ──
1042
+ // This is the minimal metadata needed to reconstruct "milestone N of the
1043
+ // project contained these phases" without replaying git history.
1044
+ const phasesCompleted = s.phases.filter((p) => {
1045
+ const st = (p.status || "").toLowerCase();
1046
+ return st === "verified" || st === "polished" || st === "completed" || st === "complete";
1047
+ }).length;
1048
+ // tasks_completed for THIS milestone = lifetime.tasks_completed minus the
1049
+ // sum of tasks already counted in prior milestones[] entries. This gives
1050
+ // the correct per-milestone count even though `t.tasks_done` only reflects
1051
+ // the current phase, not the cumulative milestone total.
1052
+ const priorMilestoneTasks = Array.isArray(t.milestones)
1053
+ ? t.milestones.reduce((sum, m) => sum + (parseInt(m && m.tasks_completed) || 0), 0)
1054
+ : 0;
1055
+ const tasksCompletedThisMilestone = Math.max(
1056
+ 0,
1057
+ (parseInt(t.lifetime && t.lifetime.tasks_completed) || 0) - priorMilestoneTasks
1058
+ );
1059
+ const summary = {
1060
+ num: closedMilestone,
1061
+ name: t.milestone_name || `Milestone ${closedMilestone}`,
1062
+ total_phases: parseInt(t.total_phases) || s.phases.length || 0,
1063
+ phases_completed: phasesCompleted,
1064
+ tasks_completed: tasksCompletedThisMilestone,
1065
+ shipped_url: t.deployed_url || "",
1066
+ closed_at: new Date().toISOString(),
1067
+ };
1068
+ t.milestones = Array.isArray(t.milestones) ? t.milestones : [];
1069
+ // Idempotency: don't duplicate if the same milestone number is already logged.
1070
+ const existing = t.milestones.findIndex((m) => m && m.num === closedMilestone);
1071
+ if (existing >= 0) {
1072
+ t.milestones[existing] = summary;
1073
+ } else {
1074
+ t.milestones.push(summary);
1075
+ }
1076
+
852
1077
  t.lifetime.milestones_completed += 1;
853
1078
  t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
1079
+ t.lifetime.last_closed_milestone = closedMilestone;
854
1080
  t.milestone = closedMilestone + 1;
1081
+ t.milestone_name = ""; // cleared; /qualia-milestone reads next one from JOURNEY.md
855
1082
  t.last_updated = new Date().toISOString();
856
1083
 
857
1084
  writeTracking(t);
@@ -871,6 +1098,83 @@ function cmdCloseMilestone(opts) {
871
1098
  });
872
1099
  }
873
1100
 
1101
+ // ─── Backfill Lifetime ───────────────────────────────────
1102
+ // Reconstructs lifetime counters from STATE.md roadmap + plan files.
1103
+ // Safe to run multiple times (idempotent — recalculates from source).
1104
+ function cmdBackfillLifetime(opts) {
1105
+ const t = readTracking();
1106
+ const s = parseStateMd(readState());
1107
+ if (!t || !s) {
1108
+ return output(fail("NO_PROJECT", "No .planning/ found."));
1109
+ }
1110
+ ensureLifetime(t);
1111
+
1112
+ let phasesCompleted = 0;
1113
+ let tasksCompleted = 0;
1114
+
1115
+ // Count completed phases from roadmap table
1116
+ for (const p of s.phases) {
1117
+ const st = (p.status || "").toLowerCase();
1118
+ if (st === "verified" || st === "completed" || st === "complete") {
1119
+ phasesCompleted++;
1120
+
1121
+ // Count tasks from that phase's plan file
1122
+ const planFile = path.join(PLANNING, `phase-${p.num}-plan.md`);
1123
+ const gapsPlanFile = path.join(PLANNING, `phase-${p.num}-gaps-plan.md`);
1124
+ for (const f of [planFile, gapsPlanFile]) {
1125
+ try {
1126
+ if (fs.existsSync(f)) {
1127
+ const content = fs.readFileSync(f, "utf8");
1128
+ const taskHeaders = content.match(/^## Task \d+/gm);
1129
+ if (taskHeaders) tasksCompleted += taskHeaders.length;
1130
+ }
1131
+ } catch {}
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ // Also count the current phase if it's past built (tasks exist but phase not yet verified)
1137
+ const currentStatus = (t.status || "").toLowerCase();
1138
+ if (currentStatus === "built" || currentStatus === "verified") {
1139
+ // Current phase tasks are already in t.tasks_done — add if not already counted
1140
+ const currentPhaseAlreadyCounted = s.phases.some(
1141
+ (p) => p.num === t.phase && ["verified", "completed", "complete"].includes((p.status || "").toLowerCase())
1142
+ );
1143
+ if (!currentPhaseAlreadyCounted && t.tasks_done > 0) {
1144
+ tasksCompleted += t.tasks_done;
1145
+ }
1146
+ }
1147
+
1148
+ const previous = { ...t.lifetime };
1149
+
1150
+ // Use Math.max — backfill must NEVER reduce lifetime counters. If the user
1151
+ // ran close-milestone previously (rolling phases into lifetime) and then
1152
+ // calls backfill, the recomputed value reflects only the current milestone
1153
+ // and would otherwise destroy the historical accumulation.
1154
+ t.lifetime.phases_completed = Math.max(t.lifetime.phases_completed || 0, phasesCompleted);
1155
+ t.lifetime.tasks_completed = Math.max(t.lifetime.tasks_completed || 0, tasksCompleted);
1156
+ // total_phases is accumulated by close-milestone only — backfill leaves it.
1157
+ t.last_updated = new Date().toISOString();
1158
+
1159
+ writeTracking(t);
1160
+
1161
+ _trace("backfill-lifetime", "allow", {
1162
+ previous,
1163
+ computed: t.lifetime,
1164
+ phases_scanned: s.phases.length,
1165
+ });
1166
+
1167
+ output({
1168
+ ok: true,
1169
+ action: "backfill-lifetime",
1170
+ previous,
1171
+ computed: t.lifetime,
1172
+ phases_scanned: s.phases.length,
1173
+ phases_completed: phasesCompleted,
1174
+ tasks_completed: tasksCompleted,
1175
+ });
1176
+ }
1177
+
874
1178
  // ─── Output ──────────────────────────────────────────────
875
1179
  function output(obj) {
876
1180
  console.log(JSON.stringify(obj, null, 2));
@@ -881,30 +1185,51 @@ function output(obj) {
881
1185
  const [cmd, ...rest] = process.argv.slice(2);
882
1186
  const opts = parseArgs(rest);
883
1187
 
884
- switch (cmd) {
885
- case "check":
886
- cmdCheck(opts);
887
- break;
888
- case "transition":
889
- cmdTransition(opts);
890
- break;
891
- case "init":
892
- cmdInit(opts);
893
- break;
894
- case "fix":
895
- cmdFix(opts);
896
- break;
897
- case "validate-plan":
898
- cmdValidatePlan(opts);
899
- break;
900
- case "close-milestone":
901
- cmdCloseMilestone(opts);
902
- break;
903
- default:
904
- output(
905
- fail(
906
- "UNKNOWN_COMMAND",
907
- `Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
908
- )
909
- );
1188
+ // Mutators must hold the .planning/.state.lock for the duration of their
1189
+ // dual STATE.md + tracking.json writes. Read commands (check, validate-plan)
1190
+ // don't need the lock. The lock is best-effort: if it can't be acquired
1191
+ // inside acquireLock's timeout, the command proceeds anyway — we'd rather
1192
+ // risk a rare race than hard-block the user.
1193
+ const READ_ONLY = new Set(["check", "validate-plan"]);
1194
+ let __lock = null;
1195
+ if (!READ_ONLY.has(cmd)) {
1196
+ __lock = acquireLock();
1197
+ process.on("exit", () => releaseLock(__lock));
1198
+ process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
1199
+ process.on("SIGTERM", () => { releaseLock(__lock); process.exit(143); });
1200
+ }
1201
+
1202
+ try {
1203
+ switch (cmd) {
1204
+ case "check":
1205
+ cmdCheck(opts);
1206
+ break;
1207
+ case "transition":
1208
+ cmdTransition(opts);
1209
+ break;
1210
+ case "init":
1211
+ cmdInit(opts);
1212
+ break;
1213
+ case "fix":
1214
+ cmdFix(opts);
1215
+ break;
1216
+ case "validate-plan":
1217
+ cmdValidatePlan(opts);
1218
+ break;
1219
+ case "close-milestone":
1220
+ cmdCloseMilestone(opts);
1221
+ break;
1222
+ case "backfill-lifetime":
1223
+ cmdBackfillLifetime(opts);
1224
+ break;
1225
+ default:
1226
+ output(
1227
+ fail(
1228
+ "UNKNOWN_COMMAND",
1229
+ `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime> [--options]`
1230
+ )
1231
+ );
1232
+ }
1233
+ } finally {
1234
+ releaseLock(__lock);
910
1235
  }
package/bin/statusline.js CHANGED
@@ -93,35 +93,55 @@ try {
93
93
  let branch = "";
94
94
  let changes = 0;
95
95
  try {
96
- const dirCheck = spawnSync("git", ["rev-parse", "--git-dir"], {
96
+ // Single git spawn: `status -b --porcelain=v1` returns branch on the
97
+ // first line (`## branch.name...`) and one change per subsequent line.
98
+ // Three separate git spawns cost ~450ms on Windows; this collapses to one.
99
+ const st = spawnSync("git", ["status", "-b", "--porcelain=v1"], {
97
100
  cwd: DIR,
98
101
  encoding: "utf8",
99
102
  timeout: 1000,
100
103
  stdio: ["ignore", "pipe", "ignore"],
104
+ shell: process.platform === "win32",
101
105
  });
102
- if (dirCheck.status === 0) {
103
- const br = spawnSync("git", ["branch", "--show-current"], {
104
- cwd: DIR,
105
- encoding: "utf8",
106
- timeout: 1000,
107
- stdio: ["ignore", "pipe", "ignore"],
108
- });
109
- if (br.status === 0) branch = (br.stdout || "").trim();
110
-
111
- const st = spawnSync("git", ["status", "--porcelain"], {
112
- cwd: DIR,
113
- encoding: "utf8",
114
- timeout: 1000,
115
- stdio: ["ignore", "pipe", "ignore"],
116
- });
117
- if (st.status === 0) {
118
- const out = (st.stdout || "").trim();
119
- changes = out ? out.split("\n").length : 0;
106
+ if (st.status === 0) {
107
+ const lines = (st.stdout || "").split("\n");
108
+ const header = lines[0] || "";
109
+ if (header.startsWith("## ")) {
110
+ // Possible forms:
111
+ // "## main"
112
+ // "## main...origin/main"
113
+ // "## main...origin/main [ahead 1, behind 2]"
114
+ // "## HEAD (no branch)" ← detached
115
+ // "## No commits yet on main"
116
+ let raw = header.slice(3);
117
+ const ellipsisIdx = raw.indexOf("...");
118
+ if (ellipsisIdx !== -1) raw = raw.slice(0, ellipsisIdx);
119
+ // Strip any trailing "[ahead/behind]" annotation that survived
120
+ raw = raw.replace(/\s*\[.*\]\s*$/, "").trim();
121
+ if (raw === "HEAD (no branch)") {
122
+ branch = "HEAD";
123
+ } else if (raw.startsWith("No commits yet on ")) {
124
+ branch = raw.slice("No commits yet on ".length).trim();
125
+ } else {
126
+ branch = raw;
127
+ }
128
+ }
129
+ // Count change lines: every non-empty line after the header
130
+ for (let i = 1; i < lines.length; i++) {
131
+ if (lines[i].length > 0) changes++;
120
132
  }
121
133
  }
122
134
  } catch {}
123
135
  try {
124
- fs.writeFileSync(cacheFile, `${branch}|${changes}`);
136
+ // Atomic write: tmp + rename so concurrent prompts can't observe
137
+ // a half-written cache file. Same pattern as state.js atomicWrite.
138
+ const tmp = `${cacheFile}.tmp.${process.pid}`;
139
+ fs.writeFileSync(tmp, `${branch}|${changes}`);
140
+ try {
141
+ fs.renameSync(tmp, cacheFile);
142
+ } catch {
143
+ try { fs.unlinkSync(tmp); } catch {}
144
+ }
125
145
  } catch {}
126
146
  }
127
147