@cspeach/cli 1.1.30 → 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 (96) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +121 -8
  4. package/dist/cli.js +36 -14
  5. package/dist/commands/config-set.js +1 -1
  6. package/dist/commands/plan-continue.js +1 -1
  7. package/dist/commands/plan-gate.js +8 -1
  8. package/dist/commands/plan-resume.js +24 -18
  9. package/dist/commands/plan-startup.js +49 -6
  10. package/dist/commands/team-discover.js +195 -0
  11. package/dist/commands/team-plan.js +585 -0
  12. package/dist/commands/team.js +375 -41
  13. package/dist/journey/chain.js +728 -0
  14. package/dist/journey/clean-core.js +213 -0
  15. package/dist/journey/flow.js +166 -0
  16. package/dist/journey/run.js +435 -0
  17. package/dist/journey/session-deps.js +77 -0
  18. package/dist/journey/trigger.js +153 -0
  19. package/dist/lane/app-chain.js +9 -7
  20. package/dist/lane/chain-token.js +9 -5
  21. package/dist/lane/roots.js +97 -41
  22. package/dist/one-shot.js +20 -1
  23. package/dist/projects/clean-core-confirm.js +156 -0
  24. package/dist/projects/detail-path.js +9 -4
  25. package/dist/projects/extract-clean-core.js +3 -0
  26. package/dist/projects/migration.js +4 -105
  27. package/dist/projects/plan-lane.js +67 -17
  28. package/dist/projects/plan-sources.js +13 -7
  29. package/dist/projects/project-folder.js +131 -0
  30. package/dist/projects/promote-command.js +39 -0
  31. package/dist/projects/save-command.js +83 -12
  32. package/dist/projects/saved-lines.js +17 -3
  33. package/dist/projects/sort-command.js +499 -0
  34. package/dist/projects/sort-core.js +248 -0
  35. package/dist/projects/workspace-retire.js +48 -0
  36. package/dist/projects/workspace.js +182 -121
  37. package/dist/register/discover.js +901 -0
  38. package/dist/register/findings.js +5 -2
  39. package/dist/register/offer.js +100 -8
  40. package/dist/register/picture.js +6 -2
  41. package/dist/register/plan-import.js +325 -0
  42. package/dist/register/records.js +25 -0
  43. package/dist/register/status-file/estate-parts.js +168 -0
  44. package/dist/register/status-file/plan-parts.js +68 -0
  45. package/dist/register/status-file/progress-chart.js +110 -0
  46. package/dist/register/status-file/register-map.js +11 -3
  47. package/dist/register/status-file/register-model.js +40 -6
  48. package/dist/register/status-file/register-parts.js +57 -11
  49. package/dist/register/status-file/register-status-file.js +24 -6
  50. package/dist/register/status-file/team-css.js +1 -1
  51. package/dist/register/status-file-sync.js +3 -0
  52. package/dist/register/status-html.js +3 -1
  53. package/dist/register/store.js +40 -2
  54. package/dist/register/workbooks.js +27 -7
  55. package/dist/register/xlsx.js +13 -3
  56. package/dist/register/xref.js +117 -0
  57. package/dist/renderer/startup-lines.js +11 -0
  58. package/dist/renderer/tool-labels.js +2 -1
  59. package/dist/renderer/tool-widget.js +15 -1
  60. package/dist/repl/at-completer.js +6 -2
  61. package/dist/repl/at-picker.js +1 -1
  62. package/dist/repl/journey-entry.js +25 -0
  63. package/dist/repl.js +157 -39
  64. package/dist/session/backfill.js +342 -0
  65. package/dist/session/project-match.js +63 -0
  66. package/dist/session/resume.js +72 -24
  67. package/dist/session/schema.js +3 -1
  68. package/dist/session/store.js +96 -51
  69. package/dist/skill-catalog.js +2 -2
  70. package/dist/skills/bundled-skills.js +1 -1
  71. package/dist/tools/_filesystem-shared.js +174 -12
  72. package/dist/tools/_team-work-path.js +133 -0
  73. package/dist/tools/approval.js +7 -1
  74. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  75. package/dist/tools/filesystem/file-edit.js +12 -4
  76. package/dist/tools/filesystem/file-read.js +8 -4
  77. package/dist/tools/filesystem/file-write.js +12 -4
  78. package/dist/tools/filesystem/glob.js +33 -8
  79. package/dist/tools/filesystem/grep.js +9 -4
  80. package/dist/tools/filesystem/read-document.js +6 -3
  81. package/dist/tools/project/cca_merge.js +8 -2
  82. package/dist/tools/project/team_estate.js +91 -0
  83. package/dist/tools/project/team_findings.js +77 -4
  84. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  85. package/dist/tools/sap-read.js +21 -4
  86. package/dist/tools/sap-write.js +200 -10
  87. package/dist/tools/subagent/read_agent.js +3 -0
  88. package/dist/tools/tool-loading.js +1 -1
  89. package/dist/tools/transport-fit-check.js +117 -0
  90. package/dist/tools/transport-fit.js +15 -7
  91. package/dist/tools/transport.js +57 -2
  92. package/dist/ui/app.js +2 -2
  93. package/dist/ui/approval-modal.js +74 -6
  94. package/dist/ui/widgets/confirm-card.js +68 -5
  95. package/dist/util/open-in-browser.js +143 -0
  96. package/package.json +120 -120
@@ -37,7 +37,10 @@ import { formatWhen } from './register-model.js';
37
37
  // Every mark this file shares with the page. One copy, in a module that knows
38
38
  // nothing about the browser: two pictures of one folder that disagree would be
39
39
  // worse than no picture at all, and these two had already begun to.
40
- import { decisionWord, developersTableHtml, dispositionsHtml, ignoredRunsHtml, mapBodyHtml, measuredWord, n, needItemHtml, notMeasuredLine, ownerLabel, provenanceLine, readNote, runsTableHtml, stateWord, whereItStandsHtml, } from './register-parts.js';
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';
41
44
  const FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
42
45
  const objects = (count) => `${n(count)} object${count === 1 ? '' : 's'}`;
43
46
  const files = (count) => `${n(count)} file${count === 1 ? '' : 's'}`;
@@ -68,7 +71,7 @@ function packageFold(block, lanes, runs, projectType) {
68
71
  // or that nobody has looked. A reader working down this list must not have to
69
72
  // take a colour on trust, and "not measured" is an answer, not a blank.
70
73
  const row = (o) => `<tr><td class="ident">${esc(o.name)}</td><td>${esc(o.type)}</td><td>${esc(stateWord(o))}</td>`
71
- + `<td>${esc(decisionWord(o.decision, projectType))}</td>`
74
+ + `<td>${esc(decisionWord(o.decision, projectType, o.state))}</td>`
72
75
  + `<td>${esc(ownerLabel(lanes.find((l) => l.id === o.lane)))}</td>`
73
76
  + `<td>${esc(measuredWord(o, runs))}</td></tr>`;
74
77
  // Every fold holds a table with the same six headings, so each one says
@@ -86,8 +89,12 @@ const UNREADABLE_DETAIL = 'Some results may be missing. The next status file wil
86
89
  * The whole document.
87
90
  *
88
91
  * Read in the order a manager reads it: whose team project, when this was true, the
89
- * one line, the picture, what is waiting for a person, who owns what — and only
90
- * then, folded away, every object one by one.
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.
91
98
  *
92
99
  * `css` is the only value written in unescaped, and the only one that never came
93
100
  * out of the folder: it is our own stylesheet, imported at build time. Every
@@ -101,10 +108,11 @@ const UNREADABLE_DETAIL = 'Some results may be missing. The next status file wil
101
108
  *
102
109
  * @param {{ project: object, register: object, summary: object, needs: object[],
103
110
  * blocks: object[], css: string, asOf: string, folderName: string,
104
- * files?: number, checksum?: string, unreadable?: number }} view
111
+ * files?: number, checksum?: string, unreadable?: number, estate?: unknown }} view
105
112
  * @returns {string}
106
113
  */
107
- export function buildStatusHtml({ project, register, summary, needs, blocks, css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, }) {
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
+ }) {
108
116
  // A file with no time on it, or with the wrong one, is worse than no file:
109
117
  // somebody will act on it. Refuse rather than print "never" or quietly stamp
110
118
  // the moment Save was pressed.
@@ -141,20 +149,30 @@ export function buildStatusHtml({ project, register, summary, needs, blocks, css
141
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>
142
150
  ${unreadable > 0 ? `<p class="notice" data-role="incomplete">${files(unreadable)} could not be read, so this picture may be incomplete.</p>` : ''}
143
151
  <p class="summary" data-role="summary">${esc(summary.line)}</p>
152
+ <p class="focus-line" data-role="focus">${focusLineHtml(register)}</p>
144
153
  ${notMeasuredLine(register) ? `<p class="unmeasured" data-role="not-measured">${esc(notMeasuredLine(register))}</p>` : ''}
145
154
  </header>
155
+ <section class="estate" data-role="estate"><h2>Estate</h2>
156
+ ${estateSectionHtml(register, { fileGiven: estate !== undefined, now: asOf })}
157
+ </section>
146
158
  <section class="stands" data-role="stands"><h2>Where it stands</h2>
147
159
  ${whereItStandsHtml(register)}
148
160
  </section>
149
161
  <section class="dispositions"><h2>Decisions</h2>
150
162
  ${dispositionsHtml(register)}
151
163
  </section>
164
+ <section class="progress" data-role="progress"><h2>Progress over time</h2>
165
+ ${progressSectionHtml(register)}
166
+ </section>
152
167
  <section class="needs" data-role="needs"><h2>Needs you</h2>
153
168
  ${needs.length ? `<ul>${needs.map(forFile).map(needItemHtml).join('')}</ul>` : '<p>Nothing is waiting for anyone.</p>'}
154
169
  </section>
155
170
  <section class="lanes" data-role="lanes"><h2>Who owns what</h2>
156
171
  ${developersTableHtml(register)}
157
172
  </section>
173
+ <section class="plan" data-role="plan"><h2>Plan and changes</h2>
174
+ ${planSectionHtml(register)}
175
+ </section>
158
176
  <section class="map"><h2>Every object</h2>
159
177
  ${mapBodyHtml(register, blocks, { role: 'img' })}
160
178
  </section>
@@ -2,4 +2,4 @@
2
2
  // GENERATED COPY — do not edit here. Edit viewer/src/team/team.css,
3
3
  // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
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/* ── 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 { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n footer, .needs, .lanes, .runs, .stands, .dispositions { 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";
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";
@@ -7,6 +7,9 @@ export const COPIES = [
7
7
  ['../viewer/src/lib/register-model.js', 'src/register/status-file/register-model.ts'],
8
8
  ['../viewer/src/lib/register-parts.js', 'src/register/status-file/register-parts.ts'],
9
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'],
10
13
  ['../viewer/src/team/team.css', 'src/register/status-file/team-css.ts'],
11
14
  ];
12
15
  export const HEADER = (from) => [
@@ -1,10 +1,12 @@
1
- import { buildStatusHtml } from './status-file/register-status-file.js';
1
+ import { buildStatusHtml as buildStatusHtmlUntyped } from './status-file/register-status-file.js';
2
2
  import { needsYou as needsYouUntyped, packageBlocks, summarize } from './status-file/register-model.js';
3
3
  import { TEAM_CSS } from './status-file/team-css.js';
4
4
  // The copy is plain JavaScript under `// @ts-nocheck`, so TypeScript infers
5
5
  // `needsYou`'s options from its `= {}` default and loses `now`. Say the shape
6
6
  // here rather than edit the generated copy.
7
7
  const needsYou = needsYouUntyped;
8
+ // The same for `estate`: its `= undefined` default reads as "always undefined".
9
+ const buildStatusHtml = buildStatusHtmlUntyped;
8
10
  export function statusHtmlFor(view) {
9
11
  const { register, unreadable, asOf } = view;
10
12
  return buildStatusHtml({
@@ -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
+ }
@@ -8,10 +8,13 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { developerTable, levelTable, measurementTable, overlapNote, packageTable, summaryRows, utcWords, workingListRows, WORKING_LIST_COLUMNS, } from '@cspeach/register-core';
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
12
  import { registerFor } from './picture.js';
13
13
  import { safeFileStem, writeXlsxFile } from './xlsx.js';
14
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: ';
15
18
  const LEVEL_LINE = 'Levels: A uses only released APIs. B calls public classic APIs. C uses SAP-internal objects. D modifies SAP standard.';
16
19
  export const workingListFileName = (name) => `${safeFileStem(name)} - working list.xlsx`;
17
20
  export const reportFileNames = (projectName) => ({
@@ -36,7 +39,8 @@ const table = (t) => [t.header, ...t.rows];
36
39
  /** The D sentence: the demo's words only when they are true of every active object (claimed ≠ proven). */
37
40
  function dSentence(register) {
38
41
  const b = register.totals.byLevel;
39
- const everyoneJudged = b.decided.none === 0 && b.now.none === 0;
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;
40
44
  return everyoneJudged && b.decided.D === 0 && b.now.D === 0
41
45
  ? 'D means an object that modifies SAP standard; none of the objects in these packages does.'
42
46
  : 'D means an object that modifies SAP standard; no object here has been judged D.';
@@ -54,15 +58,31 @@ export function combinedSheets(register, asOf, folderName) {
54
58
  name: l.owner, header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, [l.id]), widths: LIST_WIDTHS,
55
59
  }));
56
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;
57
65
  return [
58
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 },
59
69
  { name: 'All objects', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, null), widths: LIST_WIDTHS },
60
70
  ...lanes,
61
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] }] : []),
62
78
  readMe([
63
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.',
64
84
  'The numbers are the same ones the team page shows for this folder.',
65
- 'This file is made again by cspeach team report. Changes typed here are not read back.',
85
+ 'This file is made again by cspeach team report. Only the Plan sheet is read back, and only through plan import.',
66
86
  'Proven means a check run said so. A claimed fix is not counted as proven.',
67
87
  'A level moves only when a check proves the fix.',
68
88
  LEVEL_LINE,
@@ -81,7 +101,7 @@ export function regenerateWorkingList(joined, who, now = new Date()) {
81
101
  const { register } = registerFor(joined.projectDir, joined.outboxDir);
82
102
  const mine = register.lanes.filter((l) => sameName(l.owner, who)).map((l) => l.id);
83
103
  if (!mine.length)
84
- return { file: null, line: null, ownsLane: false };
104
+ return { file: null, line: null, ownsLane: false, kind: 'none' };
85
105
  const name = workingListFileName(who);
86
106
  const file = path.join(joined.outboxDir, 'writers', joined.writerId, name);
87
107
  try {
@@ -90,14 +110,14 @@ export function regenerateWorkingList(joined, who, now = new Date()) {
90
110
  catch (e) {
91
111
  const code = e?.code ?? '';
92
112
  if (HELD.has(code))
93
- 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 };
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' };
94
114
  throw e;
95
115
  }
96
- return { file, line: ` Your working list is updated: ${name}`, ownsLane: true };
116
+ return { file, line: ` Your working list is updated: ${name}`, ownsLane: true, kind: 'updated' };
97
117
  }
98
118
  catch {
99
119
  // Fixed words: the error text can carry full folder paths, which never go on screen.
100
- 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 };
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' };
101
121
  }
102
122
  }
103
123
  /** True when `file` sits where a working list goes; used by tests and by `sheet`. */
@@ -2,7 +2,8 @@
2
2
  * A minimal .xlsx writer (spec §6.1): an .xlsx is a zip of a few XML parts.
3
3
  *
4
4
  * Text cells are inline strings (`t="inlineStr"`), which Excel never evaluates
5
- * as formulas, so `=SUM(` in a reason stays text. Every cell goes through XML
5
+ * as formulas, so `=SUM(` in a reason stays text; a text cell that starts with
6
+ * = + - or @ also carries the quote-prefix style, so it stays text after an edit. Every cell goes through XML
6
7
  * escaping, and characters XML 1.0 cannot hold are dropped. One bold header
7
8
  * style, column widths, a frozen header row and an autofilter. Entry times are
8
9
  * pinned, so the same sheets give the same bytes.
@@ -20,6 +21,10 @@ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[
20
21
  const NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
21
22
  const REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
22
23
  const HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
24
+ /** Cells Excel could read as a formula: =, +, - or @ first. */
25
+ const FORMULA_LEAD = /^[=+\-@]/;
26
+ /** The cell style with `quotePrefix` (the third xf in STYLES). */
27
+ const QUOTED = 2;
23
28
  export function xmlText(v) {
24
29
  // Strip lone surrogates again after the cut: the cut can split a pair.
25
30
  return v.replace(NOT_XML, '').replace(LONE_SURROGATE, '').slice(0, MAX_CELL).replace(LONE_SURROGATE, '')
@@ -37,7 +42,11 @@ function cellXml(v, ref, style) {
37
42
  const s = style ? ` s="${style}"` : '';
38
43
  if (typeof v === 'number')
39
44
  return Number.isFinite(v) ? `<c r="${ref}"${s}><v>${v}</v></c>` : '';
40
- return `<c r="${ref}"${s} t="inlineStr"><is><t xml:space="preserve">${xmlText(String(v))}</t></is></c>`;
45
+ const text = String(v);
46
+ // Text that starts like a formula gets Excel's own quote-prefix style: shown as
47
+ // typed, and kept text even when someone edits the cell in Excel and presses Enter.
48
+ const t = !style && FORMULA_LEAD.test(text) ? ` s="${QUOTED}"` : s;
49
+ return `<c r="${ref}"${t} t="inlineStr"><is><t xml:space="preserve">${xmlText(text)}</t></is></c>`;
41
50
  }
42
51
  function sheetXml(sheet) {
43
52
  const width = Math.max(1, sheet.header.length, ...sheet.rows.map((r) => r.length));
@@ -56,7 +65,8 @@ const STYLES = `${HEAD}<styleSheet xmlns="${NS}">`
56
65
  + '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
57
66
  + '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
58
67
  + '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
59
- + '<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>'
68
+ + '<cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
69
+ + '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" quotePrefix="1"/></cellXfs>'
60
70
  + '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
61
71
  export function buildXlsx(sheets) {
62
72
  const taken = new Set();
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The where-used index, turned into calling objects (spec §5.3).
3
+ *
4
+ * WBCROSSGT and CROSS name the INCLUDE that uses an object. A caller is the
5
+ * object that include belongs to: a class (its pool includes follow a fixed
6
+ * naming scheme), an interface pool, a function group (SAPL<fg>, or an L<fg>…
7
+ * include through its D010INC master), or a report (its master, or itself).
8
+ * Anything that resolves to nothing is counted as unmapped — never guessed.
9
+ *
10
+ * Namespaced names (`/ACME/CL_THING======CP`, `/ACME/SAPLZFG`) follow the
11
+ * same rules; the namespace stays part of the object name.
12
+ */
13
+ /** Class-pool includes: name padded with '=' then CP/CU/CI/CO/CT/CS, CMnnn, CCDEF/CCIMP/CCMAC/CCAU. */
14
+ const CLASS_INCLUDE = /^([^=]{1,30})=+(C[A-Z]{1,2}|CM[0-9A-Z]{3}|CCDEF|CCIMP|CCMAC|CCAU)$/;
15
+ /** Interface-pool includes. */
16
+ const INTF_INCLUDE = /^([^=]{1,30})=+I[PU]$/;
17
+ /**
18
+ * The same pool includes read at fixed width: the object name is the first 30
19
+ * characters, padded with '='. A name of exactly 30 characters has no '=' at
20
+ * all, which the patterns above cannot see.
21
+ */
22
+ const CLASS_SUFFIX = /^(C[A-Z]{1,2}|CM[0-9A-Z]{3}|CCDEF|CCIMP|CCMAC|CCAU)$/;
23
+ const INTF_SUFFIX = /^I[PU]$/;
24
+ /** Function-group main program, optionally namespaced: SAPLZFG, /ACME/SAPLZFG. */
25
+ const FUGR_MAIN = /^(\/[A-Z0-9_]+\/)?SAPL(.+)$/;
26
+ /** Pattern-only mapping: what an include or main program name says by itself. */
27
+ function byPattern(include) {
28
+ let m = CLASS_INCLUDE.exec(include);
29
+ if (m)
30
+ return { type: 'CLAS', name: m[1] };
31
+ m = INTF_INCLUDE.exec(include);
32
+ if (m)
33
+ return { type: 'INTF', name: m[1] };
34
+ if (include.length > 30) {
35
+ const name = include.slice(0, 30).replace(/=+$/, '');
36
+ const suffix = include.slice(30);
37
+ if (name && !name.includes('=')) {
38
+ if (CLASS_SUFFIX.test(suffix))
39
+ return { type: 'CLAS', name };
40
+ if (INTF_SUFFIX.test(suffix))
41
+ return { type: 'INTF', name };
42
+ }
43
+ }
44
+ m = FUGR_MAIN.exec(include);
45
+ if (m)
46
+ return { type: 'FUGR', name: `${m[1] ?? ''}${m[2]}` };
47
+ return null;
48
+ }
49
+ /**
50
+ * The object an include belongs to. `masters` maps include → main program
51
+ * (from D010INC); a report known from TADIR is entered as mapping to itself.
52
+ */
53
+ export function objectOfInclude(include, masters) {
54
+ const inc = (include ?? '').trim().toUpperCase();
55
+ if (!inc)
56
+ return null;
57
+ const direct = byPattern(inc);
58
+ if (direct)
59
+ return direct;
60
+ const master = masters.get(inc);
61
+ if (!master)
62
+ return null;
63
+ return byPattern(master) ?? { type: 'PROG', name: master };
64
+ }
65
+ /**
66
+ * Callers per referenced object name. Index names can carry a component
67
+ * (`ZCL_X\ME:RUN`); the part before the backslash is the object. `selfOf(name)`
68
+ * gives that object's own key (`TYPE|NAME`) so a self-reference is dropped.
69
+ * `includesUnmapped` counts distinct includes CSPeach could not map.
70
+ */
71
+ export function callersFromIndex(rows, masters, selfOf) {
72
+ const out = new Map();
73
+ const unmapped = new Map();
74
+ for (const row of rows) {
75
+ const name = (row.name ?? '').trim().toUpperCase().split('\\')[0];
76
+ if (!name)
77
+ continue;
78
+ const include = (row.include ?? '').trim().toUpperCase();
79
+ let entry = out.get(name);
80
+ if (!entry) {
81
+ entry = { objects: new Set(), includesUnmapped: 0 };
82
+ out.set(name, entry);
83
+ }
84
+ const ref = objectOfInclude(include, masters);
85
+ if (!ref) {
86
+ let seen = unmapped.get(name);
87
+ if (!seen) {
88
+ seen = new Set();
89
+ unmapped.set(name, seen);
90
+ }
91
+ if (include)
92
+ seen.add(include);
93
+ entry.includesUnmapped = seen.size;
94
+ continue;
95
+ }
96
+ const key = `${ref.type}|${ref.name}`;
97
+ if (key === selfOf(name))
98
+ continue;
99
+ entry.objects.add(key);
100
+ }
101
+ return out;
102
+ }
103
+ /** The REPOSRC program name that carries an object's change date, or null for other types. */
104
+ export function reposrcNameFor(type, name) {
105
+ const t = type.toUpperCase();
106
+ const n = name.toUpperCase();
107
+ switch (t) {
108
+ case 'PROG': return n;
109
+ case 'CLAS': return `${n.padEnd(30, '=')}CP`;
110
+ case 'INTF': return `${n.padEnd(30, '=')}IP`;
111
+ case 'FUGR': {
112
+ const m = /^(\/[A-Z0-9_]+\/)(.+)$/.exec(n);
113
+ return m ? `${m[1]}SAPL${m[2]}` : `SAPL${n}`;
114
+ }
115
+ default: return null;
116
+ }
117
+ }
@@ -12,6 +12,7 @@ import { formatAttention } from './notice-shape.js';
12
12
  import { truncateToWidth, uiWidth, frameWidth } from './ui-width.js';
13
13
  import { sanitizeControlChars } from './sanitize.js';
14
14
  import { holdBrand } from './brand-settled.js';
15
+ import { formatTimeAgo } from '../session/time-ago.js';
15
16
  export { brandSettled } from './brand-settled.js';
16
17
  /** Folder budget on the panels status line (controller ruling R2). */
17
18
  const STATUS_PATH_MAX = 20;
@@ -131,6 +132,16 @@ export function panelsResumeLine(last, priorTurns) {
131
132
  const fact = last ?? `${priorTurns} prior turn${priorTurns === 1 ? '' : 's'}`;
132
133
  return ' ' + paint('dim', truncateToWidth(sanitizeControlChars(`resumed · ${fact}`), Math.max(20, uiWidth() - 3)));
133
134
  }
135
+ /** 2026-10-08 (sessions/folders) — the dim start line pointing at this folder's
136
+ * latest session. Quotes the user's prompt, so control characters are neutralised. */
137
+ export function lastSessionHereLine(s, now = new Date()) {
138
+ const when = formatTimeAgo(s.lastTurnAt, now);
139
+ const what = [
140
+ s.skill ? `/${s.skill}` : null,
141
+ s.promptSnippet ? `"${truncateToWidth(sanitizeControlChars(s.promptSnippet), 40)}"` : null,
142
+ ].filter(Boolean).join(', ');
143
+ return ' ' + paint('dim', `Last session here: ${when}${what ? ` (${what})` : ''} — cspeach --continue picks it up.`);
144
+ }
134
145
  /** A "!" line for the start screen (no Next: line → exactly one line). */
135
146
  export function panelsInterruptedNote(n) {
136
147
  return formatAttention({ text: `${n} unfinished session${n === 1 ? '' : 's'}`, fact: 'cspeach --resume' }).trimEnd();
@@ -161,6 +161,7 @@ const RULES = {
161
161
  cap_link_smoke: (a) => ({ verb: 'S/4 link check', object: str(a['s4']?.['entitySet']) ?? '' }),
162
162
  app_lane_resolve: (a) => ({ verb: 'Chose lane', object: shortLeft(str(a['specPath']) ?? '') }),
163
163
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
164
+ team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
164
165
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
165
166
  sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
166
167
  sap_snapshot_read: sapObject('Read snapshot'),
@@ -266,7 +267,7 @@ const PRESENT = {
266
267
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
267
268
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
268
269
  'Checked capability': 'Checking capability',
269
- 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Wrote texts': 'Writing texts',
270
+ 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Read estate inventory': 'Reading estate inventory', 'Wrote texts': 'Writing texts',
270
271
  };
271
272
  export function toolActivityLabel(name, args) {
272
273
  const l = toolLabel(name, args);
@@ -402,6 +402,18 @@ function approvalDeclined(content) {
402
402
  return undefined;
403
403
  }
404
404
  }
405
+ /** 1.1.31 — true when request_approval approved by the user's auto_approve setting, not by a person. */
406
+ function approvalAutoApproved(content) {
407
+ if (!content || !content.trim().startsWith('{'))
408
+ return false;
409
+ try {
410
+ const r = JSON.parse(content);
411
+ return r.approved === true && r.auto_approved === true;
412
+ }
413
+ catch {
414
+ return false;
415
+ }
416
+ }
405
417
  function panelsRow(p) {
406
418
  const width = Math.max(20, getEffectiveBodyWidth());
407
419
  const label = toolLabel(p.name, p.args, p.isError ? undefined : p.resultContent);
@@ -410,7 +422,9 @@ function panelsRow(p) {
410
422
  const declined = !p.isError && p.name === 'request_approval' ? approvalDeclined(p.resultContent) : undefined;
411
423
  const icon = p.isError ? paint('bad', '✗') : declined ? paint('warn', '·') : paint('ok', '✓');
412
424
  const timing = p.durationMs >= 1000 ? formatDuration(p.durationMs) : '';
413
- const tail = [declined ?? label.detail, timing].filter(Boolean).join(' · ');
425
+ // 1.1.31: a setting said yes, not a person — the row says so.
426
+ const auto = !p.isError && !declined && p.name === 'request_approval' && approvalAutoApproved(p.resultContent);
427
+ const tail = [declined ?? (auto ? 'auto-approved' : label.detail), timing].filter(Boolean).join(' · ');
414
428
  const verb = label.verb.padEnd(VERB_COL);
415
429
  // Budget: "✓ " + verb + " " + object + " " + tail must fit the width.
416
430
  const budget = width - 2 - verb.length - 1 - (tail ? tail.length + 2 : 0);
@@ -17,7 +17,7 @@
17
17
  * The cache TTL is short enough that newly-saved files appear within a
18
18
  * few seconds of their save without manual refresh.
19
19
  */
20
- import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
20
+ import { ensureWorkspaceSync, listLegacyFilesSync, listProjectFilesSync } from '../projects/workspace.js';
21
21
  const CACHE_TTL_MS = 3000;
22
22
  let cache = null;
23
23
  function getCachedFiles() {
@@ -25,7 +25,11 @@ function getCachedFiles() {
25
25
  if (!cache || now - cache.at > CACHE_TTL_MS) {
26
26
  try {
27
27
  const ws = ensureWorkspaceSync();
28
- cache = { files: listProjectFilesSync(ws), at: now };
28
+ const files = listProjectFilesSync(ws);
29
+ // Task 11 — old save locations stay readable for one release; their
30
+ // files follow the new folder's, same names skipped.
31
+ const legacy = listLegacyFilesSync(process.cwd(), files.map((f) => f.filename));
32
+ cache = { files: [...files, ...legacy], at: now };
29
33
  }
30
34
  catch {
31
35
  cache = { files: [], at: now };