qualia-framework 3.4.0 → 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,7 @@ 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)
@@ -79,14 +164,17 @@ function parseStateMd(content) {
79
164
  if (!content) return null;
80
165
  const schema_errors = [];
81
166
  const get = (prefix) => {
82
- 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"));
83
171
  return m ? m[1].trim() : "";
84
172
  };
85
173
  const hasField = (prefix) =>
86
174
  new RegExp(`^${prefix}:\\s*`, "m").test(content);
87
175
 
88
176
  const phaseMatch = content.match(
89
- /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+)$/m
177
+ /^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+?)\r?$/m
90
178
  );
91
179
  if (!phaseMatch) {
92
180
  schema_errors.push({
@@ -198,7 +286,7 @@ Last session: ${now}
198
286
  Last worked by: ${s.assigned_to}
199
287
  Resume: ${s.resume || "—"}
200
288
  `;
201
- fs.writeFileSync(STATE_FILE, md);
289
+ atomicWrite(STATE_FILE, md);
202
290
  }
203
291
 
204
292
  // ─── Precondition Checks ─────────────────────────────────
@@ -498,8 +586,17 @@ function cmdTransition(opts) {
498
586
  }
499
587
 
500
588
  if (target === "polished") {
501
- if (s.phases[s.phases.length - 1])
502
- 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
+ }
503
600
  }
504
601
 
505
602
  if (target === "shipped") {
@@ -512,21 +609,18 @@ function cmdTransition(opts) {
512
609
  writeStateMd(s);
513
610
  writeTracking(t);
514
611
  } catch (e) {
515
- // Revert STATE.md on failure
516
- 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);
517
614
  return output(fail("WRITE_ERROR", e.message));
518
615
  }
519
616
 
520
617
  // Skill outcome scoring — log transition for analytics
521
- _trace("state-transition", {
522
- result: "allow",
618
+ _trace("state-transition", "allow", {
523
619
  phase: s.phase,
524
620
  status: s.status,
525
621
  previous_status: prevStatus,
526
622
  verification: t.verification,
527
623
  gap_closure: prevStatus === "verified" && target === "planned",
528
- duration_ms: 0,
529
- extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
530
624
  });
531
625
 
532
626
  output({
@@ -544,6 +638,19 @@ function cmdTransition(opts) {
544
638
  function cmdInit(opts) {
545
639
  if (!opts.project) return output(fail("MISSING_ARG", "--project required"));
546
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
+
547
654
  // Parse phases
548
655
  let phases = [];
549
656
  if (opts.phases) {
@@ -591,12 +698,29 @@ function cmdInit(opts) {
591
698
  resume: "—",
592
699
  };
593
700
 
594
- // Build tracking current-phase fields reset, lifetime fields preserved
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
595
716
  const t = {
596
717
  project: opts.project,
597
718
  client: opts.client || (prevLife ? prevLife.client : ""),
598
719
  type: opts.type || (prevLife ? prevLife.type : ""),
599
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 || "" : ""),
600
724
  milestone: prevLife ? prevLife.milestone : 1,
601
725
  phase: 1,
602
726
  phase_name: phases[0].name,
@@ -608,16 +732,16 @@ function cmdInit(opts) {
608
732
  verification: "pending",
609
733
  gap_cycles: {},
610
734
  blockers: [],
735
+ session_started_at: now,
611
736
  last_updated: now,
737
+ last_pushed_at: prevLife ? prevLife.last_pushed_at || "" : "",
612
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,
613
741
  deployed_url: prevLife ? prevLife.deployed_url : "",
614
742
  notes: "",
615
- lifetime: prevLife ? { ...prevLife.lifetime } : {
616
- tasks_completed: 0,
617
- phases_completed: 0,
618
- milestones_completed: 0,
619
- total_phases: 0,
620
- },
743
+ submitted_by: opts.assigned_to || (prevLife ? prevLife.submitted_by || "" : ""),
744
+ lifetime,
621
745
  };
622
746
  // lifetime.total_phases starts at 0 for new projects. It accumulates only via
623
747
  // close-milestone (which adds current total_phases before the next init).
@@ -840,6 +964,10 @@ function cmdValidatePlan(opts) {
840
964
  }
841
965
 
842
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.
843
971
  function cmdCloseMilestone(opts) {
844
972
  const t = readTracking();
845
973
  const s = parseStateMd(readState());
@@ -849,8 +977,22 @@ function cmdCloseMilestone(opts) {
849
977
  ensureLifetime(t);
850
978
 
851
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
+
852
993
  t.lifetime.milestones_completed += 1;
853
994
  t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
995
+ t.lifetime.last_closed_milestone = closedMilestone;
854
996
  t.milestone = closedMilestone + 1;
855
997
  t.last_updated = new Date().toISOString();
856
998
 
@@ -871,6 +1013,83 @@ function cmdCloseMilestone(opts) {
871
1013
  });
872
1014
  }
873
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
+
874
1093
  // ─── Output ──────────────────────────────────────────────
875
1094
  function output(obj) {
876
1095
  console.log(JSON.stringify(obj, null, 2));
@@ -881,30 +1100,51 @@ function output(obj) {
881
1100
  const [cmd, ...rest] = process.argv.slice(2);
882
1101
  const opts = parseArgs(rest);
883
1102
 
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
- );
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);
910
1150
  }
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
 
@@ -34,6 +34,9 @@ Content-Type: application/json
34
34
  ```json
35
35
  {
36
36
  "project": "client-project-name",
37
+ "project_id": "qs-acme-portal",
38
+ "team_id": "qualia-solutions",
39
+ "git_remote": "github.com/QualiasolutionsCY/acme-portal",
37
40
  "client": "Client Name",
38
41
  "milestone": 2,
39
42
  "phase": 2,
@@ -44,14 +47,19 @@ Content-Type: application/json
44
47
  "tasks_total": 5,
45
48
  "verification": "pass",
46
49
  "gap_cycles": 0,
50
+ "build_count": 12,
51
+ "deploy_count": 3,
47
52
  "deployed_url": "https://client.vercel.app",
48
53
  "lifetime": {
49
54
  "tasks_completed": 23,
50
55
  "phases_completed": 8,
51
56
  "milestones_completed": 1,
52
- "total_phases": 8
57
+ "total_phases": 8,
58
+ "last_closed_milestone": 1
53
59
  },
60
+ "session_started_at": "2026-04-12T13:45:00Z",
54
61
  "session_duration_minutes": 45,
62
+ "last_pushed_at": "2026-04-12T14:25:00Z",
55
63
  "commits": ["abc1234", "def5678"],
56
64
  "notes": "Completed auth flow, dashboard layout, and API routes.",
57
65
  "submitted_by": "Fawzi Goussous",
@@ -59,6 +67,12 @@ Content-Type: application/json
59
67
  }
60
68
  ```
61
69
 
70
+ **`gap_cycles` polymorphism (v3.5+):** in `tracking.json` (the file the ERP
71
+ reads from git for passive monitoring) `gap_cycles` is an OBJECT keyed by
72
+ phase number — `{"1": 0, "2": 1}`. In the POST `/api/v1/reports` body,
73
+ `/qualia-report` flattens to a NUMBER for the current phase. Receivers must
74
+ accept both shapes: if object, use `gap_cycles[String(phase)] || 0`.
75
+
62
76
  **Response (200 OK):**
63
77
  ```json
64
78
  {
@@ -161,7 +175,14 @@ Authorization: Bearer <api-key>
161
175
  | submitted_by | string | yes | Team member name |
162
176
  | submitted_at | string | yes | ISO 8601 timestamp |
163
177
  | milestone | number | recommended | Current milestone number (1-indexed) |
164
- | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases |
178
+ | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases, last_closed_milestone |
179
+ | project_id | string | recommended (v3.6+) | Stable per-project identifier — preferred dedupe key over `project` slug. Survives directory renames. |
180
+ | team_id | string | recommended (v3.6+) | Installation's team identifier. Composite `(team_id, project_id)` is the canonical project key. |
181
+ | git_remote | string | optional (v3.6+) | e.g. `github.com/QualiasolutionsCY/foo`. Lets the ERP correlate tracking with the source repo. |
182
+ | session_started_at | string | optional (v3.6+) | ISO 8601 — when the current Claude Code session began. |
183
+ | last_pushed_at | string | optional (v3.6+) | ISO 8601 — distinct from `last_updated` (which fires on local writes too). |
184
+ | build_count | number | optional (v3.6+) | Lifetime build counter. |
185
+ | deploy_count | number | optional (v3.6+) | Lifetime deploy counter. |
165
186
 
166
187
  All other fields are optional but recommended for complete reporting.
167
188