@cspeach/cli 1.1.30 → 1.1.32

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 (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
package/README.md CHANGED
@@ -77,6 +77,14 @@ session abc123 │ S4H │ /abap-explain │ 2 turns │ 487 tok │ $0.07 │ s
77
77
 
78
78
  On `/exit` you get a session summary + the `cspeach --resume <id>` command in plain text so you can pick up where you left off.
79
79
 
80
+ To pick up an earlier session:
81
+
82
+ - `cspeach --continue` (or `-c`) — continue the latest session in this folder
83
+ - `cspeach --resume` — pick a session from this folder, or all folders
84
+ - `cspeach --resume <id>` — works from any folder
85
+
86
+ Files always save in the folder you start CSPeach in.
87
+
80
88
  ## UI modes
81
89
 
82
90
  An interactive terminal gets the standard display (the Ink frame). `/ui auto` (the default) picks it for you; `/ui ink` asks for it explicitly. Both save to `~/.cspeach/config.toml` under `[ui]` and apply after a relaunch.
@@ -111,7 +119,9 @@ cspeach whoami show signed-in identity + plan/trial
111
119
  cspeach logout clear local auth
112
120
  cspeach config add-sap add a SAP system
113
121
  cspeach doctor diagnose install + connectivity
114
- cspeach --resume resume a prior session
122
+ cspeach --continue, -c continue the latest session in this folder
123
+ cspeach --resume pick a session from this folder, or all folders
124
+ cspeach --resume <id> works from any folder
115
125
  cspeach --upgrade pull the latest CLI release
116
126
  cspeach --version
117
127
  ```
@@ -3,6 +3,7 @@ import { basename } from 'node:path';
3
3
  import { dispatchTool } from './tool-dispatch.js';
4
4
  import { listTools, getTool, isStandalone, toolsForContext, toAnthropicTools } from '../tools/index.js';
5
5
  import { saveSession } from '../session/store.js';
6
+ import { tagSessionOnSave } from '../session/project-match.js';
6
7
  import { recordCompletedToolCall } from '../session/pending.js';
7
8
  import { loadConfig } from '../config/loader.js';
8
9
  import { resolveModelRole, resolveEffort, modelAcceptsEffort, resolveAutoCompactThreshold, callModelFor } from '../models/resolve.js';
@@ -17,7 +18,7 @@ import { nextWritingActivity } from '../renderer/fence-state.js';
17
18
  import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner, isWidgetSuppressedTool, toolActivityText, toolHeartbeatLabel } from '../renderer/tool-widget.js';
18
19
  import { formatToolErrorSummary } from '../sap-errors/parse-adt-exception.js';
19
20
  import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
20
- import { isDeclaredOneShot } from '../renderer/tty.js';
21
+ import { isDeclaredOneShot, isHeadless } from '../renderer/tty.js';
21
22
  import { USER_STOP_REASON } from '../session/interrupt-reason.js';
22
23
  import { writeStdout, stdoutFlow } from '../renderer/transcript-flow.js';
23
24
  import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
@@ -40,6 +41,9 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
40
41
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
41
42
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
42
43
  import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
44
+ import { afterRebuildTurn } from '../journey/rebuild.js';
45
+ import { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyStoppedOnError, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
46
+ import { addJourneyLeftOut, addJourneySkips, getJourneyFlow } from '../journey/flow.js';
43
47
  import { afterPlanSave, afterSpecGapNotSaved, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
44
48
  import { shownToken } from '../lane/card.js';
45
49
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
@@ -68,6 +72,7 @@ import { CacheKeepalive, snapshotParams, recordLastRequest, isCacheablePrompt, n
68
72
  import { maybeColdPrune } from './cold-prune.js';
69
73
  import { createAdtLock, serializeAdt } from '../tools/subagent/adt-serial.js';
70
74
  import { hasReaderReadTools, INTERRUPTED_LINE } from '../tools/subagent/reader-prompt.js';
75
+ import { openStatusPage } from '../util/open-in-browser.js';
71
76
  /**
72
77
  * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
73
78
  * then platform USER/USERNAME, then a generic fallback. Role is fixed to
@@ -98,11 +103,11 @@ const SAVE_HOOK_SKILLS = new Set([
98
103
  'abap-upgrade-fix',
99
104
  'abap-upgrade-verify',
100
105
  'abap-upgrade-merge',
101
- // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca emits a
102
- // <!-- csforge:cca-manifest --> block wrapping the heavy project.json
103
- // detail at .cspeach/cca/projects/<slug>/. /abap-cca-merge produces
106
+ // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca-merge produces
104
107
  // a consolidated cca-assessment from N parallel consultant slices.
105
- 'abap-cca',
108
+ // 2026-10-06: the thin /abap-cca (1.1.31) reads the estate inventory and
109
+ // writes no envelope, so it is no longer a save-hook skill. An old
110
+ // cca-manifest in a turn is still offered through detectArtifactSkill.
106
111
  'abap-cca-merge',
107
112
  // 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
108
113
  // /abap-test emits test-coverage. Both accept cca-assessment via --from
@@ -283,7 +288,10 @@ export async function maybeOfferSave(p) {
283
288
  if (!detected)
284
289
  return;
285
290
  saveSkill = detected;
286
- p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
291
+ // Inside a journey the triage is saved as part of the step: nothing to offer.
292
+ if (!(p.journey && detected === p.skill)) {
293
+ p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
294
+ }
287
295
  }
288
296
  // B5 (2026-06-11) — defect D2: in headless runs the artifact IS the point
289
297
  // of the run, so the "Save as project file? [y/N]" prompt defaults YES
@@ -327,6 +335,9 @@ export async function maybeOfferSave(p) {
327
335
  // found in another skill's output (detectArtifactSkill) never gets it.
328
336
  ...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
329
337
  onNotSaved: (reason) => { notSaved = reason; },
338
+ ...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
339
+ // Never headless: a headless journey queues nothing.
340
+ ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
330
341
  });
331
342
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
332
343
  // questions inline (recorded as `answered` items in the SAME file) — the
@@ -405,6 +416,49 @@ function readerRunFrom(content, start) {
405
416
  function stripModelDate(model) {
406
417
  return model?.replace(/-\d{8}$/, '');
407
418
  }
419
+ /**
420
+ * Guided clean-core journey (Task 4) — what the triage save needs from the
421
+ * journey: its card (Ink card slot or a typed line), a typed reason, the
422
+ * session skips, the pause, and the page the triage was shown.
423
+ */
424
+ function journeySaveFor(session, emit) {
425
+ const page = getJourneyFlow(session)?.page;
426
+ return {
427
+ card: journeyCardConfirm(emit),
428
+ // Esc on "Why keep it?" pauses the journey, as Esc on the card does.
429
+ ask: journeyAsk(),
430
+ skip: (keys) => addJourneySkips(session, { decide: new Set(keys) }),
431
+ leaveForLater: (keys) => addJourneyLeftOut(session, keys),
432
+ pause: () => {
433
+ const line = pauseJourneyForInterruptedTurn(session);
434
+ if (line)
435
+ emit(line);
436
+ },
437
+ ...(page ? { page } : {}),
438
+ };
439
+ }
440
+ /**
441
+ * Guided clean-core journey (Task 3) — the chain's hooks from a top-level
442
+ * turn: the session's SAP client, the pending-dispatch slot, the turn's abort
443
+ * signal (Ctrl-C reaches a running check), the turn's own output.
444
+ */
445
+ function journeyHooks(params, emit) {
446
+ const ctx = params.ctx;
447
+ const standaloneTurn = isStandalone(ctx);
448
+ return {
449
+ session: ctx.session,
450
+ cwd: ctx.cwd,
451
+ headless: isHeadless(),
452
+ emit,
453
+ ...(ctx.pendingDispatch ? { queueNext: (cmd, origin) => ctx.pendingDispatch.set(cmd, origin) } : {}),
454
+ ...(!standaloneTurn && ctx.adt ? { adt: ctx.adt } : {}),
455
+ ...(ctx.sapAlias ? { sapAlias: ctx.sapAlias } : {}),
456
+ ...(standaloneTurn ? { noSap: true } : {}),
457
+ ...(params.signal ? { signal: params.signal } : {}),
458
+ // Task 6 — the status page opens in the browser after Yes on its card.
459
+ openFile: (file) => openStatusPage(file),
460
+ };
461
+ }
408
462
  export async function runTurn(params) {
409
463
  // Task 18 — a reader subagent runs INSIDE the parent's turn: it must not
410
464
  // reset the parent's Rule 8 batch counter, drain the parent's steering
@@ -470,6 +524,19 @@ export async function runTurn(params) {
470
524
  else
471
525
  console.log(line);
472
526
  }
527
+ // Guided clean-core journey (Task 3) — the journey survives only its own
528
+ // queued step (or a plain reply inside it); any other turn ends the chain
529
+ // with one line. No journey → nothing happens here.
530
+ if (!readerChild && !params.ctx.subagent) {
531
+ const journeyGate = gateJourneyForTurn(session, {
532
+ skill: params.skill,
533
+ userMessage: params.userMessage,
534
+ explicitSlash: params.explicitSlash === true,
535
+ previousSkill: session.skill,
536
+ });
537
+ if (journeyGate.ended && journeyGate.line && !isHeadless())
538
+ emit(journeyGate.line);
539
+ }
473
540
  // Phase J — `--from @<source>` phase promotion.
474
541
  // If the user typed `/abap-design --from @./spec-gap.cspeach.json` (or the
475
542
  // estimate equivalent), strip the flag, validate + snapshot the source,
@@ -642,6 +709,11 @@ ${imageExpansion.error}`));
642
709
  }
643
710
  if (laneBlock)
644
711
  userMessageForLLM = `${userMessageForLLM}\n\n${laneBlock}`;
712
+ // Guided clean-core journey (final fix wave) — the queued triage turn says
713
+ // in its own prompt: no commands, CSPeach asks row by row. '' otherwise.
714
+ const journeyBlock = params.ctx.subagent ? '' : journeyTurnBlock(session, params.skill);
715
+ if (journeyBlock)
716
+ userMessageForLLM = `${userMessageForLLM}\n\n${journeyBlock}`;
645
717
  }
646
718
  // 2026-05-06: alongside the deterministic prompt-fact extraction
647
719
  // (package / transport / etc.), inject the connected SAP system's
@@ -866,7 +938,7 @@ ${imageExpansion.error}`));
866
938
  session.turn_seq = (session.turn_seq ?? Math.max(turnNumber - 1, session.lastAutoCompactTurn ?? 0)) + 1;
867
939
  // Snapshot session.messages.length at turn start so the save hook can
868
940
  // walk every assistant message added during this turn — not just the
869
- // final one. Skills like /abap-cca emit their manifest in an early
941
+ // final one. Skills like /abap-cca-merge emit their manifest in an early
870
942
  // message before a closing ask_question widget; the final wrap-up
871
943
  // message ends the turn but doesn't carry the manifest. See
872
944
  // ./turn-assistant-text.ts for the full rationale.
@@ -1529,6 +1601,13 @@ ${imageExpansion.error}`));
1529
1601
  await turnStreamWriter.close(interruptedError !== null
1530
1602
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
1531
1603
  : undefined);
1604
+ // Guided clean-core journey (Task 3, fix round 1) — the queued step was
1605
+ // interrupted: pause the journey so "carry on" resumes it on the first try.
1606
+ if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
1607
+ const journeyLine = pauseJourneyForInterruptedTurn(session);
1608
+ if (journeyLine && !isHeadless())
1609
+ emit(journeyLine);
1610
+ }
1532
1611
  // Re-throw AFTER persistence so the outer REPL catch can render the
1533
1612
  // graceful error UX with full session-id + log-path context.
1534
1613
  if (interruptedError !== null)
@@ -1585,7 +1664,7 @@ ${imageExpansion.error}`));
1585
1664
  // a background block on the next REPL prompt.
1586
1665
  //
1587
1666
  // 2026-05-12: collect text from ALL assistant messages added during
1588
- // this turn, not just the final one. Skills like /abap-cca emit
1667
+ // this turn, not just the final one. Skills like /abap-cca-merge emit
1589
1668
  // their manifest in an early message before a closing ask_question
1590
1669
  // widget; the final wrap-up message ends the turn but doesn't
1591
1670
  // carry the manifest. See ./turn-assistant-text.ts for details.
@@ -1597,6 +1676,7 @@ ${imageExpansion.error}`));
1597
1676
  // while the user was the one being waited on (Laeeq's dry run). Pause
1598
1677
  // for the whole hook — the row hides while paused — and resume after,
1599
1678
  // so any real work that follows (auto-compact) shows again.
1679
+ let projectTagChanged = false;
1600
1680
  status.pause();
1601
1681
  try {
1602
1682
  if (!params.suppressSaveHook) {
@@ -1618,12 +1698,20 @@ ${imageExpansion.error}`));
1618
1698
  const flowLane = getAppFlow(session)?.lane;
1619
1699
  return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
1620
1700
  })(),
1701
+ // Sessions/folders (Task 6) — the first save tags the session with
1702
+ // its folder's project id, only when the session belongs to this
1703
+ // folder (ruling 18). Saved to disk right after the hook.
1704
+ onProjectId: (id) => { if (tagSessionOnSave(session, process.cwd(), id))
1705
+ projectTagChanged = true; },
1706
+ // Guided clean-core journey (Task 4) — the queued triage's rows.
1707
+ ...(!readerChild && !params.ctx.subagent && getJourneyFlow(session)?.queued?.stage === 'DECIDE'
1708
+ ? { journey: journeySaveFor(session, emit) } : {}),
1621
1709
  // Task 7 — subagent / reader turns never drive the app chain.
1622
1710
  ...(!readerChild && !params.ctx.subagent ? {
1623
1711
  appChain: {
1624
1712
  session,
1625
1713
  cwd: params.ctx.cwd,
1626
- ...(params.ctx.pendingDispatch ? { queueNext: (cmd) => params.ctx.pendingDispatch.set(cmd) } : {}),
1714
+ ...(params.ctx.pendingDispatch ? { queueNext: (cmd, origin) => params.ctx.pendingDispatch.set(cmd, origin) } : {}),
1627
1715
  ...(params.ctx.sapAlias && params.ctx.sapAlias !== 'none' ? { sapAlias: params.ctx.sapAlias } : {}),
1628
1716
  },
1629
1717
  } : {}),
@@ -1634,6 +1722,41 @@ ${imageExpansion.error}`));
1634
1722
  keepalive.disarm();
1635
1723
  status.resume();
1636
1724
  }
1725
+ // Sessions/folders (Task 6) — the session was saved before the hook;
1726
+ // persist the project id the save just filled in. Best-effort.
1727
+ if (projectTagChanged) {
1728
+ try {
1729
+ await saveSession(session);
1730
+ }
1731
+ catch { /* tagging never fails the turn */ }
1732
+ }
1733
+ // Guided clean-core journey (Task 3) — the queued step's turn ended:
1734
+ // derive the stage from the folder again and go on (the next code
1735
+ // stage, a step card, or the next queued command).
1736
+ if (!readerChild && !params.ctx.subagent && getJourneyFlow(session)?.queued) {
1737
+ try {
1738
+ await afterJourneyTurn(journeyHooks(params, emit), {
1739
+ skill: params.skill,
1740
+ awaitingAnswer: session.awaitingSkillAnswer === true,
1741
+ assistantText,
1742
+ });
1743
+ }
1744
+ catch (err) {
1745
+ if (process.env['CSPEACH_DEBUG'] === '1')
1746
+ throw err;
1747
+ // Audit M-1: paused (not ended), so the line's "carry on" works; the
1748
+ // error is logged, and a second stop at the same step says so (follow-up a).
1749
+ emit(chalk.yellow(journeyStoppedOnError(session, params.ctx.cwd, err)));
1750
+ }
1751
+ }
1752
+ // Step 2, Task 5 — a /abap-modernize rebuild handed off by the journey
1753
+ // reads `started` once one of its turns wrote to SAP (F9).
1754
+ if (!readerChild && !params.ctx.subagent) {
1755
+ try {
1756
+ await afterRebuildTurn(session, params.skill);
1757
+ }
1758
+ catch { /* the row stays handed-off */ }
1759
+ }
1637
1760
  // Phase 2 #9 (2026-05-16) — auto-compact end-of-turn hook.
1638
1761
  // Runs only on the success path (after maybeOfferSave) so an interrupted
1639
1762
  // turn never triggers a summariser call on partial state. The maybeAutoCompact
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { migrateLegacyByokKey, legacyByokKeyNotice, cleanStaleAuthTemps } from '
10
10
  * The verbs that make `cspeach project …` a mistyped team command rather than
11
11
  * a prompt somebody meant to send. See the check in `main`.
12
12
  */
13
- const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'leave']);
13
+ const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'rules', 'target', 'decide', 'leave', 'export']);
14
14
  export async function main(argv) {
15
15
  // NO_COLOR (piece 2 §1.2) — before ANY output, for every command (REPL,
16
16
  // one-shot, doctor, login, help, config …).
@@ -67,6 +67,20 @@ export async function main(argv) {
67
67
  sapAlias = parsed.sapAlias;
68
68
  args = parsed.rest;
69
69
  }
70
+ // 2026-10-08 (sessions/folders) — `--continue` / `-c`: the latest session of this folder.
71
+ if (args[0] === '--continue' || args[0] === '-c') {
72
+ if (args[1] !== undefined && !args[1].startsWith('-')) {
73
+ console.error(chalk.red('error: --continue takes no session id. To pick one: cspeach --resume'));
74
+ return 1;
75
+ }
76
+ // Like --resume: a trailing flag is refused (--sap-alias / --no-sap were taken out above).
77
+ if (args[1] !== undefined) {
78
+ console.error(chalk.red(`error: --continue takes no other flag, not '${args[1]}'. Usage: cspeach --continue`));
79
+ return 1;
80
+ }
81
+ const { runContinue } = await import('./session/resume.js');
82
+ return runContinue({ sapAlias, noSap });
83
+ }
70
84
  if (args[0] === '--resume') {
71
85
  // Final batch 5 — `--resume --one-shot "…"`: a flag is never a session id.
72
86
  if (args[1]?.startsWith('-')) {
@@ -153,17 +167,10 @@ export async function main(argv) {
153
167
  const { input } = await import('@inquirer/prompts');
154
168
  const { loadConfig } = await import('./config/loader.js');
155
169
  const cfg = await loadConfig();
156
- const open = async (url) => {
157
- const { spawn } = await import('node:child_process');
158
- // On Windows, `start` is a cmd builtin and needs a shell; spawn the URL via cmd /c.
159
- if (process.platform === 'win32') {
160
- spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
161
- }
162
- else {
163
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
164
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
165
- }
166
- };
170
+ // The shared opener (util/open-in-browser.ts): https only, never through
171
+ // cmd or a shell; rejects when nothing opened, so login prints its own line.
172
+ const { loginOpener } = await import('./util/open-in-browser.js');
173
+ const open = loginOpener();
167
174
  return runLoginCommand({
168
175
  proxyUrl: cfg.proxy_url,
169
176
  open,
@@ -202,7 +209,10 @@ export async function main(argv) {
202
209
  return {
203
210
  sql: (q, max) => adt.sqlQuery(q, max),
204
211
  system: { sid: name },
205
- atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
212
+ atc: {
213
+ runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts),
214
+ findingDocument: (href) => adt.atcFindingDocument(href),
215
+ },
206
216
  };
207
217
  },
208
218
  ask: async (question) => {
@@ -231,9 +241,14 @@ export async function main(argv) {
231
241
  // a PAID prompt on a machine that is configured and logged in. A hint, not
232
242
  // an alias: the old family does not run, and nothing is renamed back.
233
243
  if (args[0] === 'project' && TEAM_SUBCOMMANDS.has(args[1] ?? '')) {
234
- console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|leave');
244
+ console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|rules|target|decide|leave|export');
235
245
  return 1;
236
246
  }
247
+ // Sessions/folders (2026-10-08): sort project files from earlier builds into their folders. Copy only.
248
+ if (args[0] === 'files' && args[1] === 'sort') {
249
+ const { runFilesSort } = await import('./projects/sort-command.js');
250
+ return runFilesSort(args.slice(2), { log: (s) => console.log(s), cwd: process.cwd() });
251
+ }
237
252
  if (args[0] === 'analyze' && args[1] === 'project-context') {
238
253
  const { runProjectContextImpactCommand } = await import('./commands/project-context-impact.js');
239
254
  return runProjectContextImpactCommand(args.slice(2));
@@ -270,7 +285,8 @@ Usage:
270
285
  cspeach --one-shot "<prompt>" Same as above, explicit flag
271
286
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
272
287
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
273
- cspeach --resume [id] Resume a previous session (list if no id)
288
+ cspeach --continue, -c Continue the latest session in this folder
289
+ cspeach --resume [id] Pick a session from this folder (or all folders); an id works anywhere
274
290
  cspeach config add-sap Add a SAP system interactively
275
291
  cspeach config standalone Set the standalone SAP profile (no system)
276
292
  cspeach standards init Capture your team's ABAP development standards
@@ -283,18 +299,33 @@ Usage:
283
299
  cspeach login Sign in via device-code flow
284
300
  cspeach logout Sign out (keep keychain key, if any)
285
301
  cspeach whoami Print current account info
302
+ cspeach team discover Read the whole estate from SAP into this folder (read only)
303
+ cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
304
+ cspeach team start --for "FI and SD" --developers "James Miller, me" Lead: set up by name from this folder's discovery; one lane each
286
305
  cspeach team init <folder> Lead: pull the object list from SAP, start a team project
287
- cspeach team join <folder> Record your results against a team project (on this laptop only)
306
+ cspeach team join <folder> [--as "<name>"] Record your results against a team project (on this laptop only), under the lane owner's name
288
307
  cspeach team lane add|list Lead: who owns which packages
308
+ cspeach team lane move --packages <names> --to <developer> Lead: move packages by name (lanes of names only)
309
+ cspeach team lane propose --developers "A,B" [--apply] Lead: a first cut of the lanes, balanced by the work
310
+ cspeach team focus "<label>" --packages … --reason "…" Lead: what the team works on now (focus list | clear <label>)
311
+ cspeach team priority show|set|reset The priority rules; set and reset are the lead's
312
+ cspeach team plan move|scope|defer|set|note|history Lead: per-object plan changes, each with a reason
313
+ cspeach team plan import <file.xlsx> [--yes] Lead: read back the Plan sheet edited in Excel; shows the changes, asks once
289
314
  cspeach team refresh --lane <id> Measure with ATC: what is verified, what did not hold
315
+ cspeach team refresh --all --per-package [--again] One record per package; skips packages already measured
290
316
  cspeach team status [folder] Where every object stands, who owns it, what is proven
291
317
  cspeach team outbox What this laptop has recorded, and where
292
- cspeach team rule T N <d> Lead: settle a conflicting decision
293
- cspeach team decide T N <d> Record a clean-core decision: keep, fix, rebuild, retire or exception
318
+ cspeach team rule T N <d> Lead: settle a conflicting decision (a retire needs --reason)
319
+ cspeach team rules [on|off|set|add|remove|reset] The decision rules: anyone views them, the lead changes them (also /rules in a session)
320
+ cspeach team decide T N <d> Record a clean-core decision: keep, fix, rebuild, retire or exception (a retire needs --reason)
321
+ cspeach team target <A-D> --reason "…" [--packages P1,P2] [--variant <NAME>] Lead: the level fixes are proven against (team target clear removes it)
294
322
  cspeach team fixed T N Claim a fix; the next refresh checks it
323
+ cspeach team rebuild built T N --app <APP> Say a rebuild is built (the report then reads built, not yet checked)
295
324
  cspeach team sheet Write your working list (Excel) from the team project
296
325
  cspeach team report Lead: the combined workbook and status page, in reports
326
+ cspeach team export [--out <folder>] [--always | --never] The CSV files for your own tools, in reports/exports (--always: with every report; --never: stop that)
297
327
  cspeach team leave Stop recording
328
+ cspeach files sort [--apply <plan>] [--clean <plan>] Sort files from earlier builds into their folders (copies; --clean deletes checked originals)
298
329
  cspeach analyze project-context Compare flag-on vs flag-off session metrics
299
330
  cspeach --upgrade Upgrade to latest via npm
300
331
  cspeach --version Print version
@@ -212,7 +212,7 @@ const UNSET_ONLY_KEYS = new Set(['standalone']);
212
212
  * remains), keeping the top-level entry in `file_keys` is what stops sparse
213
213
  * save from dropping the now-default-equal parent table.
214
214
  */
215
- function unsetConfigKey(cfg, key) {
215
+ export function unsetConfigKey(cfg, key) {
216
216
  const parts = key.split('.');
217
217
  const top = parts[0];
218
218
  const obj = cfg;
@@ -6,6 +6,7 @@ import { paint } from '../renderer/theme.js';
6
6
  import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
7
7
  // I2's shortcuts table and note — one source for the `?` sheet and /help.
8
8
  import { SHORTCUT_ROWS, SHORTCUTS_NOTE } from '../ui/widgets/shortcuts-sheet.js';
9
+ import { BUNDLED_RUNS } from '../runs/runs.js';
9
10
  // Workflow order: plan → understand → modify → build → upgrade → ship.
10
11
  const CATEGORY_ORDER = [
11
12
  'Pre-Coding',
@@ -26,11 +27,20 @@ const fixed = (s) => () => s;
26
27
  * follows the cost display mode (credits for managed customers).
27
28
  */
28
29
  export const HELP_GROUPS = [
30
+ // Guided runs (spec 2026-10-08 §3.2) — above "Work on SAP", one row per
31
+ // declared run, from the bundled declarations (RUN.md), never from code.
32
+ { title: 'Guided runs', rows: BUNDLED_RUNS.map((r) => ({
33
+ name: r.entry,
34
+ description: fixed(`${r.title} — ${r.steps.length} steps. Also: ${r.alias}`),
35
+ // The RUN.md body paragraph (spec §3.1, "one paragraph for /help").
36
+ detail: r.paragraph,
37
+ })) },
29
38
  { title: 'Work on SAP', rows: [
30
39
  { name: '/transport [TR]', description: fixed('View or set the transport for this session') },
31
40
  { name: '/mode [advisory|gated|auto]', description: fixed('Set the write mode (Shift+Tab also cycles)') },
32
41
  { name: '/safety-mode on|off|status', description: fixed('Turn the Forge Rule confirmation cards on or off') },
33
42
  { name: '/rewind', description: fixed('Undo a write from this session (Esc Esc)') },
43
+ { name: '/rules', description: fixed("View or change the team project's decision rules") },
34
44
  { name: '/reroute <skill>', description: fixed('Re-run your last prompt with a different skill') },
35
45
  ] },
36
46
  { title: 'Local files', rows: [
@@ -102,8 +112,13 @@ export function printHelp(emit = (s) => console.log(s)) {
102
112
  for (const group of HELP_GROUPS) {
103
113
  emit('');
104
114
  emit(groupTitle(group.title));
105
- for (const r of group.rows)
115
+ for (const r of group.rows) {
106
116
  emit(row(r.name, r.description()));
117
+ // A row's detail (a guided run's paragraph) wraps in full, dim, under the description column.
118
+ if (r.detail)
119
+ for (const l of wrapDescription(r.detail, descWidth, Number.MAX_SAFE_INTEGER))
120
+ emit(`${continuationIndent}${paint('dim', l)}`);
121
+ }
107
122
  if (group.title === 'Work on SAP') {
108
123
  // Skills live under "Work on SAP", one dim sub-heading per category.
109
124
  for (const category of CATEGORY_ORDER) {
@@ -28,6 +28,7 @@ import { readProjectFile } from '../projects/status.js';
28
28
  import { parsePlanContent } from '../projects/plan-schema.js';
29
29
  import { statusesFromItems, computeNextPhaseForRun, isPhaseSatisfiedForRun, planCompletionLines, phaseDisplayTitle, } from '../projects/plan-run.js';
30
30
  import { savedLocation } from '../projects/saved-lines.js';
31
+ import { afterPlanComplete } from '../journey/rebuild.js';
31
32
  import { clearPlanGrant, getPlanGrant, getGrantTransport, installPlanGrant, noteDeltaOffered, offeredDeltaObjects, planGrantAllowed, } from '../approvals/plan-grant.js';
32
33
  import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-objects.js';
33
34
  import { isDeployPhase } from '../projects/plan-lane.js';
@@ -251,6 +252,8 @@ async function postPhaseChain(args) {
251
252
  const nextAfter = computeNextPhaseForRun(after.content.phases, st);
252
253
  if (built && !nextAfter) {
253
254
  args.log('', ...planCompletionLines(after.content.phases));
255
+ // Step 2 (C5): a rebuild whose plan this is now reads "built, not yet checked".
256
+ await afterPlanComplete(cur);
254
257
  args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
255
258
  }
256
259
  else if (!built && auditFailed && !stopShown) {
@@ -119,7 +119,7 @@ export async function preparePlanContinuation(args) {
119
119
  // App lane routing — same gate as preparePlanResume: pinned read, record
120
120
  // re-validation, stored phases checked against the lane. Before the lease,
121
121
  // so a paused plan never leaves a lock behind. Lane-less plans pass.
122
- const laneGate = await loadPlanLaneForRun(args.cwd ?? process.cwd(), parsed.content);
122
+ const laneGate = await loadPlanLaneForRun(args.cwd ?? process.cwd(), parsed.content, path);
123
123
  if (!laneGate.ok) {
124
124
  args.log(...laneGate.lines);
125
125
  return { prepared: null, stale: false, paused: true };
@@ -31,8 +31,24 @@ let guardedRunActive = false;
31
31
  export function setGuardedRunActive(active) {
32
32
  guardedRunActive = active;
33
33
  }
34
+ /* Guided clean-core journey (2026-10-07, Task 3) — the journey's own share of
35
+ * the same override, kept apart so a plan turn's set/clear and the journey's
36
+ * start/end never undo each other. Off ⇒ byte-identical to before. */
37
+ let journeyRunActive = false;
38
+ export function setJourneyRunActive(active) {
39
+ journeyRunActive = active;
40
+ }
41
+ /* Step 2 (final review I2) — the rebuild hand-off's share. The journey ends
42
+ * (its share off) BEFORE it queues the rebuild skill; that queued turn is
43
+ * auto-dispatched, so it must not inherit auto_approve either. Set just before
44
+ * the queue, cleared after the first top-level turn that follows the hand-off
45
+ * turn (journey/rebuild.ts afterRebuildTurn), whatever its skill. */
46
+ let rebuildRunActive = false;
47
+ export function setRebuildRunActive(active) {
48
+ rebuildRunActive = active;
49
+ }
34
50
  export function isGuardedRunActive() {
35
- return guardedRunActive;
51
+ return guardedRunActive || journeyRunActive || rebuildRunActive;
36
52
  }
37
53
  /* ── plan-phase read-only enforcement (Task 7, 2026-07-03) ─────────────────
38
54
  * Module-level flag consulted by tools/subagent/agent_run.ts. The REPL sets
@@ -53,6 +53,7 @@ import { acquireRunLease, releaseRunLease } from '../projects/run-lease.js';
53
53
  import { validateEnvelope } from '../projects/validate.js';
54
54
  import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
55
55
  import { getAuthorIdentity } from '../agent/loop.js';
56
+ import { afterPlanComplete } from '../journey/rebuild.js';
56
57
  import { DEFAULT_PLAN_MODE } from './plan-gate.js';
57
58
  import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
58
59
  import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
@@ -260,7 +261,7 @@ export async function preparePlanResume(args) {
260
261
  // skip this entirely.
261
262
  // R5 fix round: the stored phases must also fit the lane, checked here
262
263
  // before any phase, lease or transport work.
263
- const laneGate = await loadPlanLaneForRun(args.cwd, content);
264
+ const laneGate = await loadPlanLaneForRun(args.cwd, content, path);
264
265
  if (!laneGate.ok) {
265
266
  args.log(...laneGate.lines);
266
267
  return null;
@@ -632,7 +633,7 @@ export async function preparePlanResume(args) {
632
633
  // 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
633
634
  // from the file the plan was made from (or the plan's creation snapshot).
634
635
  // A phase must never have to hunt the folder for its gap answers.
635
- const sourcesBlock = renderPlanSourcesBlock(await resolvePlanSources({ cwd: args.cwd, envelope, content, ...(laneRecord ? { laneRecord } : {}) }), next);
636
+ const sourcesBlock = renderPlanSourcesBlock(await resolvePlanSources({ cwd: args.cwd, envelope, content, planPath: path, ...(laneRecord ? { laneRecord } : {}) }), next);
636
637
  const llmPrompt = [
637
638
  `Resume execution of the project plan "${envelope.title}" (envelope v${envelope.version}).`,
638
639
  '',
@@ -981,14 +982,9 @@ export async function finishPlanResume(args) {
981
982
  logNotSaved(args, 'the updated plan did not pass its own checks', `revision failed validation: ${check.error.message}`);
982
983
  return null;
983
984
  }
984
- let outDir;
985
- try {
986
- outDir = await ensureWorkspace();
987
- }
988
- catch {
989
- outDir = process.cwd();
990
- }
991
- const savedPath = await saveProject(revision, { cwd: outDir });
985
+ // 2026-10-08 — the new version is saved beside the plan file, wherever it
986
+ // is (an old git root, a copied folder), so a plan family never splits.
987
+ const savedPath = await savePlanVersion(revision, args.prepared.path, args.log);
992
988
  // Task 8 — regenerate the write-only markdown handover projection beside
993
989
  // the envelope. Best-effort: a failed projection write logs one dim line
994
990
  // and never blocks the run (the envelope is the source of truth).
@@ -1024,6 +1020,9 @@ export async function finishPlanResume(args) {
1024
1020
  if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
1025
1021
  // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
1026
1022
  args.log(...planCompletionLines(provisionalPhases));
1023
+ // Step 2 (C5): the live run finished — a rebuild whose plan this is reads built.
1024
+ // Not at the re-opened complete plan above (preparePlanResume).
1025
+ await afterPlanComplete(args.prepared.path);
1027
1026
  }
1028
1027
  else if (!next) {
1029
1028
  const blocked = provisionalPhases.filter((p) => statuses[p.id] === 'blocked').map((p) => phaseDisplayTitle(p, provisionalPhases));
@@ -1048,6 +1047,23 @@ export async function finishPlanResume(args) {
1048
1047
  nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
1049
1048
  };
1050
1049
  }
1050
+ /**
1051
+ * Final fix wave, item 5 — save a new plan version beside the plan file. When
1052
+ * that write fails (a read-only or missing folder), save it in the working
1053
+ * folder instead, so the run's progress is never lost, and say where.
1054
+ */
1055
+ async function savePlanVersion(revision, planPath, log) {
1056
+ const beside = dirname(planPath);
1057
+ try {
1058
+ return await saveProject(revision, { cwd: beside });
1059
+ }
1060
+ catch {
1061
+ const ws = await ensureWorkspace();
1062
+ const saved = await saveProject(revision, { cwd: ws });
1063
+ log(`Could not save beside the plan in ${beside}. Saved in ${ws} instead.`);
1064
+ return saved;
1065
+ }
1066
+ }
1051
1067
  /**
1052
1068
  * Shared revision writer for audit verdicts and chain-stop records: load the
1053
1069
  * envelope, patch ONE phase (audit and/or status) + content.lastStop,
@@ -1103,14 +1119,8 @@ async function savePlanPatch(envelopePath, phaseId, patch, log) {
1103
1119
  if (!check.ok) {
1104
1120
  throw new Error(`audit revision failed validation: ${check.error.message}`);
1105
1121
  }
1106
- let outDir;
1107
- try {
1108
- outDir = await ensureWorkspace();
1109
- }
1110
- catch {
1111
- outDir = dirname(envelopePath);
1112
- }
1113
- const saved = await saveProject(revision, { cwd: outDir });
1122
+ // 2026-10-08 — saved beside the plan file (the working folder only as a fallback).
1123
+ const saved = await savePlanVersion(revision, envelopePath, log ?? console.log);
1114
1124
  // Task 8 — every envelope save in the plan flow regenerates the markdown
1115
1125
  // handover. This is the single chokepoint for ALL chain writes:
1116
1126
  // applyAuditResult and applyPhaseResolution both route through here.