qualia-framework 6.9.2 → 6.14.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/CHANGELOG.md +78 -0
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +251 -0
- package/bin/analyze-gate.js +318 -0
- package/bin/command-surface.js +1 -0
- package/bin/install.js +6 -4
- package/bin/report-payload.js +7 -0
- package/bin/runtime-manifest.js +2 -0
- package/bin/state.js +145 -11
- package/docs/EMPLOYEE-QUICKSTART.md +3 -3
- package/docs/erp-contract.md +23 -0
- package/docs/qualia-manual.html +5 -5
- package/hooks/branch-guard.js +133 -63
- package/hooks/pre-deploy-gate.js +38 -0
- package/hooks/task-write-guard.js +165 -0
- package/package.json +1 -1
- package/skills/qualia-build/SKILL.md +30 -1
- package/skills/qualia-ship/SKILL.md +3 -0
- package/skills/qualia-update/SKILL.md +96 -0
- package/skills/qualia-verify/SKILL.md +7 -1
- package/templates/journey.md +1 -1
- package/tests/agent-status.test.sh +138 -0
- package/tests/analyze-gate.test.sh +170 -0
- package/tests/bin.test.sh +5 -4
- package/tests/hooks.test.sh +218 -17
- package/tests/install-smoke.test.sh +4 -3
- package/tests/lib.test.sh +2 -2
- package/tests/run-all.sh +2 -0
- package/tests/runner.js +3 -2
- package/tests/state.test.sh +95 -0
- package/skills/qualia-discuss/SKILL.md +0 -222
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// analyze-gate.js — cross-artifact consistency + coverage gate (Spec-Kit's
|
|
3
|
+
// most-copied feature). Runs BETWEEN plan and build: diffs the plan contract
|
|
4
|
+
// against the scope's acceptance criteria and the CONTEXT.md glossary, and
|
|
5
|
+
// flags requirements the plan under-covers plus contradictions it introduces.
|
|
6
|
+
//
|
|
7
|
+
// WHY: Qualia validates each artifact in isolation — plan-contract.js proves the
|
|
8
|
+
// contract is internally well-formed, harness-eval scores the built phase — but
|
|
9
|
+
// nothing diffs scope ↔ plan. That's exactly where a junior's idea silently
|
|
10
|
+
// loses intent: the scope asks for X, the plan quietly drops it, and no
|
|
11
|
+
// deterministic check notices. This is that check.
|
|
12
|
+
//
|
|
13
|
+
// DETERMINISTIC: keyword/token coverage, not an LLM. Same inputs → same output.
|
|
14
|
+
// Heuristic by nature (token overlap), so it is a *flag-for-review* gate: the
|
|
15
|
+
// caller decides hard-block (strict profile) vs advisory (standard). Exit 0 =
|
|
16
|
+
// clean, 1 = findings, 2 = invocation error.
|
|
17
|
+
//
|
|
18
|
+
// Inputs (auto-discovered from --phase, or passed explicitly):
|
|
19
|
+
// contract .planning/phase-{N}-contract.json (required)
|
|
20
|
+
// scope .planning/phase-{N}-context.md (optional)
|
|
21
|
+
// context .planning/CONTEXT.md (optional, glossary)
|
|
22
|
+
//
|
|
23
|
+
// Zero npm dependencies. Library + CLI.
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const pc = require("./plan-contract.js");
|
|
28
|
+
|
|
29
|
+
// Tokens shorter than this, or in the stop list, carry no coverage signal.
|
|
30
|
+
const MIN_TOKEN_LEN = 4;
|
|
31
|
+
const STOPWORDS = new Set([
|
|
32
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "your", "you",
|
|
33
|
+
"are", "was", "were", "will", "shall", "should", "must", "when", "then",
|
|
34
|
+
"each", "every", "any", "all", "not", "but", "via", "per", "page", "user",
|
|
35
|
+
"users", "data", "system", "feature", "support", "able", "ensure", "show",
|
|
36
|
+
"display", "have", "has", "can", "its", "their", "they", "them", "which",
|
|
37
|
+
"where", "what", "who", "how", "use", "uses", "used", "using", "new",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// A requirement is "covered" when at least this fraction of its significant
|
|
41
|
+
// tokens appear somewhere in the plan corpus, OR ABS_OVERLAP distinct tokens do.
|
|
42
|
+
const COVERAGE_RATIO = 0.5;
|
|
43
|
+
const ABS_OVERLAP = 3;
|
|
44
|
+
|
|
45
|
+
function tokenize(text) {
|
|
46
|
+
return String(text || "")
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.split(/[^a-z0-9]+/)
|
|
49
|
+
.filter((t) => t.length >= MIN_TOKEN_LEN && !STOPWORDS.has(t));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function significantTokens(text) {
|
|
53
|
+
return new Set(tokenize(text));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// covered ⇔ ≥COVERAGE_RATIO of the requirement's tokens are in the corpus, or
|
|
57
|
+
// ≥ABS_OVERLAP of them are. Requirements with no significant tokens are treated
|
|
58
|
+
// as covered (nothing to match — e.g. a one-word "Works.").
|
|
59
|
+
function coverage(reqText, corpusTokenSet) {
|
|
60
|
+
const reqTokens = [...significantTokens(reqText)];
|
|
61
|
+
if (reqTokens.length === 0) return { covered: true, overlap: 0, total: 0, ratio: 1 };
|
|
62
|
+
const overlap = reqTokens.filter((t) => corpusTokenSet.has(t)).length;
|
|
63
|
+
const ratio = overlap / reqTokens.length;
|
|
64
|
+
return {
|
|
65
|
+
covered: ratio >= COVERAGE_RATIO || overlap >= ABS_OVERLAP,
|
|
66
|
+
overlap,
|
|
67
|
+
total: reqTokens.length,
|
|
68
|
+
ratio,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Scope parsing ───────────────────────────────────────────────────────
|
|
73
|
+
// Pull the bullets under "## Acceptance Criteria" (testable observables) from a
|
|
74
|
+
// phase-context / scope markdown file. Bullets look like "- AC1 — {criterion}"
|
|
75
|
+
// or plain "- {criterion}". Stops at the next "## " heading.
|
|
76
|
+
function parseScopeAcceptanceCriteria(md) {
|
|
77
|
+
if (!md) return [];
|
|
78
|
+
const lines = md.split(/\r?\n/);
|
|
79
|
+
const out = [];
|
|
80
|
+
let inSection = false;
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (/^##\s+/.test(line)) {
|
|
83
|
+
inSection = /^##\s+Acceptance Criteria/i.test(line);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!inSection) continue;
|
|
87
|
+
const m = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
|
|
88
|
+
if (!m) continue;
|
|
89
|
+
// Strip a leading "AC12 —"/"AC12:"/"AC12 -" label so it isn't matched as a token.
|
|
90
|
+
const text = m[1].replace(/^AC\d+\s*[—:\-]\s*/i, "").trim();
|
|
91
|
+
if (text) out.push(text);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pull "Avoid:" aliases from CONTEXT.md glossary lines. The glossary bans
|
|
97
|
+
// alternative terms ("**Avoid:** AuthUser vs Customer"); using a banned alias in
|
|
98
|
+
// the plan is a genuine, deterministic cross-artifact contradiction.
|
|
99
|
+
function parseGlossaryBannedTerms(md) {
|
|
100
|
+
if (!md) return [];
|
|
101
|
+
const banned = new Set();
|
|
102
|
+
const lines = md.split(/\r?\n/);
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const m = line.match(/\*{0,2}Avoid:?\*{0,2}\s*(.+)$/i);
|
|
105
|
+
if (!m) continue;
|
|
106
|
+
// "AuthUser vs Customer (unless disambiguated)" → ["AuthUser", "Customer"]
|
|
107
|
+
const body = m[1].replace(/\([^)]*\)/g, " ");
|
|
108
|
+
for (const part of body.split(/\bvs\b|,|\/|;/i)) {
|
|
109
|
+
const term = part.trim().replace(/[.*_`]/g, "");
|
|
110
|
+
// Only meaningful identifier-like aliases (skip prose).
|
|
111
|
+
if (/^[A-Za-z][A-Za-z0-9 _-]{2,}$/.test(term) && !STOPWORDS.has(term.toLowerCase())) {
|
|
112
|
+
banned.add(term.trim());
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...banned];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Core analysis ─────────────────────────────────────────────────────────
|
|
120
|
+
function analyze({ contract, scopeMd, contextMd }) {
|
|
121
|
+
const findings = [];
|
|
122
|
+
|
|
123
|
+
const tasks = (contract && contract.tasks) || [];
|
|
124
|
+
const successCriteria = (contract && contract.success_criteria) || [];
|
|
125
|
+
|
|
126
|
+
// The plan corpus = everything the plan promises to do.
|
|
127
|
+
const planText = [
|
|
128
|
+
contract.goal,
|
|
129
|
+
contract.why,
|
|
130
|
+
...successCriteria,
|
|
131
|
+
...tasks.map((t) => t.title),
|
|
132
|
+
...tasks.map((t) => t.action),
|
|
133
|
+
...tasks.flatMap((t) => t.acceptance_criteria || []),
|
|
134
|
+
].filter(Boolean).join("\n");
|
|
135
|
+
const planTokens = significantTokens(planText);
|
|
136
|
+
|
|
137
|
+
// Per-task corpora for success-criterion → task mapping.
|
|
138
|
+
const taskCorpora = tasks.map((t) => ({
|
|
139
|
+
id: t.id,
|
|
140
|
+
tokens: significantTokens([t.title, t.action, ...(t.acceptance_criteria || [])].filter(Boolean).join("\n")),
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// 1. Scope acceptance criteria under-covered by the plan (the core check).
|
|
144
|
+
const scopeACs = parseScopeAcceptanceCriteria(scopeMd);
|
|
145
|
+
for (const ac of scopeACs) {
|
|
146
|
+
const cov = coverage(ac, planTokens);
|
|
147
|
+
if (!cov.covered) {
|
|
148
|
+
findings.push({
|
|
149
|
+
type: "uncovered-scope-ac",
|
|
150
|
+
severity: "HIGH",
|
|
151
|
+
message: `Scope acceptance criterion under-covered by the plan: "${ac}"`,
|
|
152
|
+
detail: `${cov.overlap}/${cov.total} key terms found in the contract (need ≥${Math.ceil(cov.total * COVERAGE_RATIO)} or ${ABS_OVERLAP}). The plan may have dropped this requirement.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Contract success criteria with no task home (orphan requirement).
|
|
158
|
+
for (const sc of successCriteria) {
|
|
159
|
+
const best = taskCorpora
|
|
160
|
+
.map((tc) => ({ id: tc.id, cov: coverage(sc, tc.tokens) }))
|
|
161
|
+
.sort((a, b) => b.cov.ratio - a.cov.ratio)[0];
|
|
162
|
+
if (!best || !best.cov.covered) {
|
|
163
|
+
findings.push({
|
|
164
|
+
type: "uncovered-success-criterion",
|
|
165
|
+
severity: "MEDIUM",
|
|
166
|
+
message: `Success criterion maps to no task: "${sc}"`,
|
|
167
|
+
detail: "No task's title/action/acceptance-criteria covers this success criterion. Either a task is missing or the criterion is unowned.",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. Glossary contradictions — a banned alias used in the plan text.
|
|
173
|
+
const banned = parseGlossaryBannedTerms(contextMd);
|
|
174
|
+
for (const term of banned) {
|
|
175
|
+
const re = new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
176
|
+
if (re.test(planText)) {
|
|
177
|
+
findings.push({
|
|
178
|
+
type: "glossary-violation",
|
|
179
|
+
severity: "MEDIUM",
|
|
180
|
+
message: `Plan uses a term CONTEXT.md bans: "${term}"`,
|
|
181
|
+
detail: "The glossary lists this under Avoid:. Use the canonical term so spec, plan, and code agree.",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 4. Scope-reduction phrases anywhere in the plan's free text (reuse the
|
|
187
|
+
// plan-contract scanner — surfaced here as part of the cross-artifact gate).
|
|
188
|
+
for (const t of tasks) {
|
|
189
|
+
const fields = [["action", t.action], ...(t.acceptance_criteria || []).map((a, i) => [`acceptance_criteria[${i}]`, a])];
|
|
190
|
+
for (const [field, val] of fields) {
|
|
191
|
+
const hits = pc.findScopeReductionPhrases(val);
|
|
192
|
+
if (hits.length) {
|
|
193
|
+
findings.push({
|
|
194
|
+
type: "scope-reduction",
|
|
195
|
+
severity: "HIGH",
|
|
196
|
+
message: `Task ${t.id} ${field} contains scope-reduction language: ${hits.join(", ")}`,
|
|
197
|
+
detail: "The plan waters down the spec. Deliver the actual requirement or split it via the locked-decision channel.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const bySeverity = (s) => findings.filter((f) => f.severity === s).length;
|
|
204
|
+
return {
|
|
205
|
+
ok: findings.length === 0,
|
|
206
|
+
phase: contract.phase,
|
|
207
|
+
counts: {
|
|
208
|
+
total: findings.length,
|
|
209
|
+
high: bySeverity("HIGH"),
|
|
210
|
+
medium: bySeverity("MEDIUM"),
|
|
211
|
+
scope_acs_checked: scopeACs.length,
|
|
212
|
+
success_criteria_checked: successCriteria.length,
|
|
213
|
+
glossary_terms_checked: banned.length,
|
|
214
|
+
},
|
|
215
|
+
scope_present: scopeMd != null,
|
|
216
|
+
context_present: contextMd != null,
|
|
217
|
+
findings,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
222
|
+
function readMaybe(p) {
|
|
223
|
+
try { return fs.readFileSync(p, "utf8"); } catch { return null; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseArgs(argv) {
|
|
227
|
+
const args = { _: [] };
|
|
228
|
+
for (let i = 2; i < argv.length; i++) {
|
|
229
|
+
const a = argv[i];
|
|
230
|
+
if (a === "--json") args.json = true;
|
|
231
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
232
|
+
else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
|
|
233
|
+
else if (a === "--contract") args.contract = argv[++i];
|
|
234
|
+
else if (a.startsWith("--contract=")) args.contract = a.slice(11);
|
|
235
|
+
else if (a === "--scope") args.scope = argv[++i];
|
|
236
|
+
else if (a.startsWith("--scope=")) args.scope = a.slice(8);
|
|
237
|
+
else if (a === "--context") args.context = argv[++i];
|
|
238
|
+
else if (a.startsWith("--context=")) args.context = a.slice(10);
|
|
239
|
+
else if (a === "--phase") args.phase = argv[++i];
|
|
240
|
+
else if (a.startsWith("--phase=")) args.phase = a.slice(8);
|
|
241
|
+
else args._.push(a);
|
|
242
|
+
}
|
|
243
|
+
if (args.phase == null && args._.length && /^\d+$/.test(args._[0])) args.phase = args._[0];
|
|
244
|
+
return args;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function usage() {
|
|
248
|
+
console.error([
|
|
249
|
+
"Usage:",
|
|
250
|
+
" analyze-gate.js <phase> [--cwd DIR] [--json]",
|
|
251
|
+
" analyze-gate.js --contract <c.json> [--scope <s.md>] [--context <ctx.md>] [--json]",
|
|
252
|
+
"",
|
|
253
|
+
"Cross-artifact coverage gate between plan and build.",
|
|
254
|
+
"Exit 0 = clean, 1 = findings, 2 = invocation error.",
|
|
255
|
+
].join("\n"));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function main(argv) {
|
|
259
|
+
const args = parseArgs(argv);
|
|
260
|
+
const root = path.resolve(args.cwd || process.cwd());
|
|
261
|
+
const planning = path.join(root, ".planning");
|
|
262
|
+
|
|
263
|
+
let contractPath = args.contract;
|
|
264
|
+
let scopePath = args.scope;
|
|
265
|
+
let contextPath = args.context;
|
|
266
|
+
if (!contractPath && args.phase != null) {
|
|
267
|
+
contractPath = path.join(planning, `phase-${args.phase}-contract.json`);
|
|
268
|
+
if (scopePath == null) scopePath = path.join(planning, `phase-${args.phase}-context.md`);
|
|
269
|
+
if (contextPath == null) contextPath = path.join(planning, "CONTEXT.md");
|
|
270
|
+
}
|
|
271
|
+
if (!contractPath) { usage(); return 2; }
|
|
272
|
+
|
|
273
|
+
const loaded = pc.readContractFile(contractPath);
|
|
274
|
+
if (!loaded.ok) {
|
|
275
|
+
if (args.json) console.log(JSON.stringify({ ok: false, ...loaded }, null, 2));
|
|
276
|
+
else console.error(`${loaded.error}: ${loaded.message}`);
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const result = analyze({
|
|
281
|
+
contract: loaded.contract,
|
|
282
|
+
scopeMd: scopePath ? readMaybe(scopePath) : null,
|
|
283
|
+
contextMd: contextPath ? readMaybe(contextPath) : null,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (args.json) {
|
|
287
|
+
console.log(JSON.stringify(result, null, 2));
|
|
288
|
+
return result.ok ? 0 : 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const c = result.counts;
|
|
292
|
+
if (result.ok) {
|
|
293
|
+
console.log(`ANALYZE PASS phase ${result.phase}: scope↔plan consistent ` +
|
|
294
|
+
`(${c.scope_acs_checked} scope AC, ${c.success_criteria_checked} success criteria, ${c.glossary_terms_checked} glossary terms checked)`);
|
|
295
|
+
if (!result.scope_present) console.log(" note: no scope file (phase-N-context.md) — scope-coverage check skipped");
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
console.error(`ANALYZE FINDINGS phase ${result.phase}: ${c.total} (${c.high} HIGH, ${c.medium} MEDIUM)`);
|
|
300
|
+
if (!result.scope_present) console.error(" note: no scope file — scope-coverage check was skipped");
|
|
301
|
+
for (const f of result.findings) {
|
|
302
|
+
console.error(` [${f.severity}] ${f.message}`);
|
|
303
|
+
console.error(` ${f.detail}`);
|
|
304
|
+
}
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
analyze,
|
|
310
|
+
coverage,
|
|
311
|
+
tokenize,
|
|
312
|
+
parseScopeAcceptanceCriteria,
|
|
313
|
+
parseGlossaryBannedTerms,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (require.main === module) {
|
|
317
|
+
process.exit(main(process.argv));
|
|
318
|
+
}
|
package/bin/command-surface.js
CHANGED
package/bin/install.js
CHANGED
|
@@ -555,8 +555,9 @@ function askCode() {
|
|
|
555
555
|
// ─── Employee mode (no team code) ────────────────────────
|
|
556
556
|
// A new employee may not have a QS-NAME-## code yet. Typing "EMPLOYEE" at the
|
|
557
557
|
// install-code prompt installs the full framework at the least-privilege role:
|
|
558
|
-
// feature branches
|
|
559
|
-
//
|
|
558
|
+
// feature branches by default; main pushes are allowed but recorded by
|
|
559
|
+
// branch-guard (counted locally + reported to the ERP, which trusts the role bit
|
|
560
|
+
// in the 0o600 config). ERP reporting stays OFF until a real team
|
|
560
561
|
// code is set, so /qualia-report degrades to a local-only report.
|
|
561
562
|
//
|
|
562
563
|
// Defaulting to EMPLOYEE (not OWNER) on a missing/keyword code is the secure
|
|
@@ -1276,7 +1277,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1276
1277
|
"session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
|
|
1277
1278
|
"pre-deploy-gate.js", "migration-guard.js", "pre-compact.js",
|
|
1278
1279
|
"git-guardrails.js", "stop-session-log.js",
|
|
1279
|
-
"fawzi-approval-guard.js",
|
|
1280
|
+
"fawzi-approval-guard.js", "task-write-guard.js",
|
|
1280
1281
|
// v5.0 — insights-driven destructive-op + wrong-account guards
|
|
1281
1282
|
"vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
|
|
1282
1283
|
// performance-audit telemetry capture (UserPromptSubmit)
|
|
@@ -1305,7 +1306,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1305
1306
|
{ type: "command", command: nodeCmd("auto-update.js"), timeout: 5 },
|
|
1306
1307
|
{ type: "command", command: nodeCmd("git-guardrails.js"), timeout: 5, statusMessage: "⬢ Checking git safety..." },
|
|
1307
1308
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1308
|
-
{ type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢
|
|
1309
|
+
{ type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢ Recording branch activity..." },
|
|
1309
1310
|
{ type: "command", if: "Bash(git push*)", command: nodeCmd("pre-push.js"), timeout: 15, statusMessage: "⬢ Syncing tracking..." },
|
|
1310
1311
|
{ type: "command", if: "Bash(vercel --prod*)", command: nodeCmd("pre-deploy-gate.js"), timeout: 600, statusMessage: "⬢ Running quality gates..." },
|
|
1311
1312
|
// v5.0 hooks — insights-driven friction prevention
|
|
@@ -1318,6 +1319,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1318
1319
|
matcher: "Edit|Write",
|
|
1319
1320
|
hooks: [
|
|
1320
1321
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1322
|
+
{ type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
|
|
1321
1323
|
{ type: "command", if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)", command: nodeCmd("migration-guard.js"), timeout: 10, statusMessage: "⬢ Checking migration safety..." },
|
|
1322
1324
|
],
|
|
1323
1325
|
},
|
package/bin/report-payload.js
CHANGED
|
@@ -122,6 +122,13 @@ function buildPayload(options = {}) {
|
|
|
122
122
|
phase_name: tracking.phase_name,
|
|
123
123
|
total_phases: tracking.total_phases,
|
|
124
124
|
status: tracking.status,
|
|
125
|
+
// v7 lifecycle: "build" (milestone journey) or "operate" (post-launch update
|
|
126
|
+
// stream). The ERP uses this to count updates vs milestones and to stop
|
|
127
|
+
// expecting a "handoff" for a launched product. lifetime.updates_completed
|
|
128
|
+
// rides along in `lifetime` below.
|
|
129
|
+
lifecycle: tracking.lifecycle || "build",
|
|
130
|
+
...(tracking.launched_at ? { launched_at: tracking.launched_at } : {}),
|
|
131
|
+
...(tracking.launch_source ? { launch_source: tracking.launch_source } : {}),
|
|
125
132
|
tasks_done: tracking.tasks_done || 0,
|
|
126
133
|
tasks_total: tracking.tasks_total || 0,
|
|
127
134
|
verification: tracking.verification || "pending",
|
package/bin/runtime-manifest.js
CHANGED
|
@@ -13,6 +13,8 @@ const RUNTIME_BIN_SCRIPTS = [
|
|
|
13
13
|
{ file: "state-ledger.js", label: "state-ledger.js (hash-chained state event ledger)" },
|
|
14
14
|
{ file: "plan-contract.js", label: "plan-contract.js (plan JSON validator)" },
|
|
15
15
|
{ file: "contract-runner.js", label: "contract-runner.js (contract evidence runner)" },
|
|
16
|
+
{ file: "agent-status.js", label: "agent-status.js (per-task build status + wave fan-in barrier)" },
|
|
17
|
+
{ file: "analyze-gate.js", label: "analyze-gate.js (cross-artifact scope↔plan coverage gate)" },
|
|
16
18
|
{ file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
|
|
17
19
|
{ file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
|
|
18
20
|
{ file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
|
package/bin/state.js
CHANGED
|
@@ -706,6 +706,9 @@ function ensureLifetime(t) {
|
|
|
706
706
|
total_phases: 0,
|
|
707
707
|
};
|
|
708
708
|
}
|
|
709
|
+
// v7 lifecycle (backward compat): pre-v7 tracking.json predates these fields.
|
|
710
|
+
if (t.lifecycle !== "operate" && t.lifecycle !== "build") t.lifecycle = "build";
|
|
711
|
+
if (typeof t.lifetime.updates_completed !== "number") t.lifetime.updates_completed = 0;
|
|
709
712
|
return t;
|
|
710
713
|
}
|
|
711
714
|
|
|
@@ -970,6 +973,25 @@ function checkPreconditions(current, target, opts) {
|
|
|
970
973
|
const anchors = doneWhenCount + acCount;
|
|
971
974
|
if (anchors < taskHeaders.length)
|
|
972
975
|
return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${anchors} 'Done when:' or 'Acceptance Criteria:' anchors`);
|
|
976
|
+
// v7 kernel: a phase cannot reach `planned` without a machine contract.
|
|
977
|
+
// checkMachineEvidence() at the `verified` gate only engages when
|
|
978
|
+
// phase-N-contract.json exists; if a contract could be skipped here, the
|
|
979
|
+
// entire evidence requirement is bypassable by omission and the prose
|
|
980
|
+
// verifier (which can PASS on inaction) would govern. Requiring it here is
|
|
981
|
+
// what makes "I built it" insufficient and "the contract ran clean" required.
|
|
982
|
+
const contractFile = path.join(PLANNING, `phase-${phase}-contract.json`);
|
|
983
|
+
if (!fs.existsSync(contractFile))
|
|
984
|
+
return fail(
|
|
985
|
+
"MISSING_CONTRACT",
|
|
986
|
+
`Machine contract not found: ${contractFile}. /qualia-plan must compile the plan into a JSON contract before 'planned'. Regenerate with /qualia-plan, or build it via bin/plan-contract.js.`
|
|
987
|
+
);
|
|
988
|
+
try {
|
|
989
|
+
const c = JSON.parse(fs.readFileSync(contractFile, "utf8"));
|
|
990
|
+
if (!c || !Array.isArray(c.tasks) || c.tasks.length === 0)
|
|
991
|
+
return fail("INVALID_CONTRACT", `${contractFile} has no tasks[]; not a valid phase contract.`);
|
|
992
|
+
} catch (e) {
|
|
993
|
+
return fail("INVALID_CONTRACT", `Could not parse ${contractFile}: ${e.message}`);
|
|
994
|
+
}
|
|
973
995
|
}
|
|
974
996
|
|
|
975
997
|
if (target === "verified") {
|
|
@@ -994,9 +1016,17 @@ function checkPreconditions(current, target, opts) {
|
|
|
994
1016
|
}
|
|
995
1017
|
|
|
996
1018
|
if (target === "handed_off") {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1019
|
+
// v7 lifecycle: the HANDOFF.md requirement is a BUILD-mode convention, not a
|
|
1020
|
+
// universal law. An "operate" project (a launched, repeatedly-shipping
|
|
1021
|
+
// product or retainer) has no single handoff moment, so it must not be
|
|
1022
|
+
// forced to produce one. opts.lifecycle is threaded from tracking by the
|
|
1023
|
+
// caller; absent/"build" keeps the original requirement.
|
|
1024
|
+
const lifecycle = opts.lifecycle || "build";
|
|
1025
|
+
if (lifecycle === "build") {
|
|
1026
|
+
const hFile = path.join(PLANNING, "HANDOFF.md");
|
|
1027
|
+
if (!fs.existsSync(hFile))
|
|
1028
|
+
return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
|
|
1029
|
+
}
|
|
1000
1030
|
}
|
|
1001
1031
|
|
|
1002
1032
|
// Gap-closure circuit breaker (configurable limit)
|
|
@@ -1055,7 +1085,14 @@ function recordLedgerEvent(meta) {
|
|
|
1055
1085
|
}
|
|
1056
1086
|
|
|
1057
1087
|
// ─── Next Command Logic ──────────────────────────────────
|
|
1058
|
-
|
|
1088
|
+
// `lifecycle` (default "build") changes the route once a project has launched.
|
|
1089
|
+
// In "operate" the project is an UPDATE STREAM, not a milestone journey: there is
|
|
1090
|
+
// no polish → ship → handoff terminal chain. After the last phase verifies, the
|
|
1091
|
+
// next move is the next update (/qualia-update), and the project never gets
|
|
1092
|
+
// dragged to a handoff it has outgrown. This is the v7 thesis in miniature —
|
|
1093
|
+
// a behavior that was hard-coded in prose is now a branch on explicit state.
|
|
1094
|
+
function nextCommand(status, phase, totalPhases, verification, lifecycle) {
|
|
1095
|
+
const operate = lifecycle === "operate";
|
|
1059
1096
|
switch (status) {
|
|
1060
1097
|
case "setup":
|
|
1061
1098
|
return `/qualia-plan ${phase}`;
|
|
@@ -1066,15 +1103,17 @@ function nextCommand(status, phase, totalPhases, verification) {
|
|
|
1066
1103
|
case "verified":
|
|
1067
1104
|
if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
|
|
1068
1105
|
if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
|
|
1069
|
-
return "/qualia-polish";
|
|
1106
|
+
return operate ? "/qualia-update" : "/qualia-polish";
|
|
1070
1107
|
case "polished":
|
|
1071
1108
|
return "/qualia-ship";
|
|
1072
1109
|
case "shipped":
|
|
1073
|
-
|
|
1110
|
+
// In build mode a shipped project hands off once. In operate it loops:
|
|
1111
|
+
// the deploy was just another update.
|
|
1112
|
+
return operate ? "/qualia-update" : "/qualia-handoff";
|
|
1074
1113
|
case "handed_off":
|
|
1075
1114
|
return "/qualia-report";
|
|
1076
1115
|
case "done":
|
|
1077
|
-
return "Done.";
|
|
1116
|
+
return operate ? "/qualia-update" : "Done.";
|
|
1078
1117
|
default:
|
|
1079
1118
|
return `/qualia`;
|
|
1080
1119
|
}
|
|
@@ -1202,11 +1241,14 @@ function cmdCheck(opts) {
|
|
|
1202
1241
|
tasks_done: t.tasks_done || 0,
|
|
1203
1242
|
tasks_total: t.tasks_total || 0,
|
|
1204
1243
|
deployed_url: t.deployed_url || "",
|
|
1244
|
+
lifecycle: t.lifecycle || "build",
|
|
1245
|
+
launched_at: t.launched_at || "",
|
|
1205
1246
|
next_command: nextCommand(
|
|
1206
1247
|
s.status,
|
|
1207
1248
|
s.phase,
|
|
1208
1249
|
s.total_phases,
|
|
1209
|
-
t.verification
|
|
1250
|
+
t.verification,
|
|
1251
|
+
t.lifecycle
|
|
1210
1252
|
),
|
|
1211
1253
|
schema_errors: s.schema_errors && s.schema_errors.length ? s.schema_errors : undefined,
|
|
1212
1254
|
});
|
|
@@ -1400,8 +1442,8 @@ function cmdTransition(opts) {
|
|
|
1400
1442
|
|
|
1401
1443
|
const phase = parseInt(opts.phase) || s.phase;
|
|
1402
1444
|
|
|
1403
|
-
// Precondition check
|
|
1404
|
-
const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase });
|
|
1445
|
+
// Precondition check (lifecycle threaded so the handoff gate can relax in operate)
|
|
1446
|
+
const check = checkPreconditions({ ...s, phase }, target, { ...opts, phase, lifecycle: t.lifecycle });
|
|
1405
1447
|
if (!check.ok) {
|
|
1406
1448
|
// --force bypasses status-ordering and plan-content errors. The use case
|
|
1407
1449
|
// is retroactive bookkeeping: a phase was built without /qualia-plan and
|
|
@@ -1433,6 +1475,18 @@ function cmdTransition(opts) {
|
|
|
1433
1475
|
if (target === "polished") applyPolishedTransition(s);
|
|
1434
1476
|
if (target === "shipped") applyShippedTransition(t, opts);
|
|
1435
1477
|
|
|
1478
|
+
// v7: in operate mode, a verified(pass) on the final phase completes one UPDATE.
|
|
1479
|
+
// This is the operate-mode analogue of closing a milestone in build mode.
|
|
1480
|
+
if (
|
|
1481
|
+
target === "verified" &&
|
|
1482
|
+
opts.verification === "pass" &&
|
|
1483
|
+
t.lifecycle === "operate" &&
|
|
1484
|
+
phase >= (parseInt(s.total_phases) || phase)
|
|
1485
|
+
) {
|
|
1486
|
+
ensureLifetime(t);
|
|
1487
|
+
t.lifetime.updates_completed = (t.lifetime.updates_completed || 0) + 1;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1436
1490
|
// Atomic commit
|
|
1437
1491
|
const writeError = commitTransitionAtomic(s, t);
|
|
1438
1492
|
if (writeError) return output(writeError);
|
|
@@ -1471,7 +1525,8 @@ function cmdTransition(opts) {
|
|
|
1471
1525
|
previous_status: prevStatus,
|
|
1472
1526
|
verification: t.verification,
|
|
1473
1527
|
gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
|
|
1474
|
-
|
|
1528
|
+
lifecycle: t.lifecycle || "build",
|
|
1529
|
+
next_command: nextCommand(s.status, s.phase, s.total_phases, t.verification, t.lifecycle),
|
|
1475
1530
|
};
|
|
1476
1531
|
if (ledger.ok) {
|
|
1477
1532
|
result.ledger_event_id = ledger.event_id;
|
|
@@ -1579,6 +1634,7 @@ function cmdInit(opts) {
|
|
|
1579
1634
|
milestones_completed: 0,
|
|
1580
1635
|
total_phases: 0,
|
|
1581
1636
|
last_closed_milestone: 0,
|
|
1637
|
+
updates_completed: 0,
|
|
1582
1638
|
};
|
|
1583
1639
|
const lifetime = prevLife
|
|
1584
1640
|
? { ...defaultLifetime, ...(prevLife.lifetime || {}) }
|
|
@@ -1607,6 +1663,11 @@ function cmdInit(opts) {
|
|
|
1607
1663
|
total_phases: totalPhases,
|
|
1608
1664
|
status: "setup",
|
|
1609
1665
|
profile,
|
|
1666
|
+
// v7 lifecycle: a new project starts in "build" (milestone journey).
|
|
1667
|
+
// `launch` flips it to "operate" (update stream). Preserved across re-init.
|
|
1668
|
+
lifecycle: prevLife ? (prevLife.lifecycle || "build") : "build",
|
|
1669
|
+
launched_at: prevLife ? (prevLife.launched_at || "") : "",
|
|
1670
|
+
launch_source: prevLife ? (prevLife.launch_source || "") : "",
|
|
1610
1671
|
wave: 0,
|
|
1611
1672
|
tasks_done: 0,
|
|
1612
1673
|
tasks_total: 0,
|
|
@@ -1665,6 +1726,76 @@ function cmdInit(opts) {
|
|
|
1665
1726
|
output(result);
|
|
1666
1727
|
}
|
|
1667
1728
|
|
|
1729
|
+
// v7: the one-time launch event. Flips the project from "build" (milestone
|
|
1730
|
+
// journey) to "operate" (update stream). This is the discrete transition the
|
|
1731
|
+
// ERP can drive when it detects a project is live (is_live / status:Launched),
|
|
1732
|
+
// so "the product is launched" becomes explicit state instead of a milestone the
|
|
1733
|
+
// team is forced to call "handoff". Idempotent: launching an operate project is
|
|
1734
|
+
// a no-op. lifecycle is canonical in tracking.json; cmdCheck surfaces it.
|
|
1735
|
+
function cmdLaunch(opts) {
|
|
1736
|
+
const beforeStateRaw = readState();
|
|
1737
|
+
const beforeTrackingRaw = readTrackingRaw();
|
|
1738
|
+
const t = parseTrackingRaw(beforeTrackingRaw);
|
|
1739
|
+
if (!t) return output(fail("NO_PROJECT", "No .planning/ found. Run /qualia-new."));
|
|
1740
|
+
ensureLifetime(t);
|
|
1741
|
+
|
|
1742
|
+
if (t.lifecycle === "operate") {
|
|
1743
|
+
return output({
|
|
1744
|
+
ok: true,
|
|
1745
|
+
action: "launch",
|
|
1746
|
+
already_launched: true,
|
|
1747
|
+
lifecycle: "operate",
|
|
1748
|
+
launched_at: t.launched_at || "",
|
|
1749
|
+
launch_source: t.launch_source || "",
|
|
1750
|
+
next_command: "/qualia-update",
|
|
1751
|
+
message: "Already launched (operate lifecycle). Nothing to do.",
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
t.lifecycle = "operate";
|
|
1756
|
+
t.launched_at = new Date().toISOString();
|
|
1757
|
+
t.launch_source = opts.source === "erp" ? "erp" : "manual";
|
|
1758
|
+
if (opts.deployed_url) t.deployed_url = opts.deployed_url;
|
|
1759
|
+
t.last_updated = new Date().toISOString();
|
|
1760
|
+
writeTracking(t);
|
|
1761
|
+
|
|
1762
|
+
const ledger = recordLedgerEvent({
|
|
1763
|
+
action: "launch",
|
|
1764
|
+
phase_before: t.phase || null,
|
|
1765
|
+
phase_after: t.phase || null,
|
|
1766
|
+
status_before: t.status || null,
|
|
1767
|
+
status_after: t.status || null,
|
|
1768
|
+
state_before: parseStateMd(beforeStateRaw),
|
|
1769
|
+
state_after: parseStateMd(readState()),
|
|
1770
|
+
tracking_before: parseTrackingRaw(beforeTrackingRaw),
|
|
1771
|
+
tracking_after: t,
|
|
1772
|
+
state_raw_before: beforeStateRaw,
|
|
1773
|
+
state_raw_after: readState(),
|
|
1774
|
+
tracking_raw_before: beforeTrackingRaw,
|
|
1775
|
+
tracking_raw_after: readTrackingRaw(),
|
|
1776
|
+
evidence_refs: opts.deployed_url ? [opts.deployed_url] : [],
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
const result = {
|
|
1780
|
+
ok: true,
|
|
1781
|
+
action: "launch",
|
|
1782
|
+
lifecycle: "operate",
|
|
1783
|
+
launched_at: t.launched_at,
|
|
1784
|
+
launch_source: t.launch_source,
|
|
1785
|
+
deployed_url: t.deployed_url || "",
|
|
1786
|
+
next_command: "/qualia-update",
|
|
1787
|
+
message:
|
|
1788
|
+
"Launched. The project is now an UPDATE STREAM (operate): no forced polish → ship → handoff. Ship updates with the build/verify/ship loop; the forced handoff is gone.",
|
|
1789
|
+
};
|
|
1790
|
+
if (ledger.ok) {
|
|
1791
|
+
result.ledger_event_id = ledger.event_id;
|
|
1792
|
+
result.ledger_event_hash = ledger.event_hash;
|
|
1793
|
+
} else {
|
|
1794
|
+
result.ledger_error = ledger.error;
|
|
1795
|
+
}
|
|
1796
|
+
output(result);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1668
1799
|
function cmdFix(opts) {
|
|
1669
1800
|
const beforeStateRaw = readState();
|
|
1670
1801
|
const beforeTrackingRaw = readTrackingRaw();
|
|
@@ -2775,6 +2906,9 @@ try {
|
|
|
2775
2906
|
case "transition":
|
|
2776
2907
|
cmdTransition(opts);
|
|
2777
2908
|
break;
|
|
2909
|
+
case "launch":
|
|
2910
|
+
cmdLaunch(opts);
|
|
2911
|
+
break;
|
|
2778
2912
|
case "init":
|
|
2779
2913
|
cmdInit(opts);
|
|
2780
2914
|
break;
|
|
@@ -29,7 +29,7 @@ Install code or "EMPLOYEE":
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
- Have a team code (`QS-NAME-##`)? Enter it — you install as that team member.
|
|
32
|
-
- **No code yet?** Type **`EMPLOYEE`**. You install at the least-privilege role
|
|
32
|
+
- **No code yet?** Type **`EMPLOYEE`**. You install at the least-privilege role. Feature branches are the norm; pushing to `main` is **allowed but recorded** — the `branch-guard` hook counts each employee main-push locally and reports it to the ERP so the OWNER can see it. The full framework — skills, agents, hooks, knowledge — is installed exactly the same.
|
|
33
33
|
|
|
34
34
|
What employee mode changes vs. a coded install:
|
|
35
35
|
|
|
@@ -37,7 +37,7 @@ What employee mode changes vs. a coded install:
|
|
|
37
37
|
|---|---|---|
|
|
38
38
|
| Role | OWNER or EMPLOYEE per code | EMPLOYEE |
|
|
39
39
|
| Skills / agents / hooks | full | full |
|
|
40
|
-
| Push to `main` | OWNER
|
|
40
|
+
| Push to `main` | OWNER (silent) | allowed, recorded to ERP |
|
|
41
41
|
| ERP reporting | on (with API key) | **off** until a code/key is set |
|
|
42
42
|
| `/qualia-report` | uploads to ERP | saves a **local** report file |
|
|
43
43
|
|
|
@@ -120,7 +120,7 @@ Pull these into a project with `vercel env pull` once the project is linked. **A
|
|
|
120
120
|
/qualia-ship
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Runs the quality gates, commits, deploys (Vercel via CLI), and verifies. As an employee
|
|
123
|
+
Runs the quality gates, commits, deploys (Vercel via CLI), and verifies. As an employee, prefer shipping through a **feature branch and review**. Direct pushes to `main` are allowed but `branch-guard` records each one (framework + ERP) for the OWNER, so use them only for changes that are trivially safe. Deploys happen only through the CLI; GitHub auto-deploy is intentionally disabled.
|
|
124
124
|
|
|
125
125
|
**Credentials this step needs:**
|
|
126
126
|
- A Vercel login on the correct team (`vercel whoami`; `vercel link` if the project isn't linked). Ask Fawzi which Vercel team the project belongs to.
|