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 +13 -2
- package/bin/install.js +28 -5
- package/bin/state.js +287 -47
- package/bin/statusline.js +40 -20
- package/docs/erp-contract.md +23 -2
- package/hooks/auto-update.js +54 -70
- package/hooks/branch-guard.js +64 -6
- package/hooks/migration-guard.js +85 -10
- package/hooks/pre-compact.js +28 -4
- package/hooks/pre-deploy-gate.js +46 -6
- package/hooks/pre-push.js +94 -27
- package/hooks/session-start.js +6 -0
- package/package.json +1 -1
- package/skills/qualia-build/SKILL.md +1 -1
- package/skills/qualia-map/SKILL.md +4 -4
- package/skills/qualia-optimize/SKILL.md +4 -4
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-verify/SKILL.md +2 -2
- package/templates/help.html +98 -31
- package/templates/tracking.json +10 -1
- package/tests/runner.js +395 -0
- package/tests/state.test.sh +40 -0
- package/skills/qualia-idk/SKILL.md +0 -8
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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*(
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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 (
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/erp-contract.md
CHANGED
|
@@ -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
|
|