@cspeach/cli 1.1.51 → 1.1.53

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 (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -45,6 +45,7 @@ import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../
45
45
  import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
46
46
  import { statusesFromItems, computeNextPhase, computeNextPhaseForRun, pendingAuditPhaseIds, renderPlanTracker, renderPlanProgressLine, buildPlanRevision, isPhaseSatisfied, isAuditResolved, planCompletionLines, findServiceBinding, phaseDisplayTitle, } from '../projects/plan-run.js';
47
47
  import { extractPlan, NO_PLAN_MANIFEST_MESSAGE } from '../projects/extract-plan.js';
48
+ import { finishCheckGuard } from './finish-check-guard.js';
48
49
  import { recordFullNotice } from '../renderer/notice-log.js';
49
50
  import { isSapUnreachableNoted } from '../sap/reachability-state.js';
50
51
  import { saveProject } from '../projects/save.js';
@@ -66,6 +67,8 @@ import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.j
66
67
  import { FIORI_DEPLOY_HANDOVER_SCOPE } from '../tools/fiori/deploy/scope.js';
67
68
  import { resolvePlanSources, renderPlanSourcesBlock } from '../projects/plan-sources.js';
68
69
  import { regroundPlan } from './plan-reground.js';
70
+ import { getGrantTransport, isPlanGrantInForceFor } from '../approvals/plan-grant.js';
71
+ import { projectTransport, transportForWrite } from '../register/project-transport.js';
69
72
  import { PAUSED_LINE, makeSapProbe, pauseUntilSapReachable, promptLockedChoice, resumeCommandFor } from './plan-sap-pause.js';
70
73
  /**
71
74
  * Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
@@ -166,6 +169,51 @@ export function buildUiPhaseContext(phases, next) {
166
169
  }
167
170
  return lines;
168
171
  }
172
+ const sameTransport = (a, b) => !!a && !!b && a.trim().toUpperCase() === b.trim().toUpperCase();
173
+ /**
174
+ * 2026-10-23 (owner-approved fix, review round 1) — the transport a create in
175
+ * this step would go to, by the write path's own order, so the step prompt can
176
+ * name it. Never the step's own work.transport ahead of that order.
177
+ * - A plan grant of THIS plan is in force: its transport (writes under the
178
+ * grant are held to it; a grant whose new transport is not bound yet names
179
+ * none).
180
+ * - Guarded mode, no grant yet: the grant offered for this step would bind
181
+ * the plan's recorded transport, else the session one; if it is not
182
+ * installed, writes take transportForWrite's order. Named only when the
183
+ * two agree.
184
+ * - Otherwise (step mode): transportForWrite for a create: the joined team
185
+ * project's request, else the session transport (an explicit /transport).
186
+ * Resolve-mode writes (set_source, update_method, delete) never fall back to
187
+ * the session transport, and an object's owning lock still wins over any
188
+ * transport passed. Pure.
189
+ */
190
+ export function resolveStepWriteTransport(i) {
191
+ if (i.grantInstalled)
192
+ return i.grantTransport?.trim() || undefined;
193
+ const perWrite = transportForWrite({ op: 'create', project: i.project, session: i.session }).request;
194
+ if (i.mode !== 'guarded')
195
+ return perWrite || undefined;
196
+ const planned = recordedPlanTransport(i.phases) ?? i.session ?? undefined;
197
+ return sameTransport(planned, perWrite) ? perWrite : undefined;
198
+ }
199
+ /**
200
+ * The step-prompt line naming the transport. `transport` is passed only when
201
+ * it is the one the write path uses AND the pre-step SAP check listed it as
202
+ * open. Returns [] (prompt unchanged) with no transport, for read-only steps
203
+ * (writes:false), design steps, and UI/CAP steps (local files; a UI deploy
204
+ * names its own).
205
+ */
206
+ export function buildStepTransportLine(next, transport) {
207
+ if (!transport)
208
+ return [];
209
+ if (!phaseWrites(next))
210
+ return [];
211
+ if (next.delegateTo === 'abap-design')
212
+ return [];
213
+ if (next.layer === 'ui' || next.layer.startsWith('cap.'))
214
+ return [];
215
+ return [`Transport for this step: ${transport} (already settled: use it, do not ask the developer which transport).`];
216
+ }
169
217
  /** Compact-example work keys for ui phases — the write-back contract
170
218
  * (review C3). Returns an OBJECT fragment because compactExample renders
171
219
  * via JSON.stringify: it is spread into the `work` object exactly like the
@@ -473,7 +521,7 @@ export async function preparePlanResume(args) {
473
521
  // C2 (D24): a finished UI-less backend stack chains to /abap-fiori-build
474
522
  // with the exact binding name — the marketed idea→app story must not
475
523
  // silently end at the service binding.
476
- args.log(...planCompletionLines(content.phases));
524
+ args.log(...planCompletionLines(content.phases, undefined, planBuildTransport(content.phases)));
477
525
  }
478
526
  else {
479
527
  const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => titleOfId(p.id));
@@ -528,17 +576,31 @@ export async function preparePlanResume(args) {
528
576
  // what this plan built, what a dead run left inactive (a stale lock is
529
577
  // waited out, then reported), and whether the transport is still open.
530
578
  let sapStateBlock = [];
579
+ // 2026-10-23 — the transport this step's writes would use (write-path
580
+ // order). It is named in the prompt only when the re-check lists it open.
581
+ // A grant counts only while it is in force (this plan, not expired, approvals
582
+ // = plan); otherwise writes take the per-write order, and so does the line.
583
+ const stepWriteTransport = resolveStepWriteTransport({
584
+ phases: content.phases,
585
+ mode: effectiveMode,
586
+ grantInstalled: isPlanGrantInForceFor(envelope.id),
587
+ grantTransport: getGrantTransport(),
588
+ project: projectTransport(args.cwd),
589
+ session: getCurrentTransport(),
590
+ });
591
+ let stepTransportOpen = false;
531
592
  if (args.reground) {
532
593
  const regroundDeps = args.reground;
533
594
  const canAsk = args.interactive === true && !!args.prompt;
534
595
  const resumeCommand = resumeCommandFor({ path, mode: flags.mode, auditFlag: flags.audit });
535
596
  for (;;) {
536
- const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log });
597
+ const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log, ...(stepWriteTransport ? { stepTransport: stepWriteTransport } : {}) });
537
598
  if (r.ok) {
538
599
  // An in-run hop stays quiet unless something needs attention.
539
600
  if (!r.quiet || args.boardStyle !== 'line')
540
601
  args.log(formatInfo(r.summary).trimEnd());
541
602
  sapStateBlock = r.sapStateBlock;
603
+ stepTransportOpen = r.stepTransportOpen === true;
542
604
  break;
543
605
  }
544
606
  if (r.reason === 'unreachable') {
@@ -562,6 +624,7 @@ export async function preparePlanResume(args) {
562
624
  continue;
563
625
  if (choice === 'continue') {
564
626
  sapStateBlock = r.sapStateBlock;
627
+ stepTransportOpen = r.stepTransportOpen === true;
565
628
  break;
566
629
  }
567
630
  args.log(PAUSED_LINE);
@@ -631,6 +694,10 @@ export async function preparePlanResume(args) {
631
694
  // '' already ends the prior element). Spread the blank AFTER the block so
632
695
  // context keeps single-blank separation on both sides.
633
696
  const uiContext = buildUiPhaseContext(content.phases, next);
697
+ // Named only when it is the transport the write uses, it is listed open, and
698
+ // it does not contradict the plan's recorded transport in <sap_state>.
699
+ const recordedTr = recordedPlanTransport(content.phases);
700
+ const stepTransport = buildStepTransportLine(next, stepTransportOpen && (!recordedTr || sameTransport(recordedTr, stepWriteTransport)) ? stepWriteTransport : undefined);
634
701
  // 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
635
702
  // from the file the plan was made from (or the plan's creation snapshot).
636
703
  // A phase must never have to hunt the folder for its gap answers.
@@ -671,6 +738,7 @@ export async function preparePlanResume(args) {
671
738
  'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — CSPeach accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
672
739
  '',
673
740
  ...(uiContext.length ? [...uiContext, ''] : []),
741
+ ...(stepTransport.length ? [...stepTransport, ''] : []),
674
742
  PLAN_RESUME_HARNESS_OVERRIDE,
675
743
  ].join('\n');
676
744
  return {
@@ -935,6 +1003,12 @@ export async function finishPlanResume(args) {
935
1003
  return null;
936
1004
  }
937
1005
  }
1006
+ // 2026-10-23 — finish-check guard (inline-waiver findings §6 Option A): a
1007
+ // step claimed "validated" while this turn's LAST rap_finish_check is below
1008
+ // 5 of 5 is saved "blocked" (a waiver only when its notes name every failed
1009
+ // item). Downgrade, never refuse. No check this turn, a 5 of 5, an
1010
+ // unreadable result or any other status → saved exactly as written.
1011
+ applyFinishCheckGuard(extract, args);
938
1012
  const nowIso = new Date().toISOString();
939
1013
  const statuses = extract.statuses;
940
1014
  const executedId = args.prepared.nextPhaseId;
@@ -1028,7 +1102,7 @@ export async function finishPlanResume(args) {
1028
1102
  args.log(renderPlanProgressLine({ content: { ...extract.content, phases: provisionalPhases }, statuses, currentId: null }));
1029
1103
  if (!next && provisionalPhases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit))) {
1030
1104
  // C2 (D24): same chain as preparePlanResume — single source in plan-run.ts.
1031
- args.log(...planCompletionLines(provisionalPhases));
1105
+ args.log(...planCompletionLines(provisionalPhases, undefined, planBuildTransport(provisionalPhases)));
1032
1106
  // Step 2 (C5): the live run finished — a rebuild whose plan this is reads built.
1033
1107
  // Not at the re-opened complete plan above (preparePlanResume).
1034
1108
  await afterPlanComplete(args.prepared.path);
@@ -1056,6 +1130,42 @@ export async function finishPlanResume(args) {
1056
1130
  nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
1057
1131
  };
1058
1132
  }
1133
+ /**
1134
+ * The finish-check guard's write: when finishCheckGuard rules the executed
1135
+ * step down, set it to 'blocked' in the status map, the item list and the
1136
+ * summary, append the note to its work.notes and log the same line.
1137
+ */
1138
+ function applyFinishCheckGuard(extract, args) {
1139
+ const executedId = args.prepared.nextPhaseId;
1140
+ const executed = extract.content.phases.find((p) => p.id === executedId);
1141
+ if (!executed)
1142
+ return;
1143
+ const ruling = finishCheckGuard({
1144
+ messages: args.messages,
1145
+ messagesStart: args.messagesStart,
1146
+ status: extract.statuses[executedId],
1147
+ step: phaseDisplayTitle(executed, extract.content.phases),
1148
+ notes: executed.work?.notes,
1149
+ });
1150
+ if (!ruling)
1151
+ return;
1152
+ extract.statuses[executedId] = ruling.status;
1153
+ for (const it of extract.items)
1154
+ if (it.id === executedId)
1155
+ it.status = ruling.status;
1156
+ const prevNotes = executed.work?.notes;
1157
+ executed.work = { ...(executed.work ?? {}), notes: prevNotes ? `${prevNotes}\n${ruling.note}` : ruling.note };
1158
+ const phases = extract.content.phases;
1159
+ const next = computeNextPhase(phases, extract.statuses);
1160
+ extract.content.summary = {
1161
+ ...extract.content.summary,
1162
+ validated: phases.filter((p) => isPhaseSatisfied(extract.statuses[p.id], p.audit)).length,
1163
+ blocked: phases.filter((p) => extract.statuses[p.id] === 'blocked').length,
1164
+ next: next?.id ?? null,
1165
+ };
1166
+ args.log('');
1167
+ args.log(ruling.note);
1168
+ }
1059
1169
  /**
1060
1170
  * Final fix wave, item 5 — save a new plan version beside the plan file. When
1061
1171
  * that write fails (a read-only or missing folder), save it in the working
@@ -17,7 +17,7 @@ import { basename, dirname } from 'node:path';
17
17
  import { readProjectFile } from '../projects/status.js';
18
18
  import { parsePlanContent } from '../projects/plan-schema.js';
19
19
  import { computeNextPhaseForRun, isPhaseSatisfiedForRun, phaseDisplayTitle, statusesFromItems, } from '../projects/plan-run.js';
20
- import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
20
+ import { collectPlannedObjects, plannedObjectFacts, planGrantCoversWrites, planGrantFactLines, planTransportFact, } from '../projects/plan-objects.js';
21
21
  import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from './plan-resume.js';
22
22
  import { newestPlanFamilyFiles } from '../session/recap.js';
23
23
  import { recordCliCard } from '../session/cli-record.js';
@@ -100,8 +100,8 @@ function objectListFacts(objects, transport, grantable) {
100
100
  return [
101
101
  `It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`,
102
102
  ...plannedObjectFacts(objects).map((r) => ` ${r}`),
103
- `Transport: ${transport ?? 'a new one for this build'}`,
104
- ...(grantable ? planGrantFactLines() : ['CSPeach asks before each write.']),
103
+ planTransportFact(transport, objects),
104
+ ...(grantable && planGrantCoversWrites(transport, objects) ? planGrantFactLines() : ['CSPeach asks before each write.']),
105
105
  ];
106
106
  }
107
107
  const OBJECT_LIST_FAILED = 'The object list could not be approved. Each SAP write asks first.';
@@ -45,7 +45,8 @@ export const DEFAULT_CONFIG = {
45
45
  // (staged rollout, owner 2026-10-17; users opt in with /chat on, a later
46
46
  // release flips this); 'professional' sends no style; 'developer' sends no
47
47
  // level (§10 D13).
48
- assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL },
48
+ // Stage 1A — 'system' is today's request; 'inline' is opt-in (config set).
49
+ assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL, skills: 'system' },
49
50
  // Default `approval-gated` — preserves current UX (user approves each write).
50
51
  write_mode: 'approval-gated',
51
52
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
@@ -321,6 +322,9 @@ async function readConfig() {
321
322
  merged.assistant.lane = DEFAULT_CONFIG.assistant.lane;
322
323
  merged.assistant.style = parseStyle(merged.assistant.style) ?? DEFAULT_CONFIG.assistant.style;
323
324
  merged.assistant.level = parseLevel(merged.assistant.level) ?? DEFAULT_CONFIG.assistant.level;
325
+ // Stage 1A — unknown values fall back, like the lane.
326
+ if (merged.assistant.skills !== 'system' && merged.assistant.skills !== 'inline')
327
+ merged.assistant.skills = DEFAULT_CONFIG.assistant.skills;
324
328
  if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
325
329
  delete merged.ui.background;
326
330
  }
@@ -87,6 +87,12 @@ export function buildEntry(opts) {
87
87
  entry.skill = opts.skill;
88
88
  if (opts.system_block !== undefined)
89
89
  entry.system_block = opts.system_block;
90
+ if (opts.system_mode !== undefined)
91
+ entry.system_mode = opts.system_mode;
92
+ if (opts.skill_appended !== undefined && opts.skill_appended.length > 0)
93
+ entry.skill_appended = opts.skill_appended;
94
+ if (opts.tools_surfaced !== undefined && opts.tools_surfaced.length > 0)
95
+ entry.tools_surfaced = opts.tools_surfaced;
90
96
  return entry;
91
97
  }
92
98
  /**
@@ -11,6 +11,12 @@
11
11
  * Kept in a module WeakMap, never in the session file (no schema change). A
12
12
  * resumed session therefore starts at 'first' again.
13
13
  */
14
+ /**
15
+ * Stage 1A, Task 1A.2 (M2) — the skill part of the key on an inline call: the
16
+ * system block is the one conversation prompt whatever the skill header says,
17
+ * and no delegate is composed into it (the delegate rides in the conversation).
18
+ */
19
+ export const INLINE_SYSTEM_BLOCK_SKILL = '_inline';
14
20
  export function systemBlockKey(p) {
15
21
  return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
16
22
  }
@@ -11,6 +11,7 @@ const KIND_TITLE = {
11
11
  'spec-gap': 'Spec gaps', design: 'Design', estimate: 'Estimate', plan: 'Plan',
12
12
  'upgrade-scan': 'Upgrade scan', 'upgrade-report': 'Upgrade report',
13
13
  answer: 'CSPeach answer', conversation: 'CSPeach conversation',
14
+ 'change-options': 'Change options',
14
15
  };
15
16
  /** Z/Y objects, SAP-style names: ZCL_X, ZFI_DUP_INVOICE_CHECK, Z_REPORT, /NS/ZCL_X. */
16
17
  const OBJECT_RE = /(?<![\w/])((?:\/[A-Z0-9]+\/)?[ZY][A-Z0-9_]{2,39})\b/;
@@ -67,6 +67,8 @@ function titlePage(meta) {
67
67
  facts.push(`Session: ${meta.session}`);
68
68
  if (meta.person)
69
69
  facts.push(`Signed in as: ${meta.person}`);
70
+ if (meta.preparedFor)
71
+ facts.push(`Prepared for: ${meta.preparedFor}`);
70
72
  return [
71
73
  new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
72
74
  ...facts.map((f) => new Paragraph({ text: f })),
@@ -18,7 +18,7 @@ import { readProjectFile } from '../projects/status.js';
18
18
  import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
19
19
  import { parsePlanContent } from '../projects/plan-schema.js';
20
20
  import { adoptPlanTransportOnResume, buildResumeCommand, planBuildTransport } from '../commands/plan-resume.js';
21
- import { collectPlannedObjects, plannedObjectFacts, planGrantFactLines } from '../projects/plan-objects.js';
21
+ import { collectPlannedObjects, plannedObjectFacts, planGrantCoversWrites, planGrantFactLines, planTransportFact, } from '../projects/plan-objects.js';
22
22
  import { phaseDisplayTitle } from '../projects/plan-run.js';
23
23
  import { isDeployPhase } from '../projects/plan-lane.js';
24
24
  import { installPlanGrant, planGrantAllowed } from '../approvals/plan-grant.js';
@@ -255,12 +255,15 @@ export function buildStartCardFacts(content, opts = {}) {
255
255
  // The plan's recorded transport, else the session's current one: the grant
256
256
  // is minted with it bound, so the card names it.
257
257
  const transport = planBuildTransport(content.phases);
258
+ // A local-only plan with no transport: the grant covers no write (it needs
259
+ // a bound transport), so the card says each write asks.
260
+ const covers = opts.grant !== false && planGrantCoversWrites(transport, objects);
258
261
  const facts = [`Steps: ${content.phases.map((p) => phaseDisplayTitle(p, content.phases)).join(' · ')}`];
259
262
  if (objects.length > 0) {
260
263
  facts.push(`It will create or change these ${objects.length} SAP object${objects.length === 1 ? '' : 's'}:`);
261
264
  facts.push(...plannedObjectFacts(objects).map((r) => ` ${r}`));
262
- facts.push(`Transport: ${transport ?? 'a new one for this build'}`);
263
- if (opts.grant === false)
265
+ facts.push(planTransportFact(transport, objects));
266
+ if (!covers)
264
267
  facts.push('CSPeach asks before each write.');
265
268
  else
266
269
  facts.push(...planGrantFactLines());
@@ -273,7 +276,7 @@ export function buildStartCardFacts(content, opts = {}) {
273
276
  }
274
277
  // The closing fact follows what was granted: with no object-list approval
275
278
  // every SAP write asks, so it never promises "only a write not on the list".
276
- facts.push(opts.grant !== false && objects.length > 0
279
+ facts.push(covers && objects.length > 0
277
280
  ? 'It runs every step and stops only for your answers, a write not on the list, a failed check, or the deploy.'
278
281
  : 'It runs every step and stops for your answers, each SAP write, a failed check, or the deploy.');
279
282
  return { facts, objects, ...(transport ? { transport } : {}) };
@@ -314,8 +317,12 @@ export async function afterSpecGapSave(h, savedPath) {
314
317
  h.queueNext(cmd, APP_ORIGIN);
315
318
  h.emit(STEP_2);
316
319
  }
317
- /** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
318
- export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1: the spec-gap result could not be saved. Ask CSPeach to "re-issue the spec gap in the standard structure", or run /abap-app ${shownToken(specToken, 'spec file')} again.`;
320
+ /**
321
+ * The result was written but could not be read as a spec-gap: the flow waits at step 1.
322
+ * Task 17 fix round 1: it follows the save step's plain "We couldn't save this…"
323
+ * line, so it says the same thing in the same voice, with no phrase to type.
324
+ */
325
+ export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1 until the spec-gap result is saved. When we write it again in the usual layout, the run goes on. To start over, run /abap-app ${shownToken(specToken, 'spec file')}.`;
319
326
  /** The user said No to the save: the run ends here. */
320
327
  export const SPEC_GAP_DECLINED_LINE = (specToken) => `The /abap-app run stopped at step 1 because the spec-gap result was not saved. To start again, run /abap-app ${shownToken(specToken, 'spec file')}.`;
321
328
  /**
@@ -284,7 +284,7 @@ export function managedProxyServesNewShapes() {
284
284
  * conversation prompt? True only when its served config says
285
285
  * `inline_skills: true`; false with no snapshot or an older proxy. Managed
286
286
  * snapshot only: BYOK builds its own system prompt and never needs it.
287
- * Dormant — nothing calls it until stage 2 (pinned by
287
+ * Read only by repl/inline-mode.ts `inlineServed` (Stage 1A; pinned by
288
288
  * __tests__/served-inline-skills-pin.test.ts). Pure sync read.
289
289
  */
290
290
  export function servedInlineSkills() {
package/dist/one-shot.js CHANGED
@@ -82,6 +82,7 @@ import './tools/project/upgrade_merge_progress.js';
82
82
  import './tools/project/team_findings.js';
83
83
  import './tools/project/team_estate.js';
84
84
  import './tools/project/team_lane_move.js';
85
+ import './tools/project/spec_answers_record.js';
85
86
  import './tools/subagent/_background-shared.js';
86
87
  import './tools/subagent/background_run.js';
87
88
  import './tools/subagent/monitor_emit.js';
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { promises as fs } from 'node:fs';
27
27
  import * as path from 'node:path';
28
+ import { MAX_ENTRIES_VISITED } from './index-files.js';
28
29
  /**
29
30
  * Default conventions used when no source has anything to say. Picked
30
31
  * to match the most common conventions in modern SAP / web projects:
@@ -268,8 +269,11 @@ async function inferStyleFromExistingFiles(cwd) {
268
269
  * node_modules, .git, dist, build, .cspeach output dirs. Prefers files
269
270
  * likely to reflect human-authored style (ts, js, abap, cds, xml).
270
271
  */
271
- async function findSampleFiles(cwd, limit) {
272
+ export async function findSampleFiles(cwd, limit, maxEntries = MAX_ENTRIES_VISITED) {
272
273
  const out = [];
274
+ // 1.1.53 item 12 — the same entries budget as the index walk: a folder of
275
+ // ~283,000 empty folders held no sample file and was walked to the end.
276
+ let entriesLeft = maxEntries;
273
277
  const TEXT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.abap', '.cds', '.xml', '.yaml', '.yml'];
274
278
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.cspeach', '.abapforge']);
275
279
  async function walk(d) {
@@ -285,6 +289,9 @@ async function findSampleFiles(cwd, limit) {
285
289
  for (const ent of entries) {
286
290
  if (out.length >= limit)
287
291
  return;
292
+ if (entriesLeft <= 0)
293
+ return;
294
+ entriesLeft--;
288
295
  if (ent.isFile()) {
289
296
  if (TEXT_EXTENSIONS.some((ext) => ent.name.endsWith(ext))) {
290
297
  out.push(path.join(d, ent.name));
@@ -35,6 +35,15 @@ import * as path from 'node:path';
35
35
  const MAX_FILES_TRAVERSED = 10_000;
36
36
  /** Cap on paths shipped in FileIndex. Selected by relevance. */
37
37
  const MAX_PATHS_IN_INDEX = 500;
38
+ /**
39
+ * 1.1.53 item 12 — budget on entries (files AND folders) the walk looks at.
40
+ * %TEMP% held ~283,000 empty folders: the file cap never fired and the walk
41
+ * opened every one (~10 s). Spent ⇒ the walk stops and the index is cut.
42
+ * Measured there: 20,000 took ~2.4 s, 5,000 ~0.95 s (with two root reads).
43
+ * Controller ruling: 12,000, so a real project (an abapGit export of
44
+ * thousands of files) keeps its full picture up to the 10,000-file cap.
45
+ */
46
+ export const MAX_ENTRIES_VISITED = 12_000;
38
47
  const ALWAYS_SKIPPED_DIRS = new Set([
39
48
  'node_modules',
40
49
  '.git',
@@ -71,14 +80,32 @@ const SOURCE_EXTENSIONS = new Set([
71
80
  * IO errors during the walk are silently skipped (the offending dir/file
72
81
  * just doesn't contribute to the count). Never throws.
73
82
  */
74
- export async function indexFiles(cwd) {
83
+ export async function indexFiles(cwd, options = {}) {
84
+ const maxEntries = options.maxEntries ?? MAX_ENTRIES_VISITED;
85
+ let entriesLeft = maxEntries;
86
+ let walkCut = false;
75
87
  const ignoreMatchers = await loadIgnoreMatchers(cwd);
76
- /** Files collected with their stat info — used to rank for the path list. */
88
+ /**
89
+ * Files collected with their stat info — used to rank for the path list.
90
+ * `order` is the file's place in a plain depth-first walk (the readdir
91
+ * index at each level), so the output keeps today's order although the
92
+ * walk now visits each level's files before its folders.
93
+ */
77
94
  const collected = [];
78
- const byExtension = {};
79
95
  let totalFiles = 0;
80
96
  let truncated = false;
81
- async function walk(dir, relPrefix) {
97
+ let rootEntries = null;
98
+ /** One entry of the budget; false (and the walk marked cut) once it is spent. */
99
+ function spend() {
100
+ if (entriesLeft <= 0) {
101
+ walkCut = true;
102
+ truncated = true;
103
+ return false;
104
+ }
105
+ entriesLeft--;
106
+ return true;
107
+ }
108
+ async function walk(dir, relPrefix, order) {
82
109
  if (truncated)
83
110
  return;
84
111
  let entries;
@@ -88,45 +115,68 @@ export async function indexFiles(cwd) {
88
115
  catch {
89
116
  return;
90
117
  }
91
- for (const ent of entries) {
118
+ // The root listing is kept for the top-level list (one read, not two).
119
+ if (relPrefix === '')
120
+ rootEntries = entries;
121
+ // 1.1.53 item 12 (fix round 3) — this level's files first, then its
122
+ // folders: one big early folder can no longer spend the entries budget
123
+ // before a spec or plan file at this level is seen.
124
+ for (let i = 0; i < entries.length; i++) {
125
+ const ent = entries[i];
126
+ if (ent.isDirectory())
127
+ continue;
92
128
  if (truncated)
93
129
  return;
130
+ if (!spend())
131
+ return;
132
+ if (!ent.isFile())
133
+ continue;
94
134
  const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
95
- if (ent.isDirectory()) {
96
- if (ALWAYS_SKIPPED_DIRS.has(ent.name))
97
- continue;
98
- if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
99
- continue;
100
- await walk(path.join(dir, ent.name), relPath);
135
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
136
+ continue;
137
+ if (totalFiles >= MAX_FILES_TRAVERSED) {
138
+ truncated = true;
139
+ return;
101
140
  }
102
- else if (ent.isFile()) {
103
- if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
104
- continue;
105
- if (totalFiles >= MAX_FILES_TRAVERSED) {
106
- truncated = true;
107
- return;
108
- }
109
- totalFiles++;
110
- const ext = extOf(ent.name);
111
- byExtension[ext] = (byExtension[ext] ?? 0) + 1;
112
- // Read mtime cheaply; on error use 0 so the file sorts late.
113
- let mtime = 0;
114
- try {
115
- const s = await fs.stat(path.join(dir, ent.name));
116
- mtime = s.mtimeMs;
117
- }
118
- catch {
119
- // ignore
120
- }
121
- collected.push({ path: relPath, ext, mtime });
141
+ totalFiles++;
142
+ // Read mtime cheaply; on error use 0 so the file sorts late.
143
+ let mtime = 0;
144
+ try {
145
+ const s = await fs.stat(path.join(dir, ent.name));
146
+ mtime = s.mtimeMs;
122
147
  }
148
+ catch {
149
+ // ignore
150
+ }
151
+ collected.push({ path: relPath, ext: extOf(ent.name), mtime, order: [...order, i] });
152
+ }
153
+ for (let i = 0; i < entries.length; i++) {
154
+ const ent = entries[i];
155
+ if (!ent.isDirectory())
156
+ continue;
157
+ if (truncated)
158
+ return;
159
+ if (!spend())
160
+ return;
161
+ if (ALWAYS_SKIPPED_DIRS.has(ent.name))
162
+ continue;
163
+ const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
164
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
165
+ continue;
166
+ await walk(path.join(dir, ent.name), relPath, [...order, i]);
123
167
  }
124
168
  }
125
- await walk(cwd, '');
169
+ await walk(cwd, '', []);
170
+ // Back to the plain depth-first order, so the counts and the ranking below
171
+ // see the files in exactly today's order.
172
+ collected.sort((a, b) => compareOrder(a.order, b.order));
173
+ const byExtension = {};
174
+ for (const f of collected)
175
+ byExtension[f.ext] = (byExtension[f.ext] ?? 0) + 1;
126
176
  // Top-level dirs that survived the walk (i.e. weren't skipped).
127
177
  let topLevelDirs = [];
128
178
  try {
129
- const topLevel = await fs.readdir(cwd, { withFileTypes: true });
179
+ const topLevel = rootEntries ?? await fs.readdir(cwd, { withFileTypes: true });
130
180
  topLevelDirs = topLevel
131
181
  .filter((e) => e.isDirectory() && !ALWAYS_SKIPPED_DIRS.has(e.name) && !matchesIgnore(ignoreMatchers, e.name, true))
132
182
  .map((e) => e.name)
@@ -144,13 +194,26 @@ export async function indexFiles(cwd) {
144
194
  return b.mtime - a.mtime;
145
195
  });
146
196
  const paths = collected.slice(0, MAX_PATHS_IN_INDEX).map((f) => f.path);
147
- return {
197
+ const index = {
148
198
  totalFiles,
149
199
  byExtension,
150
200
  topLevelDirs,
151
201
  paths,
152
202
  truncated,
153
203
  };
204
+ // Only a cut walk carries the field, so every other index is the same object as before.
205
+ if (walkCut)
206
+ index.walkCut = true;
207
+ return index;
208
+ }
209
+ /** Lexicographic order of two readdir-index paths (depth-first order). */
210
+ function compareOrder(a, b) {
211
+ const n = Math.min(a.length, b.length);
212
+ for (let i = 0; i < n; i++) {
213
+ if (a[i] !== b[i])
214
+ return a[i] - b[i];
215
+ }
216
+ return a.length - b.length;
154
217
  }
155
218
  async function loadIgnoreMatchers(cwd) {
156
219
  const patterns = [];
@@ -29,7 +29,7 @@ import { extractCap } from './domain/cap.js';
29
29
  import { extractFiori } from './domain/fiori.js';
30
30
  import { getGitState } from './git.js';
31
31
  import { indexFiles } from './index-files.js';
32
- export { renderProjectContext } from './render.js';
32
+ export { renderProjectContext, renderProjectContextCapped } from './render.js';
33
33
  /**
34
34
  * Compute the full project context for a directory. Best-effort: returns
35
35
  * null on hard failure (e.g. cwd doesn't exist), otherwise an envelope