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 +13 -2
- package/bin/install.js +28 -5
- package/bin/state.js +363 -43
- package/bin/statusline.js +40 -20
- package/docs/erp-contract.md +40 -1
- 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-milestone/SKILL.md +14 -2
- package/skills/qualia-optimize/SKILL.md +4 -4
- package/skills/qualia-quick/SKILL.md +2 -2
- package/skills/qualia-report/SKILL.md +38 -7
- package/skills/qualia-task/SKILL.md +1 -1
- package/skills/qualia-verify/SKILL.md +2 -2
- package/templates/help.html +98 -31
- package/templates/tracking.json +17 -1
- package/tests/runner.js +395 -0
- package/tests/state.test.sh +232 -4
- 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,22 @@ function readTracking() {
|
|
|
48
133
|
}
|
|
49
134
|
|
|
50
135
|
function writeTracking(t) {
|
|
51
|
-
|
|
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
|
-
|
|
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*(
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
}
|