qualia-framework 6.14.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +316 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/batch-plan.js +111 -0
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +2 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/design-tokens.js +131 -0
- package/bin/erp-event.js +177 -0
- package/bin/erp-retry.js +12 -1
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +84 -12
- package/bin/install.js +44 -13
- package/bin/knowledge-flush.js +6 -3
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/recall.js +172 -0
- package/bin/repo-map.js +188 -0
- package/bin/runtime-manifest.js +12 -0
- package/bin/state.js +112 -1
- package/bin/vault-access.js +82 -0
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +180 -0
- package/mcp/memory-mcp/server.js +257 -0
- package/package.json +6 -3
- package/qualia-design/design-dials.md +72 -0
- package/qualia-design/design-reference.md +24 -0
- package/rules/access.md +42 -0
- package/rules/codex-goal.md +28 -26
- package/rules/infrastructure.md +1 -1
- package/skills/qualia/SKILL.md +6 -0
- package/skills/qualia-build/SKILL.md +43 -9
- package/skills/qualia-eval/SKILL.md +83 -0
- package/skills/qualia-feature/SKILL.md +20 -4
- package/skills/qualia-fix/SKILL.md +13 -1
- package/skills/qualia-map/SKILL.md +15 -0
- package/skills/qualia-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +41 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- package/skills/qualia-polish/SKILL.md +3 -2
- package/skills/qualia-recall/SKILL.md +76 -0
- package/skills/qualia-report/SKILL.md +10 -0
- package/skills/qualia-scope/SKILL.md +3 -3
- package/skills/qualia-ship/SKILL.md +34 -4
- package/skills/qualia-update/SKILL.md +4 -0
- package/skills/qualia-verify/SKILL.md +53 -24
- package/templates/DESIGN.md +15 -0
- package/templates/instructions.md +32 -0
- package/templates/journey.md +1 -1
- package/templates/project-discovery.md +30 -23
- package/templates/requirements.md +7 -7
- package/tests/agent-status.test.sh +15 -0
- package/tests/batch-plan.test.sh +56 -0
- package/tests/branch-hygiene.test.sh +93 -0
- package/tests/design-tokens.test.sh +53 -0
- package/tests/erp-event.test.sh +78 -0
- package/tests/eval-runner.test.sh +147 -0
- package/tests/instructions.test.sh +109 -0
- package/tests/last-report.test.sh +156 -0
- package/tests/lib.test.sh +29 -4
- package/tests/project-sync.test.sh +175 -0
- package/tests/recall.test.sh +91 -0
- package/tests/repo-map.test.sh +70 -0
- package/tests/run-all.sh +12 -0
- package/tests/runner.js +363 -33
- package/tests/state.test.sh +92 -0
- package/tests/verify-panel.test.sh +162 -0
- package/tests/wave-plan.test.sh +153 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// last-report.js — surface "where work was left off last time" at session start.
|
|
3
|
+
//
|
|
4
|
+
// WHY: the /qualia router classifies state from state.js, .continue-here.md, and
|
|
5
|
+
// git log — but it ignores .planning/reports/, which is the richest "where we
|
|
6
|
+
// left off" signal a team has. A teammate (or future-you) picking the project
|
|
7
|
+
// back up should instantly see the last session's outcome and next step without
|
|
8
|
+
// opening a file. This extracts a compact digest from the newest report so the
|
|
9
|
+
// router can print it at the TOP of its output when a project is loaded.
|
|
10
|
+
//
|
|
11
|
+
// Reports are written by /qualia-report at .planning/reports/report-{YYYY-MM-DD}.md
|
|
12
|
+
// (the date filename may carry a suffix, e.g. report-2026-05-31-session3.md). The
|
|
13
|
+
// markdown has a "# Session Report — {date}" title, a "**Date:**" line, a
|
|
14
|
+
// "## What Was Done" section (the summary), and a "## Next Steps" section.
|
|
15
|
+
//
|
|
16
|
+
// Read-only. Zero npm dependencies.
|
|
17
|
+
// Exit 0 = a report was found, 1 = none found, 2 = bad input.
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
|
|
22
|
+
// Pull a YYYY-MM-DD from a report filename. Returns null when absent so a
|
|
23
|
+
// misnamed file falls back to mtime ordering rather than poisoning the sort.
|
|
24
|
+
function dateFromName(name) {
|
|
25
|
+
const m = name.match(/(\d{4}-\d{2}-\d{2})/);
|
|
26
|
+
return m ? m[1] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Whole-day difference between two ISO-ish dates (report date vs --now). Floors
|
|
30
|
+
// to a non-negative integer; a future report date (clock skew) reads as 0.
|
|
31
|
+
function ageDays(dateStr, nowIso) {
|
|
32
|
+
if (!dateStr) return null;
|
|
33
|
+
const then = Date.parse(dateStr + "T00:00:00Z");
|
|
34
|
+
const now = nowIso ? Date.parse(nowIso) : Date.now();
|
|
35
|
+
if (Number.isNaN(then) || Number.isNaN(now)) return null;
|
|
36
|
+
return Math.max(0, Math.floor((now - then) / 86400000));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Collapse markdown bullets/bold/whitespace into one tight prose line.
|
|
40
|
+
function flatten(s) {
|
|
41
|
+
return s
|
|
42
|
+
.replace(/^\s+/, "") // leading indentation, so list-marker anchors match
|
|
43
|
+
.replace(/\*\*/g, "")
|
|
44
|
+
.replace(/^[-*]\s+/, "") // unordered bullet
|
|
45
|
+
.replace(/^\d+[.)]\s+/, "") // ordered list marker (1. / 1))
|
|
46
|
+
.replace(/`/g, "")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Lines that carry no signal for a digest (metadata, blank, headings).
|
|
52
|
+
function isNoise(line) {
|
|
53
|
+
const t = line.trim();
|
|
54
|
+
if (!t) return true;
|
|
55
|
+
if (t.startsWith("#")) return true;
|
|
56
|
+
if (/^\*\*[A-Za-z ]+:\*\*/.test(t)) return true; // **Project:** / **Date:** etc.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find the body under a "## {heading}" until the next "## " heading or EOF.
|
|
61
|
+
function sectionBody(lines, headingRe) {
|
|
62
|
+
let i = lines.findIndex((l) => headingRe.test(l.trim()));
|
|
63
|
+
if (i === -1) return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
66
|
+
if (/^##\s/.test(lines[j].trim())) break;
|
|
67
|
+
out.push(lines[j]);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// First meaningful sentence/clause of a section, capped to keep the digest tight.
|
|
73
|
+
function firstMeaningful(bodyLines, cap = 160) {
|
|
74
|
+
for (const raw of bodyLines) {
|
|
75
|
+
if (isNoise(raw)) continue;
|
|
76
|
+
const flat = flatten(raw);
|
|
77
|
+
if (!flat) continue;
|
|
78
|
+
return flat.length > cap ? flat.slice(0, cap - 1).trimEnd() + "…" : flat;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extract the digest from a single report's markdown text.
|
|
84
|
+
function digest(text) {
|
|
85
|
+
const lines = text.split(/\r?\n/);
|
|
86
|
+
|
|
87
|
+
// Date: prefer the explicit **Date:** line, else the title's trailing date.
|
|
88
|
+
let date = null;
|
|
89
|
+
const dateLine = lines.find((l) => /^\*\*Date:\*\*/.test(l.trim()));
|
|
90
|
+
if (dateLine) date = (dateFromName(dateLine) || null);
|
|
91
|
+
if (!date) {
|
|
92
|
+
const title = lines.find((l) => /^#\s+Session Report/.test(l.trim()));
|
|
93
|
+
if (title) date = dateFromName(title);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Summary: first meaningful line of "## What Was Done", else first meaningful
|
|
97
|
+
// line of the whole body (so malformed reports still yield something).
|
|
98
|
+
let summary = firstMeaningful(sectionBody(lines, /^##\s+What Was Done/i));
|
|
99
|
+
if (!summary) summary = firstMeaningful(lines);
|
|
100
|
+
|
|
101
|
+
// Next: first meaningful line of "## Next Steps" / "## Next Step".
|
|
102
|
+
let next = firstMeaningful(sectionBody(lines, /^##\s+Next Steps?/i));
|
|
103
|
+
|
|
104
|
+
return { date, summary, next };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Library: find the newest report under root and return its digest object.
|
|
108
|
+
// { found, file, date, summary, next, age_days }
|
|
109
|
+
function latestReport(root, opts = {}) {
|
|
110
|
+
const dir = path.join(path.resolve(root || process.cwd()), ".planning", "reports");
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = fs.readdirSync(dir);
|
|
114
|
+
} catch {
|
|
115
|
+
return { found: false, file: null, date: null, summary: "", next: "", age_days: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const reports = entries
|
|
119
|
+
.filter((f) => /^report-.*\.md$/.test(f))
|
|
120
|
+
.map((f) => {
|
|
121
|
+
const full = path.join(dir, f);
|
|
122
|
+
let mtime = 0;
|
|
123
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch {}
|
|
124
|
+
return { file: f, full, date: dateFromName(f), mtime };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (reports.length === 0) {
|
|
128
|
+
return { found: false, file: null, date: null, summary: "", next: "", age_days: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Newest by filename date desc; tiebreak (same/absent date) by mtime desc.
|
|
132
|
+
reports.sort((a, b) => {
|
|
133
|
+
const ad = a.date || "";
|
|
134
|
+
const bd = b.date || "";
|
|
135
|
+
if (ad !== bd) return bd.localeCompare(ad);
|
|
136
|
+
return b.mtime - a.mtime;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const top = reports[0];
|
|
140
|
+
let text = "";
|
|
141
|
+
try { text = fs.readFileSync(top.full, "utf8"); } catch {}
|
|
142
|
+
const d = digest(text);
|
|
143
|
+
const date = d.date || top.date || null;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
found: true,
|
|
147
|
+
file: top.file,
|
|
148
|
+
date,
|
|
149
|
+
summary: d.summary,
|
|
150
|
+
next: d.next,
|
|
151
|
+
age_days: ageDays(date, opts.now),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
156
|
+
function parseArgs(argv) {
|
|
157
|
+
const args = {};
|
|
158
|
+
for (let i = 2; i < argv.length; i++) {
|
|
159
|
+
const a = argv[i];
|
|
160
|
+
if (a === "--json") args.json = true;
|
|
161
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
162
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
163
|
+
else if (a === "--now") args.now = argv[++i]; // ISO; tests inject determinism
|
|
164
|
+
else if (a.startsWith("--now=")) args.now = a.slice(6);
|
|
165
|
+
else { args._bad = a; }
|
|
166
|
+
}
|
|
167
|
+
return args;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function main(argv) {
|
|
171
|
+
const args = parseArgs(argv);
|
|
172
|
+
if (args._bad) {
|
|
173
|
+
console.error(`last-report: unknown argument '${args._bad}'`);
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = latestReport(args.cwd, { now: args.now });
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.error(`last-report: ${e.message || e}`);
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (args.json) {
|
|
186
|
+
console.log(JSON.stringify(result, null, 2));
|
|
187
|
+
return result.found ? 0 : 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!result.found) {
|
|
191
|
+
console.log("No session reports yet — nothing to surface.");
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const age = result.age_days == null ? "?" : result.age_days;
|
|
196
|
+
const summary = result.summary || "(no summary in report)";
|
|
197
|
+
let line = `Last session (${result.date || "undated"}, ${age}d ago): ${summary}`;
|
|
198
|
+
if (result.next) line += ` → next: ${result.next}`;
|
|
199
|
+
console.log(line);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { latestReport };
|
|
204
|
+
|
|
205
|
+
if (require.main === module) {
|
|
206
|
+
process.exit(main(process.argv));
|
|
207
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// project-sync.js — the single Framework→ERP project-sync snapshot.
|
|
3
|
+
//
|
|
4
|
+
// WHY: the ERP already ingests per-session reports (report-payload.js →
|
|
5
|
+
// /api/v1/reports) and a lean progress rollup (project-snapshot.js →
|
|
6
|
+
// /api/v1/project-snapshots). What was missing is ONE complete payload the ERP
|
|
7
|
+
// can use to RECONCILE a project's milestones, phases, tasks, and reports in a
|
|
8
|
+
// single read — including REQ-ID completion per milestone and the trunk/merge
|
|
9
|
+
// model so the ERP understands how feature branches become deployed main.
|
|
10
|
+
//
|
|
11
|
+
// This composes (never duplicates) the existing snapshot:
|
|
12
|
+
// - bin/project-snapshot.js → identity, current position, journey, lifetime,
|
|
13
|
+
// quality (harness_eval), progress_percent
|
|
14
|
+
// - bin/state.js → readMilestoneRequirements (REQ-ID completion),
|
|
15
|
+
// parseStateMd (roadmap phase rows)
|
|
16
|
+
// and ADDS the B2-specific reconciliation surface on top:
|
|
17
|
+
// - milestones[] enriched with REQ-ID completion (total/complete/incomplete),
|
|
18
|
+
// phases count, tasks_completed, deployed_url, status (closed/current/future)
|
|
19
|
+
// - task_rollup (lifetime tasks/build/deploy + current-phase gap_cycles)
|
|
20
|
+
// - accountability { offroad_count, offroad[] } — off-milestone work tally
|
|
21
|
+
// - integration { model: "trunk", ... } — the PR/merge model the ERP must grok
|
|
22
|
+
// - schema_version so the ERP can evolve the contract safely
|
|
23
|
+
//
|
|
24
|
+
// Read-only. Zero npm dependencies. `--json` (or no flag) prints the object;
|
|
25
|
+
// library buildProjectSync() returns it. Exit 0 = built, 2 = no .planning/.
|
|
26
|
+
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const os = require("os");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
|
|
31
|
+
const snapshotLib = require("./project-snapshot.js");
|
|
32
|
+
|
|
33
|
+
// project-sync's contract version. Bump when the SHAPE changes so the ERP can
|
|
34
|
+
// branch on it. (Distinct from project-snapshot's snapshot_version.)
|
|
35
|
+
const SCHEMA_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
function readJson(file, fallback = {}) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readText(file, fallback = "") {
|
|
46
|
+
try {
|
|
47
|
+
return fs.readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// REQ-ID completion for one milestone, read from .planning/REQUIREMENTS.md.
|
|
54
|
+
// Mirrors state.js readMilestoneRequirements() exactly (rows like
|
|
55
|
+
// `| CORE-01 | M2: Name | Phase 3 | Complete |`) but is parameterized on the
|
|
56
|
+
// planning dir so tests can point it at a temp fixture. tracked=false when
|
|
57
|
+
// REQUIREMENTS.md is absent or has no rows for the milestone (→ ERP skips the
|
|
58
|
+
// REQ gate for that milestone, can't reconcile what wasn't declared).
|
|
59
|
+
function milestoneRequirements(planningDir, milestoneNum) {
|
|
60
|
+
const md = readText(path.join(planningDir, "REQUIREMENTS.md"), null);
|
|
61
|
+
if (md == null) return { tracked: false, total: 0, complete: 0, incomplete: [] };
|
|
62
|
+
const num = parseInt(milestoneNum, 10);
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const line of md.split(/\r?\n/)) {
|
|
65
|
+
if (!/^\s*\|/.test(line)) continue;
|
|
66
|
+
const cells = line
|
|
67
|
+
.split("|")
|
|
68
|
+
.map((c) => c.trim())
|
|
69
|
+
.filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
|
|
70
|
+
if (cells.length < 4) continue;
|
|
71
|
+
const [id, milestone, , status] = cells;
|
|
72
|
+
if (!/^[A-Z]+-\d+$/.test(id)) continue; // skip header + non-REQ rows
|
|
73
|
+
const m = milestone.match(/M(\d+)\b/);
|
|
74
|
+
if (!m || parseInt(m[1], 10) !== num) continue;
|
|
75
|
+
rows.push({ id, status: status || "" });
|
|
76
|
+
}
|
|
77
|
+
const incomplete = rows
|
|
78
|
+
.filter((r) => r.status.trim().toLowerCase() !== "complete")
|
|
79
|
+
.map((r) => ({ id: r.id, status: r.status }));
|
|
80
|
+
return {
|
|
81
|
+
tracked: rows.length > 0,
|
|
82
|
+
total: rows.length,
|
|
83
|
+
complete: rows.length - incomplete.length,
|
|
84
|
+
incomplete,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Count phase rows declared for the project from STATE.md's roadmap table.
|
|
89
|
+
// Used only as a fallback when a closed milestone summary carries no phase
|
|
90
|
+
// count of its own. Best-effort: returns 0 when STATE.md is absent/malformed.
|
|
91
|
+
function roadmapPhaseCount(planningDir) {
|
|
92
|
+
const md = readText(path.join(planningDir, "STATE.md"), "");
|
|
93
|
+
const m = md.match(/\| # \| Phase \| Goal \| Status \|\n\|[-|]+\|\n([\s\S]*?)(?=\n##|\n$|$)/);
|
|
94
|
+
if (!m) return 0;
|
|
95
|
+
return m[1]
|
|
96
|
+
.trim()
|
|
97
|
+
.split("\n")
|
|
98
|
+
.filter((row) => row.split("|").map((c) => c.trim()).filter(Boolean).length >= 4).length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Build the unified milestones[] for reconciliation. Source of truth ordering:
|
|
102
|
+
// 1. JOURNEY.md milestones (the full planned arc) — gives num + name + total.
|
|
103
|
+
// 2. tracking.milestones[] (closed summaries) — gives closed_at + per-milestone
|
|
104
|
+
// phases_completed/tasks_completed + deployed_url.
|
|
105
|
+
// 3. tracking.milestone (current) — marks the active one.
|
|
106
|
+
// 4. REQUIREMENTS.md — REQ-ID completion per milestone.
|
|
107
|
+
// Each entry's `status` is closed | current | future so the ERP can reconcile
|
|
108
|
+
// which milestones to mark done, in-progress, or pending.
|
|
109
|
+
function buildMilestones(options) {
|
|
110
|
+
const { journey, closed, currentNum, planningDir } = options;
|
|
111
|
+
const closedByNum = new Map();
|
|
112
|
+
for (const m of closed) {
|
|
113
|
+
const n = Number(m && m.num);
|
|
114
|
+
if (Number.isFinite(n)) closedByNum.set(n, m);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The full set of milestone numbers we know about: journey ∪ closed ∪ current.
|
|
118
|
+
const nums = new Set();
|
|
119
|
+
for (const m of journey) nums.add(Number(m.num));
|
|
120
|
+
for (const n of closedByNum.keys()) nums.add(n);
|
|
121
|
+
if (Number.isFinite(currentNum)) nums.add(currentNum);
|
|
122
|
+
const ordered = [...nums].filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
|
123
|
+
|
|
124
|
+
const nameByNum = new Map(journey.map((m) => [Number(m.num), m.name]));
|
|
125
|
+
|
|
126
|
+
return ordered.map((num) => {
|
|
127
|
+
const closedSummary = closedByNum.get(num) || null;
|
|
128
|
+
const status = closedSummary
|
|
129
|
+
? "closed"
|
|
130
|
+
: num === currentNum
|
|
131
|
+
? "current"
|
|
132
|
+
: "future";
|
|
133
|
+
const req = milestoneRequirements(planningDir, num);
|
|
134
|
+
const entry = {
|
|
135
|
+
num,
|
|
136
|
+
name:
|
|
137
|
+
(closedSummary && closedSummary.name) ||
|
|
138
|
+
nameByNum.get(num) ||
|
|
139
|
+
`Milestone ${num}`,
|
|
140
|
+
status,
|
|
141
|
+
phases: Number(
|
|
142
|
+
(closedSummary && (closedSummary.phases_completed != null
|
|
143
|
+
? closedSummary.phases_completed
|
|
144
|
+
: closedSummary.phases)) || 0
|
|
145
|
+
),
|
|
146
|
+
tasks_completed: Number(
|
|
147
|
+
(closedSummary && closedSummary.tasks_completed) || 0
|
|
148
|
+
),
|
|
149
|
+
requirements: {
|
|
150
|
+
tracked: req.tracked,
|
|
151
|
+
total: req.total,
|
|
152
|
+
complete: req.complete,
|
|
153
|
+
incomplete: req.incomplete,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
if (closedSummary && closedSummary.closed_at) entry.closed_at = closedSummary.closed_at;
|
|
157
|
+
const deployedUrl = (closedSummary && closedSummary.deployed_url) || "";
|
|
158
|
+
if (deployedUrl) entry.deployed_url = deployedUrl;
|
|
159
|
+
return entry;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildProjectSync(options = {}) {
|
|
164
|
+
const cwd = options.cwd || process.cwd();
|
|
165
|
+
const home = options.home || os.homedir();
|
|
166
|
+
const planning = options.planningDir || path.join(cwd, ".planning");
|
|
167
|
+
const now = options.now || new Date().toISOString();
|
|
168
|
+
|
|
169
|
+
// Compose the lean dashboard snapshot — reuse its identity/current/journey/
|
|
170
|
+
// lifetime/quality blocks verbatim instead of re-deriving them here.
|
|
171
|
+
const snapshot = snapshotLib.buildSnapshot({
|
|
172
|
+
cwd,
|
|
173
|
+
home,
|
|
174
|
+
planningDir: planning,
|
|
175
|
+
qualiaHome: options.qualiaHome,
|
|
176
|
+
now,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const tracking = readJson(path.join(planning, "tracking.json"), {});
|
|
180
|
+
const journeyText = readText(path.join(planning, "JOURNEY.md"), "");
|
|
181
|
+
const journey = snapshotLib.parseJourneyMilestones(journeyText);
|
|
182
|
+
const closed = Array.isArray(tracking.milestones) ? tracking.milestones : [];
|
|
183
|
+
const currentNum = Number(tracking.milestone || snapshot.current.milestone || 1);
|
|
184
|
+
const lifetime = snapshot.lifetime;
|
|
185
|
+
|
|
186
|
+
const milestones = buildMilestones({ journey, closed, currentNum, planningDir: planning });
|
|
187
|
+
// Fill a current/future milestone's phase count from the roadmap when it has
|
|
188
|
+
// none of its own (closed summaries already carry phases_completed).
|
|
189
|
+
const roadmapPhases = roadmapPhaseCount(planning) || Number(tracking.total_phases || 0);
|
|
190
|
+
for (const m of milestones) {
|
|
191
|
+
if (m.status === "current" && !m.phases) m.phases = roadmapPhases;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const offroad = Array.isArray(tracking.offroad) ? tracking.offroad : [];
|
|
195
|
+
const offroadCount = Number(
|
|
196
|
+
(tracking.lifetime && tracking.lifetime.offroad_count) || offroad.length || 0
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
schema_version: SCHEMA_VERSION,
|
|
201
|
+
generated_at: now,
|
|
202
|
+
source: "qualia-framework",
|
|
203
|
+
payload: "project-sync",
|
|
204
|
+
framework_version: snapshot.framework_version,
|
|
205
|
+
identifiers: snapshot.identifiers,
|
|
206
|
+
project: {
|
|
207
|
+
...snapshot.project,
|
|
208
|
+
lifecycle: tracking.lifecycle || "build",
|
|
209
|
+
...(tracking.launched_at ? { launched_at: tracking.launched_at } : {}),
|
|
210
|
+
...(tracking.launch_source ? { launch_source: tracking.launch_source } : {}),
|
|
211
|
+
},
|
|
212
|
+
current: snapshot.current,
|
|
213
|
+
quality: snapshot.quality,
|
|
214
|
+
// Full milestone arc enriched with REQ-ID completion — the spine of ERP
|
|
215
|
+
// reconciliation. journey.total_milestones rides along for the denominator.
|
|
216
|
+
total_milestones: snapshot.journey.total_milestones,
|
|
217
|
+
milestones,
|
|
218
|
+
// Cumulative + current task accounting so the ERP can roll tasks up without
|
|
219
|
+
// replaying every report.
|
|
220
|
+
task_rollup: {
|
|
221
|
+
tasks_completed: lifetime.tasks_completed,
|
|
222
|
+
phases_completed: lifetime.phases_completed,
|
|
223
|
+
milestones_completed: lifetime.milestones_completed,
|
|
224
|
+
total_phases_all_milestones: lifetime.total_phases,
|
|
225
|
+
build_count: lifetime.build_count,
|
|
226
|
+
deploy_count: lifetime.deploy_count,
|
|
227
|
+
current_phase_gap_cycles: snapshot.current.gap_cycles,
|
|
228
|
+
},
|
|
229
|
+
// Accountability: off-milestone work the OWNER + ERP should see (mirrors the
|
|
230
|
+
// branch-guard main-push tally — drift is counted, not hidden).
|
|
231
|
+
accountability: {
|
|
232
|
+
offroad_count: offroadCount,
|
|
233
|
+
offroad: offroad.slice(-10),
|
|
234
|
+
},
|
|
235
|
+
// The PR/merge model the ERP must understand to map branch→main→deploy:
|
|
236
|
+
// feature branches integrate to main at /qualia-ship (trunk model); pushes
|
|
237
|
+
// to a protected branch are recorded for accountability via branch-guard's
|
|
238
|
+
// employee_main_push events (POST /api/v1/policy-events; the local journal
|
|
239
|
+
// lives in the install home, referenced not read here).
|
|
240
|
+
integration: {
|
|
241
|
+
model: "trunk",
|
|
242
|
+
integrates_at: "/qualia-ship",
|
|
243
|
+
protected_branches: ["main", "master"],
|
|
244
|
+
main_push_event_type: "employee_main_push",
|
|
245
|
+
main_push_events_path: "~/.claude/.main-push-events.json",
|
|
246
|
+
note:
|
|
247
|
+
"Feature branches integrate to main at /qualia-ship (then deploy). " +
|
|
248
|
+
"Direct pushes to a protected branch are allowed but recorded as " +
|
|
249
|
+
"employee_main_push policy events for per-employee accountability.",
|
|
250
|
+
},
|
|
251
|
+
timestamps: snapshot.timestamps,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writeProjectSync(sync, options = {}) {
|
|
256
|
+
const cwd = options.cwd || process.cwd();
|
|
257
|
+
const planning = options.planningDir || path.join(cwd, ".planning");
|
|
258
|
+
const outDir = options.outDir || path.join(planning, "snapshots");
|
|
259
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
260
|
+
const stamp = sync.generated_at.replace(/[:.]/g, "-");
|
|
261
|
+
const file = path.join(outDir, `project-sync-${stamp}.json`);
|
|
262
|
+
fs.writeFileSync(file, `${JSON.stringify(sync, null, 2)}\n`);
|
|
263
|
+
return file;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
267
|
+
function parseArgs(argv) {
|
|
268
|
+
const args = { cwd: null };
|
|
269
|
+
for (let i = 2; i < argv.length; i++) {
|
|
270
|
+
const a = argv[i];
|
|
271
|
+
if (a === "--json") args.json = true;
|
|
272
|
+
else if (a === "--write") args.write = true;
|
|
273
|
+
else if (a === "--pretty") args.pretty = true;
|
|
274
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
275
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
276
|
+
else if (a === "--now") args.now = argv[++i];
|
|
277
|
+
else if (a.startsWith("--now=")) args.now = a.slice(6);
|
|
278
|
+
}
|
|
279
|
+
return args;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function main(argv) {
|
|
283
|
+
const args = parseArgs(argv);
|
|
284
|
+
const cwd = args.cwd || process.cwd();
|
|
285
|
+
const planning = path.join(cwd, ".planning");
|
|
286
|
+
if (!fs.existsSync(planning)) {
|
|
287
|
+
console.error("project-sync: no .planning/ found — run /qualia-new to start.");
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
const sync = buildProjectSync({ cwd, now: args.now });
|
|
291
|
+
if (args.write) {
|
|
292
|
+
const file = writeProjectSync(sync, { cwd });
|
|
293
|
+
process.stdout.write(`${file}\n`);
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
process.stdout.write(`${JSON.stringify(sync, null, args.pretty || args.json ? 2 : 0)}\n`);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = {
|
|
301
|
+
buildProjectSync,
|
|
302
|
+
buildMilestones,
|
|
303
|
+
milestoneRequirements,
|
|
304
|
+
writeProjectSync,
|
|
305
|
+
SCHEMA_VERSION,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (require.main === module) {
|
|
309
|
+
try {
|
|
310
|
+
process.exit(main(process.argv));
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(`project-sync failed: ${error.message}`);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
package/bin/recall.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// recall.js — the read side of Qualia memory. Unified recall across the two
|
|
3
|
+
// memory stores so an agent (or /qualia-recall) gets ONE answer, not two:
|
|
4
|
+
//
|
|
5
|
+
// 1. knowledge layer — ~/.claude/knowledge/ (via knowledge.js, the canonical
|
|
6
|
+
// loader; we delegate so new files stay discoverable)
|
|
7
|
+
// 2. qualia-memory — QUALIA_MEMORY_ROOT/wiki, the Obsidian LLM Wiki, the
|
|
8
|
+
// team's curated cross-project lessons
|
|
9
|
+
//
|
|
10
|
+
// Symmetric counterpart to /qualia-learn (write). Zero deps — shells out to
|
|
11
|
+
// knowledge.js and grep, same posture as state.js / memory-mcp/server.js.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// recall.js <query...> # human digest across both stores
|
|
15
|
+
// recall.js <query> --json # machine output
|
|
16
|
+
// recall.js <query> --scope knowledge # one store only (knowledge | vault | all)
|
|
17
|
+
// recall.js <query> --max 20 # cap hits per store (default 50)
|
|
18
|
+
//
|
|
19
|
+
// Exit: 0 = ran (even with zero hits), 2 = bad invocation.
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const os = require("os");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
const { spawnSync } = require("child_process");
|
|
25
|
+
const { resolveRole, loadDenyMatchers, isDenied } = require("./vault-access.js");
|
|
26
|
+
|
|
27
|
+
// ─── Store resolution ───────────────────────────────────────────────────────
|
|
28
|
+
// Mirror knowledge.js's home resolution so a temp QUALIA_HOME (tests) lines up.
|
|
29
|
+
function qualiaHome() {
|
|
30
|
+
if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
|
|
31
|
+
const parent = path.basename(path.dirname(__dirname));
|
|
32
|
+
if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
|
|
33
|
+
return path.join(os.homedir(), ".claude");
|
|
34
|
+
}
|
|
35
|
+
const KNOWLEDGE_JS = path.join(__dirname, "knowledge.js");
|
|
36
|
+
const VAULT_ROOT = process.env.QUALIA_MEMORY_ROOT || path.join(os.homedir(), "qualia-memory");
|
|
37
|
+
const WIKI = path.join(VAULT_ROOT, "wiki");
|
|
38
|
+
|
|
39
|
+
// Vault access control lives in vault-access.js (shared with the memory MCP) so
|
|
40
|
+
// the OWNER_ONLY / CONDITIONAL rule has exactly one implementation.
|
|
41
|
+
|
|
42
|
+
// ─── Knowledge layer (delegate to knowledge.js — single source of truth) ─────
|
|
43
|
+
// knowledge.js prints `relpath:line: text` per hit, or a `(no matches…)` /
|
|
44
|
+
// `(knowledge layer not initialized)` sentinel line. We parse the hit shape and
|
|
45
|
+
// drop the sentinels.
|
|
46
|
+
function searchKnowledge(query, max) {
|
|
47
|
+
const r = spawnSync(process.env.NODE || "node", [KNOWLEDGE_JS, "search", query], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
timeout: 10000,
|
|
50
|
+
});
|
|
51
|
+
if (r.status !== 0 && !r.stdout) return [];
|
|
52
|
+
const hits = [];
|
|
53
|
+
for (const line of (r.stdout || "").split("\n")) {
|
|
54
|
+
const m = line.match(/^(.+?):(\d+):\s?(.*)$/);
|
|
55
|
+
if (!m) continue; // sentinel or blank
|
|
56
|
+
hits.push({ store: "knowledge", file: m[1], line: Number(m[2]), snippet: m[3].trim() });
|
|
57
|
+
if (hits.length >= max) break;
|
|
58
|
+
}
|
|
59
|
+
return hits;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Vault (grep over wiki — mirrors mcp/memory-mcp/server.js) ───────────────
|
|
63
|
+
function searchVault(query, max, role) {
|
|
64
|
+
if (!fs.existsSync(WIKI)) return [];
|
|
65
|
+
const r = spawnSync(
|
|
66
|
+
"grep",
|
|
67
|
+
["-rniF", "--include=*.md", "--include=*.txt", "--include=*.canvas", "--include=*.base", "--", query, WIKI],
|
|
68
|
+
{ encoding: "utf8", timeout: 10000 }
|
|
69
|
+
);
|
|
70
|
+
// grep exit 1 = no matches (not an error); >1 = real error.
|
|
71
|
+
if (r.status !== 0 && r.status !== 1) return [];
|
|
72
|
+
// Non-OWNER roles get OWNER_ONLY / CONDITIONAL vault paths filtered out.
|
|
73
|
+
const matchers = role === "OWNER" ? null : loadDenyMatchers(WIKI);
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const line of (r.stdout || "").split("\n")) {
|
|
76
|
+
if (!line) continue;
|
|
77
|
+
const m = line.match(/^(.+?):(\d+):(.*)$/);
|
|
78
|
+
if (!m) continue;
|
|
79
|
+
const rel = path.relative(WIKI, m[1]) || path.basename(m[1]);
|
|
80
|
+
// Compare against VAULT-ROOT-relative path, the manifest's frame of reference.
|
|
81
|
+
if (matchers && isDenied(path.join("wiki", rel).split(path.sep).join("/"), matchers)) continue;
|
|
82
|
+
hits.push({ store: "vault", file: rel, line: Number(m[2]), snippet: m[3].trim() });
|
|
83
|
+
if (hits.length >= max) break;
|
|
84
|
+
}
|
|
85
|
+
return hits;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Rank: group by file, busiest file first, lines in order within a file ───
|
|
89
|
+
function rankByFile(hits) {
|
|
90
|
+
const byFile = new Map();
|
|
91
|
+
for (const h of hits) {
|
|
92
|
+
if (!byFile.has(h.file)) byFile.set(h.file, []);
|
|
93
|
+
byFile.get(h.file).push(h);
|
|
94
|
+
}
|
|
95
|
+
return [...byFile.entries()]
|
|
96
|
+
.map(([file, hs]) => ({ file, count: hs.length, hits: hs.sort((a, b) => a.line - b.line) }))
|
|
97
|
+
.sort((a, b) => b.count - a.count);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
101
|
+
function parseArgs(argv) {
|
|
102
|
+
const opts = { json: false, scope: "all", max: 50, terms: [] };
|
|
103
|
+
for (let i = 0; i < argv.length; i++) {
|
|
104
|
+
const a = argv[i];
|
|
105
|
+
if (a === "--json") opts.json = true;
|
|
106
|
+
else if (a === "--scope") opts.scope = (argv[++i] || "").toLowerCase();
|
|
107
|
+
else if (a === "--max") opts.max = Math.max(1, parseInt(argv[++i], 10) || 50);
|
|
108
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
109
|
+
else opts.terms.push(a);
|
|
110
|
+
}
|
|
111
|
+
return opts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function usage() {
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
"recall.js — unified recall across the knowledge layer + qualia-memory vault\n\n" +
|
|
117
|
+
" recall.js <query...> [--scope all|knowledge|vault] [--json] [--max N]\n"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function main() {
|
|
122
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
123
|
+
if (opts.help) {
|
|
124
|
+
usage();
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
const query = opts.terms.join(" ").trim();
|
|
128
|
+
if (!query) {
|
|
129
|
+
usage();
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
if (!["all", "knowledge", "vault"].includes(opts.scope)) {
|
|
133
|
+
process.stderr.write(`recall.js: invalid --scope '${opts.scope}' (use all|knowledge|vault)\n`);
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const role = resolveRole(qualiaHome());
|
|
138
|
+
const knowledge = opts.scope === "vault" ? [] : searchKnowledge(query, opts.max);
|
|
139
|
+
const vault = opts.scope === "knowledge" ? [] : searchVault(query, opts.max, role);
|
|
140
|
+
const total = knowledge.length + vault.length;
|
|
141
|
+
|
|
142
|
+
if (opts.json) {
|
|
143
|
+
console.log(
|
|
144
|
+
JSON.stringify(
|
|
145
|
+
{ query, scope: opts.scope, role, total, knowledge: rankByFile(knowledge), vault: rankByFile(vault) },
|
|
146
|
+
null,
|
|
147
|
+
2
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Human digest.
|
|
154
|
+
console.log(`recall "${query}" — ${total} hit${total === 1 ? "" : "s"} (knowledge: ${knowledge.length}, vault: ${vault.length})${role !== "OWNER" ? ` · role ${role}: OWNER-only vault paths hidden` : ""}`);
|
|
155
|
+
const section = (label, hits) => {
|
|
156
|
+
if (!hits.length) return;
|
|
157
|
+
console.log(`\n${label}`);
|
|
158
|
+
for (const grp of rankByFile(hits)) {
|
|
159
|
+
console.log(` ${grp.file} (${grp.count})`);
|
|
160
|
+
for (const h of grp.hits.slice(0, 5)) {
|
|
161
|
+
const snip = h.snippet.length > 120 ? h.snippet.slice(0, 117) + "…" : h.snippet;
|
|
162
|
+
console.log(` L${h.line}: ${snip}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
section("Knowledge layer (~/.claude/knowledge/)", knowledge);
|
|
167
|
+
section("Vault (qualia-memory/wiki/)", vault);
|
|
168
|
+
if (total === 0) console.log("\n(no matches — try a broader term, or check the vault/knowledge layer exists)");
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main();
|