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.
- package/agents/roadmapper.md +10 -5
- package/bin/cli.js +121 -4
- package/bin/install.js +13 -2
- package/bin/state.js +52 -2
- package/bin/statusline.js +59 -37
- package/docs/erp-contract.md +37 -2
- package/package.json +1 -1
- package/skills/qualia-new/SKILL.md +18 -3
- package/skills/qualia-report/SKILL.md +141 -60
- package/templates/tracking.json +1 -0
- package/tests/runner.js +98 -0
package/agents/roadmapper.md
CHANGED
|
@@ -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 —
|
|
88
|
+
### 4. Build ROADMAP.md — Milestone 1's phases (progressive detail by default)
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Check the `<full_detail>` flag in your prompt:
|
|
91
91
|
|
|
92
|
-
|
|
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
|
|
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
|
-
//
|
|
148
|
-
const QUALIA_AGENT_FILES = [
|
|
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 = ["
|
|
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@
|
|
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
|
-
|
|
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@
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
// ───
|
|
190
|
-
|
|
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
|
|
193
|
-
if (fs.existsSync(
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
LINE1 += ` ${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}`;
|
package/docs/erp-contract.md
CHANGED
|
@@ -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": "
|
|
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
|
|
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
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
###
|
|
233
|
+
### 7. Update State (SKIP on --dry-run)
|
|
155
234
|
|
|
156
235
|
```bash
|
|
157
|
-
|
|
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.
|
package/templates/tracking.json
CHANGED
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
|
// ═══════════════════════════════════════════════════════════
|