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