@cspeach/cli 1.1.29 → 1.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/agent/loop.js +6 -3
  2. package/dist/auth/auth-file.js +2 -2
  3. package/dist/cli.js +4 -0
  4. package/dist/commands/login.js +1 -1
  5. package/dist/commands/logout.js +1 -1
  6. package/dist/commands/team.js +298 -26
  7. package/dist/commands/whoami.js +1 -1
  8. package/dist/config/paths.js +15 -1
  9. package/dist/one-shot.js +1 -0
  10. package/dist/projects/clean-core-confirm.js +36 -0
  11. package/dist/projects/extract-clean-core.js +79 -0
  12. package/dist/projects/save-command.js +38 -9
  13. package/dist/projects/validate.js +2 -1
  14. package/dist/projects/workspace.js +2 -1
  15. package/dist/register/atc-file.js +9 -1
  16. package/dist/register/findings.js +79 -0
  17. package/dist/register/measure.js +60 -1
  18. package/dist/register/offer.js +33 -0
  19. package/dist/register/picture.js +21 -0
  20. package/dist/register/records.js +32 -0
  21. package/dist/register/status-file/register-map.js +213 -0
  22. package/dist/register/status-file/register-model.js +307 -0
  23. package/dist/register/status-file/register-parts.js +509 -0
  24. package/dist/register/status-file/register-status-file.js +171 -0
  25. package/dist/register/status-file/team-css.js +5 -0
  26. package/dist/register/status-file-sync.js +19 -0
  27. package/dist/register/status-html.js +17 -0
  28. package/dist/register/workbooks.js +104 -0
  29. package/dist/register/write-whole.js +22 -0
  30. package/dist/register/xlsx.js +117 -0
  31. package/dist/renderer/tool-labels.js +2 -1
  32. package/dist/repl.js +1 -0
  33. package/dist/skills/bundled-skills.js +45 -45
  34. package/dist/tools/_filesystem-shared.js +16 -9
  35. package/dist/tools/project/team_findings.js +72 -0
  36. package/dist/tools/sap-write.js +8 -0
  37. package/dist/tools/tool-loading.js +1 -1
  38. package/package.json +2 -2
@@ -0,0 +1,307 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-model.js,
3
+ // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
+ // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
+ /**
6
+ * What a manager reads, worked out from the register.
7
+ *
8
+ * Pure: a Register in, plain data out. No DOM and no clock — `now` is handed
9
+ * in — so the page, the status file and the tests all get the same answer.
10
+ *
11
+ * "Proven" means the system said so: verified (had findings, a later
12
+ * measurement has none) or clean (measured, never had any). A developer's
13
+ * "fixed" is a claim and is never counted here.
14
+ */
15
+ import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
16
+ /**
17
+ * The three counts every surface divides by are defined once, in register-core,
18
+ * where the terminal reads them too. They are passed on from here so that
19
+ * register-parts.js — which draws the lane table for both the page and the
20
+ * status file — takes its numbers from the model, like everything else it draws.
21
+ */
22
+ export { activeOf, provenOf, settledOf };
23
+ /** Worst first: the order cells are banded in a block and listed in a legend. */
24
+ export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
25
+ export const STATE_LABEL = {
26
+ regressed: 'Regressed',
27
+ 'did-not-hold': 'Did not hold',
28
+ untouched: 'Untouched',
29
+ decided: 'Decided',
30
+ working: 'Working',
31
+ claimed: 'Claimed fixed',
32
+ verified: 'Verified',
33
+ clean: 'Clean',
34
+ retired: 'Retired',
35
+ };
36
+ /**
37
+ * Objects carrying a state this build has no word for. Data written by a newer
38
+ * CSPeach, or hand-edited: the map already draws them in the tenth colour, and
39
+ * the legend has to name that colour or the reader is left with a mark nobody
40
+ * accounts for.
41
+ */
42
+ export const unknownStateCount = (register) => register.objects.filter((o) => !Object.hasOwn(STATE_LABEL, o.state)).length;
43
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
44
+ const two = (n) => String(n).padStart(2, '0');
45
+ export function formatWhen(iso) {
46
+ const t = iso ? new Date(iso) : null;
47
+ if (!t || Number.isNaN(t.getTime()))
48
+ return 'never';
49
+ return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]} ${t.getUTCFullYear()} ${two(t.getUTCHours())}:${two(t.getUTCMinutes())} UTC`;
50
+ }
51
+ const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
52
+ /**
53
+ * Proven, but one step removed from the system.
54
+ *
55
+ * A file export is somebody's choice of what to put in a file, with which
56
+ * scope, at which moment. It can prove an object — a team with nobody connected
57
+ * to SAP works that way for weeks — but it is not the same thing as a run of
58
+ * the system, and every place that shows such a proof says which it was. One
59
+ * phrase, defined once, because it is printed in five of them.
60
+ */
61
+ export const FROM_FILE = 'measured from a file';
62
+ /**
63
+ * Weaker still: the file never mentioned the object, and the person who
64
+ * imported it told CSPeach to read that silence as clean. Nothing looked at the
65
+ * object at all — so where a colour rests on that, it says this instead of
66
+ * `FROM_FILE`, which would claim a measurement that never happened.
67
+ */
68
+ export const NOT_MENTIONED = 'not mentioned in a file';
69
+ /**
70
+ * The same silence, from a live check run: the run finished and never reported
71
+ * the object. There is no file, so `NOT_MENTIONED` would be the page describing
72
+ * something that did not happen — and a manager who went looking for the export
73
+ * would find none. Which of the two is said follows the BASIS that drove the
74
+ * state, never the shape of the row.
75
+ */
76
+ export const NOT_REPORTED = 'not reported by the check run';
77
+ /** "A", "A and B", "A, B and C" — how a person would say a list out loud. */
78
+ const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
79
+ /** As many names as a person reads before they stop reading names. */
80
+ const LIST_CAP = 12;
81
+ const count = (value) => value.toLocaleString('en-US');
82
+ /**
83
+ * How a cut list ends.
84
+ *
85
+ * Three endings, because a reader sent to the wrong place looks for something
86
+ * that is not drawn. Objects are cells on the map, so on the PAGE the rest of
87
+ * them are one click away. In the status FILE that map is a picture with no
88
+ * script behind it, so the same list points at the object list the file
89
+ * carries further down. And a list of something that is on neither — a pair of
90
+ * lanes, an object out of scope — says only how many it left out.
91
+ */
92
+ const seeMap = (more) => `and ${count(more)} more — see the map`;
93
+ const seeObjectList = (more) => `and ${count(more)} more — see the object list below`;
94
+ const justHowMany = (more) => `and ${count(more)} more`;
95
+ /**
96
+ * A list, and an honest count of what is not on it.
97
+ *
98
+ * A new ATC variant can regress three thousand objects in one run. Joining
99
+ * every name would put a 60 KB paragraph on the page on the day a manager most
100
+ * needs to read it — and it would still not be a list anybody could use. Twelve
101
+ * names, then the number, then where the rest of them are.
102
+ *
103
+ * `tail` writes that last part, and there is no default: a new cut list has to
104
+ * say where its own reader should look. `more` overrides what is counted, for
105
+ * a list whose items and whose problem are not the same thing.
106
+ *
107
+ * Only the DETAIL is cut. The `keys` beside it stay complete: they are data a
108
+ * map or an export reads, not something a person is looking at.
109
+ */
110
+ function upTo(parts, { tail, cap = LIST_CAP, sep = ', ', more } = {}) {
111
+ if (parts.length <= cap)
112
+ return parts.join(sep);
113
+ return [...parts.slice(0, cap), tail(more ?? parts.length - cap)].join(sep);
114
+ }
115
+ /**
116
+ * One object can be wrong in more than one way — regressed AND disputed is a
117
+ * normal pairing. Counting the ways would tell the manager four things need
118
+ * attention when three objects do, so this counts objects.
119
+ */
120
+ function attentionKeys(register) {
121
+ const keys = new Set();
122
+ for (const o of register.objects) {
123
+ if (o.state === 'regressed' || o.state === 'did-not-hold' || o.decision?.conflict)
124
+ keys.add(o.key);
125
+ }
126
+ return keys;
127
+ }
128
+ export function summarize(register, { unreadable = 0 } = {}) {
129
+ const { totals } = register;
130
+ const proven = provenOf(totals.byState);
131
+ // Floor, never round: 7,997 of 8,000 is not 100% proven, and a manager who
132
+ // reads 100% stops looking. Only every last object earns the round number.
133
+ const provenPct = totals.active ? Math.floor((proven / totals.active) * 100) : 0;
134
+ const needAttention = attentionKeys(register).size;
135
+ const lastMeasured = register.lastMeasuredAt ? `last measured ${formatWhen(register.lastMeasuredAt)}` : 'never measured';
136
+ // A project where every object was retired has nothing left to prove, and
137
+ // "0% proven" would read as a team that proved nothing. An empty project
138
+ // keeps the percentage: its map already says there is nothing in scope yet.
139
+ const provenPhrase = totals.objects > 0 && totals.active === 0 ? 'nothing left to prove' : `${provenPct}% proven`;
140
+ // A proof read out of a file is still a proof, and it is not the same thing
141
+ // as a run of the system. The one line a manager reads says how much of what
142
+ // it just called proven came that way, right beside the word.
143
+ const fromFile = (totals.fromFile?.verified ?? 0) + (totals.fromFile?.clean ?? 0);
144
+ const fromFilePhrase = fromFile > 0 ? ` · ${count(fromFile)} of them ${FROM_FILE}` : '';
145
+ // A folder from an older build carries no `undecided`; work it out the same
146
+ // way register-core does, so the line never prints a blank.
147
+ const decided = totals.decided ?? 0;
148
+ const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided);
149
+ return {
150
+ objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
151
+ lastMeasured, unreadable, fromFile,
152
+ line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${count(decided)} decided, ${count(undecided)} undecided · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
153
+ };
154
+ }
155
+ const DAY_MS = 86_400_000;
156
+ /**
157
+ * `surface` is which of the two things these words will be read on. It changes
158
+ * one mark: where a cut list of OBJECTS sends the reader. On the page that is
159
+ * the map, where a cell opens. In the status file the map is a picture, so it
160
+ * is the object list the file carries instead.
161
+ */
162
+ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface = 'page' } = {}) {
163
+ const items = [];
164
+ const inState = (state) => register.objects.filter((o) => o.state === state);
165
+ // The four lists below name objects, and an object is a cell. Everything
166
+ // else here names something the map does not draw.
167
+ const onMap = surface === 'file' ? seeObjectList : seeMap;
168
+ const regressed = inState('regressed');
169
+ if (regressed.length) {
170
+ items.push({ kind: 'regressed', title: `${plural(regressed.length, 'object has', 'objects have')} more findings than before`,
171
+ detail: upTo(regressed.map((o) => o.name), { tail: onMap }), keys: regressed.map((o) => o.key) });
172
+ }
173
+ const didNotHold = inState('did-not-hold');
174
+ if (didNotHold.length) {
175
+ // Objects, not fixes. One object can carry three failed fixes, and "3 fixes
176
+ // did not hold" sends a lead looking for three things to put right.
177
+ //
178
+ // Every writer who claimed a fix here, not just the first. The register
179
+ // carries no per-rule open counts, so the model cannot tell whose fix
180
+ // failed — naming one person would put the blame on a guess.
181
+ items.push({ kind: 'did-not-hold', title: `${plural(didNotHold.length, 'object has', 'objects have')} a fix that did not hold`,
182
+ detail: upTo(didNotHold.map((o) => {
183
+ const by = [...new Set(o.fixes.filter((f) => f.status === 'fixed').map((f) => f.by))];
184
+ return `${o.name} (fixed by ${by.length ? andList(by) : 'unknown'})`;
185
+ }), { tail: onMap }),
186
+ keys: didNotHold.map((o) => o.key) });
187
+ }
188
+ const conflicts = register.objects.filter((o) => o.decision?.conflict);
189
+ if (conflicts.length) {
190
+ items.push({ kind: 'conflict', title: `${plural(conflicts.length, 'decision needs', 'decisions need')} the lead to rule`,
191
+ detail: upTo(conflicts.map((o) => `${o.name} — ${Object.entries(o.decision.conflict).map(([w, c]) => `${w}: ${c}`).join(', ')}`), { tail: onMap, sep: '; ' }),
192
+ keys: conflicts.map((o) => o.key) });
193
+ }
194
+ // An accepted exception is work nobody has to do — on a reason somebody gave.
195
+ // One with no reason (an older record, a hand-written file) is a decision the
196
+ // lead cannot defend yet.
197
+ const noReason = register.objects.filter((o) => o.decision?.classification === 'keep' && o.decision?.exception === true && !o.decision?.reason);
198
+ if (noReason.length) {
199
+ items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
200
+ detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
201
+ }
202
+ const unowned = register.objects.filter((o) => o.lane === null);
203
+ if (unowned.length) {
204
+ const packages = [...new Set(unowned.map((o) => o.package))].sort();
205
+ items.push({ kind: 'unowned', title: `${plural(unowned.length, 'object has', 'objects have')} no owner`,
206
+ detail: `Packages: ${upTo(packages, { tail: onMap })}`, keys: unowned.map((o) => o.key) });
207
+ }
208
+ const overlap = register.objects.filter((o) => o.laneOverlap.length > 0);
209
+ if (overlap.length) {
210
+ // Three lanes can claim one object, and "two lanes" would then be wrong.
211
+ // A pair of lanes is not on the map either — no cell says "fi + sd" — so a
212
+ // cut list of pairs says only how many it left out.
213
+ items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
214
+ detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
215
+ }
216
+ // A lane that has finished its work stops writing records, and that is the
217
+ // point — it is not quiet, it is done. Only a lane with work still open can
218
+ // be waiting for someone.
219
+ const nowMs = Date.parse(now);
220
+ for (const lane of register.lanes) {
221
+ if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
222
+ continue;
223
+ if (lane.objects - settledOf(lane.byState) <= 0)
224
+ continue;
225
+ const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
226
+ if (days >= quietDays) {
227
+ items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
228
+ detail: `Last record ${formatWhen(lane.lastRecordAt)}`, keys: [] });
229
+ }
230
+ }
231
+ if (register.unknownKeys.length) {
232
+ // These objects are on no map: being out of scope is the whole of what is
233
+ // wrong with them. So the tail says what the list leaves out rather than
234
+ // where to look, and it counts ROWS — what somebody actually recorded.
235
+ // Neither cap is the size of the problem: register-core stops collecting
236
+ // keys at 50, and twelve of those are printed.
237
+ const shown = Math.min(register.unknownKeys.length, LIST_CAP);
238
+ items.push({ kind: 'unknown-objects', title: `${plural(register.skipped.rowsNotInBaseline, 'row names', 'rows name')} an object that is not in scope`,
239
+ detail: upTo(register.unknownKeys, {
240
+ more: register.skipped.rowsNotInBaseline - shown,
241
+ tail: (more) => (more === 1
242
+ ? 'and 1 more row names an object that is not in scope'
243
+ : `${justHowMany(more)} rows name objects that are not in scope`),
244
+ }),
245
+ keys: [] });
246
+ }
247
+ if (unreadable) {
248
+ items.push({ kind: 'unreadable', title: `${plural(unreadable, 'file', 'files')} could not be read yet`,
249
+ detail: 'Usually a file that is still syncing. Reload in a minute.', keys: [] });
250
+ }
251
+ return items;
252
+ }
253
+ export function packageBlocks(register) {
254
+ const rank = Object.fromEntries(STATE_ORDER.map((s, i) => [s, i]));
255
+ const byPackage = new Map();
256
+ for (const o of register.objects) {
257
+ if (!byPackage.has(o.package))
258
+ byPackage.set(o.package, []);
259
+ byPackage.get(o.package).push(o);
260
+ }
261
+ return [...byPackage.entries()]
262
+ .map(([pkg, objects]) => {
263
+ const byState = Object.fromEntries(STATE_ORDER.map((s) => [s, 0]));
264
+ for (const o of objects)
265
+ byState[o.state] += 1;
266
+ return {
267
+ package: pkg,
268
+ // 'en' on every comparison: the order of a map must not depend on the
269
+ // laptop's locale, or two people looking at one folder see the cells in
270
+ // two different places.
271
+ objects: objects.slice().sort((a, b) => rank[a.state] - rank[b.state] || a.name.localeCompare(b.name, 'en')),
272
+ byState,
273
+ };
274
+ })
275
+ .sort((a, b) => b.objects.length - a.objects.length || a.package.localeCompare(b.package, 'en'));
276
+ }
277
+ /**
278
+ * Active objects nobody has measured. Their colour rests on what people said,
279
+ * and the page says so out loud: "not measured" is a state of knowledge, not a
280
+ * blank. A retired object is not counted — there is nothing left to prove.
281
+ */
282
+ export const notMeasuredCount = (register) => register.objects.filter((o) => o.state !== 'retired' && (o.lastMeasuredAt ?? null) === null).length;
283
+ /**
284
+ * One object's measurements, newest first: when, by whom, how many open, and
285
+ * whether the numbers came from a system check or from a file somebody read in.
286
+ * `more` is how many older ones the cap left out.
287
+ *
288
+ * BOTH bases are here, the counted and the uncounted. A file export of an
289
+ * object the system has already measured moves nothing — live evidence outranks
290
+ * file evidence, per object — but hiding it would leave a manager wondering
291
+ * where the run they were told about went. It is shown, and it is labelled.
292
+ */
293
+ export function measurementHistory(o, runs, { cap = 8 } = {}) {
294
+ const all = (o.measurements ?? []).map((m) => ({ m, info: runs?.[m.run] })).filter((x) => x.info).reverse();
295
+ return {
296
+ rows: all.slice(0, cap).map(({ m, info }) => ({
297
+ at: info.at, by: info.by, open: m.open, fromFile: info.basis === 'file',
298
+ variant: info.atcVariant ?? '', scope: info.scope ?? '',
299
+ // Straight off the register, never worked out again here: whether this
300
+ // run moved the object's colour is register-core's answer, and a second
301
+ // copy of that rule in the view is a second place for it to go wrong.
302
+ counted: m.counted === true,
303
+ ...(m.evidence ? { evidence: m.evidence } : {}),
304
+ })),
305
+ more: Math.max(0, all.length - cap),
306
+ };
307
+ }