@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.
Files changed (41) hide show
  1. package/dist/agent/loop.js +6 -3
  2. package/dist/agent/turn-error-ux.js +40 -0
  3. package/dist/auth/auth-file.js +2 -2
  4. package/dist/cli.js +4 -0
  5. package/dist/commands/login.js +1 -1
  6. package/dist/commands/logout.js +1 -1
  7. package/dist/commands/team.js +298 -26
  8. package/dist/commands/whoami.js +1 -1
  9. package/dist/config/paths.js +15 -1
  10. package/dist/one-shot.js +1 -0
  11. package/dist/projects/clean-core-confirm.js +36 -0
  12. package/dist/projects/extract-clean-core.js +79 -0
  13. package/dist/projects/save-command.js +38 -9
  14. package/dist/projects/validate.js +2 -1
  15. package/dist/projects/workspace.js +2 -1
  16. package/dist/register/atc-file.js +9 -1
  17. package/dist/register/findings.js +79 -0
  18. package/dist/register/measure.js +60 -1
  19. package/dist/register/offer.js +33 -0
  20. package/dist/register/picture.js +21 -0
  21. package/dist/register/records.js +32 -0
  22. package/dist/register/status-file/register-map.js +213 -0
  23. package/dist/register/status-file/register-model.js +307 -0
  24. package/dist/register/status-file/register-parts.js +509 -0
  25. package/dist/register/status-file/register-status-file.js +171 -0
  26. package/dist/register/status-file/team-css.js +5 -0
  27. package/dist/register/status-file-sync.js +19 -0
  28. package/dist/register/status-html.js +17 -0
  29. package/dist/register/workbooks.js +104 -0
  30. package/dist/register/write-whole.js +22 -0
  31. package/dist/register/xlsx.js +117 -0
  32. package/dist/renderer/tool-labels.js +2 -1
  33. package/dist/repl.js +1 -0
  34. package/dist/skills/bundled-skills.js +83 -83
  35. package/dist/tools/_filesystem-shared.js +16 -9
  36. package/dist/tools/project/team_findings.js +72 -0
  37. package/dist/tools/sap-write.js +8 -0
  38. package/dist/tools/tool-loading.js +1 -1
  39. package/dist/ui/app.js +4 -0
  40. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  41. package/package.json +2 -2
@@ -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 runSaveCommand must ask at most ONE question (the save
295
- // confirm). See the matching note at the prompt site in
296
- // src/projects/save-command.ts before adding further prompts there.
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}`];
@@ -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: ~/.cspeach/auth.json 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.';
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: ~/.cspeach/auth.json 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.';
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
@@ -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 ~/.cspeach/auth.json'));
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}` } });
@@ -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 ~/.cspeach/auth.json`);
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
  }
@@ -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, buildRegister, narrowedLanes, objectKey, provenOf, } from '@cspeach/register-core';
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, collectRecords, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
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
- if (res.absent) {
810
- for (const line of words.absentLines?.(res.absent) ?? absentLine(res.absent, res.absentNames))
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
+ }
@@ -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 ~/.cspeach/auth.json)').trimEnd());
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
  }
@@ -1,8 +1,22 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- export function cspeachRoot() {
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
+ }