qualia-framework 7.1.0 → 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 +30 -0
- package/bin/install.js +6 -0
- package/bin/qualia-ui.js +186 -1
- package/bin/statusline.js +7 -1
- package/hooks/session-start.js +1 -0
- package/package.json +1 -1
- package/skills/qualia-report/SKILL.md +11 -0
- package/skills/qualia-ship/SKILL.md +15 -0
- package/tests/journey-spine.test.sh +171 -0
- package/tests/run-all.sh +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
> Note: git tags for historical versions were not retained; commit references are approximate
|
|
9
9
|
> and dates reflect commit history rather than npm publish timestamps.
|
|
10
10
|
|
|
11
|
+
## [7.2.0] - 2026-06-25 (journey spine — lifecycle UX for employees)
|
|
12
|
+
|
|
13
|
+
A cosmetic/UX layer that gives an employee a continuous sense of place and
|
|
14
|
+
momentum across the whole lifecycle — install → start → work → finish — reusing
|
|
15
|
+
the existing teal palette and glyph set. No new commands to learn; the framework
|
|
16
|
+
just shows "where am I, what's my forward motion, what's the one next move" at
|
|
17
|
+
every touchpoint.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **`qualia-ui.js spine`** — compact horizontal milestone ladder
|
|
21
|
+
(`M1 ●──M2 ◆──M3 ○`) with a caret aligned exactly beneath the current
|
|
22
|
+
milestone. Rendered by the session-start banner so the journey map is the
|
|
23
|
+
first thing an employee sees. Self-skips on single-milestone projects.
|
|
24
|
+
- **`qualia-ui.js onboard <name>`** — first-run mental-model card (Plan → Build
|
|
25
|
+
→ Verify → Ship + the one first move). Shown after install for non-OWNER
|
|
26
|
+
roles only.
|
|
27
|
+
- **`qualia-ui.js phase-complete <n> <name> [k=v…]`** — per-phase celebration
|
|
28
|
+
with a milestone progress bar and optional streak; wired into `/qualia-ship`.
|
|
29
|
+
- **`qualia-ui.js clockout <name> [k=v…]`** — end-of-day report card (shipped
|
|
30
|
+
counts, milestone %, streak); wired into `/qualia-report`. Every metric
|
|
31
|
+
self-omits when its data is unavailable — never fabricates.
|
|
32
|
+
- **`barTicks(done,total,width)`** — reusable `▰▱` micro-bar helper.
|
|
33
|
+
- **Always-visible progress in the statusline** — the phase pill now carries a
|
|
34
|
+
`▰▰▰▱▱` bar (`P3/5 ▰▰▰▱▱`) so forward motion is ambient while working.
|
|
35
|
+
|
|
36
|
+
### Tests
|
|
37
|
+
- New suite `journey-spine.test.sh` (33 cases): barTicks scaling/clamping, all
|
|
38
|
+
four cards, spine caret-alignment (exact column match), self-skip paths, and
|
|
39
|
+
the statusline bar. Full suite green: **28 shell suites + 179 node tests**.
|
|
40
|
+
|
|
11
41
|
## [7.1.0] - 2026-06-25 (harness gates — hallucinated deps, secrets, model routing)
|
|
12
42
|
|
|
13
43
|
Hardening pass: reviewed Google's "The New SDLC With Vibe Coding" whitepaper
|
package/bin/install.js
CHANGED
|
@@ -1572,6 +1572,12 @@ function printSummary({ member, target, claudeInstalled }) {
|
|
|
1572
1572
|
console.log(
|
|
1573
1573
|
` ${DIM} performance audit. Opt out:${RESET} ${TEAL}erp.capturePrompts=false${RESET} ${DIM}in ~/.claude/.qualia-config.json${RESET}`
|
|
1574
1574
|
);
|
|
1575
|
+
// New employees get the milestone mental model up front — OWNERs already
|
|
1576
|
+
// know how the flow works, so we skip the card for them to avoid noise.
|
|
1577
|
+
if (member.role !== "OWNER") {
|
|
1578
|
+
try { ui.onboard(member.name); } catch {}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1575
1581
|
console.log("");
|
|
1576
1582
|
console.log(` ${DIM2}${RULE}${RESET}`);
|
|
1577
1583
|
console.log(` ${TEAL}${BOLD}Welcome to the future with Qualia.${RESET}`);
|
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/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/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
|
@@ -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)
|
|
@@ -222,6 +222,21 @@ fi
|
|
|
222
222
|
```
|
|
223
223
|
Do NOT manually edit STATE.md or tracking.json — state.js handles both.
|
|
224
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
|
+
|
|
225
240
|
```bash
|
|
226
241
|
node ${QUALIA_BIN}/qualia-ui.js end "SHIPPED" "/qualia-handoff"
|
|
227
242
|
```
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# journey-spine.test.sh — lifecycle cosmetic layer (bin/qualia-ui.js spine/onboard/
|
|
3
|
+
# phase-complete/clockout + barTicks + statusline progress bar).
|
|
4
|
+
# Run: bash tests/journey-spine.test.sh
|
|
5
|
+
|
|
6
|
+
PASS=0
|
|
7
|
+
FAIL=0
|
|
8
|
+
BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)"
|
|
9
|
+
NODE="${NODE:-node}"
|
|
10
|
+
UI="$BIN_DIR/qualia-ui.js"
|
|
11
|
+
STATE="$BIN_DIR/state.js"
|
|
12
|
+
STATUSLINE="$BIN_DIR/statusline.js"
|
|
13
|
+
|
|
14
|
+
# strip ANSI for column-accurate assertions
|
|
15
|
+
strip() { sed 's/\x1b\[[0-9;]*m//g'; }
|
|
16
|
+
|
|
17
|
+
assert_contains() { if echo "$2" | strip | grep -qF -- "$3"; then echo " ✓ $1"; PASS=$((PASS+1)); else echo " ✗ $1 (missing '$3')"; FAIL=$((FAIL+1)); fi; }
|
|
18
|
+
assert_not_contains() { if echo "$2" | strip | grep -qF -- "$3"; then echo " ✗ $1 (should not contain '$3')"; FAIL=$((FAIL+1)); else echo " ✓ $1"; PASS=$((PASS+1)); fi; }
|
|
19
|
+
|
|
20
|
+
echo "journey-spine.test.sh — lifecycle cosmetic layer"
|
|
21
|
+
echo ""
|
|
22
|
+
|
|
23
|
+
$NODE -c "$UI" 2>/dev/null && { echo " ✓ qualia-ui syntax valid"; PASS=$((PASS+1)); } || { echo " ✗ qualia-ui syntax invalid"; FAIL=$((FAIL+1)); }
|
|
24
|
+
|
|
25
|
+
# ── barTicks: scaling + clamping ──
|
|
26
|
+
TICKS=$($NODE -e 'const u=require(process.argv[1]); console.log(["a"+u.barTicks(4,5),"b"+u.barTicks(0,5),"c"+u.barTicks(5,5),"d"+u.barTicks(9,5),"e"+u.barTicks(2,0)].join("\n"))' "$UI" | strip)
|
|
27
|
+
assert_contains "barTicks 4/5 → 4 filled" "$TICKS" "a▰▰▰▰▱"
|
|
28
|
+
assert_contains "barTicks 0/5 → all empty" "$TICKS" "b▱▱▱▱▱"
|
|
29
|
+
assert_contains "barTicks 5/5 → all filled" "$TICKS" "c▰▰▰▰▰"
|
|
30
|
+
assert_contains "barTicks clamps over-fill to 5" "$TICKS" "d▰▰▰▰▰"
|
|
31
|
+
assert_contains "barTicks total<1 → empty string" "$TICKS" "e"
|
|
32
|
+
|
|
33
|
+
# ── onboard card ──
|
|
34
|
+
ONB=$($NODE "$UI" onboard "Maria" 2>&1)
|
|
35
|
+
assert_contains "onboard greets by name" "$ONB" "welcome aboard, Maria"
|
|
36
|
+
assert_contains "onboard shows milestone flow" "$ONB" "Plan"
|
|
37
|
+
assert_contains "onboard shows the first move" "$ONB" "/qualia"
|
|
38
|
+
|
|
39
|
+
# ── phase-complete card ──
|
|
40
|
+
PC=$($NODE "$UI" phase-complete 3 "Checkout flow" tasks=6 mdone=4 mtotal=5 streak=3 next=/qualia-build nextname="Order confirmation" 2>&1)
|
|
41
|
+
assert_contains "phase-complete shows phase + name" "$PC" "PHASE 3 COMPLETE"
|
|
42
|
+
assert_contains "phase-complete shows task count" "$PC" "6 tasks"
|
|
43
|
+
assert_contains "phase-complete shows milestone bar" "$PC" "4/5 phases"
|
|
44
|
+
assert_contains "phase-complete 'one more' at 4/5" "$PC" "one more to close the milestone"
|
|
45
|
+
assert_contains "phase-complete shows streak" "$PC" "3 phases shipped today"
|
|
46
|
+
assert_contains "phase-complete shows next command" "$PC" "/qualia-build"
|
|
47
|
+
# streak of 1 must NOT render (not a streak)
|
|
48
|
+
PC1=$($NODE "$UI" phase-complete 1 "Cart" streak=1 2>&1)
|
|
49
|
+
assert_not_contains "phase-complete hides streak=1" "$PC1" "shipped today"
|
|
50
|
+
# milestone complete wording at full
|
|
51
|
+
PCFULL=$($NODE "$UI" phase-complete 5 "Polish" mdone=5 mtotal=5 2>&1)
|
|
52
|
+
assert_contains "phase-complete 'ready to close' at 5/5" "$PCFULL" "milestone ready to close"
|
|
53
|
+
|
|
54
|
+
# ── clockout card ──
|
|
55
|
+
CO=$($NODE "$UI" clockout "Maria" date="Thu Jun 25" phases=3 tasks=14 commits=9 mdone=4 mtotal=5 streak=4 erp=ok 2>&1)
|
|
56
|
+
assert_contains "clockout names the employee" "$CO" "CLOCK OUT"
|
|
57
|
+
assert_contains "clockout shows shipped phases" "$CO" "3 phases"
|
|
58
|
+
assert_contains "clockout shows commits" "$CO" "9 commits"
|
|
59
|
+
assert_contains "clockout shows milestone %" "$CO" "80%"
|
|
60
|
+
assert_contains "clockout shows streak" "$CO" "4 days"
|
|
61
|
+
assert_contains "clockout confirms ERP upload" "$CO" "uploaded to ERP"
|
|
62
|
+
CO_Q=$($NODE "$UI" clockout "Maria" erp=queued 2>&1)
|
|
63
|
+
assert_contains "clockout queued state" "$CO_Q" "queued"
|
|
64
|
+
|
|
65
|
+
# ── spine: needs a multi-milestone project fixture ──
|
|
66
|
+
TMP=$(mktemp -d)
|
|
67
|
+
(
|
|
68
|
+
cd "$TMP"
|
|
69
|
+
$NODE "$STATE" init --project "acme-portal" \
|
|
70
|
+
--phases '[{"name":"Cart","goal":"x"},{"name":"Checkout","goal":"y"},{"name":"Receipts","goal":"z"}]' >/dev/null 2>&1
|
|
71
|
+
mkdir -p .planning
|
|
72
|
+
cat > .planning/JOURNEY.md <<'JEOF'
|
|
73
|
+
---
|
|
74
|
+
project: "acme-portal"
|
|
75
|
+
---
|
|
76
|
+
## Milestone 1 · Foundations
|
|
77
|
+
1. **Auth**
|
|
78
|
+
## Milestone 2 · Commerce
|
|
79
|
+
1. **Cart**
|
|
80
|
+
## Milestone 3 · Growth
|
|
81
|
+
1. **Referrals**
|
|
82
|
+
## Milestone 4 · Handoff
|
|
83
|
+
1. **Docs**
|
|
84
|
+
JEOF
|
|
85
|
+
)
|
|
86
|
+
SPINE=$(cd "$TMP" && $NODE "$UI" spine 2>&1)
|
|
87
|
+
assert_contains "spine renders the Journey label" "$SPINE" "Journey"
|
|
88
|
+
assert_contains "spine renders all 4 milestones" "$SPINE" "M4"
|
|
89
|
+
assert_contains "spine shows current marker ◆" "$SPINE" "◆"
|
|
90
|
+
assert_contains "spine shows 'you are here'" "$SPINE" "you are here"
|
|
91
|
+
|
|
92
|
+
# caret column == current-marker column (exact alignment)
|
|
93
|
+
ALIGN=$(cd "$TMP" && $NODE "$UI" spine 2>&1 | strip | $NODE -e '
|
|
94
|
+
let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{
|
|
95
|
+
const lines=s.split("\n").filter(l=>l.length);
|
|
96
|
+
const spine=lines.find(l=>l.includes("Journey"));
|
|
97
|
+
const caret=lines.find(l=>l.includes("you are here"));
|
|
98
|
+
if(!spine||!caret){console.log("NOLINES");return;}
|
|
99
|
+
const markerCol=spine.indexOf("◆");
|
|
100
|
+
const caretCol=caret.indexOf("↑");
|
|
101
|
+
console.log(markerCol===caretCol?"ALIGNED":("MISALIGNED "+markerCol+" vs "+caretCol));
|
|
102
|
+
});
|
|
103
|
+
')
|
|
104
|
+
assert_contains "spine caret aligns under current marker" "$ALIGN" "ALIGNED"
|
|
105
|
+
|
|
106
|
+
# spine self-skips with <2 milestones (single-milestone project)
|
|
107
|
+
TMP2=$(mktemp -d)
|
|
108
|
+
( cd "$TMP2" && $NODE "$STATE" init --project "solo" --phases '[{"name":"A","goal":"x"}]' >/dev/null 2>&1 )
|
|
109
|
+
SPINE2=$(cd "$TMP2" && $NODE "$UI" spine 2>&1)
|
|
110
|
+
assert_not_contains "spine self-skips single-milestone project" "$SPINE2" "you are here"
|
|
111
|
+
|
|
112
|
+
# spine outside any project → no crash, no output
|
|
113
|
+
SPINE3=$(cd / && $NODE "$UI" spine 2>&1)
|
|
114
|
+
assert_not_contains "spine no-ops outside a project" "$SPINE3" "Journey"
|
|
115
|
+
|
|
116
|
+
# ── statusline: the always-visible progress bar ──
|
|
117
|
+
TMP3=$(mktemp -d); mkdir -p "$TMP3/.planning"
|
|
118
|
+
cat > "$TMP3/.planning/tracking.json" <<'TEOF'
|
|
119
|
+
{"phase":3,"total_phases":5,"status":"building","milestone":2,"milestone_name":"Commerce","tasks_done":2,"tasks_total":6,"blockers":[]}
|
|
120
|
+
TEOF
|
|
121
|
+
SL=$(printf '{"workspace":{"current_dir":"%s"},"cost":{"total_cost_usd":0},"duration_ms":0}' "$TMP3" | $NODE "$STATUSLINE" 2>&1)
|
|
122
|
+
assert_contains "statusline shows P3/5" "$SL" "P3/5"
|
|
123
|
+
assert_contains "statusline shows the tick bar" "$SL" "▰▰▰▱▱"
|
|
124
|
+
|
|
125
|
+
# ── skill-wiring smoke: run the ACTUAL ship/report closing bash ──
|
|
126
|
+
# These mirror the exact variable plumbing in skills/qualia-ship/SKILL.md and
|
|
127
|
+
# skills/qualia-report/SKILL.md (state.js parsing + ${VAR:+…} expansions), so a
|
|
128
|
+
# regression in the wiring — not just the renderer — is caught.
|
|
129
|
+
TMP4=$(mktemp -d)
|
|
130
|
+
( cd "$TMP4" && $NODE "$STATE" init --project "wired" \
|
|
131
|
+
--phases '[{"name":"Cart","goal":"x"},{"name":"Checkout","goal":"y"},{"name":"Receipts","goal":"z"}]' >/dev/null 2>&1 )
|
|
132
|
+
|
|
133
|
+
# ---- /qualia-ship closing block (verbatim plumbing) ----
|
|
134
|
+
SHIP_OUT=$(cd "$TMP4" && {
|
|
135
|
+
PHASE_NUM=$($NODE "$STATE" check 2>/dev/null | $NODE -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
|
|
136
|
+
PHASE_NAME=$($NODE "$STATE" check 2>/dev/null | $NODE -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase_name||""}catch{""}')
|
|
137
|
+
TOTAL_PHASES=$($NODE "$STATE" check 2>/dev/null | $NODE -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
|
|
138
|
+
TASKS_DONE=4
|
|
139
|
+
$NODE "$UI" phase-complete "${PHASE_NUM:-?}" "$PHASE_NAME" \
|
|
140
|
+
${TASKS_DONE:+tasks=$TASKS_DONE} \
|
|
141
|
+
${PHASE_NUM:+mdone=$PHASE_NUM} ${TOTAL_PHASES:+mtotal=$TOTAL_PHASES} \
|
|
142
|
+
next=/qualia-handoff
|
|
143
|
+
} 2>&1)
|
|
144
|
+
assert_contains "ship wiring → resolves phase number into card" "$SHIP_OUT" "PHASE 1 COMPLETE"
|
|
145
|
+
assert_contains "ship wiring → tasks plumbed through" "$SHIP_OUT" "4 tasks"
|
|
146
|
+
assert_contains "ship wiring → milestone bar plumbed" "$SHIP_OUT" "phases"
|
|
147
|
+
assert_contains "ship wiring → next command set" "$SHIP_OUT" "/qualia-handoff"
|
|
148
|
+
assert_not_contains "ship wiring → no literal \${VAR" "$SHIP_OUT" '${'
|
|
149
|
+
|
|
150
|
+
# ---- /qualia-report closing block (verbatim plumbing) ----
|
|
151
|
+
REPORT_OUT=$(cd "$TMP4" && {
|
|
152
|
+
EMP_NAME=$($NODE -pe 'try{JSON.parse(require("fs").readFileSync(process.env.HOME+"/.claude/.qualia-config.json","utf8")).installed_by||""}catch{""}' 2>/dev/null)
|
|
153
|
+
MDONE=$($NODE "$STATE" check 2>/dev/null | $NODE -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
|
|
154
|
+
MTOTAL=$($NODE "$STATE" check 2>/dev/null | $NODE -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
|
|
155
|
+
COUNT=9
|
|
156
|
+
ERP_RESULT=ok
|
|
157
|
+
$NODE "$UI" clockout "$EMP_NAME" date="2026-06-25" \
|
|
158
|
+
${COUNT:+commits=$COUNT} \
|
|
159
|
+
${MDONE:+mdone=$MDONE} ${MTOTAL:+mtotal=$MTOTAL} \
|
|
160
|
+
${ERP_RESULT:+erp=$ERP_RESULT}
|
|
161
|
+
} 2>&1)
|
|
162
|
+
assert_contains "report wiring → renders CLOCK OUT" "$REPORT_OUT" "CLOCK OUT"
|
|
163
|
+
assert_contains "report wiring → commit count plumbed" "$REPORT_OUT" "9 commits"
|
|
164
|
+
assert_contains "report wiring → milestone % plumbed" "$REPORT_OUT" "%"
|
|
165
|
+
assert_contains "report wiring → erp result plumbed" "$REPORT_OUT" "uploaded to ERP"
|
|
166
|
+
assert_not_contains "report wiring → no literal \${VAR" "$REPORT_OUT" '${'
|
|
167
|
+
|
|
168
|
+
rm -rf "$TMP" "$TMP2" "$TMP3" "$TMP4"
|
|
169
|
+
echo ""
|
|
170
|
+
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
171
|
+
[ "$FAIL" -eq 0 ]
|