qualia-framework 6.5.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // ~/.claude/bin/auto-report.js — B1 auto-capture (framework side).
3
+ //
4
+ // Fires at SHIP TIME: when a Qualia project's tracking.json reaches
5
+ // status `shipped`, POST a session report to the ERP tagged `source: "auto"`,
6
+ // so the ERP reflects real shipped work without anyone running /qualia-report.
7
+ //
8
+ // Design (mirrors the constraints learned the hard way):
9
+ // • Ship-time, NOT per-turn. The Stop hook fires every turn; this guards on
10
+ // status===shipped + a per-shipped-unit dedupe marker, so it POSTs exactly
11
+ // ONCE per shipped (milestone, phase) — never a per-turn spam stream.
12
+ // • Fail-soft. Never throws, never blocks. On any upload failure it enqueues
13
+ // to the existing erp-retry queue (drained by session-start) and exits 0.
14
+ // • One ERP-upload seam. Reuses erp-retry's postOnce/enqueue/config/key
15
+ // readers and report-payload's buildPayload — no duplicated contract.
16
+ // • No double-posting. The dedupe marker means re-running on the same shipped
17
+ // unit is a no-op; the ERP also UPSERTs on (project_id, client_report_id).
18
+ //
19
+ // Invoked fire-and-forget (detached) by hooks/stop-session-log.js, or directly:
20
+ // node auto-report.js # run the guarded auto-report for cwd
21
+ // SOURCE handled internally as "auto"; set DRY_RUN=1 to mark the report dry.
22
+
23
+ const fs = require("fs");
24
+ const os = require("os");
25
+ const path = require("path");
26
+ const crypto = require("crypto");
27
+ const { spawnSync } = require("child_process");
28
+ const { buildPayload } = require("./report-payload.js");
29
+ const { enqueue, postOnce, readApiKey, readConfig } = require("./erp-retry.js");
30
+
31
+ function qualiaHome(home = os.homedir()) {
32
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
33
+ const parent = path.basename(path.dirname(__dirname));
34
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
35
+ return path.join(home, ".claude");
36
+ }
37
+
38
+ function readJson(file) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(file, "utf8"));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function markerFile(home, projectKey) {
47
+ const safe = String(projectKey || "project").replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
48
+ return path.join(qualiaHome(home), `.qualia-auto-report-${safe}.json`);
49
+ }
50
+
51
+ function erpUrl(cfg) {
52
+ const base = (cfg && cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net";
53
+ return base.replace(/\/+$/, "") + "/api/v1/reports";
54
+ }
55
+
56
+ function allocateReportId(cwd) {
57
+ // Sequential QS-REPORT-NN via state.js (the same allocator /qualia-report uses).
58
+ try {
59
+ const r = spawnSync("node", [path.join(__dirname, "state.js"), "next-report-id"], {
60
+ cwd,
61
+ encoding: "utf8",
62
+ timeout: 4000,
63
+ });
64
+ if (r.status === 0 && r.stdout) {
65
+ const parsed = JSON.parse(r.stdout);
66
+ if (parsed && parsed.report_id) return parsed.report_id;
67
+ }
68
+ } catch {}
69
+ return "";
70
+ }
71
+
72
+ // The single decision + action. Returns a small status object; never throws.
73
+ async function maybeAutoReport({ cwd = process.cwd(), home = os.homedir(), env = process.env } = {}) {
74
+ try {
75
+ // Guard 1 — ERP configured. No key / disabled → silent no-op.
76
+ const cfg = readConfig();
77
+ if (cfg && cfg.erp && cfg.erp.enabled === false) return { skipped: "erp-disabled" };
78
+ const apiKey = readApiKey();
79
+ if (!apiKey) return { skipped: "no-key" };
80
+
81
+ // Guard 2 — Qualia project at SHIP time only.
82
+ const tracking = readJson(path.join(cwd, ".planning", "tracking.json"));
83
+ if (!tracking) return { skipped: "no-project" };
84
+ if (String(tracking.status) !== "shipped") return { skipped: "not-shipped" };
85
+
86
+ // Guard 3 — dedupe: one report per shipped (milestone, phase).
87
+ const projectKey =
88
+ tracking.project_id ||
89
+ tracking.project ||
90
+ path.basename(cwd);
91
+ const unit = `${tracking.milestone || 1}:${tracking.phase || 0}:shipped`;
92
+ const mFile = markerFile(home, projectKey);
93
+ const marker = readJson(mFile) || {};
94
+ if (marker.last === unit) return { skipped: "already-reported", unit };
95
+
96
+ // Allocate a sequential client_report_id (the ERP dedupe key).
97
+ const clientReportId = allocateReportId(cwd);
98
+ const idempotencyKey = crypto.randomUUID();
99
+ const payload = buildPayload({
100
+ cwd,
101
+ home,
102
+ env: { ...env, SOURCE: "auto", CLIENT_REPORT_ID: clientReportId },
103
+ });
104
+ const body = JSON.stringify(payload);
105
+ const url = erpUrl(cfg);
106
+
107
+ const result = await postOnce(
108
+ { url, payload: body, idempotency_key: idempotencyKey },
109
+ apiKey,
110
+ );
111
+
112
+ const writeMarker = (extra) => {
113
+ try {
114
+ fs.writeFileSync(
115
+ mFile,
116
+ JSON.stringify({ last: unit, client_report_id: clientReportId, at: new Date().toISOString(), ...extra }, null, 2),
117
+ { mode: 0o600 },
118
+ );
119
+ } catch {}
120
+ };
121
+
122
+ if (result.code === "200") {
123
+ writeMarker({ posted: true });
124
+ return { posted: clientReportId, unit };
125
+ }
126
+
127
+ // Any non-200 → enqueue for the retry queue (session-start drains it).
128
+ // Mark the unit so we don't re-allocate a new id on the next turn; the
129
+ // queued item carries this client_report_id and the ERP dedupes on it.
130
+ try {
131
+ enqueue({
132
+ client_report_id: clientReportId,
133
+ idempotency_key: idempotencyKey,
134
+ url,
135
+ payload: body,
136
+ last_error: result.error ? `network: ${result.error}` : `HTTP ${result.code}`,
137
+ });
138
+ } catch {}
139
+ writeMarker({ queued: true, last_error: result.error || `HTTP ${result.code}` });
140
+ return { queued: clientReportId, unit, error: result.error || `HTTP ${result.code}` };
141
+ } catch (e) {
142
+ // Auto-capture must never break a session.
143
+ return { skipped: "error", error: e && e.message ? e.message : String(e) };
144
+ }
145
+ }
146
+
147
+ module.exports = { maybeAutoReport };
148
+
149
+ if (require.main === module) {
150
+ maybeAutoReport()
151
+ .then((r) => {
152
+ if (process.env.QUALIA_DEBUG) process.stdout.write(JSON.stringify(r) + "\n");
153
+ process.exit(0);
154
+ })
155
+ .catch(() => process.exit(0));
156
+ }
package/bin/erp-retry.js CHANGED
@@ -274,8 +274,10 @@ function actionClear() {
274
274
  log(`queue cleared (backup at ${bak})`);
275
275
  }
276
276
 
277
- // ─── Export for in-process use (qualia-report skill enqueues directly) ──
278
- module.exports = { enqueue, readQueue, writeQueue };
277
+ // ─── Export for in-process use (qualia-report skill enqueues directly;
278
+ // auto-report.js reuses the POST + config/key readers so there is ONE
279
+ // ERP-upload seam, not two). ──
280
+ module.exports = { enqueue, readQueue, writeQueue, postOnce, readApiKey, readConfig };
279
281
 
280
282
  // ─── CLI entrypoint ─────────────────────────────────────
281
283
  if (require.main === module) {
@@ -136,6 +136,11 @@ function buildPayload(options = {}) {
136
136
  notes,
137
137
  submitted_by: env.SUBMITTED_BY || "unknown",
138
138
  submitted_at: submittedAt,
139
+ // B1 — provenance. 'auto' = captured automatically at ship-time (auto-report.js);
140
+ // 'manual' = a deliberate /qualia-report. Defaults to 'manual' so the manual
141
+ // flow is unchanged; auto-report passes SOURCE=auto.
142
+ source: env.SOURCE === "auto" ? "auto" : "manual",
143
+ ...(env.DRY_RUN === "1" ? { dry_run: true } : {}),
139
144
  };
140
145
  }
141
146
 
@@ -85,6 +85,21 @@ function readJson(p) {
85
85
  }
86
86
 
87
87
  try {
88
+ // ── B1 auto-capture: fire-and-forget the ship-time auto-report ─────────
89
+ // Detached subprocess so this hook stays fast (no network here, per its
90
+ // design). auto-report.js guards on status===shipped + a per-shipped-unit
91
+ // dedupe marker, so it's a cheap no-op on every turn except the one right
92
+ // after a ship. Wrapped + unref'd so it never blocks or breaks the session.
93
+ try {
94
+ const { spawn } = require("child_process");
95
+ const child = spawn(
96
+ process.execPath,
97
+ [path.join(__dirname, "..", "bin", "auto-report.js")],
98
+ { cwd: process.cwd(), detached: true, stdio: "ignore" },
99
+ );
100
+ child.unref();
101
+ } catch {}
102
+
88
103
  // ── Skip if too soon since last write ────────────────────
89
104
  const now = Date.now();
90
105
  let lastWrite = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "6.5.0",
3
+ "version": "6.6.0",
4
4
  "description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"
@@ -0,0 +1,158 @@
1
+ #!/bin/bash
2
+ # tests/auto-report.test.sh — B1 framework auto-capture (bin/auto-report.js +
3
+ # the source field on bin/report-payload.js). Verifies the ship-time trigger,
4
+ # the guards, the source tag, dedupe, and fail-soft enqueue — with a stub ERP
5
+ # server so nothing touches a real endpoint.
6
+
7
+ FRAMEWORK_DIR="$(cd "$(dirname "$0")/.." && pwd)"
8
+ NODE="${NODE:-node}"
9
+
10
+ echo "auto-report.test.sh — B1 framework auto-capture"
11
+
12
+ "$NODE" - "$FRAMEWORK_DIR" <<'NODE'
13
+ const path = require("path");
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const http = require("http");
17
+
18
+ const FRAMEWORK_DIR = process.argv[2];
19
+ let pass = 0, fail = 0;
20
+ const ok = (m) => { console.log(" ✓ " + m); pass++; };
21
+ const bad = (m) => { console.log(" ✗ " + m); fail++; };
22
+
23
+ function mktemp(prefix) {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
25
+ }
26
+ function setupProject(tmp, tracking) {
27
+ fs.mkdirSync(path.join(tmp, ".planning"), { recursive: true });
28
+ fs.writeFileSync(path.join(tmp, ".planning", "tracking.json"), JSON.stringify(tracking));
29
+ }
30
+ function setupHome(home, { key = true, erpUrl, enabled } = {}) {
31
+ fs.mkdirSync(home, { recursive: true });
32
+ if (key) fs.writeFileSync(path.join(home, ".erp-api-key"), "qlt_test_key");
33
+ const cfg = { erp: {} };
34
+ if (erpUrl) cfg.erp.url = erpUrl;
35
+ if (enabled === false) cfg.erp.enabled = false;
36
+ fs.writeFileSync(path.join(home, ".qualia-config.json"), JSON.stringify(cfg));
37
+ }
38
+
39
+ // Fresh module instances per case (QUALIA_HOME is read at module load).
40
+ function loadAutoReport(home) {
41
+ process.env.QUALIA_HOME = home;
42
+ const p1 = require.resolve(path.join(FRAMEWORK_DIR, "bin", "auto-report.js"));
43
+ const p2 = require.resolve(path.join(FRAMEWORK_DIR, "bin", "erp-retry.js"));
44
+ delete require.cache[p1];
45
+ delete require.cache[p2];
46
+ return require(p1);
47
+ }
48
+
49
+ (async () => {
50
+ // ── 1. report-payload tags source correctly ───────────────────────────
51
+ {
52
+ const { buildPayload } = require(path.join(FRAMEWORK_DIR, "bin", "report-payload.js"));
53
+ const proj = mktemp("ar-payload-");
54
+ setupProject(proj, { status: "shipped", project_id: "p", phase: 1, total_phases: 1 });
55
+ const auto = buildPayload({ cwd: proj, env: { SOURCE: "auto" } });
56
+ const manual = buildPayload({ cwd: proj, env: {} });
57
+ auto.source === "auto" ? ok("buildPayload: SOURCE=auto -> source 'auto'") : bad(`auto source=${auto.source}`);
58
+ manual.source === "manual" ? ok("buildPayload: default -> source 'manual'") : bad(`default source=${manual.source}`);
59
+ const dry = buildPayload({ cwd: proj, env: { SOURCE: "auto", DRY_RUN: "1" } });
60
+ dry.dry_run === true ? ok("buildPayload: DRY_RUN=1 -> dry_run true") : bad("dry_run not set");
61
+ }
62
+
63
+ // ── 2. guard: no API key -> skip ───────────────────────────────────────
64
+ {
65
+ const home = mktemp("ar-nokey-home-");
66
+ setupHome(home, { key: false });
67
+ const proj = mktemp("ar-nokey-");
68
+ setupProject(proj, { status: "shipped", project_id: "p" });
69
+ const { maybeAutoReport } = loadAutoReport(home);
70
+ const r = await maybeAutoReport({ cwd: proj, home });
71
+ r.skipped === "no-key" ? ok("guard: no ERP key -> skipped no-key") : bad(`expected no-key, got ${JSON.stringify(r)}`);
72
+ }
73
+
74
+ // ── 3. guard: ERP disabled -> skip ─────────────────────────────────────
75
+ {
76
+ const home = mktemp("ar-disabled-home-");
77
+ setupHome(home, { key: true, enabled: false });
78
+ const proj = mktemp("ar-disabled-");
79
+ setupProject(proj, { status: "shipped", project_id: "p" });
80
+ const { maybeAutoReport } = loadAutoReport(home);
81
+ const r = await maybeAutoReport({ cwd: proj, home });
82
+ r.skipped === "erp-disabled" ? ok("guard: ERP disabled -> skipped") : bad(`expected erp-disabled, got ${JSON.stringify(r)}`);
83
+ }
84
+
85
+ // ── 4. guard: status != shipped -> skip ────────────────────────────────
86
+ {
87
+ const home = mktemp("ar-notship-home-");
88
+ setupHome(home, { key: true, erpUrl: "http://127.0.0.1:1" });
89
+ const proj = mktemp("ar-notship-");
90
+ setupProject(proj, { status: "built", project_id: "p" });
91
+ const { maybeAutoReport } = loadAutoReport(home);
92
+ const r = await maybeAutoReport({ cwd: proj, home });
93
+ r.skipped === "not-shipped" ? ok("guard: status=built -> skipped not-shipped") : bad(`expected not-shipped, got ${JSON.stringify(r)}`);
94
+ }
95
+
96
+ // ── 5. ship-time POST: source 'auto' reaches the endpoint + dedupe ─────
97
+ {
98
+ let received = null;
99
+ const server = http.createServer((req, res) => {
100
+ let b = "";
101
+ req.on("data", (c) => (b += c));
102
+ req.on("end", () => {
103
+ try { received = JSON.parse(b); } catch { received = { _raw: b }; }
104
+ res.writeHead(200, { "Content-Type": "application/json" });
105
+ res.end(JSON.stringify({ ok: true, report_id: "QS-REPORT-AUTO" }));
106
+ });
107
+ });
108
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
109
+ const port = server.address().port;
110
+
111
+ const home = mktemp("ar-post-home-");
112
+ setupHome(home, { key: true, erpUrl: `http://127.0.0.1:${port}` });
113
+ const proj = mktemp("ar-post-");
114
+ setupProject(proj, { status: "shipped", project_id: "ship-proj", milestone: 1, phase: 3, total_phases: 4 });
115
+ const { maybeAutoReport } = loadAutoReport(home);
116
+
117
+ const r1 = await maybeAutoReport({ cwd: proj, home });
118
+ (r1.posted !== undefined) ? ok("ship: POST fired (status=shipped)") : bad(`expected posted, got ${JSON.stringify(r1)}`);
119
+ (received && received.source === "auto") ? ok("ship: endpoint received source 'auto'") : bad(`endpoint source=${received && received.source}`);
120
+
121
+ // marker written
122
+ const markerExists = fs.readdirSync(home).some((f) => f.startsWith(".qualia-auto-report-"));
123
+ markerExists ? ok("ship: dedupe marker written") : bad("no dedupe marker file");
124
+
125
+ // second call on the SAME shipped unit -> dedupe skip (no second POST)
126
+ received = null;
127
+ const r2 = await maybeAutoReport({ cwd: proj, home });
128
+ r2.skipped === "already-reported" ? ok("dedupe: 2nd call -> already-reported") : bad(`expected already-reported, got ${JSON.stringify(r2)}`);
129
+ received === null ? ok("dedupe: no 2nd POST sent") : bad("a duplicate POST was sent");
130
+
131
+ server.close();
132
+ }
133
+
134
+ // ── 6. fail-soft: server 500 -> enqueue, no throw ──────────────────────
135
+ {
136
+ const server = http.createServer((req, res) => {
137
+ let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { res.writeHead(500); res.end("nope"); });
138
+ });
139
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
140
+ const port = server.address().port;
141
+ const home = mktemp("ar-fail-home-");
142
+ setupHome(home, { key: true, erpUrl: `http://127.0.0.1:${port}` });
143
+ const proj = mktemp("ar-fail-");
144
+ setupProject(proj, { status: "shipped", project_id: "fail-proj", milestone: 1, phase: 1 });
145
+ const { maybeAutoReport } = loadAutoReport(home);
146
+ let threw = false, r;
147
+ try { r = await maybeAutoReport({ cwd: proj, home }); } catch { threw = true; }
148
+ !threw ? ok("fail-soft: 500 did not throw") : bad("maybeAutoReport threw on 500");
149
+ (r && r.queued !== undefined) ? ok("fail-soft: failed POST enqueued for retry") : bad(`expected queued, got ${JSON.stringify(r)}`);
150
+ const queueFile = path.join(home, ".erp-retry-queue.json");
151
+ fs.existsSync(queueFile) ? ok("fail-soft: retry queue file created") : bad("no retry queue file");
152
+ server.close();
153
+ }
154
+
155
+ console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
156
+ process.exit(fail === 0 ? 0 : 1);
157
+ })();
158
+ NODE
package/tests/run-all.sh CHANGED
@@ -12,6 +12,7 @@ SUITES=(
12
12
  "state"
13
13
  "hooks"
14
14
  "bin"
15
+ "auto-report"
15
16
  "lib"
16
17
  "skills"
17
18
  "refs"