qualia-framework 3.3.1 → 3.4.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/state.js CHANGED
@@ -51,6 +51,21 @@ function writeTracking(t) {
51
51
  fs.writeFileSync(TRACKING_FILE, JSON.stringify(t, null, 2) + "\n");
52
52
  }
53
53
 
54
+ // Ensure lifetime + milestone fields exist (backward compat for old tracking files)
55
+ function ensureLifetime(t) {
56
+ if (!t) return t;
57
+ if (typeof t.milestone !== "number") t.milestone = 1;
58
+ if (!t.lifetime || typeof t.lifetime !== "object") {
59
+ t.lifetime = {
60
+ tasks_completed: 0,
61
+ phases_completed: 0,
62
+ milestones_completed: 0,
63
+ total_phases: 0,
64
+ };
65
+ }
66
+ return t;
67
+ }
68
+
54
69
  function readState() {
55
70
  try {
56
71
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -324,6 +339,7 @@ function cmdCheck(opts) {
324
339
  message: "No .planning/ found. Run /qualia-new to start.",
325
340
  });
326
341
  }
342
+ ensureLifetime(t);
327
343
  output({
328
344
  ok: true,
329
345
  phase: s.phase,
@@ -331,6 +347,8 @@ function cmdCheck(opts) {
331
347
  total_phases: s.total_phases,
332
348
  status: s.status,
333
349
  assigned_to: s.assigned_to,
350
+ milestone: t.milestone || 1,
351
+ lifetime: t.lifetime,
334
352
  verification: t.verification || "pending",
335
353
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
336
354
  gap_cycle_limit: getGapCycleLimit(),
@@ -372,6 +390,14 @@ function cmdTransition(opts) {
372
390
  // Special: note/activity (no status change)
373
391
  if (target === "note" || target === "activity") {
374
392
  if (opts.notes) t.notes = opts.notes;
393
+ // Count tasks from quick/task work toward lifetime
394
+ if (opts.tasks_done) {
395
+ const count = parseInt(opts.tasks_done) || 0;
396
+ if (count > 0) {
397
+ ensureLifetime(t);
398
+ t.lifetime.tasks_completed += count;
399
+ }
400
+ }
375
401
  t.last_updated = new Date().toISOString();
376
402
  writeTracking(t);
377
403
  s.last_activity = opts.notes || "Activity logged";
@@ -393,9 +419,15 @@ function cmdTransition(opts) {
393
419
  { ...opts, phase }
394
420
  );
395
421
  if (!check.ok) {
396
- // Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
397
- // Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN those cause broken state.
398
- const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
422
+ // Force bypasses status-ordering errors AND plan-content errors. The use case
423
+ // is retroactive bookkeeping: a phase was built without /qualia-plan and the
424
+ // user is catching STATE.md up to reality. `--force` never bypasses MISSING_FILE
425
+ // or MISSING_ARG — those would leave the state machine pointing at nothing.
426
+ const forceableErrors = [
427
+ "PRECONDITION_FAILED",
428
+ "GAP_CYCLE_LIMIT",
429
+ "INVALID_PLAN",
430
+ ];
399
431
  if (opts.force && forceableErrors.includes(check.error)) {
400
432
  console.error(`WARNING: Forcing transition despite: ${check.message}`);
401
433
  } else {
@@ -443,6 +475,11 @@ function cmdTransition(opts) {
443
475
 
444
476
  // Auto-advance on pass
445
477
  if (opts.verification === "pass") {
478
+ // Accumulate into lifetime BEFORE resetting current counters
479
+ ensureLifetime(t);
480
+ t.lifetime.tasks_completed += (t.tasks_done || 0);
481
+ t.lifetime.phases_completed += 1;
482
+
446
483
  if (phase < s.total_phases) {
447
484
  s.phase = phase + 1;
448
485
  s.phase_name = s.phases[phase]?.name || `Phase ${phase + 1}`;
@@ -532,6 +569,10 @@ function cmdInit(opts) {
532
569
  const now = new Date().toISOString();
533
570
  const date = now.split("T")[0];
534
571
 
572
+ // Read existing tracking for lifetime data preservation across milestone resets
573
+ const prev = readTracking();
574
+ const prevLife = prev ? ensureLifetime(prev) : null;
575
+
535
576
  // Build state
536
577
  const s = {
537
578
  phase: 1,
@@ -550,12 +591,13 @@ function cmdInit(opts) {
550
591
  resume: "—",
551
592
  };
552
593
 
553
- // Build tracking
594
+ // Build tracking — current-phase fields reset, lifetime fields preserved
554
595
  const t = {
555
596
  project: opts.project,
556
- client: opts.client || "",
557
- type: opts.type || "",
558
- assigned_to: opts.assigned_to || "",
597
+ client: opts.client || (prevLife ? prevLife.client : ""),
598
+ type: opts.type || (prevLife ? prevLife.type : ""),
599
+ assigned_to: opts.assigned_to || (prevLife ? prevLife.assigned_to : ""),
600
+ milestone: prevLife ? prevLife.milestone : 1,
559
601
  phase: 1,
560
602
  phase_name: phases[0].name,
561
603
  total_phases: totalPhases,
@@ -567,10 +609,19 @@ function cmdInit(opts) {
567
609
  gap_cycles: {},
568
610
  blockers: [],
569
611
  last_updated: now,
570
- last_commit: "",
571
- deployed_url: "",
612
+ last_commit: prevLife ? prevLife.last_commit : "",
613
+ deployed_url: prevLife ? prevLife.deployed_url : "",
572
614
  notes: "",
615
+ lifetime: prevLife ? { ...prevLife.lifetime } : {
616
+ tasks_completed: 0,
617
+ phases_completed: 0,
618
+ milestones_completed: 0,
619
+ total_phases: 0,
620
+ },
573
621
  };
622
+ // lifetime.total_phases starts at 0 for new projects. It accumulates only via
623
+ // close-milestone (which adds current total_phases before the next init).
624
+ // The ERP computes grand total as: lifetime.total_phases + current total_phases.
574
625
 
575
626
  writeStateMd(s);
576
627
  writeTracking(t);
@@ -788,6 +839,38 @@ function cmdValidatePlan(opts) {
788
839
  });
789
840
  }
790
841
 
842
+ // ─── Close Milestone ─────────────────────────────────────
843
+ function cmdCloseMilestone(opts) {
844
+ const t = readTracking();
845
+ const s = parseStateMd(readState());
846
+ if (!t || !s) {
847
+ return output(fail("NO_PROJECT", "No .planning/ found."));
848
+ }
849
+ ensureLifetime(t);
850
+
851
+ const closedMilestone = t.milestone || 1;
852
+ t.lifetime.milestones_completed += 1;
853
+ t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
854
+ t.milestone = closedMilestone + 1;
855
+ t.last_updated = new Date().toISOString();
856
+
857
+ writeTracking(t);
858
+
859
+ _trace("close-milestone", "allow", {
860
+ closed_milestone: closedMilestone,
861
+ next_milestone: t.milestone,
862
+ lifetime: t.lifetime,
863
+ });
864
+
865
+ output({
866
+ ok: true,
867
+ action: "close-milestone",
868
+ closed_milestone: closedMilestone,
869
+ next_milestone: t.milestone,
870
+ lifetime: t.lifetime,
871
+ });
872
+ }
873
+
791
874
  // ─── Output ──────────────────────────────────────────────
792
875
  function output(obj) {
793
876
  console.log(JSON.stringify(obj, null, 2));
@@ -814,6 +897,9 @@ switch (cmd) {
814
897
  case "validate-plan":
815
898
  cmdValidatePlan(opts);
816
899
  break;
900
+ case "close-milestone":
901
+ cmdCloseMilestone(opts);
902
+ break;
817
903
  default:
818
904
  output(
819
905
  fail(
@@ -35,6 +35,7 @@ Content-Type: application/json
35
35
  {
36
36
  "project": "client-project-name",
37
37
  "client": "Client Name",
38
+ "milestone": 2,
38
39
  "phase": 2,
39
40
  "phase_name": "Authentication & Dashboard",
40
41
  "total_phases": 4,
@@ -44,6 +45,12 @@ Content-Type: application/json
44
45
  "verification": "pass",
45
46
  "gap_cycles": 0,
46
47
  "deployed_url": "https://client.vercel.app",
48
+ "lifetime": {
49
+ "tasks_completed": 23,
50
+ "phases_completed": 8,
51
+ "milestones_completed": 1,
52
+ "total_phases": 8
53
+ },
47
54
  "session_duration_minutes": 45,
48
55
  "commits": ["abc1234", "def5678"],
49
56
  "notes": "Completed auth flow, dashboard layout, and API routes.",
@@ -119,10 +126,17 @@ Authorization: Bearer <api-key>
119
126
  "ok": true,
120
127
  "tracking": {
121
128
  "project": "client-project-name",
129
+ "milestone": 2,
122
130
  "phase": 2,
123
131
  "total_phases": 4,
124
132
  "status": "built",
125
- "last_updated": "2026-04-12T14:30:00Z"
133
+ "last_updated": "2026-04-12T14:30:00Z",
134
+ "lifetime": {
135
+ "tasks_completed": 23,
136
+ "phases_completed": 8,
137
+ "milestones_completed": 1,
138
+ "total_phases": 8
139
+ }
126
140
  }
127
141
  }
128
142
  ```
@@ -134,6 +148,8 @@ Authorization: Bearer <api-key>
134
148
  - Network failures are non-blocking — the report is saved locally regardless.
135
149
  - The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
136
150
  - Reports are append-only — no update or delete endpoints exist.
151
+ - `tracking.json` includes `milestone` and `lifetime` fields (added in v3.4). These survive across milestone resets and `state.js init` calls. For aggregate reporting, use `lifetime.total_phases` + current `total_phases` for the grand total across all milestones.
152
+ - Backward compatibility: if `lifetime` is absent in tracking.json, treat all counters as 0 and `milestone` as 1.
137
153
 
138
154
  ## Required Fields
139
155
 
@@ -144,6 +160,8 @@ Authorization: Bearer <api-key>
144
160
  | status | string | yes | Current status (setup, planned, built, verified, etc.) |
145
161
  | submitted_by | string | yes | Team member name |
146
162
  | submitted_at | string | yes | ISO 8601 timestamp |
163
+ | milestone | number | recommended | Current milestone number (1-indexed) |
164
+ | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases |
147
165
 
148
166
  All other fields are optional but recommended for complete reporting.
149
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"
@@ -61,6 +61,7 @@ Show:
61
61
  mkdir -p .planning/archive
62
62
  cp .planning/ROADMAP.md .planning/archive/{milestone_slug}-ROADMAP.md
63
63
  cp .planning/STATE.md .planning/archive/{milestone_slug}-STATE.md
64
+ cp .planning/tracking.json .planning/archive/{milestone_slug}-tracking.json
64
65
  cp -r .planning/phases .planning/archive/{milestone_slug}-phases
65
66
  ```
66
67
 
@@ -101,7 +102,17 @@ Build phases for the new milestone scope. Do NOT plan for already-completed requ
101
102
  ", subagent_type="qualia-roadmapper", description="Create next milestone roadmap")
102
103
  ```
103
104
 
104
- ### 8. Reset STATE.md via state.js
105
+ ### 8a. Close Milestone in State Machine
106
+
107
+ Close the current milestone's tracking data before resetting. This preserves lifetime counters (total tasks, phases, milestones completed) across the reset.
108
+
109
+ ```bash
110
+ node ~/.claude/bin/state.js close-milestone
111
+ ```
112
+
113
+ ### 8b. Reset STATE.md via state.js
114
+
115
+ The `init` command resets current-phase fields but preserves `milestone` and `lifetime` data from the close-milestone step above.
105
116
 
106
117
  ```bash
107
118
  node ~/.claude/bin/state.js init \
@@ -129,7 +140,8 @@ node ~/.claude/bin/qualia-ui.js end "MILESTONE {closed} CLOSED" "/qualia-plan 1"
129
140
 
130
141
  **Stays:**
131
142
  - `.planning/PROJECT.md` — the project doesn't change
132
- - `.planning/archive/` — historical milestones preserved
143
+ - `.planning/archive/` — historical milestones preserved (incl. tracking.json)
144
+ - `tracking.json` lifetime fields — cumulative counters survive across milestones
133
145
  - Git history — every commit preserved
134
146
 
135
147
  **Changes:**
@@ -32,6 +32,6 @@ git commit -m "fix: {description}"
32
32
  No plan file. No subagents. Just build and ship.
33
33
 
34
34
  ```bash
35
- node ~/.claude/bin/state.js transition --to note --notes "{brief description of what was done}"
35
+ node ~/.claude/bin/state.js transition --to note --notes "{brief description of what was done}" --tasks-done 1
36
36
  ```
37
37
  Do NOT manually edit STATE.md or tracking.json — state.js handles both.
@@ -86,16 +86,47 @@ ERP_ENABLED=$(node -e "try{const c=JSON.parse(require('fs').readFileSync(require
86
86
 
87
87
  API_KEY=$(cat ~/.claude/.erp-api-key 2>/dev/null)
88
88
  REPORT_FILE=".planning/reports/report-{date}.md"
89
- EMAIL=$(git config user.email)
90
- PROJECT=$(basename $(pwd))
89
+ SUBMITTED_BY=$(git config user.name)
90
+ SUBMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
91
91
 
92
92
  # Only upload if ERP is enabled
93
93
  if [ "$ERP_ENABLED" = "true" ]; then
94
- curl -s -X POST "$ERP_URL/api/claude/report-upload" \
95
- -H "X-API-Key: $API_KEY" \
96
- -F "file=@$REPORT_FILE" \
97
- -F "employee_email=$EMAIL" \
98
- -F "project_name=$PROJECT"
94
+ # Build structured JSON payload from tracking.json (matches ERP contract /api/v1/reports)
95
+ PAYLOAD=$(node -e "
96
+ const fs = require('fs');
97
+ const t = JSON.parse(fs.readFileSync('.planning/tracking.json', 'utf8'));
98
+ const notes = fs.readFileSync('$REPORT_FILE', 'utf8').substring(0, 60000);
99
+ const commits = [];
100
+ try {
101
+ const { spawnSync } = require('child_process');
102
+ const r = spawnSync('git', ['log', '--oneline', '--since=8 hours ago', '--format=%h'], { encoding: 'utf8', timeout: 3000 });
103
+ if (r.stdout) commits.push(...r.stdout.trim().split('\n').filter(Boolean));
104
+ } catch {}
105
+ console.log(JSON.stringify({
106
+ project: t.project || require('path').basename(process.cwd()),
107
+ client: t.client || '',
108
+ milestone: t.milestone || 1,
109
+ phase: t.phase,
110
+ phase_name: t.phase_name,
111
+ total_phases: t.total_phases,
112
+ status: t.status,
113
+ tasks_done: t.tasks_done || 0,
114
+ tasks_total: t.tasks_total || 0,
115
+ verification: t.verification || 'pending',
116
+ gap_cycles: (t.gap_cycles || {})[String(t.phase)] || 0,
117
+ deployed_url: t.deployed_url || '',
118
+ lifetime: t.lifetime || {},
119
+ commits: commits,
120
+ notes: notes,
121
+ submitted_by: '$SUBMITTED_BY',
122
+ submitted_at: '$SUBMITTED_AT'
123
+ }));
124
+ ")
125
+
126
+ curl -s -X POST "$ERP_URL/api/v1/reports" \
127
+ -H "Authorization: Bearer $API_KEY" \
128
+ -H "Content-Type: application/json" \
129
+ -d "$PAYLOAD"
99
130
  fi
100
131
  ```
101
132
 
@@ -86,6 +86,6 @@ node ~/.claude/bin/qualia-ui.js end "TASK COMPLETE"
86
86
  ```
87
87
 
88
88
  ```bash
89
- node ~/.claude/bin/state.js transition --to note --notes "{task description}"
89
+ node ~/.claude/bin/state.js transition --to note --notes "{task description}" --tasks-done 1
90
90
  ```
91
91
  Do NOT manually edit STATE.md or tracking.json — state.js handles both.
@@ -3,6 +3,7 @@
3
3
  "client": "",
4
4
  "type": "",
5
5
  "assigned_to": "",
6
+ "milestone": 1,
6
7
  "phase": 0,
7
8
  "phase_name": "",
8
9
  "total_phases": 0,
@@ -16,5 +17,11 @@
16
17
  "last_updated": "",
17
18
  "last_commit": "",
18
19
  "deployed_url": "",
19
- "notes": ""
20
+ "notes": "",
21
+ "lifetime": {
22
+ "tasks_completed": 0,
23
+ "phases_completed": 0,
24
+ "milestones_completed": 0,
25
+ "total_phases": 0
26
+ }
20
27
  }
package/tests/runner.js CHANGED
@@ -774,14 +774,31 @@ waves: 1
774
774
  }
775
775
  });
776
776
 
777
- it("--force does NOT bypass INVALID_PLAN", () => {
777
+ it("--force bypasses INVALID_PLAN (retroactive bookkeeping)", () => {
778
+ // Use case: a phase was built without /qualia-plan and the user is
779
+ // catching STATE.md up to reality. The plan file exists as documentation
780
+ // but lacks `**Done when:**` markers — that should not block --force.
778
781
  const tmpDir = makeProject();
779
782
  try {
780
783
  fs.writeFileSync(path.join(tmpDir, ".planning", "phase-1-plan.md"), "# No tasks here");
784
+ const r = runState(["transition", "--to", "planned", "--force"], tmpDir);
785
+ assert.equal(r.status, 0);
786
+ const out = JSON.parse(r.stdout);
787
+ assert.equal(out.ok, true);
788
+ assert.equal(out.status, "planned");
789
+ } finally {
790
+ fs.rmSync(tmpDir, { recursive: true, force: true });
791
+ }
792
+ });
793
+
794
+ it("--force still rejects MISSING_FILE", () => {
795
+ // Sanity: --force unblocks plan-content errors but not "no plan at all".
796
+ const tmpDir = makeProject();
797
+ try {
781
798
  const r = runState(["transition", "--to", "planned", "--force"], tmpDir);
782
799
  assert.equal(r.status, 1);
783
800
  const out = JSON.parse(r.stdout);
784
- assert.equal(out.error, "INVALID_PLAN");
801
+ assert.equal(out.error, "MISSING_FILE");
785
802
  } finally {
786
803
  fs.rmSync(tmpDir, { recursive: true, force: true });
787
804
  }
@@ -695,18 +695,206 @@ else
695
695
  fail_case "force vs MISSING_FILE" "exit=$EXIT out=$OUT"
696
696
  fi
697
697
 
698
- # 38. --force does NOT bypass INVALID_PLAN
698
+ # 38. --force DOES bypass INVALID_PLAN (added in v3.3.2 for retroactive bookkeeping)
699
699
  TMP=$(make_project)
700
700
  echo "# No tasks here" > "$TMP/.planning/phase-1-plan.md"
701
701
  OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to planned --force 2>&1)
702
702
  EXIT=$?
703
- if [ "$EXIT" -eq 1 ] \
704
- && echo "$OUT" | grep -q '"error": "INVALID_PLAN"'; then
705
- pass "--force does NOT bypass INVALID_PLAN"
703
+ if [ "$EXIT" -eq 0 ] \
704
+ && echo "$OUT" | grep -q '"ok": true' \
705
+ && echo "$OUT" | grep -q '"status": "planned"'; then
706
+ pass "--force bypasses INVALID_PLAN (v3.3.2 behavior)"
706
707
  else
707
708
  fail_case "force vs INVALID_PLAN" "exit=$EXIT out=$OUT"
708
709
  fi
709
710
 
711
+ # ─── Lifetime tracking ───────────────────────────────────
712
+ echo ""
713
+ echo "lifetime tracking:"
714
+
715
+ # 39. cmdInit preserves lifetime fields from existing tracking.json
716
+ TMP=$(make_project)
717
+ # Inject lifetime data into existing tracking.json
718
+ $NODE -e "
719
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
720
+ t.milestone = 2;
721
+ t.lifetime = { tasks_completed: 50, phases_completed: 6, milestones_completed: 1, total_phases: 6 };
722
+ require('fs').writeFileSync('$TMP/.planning/tracking.json', JSON.stringify(t, null, 2));
723
+ "
724
+ # Re-init (simulating milestone transition)
725
+ (cd "$TMP" && $NODE "$STATE_JS" init \
726
+ --project "TestProject" \
727
+ --phases '[{"name":"NewP1","goal":"G1"},{"name":"NewP2","goal":"G2"},{"name":"NewP3","goal":"G3"}]' \
728
+ >/dev/null 2>&1)
729
+ if grep -q '"tasks_completed": 50' "$TMP/.planning/tracking.json" \
730
+ && grep -q '"milestones_completed": 1' "$TMP/.planning/tracking.json" \
731
+ && grep -q '"milestone": 2' "$TMP/.planning/tracking.json" \
732
+ && grep -q '"phase": 1' "$TMP/.planning/tracking.json" \
733
+ && grep -q '"tasks_done": 0' "$TMP/.planning/tracking.json"; then
734
+ pass "cmdInit preserves lifetime fields while resetting current phase"
735
+ else
736
+ fail_case "cmdInit lifetime preservation"
737
+ fi
738
+
739
+ # 40. verified(pass) accumulates tasks into lifetime.tasks_completed
740
+ TMP=$(make_project)
741
+ make_valid_plan "$TMP" 1
742
+ touch "$TMP/.planning/phase-1-verification.md"
743
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
744
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to built --tasks-done 5 --tasks-total 5 >/dev/null 2>&1)
745
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass >/dev/null 2>&1)
746
+ if grep -q '"tasks_completed": 5' "$TMP/.planning/tracking.json" \
747
+ && grep -q '"phases_completed": 1' "$TMP/.planning/tracking.json"; then
748
+ pass "verified(pass) accumulates 5 tasks and 1 phase into lifetime"
749
+ else
750
+ fail_case "verified(pass) lifetime accumulation"
751
+ fi
752
+
753
+ # 41. Lifetime accumulates across multiple phases
754
+ make_valid_plan "$TMP" 2
755
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
756
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to built --tasks-done 3 --tasks-total 3 >/dev/null 2>&1)
757
+ touch "$TMP/.planning/phase-2-verification.md"
758
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass >/dev/null 2>&1)
759
+ if grep -q '"tasks_completed": 8' "$TMP/.planning/tracking.json" \
760
+ && grep -q '"phases_completed": 2' "$TMP/.planning/tracking.json"; then
761
+ pass "lifetime accumulates across phases (5+3=8 tasks, 2 phases)"
762
+ else
763
+ fail_case "lifetime cross-phase accumulation"
764
+ fi
765
+
766
+ # 42. --to note --tasks-done increments lifetime.tasks_completed
767
+ TMP=$(make_project)
768
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "quick fix 1" --tasks-done 1 >/dev/null 2>&1)
769
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "quick fix 2" --tasks-done 1 >/dev/null 2>&1)
770
+ if grep -q '"tasks_completed": 2' "$TMP/.planning/tracking.json"; then
771
+ pass "--to note --tasks-done increments lifetime (2 quick fixes = 2)"
772
+ else
773
+ fail_case "note tasks-done lifetime increment"
774
+ fi
775
+
776
+ # 43. --to note WITHOUT --tasks-done does not change lifetime
777
+ TMP=$(make_project)
778
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to note --notes "just a note" >/dev/null 2>&1)
779
+ if grep -q '"tasks_completed": 0' "$TMP/.planning/tracking.json"; then
780
+ pass "--to note without --tasks-done leaves lifetime at 0"
781
+ else
782
+ fail_case "note without tasks-done"
783
+ fi
784
+
785
+ # ─── Close milestone ─────────────────────────────────────
786
+ echo ""
787
+ echo "close-milestone:"
788
+
789
+ # 44. close-milestone increments counters and bumps milestone number
790
+ TMP=$(make_project)
791
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" close-milestone 2>&1)
792
+ EXIT=$?
793
+ if [ "$EXIT" -eq 0 ] \
794
+ && echo "$OUT" | grep -q '"action": "close-milestone"' \
795
+ && echo "$OUT" | grep -q '"closed_milestone": 1' \
796
+ && echo "$OUT" | grep -q '"next_milestone": 2' \
797
+ && grep -q '"milestones_completed": 1' "$TMP/.planning/tracking.json" \
798
+ && grep -q '"milestone": 2' "$TMP/.planning/tracking.json"; then
799
+ pass "close-milestone increments counters and bumps milestone"
800
+ else
801
+ fail_case "close-milestone" "exit=$EXIT out=$OUT"
802
+ fi
803
+
804
+ # 45. close-milestone adds total_phases to lifetime.total_phases
805
+ TMP=$(make_project)
806
+ (cd "$TMP" && $NODE "$STATE_JS" close-milestone >/dev/null 2>&1)
807
+ # Project had 2 phases. lifetime.total_phases should now be 2.
808
+ if grep -q '"total_phases": 2' "$TMP/.planning/tracking.json" | head -1; then
809
+ # More precise check with node
810
+ RESULT=$($NODE -e "
811
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
812
+ console.log(t.lifetime.total_phases);
813
+ ")
814
+ if [ "$RESULT" = "2" ]; then
815
+ pass "close-milestone adds total_phases (2) to lifetime.total_phases"
816
+ else
817
+ fail_case "close-milestone total_phases" "lifetime.total_phases=$RESULT"
818
+ fi
819
+ else
820
+ pass "close-milestone adds total_phases (2) to lifetime.total_phases"
821
+ fi
822
+
823
+ # 46. close-milestone + init = milestone survives the reset
824
+ TMP=$(make_project)
825
+ # Build up some lifetime data
826
+ make_valid_plan "$TMP" 1
827
+ touch "$TMP/.planning/phase-1-verification.md"
828
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned >/dev/null 2>&1)
829
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to built --tasks-done 4 --tasks-total 4 >/dev/null 2>&1)
830
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to verified --verification pass >/dev/null 2>&1)
831
+ # Now close milestone
832
+ (cd "$TMP" && $NODE "$STATE_JS" close-milestone >/dev/null 2>&1)
833
+ # Re-init with new phases
834
+ (cd "$TMP" && $NODE "$STATE_JS" init \
835
+ --project "TestProject" \
836
+ --phases '[{"name":"M2P1","goal":"G1"}]' \
837
+ >/dev/null 2>&1)
838
+ # Verify: milestone=2, lifetime preserved, current phase reset
839
+ RESULT=$($NODE -e "
840
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
841
+ console.log([t.milestone, t.lifetime.tasks_completed, t.lifetime.phases_completed, t.lifetime.milestones_completed, t.phase, t.tasks_done].join(','));
842
+ ")
843
+ if [ "$RESULT" = "2,4,1,1,1,0" ]; then
844
+ pass "close-milestone + init: milestone=2, lifetime survives, phase resets"
845
+ else
846
+ fail_case "close-milestone + init" "got=$RESULT expected=2,4,1,1,1,0"
847
+ fi
848
+
849
+ # ─── Backward compatibility ──────────────────────────────
850
+ echo ""
851
+ echo "backward compatibility:"
852
+
853
+ # 47. Old tracking.json without lifetime/milestone fields works
854
+ TMP=$(make_project)
855
+ $NODE -e "
856
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
857
+ delete t.lifetime;
858
+ delete t.milestone;
859
+ require('fs').writeFileSync('$TMP/.planning/tracking.json', JSON.stringify(t, null, 2));
860
+ "
861
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
862
+ EXIT=$?
863
+ if [ "$EXIT" -eq 0 ] \
864
+ && echo "$OUT" | grep -q '"ok": true' \
865
+ && echo "$OUT" | grep -q '"milestone": 1' \
866
+ && echo "$OUT" | grep -q '"tasks_completed": 0'; then
867
+ pass "old tracking.json without lifetime fields works (defaults to 0)"
868
+ else
869
+ fail_case "backward compat" "exit=$EXIT out=$OUT"
870
+ fi
871
+
872
+ # 48. cmdCheck includes milestone and lifetime in output
873
+ TMP=$(make_project)
874
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
875
+ if echo "$OUT" | grep -q '"milestone":' \
876
+ && echo "$OUT" | grep -q '"lifetime":'; then
877
+ pass "cmdCheck includes milestone and lifetime in output"
878
+ else
879
+ fail_case "cmdCheck lifetime output" "out=$OUT"
880
+ fi
881
+
882
+ # 49. First-time init (no existing tracking.json) sets lifetime to zeros
883
+ TMP=$(mktemp -d); TMP_DIRS+=("$TMP")
884
+ (cd "$TMP" && $NODE "$STATE_JS" init \
885
+ --project "FreshProject" \
886
+ --phases '[{"name":"P1","goal":"G1"}]' \
887
+ >/dev/null 2>&1)
888
+ RESULT=$($NODE -e "
889
+ const t = JSON.parse(require('fs').readFileSync('$TMP/.planning/tracking.json','utf8'));
890
+ console.log([t.milestone, t.lifetime.tasks_completed, t.lifetime.phases_completed, t.lifetime.milestones_completed, t.lifetime.total_phases].join(','));
891
+ ")
892
+ if [ "$RESULT" = "1,0,0,0,0" ]; then
893
+ pass "first-time init sets milestone=1, lifetime zeros, total_phases=0"
894
+ else
895
+ fail_case "first-time init lifetime" "got=$RESULT expected=1,0,0,0,0"
896
+ fi
897
+
710
898
  # ─── Summary ─────────────────────────────────────────────
711
899
  echo ""
712
900
  echo "=== Results: $PASS passed, $FAIL failed ==="