qualia-framework 3.3.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -564,9 +564,19 @@ function cmdMigrate() {
564
564
  }
565
565
  if (!bashEntry.hooks) bashEntry.hooks = [];
566
566
 
567
+ // Compare by basename, not by absolute-path substring. If the user moved
568
+ // ~ between OS reinstalls, the OLD path embedded in settings.json no
569
+ // longer matches the NEW path and migrate would otherwise append a
570
+ // duplicate entry on every re-run.
571
+ const extractScriptName = (command) => {
572
+ const match = command && command.match(/["']([^"']+\.js)["']/);
573
+ return match ? path.basename(match[1]) : null;
574
+ };
575
+
567
576
  for (const hookFile of requiredBashHooks) {
568
577
  const cmd = nodeCmd(hookFile);
569
- const exists = bashEntry.hooks.some(h => h.command && h.command.includes(hookFile));
578
+ const targetName = path.basename(hookFile);
579
+ const exists = bashEntry.hooks.some(h => extractScriptName(h.command) === targetName);
570
580
  if (!exists) {
571
581
  const hookDef = { type: "command", command: cmd, timeout: hookFile === "pre-deploy-gate.js" ? 180 : 5 };
572
582
  if (hookFile === "branch-guard.js") hookDef.if = "Bash(git push*)";
@@ -588,7 +598,8 @@ function cmdMigrate() {
588
598
 
589
599
  for (const hookFile of requiredEditHooks) {
590
600
  const cmd = nodeCmd(hookFile);
591
- const exists = editEntry.hooks.some(h => h.command && h.command.includes(hookFile));
601
+ const targetName = path.basename(hookFile);
602
+ const exists = editEntry.hooks.some(h => extractScriptName(h.command) === targetName);
592
603
  if (!exists) {
593
604
  const hookDef = { type: "command", command: cmd, timeout: hookFile === "migration-guard.js" ? 10 : 5 };
594
605
  if (hookFile === "migration-guard.js") hookDef.if = "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)";
package/bin/install.js CHANGED
@@ -497,16 +497,39 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
497
497
  api_key_file: ".erp-api-key",
498
498
  },
499
499
  };
500
- fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
500
+ // mode 0o600: this file holds the role bit (OWNER vs EMPLOYEE) which the
501
+ // branch-guard hook trusts. Default 0644 would let any local user edit it
502
+ // and self-elevate.
503
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
504
+ try { fs.chmodSync(configFile, 0o600); } catch {}
501
505
 
502
506
  // ─── ERP API key (for report uploads) ──────────────────
507
+ // Per-user keys, never a hardcoded shared default. Sources, in order:
508
+ // 1. $QUALIA_ERP_KEY env var at install time (CI / scripted installs)
509
+ // 2. Existing ~/.claude/.erp-api-key (preserved across re-installs)
510
+ // 3. Skip — ERP disabled in config until user runs `qualia-framework set-erp-key`
503
511
  printSection("ERP Integration");
504
512
  const erpKeyFile = path.join(CLAUDE_DIR, ".erp-api-key");
505
- if (!fs.existsSync(erpKeyFile)) {
506
- fs.writeFileSync(erpKeyFile, "qualia-claude-2026", { mode: 0o600 });
507
- ok(".erp-api-key (created)");
513
+ const envKey = (process.env.QUALIA_ERP_KEY || "").trim();
514
+ if (envKey) {
515
+ fs.writeFileSync(erpKeyFile, envKey, { mode: 0o600 });
516
+ try { fs.chmodSync(erpKeyFile, 0o600); } catch {}
517
+ ok(".erp-api-key (from $QUALIA_ERP_KEY)");
518
+ } else if (fs.existsSync(erpKeyFile)) {
519
+ try { fs.chmodSync(erpKeyFile, 0o600); } catch {}
520
+ ok(".erp-api-key (existing — preserved)");
508
521
  } else {
509
- ok(".erp-api-key (exists)");
522
+ // Disable ERP in the config we just wrote.
523
+ try {
524
+ const cfg = JSON.parse(fs.readFileSync(configFile, "utf8"));
525
+ cfg.erp = { ...(cfg.erp || {}), enabled: false };
526
+ fs.writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
527
+ try { fs.chmodSync(configFile, 0o600); } catch {}
528
+ } catch {}
529
+ log(`${YELLOW}!${RESET} ERP key not configured — reports won't upload until set.`);
530
+ log(`${DIM} Set with:${RESET} ${TEAL}export QUALIA_ERP_KEY=...${RESET} ${DIM}then re-install,${RESET}`);
531
+ log(`${DIM} or write the key to:${RESET} ${WHITE}${erpKeyFile}${RESET} ${DIM}(mode 0600).${RESET}`);
532
+ log(`${DIM} Get a key from Fawzi.${RESET}`);
510
533
  }
511
534
 
512
535
  // ─── Configure settings.json ───────────────────────────
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,7 +133,22 @@ 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");
137
+ }
138
+
139
+ // Ensure lifetime + milestone fields exist (backward compat for old tracking files)
140
+ function ensureLifetime(t) {
141
+ if (!t) return t;
142
+ if (typeof t.milestone !== "number") t.milestone = 1;
143
+ if (!t.lifetime || typeof t.lifetime !== "object") {
144
+ t.lifetime = {
145
+ tasks_completed: 0,
146
+ phases_completed: 0,
147
+ milestones_completed: 0,
148
+ total_phases: 0,
149
+ };
150
+ }
151
+ return t;
52
152
  }
53
153
 
54
154
  function readState() {
@@ -64,14 +164,17 @@ function parseStateMd(content) {
64
164
  if (!content) return null;
65
165
  const schema_errors = [];
66
166
  const get = (prefix) => {
67
- const m = content.match(new RegExp(`^${prefix}:\\s*(.+)$`, "m"));
167
+ // CRLF tolerance: Windows editors save with `\r\n`. Use `(.+?)\r?$` so
168
+ // the `\r` is consumed, not captured. `.trim()` is still applied as
169
+ // belt-and-suspenders for any other trailing whitespace.
170
+ const m = content.match(new RegExp(`^${prefix}:\\s*(.+?)\\r?$`, "m"));
68
171
  return m ? m[1].trim() : "";
69
172
  };
70
173
  const hasField = (prefix) =>
71
174
  new RegExp(`^${prefix}:\\s*`, "m").test(content);
72
175
 
73
176
  const phaseMatch = content.match(
74
- /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+)$/m
177
+ /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+?)\r?$/m
75
178
  );
76
179
  if (!phaseMatch) {
77
180
  schema_errors.push({
@@ -183,7 +286,7 @@ Last session: ${now}
183
286
  Last worked by: ${s.assigned_to}
184
287
  Resume: ${s.resume || "—"}
185
288
  `;
186
- fs.writeFileSync(STATE_FILE, md);
289
+ atomicWrite(STATE_FILE, md);
187
290
  }
188
291
 
189
292
  // ─── Precondition Checks ─────────────────────────────────
@@ -324,6 +427,7 @@ function cmdCheck(opts) {
324
427
  message: "No .planning/ found. Run /qualia-new to start.",
325
428
  });
326
429
  }
430
+ ensureLifetime(t);
327
431
  output({
328
432
  ok: true,
329
433
  phase: s.phase,
@@ -331,6 +435,8 @@ function cmdCheck(opts) {
331
435
  total_phases: s.total_phases,
332
436
  status: s.status,
333
437
  assigned_to: s.assigned_to,
438
+ milestone: t.milestone || 1,
439
+ lifetime: t.lifetime,
334
440
  verification: t.verification || "pending",
335
441
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
336
442
  gap_cycle_limit: getGapCycleLimit(),
@@ -372,6 +478,14 @@ function cmdTransition(opts) {
372
478
  // Special: note/activity (no status change)
373
479
  if (target === "note" || target === "activity") {
374
480
  if (opts.notes) t.notes = opts.notes;
481
+ // Count tasks from quick/task work toward lifetime
482
+ if (opts.tasks_done) {
483
+ const count = parseInt(opts.tasks_done) || 0;
484
+ if (count > 0) {
485
+ ensureLifetime(t);
486
+ t.lifetime.tasks_completed += count;
487
+ }
488
+ }
375
489
  t.last_updated = new Date().toISOString();
376
490
  writeTracking(t);
377
491
  s.last_activity = opts.notes || "Activity logged";
@@ -449,6 +563,11 @@ function cmdTransition(opts) {
449
563
 
450
564
  // Auto-advance on pass
451
565
  if (opts.verification === "pass") {
566
+ // Accumulate into lifetime BEFORE resetting current counters
567
+ ensureLifetime(t);
568
+ t.lifetime.tasks_completed += (t.tasks_done || 0);
569
+ t.lifetime.phases_completed += 1;
570
+
452
571
  if (phase < s.total_phases) {
453
572
  s.phase = phase + 1;
454
573
  s.phase_name = s.phases[phase]?.name || `Phase ${phase + 1}`;
@@ -467,8 +586,17 @@ function cmdTransition(opts) {
467
586
  }
468
587
 
469
588
  if (target === "polished") {
470
- if (s.phases[s.phases.length - 1])
471
- s.phases[s.phases.length - 1].status = "verified";
589
+ // Mark every passed phase as polished (polish is a whole-project pass).
590
+ // Previously only the last roadmap row was touched, and was set to
591
+ // "verified" — which both lost current-phase context and used the wrong
592
+ // status string. Now we use "polished" on every row that's already at
593
+ // verified or polished or completed.
594
+ for (const p of s.phases) {
595
+ const st = (p.status || "").toLowerCase();
596
+ if (st === "verified" || st === "polished" || st === "completed" || st === "complete") {
597
+ p.status = "polished";
598
+ }
599
+ }
472
600
  }
473
601
 
474
602
  if (target === "shipped") {
@@ -481,21 +609,18 @@ function cmdTransition(opts) {
481
609
  writeStateMd(s);
482
610
  writeTracking(t);
483
611
  } catch (e) {
484
- // Revert STATE.md on failure
485
- if (backupState) fs.writeFileSync(STATE_FILE, backupState);
612
+ // Revert STATE.md on failure (atomic so the revert itself is safe)
613
+ if (backupState) atomicWrite(STATE_FILE, backupState);
486
614
  return output(fail("WRITE_ERROR", e.message));
487
615
  }
488
616
 
489
617
  // Skill outcome scoring — log transition for analytics
490
- _trace("state-transition", {
491
- result: "allow",
618
+ _trace("state-transition", "allow", {
492
619
  phase: s.phase,
493
620
  status: s.status,
494
621
  previous_status: prevStatus,
495
622
  verification: t.verification,
496
623
  gap_closure: prevStatus === "verified" && target === "planned",
497
- duration_ms: 0,
498
- extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
499
624
  });
500
625
 
501
626
  output({
@@ -513,6 +638,19 @@ function cmdTransition(opts) {
513
638
  function cmdInit(opts) {
514
639
  if (!opts.project) return output(fail("MISSING_ARG", "--project required"));
515
640
 
641
+ // Refuse to clobber an active project unless --force.
642
+ // Lifetime preservation runs lower in this fn — but current-phase fields
643
+ // (phase, status, wave, tasks_done, tasks_total, gap_cycles) ARE wiped
644
+ // on init, which is a footgun for an in-progress project.
645
+ if (!opts.force && fs.existsSync(STATE_FILE)) {
646
+ return output(
647
+ fail(
648
+ "ALREADY_INITIALIZED",
649
+ "Project already initialized at .planning/STATE.md. Use --force to re-initialize (preserves lifetime, resets current phase)."
650
+ )
651
+ );
652
+ }
653
+
516
654
  // Parse phases
517
655
  let phases = [];
518
656
  if (opts.phases) {
@@ -538,6 +676,10 @@ function cmdInit(opts) {
538
676
  const now = new Date().toISOString();
539
677
  const date = now.split("T")[0];
540
678
 
679
+ // Read existing tracking for lifetime data preservation across milestone resets
680
+ const prev = readTracking();
681
+ const prevLife = prev ? ensureLifetime(prev) : null;
682
+
541
683
  // Build state
542
684
  const s = {
543
685
  phase: 1,
@@ -556,12 +698,30 @@ function cmdInit(opts) {
556
698
  resume: "—",
557
699
  };
558
700
 
559
- // Build tracking
701
+ // Defensive lifetime hydrate: even if `prevLife.lifetime` is partial (an
702
+ // older tracking.json missing some keys), the spread would leave gaps that
703
+ // later `+=` would NaN. Build with safe defaults, then overlay.
704
+ const defaultLifetime = {
705
+ tasks_completed: 0,
706
+ phases_completed: 0,
707
+ milestones_completed: 0,
708
+ total_phases: 0,
709
+ last_closed_milestone: 0,
710
+ };
711
+ const lifetime = prevLife
712
+ ? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
713
+ : { ...defaultLifetime };
714
+
715
+ // Build tracking — current-phase fields reset, lifetime + identity preserved
560
716
  const t = {
561
717
  project: opts.project,
562
- client: opts.client || "",
563
- type: opts.type || "",
564
- assigned_to: opts.assigned_to || "",
718
+ client: opts.client || (prevLife ? prevLife.client : ""),
719
+ type: opts.type || (prevLife ? prevLife.type : ""),
720
+ assigned_to: opts.assigned_to || (prevLife ? prevLife.assigned_to : ""),
721
+ team_id: opts.team_id || (prevLife ? prevLife.team_id || "" : ""),
722
+ project_id: opts.project_id || (prevLife ? prevLife.project_id || "" : ""),
723
+ git_remote: opts.git_remote || (prevLife ? prevLife.git_remote || "" : ""),
724
+ milestone: prevLife ? prevLife.milestone : 1,
565
725
  phase: 1,
566
726
  phase_name: phases[0].name,
567
727
  total_phases: totalPhases,
@@ -572,11 +732,20 @@ function cmdInit(opts) {
572
732
  verification: "pending",
573
733
  gap_cycles: {},
574
734
  blockers: [],
735
+ session_started_at: now,
575
736
  last_updated: now,
576
- last_commit: "",
577
- deployed_url: "",
737
+ last_pushed_at: prevLife ? prevLife.last_pushed_at || "" : "",
738
+ last_commit: prevLife ? prevLife.last_commit : "",
739
+ build_count: prevLife ? (prevLife.build_count || 0) : 0,
740
+ deploy_count: prevLife ? (prevLife.deploy_count || 0) : 0,
741
+ deployed_url: prevLife ? prevLife.deployed_url : "",
578
742
  notes: "",
743
+ submitted_by: opts.assigned_to || (prevLife ? prevLife.submitted_by || "" : ""),
744
+ lifetime,
579
745
  };
746
+ // lifetime.total_phases starts at 0 for new projects. It accumulates only via
747
+ // close-milestone (which adds current total_phases before the next init).
748
+ // The ERP computes grand total as: lifetime.total_phases + current total_phases.
580
749
 
581
750
  writeStateMd(s);
582
751
  writeTracking(t);
@@ -794,6 +963,133 @@ function cmdValidatePlan(opts) {
794
963
  });
795
964
  }
796
965
 
966
+ // ─── Close Milestone ─────────────────────────────────────
967
+ // Idempotent: a sentinel `lifetime.last_closed_milestone` records the most
968
+ // recently closed milestone so re-running close-milestone (e.g., after a
969
+ // hiccup) does NOT double-count. To re-close a milestone deliberately, pass
970
+ // --force.
971
+ function cmdCloseMilestone(opts) {
972
+ const t = readTracking();
973
+ const s = parseStateMd(readState());
974
+ if (!t || !s) {
975
+ return output(fail("NO_PROJECT", "No .planning/ found."));
976
+ }
977
+ ensureLifetime(t);
978
+
979
+ const closedMilestone = t.milestone || 1;
980
+ if (
981
+ !opts.force &&
982
+ typeof t.lifetime.last_closed_milestone === "number" &&
983
+ t.lifetime.last_closed_milestone >= closedMilestone
984
+ ) {
985
+ return output(
986
+ fail(
987
+ "ALREADY_CLOSED",
988
+ `Milestone ${closedMilestone} was already closed (last_closed_milestone=${t.lifetime.last_closed_milestone}). Use --force to close again.`
989
+ )
990
+ );
991
+ }
992
+
993
+ t.lifetime.milestones_completed += 1;
994
+ t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
995
+ t.lifetime.last_closed_milestone = closedMilestone;
996
+ t.milestone = closedMilestone + 1;
997
+ t.last_updated = new Date().toISOString();
998
+
999
+ writeTracking(t);
1000
+
1001
+ _trace("close-milestone", "allow", {
1002
+ closed_milestone: closedMilestone,
1003
+ next_milestone: t.milestone,
1004
+ lifetime: t.lifetime,
1005
+ });
1006
+
1007
+ output({
1008
+ ok: true,
1009
+ action: "close-milestone",
1010
+ closed_milestone: closedMilestone,
1011
+ next_milestone: t.milestone,
1012
+ lifetime: t.lifetime,
1013
+ });
1014
+ }
1015
+
1016
+ // ─── Backfill Lifetime ───────────────────────────────────
1017
+ // Reconstructs lifetime counters from STATE.md roadmap + plan files.
1018
+ // Safe to run multiple times (idempotent — recalculates from source).
1019
+ function cmdBackfillLifetime(opts) {
1020
+ const t = readTracking();
1021
+ const s = parseStateMd(readState());
1022
+ if (!t || !s) {
1023
+ return output(fail("NO_PROJECT", "No .planning/ found."));
1024
+ }
1025
+ ensureLifetime(t);
1026
+
1027
+ let phasesCompleted = 0;
1028
+ let tasksCompleted = 0;
1029
+
1030
+ // Count completed phases from roadmap table
1031
+ for (const p of s.phases) {
1032
+ const st = (p.status || "").toLowerCase();
1033
+ if (st === "verified" || st === "completed" || st === "complete") {
1034
+ phasesCompleted++;
1035
+
1036
+ // Count tasks from that phase's plan file
1037
+ const planFile = path.join(PLANNING, `phase-${p.num}-plan.md`);
1038
+ const gapsPlanFile = path.join(PLANNING, `phase-${p.num}-gaps-plan.md`);
1039
+ for (const f of [planFile, gapsPlanFile]) {
1040
+ try {
1041
+ if (fs.existsSync(f)) {
1042
+ const content = fs.readFileSync(f, "utf8");
1043
+ const taskHeaders = content.match(/^## Task \d+/gm);
1044
+ if (taskHeaders) tasksCompleted += taskHeaders.length;
1045
+ }
1046
+ } catch {}
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ // Also count the current phase if it's past built (tasks exist but phase not yet verified)
1052
+ const currentStatus = (t.status || "").toLowerCase();
1053
+ if (currentStatus === "built" || currentStatus === "verified") {
1054
+ // Current phase tasks are already in t.tasks_done — add if not already counted
1055
+ const currentPhaseAlreadyCounted = s.phases.some(
1056
+ (p) => p.num === t.phase && ["verified", "completed", "complete"].includes((p.status || "").toLowerCase())
1057
+ );
1058
+ if (!currentPhaseAlreadyCounted && t.tasks_done > 0) {
1059
+ tasksCompleted += t.tasks_done;
1060
+ }
1061
+ }
1062
+
1063
+ const previous = { ...t.lifetime };
1064
+
1065
+ // Use Math.max — backfill must NEVER reduce lifetime counters. If the user
1066
+ // ran close-milestone previously (rolling phases into lifetime) and then
1067
+ // calls backfill, the recomputed value reflects only the current milestone
1068
+ // and would otherwise destroy the historical accumulation.
1069
+ t.lifetime.phases_completed = Math.max(t.lifetime.phases_completed || 0, phasesCompleted);
1070
+ t.lifetime.tasks_completed = Math.max(t.lifetime.tasks_completed || 0, tasksCompleted);
1071
+ // total_phases is accumulated by close-milestone only — backfill leaves it.
1072
+ t.last_updated = new Date().toISOString();
1073
+
1074
+ writeTracking(t);
1075
+
1076
+ _trace("backfill-lifetime", "allow", {
1077
+ previous,
1078
+ computed: t.lifetime,
1079
+ phases_scanned: s.phases.length,
1080
+ });
1081
+
1082
+ output({
1083
+ ok: true,
1084
+ action: "backfill-lifetime",
1085
+ previous,
1086
+ computed: t.lifetime,
1087
+ phases_scanned: s.phases.length,
1088
+ phases_completed: phasesCompleted,
1089
+ tasks_completed: tasksCompleted,
1090
+ });
1091
+ }
1092
+
797
1093
  // ─── Output ──────────────────────────────────────────────
798
1094
  function output(obj) {
799
1095
  console.log(JSON.stringify(obj, null, 2));
@@ -804,27 +1100,51 @@ function output(obj) {
804
1100
  const [cmd, ...rest] = process.argv.slice(2);
805
1101
  const opts = parseArgs(rest);
806
1102
 
807
- switch (cmd) {
808
- case "check":
809
- cmdCheck(opts);
810
- break;
811
- case "transition":
812
- cmdTransition(opts);
813
- break;
814
- case "init":
815
- cmdInit(opts);
816
- break;
817
- case "fix":
818
- cmdFix(opts);
819
- break;
820
- case "validate-plan":
821
- cmdValidatePlan(opts);
822
- break;
823
- default:
824
- output(
825
- fail(
826
- "UNKNOWN_COMMAND",
827
- `Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
828
- )
829
- );
1103
+ // Mutators must hold the .planning/.state.lock for the duration of their
1104
+ // dual STATE.md + tracking.json writes. Read commands (check, validate-plan)
1105
+ // don't need the lock. The lock is best-effort: if it can't be acquired
1106
+ // inside acquireLock's timeout, the command proceeds anyway — we'd rather
1107
+ // risk a rare race than hard-block the user.
1108
+ const READ_ONLY = new Set(["check", "validate-plan"]);
1109
+ let __lock = null;
1110
+ if (!READ_ONLY.has(cmd)) {
1111
+ __lock = acquireLock();
1112
+ process.on("exit", () => releaseLock(__lock));
1113
+ process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
1114
+ process.on("SIGTERM", () => { releaseLock(__lock); process.exit(143); });
1115
+ }
1116
+
1117
+ try {
1118
+ switch (cmd) {
1119
+ case "check":
1120
+ cmdCheck(opts);
1121
+ break;
1122
+ case "transition":
1123
+ cmdTransition(opts);
1124
+ break;
1125
+ case "init":
1126
+ cmdInit(opts);
1127
+ break;
1128
+ case "fix":
1129
+ cmdFix(opts);
1130
+ break;
1131
+ case "validate-plan":
1132
+ cmdValidatePlan(opts);
1133
+ break;
1134
+ case "close-milestone":
1135
+ cmdCloseMilestone(opts);
1136
+ break;
1137
+ case "backfill-lifetime":
1138
+ cmdBackfillLifetime(opts);
1139
+ break;
1140
+ default:
1141
+ output(
1142
+ fail(
1143
+ "UNKNOWN_COMMAND",
1144
+ `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime> [--options]`
1145
+ )
1146
+ );
1147
+ }
1148
+ } finally {
1149
+ releaseLock(__lock);
830
1150
  }