qualia-framework 7.0.1 → 7.2.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 +76 -0
- package/bin/cli.js +6 -1
- package/bin/dep-verify.mjs +328 -0
- package/bin/install.js +15 -0
- package/bin/qualia-ui.js +186 -1
- package/bin/report-payload.js +4 -1
- package/bin/runtime-manifest.js +1 -0
- package/bin/state.js +11 -4
- package/bin/statusline.js +7 -1
- package/bin/trust-score.js +1 -1
- package/hooks/pre-deploy-gate.js +54 -3
- package/hooks/secret-guard.js +162 -0
- package/hooks/session-start.js +1 -0
- package/package.json +4 -1
- package/skills/qualia-build/SKILL.md +8 -1
- package/skills/qualia-report/SKILL.md +11 -0
- package/skills/qualia-ship/SKILL.md +17 -1
- package/skills/qualia-verify/SKILL.md +11 -4
- package/tests/bin.test.sh +2 -2
- package/tests/dep-verify.test.sh +247 -0
- package/tests/hooks.test.sh +97 -0
- package/tests/install-smoke.test.sh +4 -3
- package/tests/journey-spine.test.sh +171 -0
- package/tests/lib.test.sh +4 -4
- package/tests/run-all.sh +4 -0
- package/tests/runner.js +2 -2
- package/tests/runtime-parity.test.sh +62 -0
- package/tests/secret-guard.test.sh +92 -0
- package/tests/state.test.sh +35 -0
package/bin/qualia-ui.js
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
// plan-summary <path/to/plan.md> — story-file dashboard for a plan
|
|
21
21
|
// journey-tree [path/to/JOURNEY.md] — ladder view of all milestones, current highlighted
|
|
22
22
|
// milestone-complete <num> <name> <next> — celebration banner on milestone close
|
|
23
|
+
// spine [path/to/JOURNEY.md] — compact horizontal "you are here" journey map (start banner)
|
|
24
|
+
// onboard <name> — first-run milestone mental-model card (post-install, employees)
|
|
25
|
+
// phase-complete <num> <name> [k=v…] — per-phase celebration (tasks, mdone, mtotal, streak, next, nextname)
|
|
26
|
+
// clockout <name> [k=v…] — end-of-day report card (phases, tasks, commits, mdone, mtotal, streak, date, erp)
|
|
23
27
|
|
|
24
28
|
const fs = require("fs");
|
|
25
29
|
const path = require("path");
|
|
@@ -127,6 +131,17 @@ function progressBar(phase, total) {
|
|
|
127
131
|
return `${bar} ${DIM}${pct}%${RESET}`;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
// Compact filled/empty tick bar (▰▱) for inline use in cards and the journey
|
|
135
|
+
// spine. Scales `done` of `total` onto `width` ticks. Returns "" when total<1.
|
|
136
|
+
function barTicks(done, total, width) {
|
|
137
|
+
const w = width || 5;
|
|
138
|
+
const t = Number(total) || 0;
|
|
139
|
+
if (t < 1) return "";
|
|
140
|
+
const d = Math.max(0, Math.min(t, Number(done) || 0));
|
|
141
|
+
const filled = Math.max(0, Math.min(w, Math.round((d / t) * w)));
|
|
142
|
+
return `${TEAL}${"▰".repeat(filled)}${DIM2}${"▱".repeat(w - filled)}${RESET}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
function colorForStatus(s) {
|
|
131
146
|
const colors = {
|
|
132
147
|
setup: DIM,
|
|
@@ -410,6 +425,166 @@ function cmdMilestoneComplete(num, name, nextName) {
|
|
|
410
425
|
console.log("");
|
|
411
426
|
}
|
|
412
427
|
|
|
428
|
+
// ─── Journey Spine (compact horizontal "you are here") ───
|
|
429
|
+
// One-line milestone ladder with the current milestone marked and a caret
|
|
430
|
+
// beneath it. Rendered by the session-start banner so an employee sees where
|
|
431
|
+
// they are in the arc the instant they open the terminal. Skips itself when
|
|
432
|
+
// there is no real multi-milestone journey to map (nothing to orient against).
|
|
433
|
+
function cmdSpine(journeyPath) {
|
|
434
|
+
const state = readState();
|
|
435
|
+
if (!state || !state.ok) return;
|
|
436
|
+
const current = state.milestone || 1;
|
|
437
|
+
|
|
438
|
+
// Milestone list: JOURNEY.md headers first, then state.milestones, else none.
|
|
439
|
+
let milestones = [];
|
|
440
|
+
const p = journeyPath || ".planning/JOURNEY.md";
|
|
441
|
+
try {
|
|
442
|
+
const content = fs.readFileSync(p, "utf8");
|
|
443
|
+
const re = /^## Milestone (\d+)\s*·\s*(.+?)\s*(?:\[[^\]]*\])?\r?$/gm;
|
|
444
|
+
let m;
|
|
445
|
+
while ((m = re.exec(content)) !== null) {
|
|
446
|
+
milestones.push({ num: parseInt(m[1]), name: m[2].trim() });
|
|
447
|
+
}
|
|
448
|
+
} catch {}
|
|
449
|
+
if (milestones.length === 0 && Array.isArray(state.milestones)) {
|
|
450
|
+
milestones = state.milestones
|
|
451
|
+
.map((x, i) => ({ num: x.num || i + 1, name: x.name || x.milestone_name || "" }));
|
|
452
|
+
}
|
|
453
|
+
// Nothing to orient against on a single-milestone (or unknown) project.
|
|
454
|
+
if (milestones.length < 2) return;
|
|
455
|
+
milestones.sort((a, b) => a.num - b.num);
|
|
456
|
+
|
|
457
|
+
// Build the colored spine while tracking the plain-text column of the current
|
|
458
|
+
// milestone's marker, so the caret lands exactly beneath it.
|
|
459
|
+
const SEP = "──";
|
|
460
|
+
const colored = [];
|
|
461
|
+
const plainSegs = [];
|
|
462
|
+
let caretCol = -1;
|
|
463
|
+
for (let i = 0; i < milestones.length; i++) {
|
|
464
|
+
const ms = milestones[i];
|
|
465
|
+
const isPast = ms.num < current;
|
|
466
|
+
const isCurrent = ms.num === current;
|
|
467
|
+
const marker = isPast ? `${GREEN}●${RESET}`
|
|
468
|
+
: isCurrent ? `${TEAL}${BOLD}◆${RESET}`
|
|
469
|
+
: `${DIM2}○${RESET}`;
|
|
470
|
+
const labelColor = isCurrent ? TEAL + BOLD : isPast ? DIM : WHITE;
|
|
471
|
+
const labelPlain = `M${ms.num} `;
|
|
472
|
+
if (isCurrent) {
|
|
473
|
+
const prefix = plainSegs.length ? plainSegs.join(SEP).length + SEP.length : 0;
|
|
474
|
+
caretCol = prefix + labelPlain.length; // column of the marker glyph
|
|
475
|
+
}
|
|
476
|
+
plainSegs.push(`${labelPlain}●`);
|
|
477
|
+
colored.push(`${labelColor}M${ms.num}${RESET} ${marker}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const LABEL_W = 10; // "Journey" label field width (2-space indent added below)
|
|
481
|
+
console.log("");
|
|
482
|
+
console.log(` ${pad(DIM + "Journey" + RESET, LABEL_W)}${colored.join(`${DIM2}${SEP}${RESET}`)}`);
|
|
483
|
+
if (caretCol >= 0) {
|
|
484
|
+
console.log(` ${" ".repeat(LABEL_W + caretCol)}${TEAL}↑${RESET} ${DIM}you are here${RESET}`);
|
|
485
|
+
}
|
|
486
|
+
const last = (state.last_activity || "").trim();
|
|
487
|
+
if (last && !/regenerated from increments/i.test(last)) {
|
|
488
|
+
const short = last.length > 56 ? last.slice(0, 55) + "…" : last;
|
|
489
|
+
console.log(` ${pad(DIM + "Last" + RESET, LABEL_W)}${DIM}${short}${RESET}`);
|
|
490
|
+
}
|
|
491
|
+
console.log("");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Onboard (first-run mental model for a new employee) ─
|
|
495
|
+
// Shown after install for non-OWNER roles: the milestone mental model + the one
|
|
496
|
+
// first move. Keeps a new hire from staring at a prompt wondering what to type.
|
|
497
|
+
function cmdOnboard(name) {
|
|
498
|
+
const who = name ? ` ${DIM}·${RESET} ${WHITE}welcome aboard, ${name}${RESET}` : ` ${DIM}·${RESET} ${WHITE}welcome aboard${RESET}`;
|
|
499
|
+
console.log("");
|
|
500
|
+
console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}QUALIA${RESET}${who}`);
|
|
501
|
+
console.log(` ${RULE_DIM}`);
|
|
502
|
+
console.log(` ${WHITE}You build in milestones:${RESET} ${TEAL}Plan ${DIM}→ ${TEAL}Build ${DIM}→ ${TEAL}Verify ${DIM}→ ${TEAL}Ship${RESET}`);
|
|
503
|
+
console.log(` ${DIM}You never have to guess what's next — the statusline shows where you are,${RESET}`);
|
|
504
|
+
console.log(` ${DIM}and ${RESET}${TEAL}/qualia${RESET}${DIM} always tells you the next command.${RESET}`);
|
|
505
|
+
console.log(` ${RULE_DIM}`);
|
|
506
|
+
console.log(` ${TEAL}⟶${RESET} ${WHITE}First move:${RESET} ${TEAL}${BOLD}/qualia${RESET}`);
|
|
507
|
+
console.log("");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Phase Complete (per-phase celebration + next move) ──
|
|
511
|
+
// Smaller sibling of milestone-complete, shown by /qualia-build (or /qualia-ship)
|
|
512
|
+
// when a phase closes. Optional metrics passed as key=value (tasks, mdone,
|
|
513
|
+
// mtotal, streak, next, nextname); any omitted metric is simply not rendered.
|
|
514
|
+
function cmdPhaseComplete(num, name, kv) {
|
|
515
|
+
kv = kv || {};
|
|
516
|
+
console.log("");
|
|
517
|
+
const title = name ? ` ${DIM}·${RESET} ${TEAL}${name}${RESET}` : "";
|
|
518
|
+
console.log(` ${GREEN}${BOLD}◆${RESET} ${WHITE}${BOLD}PHASE ${num} COMPLETE${RESET}${title}`);
|
|
519
|
+
console.log(` ${RULE_DIM}`);
|
|
520
|
+
const checks = [];
|
|
521
|
+
if (kv.tasks) checks.push(`${GREEN}✓${RESET} ${WHITE}${kv.tasks} tasks${RESET}`);
|
|
522
|
+
checks.push(`${GREEN}✓${RESET} ${WHITE}verified${RESET}`);
|
|
523
|
+
checks.push(`${GREEN}✓${RESET} ${WHITE}shipped${RESET}`);
|
|
524
|
+
console.log(` ${checks.join(" ")}`);
|
|
525
|
+
if (kv.mdone && kv.mtotal) {
|
|
526
|
+
const d = parseInt(kv.mdone, 10), t = parseInt(kv.mtotal, 10);
|
|
527
|
+
const remaining = t - d;
|
|
528
|
+
const tail = remaining <= 0 ? `${GREEN}milestone ready to close${RESET}`
|
|
529
|
+
: remaining === 1 ? `${DIM}one more to close the milestone${RESET}`
|
|
530
|
+
: `${DIM}${remaining} phases left in this milestone${RESET}`;
|
|
531
|
+
console.log(` ${pad(DIM + "Milestone" + RESET, 11)}${barTicks(d, t)} ${DIM}${d}/${t} phases · ${RESET}${tail}`);
|
|
532
|
+
}
|
|
533
|
+
if (kv.streak && parseInt(kv.streak, 10) > 1) {
|
|
534
|
+
console.log(` ${YELLOW}🔥 ${kv.streak} phases shipped today${RESET}`);
|
|
535
|
+
}
|
|
536
|
+
console.log(` ${RULE_DIM}`);
|
|
537
|
+
if (kv.next) {
|
|
538
|
+
const nn = kv.nextname ? ` ${DIM}(${kv.nextname})${RESET}` : "";
|
|
539
|
+
console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${kv.next}${RESET}${nn}`);
|
|
540
|
+
}
|
|
541
|
+
console.log("");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── Clock Out (end-of-day report card) ──────────────────
|
|
545
|
+
// Rendered by /qualia-report at shift close. Metrics passed as key=value
|
|
546
|
+
// (phases, tasks, commits, mdone, mtotal, streak, date, erp=ok|queued).
|
|
547
|
+
function cmdClockout(name, kv) {
|
|
548
|
+
kv = kv || {};
|
|
549
|
+
console.log("");
|
|
550
|
+
const who = name ? ` ${DIM}·${RESET} ${WHITE}${name}${RESET}` : "";
|
|
551
|
+
const date = kv.date ? ` ${DIM}·${RESET} ${DIM}${kv.date}${RESET}` : "";
|
|
552
|
+
console.log(` ${TEAL}${BOLD}▤${RESET} ${WHITE}${BOLD}CLOCK OUT${RESET}${who}${date}`);
|
|
553
|
+
console.log(` ${RULE_DIM}`);
|
|
554
|
+
const shipped = [];
|
|
555
|
+
if (kv.phases) shipped.push(`${WHITE}${kv.phases} phases${RESET}`);
|
|
556
|
+
if (kv.tasks) shipped.push(`${WHITE}${kv.tasks} tasks${RESET}`);
|
|
557
|
+
if (kv.commits) shipped.push(`${WHITE}${kv.commits} commits${RESET}`);
|
|
558
|
+
if (shipped.length) {
|
|
559
|
+
console.log(` ${pad(DIM + "Shipped" + RESET, 12)}${shipped.join(` ${DIM}·${RESET} `)}`);
|
|
560
|
+
}
|
|
561
|
+
if (kv.mdone && kv.mtotal) {
|
|
562
|
+
const d = parseInt(kv.mdone, 10), t = parseInt(kv.mtotal, 10);
|
|
563
|
+
const pct = t > 0 ? Math.round((d / t) * 100) : 0;
|
|
564
|
+
console.log(` ${pad(DIM + "Milestone" + RESET, 12)}${barTicks(d, t)} ${DIM}${pct}%${RESET}`);
|
|
565
|
+
}
|
|
566
|
+
if (kv.streak && parseInt(kv.streak, 10) >= 1) {
|
|
567
|
+
console.log(` ${pad(DIM + "Streak" + RESET, 12)}${YELLOW}${kv.streak} days${RESET}`);
|
|
568
|
+
}
|
|
569
|
+
console.log(` ${RULE_DIM}`);
|
|
570
|
+
const tail = kv.erp === "ok" ? `${GREEN}Report uploaded to ERP ✓${RESET}`
|
|
571
|
+
: kv.erp === "queued" ? `${YELLOW}Report queued — uploads next session${RESET}`
|
|
572
|
+
: `${DIM}Session report saved${RESET}`;
|
|
573
|
+
console.log(` ${tail} ${DIM}See you tomorrow.${RESET}`);
|
|
574
|
+
console.log("");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Parse trailing `key=value` CLI args into an object (used by the lifecycle
|
|
578
|
+
// cards so optional metrics are positional-order-independent).
|
|
579
|
+
function parseKV(args) {
|
|
580
|
+
const o = {};
|
|
581
|
+
for (const a of args || []) {
|
|
582
|
+
const i = a.indexOf("=");
|
|
583
|
+
if (i > 0) o[a.slice(0, i)] = a.slice(i + 1);
|
|
584
|
+
}
|
|
585
|
+
return o;
|
|
586
|
+
}
|
|
587
|
+
|
|
413
588
|
// ─── Plan Summary (story-file dashboard) ─────────────────
|
|
414
589
|
// Renders a polished overview of a plan file: phase goal, tasks grouped by wave,
|
|
415
590
|
// persona chips, dependency lines, AC count, validation count. Called by
|
|
@@ -733,6 +908,12 @@ module.exports = {
|
|
|
733
908
|
divider,
|
|
734
909
|
section,
|
|
735
910
|
sectionClose,
|
|
911
|
+
// Lifecycle cards (called in-process by install.js / reused by skills)
|
|
912
|
+
onboard: cmdOnboard,
|
|
913
|
+
phaseComplete: cmdPhaseComplete,
|
|
914
|
+
clockout: cmdClockout,
|
|
915
|
+
spine: cmdSpine,
|
|
916
|
+
barTicks,
|
|
736
917
|
// Existing helpers (kept exposed for reuse)
|
|
737
918
|
pad,
|
|
738
919
|
visibleLength,
|
|
@@ -776,9 +957,13 @@ switch (cmd) {
|
|
|
776
957
|
case "plan-summary": cmdPlanSummary(rest[0]); break;
|
|
777
958
|
case "journey-tree": cmdJourneyTree(rest[0]); break;
|
|
778
959
|
case "milestone-complete": cmdMilestoneComplete(rest[0], rest[1], rest.slice(2).join(" ")); break;
|
|
960
|
+
case "spine": cmdSpine(rest[0]); break;
|
|
961
|
+
case "onboard": cmdOnboard(rest.join(" ")); break;
|
|
962
|
+
case "phase-complete": cmdPhaseComplete(rest[0], rest[1] || "", parseKV(rest.slice(2))); break;
|
|
963
|
+
case "clockout": cmdClockout(rest[0] || "", parseKV(rest.slice(1))); break;
|
|
779
964
|
default:
|
|
780
965
|
console.error(
|
|
781
|
-
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update|plan-summary|journey-tree|milestone-complete> [args]`
|
|
966
|
+
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update|plan-summary|journey-tree|milestone-complete|spine|onboard|phase-complete|clockout> [args]`
|
|
782
967
|
);
|
|
783
968
|
process.exit(1);
|
|
784
969
|
}
|
package/bin/report-payload.js
CHANGED
|
@@ -113,7 +113,10 @@ function buildPayload(options = {}) {
|
|
|
113
113
|
? { assignment_deadline: workPacket.deadline_date }
|
|
114
114
|
: {}),
|
|
115
115
|
client: tracking.client || "",
|
|
116
|
-
client_report_id
|
|
116
|
+
// ERP validates client_report_id as either a QS-REPORT-NN form or ABSENT.
|
|
117
|
+
// An empty string is rejected (422). Omit when unset so the server assigns
|
|
118
|
+
// a UUID, per docs/erp-contract.md (optional field).
|
|
119
|
+
...(env.CLIENT_REPORT_ID ? { client_report_id: env.CLIENT_REPORT_ID } : {}),
|
|
117
120
|
framework_version: config.version || "",
|
|
118
121
|
milestone: tracking.milestone || 1,
|
|
119
122
|
milestone_name: tracking.milestone_name || "",
|
package/bin/runtime-manifest.js
CHANGED
|
@@ -27,6 +27,7 @@ const RUNTIME_BIN_SCRIPTS = [
|
|
|
27
27
|
{ file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
|
|
28
28
|
{ file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
|
|
29
29
|
{ file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
|
|
30
|
+
{ file: "dep-verify.mjs", label: "dep-verify.mjs (hallucinated/slopsquatted dependency scanner — verify + ship gate)" },
|
|
30
31
|
{ file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
|
|
31
32
|
{ file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
|
|
32
33
|
{ file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
|
package/bin/state.js
CHANGED
|
@@ -655,7 +655,9 @@ function cmdTransitionIncrement(opts, target) {
|
|
|
655
655
|
const t = ensureLifetime(readTracking() || {});
|
|
656
656
|
inc.status = target;
|
|
657
657
|
if (target === "planned") {
|
|
658
|
-
|
|
658
|
+
// A5 sets status "failed" on a failed verify (vs the legacy path which keeps
|
|
659
|
+
// "verified" + verification="fail"); count a gap cycle for either.
|
|
660
|
+
if (prevStatus === "verified" || prevStatus === "failed") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
|
|
659
661
|
} else if (target === "built") {
|
|
660
662
|
inc.tasks_done = parseInt(opts.tasks_done) || 0;
|
|
661
663
|
inc.tasks_total = parseInt(opts.tasks_total) || 0;
|
|
@@ -956,7 +958,7 @@ Resume: ${s.resume || "—"}
|
|
|
956
958
|
|
|
957
959
|
// ─── Precondition Checks ─────────────────────────────────
|
|
958
960
|
const VALID_FROM = {
|
|
959
|
-
planned: ["setup", "verified"], // verified(fail) → planned = gap closure
|
|
961
|
+
planned: ["setup", "verified", "failed"], // verified(fail) [legacy] or failed [A5] → planned = gap closure
|
|
960
962
|
built: ["planned"],
|
|
961
963
|
verified: ["built"],
|
|
962
964
|
polished: ["verified"],
|
|
@@ -1073,8 +1075,9 @@ function checkPreconditions(current, target, opts) {
|
|
|
1073
1075
|
}
|
|
1074
1076
|
}
|
|
1075
1077
|
|
|
1076
|
-
// Gap-closure circuit breaker (configurable limit)
|
|
1077
|
-
|
|
1078
|
+
// Gap-closure circuit breaker (configurable limit). Fires from either the
|
|
1079
|
+
// legacy "verified"(+fail) status or the A5 "failed" status.
|
|
1080
|
+
if (target === "planned" && (current.status === "verified" || current.status === "failed")) {
|
|
1078
1081
|
const t = readTracking() || {};
|
|
1079
1082
|
const cycles = (t.gap_cycles || {})[String(phase)] || 0;
|
|
1080
1083
|
const limit = getGapCycleLimit();
|
|
@@ -1148,6 +1151,10 @@ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
|
|
|
1148
1151
|
if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
|
|
1149
1152
|
if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
|
|
1150
1153
|
return operate ? "/qualia-update" : "/qualia-polish";
|
|
1154
|
+
// A5 increments set status "failed" on a failed verify (legacy keeps
|
|
1155
|
+
// "verified"+fail). Route both to gap closure instead of dead-ending at /qualia.
|
|
1156
|
+
case "failed":
|
|
1157
|
+
return `/qualia-plan ${phase} --gaps`;
|
|
1151
1158
|
case "polished":
|
|
1152
1159
|
return "/qualia-ship";
|
|
1153
1160
|
case "shipped":
|
package/bin/statusline.js
CHANGED
|
@@ -205,7 +205,13 @@ try {
|
|
|
205
205
|
parts.push(mStr);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
if (total > 0)
|
|
208
|
+
if (total > 0) {
|
|
209
|
+
// Always-visible forward-motion bar: phase of total, scaled to 5 ticks.
|
|
210
|
+
const w = 5;
|
|
211
|
+
const filled = Math.max(0, Math.min(w, Math.round((phase / total) * w)));
|
|
212
|
+
const ticks = "▰".repeat(filled) + "▱".repeat(w - filled);
|
|
213
|
+
parts.push(`P${phase}/${total} ${ticks}`);
|
|
214
|
+
}
|
|
209
215
|
if (tasksTotal > 0) parts.push(`T${tasksDone}/${tasksTotal}`);
|
|
210
216
|
if (status) parts.push(status);
|
|
211
217
|
|
package/bin/trust-score.js
CHANGED
|
@@ -27,7 +27,7 @@ const REQUIRED_HOOKS = [
|
|
|
27
27
|
"session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
|
|
28
28
|
"pre-deploy-gate.js", "migration-guard.js", "git-guardrails.js",
|
|
29
29
|
"stop-session-log.js", "fawzi-approval-guard.js", "vercel-account-guard.js", "env-empty-guard.js",
|
|
30
|
-
"supabase-destructive-guard.js",
|
|
30
|
+
"supabase-destructive-guard.js", "secret-guard.js",
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const REQUIRED_DESIGN_FILES = [
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -346,20 +346,71 @@ if (fs.existsSync(slopScript)) {
|
|
|
346
346
|
encoding: "utf8",
|
|
347
347
|
timeout: 60000,
|
|
348
348
|
});
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
// Fail CLOSED (same contract as the dep-verify gate below): only a clean
|
|
350
|
+
// exit 0 passes; a CRITICAL finding (1) or a crash/timeout blocks.
|
|
351
|
+
if (r.status !== 0) {
|
|
352
|
+
console.error(
|
|
353
|
+
r.status === 1
|
|
354
|
+
? "BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying."
|
|
355
|
+
: `BLOCKED: anti-slop scan did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
|
|
356
|
+
);
|
|
351
357
|
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
352
358
|
if (output) {
|
|
353
359
|
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
354
360
|
for (const line of lines) console.error(` ${line}`);
|
|
355
361
|
}
|
|
356
|
-
_trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
|
|
362
|
+
_trace("pre-deploy-gate", "block", { gate: "slop", status: r.status, error: r.error ? r.error.message : undefined });
|
|
357
363
|
process.exit(2);
|
|
358
364
|
}
|
|
359
365
|
console.log(" ✓ Anti-slop");
|
|
360
366
|
}
|
|
361
367
|
}
|
|
362
368
|
|
|
369
|
+
// Dependency verification: zero-token, zero-network scan for imports of
|
|
370
|
+
// packages that are BOTH undeclared in package.json AND absent from
|
|
371
|
+
// node_modules — the signature of an AI-hallucinated or slopsquatted
|
|
372
|
+
// dependency (the #1 named AI-generated-code security failure mode). The
|
|
373
|
+
// correctness/security companion to the design-focused anti-slop scan above.
|
|
374
|
+
// Skipped silently when the scanner isn't installed (brownfield / older
|
|
375
|
+
// installs). OWNER-only escape hatch mirrors QUALIA_SKIP_SLOP.
|
|
376
|
+
const depScript = path.join(QUALIA_HOME, "bin", "dep-verify.mjs");
|
|
377
|
+
if (fs.existsSync(depScript)) {
|
|
378
|
+
const skipDep = process.env.QUALIA_SKIP_DEPCHECK === "1";
|
|
379
|
+
if (skipDep) {
|
|
380
|
+
const depRole = String(readConfig().role || "").toUpperCase();
|
|
381
|
+
if (depRole !== "OWNER") {
|
|
382
|
+
const depState = readState();
|
|
383
|
+
blockDeploy("QUALIA_SKIP_DEPCHECK is OWNER-only.", (depState && depState.next_command) || "/qualia");
|
|
384
|
+
}
|
|
385
|
+
console.log(" ⚠ Dependency check skipped (QUALIA_SKIP_DEPCHECK=1)");
|
|
386
|
+
_trace("pre-deploy-gate", "skip-depcheck", { reason: "QUALIA_SKIP_DEPCHECK=1" });
|
|
387
|
+
} else {
|
|
388
|
+
const r = spawnSync(process.execPath, [depScript, "--severity=critical"], {
|
|
389
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
390
|
+
encoding: "utf8",
|
|
391
|
+
timeout: 60000,
|
|
392
|
+
});
|
|
393
|
+
// Fail CLOSED: only a clean exit 0 passes. Findings (1), invocation
|
|
394
|
+
// errors (2), or a crash/timeout (status null + r.error) all block — a
|
|
395
|
+
// security gate that silently passes when it cannot run is not a gate.
|
|
396
|
+
if (r.status !== 0) {
|
|
397
|
+
console.error(
|
|
398
|
+
r.status === 1
|
|
399
|
+
? "BLOCKED: hallucinated/slopsquatted imports found. Fix before deploying."
|
|
400
|
+
: `BLOCKED: dep-verify did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
|
|
401
|
+
);
|
|
402
|
+
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
403
|
+
if (output) {
|
|
404
|
+
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
405
|
+
for (const line of lines) console.error(` ${line}`);
|
|
406
|
+
}
|
|
407
|
+
_trace("pre-deploy-gate", "block", { gate: "dep-verify", status: r.status, error: r.error ? r.error.message : undefined });
|
|
408
|
+
process.exit(2);
|
|
409
|
+
}
|
|
410
|
+
console.log(" ✓ Dependencies");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
363
414
|
console.log("⬢ All gates passed.");
|
|
364
415
|
|
|
365
416
|
_trace("pre-deploy-gate", "allow");
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ~/.claude/hooks/secret-guard.js — block commits that stage secrets.
|
|
3
|
+
//
|
|
4
|
+
// Constitution non-negotiables: the service_role key is server-only and must
|
|
5
|
+
// never be committed; .env files must never be committed. Those were prose-only
|
|
6
|
+
// until now. This is the deterministic gate: a PreToolUse hook on
|
|
7
|
+
// `git commit*` that scans STAGED content (git diff --cached) for high-signal
|
|
8
|
+
// secret patterns and a staged `.env` file, and BLOCKS the commit (exit 2) if
|
|
9
|
+
// any are found.
|
|
10
|
+
//
|
|
11
|
+
// Design mirrors pre-deploy-gate.js: self-filter on the command, fail-CLOSED on
|
|
12
|
+
// a real match, OWNER-only escape (QUALIA_SECRET_SKIP=1), trace every decision.
|
|
13
|
+
// It NEVER prints the matched secret value — only the pattern name and file.
|
|
14
|
+
//
|
|
15
|
+
// Exits 2 to BLOCK. Exits 0 to allow. Cross-platform. No external deps.
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const os = require("os");
|
|
20
|
+
const { spawnSync } = require("child_process");
|
|
21
|
+
|
|
22
|
+
const _traceStart = Date.now();
|
|
23
|
+
|
|
24
|
+
function qualiaHome() {
|
|
25
|
+
if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
|
|
26
|
+
const parent = path.basename(path.dirname(__dirname));
|
|
27
|
+
if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
|
|
28
|
+
return path.join(os.homedir(), ".claude");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const QUALIA_HOME = qualiaHome();
|
|
32
|
+
const CONFIG = path.join(QUALIA_HOME, ".qualia-config.json");
|
|
33
|
+
const SHELL = process.platform === "win32";
|
|
34
|
+
let HOOK_COMMAND = null;
|
|
35
|
+
|
|
36
|
+
// Self-filter: only act on `git commit`. Direct invocation (no stdin) runs the
|
|
37
|
+
// full scan so the test suite and manual runs work.
|
|
38
|
+
(function selfFilter() {
|
|
39
|
+
if (process.stdin.isTTY) return;
|
|
40
|
+
let command = null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
43
|
+
if (raw) {
|
|
44
|
+
const payload = JSON.parse(raw);
|
|
45
|
+
command = (payload && payload.tool_input && payload.tool_input.command) || "";
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
if (command === null) return; // no/!malformed stdin — run full scan
|
|
49
|
+
HOOK_COMMAND = command;
|
|
50
|
+
if (!/\bgit\s+commit\b/.test(command)) process.exit(0);
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
function readConfig() {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(fs.readFileSync(CONFIG, "utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _trace(result, extra) {
|
|
62
|
+
try {
|
|
63
|
+
const traceDir = path.join(QUALIA_HOME, ".qualia-traces");
|
|
64
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
65
|
+
const entry = { hook: "secret-guard", result, timestamp: new Date().toISOString(), duration_ms: Date.now() - _traceStart, ...extra };
|
|
66
|
+
fs.appendFileSync(path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`), JSON.stringify(entry) + "\n");
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// High-signal secret patterns. Narrow on purpose — false positives on a commit
|
|
71
|
+
// gate are expensive, so each pattern targets a real credential shape.
|
|
72
|
+
const SECRET_PATTERNS = [
|
|
73
|
+
{ name: "private key block", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ },
|
|
74
|
+
{ name: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
75
|
+
{ name: "OpenAI/Anthropic-style secret key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
|
76
|
+
{ name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
|
|
77
|
+
{ name: "Supabase service_role key assignment", re: /SUPABASE_SERVICE_ROLE_KEY\s*[:=]\s*['"]?[A-Za-z0-9._-]{20,}/ },
|
|
78
|
+
{ name: "service_role JWT", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}[\s\S]{0,400}service_role/ },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// A staged .env file (real env, not an example/template) is a leak by itself.
|
|
82
|
+
function stagedEnvFiles(names) {
|
|
83
|
+
return names.filter((f) => {
|
|
84
|
+
const base = path.basename(f);
|
|
85
|
+
if (!/^\.env(\.|$)/.test(base)) return false;
|
|
86
|
+
return !/\.(example|sample|template|dist)$/.test(base) && base !== ".env.example";
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function git(args) {
|
|
91
|
+
return spawnSync("git", args, { encoding: "utf8", timeout: 8000, shell: SHELL });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scan() {
|
|
95
|
+
// Staged file names.
|
|
96
|
+
const nameRes = git(["diff", "--cached", "--name-only"]);
|
|
97
|
+
if (nameRes.status !== 0) return { skipped: "not-a-repo-or-git-error" };
|
|
98
|
+
const names = (nameRes.stdout || "").split(/\r?\n/).filter(Boolean);
|
|
99
|
+
if (names.length === 0) return { clean: true, reason: "nothing-staged" };
|
|
100
|
+
|
|
101
|
+
const findings = [];
|
|
102
|
+
|
|
103
|
+
for (const env of stagedEnvFiles(names)) {
|
|
104
|
+
findings.push({ file: env, pattern: "staged .env file (never commit secrets)" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Added lines only (the `+` side of the staged diff).
|
|
108
|
+
const diffRes = git(["diff", "--cached", "--unified=0"]);
|
|
109
|
+
const diff = diffRes.stdout || "";
|
|
110
|
+
let currentFile = "?";
|
|
111
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
112
|
+
if (line.startsWith("+++ b/")) { currentFile = line.slice(6); continue; }
|
|
113
|
+
if (!line.startsWith("+") || line.startsWith("+++")) continue;
|
|
114
|
+
for (const p of SECRET_PATTERNS) {
|
|
115
|
+
if (p.re.test(line)) findings.push({ file: currentFile, pattern: p.name });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { findings };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function blockAllowedByOwner() {
|
|
122
|
+
if (process.env.QUALIA_SECRET_SKIP !== "1" && !/\bQUALIA_SECRET_SKIP=1\b/.test(HOOK_COMMAND || "")) return false;
|
|
123
|
+
const role = String(readConfig().role || "").toUpperCase();
|
|
124
|
+
if (role === "OWNER") return true;
|
|
125
|
+
console.error("BLOCKED: QUALIA_SECRET_SKIP is OWNER-only.");
|
|
126
|
+
_trace("block", { reason: "skip-non-owner" });
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
result = scan();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// Fail OPEN on a scan error — a commit gate that errors must not brick every
|
|
135
|
+
// commit. A real match (below) still blocks.
|
|
136
|
+
console.error(`⚠ secret-guard: scan error (${e.message}). Commit proceeding.`);
|
|
137
|
+
_trace("warn", { error: e.message });
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result && result.findings && result.findings.length > 0) {
|
|
142
|
+
if (blockAllowedByOwner()) {
|
|
143
|
+
console.error("⚠ secret-guard: findings present but QUALIA_SECRET_SKIP=1 (OWNER). Commit proceeding.");
|
|
144
|
+
_trace("allow", { skipped_owner: true, count: result.findings.length });
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
console.error("BLOCKED: secret(s) detected in staged content. Unstage and remove before committing.");
|
|
148
|
+
// Dedupe file+pattern; never print the matched value.
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const f of result.findings) {
|
|
151
|
+
const key = `${f.file}::${f.pattern}`;
|
|
152
|
+
if (seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
console.error(` ✗ ${f.file} — ${f.pattern}`);
|
|
155
|
+
}
|
|
156
|
+
console.error(" Fix: move the secret to an env var / secrets manager; add the file to .gitignore.");
|
|
157
|
+
_trace("block", { count: seen.size });
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_trace("allow", result || {});
|
|
162
|
+
process.exit(0);
|
package/hooks/session-start.js
CHANGED
|
@@ -225,6 +225,7 @@ try {
|
|
|
225
225
|
fallbackText();
|
|
226
226
|
} else if (fs.existsSync(STATE_FILE)) {
|
|
227
227
|
runUi("banner", "router");
|
|
228
|
+
runUi("spine"); // horizontal "you are here" journey map (self-skips if <2 milestones)
|
|
228
229
|
renderWorkPacketContext();
|
|
229
230
|
const next = getNextCommand();
|
|
230
231
|
if (next) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework",
|
|
3
|
-
"version": "7.0
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework": "./bin/cli.js"
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"test:lib": "bash tests/lib.test.sh",
|
|
31
31
|
"test:skills": "bash tests/skills.test.sh",
|
|
32
32
|
"test:slop-detect": "bash tests/slop-detect.test.sh",
|
|
33
|
+
"test:dep-verify": "bash tests/dep-verify.test.sh",
|
|
34
|
+
"test:runtime-parity": "bash tests/runtime-parity.test.sh",
|
|
35
|
+
"test:secret-guard": "bash tests/secret-guard.test.sh",
|
|
33
36
|
"test:statusline": "bash tests/statusline.test.sh",
|
|
34
37
|
"test:refs": "bash tests/refs.test.sh",
|
|
35
38
|
"test:published-install": "bash tests/published-install-smoke.test.sh",
|
|
@@ -172,11 +172,18 @@ Status protocol (machine-readable fan-in — do this, do not skip):
|
|
|
172
172
|
(use BLOCKED or PARTIAL with `--note \"why\"` instead of DONE if you could not finish)
|
|
173
173
|
|
|
174
174
|
Execute. Commit. Write your DONE/BLOCKED/PARTIAL status. Return DONE/BLOCKED/PARTIAL.
|
|
175
|
-
", subagent_type="qualia-builder", description="Task {N}: {title}")
|
|
175
|
+
", subagent_type="qualia-builder", model="{tier}", description="Task {N}: {title}")
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
**Cache ordering:** Role + grounding FIRST, phase_context SECOND, task_context LAST. Stable prefix ~2-5k tokens → 92% cache hit. Pre-inline eliminates 3-5 Read calls per builder.
|
|
179
179
|
|
|
180
|
+
**Model routing (intelligent model routing — the OpEx lever).** Most builders are *implementers*, not architects: they follow a precise task contract (files, AC, validation) within established patterns. That is exactly the deterministic, low-ambiguity work that belongs on a cheaper, faster model — reserving the frontier model for the judgment-heavy steps (planning, verification, skeptic adjudication). Per task, pass `model=`:
|
|
181
|
+
- **`sonnet`** (default for builders) — single-/few-file tasks against established patterns, CRUD, wiring, styling, test scaffolding. The bulk of every phase.
|
|
182
|
+
- **frontier (omit `model=` → inherits the session model)** — tasks the contract marks high-complexity, the `architect` persona, novel algorithms, or anything touching auth/payments/migrations where a wrong-but-plausible implementation is expensive.
|
|
183
|
+
- **`haiku`** — pure mechanical edits (rename sweeps, import fixes, format-only changes); pairs naturally with `/qualia-build --batch`.
|
|
184
|
+
|
|
185
|
+
When unsure, default to `sonnet`; escalate only when the task carries real ambiguity or correctness risk. This routes the high-volume mechanical work off the frontier model without weakening the steps where model strength actually changes the outcome.
|
|
186
|
+
|
|
180
187
|
**After each task:**
|
|
181
188
|
- Verify commit: `git log --oneline -1`
|
|
182
189
|
- Show:
|
|
@@ -155,6 +155,17 @@ fi
|
|
|
155
155
|
node ${QUALIA_BIN}/qualia-ui.js divider
|
|
156
156
|
node ${QUALIA_BIN}/qualia-ui.js ok "Report $CLIENT_REPORT_ID complete."
|
|
157
157
|
node ${QUALIA_BIN}/qualia-ui.js info "Shift report submitted. You can clock out now."
|
|
158
|
+
|
|
159
|
+
# End-of-day report card. Name from config; commits = $COUNT; milestone
|
|
160
|
+
# progress from state. erp=ok when the upload confirmed, erp=queued when it was
|
|
161
|
+
# enqueued for retry. Every metric self-omits if its variable is empty.
|
|
162
|
+
EMP_NAME=$(node -pe 'try{JSON.parse(require("fs").readFileSync(process.env.HOME+"/.claude/.qualia-config.json","utf8")).installed_by||""}catch{""}' 2>/dev/null)
|
|
163
|
+
MDONE=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
|
|
164
|
+
MTOTAL=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
|
|
165
|
+
node ${QUALIA_BIN}/qualia-ui.js clockout "$EMP_NAME" date="{YYYY-MM-DD}" \
|
|
166
|
+
${COUNT:+commits=$COUNT} \
|
|
167
|
+
${MDONE:+mdone=$MDONE} ${MTOTAL:+mtotal=$MTOTAL} \
|
|
168
|
+
${ERP_RESULT:+erp=$ERP_RESULT}
|
|
158
169
|
```
|
|
159
170
|
|
|
160
171
|
## Common errors (read this when something goes wrong)
|
|
@@ -66,9 +66,10 @@ if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.
|
|
|
66
66
|
if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)"; then npm test; fi
|
|
67
67
|
npm run build # Build — must succeed
|
|
68
68
|
node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # Anti-slop — CRITICAL design tells block ship
|
|
69
|
+
node ${QUALIA_BIN}/dep-verify.mjs --severity=critical # Hallucinated/slopsquatted imports block ship
|
|
69
70
|
```
|
|
70
71
|
|
|
71
|
-
The `pre-deploy-gate.js` hook re-runs
|
|
72
|
+
`dep-verify` blocks ship when any import references a package that is neither declared in `package.json` nor installed in `node_modules` — catching AI-invented or typosquatted dependencies before they reach production. The `pre-deploy-gate.js` hook re-runs **both** scans at `vercel --prod` time as the hard, non-bypassable gate — anti-slop (OWNER-only `QUALIA_SKIP_SLOP=1` escape) and dep-verify (OWNER-only `QUALIA_SKIP_DEPCHECK=1` escape, for the rare known-good import not yet in `package.json`). Both gates fail **closed**: a crash or timeout in either scanner blocks the deploy rather than passing silently. This step surfaces failures early so they're fixed before the deploy command.
|
|
72
73
|
|
|
73
74
|
On failure:
|
|
74
75
|
1. Summarize what failed in plain language
|
|
@@ -221,6 +222,21 @@ fi
|
|
|
221
222
|
```
|
|
222
223
|
Do NOT manually edit STATE.md or tracking.json — state.js handles both.
|
|
223
224
|
|
|
225
|
+
```bash
|
|
226
|
+
# Per-phase celebration card. Reads the freshly-updated state so the phase
|
|
227
|
+
# number, name, and milestone progress bar are accurate. Streak = phases this
|
|
228
|
+
# employee shipped today (from the trace log); omitted if unavailable. Every
|
|
229
|
+
# metric self-omits when missing, so this never fabricates.
|
|
230
|
+
PHASE_NUM=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
|
|
231
|
+
PHASE_NAME=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase_name||""}catch{""}')
|
|
232
|
+
TOTAL_PHASES=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
|
|
233
|
+
node ${QUALIA_BIN}/qualia-ui.js phase-complete "${PHASE_NUM:-?}" "$PHASE_NAME" \
|
|
234
|
+
${TASKS_DONE:+tasks=$TASKS_DONE} \
|
|
235
|
+
${PHASE_NUM:+mdone=$PHASE_NUM} ${TOTAL_PHASES:+mtotal=$TOTAL_PHASES} \
|
|
236
|
+
${STREAK:+streak=$STREAK} \
|
|
237
|
+
next=/qualia-handoff
|
|
238
|
+
```
|
|
239
|
+
|
|
224
240
|
```bash
|
|
225
241
|
node ${QUALIA_BIN}/qualia-ui.js end "SHIPPED" "/qualia-handoff"
|
|
226
242
|
```
|