@cspeach/cli 1.1.48 → 1.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +7 -6
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/project.js +301 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/doctor/checks/zcspeach.js +96 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/email-template.js +56 -0
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-cca.js +0 -19
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +21 -8
- package/dist/router/router-core.js +5 -3
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/cca_merge.js +1 -11
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +19 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer-line.js +19 -1
- package/dist/ui/header.js +41 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sidebar.js +12 -0
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-line.js +62 -0
- package/dist/ui/status-row.js +38 -0
- package/dist/ui/todo-panel.js +85 -0
- package/dist/ui/widgets/ask-form.js +250 -0
- package/dist/ui/widgets/ask-question-modal.js +178 -0
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/live-check-2026-10-17-1343.txt +41 -0
- package/package.json +121 -121
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
package/dist/commands/team.js
CHANGED
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Every path written here is one a person typed. Nothing is written to a
|
|
16
16
|
* network location on CSPeach's own initiative, and none of this is reachable
|
|
17
|
-
* from a model tool
|
|
17
|
+
* from a model tool — except the lane move body (moveLanes), which the
|
|
18
|
+
* team_lane_move tool runs only after its own checks and the person's Yes on
|
|
19
|
+
* a card (Task 4, 2026-10-20).
|
|
18
20
|
*/
|
|
19
21
|
import fs from 'node:fs';
|
|
20
22
|
import os from 'node:os';
|
|
21
23
|
import path from 'node:path';
|
|
22
|
-
import { isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, proposeLanes, scopeFromWords, targetsOf, } from '@cspeach/register-core';
|
|
24
|
+
import { decidedCount, decisionWhatOf, isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, partCounts, proposeLanes, scopeFromWords, targetsOf, viewWords, } from '@cspeach/register-core';
|
|
23
25
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
24
26
|
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
25
27
|
import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
@@ -30,10 +32,11 @@ import { registerFor } from '../register/picture.js';
|
|
|
30
32
|
import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
|
|
31
33
|
import { writeXlsxFile } from '../register/xlsx.js';
|
|
32
34
|
import { writeWhole } from '../register/write-whole.js';
|
|
33
|
-
import { EXPORTS_DIR, exportsOn, setExports, writeExportFiles } from '../register/export-files.js';
|
|
34
|
-
import { previousAsOfIn, reportViewFor, statusHtmlFor } from '../register/status-html.js';
|
|
35
|
+
import { EXPORTS_DIR, exportsOff, exportsOn, exportsPresent, setExports, writeExportFiles } from '../register/export-files.js';
|
|
36
|
+
import { previousAsOfIn, projectViewFor, reportViewFor, statusHtmlFor } from '../register/status-html.js';
|
|
35
37
|
import { firstBlockLines, awaitingWord, lastCheckWord } from '../register/status-file/report-model.js';
|
|
36
38
|
import { lookupVariant, variantLookupLine } from '../register/variant.js';
|
|
39
|
+
import { TRANSPORT_NONE_WORDS, TRANSPORT_REQUEST, notARequestLine, projectTransportWords, writeProjectTransport } from '../register/project-transport.js';
|
|
37
40
|
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
38
41
|
import { rulesCommand } from './team-rules.js';
|
|
39
42
|
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
@@ -52,11 +55,11 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
|
52
55
|
*/
|
|
53
56
|
export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
54
57
|
/** After a record this laptop wrote: its working list, made again. One line, or none. */
|
|
55
|
-
function refreshSheet(deps) {
|
|
58
|
+
function refreshSheet(deps, read) {
|
|
56
59
|
const joined = activeProject();
|
|
57
60
|
if (!joined)
|
|
58
61
|
return;
|
|
59
|
-
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
|
|
62
|
+
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.(), read);
|
|
60
63
|
if (res.line)
|
|
61
64
|
deps.print(res.line);
|
|
62
65
|
}
|
|
@@ -107,6 +110,16 @@ export function flags(args, flagOnly = []) {
|
|
|
107
110
|
export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
108
111
|
/** "1 object", "3 objects" — every count this command prints reads as a sentence. */
|
|
109
112
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
113
|
+
/**
|
|
114
|
+
* `lane add` for a lane that owns none of what it matches: whose lanes hold
|
|
115
|
+
* those objects, by each owner's name (never he/she), in the order met.
|
|
116
|
+
*/
|
|
117
|
+
export function heldElsewhereLine(id, held) {
|
|
118
|
+
const owners = [...new Set(held.map((h) => `${shown(h.owner)}'s`))];
|
|
119
|
+
const named = owners.length === 1 ? owners[0] : `${owners.slice(0, -1).join(', ')} and ${owners.at(-1)}`;
|
|
120
|
+
const n = held.length;
|
|
121
|
+
return `✓ Lane ${id} added: 0 objects of its own — ${plural(n, 'object', 'objects')} it matches already ${n === 1 ? 'belongs' : 'belong'} to ${named} ${owners.length === 1 ? 'lane' : 'lanes'}.`;
|
|
122
|
+
}
|
|
110
123
|
/** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
|
|
111
124
|
function matches(pattern, value) {
|
|
112
125
|
const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
|
|
@@ -128,6 +141,7 @@ export async function runTeamCommand(args, deps) {
|
|
|
128
141
|
case 'leave': return leave(deps);
|
|
129
142
|
case 'decide': return decide(rest, deps);
|
|
130
143
|
case 'target': return target(rest, deps);
|
|
144
|
+
case 'transport': return transportVerb(rest, deps);
|
|
131
145
|
case 'fixed': return await fixed(rest, deps);
|
|
132
146
|
case 'sheet': return sheet(deps);
|
|
133
147
|
case 'report': return report(rest, deps);
|
|
@@ -138,7 +152,7 @@ export async function runTeamCommand(args, deps) {
|
|
|
138
152
|
case 'plan': return await planCommand(rest, deps);
|
|
139
153
|
case 'rebuild': return await rebuildVerb(rest, deps);
|
|
140
154
|
default:
|
|
141
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
|
|
155
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|transport|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
|
|
142
156
|
return sub ? 1 : 0;
|
|
143
157
|
}
|
|
144
158
|
}
|
|
@@ -156,9 +170,13 @@ export async function runTeamCommand(args, deps) {
|
|
|
156
170
|
return 1;
|
|
157
171
|
}
|
|
158
172
|
}
|
|
173
|
+
/** True when this seat is the project's lead. The one test: requireLead and team_lane_move both use it. */
|
|
174
|
+
export function isLead(project, deps) {
|
|
175
|
+
return whoAmI(deps).trim().toLowerCase() === project.lead.name.trim().toLowerCase();
|
|
176
|
+
}
|
|
159
177
|
export function requireLead(project, deps) {
|
|
160
178
|
const me = whoAmI(deps);
|
|
161
|
-
if (
|
|
179
|
+
if (!isLead(project, deps)) {
|
|
162
180
|
throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
|
|
163
181
|
}
|
|
164
182
|
}
|
|
@@ -201,8 +219,8 @@ function unknownInitOption(name) {
|
|
|
201
219
|
return `${said} Did you mean --${guess}?`;
|
|
202
220
|
}
|
|
203
221
|
const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
|
|
204
|
-
+ '[--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
205
|
-
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers'];
|
|
222
|
+
+ '[--name "<name>"] [--atc-variant <NAME>] [--transport <REQUEST>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
223
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers', 'transport'];
|
|
206
224
|
/** The check a project measures with when nobody names one. */
|
|
207
225
|
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
208
226
|
/** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
|
|
@@ -220,6 +238,8 @@ const firstWordOf = (name) => (name.trim().split(/\s+/)[0] ?? '').toLowerCase();
|
|
|
220
238
|
* `lane move --to`): plain text, no control characters, at most 60 characters.
|
|
221
239
|
* `what` starts the message, so the line says which option was wrong.
|
|
222
240
|
*/
|
|
241
|
+
/** A plain person's name: not empty, no control characters, at most 60 characters. */
|
|
242
|
+
export const isPlainName = (raw) => !!raw.trim() && !CONTROL.test(raw.trim()) && raw.trim().length <= MAX_PERSON_NAME;
|
|
223
243
|
export function personName(raw, what, example = 'James Miller') {
|
|
224
244
|
const name = raw === undefined || raw === 'true' ? '' : raw.trim();
|
|
225
245
|
if (!name)
|
|
@@ -281,6 +301,14 @@ async function start(args, deps) {
|
|
|
281
301
|
const developers = developersOf(opts.developers);
|
|
282
302
|
if (!forWords && !typed.length)
|
|
283
303
|
throw new RegisterError(START_USAGE);
|
|
304
|
+
// Task 8 (B11): the request the fixes are recorded in, checked before anything is written.
|
|
305
|
+
let request;
|
|
306
|
+
if (opts.transport !== undefined) {
|
|
307
|
+
const t = opts.transport === 'true' ? '' : opts.transport.trim().toUpperCase();
|
|
308
|
+
if (!TRANSPORT_REQUEST.test(t))
|
|
309
|
+
throw new RegisterError(notARequestLine(opts.transport === 'true' ? '' : opts.transport));
|
|
310
|
+
request = t;
|
|
311
|
+
}
|
|
284
312
|
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
285
313
|
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
286
314
|
throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
|
|
@@ -317,6 +345,8 @@ async function start(args, deps) {
|
|
|
317
345
|
return code;
|
|
318
346
|
}
|
|
319
347
|
join([dir, '--outbox', dir], quiet);
|
|
348
|
+
if (request)
|
|
349
|
+
writeProjectTransport(dir, { request, by: lead, at: (deps.now ?? (() => new Date()))().toISOString() });
|
|
320
350
|
let lanesWritten = null;
|
|
321
351
|
let note = '';
|
|
322
352
|
let laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
|
|
@@ -348,6 +378,8 @@ async function start(args, deps) {
|
|
|
348
378
|
// §3.1: what the variant lookup found, said on every path (init's own line, kept back above).
|
|
349
379
|
for (const l of said.filter((x) => /^ {2}(! )?CSPeach could not (find|confirm) the variant /.test(x)))
|
|
350
380
|
deps.print(l);
|
|
381
|
+
if (request)
|
|
382
|
+
deps.print(` Transport: ${request}.`);
|
|
351
383
|
deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
|
|
352
384
|
if (measured)
|
|
353
385
|
deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
|
|
@@ -355,7 +387,7 @@ async function start(args, deps) {
|
|
|
355
387
|
else if (said.includes(FIRST_CHECK_PER_PACKAGE_LINE) || objects > FIRST_CHECK_WHOLE_MAX)
|
|
356
388
|
deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
|
|
357
389
|
else if (lanesWritten)
|
|
358
|
-
deps.print(
|
|
390
|
+
deps.print(NO_CHECK_ALL_LINE);
|
|
359
391
|
else if (noMeasure)
|
|
360
392
|
deps.print(' No check run yet: cspeach team refresh measures it.');
|
|
361
393
|
if (lanesWritten) {
|
|
@@ -502,6 +534,8 @@ ${INIT_USAGE}`);
|
|
|
502
534
|
/** Above this many objects, `init` does not run the first check over the whole baseline (I15). */
|
|
503
535
|
export const FIRST_CHECK_WHOLE_MAX = 500;
|
|
504
536
|
export const FIRST_CHECK_PER_PACKAGE_LINE = ' First check per package: cspeach team refresh --all --per-package (it resumes if stopped).';
|
|
537
|
+
/** What a start with --developers says: nothing measured yet, for anyone (the journey runs the first check per package then). */
|
|
538
|
+
export const NO_CHECK_ALL_LINE = ' No check run yet: cspeach team refresh --all measures it.';
|
|
505
539
|
/**
|
|
506
540
|
* Per chosen package, what the estate knows beside the live count: its
|
|
507
541
|
* description, application area and last change. Packages the estate does
|
|
@@ -810,15 +844,24 @@ async function lane(args, deps) {
|
|
|
810
844
|
}
|
|
811
845
|
writeLanes(joined.projectDir, project.projectId, [...lanes.filter((l) => l.id !== id), next]);
|
|
812
846
|
const reg = registerFor(joined.projectDir, joined.outboxDir);
|
|
813
|
-
|
|
814
|
-
//
|
|
815
|
-
// the overlap line under it
|
|
816
|
-
|
|
847
|
+
// Owner decision (final review): the count is the objects this lane OWNS, the
|
|
848
|
+
// view's lane total, as on every other surface. An object another lane owns
|
|
849
|
+
// too is said by the overlap line under it. A lane that owns none of what it
|
|
850
|
+
// matches says whose lanes hold those objects instead, and the overlap line
|
|
851
|
+
// is left out when it would only say the same thing again.
|
|
852
|
+
const view = projectViewFor(reg.register, project);
|
|
853
|
+
const owned = view.lanes.find((l) => l.scope.kind === 'lane' && l.scope.id === id)?.total ?? 0;
|
|
854
|
+
const held = owned === 0 ? view.all.objects.filter((v) => v.alsoMatches.includes(id) && v.owner) : [];
|
|
855
|
+
if (held.length)
|
|
856
|
+
deps.print(heldElsewhereLine(id, held.map((v) => ({ owner: v.owner.name }))));
|
|
857
|
+
else
|
|
858
|
+
deps.print(`✓ Lane ${id}: ${next.owner}, ${plural(owned, 'object', 'objects')}.`);
|
|
817
859
|
if (next.packages.length && next.namePatterns.length) {
|
|
818
860
|
deps.print(` Lane ${id} means: in these packages AND matching these names.`);
|
|
819
861
|
}
|
|
820
|
-
|
|
821
|
-
|
|
862
|
+
const overlapping = reg.register.totals.overlapping ?? 0;
|
|
863
|
+
if (overlapping && overlapping !== held.length)
|
|
864
|
+
deps.print(` ! ${plural(overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
|
|
822
865
|
if (reg.register.totals.unowned)
|
|
823
866
|
deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
|
|
824
867
|
if (reason)
|
|
@@ -834,18 +877,34 @@ const patternOf = (l) => l.packages.find((p) => p.includes('*')) ?? l.namePatter
|
|
|
834
877
|
* lane of names when that developer has none yet. Lanes of names only: a lane
|
|
835
878
|
* that holds a pattern cannot give up one package, so the move is refused in
|
|
836
879
|
* one line and nothing is written. A lane left empty is removed.
|
|
880
|
+
*
|
|
881
|
+
* The verb parses the flags; moveLanes is the body, shared with the
|
|
882
|
+
* team_lane_move tool (Task 4, 2026-10-20).
|
|
837
883
|
*/
|
|
838
884
|
function laneMove(positional, opts, joined, project, lanes, deps) {
|
|
839
885
|
const names = [...new Set(list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase()))];
|
|
840
886
|
if (positional.length || !names.length || !opts.to || opts.to === 'true')
|
|
841
887
|
throw new RegisterError(MOVE_USAGE);
|
|
888
|
+
const reason = reasonFrom(opts, false);
|
|
889
|
+
moveLanes(names, opts.to, reason, joined, project, lanes, deps);
|
|
890
|
+
return 0;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* The checks and the new lanes of a move, nothing written: names typed in
|
|
894
|
+
* full, each a package of the baseline, no pattern lane on either side.
|
|
895
|
+
* Throws RegisterError with the one line a person reads.
|
|
896
|
+
*/
|
|
897
|
+
export function planLaneMove(names, toRaw, joined, lanes) {
|
|
898
|
+
names = [...new Set(names.map((n) => n.trim().toUpperCase()).filter(Boolean))];
|
|
899
|
+
if (!names.length)
|
|
900
|
+
throw new RegisterError(MOVE_USAGE);
|
|
842
901
|
if (names.some(isPatternWord))
|
|
843
902
|
throw new RegisterError('Name each package in full, for example ZSD_ORDERS — not a pattern.');
|
|
844
903
|
const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
|
|
845
904
|
for (const n of names)
|
|
846
905
|
if (!known.has(n))
|
|
847
906
|
throw new RegisterError(`${shown(n)} is not a package of this team project.`);
|
|
848
|
-
const to = personName(
|
|
907
|
+
const to = personName(toRaw, '--to');
|
|
849
908
|
const target = lanes.find((l) => l.id.toLowerCase() === to.toLowerCase()) ?? lanes.find((l) => sameName(l.owner, to));
|
|
850
909
|
const holders = lanes.filter((l) => l !== target && l.packages.some((p) => names.some((n) => matches(p, n))));
|
|
851
910
|
for (const l of [...holders, ...(target ? [target] : [])]) {
|
|
@@ -857,10 +916,8 @@ function laneMove(positional, opts, joined, project, lanes, deps) {
|
|
|
857
916
|
}
|
|
858
917
|
// Review finding 7: a move that changes nothing says so and writes nothing.
|
|
859
918
|
if (target && !holders.length && names.every((n) => target.packages.some((p) => p.toUpperCase() === n))) {
|
|
860
|
-
|
|
861
|
-
return 0;
|
|
919
|
+
return { kind: 'nothing', line: `Nothing to move: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} already in ${shown(target.owner)}'s lane.` };
|
|
862
920
|
}
|
|
863
|
-
const reason = reasonFrom(opts, false);
|
|
864
921
|
const into = target
|
|
865
922
|
? { ...target, packages: [...target.packages, ...names.filter((n) => !target.packages.some((p) => p.toUpperCase() === n))] }
|
|
866
923
|
: { id: laneIdFor(to, new Set(lanes.map((l) => l.id))), owner: to, packages: names, namePatterns: [] };
|
|
@@ -883,15 +940,42 @@ function laneMove(positional, opts, joined, project, lanes, deps) {
|
|
|
883
940
|
}
|
|
884
941
|
if (!target)
|
|
885
942
|
next.push(into);
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
943
|
+
return { kind: 'move', names, target, into, holders, next, removed };
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* The move itself: lanes.json, then ALWAYS one plan record
|
|
947
|
+
* `lane: { change: 'move' }` (with the reason when one was given), and the
|
|
948
|
+
* lines a person reads through `deps.print`. The caller has checked the lead.
|
|
949
|
+
* A move that changes nothing prints so and writes nothing: `moved` is empty.
|
|
950
|
+
*/
|
|
951
|
+
export function moveLanes(names, to, reason, joined, project, lanes, deps) {
|
|
952
|
+
const plan = planLaneMove(names, to, joined, lanes);
|
|
953
|
+
if (plan.kind === 'nothing') {
|
|
954
|
+
deps.print(plan.line);
|
|
955
|
+
return { moved: [], to: personName(to, '--to'), lane: null };
|
|
956
|
+
}
|
|
957
|
+
const { into, removed } = plan;
|
|
958
|
+
writeLanes(joined.projectDir, project.projectId, plan.next);
|
|
959
|
+
// The view after the move: the packages' own objects, and the lane's.
|
|
960
|
+
const view = projectViewFor(registerFor(joined.projectDir, joined.outboxDir).register, project);
|
|
961
|
+
const moved = plan.names.map((name) => {
|
|
962
|
+
const objects = view.all.objects.filter((o) => o.package.toUpperCase() === name.toUpperCase());
|
|
963
|
+
return { package: name, objects: objects.length, parts: partCounts(objects), decided: decidedCount(objects) };
|
|
964
|
+
});
|
|
965
|
+
const laneObjects = view.lanes.find((s) => s.scope.kind === 'lane' && s.scope.id === into.id)?.objects ?? [];
|
|
966
|
+
const movedObjects = moved.reduce((n, m) => n + m.objects, 0);
|
|
967
|
+
// Never a pronoun (nobody's is known): the person's name, else the lane id.
|
|
968
|
+
const whose = into.owner.trim() ? shown(into.owner) : into.id;
|
|
969
|
+
deps.print(`✓ ${plan.names.join(', ')} moved to ${shown(into.owner)} (${into.id}): `
|
|
970
|
+
+ `${plural(movedObjects, 'object', 'objects')} in the ${plan.names.length === 1 ? 'package' : 'packages'}; `
|
|
971
|
+
+ `${whose}'s lane now holds ${plural(into.packages.length, 'package', 'packages')}, ${plural(laneObjects.length, 'object', 'objects')}.`);
|
|
890
972
|
for (const id of removed)
|
|
891
973
|
deps.print(` Lane ${id} held nothing else and was removed.`);
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
974
|
+
deps.print(laneChangeRecord(deps, 'move', plan.target, into, into.id, reason, plan.names, plan.holders.map((l) => ({ id: l.id, owner: l.owner }))));
|
|
975
|
+
return {
|
|
976
|
+
moved, to: into.owner,
|
|
977
|
+
lane: { id: into.id, owner: into.owner, objects: laneObjects.length, parts: partCounts(laneObjects), decided: decidedCount(laneObjects) },
|
|
978
|
+
};
|
|
895
979
|
}
|
|
896
980
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
897
981
|
function status(args, deps) {
|
|
@@ -909,13 +993,17 @@ function status(args, deps) {
|
|
|
909
993
|
// previous page in reports/ says where "changed since" starts.
|
|
910
994
|
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
911
995
|
const previousAsOf = previousAsOfIn(path.join(projectDir, 'reports', reportFileNames(project.name).html));
|
|
912
|
-
|
|
996
|
+
// The project view, built once: the first block, the lane table and the transport line read it.
|
|
997
|
+
const pv = projectViewFor(r, project);
|
|
998
|
+
const view = reportViewFor({ view: pv, register: r, asOf, previousAsOf });
|
|
913
999
|
for (const l of firstBlockLines(view))
|
|
914
1000
|
deps.print(l);
|
|
915
1001
|
deps.print('');
|
|
916
1002
|
deps.print(nowLine(r));
|
|
917
1003
|
for (const l of targetLines(project))
|
|
918
1004
|
deps.print(l);
|
|
1005
|
+
// The request this project's fixes are recorded in, or how to set one.
|
|
1006
|
+
deps.print(pv.project.transport ? projectTransportWords(pv.project.transport.request) : TRANSPORT_NONE_WORDS);
|
|
919
1007
|
// Spec §3.6: a joiner who owns nothing reads that first, never a finished picture.
|
|
920
1008
|
if (!positional[0] && joined) {
|
|
921
1009
|
const owns = ownsNothingLine(projectDir, project, whoAmI(deps));
|
|
@@ -924,11 +1012,12 @@ function status(args, deps) {
|
|
|
924
1012
|
}
|
|
925
1013
|
deps.print('');
|
|
926
1014
|
if (view.lanes.length) {
|
|
927
|
-
// Every lane together, in the order the lanes were set up — never ranked.
|
|
928
|
-
|
|
1015
|
+
// Every lane together, in the order the lanes were set up — never ranked. Each object
|
|
1016
|
+
// counts once, in the lane that owns it (the view's lane parts), so the rows add up.
|
|
1017
|
+
deps.print(`${'Developer'.padEnd(20)} Objects Active Retired Decided Fixes proven Awaiting check Kept Rebuilds Fix failed Last check`);
|
|
929
1018
|
for (const row of view.lanes) {
|
|
930
|
-
deps.print(`${row.developer.padEnd(20)} ${String(row.objects).padStart(7)} ${String(row.
|
|
931
|
-
+ `${String(row.fixesProven).padStart(12)} ${awaitingWord(row).padEnd(18)} ${String(row.kept).padStart(4)} `
|
|
1019
|
+
deps.print(`${row.developer.padEnd(20)} ${String(row.objects).padStart(7)} ${String(row.active).padStart(6)} ${String(row.retired).padStart(7)} `
|
|
1020
|
+
+ `${String(row.decided).padStart(7)} ${String(row.fixesProven).padStart(12)} ${awaitingWord(row).padEnd(18)} ${String(row.kept).padStart(4)} `
|
|
932
1021
|
+ `${String(row.rebuilds).padStart(8)} ${String(row.fixFailed).padStart(10)} ${lastCheckWord(row)}`);
|
|
933
1022
|
}
|
|
934
1023
|
// What each lane does next, in the priority order every surface sorts by. No %.
|
|
@@ -945,8 +1034,10 @@ function status(args, deps) {
|
|
|
945
1034
|
deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
|
|
946
1035
|
if (t.overlapping)
|
|
947
1036
|
deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
|
|
948
|
-
|
|
949
|
-
|
|
1037
|
+
// From the decision, as the page tile and the business sheet count it (the view's count).
|
|
1038
|
+
const candidates = pv.all.retireCandidates;
|
|
1039
|
+
if (candidates)
|
|
1040
|
+
deps.print(`${plural(candidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
|
|
950
1041
|
if (t.outOfScope)
|
|
951
1042
|
deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
|
|
952
1043
|
if (t.conflicts) {
|
|
@@ -1843,7 +1934,18 @@ function rule(args, deps) {
|
|
|
1843
1934
|
// Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
|
|
1844
1935
|
if (opts.reason === 'true')
|
|
1845
1936
|
throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
|
|
1846
|
-
|
|
1937
|
+
ruleObject({ type, name, classification: classification, reason: opts.reason }, deps);
|
|
1938
|
+
return 0;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* The body of `team rule` (Task 6): the lead's ruling, written to `rulings/`.
|
|
1942
|
+
* The journey's retire Yes runs it in process — the same record, the same
|
|
1943
|
+
* checks (a retire needs its reason, only the lead rules). Throws a
|
|
1944
|
+
* RegisterError with the plain reason when it cannot be written.
|
|
1945
|
+
*/
|
|
1946
|
+
export function ruleObject(r, deps) {
|
|
1947
|
+
const { type, name, classification } = r;
|
|
1948
|
+
const reason = (r.reason ?? '').trim();
|
|
1847
1949
|
if (reason.length > MAX_REASON)
|
|
1848
1950
|
throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
|
|
1849
1951
|
// The lead's retire is what makes an object retired (D2), so it carries what was checked.
|
|
@@ -1851,14 +1953,22 @@ function rule(args, deps) {
|
|
|
1851
1953
|
throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
|
|
1852
1954
|
const joined = joinedOrThrow();
|
|
1853
1955
|
requireLead(readProjectFile(joined.projectDir), deps);
|
|
1854
|
-
const res = writeRecord(joined, 'ruling',
|
|
1956
|
+
const res = writeRecord(joined, 'ruling',
|
|
1957
|
+
// The lead typed these words (project view §3.2): a person's, said so on the row.
|
|
1958
|
+
[{ key: objectKey(type, name), classification, ...(reason ? { reason, reasonFrom: 'person' } : {}) }], { name: whoAmI(deps) }, {
|
|
1855
1959
|
dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
|
|
1856
1960
|
...(deps.rand ? { rand: deps.rand } : {}),
|
|
1857
1961
|
});
|
|
1858
1962
|
if (!res.file)
|
|
1859
1963
|
throw new RegisterError(`${objectKey(type, name)} is not in this team project's baseline.`);
|
|
1860
|
-
|
|
1861
|
-
|
|
1964
|
+
// The ruling is what the project now holds: its words read back through the view (design §5 "rule lines").
|
|
1965
|
+
const read = readBack(joined.projectDir, joined.outboxDir);
|
|
1966
|
+
const o = read?.view.object(objectKey(type, name));
|
|
1967
|
+
const shownName = `${type.toUpperCase()} ${name.toUpperCase()}`;
|
|
1968
|
+
deps.print(o && read
|
|
1969
|
+
? `✓ Ruled: ${shownName} — ${read.view.words.decision(o.decision, whoAmI(deps))}. ${sentence(read.view.words.reason(o.decision.reason))}`
|
|
1970
|
+
: `✓ Ruled: ${shownName} — ${classification}.`);
|
|
1971
|
+
return { key: objectKey(type, name) };
|
|
1862
1972
|
}
|
|
1863
1973
|
// ── decide ──────────────────────────────────────────────────────────────────
|
|
1864
1974
|
const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"] [--kind app|class|api] [--app <APP>]';
|
|
@@ -1922,36 +2032,80 @@ export function parseDecideArgs(args) {
|
|
|
1922
2032
|
ok: true,
|
|
1923
2033
|
value: {
|
|
1924
2034
|
type: type.toUpperCase(), name: name.toUpperCase(),
|
|
1925
|
-
// F3 / M19
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
|
|
2035
|
+
// F3 / M19, Task 6 round 1: only `--target` (on a fix or rebuild) names a
|
|
2036
|
+
// target, and only then does `decide` stamp `targetBy`. Project view §3.2:
|
|
2037
|
+
// without `--target` no `targetLevel` is written (no default A). A kept
|
|
2038
|
+
// level is a level (`levelBy`), never a target.
|
|
2039
|
+
targetNamed: moves && !!target.value,
|
|
1929
2040
|
row: {
|
|
1930
2041
|
classification: said.classification,
|
|
1931
2042
|
...(said.exception ? { exception: true } : {}),
|
|
1932
2043
|
...(level.value ? { level: level.value } : {}),
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
...(reason ? { reason } : {}),
|
|
2044
|
+
...(moves && target.value ? { targetLevel: target.value } : {}),
|
|
2045
|
+
// The words after --reason are the person's own.
|
|
2046
|
+
...(reason ? { reason, reasonFrom: 'person' } : {}),
|
|
1936
2047
|
...(kind ? { rebuildKind: kind } : {}),
|
|
1937
2048
|
...(app ? { app } : {}),
|
|
1938
2049
|
},
|
|
1939
2050
|
},
|
|
1940
2051
|
};
|
|
1941
2052
|
}
|
|
1942
|
-
|
|
1943
|
-
function
|
|
1944
|
-
|
|
2053
|
+
/** The register and the view of this folder (with this laptop's outbox), read once; null when it cannot be read. */
|
|
2054
|
+
function readBack(projectDir, outboxDir) {
|
|
2055
|
+
try {
|
|
2056
|
+
const { register } = registerFor(projectDir, outboxDir);
|
|
2057
|
+
return { register, view: projectViewFor(register, readProjectFile(projectDir)) };
|
|
2058
|
+
}
|
|
2059
|
+
catch {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
/** A word table's text as a sentence: one full stop at the end. */
|
|
2064
|
+
const sentence = (text) => `${text}${/[.!?]$/.test(text) ? '' : '.'}`;
|
|
2065
|
+
const samePerson = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
2066
|
+
/**
|
|
2067
|
+
* The `✓ Recorded:` words for the row the person just wrote, in the view's
|
|
2068
|
+
* word tables (project view Task 4, fix round 1): "fix (decided by you),
|
|
2069
|
+
* level C (decided by you), no target set. Why: …" — their classification
|
|
2070
|
+
* and their reason first, whatever the register settles on.
|
|
2071
|
+
*/
|
|
2072
|
+
function writtenWords(row, w, me, at) {
|
|
2073
|
+
const mine = { name: me, at };
|
|
2074
|
+
const what = decisionWhatOf({ classification: row.classification, exception: row.exception === true, ruling: false });
|
|
2075
|
+
const parts = [w.decision({ what, from: 'person', by: mine, reason: null }, me)];
|
|
1945
2076
|
if (row.level)
|
|
1946
|
-
parts.push(
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
2077
|
+
parts.push(w.level({ now: null, decided: row.level, by: mine }, me));
|
|
2078
|
+
if (what === 'fix' || what === 'rebuild') {
|
|
2079
|
+
parts.push(w.target(row.targetLevel ? { level: row.targetLevel, from: 'person', by: mine } : { level: null, from: 'none', by: null }, me));
|
|
2080
|
+
}
|
|
1950
2081
|
if (row.rebuildKind)
|
|
1951
2082
|
parts.push(REBUILD_KIND_WORDS[row.rebuildKind]);
|
|
1952
2083
|
if (row.app)
|
|
1953
2084
|
parts.push(`app ${row.app}`);
|
|
1954
|
-
return `${parts.join(', ')}${row.reason ?
|
|
2085
|
+
return `${parts.join(', ')}. ${sentence(w.reason(row.reason ? { text: row.reason, from: 'person' } : null))}`;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* When what the project now shows is not the row just written, one line from
|
|
2089
|
+
* the view: a lead's ruling, a conflict (named by person, never by a writer
|
|
2090
|
+
* id), or another writer's row that stands. Null when they agree.
|
|
2091
|
+
* A conflict the lead wrote names the command that settles it (live re-run
|
|
2092
|
+
* L3): the lead is never told "the lead settles it".
|
|
2093
|
+
*/
|
|
2094
|
+
function projectShowsLine(o, me, mine, view) {
|
|
2095
|
+
const d = o.decision;
|
|
2096
|
+
const w = view.words;
|
|
2097
|
+
if (d.from === 'ruling')
|
|
2098
|
+
return `The team project shows: ${w.decision(d, me)}.`;
|
|
2099
|
+
if (d.conflict) {
|
|
2100
|
+
const names = [...new Set((d.standing ?? []).filter((x) => x.what !== mine && !samePerson(x.name, me)).map((x) => x.name))];
|
|
2101
|
+
const whose = names.length ? names.map((n) => `${n}'s`).join(' and ') : "another person's";
|
|
2102
|
+
if (samePerson(view.project.lead, me))
|
|
2103
|
+
return `Your decision differs from ${whose}. As the lead, settle it with: cspeach team rule ${o.type} ${o.name} <decision>`;
|
|
2104
|
+
return `Your decision differs from ${whose}; the lead settles it.`;
|
|
2105
|
+
}
|
|
2106
|
+
if (d.by && !samePerson(d.by.name, me))
|
|
2107
|
+
return `The team project shows: ${w.decision(d, me)}.`;
|
|
2108
|
+
return null;
|
|
1955
2109
|
}
|
|
1956
2110
|
function decide(args, deps) {
|
|
1957
2111
|
const parsed = parseDecideArgs(args);
|
|
@@ -1964,15 +2118,25 @@ function decide(args, deps) {
|
|
|
1964
2118
|
const res = writeRecord(joined, 'decision', [{
|
|
1965
2119
|
key: objectKey(type, name), ...row,
|
|
1966
2120
|
...(targetNamed ? { targetBy: { name: by, at: now.toISOString() } } : {}),
|
|
2121
|
+
// G8: the level the person typed says who decided it; no --level, nothing stamped.
|
|
2122
|
+
...(row.level ? { levelBy: { name: by, at: now.toISOString() } } : {}),
|
|
1967
2123
|
}], { name: by }, {
|
|
1968
2124
|
now, ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1969
2125
|
});
|
|
1970
2126
|
if (!res.file)
|
|
1971
2127
|
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1972
|
-
deps.print(`✓ Recorded: ${type} ${name} — ${
|
|
2128
|
+
deps.print(`✓ Recorded: ${type} ${name} — ${writtenWords(row, viewWords, by, now.toISOString())}`);
|
|
2129
|
+
// Fix round 1: what the project now shows, read once through the view, when it is not this row.
|
|
2130
|
+
const read = readBack(joined.projectDir, joined.outboxDir);
|
|
2131
|
+
const o = read?.view.object(objectKey(type, name));
|
|
2132
|
+
const mine = decisionWhatOf({ classification: row.classification, exception: row.exception === true, ruling: false });
|
|
2133
|
+
const shows = o && read ? projectShowsLine(o, by, mine, read.view) : null;
|
|
2134
|
+
if (shows)
|
|
2135
|
+
deps.print(shows);
|
|
1973
2136
|
if (row.classification === 'retire')
|
|
1974
2137
|
deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
|
|
1975
|
-
|
|
2138
|
+
// The register just read, handed on: the working list does not read the folder again.
|
|
2139
|
+
refreshSheet(deps, read ?? undefined);
|
|
1976
2140
|
return 0;
|
|
1977
2141
|
}
|
|
1978
2142
|
// ── rebuild ─────────────────────────────────────────────────────────────────
|
|
@@ -2096,6 +2260,54 @@ function targetLines(project) {
|
|
|
2096
2260
|
return targetsOf(project).map((t) => `Target ${t.level}${t.variant ? ` — variant ${t.variant} (stored, not yet used for measuring)` : ''}`
|
|
2097
2261
|
+ `, for ${forWhom(t.packages)}. Set by ${shown(t.by)}.`);
|
|
2098
2262
|
}
|
|
2263
|
+
// ── transport ───────────────────────────────────────────────────────────────
|
|
2264
|
+
const TRANSPORT_USAGE = 'Usage: cspeach team transport <REQUEST> [--reason "<why>"] | cspeach team transport clear';
|
|
2265
|
+
/**
|
|
2266
|
+
* Task 8 (B11) — the request this project's fixes are recorded in, set or
|
|
2267
|
+
* cleared by the lead (a project started before this release sets it here).
|
|
2268
|
+
* Stored as `transport: { request, by, at }` (and `reason` when one is said);
|
|
2269
|
+
* the whole project file is written, then renamed. The modify/delete tools
|
|
2270
|
+
* write under it when a change names no transport; a request that already
|
|
2271
|
+
* holds the object still wins (the write is forced there).
|
|
2272
|
+
*/
|
|
2273
|
+
function transportVerb(args, deps) {
|
|
2274
|
+
const { positional, opts } = flags(args);
|
|
2275
|
+
for (const name of Object.keys(opts)) {
|
|
2276
|
+
if (name !== 'reason')
|
|
2277
|
+
throw new RegisterError(`--${name} is not an option of team transport.
|
|
2278
|
+
${TRANSPORT_USAGE}`);
|
|
2279
|
+
}
|
|
2280
|
+
const [word] = positional;
|
|
2281
|
+
if (!word || positional.length !== 1)
|
|
2282
|
+
throw new RegisterError(TRANSPORT_USAGE);
|
|
2283
|
+
const clearing = word.toLowerCase() === 'clear';
|
|
2284
|
+
const joined = joinedOrThrow();
|
|
2285
|
+
const project = readProjectFile(joined.projectDir);
|
|
2286
|
+
requireLead(project, deps);
|
|
2287
|
+
if (clearing) {
|
|
2288
|
+
if (!project.transport) {
|
|
2289
|
+
deps.print('Nothing to clear: no transport is set.');
|
|
2290
|
+
return 0;
|
|
2291
|
+
}
|
|
2292
|
+
writeProjectTransport(joined.projectDir, null);
|
|
2293
|
+
deps.print('✓ Transport cleared. No transport yet.');
|
|
2294
|
+
return 0;
|
|
2295
|
+
}
|
|
2296
|
+
const request = word.trim().toUpperCase();
|
|
2297
|
+
if (!TRANSPORT_REQUEST.test(request))
|
|
2298
|
+
throw new RegisterError(notARequestLine(word));
|
|
2299
|
+
let reason;
|
|
2300
|
+
if (opts.reason !== undefined) {
|
|
2301
|
+
reason = Array.from(shownUpTo(opts.reason === 'true' ? '' : opts.reason, Number.POSITIVE_INFINITY)).slice(0, MAX_REASON).join('').trim();
|
|
2302
|
+
if (!reason)
|
|
2303
|
+
throw new RegisterError('--reason needs words, for example: --reason "the clean-core request for Q4"');
|
|
2304
|
+
}
|
|
2305
|
+
writeProjectTransport(joined.projectDir, {
|
|
2306
|
+
request, by: whoAmI(deps), at: (deps.now ?? (() => new Date()))().toISOString(), ...(reason ? { reason } : {}),
|
|
2307
|
+
});
|
|
2308
|
+
deps.print(`✓ Transport: fixes in this team project are recorded in ${request}.`);
|
|
2309
|
+
return 0;
|
|
2310
|
+
}
|
|
2099
2311
|
// ── fixed ───────────────────────────────────────────────────────────────────
|
|
2100
2312
|
const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
|
|
2101
2313
|
const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
|
|
@@ -2186,8 +2398,19 @@ export const heldReportLine = (name) => ` ! "${name}" is open in another progra
|
|
|
2186
2398
|
* other, and the caller is told by file name which one was not updated.
|
|
2187
2399
|
* writeWhole has already removed its .tmp when it throws.
|
|
2188
2400
|
*/
|
|
2401
|
+
/**
|
|
2402
|
+
* Design §5 "Register inputs": a report or export of the joined project reads
|
|
2403
|
+
* this laptop's outbox too, as `team status` does, so it never lags this seat.
|
|
2404
|
+
* Another folder (one named on the command line) is read as it is.
|
|
2405
|
+
*/
|
|
2406
|
+
function outboxFor(projectDir) {
|
|
2407
|
+
const joined = activeProject();
|
|
2408
|
+
return joined && path.resolve(joined.projectDir) === path.resolve(projectDir) ? joined.outboxDir : undefined;
|
|
2409
|
+
}
|
|
2189
2410
|
export function writeReports(projectDir, project, outDir, deps) {
|
|
2190
|
-
const picture = registerFor(projectDir);
|
|
2411
|
+
const picture = registerFor(projectDir, outboxFor(projectDir));
|
|
2412
|
+
// The project view, built once for both files.
|
|
2413
|
+
const pv = projectViewFor(picture.register, project);
|
|
2191
2414
|
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
2192
2415
|
const folderName = path.basename(projectDir);
|
|
2193
2416
|
const names = reportFileNames(project.name);
|
|
@@ -2211,9 +2434,9 @@ export function writeReports(projectDir, project, outDir, deps) {
|
|
|
2211
2434
|
// "changed since" block starts there. Read BEFORE it is overwritten.
|
|
2212
2435
|
const previousAsOf = previousAsOfIn(path.join(outDir, names.html));
|
|
2213
2436
|
const saved = [
|
|
2214
|
-
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project))) ? names.xlsx : null,
|
|
2437
|
+
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project, pv))) ? names.xlsx : null,
|
|
2215
2438
|
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
2216
|
-
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
2439
|
+
project, register: picture.register, files: picture.files, unreadable: picture.unreadable, projectView: pv,
|
|
2217
2440
|
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
|
|
2218
2441
|
...(discovery ? { estate: discovery.estate } : {}),
|
|
2219
2442
|
}))) ? names.html : null,
|
|
@@ -2240,23 +2463,27 @@ function report(args, deps) {
|
|
|
2240
2463
|
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
2241
2464
|
for (const name of held)
|
|
2242
2465
|
deps.print(heldReportLine(name));
|
|
2243
|
-
// D2: the CSV export rides along
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2466
|
+
// D2: the CSV export rides along when the project file switched it on.
|
|
2467
|
+
// G5: an export already beside the report is rewritten too, so it never goes stale.
|
|
2468
|
+
const exportsDir = path.join(outDir, EXPORTS_DIR);
|
|
2469
|
+
const refresh = !exportsOn(project) && !exportsOff(project) && exportsPresent(exportsDir);
|
|
2470
|
+
if (exportsOn(project) || refresh) {
|
|
2471
|
+
const out = writeExports(joined.projectDir, project, exportsDir, deps);
|
|
2472
|
+
if (out.saved.length) {
|
|
2473
|
+
deps.print(refresh ? '✓ Updated the CSV export in the exports folder beside the report.' : '✓ Saved the CSV export in the exports folder beside the report.');
|
|
2474
|
+
}
|
|
2248
2475
|
for (const name of out.held)
|
|
2249
2476
|
deps.print(heldExportLine(name));
|
|
2250
2477
|
held.push(...out.held);
|
|
2251
2478
|
}
|
|
2252
|
-
// Records on this laptop that have not reached the folder
|
|
2479
|
+
// Records on this laptop that have not reached the folder ARE in the report (it reads the
|
|
2480
|
+
// outbox, as team status does), and the team page does not have them yet: said, as status says it.
|
|
2253
2481
|
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
2254
2482
|
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|
|
2255
2483
|
if (path.resolve(ownDir) !== path.resolve(inFolder) && fs.existsSync(ownDir)) {
|
|
2256
2484
|
const missing = fs.readdirSync(ownDir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(inFolder, f))).length;
|
|
2257
2485
|
if (missing) {
|
|
2258
|
-
deps.print(` ${
|
|
2259
|
-
+ `so ${missing === 1 ? 'it is' : 'they are'} not in this report.`);
|
|
2486
|
+
deps.print(` Includes ${missing} record file${missing === 1 ? '' : 's'} from this laptop that ${missing === 1 ? 'has' : 'have'} not been handed in yet.`);
|
|
2260
2487
|
}
|
|
2261
2488
|
}
|
|
2262
2489
|
return held.length ? 1 : 0;
|
|
@@ -2266,7 +2493,7 @@ const EXPORT_USAGE = 'Usage: cspeach team export [--out <folder>] [--always | --
|
|
|
2266
2493
|
const heldExportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team export`;
|
|
2267
2494
|
/** The CSV export of the team project folder, as the team page reads it, into `dir`. */
|
|
2268
2495
|
export function writeExports(projectDir, project, dir, deps) {
|
|
2269
|
-
const { register } = registerFor(projectDir);
|
|
2496
|
+
const { register } = registerFor(projectDir, outboxFor(projectDir));
|
|
2270
2497
|
return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2271
2498
|
}
|
|
2272
2499
|
/**
|