@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
@@ -36,6 +36,8 @@ import { shown } from '../register/atc-file.js';
36
36
  import { cleanReason, OBJECT_NAME, OBJECT_TYPE, objectShown } from './run.js';
37
37
  import { clearJourneyFlow } from './flow.js';
38
38
  import { whoAmI } from '../commands/team.js';
39
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
40
+ import { isFunctionalSeat } from '../licence/seat-state.js';
39
41
  import { setRebuildRunActive } from '../commands/plan-gate.js';
40
42
  /* ── words ──────────────────────────────────────────────────────────────── */
41
43
  export const KIND_WORDS = {
@@ -139,6 +141,9 @@ async function seatName() {
139
141
  }
140
142
  /** One evidence record: the `rebuild` row on each OLD object's key (additive; older readers drop it). */
141
143
  export function writeRebuildRows(joined, keys, row, by, now = new Date()) {
144
+ // Final review F1: a functional seat records nothing into the project (every caller catches this).
145
+ if (isFunctionalSeat())
146
+ throw new Error(FUNCTIONAL_VERB_LINE);
142
147
  const at = now.toISOString();
143
148
  return writeRecord(joined, 'evidence', keys.map((key) => ({ key, rebuild: { ...(typeof row === 'function' ? row(key) : row), by, at } })), { name: by }, { now });
144
149
  }
@@ -262,6 +267,9 @@ export function handOffRebuild(h, g, kind) {
262
267
  }
263
268
  const now = (h.now ?? (() => new Date()))();
264
269
  try {
270
+ // Final review F1: no brief into the shared folder either, on a functional seat.
271
+ if (isFunctionalSeat())
272
+ throw new Error(FUNCTIONAL_VERB_LINE);
265
273
  if (kind !== 'class') {
266
274
  writeRebuildBrief(joined.projectDir, g.app, rebuildBrief({
267
275
  kind, ...(h.sid ? { sid: h.sid } : {}), target: first.target ?? null,
@@ -14,6 +14,8 @@
14
14
  import path from 'node:path';
15
15
  import { estateSummaryOf, PART_ORDER, PART_WORDS, provenForTargetWords, usageLineWithWindow } from '@cspeach/register-core';
16
16
  import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, NO_CHECK_ALL_LINE, ownerMatch, runTeamCommand, severalLine, whoAmI, writeExports, writeReports } from '../commands/team.js';
17
+ import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
18
+ import { isFunctionalSeat } from '../licence/seat-state.js';
17
19
  import { EXPORTS_DIR, exportsOff, exportsOn, exportsPresent, setExportsAlways } from '../register/export-files.js';
18
20
  import { shown, shownUpTo } from '../register/atc-file.js';
19
21
  import { withoutRuleId } from '../register/records.js';
@@ -162,6 +164,11 @@ const IS_PATTERN = /[*?%]/;
162
164
  */
163
165
  export async function runJourneyStage(stage, ctx) {
164
166
  try {
167
+ // Final review F1: a functional seat reads a team project and never records
168
+ // into it — no start, no decision, no fix, no check, no report. Said before
169
+ // any card and before SAP is reached, headless too.
170
+ if (FUNCTIONAL_STAGES.has(stage) && isFunctionalSeat())
171
+ return result(stage, [FUNCTIONAL_VERB_LINE], { kind: 'stop' });
165
172
  if (ctx.headless)
166
173
  return nextStepOnly(stage, ctx);
167
174
  // Said before any card: a step that needs SAP, in a session without one.
@@ -189,6 +196,8 @@ export async function runJourneyStage(stage, ctx) {
189
196
  }
190
197
  const me = (ctx) => whoAmI(ctx.team.deps);
191
198
  const facts = (ctx) => readJourneyFacts(ctx.cwd, me(ctx), ctx.skips);
199
+ /** The stages that record work into the project or run SAP's checks: refused on a functional seat. */
200
+ const FUNCTIONAL_STAGES = new Set(['START', 'DECIDE', 'FIX', 'PROVE', 'REPORT']);
192
201
  /** Run a team body with its output kept back; `said` holds only this run's lines. */
193
202
  async function quiet(ctx, args) {
194
203
  const { said } = ctx.team;
@@ -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
  /**
@@ -4,3 +4,8 @@ import { cspeachRoot } from '../config/paths.js';
4
4
  export function licenceCachePath() {
5
5
  return path.join(cspeachRoot(), 'licence.json');
6
6
  }
7
+ /** <CSPeach home>/seat.json — the last verified seat kind (Team build). Holds
8
+ * no secret: the MAC is keyed by the API key, which is not in the file. */
9
+ export function seatRecordPath() {
10
+ return path.join(cspeachRoot(), 'seat.json');
11
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Team build — the seat record and the resolution rule.
3
+ *
4
+ * A developer is never wrongly locked read-only, and a functional seat never
5
+ * turns writable:
6
+ * 1. a fresh /v1/me answer wins (no field = an older proxy = developer);
7
+ * 2. else the last verified record in seat.json (HMAC over
8
+ * `keyPrefix\nkind\nverifiedAt`, keyed by the full API key);
9
+ * 3. else a pre-release sign-in (auth.json without `signedInWith`) is a
10
+ * developer;
11
+ * 4. else "not signed in" — never a guess.
12
+ * A record that is present but fails verification is `invalid`, never a
13
+ * fall-through to the legacy rule.
14
+ */
15
+ import crypto from 'node:crypto';
16
+ import { promises as fs } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { formatFailure } from '../renderer/notice-shape.js';
19
+ import { keyPrefixOf } from './licence-check.js';
20
+ import { seatRecordPath } from './paths.js';
21
+ const isSeatKind = (v) => v === 'developer' || v === 'functional';
22
+ export function seatMac(apiKey, keyPrefix, kind, verifiedAt) {
23
+ return crypto.createHmac('sha256', apiKey).update(`${keyPrefix}\n${kind}\n${verifiedAt}`).digest('hex');
24
+ }
25
+ export function makeSeatRecord(apiKey, kind, now) {
26
+ const keyPrefix = keyPrefixOf(apiKey);
27
+ const verifiedAt = now.toISOString();
28
+ return { keyPrefix, kind, verifiedAt, mac: seatMac(apiKey, keyPrefix, kind, verifiedAt) };
29
+ }
30
+ export function verifySeatRecord(raw, apiKey) {
31
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
32
+ return null;
33
+ const r = raw;
34
+ if (typeof r.keyPrefix !== 'string' || typeof r.verifiedAt !== 'string' || typeof r.mac !== 'string')
35
+ return null;
36
+ if (!isSeatKind(r.kind))
37
+ return null;
38
+ if (r.keyPrefix !== keyPrefixOf(apiKey))
39
+ return null;
40
+ const expected = Buffer.from(seatMac(apiKey, r.keyPrefix, r.kind, r.verifiedAt), 'utf-8');
41
+ const given = Buffer.from(r.mac, 'utf-8');
42
+ // Lengths first: timingSafeEqual throws on unequal lengths.
43
+ if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected))
44
+ return null;
45
+ return { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
46
+ }
47
+ /** Ruling R7b: the legacy rule's input. No auth.json (a pasted or env key, or
48
+ * a pre-login keytar seat) or an auth.json without `signedInWith` is a
49
+ * pre-release sign-in; only a sign-in made by this CLI or newer is not. */
50
+ export function isLegacySignIn(authFile) {
51
+ return !authFile || authFile.signedInWith === undefined;
52
+ }
53
+ export function resolveSeatKind(a) {
54
+ if (a.fresh?.ok) {
55
+ const word = a.fresh.profile.seat_kind;
56
+ const kind = word === undefined ? 'developer' : isSeatKind(word) ? word : null;
57
+ if (kind === null)
58
+ return { ok: false, reason: 'invalid' };
59
+ return { ok: true, kind, source: 'fresh', write: makeSeatRecord(a.apiKey, kind, a.now) };
60
+ }
61
+ if (a.stored !== undefined && a.stored !== null) {
62
+ const rec = verifySeatRecord(a.stored, a.apiKey);
63
+ return rec ? { ok: true, kind: rec.kind, source: 'stored', write: null } : { ok: false, reason: 'invalid' };
64
+ }
65
+ if (a.legacySignIn)
66
+ return { ok: true, kind: 'developer', source: 'legacy', write: null };
67
+ return { ok: false, reason: 'missing' };
68
+ }
69
+ /** seat.json as stored: null when there is no file; `{ corrupt: true }` when
70
+ * it cannot be read or parsed, so it resolves `invalid`, never `missing`. */
71
+ export async function readSeatRecord() {
72
+ let text;
73
+ try {
74
+ text = await fs.readFile(seatRecordPath(), 'utf-8');
75
+ }
76
+ catch (err) {
77
+ if (err.code === 'ENOENT')
78
+ return null;
79
+ return { corrupt: true };
80
+ }
81
+ try {
82
+ const parsed = JSON.parse(text);
83
+ return parsed === null ? { corrupt: true } : parsed;
84
+ }
85
+ catch {
86
+ return { corrupt: true };
87
+ }
88
+ }
89
+ export async function writeSeatRecord(r) {
90
+ const p = seatRecordPath();
91
+ await fs.mkdir(path.dirname(p), { recursive: true });
92
+ // Only the known fields; temp file + rename, as writeLicenceCache.
93
+ const body = { keyPrefix: r.keyPrefix, kind: r.kind, verifiedAt: r.verifiedAt, mac: r.mac };
94
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
95
+ try {
96
+ await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf-8');
97
+ await fs.rename(tmp, p);
98
+ }
99
+ finally {
100
+ await fs.rm(tmp, { force: true }).catch(() => { });
101
+ }
102
+ }
103
+ export function seatSignInMessage() {
104
+ return formatFailure({
105
+ what: 'We could not confirm your seat',
106
+ detail: ['Your SAP work, sessions and snapshots are untouched.'],
107
+ next: 'cspeach login',
108
+ }).trimEnd();
109
+ }
110
+ export const READ_ONLY_SEAT_LINE = 'Read-only seat: CSPeach reads your SAP system and never changes it. Specs, notes and Word documents still save to your folder.';
@@ -0,0 +1,4 @@
1
+ let current = 'developer';
2
+ export function getSeatKind() { return current; }
3
+ export function setSeatKind(k) { current = k; }
4
+ export function isFunctionalSeat() { return current === 'functional'; }
@@ -0,0 +1,43 @@
1
+ import { isLegacySignIn, resolveSeatKind, verifySeatRecord } from './seat-kind.js';
2
+ export async function resolveTerminalSeat(d) {
3
+ try {
4
+ const apiKey = (await d.resolveApiKey().catch(() => ({ key: null }))).key ?? '';
5
+ const stored = await d.readSeatRecord().catch(() => ({ corrupt: true }));
6
+ const authFile = await d.readAuthFile().catch(() => null);
7
+ const verified = stored !== null && stored !== undefined && apiKey !== '' && verifySeatRecord(stored, apiKey) !== null;
8
+ let fresh = null;
9
+ if (apiKey && !verified) {
10
+ try {
11
+ fresh = await d.fetchMe({ proxyUrl: await d.proxyUrl(), apiKey });
12
+ }
13
+ catch {
14
+ fresh = null;
15
+ }
16
+ if (fresh && !fresh.ok && fresh.status === 401)
17
+ return 'unconfirmed';
18
+ }
19
+ const r = resolveSeatKind({ fresh, stored, apiKey, now: d.now?.() ?? new Date(), legacySignIn: isLegacySignIn(authFile) });
20
+ if (!r.ok)
21
+ return 'unconfirmed';
22
+ if (r.write)
23
+ await d.writeSeatRecord(r.write).catch(() => { });
24
+ return r.kind;
25
+ }
26
+ catch {
27
+ return 'unconfirmed';
28
+ }
29
+ }
30
+ /** The real wiring for cli.ts: the stored key, seat.json, auth.json, the configured proxy. */
31
+ export async function resolveTerminalSeatNow() {
32
+ const [{ resolveApiKey }, { readAuthFile }, { fetchMe }, { loadConfig }, seat] = await Promise.all([
33
+ import('../auth/key-resolver.js'), import('../auth/auth-file.js'), import('../auth/me.js'), import('../config/loader.js'), import('./seat-kind.js'),
34
+ ]);
35
+ return resolveTerminalSeat({
36
+ resolveApiKey: () => resolveApiKey(),
37
+ readSeatRecord: seat.readSeatRecord,
38
+ writeSeatRecord: seat.writeSeatRecord,
39
+ readAuthFile: () => readAuthFile(),
40
+ fetchMe: (a) => fetchMe(a),
41
+ proxyUrl: async () => (await loadConfig()).proxy_url,
42
+ });
43
+ }
@@ -61,6 +61,10 @@ export class LicenceTurnGate {
61
61
  this.lastCheckAtMs = -Infinity;
62
62
  return { allow: false, message: d.message };
63
63
  }
64
+ try {
65
+ await this.o.onSeat?.(d);
66
+ }
67
+ catch { /* a seat refresh failure never blocks a turn */ }
64
68
  return d.verdict === 'unknown' ? { allow: true, line: d.line } : { allow: true };
65
69
  }
66
70
  }
package/dist/one-shot.js CHANGED
@@ -28,6 +28,10 @@ import { resolveRenderingMode } from './renderer/rendering-mode.js';
28
28
  import { setLook, setBackground } from './renderer/look.js';
29
29
  import { setAssistantStyle } from './agent/style-state.js';
30
30
  import { setAssistantLevel } from './agent/level-state.js';
31
+ import { readAuthFile } from './auth/auth-file.js';
32
+ import { fetchMe } from './auth/me.js';
33
+ import { resolveSeatKind, readSeatRecord, writeSeatRecord, seatSignInMessage, isLegacySignIn } from './licence/seat-kind.js';
34
+ import { setSeatKind } from './licence/seat-state.js';
31
35
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
32
36
  import { createClassicOutputEmitter } from './renderer/notices.js';
33
37
  import { AnswerTrim } from './renderer/answer-trim.js';
@@ -82,6 +86,7 @@ import './tools/project/upgrade_merge_progress.js';
82
86
  import './tools/project/team_findings.js';
83
87
  import './tools/project/team_estate.js';
84
88
  import './tools/project/team_lane_move.js';
89
+ import './tools/project/spec_answers_record.js';
85
90
  import './tools/subagent/_background-shared.js';
86
91
  import './tools/subagent/background_run.js';
87
92
  import './tools/subagent/monitor_emit.js';
@@ -169,6 +174,31 @@ export async function runOneShot(prompt, opts) {
169
174
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
170
175
  return 1;
171
176
  }
177
+ // Team build Task 5 — the seat kind, set before the turn. A fresh answer
178
+ // wins (managed: one /v1/me for the seat, ruling R7a; other modes: the
179
+ // licence check below), else the verified seat.json, else a pre-release
180
+ // sign-in — no auth.json, or one without signedInWith (R7b) — is a
181
+ // developer, else the sign-in message and exit 1 — never a guess.
182
+ const settleSeat = async (fresh) => {
183
+ const r = resolveSeatKind({
184
+ fresh, stored: await readSeatRecord(), apiKey: (await store.get()) ?? '', now: new Date(),
185
+ legacySignIn: isLegacySignIn(await readAuthFile()),
186
+ });
187
+ if (!r.ok) {
188
+ console.error(seatSignInMessage());
189
+ return false;
190
+ }
191
+ if (r.write)
192
+ await writeSeatRecord(r.write).catch(() => { });
193
+ setSeatKind(r.kind);
194
+ // The developer default follows a functional seat; an explicit level stays.
195
+ const level = cfg.assistant?.level ?? 'developer';
196
+ setAssistantLevel(r.kind === 'functional' && level === 'developer' ? 'functional' : level);
197
+ return true;
198
+ };
199
+ if (cfg.llm.mode === 'managed'
200
+ && !(await settleSeat(await fetchMe({ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? '' }))))
201
+ return 1;
172
202
  // Security batch item 1 — licence check at one-shot start, byok / ai-hub /
173
203
  // local only. Managed makes no new call: the proxy checks every request.
174
204
  if (cfg.llm.mode !== 'managed') {
@@ -177,6 +207,9 @@ export async function runOneShot(prompt, opts) {
177
207
  console.error(licence.message);
178
208
  return 1;
179
209
  }
210
+ // The licence check IS the /v1/me call: an ok verdict carries the kind.
211
+ if (!(await settleSeat(licence.verdict === 'ok' && licence.profile ? { ok: true, profile: licence.profile, serverDate: null } : null)))
212
+ return 1;
180
213
  if (licence.verdict === 'unknown')
181
214
  console.error(formatInfo(licence.line).trimEnd());
182
215
  // Azure tenant mode — one-shot never asks: a changed endpoint, removed
@@ -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
@@ -31,6 +31,12 @@ const CAP_CAP_ENTITIES = 60;
31
31
  const CAP_ABAP_OBJECTS = 80;
32
32
  const CAP_GIT_COMMITS = 5;
33
33
  const CAP_RECENT_OBJECTS = 10;
34
+ /** 1.1.53 item 12 — %TEMP% had ~283,000 top-level folders (a 4.37M-char line). */
35
+ export const CAP_TOP_LEVEL_DIRS = 40;
36
+ export const CAP_NAMING = 20;
37
+ /** Hard ceiling on the whole block, whatever the folder holds. */
38
+ export const PROJECT_CONTEXT_MAX_CHARS = 24_000;
39
+ const CUT_TAIL = '\n <cut>The folder is very large; this picture was shortened.</cut>\n</project_context>';
34
40
  /**
35
41
  * Render a ProjectContext as prompt text. The result is a self-contained
36
42
  * <project_context> XML-ish block ready to splice into a system prompt.
@@ -38,6 +44,28 @@ const CAP_RECENT_OBJECTS = 10;
38
44
  * unconditionally.
39
45
  */
40
46
  export function renderProjectContext(ctx) {
47
+ return renderProjectContextCapped(ctx).block;
48
+ }
49
+ /**
50
+ * The block plus whether it was cut. A block over PROJECT_CONTEXT_MAX_CHARS
51
+ * is cut at the last line end that keeps it (with the closing note) under
52
+ * the cap; a block within the cap is returned unchanged.
53
+ */
54
+ export function renderProjectContextCapped(ctx) {
55
+ const full = renderFull(ctx);
56
+ const walkCut = ctx?.index.walkCut === true;
57
+ if (full.length <= PROJECT_CONTEXT_MAX_CHARS) {
58
+ if (!walkCut)
59
+ return { block: full, cut: false };
60
+ // The walk stopped early: same note, in place of the closing tag.
61
+ return { block: full.slice(0, full.length - '\n</project_context>'.length) + CUT_TAIL, cut: true };
62
+ }
63
+ const room = PROJECT_CONTEXT_MAX_CHARS - CUT_TAIL.length;
64
+ const lastNl = full.lastIndexOf('\n', room);
65
+ const head = lastNl > 0 ? full.slice(0, lastNl) : full.slice(0, room);
66
+ return { block: head + CUT_TAIL, cut: true };
67
+ }
68
+ function renderFull(ctx) {
41
69
  if (!ctx)
42
70
  return '';
43
71
  const parts = [];
@@ -66,13 +94,13 @@ function renderConventions(c) {
66
94
  if (c.namingPatterns.classes.length || c.namingPatterns.interfaces.length || c.namingPatterns.cdsViews.length) {
67
95
  lines.push(' <naming>');
68
96
  if (c.namingPatterns.classes.length) {
69
- lines.push(` <classes>${escapeXml(c.namingPatterns.classes.join(', '))}</classes>`);
97
+ lines.push(` <classes>${escapeXml(cappedList(c.namingPatterns.classes, CAP_NAMING))}</classes>`);
70
98
  }
71
99
  if (c.namingPatterns.interfaces.length) {
72
- lines.push(` <interfaces>${escapeXml(c.namingPatterns.interfaces.join(', '))}</interfaces>`);
100
+ lines.push(` <interfaces>${escapeXml(cappedList(c.namingPatterns.interfaces, CAP_NAMING))}</interfaces>`);
73
101
  }
74
102
  if (c.namingPatterns.cdsViews.length) {
75
- lines.push(` <cds_views>${escapeXml(c.namingPatterns.cdsViews.join(', '))}</cds_views>`);
103
+ lines.push(` <cds_views>${escapeXml(cappedList(c.namingPatterns.cdsViews, CAP_NAMING))}</cds_views>`);
76
104
  }
77
105
  lines.push(' </naming>');
78
106
  }
@@ -149,7 +177,7 @@ function renderFileIndex(ctx) {
149
177
  ` <file_index total="${idx.totalFiles}"${idx.truncated ? ' truncated="true"' : ''}>`,
150
178
  ];
151
179
  if (idx.topLevelDirs.length) {
152
- lines.push(` <top_level_dirs>${escapeXml(idx.topLevelDirs.join(', '))}</top_level_dirs>`);
180
+ lines.push(` <top_level_dirs>${escapeXml(cappedList(idx.topLevelDirs, CAP_TOP_LEVEL_DIRS))}</top_level_dirs>`);
153
181
  }
154
182
  const extEntries = Object.entries(idx.byExtension).sort((a, b) => b[1] - a[1]);
155
183
  if (extEntries.length) {
@@ -293,6 +321,12 @@ function renderAbapCloud(objects, bdefs) {
293
321
  return lines.join('\n');
294
322
  }
295
323
  /* ── Helpers ───────────────────────────────────────────────────────── */
324
+ /** The first `cap` items joined, then ` (… N more)`; a list within the cap joins as before. */
325
+ function cappedList(items, cap) {
326
+ if (items.length <= cap)
327
+ return items.join(', ');
328
+ return `${items.slice(0, cap).join(', ')} (… ${items.length - cap} more)`;
329
+ }
296
330
  function escapeXml(text) {
297
331
  return text
298
332
  .replace(/&/g, '&amp;')