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 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) parts.push(`P${phase}/${total}`);
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
 
@@ -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.1.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"
@@ -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 ]
package/tests/run-all.sh CHANGED
@@ -35,6 +35,7 @@ SUITES=(
35
35
  "repo-map"
36
36
  "design-tokens"
37
37
  "batch-plan"
38
+ "journey-spine"
38
39
  )
39
40
 
40
41
  FAILED=()