@cspeach/cli 1.1.30 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +121 -8
- package/dist/cli.js +36 -14
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +375 -41
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +20 -1
- package/dist/projects/clean-core-confirm.js +156 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +83 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +25 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +24 -6
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +3 -0
- package/dist/register/status-html.js +3 -1
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +27 -7
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +157 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +77 -4
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
package/dist/commands/team.js
CHANGED
|
@@ -18,18 +18,20 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
-
import {
|
|
21
|
+
import { laneActiveOf, laneProvenOf, levelOf, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
22
22
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.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';
|
|
23
|
+
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
24
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
|
-
import { writeRecord } from '../register/records.js';
|
|
26
|
+
import { writeLeadRecord, writeRecord } from '../register/records.js';
|
|
27
27
|
import { findingsByObject } from '../register/findings.js';
|
|
28
28
|
import { registerFor } from '../register/picture.js';
|
|
29
29
|
import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
|
|
30
30
|
import { writeXlsxFile } from '../register/xlsx.js';
|
|
31
31
|
import { writeWhole } from '../register/write-whole.js';
|
|
32
32
|
import { statusHtmlFor } from '../register/status-html.js';
|
|
33
|
+
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
34
|
+
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
33
35
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
34
36
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
35
37
|
/**
|
|
@@ -43,7 +45,7 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
|
43
45
|
* compares this with the lead's name, a ruling only counts when it comes from
|
|
44
46
|
* the lead, and every record says who wrote it.
|
|
45
47
|
*/
|
|
46
|
-
const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
48
|
+
export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
47
49
|
/** After a record this laptop wrote: its working list, made again. One line, or none. */
|
|
48
50
|
function refreshSheet(deps) {
|
|
49
51
|
const joined = activeProject();
|
|
@@ -74,7 +76,7 @@ function looksLikeAMachine(name, deps) {
|
|
|
74
76
|
* and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
|
|
75
77
|
* has always taken. The verbs that pass nothing read exactly as before.
|
|
76
78
|
*/
|
|
77
|
-
function flags(args, flagOnly = []) {
|
|
79
|
+
export function flags(args, flagOnly = []) {
|
|
78
80
|
const positional = [];
|
|
79
81
|
const opts = {};
|
|
80
82
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -97,7 +99,7 @@ function flags(args, flagOnly = []) {
|
|
|
97
99
|
}
|
|
98
100
|
return { positional, opts };
|
|
99
101
|
}
|
|
100
|
-
const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
101
103
|
/** "1 object", "3 objects" — every count this command prints reads as a sentence. */
|
|
102
104
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
103
105
|
/** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
|
|
@@ -110,8 +112,9 @@ export async function runTeamCommand(args, deps) {
|
|
|
110
112
|
try {
|
|
111
113
|
switch (sub) {
|
|
112
114
|
case 'init': return await init(rest, deps);
|
|
115
|
+
case 'start': return await start(rest, deps);
|
|
113
116
|
case 'join': return join(rest, deps);
|
|
114
|
-
case 'lane': return lane(rest, deps);
|
|
117
|
+
case 'lane': return await lane(rest, deps);
|
|
115
118
|
case 'refresh': return await refresh(rest, deps);
|
|
116
119
|
case 'status': return status(rest, deps);
|
|
117
120
|
case 'outbox': return outbox(rest, deps);
|
|
@@ -121,8 +124,12 @@ export async function runTeamCommand(args, deps) {
|
|
|
121
124
|
case 'fixed': return await fixed(rest, deps);
|
|
122
125
|
case 'sheet': return sheet(deps);
|
|
123
126
|
case 'report': return report(rest, deps);
|
|
127
|
+
case 'discover': return await teamDiscover(rest, deps);
|
|
128
|
+
case 'focus': return focusCommand(rest, deps);
|
|
129
|
+
case 'priority': return priorityCommand(rest, deps);
|
|
130
|
+
case 'plan': return await planCommand(rest, deps);
|
|
124
131
|
default:
|
|
125
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report (cspeach --help for details)');
|
|
132
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report|discover|focus|priority|plan|start (cspeach --help for details)');
|
|
126
133
|
return sub ? 1 : 0;
|
|
127
134
|
}
|
|
128
135
|
}
|
|
@@ -131,16 +138,22 @@ export async function runTeamCommand(args, deps) {
|
|
|
131
138
|
deps.print(`✗ ${e.message}`);
|
|
132
139
|
return 1;
|
|
133
140
|
}
|
|
134
|
-
|
|
141
|
+
// Anything else (no SAP system configured, the connection refused, …) is one
|
|
142
|
+
// plain line too, never a stack trace (rehearsal 2026-10-07). CSPEACH_DEBUG=1
|
|
143
|
+
// still rethrows, so the trace is there when someone needs it.
|
|
144
|
+
if (process.env['CSPEACH_DEBUG'] === '1')
|
|
145
|
+
throw e;
|
|
146
|
+
deps.print(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
|
|
147
|
+
return 1;
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
|
-
function requireLead(project, deps) {
|
|
150
|
+
export function requireLead(project, deps) {
|
|
138
151
|
const me = whoAmI(deps);
|
|
139
152
|
if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
|
|
140
153
|
throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
|
-
function joinedOrThrow() {
|
|
156
|
+
export function joinedOrThrow() {
|
|
144
157
|
const joined = activeProject();
|
|
145
158
|
if (!joined)
|
|
146
159
|
throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
|
|
@@ -148,11 +161,11 @@ function joinedOrThrow() {
|
|
|
148
161
|
}
|
|
149
162
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
150
163
|
const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<your name>"] [--type clean-core|upgrade|build] '
|
|
151
|
-
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure]';
|
|
164
|
+
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
|
|
152
165
|
/** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
|
|
153
|
-
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure'];
|
|
166
|
+
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
154
167
|
/** The two that take no value, so neither can eat the folder name. */
|
|
155
|
-
const INIT_FLAGS = ['include-local', 'no-measure'];
|
|
168
|
+
const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
156
169
|
/** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
|
|
157
170
|
const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
|
|
158
171
|
/**
|
|
@@ -178,6 +191,60 @@ function unknownInitOption(name) {
|
|
|
178
191
|
return said;
|
|
179
192
|
return `${said} Did you mean --${guess}?`;
|
|
180
193
|
}
|
|
194
|
+
const START_USAGE = 'Usage: cspeach team start <packages> [--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
195
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias'];
|
|
196
|
+
/** The check a project measures with when nobody names one. */
|
|
197
|
+
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
198
|
+
/**
|
|
199
|
+
* 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
|
|
200
|
+
* command for what init + join + lane add said in three: the folder you are
|
|
201
|
+
* in, a name from the packages, you as the lead, the default check, your
|
|
202
|
+
* results straight into the folder, and every object yours. The long verbs
|
|
203
|
+
* stay for big team setups.
|
|
204
|
+
*/
|
|
205
|
+
async function start(args, deps) {
|
|
206
|
+
const { positional, opts } = flags(args, ['no-measure']);
|
|
207
|
+
for (const name of Object.keys(opts)) {
|
|
208
|
+
if (!START_OPTIONS.includes(name))
|
|
209
|
+
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
210
|
+
}
|
|
211
|
+
const packages = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
212
|
+
if (!packages.length)
|
|
213
|
+
throw new RegisterError(START_USAGE);
|
|
214
|
+
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
215
|
+
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
216
|
+
throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
|
|
217
|
+
const lead = deps.identity().name;
|
|
218
|
+
const name = opts.name && opts.name !== 'true' ? opts.name : packages.join(', ');
|
|
219
|
+
const variant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'] : DEFAULT_ATC_VARIANT;
|
|
220
|
+
// The three verbs run as they always do; their detail is kept back and the
|
|
221
|
+
// few lines that matter are said once, below.
|
|
222
|
+
const said = [];
|
|
223
|
+
const quiet = { ...deps, print: (l) => said.push(l) };
|
|
224
|
+
const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
|
|
225
|
+
...(opts['no-measure'] ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
|
|
226
|
+
const code = await init(initArgs, quiet);
|
|
227
|
+
if (code !== 0) {
|
|
228
|
+
for (const l of said)
|
|
229
|
+
deps.print(l);
|
|
230
|
+
return code;
|
|
231
|
+
}
|
|
232
|
+
join([dir, '--outbox', dir], quiet);
|
|
233
|
+
const laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
|
|
234
|
+
await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
|
|
235
|
+
const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
|
|
236
|
+
const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
|
|
237
|
+
const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
|
|
238
|
+
deps.print(`✓ Started "${name}" in this folder. Lead: ${lead}.`);
|
|
239
|
+
deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
|
|
240
|
+
if (measured)
|
|
241
|
+
deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
|
|
242
|
+
else if (opts['no-measure'])
|
|
243
|
+
deps.print(' No check run yet: cspeach team refresh measures it.');
|
|
244
|
+
deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
|
|
245
|
+
deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
181
248
|
async function init(args, deps) {
|
|
182
249
|
const { positional, opts } = flags(args, INIT_FLAGS);
|
|
183
250
|
// `--no-measur` must not read as "measure anyway": a person who meant to skip
|
|
@@ -210,6 +277,11 @@ async function init(args, deps) {
|
|
|
210
277
|
throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
|
|
211
278
|
if (!deps.connect)
|
|
212
279
|
throw new RegisterError('init needs a SAP connection to pull the object list.');
|
|
280
|
+
if (opts['from-discovery'] && opts['no-from-discovery'])
|
|
281
|
+
throw new RegisterError(`Say --from-discovery or --no-from-discovery, not both.
|
|
282
|
+
${INIT_USAGE}`);
|
|
283
|
+
// The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
|
|
284
|
+
const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
|
|
213
285
|
const { sql, system, atc } = await deps.connect(opts['sap-alias']);
|
|
214
286
|
deps.print('Reading the package list from SAP…');
|
|
215
287
|
const all = await listCustomPackages(sql);
|
|
@@ -225,6 +297,9 @@ async function init(args, deps) {
|
|
|
225
297
|
if (!chosen.length) {
|
|
226
298
|
throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
|
|
227
299
|
}
|
|
300
|
+
if (discovery)
|
|
301
|
+
for (const l of estatePackageLines(chosen, discovery.estate))
|
|
302
|
+
deps.print(l);
|
|
228
303
|
deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
|
|
229
304
|
const pull = await pullBaseline(sql, { packages: chosen });
|
|
230
305
|
if (!pull.objects.length)
|
|
@@ -244,9 +319,14 @@ async function init(args, deps) {
|
|
|
244
319
|
fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
|
|
245
320
|
fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
246
321
|
writeLanes(dir, projectId, []);
|
|
322
|
+
const evidenceLines = discovery || opts['from-discovery']
|
|
323
|
+
? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
|
|
324
|
+
: [];
|
|
247
325
|
deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
|
|
248
326
|
deps.print(` Set aside, not in scope: ${project.setAside.local} customer-named local ($TMP) entries of any type, ${pull.setAside.generated} generated, ${pull.setAside.otherTypes} non-code entries.`);
|
|
249
327
|
deps.print(` Written to ${dir}`);
|
|
328
|
+
for (const l of evidenceLines)
|
|
329
|
+
deps.print(l);
|
|
250
330
|
// F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
|
|
251
331
|
// user. That name goes into the team project file, into the writer id and
|
|
252
332
|
// into every record from then on. Said once, never asked.
|
|
@@ -272,6 +352,62 @@ async function init(args, deps) {
|
|
|
272
352
|
deps.print(l);
|
|
273
353
|
return 0;
|
|
274
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Per chosen package, what the estate knows beside the live count: its
|
|
357
|
+
* description, application area and last change. Packages the estate does
|
|
358
|
+
* not hold get the count alone.
|
|
359
|
+
*/
|
|
360
|
+
function estatePackageLines(chosen, estate) {
|
|
361
|
+
const known = new Map();
|
|
362
|
+
const pkgs = estate?.packages;
|
|
363
|
+
if (Array.isArray(pkgs))
|
|
364
|
+
for (const p of pkgs)
|
|
365
|
+
if (p && typeof p.name === 'string')
|
|
366
|
+
known.set(p.name, p);
|
|
367
|
+
return chosen.map((c) => {
|
|
368
|
+
const p = known.get(c.package);
|
|
369
|
+
const text = (v) => (typeof v === 'string' ? v.trim().slice(0, 300) : '');
|
|
370
|
+
const parts = [plural(c.objects, 'object', 'objects')];
|
|
371
|
+
if (p) {
|
|
372
|
+
if (text(p.text))
|
|
373
|
+
parts.push(text(p.text));
|
|
374
|
+
if (text(p.area))
|
|
375
|
+
parts.push(`area ${text(p.area)}`);
|
|
376
|
+
const at = text(p.lastChanged?.at);
|
|
377
|
+
if (at)
|
|
378
|
+
parts.push(`last changed ${at}${text(p.lastChanged?.by) ? ` by ${text(p.lastChanged.by)}` : ''}`);
|
|
379
|
+
}
|
|
380
|
+
return ` ${c.package}: ${parts.join(' · ')}`;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* baseline/evidence-0.json (spec 2026-10-06 §7): the inventory's evidence for
|
|
385
|
+
* the team project's own objects, from the newest object list. Rows for other
|
|
386
|
+
* objects are never written; they are counted. Never fails `init`: the team
|
|
387
|
+
* project is complete without it.
|
|
388
|
+
*/
|
|
389
|
+
function firstEvidence(dir, projectId, baseline, discovery, by, now, deps) {
|
|
390
|
+
if (!discovery)
|
|
391
|
+
return [' No discovery in this folder, so no evidence was written. Run: cspeach team discover --out <folder>'];
|
|
392
|
+
const ev = evidenceRows(discovery.objects, new Set(baseline.map((o) => objectKey(o.type, o.name))));
|
|
393
|
+
if (!ev)
|
|
394
|
+
return [' The object list of the inventory could not be read, so no evidence was written.'];
|
|
395
|
+
try {
|
|
396
|
+
const file = `objects-${discovery.stamp}.json`;
|
|
397
|
+
let sum = '';
|
|
398
|
+
try {
|
|
399
|
+
sum = sha256(fs.readFileSync(path.join(dir, 'discovery', file), 'utf8'));
|
|
400
|
+
}
|
|
401
|
+
catch { /* the checksum is a nicety */ }
|
|
402
|
+
writeLeadRecord(path.join(dir, 'baseline'), projectId, 'evidence', { source: { file, sha256: sum } }, ev.rows, by, { now, fileName: 'evidence-0.json', ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
// Node's own message carries the full path, user name and all; the code says enough.
|
|
406
|
+
const code = e?.code;
|
|
407
|
+
return [` ! The inventory's evidence could not be written${code ? ` (${code})` : ''}. The team project itself is complete.`];
|
|
408
|
+
}
|
|
409
|
+
return [` ${EVIDENCE_LINE(ev.rows.length)}`, ...(ev.outside ? [` ${OUTSIDE_LINE(ev.outside)}`] : [])];
|
|
410
|
+
}
|
|
275
411
|
/** Errors that are about this laptop's disk or the shared folder, never about SAP. */
|
|
276
412
|
const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
|
|
277
413
|
/**
|
|
@@ -376,6 +512,11 @@ function join(args, deps, asName) {
|
|
|
376
512
|
state.active = project.projectId;
|
|
377
513
|
saveState(state);
|
|
378
514
|
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
515
|
+
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
516
|
+
if (path.relative(projectDir, outboxDir) === '') {
|
|
517
|
+
deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
379
520
|
deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
|
|
380
521
|
deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
|
|
381
522
|
deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
|
|
@@ -394,8 +535,10 @@ function leave(deps) {
|
|
|
394
535
|
return 0;
|
|
395
536
|
}
|
|
396
537
|
// ── lane ────────────────────────────────────────────────────────────────────
|
|
397
|
-
function lane(args, deps) {
|
|
538
|
+
async function lane(args, deps) {
|
|
398
539
|
const [verb, ...rest] = args;
|
|
540
|
+
if (verb === 'propose')
|
|
541
|
+
return laneProposal(rest, deps);
|
|
399
542
|
const { positional, opts } = flags(rest);
|
|
400
543
|
const joined = joinedOrThrow();
|
|
401
544
|
const project = readProjectFile(joined.projectDir);
|
|
@@ -410,14 +553,20 @@ function lane(args, deps) {
|
|
|
410
553
|
requireLead(project, deps);
|
|
411
554
|
const id = positional[0];
|
|
412
555
|
if (!id)
|
|
413
|
-
throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
|
|
556
|
+
throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] [--reason "<why>"] | lane remove <id> [--reason "<why>"] | lane propose --developers "…"');
|
|
557
|
+
const reason = reasonFrom(opts, false);
|
|
414
558
|
if (verb === 'remove') {
|
|
415
559
|
writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
|
|
416
560
|
deps.print(`✓ Lane ${id} removed.`);
|
|
561
|
+
const was = lanes.find((l) => l.id === id);
|
|
562
|
+
// With a reason, the change is also kept in the plan history; without one,
|
|
563
|
+
// `lane remove` says and writes exactly what it always did.
|
|
564
|
+
if (was && reason)
|
|
565
|
+
deps.print(laneChangeRecord(deps, 'remove', was, undefined, id, reason));
|
|
417
566
|
return 0;
|
|
418
567
|
}
|
|
419
568
|
if (verb !== 'add')
|
|
420
|
-
throw new RegisterError('lane: use add, remove or
|
|
569
|
+
throw new RegisterError('lane: use add, remove, list or propose.');
|
|
421
570
|
const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
|
|
422
571
|
if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
|
|
423
572
|
throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
|
|
@@ -435,6 +584,8 @@ function lane(args, deps) {
|
|
|
435
584
|
deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
|
|
436
585
|
if (reg.register.totals.unowned)
|
|
437
586
|
deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
|
|
587
|
+
if (reason)
|
|
588
|
+
deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
|
|
438
589
|
return 0;
|
|
439
590
|
}
|
|
440
591
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
@@ -453,11 +604,21 @@ function status(args, deps) {
|
|
|
453
604
|
const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
|
|
454
605
|
const t = r.totals;
|
|
455
606
|
deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
|
|
607
|
+
// The estate around the team project, when the folder holds a discovery (spec 2026-10-06 §9).
|
|
608
|
+
if (r.estate) {
|
|
609
|
+
const e = r.estate.totals;
|
|
610
|
+
const n = (v) => v.toLocaleString('en-US');
|
|
611
|
+
deps.print(`Estate: ${n(e.packages)} ${e.packages === 1 ? 'package' : 'packages'}, `
|
|
612
|
+
+ `${n(e.codeObjects)} programs and other code objects, ${n(e.nonCodeObjects)} non-code objects, `
|
|
613
|
+
+ `${n(e.unknowns)} ${e.unknowns === 1 ? 'unknown' : 'unknowns'}`);
|
|
614
|
+
}
|
|
456
615
|
// A clean sheet out of a file is somebody's export, not this system speaking.
|
|
457
616
|
// The words "verified" and "clean" do not change; the line says how many of
|
|
458
617
|
// them were measured one step removed, so nobody reads them as live proof.
|
|
459
618
|
const fromFile = t.fromFile.verified + t.fromFile.clean;
|
|
619
|
+
// Recording fix 4: a clean object nobody decided has nothing to decide — said on its own.
|
|
460
620
|
deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
|
|
621
|
+
+ `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
|
|
461
622
|
+ `${t.byState.verified} verified, ${t.byState.clean} clean`
|
|
462
623
|
+ `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
|
|
463
624
|
// The newest run that counted — by its time, not by the order the files were
|
|
@@ -466,6 +627,7 @@ function status(args, deps) {
|
|
|
466
627
|
deps.print(r.lastMeasuredAt
|
|
467
628
|
? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
|
|
468
629
|
: 'Not measured yet: "verified" and "clean" need an ATC run.');
|
|
630
|
+
deps.print(nowLine(r));
|
|
469
631
|
deps.print('');
|
|
470
632
|
for (const [state, word] of STATE_WORDS)
|
|
471
633
|
if (t.byState[state])
|
|
@@ -478,9 +640,15 @@ function status(args, deps) {
|
|
|
478
640
|
deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
|
|
479
641
|
for (const l of r.lanes) {
|
|
480
642
|
deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
|
|
481
|
-
+ `${String(
|
|
643
|
+
+ `${String(laneActiveOf(l)).padStart(6)} ${String(laneProvenOf(l)).padStart(6)} `
|
|
482
644
|
+ `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
|
|
483
645
|
}
|
|
646
|
+
// Done and what comes next, by the same priority every surface sorts by.
|
|
647
|
+
// Names only: a lane id stands only beside its owner's name.
|
|
648
|
+
for (const l of r.lanes) {
|
|
649
|
+
const next = l.next.slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1));
|
|
650
|
+
deps.print(` ${l.owner} (${l.id}): Done ${l.donePct}%. Next: ${next.length ? next.join(', ') : 'nothing left to prove'}`);
|
|
651
|
+
}
|
|
484
652
|
// Said once per lane it is true of. A lane set up before this rule held
|
|
485
653
|
// both a package list and a name list, and took the whole package.
|
|
486
654
|
for (const id of narrowed)
|
|
@@ -491,6 +659,10 @@ function status(args, deps) {
|
|
|
491
659
|
deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
|
|
492
660
|
if (t.overlapping)
|
|
493
661
|
deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
|
|
662
|
+
if (t.retireCandidates)
|
|
663
|
+
deps.print(`${plural(t.retireCandidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
|
|
664
|
+
if (t.outOfScope)
|
|
665
|
+
deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
|
|
494
666
|
if (t.conflicts) {
|
|
495
667
|
deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
|
|
496
668
|
+ ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
|
|
@@ -546,6 +718,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
|
546
718
|
*/
|
|
547
719
|
export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
|
|
548
720
|
+ '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
|
|
721
|
+
+ ' or: cspeach team refresh (--all | --packages "ZFI_*,ZSD_*") --per-package [--again] [--variant <NAME>]\n'
|
|
549
722
|
+ ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
|
|
550
723
|
+ '[--variant <NAME>] [--absent-means-clean]';
|
|
551
724
|
/** Options that take a value, with the example each refusal shows. */
|
|
@@ -558,7 +731,7 @@ const VALUE_OPTIONS = {
|
|
|
558
731
|
'sap-alias': '--sap-alias S4H',
|
|
559
732
|
'from-file': '--from-file atc-export.csv',
|
|
560
733
|
};
|
|
561
|
-
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
|
|
734
|
+
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean', 'per-package', 'again'];
|
|
562
735
|
const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
|
|
563
736
|
/** Options that describe a check RUN. A file was made by a run that is already over. */
|
|
564
737
|
const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
|
|
@@ -648,6 +821,11 @@ export function parseRefreshArgs(args) {
|
|
|
648
821
|
}
|
|
649
822
|
const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
|
|
650
823
|
const fromFile = opts['from-file'];
|
|
824
|
+
if (opts['per-package'] !== undefined && (fromFile !== undefined || opts.lane !== undefined)) {
|
|
825
|
+
return no('--per-package goes with --all or --packages: it measures one package at a time.');
|
|
826
|
+
}
|
|
827
|
+
if (opts.again !== undefined && opts['per-package'] === undefined)
|
|
828
|
+
return no('--again only goes with --per-package.');
|
|
651
829
|
if (fromFile === undefined) {
|
|
652
830
|
if (given.length !== 1)
|
|
653
831
|
return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
|
|
@@ -691,6 +869,8 @@ export function parseRefreshArgs(args) {
|
|
|
691
869
|
oneByOne: opts['no-one-by-one'] === undefined,
|
|
692
870
|
absentMeansClean: opts['absent-means-clean'] !== undefined,
|
|
693
871
|
...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
|
|
872
|
+
...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
|
|
873
|
+
...(opts.again !== undefined ? { again: true } : {}),
|
|
694
874
|
};
|
|
695
875
|
if (opts.variant !== undefined) {
|
|
696
876
|
const variant = opts.variant.trim().toUpperCase();
|
|
@@ -930,7 +1110,7 @@ function stillStanding(register, absentNames) {
|
|
|
930
1110
|
* `once`, on purpose: the SECOND press is Node's own, so a request that has
|
|
931
1111
|
* hung can always be quit.
|
|
932
1112
|
*/
|
|
933
|
-
function onInterrupt(deps, stop) {
|
|
1113
|
+
export function onInterrupt(deps, stop) {
|
|
934
1114
|
if (deps.onInterrupt)
|
|
935
1115
|
return deps.onInterrupt(stop);
|
|
936
1116
|
const handler = () => stop();
|
|
@@ -1070,6 +1250,16 @@ async function refresh(args, deps) {
|
|
|
1070
1250
|
deps.print(CANNOT_MEASURE);
|
|
1071
1251
|
return 2;
|
|
1072
1252
|
}
|
|
1253
|
+
// 1.1.31: no scope said → your own lane, when you own exactly one.
|
|
1254
|
+
if (!args.some((a) => /^--(all|lane|packages|from-file)(=|$)/.test(a))) {
|
|
1255
|
+
const joinedNow = activeProject();
|
|
1256
|
+
if (joinedNow) {
|
|
1257
|
+
const me = whoAmI(deps).trim().toLowerCase();
|
|
1258
|
+
const mine = readLanes(joinedNow.projectDir).filter((l) => l.owner.trim().toLowerCase() === me);
|
|
1259
|
+
if (mine.length === 1)
|
|
1260
|
+
args = [...args, '--lane', mine[0].id];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1073
1263
|
const parsed = parseRefreshArgs(args);
|
|
1074
1264
|
if (!parsed.ok)
|
|
1075
1265
|
throw new RegisterError(parsed.message);
|
|
@@ -1107,6 +1297,8 @@ async function refresh(args, deps) {
|
|
|
1107
1297
|
deps.print(CANNOT_MEASURE);
|
|
1108
1298
|
return 2;
|
|
1109
1299
|
}
|
|
1300
|
+
if (opts.perPackage)
|
|
1301
|
+
return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
|
|
1110
1302
|
deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
|
|
1111
1303
|
const stop = new AbortController();
|
|
1112
1304
|
const off = onInterrupt(deps, () => {
|
|
@@ -1182,6 +1374,117 @@ async function refresh(args, deps) {
|
|
|
1182
1374
|
}
|
|
1183
1375
|
return 0;
|
|
1184
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* D6: the estate measured one package at a time, one measurement record each,
|
|
1379
|
+
* through the same `measure()` as every run. A package that already has a
|
|
1380
|
+
* counted record of this variant for exactly its own scope is skipped unless
|
|
1381
|
+
* `--again`, so a long estate run can be stopped and taken up again. Ctrl-C
|
|
1382
|
+
* stops between packages; every record already written stays.
|
|
1383
|
+
*/
|
|
1384
|
+
async function refreshPerPackage(input, deps) {
|
|
1385
|
+
const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts } = input;
|
|
1386
|
+
const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1387
|
+
const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
|
|
1388
|
+
// A package is done only when a counted run from the system (never a result file),
|
|
1389
|
+
// with this variant and this package as its scope, says it reached every object:
|
|
1390
|
+
// nothing failed, nothing left not run. A run cut short by Ctrl-C, the deadline or
|
|
1391
|
+
// a lost connection wrote a partial record, and that package is measured again.
|
|
1392
|
+
const done = new Set();
|
|
1393
|
+
if (!opts.again) {
|
|
1394
|
+
for (const run of registerFor(joined.projectDir, joined.outboxDir).register.runs) {
|
|
1395
|
+
if ((run.atcVariant ?? '').trim().toUpperCase() !== variant || !run.scope || run.basis !== 'system')
|
|
1396
|
+
continue;
|
|
1397
|
+
const c = run.coverage;
|
|
1398
|
+
// notRun must be said and be 0: a record that does not say is not proof the run got everywhere.
|
|
1399
|
+
if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
|
|
1400
|
+
&& c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked)
|
|
1401
|
+
done.add(run.scope);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const todo = packages.filter((p) => !done.has(labelOf(p)));
|
|
1405
|
+
const skipped = packages.length - todo.length;
|
|
1406
|
+
if (skipped) {
|
|
1407
|
+
deps.print(`Skipped ${plural(skipped, 'package', 'packages')} already measured (use --again to measure ${skipped === 1 ? 'it' : 'them'} again).`);
|
|
1408
|
+
}
|
|
1409
|
+
if (!todo.length)
|
|
1410
|
+
return 0;
|
|
1411
|
+
deps.print(`Measuring ${plural(todo.length, 'package', 'packages')} one at a time with ${variant}. Ctrl-C stops between packages and keeps what is written.`);
|
|
1412
|
+
// Ctrl-C is checked between packages, never handed into a package's run: a package
|
|
1413
|
+
// is measured whole or not at all. (A deadline or a lost connection can still cut a
|
|
1414
|
+
// package short; its record then shows it, and the next run measures it again.)
|
|
1415
|
+
const stop = new AbortController();
|
|
1416
|
+
const off = onInterrupt(deps, () => {
|
|
1417
|
+
if (stop.signal.aborted)
|
|
1418
|
+
return;
|
|
1419
|
+
deps.print(' Stopping after this package. What has already been measured will still be written.');
|
|
1420
|
+
deps.print(' Press Ctrl-C again to quit now.');
|
|
1421
|
+
stop.abort();
|
|
1422
|
+
});
|
|
1423
|
+
let measuredPackages = 0;
|
|
1424
|
+
let written = 0;
|
|
1425
|
+
let variantSaved = false;
|
|
1426
|
+
let lostConnection = false;
|
|
1427
|
+
try {
|
|
1428
|
+
for (const pkg of todo) {
|
|
1429
|
+
if (stop.signal.aborted)
|
|
1430
|
+
break;
|
|
1431
|
+
const objects = inScope.filter((o) => o.package === pkg).length;
|
|
1432
|
+
deps.print(`${pkg}: ${plural(objects, 'object', 'objects')}…`);
|
|
1433
|
+
const res = await measure({
|
|
1434
|
+
joined, project, baseline, lanes, scope: { kind: 'packages', patterns: [pkg] }, variant, atc, by: { name: whoAmI(deps) },
|
|
1435
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1436
|
+
...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
|
|
1437
|
+
...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
|
|
1438
|
+
...(opts.oneByOne ? {} : { oneByOne: false }),
|
|
1439
|
+
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1440
|
+
confirmAbsent: true,
|
|
1441
|
+
onProgress: progressLine(deps),
|
|
1442
|
+
});
|
|
1443
|
+
measuredPackages += 1;
|
|
1444
|
+
if (res.file) {
|
|
1445
|
+
written += 1;
|
|
1446
|
+
deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings. Written: ${path.basename(res.file)}`);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
|
|
1450
|
+
}
|
|
1451
|
+
secondPassLines(res, deps);
|
|
1452
|
+
notMeasuredLines(res, deps);
|
|
1453
|
+
if (res.file && persist && !variantSaved) {
|
|
1454
|
+
try {
|
|
1455
|
+
writeProjectVariant(joined.projectDir, variant);
|
|
1456
|
+
variantSaved = true;
|
|
1457
|
+
deps.print(`✓ This team project now measures with ${variant}.`);
|
|
1458
|
+
}
|
|
1459
|
+
catch (e) {
|
|
1460
|
+
deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
|
|
1461
|
+
+ `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (res.stoppedBy === 'connection') {
|
|
1465
|
+
lostConnection = true;
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
finally {
|
|
1471
|
+
off();
|
|
1472
|
+
}
|
|
1473
|
+
if (foreign) {
|
|
1474
|
+
deps.print(` ! Made with ${variant}, not the team project's ${(project.atcVariant ?? '').trim().toUpperCase()}. `
|
|
1475
|
+
+ 'The records are kept, but they are not counted in the picture.');
|
|
1476
|
+
}
|
|
1477
|
+
if (written)
|
|
1478
|
+
refreshSheet(deps);
|
|
1479
|
+
if (stop.signal.aborted || lostConnection) {
|
|
1480
|
+
deps.print(`✗ ${lostConnection ? 'Lost the connection to SAP' : 'Stopped'} after ${measuredPackages} of ${todo.length} packages. What was measured is written. `
|
|
1481
|
+
+ 'Run the same command again to measure the rest.');
|
|
1482
|
+
return 3;
|
|
1483
|
+
}
|
|
1484
|
+
deps.print(`✓ ${plural(written, 'package', 'packages')} measured, one record each.`);
|
|
1485
|
+
deps.print(' Next: cspeach team status');
|
|
1486
|
+
return written ? 0 : 1;
|
|
1487
|
+
}
|
|
1185
1488
|
// ── outbox / rule ───────────────────────────────────────────────────────────
|
|
1186
1489
|
function outbox(_args, deps) {
|
|
1187
1490
|
const joined = joinedOrThrow();
|
|
@@ -1200,15 +1503,25 @@ function outbox(_args, deps) {
|
|
|
1200
1503
|
deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
|
|
1201
1504
|
return 0;
|
|
1202
1505
|
}
|
|
1506
|
+
const RETIRE_RULING_NEEDS_REASON = 'A retire is confirmed with a reason: --reason "no callers (index read OK); usage unknown on this system; owner confirms"';
|
|
1203
1507
|
function rule(args, deps) {
|
|
1204
1508
|
const { positional, opts } = flags(args);
|
|
1205
1509
|
const [type, name, classification] = positional;
|
|
1206
1510
|
if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
|
|
1207
|
-
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
|
|
1511
|
+
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
|
|
1208
1512
|
}
|
|
1513
|
+
// Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
|
|
1514
|
+
if (opts.reason === 'true')
|
|
1515
|
+
throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
|
|
1516
|
+
const reason = (opts.reason ?? '').trim();
|
|
1517
|
+
if (reason.length > MAX_REASON)
|
|
1518
|
+
throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
|
|
1519
|
+
// The lead's retire is what makes an object retired (D2), so it carries what was checked.
|
|
1520
|
+
if (classification === 'retire' && !reason)
|
|
1521
|
+
throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
|
|
1209
1522
|
const joined = joinedOrThrow();
|
|
1210
1523
|
requireLead(readProjectFile(joined.projectDir), deps);
|
|
1211
|
-
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(
|
|
1524
|
+
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
|
|
1212
1525
|
dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
|
|
1213
1526
|
...(deps.rand ? { rand: deps.rand } : {}),
|
|
1214
1527
|
});
|
|
@@ -1260,6 +1573,9 @@ export function parseDecideArgs(args) {
|
|
|
1260
1573
|
return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
|
|
1261
1574
|
if (said.exception && !reason)
|
|
1262
1575
|
return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
|
|
1576
|
+
if (said.classification === 'retire' && !reason) {
|
|
1577
|
+
return { ok: false, message: 'A retire needs a reason: say what was checked, for example: --reason "no callers (index read OK); usage unknown on this system"' };
|
|
1578
|
+
}
|
|
1263
1579
|
return {
|
|
1264
1580
|
ok: true,
|
|
1265
1581
|
value: {
|
|
@@ -1296,6 +1612,8 @@ function decide(args, deps) {
|
|
|
1296
1612
|
if (!res.file)
|
|
1297
1613
|
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1298
1614
|
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
|
|
1615
|
+
if (row.classification === 'retire')
|
|
1616
|
+
deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
|
|
1299
1617
|
refreshSheet(deps);
|
|
1300
1618
|
return 0;
|
|
1301
1619
|
}
|
|
@@ -1377,28 +1695,26 @@ function sheet(deps) {
|
|
|
1377
1695
|
return res.file ? 0 : 1;
|
|
1378
1696
|
}
|
|
1379
1697
|
// ── report ──────────────────────────────────────────────────────────────────
|
|
1380
|
-
const
|
|
1698
|
+
export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
|
|
1381
1699
|
/**
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1700
|
+
* The combined workbook and the status file, made from the team project folder
|
|
1701
|
+
* (the same files the team page reads) and written whole, then renamed, into
|
|
1702
|
+
* `outDir`. Used by `report` and by `plan import`, which makes them again so the
|
|
1703
|
+
* lead sees the new export stamp.
|
|
1704
|
+
*
|
|
1705
|
+
* A report open in Excel is the likeliest failure here: Windows refuses the
|
|
1706
|
+
* rename. Each file is tried on its own, so one held file never costs the
|
|
1707
|
+
* other, and the caller is told by file name which one was not updated.
|
|
1708
|
+
* writeWhole has already removed its .tmp when it throws.
|
|
1385
1709
|
*/
|
|
1386
|
-
function
|
|
1387
|
-
const
|
|
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);
|
|
1710
|
+
export function writeReports(projectDir, project, outDir, deps) {
|
|
1711
|
+
const picture = registerFor(projectDir);
|
|
1394
1712
|
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
1395
|
-
const folderName = path.basename(
|
|
1396
|
-
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
1713
|
+
const folderName = path.basename(projectDir);
|
|
1397
1714
|
const names = reportFileNames(project.name);
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1401
|
-
// writeWhole has already removed its .tmp when it throws.
|
|
1715
|
+
// The discovery file as the folder holds it: the status file says "could not be read"
|
|
1716
|
+
// for one register-core cannot read, the way the page does.
|
|
1717
|
+
const discovery = readEstate(projectDir);
|
|
1402
1718
|
const held = [];
|
|
1403
1719
|
const attempt = (name, write) => {
|
|
1404
1720
|
try {
|
|
@@ -1417,13 +1733,31 @@ function report(args, deps) {
|
|
|
1417
1733
|
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
1418
1734
|
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
1419
1735
|
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
|
|
1736
|
+
...(discovery ? { estate: discovery.estate } : {}),
|
|
1420
1737
|
}))) ? names.html : null,
|
|
1421
1738
|
].filter((n) => n !== null);
|
|
1739
|
+
return { saved, held };
|
|
1740
|
+
}
|
|
1741
|
+
const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
|
|
1742
|
+
/**
|
|
1743
|
+
* Lead only (spec §4.5). Reads the team project folder — the same files the team
|
|
1744
|
+
* page reads, so its numbers are the page's — and writes two files whole, then
|
|
1745
|
+
* renamed, into `reports/` or the folder named with --out. Never `writers/`.
|
|
1746
|
+
*/
|
|
1747
|
+
function report(args, deps) {
|
|
1748
|
+
const { positional, opts } = flags(args);
|
|
1749
|
+
if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
|
|
1750
|
+
throw new RegisterError(REPORT_USAGE);
|
|
1751
|
+
const joined = joinedOrThrow();
|
|
1752
|
+
const project = readProjectFile(joined.projectDir);
|
|
1753
|
+
requireLead(project, deps);
|
|
1754
|
+
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
1755
|
+
const { saved, held } = writeReports(joined.projectDir, project, outDir, deps);
|
|
1422
1756
|
const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
|
|
1423
1757
|
if (saved.length)
|
|
1424
1758
|
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
1425
1759
|
for (const name of held)
|
|
1426
|
-
deps.print(
|
|
1760
|
+
deps.print(heldReportLine(name));
|
|
1427
1761
|
// Records on this laptop that have not reached the folder are not in the report.
|
|
1428
1762
|
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
1429
1763
|
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|