@cspeach/cli 1.1.53 → 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/loop.js +50 -5
- package/dist/agent/tool-dispatch.js +11 -0
- 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-reground.js +5 -0
- 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/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/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 +32 -0
- package/dist/projects/answer-blockers.js +31 -7
- package/dist/projects/blocker-answers-note.js +1 -1
- package/dist/projects/save-command.js +37 -2
- 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/repl/builtin-commands.js +4 -3
- 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 +205 -9
- 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/tools/_team-work-path.js +1 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/project/spec_answers_record.js +62 -9
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/ui/widgets/files-card.js +19 -1
- package/package.json +1 -1
package/dist/commands/team.js
CHANGED
|
@@ -23,7 +23,7 @@ import os from 'node:os';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
|
|
25
25
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
26
|
-
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
26
|
+
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, coreProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
27
27
|
import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
28
28
|
import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
|
|
29
29
|
import { writeLeadRecord, writeRecord } from '../register/records.js';
|
|
@@ -39,7 +39,11 @@ import { lookupVariant, variantLookupLine } from '../register/variant.js';
|
|
|
39
39
|
import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
|
|
40
40
|
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
41
41
|
import { rulesCommand } from './team-rules.js';
|
|
42
|
+
import { specCommand } from './team-spec.js';
|
|
42
43
|
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
44
|
+
import { CLEAN_CORE_ONLY_VERBS, FUNCTIONAL_VERB_LINE, buildJoinLine, buildVerbRefusal, createBuildProject, isBuildProject, isRecordingVerb, } from './team-build.js';
|
|
45
|
+
import { seatSignInMessage } from '../licence/seat-kind.js';
|
|
46
|
+
import { getSeatKind } from '../licence/seat-state.js';
|
|
43
47
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
44
48
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
45
49
|
/**
|
|
@@ -128,6 +132,27 @@ function matches(pattern, value) {
|
|
|
128
132
|
export async function runTeamCommand(args, deps) {
|
|
129
133
|
const [sub, ...rest] = args;
|
|
130
134
|
try {
|
|
135
|
+
// Team build (spec §7.2): a functional seat reads a team project and never
|
|
136
|
+
// records work into it. Read-only verbs never ask for the seat. On by
|
|
137
|
+
// default (final review F1): without deps.seat the process seat decides
|
|
138
|
+
// (seat-state.ts — developer unless start-up resolved functional), so a
|
|
139
|
+
// caller that builds its own TeamDeps (the clean-core journey) is held too.
|
|
140
|
+
if (isRecordingVerb(sub, rest)) {
|
|
141
|
+
const seat = await (deps.seat ?? (async () => getSeatKind()))();
|
|
142
|
+
if (seat === 'functional') {
|
|
143
|
+
deps.print(FUNCTIONAL_VERB_LINE);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
if (seat === 'unconfirmed') {
|
|
147
|
+
deps.print(seatSignInMessage());
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A team build folder has no baseline: the clean-core verbs are refused in one line.
|
|
152
|
+
if (sub && CLEAN_CORE_ONLY_VERBS.includes(sub) && activeIsBuild()) {
|
|
153
|
+
deps.print(buildVerbRefusal(sub));
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
131
156
|
switch (sub) {
|
|
132
157
|
case 'init': return await init(rest, deps);
|
|
133
158
|
case 'start': return await start(rest, deps);
|
|
@@ -151,8 +176,9 @@ export async function runTeamCommand(args, deps) {
|
|
|
151
176
|
case 'priority': return priorityCommand(rest, deps);
|
|
152
177
|
case 'plan': return await planCommand(rest, deps);
|
|
153
178
|
case 'rebuild': return await rebuildVerb(rest, deps);
|
|
179
|
+
case 'spec': return await specCommand(rest, deps);
|
|
154
180
|
default:
|
|
155
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
|
|
181
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules|spec (cspeach --help for details)');
|
|
156
182
|
return sub ? 1 : 0;
|
|
157
183
|
}
|
|
158
184
|
}
|
|
@@ -170,6 +196,28 @@ export async function runTeamCommand(args, deps) {
|
|
|
170
196
|
return 1;
|
|
171
197
|
}
|
|
172
198
|
}
|
|
199
|
+
/** The joined project is a team build. Unreadable or none: no guard (the verb says why itself). */
|
|
200
|
+
function activeIsBuild() {
|
|
201
|
+
try {
|
|
202
|
+
const joined = activeProject();
|
|
203
|
+
return !!joined && isBuildProject(readProjectFile(joined.projectDir));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The team build folder (Task 7): no SAP connection, nothing pulled or
|
|
211
|
+
* measured. The lead joins with the outbox in the folder itself.
|
|
212
|
+
*/
|
|
213
|
+
function initBuild(dir, name, lead, deps, asName) {
|
|
214
|
+
createBuildProject(dir, { name, lead: { name: lead }, now: (deps.now ?? (() => new Date()))(), ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
215
|
+
const quiet = { ...deps, print: () => { } };
|
|
216
|
+
join([dir, '--outbox', dir], quiet, asName);
|
|
217
|
+
deps.print(`✓ Team build "${name}" set up in this folder. Lead: ${lead}.`);
|
|
218
|
+
deps.print(' Specs save to specs/ for the developer. Start CSPeach here: cspeach');
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
173
221
|
/** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
|
|
174
222
|
export function isLead(project, deps) {
|
|
175
223
|
return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
|
|
@@ -191,6 +239,8 @@ const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<
|
|
|
191
239
|
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
|
|
192
240
|
/** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
|
|
193
241
|
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
242
|
+
/** What `init --type build` takes: a team build has no packages, no check and no discovery to read. */
|
|
243
|
+
const INIT_BUILD_OPTIONS = ['name', 'lead', 'type'];
|
|
194
244
|
/** The two that take no value, so neither can eat the folder name. */
|
|
195
245
|
const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
196
246
|
/** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
|
|
@@ -220,7 +270,9 @@ function unknownInitOption(name) {
|
|
|
220
270
|
}
|
|
221
271
|
const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
|
|
222
272
|
+ '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
223
|
-
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
|
|
273
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport', 'type'];
|
|
274
|
+
/** What `start --type build` takes besides the type: a team build has no packages, no check and no transport yet. */
|
|
275
|
+
const START_BUILD_OPTIONS = ['type', 'name', 'folder'];
|
|
224
276
|
/** The check a project measures with when nobody names one. */
|
|
225
277
|
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
226
278
|
/** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
|
|
@@ -288,6 +340,13 @@ async function start(args, deps) {
|
|
|
288
340
|
if (!START_OPTIONS.includes(name))
|
|
289
341
|
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
290
342
|
}
|
|
343
|
+
// Team build (Task 7): the folder you are in, its name, you as the lead. Nothing to pull.
|
|
344
|
+
if (opts.type !== undefined) {
|
|
345
|
+
if (opts.type !== 'build' || positional.length || Object.keys(opts).some((o) => !START_BUILD_OPTIONS.includes(o)))
|
|
346
|
+
throw new RegisterError(START_USAGE);
|
|
347
|
+
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
348
|
+
return initBuild(dir, opts.name && opts.name !== 'true' ? opts.name : path.basename(dir), deps.identity().name, deps);
|
|
349
|
+
}
|
|
291
350
|
const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
292
351
|
if (typed.some(isPatternWord))
|
|
293
352
|
throw new RegisterError(NOT_A_PATTERN);
|
|
@@ -432,6 +491,12 @@ async function init(args, deps) {
|
|
|
432
491
|
const type = (opts.type ?? 'clean-core');
|
|
433
492
|
if (!TYPES.includes(type))
|
|
434
493
|
throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
|
|
494
|
+
if (type === 'build') {
|
|
495
|
+
// A team build pulls, measures and discovers nothing: those options are refused, as on `start --type build`.
|
|
496
|
+
if (Object.keys(opts).some((o) => !INIT_BUILD_OPTIONS.includes(o)))
|
|
497
|
+
throw new RegisterError(INIT_USAGE);
|
|
498
|
+
return initBuild(dir, name, lead, deps, given || undefined);
|
|
499
|
+
}
|
|
435
500
|
// One variant per team project (D6), named here or never. A name SAP could
|
|
436
501
|
// not accept is refused before a folder exists, not after it is written in.
|
|
437
502
|
const atcVariant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'].trim().toUpperCase() : '';
|
|
@@ -700,6 +765,11 @@ function join(args, deps, asName) {
|
|
|
700
765
|
state.projects[project.projectId] = { projectDir, outboxDir, writerId, ...(saved ? { name: saved } : {}) };
|
|
701
766
|
state.active = project.projectId;
|
|
702
767
|
saveState(state);
|
|
768
|
+
// A team build has no objects to own: one line.
|
|
769
|
+
if (isBuildProject(project)) {
|
|
770
|
+
deps.print(buildJoinLine(project, writerId));
|
|
771
|
+
return 0;
|
|
772
|
+
}
|
|
703
773
|
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
704
774
|
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
705
775
|
if (path.relative(projectDir, outboxDir) === '') {
|
|
@@ -2494,7 +2564,7 @@ const heldExportLine = (name) => ` ! "${name}" is open in another program, so i
|
|
|
2494
2564
|
/** The CSV export of the team project folder, as the team page reads it, into `dir`. */
|
|
2495
2565
|
export function writeExports(projectDir, project, dir, deps) {
|
|
2496
2566
|
const { register } = registerFor(projectDir, outboxFor(projectDir));
|
|
2497
|
-
return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2567
|
+
return writeExportFiles(register, coreProject(project), dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2498
2568
|
}
|
|
2499
2569
|
/**
|
|
2500
2570
|
* `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
|
package/dist/journey/chain.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { estateSummaryOf, viewWords } from '@cspeach/register-core';
|
|
17
17
|
import { DEFAULT_ATC_VARIANT, FIRST_CHECK_PER_PACKAGE_LINE, FIRST_CHECK_WHOLE_MAX, isLead, ruleObject, runTeamCommand, whoAmI } from '../commands/team.js';
|
|
18
|
+
import { FUNCTIONAL_VERB_LINE } from '../commands/team-build.js';
|
|
19
|
+
import { isFunctionalSeat } from '../licence/seat-state.js';
|
|
18
20
|
import { activeProject, readBaseline, readEstate, readProjectFile, VARIANT_NAME } from '../register/store.js';
|
|
19
21
|
import { findingsByObject } from '../register/findings.js';
|
|
20
22
|
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
@@ -245,6 +247,12 @@ export async function enterJourney(trigger, h) {
|
|
|
245
247
|
h.emit(trigger.line);
|
|
246
248
|
return;
|
|
247
249
|
}
|
|
250
|
+
// Final review F1: the clean-core and upgrade steps record work and run
|
|
251
|
+
// SAP's checks. A functional seat is told so once, before any card or read.
|
|
252
|
+
if (isFunctionalSeat()) {
|
|
253
|
+
h.emit(FUNCTIONAL_VERB_LINE);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
248
256
|
startJourneyFlow(h.session, trigger.kind === 'start' ? trigger.packages : []);
|
|
249
257
|
// B8: a start sentence is a new run: its DONE never reads as "nothing left" against an earlier one.
|
|
250
258
|
if (trigger.kind === 'start')
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { projectView, provenForTarget as provenForTargetCore } from '@cspeach/register-core';
|
|
29
|
-
import { DISCOVERY_DIR, TEAM_FILE, activeProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
|
|
29
|
+
import { DISCOVERY_DIR, TEAM_FILE, activeProject, coreProject, readBaseline, readEstate, readProjectFile } from '../register/store.js';
|
|
30
30
|
import { registerFor } from '../register/picture.js';
|
|
31
31
|
import { reportFileNames } from '../register/workbooks.js';
|
|
32
32
|
import { samePath } from '../commands/team-discover.js';
|
|
@@ -250,7 +250,7 @@ export function readJourneyFacts(cwd, me, skip) {
|
|
|
250
250
|
const joined = activeProject();
|
|
251
251
|
// The project file folderRead already read (joined means its folder is this one).
|
|
252
252
|
facts.register = registerFor(joined.projectDir, joined.outboxDir, project).register;
|
|
253
|
-
facts.view = projectView(facts.register, project, skip?.decide ? { skipped: skip.decide } : {});
|
|
253
|
+
facts.view = projectView(facts.register, coreProject(project), skip?.decide ? { skipped: skip.decide } : {});
|
|
254
254
|
facts.reportsAt = reportsAt(cwd, folder.name);
|
|
255
255
|
}
|
|
256
256
|
catch {
|
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/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';
|
|
@@ -170,6 +174,31 @@ export async function runOneShot(prompt, opts) {
|
|
|
170
174
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
171
175
|
return 1;
|
|
172
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;
|
|
173
202
|
// Security batch item 1 — licence check at one-shot start, byok / ai-hub /
|
|
174
203
|
// local only. Managed makes no new call: the proxy checks every request.
|
|
175
204
|
if (cfg.llm.mode !== 'managed') {
|
|
@@ -178,6 +207,9 @@ export async function runOneShot(prompt, opts) {
|
|
|
178
207
|
console.error(licence.message);
|
|
179
208
|
return 1;
|
|
180
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;
|
|
181
213
|
if (licence.verdict === 'unknown')
|
|
182
214
|
console.error(formatInfo(licence.line).trimEnd());
|
|
183
215
|
// Azure tenant mode — one-shot never asks: a changed endpoint, removed
|
|
@@ -127,7 +127,7 @@ function readsLikeQuestion(typed) {
|
|
|
127
127
|
export async function offerAnswerBlockers(args) {
|
|
128
128
|
let env;
|
|
129
129
|
try {
|
|
130
|
-
env = readProjectFile(args.path);
|
|
130
|
+
env = (args.readEnvelope ?? readProjectFile)(args.path);
|
|
131
131
|
}
|
|
132
132
|
catch {
|
|
133
133
|
return null;
|
|
@@ -226,19 +226,43 @@ export async function offerAnswerBlockers(args) {
|
|
|
226
226
|
// Typed replies were given but not saved: say "saved", not "entered".
|
|
227
227
|
const verb = [...outcomes.values()].includes('declined-question') ? 'saved' : 'entered';
|
|
228
228
|
args.log('');
|
|
229
|
+
// Record mode (team build): the spec is answered in CSPeach, not at cspeach.dev/edit.
|
|
230
|
+
const later = args.recordAnswer ? ''
|
|
231
|
+
: blockers.length === 1 ? ' Answer it later at cspeach.dev/edit.' : ' Answer them later at cspeach.dev/edit.';
|
|
229
232
|
args.log(blockers.length === 1
|
|
230
|
-
? `No answer ${verb}. The blocker stays open
|
|
231
|
-
: `No answers ${verb}. The ${blockers.length} blockers stay open
|
|
233
|
+
? `No answer ${verb}. The blocker stays open.${later}`
|
|
234
|
+
: `No answers ${verb}. The ${blockers.length} blockers stay open.${later}`);
|
|
232
235
|
args.onOutcomes?.(outcomes);
|
|
233
236
|
return null;
|
|
234
237
|
}
|
|
235
238
|
const nowIso = (args.now ?? (() => new Date().toISOString()))();
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
if (args.recordAnswer) {
|
|
240
|
+
// Team build (Task 9): check the revision, but never write the author's file.
|
|
241
|
+
const check = validateEnvelope(JSON.parse(JSON.stringify(applyBlockerAnswers(env, answers, args.identity, nowIso))));
|
|
242
|
+
if (!check.ok) {
|
|
243
|
+
args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const saved = saveBlockerAnswers(args.path, env, answers, args.identity, nowIso);
|
|
249
|
+
if (!saved.ok) {
|
|
250
|
+
args.log(`[answer] revision invalid (${saved.error}) — answers NOT saved.`);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
240
253
|
}
|
|
241
254
|
const stillOpen = blockers.length - answers.size;
|
|
255
|
+
if (args.recordAnswer) {
|
|
256
|
+
for (const [gapId, answer] of answers)
|
|
257
|
+
args.recordAnswer({ gapId, answer, identity: args.identity, at: nowIso });
|
|
258
|
+
args.log('');
|
|
259
|
+
args.log(`Recorded ${plural(answers.size, 'answer')} for the developer.`
|
|
260
|
+
+ (stillOpen === 0 ? ''
|
|
261
|
+
: stillOpen === 1 ? ' 1 blocker is still open.'
|
|
262
|
+
: ` ${stillOpen} blockers are still open.`));
|
|
263
|
+
args.onOutcomes?.(outcomes);
|
|
264
|
+
return answers.size;
|
|
265
|
+
}
|
|
242
266
|
args.log('');
|
|
243
267
|
args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
|
|
244
268
|
+ (stillOpen === 0 ? ''
|
|
@@ -24,6 +24,12 @@ import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, read
|
|
|
24
24
|
import { specGapRefForSave } from './plan-sources.js';
|
|
25
25
|
import { checkSkeletonDecision, hasSkeletonPhase } from './plan-skeleton.js';
|
|
26
26
|
import { laneRootFor } from '../lane/roots.js';
|
|
27
|
+
import { sharedSpecTargetFor, specAuthorFor, writeSharedSpec } from './spec-share.js';
|
|
28
|
+
/** YYYY-MM-DD in local time (the Word copy's date). */
|
|
29
|
+
function localDate(d) {
|
|
30
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
31
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
32
|
+
}
|
|
27
33
|
const SKILL_REGISTRY = {
|
|
28
34
|
'abap-spec-gap': {
|
|
29
35
|
artefactType: 'spec-gap',
|
|
@@ -489,9 +495,9 @@ export async function runSaveCommand(args) {
|
|
|
489
495
|
try {
|
|
490
496
|
// Project view Task 5: the view is built once here and answers each card's target.
|
|
491
497
|
const { registerFor } = await import('../register/picture.js');
|
|
492
|
-
const { readProjectFile } = await import('../register/store.js');
|
|
498
|
+
const { coreProject, readProjectFile } = await import('../register/store.js');
|
|
493
499
|
const { projectView } = await import('@cspeach/register-core');
|
|
494
|
-
const view = projectView(registerFor(project.projectDir, project.outboxDir).register, readProjectFile(project.projectDir));
|
|
500
|
+
const view = projectView(registerFor(project.projectDir, project.outboxDir).register, coreProject(readProjectFile(project.projectDir)));
|
|
495
501
|
targets = (key) => view.object(key.trim().toUpperCase())?.target;
|
|
496
502
|
}
|
|
497
503
|
catch {
|
|
@@ -552,6 +558,35 @@ export async function runSaveCommand(args) {
|
|
|
552
558
|
promotedFrom: args.promotedFrom ?? null,
|
|
553
559
|
});
|
|
554
560
|
}
|
|
561
|
+
// Team build (Task 9) — a spec-gap saved in a team build folder goes to the
|
|
562
|
+
// shared specs/<slug>/ folder, for the developer. Found by the folder
|
|
563
|
+
// CSPeach runs in, never by activeProject() (R10). There is no baseline and
|
|
564
|
+
// nothing to record into a register: the record block below is skipped, so
|
|
565
|
+
// a clean-core project that is active elsewhere is never touched.
|
|
566
|
+
// Final review F6: the caller's folder (args.cwd), never process.cwd().
|
|
567
|
+
const shared = sharedSpecTargetFor(args.cwd, spec.artefactType);
|
|
568
|
+
if (shared) {
|
|
569
|
+
try {
|
|
570
|
+
args.onProjectId?.(shared.projectId);
|
|
571
|
+
}
|
|
572
|
+
catch { /* tagging is best-effort */ }
|
|
573
|
+
const now = new Date();
|
|
574
|
+
const r = await writeSharedSpec({
|
|
575
|
+
teamDir: shared.teamDir,
|
|
576
|
+
projectId: shared.projectId,
|
|
577
|
+
env,
|
|
578
|
+
markdown: args.skillOutput,
|
|
579
|
+
by: specAuthorFor(shared.projectId, args.author.name),
|
|
580
|
+
...(args.sapSystem ? { sapSystem: args.sapSystem } : {}),
|
|
581
|
+
today: localDate(now),
|
|
582
|
+
now,
|
|
583
|
+
});
|
|
584
|
+
const slug = basename(r.specDir);
|
|
585
|
+
args.log(`Saved: specs/${slug}/${basename(r.jsonPath)} (in the shared team folder, for the developer)`);
|
|
586
|
+
if (r.docxPath)
|
|
587
|
+
args.log(`Word copy: specs/${slug}/${basename(r.docxPath)}`);
|
|
588
|
+
return r.jsonPath;
|
|
589
|
+
}
|
|
555
590
|
// Saves land in the workspace folder, not cwd — created only NOW, after
|
|
556
591
|
// the user confirmed (declined saves and conversational turns leave no
|
|
557
592
|
// mkdir/.gitignore side effects). Falls back to args.cwd if the workspace
|