@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.
- package/dist/agent/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +150 -17
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +17 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +22 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +74 -4
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +33 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +133 -18
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +83 -20
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +239 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +159 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +116 -0
- package/dist/repl/team-build-start.js +178 -0
- package/dist/repl.js +216 -11
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/_team-work-path.js +1 -1
- package/dist/tools/approval.js +12 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +243 -0
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +2 -2
package/dist/journey/rebuild.js
CHANGED
|
@@ -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,
|
package/dist/journey/run.js
CHANGED
|
@@ -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;
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -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(
|
|
263
|
-
if (
|
|
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(
|
|
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
|
-
/**
|
|
318
|
-
|
|
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
|
/**
|
package/dist/licence/paths.js
CHANGED
|
@@ -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,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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, '&')
|