@cspeach/cli 1.1.30 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +121 -8
- package/dist/cli.js +36 -14
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -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.js +375 -41
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +20 -1
- package/dist/projects/clean-core-confirm.js +156 -0
- 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 +83 -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/discover.js +901 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +25 -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 +24 -6
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +3 -0
- package/dist/register/status-html.js +3 -1
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +27 -7
- 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 +2 -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/journey-entry.js +25 -0
- package/dist/repl.js +157 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -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 +1 -1
- 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/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/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +77 -4
- 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/util/open-in-browser.js +143 -0
- package/package.json +120 -120
|
@@ -20,7 +20,7 @@ const basisOf = (v) => {
|
|
|
20
20
|
const w = String(v).trim().toLowerCase();
|
|
21
21
|
return w === '' || w === 'system' ? 'system' : 'file';
|
|
22
22
|
};
|
|
23
|
-
export function findingsByObject(projectDir, outboxDir) {
|
|
23
|
+
export function findingsByObject(projectDir, outboxDir, opts = {}) {
|
|
24
24
|
const project = readProjectFile(projectDir);
|
|
25
25
|
const ours = variantKey(project.atcVariant);
|
|
26
26
|
const records = [
|
|
@@ -56,13 +56,16 @@ export function findingsByObject(projectDir, outboxDir) {
|
|
|
56
56
|
// handed-over file, and it goes to a terminal and to the model's tools.
|
|
57
57
|
const message = named && typeof f.message === 'string' ? shown(f.message) : '';
|
|
58
58
|
const had = seen.rules.get(rule);
|
|
59
|
+
const line = opts.lines && named && Number.isInteger(f.line) && f.line > 0 ? f.line : undefined;
|
|
59
60
|
if (had) {
|
|
60
61
|
had.count += 1;
|
|
61
62
|
if (!had.message && message)
|
|
62
63
|
had.message = message;
|
|
64
|
+
if (line !== undefined && (had.line === undefined || line < had.line))
|
|
65
|
+
had.line = line;
|
|
63
66
|
}
|
|
64
67
|
else
|
|
65
|
-
seen.rules.set(rule, { rule, message, count: 1 });
|
|
68
|
+
seen.rules.set(rule, { rule, message, count: 1, ...(line !== undefined ? { line } : {}) });
|
|
66
69
|
}
|
|
67
70
|
byKey.set(key, list);
|
|
68
71
|
}
|
package/dist/register/offer.js
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The line CSPeach prints after it activated, and verified, an object of the
|
|
3
|
+
* team project this laptop has joined (spec §4.3).
|
|
4
|
+
*
|
|
5
|
+
* 1.1.31 (owner, 2026-10-07: "why would developers claim it when CSPeach is
|
|
6
|
+
* working?"): when CSPeach itself wrote the object's source in this session,
|
|
7
|
+
* the fix is recorded for the developer — the claim covers every rule open at
|
|
8
|
+
* the object's last measurement, with the transport the write used. A claim is
|
|
9
|
+
* still not proof: only the next measurement moves it to verified.
|
|
10
|
+
* An object CSPeach activated but did not write (someone changed it in Eclipse
|
|
11
|
+
* or SE80) only gets the one-line offer; the developer claims that one.
|
|
6
12
|
*/
|
|
7
13
|
import { objectKey } from '@cspeach/register-core';
|
|
8
14
|
import { activeProject, readBaseline } from './store.js';
|
|
9
|
-
|
|
15
|
+
import { findingsByObject } from './findings.js';
|
|
16
|
+
import { writeRecord } from './records.js';
|
|
17
|
+
import { regenerateWorkingList } from './workbooks.js';
|
|
18
|
+
/** The source-writing tools, and the argument that names the object each one wrote. */
|
|
19
|
+
const SOURCE_WRITES = {
|
|
20
|
+
sap_set_source: 'name',
|
|
21
|
+
sap_update_method: 'class_name',
|
|
22
|
+
sap_set_class_include: 'className',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Objects whose source CSPeach wrote successfully in this session (upper-case
|
|
26
|
+
* name → the transport the write used, '' when none is known).
|
|
27
|
+
*/
|
|
28
|
+
export function cspeachWritesThisSession(toolCalls) {
|
|
29
|
+
const out = new Map();
|
|
30
|
+
for (const c of toolCalls ?? []) {
|
|
31
|
+
if (!c.completed || c.is_error)
|
|
32
|
+
continue;
|
|
33
|
+
const field = SOURCE_WRITES[c.tool];
|
|
34
|
+
if (!field)
|
|
35
|
+
continue;
|
|
36
|
+
const args = (c.args ?? {});
|
|
37
|
+
const name = String(args[field] ?? '').trim().toUpperCase();
|
|
38
|
+
if (!name)
|
|
39
|
+
continue;
|
|
40
|
+
let transport = typeof args['transport'] === 'string' ? args['transport'] : '';
|
|
41
|
+
try {
|
|
42
|
+
// sap_set_source / sap_update_method report the request they used as transport_used.
|
|
43
|
+
const r = JSON.parse(c.result);
|
|
44
|
+
if (typeof r?.transport_used === 'string' && r.transport_used)
|
|
45
|
+
transport = r.transport_used;
|
|
46
|
+
else if (typeof r?.transport === 'string' && r.transport)
|
|
47
|
+
transport = r.transport;
|
|
48
|
+
}
|
|
49
|
+
catch { /* a plain-text result carries no transport */ }
|
|
50
|
+
out.set(name, transport.trim().toUpperCase());
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Fixes this process already recorded (a write that activated, then an explicit activate, record once). */
|
|
55
|
+
const recordedThisRun = new Set();
|
|
56
|
+
/** Tests only. */
|
|
57
|
+
export function resetRecordedFixes() { recordedThisRun.clear(); }
|
|
58
|
+
export function teamFixOffers(objects, opts = {}) {
|
|
59
|
+
// A guided journey is on (journey/flow.ts): the same records, in solo words.
|
|
60
|
+
const solo = opts.wording === 'journey';
|
|
10
61
|
try {
|
|
11
62
|
const joined = activeProject();
|
|
12
63
|
if (!joined)
|
|
@@ -17,15 +68,56 @@ export function teamFixOffers(objects) {
|
|
|
17
68
|
for (const o of baseline)
|
|
18
69
|
byName.set(o.name.toUpperCase(), [...(byName.get(o.name.toUpperCase()) ?? []), o]);
|
|
19
70
|
const lines = [];
|
|
71
|
+
const done = new Set();
|
|
20
72
|
for (const o of objects) {
|
|
21
73
|
const type = String(o?.type ?? '').split('/')[0].trim().toUpperCase();
|
|
22
74
|
const name = String(o?.name ?? '').trim().toUpperCase();
|
|
23
75
|
const same = byName.get(name) ?? [];
|
|
24
76
|
const hit = byKey.get(objectKey(type, name)) ?? (same.length === 1 ? same[0] : undefined);
|
|
25
|
-
if (hit)
|
|
26
|
-
|
|
77
|
+
if (!hit)
|
|
78
|
+
continue;
|
|
79
|
+
const key = objectKey(hit.type, hit.name);
|
|
80
|
+
if (done.has(key))
|
|
81
|
+
continue;
|
|
82
|
+
done.add(key);
|
|
83
|
+
const label = `${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`;
|
|
84
|
+
const transport = opts.written?.get(hit.name.toUpperCase());
|
|
85
|
+
if (transport === undefined) {
|
|
86
|
+
lines.push(solo
|
|
87
|
+
? `${label} is one of your objects, but CSPeach did not write it, so no fix was recorded.`
|
|
88
|
+
: `This object is in your team project. Mark it fixed? cspeach team fixed ${label}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const runKey = `${joined.projectId}|${key}|${transport}`;
|
|
92
|
+
if (recordedThisRun.has(runKey))
|
|
93
|
+
continue;
|
|
94
|
+
const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
|
|
95
|
+
if (!f?.latestAt) {
|
|
96
|
+
lines.push(solo
|
|
97
|
+
? `${label} has not been checked yet, so no fix was recorded. The next check looks at it.`
|
|
98
|
+
: `${label} has not been measured yet, so no fix was recorded. Run cspeach team refresh to measure it.`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!f.latest.length) {
|
|
102
|
+
lines.push(solo
|
|
103
|
+
? `${label} had nothing open at its last check, so no fix was recorded. The next check looks at it.`
|
|
104
|
+
: `${label} had nothing open at its last measurement, so no fix was recorded. The next cspeach team refresh checks it.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// The name this person records under in this team project, as team verbs use it.
|
|
108
|
+
const who = opts.who ?? joined.name ?? process.env['CSPEACH_AUTHOR_NAME'] ?? process.env['USER'] ?? process.env['USERNAME'] ?? 'consultant';
|
|
109
|
+
const res = writeRecord(joined, 'fix', f.latest.map((r) => ({ key, rule: r.rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: who });
|
|
110
|
+
if (!res.file)
|
|
111
|
+
continue;
|
|
112
|
+
recordedThisRun.add(runKey);
|
|
113
|
+
const by = `✓ Recorded: ${label} fixed by CSPeach${transport ? ` in ${transport}` : ''}.`;
|
|
114
|
+
lines.push(solo ? `${by} Not yet checked — the next check proves it.` : `${by} A claim is not proof — the next cspeach team refresh checks it.`);
|
|
115
|
+
try {
|
|
116
|
+
regenerateWorkingList(joined, who);
|
|
117
|
+
}
|
|
118
|
+
catch { /* the record is kept; the list catches up on the next run */ }
|
|
27
119
|
}
|
|
28
|
-
return
|
|
120
|
+
return lines;
|
|
29
121
|
}
|
|
30
122
|
catch {
|
|
31
123
|
return []; // a folder halfway through a sync must never touch an activation
|
package/dist/register/picture.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { buildRegister, narrowedLanes } from '@cspeach/register-core';
|
|
3
|
-
import { collectRecords, readBaseline, readLanes, readProjectFile } from './store.js';
|
|
3
|
+
import { collectRecords, readBaseline, readEstate, readLanes, readProjectFile } from './store.js';
|
|
4
4
|
export function registerFor(projectDir, outboxDir) {
|
|
5
5
|
const project = readProjectFile(projectDir);
|
|
6
6
|
const collected = collectRecords(projectDir);
|
|
@@ -10,7 +10,11 @@ export function registerFor(projectDir, outboxDir) {
|
|
|
10
10
|
const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
|
|
11
11
|
const baseline = readBaseline(projectDir);
|
|
12
12
|
const lanes = readLanes(projectDir);
|
|
13
|
-
|
|
13
|
+
// The newest discovery, when the folder holds one: the estate around the team project.
|
|
14
|
+
const estate = readEstate(projectDir)?.estate;
|
|
15
|
+
const register = buildRegister({
|
|
16
|
+
project, baseline, lanes, records: [...collected.records, ...own.records], ...(estate !== undefined ? { estate } : {}),
|
|
17
|
+
});
|
|
14
18
|
return {
|
|
15
19
|
register, files: collected.files + own.files,
|
|
16
20
|
unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
/**
|
|
35
|
+
* The Plan sheet and the export stamp of a workbook `team report` wrote.
|
|
36
|
+
* Throws the one sentence for anything else: not an .xlsx, no Plan sheet, no
|
|
37
|
+
* stamp in the Read me, or a Plan sheet without Object and Type columns.
|
|
38
|
+
*/
|
|
39
|
+
export function readPlanSheet(buffer) {
|
|
40
|
+
let sheets;
|
|
41
|
+
try {
|
|
42
|
+
sheets = readSheets(buffer);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
46
|
+
}
|
|
47
|
+
const plan = sheets.find((s) => lower(s.name) === 'plan');
|
|
48
|
+
const readMe = sheets.find((s) => lower(s.name) === 'read me');
|
|
49
|
+
const stampCell = readMe?.rows.flat().find((c) => lower(c).startsWith(lower(EXPORT_STAMP)));
|
|
50
|
+
const exportStamp = stampCell ? stampCell.trim().slice(EXPORT_STAMP.length).trim() : '';
|
|
51
|
+
if (!plan || !STAMP.test(exportStamp) || Number.isNaN(Date.parse(exportStamp)))
|
|
52
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
53
|
+
const at = plan.rows.findIndex((r) => r.some((c) => c.trim() !== ''));
|
|
54
|
+
const header = at === -1 ? [] : plan.rows[at].map((c) => c.trim());
|
|
55
|
+
// Column name → index; the first column of a name wins, names it does not know are ignored.
|
|
56
|
+
const index = new Map();
|
|
57
|
+
header.forEach((name, i) => {
|
|
58
|
+
const canonical = CANONICAL.get(lower(name));
|
|
59
|
+
if (canonical && !index.has(canonical))
|
|
60
|
+
index.set(canonical, i);
|
|
61
|
+
});
|
|
62
|
+
if (!index.has('Object') || !index.has('Type'))
|
|
63
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (const raw of plan.rows.slice(at + 1)) {
|
|
66
|
+
const row = {};
|
|
67
|
+
for (const [name, i] of index)
|
|
68
|
+
row[name] = (raw[i] ?? '').trim();
|
|
69
|
+
if (Object.values(row).every((v) => v === ''))
|
|
70
|
+
continue;
|
|
71
|
+
rows.push(row);
|
|
72
|
+
}
|
|
73
|
+
return { exportStamp, header, rows };
|
|
74
|
+
}
|
|
75
|
+
// ── cells ────────────────────────────────────────────────────────────────────
|
|
76
|
+
/** 1–5 (also `2.0`, ` 2 `), `defer` in any case, '' = blank, undefined = unreadable. */
|
|
77
|
+
function priorityCell(cell) {
|
|
78
|
+
const v = cell.trim();
|
|
79
|
+
if (!v)
|
|
80
|
+
return '';
|
|
81
|
+
if (lower(v) === 'defer')
|
|
82
|
+
return 'defer';
|
|
83
|
+
const m = /^([1-5])(?:\.0+)?$/.exec(v);
|
|
84
|
+
return m ? Number(m[1]) : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** A real day as YYYY-MM-DD, or an Excel serial (46000 → 2025-12-09); '' = blank; undefined = unreadable. */
|
|
87
|
+
function dateCell(cell) {
|
|
88
|
+
const v = cell.trim();
|
|
89
|
+
if (!v)
|
|
90
|
+
return '';
|
|
91
|
+
if (DAY.test(v)) {
|
|
92
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
93
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v ? v : undefined;
|
|
94
|
+
}
|
|
95
|
+
if (SERIAL.test(v))
|
|
96
|
+
return new Date(EXCEL_EPOCH + Math.floor(Number(v)) * 86_400_000).toISOString().slice(0, 10);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function scopeCell(cell) {
|
|
100
|
+
const v = lower(cell);
|
|
101
|
+
if (!v)
|
|
102
|
+
return '';
|
|
103
|
+
// `1`/`0`: a cell Excel saved as a boolean reads back as a number.
|
|
104
|
+
if (v === 'yes' || v === 'y' || v === 'true' || v === '1')
|
|
105
|
+
return true;
|
|
106
|
+
if (v === 'no' || v === 'n' || v === 'false' || v === '0')
|
|
107
|
+
return false;
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
/** A lane id from `Owner (id)` (as exported), an owner name or a lane id, any case. '' = blank; undefined = unknown. */
|
|
111
|
+
function laneCell(cell, register, current) {
|
|
112
|
+
const v = cell.trim();
|
|
113
|
+
if (!v)
|
|
114
|
+
return '';
|
|
115
|
+
const byId = (id) => register.lanes.find((l) => lower(l.id) === lower(id))?.id;
|
|
116
|
+
const label = /^(.*)\(([^()]+)\)\s*$/.exec(v);
|
|
117
|
+
if (label) {
|
|
118
|
+
const id = byId(label[2]);
|
|
119
|
+
if (id && lower(register.lanes.find((l) => l.id === id).owner) === lower(label[1]))
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
const id = byId(v);
|
|
123
|
+
if (id)
|
|
124
|
+
return id;
|
|
125
|
+
const owned = register.lanes.filter((l) => lower(l.owner) === lower(v));
|
|
126
|
+
if (owned.length === 1)
|
|
127
|
+
return owned[0].id;
|
|
128
|
+
// An owner of several lanes: the lane the object is in now, if it is one of them.
|
|
129
|
+
if (owned.length > 1 && owned.some((l) => l.id === current))
|
|
130
|
+
return current;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
// ── values the plan holds for an object ─────────────────────────────────────
|
|
134
|
+
const manualOf = (o) => {
|
|
135
|
+
const e = o.priority.effective;
|
|
136
|
+
return e.source === 'set by hand' || e.source === 'Excel plan' ? e.band : null;
|
|
137
|
+
};
|
|
138
|
+
const laneLabel = (register, id) => {
|
|
139
|
+
const l = id ? register.lanes.find((x) => x.id === id) : undefined;
|
|
140
|
+
return l ? `${l.owner} (${l.id})` : 'no lane';
|
|
141
|
+
};
|
|
142
|
+
const shownValue = (v) => (v === '' ? 'blank' : v);
|
|
143
|
+
/** The cell as the fact columns compare it: trimmed, and a number read as a number (3 = "3" = "3.0"). */
|
|
144
|
+
function sameFact(a, b) {
|
|
145
|
+
const x = a === null || a === undefined ? '' : String(a).trim();
|
|
146
|
+
const y = b === null || b === undefined ? '' : String(b).trim();
|
|
147
|
+
if (x === y)
|
|
148
|
+
return true;
|
|
149
|
+
return x !== '' && y !== '' && Number.isFinite(Number(x)) && Number(x) === Number(y);
|
|
150
|
+
}
|
|
151
|
+
/** field → newest (by, at) of a plan, decision or ruling record newer than the export, per object. */
|
|
152
|
+
function newerTouches(records, stamp) {
|
|
153
|
+
const out = new Map();
|
|
154
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
155
|
+
for (const r of records) {
|
|
156
|
+
if (!isObj(r) || typeof r.at !== 'string' || !(r.at > stamp) || !Array.isArray(r.rows))
|
|
157
|
+
continue;
|
|
158
|
+
const by = isObj(r.by) && typeof r.by.name === 'string' ? r.by.name : 'someone';
|
|
159
|
+
for (const row of r.rows) {
|
|
160
|
+
if (!isObj(row) || typeof row.key !== 'string')
|
|
161
|
+
continue;
|
|
162
|
+
const key = normalizeKey(row.key);
|
|
163
|
+
if (!key)
|
|
164
|
+
continue;
|
|
165
|
+
const fields = [];
|
|
166
|
+
if (r.recordType === 'decision' || r.recordType === 'ruling')
|
|
167
|
+
fields.push('Priority');
|
|
168
|
+
else if (r.recordType === 'plan') {
|
|
169
|
+
const a = r.action;
|
|
170
|
+
if (a === 'priority' || (a === 'excel' && 'priority' in row))
|
|
171
|
+
fields.push('Priority');
|
|
172
|
+
if (a === 'assign' || (a === 'excel' && 'lane' in row))
|
|
173
|
+
fields.push('Lane');
|
|
174
|
+
if (a === 'scope' || (a === 'excel' && 'inScope' in row))
|
|
175
|
+
fields.push('In scope');
|
|
176
|
+
if ((a === 'note' || a === 'excel') && 'targetDate' in row)
|
|
177
|
+
fields.push('Target date');
|
|
178
|
+
if ((a === 'note' || a === 'excel') && 'note' in row)
|
|
179
|
+
fields.push('Note');
|
|
180
|
+
}
|
|
181
|
+
for (const f of fields) {
|
|
182
|
+
const byField = out.get(key) ?? new Map();
|
|
183
|
+
const had = byField.get(f);
|
|
184
|
+
if (!had || r.at > had.at)
|
|
185
|
+
byField.set(f, { by, at: r.at });
|
|
186
|
+
out.set(key, byField);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The changes the sheet asks for. `register` is the plan now; `exported` the
|
|
194
|
+
* plan as the export saw it (built from the records up to the export stamp),
|
|
195
|
+
* the current one when not given; `records` every record the folder holds, for
|
|
196
|
+
* the clashes.
|
|
197
|
+
*/
|
|
198
|
+
export function diffPlan(sheet, register, records, opts = {}) {
|
|
199
|
+
const exported = opts.exported ?? register;
|
|
200
|
+
const now = new Map(register.objects.map((o) => [o.key, o]));
|
|
201
|
+
const then = new Map(exported.objects.map((o) => [o.key, o]));
|
|
202
|
+
const exportedFacts = new Map();
|
|
203
|
+
for (const row of planRows(exported)) {
|
|
204
|
+
exportedFacts.set(objectKey(String(row[1]), String(row[0])), Object.fromEntries(PLAN_COLUMNS.map((c, i) => [c, row[i]])));
|
|
205
|
+
}
|
|
206
|
+
const touched = newerTouches(records, sheet.exportStamp);
|
|
207
|
+
const counts = {
|
|
208
|
+
priorities: 0, moves: 0, scope: 0, notes: 0, unchanged: 0, notInTeamProject: 0, clashes: 0, factEditsIgnored: 0, unknownLanes: 0, unreadableCells: 0, blankCells: 0,
|
|
209
|
+
};
|
|
210
|
+
const out = { rows: [], counts, clashes: [], unknownLanes: [], exportStamp: sheet.exportStamp };
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const cells of sheet.rows) {
|
|
213
|
+
if (Object.values(cells).every((v) => !v || !v.trim()))
|
|
214
|
+
continue; // a blank row says nothing
|
|
215
|
+
const type = cells.Type ?? '';
|
|
216
|
+
const name = cells.Object ?? '';
|
|
217
|
+
const key = type && name ? objectKey(type, name) : '';
|
|
218
|
+
const o = key ? now.get(key) : undefined;
|
|
219
|
+
if (!o) {
|
|
220
|
+
counts.notInTeamProject += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (seen.has(key))
|
|
224
|
+
continue; // the first row of an object counts
|
|
225
|
+
seen.add(key);
|
|
226
|
+
const was = then.get(key) ?? o;
|
|
227
|
+
const row = { key };
|
|
228
|
+
let unreadable = 0;
|
|
229
|
+
let blank = 0;
|
|
230
|
+
let clashed = 0;
|
|
231
|
+
let unknownLane = false;
|
|
232
|
+
/** A change the lead made in the sheet: skipped when a newer record touched the same field. */
|
|
233
|
+
const change = (field, sheetText, nowText, apply) => {
|
|
234
|
+
const newer = touched.get(key)?.get(field);
|
|
235
|
+
if (newer) {
|
|
236
|
+
out.clashes.push({ key, field, sheet: shownValue(sheetText), now: shownValue(nowText), by: newer.by, at: newer.at });
|
|
237
|
+
clashed += 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
apply();
|
|
241
|
+
};
|
|
242
|
+
if ('Priority' in cells) {
|
|
243
|
+
const v = priorityCell(cells.Priority);
|
|
244
|
+
if (v === undefined)
|
|
245
|
+
unreadable += 1;
|
|
246
|
+
else {
|
|
247
|
+
const desired = v === '' ? null : v;
|
|
248
|
+
if (desired !== manualOf(was) && desired !== manualOf(o)) {
|
|
249
|
+
change('Priority', cells.Priority, String(o.priority.effective.band), () => { row.priority = desired; counts.priorities += 1; });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if ('Lane' in cells) {
|
|
254
|
+
const v = laneCell(cells.Lane, register, o.lane);
|
|
255
|
+
const exportedCell = laneLabel(exported, was.lane);
|
|
256
|
+
if (v === undefined) {
|
|
257
|
+
// A label the export wrote for a lane removed since is not the lead's edit.
|
|
258
|
+
if (!sameFact(cells.Lane, exportedCell)) {
|
|
259
|
+
unknownLane = true;
|
|
260
|
+
out.unknownLanes.push({ key, lane: cells.Lane });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (v === '') {
|
|
264
|
+
// Blank gives an object the lead moved back to the lane patterns; a pattern lane has nothing to give back.
|
|
265
|
+
if (was.laneFrom === 'assigned' && o.laneFrom === 'assigned') {
|
|
266
|
+
change('Lane', '', laneLabel(register, o.lane), () => { row.lane = null; counts.moves += 1; });
|
|
267
|
+
}
|
|
268
|
+
else if (was.laneFrom !== 'assigned' && o.laneFrom !== 'assigned' && o.lane !== null) {
|
|
269
|
+
blank += 1; // a lane from the patterns: blank cannot take it away
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (v !== was.lane && v !== o.lane) {
|
|
273
|
+
change('Lane', cells.Lane, laneLabel(register, o.lane), () => { row.lane = v; counts.moves += 1; });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if ('In scope' in cells) {
|
|
277
|
+
const v = scopeCell(cells['In scope']);
|
|
278
|
+
if (v === undefined)
|
|
279
|
+
unreadable += 1;
|
|
280
|
+
else if (v === '')
|
|
281
|
+
blank += 1; // in scope is yes or no, never nothing
|
|
282
|
+
else if (v !== was.inScope && v !== o.inScope) {
|
|
283
|
+
change('In scope', cells['In scope'], o.inScope ? 'yes' : 'no', () => { row.inScope = v; counts.scope += 1; });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let noted = false;
|
|
287
|
+
if ('Target date' in cells) {
|
|
288
|
+
const v = dateCell(cells['Target date']);
|
|
289
|
+
const before = was.plan.targetDate ?? '';
|
|
290
|
+
const current = o.plan.targetDate ?? '';
|
|
291
|
+
if (v === undefined)
|
|
292
|
+
unreadable += 1;
|
|
293
|
+
else if (v !== before && v !== current) {
|
|
294
|
+
change('Target date', cells['Target date'], current, () => { row.targetDate = v; noted = true; });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if ('Note' in cells) {
|
|
298
|
+
const v = cells.Note;
|
|
299
|
+
const before = was.plan.note ?? '';
|
|
300
|
+
const current = o.plan.note ?? '';
|
|
301
|
+
if (v.length > MAX_NOTE)
|
|
302
|
+
unreadable += 1;
|
|
303
|
+
else if (v !== before && v !== current) {
|
|
304
|
+
change('Note', v, current, () => { row.note = v; noted = true; });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (noted)
|
|
308
|
+
counts.notes += 1;
|
|
309
|
+
const facts = exportedFacts.get(key);
|
|
310
|
+
for (const f of FACT_COLUMNS)
|
|
311
|
+
if (f in cells && facts && !sameFact(cells[f], facts[f]))
|
|
312
|
+
counts.factEditsIgnored += 1;
|
|
313
|
+
counts.unreadableCells += unreadable;
|
|
314
|
+
counts.blankCells += blank;
|
|
315
|
+
counts.clashes += clashed;
|
|
316
|
+
if (unknownLane)
|
|
317
|
+
counts.unknownLanes += 1;
|
|
318
|
+
const accepted = Object.keys(row).length > 1;
|
|
319
|
+
if (accepted)
|
|
320
|
+
out.rows.push(row);
|
|
321
|
+
else if (!unreadable && !blank && !clashed && !unknownLane)
|
|
322
|
+
counts.unchanged += 1;
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
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
|