@cspeach/cli 1.1.48 → 1.1.50

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 (93) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/level.js +1 -1
  4. package/dist/agent/loop.js +90 -8
  5. package/dist/agent/oversized-results.js +204 -0
  6. package/dist/agent/sap-connection-adapter.js +2 -0
  7. package/dist/agent/system-prompt.js +2 -2
  8. package/dist/agent/tool-dispatch.js +13 -1
  9. package/dist/agent/turn-error-ux.js +14 -0
  10. package/dist/approvals/op-labels.js +49 -10
  11. package/dist/approvals/render.js +14 -23
  12. package/dist/approvals/risk-floor.js +196 -19
  13. package/dist/approvals/visible-changes.js +35 -0
  14. package/dist/commands/help.js +1 -1
  15. package/dist/commands/plan-continue-gate.js +11 -0
  16. package/dist/commands/team-plan.js +16 -6
  17. package/dist/commands/team.js +295 -68
  18. package/dist/cost/cost-log.js +4 -0
  19. package/dist/cost/system-block.js +25 -0
  20. package/dist/journey/chain.js +489 -61
  21. package/dist/journey/clean-core.js +61 -36
  22. package/dist/journey/flow.js +75 -2
  23. package/dist/journey/people.js +37 -0
  24. package/dist/journey/rebuild.js +7 -6
  25. package/dist/journey/run.js +129 -42
  26. package/dist/journey/trigger.js +16 -10
  27. package/dist/models/server-config.js +13 -0
  28. package/dist/one-shot.js +1 -1
  29. package/dist/project-context/render.js +7 -0
  30. package/dist/projects/clean-core-confirm.js +166 -44
  31. package/dist/projects/extract-cca.js +8 -7
  32. package/dist/projects/index.js +0 -1
  33. package/dist/projects/merge-upgrade.js +2 -2
  34. package/dist/projects/project-folder.js +2 -1
  35. package/dist/projects/save-command.js +51 -14
  36. package/dist/projects/workspace.js +6 -3
  37. package/dist/register/decision-rules.js +2 -1
  38. package/dist/register/export-files.js +18 -0
  39. package/dist/register/picture.js +3 -3
  40. package/dist/register/project-transport.js +125 -0
  41. package/dist/register/records.js +25 -13
  42. package/dist/register/rules-edit.js +5 -0
  43. package/dist/register/status-file/progress-chart.js +17 -6
  44. package/dist/register/status-file/register-map.js +8 -5
  45. package/dist/register/status-file/register-model.js +55 -36
  46. package/dist/register/status-file/register-parts.js +97 -82
  47. package/dist/register/status-file/register-status-file.js +16 -7
  48. package/dist/register/status-file/report-model.js +293 -205
  49. package/dist/register/status-file/team-css.js +1 -1
  50. package/dist/register/status-html.js +11 -4
  51. package/dist/register/store.js +2 -2
  52. package/dist/register/workbooks.js +24 -13
  53. package/dist/renderer/footer-line.js +10 -2
  54. package/dist/renderer/tool-labels.js +2 -2
  55. package/dist/repl/builtin-commands.js +1 -1
  56. package/dist/repl/continue-skill.js +5 -0
  57. package/dist/repl/level-command.js +6 -15
  58. package/dist/repl.js +37 -14
  59. package/dist/router/router-core.js +5 -3
  60. package/dist/session/fresh-start.js +15 -0
  61. package/dist/skill-catalog.js +4 -7
  62. package/dist/tools/_filesystem-shared.js +7 -0
  63. package/dist/tools/_team-work-path.js +53 -0
  64. package/dist/tools/approval.js +97 -19
  65. package/dist/tools/cut-markers.js +47 -0
  66. package/dist/tools/filesystem/extract-document.js +50 -2
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-read.js +2 -2
  69. package/dist/tools/filesystem/file-write.js +8 -1
  70. package/dist/tools/filesystem/read-document.js +20 -4
  71. package/dist/tools/project/_merge-shared.js +2 -2
  72. package/dist/tools/project/_team-tool-shared.js +5 -0
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/text-elements/tools.js +7 -1
  78. package/dist/tools/tool-loading.js +1 -1
  79. package/dist/tools/tool-output-read.js +5 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +24 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/footer-line.js +19 -1
  84. package/package.json +3 -3
  85. package/dist/projects/merge-cca.js +0 -311
  86. package/dist/tools/project/cca_merge.js +0 -174
  87. package/test-harness/fe-draft/app.ts +0 -188
  88. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  89. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  90. package/test-harness/fe-draft/routes.ts +0 -224
  91. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  92. package/test-harness/fe-draft/server.ts +0 -225
  93. package/test-harness/fe-draft/variants.ts +0 -196
@@ -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/* ── 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/* ── The one report (status file, step 2) ─────────────────────────────── */\n/* One page a manager reads in thirty seconds, every lane on it. The coverage\n line is the one ruled band on the page: it says how much of the project the\n check has seen, and it comes before any number, so no count can be read\n without it. Everything else is ink on paper; the levels share one colour,\n because a level is a fact, not a grade. No peach: nothing here is a button. */\n.rp-coverage { margin: 0 0 22px; padding: 10px 0 10px; border-top: 2px solid var(--ink); border-bottom: 1px solid var(--edge); max-width: 78ch; }\n.rp-coverage-line { margin: 0; font-size: 15.5px; font-weight: 600; line-height: 1.45; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }\n.rp-trend { margin: 4px 0 0; font-size: 13.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }\n.rp-headline { margin: 0 0 8px; font-size: clamp(21px, 3vw, 28px); font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; max-width: 34ch; }\n.rp-scope { margin: 0 0 24px; font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.rp-numbers { list-style: none; margin: 0 0 32px; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); border-top: 1px solid var(--edge); border-bottom: 1px solid var(--edge); }\n.rp-numbers li { display: grid; align-content: start; gap: 2px; padding: 14px 14px 14px 0; }\n.rp-numbers li + li { padding-left: 14px; border-left: 1px solid var(--edge); }\n.rp-n { font-size: clamp(28px, 3.6vw, 36px); font-weight: 640; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }\n.rp-label { margin-top: 6px; font-size: 14.5px; font-weight: 620; line-height: 1.25; }\n.rp-sub { font-size: 12.5px; color: var(--ink-3); line-height: 1.35; }\n.rp-band { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px 40px; margin: 0 0 32px; align-items: start; }\n.rp-lvs { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--edge); }\n.rp-lvs li { display: grid; grid-template-columns: minmax(7ch, 9.5rem) 4ch minmax(0, 1fr); gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--edge); }\n.rp-lv { font-weight: 620; }\n.rp-lv-n { text-align: right; font-weight: 620; font-variant-numeric: tabular-nums; }\n.rp-lv-key { font-size: 13px; color: var(--ink-3); line-height: 1.35; }\n.rp-target { margin: 12px 0 0; padding-left: 12px; border-left: 3px solid var(--ink); font-size: 14.5px; max-width: 60ch; }\n.rp-note { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-3); max-width: 60ch; }\n.rp-decide { padding: 14px 18px; border: 1px solid var(--edge); border-left: 3px solid var(--ink); border-radius: 4px; background: var(--paper-2); min-width: 0; }\n.rp-decide ol, .rp-changed ul, .rp-kept-list, .rp-rebuilds ul { list-style: none; margin: 0; padding: 0; }\n.rp-decide li, .rp-changed li, .rp-kept-list li, .rp-rebuilds li { padding: 7px 0; border-top: 1px solid var(--edge); overflow-wrap: anywhere; }\n.rp-decide li:first-child { border-top: 0; padding-top: 0; }\n.rp-decide p, .rp-kept-list p, .rp-changed p, .rp-rebuilds p { margin: 0; font-size: 14.5px; }\n.rp-who, .rp-why { color: var(--ink-3); font-size: 13.5px !important; }\n.rp-tail { margin: 10px 0 0; font-size: 13px; color: var(--ink-3); max-width: 78ch; }\n.rp-changed { margin: 0 0 40px; max-width: 78ch; }\n.rp-detail { margin: 0 0 32px; padding-top: 28px; border-top: 1px solid var(--ink-3); min-width: 0; }\n.rp-devs tbody th { font-weight: 620; color: var(--ink); font-size: 14px; }\n.rp-devs td, .rp-devs th { font-variant-numeric: tabular-nums; }\n.rp-devs td.rp-pk { white-space: normal; min-width: 14ch; font-family: var(--mono); font-size: 12.5px; }\n.rp-kept, .rp-rebuilds { margin: 0 0 32px; max-width: 78ch; }\n.rp-key { color: var(--ink-3); }\n@media (max-width: 720px) {\n .rp-band { grid-template-columns: minmax(0, 1fr); }\n .rp-numbers { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .rp-numbers li, .rp-numbers li + li { padding: 12px 12px 12px 0; border-left: 0; border-top: 1px solid var(--edge); }\n .rp-numbers li:nth-child(even) { padding-left: 12px; border-left: 1px solid var(--edge); }\n .rp-numbers li:nth-child(-n+2) { border-top: 0; }\n .rp-lvs li { grid-template-columns: minmax(7ch, 8rem) 4ch minmax(0, 1fr); }\n}\n@media print {\n .rp-coverage, .rp-decide, .rp-target { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n .rp-first { break-after: page; }\n .rp-detail { border-top: 0; padding-top: 0; }\n .rp-numbers, .rp-band, .rp-decide, .rp-devs tr, .rp-kept-list li { break-inside: avoid; }\n}\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";
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/* A retired object keeps the retired state colour here too, so the chip reads like the map cell it counts. */\n.chip.d-retired { background: var(--s-retired); color: var(--ink-3); }\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/* ── The one report (status file, step 2) ─────────────────────────────── */\n/* One page a manager reads in thirty seconds, every lane on it. The coverage\n line is the one ruled band on the page: it says how much of the project the\n check has seen, and it comes before any number, so no count can be read\n without it. Everything else is ink on paper; the levels share one colour,\n because a level is a fact, not a grade. No peach: nothing here is a button. */\n.rp-coverage { margin: 0 0 22px; padding: 10px 0 10px; border-top: 2px solid var(--ink); border-bottom: 1px solid var(--edge); max-width: 78ch; }\n.rp-coverage-line { margin: 0; font-size: 15.5px; font-weight: 600; line-height: 1.45; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }\n.rp-trend { margin: 4px 0 0; font-size: 13.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }\n.rp-headline { margin: 0 0 8px; font-size: clamp(21px, 3vw, 28px); font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; max-width: 34ch; }\n.rp-scope { margin: 0 0 24px; font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.rp-numbers { list-style: none; margin: 0 0 32px; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); border-top: 1px solid var(--edge); border-bottom: 1px solid var(--edge); }\n.rp-numbers li { display: grid; align-content: start; gap: 2px; padding: 14px 14px 14px 0; }\n.rp-numbers li + li { padding-left: 14px; border-left: 1px solid var(--edge); }\n.rp-n { font-size: clamp(28px, 3.6vw, 36px); font-weight: 640; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }\n.rp-label { margin-top: 6px; font-size: 14.5px; font-weight: 620; line-height: 1.25; }\n.rp-sub { font-size: 12.5px; color: var(--ink-3); line-height: 1.35; }\n.rp-band { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px 40px; margin: 0 0 32px; align-items: start; }\n.rp-lvs { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--edge); }\n.rp-lvs li { display: grid; grid-template-columns: minmax(7ch, 9.5rem) 4ch minmax(0, 1fr); gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--edge); }\n.rp-lv { font-weight: 620; }\n.rp-lv-n { text-align: right; font-weight: 620; font-variant-numeric: tabular-nums; }\n.rp-lv-key { font-size: 13px; color: var(--ink-3); line-height: 1.35; }\n.rp-target { margin: 12px 0 0; padding-left: 12px; border-left: 3px solid var(--ink); font-size: 14.5px; max-width: 60ch; }\n.rp-note { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-3); max-width: 60ch; }\n.rp-decide { padding: 14px 18px; border: 1px solid var(--edge); border-left: 3px solid var(--ink); border-radius: 4px; background: var(--paper-2); min-width: 0; }\n.rp-decide ol, .rp-changed ul, .rp-kept-list, .rp-rebuilds ul { list-style: none; margin: 0; padding: 0; }\n.rp-decide li, .rp-changed li, .rp-kept-list li, .rp-rebuilds li { padding: 7px 0; border-top: 1px solid var(--edge); overflow-wrap: anywhere; }\n.rp-decide li:first-child { border-top: 0; padding-top: 0; }\n.rp-decide p, .rp-kept-list p, .rp-changed p, .rp-rebuilds p { margin: 0; font-size: 14.5px; }\n.rp-who, .rp-why { color: var(--ink-3); font-size: 13.5px !important; }\n.rp-tail { margin: 10px 0 0; font-size: 13px; color: var(--ink-3); max-width: 78ch; }\n.rp-changed { margin: 0 0 40px; max-width: 78ch; }\n.rp-detail { margin: 0 0 32px; padding-top: 28px; border-top: 1px solid var(--ink-3); min-width: 0; }\n.rp-devs tbody th { font-weight: 620; color: var(--ink); font-size: 14px; }\n.rp-devs td, .rp-devs th { font-variant-numeric: tabular-nums; }\n.rp-devs td.rp-pk { white-space: normal; min-width: 14ch; font-family: var(--mono); font-size: 12.5px; }\n.rp-kept, .rp-rebuilds { margin: 0 0 32px; max-width: 78ch; }\n.rp-key { color: var(--ink-3); }\n@media (max-width: 720px) {\n .rp-band { grid-template-columns: minmax(0, 1fr); }\n .rp-numbers { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .rp-numbers li, .rp-numbers li + li { padding: 12px 12px 12px 0; border-left: 0; border-top: 1px solid var(--edge); }\n .rp-numbers li:nth-child(even) { padding-left: 12px; border-left: 1px solid var(--edge); }\n .rp-numbers li:nth-child(-n+2) { border-top: 0; }\n .rp-lvs li { grid-template-columns: minmax(7ch, 8rem) 4ch minmax(0, 1fr); }\n}\n@media print {\n .rp-coverage, .rp-decide, .rp-target { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n .rp-first { break-after: page; }\n .rp-detail { border-top: 0; padding-top: 0; }\n .rp-numbers, .rp-band, .rp-decide, .rp-devs tr, .rp-kept-list li { break-inside: avoid; }\n}\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";
@@ -3,6 +3,7 @@
3
3
  * the viewer's own code (copied into ./status-file/ and pinned by a test).
4
4
  */
5
5
  import fs from 'node:fs';
6
+ import { projectView } from '@cspeach/register-core';
6
7
  import { buildStatusHtml as buildStatusHtmlUntyped } from './status-file/register-status-file.js';
7
8
  import { needsYou as needsYouUntyped } from './status-file/register-model.js';
8
9
  import { TEAM_CSS } from './status-file/team-css.js';
@@ -13,14 +14,20 @@ import { asOfFromStatusHtml, reportView as reportViewUntyped } from './status-fi
13
14
  const needsYou = needsYouUntyped;
14
15
  // The same for the page's own options: their `= []` / `= null` defaults read as "always empty".
15
16
  const buildStatusHtml = buildStatusHtmlUntyped;
16
- /** The one report's model, typed for the Node callers (team status, the workbook). */
17
+ /**
18
+ * The one report's model, typed for the Node callers (team status, the
19
+ * workbook). It reads the project view, which the caller builds once.
20
+ */
17
21
  export const reportViewFor = reportViewUntyped;
22
+ /** The project view of a register and its project file, the one place the Node report callers build it. */
23
+ export const projectViewFor = (register, project) => projectView(register, project);
18
24
  export function statusHtmlFor(view) {
19
- const { register, unreadable, asOf, ...rest } = view;
25
+ const { register, unreadable, asOf, projectView: given, ...rest } = view;
26
+ const pv = given ?? projectViewFor(register, view.project);
20
27
  return buildStatusHtml({
21
28
  ...rest, register, unreadable, asOf,
22
- needs: needsYou(register, { now: asOf, unreadable, surface: 'file' }),
23
- css: TEAM_CSS,
29
+ needs: needsYou(register, { now: asOf, unreadable, surface: 'file', view: pv }),
30
+ css: TEAM_CSS, projectView: pv,
24
31
  });
25
32
  }
26
33
  /**
@@ -122,8 +122,8 @@ function readTextOrSay(file, missing) {
122
122
  throw new RegisterError(`${file} could not be read. It may still be syncing.`);
123
123
  }
124
124
  }
125
- export function readBaseline(projectDir) {
126
- const project = readProjectFile(projectDir);
125
+ /** `project`: the folder's project file when the caller has already read it (one read, one answer). */
126
+ export function readBaseline(projectDir, project = readProjectFile(projectDir)) {
127
127
  const file = path.join(projectDir, project.baseline?.file ?? BASELINE_FILE);
128
128
  const text = readTextOrSay(file, `The baseline file is missing: ${file}`);
129
129
  if (project.baseline?.sha256 && sha256(text) !== project.baseline.sha256) {
@@ -10,8 +10,9 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { utcWords, workingListRows, WORKING_LIST_COLUMNS } from '@cspeach/register-core';
12
12
  import { registerFor } from './picture.js';
13
- import { BUSINESS_COLUMNS, HISTORY_COLUMNS, OBJECTS_COLUMNS, businessSheetRows, historySheetRows, objectsSheetRows, summarySheetRows, } from './status-file/report-model.js';
14
- import { reportViewFor } from './status-html.js';
13
+ import { readProjectFile } from './store.js';
14
+ import { BUSINESS_COLUMNS, HISTORY_COLUMNS, businessSheetRows, historySheetRows, objectsSheetColumns, objectsSheetRows, summarySheetRows, } from './status-file/report-model.js';
15
+ import { projectViewFor, reportViewFor } from './status-html.js';
15
16
  import { safeFileStem, writeXlsxFile } from './xlsx.js';
16
17
  const LIST_WIDTHS = [28, 6, 16, 6, 8, 22, 48, 26, 10, 10, 20, 18, 20, 22];
17
18
  const PLAN_WIDTHS = [28, 6, 16, 22, 9, 9, 12, 40, 3, 10, 18, 20, 7, 22, 20, 10];
@@ -24,9 +25,10 @@ export const reportFileNames = (projectName) => ({
24
25
  });
25
26
  const readMe = (lines) => ({ name: 'Read me', header: ['Read me'], rows: lines.map((l) => [l]), widths: [110], filter: false });
26
27
  const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
27
- export function workingListSheets(register, laneIds, who, asOf, folderName) {
28
+ /** `view` is the project view of `register` (Task 3): the rows read the project's targets through it. */
29
+ export function workingListSheets(register, laneIds, who, asOf, folderName, view) {
28
30
  return [
29
- { name: 'Working list', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, laneIds), widths: LIST_WIDTHS },
31
+ { name: 'Working list', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, laneIds, view), widths: LIST_WIDTHS },
30
32
  readMe([
31
33
  `Made by CSPeach for ${who} on ${utcWords(asOf)}, from the team project folder "${folderName}".`,
32
34
  'This file is made again after every decision, fix or measurement this laptop records. Changes typed here are not read back.',
@@ -43,17 +45,22 @@ export function workingListSheets(register, laneIds, who, asOf, folderName) {
43
45
  * status page prints, so a number in Excel and on the page cannot differ.
44
46
  * Objects carries the Plan sheet's columns, so `plan import` reads it; the
45
47
  * export stamp sits on the Summary sheet.
48
+ *
49
+ * The project view is built once (here, or by the caller that also writes the
50
+ * status file) and handed to every sheet.
46
51
  */
47
- export function combinedSheets(register, asOf, folderName, project) {
48
- const view = reportViewFor({ project, register, asOf });
52
+ export function combinedSheets(register, asOf, folderName, project, pv = projectViewFor(register, project)) {
53
+ const view = reportViewFor({ view: pv, register, asOf });
49
54
  const summary = summarySheetRows(view, { madeFor: `Made by CSPeach on ${utcWords(asOf)}, from the team project folder "${folderName}".` });
50
55
  const stampAt = summary.findIndex((r) => r[0] === 'As of') + 1;
51
56
  summary.splice(stampAt, 0, [`${EXPORT_STAMP}${asOf}`, '']);
57
+ const objectsHeader = objectsSheetColumns(pv);
58
+ const extra = objectsHeader.length - PLAN_WIDTHS.length;
52
59
  return [
53
- { name: 'Summary', header: ['Summary', 'Count'], rows: summary, widths: [64, 18, 10, 10, 12, 18, 8, 10, 10, 12], filter: false },
54
- { name: 'Objects', header: [...OBJECTS_COLUMNS], rows: objectsSheetRows(register, view.coverage.variant), widths: [...PLAN_WIDTHS, 10, 40, 20, 24] },
55
- { name: 'For the business', header: [...BUSINESS_COLUMNS], rows: businessSheetRows(register), widths: [28, 6, 16, 20, 24, 40, 30, 12, 9, 22, 12] },
56
- { name: 'History', header: [...HISTORY_COLUMNS], rows: historySheetRows(register), widths: [20, 20, 40, 28, 40] },
60
+ { name: 'Summary', header: ['Summary', 'Count'], rows: summary, widths: [64, 18, 10, 10, 10, 10, 12, 18, 8, 10, 10, 12], filter: false },
61
+ { name: 'Objects', header: objectsHeader, rows: objectsSheetRows(register, pv, view.coverage.variant), widths: [...PLAN_WIDTHS, ...[10, 40, 20, 24, 24, 24, 20].slice(0, extra)] },
62
+ { name: 'For the business', header: [...BUSINESS_COLUMNS], rows: businessSheetRows(register, pv), widths: [28, 6, 16, 20, 24, 40, 30, 12, 9, 22, 12] },
63
+ { name: 'History', header: [...HISTORY_COLUMNS], rows: historySheetRows(register, pv), widths: [20, 20, 40, 28, 40] },
57
64
  ];
58
65
  }
59
66
  export const HELD = new Set(['EBUSY', 'EPERM', 'EACCES', 'EISDIR', 'ENOTEMPTY']);
@@ -62,16 +69,20 @@ export const HELD = new Set(['EBUSY', 'EPERM', 'EACCES', 'EISDIR', 'ENOTEMPTY'])
62
69
  * (team project folder + own outbox). Never throws: a record is written whether
63
70
  * or not its workbook could be, and the person is told in one line.
64
71
  */
65
- export function regenerateWorkingList(joined, who, now = new Date()) {
72
+ export function regenerateWorkingList(joined, who, now = new Date(),
73
+ /** The register and view the caller already read for this folder (and outbox); absent: read here. */
74
+ read) {
66
75
  try {
67
- const { register } = registerFor(joined.projectDir, joined.outboxDir);
76
+ const register = read?.register ?? registerFor(joined.projectDir, joined.outboxDir).register;
68
77
  const mine = register.lanes.filter((l) => sameName(l.owner, who)).map((l) => l.id);
69
78
  if (!mine.length)
70
79
  return { file: null, line: null, ownsLane: false, kind: 'none' };
71
80
  const name = workingListFileName(who);
72
81
  const file = path.join(joined.outboxDir, 'writers', joined.writerId, name);
82
+ // The view of the project (its targets included), built once for the whole list.
83
+ const view = read?.view ?? projectViewFor(register, readProjectFile(joined.projectDir));
73
84
  try {
74
- writeXlsxFile(file, workingListSheets(register, mine, who, now.toISOString(), path.basename(joined.projectDir)));
85
+ writeXlsxFile(file, workingListSheets(register, mine, who, now.toISOString(), path.basename(joined.projectDir), view));
75
86
  }
76
87
  catch (e) {
77
88
  const code = e?.code ?? '';
@@ -10,6 +10,7 @@ import { sanitizeControlChars } from './sanitize.js';
10
10
  import { formatCost } from '../cost/pricing.js';
11
11
  import { formatCredits, CREDITS_PENDING, creditsActive, tokensOnlyActive } from '../cost/credits.js';
12
12
  import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
13
+ import { projectRequestLabel } from '../register/project-transport.js';
13
14
  export const SHORTCUTS_HINT = '? shortcuts';
14
15
  const SEP = ' · ';
15
16
  /**
@@ -31,12 +32,19 @@ export function formatSpendSegment(s) {
31
32
  * Store → footer facts. F-L2-1: the transport falls back to the one a write
32
33
  * actually stamped; UI fix round (1.1.20): then to one a transport lookup
33
34
  * confirmed. A transport the user only typed is never shown.
35
+ *
36
+ * Task 6 (project view, R8; fix round 1 ruling): `projectRequest` is the
37
+ * request of the project joined in this folder (`projectTransport(cwd)`).
38
+ * When it is set the footer reads "REQ (project)" — every write that names no
39
+ * request lands there, ahead of the session request. Absent or null (no
40
+ * project in the folder): today's facts, key for key.
34
41
  */
35
- export function footerStateFrom(s) {
42
+ export function footerStateFrom(s, projectRequest) {
43
+ const project = projectRequest ? projectRequestLabel(projectRequest) : null;
36
44
  return {
37
45
  system: s.system || undefined,
38
46
  user: s.user || undefined,
39
- transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
47
+ transport: (project ?? s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
40
48
  standalone: s.standalone === true,
41
49
  chat: s.chatLane === true,
42
50
  mode: s.writeMode ?? '',
@@ -166,6 +166,7 @@ const RULES = {
166
166
  team_findings: (a) => ({ verb: 'Read team findings', object: str(a['lane']) ?? str(a['packages']) ?? 'all objects' }),
167
167
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
168
168
  team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
169
+ team_lane_move: (a) => ({ verb: 'Move packages to another person', object: shortRight(Array.isArray(a['packages']) ? a['packages'].map(String).join(', ') : 'packages') }),
169
170
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
170
171
  sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
171
172
  sap_enhancement_options: (a) => ({ verb: 'Read enhancement options', object: shortRight([str(a['type']), str(a['name'])].filter(Boolean).join(' ')) }),
@@ -206,7 +207,6 @@ const RULES = {
206
207
  project_context_get: () => ({ verb: 'Read project context', object: '' }),
207
208
  playbook_get: (a) => ({ verb: 'Read playbook', object: shortRight(str(a['name']) ?? '') }),
208
209
  convention_get: (a) => ({ verb: 'Read convention', object: shortRight(str(a['field']) ?? '') }),
209
- cca_merge: (a) => ({ verb: 'Merged CCA', object: fileCount(a['inputs']) }),
210
210
  upgrade_merge_progress: (a) => ({ verb: 'Merged progress', object: fileCount(a['inputs']) }),
211
211
  monitor_emit: (a) => ({ verb: 'Checked background', object: shortRight(str(a['bg_id']) ?? '') }),
212
212
  agent_run: (a) => ({ verb: 'Subagent', object: shortRight(str(a['skill']) ?? '') }),
@@ -286,7 +286,7 @@ const PRESENT = {
286
286
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
287
287
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
288
288
  'Checked capability': 'Checking capability',
289
- 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Check a proposed decision rule': 'Checking a proposed decision rule', 'Read estate inventory': 'Reading estate inventory', 'Wrote texts': 'Writing texts',
289
+ 'Read texts of': 'Reading texts of', 'Read team findings': 'Reading team findings', 'Check a proposed decision rule': 'Checking a proposed decision rule', 'Read estate inventory': 'Reading estate inventory', 'Move packages to another person': 'Moving packages to another person', 'Wrote texts': 'Writing texts',
290
290
  };
291
291
  export function toolActivityLabel(name, args) {
292
292
  const l = toolLabel(name, args);
@@ -47,7 +47,7 @@ export const BUILTIN_ENTRIES = [
47
47
  { name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
48
48
  { name: '/ui', description: 'View or change rendering mode (auto / ink), or the look: /ui look panels|legacy' },
49
49
  { name: '/style', description: 'View or change the talking style: professional (default), conversational, humor or blunt' },
50
- { name: '/level', description: 'Who CSPeach is talking to: developer (default), learning or functional' },
50
+ { name: '/level', description: 'How CSPeach explains things: developer (default), learning or functional' },
51
51
  { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
52
52
  { name: '/transport', description: 'View or set the active transport for this session' },
53
53
  { name: '/rules', description: 'View or change the decision rules of the joined team project (the lead changes them)' },
@@ -0,0 +1,5 @@
1
+ export function continueSkillFor(opts) {
2
+ if (opts.lastTurnBy === 'journey')
3
+ return null;
4
+ return opts.sessionSkill ?? null;
5
+ }
@@ -7,22 +7,13 @@ import { getGlyphs } from '../renderer/glyphs.js';
7
7
  export const LEVEL_DESCRIPTIONS = Object.freeze({
8
8
  developer: "Talks to a developer — today's behaviour (default)",
9
9
  learning: 'Explains the why and the terms — for a developer still building experience',
10
- functional: 'Business first, code only when asked — hands code changes to a developer as a request file',
10
+ functional: 'Business terms first, code only when asked',
11
11
  });
12
- /** D15 — the recommended set-up for a non-developer, said once, never enforced.
13
- * /level is saved but /mode is session-only, so the tip leads with the saved
14
- * write_mode setting (the pairing would otherwise lapse next launch). */
15
- export const FUNCTIONAL_WRITE_MODE_TIP = 'For someone who should not change SAP, also run: cspeach config set write_mode advisory-only (or /mode advisory for this session)';
16
- /** D-L — one line: the confirmation, plus the D15 tip only when it applies. */
17
- export function levelConfirmation(level, writeMode) {
18
- const base = `Level set to ${level}`;
19
- return level === 'functional' && writeMode !== 'advisory-only' ? `${base} · ${FUNCTIONAL_WRITE_MODE_TIP}` : `${base}.`;
12
+ /** D-L — one plain line, the same for every level (owner decision 9: no write_mode tip). */
13
+ export function levelConfirmation(level) {
14
+ return `Level set to ${level}.`;
20
15
  }
21
- /**
22
- * `writeMode` is the mode in force now (the REPL passes the effective mode, so
23
- * a `/mode advisory` earlier in the session counts); it defaults to the config.
24
- */
25
- export async function handleLevelCommand(arg, cfg, save, emit, pick, writeMode = cfg.write_mode) {
16
+ export async function handleLevelCommand(arg, cfg, save, emit, pick) {
26
17
  const raw = arg.trim();
27
18
  let next;
28
19
  if (raw === '') {
@@ -41,7 +32,7 @@ export async function handleLevelCommand(arg, cfg, save, emit, pick, writeMode =
41
32
  setAssistantLevel(next);
42
33
  try {
43
34
  await save(cfg);
44
- emit(`${paint('ok', getGlyphs().done)} ${levelConfirmation(next, writeMode)}`);
35
+ emit(`${paint('ok', getGlyphs().done)} ${levelConfirmation(next)}`);
45
36
  }
46
37
  catch (err) {
47
38
  emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
package/dist/repl.js CHANGED
@@ -27,6 +27,7 @@ import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-stand
27
27
  import { clearAppFlow } from './lane/app-flow.js';
28
28
  import { clearJourneyFlow } from './journey/flow.js';
29
29
  import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
30
+ import { continueSkillFor } from './repl/continue-skill.js';
30
31
  import { applyResumedSkill } from './router/resume-routing.js';
31
32
  import { ACK_PHRASES, wantsContinuation, isConversationalAck, decideLane, turnStatusLabel, applyLaneTools, chatFallbackOrigin } from './repl/chat-lane.js';
32
33
  import { CHAT_SKILL } from './agent/chat-prompt.js';
@@ -50,6 +51,8 @@ import { getConnection } from './sap/connection-manager.js';
50
51
  import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
51
52
  import { AdtClient, snapshots } from '@cspeach/sap-client';
52
53
  import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
54
+ import { startFreshConversation, freshStartText } from './session/fresh-start.js';
55
+ import { planContinuationMarker } from './commands/plan-continue-gate.js';
53
56
  import { newSession } from './session/schema.js';
54
57
  import { resolveModelRole } from './models/resolve.js';
55
58
  import { familyOf, planTierModelFor, getActiveDeployments } from './models/model-identity.js';
@@ -193,11 +196,11 @@ import './tools/web/web_search.js';
193
196
  import './tools/project/project_context_get.js';
194
197
  import './tools/project/playbook_get.js';
195
198
  import './tools/project/convention_get.js';
196
- import './tools/project/cca_merge.js';
197
199
  import './tools/project/upgrade_merge_progress.js';
198
200
  import './tools/project/team_findings.js';
199
201
  import './tools/project/team_estate.js';
200
202
  import './tools/project/rules_propose.js';
203
+ import './tools/project/team_lane_move.js';
201
204
  import './tools/subagent/_background-shared.js';
202
205
  import './tools/subagent/background_run.js';
203
206
  import './tools/subagent/monitor_emit.js';
@@ -969,6 +972,11 @@ export async function runRepl(opts) {
969
972
  // notice). Anything else in the slot (the /abap-app start, a skill switch)
970
973
  // is not a hop. Shared by the Ink and readline branches like the slot.
971
974
  const planHops = createPlanHopTracker(pendingDispatchAccessor);
975
+ // Task 3 (2026-10-20) — who started the skill turn that ran last: the person,
976
+ // or the CSPeach source that queued it. Set just before each runTurn (both
977
+ // branches), read by the picker's "continue" preselect (continueSkillFor):
978
+ // a turn the journey queued is never offered as the person's next default.
979
+ let lastTurnBy = 'person';
972
980
  // v0.3: Ink-mode REPL branch. When running under a real TTY in REPL mode
973
981
  // (not one-shot, not CI), mount the <App /> frame instead of the readline
974
982
  // loop. The non-Ink path below is preserved byte-for-byte for piped/CI
@@ -1294,13 +1302,12 @@ export async function runRepl(opts) {
1294
1302
  return;
1295
1303
  }
1296
1304
  // /level [value] — who CSPeach is talking to (spec 2026-10-17 §10 D13); saved like /style.
1297
- // The D15 tip reads the write mode in force now (a /mode change this session counts).
1305
+ // The level only changes how CSPeach explains (owner decision 9); it never reads the write mode.
1298
1306
  if (trimmed === '/level' || trimmed.startsWith('/level ')) {
1299
1307
  if (!cfg.assistant)
1300
1308
  cfg.assistant = { ...DEFAULT_CONFIG.assistant };
1301
1309
  const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
1302
- const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
1303
- await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, emit, pickLevelInk, writeModeNow);
1310
+ await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, emit, pickLevelInk);
1304
1311
  return;
1305
1312
  }
1306
1313
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -1332,6 +1339,7 @@ export async function runRepl(opts) {
1332
1339
  if (pendingCancel.applyTo(rerouteAbort))
1333
1340
  interruptCount = 1;
1334
1341
  turnStatusEmitter.start(`/${rr.skill}`);
1342
+ lastTurnBy = submitOpts?.queued?.by ?? 'person'; // Task 3
1335
1343
  // A turn error is rendered into the conversation like the normal turn
1336
1344
  // path (never an unhandled rejection out of `void handleSubmit`).
1337
1345
  await runRerouteTurn({
@@ -1370,7 +1378,11 @@ export async function runRepl(opts) {
1370
1378
  todoEmitter.emit('update', []);
1371
1379
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1372
1380
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1373
- chunkEmitter.emit('chunk', '\n' + (laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')));
1381
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1382
+ startFreshConversation(session);
1383
+ idleKeepalive.disarm(); // no idle cache ping for the old history
1384
+ await saveSession(session);
1385
+ chunkEmitter.emit('chunk', '\n' + formatInfo(freshStartText(laneSetting)));
1374
1386
  return;
1375
1387
  }
1376
1388
  // /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
@@ -1640,7 +1652,8 @@ export async function runRepl(opts) {
1640
1652
  // default cursor to it (one-Enter "continue" UX for follow-up
1641
1653
  // questions). session.skill = immediately-previous turn's
1642
1654
  // skill, NOT session.last_skill which is from TWO turns ago.
1643
- const continueSkill = session.skill ?? null;
1655
+ // Task 3 — none after a turn the journey queued.
1656
+ const continueSkill = continueSkillFor({ sessionSkill: session.skill, lastTurnBy });
1644
1657
  let pickedResult;
1645
1658
  if (cfg.classifier.safe_mode) {
1646
1659
  pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
@@ -1918,8 +1931,8 @@ export async function runRepl(opts) {
1918
1931
  // prompt is NOT replaced and the context is NOT reset — "continue"
1919
1932
  // needs the accumulated transcript.
1920
1933
  let planContinue = null;
1921
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
1922
- const marker = session.plan_in_flight;
1934
+ const marker = planContinuationMarker(session, skill, planResume);
1935
+ if (marker) {
1923
1936
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
1924
1937
  const r = await preparePlanContinuation({
1925
1938
  marker,
@@ -2023,6 +2036,7 @@ export async function runRepl(opts) {
2023
2036
  }
2024
2037
  // 2026-10-04 — the phase's expert skill rides along in the system prompt.
2025
2038
  const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
2039
+ lastTurnBy = submitOpts?.queued?.by ?? 'person'; // Task 3
2026
2040
  await runTurn({
2027
2041
  provider,
2028
2042
  userMessage: prompt,
@@ -3011,6 +3025,7 @@ export async function runRepl(opts) {
3011
3025
  }
3012
3026
  // Task 13 — classic in-run hop flag: set by the loop-top consumer from planHops.take().
3013
3027
  let classicPlanHop = false;
3028
+ let classicLineBy = 'person';
3014
3029
  while (true) {
3015
3030
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
3016
3031
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -3025,6 +3040,8 @@ export async function runRepl(opts) {
3025
3040
  // immediately so a stale value never re-fires.
3026
3041
  classicPlanHop = false; // a typed line is never an in-run hop
3027
3042
  const classicQueued = pendingDispatchAccessor.take();
3043
+ // Task 3 — who started this line: the CSPeach source that queued it, else the person.
3044
+ classicLineBy = classicQueued?.origin?.by ?? 'person';
3028
3045
  if (classicQueued) {
3029
3046
  const dispatchedCmd = classicQueued.cmd;
3030
3047
  // No context clear here: beginPlanPhaseContext (plan-boundary.ts,
@@ -3334,8 +3351,7 @@ export async function runRepl(opts) {
3334
3351
  if (trimmed === '/level' || trimmed.startsWith('/level ')) {
3335
3352
  if (!cfg.assistant)
3336
3353
  cfg.assistant = { ...DEFAULT_CONFIG.assistant };
3337
- const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
3338
- await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, (s) => console.log(s), pickLevelClassic, writeModeNow);
3354
+ await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, (s) => console.log(s), pickLevelClassic);
3339
3355
  continue;
3340
3356
  }
3341
3357
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -3359,6 +3375,7 @@ export async function runRepl(opts) {
3359
3375
  if (g.line)
3360
3376
  console.log(formatInfo(g.line).trimEnd());
3361
3377
  }
3378
+ lastTurnBy = classicLineBy; // Task 3
3362
3379
  // A turn error prints the same stderr block as the normal turn path.
3363
3380
  await runRerouteTurn({
3364
3381
  run: () => runTurn({
@@ -3386,7 +3403,11 @@ export async function runRepl(opts) {
3386
3403
  session.todos = []; // P2 — /new clears the plan (spec §4)
3387
3404
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3388
3405
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
3389
- console.log((laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')).trimEnd());
3406
+ // 2026-10-22 — and the conversation history: the next prompt sends none of it.
3407
+ startFreshConversation(session);
3408
+ idleKeepalive.disarm(); // no idle cache ping for the old history
3409
+ await saveSession(session);
3410
+ console.log(formatInfo(freshStartText(laneSetting)).trimEnd());
3390
3411
  continue;
3391
3412
  }
3392
3413
  // /chat [on|off] — classic mirror of the Ink handler above.
@@ -3721,7 +3742,8 @@ export async function runRepl(opts) {
3721
3742
  // 2026-05-17 — pass the just-used skill so the picker can
3722
3743
  // default cursor to it. session.skill = immediately-previous
3723
3744
  // turn's skill, NOT session.last_skill (TWO turns ago).
3724
- const continueSkillClassic = session.skill ?? null;
3745
+ // Task 3 — none after a turn the journey queued.
3746
+ const continueSkillClassic = continueSkillFor({ sessionSkill: session.skill, lastTurnBy });
3725
3747
  let pickedResult;
3726
3748
  // Coaching picker path — inquirer takes over stdin. Use the shared
3727
3749
  // withInquirer guard so the suppress-close window stays active
@@ -3978,8 +4000,8 @@ export async function runRepl(opts) {
3978
4000
  // of the Ink branch; see there for the full design note: mutually
3979
4001
  // exclusive with --resume, prompt NOT replaced, context NOT reset).
3980
4002
  let planContinue = null;
3981
- if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
3982
- const marker = session.plan_in_flight;
4003
+ const marker = planContinuationMarker(session, skill, planResume);
4004
+ if (marker) {
3983
4005
  const { preparePlanContinuation } = await import('./commands/plan-continue.js');
3984
4006
  const r = await preparePlanContinuation({
3985
4007
  marker,
@@ -4097,6 +4119,7 @@ export async function runRepl(opts) {
4097
4119
  }
4098
4120
  // 2026-10-04 — the phase's expert skill rides along in the system prompt.
4099
4121
  const planPhaseDelegate = planPhaseCtx?.nextDelegateTo;
4122
+ lastTurnBy = classicLineBy; // Task 3
4100
4123
  await runTurn({
4101
4124
  provider,
4102
4125
  userMessage: prompt,
@@ -37,8 +37,10 @@ export const ROUTER_SCHEMA_VERSION = 1;
37
37
  * CLI back to older routing. router-revision-pin.test.ts fails until bumped.
38
38
  */
39
39
  // 3 (2026-10-09): runs_system (shadow-only text) added; live text unchanged.
40
- export const ROUTER_REVISION = 3;
41
- // 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
40
+ // 4 (2026-10-20): abap-cca-merge cut (drift spec §3.6); the CLI aliases the old name to abap-cca.
41
+ export const ROUTER_REVISION = 4;
42
+ // 2026-06-12: keep in lockstep with .claude/skills (35 abap skills then; 2026-10-20: 32 listed
43
+ // here; abap-clean-core joins with runs, after the 2026-06-28 merges and the abap-cca-merge cut).
42
44
  // abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
43
45
  // added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
44
46
  // 2026-06-28 (M1-M4 portfolio merges): removed 4 donor skills — abap-radar → abap-explain
@@ -55,12 +57,12 @@ export const ROUTER_REVISION = 3;
55
57
  // fell back to abap-generate at low confidence.
56
58
  // 2026-10-01: added abap-app (app lane routing slice 1) — one entry from a spec
57
59
  // document to a running app; the CAP routing note below is unchanged.
60
+ // 2026-10-20: removed abap-cca-merge (drift spec §3.6).
58
61
  export const SKILL_DESCRIPTIONS = {
59
62
  'abap-app': 'Build an app FROM a spec or requirements document (Word, PDF, Excel or text) — finds the spec gaps, picks the app lane (backend + UI) and confirms it with the developer, then plans and builds the app.',
60
63
  'abap-atc-fix': 'Run ATC checks on an object and auto-fix findings where safe.',
61
64
  'abap-cap': 'Build a CAP (SAP Cloud Application Programming Model, Node.js / CDS) app on the laptop — domain model, service, handlers, Fiori Elements app — run it locally and prove it with the Fiori checks. Side-by-side (BTP) extensions, not the ABAP core.',
62
65
  'abap-cca': 'Custom Code Analysis — inventory + classify a whole custom-code estate (Keep/Fix/Retire) for S/4HANA migration / Clean Core.',
63
- 'abap-cca-merge': 'Merge multiple parallel CCA assessment files into one consolidated assessment.',
64
66
  'abap-data-model': 'Design and create DDIC objects — domains, data elements, tables, structures.',
65
67
  'abap-design': 'Design solution architecture — object list, dependency sequence, pattern selection. After spec-gap, before coding.',
66
68
  'abap-dump': 'Diagnose ABAP short dumps (ST22 runtime errors) and fix them.',