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.
Files changed (72) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +316 -0
  3. package/CLAUDE.md +3 -1
  4. package/agents/roadmapper.md +16 -14
  5. package/bin/agent-status.js +24 -11
  6. package/bin/batch-plan.js +111 -0
  7. package/bin/branch-hygiene.js +135 -0
  8. package/bin/command-surface.js +2 -0
  9. package/bin/compile-instructions.js +82 -0
  10. package/bin/design-tokens.js +131 -0
  11. package/bin/erp-event.js +177 -0
  12. package/bin/erp-retry.js +12 -1
  13. package/bin/eval-runner.js +218 -0
  14. package/bin/host-adapters.js +84 -12
  15. package/bin/install.js +44 -13
  16. package/bin/knowledge-flush.js +6 -3
  17. package/bin/last-report.js +207 -0
  18. package/bin/project-sync.js +315 -0
  19. package/bin/recall.js +172 -0
  20. package/bin/repo-map.js +188 -0
  21. package/bin/runtime-manifest.js +12 -0
  22. package/bin/state.js +112 -1
  23. package/bin/vault-access.js +82 -0
  24. package/bin/verify-panel.js +294 -0
  25. package/bin/wave-plan.js +211 -0
  26. package/docs/erp-contract.md +180 -0
  27. package/mcp/memory-mcp/server.js +257 -0
  28. package/package.json +6 -3
  29. package/qualia-design/design-dials.md +72 -0
  30. package/qualia-design/design-reference.md +24 -0
  31. package/rules/access.md +42 -0
  32. package/rules/codex-goal.md +28 -26
  33. package/rules/infrastructure.md +1 -1
  34. package/skills/qualia/SKILL.md +6 -0
  35. package/skills/qualia-build/SKILL.md +43 -9
  36. package/skills/qualia-eval/SKILL.md +83 -0
  37. package/skills/qualia-feature/SKILL.md +20 -4
  38. package/skills/qualia-fix/SKILL.md +13 -1
  39. package/skills/qualia-map/SKILL.md +15 -0
  40. package/skills/qualia-milestone/SKILL.md +12 -6
  41. package/skills/qualia-new/REFERENCE.md +6 -4
  42. package/skills/qualia-new/SKILL.md +41 -15
  43. package/skills/qualia-plan/SKILL.md +2 -2
  44. package/skills/qualia-polish/SKILL.md +3 -2
  45. package/skills/qualia-recall/SKILL.md +76 -0
  46. package/skills/qualia-report/SKILL.md +10 -0
  47. package/skills/qualia-scope/SKILL.md +3 -3
  48. package/skills/qualia-ship/SKILL.md +34 -4
  49. package/skills/qualia-update/SKILL.md +4 -0
  50. package/skills/qualia-verify/SKILL.md +53 -24
  51. package/templates/DESIGN.md +15 -0
  52. package/templates/instructions.md +32 -0
  53. package/templates/journey.md +1 -1
  54. package/templates/project-discovery.md +30 -23
  55. package/templates/requirements.md +7 -7
  56. package/tests/agent-status.test.sh +15 -0
  57. package/tests/batch-plan.test.sh +56 -0
  58. package/tests/branch-hygiene.test.sh +93 -0
  59. package/tests/design-tokens.test.sh +53 -0
  60. package/tests/erp-event.test.sh +78 -0
  61. package/tests/eval-runner.test.sh +147 -0
  62. package/tests/instructions.test.sh +109 -0
  63. package/tests/last-report.test.sh +156 -0
  64. package/tests/lib.test.sh +29 -4
  65. package/tests/project-sync.test.sh +175 -0
  66. package/tests/recall.test.sh +91 -0
  67. package/tests/repo-map.test.sh +70 -0
  68. package/tests/run-all.sh +12 -0
  69. package/tests/runner.js +363 -33
  70. package/tests/state.test.sh +92 -0
  71. package/tests/verify-panel.test.sh +162 -0
  72. 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
+ }
@@ -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
+ }