@cspeach/cli 1.1.29 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.cspeach/project.json +4 -0
  2. package/README.md +11 -1
  3. package/dist/agent/loop.js +127 -11
  4. package/dist/auth/auth-file.js +2 -2
  5. package/dist/cli.js +39 -13
  6. package/dist/commands/config-set.js +1 -1
  7. package/dist/commands/login.js +1 -1
  8. package/dist/commands/logout.js +1 -1
  9. package/dist/commands/plan-continue.js +1 -1
  10. package/dist/commands/plan-gate.js +8 -1
  11. package/dist/commands/plan-resume.js +24 -18
  12. package/dist/commands/plan-startup.js +49 -6
  13. package/dist/commands/team-discover.js +195 -0
  14. package/dist/commands/team-plan.js +585 -0
  15. package/dist/commands/team.js +652 -46
  16. package/dist/commands/whoami.js +1 -1
  17. package/dist/config/paths.js +15 -1
  18. package/dist/journey/chain.js +728 -0
  19. package/dist/journey/clean-core.js +213 -0
  20. package/dist/journey/flow.js +166 -0
  21. package/dist/journey/run.js +435 -0
  22. package/dist/journey/session-deps.js +77 -0
  23. package/dist/journey/trigger.js +153 -0
  24. package/dist/lane/app-chain.js +9 -7
  25. package/dist/lane/chain-token.js +9 -5
  26. package/dist/lane/roots.js +97 -41
  27. package/dist/one-shot.js +21 -1
  28. package/dist/projects/clean-core-confirm.js +192 -0
  29. package/dist/projects/detail-path.js +9 -4
  30. package/dist/projects/extract-clean-core.js +82 -0
  31. package/dist/projects/migration.js +4 -105
  32. package/dist/projects/plan-lane.js +67 -17
  33. package/dist/projects/plan-sources.js +13 -7
  34. package/dist/projects/project-folder.js +131 -0
  35. package/dist/projects/promote-command.js +39 -0
  36. package/dist/projects/save-command.js +115 -15
  37. package/dist/projects/saved-lines.js +17 -3
  38. package/dist/projects/sort-command.js +499 -0
  39. package/dist/projects/sort-core.js +248 -0
  40. package/dist/projects/validate.js +2 -1
  41. package/dist/projects/workspace-retire.js +48 -0
  42. package/dist/projects/workspace.js +182 -120
  43. package/dist/register/atc-file.js +9 -1
  44. package/dist/register/discover.js +901 -0
  45. package/dist/register/findings.js +82 -0
  46. package/dist/register/measure.js +60 -1
  47. package/dist/register/offer.js +125 -0
  48. package/dist/register/picture.js +25 -0
  49. package/dist/register/plan-import.js +325 -0
  50. package/dist/register/records.js +57 -0
  51. package/dist/register/status-file/estate-parts.js +168 -0
  52. package/dist/register/status-file/plan-parts.js +68 -0
  53. package/dist/register/status-file/progress-chart.js +110 -0
  54. package/dist/register/status-file/register-map.js +221 -0
  55. package/dist/register/status-file/register-model.js +341 -0
  56. package/dist/register/status-file/register-parts.js +555 -0
  57. package/dist/register/status-file/register-status-file.js +189 -0
  58. package/dist/register/status-file/team-css.js +5 -0
  59. package/dist/register/status-file-sync.js +22 -0
  60. package/dist/register/status-html.js +19 -0
  61. package/dist/register/store.js +40 -2
  62. package/dist/register/workbooks.js +124 -0
  63. package/dist/register/write-whole.js +22 -0
  64. package/dist/register/xlsx.js +127 -0
  65. package/dist/register/xref.js +117 -0
  66. package/dist/renderer/startup-lines.js +11 -0
  67. package/dist/renderer/tool-labels.js +3 -1
  68. package/dist/renderer/tool-widget.js +15 -1
  69. package/dist/repl/at-completer.js +6 -2
  70. package/dist/repl/at-picker.js +1 -1
  71. package/dist/repl/journey-entry.js +25 -0
  72. package/dist/repl.js +158 -39
  73. package/dist/session/backfill.js +342 -0
  74. package/dist/session/project-match.js +63 -0
  75. package/dist/session/resume.js +72 -24
  76. package/dist/session/schema.js +3 -1
  77. package/dist/session/store.js +96 -51
  78. package/dist/skill-catalog.js +2 -2
  79. package/dist/skills/bundled-skills.js +45 -45
  80. package/dist/tools/_filesystem-shared.js +190 -21
  81. package/dist/tools/_team-work-path.js +133 -0
  82. package/dist/tools/approval.js +7 -1
  83. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  84. package/dist/tools/filesystem/file-edit.js +12 -4
  85. package/dist/tools/filesystem/file-read.js +8 -4
  86. package/dist/tools/filesystem/file-write.js +12 -4
  87. package/dist/tools/filesystem/glob.js +33 -8
  88. package/dist/tools/filesystem/grep.js +9 -4
  89. package/dist/tools/filesystem/read-document.js +6 -3
  90. package/dist/tools/project/cca_merge.js +8 -2
  91. package/dist/tools/project/team_estate.js +91 -0
  92. package/dist/tools/project/team_findings.js +145 -0
  93. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  94. package/dist/tools/sap-read.js +21 -4
  95. package/dist/tools/sap-write.js +203 -5
  96. package/dist/tools/subagent/read_agent.js +3 -0
  97. package/dist/tools/tool-loading.js +2 -2
  98. package/dist/tools/transport-fit-check.js +117 -0
  99. package/dist/tools/transport-fit.js +15 -7
  100. package/dist/tools/transport.js +57 -2
  101. package/dist/ui/app.js +2 -2
  102. package/dist/ui/approval-modal.js +74 -6
  103. package/dist/ui/widgets/confirm-card.js +68 -5
  104. package/dist/util/open-in-browser.js +143 -0
  105. package/package.json +120 -120
@@ -0,0 +1,4 @@
1
+ {
2
+ "id": "032010b3",
3
+ "createdAt": "2026-10-08T22:43:38.438Z"
4
+ }
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,8 @@ 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 { afterJourneyTurn, gateJourneyForTurn, journeyAsk, journeyCardConfirm, journeyTurnBlock, pauseJourneyForInterruptedTurn } from '../journey/chain.js';
45
+ import { addJourneyLeftOut, addJourneySkips, clearJourneyFlow, getJourneyFlow } from '../journey/flow.js';
43
46
  import { afterPlanSave, afterSpecGapNotSaved, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
44
47
  import { shownToken } from '../lane/card.js';
45
48
  import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
@@ -68,6 +71,7 @@ import { CacheKeepalive, snapshotParams, recordLastRequest, isCacheablePrompt, n
68
71
  import { maybeColdPrune } from './cold-prune.js';
69
72
  import { createAdtLock, serializeAdt } from '../tools/subagent/adt-serial.js';
70
73
  import { hasReaderReadTools, INTERRUPTED_LINE } from '../tools/subagent/reader-prompt.js';
74
+ import { openStatusPage } from '../util/open-in-browser.js';
71
75
  /**
72
76
  * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
73
77
  * then platform USER/USERNAME, then a generic fallback. Role is fixed to
@@ -98,11 +102,11 @@ const SAVE_HOOK_SKILLS = new Set([
98
102
  'abap-upgrade-fix',
99
103
  'abap-upgrade-verify',
100
104
  '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
105
+ // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca-merge produces
104
106
  // a consolidated cca-assessment from N parallel consultant slices.
105
- 'abap-cca',
107
+ // 2026-10-06: the thin /abap-cca (1.1.31) reads the estate inventory and
108
+ // writes no envelope, so it is no longer a save-hook skill. An old
109
+ // cca-manifest in a turn is still offered through detectArtifactSkill.
106
110
  'abap-cca-merge',
107
111
  // 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
108
112
  // /abap-test emits test-coverage. Both accept cca-assessment via --from
@@ -283,7 +287,10 @@ export async function maybeOfferSave(p) {
283
287
  if (!detected)
284
288
  return;
285
289
  saveSkill = detected;
286
- p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
290
+ // Inside a journey the triage is saved as part of the step: nothing to offer.
291
+ if (!(p.journey && detected === p.skill)) {
292
+ p.emit(formatInfo(`${detected} output found in this turn (routed as ${p.skill}) · offering to save it`).trimEnd());
293
+ }
287
294
  }
288
295
  // B5 (2026-06-11) — defect D2: in headless runs the artifact IS the point
289
296
  // of the run, so the "Save as project file? [y/N]" prompt defaults YES
@@ -291,9 +298,12 @@ export async function maybeOfferSave(p) {
291
298
  // logged so the transcript stays honest about who said yes.
292
299
  //
293
300
  // CONTRACT: this headless prompt blanket-answers 'y' to ANY question it
294
- // is asked, so runSaveCommand must ask at most ONE question (the save
295
- // confirm). See the matching note at the prompt site in
296
- // src/projects/save-command.ts before adding further prompts there.
301
+ // is asked, so `prompt` may carry only ONE question (the save confirm).
302
+ // runSaveCommand asks 1+N cards: the save confirm, then (clean-core
303
+ // decisions) one card per row — and the row cards go through `confirm`
304
+ // only, never `prompt`, so a headless run confirms no row. See the
305
+ // matching note at the prompt site in src/projects/save-command.ts
306
+ // before adding further prompts there.
297
307
  const { isHeadless } = await import('../renderer/tty.js');
298
308
  const prompt = isHeadless()
299
309
  ? async (q) => {
@@ -324,6 +334,9 @@ export async function maybeOfferSave(p) {
324
334
  // found in another skill's output (detectArtifactSkill) never gets it.
325
335
  ...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
326
336
  onNotSaved: (reason) => { notSaved = reason; },
337
+ ...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
338
+ // Never headless: a headless journey queues nothing.
339
+ ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
327
340
  });
328
341
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
329
342
  // questions inline (recorded as `answered` items in the SAME file) — the
@@ -402,6 +415,49 @@ function readerRunFrom(content, start) {
402
415
  function stripModelDate(model) {
403
416
  return model?.replace(/-\d{8}$/, '');
404
417
  }
418
+ /**
419
+ * Guided clean-core journey (Task 4) — what the triage save needs from the
420
+ * journey: its card (Ink card slot or a typed line), a typed reason, the
421
+ * session skips, the pause, and the page the triage was shown.
422
+ */
423
+ function journeySaveFor(session, emit) {
424
+ const page = getJourneyFlow(session)?.page;
425
+ return {
426
+ card: journeyCardConfirm(emit),
427
+ // Esc on "Why keep it?" pauses the journey, as Esc on the card does.
428
+ ask: journeyAsk(),
429
+ skip: (keys) => addJourneySkips(session, { decide: new Set(keys) }),
430
+ leaveForLater: (keys) => addJourneyLeftOut(session, keys),
431
+ pause: () => {
432
+ const line = pauseJourneyForInterruptedTurn(session);
433
+ if (line)
434
+ emit(line);
435
+ },
436
+ ...(page ? { page } : {}),
437
+ };
438
+ }
439
+ /**
440
+ * Guided clean-core journey (Task 3) — the chain's hooks from a top-level
441
+ * turn: the session's SAP client, the pending-dispatch slot, the turn's abort
442
+ * signal (Ctrl-C reaches a running check), the turn's own output.
443
+ */
444
+ function journeyHooks(params, emit) {
445
+ const ctx = params.ctx;
446
+ const standaloneTurn = isStandalone(ctx);
447
+ return {
448
+ session: ctx.session,
449
+ cwd: ctx.cwd,
450
+ headless: isHeadless(),
451
+ emit,
452
+ ...(ctx.pendingDispatch ? { queueNext: (cmd) => ctx.pendingDispatch.set(cmd) } : {}),
453
+ ...(!standaloneTurn && ctx.adt ? { adt: ctx.adt } : {}),
454
+ ...(ctx.sapAlias ? { sapAlias: ctx.sapAlias } : {}),
455
+ ...(standaloneTurn ? { noSap: true } : {}),
456
+ ...(params.signal ? { signal: params.signal } : {}),
457
+ // Task 6 — the status page opens in the browser after Yes on its card.
458
+ openFile: (file) => openStatusPage(file),
459
+ };
460
+ }
405
461
  export async function runTurn(params) {
406
462
  // Task 18 — a reader subagent runs INSIDE the parent's turn: it must not
407
463
  // reset the parent's Rule 8 batch counter, drain the parent's steering
@@ -467,6 +523,19 @@ export async function runTurn(params) {
467
523
  else
468
524
  console.log(line);
469
525
  }
526
+ // Guided clean-core journey (Task 3) — the journey survives only its own
527
+ // queued step (or a plain reply inside it); any other turn ends the chain
528
+ // with one line. No journey → nothing happens here.
529
+ if (!readerChild && !params.ctx.subagent) {
530
+ const journeyGate = gateJourneyForTurn(session, {
531
+ skill: params.skill,
532
+ userMessage: params.userMessage,
533
+ explicitSlash: params.explicitSlash === true,
534
+ previousSkill: session.skill,
535
+ });
536
+ if (journeyGate.ended && journeyGate.line && !isHeadless())
537
+ emit(journeyGate.line);
538
+ }
470
539
  // Phase J — `--from @<source>` phase promotion.
471
540
  // If the user typed `/abap-design --from @./spec-gap.cspeach.json` (or the
472
541
  // estimate equivalent), strip the flag, validate + snapshot the source,
@@ -639,6 +708,11 @@ ${imageExpansion.error}`));
639
708
  }
640
709
  if (laneBlock)
641
710
  userMessageForLLM = `${userMessageForLLM}\n\n${laneBlock}`;
711
+ // Guided clean-core journey (final fix wave) — the queued triage turn says
712
+ // in its own prompt: no commands, CSPeach asks row by row. '' otherwise.
713
+ const journeyBlock = params.ctx.subagent ? '' : journeyTurnBlock(session, params.skill);
714
+ if (journeyBlock)
715
+ userMessageForLLM = `${userMessageForLLM}\n\n${journeyBlock}`;
642
716
  }
643
717
  // 2026-05-06: alongside the deterministic prompt-fact extraction
644
718
  // (package / transport / etc.), inject the connected SAP system's
@@ -863,7 +937,7 @@ ${imageExpansion.error}`));
863
937
  session.turn_seq = (session.turn_seq ?? Math.max(turnNumber - 1, session.lastAutoCompactTurn ?? 0)) + 1;
864
938
  // Snapshot session.messages.length at turn start so the save hook can
865
939
  // walk every assistant message added during this turn — not just the
866
- // final one. Skills like /abap-cca emit their manifest in an early
940
+ // final one. Skills like /abap-cca-merge emit their manifest in an early
867
941
  // message before a closing ask_question widget; the final wrap-up
868
942
  // message ends the turn but doesn't carry the manifest. See
869
943
  // ./turn-assistant-text.ts for the full rationale.
@@ -1526,6 +1600,13 @@ ${imageExpansion.error}`));
1526
1600
  await turnStreamWriter.close(interruptedError !== null
1527
1601
  ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
1528
1602
  : undefined);
1603
+ // Guided clean-core journey (Task 3, fix round 1) — the queued step was
1604
+ // interrupted: pause the journey so "carry on" resumes it on the first try.
1605
+ if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
1606
+ const journeyLine = pauseJourneyForInterruptedTurn(session);
1607
+ if (journeyLine && !isHeadless())
1608
+ emit(journeyLine);
1609
+ }
1529
1610
  // Re-throw AFTER persistence so the outer REPL catch can render the
1530
1611
  // graceful error UX with full session-id + log-path context.
1531
1612
  if (interruptedError !== null)
@@ -1582,7 +1663,7 @@ ${imageExpansion.error}`));
1582
1663
  // a background block on the next REPL prompt.
1583
1664
  //
1584
1665
  // 2026-05-12: collect text from ALL assistant messages added during
1585
- // this turn, not just the final one. Skills like /abap-cca emit
1666
+ // this turn, not just the final one. Skills like /abap-cca-merge emit
1586
1667
  // their manifest in an early message before a closing ask_question
1587
1668
  // widget; the final wrap-up message ends the turn but doesn't
1588
1669
  // carry the manifest. See ./turn-assistant-text.ts for details.
@@ -1594,6 +1675,7 @@ ${imageExpansion.error}`));
1594
1675
  // while the user was the one being waited on (Laeeq's dry run). Pause
1595
1676
  // for the whole hook — the row hides while paused — and resume after,
1596
1677
  // so any real work that follows (auto-compact) shows again.
1678
+ let projectTagChanged = false;
1597
1679
  status.pause();
1598
1680
  try {
1599
1681
  if (!params.suppressSaveHook) {
@@ -1615,6 +1697,14 @@ ${imageExpansion.error}`));
1615
1697
  const flowLane = getAppFlow(session)?.lane;
1616
1698
  return flowLane ? { lane: { ...flowLane, cwd: params.ctx.cwd } } : {};
1617
1699
  })(),
1700
+ // Sessions/folders (Task 6) — the first save tags the session with
1701
+ // its folder's project id, only when the session belongs to this
1702
+ // folder (ruling 18). Saved to disk right after the hook.
1703
+ onProjectId: (id) => { if (tagSessionOnSave(session, process.cwd(), id))
1704
+ projectTagChanged = true; },
1705
+ // Guided clean-core journey (Task 4) — the queued triage's rows.
1706
+ ...(!readerChild && !params.ctx.subagent && getJourneyFlow(session)?.queued?.stage === 'DECIDE'
1707
+ ? { journey: journeySaveFor(session, emit) } : {}),
1618
1708
  // Task 7 — subagent / reader turns never drive the app chain.
1619
1709
  ...(!readerChild && !params.ctx.subagent ? {
1620
1710
  appChain: {
@@ -1631,6 +1721,32 @@ ${imageExpansion.error}`));
1631
1721
  keepalive.disarm();
1632
1722
  status.resume();
1633
1723
  }
1724
+ // Sessions/folders (Task 6) — the session was saved before the hook;
1725
+ // persist the project id the save just filled in. Best-effort.
1726
+ if (projectTagChanged) {
1727
+ try {
1728
+ await saveSession(session);
1729
+ }
1730
+ catch { /* tagging never fails the turn */ }
1731
+ }
1732
+ // Guided clean-core journey (Task 3) — the queued step's turn ended:
1733
+ // derive the stage from the folder again and go on (the next code
1734
+ // stage, a step card, or the next queued command).
1735
+ if (!readerChild && !params.ctx.subagent && getJourneyFlow(session)?.queued) {
1736
+ try {
1737
+ await afterJourneyTurn(journeyHooks(params, emit), {
1738
+ skill: params.skill,
1739
+ awaitingAnswer: session.awaitingSkillAnswer === true,
1740
+ assistantText,
1741
+ });
1742
+ }
1743
+ catch (err) {
1744
+ if (process.env['CSPEACH_DEBUG'] === '1')
1745
+ throw err;
1746
+ clearJourneyFlow(session);
1747
+ emit(chalk.yellow('The clean-core steps stopped here. Say carry on to continue.'));
1748
+ }
1749
+ }
1634
1750
  // Phase 2 #9 (2026-05-16) — auto-compact end-of-turn hook.
1635
1751
  // Runs only on the success path (after maybeOfferSave) so an interrupted
1636
1752
  // turn never triggers a summariser call on partial state. The maybeAutoCompact
@@ -202,9 +202,9 @@ export async function migrateLegacyByokKey(deps) {
202
202
  export function legacyByokKeyNotice(outcome) {
203
203
  switch (outcome) {
204
204
  case 'conflict':
205
- return 'cspeach: ~/.cspeach/auth.json still holds a pasted Anthropic API key in plain text that is different from the one in your OS keychain (the one CSPeach uses). It was kept in case it is your only copy — remove the "byokAnthropicKey" line from auth.json once you no longer need it.';
205
+ return 'cspeach: auth.json in your CSPeach home still holds a pasted Anthropic API key in plain text that is different from the one in your OS keychain (the one CSPeach uses). It was kept in case it is your only copy — remove the "byokAnthropicKey" line from auth.json once you no longer need it.';
206
206
  case 'keychain-unavailable':
207
- return 'cspeach: ~/.cspeach/auth.json still holds your Anthropic API key in plain text — the OS keychain is not available, so it could not be moved there (on Linux: install and unlock a Secret Service keyring such as gnome-keyring). CSPeach will move it on the next start once the keychain works.';
207
+ return 'cspeach: auth.json in your CSPeach home still holds your Anthropic API key in plain text — the OS keychain is not available, so it could not be moved there (on Linux: install and unlock a Secret Service keyring such as gnome-keyring). CSPeach will move it on the next start once the keychain works.';
208
208
  default:
209
209
  return null;
210
210
  }
package/dist/cli.js CHANGED
@@ -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,
@@ -234,6 +241,11 @@ export async function main(argv) {
234
241
  console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|leave');
235
242
  return 1;
236
243
  }
244
+ // Sessions/folders (2026-10-08): sort project files from earlier builds into their folders. Copy only.
245
+ if (args[0] === 'files' && args[1] === 'sort') {
246
+ const { runFilesSort } = await import('./projects/sort-command.js');
247
+ return runFilesSort(args.slice(2), { log: (s) => console.log(s), cwd: process.cwd() });
248
+ }
237
249
  if (args[0] === 'analyze' && args[1] === 'project-context') {
238
250
  const { runProjectContextImpactCommand } = await import('./commands/project-context-impact.js');
239
251
  return runProjectContextImpactCommand(args.slice(2));
@@ -270,7 +282,8 @@ Usage:
270
282
  cspeach --one-shot "<prompt>" Same as above, explicit flag
271
283
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
272
284
  cspeach --no-sap Start without a SAP system (paste-mode skills only)
273
- cspeach --resume [id] Resume a previous session (list if no id)
285
+ cspeach --continue, -c Continue the latest session in this folder
286
+ cspeach --resume [id] Pick a session from this folder (or all folders); an id works anywhere
274
287
  cspeach config add-sap Add a SAP system interactively
275
288
  cspeach config standalone Set the standalone SAP profile (no system)
276
289
  cspeach standards init Capture your team's ABAP development standards
@@ -283,14 +296,27 @@ Usage:
283
296
  cspeach login Sign in via device-code flow
284
297
  cspeach logout Sign out (keep keychain key, if any)
285
298
  cspeach whoami Print current account info
299
+ cspeach team discover Read the whole estate from SAP into this folder (read only)
300
+ cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
286
301
  cspeach team init <folder> Lead: pull the object list from SAP, start a team project
287
302
  cspeach team join <folder> Record your results against a team project (on this laptop only)
288
303
  cspeach team lane add|list Lead: who owns which packages
304
+ cspeach team lane propose --developers "A,B" [--apply] Lead: a first cut of the lanes, balanced by the work
305
+ cspeach team focus "<label>" --packages … --reason "…" Lead: what the team works on now (focus list | clear <label>)
306
+ cspeach team priority show|set|reset The priority rules; set and reset are the lead's
307
+ cspeach team plan move|scope|defer|set|note|history Lead: per-object plan changes, each with a reason
308
+ cspeach team plan import <file.xlsx> [--yes] Lead: read back the Plan sheet edited in Excel; shows the changes, asks once
289
309
  cspeach team refresh --lane <id> Measure with ATC: what is verified, what did not hold
310
+ cspeach team refresh --all --per-package [--again] One record per package; skips packages already measured
290
311
  cspeach team status [folder] Where every object stands, who owns it, what is proven
291
312
  cspeach team outbox What this laptop has recorded, and where
292
- cspeach team rule T N <d> Lead: settle a conflicting decision
313
+ cspeach team rule T N <d> Lead: settle a conflicting decision (a retire needs --reason)
314
+ cspeach team decide T N <d> Record a clean-core decision: keep, fix, rebuild, retire or exception (a retire needs --reason)
315
+ cspeach team fixed T N Claim a fix; the next refresh checks it
316
+ cspeach team sheet Write your working list (Excel) from the team project
317
+ cspeach team report Lead: the combined workbook and status page, in reports
293
318
  cspeach team leave Stop recording
319
+ cspeach files sort [--apply <plan>] [--clean <plan>] Sort files from earlier builds into their folders (copies; --clean deletes checked originals)
294
320
  cspeach analyze project-context Compare flag-on vs flag-off session metrics
295
321
  cspeach --upgrade Upgrade to latest via npm
296
322
  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;
@@ -97,7 +97,7 @@ export async function runLoginCommand(deps) {
97
97
  apiKey: polled.api_key,
98
98
  customerId: polled.customer_id,
99
99
  });
100
- deps.print(done('Saved your CSPeach key to ~/.cspeach/auth.json'));
100
+ deps.print(done('Saved your CSPeach key to auth.json in your CSPeach home'));
101
101
  // 6) /v1/me — figure out if BYOK is required.
102
102
  try {
103
103
  const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
@@ -10,7 +10,7 @@ export async function runLogoutCommand(deps) {
10
10
  const before = await readAuthFile();
11
11
  await deleteAuthFile();
12
12
  if (before) {
13
- deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed ~/.cspeach/auth.json`);
13
+ deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed auth.json from your CSPeach home`);
14
14
  deps.print(` ${paint('dim', 'The REPL uses the keychain-stored key if you have one.')}`);
15
15
  deps.print(` ${paint('dim', 'Next:')} cspeach login (to sign in again)`);
16
16
  }
@@ -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,15 @@ 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
+ }
34
41
  export function isGuardedRunActive() {
35
- return guardedRunActive;
42
+ return guardedRunActive || journeyRunActive;
36
43
  }
37
44
  /* ── plan-phase read-only enforcement (Task 7, 2026-07-03) ─────────────────
38
45
  * Module-level flag consulted by tools/subagent/agent_run.ts. The REPL sets
@@ -260,7 +260,7 @@ export async function preparePlanResume(args) {
260
260
  // skip this entirely.
261
261
  // R5 fix round: the stored phases must also fit the lane, checked here
262
262
  // before any phase, lease or transport work.
263
- const laneGate = await loadPlanLaneForRun(args.cwd, content);
263
+ const laneGate = await loadPlanLaneForRun(args.cwd, content, path);
264
264
  if (!laneGate.ok) {
265
265
  args.log(...laneGate.lines);
266
266
  return null;
@@ -632,7 +632,7 @@ export async function preparePlanResume(args) {
632
632
  // 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
633
633
  // from the file the plan was made from (or the plan's creation snapshot).
634
634
  // 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);
635
+ const sourcesBlock = renderPlanSourcesBlock(await resolvePlanSources({ cwd: args.cwd, envelope, content, planPath: path, ...(laneRecord ? { laneRecord } : {}) }), next);
636
636
  const llmPrompt = [
637
637
  `Resume execution of the project plan "${envelope.title}" (envelope v${envelope.version}).`,
638
638
  '',
@@ -981,14 +981,9 @@ export async function finishPlanResume(args) {
981
981
  logNotSaved(args, 'the updated plan did not pass its own checks', `revision failed validation: ${check.error.message}`);
982
982
  return null;
983
983
  }
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 });
984
+ // 2026-10-08 — the new version is saved beside the plan file, wherever it
985
+ // is (an old git root, a copied folder), so a plan family never splits.
986
+ const savedPath = await savePlanVersion(revision, args.prepared.path, args.log);
992
987
  // Task 8 — regenerate the write-only markdown handover projection beside
993
988
  // the envelope. Best-effort: a failed projection write logs one dim line
994
989
  // and never blocks the run (the envelope is the source of truth).
@@ -1048,6 +1043,23 @@ export async function finishPlanResume(args) {
1048
1043
  nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
1049
1044
  };
1050
1045
  }
1046
+ /**
1047
+ * Final fix wave, item 5 — save a new plan version beside the plan file. When
1048
+ * that write fails (a read-only or missing folder), save it in the working
1049
+ * folder instead, so the run's progress is never lost, and say where.
1050
+ */
1051
+ async function savePlanVersion(revision, planPath, log) {
1052
+ const beside = dirname(planPath);
1053
+ try {
1054
+ return await saveProject(revision, { cwd: beside });
1055
+ }
1056
+ catch {
1057
+ const ws = await ensureWorkspace();
1058
+ const saved = await saveProject(revision, { cwd: ws });
1059
+ log(`Could not save beside the plan in ${beside}. Saved in ${ws} instead.`);
1060
+ return saved;
1061
+ }
1062
+ }
1051
1063
  /**
1052
1064
  * Shared revision writer for audit verdicts and chain-stop records: load the
1053
1065
  * envelope, patch ONE phase (audit and/or status) + content.lastStop,
@@ -1103,14 +1115,8 @@ async function savePlanPatch(envelopePath, phaseId, patch, log) {
1103
1115
  if (!check.ok) {
1104
1116
  throw new Error(`audit revision failed validation: ${check.error.message}`);
1105
1117
  }
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 });
1118
+ // 2026-10-08 — saved beside the plan file (the working folder only as a fallback).
1119
+ const saved = await savePlanVersion(revision, envelopePath, log ?? console.log);
1114
1120
  // Task 8 — every envelope save in the plan flow regenerates the markdown
1115
1121
  // handover. This is the single chokepoint for ALL chain writes:
1116
1122
  // applyAuditResult and applyPhaseResolution both route through here.
@@ -1,15 +1,45 @@
1
+ // cspeach-cli/src/commands/plan-startup.ts
2
+ //
3
+ // 2026-10-04 (continuous run, spec §3.5) — typing `cspeach` in the project
4
+ // folder offers to continue an unfinished plan:
5
+ // one plan → "Continue 'FI Document Aging' (3 of 6 done, next: Service)?"
6
+ // several → "Continue an unfinished plan?", newest first (max 5), + "Not now"
7
+ // Approvals do not survive a restart (the session is new), so Continue shows
8
+ // the plan's object list again and installs a fresh object-list grant
9
+ // (installPlanGrant: same policy as "Build this now?"), then submits the plain
10
+ // resume command (no mode flag: the user's plan_mode decides).
11
+ //
12
+ // F13 — a resume the user typed in guarded mode, with no grant for that plan,
13
+ // shows the same object list once before the step (offerObjectListOnResume).
14
+ // Yes installs the grant; No runs the step with a card per write. Never on a
15
+ // chain-queued hop, never twice for one Continue.
16
+ import { basename, dirname } from 'node:path';
1
17
  import { readProjectFile } from '../projects/status.js';
2
18
  import { parsePlanContent } from '../projects/plan-schema.js';
3
19
  import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, statusesFromItems, } from '../projects/plan-run.js';
4
20
  import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
5
21
  import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
6
22
  import { newestPlanFamilyFiles } from '../session/recap.js';
23
+ import { legacyDirsFor } from '../projects/project-folder.js';
7
24
  import { getPlanGrant, installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
8
25
  import { DEFAULT_PLAN_MODE } from './plan-gate.js';
9
- /** Plans in the folder with a step left to build, newest first. */
10
- export function listUnfinishedPlans(workspaceDir, max = 5) {
26
+ /**
27
+ * Plans in the folder with a step left to build, newest first. With `cwd`,
28
+ * the old save locations for that folder are read too (Task 11, one
29
+ * release), after the new folder; a same-name file in the new folder wins.
30
+ */
31
+ export function listUnfinishedPlans(workspaceDir, max = 5, cwd) {
11
32
  const out = [];
12
- for (const f of newestPlanFamilyFiles(workspaceDir)) {
33
+ const dirs = [workspaceDir, ...(cwd ? safeLegacyDirs(cwd) : [])];
34
+ const seen = new Set();
35
+ const files = dirs.flatMap((d, i) => newestPlanFamilyFiles(d).map((f) => ({ ...f, earlier: i > 0 }))).filter((f) => {
36
+ const name = basename(f.path).toLowerCase();
37
+ if (seen.has(name))
38
+ return false;
39
+ seen.add(name);
40
+ return true;
41
+ });
42
+ for (const f of files) {
13
43
  if (out.length >= max)
14
44
  break;
15
45
  let env;
@@ -42,13 +72,23 @@ export function listUnfinishedPlans(workspaceDir, max = 5) {
42
72
  nextTitle: phaseDisplayTitle(next, phases),
43
73
  objects: collectPlannedObjects(pc.content),
44
74
  ...(transport ? { transport } : {}),
75
+ ...(f.earlier ? { earlier: true } : {}),
45
76
  });
46
77
  }
47
78
  return out;
48
79
  }
80
+ function safeLegacyDirs(cwd) {
81
+ try {
82
+ return legacyDirsFor(cwd);
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
49
88
  function progress(p) {
50
89
  return `${p.done} of ${p.total} done, next: ${p.nextTitle ?? 'the independent check'}`;
51
90
  }
91
+ const EARLIER = '(earlier location)';
52
92
  export function continueQuestion(p) {
53
93
  return `Continue '${p.title}' (${progress(p)})?`;
54
94
  }
@@ -97,7 +137,7 @@ function grantInstalledFor(planId, sessionId) {
97
137
  && typeof g.expiresAt === 'number' && Date.now() < g.expiresAt;
98
138
  }
99
139
  export async function offerContinueAtStartup(a) {
100
- const plans = listUnfinishedPlans(a.workspaceDir);
140
+ const plans = listUnfinishedPlans(a.workspaceDir, 5, a.cwd);
101
141
  if (plans.length === 0)
102
142
  return 'none';
103
143
  const allowed = a.grantAllowed ?? planGrantAllowed;
@@ -109,7 +149,10 @@ export async function offerContinueAtStartup(a) {
109
149
  grantable = p.objects.length > 0 && await allowed(a.sapAlias).catch(() => false);
110
150
  const v = await a.choose({
111
151
  title: continueQuestion(p),
112
- facts: objectListFacts(p.objects, p.transport, grantable),
152
+ facts: [
153
+ ...(p.earlier ? [`This plan is in an earlier location: ${dirname(p.path)}`] : []),
154
+ ...objectListFacts(p.objects, p.transport, grantable),
155
+ ],
113
156
  choices: [{ value: 'continue', label: 'Continue' }, { value: 'later', label: 'Not now' }],
114
157
  });
115
158
  if (v === 'continue') {
@@ -122,7 +165,7 @@ export async function offerContinueAtStartup(a) {
122
165
  title: 'Continue an unfinished plan?',
123
166
  facts: ['Newest first.'],
124
167
  choices: [
125
- ...plans.map((p, i) => ({ value: String(i), label: `${p.title} (${progress(p)})` })),
168
+ ...plans.map((p, i) => ({ value: String(i), label: `${p.title} (${progress(p)})${p.earlier ? ` ${EARLIER}` : ''}` })),
126
169
  { value: 'later', label: 'Not now' },
127
170
  ],
128
171
  });