@cspeach/cli 1.1.13 → 1.1.15

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/README.md CHANGED
@@ -128,6 +128,69 @@ If `cspeach doctor` fails:
128
128
 
129
129
  If the REPL hangs on first prompt, check `https://api.cspeach.dev/health`.
130
130
 
131
+ ## Working on team projects (contributors only)
132
+
133
+ `cspeach team …` and the `/team/` page are built on two sibling packages in this
134
+ repo:
135
+
136
+ - `register-core/` — the merge: records in, one picture out
137
+ - `sap-client/` — the SAP calls, including `atcRunSet`
138
+
139
+ The CLI depends on them as **`file:` links**, and pnpm **copies their `dist/`**
140
+ into `node_modules` at install time. Neither `dist/` is in git. So a fresh
141
+ checkout, or any checkout where somebody changed one of them and did not
142
+ rebuild, runs the CLI against whatever was copied last — usually a
143
+ `TypeError: … is not a function` in the middle of a `cspeach team refresh`.
144
+
145
+ `src/register/__tests__/linked-packages.test.ts` is the tripwire. When it fails
146
+ with *"The linked packages are out of date"*, run the recipe below.
147
+
148
+ ### The recipe
149
+
150
+ Git Bash:
151
+
152
+ ```bash
153
+ cd sap-client && npm run build
154
+ cd ../register-core && ../cspeach-cli/node_modules/.bin/tsc
155
+ cd ../cspeach-cli && pnpm install
156
+ ```
157
+
158
+ Windows (PowerShell or cmd):
159
+
160
+ ```text
161
+ cd sap-client
162
+ npm run build
163
+ cd ..\register-core
164
+ ..\cspeach-cli\node_modules\.bin\tsc.CMD
165
+ cd ..\cspeach-cli
166
+ pnpm install
167
+ ```
168
+
169
+ Then check the link really took:
170
+
171
+ ```bash
172
+ grep -c ruleIdFromAtc cspeach-cli/node_modules/@cspeach/register-core/dist/rules.js # ≥ 1
173
+ grep -c atcRunSet cspeach-cli/node_modules/@cspeach/sap-client/dist/adt-client.js # ≥ 1
174
+ ```
175
+
176
+ If either prints `0`, run `pnpm install --force` in `cspeach-cli/`.
177
+
178
+ Notes:
179
+
180
+ - **`npm run build` does not work in `register-core/`** in this checkout — its
181
+ devDependencies are not installed. Build it with the CLI's own compiler, as
182
+ above. `sap-client/` builds normally.
183
+ - **`dist/index.js` is only a re-export barrel**, so grep a real module
184
+ (`dist/rules.js`, `dist/register.js`) when you check a new name landed.
185
+ - **`pnpm install` rewrites one path separator in `cspeach-cli/pnpm-lock.yaml`.**
186
+ That is noise. Restore it before you commit:
187
+ `git checkout -- cspeach-cli/pnpm-lock.yaml`.
188
+ - The **viewer** reads `register-core` from SOURCE (a Vite alias) and needs none
189
+ of this.
190
+ - Both packages are **unpublished**. `prepublishOnly` blocks `npm publish` of
191
+ the CLI while either dependency is still a `file:` link; they have to be
192
+ published and the versions pinned before a CLI release.
193
+
131
194
  ## Links
132
195
 
133
196
  - **Landing**: https://cspeach.dev
package/dist/cli.js CHANGED
@@ -4,6 +4,11 @@ import { runAddSapWizard } from './sap/onboarding.js';
4
4
  import { runGcOnStartup } from './session/gc.js';
5
5
  import { checkForUpgrade } from './upgrade-check.js';
6
6
  import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
7
+ /**
8
+ * The verbs that make `cspeach project …` a mistyped team command rather than
9
+ * a prompt somebody meant to send. See the check in `main`.
10
+ */
11
+ const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'leave']);
7
12
  export async function main(argv) {
8
13
  runGcOnStartup().catch(() => { });
9
14
  checkForUpgrade().catch(() => { });
@@ -144,6 +149,47 @@ export async function main(argv) {
144
149
  const cfg = await loadConfig();
145
150
  return runWhoamiCommand({ proxyUrl: cfg.proxy_url, print: (s) => console.log(s) });
146
151
  }
152
+ // A team project — many laptops, one picture (spec 2026-09-18).
153
+ if (args[0] === 'team') {
154
+ const { runTeamCommand } = await import('./commands/team.js');
155
+ const { getAuthorIdentity } = await import('./agent/loop.js');
156
+ return runTeamCommand(args.slice(1), {
157
+ print: (s) => console.log(s),
158
+ identity: () => getAuthorIdentity(),
159
+ noSap,
160
+ // Standalone: no connection is offered at all, so nothing in `team` can reach for one.
161
+ connect: noSap ? undefined : async (alias) => {
162
+ const { loadConfig } = await import('./config/loader.js');
163
+ const { getConnection } = await import('./sap/connection-manager.js');
164
+ const { AdtClient } = await import('@cspeach/sap-client');
165
+ const cfg = await loadConfig();
166
+ const name = alias ?? sapAlias ?? Object.keys(cfg.sap)[0];
167
+ if (!name || !cfg.sap[name])
168
+ throw new Error('No SAP system is configured. Run: cspeach config add-sap');
169
+ const adt = new AdtClient(await getConnection(name, cfg.sap[name]));
170
+ return {
171
+ sql: (q, max) => adt.sqlQuery(q, max),
172
+ system: { sid: name },
173
+ atc: { runSet: (refs, variant, opts) => adt.atcRunSet(refs, variant, opts) },
174
+ };
175
+ },
176
+ ask: async (question) => {
177
+ const { isHeadless } = await import('./renderer/tty.js');
178
+ if (isHeadless())
179
+ return '';
180
+ const { input } = await import('@inquirer/prompts');
181
+ return input({ message: question });
182
+ },
183
+ });
184
+ }
185
+ // Typed from yesterday's notes. Without this the words fall through to the
186
+ // one-shot path at the bottom of this function and are sent to the model as
187
+ // a PAID prompt on a machine that is configured and logged in. A hint, not
188
+ // an alias: the old family does not run, and nothing is renamed back.
189
+ if (args[0] === 'project' && TEAM_SUBCOMMANDS.has(args[1] ?? '')) {
190
+ console.log('Unknown command: cspeach project. Team projects use: cspeach team init|join|lane|refresh|status|outbox|rule|leave');
191
+ return 1;
192
+ }
147
193
  if (args[0] === 'analyze' && args[1] === 'project-context') {
148
194
  const { runProjectContextImpactCommand } = await import('./commands/project-context-impact.js');
149
195
  return runProjectContextImpactCommand(args.slice(2));
@@ -162,32 +208,40 @@ export async function main(argv) {
162
208
  return runRepl({ sapAlias, noSap });
163
209
  }
164
210
  function printHelp() {
165
- console.log(`
166
- CSPeach — AI-assisted ABAP development CLI
167
-
168
- Usage:
169
- cspeach Start interactive REPL
170
- cspeach "<prompt>" One-shot — runs one turn and exits
171
- cspeach --one-shot "<prompt>" Same as above, explicit flag
172
- cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
173
- cspeach --no-sap Start without a SAP system (paste-mode skills only)
174
- cspeach --resume [id] Resume a previous session (list if no id)
175
- cspeach config add-sap Add a SAP system interactively
176
- cspeach config standalone Set the standalone SAP profile (no system)
177
- cspeach standards init Capture your team's ABAP development standards
178
- cspeach configure Alias for \`cspeach config add-sap\`
179
- cspeach config set <key> <value> Update a config key
180
- cspeach config unset <key> Remove a config key (revert to default)
181
- cspeach config show [key] Show config (full or single key)
182
- cspeach doctor Diagnose install + connectivity
183
- cspeach login Sign in via device-code flow
184
- cspeach logout Sign out (keep keychain key, if any)
185
- cspeach whoami Print current account info
186
- cspeach analyze project-context Compare flag-on vs flag-off session metrics
187
- cspeach --upgrade Upgrade to latest via npm
188
- cspeach --version Print version
189
- cspeach --help Show this help
190
-
191
- Docs: https://cspeach.dev
211
+ console.log(`
212
+ CSPeach — AI-assisted ABAP development CLI
213
+
214
+ Usage:
215
+ cspeach Start interactive REPL
216
+ cspeach "<prompt>" One-shot — runs one turn and exits
217
+ cspeach --one-shot "<prompt>" Same as above, explicit flag
218
+ cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
219
+ cspeach --no-sap Start without a SAP system (paste-mode skills only)
220
+ cspeach --resume [id] Resume a previous session (list if no id)
221
+ cspeach config add-sap Add a SAP system interactively
222
+ cspeach config standalone Set the standalone SAP profile (no system)
223
+ cspeach standards init Capture your team's ABAP development standards
224
+ cspeach configure Alias for \`cspeach config add-sap\`
225
+ cspeach config set <key> <value> Update a config key
226
+ cspeach config unset <key> Remove a config key (revert to default)
227
+ cspeach config show [key] Show config (full or single key)
228
+ cspeach doctor Diagnose install + connectivity
229
+ cspeach login Sign in via device-code flow
230
+ cspeach logout Sign out (keep keychain key, if any)
231
+ cspeach whoami Print current account info
232
+ cspeach team init <folder> Lead: pull the object list from SAP, start a team project
233
+ cspeach team join <folder> Record your results against a team project (on this laptop only)
234
+ cspeach team lane add|list Lead: who owns which packages
235
+ cspeach team refresh --lane <id> Measure with ATC: what is verified, what did not hold
236
+ cspeach team status [folder] Where every object stands, who owns it, what is proven
237
+ cspeach team outbox What this laptop has recorded, and where
238
+ cspeach team rule T N <d> Lead: settle a conflicting decision
239
+ cspeach team leave Stop recording
240
+ cspeach analyze project-context Compare flag-on vs flag-off session metrics
241
+ cspeach --upgrade Upgrade to latest via npm
242
+ cspeach --version Print version
243
+ cspeach --help Show this help
244
+
245
+ Docs: https://cspeach.dev
192
246
  `);
193
247
  }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `cspeach project …` — the Project Register from the terminal
3
+ * (spec: docs/superpowers/specs/2026-09-18-project-register-folder-mode.md §6).
4
+ *
5
+ * init <folder> lead: pull the baseline from SAP, write the project folder
6
+ * join <folder> everyone: start recording against a project
7
+ * lane … lead: who owns which packages
8
+ * status [folder] anyone: the register as text
9
+ * outbox what this laptop has recorded, and where
10
+ * rule … lead: settle a disagreement
11
+ * leave stop recording
12
+ *
13
+ * Every path written here is one a person typed. Nothing is written to a
14
+ * network location on CSPeach's own initiative, and none of this is reachable
15
+ * from a model tool.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { buildRegister, objectKey } from '@cspeach/register-core';
20
+ import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
21
+ import { BASELINE_FILE, PROJECT_FILE, RegisterError, activeProject, collectRecords, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, writeLanes, writerIdFor, } from '../register/store.js';
22
+ import { writeRecord } from '../register/records.js';
23
+ const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
24
+ const TYPES = ['clean-core', 'upgrade', 'build'];
25
+ function flags(args) {
26
+ const positional = [];
27
+ const opts = {};
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a.startsWith('--')) {
31
+ opts[a.slice(2)] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : 'true';
32
+ }
33
+ else
34
+ positional.push(a);
35
+ }
36
+ return { positional, opts };
37
+ }
38
+ const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
39
+ /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
40
+ function matches(pattern, value) {
41
+ const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
42
+ return re.test(value);
43
+ }
44
+ export async function runProjectCommand(args, deps) {
45
+ const [sub, ...rest] = args;
46
+ try {
47
+ switch (sub) {
48
+ case 'init': return await init(rest, deps);
49
+ case 'join': return join(rest, deps);
50
+ case 'lane': return lane(rest, deps);
51
+ case 'status': return status(rest, deps);
52
+ case 'outbox': return outbox(rest, deps);
53
+ case 'rule': return rule(rest, deps);
54
+ case 'leave': return leave(deps);
55
+ default:
56
+ deps.print('Usage: cspeach project init|join|lane|status|outbox|rule|leave (cspeach --help for details)');
57
+ return sub ? 1 : 0;
58
+ }
59
+ }
60
+ catch (e) {
61
+ if (e instanceof RegisterError || e instanceof BaselineError) {
62
+ deps.print(`✗ ${e.message}`);
63
+ return 1;
64
+ }
65
+ throw e;
66
+ }
67
+ }
68
+ function requireLead(project, deps) {
69
+ const me = deps.identity().name;
70
+ if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
71
+ throw new RegisterError(`Only the project lead (${project.lead.name}) changes this. You are ${me}.`);
72
+ }
73
+ }
74
+ function joinedOrThrow() {
75
+ const joined = activeProject();
76
+ if (!joined)
77
+ throw new RegisterError('This laptop has not joined a project. Run: cspeach project join <folder>');
78
+ return joined;
79
+ }
80
+ // ── init ────────────────────────────────────────────────────────────────────
81
+ async function init(args, deps) {
82
+ const { positional, opts } = flags(args);
83
+ const folder = positional[0];
84
+ if (!folder)
85
+ throw new RegisterError('Usage: cspeach project init <folder> --name "<name>" [--type clean-core|upgrade|build] [--packages ZFI_*,ZSD_*] [--include-local]');
86
+ const dir = path.resolve(folder);
87
+ if (fs.existsSync(path.join(dir, PROJECT_FILE)))
88
+ throw new RegisterError(`${dir} already holds a project. Pick an empty folder.`);
89
+ const name = opts.name ?? path.basename(dir);
90
+ const type = (opts.type ?? 'clean-core');
91
+ if (!TYPES.includes(type))
92
+ throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
93
+ if (!deps.connect)
94
+ throw new RegisterError('init needs a SAP connection to pull the object list.');
95
+ const { sql, system } = await deps.connect(opts['sap-alias']);
96
+ deps.print('Reading the package list from SAP…');
97
+ const all = await listCustomPackages(sql);
98
+ const patterns = list(opts.packages);
99
+ let chosen = all.filter((p) => patterns.some((pat) => matches(pat, p.package)));
100
+ if (!patterns.length && deps.choosePackages) {
101
+ const picked = new Set(await deps.choosePackages(all));
102
+ chosen = all.filter((p) => picked.has(p.package));
103
+ }
104
+ const local = all.find((p) => p.package === LOCAL_PACKAGE)?.objects ?? 0;
105
+ if (!opts['include-local'])
106
+ chosen = chosen.filter((p) => p.package !== LOCAL_PACKAGE);
107
+ if (!chosen.length) {
108
+ throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
109
+ }
110
+ deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
111
+ const pull = await pullBaseline(sql, { packages: chosen });
112
+ if (!pull.objects.length)
113
+ throw new RegisterError('Those packages hold no code objects. Nothing was written.');
114
+ const now = (deps.now ?? (() => new Date()))();
115
+ const projectId = projectIdFor(name);
116
+ const baselineText = `${JSON.stringify({ kind: 'baseline', formatVersion: 1, projectId, objects: pull.objects }, null, 1)}\n`;
117
+ const project = {
118
+ kind: 'project', formatVersion: 1, projectId, name, type, lead: { name: deps.identity().name },
119
+ ...(system ? { system } : {}),
120
+ ...(opts['atc-variant'] ? { atcVariant: opts['atc-variant'] } : {}),
121
+ baseline: { file: BASELINE_FILE.replace(/\\/g, '/'), sha256: sha256(baselineText), takenAt: now.toISOString(), objectCount: pull.objects.length },
122
+ setAside: { local: opts['include-local'] ? 0 : local, generated: pull.setAside.generated, otherTypes: pull.setAside.otherTypes },
123
+ createdAt: now.toISOString(),
124
+ };
125
+ fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
126
+ fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
127
+ fs.writeFileSync(path.join(dir, PROJECT_FILE), `${JSON.stringify(project, null, 2)}\n`);
128
+ writeLanes(dir, projectId, []);
129
+ deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
130
+ deps.print(` Set aside, not in scope: ${project.setAside.local} local ($TMP), ${pull.setAside.generated} generated, ${pull.setAside.otherTypes} non-code entries.`);
131
+ deps.print(` Written to ${dir}`);
132
+ deps.print(' Next: cspeach project lane add <id> --owner "<name>" --packages "ZFI_*" then each person: cspeach project join <folder>');
133
+ return join([dir], deps);
134
+ }
135
+ // ── join / leave ────────────────────────────────────────────────────────────
136
+ function join(args, deps) {
137
+ const { positional, opts } = flags(args);
138
+ if (!positional[0])
139
+ throw new RegisterError('Usage: cspeach project join <project folder> [--outbox <folder>]');
140
+ const projectDir = path.resolve(positional[0]);
141
+ const project = readProjectFile(projectDir);
142
+ const objects = readBaseline(projectDir).length; // also verifies the checksum
143
+ const state = loadState();
144
+ const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
145
+ const writerId = writerIdFor(deps.identity().name);
146
+ fs.mkdirSync(path.join(outboxDir, 'writers', writerId, 'records'), { recursive: true });
147
+ state.projects[project.projectId] = { projectDir, outboxDir, writerId };
148
+ state.active = project.projectId;
149
+ saveState(state);
150
+ deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
151
+ deps.print(` Your results are recorded on this laptop, in ${outboxDir}`);
152
+ deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the project folder,');
153
+ deps.print(' or keep the outbox inside your own synced folder: cspeach project join <folder> --outbox <folder>');
154
+ return 0;
155
+ }
156
+ function leave(deps) {
157
+ const state = loadState();
158
+ if (!state.active) {
159
+ deps.print('This laptop is not recording against a project.');
160
+ return 0;
161
+ }
162
+ const was = state.active;
163
+ state.active = null;
164
+ saveState(state);
165
+ deps.print(`✓ Stopped recording against ${was}. Files already written stay where they are.`);
166
+ return 0;
167
+ }
168
+ // ── lane ────────────────────────────────────────────────────────────────────
169
+ function lane(args, deps) {
170
+ const [verb, ...rest] = args;
171
+ const { positional, opts } = flags(rest);
172
+ const joined = joinedOrThrow();
173
+ const project = readProjectFile(joined.projectDir);
174
+ const lanes = readLanes(joined.projectDir);
175
+ if (verb === 'list' || !verb) {
176
+ if (!lanes.length)
177
+ deps.print('No lanes yet. cspeach project lane add <id> --owner "<name>" --packages "ZFI_*"');
178
+ for (const l of lanes)
179
+ deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${[...l.packages, ...l.namePatterns.map((n) => `name:${n}`)].join(', ')}`);
180
+ return 0;
181
+ }
182
+ requireLead(project, deps);
183
+ const id = positional[0];
184
+ if (!id)
185
+ throw new RegisterError('Usage: cspeach project lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
186
+ if (verb === 'remove') {
187
+ writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
188
+ deps.print(`✓ Lane ${id} removed.`);
189
+ return 0;
190
+ }
191
+ if (verb !== 'add')
192
+ throw new RegisterError('lane: use add, remove or list.');
193
+ const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
194
+ if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
195
+ throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
196
+ }
197
+ writeLanes(joined.projectDir, project.projectId, [...lanes.filter((l) => l.id !== id), next]);
198
+ const reg = registerFor(joined.projectDir, joined.outboxDir);
199
+ const mine = reg.register.lanes.find((l) => l.id === id);
200
+ deps.print(`✓ Lane ${id}: ${next.owner}, ${mine.objects} objects.`);
201
+ if (reg.register.totals.overlapping)
202
+ deps.print(` ! ${reg.register.totals.overlapping} objects now sit in more than one lane.`);
203
+ if (reg.register.totals.unowned)
204
+ deps.print(` ${reg.register.totals.unowned} objects still have no owner.`);
205
+ return 0;
206
+ }
207
+ // ── status ──────────────────────────────────────────────────────────────────
208
+ function registerFor(projectDir, outboxDir) {
209
+ const project = readProjectFile(projectDir);
210
+ const collected = collectRecords(projectDir);
211
+ // This laptop's own outbox counts too, so a developer sees their work before handing it in.
212
+ const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
213
+ const register = buildRegister({
214
+ project, baseline: readBaseline(projectDir), lanes: readLanes(projectDir),
215
+ records: [...collected.records, ...own.records],
216
+ });
217
+ return { register, files: collected.files + own.files, unreadable: collected.unreadable + own.unreadable };
218
+ }
219
+ const STATE_WORDS = [
220
+ ['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
221
+ ['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
222
+ ['regressed', 'regressed'], ['retired', 'retired'],
223
+ ];
224
+ function status(args, deps) {
225
+ const { positional } = flags(args);
226
+ const joined = activeProject();
227
+ const projectDir = positional[0] ? path.resolve(positional[0]) : joined?.projectDir;
228
+ if (!projectDir)
229
+ throw new RegisterError('Usage: cspeach project status [<project folder>]');
230
+ const project = readProjectFile(projectDir);
231
+ const { register: r, files, unreadable } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
232
+ const t = r.totals;
233
+ deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
234
+ deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. ${t.byState.verified} verified, ${t.byState.clean} clean.`);
235
+ deps.print(r.lastMeasuredAt ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC.` : 'Not measured yet: "verified" and "clean" need an ATC run.');
236
+ deps.print('');
237
+ for (const [state, word] of STATE_WORDS)
238
+ if (t.byState[state])
239
+ deps.print(` ${String(t.byState[state]).padStart(6)} ${word}`);
240
+ deps.print('');
241
+ if (r.lanes.length) {
242
+ deps.print('Lane Owner Objects Done Last record');
243
+ for (const l of r.lanes) {
244
+ const done = l.byState.verified + l.byState.clean + l.byState.retired;
245
+ deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} ${String(done).padStart(5)} ${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
246
+ }
247
+ deps.print('');
248
+ }
249
+ if (t.unowned)
250
+ deps.print(`! ${t.unowned} objects have no owner.`);
251
+ if (t.overlapping)
252
+ deps.print(`! ${t.overlapping} objects sit in more than one lane.`);
253
+ if (t.conflicts) {
254
+ deps.print(`! ${t.conflicts} objects have conflicting decisions. The lead settles each with: cspeach project rule <TYPE> <NAME> <decision>`);
255
+ for (const o of r.objects.filter((x) => x.decision?.conflict).slice(0, 10)) {
256
+ deps.print(` ${o.key}: ${Object.entries(o.decision.conflict).map(([w, c]) => `${w} says ${c}`).join(', ')}`);
257
+ }
258
+ }
259
+ const s = r.skipped;
260
+ if (unreadable)
261
+ deps.print(` ${unreadable} of ${files} files could not be read yet (still syncing, or damaged).`);
262
+ if (s.rowsNotInBaseline)
263
+ deps.print(` ${s.rowsNotInBaseline} rows named objects that are not in this project: ${r.unknownKeys.slice(0, 5).join(', ')}`);
264
+ if (s.otherProject)
265
+ deps.print(` ${s.otherProject} files belong to another project and were ignored.`);
266
+ if (s.rulingsNotFromLead)
267
+ deps.print(` ${s.rulingsNotFromLead} rulings were not from the lead and were ignored.`);
268
+ return 0;
269
+ }
270
+ // ── outbox / rule ───────────────────────────────────────────────────────────
271
+ function outbox(_args, deps) {
272
+ const joined = joinedOrThrow();
273
+ const dir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
274
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
275
+ deps.print(`Records this laptop has written for ${joined.projectId}: ${files.length}`);
276
+ deps.print(` ${dir}`);
277
+ for (const f of files.slice(-15)) {
278
+ let rows = '?';
279
+ try {
280
+ rows = String(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')).rows.length);
281
+ }
282
+ catch { /* listed anyway */ }
283
+ deps.print(` ${f} (${rows} rows)`);
284
+ }
285
+ deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
286
+ return 0;
287
+ }
288
+ function rule(args, deps) {
289
+ const { positional, opts } = flags(args);
290
+ const [type, name, classification] = positional;
291
+ if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
292
+ throw new RegisterError(`Usage: cspeach project rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
293
+ }
294
+ const joined = joinedOrThrow();
295
+ requireLead(readProjectFile(joined.projectDir), deps);
296
+ const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(opts.reason ? { reason: opts.reason } : {}) }], deps.identity(), { dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.() });
297
+ if (!res.file)
298
+ throw new RegisterError(`${objectKey(type, name)} is not in this project's baseline.`);
299
+ deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
300
+ return 0;
301
+ }