create-cmp-cli 0.24.0 → 0.25.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.
Files changed (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -0,0 +1,642 @@
1
+ // MOVED from inspector/mcp/src/lib/console-overview.mjs — NORTH-STAR §9, stage
2
+ // 0.5, "the console into the harness". Unchanged in the move; like
3
+ // console-shell.mjs it had no imports, so it crossed the package boundary
4
+ // without leaving an edge behind. See console-shell.mjs's note for why the
5
+ // destination is `src/console/` rather than `src/lib/`.
6
+ //
7
+ // console-overview.mjs — the front door (STUDIO-REDESIGN.md §3.7).
8
+ //
9
+ // WHY THIS SECTION EXISTS, and what it supersedes. §2 of the redesign made the
10
+ // dashboard function "ambient — never a separate tab": every fact renders in
11
+ // the section that owns it, and the rail carries the roll-up. That rule was
12
+ // right about ownership and wrong about ENTRY. It was patched once already —
13
+ // the 07-28 audit added the governance strip to answer "I should be able to see
14
+ // the status at all times" — and the second report of the same failure (2026-08-22,
15
+ // Karel: "not easy to see and use ... there is so much happening all over the
16
+ // place") is the evidence that a rail widget cannot carry it. Nine peer sections
17
+ // with no hierarchy of attention above them is not a navigation problem; it is a
18
+ // missing page.
19
+ //
20
+ // So the ambient rule is superseded HERE and ONLY here: exactly one section may
21
+ // aggregate, it is the first one, and it OWNS NO FACTS. Every rule that made the
22
+ // ambient design right still binds:
23
+ //
24
+ // - Composition only. This module derives nothing. It arranges what
25
+ // deriveHumanQueue, getApprovalAnchoredDiff, getDigestData and the receipt
26
+ // bridge already returned. If a number here could ever disagree with the
27
+ // section that owns it, this file is wrong.
28
+ // - Sign where you read, AMENDED 2026-08-24 (Karel: "in the overview give the
29
+ // option to approve as well"). Each row now carries its own signature
30
+ // control, because the rule's purpose — never sign what you have not read —
31
+ // is served by the evidence already on the row (the drift file split, the
32
+ // promise tally and receipt) plus "read it first" one click away, not by
33
+ // forcing a round trip for a decision already made. The control is NOT a
34
+ // second mechanism: it emits the exact markup the existing wiring speaks
35
+ // (.approve-btn / .feature-accept-btn), so there is still one approve path,
36
+ // one accept path, and refusals stay the server's.
37
+ // - Evidence-or-silence. An unavailable ledger says so in its own words. No
38
+ // block invents an empty state that reads like a clean bill of health.
39
+ //
40
+ // The page answers the returning owner's three questions, in the order they are
41
+ // actually asked: what needs me (queue) · what changed (digest) · is it still
42
+ // proven (the standing line in the header).
43
+ //
44
+ // The one import, added 2026-09-08, and it does not breach "composition only":
45
+ // two surfaces here show an evidence rung — the standing line and the
46
+ // recent-requests rows — and NORTH-STAR.md §6.5 requires both to show the pack
47
+ // that graded it. console-evidence.mjs is where the console keeps that one
48
+ // spelling, so importing it is the same rule as taking `receiptGlyph` from the
49
+ // caller: this file arranges a derivation it does not own. The module is a
50
+ // sibling inside `src/console/`, so the package boundary this file crossed
51
+ // cleanly is untouched.
52
+
53
+ import { rungWithPack, rungPackNote } from "./console-evidence.mjs";
54
+
55
+ const esc = (s) =>
56
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
57
+ const escAttr = (s) => esc(s).replace(/"/g, "&quot;");
58
+
59
+ /**
60
+ * The section's status line — the "is it still proven" answer, in one line:
61
+ * the lane's own glyph + verdict + rung, then the signing tally. Both halves
62
+ * are read from data the rail already shows, so the two can never disagree.
63
+ *
64
+ * @param {{receipt?: object|null, statuses?: object[], receiptGlyph: Function,
65
+ * formatAge?: Function}} p `receiptGlyph` is injected (console-shell owns
66
+ * that derivation — importing it here would fork nothing, but passing it
67
+ * keeps the ONE-derivation rule visible at the call site).
68
+ */
69
+ export function overviewStatusHtml({ receipt, statuses = [], receiptGlyph, formatAge } = {}) {
70
+ const g = receiptGlyph ? receiptGlyph(receipt) : null;
71
+ const lane = g
72
+ ? `<span class="glyph ${g.cls}" title="${escAttr(g.label)}">${g.ch}</span> ${esc(g.label)}`
73
+ : "";
74
+ const age =
75
+ receipt && receipt.available && typeof receipt.ageMs === "number" && formatAge
76
+ ? ` &middot; ${esc(formatAge(receipt.ageMs))}`
77
+ : "";
78
+ // The rung carries the pack that graded it (§6.5), because this is the line a
79
+ // returning owner reads to answer "is it still proven" and a bare L2 does not
80
+ // answer it: §8.9 makes one pack's L2 and another's different claims, so the
81
+ // grade alone tells the reader which of two unlike things they are looking at
82
+ // only if they already knew. The badge holds both or neither.
83
+ const label =
84
+ receipt && receipt.available ? rungWithPack(receipt.evidenceLevel, receipt.packId ?? receipt.pack) : null;
85
+ const rung = label
86
+ ? ` &middot; <span class="badge evidence-rung" title="${escAttr(rungPackNote(receipt.evidenceLevel, receipt.packId ?? receipt.pack))}">${esc(label)}</span>`
87
+ : "";
88
+ // The tally is silent on an ungoverned project rather than printing "0 of 0
89
+ // signed", which reads as a finding when it is an absence.
90
+ const tally = statuses.length
91
+ ? ` &middot; ${statuses.filter((s) => s.status === "approved").length} of ${statuses.length} signed`
92
+ : "";
93
+ return `${lane}${age}${rung}${tally}`;
94
+ }
95
+
96
+ /**
97
+ * A queue item's own signature control (Karel, 2026-08-24: "in the overview
98
+ * give the option to approve as well").
99
+ *
100
+ * This SUPERSEDES the front door's original "names the act and jumps, never
101
+ * signs" rule. What that rule was protecting — never sign what you have not
102
+ * read — is preserved by construction rather than by making you travel: every
103
+ * row already carries the evidence its signature is about (a drifted
104
+ * artifact's changed/still-signed file split, a proven feature's promise tally
105
+ * and receipt), and "read it in full" stays one click away as the secondary
106
+ * action. What the rule was costing was a mandatory round trip for the
107
+ * decision you had already made.
108
+ *
109
+ * It is NOT a second mechanism: the markup is exactly the contract the
110
+ * existing wiring speaks (.approve-btn[data-artifact] -> POST /api/approve,
111
+ * .feature-accept-btn[data-name] -> POST /api/feature/accept), the panel is
112
+ * already in GOVERNED_PANELS so the SSE swap re-wires these buttons like any
113
+ * other, and refusals stay the server's to make.
114
+ */
115
+ function itemActionHtml(item, { byArtifact, byFeature }) {
116
+ const record = byArtifact.get(item.artifact) || null;
117
+ const featureName = item.artifact.startsWith("feature-brief:") ? item.artifact.slice("feature-brief:".length) : null;
118
+ const feature = featureName ? byFeature.get(featureName) : null;
119
+
120
+ // Acceptance is a different verb on a different endpoint: the brief is
121
+ // already signed; what is being said is "the proven thing is what I wanted".
122
+ if (feature && feature.provenDone && record && record.status === "approved") {
123
+ return `<button type="button" class="feature-accept-btn fd-sign" data-name="${escAttr(featureName)}">Accept</button>`;
124
+ }
125
+ // A refused artifact never gets a button that could only fail on click —
126
+ // the same honesty signatureBarHtml applies at its own bar.
127
+ if (record && record.resolvable === false) return "";
128
+ const label = record && record.status === "unreviewed" ? "Approve" : "Re-approve";
129
+ return `<button type="button" class="approve-btn fd-sign" data-artifact="${escAttr(item.artifact)}">${label}</button>`;
130
+ }
131
+
132
+ /**
133
+ * A queue item's supporting evidence — enough to judge urgency WITHOUT opening
134
+ * the owning section, and never more. A drifted artifact shows the file split
135
+ * (the changed/still-signed counts approval-diff already computed) and names
136
+ * the changed files; the DIFF ITSELF stays in the section that owns it. A
137
+ * proven feature shows the derivation that made it proven.
138
+ */
139
+ function itemEvidenceHtml(item, { byArtifact, byFeature, anchoredDiffs }) {
140
+ const record = byArtifact.get(item.artifact) || null;
141
+ const anchored = anchoredDiffs ? anchoredDiffs[item.artifact] : null;
142
+
143
+ if (record && (record.status === "changed-since-approval" || record.status === "reopened")) {
144
+ if (!anchored || !anchored.available) {
145
+ // The honest failure: approval-diff could not locate the signed bytes.
146
+ // Saying so is the point — a guessed "roughly then" diff is the one
147
+ // output that would make this page untrustworthy.
148
+ const why = anchored && anchored.reason ? anchored.reason : "the signed bytes were not located in recent history";
149
+ return ` <p class="fd-evidence fd-evidence-absent">what changed vs. the signed bytes is not derivable &mdash; ${esc(why)}</p>`;
150
+ }
151
+ const files = anchored.files || { changed: [], unchanged: [] };
152
+ const changed = files.changed || [];
153
+ const unchanged = files.unchanged || [];
154
+ const total = changed.length + unchanged.length;
155
+ if (changed.length === 0) {
156
+ return ` <p class="fd-evidence">no file in this artifact differs from the signed bytes &mdash; the hash moved for another reason; open the section for the anchored diff</p>`;
157
+ }
158
+ const list = changed
159
+ .slice(0, 8)
160
+ .map((f) => `<li><span class="fd-status">${esc(f.status)}</span> <code>${esc(f.path)}</code></li>`)
161
+ .join("");
162
+ const more = changed.length > 8 ? `<li class="fd-more">&hellip; and ${changed.length - 8} more</li>` : "";
163
+ const still =
164
+ unchanged.length > 0
165
+ ? ` &middot; ${unchanged.length} of ${total} still exactly as signed`
166
+ : "";
167
+ return ` <details class="fd-evidence">
168
+ <summary>${changed.length} file${changed.length === 1 ? "" : "s"} changed since you signed${still}</summary>
169
+ <ul class="fd-files">${list}${more}</ul>
170
+ </details>`;
171
+ }
172
+
173
+ // A proven feature awaiting acceptance: show WHY it is proven — the same
174
+ // derivation the Features card states, never a re-computation of it.
175
+ const featureName = item.artifact.startsWith("feature-brief:") ? item.artifact.slice("feature-brief:".length) : null;
176
+ const feature = featureName ? byFeature.get(featureName) : null;
177
+ if (feature && feature.provenDone) {
178
+ const clauses =
179
+ typeof feature.covered === "number" && typeof feature.total === "number" && feature.total > 0
180
+ ? `${feature.covered} of ${feature.total} clauses cited`
181
+ : null;
182
+ const verdict = feature.receipt && feature.receipt.present ? `receipt ${feature.receipt.verdict}` : null;
183
+ const attests = feature.receipt && feature.receipt.attestsTree ? "attesting this tree" : null;
184
+ const bits = [clauses, verdict, attests].filter(Boolean).join(" &middot; ");
185
+ return bits ? ` <p class="fd-evidence">${bits}</p>` : "";
186
+ }
187
+
188
+ if (record && record.status === "unreviewed") {
189
+ return ` <p class="fd-evidence">never signed &mdash; this artifact has had no human judgment yet</p>`;
190
+ }
191
+ return "";
192
+ }
193
+
194
+ /**
195
+ * The front door body.
196
+ *
197
+ * @param {object} p
198
+ * @param {Array<{artifact: string, tab: string, label: string}>} p.queue deriveHumanQueue()
199
+ * @param {object[]} [p.statuses] approvals statuses (for the ungoverned empty state)
200
+ * @param {object[]} [p.features] the feature board's features
201
+ * @param {object} [p.anchoredDiffs] artifact id -> getApprovalAnchoredDiff() result
202
+ * @param {string} [p.digestHtml] digestTabHtml(digest) — passed in, not imported,
203
+ * so this module stays acyclic and the digest keeps ONE renderer
204
+ * @param {string} [p.digestSince] the digest window, for the block's own subtitle
205
+ * @param {Function} [p.statusGlyph] console-shell's statusGlyph (injected, one derivation)
206
+ * @param {object[]} [p.journal] the signing journal's events — History, moved
207
+ * here from the rail strip so the strip could stop duplicating this page
208
+ * @param {Function} [p.formatAge] console-shell's formatAgeCoarse (injected)
209
+ */
210
+ export function overviewBodyHtml({
211
+ queue = [],
212
+ statuses = [],
213
+ features = [],
214
+ anchoredDiffs = {},
215
+ digestHtml = "",
216
+ digestSince = null,
217
+ statusGlyph,
218
+ journal = [],
219
+ formatAge,
220
+ walks = null,
221
+ } = {}) {
222
+ const byArtifact = new Map(statuses.map((s) => [s.id, s]));
223
+ const byFeature = new Map(features.map((f) => [f.name, f]));
224
+
225
+ let queueHtml;
226
+ if (statuses.length === 0) {
227
+ // Not "nothing waits on you" — that is a claim. There is no ledger to read.
228
+ queueHtml = ` <p class="empty-inline">no approvals ledger in this project &mdash; nothing here is governed yet, so no signature can be waiting. A project gains a ledger at <code>qa/approvals.json</code>.</p>`;
229
+ } else if (queue.length === 0) {
230
+ queueHtml = ` <p class="fd-clear"><span class="glyph glyph-signed">&#9679;</span> Nothing waits on you. Every governed artifact is signed and unchanged since.</p>`;
231
+ } else {
232
+ queueHtml = ` <div class="banner sig-error" id="overview-error" hidden></div>
233
+ <ol class="fd-queue">
234
+ ${queue
235
+ .map((item) => {
236
+ const record = byArtifact.get(item.artifact) || null;
237
+ // The glyph states the ACT, not the artifact's current status. An
238
+ // acceptance row's artifact is `approved` — its brief was signed long ago —
239
+ // and statusGlyph would correctly render that as a green ●, which on a
240
+ // queue of things waiting on you reads as "done". `glyph-attn` is the rail's
241
+ // existing vocabulary for exactly this state (accent ●: needs your
242
+ // acceptance), so the two surfaces stay in one language.
243
+ const g =
244
+ record && record.status === "approved"
245
+ ? { ch: "●", cls: "glyph-attn", label: "proven — awaiting your acceptance" }
246
+ : statusGlyph
247
+ ? statusGlyph(record)
248
+ : null;
249
+ const glyph = g
250
+ ? `<span class="glyph ${g.cls}" title="${escAttr(g.label)}">${g.ch}</span>`
251
+ : `<span class="glyph glyph-unsigned">&#9675;</span>`;
252
+ // data-go-* is the strip's jump contract, delegated at document level so
253
+ // this page reuses the SAME navigation the governance strip uses — one
254
+ // handler, one behavior, no second mechanism.
255
+ return ` <li class="fd-item">
256
+ <p class="fd-act">${glyph} <span class="fd-label">${esc(item.label)}</span>
257
+ <span class="fd-actions">${itemActionHtml(item, { byArtifact, byFeature })}<button type="button" class="gov-jump fd-go" data-go-tab="${escAttr(item.tab)}" data-go-artifact="${escAttr(item.artifact)}" title="open the artifact and read it in full">read it first</button></span></p>
258
+ ${itemEvidenceHtml(item, { byArtifact, byFeature, anchoredDiffs })}
259
+ </li>`;
260
+ })
261
+ .join("\n")}
262
+ </ol>`;
263
+ }
264
+
265
+ // History, moved off the rail strip (2026-08-22) so the strip could stop being
266
+ // a lower-fidelity copy of this page. The journal is the SIGNING record —
267
+ // verb, artifact, who, and a reopen's reason — which is a different ledger
268
+ // from the digest's git-derived events, so it sits alongside them, not
269
+ // instead of them. Newest first, capped: this is a glance; `--log` is the
270
+ // full record.
271
+ const recent = [...journal].slice(-8).reverse();
272
+ const historyHtml =
273
+ recent.length === 0
274
+ ? ""
275
+ : ` <h3 class="fd-h">History</h3>
276
+ <div class="fd-history">
277
+ ${recent
278
+ .map((e) => {
279
+ const glyph = e.verb === "approve" ? "●" : e.verb === "reopen" ? "◐" : e.verb === "accept" ? "◆" : "·";
280
+ const ageMs = e.at ? Date.now() - Date.parse(e.at) : NaN;
281
+ const age = formatAge && !Number.isNaN(ageMs) ? formatAge(ageMs) : "";
282
+ const who = e.via ? ` via ${e.via}` : "";
283
+ return ` <p class="gov-event" title="${escAttr(e.at || "")}"><span class="gov-event-glyph">${glyph}</span> ${esc(e.verb)} ${esc(e.artifact || "")}${esc(who)}${age ? ` &middot; ${esc(age)}` : ""}${e.reason ? `<span class="gov-event-reason">${esc(e.reason)}</span>` : ""}</p>`;
284
+ })
285
+ .join("\n")}
286
+ </div>`;
287
+
288
+ const since = digestSince ? ` <span class="fd-since">window: since ${esc(digestSince)}</span>` : "";
289
+ const changedBlock = digestHtml
290
+ ? ` <h3 class="fd-h">What changed${since}</h3>
291
+ <div class="fd-digest">
292
+ ${digestHtml}
293
+ </div>`
294
+ : "";
295
+
296
+ // Page anatomy (studio-drive-mode): Drive leads with the live chain, then
297
+ // what-needs-you, then the walks; the digest and history are the page's own
298
+ // MIRROR tail — complete, derived, and collapsed by default.
299
+ const fold = (label, inner) =>
300
+ inner ? ` <details class="fd-fold"><summary>${label}</summary>\n${inner}\n </details>` : "";
301
+
302
+ return `${driveChainHtml(walks && walks.chain ? walks.chain : null)} <p class="meta">The three questions, in the order they get asked. Every line below is arranged
303
+ from the section that owns it &mdash; this page derives nothing of its own, and signing happens where you read.</p>
304
+ <h3 class="fd-h">What needs you${queue.length ? ` <span class="fd-count">${queue.length}</span>` : ""}</h3>
305
+ ${queueHtml}
306
+ ${walksHtml(features, statuses, walks)}
307
+ ${fold("What changed", changedBlock)}
308
+ ${fold("History", historyHtml)}`;
309
+ }
310
+
311
+ // ── The live chain (studio-drive-mode) ───────────────────────────────────────
312
+ //
313
+ // What the agent is doing in the CURRENT REQUEST: the request itself (tier 1,
314
+ // recorded mechanically from the human's own prompt), the declared step chain
315
+ // (tier 2 — agent-declared, so its AGE is always shown), and what is actually
316
+ // running right now (tier 3 — the lane/render markers, derived, overriding).
317
+ // The chain gates nothing; the walk below stays the truth for doneness.
318
+
319
+ /** "40s ago" / "12 min ago" — mirror of the harness's formatAge, display-only. */
320
+ function fmtChainAge(ms) {
321
+ if (!(ms >= 0)) return "age unknown";
322
+ if (ms < 90000) return `${Math.round(ms / 1000)}s ago`;
323
+ if (ms < 90 * 60000) return `${Math.round(ms / 60000)} min ago`;
324
+ return `${Math.round(ms / 3600000)}h ago`;
325
+ }
326
+
327
+ /** "12s" / "~3 min" — mirror of the harness's formatDuration, display-only. */
328
+ function fmtChainDur(ms) {
329
+ if (!(ms >= 0)) return "";
330
+ if (ms < 120000) return `${Math.max(1, Math.round(ms / 1000))}s`;
331
+ return `~${Math.round(ms / 60000)} min`;
332
+ }
333
+
334
+ // Provenance chips (drive-narration N3) — each chain tier labeled as what it
335
+ // is: the request `recorded` (mechanically, from the human's own prompt), the
336
+ // steps `declared` (by the agent, aged), the busy line `observed` (the lane's
337
+ // own marker). The one distinction no self-reported progress display makes.
338
+ const CH_PROV = {
339
+ recorded: `<span class="ch-prov ch-prov-obs" title="recorded mechanically from your own prompt — not the agent's words">recorded</span>`,
340
+ declared: `<span class="ch-prov ch-prov-dec" title="declared by the agent — the age says how fresh the claim is">declared</span>`,
341
+ observed: `<span class="ch-prov ch-prov-obs" title="observed from the lane's own marker — not an agent claim">observed</span>`,
342
+ };
343
+
344
+ /**
345
+ * The busy phrase: the harness's own pre-rendered narration when the project's
346
+ * plan.mjs provides it (busyText — one voice everywhere), else the legacy
347
+ * truthiness phrasing for older harnesses.
348
+ */
349
+ function chainBusyPhrase(chain) {
350
+ if (typeof chain.busyText === "string") return chain.busyText;
351
+ if (chain.busy && chain.busy.lane) return "the full check is running NOW";
352
+ if (chain.busy && chain.busy.render) return "a preview render is in flight";
353
+ return "";
354
+ }
355
+
356
+ /** One row per closed chain (N5): request → steps → outcome, newest first. */
357
+ function chainHistoryHtml(history) {
358
+ if (!Array.isArray(history) || history.length === 0) return "";
359
+ const rows = history
360
+ .map((h) => {
361
+ const label = h.title || h.request || "(untitled request)";
362
+ const ageMs = h.at ? Date.now() - Date.parse(h.at) : NaN;
363
+ // The trail's own glance (qa/lib/plan.mjs receiptGlance) stores the rung
364
+ // AND the pack it was graded under, and this row shows both for a reason
365
+ // the other rung surfaces do not have: these rows OUTLIVE the run that
366
+ // made them, so a bare grade here can never be attributed later by
367
+ // anything. A row reading "PASS · L2" is unreadable the moment a second
368
+ // pack exists in the repo's history (§8.9).
369
+ const rung = h.receipt ? rungWithPack(h.receipt.rung, h.receipt.pack) : null;
370
+ const outcome = h.receipt && h.receipt.verdict
371
+ ? `<span class="ch-hist-outcome ${h.receipt.verdict === "PASS" ? "ok" : "bad"}"${rung ? ` title="${escAttr(rungPackNote(h.receipt.rung, h.receipt.pack))}"` : ""}>${esc(h.receipt.verdict)}${rung ? ` &middot; ${esc(rung)}` : ""}</span>`
372
+ : `<span class="ch-hist-outcome">no receipt at close</span>`;
373
+ const dur = typeof h.durationMs === "number" && h.durationMs > 0 ? ` &middot; ${esc(fmtChainDur(h.durationMs))}` : "";
374
+ const steps = Array.isArray(h.steps) && h.steps.length ? ` &middot; ${h.steps.length} step${h.steps.length === 1 ? "" : "s"}` : "";
375
+ return ` <p class="ch-hist-row" title="${escAttr(Array.isArray(h.steps) ? h.steps.join(" → ") : "")}">${esc(label)}${steps}${dur} &middot; ${outcome}${Number.isNaN(ageMs) ? "" : ` &middot; ${esc(fmtChainAge(ageMs))}`}</p>`;
376
+ })
377
+ .join("\n");
378
+ return ` <details class="ch-hist"><summary>Recent requests <span class="fd-count">${history.length}</span></summary>\n${rows}\n </details>\n`;
379
+ }
380
+
381
+ export function driveChainHtml(chain) {
382
+ if (!chain || (!chain.plan && !chain.request)) return "";
383
+ const title = chain.plan && chain.plan.title ? chain.plan.title : chain.request ? chain.request.text : "";
384
+ const busyPhrase = chainBusyPhrase(chain);
385
+ const busy = busyPhrase !== "" ? `${CH_PROV.observed} <span class="ch-busy">${esc(busyPhrase)}</span>` : "";
386
+ let steps = "";
387
+ let meta = "";
388
+ if (chain.plan) {
389
+ const cur = chain.plan.current;
390
+ const nowMs = Date.now();
391
+ steps = ` <p class="ch-steps">${chain.plan.steps
392
+ .map((st) => {
393
+ const cls = st.done ? "ch-done" : st.n === cur ? "ch-cur" : "ch-todo";
394
+ const glyph = st.done ? "✓" : st.n === cur ? "◉" : "○";
395
+ // N1: a done step wears its wall time, the current one its elapsed —
396
+ // derived from the declaration's own write stamps, absent pre-N1.
397
+ let time = "";
398
+ if (st.done) {
399
+ const a = Date.parse(st.startedAt || "");
400
+ const b = Date.parse(st.doneAt || "");
401
+ if (!Number.isNaN(a) && !Number.isNaN(b)) time = ` <span class="ch-time">(${esc(fmtChainDur(Math.max(0, b - a)))})</span>`;
402
+ } else if (st.n === cur) {
403
+ const a = Date.parse(st.startedAt || "");
404
+ if (!Number.isNaN(a)) time = ` <span class="ch-time">${esc(fmtChainDur(Math.max(0, nowMs - a)))} in</span>`;
405
+ }
406
+ return `<span class="ch-step ${cls}"><span class="ch-glyph">${glyph}</span> ${st.n}. ${esc(st.label)}${time}</span>`;
407
+ })
408
+ .join('<span class="ch-arrow">→</span>')}</p>`;
409
+ const now = chain.plan.steps.find((st) => st.n === cur) ?? null;
410
+ meta = ` <p class="ch-meta">${now ? `now: step ${now.n} of ${chain.plan.steps.length} — ${esc(now.label)}` : "chain complete"}${busy ? ` &middot; ${busy}` : ""} &middot; ${CH_PROV.declared} <span class="ch-age">updated ${esc(fmtChainAge(chain.planAgeMs))}</span></p>`;
411
+ } else {
412
+ meta = ` <p class="ch-meta">no declared step chain for this request yet${busy ? ` &middot; ${busy}` : ""}</p>`;
413
+ }
414
+ return ` <div class="ch-strip">
415
+ <p class="ch-request"><span class="lbl">Request</span> ${esc(title)} ${CH_PROV.recorded}</p>
416
+ ${steps}${meta}${chainHistoryHtml(chain.history)} </div>
417
+ `;
418
+ }
419
+
420
+ // ── In flight — the walks (docs/features/walk-status.md) ─────────────────────
421
+ //
422
+ // The six-stage projection of the feature board: Decide · Design · Contract ·
423
+ // Build · Prove · Sign-off, promises (clauses) as Build's inner progress.
424
+ // STEP_STAGE mirrors qa/lib/walk.mjs VERBATIM — the harness owns the mapping;
425
+ // this is its rendering. Stage states are positional around the board's own
426
+ // derived nextStep, exactly like walkOfFeature.
427
+
428
+ const WK_STAGES = [
429
+ ["decide", "Decide"], ["design", "Design"], ["contract", "Contract"],
430
+ ["build", "Build"], ["prove", "Prove"], ["signoff", "Sign-off"],
431
+ ];
432
+ const WK_STEP_STAGE = {
433
+ "sign-brief": "decide", "re-approve": "decide",
434
+ design: "design", audit: "design", "sign-design": "design",
435
+ contract: "contract", "sign-spec": "contract",
436
+ build: "build", redesign: "build", prove: "prove", accept: "signoff",
437
+ };
438
+
439
+ /**
440
+ * The In-flight block: one card per OPEN walk (most-recent board order), plus
441
+ * arrivals — REOPENED artifacts no open walk accounts for (a harness wave's
442
+ * rule-change reopens). Drifted artifacts are deliberately NOT repeated here:
443
+ * they are already loud in the What-needs-you queue above; one fact, one place.
444
+ * Empty walks + no arrivals -> "" (silence, never an empty frame).
445
+ *
446
+ * Two renderings (walk-legibility L5). When the project's own walk derivation
447
+ * is available (`walksData` from getWalksData — qa/lib/walk.mjs), the card is
448
+ * the walk's PRIMARY human surface: the promise list itself with per-promise
449
+ * kept state, the stage gloss, Prove's measured lane cost, the signature
450
+ * button ON the your-turn row (the same .approve-btn/.feature-accept-btn wire
451
+ * every other control speaks — one approve path), and each arrival's
452
+ * now-or-after choice as two buttons that post a general comment for the
453
+ * agent to observe (the pick-btn precedent — no new decision machinery).
454
+ * Without it (pre-walk scaffolds), the original board-mirroring thumbnail.
455
+ */
456
+ export function walksHtml(features = [], statuses = [], walksData = null) {
457
+ if (walksData && walksData.available) return walksRichHtml(walksData, statuses);
458
+ const open = features.filter((f) => f.phase !== "accepted");
459
+ const owned = new Set();
460
+ for (const f of open) {
461
+ owned.add(`feature-brief:${f.name}`);
462
+ owned.add(`feature-design:${f.name}`);
463
+ for (const n of f.specNames || [f.name]) owned.add(`feature-spec:${n}`);
464
+ for (const t of f.touches || []) owned.add(t.id);
465
+ }
466
+ const arrivals = statuses.filter((s) => s.status === "reopened" && !owned.has(s.id));
467
+ if (open.length === 0 && arrivals.length === 0) return "";
468
+
469
+ const cards = open.map((f) => {
470
+ const currentKey = WK_STEP_STAGE[f.nextStep && f.nextStep.key] ?? null;
471
+ const idx = currentKey ? WK_STAGES.findIndex(([k]) => k === currentKey) : WK_STAGES.length;
472
+ const dots = WK_STAGES.map(([key, label], i) => {
473
+ const state =
474
+ key === "design" && f.design === null ? "skip" : i < idx ? "done" : i === idx ? "cur" : "todo";
475
+ const title = state === "skip" ? `${label} — no UI surface` : label;
476
+ return `<span class="wk-stage wk-${state}" title="${escAttr(title)}"><span class="wk-dot"></span>${esc(label)}</span>`;
477
+ }).join("");
478
+ const promises =
479
+ typeof f.total === "number" && f.total > 0
480
+ ? `<span class="wk-promises">${f.covered} of ${f.total} promises kept</span>`
481
+ : "";
482
+ const owner = f.nextStep ? f.nextStep.label : "";
483
+ const you =
484
+ f.nextStep && f.nextStep.owner === "human"
485
+ ? `<span class="wk-turn">YOUR TURN</span> ${esc(owner)}`
486
+ : `<span class="wk-agent">agent</span> ${esc(owner)}`;
487
+ return ` <div class="wk-card" data-walk="${escAttr(f.name)}">
488
+ <p class="wk-name">${esc(f.name)}${promises}</p>
489
+ <p class="wk-stages">${dots}</p>
490
+ <p class="wk-you">${you}</p>
491
+ </div>`;
492
+ });
493
+
494
+ const arrived = arrivals.map(
495
+ (a) =>
496
+ ` <p class="wk-arrival">&#9650; ARRIVED, UNPLANNED &mdash; ${esc(a.label || a.id)} &middot; reopened outside every open walk &mdash; now, or after the current walk lands?</p>`,
497
+ );
498
+
499
+ return ` <h3 class="fd-h">In flight${open.length ? ` <span class="fd-count">${open.length}</span>` : ""}</h3>
500
+ ${cards.join("\n")}
501
+ ${arrived.join("\n")}`;
502
+ }
503
+
504
+ /**
505
+ * The rich In-flight rendering — the walk derivation's own objects
506
+ * (qa/lib/walk.mjs via getWalksData), arranged. Derives nothing: stage
507
+ * states, promise kept-ness, the measured lane cost and whose-turn all
508
+ * arrive computed; this maps them to markup.
509
+ */
510
+ function walksRichHtml(walksData, statuses = []) {
511
+ const { walks = [], arrivals = [], gloss = {} } = walksData;
512
+ if (walks.length === 0 && arrivals.length === 0) return "";
513
+ const byId = new Map(statuses.map((s) => [s.id, s]));
514
+
515
+ const stageState = { done: "done", current: "cur", pending: "todo", skipped: "skip" };
516
+ const cards = walks.map((w) => {
517
+ const dots = w.stages
518
+ .map((s) => {
519
+ const cls = stageState[s.state] ?? "todo";
520
+ const title = s.note ? `${s.label} — ${s.note}` : s.label;
521
+ const note = s.note && s.key === "prove" ? ` <span class="wk-note">${esc(s.note)}</span>` : "";
522
+ return `<span class="wk-stage wk-${cls}" title="${escAttr(title)}"><span class="wk-dot"></span>${esc(s.label)}${note}</span>`;
523
+ })
524
+ .join("");
525
+
526
+ const g = gloss[w.currentStage];
527
+ const stageLine = w.currentStage
528
+ ? `<span class="wk-gloss">${esc(w.stages.find((s) => s.key === w.currentStage)?.label ?? "")}${g ? ` — ${esc(g)}` : ""}</span>`
529
+ : "";
530
+
531
+ // The promises THEMSELVES (L5): kept ✓ / being-kept ▸ / pending ○, in the
532
+ // spec's own words. The list scrolls (CSS) rather than truncates — a
533
+ // collapsed tally is exactly what this rendering replaces.
534
+ const all = (w.promises && w.promises.all) || [];
535
+ const currentId = w.promises && w.promises.current ? w.promises.current.id : null;
536
+ const promiseList =
537
+ all.length > 0
538
+ ? ` <ul class="wk-plist">\n${all
539
+ .map((p) => {
540
+ const cls = p.kept ? "wk-p-kept" : p.id === currentId ? "wk-p-cur" : "wk-p-todo";
541
+ const glyph = p.kept ? "✓" : p.id === currentId ? "▸" : "○";
542
+ return ` <li class="${cls}"><span class="wk-p-glyph">${glyph}</span> <code>${esc(p.id)}</code>${p.title ? ` ${esc(p.title)}` : ""}</li>`;
543
+ })
544
+ .join("\n")}\n </ul>`
545
+ : "";
546
+
547
+ // The signature ON the row (L5): the walk names which signature the step
548
+ // waits for; the button speaks the one existing wire (.approve-btn /
549
+ // .feature-accept-btn — refusals stay the server's). An already-approved
550
+ // artifact gets no button that could only fail on click.
551
+ const buttons = (w.you.signable || [])
552
+ .map((s) => {
553
+ if (s.verb === "accept")
554
+ return `<button type="button" class="feature-accept-btn fd-sign" data-name="${escAttr(s.artifact)}">Accept</button>`;
555
+ const record = byId.get(s.artifact);
556
+ if (record && record.status === "approved") return "";
557
+ if (record && record.resolvable === false) return "";
558
+ const label = record && record.status === "unreviewed" ? "Approve" : "Re-approve";
559
+ return `<button type="button" class="approve-btn fd-sign" data-artifact="${escAttr(s.artifact)}">${label}</button>`;
560
+ })
561
+ .filter(Boolean)
562
+ .join(" ");
563
+
564
+ const you =
565
+ w.you.turn === "you"
566
+ ? `<span class="wk-turn">YOUR TURN</span> ${esc(w.you.act ?? "")}${buttons ? ` ${buttons}` : ""}`
567
+ : w.you.turn === "agent"
568
+ ? `<span class="wk-agent">agent</span> ${esc(w.you.act ?? "")}${
569
+ w.stops && w.stops.length
570
+ ? ` <span class="wk-stops">next stop${w.stops.length > 1 ? "s" : ""} for you: ${esc(w.stops.join(", "))}</span>`
571
+ : ""
572
+ }`
573
+ : esc(w.doneReason ?? "closed");
574
+
575
+ const tally =
576
+ w.promises && typeof w.promises.total === "number" && w.promises.total > 0
577
+ ? `<span class="wk-promises">${w.promises.kept} of ${w.promises.total} promises kept</span>`
578
+ : "";
579
+
580
+ return ` <div class="wk-card" data-walk="${escAttr(w.name)}">
581
+ <p class="wk-name">${esc(w.name)}${tally} ${stageLine}</p>
582
+ <p class="wk-stages">${dots}</p>
583
+ ${promiseList ? `${promiseList}\n` : ""} <p class="wk-you">${you}</p>
584
+ </div>`;
585
+ });
586
+
587
+ // Arrivals: the now-or-after question as two buttons (L5). Each posts a
588
+ // general comment over the EXISTING /api/comment wire (the pick-btn
589
+ // precedent) — the agent observes it via review_comments; no new state.
590
+ const arrived = arrivals.map(
591
+ (a) =>
592
+ ` <p class="wk-arrival">&#9650; ARRIVED, UNPLANNED &mdash; ${esc(a.label || a.id)}${
593
+ a.reason ? ` &middot; ${esc(a.reason)}` : ""
594
+ } &mdash; when?
595
+ <button type="button" class="wk-arrival-btn" data-arrival="${escAttr(a.id)}" data-choice="now">Now</button>
596
+ <button type="button" class="wk-arrival-btn" data-arrival="${escAttr(a.id)}" data-choice="after">After the current walk</button>
597
+ </p>`,
598
+ );
599
+
600
+ // The card quotes what a check COSTS, so it must not quote a cache hit. A
601
+ // single last-run figure is dominated by Gradle's cache state; once the
602
+ // journal holds more than two full runs the typical and worst runs are named
603
+ // alongside it. Every figure is a run that happened — never an estimate.
604
+ const lane = walksData.lane;
605
+ const laneSpread =
606
+ lane && lane.runs > 2 && lane.medianMs > 0
607
+ ? ` &middot; typically ${esc(fmtLaneMs(lane.medianMs))}${lane.maxMs > lane.medianMs ? `, worst ${esc(fmtLaneMs(lane.maxMs))}` : ""}`
608
+ : "";
609
+ const laneLine =
610
+ lane && typeof lane.durationMs === "number"
611
+ ? ` <p class="wk-lane">full check: ${esc(fmtLaneMs(lane.durationMs))} last run${laneSpread} (measured)</p>`
612
+ : "";
613
+
614
+ return ` <h3 class="fd-h">In flight${walks.length ? ` <span class="fd-count">${walks.length}</span>` : ""}</h3>
615
+ ${cards.join("\n")}
616
+ ${arrived.join("\n")}
617
+ ${laneLine}`;
618
+ }
619
+
620
+ /** Mirror of the harness's humanDuration formatting, display-only. */
621
+ function fmtLaneMs(ms) {
622
+ if (!(ms > 0)) return "unknown";
623
+ return ms < 120000 ? `${Math.round(ms / 1000)}s` : `~${Math.round(ms / 60000)} min`;
624
+ }
625
+
626
+ /** The front door's rail glyph — the queue's own state, never a fifth meaning. */
627
+ export function overviewGlyph(queue = [], statuses = []) {
628
+ if (statuses.length === 0) return null;
629
+ if (queue.length === 0) return { ch: "●", cls: "glyph-signed", label: "nothing waiting on you" };
630
+ // Drift is read from the LEDGER, never from the label's wording — matching
631
+ // prose to pick a colour makes the glyph silently wrong the day someone
632
+ // rewords deriveHumanQueue, and a rail glyph that quietly stops going red is
633
+ // the failure this console exists to prevent.
634
+ const byId = new Map(statuses.map((st) => [st.id, st]));
635
+ const drifted = queue.some((q) => {
636
+ const record = byId.get(q.artifact);
637
+ return record && record.status === "changed-since-approval";
638
+ });
639
+ return drifted
640
+ ? { ch: "⚠", cls: "glyph-drift", label: `${queue.length} act(s) waiting — drift among them` }
641
+ : { ch: "○", cls: "glyph-unsigned", label: `${queue.length} act(s) waiting on you` };
642
+ }