@cspeach/cli 1.1.29 → 1.1.31

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 (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -0,0 +1,189 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-status-file.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * The status file: the team project picture as one document a lead can mail.
7
+ *
8
+ * For the people who will never open a folder in a browser. It holds no code to
9
+ * run and points at nothing — the stylesheet is inside it, the map is inline
10
+ * SVG, the object list unfolds with `details`, which the browser opens on its
11
+ * own. It carries its own policy, so the copy a manager forwards can no more
12
+ * call out than the page it came from.
13
+ *
14
+ * It is a snapshot and says so, with the time, in the first line under the
15
+ * title. One moment runs through the whole file — `asOf`, when the folder was
16
+ * READ — and nothing else. Not when somebody pressed Save: a page left open
17
+ * over lunch and saved at three o'clock holds the eleven o'clock folder, and
18
+ * the quiet-lane day counts inside it were worked out against eleven o'clock
19
+ * too. The name, the document title and the stamp all carry that one day, so
20
+ * they cannot disagree — and they would, across midnight UTC, which is early
21
+ * evening in the United States.
22
+ *
23
+ * Nothing here reaches for the clock: the time is handed in, so the page, this
24
+ * file and the tests all print the same moment.
25
+ *
26
+ * One moment, and one folder. The stamp says which folder this was made from,
27
+ * so the provenance line above it does not — it is handed no folder name and
28
+ * stops after the lead. The page, which has no stamp, keeps the whole sentence.
29
+ *
30
+ * Pure: data in, one string out. It is built from the model's own answers —
31
+ * the same `collectView` the page renders — never from the page, which wires
32
+ * listeners and reads the keyboard. Two pictures of one folder that disagree
33
+ * would be worse than no picture at all.
34
+ */
35
+ import { esc } from './register-map.js';
36
+ import { formatWhen } from './register-model.js';
37
+ // Every mark this file shares with the page. One copy, in a module that knows
38
+ // nothing about the browser: two pictures of one folder that disagree would be
39
+ // worse than no picture at all, and these two had already begun to.
40
+ import { decisionWord, developersTableHtml, dispositionsHtml, ignoredRunsHtml, mapBodyHtml, measuredWord, n, needItemHtml, focusLineHtml, notMeasuredLine, ownerLabel, provenanceLine, readNote, runsTableHtml, stateWord, whereItStandsHtml, } from './register-parts.js';
41
+ import { estateSectionHtml } from './estate-parts.js';
42
+ import { progressSectionHtml } from './progress-chart.js';
43
+ import { planSectionHtml } from './plan-parts.js';
44
+ const FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
45
+ const objects = (count) => `${n(count)} object${count === 1 ? '' : 's'}`;
46
+ const files = (count) => `${n(count)} file${count === 1 ? '' : 's'}`;
47
+ /**
48
+ * What the file is called once it lands in somebody's downloads. A manager with
49
+ * four of these in a folder should be able to tell them apart without opening
50
+ * one, so the name carries the team project and the day — the day the folder
51
+ * was read, the same day the file states inside itself.
52
+ * @param {{ name?: string }} project
53
+ * @param {string} asOf ISO time the folder was read
54
+ * @returns {string}
55
+ */
56
+ export function statusFileName(project, asOf) {
57
+ const slug = String(project?.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'team-project';
58
+ return `${slug}-status-${String(asOf).slice(0, 10)}.html`;
59
+ }
60
+ /**
61
+ * One package, folded shut. The count is in the fold's own line so the size of
62
+ * a package is readable without opening it — a real project puts thousands of
63
+ * rows behind one of these.
64
+ */
65
+ function packageFold(block, lanes, runs, projectType) {
66
+ // An empty cell reads as a value somebody lost rather than as one nobody has
67
+ // set yet, so both absences say themselves in words — in the same lower case
68
+ // as the values they stand in for ("fix", "retire", a person's name).
69
+ //
70
+ // The last column is the whole of step 2 in one cell: what the system found,
71
+ // or that nobody has looked. A reader working down this list must not have to
72
+ // take a colour on trust, and "not measured" is an answer, not a blank.
73
+ const row = (o) => `<tr><td class="ident">${esc(o.name)}</td><td>${esc(o.type)}</td><td>${esc(stateWord(o))}</td>`
74
+ + `<td>${esc(decisionWord(o.decision, projectType, o.state))}</td>`
75
+ + `<td>${esc(ownerLabel(lanes.find((l) => l.id === o.lane)))}</td>`
76
+ + `<td>${esc(measuredWord(o, runs))}</td></tr>`;
77
+ // Every fold holds a table with the same six headings, so each one says
78
+ // which package it belongs to — otherwise a screen reader announces the
79
+ // twelfth "Object, Type, State, Decision, Owner, Measured" with nothing to
80
+ // tell it from the eleventh.
81
+ return `<details data-role="package"><summary>${esc(block.package)} — ${objects(block.objects.length)}</summary>`
82
+ + `<div class="scroller"><table aria-label="${esc(block.package)}"><thead><tr><th scope="col">Object</th><th scope="col">Type</th>`
83
+ + '<th scope="col">State</th><th scope="col">Decision</th><th scope="col">Owner</th><th scope="col">Measured</th></tr></thead>'
84
+ + `<tbody>${block.objects.map(row).join('')}</tbody></table></div></details>`;
85
+ }
86
+ /** Nothing in a saved file can reload, so the page's advice is not repeated here. */
87
+ const UNREADABLE_DETAIL = 'Some results may be missing. The next status file will include them once the files have finished syncing.';
88
+ /**
89
+ * The whole document.
90
+ *
91
+ * Read in the order a manager reads it: whose team project, when this was true, the
92
+ * one line and the focus, the estate (or the sentence saying there is none, or
93
+ * that it could not be read — `estate` is the discovery file as the folder read
94
+ * gave it, exactly as the page gets it), where it stands,
95
+ * progress over time, what is waiting for a person, who owns what, the plan —
96
+ * and only then, folded away, every object one by one. The same sections, in
97
+ * the same order, as the page.
98
+ *
99
+ * `css` is the only value written in unescaped, and the only one that never came
100
+ * out of the folder: it is our own stylesheet, imported at build time. Every
101
+ * name, package, decision and person below goes through `esc()`.
102
+ *
103
+ * `files`, `checksum` and `register.skipped` are what the read note is worked
104
+ * out from. The file prints it for the same reason the page does, and with more
105
+ * reason: whoever this was mailed to cannot look in the folder to find out that
106
+ * a ruling was ignored, and an ignored ruling is a decision somebody made that
107
+ * this picture does not show.
108
+ *
109
+ * @param {{ project: object, register: object, summary: object, needs: object[],
110
+ * blocks: object[], css: string, asOf: string, folderName: string,
111
+ * files?: number, checksum?: string, unreadable?: number, estate?: unknown }} view
112
+ * @returns {string}
113
+ */
114
+ export function buildStatusHtml({ project, register, summary, needs, blocks, css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, estate = undefined, // the default keeps it optional for the TypeScript copy
115
+ }) {
116
+ // A file with no time on it, or with the wrong one, is worse than no file:
117
+ // somebody will act on it. Refuse rather than print "never" or quietly stamp
118
+ // the moment Save was pressed.
119
+ if (!asOf)
120
+ throw new Error('buildStatusHtml needs asOf — the time the folder was read. The file states it as the truth of the picture.');
121
+ // The file says how many files were missing. A caller that reports the need
122
+ // but not the count would have it say "0 files could not be read", which is
123
+ // the opposite of what the need means.
124
+ if (needs?.some((i) => i.kind === 'unreadable') && !(Number.isFinite(unreadable) && unreadable > 0)) {
125
+ throw new Error('buildStatusHtml needs unreadable — a need says files could not be read, and the file states how many.');
126
+ }
127
+ // `css` is ours, but this function is exported and pure, so a caller could one
128
+ // day hand it something with a closing style tag in it, which would end the
129
+ // stylesheet early and put the rest of it into the document as text — or as
130
+ // markup. Refuse; do not rewrite it behind the caller's back.
131
+ if (/<\/style/i.test(css))
132
+ throw new Error('buildStatusHtml refuses css that closes the style element.');
133
+ // "Reload in a minute" is true on the page and false in a file that cannot
134
+ // reload. The page keeps the model's wording; the file says what a reader
135
+ // holding a snapshot can actually expect. This is the ONE mark where the two
136
+ // surfaces are allowed to differ.
137
+ const forFile = (i) => (i.kind !== 'unreadable' ? i
138
+ : { ...i, title: `${files(unreadable)} could not be read when this was made`, detail: UNREADABLE_DETAIL });
139
+ return `<!doctype html>
140
+ <html lang="en"><head><meta charset="utf-8">
141
+ <meta http-equiv="Content-Security-Policy" content="${FILE_CSP}">
142
+ <meta name="viewport" content="width=device-width, initial-scale=1">
143
+ <title>${esc(project.name)} — status as of ${esc(String(asOf).slice(0, 10))}</title>
144
+ <style>${css}</style></head>
145
+ <body><main id="app">
146
+ <header class="masthead">
147
+ <p class="provenance">${provenanceLine(project)}</p>
148
+ <h1>${esc(project.name)}</h1>
149
+ <p class="stamp" data-role="stamp">Status as of ${esc(formatWhen(asOf))}. This file does not update. Made by CSPeach from the folder ${esc(folderName)}.</p>
150
+ ${unreadable > 0 ? `<p class="notice" data-role="incomplete">${files(unreadable)} could not be read, so this picture may be incomplete.</p>` : ''}
151
+ <p class="summary" data-role="summary">${esc(summary.line)}</p>
152
+ <p class="focus-line" data-role="focus">${focusLineHtml(register)}</p>
153
+ ${notMeasuredLine(register) ? `<p class="unmeasured" data-role="not-measured">${esc(notMeasuredLine(register))}</p>` : ''}
154
+ </header>
155
+ <section class="estate" data-role="estate"><h2>Estate</h2>
156
+ ${estateSectionHtml(register, { fileGiven: estate !== undefined, now: asOf })}
157
+ </section>
158
+ <section class="stands" data-role="stands"><h2>Where it stands</h2>
159
+ ${whereItStandsHtml(register)}
160
+ </section>
161
+ <section class="dispositions"><h2>Decisions</h2>
162
+ ${dispositionsHtml(register)}
163
+ </section>
164
+ <section class="progress" data-role="progress"><h2>Progress over time</h2>
165
+ ${progressSectionHtml(register)}
166
+ </section>
167
+ <section class="needs" data-role="needs"><h2>Needs you</h2>
168
+ ${needs.length ? `<ul>${needs.map(forFile).map(needItemHtml).join('')}</ul>` : '<p>Nothing is waiting for anyone.</p>'}
169
+ </section>
170
+ <section class="lanes" data-role="lanes"><h2>Who owns what</h2>
171
+ ${developersTableHtml(register)}
172
+ </section>
173
+ <section class="plan" data-role="plan"><h2>Plan and changes</h2>
174
+ ${planSectionHtml(register)}
175
+ </section>
176
+ <section class="map"><h2>Every object</h2>
177
+ ${mapBodyHtml(register, blocks, { role: 'img' })}
178
+ </section>
179
+ <section class="runs" data-role="runs"><h2>Measurements</h2>
180
+ ${runsTableHtml(register.runs)}
181
+ ${ignoredRunsHtml(register.ignoredRuns)}
182
+ </section>
183
+ <section class="objects"><h2>The object list</h2>
184
+ ${blocks.map((b) => packageFold(b, register.lanes, register.runs, project.type)).join('')}
185
+ </section>
186
+ <footer><p data-role="read-note">${esc(readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' }))}</p>
187
+ <p data-role="trust">This file holds object names, packages, team member names, decisions, fix status and check results. No source code. No passwords. It contains no script and no links, and it cannot load anything from the internet.</p></footer>
188
+ </main></body></html>`;
189
+ }
@@ -0,0 +1,5 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/team/team.css,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ export const TEAM_CSS = "/**\n * /team/ — one stylesheet, shared by the page and by the status file that\n * the page saves. That is why it pulls in no other stylesheet, no font file and\n * no image by address, and carries no selector that depends on anything outside\n * itself: the status file is one document on somebody's desktop, with no\n * network behind it.\n *\n * The nine state colours are the only colours on this page that mean anything.\n * Everything else is paper, ink and one edge, so nothing competes with the map.\n * The peach accent is kept for two things and no others: the button you are\n * meant to press, and where the keyboard currently is.\n */\n\n:root {\n --peach: oklch(0.74 0.13 35); --peach-deep: oklch(0.58 0.16 35);\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80);\n --ink: oklch(0.20 0.012 40); --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40);\n --edge: oklch(0.88 0.010 80);\n /* State colours: red = the system disagrees, grey = nobody has started, amber = words only, green = proven. */\n --s-regressed: oklch(0.52 0.20 27); --s-did-not-hold: oklch(0.64 0.19 40);\n --s-untouched: oklch(0.84 0.006 80); --s-decided: oklch(0.80 0.06 250); --s-working: oklch(0.70 0.10 250);\n --s-claimed: oklch(0.82 0.13 90); --s-verified: oklch(0.60 0.15 145); --s-clean: oklch(0.80 0.09 145);\n --s-retired: oklch(0.93 0.004 80);\n /* The tenth colour is not a state. It marks data holding a state this build\n does not know, and it is meant to look wrong: a cell nobody can explain\n must never pass for a quiet one. */\n --s-unknown: oklch(0.62 0.30 330);\n --font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n --mono: ui-monospace, \"Cascadia Mono\", \"SF Mono\", Menlo, monospace;\n /* The drawings on the empty screen are themed with the site's own names, so\n the page has to answer to all of them or a drawing falls back to a fixed\n light hex and goes wrong in the dark. Fonts included: font-src is 'none'\n here, so the display face names system serifs and never a downloaded one. */\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35);\n --ok: oklch(0.60 0.15 145); --warn: oklch(0.70 0.15 75); --bad: oklch(0.56 0.19 27); --slate: oklch(0.55 0.02 250);\n --dgm-node: #ffffff;\n --font-sans: var(--font); --font-mono: var(--mono); --font-display: Georgia, \"Times New Roman\", serif;\n color-scheme: light dark;\n font-family: var(--font);\n color: var(--ink); background: var(--paper);\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --paper: oklch(0.19 0.008 60); --paper-2: oklch(0.24 0.010 60); --ink: oklch(0.94 0.006 80);\n --ink-2: oklch(0.82 0.008 80); --ink-3: oklch(0.68 0.008 80); --edge: oklch(0.32 0.010 60);\n --s-untouched: oklch(0.36 0.006 80); --s-retired: oklch(0.27 0.004 80);\n --paper-3: oklch(0.29 0.012 60); --ink-4: oklch(0.56 0.008 80); --edge-2: oklch(0.40 0.012 60);\n --peach-soft: oklch(0.36 0.06 35); --peach-wash: oklch(0.27 0.03 35); --dgm-node: var(--paper-2);\n }\n}\n\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); line-height: 1.55; -webkit-text-size-adjust: 100%; }\n#app { max-width: 1180px; margin: 0 auto; padding: 32px 16px 64px; }\n[hidden] { display: none !important; }\n:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; border-radius: 4px; }\n\n/* ── The answer, before anything else ─────────────────────────────────── */\n.masthead { margin: 0 0 40px; }\n/* SAP names run long and never break on their own (WAVE4_CLEANCORE_FINAL_V2),\n so anything that can hold one is allowed to break inside a word. */\n.provenance { margin: 0 0 10px; font-size: 14px; color: var(--ink-3); max-width: 70ch; overflow-wrap: anywhere; }\nh1 { margin: 0 0 12px; font-size: clamp(28px, 4.6vw, 44px); font-weight: 640; letter-spacing: -0.022em; line-height: 1.05; max-width: 22ch; overflow-wrap: anywhere; }\n.summary { margin: 0 0 22px; font-size: clamp(17px, 2.2vw, 21px); font-weight: 480; line-height: 1.4; color: var(--ink-2); max-width: 58ch; }\n.notice { margin: 0 0 20px; padding: 12px 14px; border-left: 3px solid var(--s-regressed); background: var(--paper-2); color: var(--ink); max-width: 70ch; }\n\n.actions { display: flex; flex-wrap: wrap; gap: 8px; }\nbutton { font: inherit; padding: 9px 15px; border-radius: 8px; cursor: pointer; max-width: 100%; overflow-wrap: anywhere; }\nbutton.primary { border: 1px solid var(--peach-deep); background: var(--peach); color: oklch(0.20 0.012 40); }\nbutton.quiet { border: 1px solid var(--edge); background: transparent; color: var(--ink-2); }\nbutton.quiet:hover { border-color: var(--ink-3); color: var(--ink); }\n/* The colour switch: the chosen mode reads as chosen, from the same tokens. */\nbutton.quiet[aria-pressed=\"true\"] { border-color: var(--ink); color: var(--ink); font-weight: 600; }\n.map > .actions { margin: 0 0 12px; }\n\n/* ── The map: the hero. No borders, no card, nothing to look at but colour ── */\n.map { margin: 0 0 48px; }\n.legend { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 0; margin: 0 0 20px; font-size: 13px; color: var(--ink-2); }\n.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: baseline; }\n.blocks { display: flex; flex-wrap: wrap; gap: 28px 32px; align-items: flex-start; }\n.blocks figure { margin: 0; max-width: 100%; }\n.blocks h3 { margin: 0 0 8px; font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }\n.blocks figcaption { margin-top: 6px; font-size: 11.5px; color: var(--ink-3); }\n.block-map { display: block; max-width: 100%; height: auto; }\n.cell { cursor: pointer; }\n.cell.selected { stroke: var(--ink); stroke-width: 2; }\n/* The map has one tab stop and the arrow keys move it, so where that stop\n currently sits has to be unmistakable — an outline alone is too thin at 10px. */\n.cell:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; stroke: var(--ink); stroke-width: 2; }\n\n.s-regressed { fill: var(--s-regressed); background: var(--s-regressed); }\n.s-did-not-hold { fill: var(--s-did-not-hold); background: var(--s-did-not-hold); }\n.s-untouched { fill: var(--s-untouched); background: var(--s-untouched); }\n.s-decided { fill: var(--s-decided); background: var(--s-decided); }\n.s-working { fill: var(--s-working); background: var(--s-working); }\n.s-claimed { fill: var(--s-claimed); background: var(--s-claimed); }\n.s-verified { fill: var(--s-verified); background: var(--s-verified); }\n.s-clean { fill: var(--s-clean); background: var(--s-clean); }\n.s-retired { fill: var(--s-retired); background: var(--s-retired); }\n.s-unknown { fill: var(--s-unknown); background: var(--s-unknown); }\n\n/* The one bordered surface on the page, because it is a thing that opened. */\n.detail { margin-top: 28px; padding: 18px 20px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper-2); scroll-margin-top: 16px; }\n.detail h3 { margin: 0 0 10px; font-size: 17px; font-weight: 620; }\n.detail h3 small { font-family: var(--font); font-size: 13px; font-weight: 400; color: var(--ink-3); }\n.detail p { margin: 0 0 8px; font-size: 14px; }\n.detail .state { font-size: 14px; color: var(--ink-2); }\n.detail table { margin: 12px 0; }\n.ident { font-family: var(--mono); overflow-wrap: anywhere; }\n.warn { color: var(--s-regressed); }\n\n/* ── What needs a person, and who owns what ───────────────────────────── */\n.pair { display: grid; gap: 40px 48px; }\n@media (min-width: 900px) { .pair { grid-template-columns: 1.3fr 1fr; } }\n.needs, .lanes { min-width: 0; }\nh2 { margin: 0 0 14px; font-size: 15px; font-weight: 620; color: var(--ink-2); }\n.needs ul { list-style: none; margin: 0; padding: 0; }\n/* The bar carries the state's own colour where the item is a state, and a\n neutral one where it is not — so the colour never means a second thing. */\n.need { display: grid; gap: 3px; margin-bottom: 12px; padding: 2px 0 2px 12px; border-left: 3px solid var(--ink-3); }\n.need b { font-size: 14.5px; font-weight: 620; }\n.need span { font-size: 14px; color: var(--ink-2); overflow-wrap: anywhere; }\n.k-regressed { border-left-color: var(--s-regressed); }\n.k-did-not-hold { border-left-color: var(--s-did-not-hold); }\n\n.scroller { overflow-x: auto; }\ntable { width: 100%; border-collapse: collapse; font-size: 14px; }\nth, td { text-align: left; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--edge); white-space: nowrap; }\nth { font-weight: 600; color: var(--ink-3); font-size: 12.5px; }\n\n/* ── How it works (the empty screen) ──────────────────────────────────── */\n.how { margin-top: 48px; }\n.how p { font-size: 14px; color: var(--ink-2); max-width: 70ch; }\n/* The drawings follow the page's own tokens, so the card is the page's paper in\n both themes rather than a fixed light slab. Each drawing ships twice: a wide\n composition and a narrow one that stacks the same nodes and says the same\n words. This is the only thing that chooses between them — no script, and no\n style attribute, because the page's policy forbids one. */\n.diagram { margin-bottom: 16px; padding: 20px 16px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper); }\n.diagram svg { display: block; width: 100%; height: auto; margin: 0 auto; }\n.diagram .narrow { display: none; }\n@media (max-width: 640px) {\n .diagram { padding: 16px 8px; }\n .diagram .wide { display: none; }\n .diagram .narrow { display: block; max-width: 420px; margin: 0 auto; }\n}\n.how + .how { margin-top: 40px; }\n\nfooter { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); }\nfooter p { margin: 0 0 8px; max-width: 78ch; }\n\n/* ── The way off the page ─────────────────────────────────────────────── */\n/* It sits outside the app, so it lines itself up with the app's own column\n instead of running the full width of the window. No rule above it: the\n page's own footer has already drawn one, and two lines in a row read as a\n mistake. It does not belong on paper — a printed record is not clickable. */\n.site-foot { max-width: 1180px; margin: 24px auto 0; padding: 0 16px 48px; border-top: 0; }\n.site-foot a { color: var(--ink-2); }\n.site-foot a:hover { color: var(--ink); }\n@media print { .site-foot { display: none; } }\n\n/* ── The status file ──────────────────────────────────────────────────── */\n/* The saved copy is this same page as one document, so it wears this same\n stylesheet and nearly all of the rules above. It has no buttons to press, so\n the peach accent never appears in it — the accent marks the thing you act on,\n and in a document there is nothing to act on. These are the only rules it\n adds.\n\n The stamp is the one mark that says this is a document and not a page: a\n moment, boxed and set in the face this page already uses for machine facts.\n It is bordered rather than filled so it survives a printer that drops\n colour. */\n.stamp {\n display: inline-block; margin: 0 0 22px; padding: 7px 11px; max-width: 100%;\n border: 1px solid var(--edge); border-radius: 6px;\n font-family: var(--mono); font-size: 12.5px; line-height: 1.5; color: var(--ink-2); overflow-wrap: anywhere;\n}\n\n/* Thousands of rows folded into one line per package: the count is in the line\n so the size of a package reads without opening it. Ruled, not carded — these\n are rows of a list, not a set of tiles. */\n.objects { margin-top: 48px; }\n.objects details { border-top: 1px solid var(--edge); }\n.objects details:last-of-type { border-bottom: 1px solid var(--edge); }\n.objects summary { padding: 11px 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }\n.objects summary:hover, .objects details[open] > summary { color: var(--ink); }\n.objects .scroller { padding-bottom: 16px; }\n\n@media print {\n /* A printed record that quietly leaves out the folded half is not the record,\n so every package opens on paper. Two rules for the two ways a browser hides\n a closed fold; a browser that knows neither selector ignores its rule. */\n .objects details:not([open]) > :not(summary) { display: block; }\n .objects details::details-content { content-visibility: visible; }\n .objects summary { break-after: avoid; }\n .stamp { border-color: var(--ink-3); }\n}\n\n/* ── What was measured, and what was not ──────────────────────────────── */\n/* Two marks, and deliberately no new colour. The nine state colours are the\n only colours on this page that mean anything, and how a number was arrived at\n is not a tenth state — so the file tag is an outline, and the line about what\n nobody has measured borrows the amber the key already reads as \"words only\".\n Both are bars and pills the page has elsewhere; nothing new is introduced. */\n.unmeasured { margin: 0 0 20px; padding: 10px 14px; border-left: 3px solid var(--s-claimed); background: var(--paper-2); font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.detail .unmeasured { margin: 12px 0 0; }\n.runs { margin-top: 48px; min-width: 0; }\n.tag { display: inline-block; padding: 1px 7px; border: 1px solid var(--edge); border-radius: 999px; font-size: 12px; color: var(--ink-2); background: var(--paper); white-space: nowrap; }\n.quiet-note { font-size: 13px; color: var(--ink-3); }\n/* A note that belongs to the run above it. The run keeps no rule under it, so\n the two read as one thing rather than as a row and a stray sentence. */\ntr.has-note > td { border-bottom: 0; }\ntd.run-note { white-space: normal; padding-top: 0; }\n/* The runs nobody folded in. Indented off the table above and set quieter, so\n it reads as a footnote to the measurements rather than as more of them. */\n.aside { margin-top: 18px; padding-left: 12px; border-left: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); max-width: 70ch; }\n.aside p { margin: 0 0 6px; }\n.aside ul { margin: 0; padding-left: 18px; }\n.aside li { overflow-wrap: anywhere; }\n\n/* ── Where it stands, and what was decided ────────────────────────────── */\n/* Five numbers a manager reads before the map. Not cards: each is a number on\n the page's paper with the same left bar the \"Needs you\" items carry, so the\n page keeps one way of marking a thing out. The bar is the edge colour until a\n number means trouble — then it borrows the red the key already reads as \"the\n system disagrees\", and nothing else on this strip is coloured. */\n.stands, .dispositions { margin: 0 0 40px; }\n.tiles { list-style: none; margin: 0 0 24px; padding: 0; display: grid; gap: 12px 20px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }\n.tile { padding: 2px 0 2px 12px; border-left: 3px solid var(--edge); display: grid; gap: 1px; align-content: start; }\n.tile-n { font-size: clamp(26px, 3.4vw, 32px); font-weight: 640; letter-spacing: -0.02em; line-height: 1.1; color: var(--ink); font-variant-numeric: tabular-nums; }\n.tile-label { font-size: 14.5px; font-weight: 620; color: var(--ink); }\n.tile-note { font-size: 13px; color: var(--ink-3); }\n.tile.bad { border-left-color: var(--s-regressed); }\n.tile.bad .tile-n { color: var(--s-regressed); }\n.empty-note { margin: 0 0 20px; color: var(--ink-2); max-width: 70ch; }\n\n/* The two level bars. A and B lean green, C amber, D red, \"not decided\" the\n grey of an object nobody has started — the key's own reading of each colour,\n so a level never asks the eye to learn a new palette. The words under each\n bar carry the same numbers: colour is never the only way to read it. */\n.levels { display: grid; gap: 14px; max-width: 640px; }\n.levelbar { margin: 0; }\n.levelbar figcaption { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 4px; }\n.levelbar svg { display: block; width: 100%; height: 12px; border-radius: 2px; }\n.levelwords, .legend-line { margin: 4px 0 0; font-size: 13px; color: var(--ink-3); }\n.l-A { fill: var(--s-verified); background: var(--s-verified); }\n.l-B { fill: var(--s-clean); background: var(--s-clean); }\n.l-C { fill: var(--s-claimed); background: var(--s-claimed); }\n.l-D { fill: var(--s-regressed); background: var(--s-regressed); }\n.l-none { fill: var(--s-untouched); background: var(--s-untouched); }\n\n/* The five dispositions, one line. Outlined pills — the same mark as the\n \"measured from a file\" tag — because a disposition is a person's choice,\n not a state, and only states wear colour here. */\n.chips { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.chip { padding: 3px 11px; border: 1px solid var(--edge); border-radius: 999px; font-size: 14px; color: var(--ink-2); background: var(--paper); }\n.chip b { font-weight: 640; color: var(--ink); font-variant-numeric: tabular-nums; }\n.exceptions { max-width: 78ch; font-size: 14px; }\n.exceptions summary { cursor: pointer; color: var(--ink-2); padding: 4px 0; }\n.exceptions summary:hover, .exceptions[open] > summary { color: var(--ink); }\n.exceptions ul { margin: 6px 0 0; padding-left: 18px; }\n.exceptions li { margin-bottom: 4px; overflow-wrap: anywhere; }\n.lanes .quiet-note { margin: 8px 0 0; }\n.k-exception-no-reason { border-left-color: var(--warn); }\n\n/* ── The focus, the estate, and what the plan adds (spec 2026-10-06) ──── */\n/* The focus sits under the one line of truth as a quieter second line: the\n same left bar as a tile, in the edge colour, because it is context for the\n numbers, not a number that means trouble. */\n.focus-line { margin: -10px 0 22px; padding: 2px 0 2px 12px; border-left: 3px solid var(--edge); font-size: 15px; color: var(--ink-2); max-width: 70ch; overflow-wrap: anywhere; }\n/* The estate reads before the team project's own numbers. Same tiles, same\n table, same quiet notes as the rest of the page — nothing new to learn. */\n.estate { margin: 0 0 40px; min-width: 0; }\n.estate h3 { margin: 0 0 8px; font-size: 14px; font-weight: 620; color: var(--ink-2); }\n/* The one tile whose value is words, not a count: set smaller so a sentence\n does not shout as loudly as a number. */\n.tile-text { font-size: 16px; font-weight: 600; line-height: 1.3; color: var(--ink); overflow-wrap: anywhere; }\n.estate-scope { margin: 0 0 20px; font-size: 15px; color: var(--ink-2); }\n.estate-unknowns { margin: 0 0 24px; max-width: 78ch; font-size: 14px; }\n.estate-unknowns ul { margin: 0; padding: 0; list-style: none; }\n.estate-unknowns li { margin-bottom: 6px; padding-left: 12px; border-left: 3px solid var(--warn); overflow-wrap: anywhere; }\n.estate-unknowns p { margin: 0; }\n/* A retire nobody has confirmed yet is words only — the amber the key already\n reads that way. The other plan items are housekeeping for the lead. */\n.tile.retire-candidates { border-left-color: var(--s-claimed); }\n.k-retire-candidate { border-left-color: var(--s-claimed); }\n.k-out-of-scope-no-reason, .k-excel-clashes { border-left-color: var(--warn); }\n/* Next names up to three objects; let them wrap rather than widen the table. */\ntd.next { white-space: normal; min-width: 14ch; }\n\n/* ── Progress over time, and the plan (spec 2026-10-06 §9 items 5, 8, 9) ── */\n/* The chart borrows the key's own colours: decided blue, claimed amber,\n proven green. Level A is not a state, so it is the quiet ink, dotted. Each\n line also has its own dash, and the legend says the numbers in words, so a\n printer that drops colour or an eye that cannot tell them apart still reads\n it. Ruled like the level bars, not carded. */\n.progress, .plan { margin: 0 0 40px; min-width: 0; }\n.progress h3, .plan h3 { margin: 20px 0 8px; font-size: 14px; font-weight: 620; color: var(--ink-2); }\n.progress-chart { margin: 0; max-width: 720px; }\n.progress-chart svg { display: block; width: 100%; height: auto; }\n.progress-chart .grid { stroke: var(--edge); stroke-width: 1; }\n.progress-chart .axis { fill: var(--ink-3); font-size: 11px; font-family: var(--font); }\n.pl { fill: none; stroke-width: 2.25; stroke-linejoin: round; stroke-linecap: round; }\n.pl-decided, .pd-decided { stroke: var(--s-decided); }\n.pl-claimed, .pd-claimed { stroke: var(--s-claimed); }\n.pl-proven, .pd-proven { stroke: var(--s-verified); }\n.pl-a, .pd-a { stroke: var(--ink-3); }\n.pl-decided { stroke-dasharray: 6 4; }\n.pl-claimed { stroke-dasharray: 2 3; }\n.pl-a { stroke-dasharray: 1 4; stroke-width: 2.5; }\n.pd { fill: var(--paper); stroke-width: 2; }\n.chart-legend { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 13px; color: var(--ink-2); }\n.key { display: inline-block; width: 18px; height: 0; margin: 0 6px 0 0; vertical-align: middle; border-top: 3px solid var(--ink-3); }\n.key-decided { border-top: 3px dashed var(--s-decided); }\n.key-claimed { border-top: 3px dotted var(--s-claimed); }\n.key-proven { border-top-color: var(--s-verified); }\n.key-a { border-top: 3px dotted var(--ink-3); }\n/* Done % per developer and per focus: a track, a fill, the number in words. */\n.pct-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-width: 720px; }\n.pct-bars li { display: grid; grid-template-columns: minmax(10ch, 16rem) minmax(80px, 1fr) auto; gap: 4px 12px; align-items: center; font-size: 14px; }\n.pct-label { overflow-wrap: anywhere; color: var(--ink); }\n.pct-bar { display: block; height: 10px; border-radius: 2px; background: var(--s-untouched); overflow: hidden; }\n.pct-fill { display: block; height: 100%; background: var(--s-verified); }\n.pct-fill.f-focus { background: var(--s-clean); }\n.pct-n { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }\n@media (max-width: 560px) { .pct-bars li { grid-template-columns: 1fr auto; } .pct-bar { grid-column: 1 / -1; grid-row: 2; } }\n.w-0{width:0%} .w-5{width:5%} .w-10{width:10%} .w-15{width:15%} .w-20{width:20%} .w-25{width:25%} .w-30{width:30%} .w-35{width:35%} .w-40{width:40%} .w-45{width:45%} .w-50{width:50%} .w-55{width:55%} .w-60{width:60%} .w-65{width:65%} .w-70{width:70%} .w-75{width:75%} .w-80{width:80%} .w-85{width:85%} .w-90{width:90%} .w-95{width:95%} .w-100{width:100%}\n.plan-weights { margin: 0 0 12px; font-size: 14.5px; color: var(--ink-2); max-width: 78ch; }\n.focus-stack { margin: 0; padding-left: 22px; max-width: 78ch; font-size: 14px; }\n.focus-stack li { margin-bottom: 6px; overflow-wrap: anywhere; }\ntd.what { white-space: normal; min-width: 18ch; overflow-wrap: anywhere; }\n/* The detail panel's longer evidence: two small lists, the replaced decisions quieter. */\n.detail h4 { margin: 14px 0 6px; font-size: 13.5px; font-weight: 620; color: var(--ink-2); }\n.detail .history { margin: 0 0 8px; padding-left: 18px; font-size: 14px; }\n.detail .history li { margin-bottom: 4px; overflow-wrap: anywhere; }\n.detail .history li.superseded { color: var(--ink-3); }\n\n/* ── Paper ────────────────────────────────────────────────────────────── */\n/* Paper is white, whatever the machine is set to. A manager on a dark-mode\n laptop who prints this — or prints the status file they were mailed, which\n carries this stylesheet inside it — would otherwise get white ink on white\n paper, and the map is the whole message. Every token the dark theme\n overrides is put back here to its light value; a test reads both lists and\n fails if one of them gains a token the other does not. */\n@media print {\n :root {\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80); --ink: oklch(0.20 0.012 40);\n --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40); --edge: oklch(0.88 0.010 80);\n --s-untouched: oklch(0.84 0.006 80); --s-retired: oklch(0.93 0.004 80);\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35); --dgm-node: #ffffff;\n }\n}\n\n@media print {\n .actions, .detail { display: none; }\n #app { max-width: none; padding: 0; }\n .pair { grid-template-columns: 1fr; }\n .cell { cursor: default; }\n /* The map is the message. A printer that drops the colour prints nothing.\n `.notice` is on this list for the status file: on paper it is the line that\n says the picture may be incomplete, and a caveat with its mark stripped off\n is a caveat somebody skims past. */\n .cell, .swatch, .need, .notice, .unmeasured, .tag, .diagram, .tile, .lvl, .chip, .focus-line, .estate-unknowns li, .pct-bar, .pct-fill, .pl, .key { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n footer, .needs, .lanes, .runs, .stands, .dispositions, .estate-unknowns, .progress-chart, .pct-bars { break-inside: avoid; }\n /* A printed list of exceptions that stays folded is a list nobody can read. */\n .exceptions:not([open]) > :not(summary) { display: block; }\n .exceptions::details-content { content-visibility: visible; }\n}\n";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which viewer files the CLI carries a copy of, and how each copy is written.
3
+ * Shared by scripts/sync-status-file.ts and the pin test.
4
+ */
5
+ export const COPIES = [
6
+ ['../viewer/src/lib/register-map.js', 'src/register/status-file/register-map.ts'],
7
+ ['../viewer/src/lib/register-model.js', 'src/register/status-file/register-model.ts'],
8
+ ['../viewer/src/lib/register-parts.js', 'src/register/status-file/register-parts.ts'],
9
+ ['../viewer/src/lib/register-status-file.js', 'src/register/status-file/register-status-file.ts'],
10
+ ['../viewer/src/lib/estate-parts.js', 'src/register/status-file/estate-parts.ts'],
11
+ ['../viewer/src/lib/progress-chart.js', 'src/register/status-file/progress-chart.ts'],
12
+ ['../viewer/src/lib/plan-parts.js', 'src/register/status-file/plan-parts.ts'],
13
+ ['../viewer/src/team/team.css', 'src/register/status-file/team-css.ts'],
14
+ ];
15
+ export const HEADER = (from) => [
16
+ '// @ts-nocheck',
17
+ `// GENERATED COPY — do not edit here. Edit ${from.replace('../', '')},`,
18
+ '// then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).',
19
+ '// src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.',
20
+ '',
21
+ ].join('\n');
22
+ export const cssModule = (css) => `${HEADER('../viewer/src/team/team.css')}export const TEAM_CSS = ${JSON.stringify(css)};\n`;
@@ -0,0 +1,19 @@
1
+ import { buildStatusHtml as buildStatusHtmlUntyped } from './status-file/register-status-file.js';
2
+ import { needsYou as needsYouUntyped, packageBlocks, summarize } from './status-file/register-model.js';
3
+ import { TEAM_CSS } from './status-file/team-css.js';
4
+ // The copy is plain JavaScript under `// @ts-nocheck`, so TypeScript infers
5
+ // `needsYou`'s options from its `= {}` default and loses `now`. Say the shape
6
+ // here rather than edit the generated copy.
7
+ const needsYou = needsYouUntyped;
8
+ // The same for `estate`: its `= undefined` default reads as "always undefined".
9
+ const buildStatusHtml = buildStatusHtmlUntyped;
10
+ export function statusHtmlFor(view) {
11
+ const { register, unreadable, asOf } = view;
12
+ return buildStatusHtml({
13
+ ...view,
14
+ summary: summarize(register, { unreadable }),
15
+ needs: needsYou(register, { now: asOf, unreadable, surface: 'file' }),
16
+ blocks: packageBlocks(register),
17
+ css: TEAM_CSS,
18
+ });
19
+ }
@@ -178,7 +178,7 @@ function readRecord(file) {
178
178
  return { ok: false };
179
179
  }
180
180
  }
181
- /** Every record-shaped JSON under a folder: writers/<id>/records, rulings, baseline/measurement-*.json. */
181
+ /** Every record-shaped JSON under a folder: writers/<id>/records, rulings, plan, baseline/measurement-*.json, baseline/evidence-*.json. */
182
182
  export function collectRecords(root) {
183
183
  const records = [];
184
184
  let unreadable = 0;
@@ -213,9 +213,12 @@ export function collectRecords(root) {
213
213
  for (const w of writers)
214
214
  readDir(path.join(root, 'writers', w, 'records'));
215
215
  readDir(path.join(root, 'rulings'));
216
+ // The lead's plan records (spec 2026-10-06 §6.1): written only by the lead, read like any other.
217
+ readDir(path.join(root, 'plan'));
216
218
  try {
217
219
  for (const name of fs.readdirSync(path.join(root, 'baseline'))) {
218
- if (!/^measurement-.*\.json$/i.test(name))
220
+ // Measurement zero and the inventory's evidence (evidence-0.json).
221
+ if (!/^(measurement|evidence)-.*\.json$/i.test(name))
219
222
  continue;
220
223
  // Measurement zero lands here, written by the lead and then synced: the
221
224
  // same hazards as any other record, read the same way.
@@ -225,3 +228,38 @@ export function collectRecords(root) {
225
228
  catch { /* no baseline folder here (an outbox has none) */ }
226
229
  return { records, unreadable, files };
227
230
  }
231
+ export const DISCOVERY_DIR = 'discovery';
232
+ const ESTATE_FILE = /^estate-(\d{8}T\d{6}Z(?:-\d{1,4})?)\.json$/;
233
+ /** Newest first: by the time, then by the `-n` a second discovery of the same second carries (-10 after -2). */
234
+ const newestStampFirst = (a, b) => {
235
+ const [ta = '', na = '1'] = a.split('-');
236
+ const [tb = '', nb = '1'] = b.split('-');
237
+ return ta !== tb ? (ta < tb ? 1 : -1) : Number(nb) - Number(na);
238
+ };
239
+ /**
240
+ * The newest discovery in a folder (spec 2026-10-06 §5.2): `estate-<stamp>.json`
241
+ * and the `objects-<stamp>.json` of the same stamp. Older discoveries stay on
242
+ * disk as history and are never read here. A newest file that cannot be read
243
+ * (half synced) gives way to the one before it; `objects` is null when its
244
+ * file is missing or unreadable. Neither file is a record, and both are handed
245
+ * on as they are: the readers downstream read them the strict way.
246
+ */
247
+ export function readEstate(dir) {
248
+ const folder = path.join(dir, DISCOVERY_DIR);
249
+ let names = [];
250
+ try {
251
+ names = fs.readdirSync(folder);
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ const stamps = names.map((n) => ESTATE_FILE.exec(n)?.[1]).filter((s) => !!s).sort(newestStampFirst);
257
+ for (const stamp of stamps) {
258
+ const estate = readRecord(path.join(folder, `estate-${stamp}.json`));
259
+ if (!estate.ok || !estate.value || typeof estate.value !== 'object')
260
+ continue;
261
+ const objects = readRecord(path.join(folder, `objects-${stamp}.json`));
262
+ return { estate: estate.value, objects: objects.ok ? objects.value : null, stamp };
263
+ }
264
+ return null;
265
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The two workbooks (spec §6.2, §6.3), built from the sheet model in
3
+ * register-core — the same functions the team page prints — so a number in
4
+ * Excel and the same number on the page cannot differ.
5
+ *
6
+ * Regenerated, never read back: an edit typed into a workbook changes nothing.
7
+ * A workbook is not a `.json` record, so no reader can mistake it for one.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { developerTable, estateSheets, levelTable, measurementTable, overlapNote, packageTable, planRows, priorityRulesRows, progressRows, summaryRows, utcWords, workingListRows, PLAN_COLUMNS, PLAN_EDITABLE, WORKING_LIST_COLUMNS, } from '@cspeach/register-core';
12
+ import { registerFor } from './picture.js';
13
+ import { safeFileStem, writeXlsxFile } from './xlsx.js';
14
+ const LIST_WIDTHS = [28, 6, 16, 6, 8, 22, 48, 26, 10, 10, 20, 18, 20, 22];
15
+ const PLAN_WIDTHS = [28, 6, 16, 22, 9, 9, 12, 40, 3, 10, 18, 20, 7, 22, 20, 10];
16
+ /** The Read me line `plan import` reads the export stamp from. */
17
+ export const EXPORT_STAMP = 'Export stamp: ';
18
+ const LEVEL_LINE = 'Levels: A uses only released APIs. B calls public classic APIs. C uses SAP-internal objects. D modifies SAP standard.';
19
+ export const workingListFileName = (name) => `${safeFileStem(name)} - working list.xlsx`;
20
+ export const reportFileNames = (projectName) => ({
21
+ xlsx: `${safeFileStem(projectName)} - clean core.xlsx`, html: `${safeFileStem(projectName)} - status.html`,
22
+ });
23
+ const readMe = (lines) => ({ name: 'Read me', header: ['Read me'], rows: lines.map((l) => [l]), widths: [110], filter: false });
24
+ const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
25
+ export function workingListSheets(register, laneIds, who, asOf, folderName) {
26
+ return [
27
+ { name: 'Working list', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, laneIds), widths: LIST_WIDTHS },
28
+ readMe([
29
+ `Made by CSPeach for ${who} on ${utcWords(asOf)}, from the team project folder "${folderName}".`,
30
+ 'This file is made again after every decision, fix or measurement this laptop records. Changes typed here are not read back.',
31
+ 'Record a decision with: cspeach team decide <TYPE> <NAME> <decision>',
32
+ 'Record a fix with: cspeach team fixed <TYPE> <NAME>',
33
+ 'It shows the team project as this laptop sees it, including records not handed in yet, so it can be ahead of the team page.',
34
+ LEVEL_LINE,
35
+ ]),
36
+ ];
37
+ }
38
+ const table = (t) => [t.header, ...t.rows];
39
+ /** The D sentence: the demo's words only when they are true of every active object (claimed ≠ proven). */
40
+ function dSentence(register) {
41
+ const b = register.totals.byLevel;
42
+ // An object with nothing to decide was never judged either: the demo's words still need every object judged.
43
+ const everyoneJudged = b.decided.none === 0 && b.now.none === 0 && !b.nothingToDecide;
44
+ return everyoneJudged && b.decided.D === 0 && b.now.D === 0
45
+ ? 'D means an object that modifies SAP standard; none of the objects in these packages does.'
46
+ : 'D means an object that modifies SAP standard; no object here has been judged D.';
47
+ }
48
+ export function combinedSheets(register, asOf, folderName) {
49
+ const note = overlapNote(register);
50
+ const summary = [
51
+ ...summaryRows(register),
52
+ [], ...table(levelTable(register)),
53
+ [], ...table(packageTable(register)),
54
+ [], ...table(developerTable(register)),
55
+ ...(note ? [[note]] : []),
56
+ ];
57
+ const lanes = register.lanes.map((l) => ({
58
+ name: l.owner, header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, [l.id]), widths: LIST_WIDTHS,
59
+ }));
60
+ const runs = measurementTable(register);
61
+ const progress = progressRows(register);
62
+ const capped = register.progressCapped;
63
+ const rules = priorityRulesRows(register);
64
+ const estate = register.estate ? estateSheets(register.estate).find((s) => s.name === 'Packages')?.table : undefined;
65
+ return [
66
+ { name: 'Summary', header: ['Summary', 'Count'], rows: summary, widths: [34, 14, 12, 12, 12, 12, 14, 12, 22], filter: false },
67
+ // Before the lane sheets, so a lane owner called "Plan" never takes the name plan import looks for.
68
+ { name: 'Plan', header: [...PLAN_COLUMNS], rows: planRows(register), widths: PLAN_WIDTHS },
69
+ { name: 'All objects', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, null), widths: LIST_WIDTHS },
70
+ ...lanes,
71
+ { name: 'Measurements', header: runs.header, rows: runs.rows, widths: [20, 20, 26, 22, 10, 14, 24] },
72
+ {
73
+ name: 'Progress', header: progress.header, widths: [20, 9, 9, 9, 9, 9, 9, 10, 13], filter: !capped,
74
+ rows: [...progress.rows, ...(capped ? [[], [`Showing the last ${capped.shown} of ${capped.total} check runs.`]] : [])],
75
+ },
76
+ { name: 'Priority rules', header: rules.header, rows: rules.rows, widths: [24, 18, 30], filter: false },
77
+ ...(estate ? [{ name: 'Estate inventory', header: estate.header, rows: estate.rows, widths: [22, 34, 18, 18, 16, 9, 12, 15, 10, 20, 16, 9] }] : []),
78
+ readMe([
79
+ `Made by CSPeach on ${utcWords(asOf)}, from the team project folder "${folderName}".`,
80
+ `${EXPORT_STAMP}${asOf}`,
81
+ `Edit only the five columns before the dash: ${PLAN_EDITABLE.join(', ')}.`,
82
+ 'Bring the sheet back with: cspeach team plan import <file>',
83
+ 'Changes made by someone else after this export are shown and skipped, never overwritten.',
84
+ 'The numbers are the same ones the team page shows for this folder.',
85
+ 'This file is made again by cspeach team report. Only the Plan sheet is read back, and only through plan import.',
86
+ 'Proven means a check run said so. A claimed fix is not counted as proven.',
87
+ 'A level moves only when a check proves the fix.',
88
+ LEVEL_LINE,
89
+ dSentence(register),
90
+ ]),
91
+ ];
92
+ }
93
+ export const HELD = new Set(['EBUSY', 'EPERM', 'EACCES', 'EISDIR', 'ENOTEMPTY']);
94
+ /**
95
+ * This writer's working list, made again from the folder as this laptop sees it
96
+ * (team project folder + own outbox). Never throws: a record is written whether
97
+ * or not its workbook could be, and the person is told in one line.
98
+ */
99
+ export function regenerateWorkingList(joined, who, now = new Date()) {
100
+ try {
101
+ const { register } = registerFor(joined.projectDir, joined.outboxDir);
102
+ const mine = register.lanes.filter((l) => sameName(l.owner, who)).map((l) => l.id);
103
+ if (!mine.length)
104
+ return { file: null, line: null, ownsLane: false, kind: 'none' };
105
+ const name = workingListFileName(who);
106
+ const file = path.join(joined.outboxDir, 'writers', joined.writerId, name);
107
+ try {
108
+ writeXlsxFile(file, workingListSheets(register, mine, who, now.toISOString(), path.basename(joined.projectDir)));
109
+ }
110
+ catch (e) {
111
+ const code = e?.code ?? '';
112
+ if (HELD.has(code))
113
+ return { file: null, line: ' ! Your working list is open in another program, so it was not updated. Close it and run: cspeach team sheet', ownsLane: true, kind: 'held' };
114
+ throw e;
115
+ }
116
+ return { file, line: ` Your working list is updated: ${name}`, ownsLane: true, kind: 'updated' };
117
+ }
118
+ catch {
119
+ // Fixed words: the error text can carry full folder paths, which never go on screen.
120
+ return { file: null, line: ' ! Your working list could not be updated: the team project folder could not be read. Run cspeach team status for the reason.', ownsLane: true, kind: 'unreadable' };
121
+ }
122
+ }
123
+ /** True when `file` sits where a working list goes; used by tests and by `sheet`. */
124
+ export const workingListExists = (file) => !!file && fs.existsSync(file);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Whole, then renamed — like a record. A sync tool must never pick up half a
3
+ * file, and a refused rename must not leave a stray `.tmp` in a folder a whole
4
+ * team syncs. The error the caller gets is the real one.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ export function writeWhole(file, data) {
9
+ fs.mkdirSync(path.dirname(file), { recursive: true });
10
+ const tmp = `${file}.tmp`;
11
+ fs.writeFileSync(tmp, data);
12
+ try {
13
+ fs.renameSync(tmp, file);
14
+ }
15
+ catch (e) {
16
+ try {
17
+ fs.unlinkSync(tmp);
18
+ }
19
+ catch { /* the reason the rename failed may be the reason this fails too */ }
20
+ throw e;
21
+ }
22
+ }