@cspeach/cli 1.1.30 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -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-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- 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 +100 -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/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -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 +106 -90
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- 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 +3 -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/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +236 -59
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -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 +53 -53
- 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/dispatch-skill.js +8 -4
- 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/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- 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/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +3 -3
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steering a team project (spec 2026-10-06 §7, D3): the lane proposal, the
|
|
3
|
+
* focus, the priority rules and the per-object plan changes.
|
|
4
|
+
*
|
|
5
|
+
* lane propose --developers "…" [--apply] lead: a first cut of the lanes
|
|
6
|
+
* focus "<label>" (--packages|--objects|--lane) --reason "…" lead: what the team works on now
|
|
7
|
+
* focus list | clear <id|label>
|
|
8
|
+
* priority show | set … --reason | reset --reason
|
|
9
|
+
* plan move|scope|defer|set|note|history
|
|
10
|
+
*
|
|
11
|
+
* Every change is one append-only record in the lead's `plan/` folder, written
|
|
12
|
+
* whole then renamed. Lanes stay patterns in `lanes.json`; everything per object
|
|
13
|
+
* is a plan record, and the register folds them on read. Nothing here edits a
|
|
14
|
+
* record or reaches SAP, and none of it is reachable from a model tool.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { buildRegister, DEFAULT_WEIGHTS, inLane, objectKey, proposeLanes, utcWords, } from '@cspeach/register-core';
|
|
20
|
+
import { RegisterError, collectRecords, readBaseline, readLanes, readProjectFile, writeLanes, } from '../register/store.js';
|
|
21
|
+
import { diffPlan, readPlanSheet } from '../register/plan-import.js';
|
|
22
|
+
import { writeLeadRecord } from '../register/records.js';
|
|
23
|
+
import { registerFor } from '../register/picture.js';
|
|
24
|
+
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
25
|
+
import { flags, heldReportLine, joinedOrThrow, list, requireLead, whoAmI, writeReports } from './team.js';
|
|
26
|
+
const MAX_REASON = 300;
|
|
27
|
+
export const NEEDS_REASON = '--reason needs words: what changed and why.';
|
|
28
|
+
const NO_FOCUS = 'No focus set. Working lists follow the suggested order.';
|
|
29
|
+
const NO_MATCH = 'That selector matches no object in this team project.';
|
|
30
|
+
const NOTHING_REPLANNED = 'Nothing has been re-planned. Lists follow the suggested order.';
|
|
31
|
+
const WEIGHT_PARTS = ['effort', 'risk', 'shared', 'usage', 'disposition'];
|
|
32
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
33
|
+
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
34
|
+
/** "7 Oct" — a record's day, in UTC like every time the team project shows. */
|
|
35
|
+
export const dayMonth = (iso) => {
|
|
36
|
+
const d = new Date(iso);
|
|
37
|
+
return Number.isNaN(d.getTime()) ? '' : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
|
|
38
|
+
};
|
|
39
|
+
const nameOf = (key) => key.slice(key.indexOf('|') + 1);
|
|
40
|
+
const matches = (pattern, value) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i').test(value);
|
|
41
|
+
/**
|
|
42
|
+
* The reason, the way `decide` reads one: trimmed, a bare flag refused, at most
|
|
43
|
+
* 300 characters. `required` refuses an empty one with the one sentence.
|
|
44
|
+
*/
|
|
45
|
+
export function reasonFrom(opts, required) {
|
|
46
|
+
if (opts.reason === 'true')
|
|
47
|
+
throw new RegisterError(NEEDS_REASON);
|
|
48
|
+
const reason = (opts.reason ?? '').trim();
|
|
49
|
+
if (required && !reason)
|
|
50
|
+
throw new RegisterError(NEEDS_REASON);
|
|
51
|
+
if (reason.length > MAX_REASON)
|
|
52
|
+
throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
|
|
53
|
+
return reason;
|
|
54
|
+
}
|
|
55
|
+
/** Anything a verb does not know is a typo, refused rather than ignored. */
|
|
56
|
+
function onlyOptions(opts, known, usage) {
|
|
57
|
+
for (const name of Object.keys(opts))
|
|
58
|
+
if (!known.includes(name))
|
|
59
|
+
throw new RegisterError(`Unknown option --${name}.\n${usage}`);
|
|
60
|
+
}
|
|
61
|
+
/** The joined team project, checked to be led from this laptop. */
|
|
62
|
+
function asLead(deps) {
|
|
63
|
+
const joined = joinedOrThrow();
|
|
64
|
+
const project = readProjectFile(joined.projectDir);
|
|
65
|
+
requireLead(project, deps);
|
|
66
|
+
return { projectDir: joined.projectDir, outboxDir: joined.outboxDir, project, by: { name: whoAmI(deps), writer: joined.writerId } };
|
|
67
|
+
}
|
|
68
|
+
/** One plan record into `plan/`; the file NAME comes back, never the path. */
|
|
69
|
+
function writePlan(lead, header, rows, deps) {
|
|
70
|
+
const { file } = writeLeadRecord(path.join(lead.projectDir, 'plan'), lead.project.projectId, 'plan', header, rows, lead.by, {
|
|
71
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
72
|
+
});
|
|
73
|
+
return path.basename(file);
|
|
74
|
+
}
|
|
75
|
+
const writtenLine = (file) => ` Written to the plan folder: ${file}`;
|
|
76
|
+
/** `TYPE NAME` → the key, refused by name when the baseline does not hold it. */
|
|
77
|
+
function keyIn(projectDir, type, name, usage) {
|
|
78
|
+
if (!type || !name)
|
|
79
|
+
throw new RegisterError(usage);
|
|
80
|
+
const key = objectKey(type.toUpperCase(), name.toUpperCase());
|
|
81
|
+
if (!readBaseline(projectDir).some((o) => objectKey(o.type, o.name) === key)) {
|
|
82
|
+
throw new RegisterError(`${shown(type.toUpperCase())} ${shown(name.toUpperCase())} is not in this team project's object list.`);
|
|
83
|
+
}
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
86
|
+
const lanesWords = (lanes) => lanes.map((l) => `${l.owner} (${l.id})`).join(', ') || 'none yet';
|
|
87
|
+
function laneOrThrow(lanes, id) {
|
|
88
|
+
const lane = lanes.find((l) => l.id.toLowerCase() === id.trim().toLowerCase());
|
|
89
|
+
if (!lane)
|
|
90
|
+
throw new RegisterError(`There is no lane "${shown(id)}". Lanes: ${lanesWords(lanes)}.`);
|
|
91
|
+
return lane;
|
|
92
|
+
}
|
|
93
|
+
// ── lane propose, and the record beside lane add / remove ──────────────────
|
|
94
|
+
const PROPOSE_USAGE = 'Usage: cspeach team lane propose --developers "Sarah Davis,James Miller" [--apply] [--reason "<why>"]';
|
|
95
|
+
export async function laneProposal(args, deps) {
|
|
96
|
+
const { positional, opts } = flags(args, ['apply']);
|
|
97
|
+
onlyOptions(opts, ['developers', 'apply', 'reason'], PROPOSE_USAGE);
|
|
98
|
+
if (positional.length || !opts.developers || opts.developers === 'true')
|
|
99
|
+
throw new RegisterError(PROPOSE_USAGE);
|
|
100
|
+
const reason = reasonFrom(opts, false);
|
|
101
|
+
const lead = asLead(deps);
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const developers = list(opts.developers).map((d) => d.slice(0, 80)).filter((d) => {
|
|
104
|
+
const k = d.toLowerCase();
|
|
105
|
+
return seen.has(k) ? false : (seen.add(k), true);
|
|
106
|
+
});
|
|
107
|
+
if (!developers.length)
|
|
108
|
+
throw new RegisterError(PROPOSE_USAGE);
|
|
109
|
+
const { register } = registerFor(lead.projectDir, lead.outboxDir);
|
|
110
|
+
const proposal = proposeLanes(register, developers);
|
|
111
|
+
for (const l of proposal.lanes) {
|
|
112
|
+
deps.print(`${l.owner} (${l.id}): ${l.packages.join(', ') || 'no package'} — ${plural(l.objects, 'object', 'objects')}, `
|
|
113
|
+
+ `${plural(l.findings, 'finding', 'findings')}, ${l.share}% of the work`);
|
|
114
|
+
}
|
|
115
|
+
deps.print(proposal.note
|
|
116
|
+
? ` ${proposal.note[0].toUpperCase()}${proposal.note.slice(1)}.`
|
|
117
|
+
: ' Balanced by the work: objects with findings count three times, then findings, then objects.');
|
|
118
|
+
deps.print(`Unowned after this proposal: ${proposal.unowned}`);
|
|
119
|
+
const existing = readLanes(lead.projectDir);
|
|
120
|
+
let yes = opts.apply === 'true';
|
|
121
|
+
if (!yes) {
|
|
122
|
+
if (!deps.ask) {
|
|
123
|
+
deps.print(`Nothing was written. To apply it: cspeach team lane propose --developers "${developers.join(',')}" --apply`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
if (existing.length)
|
|
127
|
+
deps.print(` Applying replaces the lanes there are now: ${lanesWords(existing)}.`);
|
|
128
|
+
const answer = (await deps.ask('Apply this proposal? [y/n]')).trim().toLowerCase();
|
|
129
|
+
yes = answer === 'y' || answer === 'yes';
|
|
130
|
+
if (!yes) {
|
|
131
|
+
deps.print('Nothing was written.');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const next = proposal.lanes.map((l) => ({ id: l.id, owner: l.owner, packages: l.packages, namePatterns: [] }));
|
|
136
|
+
writeLanes(lead.projectDir, lead.project.projectId, next);
|
|
137
|
+
const files = [];
|
|
138
|
+
// A lane the proposal drops is a removal in the history, so nothing leaves lanes.json unrecorded.
|
|
139
|
+
for (const gone of existing.filter((e) => !next.some((l) => l.id === e.id))) {
|
|
140
|
+
files.push(writePlan(lead, {
|
|
141
|
+
action: 'lane',
|
|
142
|
+
lane: { id: gone.id, change: 'remove', before: { owner: gone.owner, packages: gone.packages }, after: null },
|
|
143
|
+
...(reason ? { reason } : {}),
|
|
144
|
+
}, [], deps));
|
|
145
|
+
}
|
|
146
|
+
for (const l of next) {
|
|
147
|
+
const was = existing.find((e) => e.id === l.id);
|
|
148
|
+
files.push(writePlan(lead, {
|
|
149
|
+
action: 'lane',
|
|
150
|
+
lane: { id: l.id, change: 'propose', before: was ? { owner: was.owner, packages: was.packages } : null, after: { owner: l.owner, packages: l.packages } },
|
|
151
|
+
...(reason ? { reason } : {}),
|
|
152
|
+
}, [], deps));
|
|
153
|
+
}
|
|
154
|
+
deps.print(`✓ ${plural(next.length, 'lane', 'lanes')} written to lanes.json.`);
|
|
155
|
+
if (existing.length)
|
|
156
|
+
deps.print(` Replaced: ${lanesWords(existing)}.`);
|
|
157
|
+
deps.print(` Written to the plan folder: ${files.join(', ')}`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
/** `lane add|remove`: the history beside the lanes file. Returns the line to print. */
|
|
161
|
+
export function laneChangeRecord(deps, change, before, after, id, reason) {
|
|
162
|
+
const lead = asLead(deps);
|
|
163
|
+
const side = (l) => (l ? { owner: l.owner, packages: l.packages, namePatterns: l.namePatterns } : null);
|
|
164
|
+
const file = writePlan(lead, { action: 'lane', lane: { id, change, before: side(before), after: side(after) }, ...(reason ? { reason } : {}) }, [], deps);
|
|
165
|
+
return writtenLine(file);
|
|
166
|
+
}
|
|
167
|
+
// ── focus ───────────────────────────────────────────────────────────────────
|
|
168
|
+
const FOCUS_USAGE = 'Usage: cspeach team focus "<label>" (--packages "ZFI_*" | --objects "TYPE NAME,…" | --lane <id>) --reason "<why>" [--order <n>]\n'
|
|
169
|
+
+ ' or: cspeach team focus list | clear <label>';
|
|
170
|
+
/** "Finance first" → "finance-first": the same label is the same focus, so setting it again replaces it. */
|
|
171
|
+
export const focusIdOf = (label) => label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '');
|
|
172
|
+
function focusLine(f) {
|
|
173
|
+
return `${f.label} — set by ${f.by} on ${dayMonth(f.at)}${f.reason ? ` because ${f.reason}` : ''}; ${f.proven} of ${f.active} done`;
|
|
174
|
+
}
|
|
175
|
+
/** The status line: the focus the team works on now, or the sentence for none. */
|
|
176
|
+
export function nowLine(register) {
|
|
177
|
+
const f = register.plan.focuses[0];
|
|
178
|
+
return f ? `Now: ${focusLine(f)}` : NO_FOCUS;
|
|
179
|
+
}
|
|
180
|
+
export function focusCommand(args, deps) {
|
|
181
|
+
const { positional, opts } = flags(args);
|
|
182
|
+
const [first, ...more] = positional;
|
|
183
|
+
if (first === 'list' && !more.length && !Object.keys(opts).length)
|
|
184
|
+
return focusList(deps);
|
|
185
|
+
if (first === 'clear')
|
|
186
|
+
return focusClear(more, opts, deps);
|
|
187
|
+
onlyOptions(opts, ['packages', 'objects', 'lane', 'reason', 'order'], FOCUS_USAGE);
|
|
188
|
+
if (!first || more.length)
|
|
189
|
+
throw new RegisterError(FOCUS_USAGE);
|
|
190
|
+
const label = first.trim().slice(0, MAX_REASON);
|
|
191
|
+
const id = focusIdOf(label);
|
|
192
|
+
if (!id)
|
|
193
|
+
throw new RegisterError(`A focus label needs letters or digits, for example: "Finance first"\n${FOCUS_USAGE}`);
|
|
194
|
+
const selectors = ['packages', 'objects', 'lane'].filter((k) => opts[k] !== undefined);
|
|
195
|
+
if (selectors.length !== 1)
|
|
196
|
+
throw new RegisterError(`Say which objects, one of: --packages "ZFI_*" --objects "PROG ZFI_POST,CLAS ZCL_FI_TAX" --lane <id>\n${FOCUS_USAGE}`);
|
|
197
|
+
const sel = selectors[0];
|
|
198
|
+
if (opts[sel] === 'true' || !opts[sel].trim())
|
|
199
|
+
throw new RegisterError(`--${sel} needs a value.\n${FOCUS_USAGE}`);
|
|
200
|
+
const reason = reasonFrom(opts, true);
|
|
201
|
+
let order;
|
|
202
|
+
if (opts.order !== undefined) {
|
|
203
|
+
if (!/^\d+$/.test(opts.order) || Number(opts.order) < 1)
|
|
204
|
+
throw new RegisterError('--order is a whole number from 1, for example: --order 1');
|
|
205
|
+
order = Number(opts.order);
|
|
206
|
+
}
|
|
207
|
+
const lead = asLead(deps);
|
|
208
|
+
const { register } = registerFor(lead.projectDir, lead.outboxDir);
|
|
209
|
+
let keys;
|
|
210
|
+
let selector;
|
|
211
|
+
if (sel === 'packages') {
|
|
212
|
+
const patterns = list(opts.packages);
|
|
213
|
+
keys = register.objects.filter((o) => patterns.some((p) => matches(p, o.package))).map((o) => o.key);
|
|
214
|
+
selector = { packages: patterns };
|
|
215
|
+
}
|
|
216
|
+
else if (sel === 'objects') {
|
|
217
|
+
const named = list(opts.objects).map((pair) => {
|
|
218
|
+
const [type, name, ...rest] = pair.split(/\s+/);
|
|
219
|
+
if (!type || !name || rest.length)
|
|
220
|
+
throw new RegisterError(`"${shown(pair)}" is not TYPE NAME, for example: --objects "PROG ZFI_POST,CLAS ZCL_FI_TAX"`);
|
|
221
|
+
return keyIn(lead.projectDir, type, name, FOCUS_USAGE);
|
|
222
|
+
});
|
|
223
|
+
keys = [...new Set(named)];
|
|
224
|
+
selector = { objects: keys };
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const lane = laneOrThrow(readLanes(lead.projectDir), opts.lane);
|
|
228
|
+
keys = register.objects.filter((o) => inLane(o, lane.id)).map((o) => o.key);
|
|
229
|
+
selector = { lane: lane.id };
|
|
230
|
+
}
|
|
231
|
+
if (!keys.length)
|
|
232
|
+
throw new RegisterError(NO_MATCH);
|
|
233
|
+
// The same label again REPLACES the focus (same id) and keeps its place in the
|
|
234
|
+
// stack unless --order moves it; a new label goes to the end.
|
|
235
|
+
const had = register.plan.focuses.find((f) => f.id === id);
|
|
236
|
+
const finalOrder = order ?? had?.order ?? (register.plan.focuses.reduce((m, f) => Math.max(m, f.order), 0) + 1);
|
|
237
|
+
const file = writePlan(lead, { action: 'focus-set', focus: { id, label, reason, order: finalOrder }, selector, reason }, keys.map((key) => ({ key })), deps);
|
|
238
|
+
const after = registerFor(lead.projectDir, lead.outboxDir).register;
|
|
239
|
+
deps.print(`✓ Focus set: ${plural(keys.length, 'object', 'objects')}. Now: ${after.plan.focuses[0]?.label ?? label}.`);
|
|
240
|
+
if (had)
|
|
241
|
+
deps.print(` This replaces the focus "${had.label}" set on ${dayMonth(had.at)}.`);
|
|
242
|
+
deps.print(writtenLine(file));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
function focusList(deps) {
|
|
246
|
+
const joined = joinedOrThrow();
|
|
247
|
+
const { register } = registerFor(joined.projectDir, joined.outboxDir);
|
|
248
|
+
if (!register.plan.focuses.length) {
|
|
249
|
+
deps.print(NO_FOCUS);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
register.plan.focuses.forEach((f, i) => {
|
|
253
|
+
deps.print(`${i + 1}. ${focusLine(f)} (${f.donePct}%)`);
|
|
254
|
+
});
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
function focusClear(positional, opts, deps) {
|
|
258
|
+
onlyOptions(opts, ['reason'], FOCUS_USAGE);
|
|
259
|
+
const said = positional.join(' ').trim();
|
|
260
|
+
if (!said)
|
|
261
|
+
throw new RegisterError(FOCUS_USAGE);
|
|
262
|
+
const reason = reasonFrom(opts, false);
|
|
263
|
+
const lead = asLead(deps);
|
|
264
|
+
const { register } = registerFor(lead.projectDir, lead.outboxDir);
|
|
265
|
+
const f = register.plan.focuses.find((x) => x.id === said || x.id === focusIdOf(said) || x.label.toLowerCase() === said.toLowerCase());
|
|
266
|
+
if (!f) {
|
|
267
|
+
throw new RegisterError(`There is no focus "${shown(said)}". Focuses: ${register.plan.focuses.map((x) => x.label).join(', ') || 'none set'}.`);
|
|
268
|
+
}
|
|
269
|
+
const file = writePlan(lead, { action: 'focus-clear', focusId: f.id, ...(reason ? { reason } : {}) }, [], deps);
|
|
270
|
+
const after = registerFor(lead.projectDir, lead.outboxDir).register;
|
|
271
|
+
deps.print(`✓ Focus cleared: ${f.label}. ${after.plan.focuses[0] ? `Now: ${after.plan.focuses[0].label}.` : NO_FOCUS}`);
|
|
272
|
+
deps.print(writtenLine(file));
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
// ── priority ────────────────────────────────────────────────────────────────
|
|
276
|
+
const PRIORITY_USAGE = 'Usage: cspeach team priority show\n'
|
|
277
|
+
+ ' or: cspeach team priority set --effort <n> --risk <n> --shared <n> --usage <n> --disposition <n> --reason "<why>"\n'
|
|
278
|
+
+ ' or: cspeach team priority reset --reason "<why>"';
|
|
279
|
+
const weightsWords = (w) => WEIGHT_PARTS.map((p) => `${p} ${w[p]}`).join(', ');
|
|
280
|
+
export function priorityCommand(args, deps) {
|
|
281
|
+
const [verb, ...rest] = args;
|
|
282
|
+
const { positional, opts } = flags(rest);
|
|
283
|
+
if (positional.length)
|
|
284
|
+
throw new RegisterError(PRIORITY_USAGE);
|
|
285
|
+
if (verb === 'show' || verb === undefined) {
|
|
286
|
+
onlyOptions(opts, [], PRIORITY_USAGE);
|
|
287
|
+
const joined = joinedOrThrow();
|
|
288
|
+
const { plan } = registerFor(joined.projectDir, joined.outboxDir).register;
|
|
289
|
+
deps.print(`Rule weights: ${weightsWords(plan.weights)}`
|
|
290
|
+
+ `${plan.weightsFrom === 'set' ? ` — set by ${plan.weightsBy ?? 'the lead'} on ${dayMonth(plan.weightsAt ?? '')}.` : ' — the defaults.'}`);
|
|
291
|
+
deps.print('Bands: 1 at a score of 80 or more, 2 at 60 or more, 3 at 40 or more, 4 at 20 or more, 5 below 20.');
|
|
292
|
+
deps.print('A priority the lead sets by hand wins, then the focus, then these weights.');
|
|
293
|
+
deps.print('To change them: cspeach team priority set --effort n --risk n --shared n --usage n --disposition n --reason "<why>"');
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
if (verb === 'set') {
|
|
297
|
+
onlyOptions(opts, [...WEIGHT_PARTS, 'reason'], PRIORITY_USAGE);
|
|
298
|
+
const weights = {};
|
|
299
|
+
for (const part of WEIGHT_PARTS) {
|
|
300
|
+
const v = opts[part];
|
|
301
|
+
if (v === undefined || !/^\d+$/.test(v) || Number(v) > 100) {
|
|
302
|
+
throw new RegisterError('priority set needs all five, each a whole number from 0 to 100: '
|
|
303
|
+
+ '--effort n --risk n --shared n --usage n --disposition n');
|
|
304
|
+
}
|
|
305
|
+
weights[part] = Number(v);
|
|
306
|
+
}
|
|
307
|
+
const reason = reasonFrom(opts, true);
|
|
308
|
+
const lead = asLead(deps);
|
|
309
|
+
const file = writePlan(lead, { action: 'weights', weights, reason }, [], deps);
|
|
310
|
+
deps.print(`✓ Rule weights set: ${weightsWords(weights)}.`);
|
|
311
|
+
deps.print(writtenLine(file));
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
if (verb === 'reset') {
|
|
315
|
+
onlyOptions(opts, ['reason'], PRIORITY_USAGE);
|
|
316
|
+
const reason = reasonFrom(opts, true);
|
|
317
|
+
const lead = asLead(deps);
|
|
318
|
+
const file = writePlan(lead, { action: 'weights', weights: { ...DEFAULT_WEIGHTS }, reason }, [], deps);
|
|
319
|
+
deps.print(`✓ Rule weights back to the defaults: ${weightsWords(DEFAULT_WEIGHTS)}.`);
|
|
320
|
+
deps.print(writtenLine(file));
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
throw new RegisterError(PRIORITY_USAGE);
|
|
324
|
+
}
|
|
325
|
+
// ── plan ────────────────────────────────────────────────────────────────────
|
|
326
|
+
const PLAN_USAGE = 'Usage: cspeach team plan move <TYPE> <NAME> (--lane <id> | --unassign) --reason "<why>"\n'
|
|
327
|
+
+ ' or: cspeach team plan scope (<TYPE> <NAME> | --package <pkg>) (--out | --in) --reason "<why>"\n'
|
|
328
|
+
+ ' or: cspeach team plan defer <TYPE> <NAME> --reason "<why>"\n'
|
|
329
|
+
+ ' or: cspeach team plan set <TYPE> <NAME> --priority 1-5|clear [--reason "<why>"]\n'
|
|
330
|
+
+ ' or: cspeach team plan note <TYPE> <NAME> [--target 2026-11-30] [--note "<text>"]\n'
|
|
331
|
+
+ ' or: cspeach team plan import <file.xlsx> [--yes]\n'
|
|
332
|
+
+ ' or: cspeach team plan history [--object <TYPE> <NAME>]';
|
|
333
|
+
/** A real calendar day, YYYY-MM-DD. */
|
|
334
|
+
const isDay = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
|
|
335
|
+
export function planCommand(args, deps) {
|
|
336
|
+
const [verb, ...rest] = args;
|
|
337
|
+
switch (verb) {
|
|
338
|
+
case 'import': return planImport(rest, deps);
|
|
339
|
+
case 'move': return planMove(rest, deps);
|
|
340
|
+
case 'scope': return planScope(rest, deps);
|
|
341
|
+
case 'defer': return planDefer(rest, deps);
|
|
342
|
+
case 'set': return planSet(rest, deps);
|
|
343
|
+
case 'note': return planNote(rest, deps);
|
|
344
|
+
case 'history': return planHistory(rest, deps);
|
|
345
|
+
default: throw new RegisterError(PLAN_USAGE);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function planMove(args, deps) {
|
|
349
|
+
const { positional, opts } = flags(args, ['unassign']);
|
|
350
|
+
onlyOptions(opts, ['lane', 'unassign', 'reason'], PLAN_USAGE);
|
|
351
|
+
if (positional.length !== 2)
|
|
352
|
+
throw new RegisterError(PLAN_USAGE);
|
|
353
|
+
if ((opts.lane === undefined) === (opts.unassign === undefined) || opts.lane === 'true') {
|
|
354
|
+
throw new RegisterError(`Say where it goes, one of: --lane <id> --unassign\n${PLAN_USAGE}`);
|
|
355
|
+
}
|
|
356
|
+
const reason = reasonFrom(opts, true);
|
|
357
|
+
const lead = asLead(deps);
|
|
358
|
+
const key = keyIn(lead.projectDir, positional[0], positional[1], PLAN_USAGE);
|
|
359
|
+
const lane = opts.lane !== undefined ? laneOrThrow(readLanes(lead.projectDir), opts.lane) : null;
|
|
360
|
+
const file = writePlan(lead, { action: 'assign', reason }, [{ key, lane: lane ? lane.id : null, reason }], deps);
|
|
361
|
+
deps.print(lane
|
|
362
|
+
? `✓ Moved ${nameOf(key)} to ${lane.owner} (${lane.id}).`
|
|
363
|
+
: `✓ ${nameOf(key)} goes back to the lane patterns.`);
|
|
364
|
+
deps.print(writtenLine(file));
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
function planScope(args, deps) {
|
|
368
|
+
const { positional, opts } = flags(args, ['out', 'in']);
|
|
369
|
+
onlyOptions(opts, ['package', 'out', 'in', 'reason'], PLAN_USAGE);
|
|
370
|
+
if ((opts.out === undefined) === (opts.in === undefined))
|
|
371
|
+
throw new RegisterError(`Say --out or --in.\n${PLAN_USAGE}`);
|
|
372
|
+
const inScope = opts.in !== undefined;
|
|
373
|
+
const byPackage = opts.package !== undefined;
|
|
374
|
+
if (byPackage ? positional.length || opts.package === 'true' : positional.length !== 2)
|
|
375
|
+
throw new RegisterError(PLAN_USAGE);
|
|
376
|
+
const reason = reasonFrom(opts, true);
|
|
377
|
+
const lead = asLead(deps);
|
|
378
|
+
let keys;
|
|
379
|
+
if (byPackage) {
|
|
380
|
+
keys = readBaseline(lead.projectDir).filter((o) => matches(opts.package, o.package)).map((o) => objectKey(o.type, o.name));
|
|
381
|
+
keys = [...new Set(keys)];
|
|
382
|
+
if (!keys.length)
|
|
383
|
+
throw new RegisterError(`No object of this team project is in package ${shown(opts.package)}.`);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
keys = [keyIn(lead.projectDir, positional[0], positional[1], PLAN_USAGE)];
|
|
387
|
+
}
|
|
388
|
+
const file = writePlan(lead, { action: 'scope', reason }, keys.map((key) => ({ key, inScope, reason })), deps);
|
|
389
|
+
const what = keys.length === 1 ? nameOf(keys[0]) : plural(keys.length, 'object', 'objects');
|
|
390
|
+
deps.print(inScope ? `✓ ${what} back in scope.` : `✓ ${what} taken out of scope. ${keys.length === 1 ? 'It stays' : 'They stay'} on the map, with the reason.`);
|
|
391
|
+
deps.print(writtenLine(file));
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
function planDefer(args, deps) {
|
|
395
|
+
const { positional, opts } = flags(args);
|
|
396
|
+
onlyOptions(opts, ['reason'], PLAN_USAGE);
|
|
397
|
+
if (positional.length !== 2)
|
|
398
|
+
throw new RegisterError(PLAN_USAGE);
|
|
399
|
+
const reason = reasonFrom(opts, true);
|
|
400
|
+
const lead = asLead(deps);
|
|
401
|
+
const key = keyIn(lead.projectDir, positional[0], positional[1], PLAN_USAGE);
|
|
402
|
+
const file = writePlan(lead, { action: 'priority', reason }, [{ key, priority: 'defer', reason }], deps);
|
|
403
|
+
deps.print(`✓ ${nameOf(key)} deferred: it goes to the end of every working list.`);
|
|
404
|
+
deps.print(writtenLine(file));
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
function planSet(args, deps) {
|
|
408
|
+
const { positional, opts } = flags(args);
|
|
409
|
+
onlyOptions(opts, ['priority', 'reason'], PLAN_USAGE);
|
|
410
|
+
if (positional.length !== 2)
|
|
411
|
+
throw new RegisterError(PLAN_USAGE);
|
|
412
|
+
const said = (opts.priority ?? '').trim().toLowerCase();
|
|
413
|
+
if (said !== 'clear' && !/^[1-5]$/.test(said))
|
|
414
|
+
throw new RegisterError('--priority is 1 to 5, or clear. 1 is done first.');
|
|
415
|
+
const reason = reasonFrom(opts, false);
|
|
416
|
+
const lead = asLead(deps);
|
|
417
|
+
const key = keyIn(lead.projectDir, positional[0], positional[1], PLAN_USAGE);
|
|
418
|
+
const priority = said === 'clear' ? null : Number(said);
|
|
419
|
+
const file = writePlan(lead, { action: 'priority', ...(reason ? { reason } : {}) }, [{ key, priority, ...(reason ? { reason } : {}) }], deps);
|
|
420
|
+
deps.print(priority === null
|
|
421
|
+
? `✓ Priority of ${nameOf(key)} cleared: it follows the focus and the rules again.`
|
|
422
|
+
: `✓ Priority of ${nameOf(key)} set to ${priority}.`);
|
|
423
|
+
deps.print(writtenLine(file));
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
function planNote(args, deps) {
|
|
427
|
+
const { positional, opts } = flags(args);
|
|
428
|
+
onlyOptions(opts, ['target', 'note'], PLAN_USAGE);
|
|
429
|
+
if (positional.length !== 2)
|
|
430
|
+
throw new RegisterError(PLAN_USAGE);
|
|
431
|
+
if (opts.target === undefined && opts.note === undefined)
|
|
432
|
+
throw new RegisterError(`Say --target 2026-11-30 or --note "<text>" (clear empties either).\n${PLAN_USAGE}`);
|
|
433
|
+
const row = {};
|
|
434
|
+
if (opts.target !== undefined) {
|
|
435
|
+
const t = opts.target.trim();
|
|
436
|
+
if (t.toLowerCase() === 'clear')
|
|
437
|
+
row.targetDate = '';
|
|
438
|
+
else if (isDay(t))
|
|
439
|
+
row.targetDate = t;
|
|
440
|
+
else
|
|
441
|
+
throw new RegisterError('--target is a day written 2026-11-30, or clear.');
|
|
442
|
+
}
|
|
443
|
+
if (opts.note !== undefined) {
|
|
444
|
+
if (opts.note === 'true')
|
|
445
|
+
throw new RegisterError('--note needs words, or clear.');
|
|
446
|
+
const n = opts.note.trim();
|
|
447
|
+
if (n.length > MAX_REASON)
|
|
448
|
+
throw new RegisterError(`Keep the note under ${MAX_REASON} characters.`);
|
|
449
|
+
row.note = n.toLowerCase() === 'clear' ? '' : n;
|
|
450
|
+
}
|
|
451
|
+
const lead = asLead(deps);
|
|
452
|
+
const key = keyIn(lead.projectDir, positional[0], positional[1], PLAN_USAGE);
|
|
453
|
+
const file = writePlan(lead, { action: 'note' }, [{ key, ...row }], deps);
|
|
454
|
+
deps.print(`✓ Plan note for ${nameOf(key)} recorded.`);
|
|
455
|
+
deps.print(writtenLine(file));
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
const HISTORY_SHOWN = 50;
|
|
459
|
+
function planHistory(args, deps) {
|
|
460
|
+
const { positional, opts } = flags(args);
|
|
461
|
+
onlyOptions(opts, ['object'], PLAN_USAGE);
|
|
462
|
+
const joined = joinedOrThrow();
|
|
463
|
+
const { register } = registerFor(joined.projectDir, joined.outboxDir);
|
|
464
|
+
let changes = register.plan.history;
|
|
465
|
+
if (opts.object !== undefined) {
|
|
466
|
+
if (opts.object === 'true' || positional.length !== 1)
|
|
467
|
+
throw new RegisterError(PLAN_USAGE);
|
|
468
|
+
const key = keyIn(joined.projectDir, opts.object, positional[0], PLAN_USAGE);
|
|
469
|
+
const all = changes;
|
|
470
|
+
changes = changes.filter((c) => c.keys.includes(key));
|
|
471
|
+
// A bulk change names its first 20 objects only, so "nothing" may not be the whole truth.
|
|
472
|
+
if (!changes.length && all.some((c) => c.more)) {
|
|
473
|
+
deps.print('Nothing has been re-planned for this object (bulk changes list their first 20 objects only).');
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else if (positional.length)
|
|
478
|
+
throw new RegisterError(PLAN_USAGE);
|
|
479
|
+
if (!changes.length) {
|
|
480
|
+
deps.print(NOTHING_REPLANNED);
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
for (const c of changes.slice(0, HISTORY_SHOWN)) {
|
|
484
|
+
deps.print(`${dayMonth(c.at)} ${c.at.slice(11, 16)} UTC ${c.by}: ${c.what}${c.why ? ` — because ${shownUpTo(c.why, MAX_REASON)}` : ''}`);
|
|
485
|
+
}
|
|
486
|
+
if (changes.length > HISTORY_SHOWN)
|
|
487
|
+
deps.print(`and ${changes.length - HISTORY_SHOWN} more`);
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
// ── plan import ─────────────────────────────────────────────────────────────
|
|
491
|
+
const IMPORT_USAGE = 'Usage: cspeach team plan import <file.xlsx> [--yes]';
|
|
492
|
+
/** "1 priority, 0 moves, 1 scope change, 2 notes" — the four kinds of change, always all four. */
|
|
493
|
+
const changeWords = (c) => [
|
|
494
|
+
plural(c.priorities, 'priority', 'priorities'), plural(c.moves, 'move', 'moves'),
|
|
495
|
+
plural(c.scope, 'scope change', 'scope changes'), plural(c.notes, 'note', 'notes'),
|
|
496
|
+
].join(', ');
|
|
497
|
+
/**
|
|
498
|
+
* Lead only (spec 2026-10-06 §8). Reads the Plan sheet of a workbook `team
|
|
499
|
+
* report` wrote, prints the diff, asks once, and writes ONE `excel` plan record
|
|
500
|
+
* with the accepted rows. Then the workbook and the status file are made again,
|
|
501
|
+
* so the lead sees the new export stamp. A workbook held open keeps the import:
|
|
502
|
+
* the record is written first, and one line says which file was not updated.
|
|
503
|
+
*/
|
|
504
|
+
async function planImport(args, deps) {
|
|
505
|
+
const { positional, opts } = flags(args, ['yes']);
|
|
506
|
+
onlyOptions(opts, ['yes'], IMPORT_USAGE);
|
|
507
|
+
if (positional.length !== 1)
|
|
508
|
+
throw new RegisterError(IMPORT_USAGE);
|
|
509
|
+
const lead = asLead(deps);
|
|
510
|
+
const fileName = path.basename(positional[0]);
|
|
511
|
+
let buffer;
|
|
512
|
+
try {
|
|
513
|
+
buffer = fs.readFileSync(path.resolve(positional[0]));
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
throw new RegisterError(`"${shown(fileName)}" could not be read. Check the file name.`);
|
|
517
|
+
}
|
|
518
|
+
const sheet = readPlanSheet(buffer);
|
|
519
|
+
// The plan now and the plan as the export saw it, both from the team project
|
|
520
|
+
// folder (the files report reads). Clashes also count this laptop's own records
|
|
521
|
+
// not handed in yet: a newer word of the lead's is still newer.
|
|
522
|
+
const folder = collectRecords(lead.projectDir).records;
|
|
523
|
+
const own = path.resolve(lead.outboxDir) !== path.resolve(lead.projectDir) ? collectRecords(lead.outboxDir).records : [];
|
|
524
|
+
const input = { project: lead.project, baseline: readBaseline(lead.projectDir), lanes: readLanes(lead.projectDir), progress: false };
|
|
525
|
+
const register = buildRegister({ ...input, records: folder });
|
|
526
|
+
const atOrBefore = (r) => {
|
|
527
|
+
const at = r?.at;
|
|
528
|
+
return typeof at === 'string' && at <= sheet.exportStamp;
|
|
529
|
+
};
|
|
530
|
+
const exported = buildRegister({ ...input, records: folder.filter(atOrBefore) });
|
|
531
|
+
const d = diffPlan(sheet, register, [...folder, ...own], { exported });
|
|
532
|
+
const c = d.counts;
|
|
533
|
+
deps.print(`${changeWords(c)}; ${plural(c.unchanged, 'row', 'rows')} unchanged; `
|
|
534
|
+
+ `${plural(c.notInTeamProject, 'object', 'objects')} not in this team project; ${plural(c.clashes, 'clash', 'clashes')}`);
|
|
535
|
+
for (const x of d.clashes) {
|
|
536
|
+
deps.print(` ${nameOf(x.key)} — ${x.field}: sheet says ${shown(x.sheet)}, now ${shown(x.now)} (set by ${shown(x.by)} at ${utcWords(x.at)}); skipped`);
|
|
537
|
+
}
|
|
538
|
+
for (const u of d.unknownLanes)
|
|
539
|
+
deps.print(` ${nameOf(u.key)} — Lane: "${shown(u.lane)}" is not a lane in this team project; skipped`);
|
|
540
|
+
if (c.factEditsIgnored)
|
|
541
|
+
deps.print(`${plural(c.factEditsIgnored, 'edit in fact columns was', 'edits in fact columns were')} ignored.`);
|
|
542
|
+
if (c.unreadableCells) {
|
|
543
|
+
deps.print(c.unreadableCells === 1 ? '1 cell could not be read and was left as it is.'
|
|
544
|
+
: `${c.unreadableCells} cells could not be read and were left as they are.`);
|
|
545
|
+
}
|
|
546
|
+
if (c.blankCells) {
|
|
547
|
+
deps.print(c.blankCells === 1 ? '1 cell was left blank where a value is needed; nothing changed for it.'
|
|
548
|
+
: `${c.blankCells} cells were left blank where a value is needed; nothing changed for them.`);
|
|
549
|
+
}
|
|
550
|
+
if (!d.rows.length) {
|
|
551
|
+
deps.print('Nothing to import. Nothing was written.');
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
if (opts.yes !== 'true') {
|
|
555
|
+
if (!deps.ask) {
|
|
556
|
+
deps.print(`Nothing was written. To import it: cspeach team plan import "${shown(fileName)}" --yes`);
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
const answer = (await deps.ask('Import these changes? [y/n]')).trim().toLowerCase();
|
|
560
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
561
|
+
deps.print('Nothing was written.');
|
|
562
|
+
return 0;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const file = writePlan(lead, {
|
|
566
|
+
action: 'excel',
|
|
567
|
+
source: { file: fileName, sha256: createHash('sha256').update(buffer).digest('hex'), exportStamp: sheet.exportStamp },
|
|
568
|
+
clashesSkipped: c.clashes,
|
|
569
|
+
reason: `edited in the Plan sheet (${fileName})`.slice(0, MAX_REASON),
|
|
570
|
+
}, d.rows, deps);
|
|
571
|
+
deps.print(`✓ Imported from "${shown(fileName)}": ${changeWords(c)}.`);
|
|
572
|
+
deps.print(writtenLine(file));
|
|
573
|
+
// The record is kept whatever happens next; a failure here is one plain line, never a stack.
|
|
574
|
+
try {
|
|
575
|
+
const { saved, held } = writeReports(lead.projectDir, lead.project, path.join(lead.projectDir, 'reports'), deps);
|
|
576
|
+
if (saved.length)
|
|
577
|
+
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in the reports folder of the team project.`);
|
|
578
|
+
for (const name of held)
|
|
579
|
+
deps.print(heldReportLine(name));
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
deps.print(' ! The workbook and the status file could not be made again. The import is kept. Run cspeach team report for the reason.');
|
|
583
|
+
}
|
|
584
|
+
return 0;
|
|
585
|
+
}
|