@cspeach/cli 1.1.28 → 1.1.30
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 +6 -3
- package/dist/agent/turn-error-ux.js +40 -0
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/team.js +298 -26
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/one-shot.js +1 -0
- package/dist/projects/clean-core-confirm.js +36 -0
- package/dist/projects/extract-clean-core.js +79 -0
- package/dist/projects/save-command.js +38 -9
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace.js +2 -1
- package/dist/register/atc-file.js +9 -1
- package/dist/register/findings.js +79 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +33 -0
- package/dist/register/picture.js +21 -0
- package/dist/register/records.js +32 -0
- package/dist/register/status-file/register-map.js +213 -0
- package/dist/register/status-file/register-model.js +307 -0
- package/dist/register/status-file/register-parts.js +509 -0
- package/dist/register/status-file/register-status-file.js +171 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +19 -0
- package/dist/register/status-html.js +17 -0
- package/dist/register/workbooks.js +104 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +117 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +1 -0
- package/dist/skills/bundled-skills.js +83 -83
- package/dist/tools/_filesystem-shared.js +16 -9
- package/dist/tools/project/team_findings.js +72 -0
- package/dist/tools/sap-write.js +8 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -291,9 +291,12 @@ export async function maybeOfferSave(p) {
|
|
|
291
291
|
// logged so the transcript stays honest about who said yes.
|
|
292
292
|
//
|
|
293
293
|
// CONTRACT: this headless prompt blanket-answers 'y' to ANY question it
|
|
294
|
-
// is asked, so
|
|
295
|
-
//
|
|
296
|
-
//
|
|
294
|
+
// is asked, so `prompt` may carry only ONE question (the save confirm).
|
|
295
|
+
// runSaveCommand asks 1+N cards: the save confirm, then (clean-core
|
|
296
|
+
// decisions) one card per row — and the row cards go through `confirm`
|
|
297
|
+
// only, never `prompt`, so a headless run confirms no row. See the
|
|
298
|
+
// matching note at the prompt site in src/projects/save-command.ts
|
|
299
|
+
// before adding further prompts there.
|
|
297
300
|
const { isHeadless } = await import('../renderer/tty.js');
|
|
298
301
|
const prompt = isHeadless()
|
|
299
302
|
? async (q) => {
|
|
@@ -82,6 +82,33 @@ function describeApiError(err, msg) {
|
|
|
82
82
|
const label = status ? `service error ${status}` : 'service error';
|
|
83
83
|
return (text ? `${label}: ${text}` : label).slice(0, 200);
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* The proxy's `409 {"error":"upgrade_required",min_cli_version,current}`, from
|
|
87
|
+
* the SDK error's parsed `.error` body or, failing that, its message text.
|
|
88
|
+
*/
|
|
89
|
+
function upgradeRequired(err) {
|
|
90
|
+
const e = err;
|
|
91
|
+
let body = e && typeof e.error === 'object' ? e.error : null;
|
|
92
|
+
if (body?.error !== 'upgrade_required') {
|
|
93
|
+
const msg = typeof e?.message === 'string' ? e.message : '';
|
|
94
|
+
const brace = msg.indexOf('{');
|
|
95
|
+
if (!/^409\b/.test(msg) || brace < 0)
|
|
96
|
+
return null;
|
|
97
|
+
try {
|
|
98
|
+
body = JSON.parse(msg.slice(brace, msg.lastIndexOf('}') + 1));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (body?.error !== 'upgrade_required')
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (e?.status != null && e.status !== 409)
|
|
107
|
+
return null;
|
|
108
|
+
const min = typeof body.min_cli_version === 'string' ? body.min_cli_version : 'a newer version';
|
|
109
|
+
const current = typeof body.current === 'string' ? body.current : 'an older version';
|
|
110
|
+
return { min, current };
|
|
111
|
+
}
|
|
85
112
|
/**
|
|
86
113
|
* Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
|
|
87
114
|
* Best-effort — failure here is silent, the user-facing block still renders.
|
|
@@ -164,6 +191,19 @@ export async function renderTurnError(input) {
|
|
|
164
191
|
print('');
|
|
165
192
|
return;
|
|
166
193
|
}
|
|
194
|
+
// 1.1.29 — the proxy's 409 upgrade_required (skill needs a newer CLI). It
|
|
195
|
+
// reaches here, not loop.ts's create-time check: the SDK raises it while
|
|
196
|
+
// the stream is read. "type continue" would only repeat the 409.
|
|
197
|
+
const upgrade = upgradeRequired(input.err);
|
|
198
|
+
if (upgrade) {
|
|
199
|
+
printBlock(print, formatFailure({
|
|
200
|
+
what: 'This needs a newer CSPeach',
|
|
201
|
+
detail: [`needs ${upgrade.min} or later · you have ${upgrade.current}`],
|
|
202
|
+
next: input.inSession ? 'type /exit, then run: cspeach --upgrade' : 'cspeach --upgrade',
|
|
203
|
+
}));
|
|
204
|
+
print('');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
167
207
|
const reason = describeError(input.err);
|
|
168
208
|
const logPath = await writeErrorLog(id, input.err);
|
|
169
209
|
const detail = [reason, `Session: ${id}`];
|
package/dist/auth/auth-file.js
CHANGED
|
@@ -202,9 +202,9 @@ export async function migrateLegacyByokKey(deps) {
|
|
|
202
202
|
export function legacyByokKeyNotice(outcome) {
|
|
203
203
|
switch (outcome) {
|
|
204
204
|
case 'conflict':
|
|
205
|
-
return 'cspeach:
|
|
205
|
+
return 'cspeach: auth.json in your CSPeach home still holds a pasted Anthropic API key in plain text that is different from the one in your OS keychain (the one CSPeach uses). It was kept in case it is your only copy — remove the "byokAnthropicKey" line from auth.json once you no longer need it.';
|
|
206
206
|
case 'keychain-unavailable':
|
|
207
|
-
return 'cspeach:
|
|
207
|
+
return 'cspeach: auth.json in your CSPeach home still holds your Anthropic API key in plain text — the OS keychain is not available, so it could not be moved there (on Linux: install and unlock a Secret Service keyring such as gnome-keyring). CSPeach will move it on the next start once the keychain works.';
|
|
208
208
|
default:
|
|
209
209
|
return null;
|
|
210
210
|
}
|
package/dist/cli.js
CHANGED
|
@@ -290,6 +290,10 @@ Usage:
|
|
|
290
290
|
cspeach team status [folder] Where every object stands, who owns it, what is proven
|
|
291
291
|
cspeach team outbox What this laptop has recorded, and where
|
|
292
292
|
cspeach team rule T N <d> Lead: settle a conflicting decision
|
|
293
|
+
cspeach team decide T N <d> Record a clean-core decision: keep, fix, rebuild, retire or exception
|
|
294
|
+
cspeach team fixed T N Claim a fix; the next refresh checks it
|
|
295
|
+
cspeach team sheet Write your working list (Excel) from the team project
|
|
296
|
+
cspeach team report Lead: the combined workbook and status page, in reports
|
|
293
297
|
cspeach team leave Stop recording
|
|
294
298
|
cspeach analyze project-context Compare flag-on vs flag-off session metrics
|
|
295
299
|
cspeach --upgrade Upgrade to latest via npm
|
package/dist/commands/login.js
CHANGED
|
@@ -97,7 +97,7 @@ export async function runLoginCommand(deps) {
|
|
|
97
97
|
apiKey: polled.api_key,
|
|
98
98
|
customerId: polled.customer_id,
|
|
99
99
|
});
|
|
100
|
-
deps.print(done('Saved your CSPeach key to
|
|
100
|
+
deps.print(done('Saved your CSPeach key to auth.json in your CSPeach home'));
|
|
101
101
|
// 6) /v1/me — figure out if BYOK is required.
|
|
102
102
|
try {
|
|
103
103
|
const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
|
package/dist/commands/logout.js
CHANGED
|
@@ -10,7 +10,7 @@ export async function runLogoutCommand(deps) {
|
|
|
10
10
|
const before = await readAuthFile();
|
|
11
11
|
await deleteAuthFile();
|
|
12
12
|
if (before) {
|
|
13
|
-
deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed
|
|
13
|
+
deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed auth.json from your CSPeach home`);
|
|
14
14
|
deps.print(` ${paint('dim', 'The REPL uses the keychain-stored key if you have one.')}`);
|
|
15
15
|
deps.print(` ${paint('dim', 'Next:')} cspeach login (to sign in again)`);
|
|
16
16
|
}
|
package/dist/commands/team.js
CHANGED
|
@@ -18,12 +18,18 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
-
import { activeOf,
|
|
21
|
+
import { activeOf, levelOf, normalizeRuleId, objectKey, provenOf, } from '@cspeach/register-core';
|
|
22
22
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
23
|
-
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject,
|
|
24
|
-
import { measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
23
|
+
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
24
|
+
import { MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
25
25
|
import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
|
|
26
26
|
import { writeRecord } from '../register/records.js';
|
|
27
|
+
import { findingsByObject } from '../register/findings.js';
|
|
28
|
+
import { registerFor } from '../register/picture.js';
|
|
29
|
+
import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
|
|
30
|
+
import { writeXlsxFile } from '../register/xlsx.js';
|
|
31
|
+
import { writeWhole } from '../register/write-whole.js';
|
|
32
|
+
import { statusHtmlFor } from '../register/status-html.js';
|
|
27
33
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
28
34
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
29
35
|
/**
|
|
@@ -38,6 +44,15 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
|
38
44
|
* the lead, and every record says who wrote it.
|
|
39
45
|
*/
|
|
40
46
|
const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
47
|
+
/** After a record this laptop wrote: its working list, made again. One line, or none. */
|
|
48
|
+
function refreshSheet(deps) {
|
|
49
|
+
const joined = activeProject();
|
|
50
|
+
if (!joined)
|
|
51
|
+
return;
|
|
52
|
+
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
|
|
53
|
+
if (res.line)
|
|
54
|
+
deps.print(res.line);
|
|
55
|
+
}
|
|
41
56
|
/**
|
|
42
57
|
* Names that are a machine's, not a person's. A team project whose lead is
|
|
43
58
|
* "Admin" says nothing about who leads it, and the writer id, the rulings and
|
|
@@ -102,8 +117,12 @@ export async function runTeamCommand(args, deps) {
|
|
|
102
117
|
case 'outbox': return outbox(rest, deps);
|
|
103
118
|
case 'rule': return rule(rest, deps);
|
|
104
119
|
case 'leave': return leave(deps);
|
|
120
|
+
case 'decide': return decide(rest, deps);
|
|
121
|
+
case 'fixed': return await fixed(rest, deps);
|
|
122
|
+
case 'sheet': return sheet(deps);
|
|
123
|
+
case 'report': return report(rest, deps);
|
|
105
124
|
default:
|
|
106
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave (cspeach --help for details)');
|
|
125
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report (cspeach --help for details)');
|
|
107
126
|
return sub ? 1 : 0;
|
|
108
127
|
}
|
|
109
128
|
}
|
|
@@ -298,6 +317,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
298
317
|
const res = await measure({
|
|
299
318
|
joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
|
|
300
319
|
dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
|
|
320
|
+
confirmAbsent: true,
|
|
301
321
|
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
302
322
|
signal: stop.signal,
|
|
303
323
|
onProgress: progressLine(deps),
|
|
@@ -312,7 +332,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
312
332
|
// line: this one record belongs to the team project, not to this laptop.
|
|
313
333
|
if (res.file)
|
|
314
334
|
deps.print(' This first measurement is part of the team project\'s baseline, in the shared folder.');
|
|
315
|
-
const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
335
|
+
const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
316
336
|
if (stoppedBy) {
|
|
317
337
|
const why = STOPPED_WORDS[stoppedBy];
|
|
318
338
|
deps.print(res.file
|
|
@@ -418,24 +438,6 @@ function lane(args, deps) {
|
|
|
418
438
|
return 0;
|
|
419
439
|
}
|
|
420
440
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
421
|
-
function registerFor(projectDir, outboxDir) {
|
|
422
|
-
const project = readProjectFile(projectDir);
|
|
423
|
-
const collected = collectRecords(projectDir);
|
|
424
|
-
// This laptop's own outbox counts too, so a developer sees their work before
|
|
425
|
-
// handing it in. It is also the one way this picture can legitimately differ
|
|
426
|
-
// from the team project page's, so `status` says out loud when it counted them.
|
|
427
|
-
const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
|
|
428
|
-
const baseline = readBaseline(projectDir);
|
|
429
|
-
const lanes = readLanes(projectDir);
|
|
430
|
-
const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
|
|
431
|
-
return {
|
|
432
|
-
register, files: collected.files + own.files,
|
|
433
|
-
unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
|
|
434
|
-
// F2: a lanes.json written before names began to narrow packages holds
|
|
435
|
-
// fewer objects now. The lead is told, not left to work it out from a count.
|
|
436
|
-
narrowed: narrowedLanes(lanes, baseline),
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
441
|
const STATE_WORDS = [
|
|
440
442
|
['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
|
|
441
443
|
['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
|
|
@@ -805,9 +807,49 @@ export const absentLine = (count, names) => [
|
|
|
805
807
|
+ `${count === 1 ? 'It is' : 'They are'} left as not measured.`,
|
|
806
808
|
...(names.length ? [` ${someNames(names)}`] : []),
|
|
807
809
|
];
|
|
810
|
+
/**
|
|
811
|
+
* R-1, said plainly: how many silent objects were checked again on their own,
|
|
812
|
+
* and — for the ones that were not — the true reason. An object the second pass
|
|
813
|
+
* skipped or never finished gets THIS reason, never the variant's.
|
|
814
|
+
*/
|
|
815
|
+
function secondPassLines(res, deps) {
|
|
816
|
+
const pass = res.secondPass;
|
|
817
|
+
if (!pass)
|
|
818
|
+
return;
|
|
819
|
+
if (pass.asked) {
|
|
820
|
+
deps.print(` ${plural(pass.asked, 'object the run did not list was', 'objects the run did not list were')} checked again on `
|
|
821
|
+
+ `${pass.asked === 1 ? 'its' : 'their'} own: ${pass.listed} listed.`);
|
|
822
|
+
}
|
|
823
|
+
const n = pass.notChecked ?? 0;
|
|
824
|
+
const own = n === 1 ? 'on its own and stays' : 'on their own and stay';
|
|
825
|
+
if (pass.stopped) {
|
|
826
|
+
const why = STOPPED_WORDS[pass.stopped];
|
|
827
|
+
deps.print(n
|
|
828
|
+
? ` ! Checking those objects one by one ${why} before it finished: ${plural(n, 'object was', 'objects were')} not checked again ${own} not measured.`
|
|
829
|
+
: ` ! Checking those objects one by one ${why} before it finished.`);
|
|
830
|
+
}
|
|
831
|
+
else if (n) {
|
|
832
|
+
deps.print(` ! ${plural(n, 'object was', 'objects were')} not checked again: ${n === 1 ? 'its own check' : 'their own checks'} did not finish. `
|
|
833
|
+
+ `${n === 1 ? 'It stays' : 'They stay'} not measured.`);
|
|
834
|
+
}
|
|
835
|
+
if (pass.skipped && pass.skippedBy === 'time') {
|
|
836
|
+
deps.print(` ! ${plural(pass.skipped, 'object the run did not list was', 'objects the run did not list were')} not checked again on `
|
|
837
|
+
+ `${pass.skipped === 1 ? 'its' : 'their'} own: the time budget ran out first. ${pass.skipped === 1 ? 'It stays' : 'They stay'} not measured.`);
|
|
838
|
+
}
|
|
839
|
+
else if (pass.skipped) {
|
|
840
|
+
deps.print(` ! ${plural(pass.skipped, 'more object was', 'more objects were')} not checked again `
|
|
841
|
+
+ `(one measurement checks up to ${MAX_SECOND_PASS} on their own). Narrow the run with --lane or --packages to check the rest.`);
|
|
842
|
+
}
|
|
843
|
+
if ((n || pass.skipped) && res.notCheckedNames?.length)
|
|
844
|
+
deps.print(` ${someNames(res.notCheckedNames)}`);
|
|
845
|
+
}
|
|
808
846
|
function notMeasuredLines(res, deps, words = {}) {
|
|
809
|
-
|
|
810
|
-
|
|
847
|
+
// Objects the second pass skipped or never finished were said above, with
|
|
848
|
+
// their own reason. Only the rest are the variant's silence.
|
|
849
|
+
const unchecked = new Set(res.notCheckedNames ?? []);
|
|
850
|
+
const silent = Math.max(0, res.absent - (res.secondPass?.skipped ?? 0) - (res.secondPass?.notChecked ?? 0));
|
|
851
|
+
if (silent) {
|
|
852
|
+
for (const line of words.absentLines?.(silent) ?? absentLine(silent, res.absentNames.filter((k) => !unchecked.has(k))))
|
|
811
853
|
deps.print(line);
|
|
812
854
|
}
|
|
813
855
|
for (const c of res.refusedChunks) {
|
|
@@ -826,10 +868,12 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
826
868
|
deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
|
|
827
869
|
for (const note of words.notes ?? [])
|
|
828
870
|
deps.print(note);
|
|
871
|
+
secondPassLines(res, deps);
|
|
829
872
|
notMeasuredLines(res, deps, words);
|
|
830
873
|
return;
|
|
831
874
|
}
|
|
832
875
|
deps.print(words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`);
|
|
876
|
+
secondPassLines(res, deps);
|
|
833
877
|
// F3: SAP reports some findings twice — same place, same message, two finding
|
|
834
878
|
// ids. One problem in the code is one finding, so it is counted once; a
|
|
835
879
|
// person comparing this with their own ATC screen has to be told why the two
|
|
@@ -850,6 +894,11 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
850
894
|
deps.print(` Written to ${res.file}`);
|
|
851
895
|
deps.print(' Next: cspeach team status');
|
|
852
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* A second pass that was cut short, or never had time to start, leaves objects
|
|
899
|
+
* not checked again: the measurement is partial, and the exit code says so.
|
|
900
|
+
*/
|
|
901
|
+
const secondPassStop = (res) => res.secondPass?.stopped ?? (res.secondPass?.skippedBy === 'time' && res.secondPass.skipped ? 'deadline' : undefined);
|
|
853
902
|
/** Why a run ended before it reached the end of the set, in plain words. */
|
|
854
903
|
const STOPPED_WORDS = {
|
|
855
904
|
deadline: 'ran out of time',
|
|
@@ -998,6 +1047,7 @@ function refreshFromFile(opts, deps) {
|
|
|
998
1047
|
});
|
|
999
1048
|
if (!res.file)
|
|
1000
1049
|
return 1;
|
|
1050
|
+
refreshSheet(deps);
|
|
1001
1051
|
// Live evidence outranks file evidence, per object. An import that changes no
|
|
1002
1052
|
// state is legal and is kept — but nobody should have to work that out from
|
|
1003
1053
|
// the picture afterwards, and no closing line may read as a fresh measurement.
|
|
@@ -1079,6 +1129,7 @@ async function refresh(args, deps) {
|
|
|
1079
1129
|
// the lead's first run, whose name is not on disk yet.
|
|
1080
1130
|
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1081
1131
|
signal: stop.signal,
|
|
1132
|
+
confirmAbsent: true,
|
|
1082
1133
|
onProgress: progressLine(deps),
|
|
1083
1134
|
});
|
|
1084
1135
|
}
|
|
@@ -1086,6 +1137,8 @@ async function refresh(args, deps) {
|
|
|
1086
1137
|
off();
|
|
1087
1138
|
}
|
|
1088
1139
|
reportMeasurement(res, variant, deps, foreign ? (project.atcVariant ?? '').trim().toUpperCase() : undefined);
|
|
1140
|
+
if (res.file)
|
|
1141
|
+
refreshSheet(deps);
|
|
1089
1142
|
// The picture, once, for the two things said after the report. Built lazily:
|
|
1090
1143
|
// a refresh that measured nothing and reported nothing does not need it.
|
|
1091
1144
|
let merged = null;
|
|
@@ -1111,7 +1164,7 @@ async function refresh(args, deps) {
|
|
|
1111
1164
|
+ `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
|
|
1112
1165
|
}
|
|
1113
1166
|
}
|
|
1114
|
-
const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
1167
|
+
const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
1115
1168
|
if (stoppedBy) {
|
|
1116
1169
|
const why = STOPPED_WORDS[stoppedBy];
|
|
1117
1170
|
deps.print(res.file
|
|
@@ -1164,3 +1217,222 @@ function rule(args, deps) {
|
|
|
1164
1217
|
deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
|
|
1165
1218
|
return 0;
|
|
1166
1219
|
}
|
|
1220
|
+
// ── decide ──────────────────────────────────────────────────────────────────
|
|
1221
|
+
const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
|
|
1222
|
+
const DECIDE_OPTIONS = ['level', 'target', 'reason'];
|
|
1223
|
+
/** The five words a person types, onto the four classifications the files hold (spec §4.2). */
|
|
1224
|
+
const DECIDE_WORDS = {
|
|
1225
|
+
keep: { classification: 'keep' },
|
|
1226
|
+
exception: { classification: 'keep', exception: true },
|
|
1227
|
+
fix: { classification: 'fix' },
|
|
1228
|
+
rebuild: { classification: 'redesign' },
|
|
1229
|
+
redesign: { classification: 'redesign' },
|
|
1230
|
+
retire: { classification: 'retire' },
|
|
1231
|
+
};
|
|
1232
|
+
const MAX_REASON = 300;
|
|
1233
|
+
export function parseDecideArgs(args) {
|
|
1234
|
+
const { positional, opts } = flags(args);
|
|
1235
|
+
for (const name of Object.keys(opts)) {
|
|
1236
|
+
if (!DECIDE_OPTIONS.includes(name))
|
|
1237
|
+
return { ok: false, message: `--${name} is not an option of team decide.\n${DECIDE_USAGE}` };
|
|
1238
|
+
}
|
|
1239
|
+
const [type, name, word] = positional;
|
|
1240
|
+
const said = word ? DECIDE_WORDS[word.toLowerCase()] : undefined;
|
|
1241
|
+
if (!type || !name || !said || positional.length > 3)
|
|
1242
|
+
return { ok: false, message: DECIDE_USAGE };
|
|
1243
|
+
const letter = (flag) => {
|
|
1244
|
+
if (opts[flag] === undefined)
|
|
1245
|
+
return { ok: true };
|
|
1246
|
+
const value = levelOf(opts[flag].trim().toUpperCase());
|
|
1247
|
+
return value ? { ok: true, value } : { ok: false };
|
|
1248
|
+
};
|
|
1249
|
+
const level = letter('level');
|
|
1250
|
+
const target = letter('target');
|
|
1251
|
+
if (!level.ok || !target.ok)
|
|
1252
|
+
return { ok: false, message: 'A level is one of A, B, C or D. A uses only released APIs; D modifies SAP standard.' };
|
|
1253
|
+
const moves = said.classification === 'fix' || said.classification === 'redesign';
|
|
1254
|
+
if (target.value && !moves)
|
|
1255
|
+
return { ok: false, message: 'A target level only goes with fix or rebuild.' };
|
|
1256
|
+
if (opts.reason === 'true')
|
|
1257
|
+
return { ok: false, message: '--reason needs words, for example: --reason "reads BSID directly"' };
|
|
1258
|
+
const reason = (opts.reason ?? '').trim();
|
|
1259
|
+
if (reason.length > MAX_REASON)
|
|
1260
|
+
return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
|
|
1261
|
+
if (said.exception && !reason)
|
|
1262
|
+
return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
|
|
1263
|
+
return {
|
|
1264
|
+
ok: true,
|
|
1265
|
+
value: {
|
|
1266
|
+
type: type.toUpperCase(), name: name.toUpperCase(),
|
|
1267
|
+
row: {
|
|
1268
|
+
classification: said.classification,
|
|
1269
|
+
...(said.exception ? { exception: true } : {}),
|
|
1270
|
+
...(level.value ? { level: level.value } : {}),
|
|
1271
|
+
// Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
|
|
1272
|
+
...(moves ? { targetLevel: target.value ?? 'A' } : {}),
|
|
1273
|
+
...(reason ? { reason } : {}),
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
|
|
1279
|
+
function decisionWords(row) {
|
|
1280
|
+
const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
|
|
1281
|
+
if (row.level)
|
|
1282
|
+
parts.push(`level ${row.level}`);
|
|
1283
|
+
if (row.targetLevel)
|
|
1284
|
+
parts.push(`target ${row.targetLevel}`);
|
|
1285
|
+
return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
|
|
1286
|
+
}
|
|
1287
|
+
function decide(args, deps) {
|
|
1288
|
+
const parsed = parseDecideArgs(args);
|
|
1289
|
+
if (!parsed.ok)
|
|
1290
|
+
throw new RegisterError(parsed.message);
|
|
1291
|
+
const joined = joinedOrThrow();
|
|
1292
|
+
const { type, name, row } = parsed.value;
|
|
1293
|
+
const res = writeRecord(joined, 'decision', [{ key: objectKey(type, name), ...row }], { name: whoAmI(deps) }, {
|
|
1294
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1295
|
+
});
|
|
1296
|
+
if (!res.file)
|
|
1297
|
+
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1298
|
+
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
|
|
1299
|
+
refreshSheet(deps);
|
|
1300
|
+
return 0;
|
|
1301
|
+
}
|
|
1302
|
+
// ── fixed ───────────────────────────────────────────────────────────────────
|
|
1303
|
+
const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
|
|
1304
|
+
const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
|
|
1305
|
+
/**
|
|
1306
|
+
* "I fixed this" — a claim, matched to a finding by (object, rule). It moves no
|
|
1307
|
+
* colour to proven: only the next measurement does (spec §4.3).
|
|
1308
|
+
*/
|
|
1309
|
+
async function fixed(args, deps) {
|
|
1310
|
+
const { positional, opts } = flags(args);
|
|
1311
|
+
for (const name of Object.keys(opts)) {
|
|
1312
|
+
if (name !== 'rule' && name !== 'transport')
|
|
1313
|
+
throw new RegisterError(`--${name} is not an option of team fixed.\n${FIXED_USAGE}`);
|
|
1314
|
+
}
|
|
1315
|
+
const [rawType, rawName] = positional;
|
|
1316
|
+
if (!rawType || !rawName || positional.length > 2)
|
|
1317
|
+
throw new RegisterError(FIXED_USAGE);
|
|
1318
|
+
const type = rawType.toUpperCase();
|
|
1319
|
+
const name = rawName.toUpperCase();
|
|
1320
|
+
const joined = joinedOrThrow();
|
|
1321
|
+
const key = objectKey(type, name);
|
|
1322
|
+
if (!readBaseline(joined.projectDir).some((o) => objectKey(o.type, o.name) === key)) {
|
|
1323
|
+
// `shown()`: the person's own arguments, still printed through CSPeach.
|
|
1324
|
+
throw new RegisterError(`${shown(type)} ${shown(name)} is not in this team project's object list.`);
|
|
1325
|
+
}
|
|
1326
|
+
let transport;
|
|
1327
|
+
if (opts.transport !== undefined) {
|
|
1328
|
+
transport = opts.transport === 'true' ? '' : opts.transport.trim().toUpperCase();
|
|
1329
|
+
if (!/^[A-Z0-9]{1,20}$/.test(transport))
|
|
1330
|
+
throw new RegisterError('--transport needs the request number, for example: --transport S4HK903500');
|
|
1331
|
+
}
|
|
1332
|
+
let rules;
|
|
1333
|
+
if (opts.rule !== undefined) {
|
|
1334
|
+
const rule = opts.rule === 'true' ? '' : normalizeRuleId(opts.rule);
|
|
1335
|
+
if (!rule)
|
|
1336
|
+
throw new RegisterError('--rule needs the rule id, for example: --rule "API:BSID"');
|
|
1337
|
+
rules = [rule];
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
|
|
1341
|
+
if (!f?.latestAt)
|
|
1342
|
+
throw new RegisterError('Nothing measured yet to claim against; name the rule with --rule.');
|
|
1343
|
+
if (!f.latest.length) {
|
|
1344
|
+
deps.print(`Nothing is open for ${type} ${name} at its last measurement (${utcLine(f.latestAt)}). Nothing was recorded.`);
|
|
1345
|
+
return 0;
|
|
1346
|
+
}
|
|
1347
|
+
deps.print(`Open for ${type} ${name} at its last measurement, ${utcLine(f.latestAt)}:`);
|
|
1348
|
+
for (const r of f.latest.slice(0, 10))
|
|
1349
|
+
deps.print(` ${r.count}× ${r.rule}${r.message ? ` — ${r.message}` : ''}`);
|
|
1350
|
+
if (f.latest.length > 10)
|
|
1351
|
+
deps.print(` and ${f.latest.length - 10} more`);
|
|
1352
|
+
if (!deps.ask)
|
|
1353
|
+
throw new RegisterError('Marking findings fixed needs a yes. In a script, name each rule with --rule.');
|
|
1354
|
+
const total = f.latest.reduce((n, r) => n + r.count, 0);
|
|
1355
|
+
const answer = (await deps.ask(`Mark these ${plural(total, 'finding', 'findings')} as fixed? [y/n]`)).trim().toLowerCase();
|
|
1356
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
1357
|
+
deps.print('Nothing was recorded.');
|
|
1358
|
+
return 1;
|
|
1359
|
+
}
|
|
1360
|
+
rules = f.latest.map((r) => r.rule);
|
|
1361
|
+
}
|
|
1362
|
+
writeRecord(joined, 'fix', rules.map((rule) => ({ key, rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: whoAmI(deps) }, { ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
1363
|
+
deps.print(`✓ Claimed: ${type} ${name}, ${plural(rules.length, 'rule', 'rules')} marked fixed. A claim is not proof — the next cspeach team refresh checks it.`);
|
|
1364
|
+
refreshSheet(deps);
|
|
1365
|
+
return 0;
|
|
1366
|
+
}
|
|
1367
|
+
// ── sheet ───────────────────────────────────────────────────────────────────
|
|
1368
|
+
function sheet(deps) {
|
|
1369
|
+
const joined = joinedOrThrow();
|
|
1370
|
+
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
|
|
1371
|
+
if (!res.ownsLane) {
|
|
1372
|
+
deps.print('You own no lane in this team project, so there is no working list to write. The lead adds lanes with: cspeach team lane add');
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
if (res.line)
|
|
1376
|
+
deps.print(res.line.trimStart());
|
|
1377
|
+
return res.file ? 0 : 1;
|
|
1378
|
+
}
|
|
1379
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
1380
|
+
const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
|
|
1381
|
+
/**
|
|
1382
|
+
* Lead only (spec §4.5). Reads the team project folder — the same files the team
|
|
1383
|
+
* page reads, so its numbers are the page's — and writes two files whole, then
|
|
1384
|
+
* renamed, into `reports/` or the folder named with --out. Never `writers/`.
|
|
1385
|
+
*/
|
|
1386
|
+
function report(args, deps) {
|
|
1387
|
+
const { positional, opts } = flags(args);
|
|
1388
|
+
if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
|
|
1389
|
+
throw new RegisterError(REPORT_USAGE);
|
|
1390
|
+
const joined = joinedOrThrow();
|
|
1391
|
+
const project = readProjectFile(joined.projectDir);
|
|
1392
|
+
requireLead(project, deps);
|
|
1393
|
+
const picture = registerFor(joined.projectDir);
|
|
1394
|
+
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
1395
|
+
const folderName = path.basename(joined.projectDir);
|
|
1396
|
+
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
1397
|
+
const names = reportFileNames(project.name);
|
|
1398
|
+
// A report open in Excel is the likeliest failure here: Windows refuses the
|
|
1399
|
+
// rename. Each file is tried on its own, so one held file never costs the
|
|
1400
|
+
// other, and the lead is told by file name which one was not updated.
|
|
1401
|
+
// writeWhole has already removed its .tmp when it throws.
|
|
1402
|
+
const held = [];
|
|
1403
|
+
const attempt = (name, write) => {
|
|
1404
|
+
try {
|
|
1405
|
+
write();
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
catch (e) {
|
|
1409
|
+
if (!HELD.has(e?.code ?? ''))
|
|
1410
|
+
throw e;
|
|
1411
|
+
held.push(name);
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
const saved = [
|
|
1416
|
+
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName))) ? names.xlsx : null,
|
|
1417
|
+
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
1418
|
+
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
1419
|
+
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
|
|
1420
|
+
}))) ? names.html : null,
|
|
1421
|
+
].filter((n) => n !== null);
|
|
1422
|
+
const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
|
|
1423
|
+
if (saved.length)
|
|
1424
|
+
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
1425
|
+
for (const name of held)
|
|
1426
|
+
deps.print(` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`);
|
|
1427
|
+
// Records on this laptop that have not reached the folder are not in the report.
|
|
1428
|
+
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
1429
|
+
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|
|
1430
|
+
if (path.resolve(ownDir) !== path.resolve(inFolder) && fs.existsSync(ownDir)) {
|
|
1431
|
+
const missing = fs.readdirSync(ownDir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(inFolder, f))).length;
|
|
1432
|
+
if (missing) {
|
|
1433
|
+
deps.print(` ${plural(missing, 'record file on this laptop is', 'record files on this laptop are')} not in the team project folder yet, `
|
|
1434
|
+
+ `so ${missing === 1 ? 'it is' : 'they are'} not in this report.`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return held.length ? 1 : 0;
|
|
1438
|
+
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -15,7 +15,7 @@ const serverText = (v) => sanitizeControlChars(String(v ?? '')).replace(/\s*\r?\
|
|
|
15
15
|
export async function runWhoamiCommand(deps) {
|
|
16
16
|
const auth = await readAuthFile();
|
|
17
17
|
if (!auth) {
|
|
18
|
-
deps.print(formatInfo('Not signed in with cspeach login (no
|
|
18
|
+
deps.print(formatInfo('Not signed in with cspeach login (no auth.json in your CSPeach home)').trimEnd());
|
|
19
19
|
deps.print(` ${paint('dim', 'Next:')} cspeach login (the REPL may still use a keychain key — run cspeach to check)`);
|
|
20
20
|
return 0;
|
|
21
21
|
}
|
package/dist/config/paths.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
-
|
|
3
|
+
/** `~/.cspeach` — where CSPeach lives unless CSPEACH_HOME says otherwise. */
|
|
4
|
+
export function defaultCspeachRoot() {
|
|
4
5
|
return path.join(os.homedir(), '.cspeach');
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* The CLI home. `CSPEACH_HOME` moves it (two seats on one machine — spec
|
|
9
|
+
* 2026-10-06 §8); blank or unset keeps `~/.cspeach`. A drive root (`C:\`,
|
|
10
|
+
* `/`) is never accepted: it has no folder name, so the file tools' home
|
|
11
|
+
* denylist (which matches the home's own folder name) could not hold it.
|
|
12
|
+
*/
|
|
13
|
+
export function cspeachRoot() {
|
|
14
|
+
const chosen = process.env.CSPEACH_HOME?.trim();
|
|
15
|
+
if (!chosen)
|
|
16
|
+
return defaultCspeachRoot();
|
|
17
|
+
const resolved = path.resolve(chosen);
|
|
18
|
+
return path.basename(resolved) ? resolved : defaultCspeachRoot();
|
|
19
|
+
}
|
|
6
20
|
export function configFile() {
|
|
7
21
|
return path.join(cspeachRoot(), 'config.toml');
|
|
8
22
|
}
|
package/dist/one-shot.js
CHANGED
|
@@ -68,6 +68,7 @@ import './tools/project/playbook_get.js';
|
|
|
68
68
|
import './tools/project/convention_get.js';
|
|
69
69
|
import './tools/project/cca_merge.js';
|
|
70
70
|
import './tools/project/upgrade_merge_progress.js';
|
|
71
|
+
import './tools/project/team_findings.js';
|
|
71
72
|
import './tools/subagent/_background-shared.js';
|
|
72
73
|
import './tools/subagent/background_run.js';
|
|
73
74
|
import './tools/subagent/monitor_emit.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const WORD = {
|
|
2
|
+
keep: 'keep', exception: 'accepted exception', fix: 'fix', rebuild: 'rebuild beside the core', retire: 'retire',
|
|
3
|
+
};
|
|
4
|
+
export function rowWords(r) {
|
|
5
|
+
const parts = [WORD[r.disposition]];
|
|
6
|
+
if (r.level)
|
|
7
|
+
parts.push(`level ${r.level}`);
|
|
8
|
+
if (r.disposition === 'fix' || r.disposition === 'rebuild')
|
|
9
|
+
parts.push(`target ${r.targetLevel ?? 'A'}`);
|
|
10
|
+
return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
|
|
11
|
+
}
|
|
12
|
+
const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
|
|
13
|
+
export async function confirmCleanCoreRows(ex, a) {
|
|
14
|
+
if (!a.joined) {
|
|
15
|
+
// Review Focus 4: the file is still saved; say why nothing is recorded.
|
|
16
|
+
a.log('Not recorded for a team project: this laptop has not joined one. To record these decisions, join with: cspeach team join <folder>');
|
|
17
|
+
return ex;
|
|
18
|
+
}
|
|
19
|
+
if (!a.confirm) {
|
|
20
|
+
a.log(`Not recorded for the team project: each row needs a yes, and this session cannot ask. Record them with: ${DECIDE_HINT}`);
|
|
21
|
+
return ex;
|
|
22
|
+
}
|
|
23
|
+
const rows = [];
|
|
24
|
+
for (const r of ex.content.adjudications) {
|
|
25
|
+
const answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?`, { default: 'n' });
|
|
26
|
+
rows.push({ ...r, confirmed: answer === 'y' });
|
|
27
|
+
}
|
|
28
|
+
const yes = rows.filter((r) => r.confirmed).length;
|
|
29
|
+
a.log(`${yes} of ${rows.length} rows confirmed. To record a row differently: ${DECIDE_HINT}`);
|
|
30
|
+
const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
|
|
31
|
+
return {
|
|
32
|
+
...ex,
|
|
33
|
+
content: { ...ex.content, adjudications: rows },
|
|
34
|
+
items: ex.items.map((i) => ({ ...i, status: byId.get(i.id) ? 'confirmed' : 'declined' })),
|
|
35
|
+
};
|
|
36
|
+
}
|