@cspeach/cli 1.1.52 → 1.1.54

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 (85) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +150 -17
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +17 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/auth/auth-file.js +3 -0
  9. package/dist/auth/key-resolver.js +8 -3
  10. package/dist/auth/me.js +6 -2
  11. package/dist/cli.js +22 -0
  12. package/dist/commands/login.js +22 -4
  13. package/dist/commands/plan-chain.js +2 -2
  14. package/dist/commands/plan-reground.js +5 -0
  15. package/dist/commands/plan-resume.js +2 -2
  16. package/dist/commands/plan-startup.js +3 -3
  17. package/dist/commands/sap-link.js +14 -1
  18. package/dist/commands/team-build-help.js +19 -0
  19. package/dist/commands/team-build.js +97 -0
  20. package/dist/commands/team-plan.js +2 -2
  21. package/dist/commands/team-spec.js +107 -0
  22. package/dist/commands/team.js +74 -4
  23. package/dist/docx/doc-meta.js +1 -0
  24. package/dist/docx/markdown-to-docx.js +2 -0
  25. package/dist/journey/chain.js +8 -0
  26. package/dist/journey/clean-core.js +2 -2
  27. package/dist/journey/rebuild.js +8 -0
  28. package/dist/journey/run.js +9 -0
  29. package/dist/lane/app-chain.js +13 -6
  30. package/dist/licence/paths.js +5 -0
  31. package/dist/licence/seat-kind.js +110 -0
  32. package/dist/licence/seat-state.js +4 -0
  33. package/dist/licence/terminal-seat.js +43 -0
  34. package/dist/licence/turn-gate.js +4 -0
  35. package/dist/one-shot.js +33 -0
  36. package/dist/project-context/conventions.js +8 -1
  37. package/dist/project-context/index-files.js +97 -34
  38. package/dist/project-context/index.js +1 -1
  39. package/dist/project-context/render.js +38 -4
  40. package/dist/projects/answer-blockers.js +133 -18
  41. package/dist/projects/blocker-answers-note.js +89 -0
  42. package/dist/projects/change-options.js +70 -0
  43. package/dist/projects/document-save.js +86 -0
  44. package/dist/projects/extract-design.js +4 -0
  45. package/dist/projects/extract-estimate.js +4 -0
  46. package/dist/projects/extract-spec-gap.js +8 -0
  47. package/dist/projects/index.js +1 -1
  48. package/dist/projects/plan-objects.js +24 -0
  49. package/dist/projects/plan-run.js +12 -2
  50. package/dist/projects/save-command.js +83 -20
  51. package/dist/projects/saved-lines.js +19 -17
  52. package/dist/projects/spec-share.js +239 -0
  53. package/dist/register/picture.js +2 -2
  54. package/dist/register/store.js +19 -0
  55. package/dist/renderer/change-card.js +14 -0
  56. package/dist/renderer/html-entities.js +25 -0
  57. package/dist/renderer/machine-blocks.js +54 -7
  58. package/dist/renderer/tool-labels.js +2 -0
  59. package/dist/renderer/tool-widget.js +20 -5
  60. package/dist/repl/builtin-commands.js +4 -3
  61. package/dist/repl/chat-lane.js +10 -1
  62. package/dist/repl/file-picker.js +52 -5
  63. package/dist/repl/mode-cycle.js +12 -0
  64. package/dist/repl/open-questions-gate.js +159 -0
  65. package/dist/repl/ready-phrase.js +108 -0
  66. package/dist/repl/startup-account.js +64 -1
  67. package/dist/repl/team-build-screen.js +116 -0
  68. package/dist/repl/team-build-start.js +178 -0
  69. package/dist/repl.js +216 -11
  70. package/dist/sap-link/policy.js +4 -0
  71. package/dist/sap-link/registry.js +9 -3
  72. package/dist/sap-link/server.js +4 -1
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/user-prompt.js +2 -0
  75. package/dist/tools/_team-work-path.js +1 -1
  76. package/dist/tools/approval.js +12 -1
  77. package/dist/tools/cap/sap-link-tool.js +2 -0
  78. package/dist/tools/project/spec_answers_record.js +243 -0
  79. package/dist/tools/project/team_findings.js +2 -2
  80. package/dist/tools/seat-gate.js +41 -0
  81. package/dist/tools/tool-loading.js +2 -0
  82. package/dist/ui/approval-modal.js +2 -1
  83. package/dist/ui/widgets/ask-card.js +171 -29
  84. package/dist/ui/widgets/files-card.js +19 -1
  85. package/package.json +2 -2
@@ -26,6 +26,7 @@ How you talk
26
26
  - Plain words, short sentences. Use headings, tables or lists only when they make the answer easier to read.
27
27
  - Have a view. If something is a bad idea, say so and say why. If you're not sure, say what you'd check.
28
28
  - When there is a next step, say what you would do in your own words. Do not end a reply with a word, phrase or command for the person to type to go on. If they ask which command does something, name it. If the next step is a workflow and they have asked for it, call hand_off now: the workflow shows its plan and asks before it writes. If it is a real choice, ask the question.
29
+ - When there is a clear next step, end with it as one question card (next step recommended + 'Not now'). A step that only saves local files or plans runs on yes without asking again. SAP writes still show their approval card. Use ask_question for the card.
29
30
  - A playbook's report header (## [abap-x] — object, TL;DR, Headline, Top 3, Verdict, Next) goes only in a saved document and in the workflow's report after the person typed an /abap-… command. Not after your own hand_off, and not in other turns: there, answer as you would in conversation.
30
31
  - After a write, say what changed and what is next. Do not explain approvals, risk ratings or activation mechanics unless asked. If something failed or stayed inactive, say so.
31
32
  - No flattery. Apologise only when you got something wrong.
@@ -1,7 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { basename } from 'node:path';
2
+ import { basename, dirname } from 'node:path';
3
3
  import { dispatchTool } from './tool-dispatch.js';
4
4
  import { listTools, getTool, isStandalone, toolsForContext, toAnthropicTools } from '../tools/index.js';
5
+ import { toolsForSeat } from '../tools/seat-gate.js';
6
+ import { getSeatKind } from '../licence/seat-state.js';
5
7
  import { saveSession } from '../session/store.js';
6
8
  import { tagSessionOnSave } from '../session/project-match.js';
7
9
  import { recordCompletedToolCall } from '../session/pending.js';
@@ -9,7 +11,7 @@ import { loadConfig } from '../config/loader.js';
9
11
  import { resolveModelRole, resolveEffort, modelAcceptsEffort, resolveAutoCompactThreshold, callModelFor } from '../models/resolve.js';
10
12
  import { getCustomerModelConfig, managedProxyServesNewShapes, servedToolLoading } from '../models/server-config.js';
11
13
  import { familyOf, familyAcceptsEffort } from '../models/model-identity.js';
12
- import { buildToolList, buildSkillToolAddition, placeSystemMessagesInPlace, resolveToolLoading, } from '../tools/tool-loading.js';
14
+ import { buildToolList, buildSkillToolAddition, buildToolAdditionMessage, deferredNames, surfacedToolNames, placeSystemMessagesInPlace, resolveToolLoading, } from '../tools/tool-loading.js';
13
15
  import { settleSessionToolLoading } from './tool-loading-pin.js';
14
16
  import { resolveSkillAlias } from '../skill-catalog.js';
15
17
  import { retryWithBackoff } from './retry.js';
@@ -46,6 +48,7 @@ import { enrichUserMessage } from '../router/intent-extractor.js';
46
48
  import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
47
49
  import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
48
50
  import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
51
+ import { blockerAnswersNote, SPEC_ANSWERS_RECORD_TOOL } from '../projects/blocker-answers-note.js';
49
52
  import { planLaneBlockForTurn } from '../projects/plan-lane.js';
50
53
  import { DOCUMENT_SKILLS, WORD_AFTER_ARTIFACT_SAVE } from '../projects/document-skills.js';
51
54
  import { metaFromMarkdown } from '../docx/doc-meta.js';
@@ -70,7 +73,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
70
73
  import { renderStandardsBlock } from '../standards/standards-file.js';
71
74
  import { composeSessionContextBlock } from './session-context.js';
72
75
  import { objectKeyFromInput } from './retry-key.js';
73
- import { repairPartialBlocks, stripPreFallbackBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly } from './repair-partial.js';
76
+ import { repairPartialBlocks, stripPreFallbackBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError, stripUnsignedThinking, isThinkingOnly, dropTrailingThinkingOnly, closedOffStepsLine } from './repair-partial.js';
74
77
  import { drainSteeringEntries, enqueueSteering } from './steering-queue.js';
75
78
  import { steeringReadLine } from '../renderer/steering-echo.js';
76
79
  import { TurnStreamWriter } from './turn-stream.js';
@@ -432,6 +435,19 @@ function localToday() {
432
435
  * package) loads only here, inside the catch: a broken docx install costs
433
436
  * one line after the save and never the save itself.
434
437
  */
438
+ /** A spec gap saved into a team build's shared specs/ folder (merge of main into feat/team-build). */
439
+ async function isSharedSpecSave(cwd, saveSkill, savedPath) {
440
+ if (saveSkill !== 'abap-spec-gap')
441
+ return false;
442
+ try {
443
+ const { sharedSpecTargetFor, isSharedSpecPath } = await import('../projects/spec-share.js');
444
+ const shared = sharedSpecTargetFor(cwd, 'spec-gap');
445
+ return !!shared && isSharedSpecPath(shared.teamDir, savedPath);
446
+ }
447
+ catch {
448
+ return false;
449
+ }
450
+ }
435
451
  async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
436
452
  try {
437
453
  const { offerWordCopyAfterArtifactSave } = await import('../projects/document-save.js');
@@ -448,9 +464,51 @@ async function offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm) {
448
464
  p.emit(formatInfo(`Word copy not written: ${e.message}`).trimEnd());
449
465
  }
450
466
  }
467
+ /**
468
+ * Change options (2026-10-23) — an answer carrying the change-options marker
469
+ * pair gets its marked part saved (every complete pair, in order: a level
470
+ * picked in session adds the decided document), whatever the skill and even when the turn
471
+ * ends on the level question. Never on a sub-agent turn or inside a guided
472
+ * flow. Returns true when the marker check handled the turn (the question was
473
+ * asked or the document saved), so maybeOfferSave runs nothing else. An answer
474
+ * with no complete marker pair returns false and takes today's path unchanged.
475
+ */
476
+ async function maybeOfferChangeOptions(p, confirm) {
477
+ if (p.subagent || p.guided || p.assistantText.includes('<!-- guided-run:'))
478
+ return false;
479
+ if (!p.assistantText.includes('csforge:change-options'))
480
+ return false;
481
+ const { extractChangeOptions } = await import('../projects/change-options.js');
482
+ // Every complete pair, in order: a level picked in session shows the
483
+ // document again with the decision, and that one is saved too (last).
484
+ const docs = extractChangeOptions(p.assistantText).filter((d) => d.markdown.trim().length > 0);
485
+ if (docs.length === 0)
486
+ return false;
487
+ // Set before the save question, so a declined save still routes the level reply.
488
+ if (p.session && !p.subagent)
489
+ p.session.lastChangeOptions = { object: docs[docs.length - 1].object, skill: p.skill };
490
+ try {
491
+ const { offerChangeOptionsSave } = await import('../projects/document-save.js');
492
+ const { loadConfig, resolveWordCopy } = await import('../config/loader.js');
493
+ await offerChangeOptionsSave({
494
+ docs: docs.map((d) => ({ object: d.object, markdown: d.markdown })), cwd: process.cwd(), today: localToday(),
495
+ sapSystem: p.sapSystem ?? null,
496
+ ...(p.sessionId ? { sessionId: p.sessionId } : {}),
497
+ ...(p.operator ? { operator: p.operator } : {}),
498
+ headless: isHeadless(), wordCopy: resolveWordCopy(await loadConfig()), confirm, emit: p.emit,
499
+ });
500
+ }
501
+ catch (e) {
502
+ p.emit(formatInfo(`Not saved. ${e.message}`).trimEnd());
503
+ }
504
+ return true;
505
+ }
451
506
  export async function maybeOfferSave(p) {
452
507
  if (p.assistantText.trim().length === 0)
453
508
  return;
509
+ // Change options (2026-10-23): the marker check runs first, for every skill.
510
+ if (await maybeOfferChangeOptions(p, p.confirm ?? inkAwareConfirm))
511
+ return;
454
512
  // Word export (2026-10-19): reviews and handovers are prose documents, not
455
513
  // envelopes. They get a Markdown save under cspeach-out and the Word
456
514
  // question (projects/document-save.ts). Nothing below this branch changes
@@ -547,7 +605,7 @@ export async function maybeOfferSave(p) {
547
605
  tokensUsed: p.tokensUsed,
548
606
  model: p.model,
549
607
  author: getAuthorIdentity(),
550
- cwd: process.cwd(),
608
+ cwd: p.cwd ?? process.cwd(),
551
609
  prompt,
552
610
  // Piece 2 / Q3 — interactive saves ask a Yes/No card; headless passes
553
611
  // no confirm so the blanket-'y' prompt above still answers (B5 contract).
@@ -562,6 +620,7 @@ export async function maybeOfferSave(p) {
562
620
  ...(p.latestSkeleton && p.skill === saveSkill ? { latestSkeleton: p.latestSkeleton } : {}),
563
621
  onNotSaved: (reason) => { notSaved = reason; },
564
622
  ...(p.onProjectId ? { onProjectId: p.onProjectId } : {}),
623
+ ...(p.sapSystem ? { sapSystem: p.sapSystem } : {}),
565
624
  // Never headless: a headless journey queues nothing.
566
625
  ...(p.journey && !isHeadless() && p.skill === saveSkill ? { journey: p.journey } : {}),
567
626
  });
@@ -569,7 +628,9 @@ export async function maybeOfferSave(p) {
569
628
  // is a document (spec gaps, design, estimate, plan, upgrade scan/report)
570
629
  // gets the same Word step as a review: `word_copy` decides, headless
571
630
  // never asks. The project file itself is untouched. Sub-agent turns: no.
572
- if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]) {
631
+ // A team build shared spec already has its Word copy beside it (save-command.ts).
632
+ if (savedPath && !p.subagent && WORD_AFTER_ARTIFACT_SAVE[saveSkill]
633
+ && !(await isSharedSpecSave(p.cwd ?? process.cwd(), saveSkill, savedPath))) {
573
634
  await offerWordAfterArtifactSave(p, saveSkill, savedPath, confirm);
574
635
  }
575
636
  // After a spec-gap file is saved, let a developer answer the open BLOCKER
@@ -577,7 +638,31 @@ export async function maybeOfferSave(p) {
577
638
  // business questions stay open for a consultant to answer in the viewer.
578
639
  // Uses inkAwarePrompt directly (NOT the save hook's blanket-'y' headless
579
640
  // prompt): in headless it returns '' → declined, so nothing is written.
641
+ // Task 16 (F-M): what the form recorded reaches the model as one note on
642
+ // its next call (session.blockerAnswersNote, sent by runTurn). Main turns
643
+ // with a session only; declined / not offered / not saved → no note.
580
644
  if (savedPath && saveSkill === 'abap-spec-gap') {
645
+ // Team build (Task 9): a spec in the shared specs/ folder is never
646
+ // rewritten — each answer is a record of its own, and the spec is read
647
+ // with the records applied. Found by the folder (R10), never activeProject().
648
+ const { sharedSpecTargetFor, isSharedSpecPath, composeSpec, readAnswers, writeAnswerRecord, specAuthorFor } = await import('../projects/spec-share.js');
649
+ const { readProjectFile } = await import('../projects/status.js');
650
+ const shared = sharedSpecTargetFor(p.cwd ?? process.cwd(), 'spec-gap');
651
+ const records = shared && isSharedSpecPath(shared.teamDir, savedPath)
652
+ ? {
653
+ // R11: only the answers recorded for this envelope.
654
+ readEnvelope: (path) => composeSpec(readProjectFile(path), readAnswers(dirname(path)).filter((a) => a.file === basename(path))),
655
+ recordAnswer: (a) => {
656
+ const specDir = dirname(savedPath);
657
+ writeAnswerRecord(specDir, {
658
+ projectId: shared.projectId, slug: basename(specDir), file: basename(savedPath),
659
+ gapId: a.gapId, answer: a.answer, by: specAuthorFor(shared.projectId, a.identity.name), at: a.at,
660
+ });
661
+ },
662
+ }
663
+ : {};
664
+ const noteSession = p.session && !p.subagent ? p.session : undefined;
665
+ let outcomes;
581
666
  await offerAnswerBlockers({
582
667
  path: savedPath,
583
668
  identity: getAuthorIdentity(),
@@ -585,7 +670,18 @@ export async function maybeOfferSave(p) {
585
670
  confirm: inkAwareConfirm,
586
671
  choose: inkAwareChoose,
587
672
  log: (...lines) => lines.forEach((l) => p.emit(l)),
673
+ ...records,
674
+ ...(noteSession ? { onOutcomes: (o) => { outcomes = o; } } : {}),
588
675
  });
676
+ if (noteSession && outcomes) {
677
+ // The path is shown against the read tool's base (ctx.cwd), so the model opens it with file_read.
678
+ const note = blockerAnswersNote({
679
+ path: savedPath, cwd: p.readCwd ?? process.cwd(), outcomes,
680
+ ...('readEnvelope' in records ? { readEnvelope: records.readEnvelope } : {}),
681
+ });
682
+ if (note)
683
+ noteSession.blockerAnswersNote = note;
684
+ }
589
685
  }
590
686
  // App lane routing (Task 7) — the harness-owned /abap-app chain. Built
591
687
  // from the exact saved path, never from model text. Outside an app flow
@@ -1068,7 +1164,12 @@ ${imageExpansion.error}`));
1068
1164
  if (mainTurn && !inlineTurn && historyHoldsInline(session.messages)) {
1069
1165
  playbookLine = isChatSkill(params.skill) ? playbookLineSystemChat() : playbookLineSystem(params.skill);
1070
1166
  }
1071
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, playbookLine]);
1167
+ // Task 16 (F-M) — what the blocker form recorded after the last turn: once,
1168
+ // on the next main turn (never a sub-agent or reader child), then dropped.
1169
+ const blockerNote = mainTurn && session.blockerAnswersNote
1170
+ ? `<blocker_answers>\n${session.blockerAnswersNote}\n</blocker_answers>`
1171
+ : '';
1172
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote, blockerNote, playbookLine]);
1072
1173
  // Deterministically extract structured facts from the user's prompt
1073
1174
  // (package, transport, object names, environment hint, create intent)
1074
1175
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -1084,11 +1185,12 @@ ${imageExpansion.error}`));
1084
1185
  // Final batch 1 — a caller-enriched message still gets the switch note,
1085
1186
  // inside its own <session_context> block.
1086
1187
  // Stage 1A — the <playbook> line rides the same way ('' ⇒ unchanged).
1087
- const callerNotes = composeSessionContextBlock([switchNote, playbookLine]);
1188
+ const callerNotes = composeSessionContextBlock([switchNote, blockerNote, playbookLine]);
1088
1189
  const enriched = userMessageForLLM.startsWith('<session_context>')
1089
1190
  ? (callerNotes ? userMessageForLLM.replace('<session_context>', `<session_context>\n ${callerNotes}`) : userMessageForLLM)
1090
1191
  : enrichUserMessage(userMessageForLLM, sessionContextBlock);
1091
1192
  const switchNoteUsed = switchNote !== '' && enriched.includes(switchNote);
1193
+ const blockerNoteUsed = blockerNote !== '' && enriched.includes(blockerNote);
1092
1194
  // Phase 2 of Track B — opt-in project-context enrichment, gated by
1093
1195
  // CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
1094
1196
  // identical to today. When on, the rendered <project_context> block
@@ -1116,6 +1218,7 @@ ${imageExpansion.error}`));
1116
1218
  projectContextBlock = await maybeBuildProjectContext({
1117
1219
  cwd: params.ctx.cwd,
1118
1220
  sapConnection,
1221
+ emit: (line) => emit(chalk.dim(line)),
1119
1222
  });
1120
1223
  }
1121
1224
  const contextualContent = projectContextBlock
@@ -1137,6 +1240,9 @@ ${imageExpansion.error}`));
1137
1240
  lane: session.lane,
1138
1241
  tool_set_skill: session.tool_set_skill,
1139
1242
  turn_seq: session.turn_seq,
1243
+ // Task 16 fix round 1 (I-1, R17): the one-time notes this turn consumes.
1244
+ systemSwitchNote: session.systemSwitchNote,
1245
+ blockerAnswersNote: session.blockerAnswersNote,
1140
1246
  };
1141
1247
  let round0Steers = [];
1142
1248
  // Images (if any) ride as blocks ahead of the text; with none this is the
@@ -1152,6 +1258,8 @@ ${imageExpansion.error}`));
1152
1258
  // it is in a pushed user message.
1153
1259
  if (switchNoteUsed)
1154
1260
  delete session.systemSwitchNote;
1261
+ if (blockerNoteUsed)
1262
+ delete session.blockerAnswersNote;
1155
1263
  if (imageExpansion.images.length > 0 && params.provider.name === 'local-openai-compat') {
1156
1264
  // The local (OpenAI-compatible) provider keeps text blocks only.
1157
1265
  emit(chalk.dim('(local model: the image is not sent — the model gets the text only)'));
@@ -1193,12 +1301,19 @@ ${imageExpansion.error}`));
1193
1301
  const hiddenSapTools = connectedTurn
1194
1302
  ? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
1195
1303
  : [];
1196
- const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
1304
+ // Team build Task 6 — a functional seat is not offered the tools it may not
1305
+ // call (dispatch refuses them anyway). Developers: the same array.
1306
+ const contextTools = toolsForSeat(withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools), getSeatKind());
1197
1307
  // Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
1198
1308
  // when a reader would have no real read tool (standalone, file tools off).
1199
- const visibleTools = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
1309
+ const readerVisible = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
1200
1310
  ? contextTools.filter((t) => t.name !== READ_AGENT)
1201
1311
  : contextTools;
1312
+ // Task 18 fix round 1 (item 5) — an agent_run child never records spec
1313
+ // answers: it could race the blocker form on the same file.
1314
+ const visibleTools = params.ctx.subagent
1315
+ ? readerVisible.filter((t) => t.name !== SPEC_ANSWERS_RECORD_TOOL)
1316
+ : readerVisible;
1202
1317
  // Final review C1 (controller ruling) — deferred shapes only for a model on
1203
1318
  // the probe-proven allow-list, decided by the model actually sent on the
1204
1319
  // call (a plan-tier sonnet override, a haiku child or claude-opus-4-8 ⇒
@@ -1254,13 +1369,7 @@ ${imageExpansion.error}`));
1254
1369
  // Stage 1A — skill_appended goes on the turn's first cost line (a call
1255
1370
  // retried after a prompt-too-long recovery logs no line of its own).
1256
1371
  let skillAppendedLogged = false;
1257
- const surfaceStarters = (skill) => {
1258
- const msg = buildSkillToolAddition({
1259
- skill,
1260
- toolLoading: session.tool_loading,
1261
- toolList: turnTools,
1262
- messages: session.messages,
1263
- });
1372
+ const pushToolAddition = (msg) => {
1264
1373
  if (msg)
1265
1374
  session.messages.push(msg);
1266
1375
  if (msg && inlineTurn) {
@@ -1270,10 +1379,24 @@ ${imageExpansion.error}`));
1270
1379
  }
1271
1380
  }
1272
1381
  };
1382
+ const surfaceStarters = (skill) => {
1383
+ pushToolAddition(buildSkillToolAddition({
1384
+ skill,
1385
+ toolLoading: session.tool_loading,
1386
+ toolList: turnTools,
1387
+ messages: session.messages,
1388
+ }));
1389
+ };
1273
1390
  // A turn on a different skill than the one the array was built for: the
1274
1391
  // user message was just pushed, so the system message follows it.
1275
1392
  if (params.skill !== session.tool_set_skill)
1276
1393
  surfaceStarters(params.skill);
1394
+ // Task 18 fix round 1 (item 7) — the blocker note names spec_answers_record:
1395
+ // on the turn that carries it, surface the tool when this list defers it
1396
+ // (loaded already, static, or surfaced before: nothing is added).
1397
+ if (blockerNoteUsed && session.tool_loading !== 'static' && !surfacedToolNames(session.messages).has(SPEC_ANSWERS_RECORD_TOOL)) {
1398
+ pushToolAddition(buildToolAdditionMessage([SPEC_ANSWERS_RECORD_TOOL], deferredNames(turnTools)));
1399
+ }
1277
1400
  /**
1278
1401
  * key: `${tool_name}:${object}` — consecutive failures per (tool, target).
1279
1402
  * For object-identifying tools (sap_set_source / sap_activate / etc.) the
@@ -1446,6 +1569,11 @@ ${imageExpansion.error}`));
1446
1569
  session.lane = preTurnState.lane;
1447
1570
  session.tool_set_skill = preTurnState.tool_set_skill;
1448
1571
  session.turn_seq = preTurnState.turn_seq;
1572
+ // The re-run turn still gets the one-time notes (they were dropped on push).
1573
+ if (preTurnState.systemSwitchNote !== undefined)
1574
+ session.systemSwitchNote = preTurnState.systemSwitchNote;
1575
+ if (preTurnState.blockerAnswersNote !== undefined)
1576
+ session.blockerAnswersNote = preTurnState.blockerAnswersNote;
1449
1577
  // Steering drained in round 0 goes back to the queue, ahead of any
1450
1578
  // typed since, so the re-run turn still sees it.
1451
1579
  // LT-15 Task 4 — the round-0 ones were shown as "↳ read": marked, so the
@@ -1551,7 +1679,7 @@ ${imageExpansion.error}`));
1551
1679
  // that ended before the model answered the tool_addition.
1552
1680
  placeSystemMessagesInPlace(session.messages);
1553
1681
  if (healedOrphans > 0) {
1554
- emit(chalk.dim(`(repaired ${healedOrphans} tool call(s) cut off by an interruption — the model will redo them)`));
1682
+ emit(chalk.dim(closedOffStepsLine(healedOrphans)));
1555
1683
  }
1556
1684
  // Task 4 — effort only from an explicit act (ruling F2); never to a
1557
1685
  // model that rejects it (probe P1: claude-haiku-4-5* ⇒ 400).
@@ -2281,10 +2409,15 @@ ${imageExpansion.error}`));
2281
2409
  model: params.modelOverride ?? resolveModelRole('session_default', cfg) ?? session.model ?? 'unknown',
2282
2410
  emit,
2283
2411
  sapSystem: session.sap_system,
2412
+ // Final review F6: the save and the team build folder follow this turn's folder.
2413
+ cwd: params.ctx.cwd,
2414
+ ...(params.operator ? { operator: params.operator } : {}),
2415
+ sessionId: session.id,
2284
2416
  ...(params.ctx.subagent ? { subagent: true } : {}),
2285
2417
  ...(session.awaitingSkillAnswer === true ? { awaitingAnswer: true } : {}),
2286
2418
  ...(getJourneyFlow(session) || getAppFlow(session) ? { guided: true } : {}),
2287
2419
  session,
2420
+ readCwd: params.ctx.cwd,
2288
2421
  promotedFrom: promotedFromForSave,
2289
2422
  ...(promotedFromPathForSave ? { promotedFromPath: promotedFromPathForSave } : {}),
2290
2423
  ...(() => {
@@ -30,7 +30,7 @@
30
30
  * Phase 3. Today the cache is process-lifetime; users get a fresh
31
31
  * context next CLI invocation.
32
32
  */
33
- import { getProjectContext, renderProjectContext } from '../project-context/index.js';
33
+ import { getProjectContext, renderProjectContextCapped } from '../project-context/index.js';
34
34
  /**
35
35
  * The env-var name that gates this enrichment. Treated case-insensitively
36
36
  * but value comparison is exact ("on" only — anything else is OFF).
@@ -70,6 +70,8 @@ export function resetProjectContextCache() {
70
70
  export function __resetProjectContextCacheForTests() {
71
71
  resetProjectContextCache();
72
72
  }
73
+ /** The one plain line shown when the folder picture was shortened. */
74
+ export const PROJECT_CONTEXT_CUT_LINE = 'This folder is very large, so we work from a shortened picture of it.';
73
75
  /**
74
76
  * Returns the rendered <project_context> block for splicing into the
75
77
  * user message, or '' when the flag is off / no context was detectable.
@@ -94,7 +96,14 @@ export async function maybeBuildProjectContext(options) {
94
96
  const ctx = await getProjectContext(options.cwd, {
95
97
  sapConnection: options.sapConnection ?? null,
96
98
  });
97
- cachedBlock = ctx ? renderProjectContext(ctx) : '';
99
+ const rendered = ctx ? renderProjectContextCapped(ctx) : { block: '', cut: false };
100
+ cachedBlock = rendered.block;
101
+ if (rendered.cut) {
102
+ try {
103
+ options.emit?.(PROJECT_CONTEXT_CUT_LINE);
104
+ }
105
+ catch { /* a display failure never drops the block */ }
106
+ }
98
107
  }
99
108
  catch (err) {
100
109
  // getProjectContext is documented as never-throws; reaching this
@@ -210,6 +210,15 @@ export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the
210
210
  * SAP change. Same shape and flags as INTERRUPTED_TOOL_RESULT (is_error: true).
211
211
  */
212
212
  export const STOPPED_BEFORE_START_RESULT = 'This tool call was stopped before anything ran — no change was made in SAP. Ask again if it is still needed.';
213
+ /**
214
+ * 1.1.53 item 10 — the dim line shown when healOrphanToolUses closed off steps
215
+ * cut by an earlier stop. Plain words for the person; no internals.
216
+ */
217
+ export function closedOffStepsLine(n) {
218
+ return n === 1
219
+ ? '(we closed off a step that was stopped earlier)'
220
+ : `(we closed off ${n} steps that were stopped earlier)`;
221
+ }
213
222
  /**
214
223
  * LT-15 D6 (review M5) — the same, for the write pipeline's second guard: by
215
224
  * then the approval was spent and SAP was read (a local snapshot may exist),
@@ -12,6 +12,8 @@ import { riskOf } from './risk-gate.js';
12
12
  import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
13
13
  import { INTERRUPTED_TOOL_RESULT, STOPPED_BEFORE_START_RESULT } from './repair-partial.js';
14
14
  import { WRITE_TOOLS } from '../tools/write-mode.js';
15
+ import { seatRefusal } from '../tools/seat-gate.js';
16
+ import { getSeatKind } from '../licence/seat-state.js';
15
17
  const READER_TOOL_SET = new Set(READER_TOOLS);
16
18
  /** Chat lane (spec 2026-10-17 D4) — the model-facing reason; names hand_off. */
17
19
  export const CHAT_READ_ONLY_REASON = 'This chat reads but never changes SAP. To make this change, call hand_off with the right workflow (for example abap-refactor, abap-generate, abap-transport) and a clear request — the workflow shows its plan and asks before it writes.';
@@ -70,6 +72,15 @@ export async function dispatchTool(name, args, ctx) {
70
72
  if (ctx.readerMode && !READER_TOOL_SET.has(name)) {
71
73
  return { content: `error: read-only reader — ${name} is not allowed here`, is_error: true };
72
74
  }
75
+ // Team build Task 6 — a functional seat reads SAP and never changes it, in
76
+ // every lane (skill, chat, and an agent_run child, which shares the process
77
+ // seat) whatever write_mode, role or /mode says. The refused tools are not
78
+ // offered (agent/loop.ts toolsForSeat); THIS is the guarantee for a call the
79
+ // model makes anyway. Before the chat gate and every card below. Developers:
80
+ // seatRefusal is null, so today's dispatch is untouched.
81
+ const seat = seatRefusal(getSeatKind(), tool, args);
82
+ if (seat)
83
+ return { content: JSON.stringify({ error: 'seat_read_only', tool: name, message: seat }), is_error: true };
73
84
  // Chat lane (spec 2026-10-17 D4, §6.3) — chat reads freely and never changes
74
85
  // SAP. The prompt says so; THIS is the guarantee: every SAP-mutating tool and
75
86
  // request_approval are refused while the session's last turn ran in chat,
@@ -113,8 +124,12 @@ export async function dispatchTool(name, args, ctx) {
113
124
  }
114
125
  // Final review I-3 — the D11 re-check can take seconds of SAP reads; an Esc
115
126
  // in that window must stop the call here, not let the handler run "as unknown".
116
- if (ctx.signal?.aborted)
117
- return { content: INTERRUPTED_TOOL_RESULT, is_error: true };
127
+ // 1.1.53 item 10 — the handler has not run (the gate only reads). A tool
128
+ // that cannot change SAP (hand_off, reads, local files) says so plainly; a
129
+ // tool that can change SAP keeps the "effect unknown, check" safety text.
130
+ if (ctx.signal?.aborted) {
131
+ return { content: isChatRefusedTool(tool) ? INTERRUPTED_TOOL_RESULT : STOPPED_BEFORE_START_RESULT, is_error: true };
132
+ }
118
133
  // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
119
134
  // login to a local child process ALWAYS asks a human, once per session per
120
135
  // alias, even as the first mutating call of a turn (where Rule 8 below stays
@@ -3,6 +3,7 @@ import { sanitizeControlChars } from '../renderer/sanitize.js';
3
3
  import { wrapRows } from '../renderer/wrap-rows.js';
4
4
  import { shownUpTo } from '../register/atc-file.js';
5
5
  import { transportForWrite } from '../register/project-transport.js';
6
+ import { decodeDisplayEntities, decodeDisplayBlock } from '../renderer/html-entities.js';
6
7
  /**
7
8
  * Human-readable operation + object labels for the approval surfaces.
8
9
  *
@@ -103,7 +104,7 @@ export function affectsLine(change) {
103
104
  // line comes from the change's op and type, never from a model-supplied field.
104
105
  if (change.op === 'delete' && typeCode(change.type) === 'FUGR')
105
106
  return FUGR_DELETE_LINE;
106
- const t = sanitizeControlChars(String(change.affects ?? '')).trim();
107
+ const t = decodeDisplayEntities(sanitizeControlChars(String(change.affects ?? ''))).trim();
107
108
  return t ? `Affects: ${t}` : null;
108
109
  }
109
110
  /** Task 7 — the longest behaviour text a card shows, and the most rows it takes. */
@@ -121,7 +122,8 @@ export function behaviourText(change) {
121
122
  // shownUpTo drops whole escape sequences first (a lone sanitizeControlChars
122
123
  // pass would leave their '[31m' tails), then every control character.
123
124
  // The cut counts code points, so it never leaves half a surrogate pair.
124
- const flat = Array.from(shownUpTo(String(change.behaviour ?? ''), Number.POSITIVE_INFINITY));
125
+ // 1.1.53 item 5: entities decode before the cut, so the cut counts what shows.
126
+ const flat = Array.from(decodeDisplayEntities(shownUpTo(String(change.behaviour ?? ''), Number.POSITIVE_INFINITY)));
125
127
  const t = flat.length > BEHAVIOUR_MAX ? `${flat.slice(0, BEHAVIOUR_MAX).join('')}…` : flat.join('');
126
128
  if (!t || NOTHING_CHANGES.test(t))
127
129
  return null;
@@ -209,7 +211,8 @@ export function displayTransport(op, explicit, session, owning, project) {
209
211
  * modal's [v] view and the classic prompt's View source branch.
210
212
  */
211
213
  export function toSourceLines(content) {
212
- return content
214
+ // 1.1.53 item 5: display decode of the whole block (kept as written when it has a raw field symbol or markup).
215
+ return decodeDisplayBlock(content)
213
216
  .split('\n')
214
217
  .filter((l) => !l.startsWith('@@') && !l.startsWith('+++') && !l.startsWith('---'))
215
218
  .map((l) => l.replace(/^[+-] ?/, ''));
@@ -14,6 +14,7 @@ import { paint } from '../renderer/theme.js';
14
14
  import { sanitizeControlChars } from '../renderer/sanitize.js';
15
15
  import { planRows, looksLikeUnifiedDiff } from './visible-changes.js';
16
16
  import { withSpinnerSuspended } from '../renderer/tool-widget.js';
17
+ import { decodeDisplayEntities, decodeDisplayBlock } from '../renderer/html-entities.js';
17
18
  // Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
18
19
  const peach = (s) => paint('brand', s);
19
20
  /**
@@ -294,11 +295,11 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
294
295
  // proper diff content.
295
296
  if (change.diff) {
296
297
  if (looksLikeUnifiedDiff(change.diff)) {
297
- console.log('\n' + colorizeDiff(change.diff) + '\n');
298
+ console.log('\n' + colorizeDiff(decodeDisplayBlock(change.diff)) + '\n');
298
299
  }
299
300
  else {
300
301
  console.log('\n' + chalk.yellow(`${getGlyphs().attention} Model did not provide a unified diff — only a description:`));
301
- console.log(chalk.dim(change.diff) + '\n');
302
+ console.log(chalk.dim(decodeDisplayEntities(change.diff)) + '\n');
302
303
  }
303
304
  }
304
305
  else {
@@ -40,6 +40,8 @@ export async function readAuthFile() {
40
40
  out.apiKey = parsed.apiKey;
41
41
  if (typeof parsed.savedAt === 'string')
42
42
  out.savedAt = parsed.savedAt;
43
+ if (typeof parsed.signedInWith === 'string')
44
+ out.signedInWith = parsed.signedInWith;
43
45
  return out;
44
46
  }
45
47
  catch {
@@ -54,6 +56,7 @@ export async function writeAuthFile(auth) {
54
56
  ...(auth.apiKey !== undefined ? { apiKey: auth.apiKey } : {}),
55
57
  customerId: auth.customerId,
56
58
  ...(auth.savedAt !== undefined ? { savedAt: auth.savedAt } : {}),
59
+ ...(auth.signedInWith !== undefined ? { signedInWith: auth.signedInWith } : {}),
57
60
  };
58
61
  await writeAtomic0600(p, JSON.stringify(body, null, 2));
59
62
  }
@@ -27,6 +27,7 @@ import { promises as fs } from 'node:fs';
27
27
  import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
28
28
  import { readAuthFile, writeAuthFile, deleteAuthFile, authFileMtimeMs, authFileDir } from './auth-file.js';
29
29
  import { licenceCachePath } from '../licence/paths.js';
30
+ import { CLI_VERSION } from '../lib/version.js';
30
31
  export const KEYCHAIN_SERVICE = 'cspeach';
31
32
  export const DEFAULT_ACCOUNT = 'api-key';
32
33
  async function defaultIo() {
@@ -181,7 +182,8 @@ export async function migrateSignIn(deps = {}) {
181
182
  return auth?.apiKey ? 'store-unavailable' : 'none';
182
183
  }
183
184
  try {
184
- await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}) });
185
+ // Team build: signedInWith stays, so a new sign-in never turns "legacy".
186
+ await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}), ...(auth.signedInWith !== undefined ? { signedInWith: auth.signedInWith } : {}) });
185
187
  }
186
188
  catch {
187
189
  return 'failed'; // the store holds the key; the next start removes the copy ('removed')
@@ -217,12 +219,15 @@ export async function saveSignIn(a, deps = {}) {
217
219
  catch {
218
220
  stored = false;
219
221
  }
222
+ // Team build: signedInWith marks a sign-in made by this CLI or newer; an
223
+ // auth.json without it is a pre-release sign-in (a developer seat).
224
+ const signedInWith = CLI_VERSION;
220
225
  if (!stored) {
221
- await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt });
226
+ await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt, signedInWith });
222
227
  return 'file';
223
228
  }
224
229
  await recordStoreWrite(account, savedAt).catch(() => { }); // bookkeeping only, no secret
225
- await writeAuthFile({ customerId: a.customerId, savedAt });
230
+ await writeAuthFile({ customerId: a.customerId, savedAt, signedInWith });
226
231
  return 'store';
227
232
  }
228
233
  /**
package/dist/auth/me.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Fetch /v1/me — customer profile + entitlements + usage.
3
3
  * Result envelope so the REPL doesn't need try/catch.
4
4
  */
5
+ import { CLI_VERSION } from '../lib/version.js';
5
6
  /** Default /v1/me budget. The panels brand line waits for this call (D5), so a
6
7
  * hung proxy must not mean a blank screen. */
7
8
  export const FETCH_ME_TIMEOUT_MS = 5000;
@@ -18,8 +19,11 @@ export async function fetchMe(opts) {
18
19
  try {
19
20
  return await Promise.race([
20
21
  (async () => {
21
- // No extra headers → exactly `{ Authorization }`, as before the batch.
22
- const r = await fetch(url, { headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${opts.apiKey}` }, signal });
22
+ // No extra headers → exactly `{ User-Agent, Authorization }`. Team build
23
+ // (R5): every /v1/me call names this CLI, so the proxy can tell a
24
+ // current CLI from one too old for a functional seat. Set after the
25
+ // caller's headers, so no caller can drop it.
26
+ const r = await fetch(url, { headers: { ...(opts.headers ?? {}), 'User-Agent': `cspeach/${CLI_VERSION}`, Authorization: `Bearer ${opts.apiKey}` }, signal });
23
27
  const serverDate = r.headers.get('date');
24
28
  if (!r.ok) {
25
29
  const body = await r.json().catch(() => ({}));
package/dist/cli.js CHANGED
@@ -52,6 +52,13 @@ export async function main(argv) {
52
52
  printHelp();
53
53
  return 0;
54
54
  }
55
+ // Team build (Task 12): `cspeach help team-build` prints the how-to. Above the
56
+ // one-shot fallback, so the words are never sent to the model as a prompt.
57
+ if (args[0] === 'help' && args[1] === 'team-build' && args.length === 2) {
58
+ const { printTeamBuildHelp } = await import('./commands/team-build-help.js');
59
+ printTeamBuildHelp();
60
+ return 0;
61
+ }
55
62
  if (args[0] === '--version') {
56
63
  console.log(CLI_VERSION);
57
64
  return 0;
@@ -248,6 +255,14 @@ export async function main(argv) {
248
255
  const { input } = await import('@inquirer/prompts');
249
256
  return input({ message: question });
250
257
  },
258
+ // Team build (ruling R2, final review F2): the terminal verbs run no
259
+ // start-up, so the seat is resolved here — the verified record, else one
260
+ // /v1/me when there is none, else the offline rule. Asked only before a
261
+ // verb that records work.
262
+ seat: async () => {
263
+ const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
264
+ return resolveTerminalSeatNow();
265
+ },
251
266
  });
252
267
  }
253
268
  // CAP phase 2 — run the SAP link in the foreground for a local CAP app.
@@ -256,6 +271,11 @@ export async function main(argv) {
256
271
  return runSapLinkCommand(args.slice(2), {
257
272
  print: (s) => console.log(s),
258
273
  defaultAlias: sapAlias,
274
+ // Final review F3: resolved as the team verbs do; a functional seat's link reads and never writes.
275
+ seat: async () => {
276
+ const { resolveTerminalSeatNow } = await import('./licence/terminal-seat.js');
277
+ return resolveTerminalSeatNow();
278
+ },
259
279
  standalone: async () => {
260
280
  const { loadConfig, resolveStandalone } = await import('./config/loader.js');
261
281
  return resolveStandalone(await loadConfig(), noSap);
@@ -334,10 +354,12 @@ Usage:
334
354
  cspeach login azure Sign in to Microsoft for your company's Azure (--device-code: use a code instead of the browser)
335
355
  cspeach logout Sign out (removes the CSPeach key from this home)
336
356
  cspeach whoami Print current account info
357
+ cspeach help team-build How two people share one folder: the functional writes the spec, the developer builds it
337
358
  cspeach team discover Read the whole estate from SAP into this folder (read only)
338
359
  cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
339
360
  cspeach team start --for "FI and SD" --developers "James Miller, me" Lead: set up by name from this folder's discovery; one lane each
340
361
  cspeach team init <folder> Lead: pull the object list from SAP, start a team project
362
+ cspeach team spec list|ready [<slug>] Team build: the specs in this folder; mark one ready for the developer
341
363
  cspeach team join <folder> [--as "<name>"] Record your results against a team project (on this laptop only), under the lane owner's name
342
364
  cspeach team lane add|list Lead: who owns which packages
343
365
  cspeach team lane move --packages <names> --to <developer> Lead: move packages by name (lanes of names only)