qualia-framework 3.2.0 → 3.3.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/CLAUDE.md +3 -4
- package/README.md +59 -23
- package/agents/plan-checker.md +158 -0
- package/agents/planner.md +52 -0
- package/agents/research-synthesizer.md +86 -0
- package/agents/researcher.md +119 -0
- package/agents/roadmapper.md +157 -0
- package/agents/verifier.md +180 -32
- package/bin/cli.js +403 -9
- package/bin/install.js +219 -70
- package/bin/qualia-ui.js +11 -11
- package/bin/state.js +200 -6
- package/bin/statusline.js +4 -4
- package/docs/erp-contract.md +161 -0
- package/hooks/branch-guard.js +23 -2
- package/hooks/migration-guard.js +23 -0
- package/hooks/pre-compact.js +20 -0
- package/hooks/pre-deploy-gate.js +39 -0
- package/hooks/pre-push.js +20 -0
- package/hooks/session-start.js +16 -43
- package/package.json +6 -4
- package/references/questioning.md +123 -0
- package/rules/infrastructure.md +87 -0
- package/skills/qualia/SKILL.md +1 -0
- package/skills/qualia-build/SKILL.md +18 -0
- package/skills/qualia-design/SKILL.md +14 -8
- package/skills/qualia-discuss/SKILL.md +115 -0
- package/skills/qualia-help/SKILL.md +60 -0
- package/skills/qualia-learn/SKILL.md +27 -4
- package/skills/qualia-map/SKILL.md +145 -0
- package/skills/qualia-milestone/SKILL.md +148 -0
- package/skills/qualia-new/SKILL.md +374 -229
- package/skills/qualia-plan/SKILL.md +135 -30
- package/skills/qualia-polish/SKILL.md +167 -117
- package/skills/qualia-report/SKILL.md +17 -8
- package/skills/qualia-research/SKILL.md +124 -0
- package/skills/qualia-review/SKILL.md +126 -41
- package/skills/qualia-test/SKILL.md +134 -0
- package/skills/qualia-verify/SKILL.md +1 -1
- package/templates/DESIGN.md +440 -102
- package/templates/help.html +476 -0
- package/templates/phase-context.md +48 -0
- package/templates/plan.md +14 -0
- package/templates/projects/ai-agent.md +55 -0
- package/templates/projects/mobile-app.md +56 -0
- package/templates/projects/voice-agent.md +55 -0
- package/templates/projects/website.md +58 -0
- package/templates/requirements.md +69 -0
- package/templates/research-project/ARCHITECTURE.md +70 -0
- package/templates/research-project/FEATURES.md +60 -0
- package/templates/research-project/PITFALLS.md +73 -0
- package/templates/research-project/STACK.md +51 -0
- package/templates/research-project/SUMMARY.md +86 -0
- package/templates/roadmap.md +71 -0
- package/tests/bin.test.sh +20 -6
- package/tests/hooks.test.sh +76 -7
- package/tests/runner.js +1915 -0
- package/tests/state.test.sh +189 -11
package/bin/state.js
CHANGED
|
@@ -9,6 +9,17 @@ const PLANNING = ".planning";
|
|
|
9
9
|
const STATE_FILE = path.join(PLANNING, "STATE.md");
|
|
10
10
|
const TRACKING_FILE = path.join(PLANNING, "tracking.json");
|
|
11
11
|
|
|
12
|
+
// ─── Trace ──────────────────────────────────────────────
|
|
13
|
+
function _trace(event, data) {
|
|
14
|
+
try {
|
|
15
|
+
const traceDir = path.join(require("os").homedir(), ".claude", ".qualia-traces");
|
|
16
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
17
|
+
const entry = { hook: event, timestamp: new Date().toISOString(), ...data };
|
|
18
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
19
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
20
|
+
} catch { /* trace failures must not disrupt state machine */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
// ─── Arg Parsing ─────────────────────────────────────────
|
|
13
24
|
function parseArgs(argv) {
|
|
14
25
|
const args = {};
|
|
@@ -186,6 +197,25 @@ const VALID_FROM = {
|
|
|
186
197
|
done: ["handed_off"],
|
|
187
198
|
};
|
|
188
199
|
|
|
200
|
+
// ─── Configurable Gap Cycle Limit ────────────────────────
|
|
201
|
+
function getGapCycleLimit() {
|
|
202
|
+
// Priority: tracking.json.gap_cycle_limit > PROJECT.md > default (2)
|
|
203
|
+
try {
|
|
204
|
+
const t = readTracking();
|
|
205
|
+
if (t && typeof t.gap_cycle_limit === "number" && t.gap_cycle_limit > 0) {
|
|
206
|
+
return t.gap_cycle_limit;
|
|
207
|
+
}
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const projectMd = fs.readFileSync(path.join(PLANNING, "PROJECT.md"), "utf8");
|
|
212
|
+
const match = projectMd.match(/^gap_cycle_limit:\s*(\d+)/m);
|
|
213
|
+
if (match) return parseInt(match[1]);
|
|
214
|
+
} catch {}
|
|
215
|
+
|
|
216
|
+
return 2; // default
|
|
217
|
+
}
|
|
218
|
+
|
|
189
219
|
function checkPreconditions(current, target, opts) {
|
|
190
220
|
const phase = parseInt(opts.phase) || current.phase;
|
|
191
221
|
|
|
@@ -207,6 +237,14 @@ function checkPreconditions(current, target, opts) {
|
|
|
207
237
|
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
208
238
|
if (!fs.existsSync(planFile))
|
|
209
239
|
return fail("MISSING_FILE", `Plan file not found: ${planFile}`);
|
|
240
|
+
// Validate plan content (not just existence)
|
|
241
|
+
const planContent = fs.readFileSync(planFile, "utf8");
|
|
242
|
+
const taskHeaders = planContent.match(/^## Task \d+/gm);
|
|
243
|
+
if (!taskHeaders || taskHeaders.length === 0)
|
|
244
|
+
return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
|
|
245
|
+
const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
|
|
246
|
+
if (doneWhenCount < taskHeaders.length)
|
|
247
|
+
return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
if (target === "verified") {
|
|
@@ -228,14 +266,15 @@ function checkPreconditions(current, target, opts) {
|
|
|
228
266
|
return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
|
|
229
267
|
}
|
|
230
268
|
|
|
231
|
-
// Gap-closure circuit breaker
|
|
269
|
+
// Gap-closure circuit breaker (configurable limit)
|
|
232
270
|
if (target === "planned" && current.status === "verified") {
|
|
233
271
|
const t = readTracking() || {};
|
|
234
272
|
const cycles = (t.gap_cycles || {})[String(phase)] || 0;
|
|
235
|
-
|
|
273
|
+
const limit = getGapCycleLimit();
|
|
274
|
+
if (cycles >= limit) {
|
|
236
275
|
return fail(
|
|
237
276
|
"GAP_CYCLE_LIMIT",
|
|
238
|
-
`Phase ${phase} has failed verification ${cycles} times. Escalate to Fawzi or re-plan from scratch.`
|
|
277
|
+
`Phase ${phase} has failed verification ${cycles} times (limit: ${limit}). Escalate to Fawzi or re-plan from scratch.`
|
|
239
278
|
);
|
|
240
279
|
}
|
|
241
280
|
}
|
|
@@ -294,6 +333,7 @@ function cmdCheck(opts) {
|
|
|
294
333
|
assigned_to: s.assigned_to,
|
|
295
334
|
verification: t.verification || "pending",
|
|
296
335
|
gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
|
|
336
|
+
gap_cycle_limit: getGapCycleLimit(),
|
|
297
337
|
tasks_done: t.tasks_done || 0,
|
|
298
338
|
tasks_total: t.tasks_total || 0,
|
|
299
339
|
deployed_url: t.deployed_url || "",
|
|
@@ -331,7 +371,6 @@ function cmdTransition(opts) {
|
|
|
331
371
|
|
|
332
372
|
// Special: note/activity (no status change)
|
|
333
373
|
if (target === "note" || target === "activity") {
|
|
334
|
-
const now = new Date().toISOString().split("T")[0];
|
|
335
374
|
if (opts.notes) t.notes = opts.notes;
|
|
336
375
|
t.last_updated = new Date().toISOString();
|
|
337
376
|
writeTracking(t);
|
|
@@ -353,7 +392,16 @@ function cmdTransition(opts) {
|
|
|
353
392
|
target,
|
|
354
393
|
{ ...opts, phase }
|
|
355
394
|
);
|
|
356
|
-
if (!check.ok)
|
|
395
|
+
if (!check.ok) {
|
|
396
|
+
// Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
|
|
397
|
+
// Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN — those cause broken state.
|
|
398
|
+
const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
|
|
399
|
+
if (opts.force && forceableErrors.includes(check.error)) {
|
|
400
|
+
console.error(`WARNING: Forcing transition despite: ${check.message}`);
|
|
401
|
+
} else {
|
|
402
|
+
return output(check);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
357
405
|
|
|
358
406
|
const prevStatus = s.status;
|
|
359
407
|
|
|
@@ -432,6 +480,18 @@ function cmdTransition(opts) {
|
|
|
432
480
|
return output(fail("WRITE_ERROR", e.message));
|
|
433
481
|
}
|
|
434
482
|
|
|
483
|
+
// Skill outcome scoring — log transition for analytics
|
|
484
|
+
_trace("state-transition", {
|
|
485
|
+
result: "allow",
|
|
486
|
+
phase: s.phase,
|
|
487
|
+
status: s.status,
|
|
488
|
+
previous_status: prevStatus,
|
|
489
|
+
verification: t.verification,
|
|
490
|
+
gap_closure: prevStatus === "verified" && target === "planned",
|
|
491
|
+
duration_ms: 0,
|
|
492
|
+
extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
|
|
493
|
+
});
|
|
494
|
+
|
|
435
495
|
output({
|
|
436
496
|
ok: true,
|
|
437
497
|
phase: s.phase,
|
|
@@ -597,6 +657,137 @@ function cmdFix(opts) {
|
|
|
597
657
|
});
|
|
598
658
|
}
|
|
599
659
|
|
|
660
|
+
function cmdValidatePlan(opts) {
|
|
661
|
+
const phase = parseInt(opts.phase) || 1;
|
|
662
|
+
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
663
|
+
|
|
664
|
+
if (!fs.existsSync(planFile)) {
|
|
665
|
+
return output(fail("MISSING_FILE", `Plan file not found: ${planFile}`));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const content = fs.readFileSync(planFile, "utf8");
|
|
669
|
+
const errors = [];
|
|
670
|
+
|
|
671
|
+
// Check frontmatter exists
|
|
672
|
+
if (!/^---\n/.test(content)) {
|
|
673
|
+
errors.push("Missing frontmatter (---) at start of file");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Check task count > 0
|
|
677
|
+
const taskHeaders = content.match(/^## Task \d+/gm);
|
|
678
|
+
if (!taskHeaders || taskHeaders.length === 0) {
|
|
679
|
+
errors.push("No task headers found (expected '## Task N — title')");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Check "Done when" exists for each task
|
|
683
|
+
const taskCount = taskHeaders ? taskHeaders.length : 0;
|
|
684
|
+
const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
|
|
685
|
+
if (doneWhenCount < taskCount) {
|
|
686
|
+
errors.push(
|
|
687
|
+
`${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Check Success Criteria section exists
|
|
692
|
+
if (!/## Success Criteria/m.test(content)) {
|
|
693
|
+
errors.push("Missing '## Success Criteria' section");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Check goal in frontmatter
|
|
697
|
+
if (!/^goal:/m.test(content)) {
|
|
698
|
+
errors.push("Missing 'goal:' in frontmatter");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ─── Verification Contract Validation (non-blocking) ────
|
|
702
|
+
const warnings = [];
|
|
703
|
+
const VALID_CHECK_TYPES = ["file-exists", "grep-match", "command-exit", "behavioral"];
|
|
704
|
+
let contractCount = 0;
|
|
705
|
+
|
|
706
|
+
if (/^## Verification Contract/m.test(content)) {
|
|
707
|
+
// Extract the contract section (from header to next ## or end of file)
|
|
708
|
+
const contractSectionMatch = content.match(
|
|
709
|
+
/^## Verification Contract\s*\n([\s\S]+)/m
|
|
710
|
+
);
|
|
711
|
+
if (contractSectionMatch) {
|
|
712
|
+
// Trim at the next ## heading that isn't ### (i.e., a new top-level section)
|
|
713
|
+
let contractSection = contractSectionMatch[1];
|
|
714
|
+
const nextH2 = contractSection.search(/\n## (?!#)/);
|
|
715
|
+
if (nextH2 !== -1) contractSection = contractSection.substring(0, nextH2);
|
|
716
|
+
// Each contract starts with ### Contract for Task N
|
|
717
|
+
const contractBlocks = contractSection.match(/^### Contract for Task \d+/gm);
|
|
718
|
+
contractCount = contractBlocks ? contractBlocks.length : 0;
|
|
719
|
+
|
|
720
|
+
if (contractCount === 0) {
|
|
721
|
+
warnings.push("Verification Contract section exists but contains no contract blocks (expected '### Contract for Task N')");
|
|
722
|
+
} else {
|
|
723
|
+
// Split into individual contract blocks for validation
|
|
724
|
+
const blockSplits = contractSection.split(/^(?=### Contract for Task \d+)/m).filter(Boolean);
|
|
725
|
+
for (const block of blockSplits) {
|
|
726
|
+
const taskNumMatch = block.match(/^### Contract for Task (\d+)/);
|
|
727
|
+
if (!taskNumMatch) continue;
|
|
728
|
+
const taskNum = taskNumMatch[1];
|
|
729
|
+
|
|
730
|
+
const checkTypeMatch = block.match(/\*\*Check type:\*\*\s*(.+)/);
|
|
731
|
+
const hasCommand = /\*\*Command:\*\*/.test(block);
|
|
732
|
+
const hasExpected = /\*\*Expected:\*\*/.test(block);
|
|
733
|
+
const hasFailIf = /\*\*Fail if:\*\*/.test(block);
|
|
734
|
+
|
|
735
|
+
if (!checkTypeMatch) {
|
|
736
|
+
warnings.push(`Contract for Task ${taskNum}: missing 'Check type'`);
|
|
737
|
+
} else {
|
|
738
|
+
const checkType = checkTypeMatch[1].trim().toLowerCase();
|
|
739
|
+
if (!VALID_CHECK_TYPES.includes(checkType)) {
|
|
740
|
+
warnings.push(
|
|
741
|
+
`Contract for Task ${taskNum}: invalid check type '${checkType}' (valid: ${VALID_CHECK_TYPES.join(", ")})`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
// behavioral type doesn't require Command or Expected
|
|
745
|
+
const isBehavioral = checkType === "behavioral";
|
|
746
|
+
if (!isBehavioral && !hasCommand) {
|
|
747
|
+
warnings.push(`Contract for Task ${taskNum}: missing 'Command' (required for ${checkType})`);
|
|
748
|
+
}
|
|
749
|
+
if (!isBehavioral && !hasExpected) {
|
|
750
|
+
warnings.push(`Contract for Task ${taskNum}: missing 'Expected' (required for ${checkType})`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!hasFailIf) {
|
|
755
|
+
warnings.push(`Contract for Task ${taskNum}: missing 'Fail if'`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Warn if contract count < task count
|
|
761
|
+
if (taskCount > 0 && contractCount > 0 && contractCount < taskCount) {
|
|
762
|
+
warnings.push(
|
|
763
|
+
`Only ${contractCount} contract(s) for ${taskCount} task(s) — not all tasks have verification contracts`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (errors.length > 0) {
|
|
770
|
+
return output({
|
|
771
|
+
ok: false,
|
|
772
|
+
error: "PLAN_VALIDATION_FAILED",
|
|
773
|
+
phase,
|
|
774
|
+
errors,
|
|
775
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
776
|
+
message: `Plan file has ${errors.length} issue(s)`,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
output({
|
|
781
|
+
ok: true,
|
|
782
|
+
action: "validate-plan",
|
|
783
|
+
phase,
|
|
784
|
+
task_count: taskCount,
|
|
785
|
+
done_when_count: doneWhenCount,
|
|
786
|
+
contract_count: contractCount,
|
|
787
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
600
791
|
// ─── Output ──────────────────────────────────────────────
|
|
601
792
|
function output(obj) {
|
|
602
793
|
console.log(JSON.stringify(obj, null, 2));
|
|
@@ -620,11 +811,14 @@ switch (cmd) {
|
|
|
620
811
|
case "fix":
|
|
621
812
|
cmdFix(opts);
|
|
622
813
|
break;
|
|
814
|
+
case "validate-plan":
|
|
815
|
+
cmdValidatePlan(opts);
|
|
816
|
+
break;
|
|
623
817
|
default:
|
|
624
818
|
output(
|
|
625
819
|
fail(
|
|
626
820
|
"UNKNOWN_COMMAND",
|
|
627
|
-
`Usage: state.js <check|transition|init|fix> [--options]`
|
|
821
|
+
`Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
|
|
628
822
|
)
|
|
629
823
|
);
|
|
630
824
|
}
|
package/bin/statusline.js
CHANGED
|
@@ -224,11 +224,11 @@ try {
|
|
|
224
224
|
if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
|
|
225
225
|
if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
|
|
226
226
|
if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
|
|
227
|
-
// Memory, hooks, skills — context indicators
|
|
227
|
+
// Memory, hooks, skills — context indicators with labels
|
|
228
228
|
const contextParts = [];
|
|
229
|
-
if (MEMORY_COUNT > 0) contextParts.push(`${
|
|
230
|
-
if (HOOKS_COUNT > 0) contextParts.push(`${
|
|
231
|
-
if (SKILLS_COUNT > 0) contextParts.push(`${
|
|
229
|
+
if (MEMORY_COUNT > 0) contextParts.push(`${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`);
|
|
230
|
+
if (HOOKS_COUNT > 0) contextParts.push(`${DIM}hooks${RESET} ${TEAL_GLOW}${HOOKS_COUNT}${RESET}`);
|
|
231
|
+
if (SKILLS_COUNT > 0) contextParts.push(`${DIM}skills${RESET} ${TEAL_DIM}${SKILLS_COUNT}${RESET}`);
|
|
232
232
|
if (contextParts.length > 0) {
|
|
233
233
|
LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
|
|
234
234
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ERP API Contract
|
|
2
|
+
|
|
3
|
+
The Qualia Framework optionally uploads session reports to the company ERP at `https://portal.qualiasolutions.net`. This document specifies the API shape.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
Stored in `~/.claude/.qualia-config.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"erp": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"url": "https://portal.qualiasolutions.net",
|
|
14
|
+
"api_key_file": ".erp-api-key"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The API key is read from `~/.claude/.erp-api-key` (file mode 0600).
|
|
20
|
+
|
|
21
|
+
## Endpoints
|
|
22
|
+
|
|
23
|
+
### POST /api/v1/reports
|
|
24
|
+
|
|
25
|
+
Upload a session report.
|
|
26
|
+
|
|
27
|
+
**Headers:**
|
|
28
|
+
```
|
|
29
|
+
Authorization: Bearer <api-key>
|
|
30
|
+
Content-Type: application/json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Request Body:**
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"project": "client-project-name",
|
|
37
|
+
"client": "Client Name",
|
|
38
|
+
"phase": 2,
|
|
39
|
+
"phase_name": "Authentication & Dashboard",
|
|
40
|
+
"total_phases": 4,
|
|
41
|
+
"status": "built",
|
|
42
|
+
"tasks_done": 5,
|
|
43
|
+
"tasks_total": 5,
|
|
44
|
+
"verification": "pass",
|
|
45
|
+
"gap_cycles": 0,
|
|
46
|
+
"deployed_url": "https://client.vercel.app",
|
|
47
|
+
"session_duration_minutes": 45,
|
|
48
|
+
"commits": ["abc1234", "def5678"],
|
|
49
|
+
"notes": "Completed auth flow, dashboard layout, and API routes.",
|
|
50
|
+
"submitted_by": "Fawzi Goussous",
|
|
51
|
+
"submitted_at": "2026-04-12T14:30:00Z"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Response (200 OK):**
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"ok": true,
|
|
59
|
+
"report_id": "rpt_abc123def456",
|
|
60
|
+
"message": "Report received"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Response (401 Unauthorized):**
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"ok": false,
|
|
68
|
+
"error": "INVALID_API_KEY",
|
|
69
|
+
"message": "API key is invalid or expired"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Response (422 Unprocessable Entity):**
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"ok": false,
|
|
77
|
+
"error": "VALIDATION_FAILED",
|
|
78
|
+
"message": "Missing required field: project"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### GET /api/v1/reports/:project
|
|
83
|
+
|
|
84
|
+
Retrieve reports for a project.
|
|
85
|
+
|
|
86
|
+
**Headers:**
|
|
87
|
+
```
|
|
88
|
+
Authorization: Bearer <api-key>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Response (200 OK):**
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"ok": true,
|
|
95
|
+
"reports": [
|
|
96
|
+
{
|
|
97
|
+
"report_id": "rpt_abc123def456",
|
|
98
|
+
"phase": 2,
|
|
99
|
+
"status": "built",
|
|
100
|
+
"submitted_at": "2026-04-12T14:30:00Z",
|
|
101
|
+
"submitted_by": "Fawzi Goussous"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### GET /api/v1/tracking/:project
|
|
108
|
+
|
|
109
|
+
Retrieve current tracking state (same shape as tracking.json).
|
|
110
|
+
|
|
111
|
+
**Headers:**
|
|
112
|
+
```
|
|
113
|
+
Authorization: Bearer <api-key>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Response (200 OK):**
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"ok": true,
|
|
120
|
+
"tracking": {
|
|
121
|
+
"project": "client-project-name",
|
|
122
|
+
"phase": 2,
|
|
123
|
+
"total_phases": 4,
|
|
124
|
+
"status": "built",
|
|
125
|
+
"last_updated": "2026-04-12T14:30:00Z"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Behavior
|
|
131
|
+
|
|
132
|
+
- When `erp.enabled` is `false`, `/qualia-report` skips the upload silently.
|
|
133
|
+
- When the API key file is missing or empty, the upload is skipped with a warning.
|
|
134
|
+
- Network failures are non-blocking — the report is saved locally regardless.
|
|
135
|
+
- The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
|
|
136
|
+
- Reports are append-only — no update or delete endpoints exist.
|
|
137
|
+
|
|
138
|
+
## Required Fields
|
|
139
|
+
|
|
140
|
+
| Field | Type | Required | Description |
|
|
141
|
+
|-------|------|----------|-------------|
|
|
142
|
+
| project | string | yes | Project slug from tracking.json |
|
|
143
|
+
| phase | number | yes | Current phase number |
|
|
144
|
+
| status | string | yes | Current status (setup, planned, built, verified, etc.) |
|
|
145
|
+
| submitted_by | string | yes | Team member name |
|
|
146
|
+
| submitted_at | string | yes | ISO 8601 timestamp |
|
|
147
|
+
|
|
148
|
+
All other fields are optional but recommended for complete reporting.
|
|
149
|
+
|
|
150
|
+
## Rate Limits
|
|
151
|
+
|
|
152
|
+
- 60 requests per minute per API key
|
|
153
|
+
- Report body max size: 64KB
|
|
154
|
+
- No batch endpoint — one report per request
|
|
155
|
+
|
|
156
|
+
## Security
|
|
157
|
+
|
|
158
|
+
- API keys are per-user, not per-project
|
|
159
|
+
- Keys expire after 90 days (re-issue via Fawzi)
|
|
160
|
+
- All traffic is HTTPS-only
|
|
161
|
+
- No PII beyond team member names is transmitted
|
package/hooks/branch-guard.js
CHANGED
|
@@ -10,11 +10,30 @@ const path = require("path");
|
|
|
10
10
|
const os = require("os");
|
|
11
11
|
const { spawnSync } = require("child_process");
|
|
12
12
|
|
|
13
|
+
const _traceStart = Date.now();
|
|
14
|
+
|
|
13
15
|
const CONFIG = path.join(os.homedir(), ".claude", ".qualia-config.json");
|
|
14
16
|
|
|
17
|
+
function _trace(hookName, result, extra) {
|
|
18
|
+
try {
|
|
19
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
20
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
21
|
+
const entry = {
|
|
22
|
+
hook: hookName,
|
|
23
|
+
result,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
duration_ms: Date.now() - _traceStart,
|
|
26
|
+
...extra,
|
|
27
|
+
};
|
|
28
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
29
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
function fail(msg) {
|
|
16
34
|
console.log(msg);
|
|
17
|
-
|
|
35
|
+
_trace("branch-guard", "block", { reason: msg });
|
|
36
|
+
process.exit(2);
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
let role = "";
|
|
@@ -40,8 +59,10 @@ if (branch === "main" || branch === "master") {
|
|
|
40
59
|
if (role !== "OWNER") {
|
|
41
60
|
console.log(`BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`);
|
|
42
61
|
console.log("Run: git checkout -b feature/your-feature-name");
|
|
43
|
-
|
|
62
|
+
_trace("branch-guard", "block", { reason: `non-owner push to ${branch}` });
|
|
63
|
+
process.exit(2);
|
|
44
64
|
}
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
_trace("branch-guard", "allow");
|
|
47
68
|
process.exit(0);
|
package/hooks/migration-guard.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require("fs");
|
|
8
8
|
|
|
9
|
+
const _traceStart = Date.now();
|
|
10
|
+
|
|
9
11
|
function readInput() {
|
|
10
12
|
try {
|
|
11
13
|
const raw = fs.readFileSync(0, "utf8");
|
|
@@ -20,8 +22,27 @@ const ti = input.tool_input || {};
|
|
|
20
22
|
const file = String(ti.file_path || "").replace(/\\/g, "/");
|
|
21
23
|
const content = String(ti.content || ti.new_string || "");
|
|
22
24
|
|
|
25
|
+
function _trace(hookName, result, extra) {
|
|
26
|
+
try {
|
|
27
|
+
const os = require("os");
|
|
28
|
+
const path = require("path");
|
|
29
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
30
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
31
|
+
const entry = {
|
|
32
|
+
hook: hookName,
|
|
33
|
+
result,
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
duration_ms: Date.now() - _traceStart,
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
const filePath = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
39
|
+
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
// Only inspect migration/SQL files
|
|
24
44
|
if (!/migration|migrate|\.sql$/i.test(file)) {
|
|
45
|
+
_trace("migration-guard", "allow", { reason: "non-migration file" });
|
|
25
46
|
process.exit(0);
|
|
26
47
|
}
|
|
27
48
|
|
|
@@ -54,7 +75,9 @@ if (errors.length > 0) {
|
|
|
54
75
|
}
|
|
55
76
|
console.log("");
|
|
56
77
|
console.log("Fix these before proceeding. If intentional, ask Fawzi to approve.");
|
|
78
|
+
_trace("migration-guard", "block", { errors });
|
|
57
79
|
process.exit(2);
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
_trace("migration-guard", "allow");
|
|
60
83
|
process.exit(0);
|
package/hooks/pre-compact.js
CHANGED
|
@@ -7,6 +7,8 @@ const fs = require("fs");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const { spawnSync } = require("child_process");
|
|
9
9
|
|
|
10
|
+
const _traceStart = Date.now();
|
|
11
|
+
|
|
10
12
|
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
11
13
|
|
|
12
14
|
try {
|
|
@@ -29,4 +31,22 @@ try {
|
|
|
29
31
|
// Silent — never block compaction
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function _trace(hookName, result, extra) {
|
|
35
|
+
try {
|
|
36
|
+
const os = require("os");
|
|
37
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
38
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
39
|
+
const entry = {
|
|
40
|
+
hook: hookName,
|
|
41
|
+
result,
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
duration_ms: Date.now() - _traceStart,
|
|
44
|
+
...extra,
|
|
45
|
+
};
|
|
46
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
47
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_trace("pre-compact", "allow");
|
|
32
52
|
process.exit(0);
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -9,6 +9,25 @@ const fs = require("fs");
|
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const { spawnSync } = require("child_process");
|
|
11
11
|
|
|
12
|
+
const _traceStart = Date.now();
|
|
13
|
+
|
|
14
|
+
function _trace(hookName, result, extra) {
|
|
15
|
+
try {
|
|
16
|
+
const os = require("os");
|
|
17
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
18
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
19
|
+
const entry = {
|
|
20
|
+
hook: hookName,
|
|
21
|
+
result,
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
duration_ms: Date.now() - _traceStart,
|
|
24
|
+
...extra,
|
|
25
|
+
};
|
|
26
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
27
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
function runGate(label, cmd, args, { required = true } = {}) {
|
|
13
32
|
const r = spawnSync(cmd, args, {
|
|
14
33
|
stdio: "ignore",
|
|
@@ -21,6 +40,7 @@ function runGate(label, cmd, args, { required = true } = {}) {
|
|
|
21
40
|
}
|
|
22
41
|
if (required) {
|
|
23
42
|
console.log(`BLOCKED: ${label} errors. Fix before deploying.`);
|
|
43
|
+
_trace("pre-deploy-gate", "block", { gate: label });
|
|
24
44
|
process.exit(1);
|
|
25
45
|
}
|
|
26
46
|
return false;
|
|
@@ -60,10 +80,27 @@ function scanServiceRoleLeaks() {
|
|
|
60
80
|
const leaks = [];
|
|
61
81
|
for (const root of roots) {
|
|
62
82
|
for (const file of walk(root)) {
|
|
83
|
+
// --- Path-based skips (no I/O needed) ---
|
|
84
|
+
|
|
63
85
|
// Skip server-only files (convention: *.server.ts, server/ dirs)
|
|
64
86
|
if (/\.server\.|[\\/]server[\\/]/.test(file)) continue;
|
|
87
|
+
|
|
88
|
+
// Skip App Router route handlers (always server-side)
|
|
89
|
+
if (/[\\/]route\.(ts|tsx|js|jsx|mjs)$/.test(file)) continue;
|
|
90
|
+
|
|
91
|
+
// Skip middleware (always server-side)
|
|
92
|
+
if (/[\\/]middleware\.(ts|tsx|js|jsx|mjs)$/.test(file)) continue;
|
|
93
|
+
|
|
94
|
+
// Skip files in app/api/ directory (always server-side)
|
|
95
|
+
if (/[\\/]app[\\/]api[\\/]/.test(file)) continue;
|
|
96
|
+
|
|
97
|
+
// --- Content-based checks (requires reading file) ---
|
|
65
98
|
try {
|
|
66
99
|
const content = fs.readFileSync(file, "utf8");
|
|
100
|
+
|
|
101
|
+
// Skip files with "use server" directive (Server Actions / Server Components)
|
|
102
|
+
if (/^["']use server["']/m.test(content)) continue;
|
|
103
|
+
|
|
67
104
|
if (/service_role/.test(content)) {
|
|
68
105
|
leaks.push(file);
|
|
69
106
|
}
|
|
@@ -102,9 +139,11 @@ if (leaks.length > 0) {
|
|
|
102
139
|
for (const f of leaks.slice(0, 10)) {
|
|
103
140
|
console.log(` ✗ ${f}`);
|
|
104
141
|
}
|
|
142
|
+
_trace("pre-deploy-gate", "block", { gate: "security", leaks: leaks.slice(0, 10) });
|
|
105
143
|
process.exit(1);
|
|
106
144
|
}
|
|
107
145
|
console.log(" ✓ Security");
|
|
108
146
|
console.log("⬢ All gates passed.");
|
|
109
147
|
|
|
148
|
+
_trace("pre-deploy-gate", "allow");
|
|
110
149
|
process.exit(0);
|
package/hooks/pre-push.js
CHANGED
|
@@ -8,6 +8,8 @@ const fs = require("fs");
|
|
|
8
8
|
const path = require("path");
|
|
9
9
|
const { spawnSync } = require("child_process");
|
|
10
10
|
|
|
11
|
+
const _traceStart = Date.now();
|
|
12
|
+
|
|
11
13
|
const TRACKING = path.join(".planning", "tracking.json");
|
|
12
14
|
|
|
13
15
|
try {
|
|
@@ -30,4 +32,22 @@ try {
|
|
|
30
32
|
process.stderr.write(`WARNING: tracking sync failed: ${err.message}\n`);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function _trace(hookName, result, extra) {
|
|
36
|
+
try {
|
|
37
|
+
const os = require("os");
|
|
38
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
39
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
40
|
+
const entry = {
|
|
41
|
+
hook: hookName,
|
|
42
|
+
result,
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
duration_ms: Date.now() - _traceStart,
|
|
45
|
+
...extra,
|
|
46
|
+
};
|
|
47
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
48
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_trace("pre-push", "allow");
|
|
33
53
|
process.exit(0);
|