qualia-framework 6.14.0 → 6.22.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 +130 -0
- package/CLAUDE.md +3 -1
- package/agents/roadmapper.md +16 -14
- package/bin/agent-status.js +24 -11
- package/bin/branch-hygiene.js +135 -0
- package/bin/command-surface.js +1 -0
- package/bin/compile-instructions.js +82 -0
- package/bin/eval-runner.js +218 -0
- package/bin/host-adapters.js +72 -12
- package/bin/install.js +21 -13
- package/bin/last-report.js +207 -0
- package/bin/project-sync.js +315 -0
- package/bin/runtime-manifest.js +6 -0
- package/bin/state.js +112 -1
- package/bin/verify-panel.js +294 -0
- package/bin/wave-plan.js +211 -0
- package/docs/erp-contract.md +145 -0
- package/package.json +3 -2
- 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 +12 -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-milestone/SKILL.md +12 -6
- package/skills/qualia-new/REFERENCE.md +6 -4
- package/skills/qualia-new/SKILL.md +27 -15
- package/skills/qualia-plan/SKILL.md +2 -2
- 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 +45 -24
- 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/branch-hygiene.test.sh +93 -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 +2 -2
- package/tests/project-sync.test.sh +175 -0
- package/tests/run-all.sh +7 -0
- 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,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// verify-panel.js — deterministic aggregator for the verifier panel + skeptics.
|
|
3
|
+
//
|
|
4
|
+
// WHY: a single LLM judge is adversarially fragile — a lone stray token can
|
|
5
|
+
// swing it (~35% false positives in the lit). R8 replaces single-pass verify
|
|
6
|
+
// with (a) a PANEL of verifiers each scoring one lens (correctness / security /
|
|
7
|
+
// performance / design) and (b) per-finding adversarial SKEPTICS that vote on
|
|
8
|
+
// whether each finding is real. This module is the part that must NOT be a vibe:
|
|
9
|
+
// it dedupes the panel's findings, applies majority-survives over the skeptic
|
|
10
|
+
// votes, and computes the verdict + scores with the rules/grounding.md formula.
|
|
11
|
+
// Same inputs → same verdict.
|
|
12
|
+
//
|
|
13
|
+
// Input (panel JSON the skill assembles from agent outputs):
|
|
14
|
+
// {
|
|
15
|
+
// "phase": 2,
|
|
16
|
+
// "lenses": ["correctness","security","performance","design"],
|
|
17
|
+
// "findings": [
|
|
18
|
+
// { "lens":"security", "file":"lib/auth.ts", "line":42,
|
|
19
|
+
// "severity":"CRITICAL", "title":"service_role reachable client-side",
|
|
20
|
+
// "votes": { "real": 2, "notReal": 1 } }
|
|
21
|
+
// ]
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// Exit 0 = PASS, 1 = FAIL, 2 = invocation error.
|
|
25
|
+
// Zero npm dependencies. Library + CLI.
|
|
26
|
+
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const path = require("path");
|
|
29
|
+
|
|
30
|
+
const SEVERITY_WEIGHT = { CRITICAL: 8, HIGH: 4, MEDIUM: 2, LOW: 1 };
|
|
31
|
+
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
32
|
+
|
|
33
|
+
function normSeverity(s) {
|
|
34
|
+
const up = String(s || "").toUpperCase();
|
|
35
|
+
return SEVERITY_WEIGHT[up] != null ? up : "LOW";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function slug(title) {
|
|
39
|
+
return String(title || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().slice(0, 48);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findingKey(f) {
|
|
43
|
+
const file = String(f.file || "").replace(/\\/g, "/");
|
|
44
|
+
const line = f.line != null ? f.line : "";
|
|
45
|
+
return `${file}:${line}:${slug(f.title)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Merge findings that point at the same file:line:title (e.g. the correctness
|
|
49
|
+
// AND security lens both flagged it). Keep the highest severity, union the
|
|
50
|
+
// lenses, and sum the skeptic votes so the merged finding is judged once.
|
|
51
|
+
function dedupeFindings(findings) {
|
|
52
|
+
const byKey = new Map();
|
|
53
|
+
for (const raw of findings || []) {
|
|
54
|
+
const f = {
|
|
55
|
+
file: raw.file || null,
|
|
56
|
+
line: raw.line != null ? raw.line : null,
|
|
57
|
+
title: raw.title || "",
|
|
58
|
+
severity: normSeverity(raw.severity),
|
|
59
|
+
lenses: raw.lens ? [raw.lens] : (Array.isArray(raw.lenses) ? raw.lenses.slice() : []),
|
|
60
|
+
votes: {
|
|
61
|
+
real: Number((raw.votes && raw.votes.real) || 0),
|
|
62
|
+
notReal: Number((raw.votes && raw.votes.notReal) || 0),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const key = findingKey(f);
|
|
66
|
+
const prev = byKey.get(key);
|
|
67
|
+
if (!prev) { byKey.set(key, f); continue; }
|
|
68
|
+
if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[prev.severity]) prev.severity = f.severity;
|
|
69
|
+
for (const l of f.lenses) if (!prev.lenses.includes(l)) prev.lenses.push(l);
|
|
70
|
+
prev.votes.real += f.votes.real;
|
|
71
|
+
prev.votes.notReal += f.votes.notReal;
|
|
72
|
+
}
|
|
73
|
+
return [...byKey.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Majority-survives: a finding is KILLED only when the skeptics who voted are a
|
|
77
|
+
// strict majority calling it not-real. With no votes recorded it survives
|
|
78
|
+
// (unverified ≠ disproven — verification stays conservative).
|
|
79
|
+
function survives(finding) {
|
|
80
|
+
const r = finding.votes.real;
|
|
81
|
+
const n = finding.votes.notReal;
|
|
82
|
+
if (r + n === 0) return true;
|
|
83
|
+
return !(n > r);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scoreFromCounts(counts) {
|
|
87
|
+
const ws =
|
|
88
|
+
counts.CRITICAL * SEVERITY_WEIGHT.CRITICAL +
|
|
89
|
+
counts.HIGH * SEVERITY_WEIGHT.HIGH +
|
|
90
|
+
counts.MEDIUM * SEVERITY_WEIGHT.MEDIUM +
|
|
91
|
+
counts.LOW * SEVERITY_WEIGHT.LOW;
|
|
92
|
+
return Math.max(1, 5 - Math.floor(ws / 8));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyCounts() {
|
|
96
|
+
return { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function aggregate(panel) {
|
|
100
|
+
const deduped = dedupeFindings(panel.findings || []);
|
|
101
|
+
const surviving = [];
|
|
102
|
+
const killed = [];
|
|
103
|
+
for (const f of deduped) {
|
|
104
|
+
(survives(f) ? surviving : killed).push({ ...f, verified: f.votes.real + f.votes.notReal > 0 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const counts = emptyCounts();
|
|
108
|
+
const perLens = {};
|
|
109
|
+
for (const lens of panel.lenses || []) perLens[lens] = emptyCounts();
|
|
110
|
+
for (const f of surviving) {
|
|
111
|
+
counts[f.severity]++;
|
|
112
|
+
for (const lens of f.lenses) {
|
|
113
|
+
if (!perLens[lens]) perLens[lens] = emptyCounts();
|
|
114
|
+
perLens[lens][f.severity]++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A surviving CRITICAL or HIGH fails the phase (rules/grounding.md severity).
|
|
119
|
+
const verdict = counts.CRITICAL === 0 && counts.HIGH === 0 ? "PASS" : "FAIL";
|
|
120
|
+
const perLensScores = {};
|
|
121
|
+
for (const [lens, c] of Object.entries(perLens)) perLensScores[lens] = scoreFromCounts(c);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
ok: verdict === "PASS",
|
|
125
|
+
verdict,
|
|
126
|
+
phase: panel.phase != null ? panel.phase : null,
|
|
127
|
+
counts,
|
|
128
|
+
score: scoreFromCounts(counts),
|
|
129
|
+
per_lens_scores: perLensScores,
|
|
130
|
+
surviving,
|
|
131
|
+
killed,
|
|
132
|
+
totals: { findings: deduped.length, surviving: surviving.length, killed: killed.length },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
137
|
+
function parseArgs(argv) {
|
|
138
|
+
const args = { _: [] };
|
|
139
|
+
for (let i = 2; i < argv.length; i++) {
|
|
140
|
+
const a = argv[i];
|
|
141
|
+
if (a === "--json") args.json = true;
|
|
142
|
+
else if (a === "--write") args.write = true;
|
|
143
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
144
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
145
|
+
else args._.push(a);
|
|
146
|
+
}
|
|
147
|
+
return args;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function usage() {
|
|
151
|
+
console.error([
|
|
152
|
+
"Usage:",
|
|
153
|
+
" verify-panel.js <panel.json> [--json] [--write] [--cwd DIR]",
|
|
154
|
+
"",
|
|
155
|
+
"Aggregates panel findings + skeptic votes → deterministic verdict.",
|
|
156
|
+
"Exit 0 = PASS, 1 = FAIL, 2 = invocation error.",
|
|
157
|
+
].join("\n"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function toMarkdown(r) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
lines.push(`# Verifier Panel — phase ${r.phase}`);
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push(`result: ${r.verdict}`);
|
|
165
|
+
lines.push(`score: ${r.score}/5`);
|
|
166
|
+
lines.push(`findings: ${r.totals.surviving} surviving / ${r.totals.killed} killed by skeptics`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
lines.push(`Severity (surviving): CRITICAL ${r.counts.CRITICAL} · HIGH ${r.counts.HIGH} · MEDIUM ${r.counts.MEDIUM} · LOW ${r.counts.LOW}`);
|
|
169
|
+
lines.push("");
|
|
170
|
+
if (r.surviving.length) {
|
|
171
|
+
lines.push("## Surviving findings");
|
|
172
|
+
for (const f of r.surviving) {
|
|
173
|
+
const where = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "(no location)";
|
|
174
|
+
lines.push(`- **[${f.severity}]** ${f.title} — ${where} _(lens: ${f.lenses.join(", ") || "?"}; votes ${f.votes.real}✓/${f.votes.notReal}✗)_`);
|
|
175
|
+
}
|
|
176
|
+
lines.push("");
|
|
177
|
+
}
|
|
178
|
+
if (r.killed.length) {
|
|
179
|
+
lines.push("## Killed by skeptics (majority not-real)");
|
|
180
|
+
for (const f of r.killed) {
|
|
181
|
+
const where = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "(no location)";
|
|
182
|
+
lines.push(`- ~~[${f.severity}] ${f.title}~~ — ${where} _(votes ${f.votes.real}✓/${f.votes.notReal}✗)_`);
|
|
183
|
+
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n") + "\n";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Assemble per-lens finding files (.planning/phase-{N}-panel-{lens}.json, each
|
|
190
|
+
// an array of {file,line,severity,title}) into one panel.json — findings tagged
|
|
191
|
+
// with their lens, votes zeroed for the skeptic pass to fill. Deterministic so
|
|
192
|
+
// the orchestrator never hand-builds the panel skeleton.
|
|
193
|
+
function assemble(root, phase) {
|
|
194
|
+
const dir = path.join(root, ".planning");
|
|
195
|
+
const re = new RegExp(`^phase-${phase}-panel-([a-z]+)\\.json$`);
|
|
196
|
+
const lenses = [];
|
|
197
|
+
const findings = [];
|
|
198
|
+
let files = [];
|
|
199
|
+
try { files = fs.readdirSync(dir); } catch { files = []; }
|
|
200
|
+
for (const f of files.sort()) {
|
|
201
|
+
const m = f.match(re);
|
|
202
|
+
if (!m) continue;
|
|
203
|
+
const lens = m[1];
|
|
204
|
+
let arr;
|
|
205
|
+
try { arr = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); } catch { continue; }
|
|
206
|
+
if (!Array.isArray(arr)) continue;
|
|
207
|
+
lenses.push(lens);
|
|
208
|
+
for (const item of arr) {
|
|
209
|
+
findings.push({
|
|
210
|
+
lens,
|
|
211
|
+
file: item.file || null,
|
|
212
|
+
line: item.line != null ? item.line : null,
|
|
213
|
+
severity: item.severity || "LOW",
|
|
214
|
+
title: item.title || "",
|
|
215
|
+
votes: { real: 0, notReal: 0 },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { phase: Number(phase), lenses, findings };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function main(argv) {
|
|
223
|
+
// assemble subcommand: verify-panel.js assemble <phase> [--cwd DIR]
|
|
224
|
+
if (argv[2] === "assemble") {
|
|
225
|
+
const sub = parseArgs(["", "", ...argv.slice(3)]);
|
|
226
|
+
const phase = sub._[0];
|
|
227
|
+
if (phase == null) { usage(); return 2; }
|
|
228
|
+
const root = path.resolve(sub.cwd || process.cwd());
|
|
229
|
+
const panel = assemble(root, phase);
|
|
230
|
+
const out = path.join(root, ".planning", `phase-${phase}-panel.json`);
|
|
231
|
+
try {
|
|
232
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
233
|
+
fs.writeFileSync(out, JSON.stringify(panel, null, 2) + "\n");
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error(`ERROR: could not write ${out}: ${e.message}`);
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
console.log(`assembled ${panel.findings.length} finding(s) from ${panel.lenses.length} lens file(s) → ${path.relative(root, out)}`);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const args = parseArgs(argv);
|
|
243
|
+
const panelPath = args._[0];
|
|
244
|
+
if (!panelPath || panelPath === "-h" || panelPath === "--help") { usage(); return 2; }
|
|
245
|
+
const root = path.resolve(args.cwd || process.cwd());
|
|
246
|
+
|
|
247
|
+
let panel;
|
|
248
|
+
try {
|
|
249
|
+
panel = JSON.parse(fs.readFileSync(path.resolve(root, panelPath), "utf8"));
|
|
250
|
+
} catch (e) {
|
|
251
|
+
console.error(`ERROR: cannot read panel JSON: ${e.message}`);
|
|
252
|
+
return 2;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const result = aggregate(panel);
|
|
256
|
+
|
|
257
|
+
if (args.write && result.phase != null) {
|
|
258
|
+
const dir = path.join(root, ".planning");
|
|
259
|
+
try {
|
|
260
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
261
|
+
fs.writeFileSync(path.join(dir, `phase-${result.phase}-verification-panel.json`), JSON.stringify(result, null, 2) + "\n");
|
|
262
|
+
fs.writeFileSync(path.join(dir, `phase-${result.phase}-verification-panel.md`), toMarkdown(result));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
console.error(`WARN: could not write panel artifacts: ${e.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (args.json) {
|
|
269
|
+
console.log(JSON.stringify(result, null, 2));
|
|
270
|
+
} else {
|
|
271
|
+
console.log(`PANEL ${result.verdict} phase ${result.phase}: ` +
|
|
272
|
+
`${result.totals.surviving} surviving (${result.counts.CRITICAL}C/${result.counts.HIGH}H/${result.counts.MEDIUM}M/${result.counts.LOW}L), ` +
|
|
273
|
+
`${result.totals.killed} killed by skeptics, score ${result.score}/5`);
|
|
274
|
+
if (!result.ok) for (const f of result.surviving) {
|
|
275
|
+
if (f.severity === "CRITICAL" || f.severity === "HIGH") {
|
|
276
|
+
console.log(` [${f.severity}] ${f.title} — ${f.file || "?"}${f.line != null ? `:${f.line}` : ""}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return result.ok ? 0 : 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
dedupeFindings,
|
|
285
|
+
survives,
|
|
286
|
+
scoreFromCounts,
|
|
287
|
+
aggregate,
|
|
288
|
+
assemble,
|
|
289
|
+
SEVERITY_WEIGHT,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (require.main === module) {
|
|
293
|
+
process.exit(main(process.argv));
|
|
294
|
+
}
|
package/bin/wave-plan.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wave-plan.js — derive a deterministic build schedule from the task DAG.
|
|
3
|
+
//
|
|
4
|
+
// WHY: /qualia-build spawned EVERY task in a contract "wave" concurrently, with
|
|
5
|
+
// no cap. That's two failure modes at once:
|
|
6
|
+
// 1. Over-serialization — the planner's hand-numbered waves can be deeper than
|
|
7
|
+
// the dependency graph requires (tasks that COULD run together placed in
|
|
8
|
+
// separate waves), so the build is slower than necessary.
|
|
9
|
+
// 2. Over-parallelization — a wide wave (say 9 independent tasks) spawns 9
|
|
10
|
+
// concurrent builders, past the 3–5 sweet spot where coordination +
|
|
11
|
+
// context cost overwhelms the gain (the LangGraph max_concurrency lesson).
|
|
12
|
+
//
|
|
13
|
+
// This recomputes waves from `depends_on` (minimal-depth topological levels —
|
|
14
|
+
// maximal safe parallelism) and splits each level into batches capped at
|
|
15
|
+
// max_concurrency. Output is an ordered list of batches the orchestrator spawns
|
|
16
|
+
// one at a time. Deterministic: same contract + same cap → same schedule.
|
|
17
|
+
//
|
|
18
|
+
// max_concurrency:
|
|
19
|
+
// --parallel N → exactly N (N ≥ 1)
|
|
20
|
+
// auto (default)→ 1 if < 3 total tasks ("don't parallelize tiny phases"),
|
|
21
|
+
// else SWEET (5).
|
|
22
|
+
//
|
|
23
|
+
// Zero npm dependencies. Library + CLI. Exit 0 ok, 1 cycle, 2 invocation error.
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const pc = require("./plan-contract.js");
|
|
28
|
+
|
|
29
|
+
const SWEET = 5;
|
|
30
|
+
|
|
31
|
+
function numericId(id) {
|
|
32
|
+
const m = String(id).match(/\d+/);
|
|
33
|
+
return m ? Number(m[0]) : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Minimal-depth level for each task: level = 1 + max(level(deps)); roots = 1.
|
|
37
|
+
// Returns { levels: Map<id,level>, cycle: [...] | null }.
|
|
38
|
+
function deriveLevels(tasks) {
|
|
39
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
40
|
+
const levels = new Map();
|
|
41
|
+
const visiting = new Set();
|
|
42
|
+
|
|
43
|
+
function depth(id, trail) {
|
|
44
|
+
if (levels.has(id)) return levels.get(id);
|
|
45
|
+
if (visiting.has(id)) {
|
|
46
|
+
const err = new Error("cycle");
|
|
47
|
+
err.cycle = [...trail, id];
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
const t = byId.get(id);
|
|
51
|
+
const deps = (t && t.depends_on) || [];
|
|
52
|
+
visiting.add(id);
|
|
53
|
+
let max = 0;
|
|
54
|
+
for (const d of deps) {
|
|
55
|
+
if (!byId.has(d)) continue; // unknown dep — plan-contract validates this separately
|
|
56
|
+
max = Math.max(max, depth(d, [...trail, id]));
|
|
57
|
+
}
|
|
58
|
+
visiting.delete(id);
|
|
59
|
+
const lvl = max + 1;
|
|
60
|
+
levels.set(id, lvl);
|
|
61
|
+
return lvl;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
for (const t of tasks) depth(t.id, []);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e.cycle) return { levels: null, cycle: e.cycle };
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
return { levels, cycle: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveConcurrency(taskCount, parallel) {
|
|
74
|
+
if (parallel != null) {
|
|
75
|
+
const n = Number(parallel);
|
|
76
|
+
if (!Number.isFinite(n) || n < 1) throw new Error(`--parallel must be an integer ≥ 1 (got ${parallel})`);
|
|
77
|
+
return Math.floor(n);
|
|
78
|
+
}
|
|
79
|
+
return taskCount < 3 ? 1 : SWEET;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function chunk(arr, size) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function plan(contract, opts = {}) {
|
|
89
|
+
const tasks = (contract.tasks || []).slice();
|
|
90
|
+
const { levels, cycle } = deriveLevels(tasks);
|
|
91
|
+
if (cycle) return { ok: false, error: "CYCLE", cycle: cycle.join(" → ") };
|
|
92
|
+
|
|
93
|
+
const maxConcurrency = resolveConcurrency(tasks.length, opts.parallel);
|
|
94
|
+
|
|
95
|
+
// Group task ids by derived level, ordered numerically within a level.
|
|
96
|
+
const byLevel = new Map();
|
|
97
|
+
for (const t of tasks) {
|
|
98
|
+
const lvl = levels.get(t.id);
|
|
99
|
+
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
|
100
|
+
byLevel.get(lvl).push(t.id);
|
|
101
|
+
}
|
|
102
|
+
const sortedLevels = [...byLevel.keys()].sort((a, b) => a - b);
|
|
103
|
+
|
|
104
|
+
// Emit batches: each derived level split into ≤ maxConcurrency chunks.
|
|
105
|
+
const batches = [];
|
|
106
|
+
for (const lvl of sortedLevels) {
|
|
107
|
+
const ids = byLevel.get(lvl).sort((a, b) => numericId(a) - numericId(b));
|
|
108
|
+
for (const group of chunk(ids, maxConcurrency)) {
|
|
109
|
+
batches.push({ wave: lvl, batch: batches.length + 1, tasks: group });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Note where the planner's declared wave is deeper than necessary (the DAG
|
|
114
|
+
// permits more parallelism than the contract used).
|
|
115
|
+
const notes = [];
|
|
116
|
+
const declared = new Map(tasks.map((t) => [t.id, t.wave]));
|
|
117
|
+
let overSerialized = 0;
|
|
118
|
+
for (const t of tasks) {
|
|
119
|
+
const d = declared.get(t.id);
|
|
120
|
+
const derived = levels.get(t.id);
|
|
121
|
+
if (typeof d === "number" && d > derived) overSerialized++;
|
|
122
|
+
}
|
|
123
|
+
if (overSerialized > 0) {
|
|
124
|
+
notes.push(`${overSerialized} task(s) declared a deeper wave than the dependency graph requires — the schedule below runs them earlier (more parallel).`);
|
|
125
|
+
}
|
|
126
|
+
const widest = Math.max(0, ...sortedLevels.map((l) => byLevel.get(l).length));
|
|
127
|
+
if (widest > maxConcurrency) {
|
|
128
|
+
notes.push(`widest level has ${widest} independent tasks; capped to batches of ${maxConcurrency}.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
phase: contract.phase != null ? contract.phase : null,
|
|
134
|
+
task_count: tasks.length,
|
|
135
|
+
max_concurrency: maxConcurrency,
|
|
136
|
+
derived_levels: sortedLevels.length,
|
|
137
|
+
batch_count: batches.length,
|
|
138
|
+
batches,
|
|
139
|
+
notes,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
144
|
+
function parseArgs(argv) {
|
|
145
|
+
const args = { _: [] };
|
|
146
|
+
for (let i = 2; i < argv.length; i++) {
|
|
147
|
+
const a = argv[i];
|
|
148
|
+
if (a === "--json") args.json = true;
|
|
149
|
+
else if (a === "--parallel") args.parallel = argv[++i];
|
|
150
|
+
else if (a.startsWith("--parallel=")) args.parallel = a.slice(11);
|
|
151
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
152
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
153
|
+
else args._.push(a);
|
|
154
|
+
}
|
|
155
|
+
return args;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function usage() {
|
|
159
|
+
console.error([
|
|
160
|
+
"Usage:",
|
|
161
|
+
" wave-plan.js <contract.json> [--parallel N] [--json] [--cwd DIR]",
|
|
162
|
+
"",
|
|
163
|
+
"Derives a dependency-ordered, concurrency-capped build schedule.",
|
|
164
|
+
"Exit 0 = ok, 1 = cycle in DAG, 2 = invocation error.",
|
|
165
|
+
].join("\n"));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main(argv) {
|
|
169
|
+
const args = parseArgs(argv);
|
|
170
|
+
const contractPath = args._[0];
|
|
171
|
+
if (!contractPath || contractPath === "-h" || contractPath === "--help") { usage(); return 2; }
|
|
172
|
+
const root = path.resolve(args.cwd || process.cwd());
|
|
173
|
+
|
|
174
|
+
const loaded = pc.readContractFile(path.resolve(root, contractPath));
|
|
175
|
+
if (!loaded.ok) {
|
|
176
|
+
if (args.json) console.log(JSON.stringify({ ok: false, ...loaded }));
|
|
177
|
+
else console.error(`${loaded.error}: ${loaded.message}`);
|
|
178
|
+
return 2;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = plan(loaded.contract, { parallel: args.parallel });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
if (args.json) console.log(JSON.stringify({ ok: false, error: "BAD_ARG", message: e.message }));
|
|
186
|
+
else console.error(`ERROR: ${e.message}`);
|
|
187
|
+
return 2;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!result.ok) {
|
|
191
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
192
|
+
else console.error(`${result.error}: ${result.cycle || ""}`);
|
|
193
|
+
return result.error === "CYCLE" ? 1 : 2;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (args.json) { console.log(JSON.stringify(result, null, 2)); return 0; }
|
|
197
|
+
|
|
198
|
+
console.log(`Build schedule — phase ${result.phase}: ${result.task_count} task(s), ` +
|
|
199
|
+
`${result.batch_count} batch(es), max ${result.max_concurrency} concurrent`);
|
|
200
|
+
for (const b of result.batches) {
|
|
201
|
+
console.log(` wave ${b.wave} · batch ${b.batch}: ${b.tasks.join(", ")}`);
|
|
202
|
+
}
|
|
203
|
+
for (const n of result.notes) console.log(` note: ${n}`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { deriveLevels, resolveConcurrency, plan, SWEET };
|
|
208
|
+
|
|
209
|
+
if (require.main === module) {
|
|
210
|
+
process.exit(main(process.argv));
|
|
211
|
+
}
|
package/docs/erp-contract.md
CHANGED
|
@@ -346,6 +346,151 @@ Snapshot shape:
|
|
|
346
346
|
}
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
+
## Project Sync Payload (reconciliation)
|
|
350
|
+
|
|
351
|
+
`qualia-framework project-sync` builds the **single complete project-sync snapshot** — the one payload the ERP reads to RECONCILE a project's milestones, phases, tasks, and reports in one pass, and to understand the PR/merge model that maps a feature branch to deployed `main`.
|
|
352
|
+
|
|
353
|
+
It is distinct from the two existing emitters:
|
|
354
|
+
- `POST /api/v1/reports` (report-payload.js) = ONE work session.
|
|
355
|
+
- `POST /api/v1/project-snapshots` (project-snapshot.js) = lean progress rollup for dashboards (`progress_percent`).
|
|
356
|
+
- **project-sync** = the FULL reconciliation surface: every milestone with REQ-ID completion, current position, task rollup, accountability counters, and the trunk/merge model. project-sync **composes** project-snapshot's identity/current/journey/lifetime/quality blocks (no duplication) and enriches them.
|
|
357
|
+
|
|
358
|
+
```text
|
|
359
|
+
qualia-framework project-sync # print JSON (compact) to stdout
|
|
360
|
+
qualia-framework project-sync --json # print JSON (pretty)
|
|
361
|
+
qualia-framework project-sync --write # persist .planning/snapshots/project-sync-<ts>.json
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Exit 0 = built; exit 2 = no `.planning/` (run `/qualia-new`). Read-only, zero dependencies. Graceful when JOURNEY.md / REQUIREMENTS.md / fields are absent (milestones still emit, REQ blocks report `tracked: false`).
|
|
365
|
+
|
|
366
|
+
### Payload shape
|
|
367
|
+
|
|
368
|
+
```json
|
|
369
|
+
{
|
|
370
|
+
"schema_version": 1,
|
|
371
|
+
"generated_at": "2026-06-21T00:00:00.000Z",
|
|
372
|
+
"source": "qualia-framework",
|
|
373
|
+
"payload": "project-sync",
|
|
374
|
+
"framework_version": "6.14.0",
|
|
375
|
+
"identifiers": {
|
|
376
|
+
"project_id": "qs-acme-portal",
|
|
377
|
+
"team_id": "qualia-solutions",
|
|
378
|
+
"git_remote": "github.com/QualiasolutionsCY/acme-portal",
|
|
379
|
+
"erp_project_id": "7b5d3b4e-2b8a-4de4-91a1-9b2f3182f5ef"
|
|
380
|
+
},
|
|
381
|
+
"project": {
|
|
382
|
+
"name": "acme-portal",
|
|
383
|
+
"client": "Acme",
|
|
384
|
+
"status": "built",
|
|
385
|
+
"deployed_url": "https://client.vercel.app",
|
|
386
|
+
"progress_percent": 42,
|
|
387
|
+
"lifecycle": "build",
|
|
388
|
+
"launched_at": "2026-05-01T00:00:00Z"
|
|
389
|
+
},
|
|
390
|
+
"current": {
|
|
391
|
+
"milestone": 2, "milestone_name": "Product",
|
|
392
|
+
"phase": 2, "phase_name": "Dashboard", "total_phases": 4,
|
|
393
|
+
"tasks_done": 3, "tasks_total": 5,
|
|
394
|
+
"verification": "pending", "gap_cycles": 1
|
|
395
|
+
},
|
|
396
|
+
"quality": { "harness_eval": { "status": "PASS", "score": 92, "phase": 2, "generated_at": "…", "artifact": "…" } },
|
|
397
|
+
"total_milestones": 3,
|
|
398
|
+
"milestones": [
|
|
399
|
+
{
|
|
400
|
+
"num": 1, "name": "Foundation", "status": "closed",
|
|
401
|
+
"phases": 3, "tasks_completed": 12,
|
|
402
|
+
"requirements": { "tracked": true, "total": 4, "complete": 4, "incomplete": [] },
|
|
403
|
+
"closed_at": "2026-04-10T18:00:00Z",
|
|
404
|
+
"deployed_url": "https://m1.vercel.app"
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
"num": 2, "name": "Product", "status": "current",
|
|
408
|
+
"phases": 4, "tasks_completed": 0,
|
|
409
|
+
"requirements": { "tracked": true, "total": 5, "complete": 3,
|
|
410
|
+
"incomplete": [ { "id": "CORE-03", "status": "Incomplete" } ] }
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"num": 3, "name": "Handoff", "status": "future",
|
|
414
|
+
"phases": 0, "tasks_completed": 0,
|
|
415
|
+
"requirements": { "tracked": false, "total": 0, "complete": 0, "incomplete": [] }
|
|
416
|
+
}
|
|
417
|
+
],
|
|
418
|
+
"task_rollup": {
|
|
419
|
+
"tasks_completed": 15, "phases_completed": 4, "milestones_completed": 1,
|
|
420
|
+
"total_phases_all_milestones": 7, "build_count": 4, "deploy_count": 1,
|
|
421
|
+
"current_phase_gap_cycles": 1
|
|
422
|
+
},
|
|
423
|
+
"accountability": {
|
|
424
|
+
"offroad_count": 2,
|
|
425
|
+
"offroad": [ { "at": "2026-06-01T10:00:00Z", "milestone": 2, "ref": "BUG-7", "note": "hotfix login" } ]
|
|
426
|
+
},
|
|
427
|
+
"integration": {
|
|
428
|
+
"model": "trunk",
|
|
429
|
+
"integrates_at": "/qualia-ship",
|
|
430
|
+
"protected_branches": ["main", "master"],
|
|
431
|
+
"main_push_event_type": "employee_main_push",
|
|
432
|
+
"main_push_events_path": "~/.claude/.main-push-events.json",
|
|
433
|
+
"note": "Feature branches integrate to main at /qualia-ship (then deploy). Direct pushes to a protected branch are allowed but recorded as employee_main_push policy events for per-employee accountability."
|
|
434
|
+
},
|
|
435
|
+
"timestamps": { "session_started_at": "", "last_pushed_at": "", "last_updated": "" }
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Field meanings
|
|
440
|
+
|
|
441
|
+
| Field | Meaning |
|
|
442
|
+
|-------|---------|
|
|
443
|
+
| `schema_version` | project-sync contract version. The ERP branches on this to evolve the shape safely. **Distinct** from `snapshot_version` (project-snapshot). |
|
|
444
|
+
| `payload` | Always `"project-sync"` — discriminates this from the report / snapshot payloads when they share an endpoint or queue. |
|
|
445
|
+
| `identifiers` | Same resolution block as project-snapshot — `erp_project_id` is the strongest link, then `git_remote`, then `(team_id, project_id)`. |
|
|
446
|
+
| `project.lifecycle` | `build` (milestone journey) or `operate` (post-launch update stream). The ERP stops expecting a handoff for an `operate` project. |
|
|
447
|
+
| `project.launched_at` / `launch_source` | Present once launched; lets the ERP date the build→operate transition. |
|
|
448
|
+
| `current.*` | The active milestone/phase position + verification + flattened `gap_cycles` (number, current phase). |
|
|
449
|
+
| `total_milestones` | Denominator for milestone-completion (from JOURNEY.md, else max known). |
|
|
450
|
+
| `milestones[]` | The reconciliation spine — every known milestone (journey ∪ closed ∪ current), ordered by num. |
|
|
451
|
+
| `milestones[].status` | `closed` \| `current` \| `future`. The ERP marks done / in-progress / pending from this. |
|
|
452
|
+
| `milestones[].phases` | Phase count: `phases_completed` for closed milestones; the current roadmap phase count for the active one; 0 for future. |
|
|
453
|
+
| `milestones[].tasks_completed` | Tasks completed within that milestone (closed summaries carry this; 0 for current/future — use `task_rollup` for cumulative). |
|
|
454
|
+
| `milestones[].requirements` | REQ-ID completion from REQUIREMENTS.md: `tracked` (false ⇒ no REQ rows declared for this milestone, ERP skips the REQ gate), `total`, `complete`, `incomplete[]` (`{id, status}`). |
|
|
455
|
+
| `milestones[].closed_at` / `deployed_url` | Present when the closed-milestone summary carries them. |
|
|
456
|
+
| `task_rollup` | Cumulative counters so the ERP rolls tasks up without replaying every report: lifetime tasks/phases/milestones, `total_phases_all_milestones`, build/deploy counts, current-phase gap cycles. |
|
|
457
|
+
| `accountability.offroad_count` | Count of off-milestone work units (mirrors the branch-guard main-push tally — drift is counted, not hidden). |
|
|
458
|
+
| `accountability.offroad[]` | Last 10 off-road entries: `{at, milestone, ref, note}`. |
|
|
459
|
+
| `integration` | The PR/merge model (see below). |
|
|
460
|
+
|
|
461
|
+
### How the ERP reconciles from this payload
|
|
462
|
+
|
|
463
|
+
1. **Resolve the project** via `identifiers` (same precedence as reports/snapshots).
|
|
464
|
+
2. **Milestones** — upsert each `milestones[]` entry by `num`. Set the ERP milestone state from `status` (`closed`→done, `current`→in-progress, `future`→pending). When `requirements.tracked` is true, the milestone is **complete only if** `complete === total`; surface `incomplete[]` REQ-IDs as the remaining work. When `tracked` is false, fall back to `status` alone (no REQ table was declared).
|
|
465
|
+
3. **Phases** — `milestones[].phases` + `current.total_phases` give per-milestone phase counts; `task_rollup.total_phases_all_milestones` is the grand total across the whole journey.
|
|
466
|
+
4. **Tasks** — use `task_rollup.tasks_completed` for the cumulative figure and `milestones[].tasks_completed` for per-closed-milestone attribution. Do **not** sum reports to get this — the rollup is authoritative.
|
|
467
|
+
5. **Reports** — project-sync does not carry session notes; it reconciles *state*, while `/api/v1/reports` carries each *session*. Reconcile both against the same resolved project: reports for the activity feed, project-sync for the milestone/phase/task tree.
|
|
468
|
+
6. **Accountability** — store `offroad_count` per project so off-milestone drift is visible alongside the per-employee `employee_main_push` tally from `/api/v1/policy-events`.
|
|
469
|
+
|
|
470
|
+
### PR / merge model (what the ERP must understand)
|
|
471
|
+
|
|
472
|
+
The framework uses **trunk integration**, surfaced in `integration`:
|
|
473
|
+
|
|
474
|
+
- Work happens on a **feature branch** (`branch-guard` enforces feature-branch-only; `main`/`master` are protected).
|
|
475
|
+
- `/qualia-ship` **integrates the feature branch into `main`** and then deploys (`integrates_at: "/qualia-ship"`). There is no long-lived release branch; `main` is always the deployable trunk.
|
|
476
|
+
- A direct push to a protected branch is **allowed, not blocked** — but `branch-guard` records an `employee_main_push` policy event (`POST /api/v1/policy-events`, keyed by `(type, actor_code)`) for per-employee accountability. The local journal lives at `main_push_events_path` (`~/.claude/.main-push-events.json`) in the install home; project-sync **references** it, it does not read across the home boundary.
|
|
477
|
+
|
|
478
|
+
So the branch→main→deploy mapping the ERP should encode: **a milestone's `deployed_url` + `status: closed` means its feature work was integrated to `main` and shipped via `/qualia-ship`.** Main-push events are the exception trail, not the normal integration path.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Framework emits (this repo) vs ERP ingests (backend — out of scope here)
|
|
483
|
+
|
|
484
|
+
**Framework emits (delivered in this repo):**
|
|
485
|
+
- `qualia-framework project-sync [--json|--write]` builds and (optionally) persists the payload above.
|
|
486
|
+
- Deterministic, read-only, zero-dependency; composes project-snapshot; graceful on missing fields.
|
|
487
|
+
- The payload SHAPE and field semantics documented in this section are the contract.
|
|
488
|
+
|
|
489
|
+
**ERP ingests (backend work — NOT in this repo):**
|
|
490
|
+
- A `POST /api/v1/project-sync` endpoint (or extend project-snapshots to accept `payload: "project-sync"`) that authenticates the Bearer key and validates `schema_version`.
|
|
491
|
+
- Project resolution + the reconciliation logic in "How the ERP reconciles" above — upserting milestones/phases/tasks, marking completion from REQ counts, and storing `offroad_count`.
|
|
492
|
+
- Wiring an upload path (the framework's `project-snapshot.js` already has `uploadSnapshot()`/retry plumbing the ERP team can mirror for project-sync once the endpoint exists). project-sync itself ships **emit + document** only; the HTTP upload + server-side ingest are the backend's to build.
|
|
493
|
+
|
|
349
494
|
## Behavior
|
|
350
495
|
|
|
351
496
|
- When `erp.enabled` is `false`, `/qualia-report` skips the upload and prints an info line so the employee knows the report stayed local.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.22.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"
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"test:statusline": "bash tests/statusline.test.sh",
|
|
34
34
|
"test:refs": "bash tests/refs.test.sh",
|
|
35
35
|
"test:published-install": "bash tests/published-install-smoke.test.sh",
|
|
36
|
-
"test:shell": "bash tests/run-all.sh"
|
|
36
|
+
"test:shell": "bash tests/run-all.sh",
|
|
37
|
+
"compile:instructions": "node bin/compile-instructions.js"
|
|
37
38
|
},
|
|
38
39
|
"files": [
|
|
39
40
|
"bin/",
|