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
package/bin/repo-map.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// repo-map.js — a cheap, deterministic symbol map of a codebase. The zero-dep
|
|
3
|
+
// answer to Aider's tree-sitter repo-map (R18): give /qualia-map (and any agent
|
|
4
|
+
// onboarding to a brownfield repo) the STRUCTURE — every file's top-level
|
|
5
|
+
// symbols — without reading whole files. Grounds the scan; cuts token cost.
|
|
6
|
+
//
|
|
7
|
+
// Not a parser: language-aware regexes over top-level (column-0) declarations.
|
|
8
|
+
// Good enough to answer "what's in this repo and where" — exports, functions,
|
|
9
|
+
// classes, types — ranked by symbol density so the busiest files surface first.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// repo-map.js [dir] # human tree (default cwd)
|
|
13
|
+
// repo-map.js [dir] --json # machine output
|
|
14
|
+
// repo-map.js [dir] --max-files N # cap files shown (default 60)
|
|
15
|
+
//
|
|
16
|
+
// Exit: 0 = ran, 2 = bad invocation / dir missing. Zero deps.
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
const IGNORE_DIRS = new Set([
|
|
22
|
+
".git", "node_modules", "dist", "build", ".next", "out", "coverage", "vendor",
|
|
23
|
+
".venv", "venv", "__pycache__", ".turbo", ".cache", "target", "bin/obj",
|
|
24
|
+
".pytest_cache", ".mypy_cache", ".gradle", "Pods", ".terraform",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Per-extension symbol extractors. Each regex captures the symbol name in a
|
|
28
|
+
// named-or-numbered group; `kind` labels it. Anchored to line start (top-level).
|
|
29
|
+
const EXTRACTORS = {
|
|
30
|
+
js: jsLike, jsx: jsLike, ts: jsLike, tsx: jsLike, mjs: jsLike, cjs: jsLike,
|
|
31
|
+
py: py, go: go, rs: rs, rb: rb, java: java, php: php,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function scanLines(content, rules) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
for (const { re, kind } of rules) {
|
|
40
|
+
const m = line.match(re);
|
|
41
|
+
if (m) {
|
|
42
|
+
out.push({ kind, name: m[m.length - 1], line: i + 1 });
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function jsLike(c) {
|
|
51
|
+
return scanLines(c, [
|
|
52
|
+
{ re: /^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)/, kind: "fn" },
|
|
53
|
+
{ re: /^export\s+(?:default\s+)?class\s+(\w+)/, kind: "class" },
|
|
54
|
+
{ re: /^export\s+(?:abstract\s+)?(?:interface|type|enum)\s+(\w+)/, kind: "type" },
|
|
55
|
+
{ re: /^export\s+(?:const|let|var)\s+(\w+)/, kind: "const" },
|
|
56
|
+
{ re: /^(?:async\s+)?function\s+(\w+)/, kind: "fn" },
|
|
57
|
+
{ re: /^class\s+(\w+)/, kind: "class" },
|
|
58
|
+
{ re: /^(?:export\s+)?default\s+class\s+(\w+)/, kind: "class" },
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
function py(c) {
|
|
62
|
+
return scanLines(c, [
|
|
63
|
+
{ re: /^(?:async\s+)?def\s+(\w+)/, kind: "fn" },
|
|
64
|
+
{ re: /^class\s+(\w+)/, kind: "class" },
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
function go(c) {
|
|
68
|
+
return scanLines(c, [
|
|
69
|
+
{ re: /^func\s+\([^)]*\)\s+(\w+)/, kind: "method" },
|
|
70
|
+
{ re: /^func\s+(\w+)/, kind: "fn" },
|
|
71
|
+
{ re: /^type\s+(\w+)/, kind: "type" },
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
function rs(c) {
|
|
75
|
+
return scanLines(c, [
|
|
76
|
+
{ re: /^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/, kind: "fn" },
|
|
77
|
+
{ re: /^(?:pub\s+)?struct\s+(\w+)/, kind: "struct" },
|
|
78
|
+
{ re: /^(?:pub\s+)?enum\s+(\w+)/, kind: "enum" },
|
|
79
|
+
{ re: /^(?:pub\s+)?trait\s+(\w+)/, kind: "trait" },
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
function rb(c) {
|
|
83
|
+
return scanLines(c, [
|
|
84
|
+
{ re: /^\s*def\s+([\w.?!]+)/, kind: "fn" },
|
|
85
|
+
{ re: /^\s*class\s+(\w+)/, kind: "class" },
|
|
86
|
+
{ re: /^\s*module\s+(\w+)/, kind: "module" },
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
function java(c) {
|
|
90
|
+
return scanLines(c, [
|
|
91
|
+
{ re: /^\s*(?:public|private|protected)?\s*(?:abstract\s+)?class\s+(\w+)/, kind: "class" },
|
|
92
|
+
{ re: /^\s*(?:public|private|protected)\s+(?:static\s+)?(?:interface|enum)\s+(\w+)/, kind: "type" },
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
function php(c) {
|
|
96
|
+
return scanLines(c, [
|
|
97
|
+
{ re: /^\s*(?:abstract\s+)?class\s+(\w+)/, kind: "class" },
|
|
98
|
+
{ re: /^\s*function\s+(\w+)/, kind: "fn" },
|
|
99
|
+
{ re: /^\s*(?:interface|trait)\s+(\w+)/, kind: "type" },
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walk(root, max) {
|
|
104
|
+
const results = [];
|
|
105
|
+
const stack = [root];
|
|
106
|
+
while (stack.length) {
|
|
107
|
+
const dir = stack.pop();
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
for (const e of entries) {
|
|
115
|
+
if (e.name.startsWith(".") && e.name !== ".") {
|
|
116
|
+
if (e.isDirectory() && IGNORE_DIRS.has(e.name)) continue;
|
|
117
|
+
}
|
|
118
|
+
const full = path.join(dir, e.name);
|
|
119
|
+
if (e.isDirectory()) {
|
|
120
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
121
|
+
stack.push(full);
|
|
122
|
+
} else if (e.isFile()) {
|
|
123
|
+
const ext = path.extname(e.name).slice(1).toLowerCase();
|
|
124
|
+
const extract = EXTRACTORS[ext];
|
|
125
|
+
if (!extract) continue;
|
|
126
|
+
let content;
|
|
127
|
+
try {
|
|
128
|
+
content = fs.readFileSync(full, "utf8");
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (content.length > 2 * 1024 * 1024) continue; // skip huge/minified
|
|
133
|
+
const symbols = extract(content);
|
|
134
|
+
if (symbols.length) results.push({ file: path.relative(root, full), symbols });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (results.length > 5000) break; // hard backstop
|
|
138
|
+
}
|
|
139
|
+
// Busiest files first — the structural backbone surfaces at the top.
|
|
140
|
+
results.sort((a, b) => b.symbols.length - a.symbols.length || a.file.localeCompare(b.file));
|
|
141
|
+
void max;
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseArgs(argv) {
|
|
146
|
+
const opts = { dir: ".", json: false, maxFiles: 60 };
|
|
147
|
+
for (let i = 0; i < argv.length; i++) {
|
|
148
|
+
const a = argv[i];
|
|
149
|
+
if (a === "--json") opts.json = true;
|
|
150
|
+
else if (a === "--max-files") opts.maxFiles = Math.max(1, parseInt(argv[++i], 10) || 60);
|
|
151
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
152
|
+
else opts.dir = a;
|
|
153
|
+
}
|
|
154
|
+
return opts;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function main() {
|
|
158
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
159
|
+
if (opts.help) {
|
|
160
|
+
process.stdout.write("repo-map.js [dir] [--json] [--max-files N]\n");
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
const root = path.resolve(opts.dir);
|
|
164
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
165
|
+
process.stderr.write(`repo-map.js: not a directory: ${opts.dir}\n`);
|
|
166
|
+
process.exit(2);
|
|
167
|
+
}
|
|
168
|
+
const files = walk(root, opts.maxFiles);
|
|
169
|
+
const totalSymbols = files.reduce((n, f) => n + f.symbols.length, 0);
|
|
170
|
+
|
|
171
|
+
if (opts.json) {
|
|
172
|
+
console.log(JSON.stringify({ root, total_files: files.length, total_symbols: totalSymbols, files: files.slice(0, opts.maxFiles) }, null, 2));
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`repo-map: ${files.length} source file${files.length === 1 ? "" : "s"}, ${totalSymbols} top-level symbols`);
|
|
177
|
+
if (files.length > opts.maxFiles) console.log(`(showing the ${opts.maxFiles} densest; pass --max-files to widen)`);
|
|
178
|
+
for (const f of files.slice(0, opts.maxFiles)) {
|
|
179
|
+
console.log(`\n${f.file} (${f.symbols.length})`);
|
|
180
|
+
for (const s of f.symbols.slice(0, 24)) {
|
|
181
|
+
console.log(` ${s.kind.padEnd(6)} ${s.name} :${s.line}`);
|
|
182
|
+
}
|
|
183
|
+
if (f.symbols.length > 24) console.log(` … +${f.symbols.length - 24} more`);
|
|
184
|
+
}
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main();
|
package/bin/runtime-manifest.js
CHANGED
|
@@ -10,18 +10,30 @@ const RUNTIME_BIN_SCRIPTS = [
|
|
|
10
10
|
{ file: "statusline.js", label: "statusline.js (status bar renderer)" },
|
|
11
11
|
{ file: "knowledge.js", label: "knowledge.js (memory-layer loader)" },
|
|
12
12
|
{ file: "knowledge-flush.js", label: "knowledge-flush.js (cron-runnable flush)" },
|
|
13
|
+
{ file: "recall.js", label: "recall.js (read-side memory recall — knowledge layer + role-filtered vault)" },
|
|
14
|
+
{ file: "vault-access.js", label: "vault-access.js (shared vault access-control — honors wiki/_meta/access.md)" },
|
|
15
|
+
{ file: "repo-map.js", label: "repo-map.js (zero-dep symbol map for brownfield onboarding — /qualia-map)" },
|
|
16
|
+
{ file: "design-tokens.js", label: "design-tokens.js (per-client CSS-variable token registry — R10)" },
|
|
17
|
+
{ file: "batch-plan.js", label: "batch-plan.js (file-disjoint batch split for /qualia-build --batch — R20)" },
|
|
13
18
|
{ file: "state-ledger.js", label: "state-ledger.js (hash-chained state event ledger)" },
|
|
14
19
|
{ file: "plan-contract.js", label: "plan-contract.js (plan JSON validator)" },
|
|
15
20
|
{ file: "contract-runner.js", label: "contract-runner.js (contract evidence runner)" },
|
|
16
21
|
{ file: "agent-status.js", label: "agent-status.js (per-task build status + wave fan-in barrier)" },
|
|
17
22
|
{ file: "analyze-gate.js", label: "analyze-gate.js (cross-artifact scope↔plan coverage gate)" },
|
|
23
|
+
{ file: "verify-panel.js", label: "verify-panel.js (verifier panel + skeptic majority-survives aggregator)" },
|
|
24
|
+
{ file: "wave-plan.js", label: "wave-plan.js (dependency-derived, concurrency-capped build schedule)" },
|
|
25
|
+
{ file: "eval-runner.js", label: "eval-runner.js (layered assertion runner for AI-feature eval suites)" },
|
|
26
|
+
{ file: "branch-hygiene.js", label: "branch-hygiene.js (clock-out sweep — stranded branches + stale PRs)" },
|
|
27
|
+
{ file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
|
|
18
28
|
{ file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
|
|
19
29
|
{ file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
|
|
20
30
|
{ file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
|
|
31
|
+
{ file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
|
|
21
32
|
{ file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
|
|
22
33
|
{ file: "report-payload.js", label: "report-payload.js (Framework -> ERP report payload builder)" },
|
|
23
34
|
{ file: "auto-report.js", label: "auto-report.js (B1 ship-time auto-report)" },
|
|
24
35
|
{ file: "project-snapshot.js", label: "project-snapshot.js (ERP/admin project progress snapshot)" },
|
|
36
|
+
{ file: "project-sync.js", label: "project-sync.js (Framework -> ERP full project-sync reconciliation payload)" },
|
|
25
37
|
{ file: "trust-score.js", label: "trust-score.js (harness health scoring)" },
|
|
26
38
|
{ file: "harness-eval.js", label: "harness-eval.js (project eval scoring + evidence artifact)" },
|
|
27
39
|
{ file: "codex-goal.js", label: "codex-goal.js (Codex /goal objective + token-budget suggester)" },
|
package/bin/state.js
CHANGED
|
@@ -607,9 +607,28 @@ function cmdTransitionIncrement(opts, target) {
|
|
|
607
607
|
const c = parseInt(opts.tasks_done) || 0;
|
|
608
608
|
if (c > 0) t.lifetime.tasks_completed += c;
|
|
609
609
|
}
|
|
610
|
+
// Scope tagging (anti-drift): /qualia-feature + /qualia-fix declare whether
|
|
611
|
+
// the work served the active milestone (--scope in --ref CORE-03) or was
|
|
612
|
+
// off-road (--scope off). Off-road work is COUNTED + ledgered so it can't
|
|
613
|
+
// drift invisibly — the OWNER + ERP see the tally, mirroring branch-guard.
|
|
614
|
+
const scope = String(opts.scope || "").toLowerCase();
|
|
615
|
+
if (scope === "off" || scope === "in") {
|
|
616
|
+
if (typeof t.lifetime.offroad_count !== "number") t.lifetime.offroad_count = 0;
|
|
617
|
+
if (scope === "off") {
|
|
618
|
+
t.lifetime.offroad_count += 1;
|
|
619
|
+
if (!Array.isArray(t.offroad)) t.offroad = [];
|
|
620
|
+
t.offroad.push({
|
|
621
|
+
at: new Date().toISOString(),
|
|
622
|
+
milestone: parseInt(t.milestone, 10) || null,
|
|
623
|
+
ref: opts.ref || null,
|
|
624
|
+
note: opts.notes || null,
|
|
625
|
+
});
|
|
626
|
+
if (t.offroad.length > 50) t.offroad = t.offroad.slice(-50); // keep recent
|
|
627
|
+
}
|
|
628
|
+
}
|
|
610
629
|
writeTracking(t);
|
|
611
630
|
regenerateViews(opts.notes || "Activity logged");
|
|
612
|
-
return output({ ok: true, action: target, layout: "increments" });
|
|
631
|
+
return output({ ok: true, action: target, layout: "increments", scope: scope || undefined, offroad_count: t.lifetime.offroad_count });
|
|
613
632
|
}
|
|
614
633
|
|
|
615
634
|
// Resolve the target increment: explicit --id, else --phase N (back-compat
|
|
@@ -759,6 +778,31 @@ function normalizeMilestoneName(name) {
|
|
|
759
778
|
return String(name == null ? "" : name).trim().toLowerCase();
|
|
760
779
|
}
|
|
761
780
|
|
|
781
|
+
// Parse the REQUIREMENTS.md traceability table for one milestone's REQ-IDs and
|
|
782
|
+
// their status. Rows look like: `| CORE-01 | M2: Name | Phase 3 | Complete |`.
|
|
783
|
+
// Returns { tracked: bool, total, incomplete: [{id, status}] }. tracked=false
|
|
784
|
+
// when REQUIREMENTS.md is absent or has no rows for this milestone (→ gate
|
|
785
|
+
// skips, like analyze-gate's no-scope-file: can't enforce what isn't declared).
|
|
786
|
+
function readMilestoneRequirements(milestoneNum) {
|
|
787
|
+
let md;
|
|
788
|
+
try { md = fs.readFileSync(path.join(PLANNING, "REQUIREMENTS.md"), "utf8"); }
|
|
789
|
+
catch { return { tracked: false, total: 0, incomplete: [] }; }
|
|
790
|
+
const num = parseInt(milestoneNum, 10);
|
|
791
|
+
const rows = [];
|
|
792
|
+
for (const line of md.split(/\r?\n/)) {
|
|
793
|
+
if (!/^\s*\|/.test(line)) continue;
|
|
794
|
+
const cells = line.split("|").map((c) => c.trim()).filter((c, i, a) => !(i === 0 && c === "") && !(i === a.length - 1 && c === ""));
|
|
795
|
+
if (cells.length < 4) continue;
|
|
796
|
+
const [id, milestone, , status] = cells;
|
|
797
|
+
if (!/^[A-Z]+-\d+$/.test(id)) continue; // skip header + non-REQ rows
|
|
798
|
+
const m = milestone.match(/M(\d+)\b/);
|
|
799
|
+
if (!m || parseInt(m[1], 10) !== num) continue;
|
|
800
|
+
rows.push({ id, status: status || "" });
|
|
801
|
+
}
|
|
802
|
+
const incomplete = rows.filter((r) => r.status.trim().toLowerCase() !== "complete");
|
|
803
|
+
return { tracked: rows.length > 0, total: rows.length, incomplete };
|
|
804
|
+
}
|
|
805
|
+
|
|
762
806
|
function readState() {
|
|
763
807
|
try {
|
|
764
808
|
return fs.readFileSync(STATE_FILE, "utf8");
|
|
@@ -1275,6 +1319,28 @@ function applyNoteOrActivity(target, s, t, opts) {
|
|
|
1275
1319
|
t.lifetime.tasks_completed += count;
|
|
1276
1320
|
}
|
|
1277
1321
|
}
|
|
1322
|
+
// Scope tagging (anti-drift): /qualia-feature + /qualia-fix declare whether the
|
|
1323
|
+
// work served the active milestone (--scope in --ref CORE-03) or was off-road
|
|
1324
|
+
// (--scope off). Off-road work is COUNTED + ledgered so it can't drift
|
|
1325
|
+
// invisibly — the OWNER + ERP see the tally, mirroring branch-guard.
|
|
1326
|
+
const scope = String(opts.scope || "").toLowerCase();
|
|
1327
|
+
let offroadCount;
|
|
1328
|
+
if (scope === "in" || scope === "off") {
|
|
1329
|
+
ensureLifetime(t);
|
|
1330
|
+
if (typeof t.lifetime.offroad_count !== "number") t.lifetime.offroad_count = 0;
|
|
1331
|
+
if (scope === "off") {
|
|
1332
|
+
t.lifetime.offroad_count += 1;
|
|
1333
|
+
if (!Array.isArray(t.offroad)) t.offroad = [];
|
|
1334
|
+
t.offroad.push({
|
|
1335
|
+
at: new Date().toISOString(),
|
|
1336
|
+
milestone: parseInt(t.milestone, 10) || null,
|
|
1337
|
+
ref: opts.ref || null,
|
|
1338
|
+
note: opts.notes || null,
|
|
1339
|
+
});
|
|
1340
|
+
if (t.offroad.length > 50) t.offroad = t.offroad.slice(-50);
|
|
1341
|
+
}
|
|
1342
|
+
offroadCount = t.lifetime.offroad_count;
|
|
1343
|
+
}
|
|
1278
1344
|
t.last_updated = new Date().toISOString();
|
|
1279
1345
|
writeTracking(t);
|
|
1280
1346
|
s.last_activity = opts.notes || "Activity logged";
|
|
@@ -1284,6 +1350,8 @@ function applyNoteOrActivity(target, s, t, opts) {
|
|
|
1284
1350
|
phase: s.phase,
|
|
1285
1351
|
status: s.status,
|
|
1286
1352
|
action: target,
|
|
1353
|
+
scope: scope || undefined,
|
|
1354
|
+
offroad_count: offroadCount,
|
|
1287
1355
|
};
|
|
1288
1356
|
}
|
|
1289
1357
|
|
|
@@ -2314,6 +2382,27 @@ function cmdValidatePlan(opts) {
|
|
|
2314
2382
|
// recently closed milestone so re-running close-milestone (e.g., after a
|
|
2315
2383
|
// hiccup) does NOT double-count. To re-close a milestone deliberately, pass
|
|
2316
2384
|
// --force.
|
|
2385
|
+
// Report REQ-ID completion for a milestone (defaults to the current one). The
|
|
2386
|
+
// same check close-milestone gates on — exposed so /qualia-milestone can show
|
|
2387
|
+
// coverage before closing, and so it's directly testable. Exit 0 = all complete
|
|
2388
|
+
// (or untracked), 1 = incomplete requirements remain.
|
|
2389
|
+
function cmdReqsCheck(opts) {
|
|
2390
|
+
const t = readTracking();
|
|
2391
|
+
const milestone = opts.milestone != null ? parseInt(opts.milestone, 10) : (t ? parseInt(t.milestone, 10) || 1 : 1);
|
|
2392
|
+
const reqs = readMilestoneRequirements(milestone);
|
|
2393
|
+
const ok = !reqs.tracked || reqs.incomplete.length === 0;
|
|
2394
|
+
output({
|
|
2395
|
+
ok,
|
|
2396
|
+
action: "reqs-check",
|
|
2397
|
+
milestone,
|
|
2398
|
+
tracked: reqs.tracked,
|
|
2399
|
+
total: reqs.total,
|
|
2400
|
+
complete: reqs.total - reqs.incomplete.length,
|
|
2401
|
+
incomplete: reqs.incomplete,
|
|
2402
|
+
});
|
|
2403
|
+
process.exitCode = ok ? 0 : 1;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2317
2406
|
function cmdCloseMilestone(opts) {
|
|
2318
2407
|
const beforeStateRaw = readState();
|
|
2319
2408
|
const beforeTrackingRaw = readTrackingRaw();
|
|
@@ -2323,6 +2412,7 @@ function cmdCloseMilestone(opts) {
|
|
|
2323
2412
|
return output(fail("NO_PROJECT", "No .planning/ found."));
|
|
2324
2413
|
}
|
|
2325
2414
|
ensureLifetime(t);
|
|
2415
|
+
const closeWarnings = [];
|
|
2326
2416
|
|
|
2327
2417
|
// parseInt — legacy tracking.json files carry milestone as a string ("9"),
|
|
2328
2418
|
// which would corrupt `closedMilestone + 1` ("91") and break num dedupe.
|
|
@@ -2367,6 +2457,23 @@ function cmdCloseMilestone(opts) {
|
|
|
2367
2457
|
)
|
|
2368
2458
|
);
|
|
2369
2459
|
}
|
|
2460
|
+
// REQ-ID coverage gate: a milestone isn't "done" just because its phases
|
|
2461
|
+
// verified — its agreed requirements must actually be Complete. Stops
|
|
2462
|
+
// "finishing a milestone with scope left open". strict blocks; standard warns.
|
|
2463
|
+
const reqs = readMilestoneRequirements(closedMilestone);
|
|
2464
|
+
if (reqs.tracked && reqs.incomplete.length > 0) {
|
|
2465
|
+
const profile = resolveProfile(s, t);
|
|
2466
|
+
const list = reqs.incomplete.map((r) => `${r.id}:${r.status || "Pending"}`).join(", ");
|
|
2467
|
+
if (profile === "strict") {
|
|
2468
|
+
return output(
|
|
2469
|
+
fail(
|
|
2470
|
+
"MILESTONE_REQS_INCOMPLETE",
|
|
2471
|
+
`Milestone ${closedMilestone} has ${reqs.incomplete.length}/${reqs.total} requirement(s) not Complete in REQUIREMENTS.md: ${list}. Finish or explicitly defer them (move to Out of Scope), or use --force.`
|
|
2472
|
+
)
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
closeWarnings.push(`${reqs.incomplete.length}/${reqs.total} requirement(s) not Complete: ${list} (standard profile — proceeding; record why in the report).`);
|
|
2476
|
+
}
|
|
2370
2477
|
}
|
|
2371
2478
|
|
|
2372
2479
|
// ─── Append a summary to milestones[] so the ERP can render the tree ──
|
|
@@ -2485,6 +2592,7 @@ function cmdCloseMilestone(opts) {
|
|
|
2485
2592
|
} else {
|
|
2486
2593
|
result.ledger_error = ledger.error;
|
|
2487
2594
|
}
|
|
2595
|
+
if (closeWarnings.length) result.warnings = closeWarnings;
|
|
2488
2596
|
output(result);
|
|
2489
2597
|
}
|
|
2490
2598
|
|
|
@@ -2921,6 +3029,9 @@ try {
|
|
|
2921
3029
|
case "close-milestone":
|
|
2922
3030
|
cmdCloseMilestone(opts);
|
|
2923
3031
|
break;
|
|
3032
|
+
case "reqs-check":
|
|
3033
|
+
cmdReqsCheck(opts);
|
|
3034
|
+
break;
|
|
2924
3035
|
case "backfill-lifetime":
|
|
2925
3036
|
cmdBackfillLifetime(opts);
|
|
2926
3037
|
break;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// vault-access.js — the SINGLE source for qualia-memory vault access control.
|
|
2
|
+
//
|
|
3
|
+
// The vault carries an access manifest at wiki/_meta/access.md naming OWNER_ONLY
|
|
4
|
+
// and CONDITIONAL paths. Every deterministic reader of the vault (recall.js, the
|
|
5
|
+
// memory MCP server) enforces it THROUGH THIS MODULE so the security rule has one
|
|
6
|
+
// implementation that can't drift between callers. Fail-closed: an unknown role
|
|
7
|
+
// is treated as non-OWNER (restricted). See rules/access.md.
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
// Resolve the caller's role. QUALIA_ROLE env wins (tests / overrides); else read
|
|
14
|
+
// `role` from <home>/.qualia-config.json; else RESTRICTED (fail-closed).
|
|
15
|
+
function resolveRole(home) {
|
|
16
|
+
if (process.env.QUALIA_ROLE) return process.env.QUALIA_ROLE.toUpperCase();
|
|
17
|
+
const h = home || process.env.QUALIA_HOME || path.join(os.homedir(), ".claude");
|
|
18
|
+
try {
|
|
19
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(h, ".qualia-config.json"), "utf8"));
|
|
20
|
+
if (cfg && typeof cfg.role === "string") return cfg.role.toUpperCase();
|
|
21
|
+
} catch {}
|
|
22
|
+
return "RESTRICTED";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Parse the OWNER_ONLY + CONDITIONAL path tokens from wiki/_meta/access.md into
|
|
26
|
+
// matchers over VAULT-ROOT-relative paths (e.g. "wiki/_meta/access.md").
|
|
27
|
+
// Returns null when no manifest exists (caller decides; wiki is curated-by-design).
|
|
28
|
+
function loadDenyMatchers(wikiDir) {
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = fs.readFileSync(path.join(wikiDir, "_meta", "access.md"), "utf8");
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const matchers = [];
|
|
36
|
+
for (const section of ["## OWNER_ONLY", "## CONDITIONAL"]) {
|
|
37
|
+
const start = text.indexOf(section);
|
|
38
|
+
if (start === -1) continue;
|
|
39
|
+
const rest = text.slice(start + section.length);
|
|
40
|
+
const end = rest.indexOf("\n## ");
|
|
41
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
42
|
+
for (const m of body.matchAll(/`([^`]+)`/g)) {
|
|
43
|
+
const tok = m[1].trim();
|
|
44
|
+
if (!tok.includes("/") && !tok.includes("*")) continue; // skip non-path backticks
|
|
45
|
+
matchers.push(tok);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return matchers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Does a VAULT-ROOT-relative path match any deny pattern?
|
|
52
|
+
// "dir/" → prefix match "dir/*.md" → prefix + extension
|
|
53
|
+
// "a/*/b" → glob → regex bare → exact or prefix
|
|
54
|
+
function isDenied(vaultRelPath, matchers) {
|
|
55
|
+
for (const tok of matchers) {
|
|
56
|
+
if (tok.endsWith("/*.md")) {
|
|
57
|
+
const dir = tok.slice(0, -5);
|
|
58
|
+
if (vaultRelPath.startsWith(dir + "/") && vaultRelPath.endsWith(".md")) return true;
|
|
59
|
+
} else if (tok.endsWith("/")) {
|
|
60
|
+
if (vaultRelPath.startsWith(tok)) return true;
|
|
61
|
+
} else if (tok.includes("*")) {
|
|
62
|
+
const re = new RegExp("^" + tok.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$");
|
|
63
|
+
if (re.test(vaultRelPath)) return true;
|
|
64
|
+
} else if (vaultRelPath === tok || vaultRelPath.startsWith(tok + "/")) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Convenience for callers that work in WIKI-relative paths (recall, memory-mcp).
|
|
72
|
+
// OWNER sees everything; non-OWNER is denied OWNER_ONLY/CONDITIONAL wiki paths.
|
|
73
|
+
// Unknown manifest → allow (wiki is curated; sensitive stores live outside wiki/).
|
|
74
|
+
function isWikiPathAllowed(wikiRelPath, role, wikiDir) {
|
|
75
|
+
if (role === "OWNER") return true;
|
|
76
|
+
const matchers = loadDenyMatchers(wikiDir);
|
|
77
|
+
if (!matchers) return true;
|
|
78
|
+
const vaultRel = path.join("wiki", wikiRelPath).split(path.sep).join("/");
|
|
79
|
+
return !isDenied(vaultRel, matchers);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { resolveRole, loadDenyMatchers, isDenied, isWikiPathAllowed };
|