qualia-framework 4.0.3 → 4.0.5

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.
@@ -85,14 +85,18 @@ Use the research SUMMARY.md as your starting point. Don't force-fit the template
85
85
  - **Phases** — 2-5 phases. For Milestone 1, include full detail (goal + success criteria). For M2..M{N-1}, names + one-line goals are enough (progressive detail — full detail gets written when that milestone opens). For Handoff, use the fixed 4-phase template.
86
86
  - **Requirements covered** — list the REQ-IDs this milestone delivers
87
87
 
88
- ### 4. Build ROADMAP.md — ONLY Milestone 1's phases (fully detailed)
88
+ ### 4. Build ROADMAP.md — Milestone 1's phases (progressive detail by default)
89
89
 
90
- The current milestone gets full phase detail. Future milestones stay as sketches in JOURNEY.md until they open.
90
+ Check the `<full_detail>` flag in your prompt:
91
91
 
92
- For each phase in Milestone 1:
92
+ **`full_detail=false` (default):** Only Milestone 1 gets full phase detail in ROADMAP.md. Future milestones stay as sketches in JOURNEY.md until they open. This matches progressive-detail planning and is the recommended default.
93
+
94
+ **`full_detail=true`:** Write full phase detail for EVERY milestone (M1..Handoff) in ROADMAP.md, sectioned by milestone. Use when the client wants a fully-committed plan at kickoff. Trade-off: M2+ detail often needs revision as M1 ships — flag this in your summary.
95
+
96
+ For each phase in the milestone(s) you're detailing:
93
97
  - **Name** + **goal** (one line)
94
98
  - **Success criteria** — 2-5 observable user-facing behaviors
95
- - **Requirements covered** — REQ-IDs from REQUIREMENTS.md Milestone 1 section
99
+ - **Requirements covered** — REQ-IDs from REQUIREMENTS.md section for that milestone
96
100
 
97
101
  ### 5. Validate Coverage
98
102
 
@@ -104,7 +108,8 @@ Before writing, verify:
104
108
  - [ ] Final milestone is literally named "Handoff" with the 4 standard phases
105
109
  - [ ] No milestone depends on a later milestone
106
110
  - [ ] Milestone 1 has full phase-level detail (goals + success criteria) ready for `/qualia-plan 1`
107
- - [ ] M2..M{N-1} have phase names + one-line goals (sketch, not full detail)
111
+ - [ ] If `full_detail=false` (default): M2..M{N-1} have phase names + one-line goals (sketch, not full detail)
112
+ - [ ] If `full_detail=true`: every milestone in ROADMAP.md has full phase detail; flag this mode explicitly in your summary output
108
113
 
109
114
  If any check fails, fix it. The orchestrator trusts your output.
110
115
 
package/bin/cli.js CHANGED
@@ -144,8 +144,17 @@ const QUALIA_LEGACY_HOOK_FILES = [
144
144
  "block-env-edit.js", // removed in v3.2.0
145
145
  ];
146
146
 
147
- // 4 Qualia agents — only these are removed.
148
- const QUALIA_AGENT_FILES = ["planner.md", "builder.md", "verifier.md", "qa-browser.md"];
147
+ // 8 Qualia agents — only these are removed.
148
+ const QUALIA_AGENT_FILES = [
149
+ "planner.md",
150
+ "builder.md",
151
+ "verifier.md",
152
+ "qa-browser.md",
153
+ "plan-checker.md",
154
+ "researcher.md",
155
+ "research-synthesizer.md",
156
+ "roadmapper.md",
157
+ ];
149
158
 
150
159
  // 3 Qualia bin scripts.
151
160
  const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js"];
@@ -557,7 +566,7 @@ function cmdMigrate() {
557
566
 
558
567
  // Check PreToolUse hooks — ensure all critical hooks are present
559
568
  const requiredBashHooks = ["auto-update.js", "branch-guard.js", "pre-push.js", "pre-deploy-gate.js"];
560
- const requiredEditHooks = ["block-env-edit.js", "migration-guard.js"];
569
+ const requiredEditHooks = ["migration-guard.js"];
561
570
 
562
571
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
563
572
 
@@ -649,7 +658,7 @@ function cmdMigrate() {
649
658
  if (!settings.mcpServers["next-devtools"]) {
650
659
  settings.mcpServers["next-devtools"] = {
651
660
  command: "npx",
652
- args: ["next-devtools-mcp@0.3.10"],
661
+ args: ["next-devtools-mcp@latest"],
653
662
  disabled: false,
654
663
  };
655
664
  changes++;
@@ -760,6 +769,109 @@ function cmdAnalytics() {
760
769
  console.log("");
761
770
  }
762
771
 
772
+ // ─── ERP Ping ───────────────────────────────────────────
773
+ // Synthetic POST to ERP /api/v1/reports to verify connectivity, auth key
774
+ // validity, and endpoint health. Uses a distinct dry_run=true flag in the
775
+ // payload so receivers can filter these out of real report views.
776
+
777
+ function cmdErpPing() {
778
+ banner();
779
+ console.log("");
780
+
781
+ const cfg = readConfig();
782
+ const erpUrl = (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net";
783
+ const erpEnabled = !(cfg.erp && cfg.erp.enabled === false);
784
+ const keyFile = path.join(CLAUDE_DIR, ".erp-api-key");
785
+
786
+ console.log(` ${DIM}URL:${RESET} ${WHITE}${erpUrl}${RESET}`);
787
+ console.log(` ${DIM}Enabled:${RESET} ${erpEnabled ? `${GREEN}yes${RESET}` : `${YELLOW}no (erp.enabled=false)${RESET}`}`);
788
+
789
+ let apiKey = "";
790
+ try {
791
+ apiKey = fs.readFileSync(keyFile, "utf8").trim();
792
+ } catch {}
793
+ if (!apiKey) {
794
+ console.log(` ${DIM}Key:${RESET} ${RED}missing${RESET} ${DIM}(${keyFile})${RESET}`);
795
+ console.log("");
796
+ console.log(` ${RED}✗ Cannot ping — no API key. Ask Fawzi for one.${RESET}`);
797
+ console.log("");
798
+ process.exit(1);
799
+ }
800
+ console.log(` ${DIM}Key:${RESET} ${GREEN}present${RESET} ${DIM}(${apiKey.length} bytes)${RESET}`);
801
+ console.log("");
802
+
803
+ if (!erpEnabled) {
804
+ console.log(` ${YELLOW}ERP is disabled in config. Enable with:${RESET}`);
805
+ console.log(` ${DIM} qualia-framework erp-ping --enable${RESET}`);
806
+ console.log("");
807
+ process.exit(1);
808
+ }
809
+
810
+ const payload = JSON.stringify({
811
+ project: "qualia-framework-erp-ping",
812
+ project_id: "ping",
813
+ team_id: "qualia-solutions",
814
+ client_report_id: "QS-PING-00",
815
+ phase: 0,
816
+ phase_name: "ping",
817
+ status: "setup",
818
+ milestone: 0,
819
+ milestone_name: "ping",
820
+ submitted_by: cfg.installed_by || "ping",
821
+ submitted_at: new Date().toISOString(),
822
+ notes: "ERP PING — synthetic connectivity test, safe to ignore",
823
+ dry_run: true,
824
+ });
825
+
826
+ const started = Date.now();
827
+ const r = spawnSync("curl", [
828
+ "-sS", "-X", "POST",
829
+ "-H", `Authorization: Bearer ${apiKey}`,
830
+ "-H", "Content-Type: application/json",
831
+ "-d", payload,
832
+ "--max-time", "10",
833
+ "-w", "\n__HTTP__%{http_code}",
834
+ `${erpUrl}/api/v1/reports`,
835
+ ], { encoding: "utf8", timeout: 12000 });
836
+
837
+ const duration = Date.now() - started;
838
+ const raw = (r.stdout || "") + (r.stderr || "");
839
+ const httpMatch = raw.match(/__HTTP__(\d+)/);
840
+ const httpCode = httpMatch ? httpMatch[1] : "—";
841
+ const body = raw.replace(/\n?__HTTP__\d+/, "").trim();
842
+
843
+ console.log(` ${DIM}Response:${RESET} ${WHITE}HTTP ${httpCode}${RESET} ${DIM}(${duration}ms)${RESET}`);
844
+ if (body) {
845
+ try {
846
+ const j = JSON.parse(body);
847
+ if (j.ok && j.report_id) {
848
+ console.log(` ${DIM}report_id:${RESET} ${GREEN}${j.report_id}${RESET}`);
849
+ }
850
+ if (!j.ok && j.error) {
851
+ console.log(` ${DIM}error:${RESET} ${RED}${j.error}${RESET} ${DIM}${j.message || ""}${RESET}`);
852
+ }
853
+ } catch {
854
+ console.log(` ${DIM}body:${RESET} ${WHITE}${body.slice(0, 200)}${RESET}`);
855
+ }
856
+ }
857
+ console.log("");
858
+
859
+ if (httpCode === "200") {
860
+ console.log(` ${GREEN}✓ ERP reachable, key valid, endpoint healthy.${RESET}`);
861
+ console.log("");
862
+ process.exit(0);
863
+ }
864
+ if (httpCode === "401") {
865
+ console.log(` ${RED}✗ API key rejected. Ask Fawzi for a fresh key.${RESET}`);
866
+ } else if (httpCode === "—") {
867
+ console.log(` ${RED}✗ No response — DNS, TLS, or network issue.${RESET}`);
868
+ } else {
869
+ console.log(` ${YELLOW}! Unexpected response. Check ERP status.${RESET}`);
870
+ }
871
+ console.log("");
872
+ process.exit(1);
873
+ }
874
+
763
875
  function cmdHelp() {
764
876
  banner();
765
877
  console.log("");
@@ -772,6 +884,7 @@ function cmdHelp() {
772
884
  console.log(` qualia-framework ${TEAL}team${RESET} Manage team members (${DIM}list|add|remove${RESET})`);
773
885
  console.log(` qualia-framework ${TEAL}traces${RESET} View recent hook telemetry`);
774
886
  console.log(` qualia-framework ${TEAL}analytics${RESET} Show outcome scoring & gap cycle stats`);
887
+ console.log(` qualia-framework ${TEAL}erp-ping${RESET} Verify ERP connectivity + API key`);
775
888
  console.log("");
776
889
  console.log(` ${WHITE}After install:${RESET}`);
777
890
  console.log(` ${TG}/qualia${RESET} What should I do next?`);
@@ -824,6 +937,10 @@ switch (cmd) {
824
937
  case "stats":
825
938
  cmdAnalytics();
826
939
  break;
940
+ case "erp-ping":
941
+ case "ping":
942
+ cmdErpPing();
943
+ break;
827
944
  default:
828
945
  cmdHelp();
829
946
  }
package/bin/install.js CHANGED
@@ -517,7 +517,18 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
517
517
  ok(".erp-api-key (from $QUALIA_ERP_KEY)");
518
518
  } else if (fs.existsSync(erpKeyFile)) {
519
519
  try { fs.chmodSync(erpKeyFile, 0o600); } catch {}
520
- ok(".erp-api-key (existing — preserved)");
520
+ // Sanity check: warn on a clearly-empty/placeholder key. Genuine tokens
521
+ // from the ERP are ≥ 20 bytes; under 10 is almost certainly a mistake.
522
+ try {
523
+ const existingKey = fs.readFileSync(erpKeyFile, "utf8").trim();
524
+ if (existingKey.length < 10) {
525
+ warn(`.erp-api-key exists but looks truncated (${existingKey.length} bytes) — verify with 'qualia-framework erp-ping'`);
526
+ } else {
527
+ ok(".erp-api-key (existing — preserved)");
528
+ }
529
+ } catch {
530
+ ok(".erp-api-key (existing — preserved)");
531
+ }
521
532
  } else {
522
533
  // Disable ERP in the config we just wrote.
523
534
  try {
@@ -673,7 +684,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
673
684
  if (!settings.mcpServers["next-devtools"]) {
674
685
  settings.mcpServers["next-devtools"] = {
675
686
  command: "npx",
676
- args: ["next-devtools-mcp@0.3.10"],
687
+ args: ["next-devtools-mcp@latest"],
677
688
  disabled: false,
678
689
  };
679
690
  ok("MCP: next-devtools (runtime error visibility for Next.js projects)");
package/bin/state.js CHANGED
@@ -194,6 +194,7 @@ function ensureLifetime(t) {
194
194
  if (typeof t.milestone !== "number") t.milestone = 1;
195
195
  if (typeof t.milestone_name !== "string") t.milestone_name = "";
196
196
  if (!Array.isArray(t.milestones)) t.milestones = [];
197
+ if (typeof t.report_seq !== "number") t.report_seq = 0;
197
198
  if (!t.lifetime || typeof t.lifetime !== "object") {
198
199
  t.lifetime = {
199
200
  tasks_completed: 0,
@@ -205,6 +206,26 @@ function ensureLifetime(t) {
205
206
  return t;
206
207
  }
207
208
 
209
+ // Parse JOURNEY.md and extract the human name of the Nth milestone.
210
+ // Matches headers like:
211
+ // ## Milestone 2 · Core Features
212
+ // ## Milestone 2 · Core Features [CURRENT]
213
+ // ## Milestone 5 · Handoff [FINAL]
214
+ // Returns "" if JOURNEY.md is absent or the milestone isn't in it.
215
+ function readNextMilestoneNameFromJourney(milestoneNum) {
216
+ try {
217
+ const journeyPath = path.join(PLANNING, "JOURNEY.md");
218
+ if (!fs.existsSync(journeyPath)) return "";
219
+ const content = fs.readFileSync(journeyPath, "utf8");
220
+ const re = new RegExp(`^##\\s+Milestone\\s+${milestoneNum}\\s*[·•-]\\s*([^\\n\\[]+)`, "m");
221
+ const m = content.match(re);
222
+ if (m && m[1]) return m[1].trim();
223
+ return "";
224
+ } catch {
225
+ return "";
226
+ }
227
+ }
228
+
208
229
  function readState() {
209
230
  try {
210
231
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -1141,7 +1162,12 @@ function cmdCloseMilestone(opts) {
1141
1162
  t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
1142
1163
  t.lifetime.last_closed_milestone = closedMilestone;
1143
1164
  t.milestone = closedMilestone + 1;
1144
- t.milestone_name = ""; // cleared; /qualia-milestone reads next one from JOURNEY.md
1165
+ // Try to pre-populate next milestone's name from JOURNEY.md so the ERP
1166
+ // tree view doesn't show a blank between close-milestone and the next
1167
+ // state.js init --force (which happens in /qualia-milestone step 7).
1168
+ // If JOURNEY.md is missing or unparseable, fall through with blank —
1169
+ // /qualia-milestone will still set it via init --force.
1170
+ t.milestone_name = readNextMilestoneNameFromJourney(t.milestone);
1145
1171
  t.last_updated = new Date().toISOString();
1146
1172
 
1147
1173
  writeTracking(t);
@@ -1238,6 +1264,27 @@ function cmdBackfillLifetime(opts) {
1238
1264
  });
1239
1265
  }
1240
1266
 
1267
+ // ─── Next Report ID ──────────────────────────────────────
1268
+ // Increments report_seq and returns the next QS-REPORT-NN id. Per-project
1269
+ // counter (lives in tracking.json). /qualia-report calls this to tag each
1270
+ // session report with a stable, human-readable client ID before POSTing
1271
+ // to the ERP. If --peek is passed, the next id is returned WITHOUT
1272
+ // incrementing — useful for --dry-run previews.
1273
+ function cmdNextReportId(opts) {
1274
+ const t = readTracking();
1275
+ if (!t) return output(fail("NO_PROJECT", "No .planning/ found."));
1276
+ ensureLifetime(t);
1277
+ const peek = !!opts.peek;
1278
+ const next = (parseInt(t.report_seq) || 0) + 1;
1279
+ const id = `QS-REPORT-${String(next).padStart(2, "0")}`;
1280
+ if (!peek) {
1281
+ t.report_seq = next;
1282
+ t.last_updated = new Date().toISOString();
1283
+ writeTracking(t);
1284
+ }
1285
+ output({ ok: true, action: "next-report-id", report_id: id, report_seq: next, peeked: peek });
1286
+ }
1287
+
1241
1288
  // ─── Output ──────────────────────────────────────────────
1242
1289
  function output(obj) {
1243
1290
  console.log(JSON.stringify(obj, null, 2));
@@ -1289,11 +1336,14 @@ try {
1289
1336
  case "backfill-lifetime":
1290
1337
  cmdBackfillLifetime(opts);
1291
1338
  break;
1339
+ case "next-report-id":
1340
+ cmdNextReportId(opts);
1341
+ break;
1292
1342
  default:
1293
1343
  output(
1294
1344
  fail(
1295
1345
  "UNKNOWN_COMMAND",
1296
- `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime> [--options]`
1346
+ `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|next-report-id> [--options]`
1297
1347
  )
1298
1348
  );
1299
1349
  }
package/bin/statusline.js CHANGED
@@ -154,6 +154,8 @@ try {
154
154
  } catch {}
155
155
 
156
156
  // ─── Phase info from .planning/tracking.json ─────────────
157
+ // Shows: [M{n}·{milestoneName}] P{phase}/{total} T{done}/{total} {status} [!{blockers}]
158
+ // Every segment is optional — missing data is skipped, never rendered as a placeholder.
157
159
  let PHASE_INFO = "";
158
160
  try {
159
161
  const trackingPath = path.join(DIR, ".planning", "tracking.json");
@@ -162,12 +164,46 @@ try {
162
164
  const phase = Number(tracking.phase || 0) || 0;
163
165
  const total = Number(tracking.total_phases || 0) || 0;
164
166
  const status = String(tracking.status || "");
167
+ const milestone = Number(tracking.milestone || 0) || 0;
168
+ const milestoneName = String(tracking.milestone_name || "");
169
+ const tasksDone = Number(tracking.tasks_done || 0) || 0;
170
+ const tasksTotal = Number(tracking.tasks_total || 0) || 0;
171
+ const blockers = Array.isArray(tracking.blockers) ? tracking.blockers.length : 0;
172
+
173
+ const parts = [];
174
+
175
+ // Milestone: M{n}·{shortName} (short name trimmed to 14 chars)
176
+ if (milestone > 0) {
177
+ let mStr = `M${milestone}`;
178
+ if (milestoneName) {
179
+ const shortName = milestoneName.length > 14 ? milestoneName.slice(0, 13) + "…" : milestoneName;
180
+ mStr += `${DIM}·${RESET}${TEAL_GLOW}${shortName}`;
181
+ }
182
+ parts.push(`${TEAL}${mStr}${RESET}`);
183
+ }
184
+
185
+ // Phase: P{phase}/{total}
165
186
  if (total > 0) {
166
- const pdone = Math.floor((phase * 100) / total);
167
- const pfill = Math.max(0, Math.min(4, Math.floor(pdone / 25)));
168
- const pempt = 4 - pfill;
169
- const pbar = "●".repeat(pfill) + "○".repeat(pempt);
170
- PHASE_INFO = `${TEAL}${pbar}${RESET} ${WHITE}P${phase}/${total}${RESET} ${TEAL_GLOW}${status}${RESET}`;
187
+ parts.push(`${WHITE}P${phase}/${total}${RESET}`);
188
+ }
189
+
190
+ // Tasks within phase: T{done}/{total}
191
+ if (tasksTotal > 0) {
192
+ parts.push(`${DIM}T${RESET}${WHITE}${tasksDone}/${tasksTotal}${RESET}`);
193
+ }
194
+
195
+ // Status
196
+ if (status) {
197
+ parts.push(`${TEAL_GLOW}${status}${RESET}`);
198
+ }
199
+
200
+ // Blockers — red badge, only when > 0
201
+ if (blockers > 0) {
202
+ parts.push(`${RED}!${blockers}${RESET}`);
203
+ }
204
+
205
+ if (parts.length > 0) {
206
+ PHASE_INFO = parts.join(` ${DIM}·${RESET} `);
171
207
  }
172
208
  }
173
209
  } catch {}
@@ -186,36 +222,22 @@ try {
186
222
  }
187
223
  } catch {}
188
224
 
189
- // ─── Hooks count ─────────────────────────────────────────
190
- let HOOKS_COUNT = 0;
225
+ // ─── Qualia identity: first name of the installed employee ─────────
226
+ // Read from ~/.claude/.qualia-config.json. Used as the "signature" at the
227
+ // end of line 2. Gracefully degrades to empty string if the config is
228
+ // missing (pre-install, broken install, or running outside a Qualia env).
229
+ let QUALIA_FIRST_NAME = "";
191
230
  try {
192
- const settingsPath = path.join(HOME, ".claude", "settings.json");
193
- if (fs.existsSync(settingsPath)) {
194
- const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
195
- if (settings.hooks) {
196
- for (const event of Object.values(settings.hooks)) {
197
- if (Array.isArray(event)) {
198
- for (const matcher of event) {
199
- if (matcher.hooks && Array.isArray(matcher.hooks)) {
200
- HOOKS_COUNT += matcher.hooks.length;
201
- }
202
- }
203
- }
204
- }
231
+ const configPath = path.join(HOME, ".claude", ".qualia-config.json");
232
+ if (fs.existsSync(configPath)) {
233
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
234
+ const fullName = String(cfg.installed_by || "").trim();
235
+ if (fullName) {
236
+ QUALIA_FIRST_NAME = fullName.split(/\s+/)[0] || "";
205
237
  }
206
238
  }
207
239
  } catch {}
208
240
 
209
- // ─── Skills count ────────────────────────────────────────
210
- let SKILLS_COUNT = 0;
211
- try {
212
- const skillsDir = path.join(HOME, ".claude", "skills");
213
- if (fs.existsSync(skillsDir)) {
214
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
215
- SKILLS_COUNT = entries.filter(e => e.isDirectory() || e.name.endsWith(".md")).length;
216
- }
217
- } catch {}
218
-
219
241
  // ─── Duration ────────────────────────────────────────────
220
242
  let DUR = "0s";
221
243
  try {
@@ -247,13 +269,13 @@ try {
247
269
  if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
248
270
  if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
249
271
  if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
250
- // Memory, hooks, skills context indicators with labels
251
- const contextParts = [];
252
- if (MEMORY_COUNT > 0) contextParts.push(`${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`);
253
- if (HOOKS_COUNT > 0) contextParts.push(`${DIM}hooks${RESET} ${TEAL_GLOW}${HOOKS_COUNT}${RESET}`);
254
- if (SKILLS_COUNT > 0) contextParts.push(`${DIM}skills${RESET} ${TEAL_DIM}${SKILLS_COUNT}${RESET}`);
255
- if (contextParts.length > 0) {
256
- LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
272
+ // Memory the one context indicator that's actually project-specific
273
+ if (MEMORY_COUNT > 0) {
274
+ LINE1 += ` ${DIM}│${RESET} ${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`;
275
+ }
276
+ // Qualia member signature end of line 1 so it sits above line 2's model info
277
+ if (QUALIA_FIRST_NAME) {
278
+ LINE1 += ` ${DIM}│${RESET} ${TEAL}⬢${RESET} ${TEAL_GLOW}Qualia member${RESET}${DIM}:${RESET} ${WHITE}${QUALIA_FIRST_NAME}${RESET}`;
257
279
  }
258
280
  } catch {
259
281
  LINE1 = `${TEAL}⬢${RESET} ${WHITE}qualia${RESET}`;
@@ -28,8 +28,16 @@ Upload a session report.
28
28
  ```
29
29
  Authorization: Bearer <api-key>
30
30
  Content-Type: application/json
31
+ Idempotency-Key: <uuid> # optional; 24h replay window — see below
31
32
  ```
32
33
 
34
+ **Idempotency-Key behavior (v3.6+):**
35
+ When present, must be a valid UUID. Replays of the same key within 24h return
36
+ the original `report_id` with `Idempotent-Replay: true` response header and
37
+ 200 status — no new row is created. Invalid UUID format returns 400.
38
+ Independent of `client_report_id` UPSERT (both can be used together; see
39
+ below).
40
+
33
41
  **Request Body:**
34
42
  ```json
35
43
  {
@@ -38,6 +46,7 @@ Content-Type: application/json
38
46
  "team_id": "qualia-solutions",
39
47
  "git_remote": "github.com/QualiasolutionsCY/acme-portal",
40
48
  "client": "Client Name",
49
+ "client_report_id": "QS-REPORT-03",
41
50
  "milestone": 2,
42
51
  "milestone_name": "Core Product",
43
52
  "milestones": [
@@ -87,11 +96,27 @@ accept both shapes: if object, use `gap_cycles[String(phase)] || 0`.
87
96
  ```json
88
97
  {
89
98
  "ok": true,
90
- "report_id": "rpt_abc123def456",
99
+ "report_id": "QS-REPORT-03",
91
100
  "message": "Report received"
92
101
  }
93
102
  ```
94
103
 
104
+ `report_id` semantics:
105
+ - **v4.0.4+ payloads** (`client_report_id` present): ERP echoes the
106
+ `client_report_id` string back as `report_id` for display consistency.
107
+ Example: request sends `client_report_id: "QS-REPORT-03"` → response
108
+ returns `report_id: "QS-REPORT-03"`.
109
+ - **Legacy payloads** (no `client_report_id`): ERP returns its internal UUID
110
+ (e.g. `"a5304d8b-a5ac-4e22-b0c0-fed5f50299bb"`) as `report_id`.
111
+
112
+ **Idempotent UPSERT on retry (v4.0.4+):**
113
+ When BOTH `project_id` and `client_report_id` are present, the ERP treats
114
+ `(project_id, client_report_id)` as a unique key and UPSERTs. Retries after
115
+ a transient failure produce the same row and return the same `report_id`
116
+ — no duplicate. This is stronger than the 24h Idempotency-Key window (which
117
+ is exact-replay only) because `client_report_id` uniqueness is enforced
118
+ permanently.
119
+
95
120
  **Response (401 Unauthorized):**
96
121
  ```json
97
122
  {
@@ -171,7 +196,15 @@ Authorization: Bearer <api-key>
171
196
  - When the API key file is missing or empty, the upload is skipped with a warning.
172
197
  - Network failures are non-blocking — the report is saved locally regardless.
173
198
  - The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
174
- - Reports are append-only — no update or delete endpoints exist.
199
+ - Reports are append-only — no PUT/PATCH/DELETE endpoints exist for
200
+ external callers. Internal idempotent UPSERT on `(project_id,
201
+ client_report_id)` retries is the one exception (see "Idempotent UPSERT
202
+ on retry" above).
203
+ - **`dry_run` retention (v4.0.4+):** The ERP deletes rows where
204
+ `dry_run = true AND submitted_at < now() - 7 days` via a daily cron at
205
+ 03:00 UTC. Production report views (list, project tree, email digests)
206
+ exclude `dry_run = true` rows at read time by default. Admins can opt in
207
+ via `includeDryRun: true` on the server-action readers for diagnostics.
175
208
  - `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.
176
209
  - Backward compatibility: if `lifetime` is absent in tracking.json, treat all counters as 0 and `milestone` as 1.
177
210
 
@@ -195,6 +228,8 @@ Authorization: Bearer <api-key>
195
228
  | last_pushed_at | string | optional (v3.6+) | ISO 8601 — distinct from `last_updated` (which fires on local writes too). |
196
229
  | build_count | number | optional (v3.6+) | Lifetime build counter. |
197
230
  | deploy_count | number | optional (v3.6+) | Lifetime deploy counter. |
231
+ | client_report_id | string | recommended (v4.0.4+) | Client-side sequential identifier: `QS-REPORT-01`, `QS-REPORT-02`, … per-project. Stable across retries. Preferred dedupe key over the ERP-generated `report_id`; safe to adopt as the ERP's primary report key. |
232
+ | dry_run | boolean | optional (v4.0.4+) | `true` marks a synthetic ping (from `qualia-framework erp-ping`). Receivers should filter these out of production report views. |
198
233
 
199
234
  All other fields are optional but recommended for complete reporting.
200
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"
@@ -21,8 +21,9 @@ Initialize a project with the **entire arc mapped from kickoff to handoff**. All
21
21
 
22
22
  ## Flags
23
23
 
24
- - `/qualia-new` — full-journey flow, stops after approval (default, backward-compatible)
24
+ - `/qualia-new` — full-journey flow, stops after approval (default, backward-compatible). **Progressive detail**: Milestone 1 gets full phase detail; M2..M{N-1} get sketched (names + one-line goals). Full detail for later milestones is filled in by `/qualia-milestone` when each one opens. This matches how real projects unfold — M1's discoveries reshape M3's plan, so deep-planning M3 now tends to waste effort.
25
25
  - `/qualia-new --auto` — full-journey flow, then auto-chains into `/qualia-plan 1 → /qualia-build → /qualia-verify` for Milestone 1
26
+ - `/qualia-new --full-detail` — ALL milestones get full phase-level detail upfront (success criteria per phase for every milestone, not just M1). Use when the client wants a fully-committed plan at kickoff. Trade-off: later milestones often need revision as M1 ships. Combinable with `--auto`.
26
27
  - `/qualia-new --quick` — 4-phase flat wizard for trivial projects (landing pages, prototypes). Skips research and journey mapping.
27
28
 
28
29
  ## The Shift From Previous Versions
@@ -238,7 +239,7 @@ Gather any additional requirements the user wants that research missed.
238
239
  node ~/.claude/bin/qualia-ui.js banner roadmap
239
240
  ```
240
241
 
241
- Spawn the roadmapper with full-journey mandate:
242
+ Spawn the roadmapper with full-journey mandate. If the user passed `--full-detail`, include `<full_detail>true</full_detail>` in the prompt so the roadmapper writes complete phase detail for ALL milestones.
242
243
 
243
244
  ```
244
245
  Agent(prompt="
@@ -248,7 +249,7 @@ Read your role: @~/.claude/agents/roadmapper.md
248
249
  Create the FULL JOURNEY for this project:
249
250
  - .planning/JOURNEY.md — all milestones (2-5 including Handoff) with exit criteria
250
251
  - .planning/REQUIREMENTS.md — requirements grouped by milestone
251
- - .planning/ROADMAP.md — Milestone 1's phase detail only (ready for /qualia-plan 1)
252
+ - .planning/ROADMAP.md — Milestone 1's phase detail (and ALL milestones if full_detail=true)
252
253
 
253
254
  User-scoped v1 features:
254
255
  {list of features selected in Step 9, grouped by category}
@@ -256,6 +257,10 @@ User-scoped v1 features:
256
257
  Template type: {template_type from config.json}
257
258
  If set, use ~/.claude/qualia-templates/projects/{type}.md as the milestone arc starting point.
258
259
 
260
+ <full_detail>{true if --full-detail, else false}</full_detail>
261
+ - false (default): Milestone 1 gets full phase detail; M2..M{N-1} stay as sketches. Detail fills in when each milestone opens via /qualia-milestone.
262
+ - true: every milestone (M1..Handoff) gets full phase-level detail in ROADMAP.md upfront. Useful when the client wants a fully-committed plan at kickoff.
263
+
259
264
  The final milestone MUST be named 'Handoff' with the fixed 4 phases
260
265
  (Polish, Content + SEO, Final QA, Handoff). Do not omit it.
261
266
 
@@ -331,6 +336,16 @@ git add .planning/JOURNEY.md .planning/REQUIREMENTS.md .planning/ROADMAP.md .pla
331
336
  git commit -m "docs: journey + requirements + milestone 1 roadmap ({N} milestones)"
332
337
  ```
333
338
 
339
+ **After approval, show the progressive-detail reminder explicitly:**
340
+
341
+ ```bash
342
+ node ~/.claude/bin/qualia-ui.js info "Milestone 1 is fully planned now."
343
+ node ~/.claude/bin/qualia-ui.js info "Milestones 2..{N-1} are sketched (names + one-line goals)."
344
+ node ~/.claude/bin/qualia-ui.js info "Full phase detail for each later milestone gets written when /qualia-milestone opens it."
345
+ ```
346
+
347
+ (Skip this block when `--full-detail` was used — all milestones are already fully planned in that case.)
348
+
334
349
  ### Step 13. Environment Setup
335
350
 
336
351
  Supabase project? `supabase link` or create. Vercel project? `vercel link`. Env vars? `.env.local` with placeholders from PROJECT.md stack.
@@ -12,6 +12,11 @@ allowed-tools:
12
12
 
13
13
  Generate a concise report of what was done. Committed to git and uploaded to the ERP for clock-out.
14
14
 
15
+ ## Flags
16
+
17
+ - `/qualia-report` — normal flow (generate, commit, push, upload to ERP)
18
+ - `/qualia-report --dry-run` — generate + show payload, SKIP upload and SKIP commit. Useful for debugging or previewing before a real clock-out.
19
+
15
20
  ## Process
16
21
 
17
22
  ```bash
@@ -69,16 +74,33 @@ None. / - {blocker}
69
74
  {list from git log}
70
75
  ```
71
76
 
72
- ### 4. Commit and Push
77
+ ### 4. Obtain Client Report ID (QS-REPORT-NN)
78
+
79
+ Each session report gets a stable, sequential client-side identifier that travels with the report all the way to the ERP. The sequence is per-project, persisted in `tracking.json.report_seq`.
73
80
 
74
81
  ```bash
75
- mkdir -p .planning/reports
76
- git add .planning/reports/report-{date}.md
77
- git commit -m "report: session {YYYY-MM-DD}"
78
- git push
82
+ # --dry-run: peek without incrementing
83
+ if [ "$DRY_RUN" = "true" ]; then
84
+ CLIENT_REPORT_ID=$(node ~/.claude/bin/state.js next-report-id --peek 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).report_id||'')")
85
+ else
86
+ CLIENT_REPORT_ID=$(node ~/.claude/bin/state.js next-report-id 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).report_id||'')")
87
+ fi
79
88
  ```
80
89
 
81
- ### 5. Upload to ERP (if enabled)
90
+ Example: first report on a fresh project → `QS-REPORT-01`. Next → `QS-REPORT-02`. Etc.
91
+
92
+ ### 5. Commit and Push (SKIP on --dry-run)
93
+
94
+ ```bash
95
+ if [ "$DRY_RUN" != "true" ]; then
96
+ mkdir -p .planning/reports
97
+ git add .planning/reports/report-{date}.md .planning/tracking.json
98
+ git commit -m "report: {CLIENT_REPORT_ID} session {YYYY-MM-DD}"
99
+ git push
100
+ fi
101
+ ```
102
+
103
+ ### 6. Upload to ERP (SKIP on --dry-run)
82
104
 
83
105
  Read `~/.claude/.qualia-config.json` and check the `erp` object:
84
106
  - If `erp.enabled` is `false`, skip this step and print: "ERP upload skipped (disabled in config)."
@@ -94,67 +116,126 @@ REPORT_FILE=".planning/reports/report-{date}.md"
94
116
  SUBMITTED_BY=$(git config user.name)
95
117
  SUBMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
96
118
 
97
- # Only upload if ERP is enabled
119
+ # Build structured JSON payload from tracking.json (matches ERP contract /api/v1/reports)
120
+ # v4: include milestone_name, milestones[], team_id, project_id, git_remote,
121
+ # session_started_at, last_pushed_at, build_count, deploy_count — the ERP
122
+ # uses these to render the project tree (milestone → phases → unphased) correctly.
123
+ # v4.0.4: client_report_id carries the QS-REPORT-NN identifier.
124
+ PAYLOAD=$(node -e "
125
+ const fs = require('fs');
126
+ const t = JSON.parse(fs.readFileSync('.planning/tracking.json', 'utf8'));
127
+ const notes = fs.readFileSync('$REPORT_FILE', 'utf8').substring(0, 60000);
128
+ const commits = [];
129
+ try {
130
+ const { spawnSync } = require('child_process');
131
+ const r = spawnSync('git', ['log', '--oneline', '--since=8 hours ago', '--format=%h'], { encoding: 'utf8', timeout: 3000 });
132
+ if (r.stdout) commits.push(...r.stdout.trim().split('\n').filter(Boolean));
133
+ } catch {}
134
+ console.log(JSON.stringify({
135
+ project: t.project || require('path').basename(process.cwd()),
136
+ project_id: t.project_id || '',
137
+ team_id: t.team_id || '',
138
+ git_remote: t.git_remote || '',
139
+ client: t.client || '',
140
+ client_report_id: '$CLIENT_REPORT_ID',
141
+ milestone: t.milestone || 1,
142
+ milestone_name: t.milestone_name || '',
143
+ milestones: Array.isArray(t.milestones) ? t.milestones : [],
144
+ phase: t.phase,
145
+ phase_name: t.phase_name,
146
+ total_phases: t.total_phases,
147
+ status: t.status,
148
+ tasks_done: t.tasks_done || 0,
149
+ tasks_total: t.tasks_total || 0,
150
+ verification: t.verification || 'pending',
151
+ gap_cycles: (t.gap_cycles || {})[String(t.phase)] || 0,
152
+ build_count: t.build_count || 0,
153
+ deploy_count: t.deploy_count || 0,
154
+ deployed_url: t.deployed_url || '',
155
+ session_started_at: t.session_started_at || '',
156
+ last_pushed_at: t.last_pushed_at || '',
157
+ lifetime: t.lifetime || {},
158
+ commits: commits,
159
+ notes: notes,
160
+ submitted_by: '$SUBMITTED_BY',
161
+ submitted_at: '$SUBMITTED_AT'
162
+ }));
163
+ ")
164
+
165
+ # --dry-run: print payload and stop (no POST, no commit, no increment already handled in step 4)
166
+ if [ "$DRY_RUN" = "true" ]; then
167
+ echo "--- DRY RUN · payload ---"
168
+ echo "$PAYLOAD" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));console.log(JSON.stringify(d,null,2))"
169
+ echo "--- DRY RUN · would POST to: $ERP_URL/api/v1/reports ---"
170
+ echo "--- DRY RUN · client_report_id would be: $CLIENT_REPORT_ID ---"
171
+ exit 0
172
+ fi
173
+
174
+ # Real upload — 3 attempts with exponential backoff (1s, 3s, 9s).
175
+ # The local report file is already committed, so a failed upload doesn't
176
+ # lose data — it just leaves the ERP view stale until the next push or
177
+ # manual retry.
98
178
  if [ "$ERP_ENABLED" = "true" ]; then
99
- # Build structured JSON payload from tracking.json (matches ERP contract /api/v1/reports)
100
- # v4: include milestone_name, milestones[], team_id, project_id, git_remote,
101
- # session_started_at, last_pushed_at, build_count, deploy_count — the ERP
102
- # uses these to render the project tree (milestone → phases → unphased) correctly.
103
- PAYLOAD=$(node -e "
104
- const fs = require('fs');
105
- const t = JSON.parse(fs.readFileSync('.planning/tracking.json', 'utf8'));
106
- const notes = fs.readFileSync('$REPORT_FILE', 'utf8').substring(0, 60000);
107
- const commits = [];
108
- try {
109
- const { spawnSync } = require('child_process');
110
- const r = spawnSync('git', ['log', '--oneline', '--since=8 hours ago', '--format=%h'], { encoding: 'utf8', timeout: 3000 });
111
- if (r.stdout) commits.push(...r.stdout.trim().split('\n').filter(Boolean));
112
- } catch {}
113
- console.log(JSON.stringify({
114
- project: t.project || require('path').basename(process.cwd()),
115
- project_id: t.project_id || '',
116
- team_id: t.team_id || '',
117
- git_remote: t.git_remote || '',
118
- client: t.client || '',
119
- milestone: t.milestone || 1,
120
- milestone_name: t.milestone_name || '',
121
- milestones: Array.isArray(t.milestones) ? t.milestones : [],
122
- phase: t.phase,
123
- phase_name: t.phase_name,
124
- total_phases: t.total_phases,
125
- status: t.status,
126
- tasks_done: t.tasks_done || 0,
127
- tasks_total: t.tasks_total || 0,
128
- verification: t.verification || 'pending',
129
- gap_cycles: (t.gap_cycles || {})[String(t.phase)] || 0,
130
- build_count: t.build_count || 0,
131
- deploy_count: t.deploy_count || 0,
132
- deployed_url: t.deployed_url || '',
133
- session_started_at: t.session_started_at || '',
134
- last_pushed_at: t.last_pushed_at || '',
135
- lifetime: t.lifetime || {},
136
- commits: commits,
137
- notes: notes,
138
- submitted_by: '$SUBMITTED_BY',
139
- submitted_at: '$SUBMITTED_AT'
140
- }));
141
- ")
142
-
143
- curl -s -X POST "$ERP_URL/api/v1/reports" \
144
- -H "Authorization: Bearer $API_KEY" \
145
- -H "Content-Type: application/json" \
146
- -d "$PAYLOAD"
179
+ MAX_ATTEMPTS=3
180
+ ATTEMPT=1
181
+ SUCCESS=false
182
+ while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
183
+ RESPONSE=$(curl -sS -X POST "$ERP_URL/api/v1/reports" \
184
+ -H "Authorization: Bearer $API_KEY" \
185
+ -H "Content-Type: application/json" \
186
+ -d "$PAYLOAD" \
187
+ --max-time 10 \
188
+ -w "\n__HTTP__%{http_code}" 2>&1)
189
+ HTTP_CODE=$(echo "$RESPONSE" | grep -o "__HTTP__[0-9]*" | sed 's/__HTTP__//')
190
+ BODY=$(echo "$RESPONSE" | sed 's/__HTTP__[0-9]*//g')
191
+
192
+ if [ "$HTTP_CODE" = "200" ]; then
193
+ SUCCESS=true
194
+ # Parse and display the ERP-returned report_id alongside our local QS-REPORT-NN
195
+ ERP_REPORT_ID=$(echo "$BODY" | node -e "try{const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(d.report_id||'')}catch{}")
196
+ node ~/.claude/bin/qualia-ui.js ok "Uploaded as $CLIENT_REPORT_ID (ERP: ${ERP_REPORT_ID:-none})"
197
+ break
198
+ fi
199
+
200
+ # 401 / 422 are permanent failures — no retry.
201
+ if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
202
+ node ~/.claude/bin/qualia-ui.js warn "ERP rejected report (HTTP $HTTP_CODE). Ask Fawzi."
203
+ echo "$BODY" | head -3
204
+ break
205
+ fi
206
+
207
+ # Transient failure — back off and retry.
208
+ if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
209
+ SLEEP=$(( 1 * 3 ** (ATTEMPT - 1) ))
210
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload attempt $ATTEMPT failed (HTTP ${HTTP_CODE:-timeout}), retrying in ${SLEEP}s..."
211
+ sleep $SLEEP
212
+ fi
213
+ ATTEMPT=$(( ATTEMPT + 1 ))
214
+ done
215
+
216
+ if [ "$SUCCESS" != "true" ]; then
217
+ node ~/.claude/bin/qualia-ui.js warn "ERP upload failed after $MAX_ATTEMPTS attempts. $CLIENT_REPORT_ID is committed locally; it will NOT appear in the ERP until you retry with 'curl' or re-run /qualia-report."
218
+ fi
219
+ fi
220
+
221
+ if [ "$ERP_ENABLED" != "true" ]; then
222
+ node ~/.claude/bin/qualia-ui.js info "ERP upload skipped (disabled in config). Report committed locally as $CLIENT_REPORT_ID."
147
223
  fi
148
224
  ```
149
225
 
150
- If the upload succeeds, print: "Report uploaded to ERP. You can now clock out."
151
- If it fails (no API key, network error), print the error and tell the employee to ask Fawzi.
152
- If ERP is disabled, print: "ERP upload skipped (disabled in config)."
226
+ Summary rules:
227
+ - **Upload succeeds:** print "Uploaded as QS-REPORT-NN (ERP: {uuid})". Employee can clock out.
228
+ - **401/422:** no retry. Print the error, tell the employee to ask Fawzi.
229
+ - **Transient (timeout, 5xx, network):** retry 3x with 1s/3s/9s backoff.
230
+ - **All retries fail:** tell employee the report is committed locally, ERP will be stale until retry.
231
+ - **ERP disabled:** skip silently with a note, local commit still happens.
153
232
 
154
- ### 6. Update State
233
+ ### 7. Update State (SKIP on --dry-run)
155
234
 
156
235
  ```bash
157
- node ~/.claude/bin/state.js transition --to activity --notes "Session report generated"
236
+ if [ "$DRY_RUN" != "true" ]; then
237
+ node ~/.claude/bin/state.js transition --to activity --notes "Session report $CLIENT_REPORT_ID generated"
238
+ fi
158
239
  ```
159
240
 
160
241
  Do NOT manually edit STATE.md or tracking.json — state.js handles both.
@@ -26,6 +26,7 @@
26
26
  "build_count": 0,
27
27
  "deploy_count": 0,
28
28
  "deployed_url": "",
29
+ "report_seq": 0,
29
30
  "notes": "",
30
31
  "submitted_by": "",
31
32
  "lifetime": {
package/tests/runner.js CHANGED
@@ -1288,6 +1288,104 @@ waves: 1
1288
1288
  fs.rmSync(tmpDir, { recursive: true, force: true });
1289
1289
  }
1290
1290
  });
1291
+
1292
+ // ─── v4.0.4: next-report-id ────────────────────────────────
1293
+ it("next-report-id returns QS-REPORT-01 on fresh project and increments", () => {
1294
+ const tmpDir = makeProject();
1295
+ try {
1296
+ const r1 = spawnSync(process.execPath,
1297
+ [path.join(BIN, "state.js"), "next-report-id"],
1298
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1299
+ assert.equal(r1.status, 0, `next-report-id failed: ${r1.stderr || r1.stdout}`);
1300
+ const j1 = JSON.parse(r1.stdout);
1301
+ assert.equal(j1.report_id, "QS-REPORT-01");
1302
+ assert.equal(j1.report_seq, 1);
1303
+ assert.equal(j1.peeked, false);
1304
+
1305
+ const r2 = spawnSync(process.execPath,
1306
+ [path.join(BIN, "state.js"), "next-report-id"],
1307
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1308
+ const j2 = JSON.parse(r2.stdout);
1309
+ assert.equal(j2.report_id, "QS-REPORT-02");
1310
+ assert.equal(j2.report_seq, 2);
1311
+ } finally {
1312
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1313
+ }
1314
+ });
1315
+
1316
+ it("next-report-id --peek does NOT increment the counter", () => {
1317
+ const tmpDir = makeProject();
1318
+ try {
1319
+ const r1 = spawnSync(process.execPath,
1320
+ [path.join(BIN, "state.js"), "next-report-id", "--peek"],
1321
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1322
+ const j1 = JSON.parse(r1.stdout);
1323
+ assert.equal(j1.report_id, "QS-REPORT-01");
1324
+ assert.equal(j1.peeked, true);
1325
+
1326
+ // Peek again — should still return QS-REPORT-01 since nothing incremented
1327
+ const r2 = spawnSync(process.execPath,
1328
+ [path.join(BIN, "state.js"), "next-report-id", "--peek"],
1329
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1330
+ const j2 = JSON.parse(r2.stdout);
1331
+ assert.equal(j2.report_id, "QS-REPORT-01");
1332
+ assert.equal(j2.report_seq, 1);
1333
+
1334
+ // On-disk report_seq should still be 0
1335
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1336
+ assert.ok(!t.report_seq || t.report_seq === 0,
1337
+ `report_seq should remain 0 after peek, got ${t.report_seq}`);
1338
+ } finally {
1339
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1340
+ }
1341
+ });
1342
+
1343
+ // ─── v4.0.4: close-milestone pre-populates next milestone_name from JOURNEY.md
1344
+ it("close-milestone pre-populates next milestone_name from JOURNEY.md", () => {
1345
+ const tmpDir = makeProject();
1346
+ try {
1347
+ // Write JOURNEY.md with Milestone 2 definition
1348
+ fs.writeFileSync(path.join(tmpDir, ".planning", "JOURNEY.md"), `# Journey
1349
+
1350
+ ## Milestone 1 · Foundation [CURRENT]
1351
+ Exit: scaffolding done
1352
+
1353
+ ## Milestone 2 · Core Features
1354
+ Exit: auth + dashboard
1355
+
1356
+ ## Milestone 3 · Handoff [FINAL]
1357
+ Exit: client takeover
1358
+ `);
1359
+ const r = spawnSync(process.execPath,
1360
+ [path.join(BIN, "state.js"), "close-milestone", "--force"],
1361
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1362
+ assert.equal(r.status, 0, `close-milestone failed: ${r.stderr || r.stdout}`);
1363
+
1364
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1365
+ assert.equal(t.milestone, 2);
1366
+ assert.equal(t.milestone_name, "Core Features",
1367
+ `milestone_name should be pre-populated from JOURNEY.md, got '${t.milestone_name}'`);
1368
+ } finally {
1369
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1370
+ }
1371
+ });
1372
+
1373
+ it("close-milestone leaves milestone_name blank when JOURNEY.md is missing", () => {
1374
+ const tmpDir = makeProject();
1375
+ try {
1376
+ // No JOURNEY.md — milestone_name should fall back to blank (legacy behavior)
1377
+ const r = spawnSync(process.execPath,
1378
+ [path.join(BIN, "state.js"), "close-milestone", "--force"],
1379
+ { encoding: "utf8", cwd: tmpDir, timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1380
+ assert.equal(r.status, 0);
1381
+
1382
+ const t = JSON.parse(fs.readFileSync(path.join(tmpDir, ".planning", "tracking.json"), "utf8"));
1383
+ assert.equal(t.milestone_name, "",
1384
+ "milestone_name must be blank when JOURNEY.md is absent (fallback unchanged)");
1385
+ } finally {
1386
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1387
+ }
1388
+ });
1291
1389
  });
1292
1390
 
1293
1391
  // ═══════════════════════════════════════════════════════════