@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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Plan sheet, read back (spec 2026-10-06 §8). `team report` writes it; the
|
|
3
|
+
* lead edits the five columns before the dash in Excel; `team plan import`
|
|
4
|
+
* reads it here, compares it with what the export said and what the plan says
|
|
5
|
+
* now, and hands back the changes as the rows of ONE `excel` plan record.
|
|
6
|
+
*
|
|
7
|
+
* Read the strict way:
|
|
8
|
+
* - the header row is matched by column name (any case, trimmed), unknown columns ignored;
|
|
9
|
+
* - rows are matched by Type + Object, never by position; blank rows ignored;
|
|
10
|
+
* - a cell is a change only when it differs from what the export wrote AND from
|
|
11
|
+
* what the plan says now, so a cell the lead never touched never undoes
|
|
12
|
+
* someone else's newer change;
|
|
13
|
+
* - a change to a field a newer plan, decision or ruling record touched is a
|
|
14
|
+
* clash: shown with both values and skipped, never overwritten;
|
|
15
|
+
* - the fact columns after the dash are compared with the export and never read.
|
|
16
|
+
*
|
|
17
|
+
* Pure: buffer and register in, rows and counts out. Nothing is written here.
|
|
18
|
+
*/
|
|
19
|
+
import { normalizeKey, objectKey, planRows, PLAN_COLUMNS, } from '@cspeach/register-core';
|
|
20
|
+
import { readSheets } from '../tools/filesystem/extract-xlsx.js';
|
|
21
|
+
import { RegisterError } from './store.js';
|
|
22
|
+
import { EXPORT_STAMP } from './workbooks.js';
|
|
23
|
+
export const NOT_A_PLAN_SHEET = 'This is not a Plan sheet CSPeach wrote.';
|
|
24
|
+
const MAX_NOTE = 300;
|
|
25
|
+
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
26
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
|
+
const SERIAL = /^\d{5}(\.\d+)?$/;
|
|
28
|
+
/** Excel's day zero for serial dates (the 1900 system, after its leap-year bug). */
|
|
29
|
+
const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
|
|
30
|
+
const DASH = PLAN_COLUMNS.indexOf('—');
|
|
31
|
+
const FACT_COLUMNS = PLAN_COLUMNS.slice(DASH + 1);
|
|
32
|
+
const lower = (s) => s.trim().toLowerCase();
|
|
33
|
+
const CANONICAL = new Map(PLAN_COLUMNS.map((c) => [lower(c), c]));
|
|
34
|
+
// The Objects sheet (1.1.32) heads the decided level "Level decided", beside its "Level now".
|
|
35
|
+
CANONICAL.set('level decided', 'Level');
|
|
36
|
+
/**
|
|
37
|
+
* The Plan sheet and the export stamp of a workbook `team report` wrote.
|
|
38
|
+
* Throws the one sentence for anything else: not an .xlsx, no Plan sheet, no
|
|
39
|
+
* stamp in the Read me, or a Plan sheet without Object and Type columns.
|
|
40
|
+
*/
|
|
41
|
+
export function readPlanSheet(buffer) {
|
|
42
|
+
let sheets;
|
|
43
|
+
try {
|
|
44
|
+
sheets = readSheets(buffer);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
48
|
+
}
|
|
49
|
+
// 1.1.32 writes "Objects" with the stamp on "Summary"; a 1.1.31 workbook has "Plan" and "Read me".
|
|
50
|
+
const plan = sheets.find((s) => lower(s.name) === 'objects') ?? sheets.find((s) => lower(s.name) === 'plan');
|
|
51
|
+
const stampCell = sheets.filter((s) => lower(s.name) === 'summary' || lower(s.name) === 'read me')
|
|
52
|
+
.flatMap((s) => s.rows.flat()).find((c) => lower(c).startsWith(lower(EXPORT_STAMP)));
|
|
53
|
+
const exportStamp = stampCell ? stampCell.trim().slice(EXPORT_STAMP.length).trim() : '';
|
|
54
|
+
if (!plan || !STAMP.test(exportStamp) || Number.isNaN(Date.parse(exportStamp)))
|
|
55
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
56
|
+
const at = plan.rows.findIndex((r) => r.some((c) => c.trim() !== ''));
|
|
57
|
+
const header = at === -1 ? [] : plan.rows[at].map((c) => c.trim());
|
|
58
|
+
// Column name → index; the first column of a name wins, names it does not know are ignored.
|
|
59
|
+
const index = new Map();
|
|
60
|
+
header.forEach((name, i) => {
|
|
61
|
+
const canonical = CANONICAL.get(lower(name));
|
|
62
|
+
if (canonical && !index.has(canonical))
|
|
63
|
+
index.set(canonical, i);
|
|
64
|
+
});
|
|
65
|
+
if (!index.has('Object') || !index.has('Type'))
|
|
66
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
67
|
+
const rows = [];
|
|
68
|
+
for (const raw of plan.rows.slice(at + 1)) {
|
|
69
|
+
const row = {};
|
|
70
|
+
for (const [name, i] of index)
|
|
71
|
+
row[name] = (raw[i] ?? '').trim();
|
|
72
|
+
if (Object.values(row).every((v) => v === ''))
|
|
73
|
+
continue;
|
|
74
|
+
rows.push(row);
|
|
75
|
+
}
|
|
76
|
+
return { exportStamp, header, rows };
|
|
77
|
+
}
|
|
78
|
+
// ── cells ────────────────────────────────────────────────────────────────────
|
|
79
|
+
/** 1–5 (also `2.0`, ` 2 `), `defer` in any case, '' = blank, undefined = unreadable. */
|
|
80
|
+
function priorityCell(cell) {
|
|
81
|
+
const v = cell.trim();
|
|
82
|
+
if (!v)
|
|
83
|
+
return '';
|
|
84
|
+
if (lower(v) === 'defer')
|
|
85
|
+
return 'defer';
|
|
86
|
+
const m = /^([1-5])(?:\.0+)?$/.exec(v);
|
|
87
|
+
return m ? Number(m[1]) : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** A real day as YYYY-MM-DD, or an Excel serial (46000 → 2025-12-09); '' = blank; undefined = unreadable. */
|
|
90
|
+
function dateCell(cell) {
|
|
91
|
+
const v = cell.trim();
|
|
92
|
+
if (!v)
|
|
93
|
+
return '';
|
|
94
|
+
if (DAY.test(v)) {
|
|
95
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
96
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v ? v : undefined;
|
|
97
|
+
}
|
|
98
|
+
if (SERIAL.test(v))
|
|
99
|
+
return new Date(EXCEL_EPOCH + Math.floor(Number(v)) * 86_400_000).toISOString().slice(0, 10);
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
function scopeCell(cell) {
|
|
103
|
+
const v = lower(cell);
|
|
104
|
+
if (!v)
|
|
105
|
+
return '';
|
|
106
|
+
// `1`/`0`: a cell Excel saved as a boolean reads back as a number.
|
|
107
|
+
if (v === 'yes' || v === 'y' || v === 'true' || v === '1')
|
|
108
|
+
return true;
|
|
109
|
+
if (v === 'no' || v === 'n' || v === 'false' || v === '0')
|
|
110
|
+
return false;
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** A lane id from `Owner (id)` (as exported), an owner name or a lane id, any case. '' = blank; undefined = unknown. */
|
|
114
|
+
function laneCell(cell, register, current) {
|
|
115
|
+
const v = cell.trim();
|
|
116
|
+
if (!v)
|
|
117
|
+
return '';
|
|
118
|
+
const byId = (id) => register.lanes.find((l) => lower(l.id) === lower(id))?.id;
|
|
119
|
+
const label = /^(.*)\(([^()]+)\)\s*$/.exec(v);
|
|
120
|
+
if (label) {
|
|
121
|
+
const id = byId(label[2]);
|
|
122
|
+
if (id && lower(register.lanes.find((l) => l.id === id).owner) === lower(label[1]))
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
const id = byId(v);
|
|
126
|
+
if (id)
|
|
127
|
+
return id;
|
|
128
|
+
const owned = register.lanes.filter((l) => lower(l.owner) === lower(v));
|
|
129
|
+
if (owned.length === 1)
|
|
130
|
+
return owned[0].id;
|
|
131
|
+
// An owner of several lanes: the lane the object is in now, if it is one of them.
|
|
132
|
+
if (owned.length > 1 && owned.some((l) => l.id === current))
|
|
133
|
+
return current;
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
// ── values the plan holds for an object ─────────────────────────────────────
|
|
137
|
+
const manualOf = (o) => {
|
|
138
|
+
const e = o.priority.effective;
|
|
139
|
+
return e.source === 'set by hand' || e.source === 'Excel plan' ? e.band : null;
|
|
140
|
+
};
|
|
141
|
+
const laneLabel = (register, id) => {
|
|
142
|
+
const l = id ? register.lanes.find((x) => x.id === id) : undefined;
|
|
143
|
+
return l ? `${l.owner} (${l.id})` : 'no lane';
|
|
144
|
+
};
|
|
145
|
+
const shownValue = (v) => (v === '' ? 'blank' : v);
|
|
146
|
+
/** The cell as the fact columns compare it: trimmed, and a number read as a number (3 = "3" = "3.0"). */
|
|
147
|
+
function sameFact(a, b) {
|
|
148
|
+
const x = a === null || a === undefined ? '' : String(a).trim();
|
|
149
|
+
const y = b === null || b === undefined ? '' : String(b).trim();
|
|
150
|
+
if (x === y)
|
|
151
|
+
return true;
|
|
152
|
+
return x !== '' && y !== '' && Number.isFinite(Number(x)) && Number(x) === Number(y);
|
|
153
|
+
}
|
|
154
|
+
/** field → newest (by, at) of a plan, decision or ruling record newer than the export, per object. */
|
|
155
|
+
function newerTouches(records, stamp) {
|
|
156
|
+
const out = new Map();
|
|
157
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
158
|
+
for (const r of records) {
|
|
159
|
+
if (!isObj(r) || typeof r.at !== 'string' || !(r.at > stamp) || !Array.isArray(r.rows))
|
|
160
|
+
continue;
|
|
161
|
+
const by = isObj(r.by) && typeof r.by.name === 'string' ? r.by.name : 'someone';
|
|
162
|
+
for (const row of r.rows) {
|
|
163
|
+
if (!isObj(row) || typeof row.key !== 'string')
|
|
164
|
+
continue;
|
|
165
|
+
const key = normalizeKey(row.key);
|
|
166
|
+
if (!key)
|
|
167
|
+
continue;
|
|
168
|
+
const fields = [];
|
|
169
|
+
if (r.recordType === 'decision' || r.recordType === 'ruling')
|
|
170
|
+
fields.push('Priority');
|
|
171
|
+
else if (r.recordType === 'plan') {
|
|
172
|
+
const a = r.action;
|
|
173
|
+
if (a === 'priority' || (a === 'excel' && 'priority' in row))
|
|
174
|
+
fields.push('Priority');
|
|
175
|
+
if (a === 'assign' || (a === 'excel' && 'lane' in row))
|
|
176
|
+
fields.push('Lane');
|
|
177
|
+
if (a === 'scope' || (a === 'excel' && 'inScope' in row))
|
|
178
|
+
fields.push('In scope');
|
|
179
|
+
if ((a === 'note' || a === 'excel') && 'targetDate' in row)
|
|
180
|
+
fields.push('Target date');
|
|
181
|
+
if ((a === 'note' || a === 'excel') && 'note' in row)
|
|
182
|
+
fields.push('Note');
|
|
183
|
+
}
|
|
184
|
+
for (const f of fields) {
|
|
185
|
+
const byField = out.get(key) ?? new Map();
|
|
186
|
+
const had = byField.get(f);
|
|
187
|
+
if (!had || r.at > had.at)
|
|
188
|
+
byField.set(f, { by, at: r.at });
|
|
189
|
+
out.set(key, byField);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The changes the sheet asks for. `register` is the plan now; `exported` the
|
|
197
|
+
* plan as the export saw it (built from the records up to the export stamp),
|
|
198
|
+
* the current one when not given; `records` every record the folder holds, for
|
|
199
|
+
* the clashes.
|
|
200
|
+
*/
|
|
201
|
+
export function diffPlan(sheet, register, records, opts = {}) {
|
|
202
|
+
const exported = opts.exported ?? register;
|
|
203
|
+
const now = new Map(register.objects.map((o) => [o.key, o]));
|
|
204
|
+
const then = new Map(exported.objects.map((o) => [o.key, o]));
|
|
205
|
+
const exportedFacts = new Map();
|
|
206
|
+
for (const row of planRows(exported)) {
|
|
207
|
+
exportedFacts.set(objectKey(String(row[1]), String(row[0])), Object.fromEntries(PLAN_COLUMNS.map((c, i) => [c, row[i]])));
|
|
208
|
+
}
|
|
209
|
+
const touched = newerTouches(records, sheet.exportStamp);
|
|
210
|
+
const counts = {
|
|
211
|
+
priorities: 0, moves: 0, scope: 0, notes: 0, unchanged: 0, notInTeamProject: 0, clashes: 0, factEditsIgnored: 0, unknownLanes: 0, unreadableCells: 0, blankCells: 0,
|
|
212
|
+
};
|
|
213
|
+
const out = { rows: [], counts, clashes: [], unknownLanes: [], exportStamp: sheet.exportStamp };
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
for (const cells of sheet.rows) {
|
|
216
|
+
if (Object.values(cells).every((v) => !v || !v.trim()))
|
|
217
|
+
continue; // a blank row says nothing
|
|
218
|
+
const type = cells.Type ?? '';
|
|
219
|
+
const name = cells.Object ?? '';
|
|
220
|
+
const key = type && name ? objectKey(type, name) : '';
|
|
221
|
+
const o = key ? now.get(key) : undefined;
|
|
222
|
+
if (!o) {
|
|
223
|
+
counts.notInTeamProject += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (seen.has(key))
|
|
227
|
+
continue; // the first row of an object counts
|
|
228
|
+
seen.add(key);
|
|
229
|
+
const was = then.get(key) ?? o;
|
|
230
|
+
const row = { key };
|
|
231
|
+
let unreadable = 0;
|
|
232
|
+
let blank = 0;
|
|
233
|
+
let clashed = 0;
|
|
234
|
+
let unknownLane = false;
|
|
235
|
+
/** A change the lead made in the sheet: skipped when a newer record touched the same field. */
|
|
236
|
+
const change = (field, sheetText, nowText, apply) => {
|
|
237
|
+
const newer = touched.get(key)?.get(field);
|
|
238
|
+
if (newer) {
|
|
239
|
+
out.clashes.push({ key, field, sheet: shownValue(sheetText), now: shownValue(nowText), by: newer.by, at: newer.at });
|
|
240
|
+
clashed += 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
apply();
|
|
244
|
+
};
|
|
245
|
+
if ('Priority' in cells) {
|
|
246
|
+
const v = priorityCell(cells.Priority);
|
|
247
|
+
if (v === undefined)
|
|
248
|
+
unreadable += 1;
|
|
249
|
+
else {
|
|
250
|
+
const desired = v === '' ? null : v;
|
|
251
|
+
if (desired !== manualOf(was) && desired !== manualOf(o)) {
|
|
252
|
+
change('Priority', cells.Priority, String(o.priority.effective.band), () => { row.priority = desired; counts.priorities += 1; });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if ('Lane' in cells) {
|
|
257
|
+
const v = laneCell(cells.Lane, register, o.lane);
|
|
258
|
+
const exportedCell = laneLabel(exported, was.lane);
|
|
259
|
+
if (v === undefined) {
|
|
260
|
+
// A label the export wrote for a lane removed since is not the lead's edit.
|
|
261
|
+
if (!sameFact(cells.Lane, exportedCell)) {
|
|
262
|
+
unknownLane = true;
|
|
263
|
+
out.unknownLanes.push({ key, lane: cells.Lane });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (v === '') {
|
|
267
|
+
// Blank gives an object the lead moved back to the lane patterns; a pattern lane has nothing to give back.
|
|
268
|
+
if (was.laneFrom === 'assigned' && o.laneFrom === 'assigned') {
|
|
269
|
+
change('Lane', '', laneLabel(register, o.lane), () => { row.lane = null; counts.moves += 1; });
|
|
270
|
+
}
|
|
271
|
+
else if (was.laneFrom !== 'assigned' && o.laneFrom !== 'assigned' && o.lane !== null) {
|
|
272
|
+
blank += 1; // a lane from the patterns: blank cannot take it away
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (v !== was.lane && v !== o.lane) {
|
|
276
|
+
change('Lane', cells.Lane, laneLabel(register, o.lane), () => { row.lane = v; counts.moves += 1; });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if ('In scope' in cells) {
|
|
280
|
+
const v = scopeCell(cells['In scope']);
|
|
281
|
+
if (v === undefined)
|
|
282
|
+
unreadable += 1;
|
|
283
|
+
else if (v === '')
|
|
284
|
+
blank += 1; // in scope is yes or no, never nothing
|
|
285
|
+
else if (v !== was.inScope && v !== o.inScope) {
|
|
286
|
+
change('In scope', cells['In scope'], o.inScope ? 'yes' : 'no', () => { row.inScope = v; counts.scope += 1; });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
let noted = false;
|
|
290
|
+
if ('Target date' in cells) {
|
|
291
|
+
const v = dateCell(cells['Target date']);
|
|
292
|
+
const before = was.plan.targetDate ?? '';
|
|
293
|
+
const current = o.plan.targetDate ?? '';
|
|
294
|
+
if (v === undefined)
|
|
295
|
+
unreadable += 1;
|
|
296
|
+
else if (v !== before && v !== current) {
|
|
297
|
+
change('Target date', cells['Target date'], current, () => { row.targetDate = v; noted = true; });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if ('Note' in cells) {
|
|
301
|
+
const v = cells.Note;
|
|
302
|
+
const before = was.plan.note ?? '';
|
|
303
|
+
const current = o.plan.note ?? '';
|
|
304
|
+
if (v.length > MAX_NOTE)
|
|
305
|
+
unreadable += 1;
|
|
306
|
+
else if (v !== before && v !== current) {
|
|
307
|
+
change('Note', v, current, () => { row.note = v; noted = true; });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (noted)
|
|
311
|
+
counts.notes += 1;
|
|
312
|
+
const facts = exportedFacts.get(key);
|
|
313
|
+
for (const f of FACT_COLUMNS)
|
|
314
|
+
if (f in cells && facts && !sameFact(cells[f], facts[f]))
|
|
315
|
+
counts.factEditsIgnored += 1;
|
|
316
|
+
counts.unreadableCells += unreadable;
|
|
317
|
+
counts.blankCells += blank;
|
|
318
|
+
counts.clashes += clashed;
|
|
319
|
+
if (unknownLane)
|
|
320
|
+
counts.unknownLanes += 1;
|
|
321
|
+
const accepted = Object.keys(row).length > 1;
|
|
322
|
+
if (accepted)
|
|
323
|
+
out.rows.push(row);
|
|
324
|
+
else if (!unreadable && !blank && !clashed && !unknownLane)
|
|
325
|
+
counts.unchanged += 1;
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
package/dist/register/records.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
14
14
|
import { defaultRand, readBaseline } from './store.js';
|
|
15
15
|
import { safeDetailRel, resolveDetailFileSync } from '../projects/detail-path.js';
|
|
16
|
+
import { writeWhole } from './write-whole.js';
|
|
16
17
|
const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
|
|
17
18
|
export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
18
19
|
const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
|
|
@@ -63,6 +64,30 @@ export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
|
63
64
|
}
|
|
64
65
|
return { file, written: kept.length, refused };
|
|
65
66
|
}
|
|
67
|
+
/** `20261007T080000Z`: the ISO time without dashes, colons or milliseconds, as every record file name carries it. */
|
|
68
|
+
export const fileStamp = (now) => now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
69
|
+
/**
|
|
70
|
+
* A record the LEAD writes into one of the lead's own folders (`plan/`,
|
|
71
|
+
* `baseline/`), whose rows may be empty — a plan header says everything in
|
|
72
|
+
* its header fields. The caller has already decided which rows belong; this
|
|
73
|
+
* only writes, whole then renamed. Never under `writers/`.
|
|
74
|
+
*/
|
|
75
|
+
export function writeLeadRecord(dir, projectId, recordType, header, rows, by, opts = {}) {
|
|
76
|
+
const now = opts.now ?? new Date();
|
|
77
|
+
const recordId = (opts.rand ?? defaultRand)(6);
|
|
78
|
+
const name = opts.fileName ? path.basename(opts.fileName) : `${fileStamp(now)}-${recordType}-${recordId.slice(0, 4)}.json`;
|
|
79
|
+
const file = path.join(dir, name);
|
|
80
|
+
const own = {
|
|
81
|
+
kind: 'record', formatVersion: 1, recordId, projectId, recordType,
|
|
82
|
+
at: now.toISOString(), by: { name: by.name, writer: by.writer },
|
|
83
|
+
};
|
|
84
|
+
// A header field never stands in for one of the record's own fields.
|
|
85
|
+
const extra = Object.fromEntries(Object.entries(header).filter(([k]) => !(k in own) && k !== 'rows'));
|
|
86
|
+
const record = { ...own, ...extra, rows: [...rows] };
|
|
87
|
+
writeWhole(file, `${JSON.stringify(record, null, 2)}
|
|
88
|
+
`);
|
|
89
|
+
return { file, recordId };
|
|
90
|
+
}
|
|
66
91
|
/**
|
|
67
92
|
* Decisions from a saved CCA assessment. The full list lives in the detail
|
|
68
93
|
* file's `objects.json` (the envelope table stops at 50 rows), so that is read
|
|
@@ -142,7 +167,15 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
|
|
|
142
167
|
* per row, clean-core-confirm.ts). The five words map onto the four
|
|
143
168
|
* classifications (spec §4.2); a fix or a rebuild with no target reaches A.
|
|
144
169
|
*/
|
|
145
|
-
export function decisionRowsFromCleanCore(content
|
|
170
|
+
export function decisionRowsFromCleanCore(content,
|
|
171
|
+
/**
|
|
172
|
+
* Who said Yes, and when. With it, a confirmed row whose proposal NAMED the
|
|
173
|
+
* level its target is read from — a fix/rebuild target, a keep/exception
|
|
174
|
+
* level — carries `targetBy` (F3, M19): the person's Yes set that target.
|
|
175
|
+
* A fix with no target in the proposal still records the legacy A, and that
|
|
176
|
+
* is no target. Absent: rows exactly as before.
|
|
177
|
+
*/
|
|
178
|
+
by) {
|
|
146
179
|
const rows = [];
|
|
147
180
|
for (const r of content.adjudications ?? []) {
|
|
148
181
|
if (r?.confirmed !== true)
|
|
@@ -156,11 +189,13 @@ export function decisionRowsFromCleanCore(content) {
|
|
|
156
189
|
if (!base)
|
|
157
190
|
continue;
|
|
158
191
|
const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
|
|
192
|
+
const named = moves ? !!r.targetLevel : base.classification === 'keep' && !!r.level;
|
|
159
193
|
rows.push({
|
|
160
194
|
key: objectKey(r.objectType, r.objectName), ...base,
|
|
161
195
|
...(r.level ? { level: r.level } : {}),
|
|
162
196
|
...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
|
|
163
197
|
...(reason ? { reason } : {}),
|
|
198
|
+
...(by && named ? { targetBy: { name: by.name, at: by.at } } : {}),
|
|
164
199
|
});
|
|
165
200
|
}
|
|
166
201
|
return rows;
|
|
@@ -189,8 +224,10 @@ opts = {}) {
|
|
|
189
224
|
return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
|
|
190
225
|
}
|
|
191
226
|
if (env.artefactType === 'clean-core-decision') {
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
// Every row here had a person's Yes (confirmCleanCoreRows / the journey's row card).
|
|
228
|
+
const at = (opts.now ?? new Date()).toISOString();
|
|
229
|
+
const rows = decisionRowsFromCleanCore(env.content, { name: by.name, at });
|
|
230
|
+
return writeRecord(joined, 'decision', rows, by, { source, ...opts, now: opts.now ?? new Date(at) });
|
|
194
231
|
}
|
|
195
232
|
return null;
|
|
196
233
|
}
|