qualia-framework 4.3.0 → 4.4.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/README.md +15 -11
- package/bin/agent-runs.js +233 -0
- package/bin/cli.js +218 -19
- package/bin/install.js +19 -5
- package/bin/plan-contract.js +220 -0
- package/bin/state.js +15 -9
- package/docs/agent-runs.md +273 -0
- package/docs/plan-contract.md +321 -0
- package/hooks/auto-update.js +3 -7
- package/hooks/pre-compact.js +22 -11
- package/hooks/pre-deploy-gate.js +16 -2
- package/hooks/pre-push.js +22 -2
- package/hooks/stop-session-log.js +1 -1
- package/package.json +8 -2
- package/skills/qualia-build/SKILL.md +5 -5
- package/skills/qualia-flush/SKILL.md +1 -1
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +1 -1
- package/skills/qualia-ship/SKILL.md +12 -10
- package/templates/help.html +13 -7
- package/tests/bin.test.sh +6 -3
- package/tests/hooks.test.sh +9 -20
- package/tests/lib.test.sh +217 -0
- package/tests/runner.js +96 -75
- package/tests/state.test.sh +4 -3
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# Plan Contract
|
|
2
|
+
|
|
3
|
+
Machine-readable plan format consumed by builder, verifier, plan-checker, and `state.js`. Replaces ad-hoc markdown re-parsing — markdown plans become presentation, this JSON contract is truth.
|
|
4
|
+
|
|
5
|
+
Status: **draft, v1.** Pressure-test the shape against real phases before locking.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Today, `templates/plan.md` is structured markdown. Planner emits it, builder re-interprets it, verifier re-interprets it, plan-checker re-interprets it. Three independent LLM interpretations of the same prose = drift. The drift is invisible until verification fails for a reason that doesn't match the planner's intent.
|
|
10
|
+
|
|
11
|
+
The contract shifts every machine-driven step (task assignment, dependency check, verification execution) onto deterministic JSON. Prose stays in `phase-N-plan.md` for humans; code reads `phase-N-contract.json`.
|
|
12
|
+
|
|
13
|
+
## File layout
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
.planning/
|
|
17
|
+
phase-1-plan.md # human-facing prose (existing)
|
|
18
|
+
phase-1-contract.json # machine truth (NEW)
|
|
19
|
+
phase-1-deviations.json # builder→verifier deltas (existing)
|
|
20
|
+
phase-1-verification.md # verifier output (existing)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`contract.json` is committed. It is regenerated only by re-running `/qualia-plan` or `qualia-framework state.js compile-plan`.
|
|
24
|
+
|
|
25
|
+
## Schema (v1)
|
|
26
|
+
|
|
27
|
+
TypeScript-flavored for readability. Authoritative validator lives at `bin/lib/plan-contract.js` (Zod or hand-rolled — TBD; framework currently has zero deps).
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
interface PlanContract {
|
|
31
|
+
version: 1; // bump on breaking change
|
|
32
|
+
phase: number; // 1-indexed
|
|
33
|
+
goal: string; // 1-2 sentences, what's TRUE when done
|
|
34
|
+
why: string; // unlocks-what; one sentence
|
|
35
|
+
generated_at: string; // ISO 8601 UTC
|
|
36
|
+
generated_by: "planner" | "compile-plan" | "manual";
|
|
37
|
+
source_plan_hash: string; // sha256 of phase-N-plan.md at compile time; "" if generated_by="manual"
|
|
38
|
+
tasks: Task[];
|
|
39
|
+
success_criteria: string[]; // phase-level user-facing truths
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Task {
|
|
43
|
+
id: string; // "T1", "T2" — stable across reorders
|
|
44
|
+
title: string;
|
|
45
|
+
wave: number; // 1-indexed; tasks in same wave run in parallel
|
|
46
|
+
depends_on: string[]; // task ids this task needs
|
|
47
|
+
persona?: PersonaTag; // optional, for agent specialization
|
|
48
|
+
files_modify: string[]; // repo-relative paths
|
|
49
|
+
files_create: string[]; // repo-relative paths
|
|
50
|
+
files_delete: string[]; // repo-relative paths (for refactors that remove code)
|
|
51
|
+
acceptance_criteria: string[]; // observable behaviors (human-facing)
|
|
52
|
+
action: string; // concrete builder steps (advisory prose, max 500 chars)
|
|
53
|
+
context_files: string[]; // repo-relative paths the builder should read
|
|
54
|
+
verification: VerificationCheck[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type PersonaTag =
|
|
58
|
+
| "security" | "architect" | "ux" | "frontend"
|
|
59
|
+
| "backend" | "data" | "performance" | "none";
|
|
60
|
+
|
|
61
|
+
type VerificationCheck =
|
|
62
|
+
| FileExistsCheck
|
|
63
|
+
| GrepMatchCheck
|
|
64
|
+
| CommandExitCheck
|
|
65
|
+
| BehavioralCheck;
|
|
66
|
+
|
|
67
|
+
interface FileExistsCheck {
|
|
68
|
+
type: "file-exists";
|
|
69
|
+
path: string; // repo-relative
|
|
70
|
+
must_contain?: string; // optional substring assertion
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface GrepMatchCheck {
|
|
74
|
+
type: "grep-match";
|
|
75
|
+
path: string; // file or glob
|
|
76
|
+
pattern: string; // regex
|
|
77
|
+
expect: "present" | "absent";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CommandExitCheck {
|
|
81
|
+
type: "command-exit";
|
|
82
|
+
command: string; // executed via execFile, NOT shell
|
|
83
|
+
args: string[]; // positional args (no shell parsing)
|
|
84
|
+
cwd?: string; // repo-relative; default = repo root
|
|
85
|
+
expected_exit: number; // typically 0
|
|
86
|
+
timeout_ms?: number; // default 30000
|
|
87
|
+
expect_stdout_match?: string; // regex; optional
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface BehavioralCheck {
|
|
91
|
+
type: "behavioral";
|
|
92
|
+
description: string; // human-readable; verifier interprets
|
|
93
|
+
evidence_required: Evidence[]; // structured citation requirements; vibes-based passes blocked at schema level
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface Evidence {
|
|
97
|
+
path: string; // repo-relative file path the verifier must cite
|
|
98
|
+
matcher?: string; // optional regex the cited line must satisfy
|
|
99
|
+
description: string; // what the cited line should demonstrate
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Why these four check types
|
|
104
|
+
|
|
105
|
+
They map 1:1 with the existing markdown Verification Contract section, so compilation is mechanical:
|
|
106
|
+
|
|
107
|
+
| Markdown section | Maps to |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `Check type: file-exists` | `FileExistsCheck` |
|
|
110
|
+
| `Check type: grep-match` | `GrepMatchCheck` |
|
|
111
|
+
| `Check type: command-exit` | `CommandExitCheck` |
|
|
112
|
+
| `Check type: behavioral` | `BehavioralCheck` (last resort) |
|
|
113
|
+
|
|
114
|
+
`behavioral` is the only check that retains LLM interpretation — and even there, the schema forces evidence-required so the verifier can't produce vibes-based passes.
|
|
115
|
+
|
|
116
|
+
## Example: a real phase contract
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"version": 1,
|
|
121
|
+
"phase": 2,
|
|
122
|
+
"goal": "Authenticated users can sign in with email/password and reach the dashboard.",
|
|
123
|
+
"why": "Session persistence is the #1 abandonment trigger in onboarding — verification emails are wasted without it.",
|
|
124
|
+
"generated_at": "2026-04-28T14:32:00Z",
|
|
125
|
+
"generated_by": "planner",
|
|
126
|
+
"source_plan_hash": "sha256:9c1ae6f2b4d8e1f3a5c7b9d0e2f4a6c8e0b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9",
|
|
127
|
+
"tasks": [
|
|
128
|
+
{
|
|
129
|
+
"id": "T1",
|
|
130
|
+
"title": "Add email/password sign-in handler",
|
|
131
|
+
"wave": 1,
|
|
132
|
+
"depends_on": [],
|
|
133
|
+
"persona": "backend",
|
|
134
|
+
"files_modify": ["src/lib/auth.ts"],
|
|
135
|
+
"files_create": ["src/lib/auth-schema.ts"],
|
|
136
|
+
"files_delete": [],
|
|
137
|
+
"acceptance_criteria": [
|
|
138
|
+
"POST /api/auth/signin returns 200 with valid creds",
|
|
139
|
+
"POST /api/auth/signin returns 401 with invalid creds",
|
|
140
|
+
"Session cookie is httpOnly and sameSite=lax"
|
|
141
|
+
],
|
|
142
|
+
"action": "Use supabase.auth.signInWithPassword. Validate email/password with Zod schema. Set cookie via Next.js Response API.",
|
|
143
|
+
"context_files": [
|
|
144
|
+
"src/lib/supabase/server.ts",
|
|
145
|
+
"src/lib/supabase/client.ts"
|
|
146
|
+
],
|
|
147
|
+
"verification": [
|
|
148
|
+
{
|
|
149
|
+
"type": "file-exists",
|
|
150
|
+
"path": "src/lib/auth-schema.ts",
|
|
151
|
+
"must_contain": "z.object"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"type": "command-exit",
|
|
155
|
+
"command": "npx",
|
|
156
|
+
"args": ["tsc", "--noEmit"],
|
|
157
|
+
"expected_exit": 0,
|
|
158
|
+
"timeout_ms": 60000
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"type": "grep-match",
|
|
162
|
+
"path": "src/lib/auth.ts",
|
|
163
|
+
"pattern": "signInWithPassword",
|
|
164
|
+
"expect": "present"
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "T2",
|
|
170
|
+
"title": "Wire sign-in form to handler",
|
|
171
|
+
"wave": 2,
|
|
172
|
+
"depends_on": ["T1"],
|
|
173
|
+
"persona": "frontend",
|
|
174
|
+
"files_modify": ["src/app/(auth)/signin/page.tsx"],
|
|
175
|
+
"files_create": [],
|
|
176
|
+
"files_delete": [],
|
|
177
|
+
"acceptance_criteria": [
|
|
178
|
+
"Form posts to /api/auth/signin",
|
|
179
|
+
"Error toast shows on 401",
|
|
180
|
+
"Redirect to /dashboard on 200"
|
|
181
|
+
],
|
|
182
|
+
"action": "Add server action; show error state via useFormState; redirect via redirect() from next/navigation.",
|
|
183
|
+
"context_files": ["src/app/(auth)/signin/page.tsx"],
|
|
184
|
+
"verification": [
|
|
185
|
+
{
|
|
186
|
+
"type": "behavioral",
|
|
187
|
+
"description": "Form submission with valid creds redirects to /dashboard",
|
|
188
|
+
"evidence_required": [
|
|
189
|
+
{
|
|
190
|
+
"path": "src/app/(auth)/signin/page.tsx",
|
|
191
|
+
"matcher": "redirect\\(['\"]/dashboard",
|
|
192
|
+
"description": "redirect() call targeting /dashboard after successful signin"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"path": "src/app/(auth)/signin/page.tsx",
|
|
196
|
+
"matcher": "useFormState|action=",
|
|
197
|
+
"description": "form is wired to a server action or POST handler"
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
"success_criteria": [
|
|
205
|
+
"User can sign in with valid credentials and land on /dashboard",
|
|
206
|
+
"User sees a clear error message on invalid credentials without leaving the page",
|
|
207
|
+
"Session persists across page reloads"
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Validation rules (enforced at emission)
|
|
213
|
+
|
|
214
|
+
1. **`tasks[].id` must be unique** within the phase.
|
|
215
|
+
2. **Task ids must match** `^T\d+$` — `T1`, `T2`, etc. The compiler prefixes markdown task numbers (`## Task 1` → `T1`).
|
|
216
|
+
3. **`depends_on` must reference ids that exist** in the same contract.
|
|
217
|
+
4. **No cycles in `depends_on`.**
|
|
218
|
+
5. **Wave assignment must respect dependencies** — a task's wave must be `>` than the max wave of its dependencies. (Trivially: if T2 depends on T1, T2.wave > T1.wave.)
|
|
219
|
+
6. **At least one verification check per task.** Empty `verification: []` is rejected.
|
|
220
|
+
7. **`files_modify`, `files_create`, `files_delete` are pairwise disjoint** — a file is in at most one of the three.
|
|
221
|
+
8. **`command-exit` checks must use execFile-safe args** — no shell metacharacters in `command`; `args[]` carries positional values.
|
|
222
|
+
9. **`success_criteria` minimum 1 entry.**
|
|
223
|
+
10. **`action` ≤ 500 characters** — enforced. Keeps planner from over-specifying implementation.
|
|
224
|
+
11. **`evidence_required[].path` must be repo-relative** and `matcher` (when present) must be a valid regex.
|
|
225
|
+
|
|
226
|
+
`bin/state.js validate-plan` runs these. Failures block transition to `built`.
|
|
227
|
+
|
|
228
|
+
Validator implementation: hand-rolled at `bin/lib/plan-contract.js`, ~80 LOC, zero dependencies. Framework's no-deps posture is preserved.
|
|
229
|
+
|
|
230
|
+
## Drift detection (contract vs markdown)
|
|
231
|
+
|
|
232
|
+
Manual edits to `phase-N-plan.md` happen in practice. Without detection, the contract silently goes stale: builder reads JSON truth that no longer matches what humans see in markdown.
|
|
233
|
+
|
|
234
|
+
`source_plan_hash` is `sha256(plan_md_contents)` at compile time, prefixed `sha256:`. Stored in the contract.
|
|
235
|
+
|
|
236
|
+
`bin/state.js validate-plan --check-drift` re-hashes the current plan markdown and compares. Drift behavior:
|
|
237
|
+
|
|
238
|
+
| Scenario | Action |
|
|
239
|
+
|---|---|
|
|
240
|
+
| Hashes match | OK, no output |
|
|
241
|
+
| Hashes differ | Exit 2, message: `plan.md drifted from contract; run compile-plan --refresh` |
|
|
242
|
+
| Contract missing `source_plan_hash` (legacy or `manual`) | Warn but pass — drift checking disabled for that contract |
|
|
243
|
+
|
|
244
|
+
`compile-plan --refresh` re-reads markdown, regenerates contract, updates hash. Builder/verifier refuse to run if `--check-drift` fails.
|
|
245
|
+
|
|
246
|
+
## Verification execution errors
|
|
247
|
+
|
|
248
|
+
A check that *cannot run* (binary missing, timeout, cwd doesn't exist) is distinct from a check that *ran and failed*. The verifier records:
|
|
249
|
+
|
|
250
|
+
| Outcome | `verification_result` | `failure_reason` |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| Check ran, passed | `pass` | — |
|
|
253
|
+
| Check ran, criteria unmet | `fail` | `verification-criteria-unmet` |
|
|
254
|
+
| Behavioral check, evidence missing | `fail` | `verification-evidence-missing` |
|
|
255
|
+
| Check itself errored (cmd not found, timeout, etc.) | `partial` | `verification-execution-error` |
|
|
256
|
+
|
|
257
|
+
Execution errors are NOT verification failures. They block phase advance the same way, but a postmortem treats them differently — fix the infrastructure, then re-run.
|
|
258
|
+
|
|
259
|
+
## How builder reads it
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
// pseudocode — the actual implementation lives in skills/qualia-build
|
|
263
|
+
const contract = JSON.parse(fs.readFileSync(`.planning/phase-${N}-contract.json`));
|
|
264
|
+
const myTask = contract.tasks.find(t => t.id === assignedTaskId);
|
|
265
|
+
|
|
266
|
+
// builder gets:
|
|
267
|
+
// - exact files to touch
|
|
268
|
+
// - acceptance_criteria as the "definition of done"
|
|
269
|
+
// - context_files to read first
|
|
270
|
+
// - verification[] is the self-check before declaring DONE
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The builder still receives the Action prose as advisory guidance. The contract is the boundary.
|
|
274
|
+
|
|
275
|
+
## How verifier reads it
|
|
276
|
+
|
|
277
|
+
For each task in the contract:
|
|
278
|
+
1. Walk `verification[]`.
|
|
279
|
+
2. For deterministic checks (`file-exists`, `grep-match`, `command-exit`): execute and record pass/fail with stdout/stderr captured. Distinguish "ran and failed" (`verification-criteria-unmet`) from "could not run" (`verification-execution-error`).
|
|
280
|
+
3. For `behavioral` checks: for each `evidence_required[i]`, the verifier MUST produce a `{path, line, snippet}` citation. If `matcher` is present, the cited line must satisfy the regex. Missing evidence or matcher mismatch → automatic `verification-evidence-missing`.
|
|
281
|
+
4. Aggregate per-task → per-phase pass/fail.
|
|
282
|
+
5. Write `phase-N-verification.json` (machine output) alongside `phase-N-verification.md` (human output).
|
|
283
|
+
|
|
284
|
+
This eliminates the "verifier wrote a glowing pass when half the criteria weren't actually met" failure mode — `evidence_required[]` is structured, so vibes-based passes are blocked at the schema level.
|
|
285
|
+
|
|
286
|
+
## Compile mode (migrating in-flight projects)
|
|
287
|
+
|
|
288
|
+
`bin/state.js compile-plan --phase N` reads `phase-N-plan.md` and emits a best-effort `phase-N-contract.json`:
|
|
289
|
+
|
|
290
|
+
- Frontmatter → `phase`, `goal`
|
|
291
|
+
- `## Task N — title` blocks → `tasks[]`
|
|
292
|
+
- `**Files:**` line → `files_modify` (cannot distinguish create vs modify from prose; defaults to modify, warns)
|
|
293
|
+
- `**Acceptance Criteria:**` bullets → `acceptance_criteria`
|
|
294
|
+
- `### Contract for Task N` blocks → `verification[]`
|
|
295
|
+
- Missing fields → `compile-plan` exits non-zero with a list of gaps
|
|
296
|
+
|
|
297
|
+
Compile is a one-time bridge. New plans emit JSON directly from the planner agent.
|
|
298
|
+
|
|
299
|
+
## Design decisions (locked v1)
|
|
300
|
+
|
|
301
|
+
These were called out as open questions during draft; resolved here so implementation can proceed.
|
|
302
|
+
|
|
303
|
+
1. **Persona enum:** dropped `data` — covered by `backend`. Six personas + `none`.
|
|
304
|
+
2. **`acceptance_criteria` vs `verification[]`:** kept separate. AC is the human-facing definition of done (lands in commit messages, milestone summaries, ERP reports). `verification[]` is the mechanical execution path. The verifier never interprets AC — it executes `verification[]`. This separation is the whole point of the contract.
|
|
305
|
+
3. **`action` cap:** 500 chars. Advisory only. Validator enforces.
|
|
306
|
+
4. **Versioning:** in-place migration via `compile-plan --upgrade`. `version` field tells the loader which schema to apply. No filename suffixes — canonical filename stays `phase-N-contract.json`.
|
|
307
|
+
5. **Wave placement:** lives on the task. The validator enforces `task.wave > max(deps wave)` so the redundancy with `depends_on` is contained. Wave is a scheduling/display hint; `depends_on` is the constraint.
|
|
308
|
+
6. **`behavioral` checks:** permanent. UX feel, error message clarity, animation timing — none of these are deterministic. The escape hatch is healthy. The `evidence_required[]` field forces verifier to cite proof; vibes-based passes are blocked at the schema level.
|
|
309
|
+
7. **Validator:** hand-rolled in plain Node. Framework keeps zero npm dependencies. Zod is rejected for this layer.
|
|
310
|
+
|
|
311
|
+
## Migration plan
|
|
312
|
+
|
|
313
|
+
1. Add schema + validator + `compile-plan` command. No callers yet.
|
|
314
|
+
2. Backfill contracts for active projects via `compile-plan` — manual review of warnings.
|
|
315
|
+
3. Update planner agent prompt to emit JSON alongside markdown.
|
|
316
|
+
4. Update builder skill to read JSON for files/AC/verification; markdown still readable.
|
|
317
|
+
5. Update verifier agent to execute `verification[]` deterministically; keep prose verification report for humans.
|
|
318
|
+
6. Update plan-checker to validate JSON.
|
|
319
|
+
7. After two milestones run cleanly on JSON, mark prose plan as advisory-only in docs.
|
|
320
|
+
|
|
321
|
+
No hard cutover. Both formats coexist during migration.
|
package/hooks/auto-update.js
CHANGED
|
@@ -63,12 +63,12 @@ try {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// Synchronously fetch the latest version from npm. Tight timeout so the hook
|
|
66
|
-
// never blocks Claude Code for long.
|
|
67
|
-
//
|
|
68
|
-
// when the network is unreachable).
|
|
66
|
+
// never blocks Claude Code for long. Stamp the cache before the network call:
|
|
67
|
+
// if npm/DNS is down, we avoid paying the timeout on every Bash command.
|
|
69
68
|
let latest = "";
|
|
70
69
|
try {
|
|
71
70
|
fs.writeFileSync(LOCK_FILE, String(process.pid));
|
|
71
|
+
fs.writeFileSync(CACHE_FILE, String(Math.floor(Date.now() / 1000)));
|
|
72
72
|
const r = spawnSync("npm", ["view", "qualia-framework", "version"], {
|
|
73
73
|
encoding: "utf8",
|
|
74
74
|
timeout: 3000,
|
|
@@ -80,14 +80,10 @@ try {
|
|
|
80
80
|
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
81
81
|
|
|
82
82
|
if (!latest) {
|
|
83
|
-
// Fetch failed — leave cache untouched so the next call retries.
|
|
84
83
|
_trace("auto-update", "allow", { reason: "npm-fetch-failed" });
|
|
85
84
|
process.exit(0);
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
// Successful fetch — debounce future checks for 24h.
|
|
89
|
-
fs.writeFileSync(CACHE_FILE, String(Math.floor(Date.now() / 1000)));
|
|
90
|
-
|
|
91
87
|
const cmp = (a, b) => {
|
|
92
88
|
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
93
89
|
for (let i = 0; i < 3; i++) {
|
package/hooks/pre-compact.js
CHANGED
|
@@ -37,6 +37,23 @@ function readCompactConfig() {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function git(args, opts = {}) {
|
|
41
|
+
return spawnSync("git", args, {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
timeout: 3000,
|
|
44
|
+
shell: process.platform === "win32",
|
|
45
|
+
...opts,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stateHasPendingChanges() {
|
|
50
|
+
const diff = git(["diff", "--name-only", "--", STATE_FILE]);
|
|
51
|
+
if ((diff.stdout || "").split(/\r?\n/).includes(STATE_FILE)) return true;
|
|
52
|
+
|
|
53
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard", "--", STATE_FILE]);
|
|
54
|
+
return (untracked.stdout || "").split(/\r?\n/).includes(STATE_FILE);
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
let _commitStatus = null;
|
|
41
58
|
let _commitReason = "no-state-file";
|
|
42
59
|
let _commitFlags = null;
|
|
@@ -45,17 +62,9 @@ try {
|
|
|
45
62
|
if (fs.existsSync(STATE_FILE)) {
|
|
46
63
|
console.log("QUALIA: Saving state before compaction...");
|
|
47
64
|
_commitReason = "state-clean";
|
|
48
|
-
// Check if STATE.md has
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
timeout: 3000,
|
|
52
|
-
shell: process.platform === "win32",
|
|
53
|
-
});
|
|
54
|
-
if ((diff.stdout || "").includes("STATE.md")) {
|
|
55
|
-
const addRes = spawnSync("git", ["add", STATE_FILE], {
|
|
56
|
-
timeout: 3000,
|
|
57
|
-
shell: process.platform === "win32",
|
|
58
|
-
});
|
|
65
|
+
// Check if STATE.md has tracked or untracked changes.
|
|
66
|
+
if (stateHasPendingChanges()) {
|
|
67
|
+
const addRes = git(["add", STATE_FILE]);
|
|
59
68
|
const cfg = readCompactConfig();
|
|
60
69
|
const commitArgs = ["commit"];
|
|
61
70
|
if (!cfg.respect_user_hooks) commitArgs.push("--no-verify");
|
|
@@ -76,6 +85,8 @@ try {
|
|
|
76
85
|
_commitReason = addRes.status === 0 && commitRes.status === 0
|
|
77
86
|
? "committed"
|
|
78
87
|
: "commit-failed";
|
|
88
|
+
} else {
|
|
89
|
+
_commitReason = "state-clean";
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
} catch {
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -31,7 +31,8 @@ function _trace(hookName, result, extra) {
|
|
|
31
31
|
|
|
32
32
|
function runGate(label, cmd, args, { required = true } = {}) {
|
|
33
33
|
const r = spawnSync(cmd, args, {
|
|
34
|
-
stdio: "ignore",
|
|
34
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
35
|
+
encoding: "utf8",
|
|
35
36
|
timeout: 180000,
|
|
36
37
|
shell: process.platform === "win32",
|
|
37
38
|
});
|
|
@@ -41,7 +42,20 @@ function runGate(label, cmd, args, { required = true } = {}) {
|
|
|
41
42
|
}
|
|
42
43
|
if (required) {
|
|
43
44
|
console.error(`BLOCKED: ${label} errors. Fix before deploying.`);
|
|
44
|
-
|
|
45
|
+
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
46
|
+
if (output) {
|
|
47
|
+
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
48
|
+
console.error(`Last ${lines.length} output line${lines.length === 1 ? "" : "s"}:`);
|
|
49
|
+
for (const line of lines) console.error(` ${line}`);
|
|
50
|
+
} else if (r.error) {
|
|
51
|
+
console.error(` ${r.error.message}`);
|
|
52
|
+
}
|
|
53
|
+
_trace("pre-deploy-gate", "block", {
|
|
54
|
+
gate: label,
|
|
55
|
+
status: r.status,
|
|
56
|
+
signal: r.signal || undefined,
|
|
57
|
+
error: r.error ? r.error.message : undefined,
|
|
58
|
+
});
|
|
45
59
|
process.exit(2);
|
|
46
60
|
}
|
|
47
61
|
return false;
|
package/hooks/pre-push.js
CHANGED
|
@@ -102,6 +102,15 @@ function commitStamp() {
|
|
|
102
102
|
return { committed: true, sha: lastCommit, ts: now };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function shouldBlock(result) {
|
|
106
|
+
const hardSkips = new Set([
|
|
107
|
+
"tracking-unreadable",
|
|
108
|
+
"git-add-failed",
|
|
109
|
+
"git-commit-failed",
|
|
110
|
+
]);
|
|
111
|
+
return result && hardSkips.has(result.skipped);
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
function _trace(result, extra) {
|
|
106
115
|
try {
|
|
107
116
|
const traceDir = path.join(HOME, ".claude", ".qualia-traces");
|
|
@@ -120,10 +129,21 @@ function _trace(result, extra) {
|
|
|
120
129
|
|
|
121
130
|
try {
|
|
122
131
|
const result = commitStamp();
|
|
132
|
+
if (shouldBlock(result)) {
|
|
133
|
+
const detail = result.error ? ` ${String(result.error).slice(0, 500)}` : "";
|
|
134
|
+
const msg = `BLOCKED: tracking.json sync failed (${result.skipped}). Fix before pushing.${detail}`;
|
|
135
|
+
console.error(msg);
|
|
136
|
+
console.log(msg);
|
|
137
|
+
_trace("block", result);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
}
|
|
123
140
|
_trace("allow", result);
|
|
124
141
|
} catch (err) {
|
|
125
|
-
|
|
126
|
-
|
|
142
|
+
const msg = `BLOCKED: tracking.json sync failed (${err.message}). Fix before pushing.`;
|
|
143
|
+
console.error(msg);
|
|
144
|
+
console.log(msg);
|
|
145
|
+
_trace("block", { error: err.message });
|
|
146
|
+
process.exit(2);
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
process.exit(0);
|
|
@@ -103,7 +103,7 @@ try {
|
|
|
103
103
|
const tracking = readJson(path.join(repoRoot, ".planning", "tracking.json"));
|
|
104
104
|
if (tracking) {
|
|
105
105
|
const p = tracking.phase || 0;
|
|
106
|
-
const pt = tracking.phase_total || 0;
|
|
106
|
+
const pt = tracking.total_phases || tracking.phase_total || 0;
|
|
107
107
|
if (pt > 0) phase = `${p}/${pt}`;
|
|
108
108
|
const td = tracking.tasks_done || 0;
|
|
109
109
|
const tt = tracking.tasks_total || 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework": "./bin/cli.js"
|
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/Qualiasolutions/qualia-framework#readme",
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "
|
|
26
|
+
"test": "npm run test:shell",
|
|
27
|
+
"test:state": "bash tests/state.test.sh",
|
|
28
|
+
"test:hooks": "bash tests/hooks.test.sh",
|
|
29
|
+
"test:bin": "bash tests/bin.test.sh",
|
|
30
|
+
"test:lib": "bash tests/lib.test.sh",
|
|
31
|
+
"test:statusline": "bash tests/statusline.test.sh",
|
|
32
|
+
"test:shell": "bash tests/statusline.test.sh && bash tests/state.test.sh && bash tests/hooks.test.sh && bash tests/bin.test.sh && bash tests/lib.test.sh"
|
|
27
33
|
},
|
|
28
34
|
"files": [
|
|
29
35
|
"bin/",
|
|
@@ -32,9 +32,9 @@ cat .planning/phase-{N}-plan.md
|
|
|
32
32
|
|
|
33
33
|
Parse: tasks, waves, file references.
|
|
34
34
|
|
|
35
|
-
### 1b. Create Recovery
|
|
35
|
+
### 1b. Create Recovery Reference
|
|
36
36
|
|
|
37
|
-
Before executing any tasks,
|
|
37
|
+
Before executing any tasks, record current HEAD for diagnosis. This is a reference, not an automatic rollback instruction.
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
git tag -f "pre-build-phase-{N}" HEAD 2>/dev/null
|
|
@@ -44,10 +44,10 @@ git tag -f "pre-build-phase-{N}" HEAD 2>/dev/null
|
|
|
44
44
|
node ~/.claude/bin/qualia-ui.js info "Recovery point: pre-build-phase-{N}"
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
If a wave fails and the user
|
|
47
|
+
If a wave fails, stop and inspect `git status` plus the failed task output. Do not run destructive rollback commands automatically. Preserve user work, then either fix forward or ask before reverting specific files.
|
|
48
48
|
```bash
|
|
49
|
-
git
|
|
50
|
-
|
|
49
|
+
git status --short
|
|
50
|
+
git diff --stat
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### 2. Execute Waves
|
|
@@ -30,7 +30,7 @@ the same — manually-triggered, internal-data only, no vector DB.
|
|
|
30
30
|
- **Manually:** `/qualia-flush` whenever the daily-log feels rich. Once a week
|
|
31
31
|
is the recommended cadence. More than once a day is wasteful — the
|
|
32
32
|
signal-to-noise ratio is too low at single-day windows.
|
|
33
|
-
- **
|
|
33
|
+
- **CLI runner:** `qualia-framework flush` wraps the cron-friendly
|
|
34
34
|
`bin/knowledge-flush.js` non-interactive runner.
|
|
35
35
|
|
|
36
36
|
## Inputs
|
|
@@ -24,7 +24,7 @@ node ~/.claude/bin/qualia-ui.js banner quick
|
|
|
24
24
|
2. **Build:** Do it directly — read before write, MVP only
|
|
25
25
|
3. **Verify:** Run `npx tsc --noEmit`, test locally
|
|
26
26
|
4. **Commit:** Atomic commit with clear message
|
|
27
|
-
5. **Update:**
|
|
27
|
+
5. **Update:** Record the work through `state.js`
|
|
28
28
|
|
|
29
29
|
End with:
|
|
30
30
|
```bash
|
|
@@ -134,7 +134,7 @@ SUBMITTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
134
134
|
# returns a generic 401 that is hard to diagnose.
|
|
135
135
|
if [ "$ERP_ENABLED" = "true" ] && [ -z "$API_KEY" ] && [ "$DRY_RUN" != "true" ]; then
|
|
136
136
|
node ~/.claude/bin/qualia-ui.js warn "ERP API key missing (~/.claude/.erp-api-key is empty or unreadable). Skipping upload."
|
|
137
|
-
node ~/.claude/bin/qualia-ui.js info "Ask Fawzi for the ERP key,
|
|
137
|
+
node ~/.claude/bin/qualia-ui.js info "Ask Fawzi for the ERP key, run 'qualia-framework set-erp-key <key>', then run 'qualia-framework erp-ping'."
|
|
138
138
|
ERP_ENABLED="false"
|
|
139
139
|
fi
|
|
140
140
|
|
|
@@ -37,13 +37,14 @@ VERIFICATION=$(echo "$STATE" | node -e "try{const d=JSON.parse(require('fs').rea
|
|
|
37
37
|
# verified+pass — final phase verified; skipping polish is allowed for hotfixes
|
|
38
38
|
# Anything else (setup, planned, built, shipped, handed_off, verified+fail) is refused.
|
|
39
39
|
if [ "$STATUS" != "polished" ] && ! { [ "$STATUS" = "verified" ] && [ "$VERIFICATION" = "pass" ]; }; then
|
|
40
|
+
if [ "${QUALIA_SHIP_FORCE:-0}" = "1" ]; then
|
|
41
|
+
node ~/.claude/bin/qualia-ui.js warn "Forced ship from state '$STATUS' (verification: ${VERIFICATION:-none}). Record the reason in the final report."
|
|
42
|
+
else
|
|
40
43
|
node ~/.claude/bin/qualia-ui.js fail "Cannot ship from state '$STATUS' (verification: ${VERIFICATION:-none})."
|
|
41
44
|
node ~/.claude/bin/qualia-ui.js info "Run /qualia-polish first, or /qualia-verify {phase} if verification is still pending."
|
|
42
|
-
node ~/.claude/bin/qualia-ui.js info "
|
|
43
|
-
# The --force escape hatch exists for production hotfixes where the polished
|
|
44
|
-
# state was never reached. The operator is expected to have read and
|
|
45
|
-
# understood the pending verification findings.
|
|
45
|
+
node ~/.claude/bin/qualia-ui.js info "Hotfix override: set QUALIA_SHIP_FORCE=1 only when the user explicitly approved it."
|
|
46
46
|
exit 1
|
|
47
|
+
fi
|
|
47
48
|
fi
|
|
48
49
|
```
|
|
49
50
|
|
|
@@ -53,7 +54,8 @@ Run in sequence. Auto-fix failures (up to 2 attempts).
|
|
|
53
54
|
|
|
54
55
|
```bash
|
|
55
56
|
npx tsc --noEmit # TypeScript — must pass
|
|
56
|
-
|
|
57
|
+
if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.lint?0:1)"; then npm run lint; fi
|
|
58
|
+
if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)"; then npm test; fi
|
|
57
59
|
npm run build # Build — must succeed
|
|
58
60
|
```
|
|
59
61
|
|
|
@@ -130,15 +132,15 @@ wrangler deploy # Cloudflare Workers
|
|
|
130
132
|
|
|
131
133
|
### 5. Post-Deploy Verification
|
|
132
134
|
|
|
133
|
-
Read the deployed URL from `tracking.json.deployed_url`
|
|
135
|
+
Read the deployed URL from `tracking.json.deployed_url` or from an explicit user-provided URL. Do NOT use a `{domain}` placeholder — that expects the LLM to hallucinate the URL, which is exactly the kind of silent fail the state guard above prevents.
|
|
134
136
|
|
|
135
137
|
```bash
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
URL
|
|
138
|
+
# If the user invoked `/qualia-ship --url ...`, set QUALIA_SHIP_URL to that
|
|
139
|
+
# exact value before running this block. Otherwise use tracking.json.
|
|
140
|
+
URL="${QUALIA_SHIP_URL:-$(node -e 'try{const t=JSON.parse(require("fs").readFileSync(".planning/tracking.json","utf8"));process.stdout.write(t.deployed_url||"")}catch{}')}"
|
|
139
141
|
if [ -z "$URL" ]; then
|
|
140
142
|
node ~/.claude/bin/qualia-ui.js warn "No deployed_url in tracking.json — parse it from the deploy command output (vercel/supabase/wrangler all print the URL on success)."
|
|
141
|
-
node ~/.claude/bin/qualia-ui.js info "Re-run with: /qualia-ship --url https://your-site.com"
|
|
143
|
+
node ~/.claude/bin/qualia-ui.js info "Re-run with: /qualia-ship --url https://your-site.com, then export that value as QUALIA_SHIP_URL for this check."
|
|
142
144
|
exit 1
|
|
143
145
|
fi
|
|
144
146
|
|