@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
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
* measurement has none) or clean (measured, never had any). A developer's
|
|
13
13
|
* "fixed" is a claim and is never counted here.
|
|
14
14
|
*/
|
|
15
|
-
import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
|
|
15
|
+
import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspeach/register-core';
|
|
16
16
|
/**
|
|
17
17
|
* The three counts every surface divides by are defined once, in register-core,
|
|
18
18
|
* where the terminal reads them too. They are passed on from here so that
|
|
19
19
|
* register-parts.js — which draws the lane table for both the page and the
|
|
20
20
|
* status file — takes its numbers from the model, like everything else it draws.
|
|
21
21
|
*/
|
|
22
|
-
export { activeOf, provenOf, settledOf };
|
|
22
|
+
export { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf };
|
|
23
23
|
/** Worst first: the order cells are banded in a block and listed in a legend. */
|
|
24
24
|
export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
|
|
25
25
|
export const STATE_LABEL = {
|
|
@@ -79,6 +79,8 @@ const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.sl
|
|
|
79
79
|
/** As many names as a person reads before they stop reading names. */
|
|
80
80
|
const LIST_CAP = 12;
|
|
81
81
|
const count = (value) => value.toLocaleString('en-US');
|
|
82
|
+
/** A clean (or verified) object nobody decided: counted on its own, never as undecided. */
|
|
83
|
+
export const NOTHING_TO_DECIDE = 'clean, nothing to decide';
|
|
82
84
|
/**
|
|
83
85
|
* How a cut list ends.
|
|
84
86
|
*
|
|
@@ -145,11 +147,18 @@ export function summarize(register, { unreadable = 0 } = {}) {
|
|
|
145
147
|
// A folder from an older build carries no `undecided`; work it out the same
|
|
146
148
|
// way register-core does, so the line never prints a blank.
|
|
147
149
|
const decided = totals.decided ?? 0;
|
|
148
|
-
|
|
150
|
+
// Recording fix 4 (2026-10-07): a clean object nobody decided has nothing
|
|
151
|
+
// to decide. register-core counts it on its own; it is never "undecided".
|
|
152
|
+
const nothingToDecide = Number.isFinite(totals.cleanNothingToDecide) ? totals.cleanNothingToDecide : 0;
|
|
153
|
+
const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided - nothingToDecide);
|
|
154
|
+
const decidedPhrase = nothingToDecide > 0
|
|
155
|
+
? `${count(decided)} decided${undecided > 0 ? `, ${count(undecided)} undecided` : ''} · ${count(nothingToDecide)} ${NOTHING_TO_DECIDE}`
|
|
156
|
+
: `${count(decided)} decided, ${count(undecided)} undecided`;
|
|
149
157
|
return {
|
|
150
158
|
objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
|
|
151
159
|
lastMeasured, unreadable, fromFile,
|
|
152
|
-
|
|
160
|
+
...(nothingToDecide > 0 ? { nothingToDecide } : {}),
|
|
161
|
+
line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
|
|
153
162
|
};
|
|
154
163
|
}
|
|
155
164
|
const DAY_MS = 86_400_000;
|
|
@@ -199,6 +208,21 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
199
208
|
items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
|
|
200
209
|
detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
|
|
201
210
|
}
|
|
211
|
+
// A developer's "retire" is a candidate until the lead confirms it with a
|
|
212
|
+
// reason (spec 2026-10-06 D2). Until then the object stays active, and the
|
|
213
|
+
// lead is the one person who can move it.
|
|
214
|
+
const candidates = register.objects.filter((o) => o.retireCandidate === true);
|
|
215
|
+
if (candidates.length) {
|
|
216
|
+
items.push({ kind: 'retire-candidate', title: `${plural(candidates.length, 'retire candidate waits', 'retire candidates wait')} for your confirmation`,
|
|
217
|
+
detail: upTo(candidates.map((o) => o.name), { tail: onMap }), keys: candidates.map((o) => o.key) });
|
|
218
|
+
}
|
|
219
|
+
// Out of scope is a decision about work nobody will do, so it needs a reason
|
|
220
|
+
// the lead can defend. A record from an older build may carry none.
|
|
221
|
+
const outNoReason = register.objects.filter((o) => o.inScope === false && !o.scopeReason);
|
|
222
|
+
if (outNoReason.length) {
|
|
223
|
+
items.push({ kind: 'out-of-scope-no-reason', title: `${plural(outNoReason.length, 'object was', 'objects were')} taken out of scope with no reason`,
|
|
224
|
+
detail: upTo(outNoReason.map((o) => o.name), { tail: onMap }), keys: outNoReason.map((o) => o.key) });
|
|
225
|
+
}
|
|
202
226
|
const unowned = register.objects.filter((o) => o.lane === null);
|
|
203
227
|
if (unowned.length) {
|
|
204
228
|
const packages = [...new Set(unowned.map((o) => o.package))].sort();
|
|
@@ -213,6 +237,16 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
213
237
|
items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
|
|
214
238
|
detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
|
|
215
239
|
}
|
|
240
|
+
// Rows of the last Excel import that clashed with a change recorded after the
|
|
241
|
+
// workbook was made were skipped, not applied. Only the newest import counts:
|
|
242
|
+
// an older one's skips were either entered again or are no longer wanted.
|
|
243
|
+
const lastImport = (register.plan?.history ?? []).find((c) => c.action === 'excel');
|
|
244
|
+
const clashes = lastImport?.clashesSkipped ?? 0;
|
|
245
|
+
if (Number.isInteger(clashes) && clashes > 0) {
|
|
246
|
+
items.push({ kind: 'excel-clashes', title: `${plural(clashes, 'Excel plan row was', 'Excel plan rows were')} skipped at the last import`,
|
|
247
|
+
detail: 'They clashed with changes recorded after the workbook was made. Make a fresh workbook with cspeach team report and enter them again.',
|
|
248
|
+
keys: [] });
|
|
249
|
+
}
|
|
216
250
|
// A lane that has finished its work stops writing records, and that is the
|
|
217
251
|
// point — it is not quiet, it is done. Only a lane with work still open can
|
|
218
252
|
// be waiting for someone.
|
|
@@ -220,8 +254,8 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
220
254
|
for (const lane of register.lanes) {
|
|
221
255
|
if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
|
|
222
256
|
continue;
|
|
223
|
-
if (lane
|
|
224
|
-
continue;
|
|
257
|
+
if (laneActiveOf(lane) - laneProvenOf(lane) <= 0)
|
|
258
|
+
continue; // the lane's own Done % denominator
|
|
225
259
|
const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
|
|
226
260
|
if (days >= quietDays) {
|
|
227
261
|
items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* style attribute.
|
|
22
22
|
*/
|
|
23
23
|
import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
|
|
24
|
-
import {
|
|
24
|
+
import { formatWhen, laneActiveOf, laneProvenOf, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, NOTHING_TO_DECIDE, } from './register-model.js';
|
|
25
25
|
import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
|
|
26
26
|
// The map owns it, because the map's cells need it too; it is offered here as
|
|
27
27
|
// well, where every other shared mark lives.
|
|
@@ -119,6 +119,8 @@ export function lanesTableHtml(lanes, objects) {
|
|
|
119
119
|
for (const o of objects ?? []) {
|
|
120
120
|
if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
|
|
121
121
|
continue;
|
|
122
|
+
if (o.inScope === false)
|
|
123
|
+
continue; // the same objects the lane's Proven counts
|
|
122
124
|
// Every lane that claims the object, exactly as the lane's own counts are
|
|
123
125
|
// taken: an object two lanes both claim is proven for both of them, so
|
|
124
126
|
// "(n from a file)" can never be larger than the Proven it sits beside.
|
|
@@ -129,10 +131,10 @@ export function lanesTableHtml(lanes, objects) {
|
|
|
129
131
|
.map((h) => `<th scope="col">${h}</th>`).join('');
|
|
130
132
|
const provenCell = (l) => {
|
|
131
133
|
const said = fromFile.get(l.id) ?? 0;
|
|
132
|
-
return `${n(
|
|
134
|
+
return `${n(laneProvenOf(l))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
|
|
133
135
|
};
|
|
134
136
|
const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
|
|
135
|
-
+ `<td>${n(
|
|
137
|
+
+ `<td>${n(laneActiveOf(l))}</td><td>${provenCell(l)}</td>`
|
|
136
138
|
+ `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
|
|
137
139
|
+ `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
|
|
138
140
|
return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
|
|
@@ -165,7 +167,7 @@ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
|
|
|
165
167
|
return '<p>No objects in scope yet.</p>';
|
|
166
168
|
// The status file never passes `colourBy`: it prints the state map only.
|
|
167
169
|
const legend = colourBy === 'level'
|
|
168
|
-
? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0 })
|
|
170
|
+
? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0, nothingToDecide: register.totals.byLevel?.nothingToDecide ?? 0 })
|
|
169
171
|
: legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
|
|
170
172
|
return `${legend}
|
|
171
173
|
<div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
|
|
@@ -401,9 +403,10 @@ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', re
|
|
|
401
403
|
* it (the caller escapes it), never as "not decided": somebody DID decide, and
|
|
402
404
|
* calling it undecided would put the object in a bucket nobody chose.
|
|
403
405
|
*/
|
|
404
|
-
export function decisionWord(decision, projectType) {
|
|
406
|
+
export function decisionWord(decision, projectType, state) {
|
|
407
|
+
// Recording fix 4: a clean (or verified) object nobody decided has nothing to decide.
|
|
405
408
|
if (!decision)
|
|
406
|
-
return 'not decided';
|
|
409
|
+
return state === 'clean' || state === 'verified' ? 'clean — nothing to decide' : 'not decided';
|
|
407
410
|
if (projectType === 'clean-core') {
|
|
408
411
|
const d = dispositionOf(decision);
|
|
409
412
|
if (d)
|
|
@@ -428,6 +431,9 @@ export function whereItStandsHtml(register) {
|
|
|
428
431
|
+ tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
|
|
429
432
|
+ tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
|
|
430
433
|
+ tile('Retired', s.retired ?? 0, 'nothing left to do')
|
|
434
|
+
// Only when there are some: a developer said retire and the lead has not
|
|
435
|
+
// confirmed it yet, so these objects still count as active work.
|
|
436
|
+
+ ((t.retireCandidates ?? 0) > 0 ? tile('Retire candidates', t.retireCandidates, 'wait for the lead to confirm', 'retire-candidates') : '')
|
|
431
437
|
+ '</ul>'
|
|
432
438
|
+ (!(t.decided > 0) ? '<p class="empty-note" data-role="nothing-decided">Nothing decided yet. Developers record decisions with /abap-clean-core or cspeach team decide.</p>' : '')
|
|
433
439
|
+ levelBarsHtml(register);
|
|
@@ -437,7 +443,7 @@ export function whereItStandsHtml(register) {
|
|
|
437
443
|
* never stands alone. Segments are paths, not rects: a `rect` on these surfaces
|
|
438
444
|
* is a map cell, and the budget and the keyboard both count cells by that tag.
|
|
439
445
|
*/
|
|
440
|
-
function levelBar(title, counts) {
|
|
446
|
+
function levelBar(title, counts, nothingToDecide = 0) {
|
|
441
447
|
const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
|
|
442
448
|
let x = 0;
|
|
443
449
|
const rects = total
|
|
@@ -451,7 +457,9 @@ function levelBar(title, counts) {
|
|
|
451
457
|
return r;
|
|
452
458
|
}).join('')
|
|
453
459
|
: '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
|
|
454
|
-
|
|
460
|
+
// A clean object nobody decided is not "not decided": it is said on its own, after the buckets.
|
|
461
|
+
const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ')
|
|
462
|
+
+ (nothingToDecide > 0 ? ` · ${NOTHING_TO_DECIDE} ${n(nothingToDecide)}` : '');
|
|
455
463
|
return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
|
|
456
464
|
+ `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
|
|
457
465
|
+ `<p class="levelwords">${esc(words)}</p></figure>`;
|
|
@@ -460,7 +468,7 @@ function levelBar(title, counts) {
|
|
|
460
468
|
export function levelBarsHtml(register) {
|
|
461
469
|
const b = register.totals?.byLevel;
|
|
462
470
|
const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
|
|
463
|
-
return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided)}${levelBar('Levels now', b?.now)}`
|
|
471
|
+
return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided, b?.nothingToDecide ?? 0)}${levelBar('Levels now', b?.now, b?.nothingToDecide ?? 0)}`
|
|
464
472
|
+ `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
|
|
465
473
|
}
|
|
466
474
|
/**
|
|
@@ -500,10 +508,48 @@ export function developersTableHtml(register) {
|
|
|
500
508
|
const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
|
|
501
509
|
return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
|
|
502
510
|
};
|
|
503
|
-
|
|
511
|
+
// Spec 2026-10-06 §9 item 7: how far each lane is, what it does next (the
|
|
512
|
+
// first three by effective priority) and how much recorded work sits outside
|
|
513
|
+
// the focus — a plain count, recorded and never blocked.
|
|
514
|
+
const nameOf = new Map(register.objects.map((o) => [o.key, o.name]));
|
|
515
|
+
const plan = (lane) => {
|
|
516
|
+
// A key with no object behind it names nothing a reader can find, so it is
|
|
517
|
+
// left out rather than printed as the raw key.
|
|
518
|
+
const keys = (lane.next ?? []).slice(0, 3);
|
|
519
|
+
const next = keys.map((k) => nameOf.get(k)).filter((name) => name);
|
|
520
|
+
return `<td>${n(lane.donePct ?? 0)}%</td>`
|
|
521
|
+
+ `<td class="next">${keys.length ? next.map((name) => `<span class="ident">${cell(name)}</span>`).join(', ') : '<span class="quiet-note">nothing open</span>'}</td>`
|
|
522
|
+
+ `<td>${n(lane.outsideFocus ?? 0)}</td>`;
|
|
523
|
+
};
|
|
524
|
+
const head = [...t.header, 'Done %', 'Next', 'Outside the focus'].map((h) => `<th scope="col">${esc(h)}</th>`).join('');
|
|
504
525
|
const body = t.rows.map((r, i) => `<tr><td>${cell(r[0])}</td>${r.slice(1, -1).map((v, j) => num(v, j + 1, register.lanes[i])).join('')}`
|
|
505
|
-
+ `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td
|
|
526
|
+
+ `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td>${plan(register.lanes[i])}</tr>`).join('');
|
|
506
527
|
const note = overlapNote(register);
|
|
507
528
|
return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
|
|
508
529
|
+ (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
|
|
509
530
|
}
|
|
531
|
+
/* ── The focus, under the headline (spec 2026-10-06 §9 item 1) ──────────── */
|
|
532
|
+
const MONTH_WORDS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
533
|
+
/** A day as a person says it: 24 Sep 2026. UTC, like every time on this page. */
|
|
534
|
+
export const dayWords = (iso) => {
|
|
535
|
+
const t = iso ? new Date(iso) : null;
|
|
536
|
+
return !t || Number.isNaN(t.getTime()) ? '' : `${t.getUTCDate()} ${MONTH_WORDS[t.getUTCMonth()]} ${t.getUTCFullYear()}`;
|
|
537
|
+
};
|
|
538
|
+
export const NO_FOCUS = 'No focus set. Working lists follow the suggested order.';
|
|
539
|
+
/**
|
|
540
|
+
* The second line of the headline: the focus on top of the stack — what, who
|
|
541
|
+
* set it and when, why, and how much of it is done — or the sentence that says
|
|
542
|
+
* there is none. Escaped markup.
|
|
543
|
+
*/
|
|
544
|
+
export function focusLineHtml(register) {
|
|
545
|
+
const f = register?.plan?.focuses?.[0];
|
|
546
|
+
if (!f)
|
|
547
|
+
return esc(NO_FOCUS);
|
|
548
|
+
const day = dayWords(f.at);
|
|
549
|
+
return [
|
|
550
|
+
`Now: ${cell(f.label)}`,
|
|
551
|
+
`set by ${cell(f.by)}${day ? ` ${esc(day)}` : ''}`,
|
|
552
|
+
...(f.reason ? [`because ${cell(f.reason)}`] : []),
|
|
553
|
+
`${n(f.donePct ?? 0)}% done`,
|
|
554
|
+
].join(' · ');
|
|
555
|
+
}
|
|
@@ -34,12 +34,9 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { esc } from './register-map.js';
|
|
36
36
|
import { formatWhen } from './register-model.js';
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// worse than no picture at all, and these two had already begun to.
|
|
40
|
-
import { decisionWord, developersTableHtml, dispositionsHtml, ignoredRunsHtml, mapBodyHtml, measuredWord, n, needItemHtml, notMeasuredLine, ownerLabel, provenanceLine, readNote, runsTableHtml, stateWord, whereItStandsHtml, } from './register-parts.js';
|
|
37
|
+
import { n, provenanceLine, readNote } from './register-parts.js';
|
|
38
|
+
import { FIX_RULE, LEVEL_KEY, NO_TARGET_LINE, READING_LINE, awaitingWord, dayWords, lastCheckWord, reportView, trendLine, } from './report-model.js';
|
|
41
39
|
const FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
|
|
42
|
-
const objects = (count) => `${n(count)} object${count === 1 ? '' : 's'}`;
|
|
43
40
|
const files = (count) => `${n(count)} file${count === 1 ? '' : 's'}`;
|
|
44
41
|
/**
|
|
45
42
|
* What the file is called once it lands in somebody's downloads. A manager with
|
|
@@ -54,118 +51,137 @@ export function statusFileName(project, asOf) {
|
|
|
54
51
|
const slug = String(project?.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'team-project';
|
|
55
52
|
return `${slug}-status-${String(asOf).slice(0, 10)}.html`;
|
|
56
53
|
}
|
|
57
|
-
/**
|
|
58
|
-
* One package, folded shut. The count is in the fold's own line so the size of
|
|
59
|
-
* a package is readable without opening it — a real project puts thousands of
|
|
60
|
-
* rows behind one of these.
|
|
61
|
-
*/
|
|
62
|
-
function packageFold(block, lanes, runs, projectType) {
|
|
63
|
-
// An empty cell reads as a value somebody lost rather than as one nobody has
|
|
64
|
-
// set yet, so both absences say themselves in words — in the same lower case
|
|
65
|
-
// as the values they stand in for ("fix", "retire", a person's name).
|
|
66
|
-
//
|
|
67
|
-
// The last column is the whole of step 2 in one cell: what the system found,
|
|
68
|
-
// or that nobody has looked. A reader working down this list must not have to
|
|
69
|
-
// take a colour on trust, and "not measured" is an answer, not a blank.
|
|
70
|
-
const row = (o) => `<tr><td class="ident">${esc(o.name)}</td><td>${esc(o.type)}</td><td>${esc(stateWord(o))}</td>`
|
|
71
|
-
+ `<td>${esc(decisionWord(o.decision, projectType))}</td>`
|
|
72
|
-
+ `<td>${esc(ownerLabel(lanes.find((l) => l.id === o.lane)))}</td>`
|
|
73
|
-
+ `<td>${esc(measuredWord(o, runs))}</td></tr>`;
|
|
74
|
-
// Every fold holds a table with the same six headings, so each one says
|
|
75
|
-
// which package it belongs to — otherwise a screen reader announces the
|
|
76
|
-
// twelfth "Object, Type, State, Decision, Owner, Measured" with nothing to
|
|
77
|
-
// tell it from the eleventh.
|
|
78
|
-
return `<details data-role="package"><summary>${esc(block.package)} — ${objects(block.objects.length)}</summary>`
|
|
79
|
-
+ `<div class="scroller"><table aria-label="${esc(block.package)}"><thead><tr><th scope="col">Object</th><th scope="col">Type</th>`
|
|
80
|
-
+ '<th scope="col">State</th><th scope="col">Decision</th><th scope="col">Owner</th><th scope="col">Measured</th></tr></thead>'
|
|
81
|
-
+ `<tbody>${block.objects.map(row).join('')}</tbody></table></div></details>`;
|
|
82
|
-
}
|
|
83
54
|
/** Nothing in a saved file can reload, so the page's advice is not repeated here. */
|
|
84
|
-
const
|
|
55
|
+
const LANE_CAP = 8;
|
|
56
|
+
const KEPT_CAP = 10;
|
|
57
|
+
const numbersHtml = (view) => `<ul class="rp-numbers" data-role="numbers">${view.numbers.map((x) => `<li data-num="${esc(x.id)}">`
|
|
58
|
+
+ `<span class="rp-n">${n(x.n)}</span><span class="rp-label">${esc(x.label)}</span><span class="rp-sub">${esc(x.sub)}</span></li>`).join('')}</ul>`;
|
|
59
|
+
function levelsHtml(view) {
|
|
60
|
+
const rows = view.levels.map((l) => `<li><span class="rp-lv">${esc(l.word)}</span><span class="rp-lv-n">${n(l.n)}</span>`
|
|
61
|
+
+ `<span class="rp-lv-key">${esc(l.key)}</span></li>`).join('');
|
|
62
|
+
const t = view.target;
|
|
63
|
+
const target = t.set
|
|
64
|
+
? `<p class="rp-target" data-role="target">${esc(t.line)}${t.keptLine ? ` ${esc(t.keptLine)}` : ''}</p>`
|
|
65
|
+
+ `${(t.provenLines ?? []).map((l) => `<p class="rp-target" data-role="proven-for-target">${esc(l)}</p>`).join('')}<p class="rp-note">${esc(READING_LINE)}</p>`
|
|
66
|
+
: `<p class="rp-target" data-role="target">${esc(NO_TARGET_LINE)}</p>`;
|
|
67
|
+
return `<section class="rp-levels" data-role="levels"><h2>Objects by clean-core level, now</h2><ul class="rp-lvs">${rows}</ul>${target}</section>`;
|
|
68
|
+
}
|
|
69
|
+
function decisionsHtml(view) {
|
|
70
|
+
const d = view.decisions;
|
|
71
|
+
const list = d.total
|
|
72
|
+
? `<ol>${d.shown.map((x) => `<li><p>${esc(x.text)}</p><p class="rp-who">Decides: ${esc(x.decides)}.${x.since ? ` Since ${esc(dayWords(x.since))}.` : ''}</p></li>`).join('')}</ol>`
|
|
73
|
+
+ (d.more ? `<p class="rp-tail">And ${n(d.more)} more in the workbook.</p>` : '')
|
|
74
|
+
: '<p>Nothing is waiting for a decision.</p>';
|
|
75
|
+
return `<section class="rp-decide" data-role="decisions"><h2>Decisions needed: ${d.total ? n(d.total) : 'none'}</h2>${list}</section>`;
|
|
76
|
+
}
|
|
77
|
+
function changedHtml(view) {
|
|
78
|
+
const c = view.changed;
|
|
79
|
+
if (c.first)
|
|
80
|
+
return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report</h2><p>${esc(c.line)}</p></section>`;
|
|
81
|
+
return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report, ${esc(dayWords(c.since))}</h2>`
|
|
82
|
+
+ (c.line ? `<p>${esc(c.line)}</p>` : '')
|
|
83
|
+
+ (c.lines.length ? `<ul>${c.lines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
|
|
84
|
+
+ (c.more ? '<p class="rp-tail">The ones left out are in History.</p>' : '')
|
|
85
|
+
+ (c.moreObjects ? `<p class="rp-tail">The rest of the objects a check proved or failed are on the Objects sheet.</p>` : '')
|
|
86
|
+
+ '</section>';
|
|
87
|
+
}
|
|
88
|
+
const LANE_HEAD = ['Lane packages', 'Objects', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'];
|
|
89
|
+
function lanesHtml(view) {
|
|
90
|
+
const rows = view.lanes.slice(0, LANE_CAP).map((r) => `<tr><th scope="row">${esc(r.developer)}</th>`
|
|
91
|
+
+ `<td data-l="Lane packages" class="rp-pk">${esc(r.packagesWord)}</td><td data-l="Objects">${n(r.objects)}</td><td data-l="Decided">${n(r.decided)}</td>`
|
|
92
|
+
+ `<td data-l="Fixes proven">${n(r.fixesProven)}</td><td data-l="Awaiting check">${esc(awaitingWord(r))}</td><td data-l="Kept">${n(r.kept)}</td>`
|
|
93
|
+
+ `<td data-l="Rebuilds">${n(r.rebuilds)}</td><td data-l="Fix failed">${n(r.fixFailed)}</td><td data-l="Last check">${esc(lastCheckWord(r))}</td></tr>`).join('');
|
|
94
|
+
const more = view.lanes.length > LANE_CAP ? `<p class="rp-tail">And ${n(view.lanes.length - LANE_CAP)} more on the workbook's Summary sheet.</p>` : '';
|
|
95
|
+
return '<section class="rp-detail" data-role="lanes"><h2>By lane / developer</h2>'
|
|
96
|
+
+ `<div class="scroller"><table class="rp-devs" aria-label="By lane / developer"><thead><tr><th scope="col">Developer</th>${LANE_HEAD.map((h) => `<th scope="col">${h}</th>`).join('')}</tr></thead>`
|
|
97
|
+
+ `<tbody>${rows}</tbody></table></div>${more}`
|
|
98
|
+
+ "<p class=\"rp-tail\">Lanes are listed in the order they were set up, never ranked. On the workbook's Objects sheet, filter Lane to see one developer's list.</p></section>";
|
|
99
|
+
}
|
|
100
|
+
function keptHtml(view) {
|
|
101
|
+
const k = view.kept;
|
|
102
|
+
const body = k.length
|
|
103
|
+
? `<ul class="rp-kept-list">${k.slice(0, KEPT_CAP).map((x) => `<li><p>${esc(x.line)}</p>${x.why ? `<p class="rp-why">${esc(x.why)}</p>` : ''}<p class="rp-why">${esc(x.by)}</p></li>`).join('')}</ul>`
|
|
104
|
+
+ (k.length > KEPT_CAP ? `<p class="rp-tail">And ${n(k.length - KEPT_CAP)} more on the workbook's For the business sheet.</p>` : '')
|
|
105
|
+
: '<p>No object is kept by decision.</p>';
|
|
106
|
+
return `<section class="rp-kept" data-role="kept"><h2>Kept as is, by decision (${n(k.length)})</h2>${body}</section>`;
|
|
107
|
+
}
|
|
108
|
+
function rebuildsHtml(view) {
|
|
109
|
+
const g = view.rebuilds;
|
|
110
|
+
if (!g.total)
|
|
111
|
+
return '';
|
|
112
|
+
return `<section class="rp-rebuilds" data-role="rebuilds"><h2>Rebuilds (${n(g.total)})</h2><p>${esc(g.words.join(' · '))}.</p>`
|
|
113
|
+
+ (g.staleLines.length ? `<ul>${g.staleLines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
|
|
114
|
+
+ '</section>';
|
|
115
|
+
}
|
|
85
116
|
/**
|
|
86
|
-
* The whole document.
|
|
117
|
+
* The whole document: the one report.
|
|
87
118
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
119
|
+
* Page 1, in order: the coverage line (with the check and its date) · the
|
|
120
|
+
* headline and scope · five numbers (six with rebuilds) · levels now ·
|
|
121
|
+
* decisions needed · changed since the last report. Page 2: by lane /
|
|
122
|
+
* developer, the kept-by-decision list, the rebuild group.
|
|
91
123
|
*
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
124
|
+
* `previousAsOf` is the as-of stamp of the status page this one replaces (read
|
|
125
|
+
* from its `cspeach-as-of` meta tag before it is overwritten); null for the
|
|
126
|
+
* first report. `summary`, `needs` and `blocks` are still accepted from the
|
|
127
|
+
* page's `collectView` and not drawn: the one report works its numbers out
|
|
128
|
+
* from the same folder, once, in report-model.js.
|
|
129
|
+
*
|
|
130
|
+
* `estate` is the discovery file as the folder read it: given but not read into
|
|
131
|
+
* the picture, the scope sentence says it could not be read, never "none".
|
|
95
132
|
*
|
|
96
|
-
* `
|
|
97
|
-
* out
|
|
98
|
-
* reason: whoever this was mailed to cannot look in the folder to find out that
|
|
99
|
-
* a ruling was ignored, and an ignored ruling is a decision somebody made that
|
|
100
|
-
* this picture does not show.
|
|
133
|
+
* `css` is the only value written in unescaped, and the only one that never came
|
|
134
|
+
* out of the folder. Every name, package, decision and person goes through `esc()`.
|
|
101
135
|
*
|
|
102
|
-
* @param {{ project: object, register: object,
|
|
103
|
-
*
|
|
104
|
-
*
|
|
136
|
+
* @param {{ project: object, register: object, css: string, asOf: string, folderName: string,
|
|
137
|
+
* files?: number, checksum?: string, unreadable?: number, needs?: object[], previousAsOf?: string|null,
|
|
138
|
+
* estate?: unknown }} view
|
|
105
139
|
* @returns {string}
|
|
106
140
|
*/
|
|
107
|
-
export function buildStatusHtml({ project, register,
|
|
108
|
-
// A file with no time on it
|
|
109
|
-
// somebody will act on it. Refuse rather than print "never" or quietly stamp
|
|
110
|
-
// the moment Save was pressed.
|
|
141
|
+
export function buildStatusHtml({ project, register, needs = [], css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, previousAsOf = null, estate = undefined, }) {
|
|
142
|
+
// A file with no time on it is worse than no file: somebody will act on it.
|
|
111
143
|
if (!asOf)
|
|
112
144
|
throw new Error('buildStatusHtml needs asOf — the time the folder was read. The file states it as the truth of the picture.');
|
|
113
|
-
//
|
|
114
|
-
// but not the count would have it say "0 files could not be read", which is
|
|
115
|
-
// the opposite of what the need means.
|
|
145
|
+
// "0 files could not be read" would be the opposite of what the need means.
|
|
116
146
|
if (needs?.some((i) => i.kind === 'unreadable') && !(Number.isFinite(unreadable) && unreadable > 0)) {
|
|
117
147
|
throw new Error('buildStatusHtml needs unreadable — a need says files could not be read, and the file states how many.');
|
|
118
148
|
}
|
|
119
|
-
//
|
|
120
|
-
// day hand it something with a closing style tag in it, which would end the
|
|
121
|
-
// stylesheet early and put the rest of it into the document as text — or as
|
|
122
|
-
// markup. Refuse; do not rewrite it behind the caller's back.
|
|
149
|
+
// A closing style tag would end the stylesheet early. Refuse; never rewrite it.
|
|
123
150
|
if (/<\/style/i.test(css))
|
|
124
151
|
throw new Error('buildStatusHtml refuses css that closes the style element.');
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// surfaces are allowed to differ.
|
|
129
|
-
const forFile = (i) => (i.kind !== 'unreadable' ? i
|
|
130
|
-
: { ...i, title: `${files(unreadable)} could not be read when this was made`, detail: UNREADABLE_DETAIL });
|
|
152
|
+
const view = reportView({ project, register, asOf, previousAsOf });
|
|
153
|
+
const trend = trendLine(view.trend);
|
|
154
|
+
const levelKey = `Levels: ${['A', 'B', 'C', 'D'].map((l) => `${l} ${LEVEL_KEY[l]}`).join('; ')}. ${FIX_RULE}`;
|
|
131
155
|
return `<!doctype html>
|
|
132
156
|
<html lang="en"><head><meta charset="utf-8">
|
|
133
157
|
<meta http-equiv="Content-Security-Policy" content="${FILE_CSP}">
|
|
134
158
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
159
|
+
<meta name="cspeach-as-of" content="${esc(asOf)}">
|
|
135
160
|
<title>${esc(project.name)} — status as of ${esc(String(asOf).slice(0, 10))}</title>
|
|
136
161
|
<style>${css}</style></head>
|
|
137
|
-
<body><main id="app">
|
|
162
|
+
<body><main id="app" class="rp">
|
|
138
163
|
<header class="masthead">
|
|
139
164
|
<p class="provenance">${provenanceLine(project)}</p>
|
|
140
165
|
<h1>${esc(project.name)}</h1>
|
|
141
166
|
<p class="stamp" data-role="stamp">Status as of ${esc(formatWhen(asOf))}. This file does not update. Made by CSPeach from the folder ${esc(folderName)}.</p>
|
|
142
167
|
${unreadable > 0 ? `<p class="notice" data-role="incomplete">${files(unreadable)} could not be read, so this picture may be incomplete.</p>` : ''}
|
|
143
|
-
<p class="summary" data-role="summary">${esc(summary.line)}</p>
|
|
144
|
-
${notMeasuredLine(register) ? `<p class="unmeasured" data-role="not-measured">${esc(notMeasuredLine(register))}</p>` : ''}
|
|
145
168
|
</header>
|
|
146
|
-
<
|
|
147
|
-
${
|
|
148
|
-
</
|
|
149
|
-
<
|
|
150
|
-
${
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
${
|
|
154
|
-
</
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
${
|
|
160
|
-
</
|
|
161
|
-
<
|
|
162
|
-
${runsTableHtml(register.runs)}
|
|
163
|
-
${ignoredRunsHtml(register.ignoredRuns)}
|
|
164
|
-
</section>
|
|
165
|
-
<section class="objects"><h2>The object list</h2>
|
|
166
|
-
${blocks.map((b) => packageFold(b, register.lanes, register.runs, project.type)).join('')}
|
|
167
|
-
</section>
|
|
168
|
-
<footer><p data-role="read-note">${esc(readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' }))}</p>
|
|
169
|
+
<div class="rp-first">
|
|
170
|
+
<div class="rp-coverage"><p class="rp-coverage-line" data-role="coverage">${esc(view.coverage.line)}</p>${trend ? `<p class="rp-trend" data-role="trend">${esc(trend)}</p>` : ''}${view.coverage.notes.map((note) => `<p class="rp-trend" data-role="coverage-note">${esc(note)}</p>`).join('')}</div>
|
|
171
|
+
<p class="rp-headline" data-role="headline">${esc(view.headline)}</p>
|
|
172
|
+
<p class="rp-scope" data-role="scope">${esc(view.scope)}${estate !== undefined && !register.estate ? ' The estate inventory in this folder could not be read.' : ''}</p>
|
|
173
|
+
${numbersHtml(view)}
|
|
174
|
+
<div class="rp-band">
|
|
175
|
+
${levelsHtml(view)}
|
|
176
|
+
${decisionsHtml(view)}
|
|
177
|
+
</div>
|
|
178
|
+
${changedHtml(view)}
|
|
179
|
+
</div>
|
|
180
|
+
${lanesHtml(view)}
|
|
181
|
+
${keptHtml(view)}
|
|
182
|
+
${rebuildsHtml(view)}
|
|
183
|
+
<footer><p class="rp-key">${esc(levelKey)}</p>
|
|
184
|
+
<p data-role="read-note">${esc(`${readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' })}`.replace(/([^.])$/, '$1.'))}</p>
|
|
169
185
|
<p data-role="trust">This file holds object names, packages, team member names, decisions, fix status and check results. No source code. No passwords. It contains no script and no links, and it cannot load anything from the internet.</p></footer>
|
|
170
186
|
</main></body></html>`;
|
|
171
187
|
}
|