@cspeach/cli 1.1.47 → 1.1.49
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/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +7 -6
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/config-set.js +5 -5
- package/dist/commands/cost.js +2 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/project.js +301 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/config/loader.js +13 -0
- package/dist/cost/cost-log.js +7 -4
- package/dist/cost/credits-wire.js +11 -6
- package/dist/cost/credits.js +15 -0
- package/dist/cost/display-mode.js +16 -4
- package/dist/doctor/checks/zcspeach.js +96 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/email-template.js +56 -0
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-cca.js +0 -19
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/status-footer.js +2 -1
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +21 -8
- package/dist/router/router-core.js +5 -3
- package/dist/session/audit-export.js +4 -1
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/cca_merge.js +1 -11
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +19 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer-line.js +19 -1
- package/dist/ui/header.js +41 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sidebar.js +12 -0
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-line.js +62 -0
- package/dist/ui/status-row.js +38 -0
- package/dist/ui/todo-panel.js +85 -0
- package/dist/ui/widgets/ask-form.js +250 -0
- package/dist/ui/widgets/ask-question-modal.js +178 -0
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/live-check-2026-10-17-1343.txt +41 -0
- package/package.json +121 -121
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
|
@@ -1,47 +1,58 @@
|
|
|
1
1
|
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
2
|
+
import { withoutRuleId } from '../register/records.js';
|
|
2
3
|
import { STEPS, stepOf } from '../journey/clean-core.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
|
|
4
|
+
import { proposalOf, viewWords } from '@cspeach/register-core';
|
|
5
|
+
const NO_TARGET = { level: null, from: 'none', by: null };
|
|
6
|
+
/**
|
|
7
|
+
* The row in words: the project view's proposal words (project view Task 5),
|
|
8
|
+
* then the proposal's reason. The card never says the target the model
|
|
9
|
+
* proposed — a Yes does not set one. It says the object's target as the view
|
|
10
|
+
* reads it (`team decide --target`, or the lead's `team target`), else "no target set".
|
|
11
|
+
*/
|
|
12
|
+
export function rowWords(r, target) {
|
|
13
|
+
return `${viewWords.proposal(proposalOf(r), target ?? NO_TARGET)}${r.reason ? `, because: ${r.reason}` : ''}`;
|
|
14
14
|
}
|
|
15
|
+
async function retireYes(r, reason, w) {
|
|
16
|
+
if (w.lead === true && w.rule) {
|
|
17
|
+
try {
|
|
18
|
+
await w.rule({ type: r.objectType.trim().toUpperCase(), name: r.objectName.trim().toUpperCase(), reason });
|
|
19
|
+
return { kind: 'ruled' };
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return { kind: 'failed', line: `Not recorded for ${shown(r.objectType)} ${shown(r.objectName)}: ${shownUpTo(e instanceof Error ? e.message : String(e), 200)}` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { kind: 'candidate', reason };
|
|
26
|
+
}
|
|
27
|
+
const ruledLine = (type, name) => `✓ Recorded: ${type} ${name} — retired, reason recorded.`;
|
|
28
|
+
const candidateLine = (type, name) => `✓ Recorded: ${type} ${name} — retire candidate, your reason recorded.`;
|
|
15
29
|
const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
|
|
16
|
-
/** What a recorded reason keeps (records.ts). */
|
|
17
|
-
const MAX_ROW_REASON = 300;
|
|
18
30
|
const RULE_ROW = {
|
|
19
31
|
keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire',
|
|
20
32
|
};
|
|
21
|
-
/**
|
|
22
|
-
const
|
|
33
|
+
/** The fact a row that agrees with its rule carries: the rule is said by its note, never as an id inside the reason. */
|
|
34
|
+
export const matchesRuleLine = (note) => `Matches your rule: ${note}`;
|
|
23
35
|
/**
|
|
24
|
-
* The row read against the rule that decides its object.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
36
|
+
* The row read against the rule that decides its object. The reason is always
|
|
37
|
+
* the proposal's own words: a "Rule <id>:" the model wrote is taken off, and
|
|
38
|
+
* no rule id is put in (project view Task 4 — the recorded reason is the
|
|
39
|
+
* proposal's text, marked as such). Agrees (same disposition): `matches` is
|
|
40
|
+
* the fact line "Matches your rule: <note>". Differs: `differs` is the fact
|
|
41
|
+
* line "Differs from rule <id>: <why>". No rule: neither. The person still
|
|
29
42
|
* answers the card either way.
|
|
30
43
|
*/
|
|
31
44
|
export function ruleRow(r, p) {
|
|
32
|
-
const own = (r.reason ?? '').
|
|
45
|
+
const own = withoutRuleId(r.reason ?? '').trim();
|
|
46
|
+
const row = own === r.reason ? r : { ...r, reason: own };
|
|
33
47
|
if (!p)
|
|
34
|
-
return { row
|
|
35
|
-
const id = shown(p.ruleId);
|
|
48
|
+
return { row };
|
|
36
49
|
const why = shownUpTo(p.why, 200);
|
|
37
50
|
// Agrees = same disposition, and the same target when the rule itself names one (owner ruling, review 9 #7).
|
|
38
51
|
const targetDiffers = p.targetFrom === 'rule' && p.target !== undefined && r.targetLevel !== p.target;
|
|
39
52
|
if (RULE_ROW[p.disposition] !== r.disposition || targetDiffers) {
|
|
40
|
-
return { row
|
|
53
|
+
return { row, differs: `Differs from rule ${shown(p.ruleId)}: ${why}` };
|
|
41
54
|
}
|
|
42
|
-
|
|
43
|
-
const head = `Rule ${id}: `;
|
|
44
|
-
return { row: { ...r, reason: head + Array.from(own || why).slice(0, Math.max(0, MAX_ROW_REASON - head.length)).join('') } };
|
|
55
|
+
return { row, matches: matchesRuleLine(why) };
|
|
45
56
|
}
|
|
46
57
|
export async function confirmCleanCoreRows(ex, a) {
|
|
47
58
|
if (!a.joined) {
|
|
@@ -54,12 +65,56 @@ export async function confirmCleanCoreRows(ex, a) {
|
|
|
54
65
|
return ex;
|
|
55
66
|
}
|
|
56
67
|
const rows = [];
|
|
68
|
+
let ruled = 0;
|
|
57
69
|
for (const given of ex.content.adjudications) {
|
|
58
|
-
const { row: r, differs } = a.rules ? ruleRow(given, a.rules(keyOf(given))) : { row: given, differs: undefined };
|
|
59
|
-
const
|
|
60
|
-
|
|
70
|
+
const { row: r, differs, matches } = a.rules ? ruleRow(given, a.rules(keyOf(given))) : { row: given, differs: undefined, matches: undefined };
|
|
71
|
+
const note = differs ?? matches;
|
|
72
|
+
const question = `Record ${r.objectType} ${r.objectName}: ${rowWords(r, a.targets?.(keyOf(r)))}?${note ? ` (${note})` : ''}`;
|
|
73
|
+
if (r.disposition !== 'retire') {
|
|
74
|
+
const answer = await a.confirm(question, { default: 'n' });
|
|
75
|
+
// A Yes accepts the proposal's words (project view §3.2): marked so, never as the person's.
|
|
76
|
+
rows.push(answer === 'y' ? { ...r, reasonBy: 'proposal', confirmed: true } : { ...r, confirmed: false });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Task 6 round 1 (B4): a retire Yes asks what the person checked; the model's words are never recorded.
|
|
80
|
+
let done = { ...r, confirmed: false };
|
|
81
|
+
for (let tries = 0; tries < 5; tries++) {
|
|
82
|
+
if ((await a.confirm(question, { default: 'n' })) !== 'y')
|
|
83
|
+
break;
|
|
84
|
+
let said = '';
|
|
85
|
+
if (a.ask) {
|
|
86
|
+
try {
|
|
87
|
+
said = await a.ask(RETIRE_QUESTION);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
said = '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const reason = cleanKeepReason(said);
|
|
94
|
+
if (!reason) {
|
|
95
|
+
a.log(retireNeedsLine(shown(r.objectType), shown(r.objectName)));
|
|
96
|
+
if (!a.ask)
|
|
97
|
+
break; // nothing can ask: never the same card over and over
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const out = await retireYes(r, reason, a);
|
|
101
|
+
if (out.kind === 'failed') {
|
|
102
|
+
a.log(out.line);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (out.kind === 'ruled') {
|
|
106
|
+
ruled += 1;
|
|
107
|
+
a.log(ruledLine(shown(r.objectType), shown(r.objectName)));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
done = { ...r, reason: out.reason, reasonBy: 'person', confirmed: true };
|
|
111
|
+
a.log(candidateLine(shown(r.objectType), shown(r.objectName)));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
rows.push(done);
|
|
61
115
|
}
|
|
62
|
-
|
|
116
|
+
// A ruling is recorded too (in the lead's own folder), so it counts as confirmed.
|
|
117
|
+
const yes = rows.filter((r) => r.confirmed).length + ruled;
|
|
63
118
|
a.log(`${yes} of ${rows.length} rows confirmed. To record a row differently: ${DECIDE_HINT}`);
|
|
64
119
|
const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
|
|
65
120
|
return {
|
|
@@ -69,6 +124,10 @@ export async function confirmCleanCoreRows(ex, a) {
|
|
|
69
124
|
};
|
|
70
125
|
}
|
|
71
126
|
export const KEEP_QUESTION = 'Why keep it?';
|
|
127
|
+
/** Task 6 (B4): asked on a retire row's Yes, and on the lead's retire card. */
|
|
128
|
+
export const RETIRE_QUESTION = 'What did you check?';
|
|
129
|
+
/** A retire Yes with nothing typed: nothing is recorded, and the card comes again. */
|
|
130
|
+
export const retireNeedsLine = (type, name) => `A retire needs what you checked. Nothing was recorded for ${type} ${name} yet.`;
|
|
72
131
|
/** "Yes to the remaining n shown" is offered once this many rows are answered. */
|
|
73
132
|
const OFFER_REST_AFTER = 3;
|
|
74
133
|
const MAX_KEEP_REASON = 300;
|
|
@@ -81,31 +140,44 @@ export function cleanKeepReason(raw) {
|
|
|
81
140
|
return Array.from(shownUpTo(raw ?? '', Number.POSITIVE_INFINITY)).slice(0, MAX_KEEP_REASON).join('').trim();
|
|
82
141
|
}
|
|
83
142
|
/** The proposal in words, without the reason (the reason is a fact line on the card). */
|
|
84
|
-
function proposalWords(r) {
|
|
85
|
-
return rowWords({ ...r, reason: '' });
|
|
143
|
+
function proposalWords(r, target) {
|
|
144
|
+
return rowWords({ ...r, reason: '' }, target);
|
|
86
145
|
}
|
|
87
146
|
export async function decideCleanCoreRows(ex, j) {
|
|
88
147
|
const all = ex.content.adjudications;
|
|
89
|
-
const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined }));
|
|
148
|
+
const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined, matches: undefined }));
|
|
90
149
|
const rows = ruled.map(({ row }) => ({ ...row, confirmed: false }));
|
|
91
|
-
const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false };
|
|
150
|
+
const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false, ruled: [], candidates: [] };
|
|
92
151
|
const footnote = `Step ${stepOf('DECIDE')} of ${STEPS}`;
|
|
152
|
+
const isRetire = (r) => r.disposition === 'retire';
|
|
153
|
+
/** "Yes to the remaining n shown" said: the rows that are not a retire are recorded as proposed; each retire is still asked. */
|
|
154
|
+
let restYes = false;
|
|
93
155
|
let answered = 0;
|
|
94
156
|
rowLoop: for (let i = 0; i < rows.length; i++) {
|
|
95
157
|
const r = rows[i];
|
|
158
|
+
// Recorded already, when "Yes to the remaining n" was said.
|
|
159
|
+
if (restYes && !isRetire(r))
|
|
160
|
+
continue;
|
|
96
161
|
const type = shown(r.objectType);
|
|
97
162
|
const name = shown(r.objectName);
|
|
98
|
-
|
|
163
|
+
// Task 6: "the remaining n" counts only what it records — a retire is always asked on its own.
|
|
164
|
+
const left = rows.slice(i).filter((x) => !isRetire(x)).length;
|
|
99
165
|
const why = shownUpTo(r.reason ?? '', MAX_KEEP_REASON);
|
|
100
166
|
let canKeep = true;
|
|
101
167
|
let settled = false;
|
|
102
168
|
for (let tries = 0; tries < 20 && !settled; tries++) {
|
|
103
|
-
const offerRest = answered >= OFFER_REST_AFTER && left >= 2;
|
|
169
|
+
const offerRest = !restYes && answered >= OFFER_REST_AFTER && left >= 2;
|
|
104
170
|
const res = await j.card({
|
|
105
171
|
id: 'journey-row',
|
|
106
172
|
tag: 'QUESTION',
|
|
107
|
-
|
|
108
|
-
|
|
173
|
+
// Project view Task 5: what was proposed, in the view's words; the reason is the proposal's, never the person's.
|
|
174
|
+
title: `Record ${type} ${name} as proposed: ${proposalWords({ ...r, objectType: type, objectName: name }, j.targets?.(keyOf(r)))}?`,
|
|
175
|
+
facts: [
|
|
176
|
+
...(why ? [viewWords.cardReason({ text: why, from: 'proposal' })] : []),
|
|
177
|
+
...(ruled[i].differs ? [ruled[i].differs] : []),
|
|
178
|
+
...(ruled[i].matches ? [ruled[i].matches] : []),
|
|
179
|
+
`Row ${i + 1} of ${rows.length}.`,
|
|
180
|
+
],
|
|
109
181
|
choices: [
|
|
110
182
|
{ value: 'yes', label: 'Yes' },
|
|
111
183
|
...(canKeep ? [{ value: 'keep', label: 'Keep as is' }] : []),
|
|
@@ -123,14 +195,55 @@ export async function decideCleanCoreRows(ex, j) {
|
|
|
123
195
|
break rowLoop;
|
|
124
196
|
}
|
|
125
197
|
if (res.value === 'all' && offerRest) {
|
|
198
|
+
// Round 1: every row from here that is not a retire is recorded now, so a card put away later keeps them.
|
|
199
|
+
restYes = true;
|
|
126
200
|
for (let k = i; k < rows.length; k++) {
|
|
127
|
-
|
|
201
|
+
if (isRetire(rows[k]))
|
|
202
|
+
continue;
|
|
203
|
+
rows[k] = { ...rows[k], reasonBy: 'proposal', confirmed: true };
|
|
128
204
|
tally.yes.push(keyOf(rows[k]));
|
|
129
205
|
}
|
|
130
|
-
|
|
206
|
+
// This row is a retire: its own card again, without the offer.
|
|
207
|
+
if (isRetire(r))
|
|
208
|
+
continue;
|
|
209
|
+
settled = true;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (res.value === 'yes' && isRetire(r)) {
|
|
213
|
+
// Task 6 (B4): what the person checked is the reason recorded — never the model's words.
|
|
214
|
+
let said;
|
|
215
|
+
try {
|
|
216
|
+
said = await j.ask(RETIRE_QUESTION);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
tally.cancelled = true;
|
|
220
|
+
break rowLoop;
|
|
221
|
+
}
|
|
222
|
+
const reason = cleanKeepReason(said);
|
|
223
|
+
if (!reason) {
|
|
224
|
+
j.log(retireNeedsLine(type, name));
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// The lead's Yes is the ruling; the row stays unconfirmed, so no writer row is added.
|
|
228
|
+
const out = await retireYes(r, reason, j);
|
|
229
|
+
if (out.kind === 'failed') {
|
|
230
|
+
j.log(out.line);
|
|
231
|
+
tally.skipped.push(keyOf(r));
|
|
232
|
+
j.skip([keyOf(r)]);
|
|
233
|
+
}
|
|
234
|
+
else if (out.kind === 'ruled') {
|
|
235
|
+
tally.ruled.push(keyOf(r));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
rows[i] = { ...r, reason: out.reason, reasonBy: 'person', confirmed: true };
|
|
239
|
+
tally.yes.push(keyOf(r));
|
|
240
|
+
tally.candidates.push(keyOf(r));
|
|
241
|
+
}
|
|
242
|
+
settled = true;
|
|
243
|
+
continue;
|
|
131
244
|
}
|
|
132
245
|
if (res.value === 'yes') {
|
|
133
|
-
rows[i] = { ...r, confirmed: true };
|
|
246
|
+
rows[i] = { ...r, reasonBy: 'proposal', confirmed: true };
|
|
134
247
|
tally.yes.push(keyOf(r));
|
|
135
248
|
settled = true;
|
|
136
249
|
continue;
|
|
@@ -160,7 +273,7 @@ export async function decideCleanCoreRows(ex, j) {
|
|
|
160
273
|
j.log(`A keep needs a reason. Nothing was recorded for ${type} ${name} yet.`);
|
|
161
274
|
continue;
|
|
162
275
|
}
|
|
163
|
-
rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, confirmed: true };
|
|
276
|
+
rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, reasonBy: 'person', confirmed: true };
|
|
164
277
|
tally.kept.push({ key: keyOf(r), type: r.objectType.toUpperCase(), name: r.objectName.toUpperCase(), level: r.level });
|
|
165
278
|
settled = true;
|
|
166
279
|
}
|
|
@@ -191,9 +304,18 @@ export async function decideCleanCoreRows(ex, j) {
|
|
|
191
304
|
export function journeyRowLines(t, refused) {
|
|
192
305
|
const out = new Set(refused.map((k) => k.trim().toUpperCase()));
|
|
193
306
|
const kept = t.kept.filter((k) => !out.has(k.key));
|
|
194
|
-
const
|
|
307
|
+
const ruled = t.ruled ?? [];
|
|
308
|
+
const candidates = (t.candidates ?? []).filter((k) => !out.has(k.toUpperCase()));
|
|
309
|
+
// A ruling is recorded too (in the lead's own folder; never refused here).
|
|
310
|
+
const yes = t.yes.filter((k) => !out.has(k.toUpperCase())).length + ruled.length;
|
|
311
|
+
const said = (key) => {
|
|
312
|
+
const [type = '', name = ''] = key.split('|');
|
|
313
|
+
return [shown(type), shown(name)];
|
|
314
|
+
};
|
|
195
315
|
return [
|
|
196
316
|
...kept.map((k) => `✓ Recorded: ${shown(k.type)} ${shown(k.name)} — kept at ${k.level}, reason recorded.`),
|
|
317
|
+
...ruled.map((k) => ruledLine(...said(k))),
|
|
318
|
+
...candidates.map((k) => candidateLine(...said(k))),
|
|
197
319
|
`${yes} of ${t.total} rows recorded; ${kept.length} kept by decision; ${t.skipped.length} skipped.`,
|
|
198
320
|
];
|
|
199
321
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const EDITOR_URL = process.env.CSPEACH_EDITOR_URL ?? 'https://cspeach.dev/edit';
|
|
2
|
+
const SUBJECT_BY_TYPE = {
|
|
3
|
+
'spec-gap': (t) => `Gap analysis — ${t}`,
|
|
4
|
+
'design': (t) => `Design — ${t}`,
|
|
5
|
+
'estimate': (t) => `Estimate — ${t}`,
|
|
6
|
+
'cca': (t) => `Custom code analysis — ${t}`,
|
|
7
|
+
'incident': (t) => `Incident report — ${t}`,
|
|
8
|
+
'upgrade-scan': (t) => `S/4HANA upgrade scan — ${t}`,
|
|
9
|
+
'upgrade-progress': (t) => `Upgrade remediation progress — ${t}`,
|
|
10
|
+
'upgrade-report': (t) => `Upgrade remediation report — ${t}`,
|
|
11
|
+
'cca-assessment': (t) => `Custom code assessment — ${t}`,
|
|
12
|
+
'modernize-result': (t) => `Modernization result — ${t}`,
|
|
13
|
+
'test-coverage': (t) => `Test coverage report — ${t}`,
|
|
14
|
+
'plan': (t) => `Project plan — ${t}`,
|
|
15
|
+
};
|
|
16
|
+
const BODY_BY_TYPE = {
|
|
17
|
+
'spec-gap': (a) => `Attached is the gap analysis for the ${a.title}.\n${a.itemCount} ${a.itemNoun} across business / data / authorisation / edge cases.`,
|
|
18
|
+
'design': (a) => `Attached is the architecture design for the ${a.title}.\n${a.itemCount} ${a.itemNoun} to review (keep / redesign / defer).`,
|
|
19
|
+
'estimate': (a) => `Attached is the effort estimate for the ${a.title}.\n${a.itemCount} ${a.itemNoun} to review (confirm / dispute / defer).`,
|
|
20
|
+
'cca': (a) => `Attached is the custom code analysis for the ${a.title}.\n${a.itemCount} ${a.itemNoun} classified.`,
|
|
21
|
+
'incident': (a) => `Attached is the incident report for the ${a.title}.`,
|
|
22
|
+
'upgrade-scan': (a) => `Attached is the S/4HANA upgrade scan baseline for ${a.title}.\n${a.itemCount} ${a.itemNoun} to remediate.`,
|
|
23
|
+
'upgrade-progress': (a) => `Attached is the upgrade remediation progress for ${a.title}.\n${a.itemCount} ${a.itemNoun} attempted.`,
|
|
24
|
+
'upgrade-report': (a) => `Attached is the upgrade remediation report for ${a.title}.\n${a.itemCount} ${a.itemNoun} listed (regressions to address before sign-off).`,
|
|
25
|
+
'cca-assessment': (a) => `Attached is the custom code assessment for ${a.title}.\n${a.itemCount} ${a.itemNoun} classified — review keep / fix / retire / redesign per object.`,
|
|
26
|
+
'modernize-result': (a) => `Attached is the modernization result for ${a.title}.\n${a.itemCount} ${a.itemNoun} attempted — review applied / skipped / failed per object before sign-off.`,
|
|
27
|
+
'test-coverage': (a) => `Attached is the test coverage report for ${a.title}.\n${a.itemCount} test ${a.itemNoun} generated — review the targets + method counts before merging.`,
|
|
28
|
+
'plan': (a) => `Attached is the project plan for ${a.title}.\n${a.itemCount} ${a.itemNoun} ordered bottom-up — each phase runs in its own session via /abap-plan --resume.`,
|
|
29
|
+
};
|
|
30
|
+
export function renderEmailTemplate(a) {
|
|
31
|
+
const subject = SUBJECT_BY_TYPE[a.artefactType](a.title);
|
|
32
|
+
const body = BODY_BY_TYPE[a.artefactType](a);
|
|
33
|
+
return [
|
|
34
|
+
'─────────────────────────────────────────────────────────────────',
|
|
35
|
+
'Suggested email — copy from below the line, edit, send',
|
|
36
|
+
'─────────────────────────────────────────────────────────────────',
|
|
37
|
+
`Subject: ${subject}`,
|
|
38
|
+
'',
|
|
39
|
+
'Hi,',
|
|
40
|
+
'',
|
|
41
|
+
body,
|
|
42
|
+
'',
|
|
43
|
+
'To review:',
|
|
44
|
+
` 1. Open ${EDITOR_URL}`,
|
|
45
|
+
` 2. Drag the attached ${a.filename} onto the page`,
|
|
46
|
+
' 3. Click each item, mark its status, add comments',
|
|
47
|
+
' 4. Click "Download updated file"',
|
|
48
|
+
' 5. Email me the downloaded file',
|
|
49
|
+
'',
|
|
50
|
+
'If your email blocks .json, rename it to .cspeach.json.txt before sending —',
|
|
51
|
+
'the editor accepts both.',
|
|
52
|
+
'',
|
|
53
|
+
'Thanks,',
|
|
54
|
+
a.author,
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parser for the `<!-- csforge:cca-manifest ... -->` block that
|
|
3
|
-
* /abap-cca
|
|
4
|
-
*
|
|
5
|
-
* deterministic extractor.
|
|
3
|
+
* /abap-cca emitted at the end of its markdown output (an old saved one
|
|
4
|
+
* still loads; the register reads it for decision records). Hidden from
|
|
5
|
+
* human renderers, structured enough for a deterministic extractor.
|
|
6
6
|
*
|
|
7
7
|
* Format mirrors the upgrade-manifest pattern (see extract-upgrade.ts):
|
|
8
8
|
*
|
|
@@ -104,9 +104,10 @@ function asConfidence(raw) {
|
|
|
104
104
|
/**
|
|
105
105
|
* Parse the optional 6th table cell on a merged row:
|
|
106
106
|
* `conflict=<label>:<cls>;<label>:<cls>`
|
|
107
|
-
* (rendered by merge-cca.ts
|
|
108
|
-
* for absent / unrecognisable cells;
|
|
109
|
-
* skipped pair-wise so one typo
|
|
107
|
+
* (rendered by the former merge-cca.ts, cut 2026-10-20; files it saved
|
|
108
|
+
* still load). Returns undefined for absent / unrecognisable cells;
|
|
109
|
+
* invalid classification values are skipped pair-wise so one typo
|
|
110
|
+
* doesn't drop the whole record.
|
|
110
111
|
*/
|
|
111
112
|
function parseConflictCell(raw) {
|
|
112
113
|
if (!raw)
|
|
@@ -165,7 +166,7 @@ export function extractCcaAssessment(markdown) {
|
|
|
165
166
|
// Battery S4 caught manifests whose counts contradicted their own rows
|
|
166
167
|
// (`retire: 0` next to 7 retire rows) and tables silently truncated
|
|
167
168
|
// (27/104 rows, no marker). These manifests are CHAIN INPUT for
|
|
168
|
-
//
|
|
169
|
+
// the register's decision records — a quiet save corrupts the estate
|
|
169
170
|
// numbers, so a contradiction fails the extract LOUDLY instead (same
|
|
170
171
|
// philosophy as extract-plan's loud-failure path in save-command.ts).
|
|
171
172
|
const truncated = kv.classifications_truncated === 'true';
|
package/dist/projects/index.js
CHANGED
|
@@ -6,7 +6,6 @@ export { canonicalSha256 } from './canonicalize.js';
|
|
|
6
6
|
export { titleSlug, shortId, projectFilename } from './filename.js';
|
|
7
7
|
export { runSaveCommand, detectArtifactSkill, peekPlanManifestTitle, resolvePriorPlanEnvelope } from './save-command.js';
|
|
8
8
|
export { offerAnswerBlockers, openBlockers, applyBlockerAnswers } from './answer-blockers.js';
|
|
9
|
-
export { mergeCcaAssessments, renderCcaMergeManifest, parseScopePatterns, scopePatternsIntersect, CCA_MANIFEST_ROW_CAP } from './merge-cca.js';
|
|
10
9
|
export { mergeUpgradeProgress, renderUpgradeMergeManifest } from './merge-upgrade.js';
|
|
11
10
|
export { extractDesign } from './extract-design.js';
|
|
12
11
|
export { extractEstimate } from './extract-estimate.js';
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* file must be consulted for full coverage
|
|
28
28
|
*/
|
|
29
29
|
import { OUTPUT_ROOT } from './output-paths.js';
|
|
30
|
-
import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
31
30
|
/** Higher = more restrictive. Conflict resolution picks the max. */
|
|
32
31
|
const RESTRICTIVENESS = {
|
|
33
32
|
retire: 3,
|
|
@@ -95,19 +94,6 @@ export function scopePatternsIntersect(a, b) {
|
|
|
95
94
|
function todayUtc() {
|
|
96
95
|
return new Date().toISOString().slice(0, 10);
|
|
97
96
|
}
|
|
98
|
-
/** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
|
|
99
|
-
export function assertMergeSlug(label, value) {
|
|
100
|
-
const ok = typeof value === 'string'
|
|
101
|
-
&& value.length > 0 && value.length <= 100
|
|
102
|
-
&& /^[A-Za-z0-9._-]+$/.test(value)
|
|
103
|
-
&& !value.includes('..') && value !== '.'
|
|
104
|
-
&& !/[. ]$/.test(value)
|
|
105
|
-
&& !isWindowsReservedName(value);
|
|
106
|
-
if (!ok) {
|
|
107
|
-
throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
|
|
108
|
-
+ '(no "..", no device names such as CON or NUL).');
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
97
|
export function mergeCcaAssessments(inputs, opts = {}) {
|
|
112
98
|
if (inputs.length < 2) {
|
|
113
99
|
throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
|
|
@@ -228,11 +214,6 @@ export function mergeCcaAssessments(inputs, opts = {}) {
|
|
|
228
214
|
// overlap is already warned about above).
|
|
229
215
|
const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
|
|
230
216
|
const date = opts.mergedDate ?? todayUtc();
|
|
231
|
-
// SEC (review round 2): projectId comes from INSIDE an envelope (model-
|
|
232
|
-
// writable) and becomes a folder name under .cspeach/cca/projects/. Only a
|
|
233
|
-
// plain slug may do that — never `../`, a drive, a stream or a device name.
|
|
234
|
-
assertMergeSlug('projectId', first.content.projectId);
|
|
235
|
-
assertMergeSlug('merged date', date);
|
|
236
217
|
const mergedProjectId = `${first.content.projectId}_merged_${date}`;
|
|
237
218
|
const scopes = [];
|
|
238
219
|
for (const inp of inputs) {
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* into ONE consolidated upgrade-progress (the /abap-upgrade-merge
|
|
4
4
|
* skill's engine).
|
|
5
5
|
*
|
|
6
|
-
* WHY CODE, NOT PROSE:
|
|
7
|
-
* D6/D7) showed the model cannot keep counts and rows consistent when
|
|
6
|
+
* WHY CODE, NOT PROSE: battery S4 (docs/research/battery/
|
|
7
|
+
* s4-cca-scorecard.md, defects D6/D7) showed the model cannot keep counts and rows consistent when
|
|
8
8
|
* reconciling envelopes by hand. This module owns the arithmetic.
|
|
9
9
|
*
|
|
10
10
|
* Rules implemented (mirrors .claude/skills/abap-upgrade-merge/SKILL.md):
|
|
@@ -25,7 +25,8 @@ const real = (p) => { try {
|
|
|
25
25
|
catch {
|
|
26
26
|
return path.resolve(p);
|
|
27
27
|
} };
|
|
28
|
-
|
|
28
|
+
/** A folder as the file system opens it: links followed, no trailing separator, case-folded on Windows. */
|
|
29
|
+
export const key = (p) => { const r = real(p).replace(/[\\/]+$/, ''); return process.platform === 'win32' ? r.toLowerCase() : r; };
|
|
29
30
|
/** The team project id when `cwd` holds a valid cspeach-team.json, else null. */
|
|
30
31
|
export function teamProjectId(cwd) {
|
|
31
32
|
try {
|
|
@@ -72,14 +72,6 @@ const SKILL_REGISTRY = {
|
|
|
72
72
|
itemNoun: 'classifications',
|
|
73
73
|
manifestMarker: 'cca-manifest',
|
|
74
74
|
},
|
|
75
|
-
'abap-cca-merge': {
|
|
76
|
-
// Merge produces a NEW cca-assessment (consolidated) — same shape as
|
|
77
|
-
// a single consultant's slice, just reconciled across N parallel reviews.
|
|
78
|
-
artefactType: 'cca-assessment',
|
|
79
|
-
extract: (md) => extractCcaAssessment(md),
|
|
80
|
-
itemNoun: 'classifications',
|
|
81
|
-
manifestMarker: 'cca-manifest',
|
|
82
|
-
},
|
|
83
75
|
'abap-modernize': {
|
|
84
76
|
artefactType: 'modernize-result',
|
|
85
77
|
extract: (md) => extractModernizeResult(md),
|
|
@@ -314,8 +306,8 @@ export async function runSaveCommand(args) {
|
|
|
314
306
|
// extractor throws, and bailing here would silently lose an artifact that
|
|
315
307
|
// carries a complete, recognizable manifest. If the manifest resolves to
|
|
316
308
|
// a DIFFERENT registry skill, retry the save under that skill. Skipped
|
|
317
|
-
// when both skills share the same manifest-marker family
|
|
318
|
-
//
|
|
309
|
+
// when both skills share the same manifest-marker family
|
|
310
|
+
// (abap-upgrade-merge ↔ abap-upgrade-fix): the family
|
|
319
311
|
// extractor already ran and failed, and re-dispatching would only launder
|
|
320
312
|
// merge provenance onto the non-merge skill — keep the label's failure.
|
|
321
313
|
const detected = detectArtifactSkill(args.skillOutput);
|
|
@@ -401,7 +393,9 @@ export async function runSaveCommand(args) {
|
|
|
401
393
|
// the row cards go through `confirm` only (never `prompt`), and a headless
|
|
402
394
|
// run confirms no row. Any further question routed through `prompt` would
|
|
403
395
|
// silently receive 'y' too: give maybeOfferSave's headless prompt a
|
|
404
|
-
// question-aware answer first.
|
|
396
|
+
// question-aware answer first. (Task 6 round 1: a retire row's "What did
|
|
397
|
+
// you check?" uses `prompt`, but only when `confirm` is given — which
|
|
398
|
+
// maybeOfferSave never does headless — so the blanket 'y' never reaches it.)
|
|
405
399
|
// Inside a journey a triage is saved without the question: the journey needs the rows.
|
|
406
400
|
const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
|
|
407
401
|
const answer = journey ? 'y' : await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
|
|
@@ -412,6 +406,8 @@ export async function runSaveCommand(args) {
|
|
|
412
406
|
// 2026-10-06 — a clean-core triage becomes team project decisions only row by
|
|
413
407
|
// row, each on its own Yes. Without a joined team project nothing is asked.
|
|
414
408
|
let tally;
|
|
409
|
+
/** Task 6: rulings the lead's retire Yes wrote during this save (no writer row beside them). */
|
|
410
|
+
let ruledHere = 0;
|
|
415
411
|
if (spec.artefactType === 'clean-core-decision') {
|
|
416
412
|
const { confirmCleanCoreRows, decideCleanCoreRows } = await import('./clean-core-confirm.js');
|
|
417
413
|
const { activeProject } = await import('../register/store.js');
|
|
@@ -433,10 +429,48 @@ export async function runSaveCommand(args) {
|
|
|
433
429
|
rules = () => null;
|
|
434
430
|
}
|
|
435
431
|
}
|
|
432
|
+
// Task 6 (B4): the lead's retire Yes is the ruling `team rule` writes, run in process —
|
|
433
|
+
// on the journey's row cards and on the Yes/No cards alike. Unreadable project file →
|
|
434
|
+
// not the lead: a retire stays a candidate. Round 1: the card says the object's
|
|
435
|
+
// target as the project view reads it (a person or the lead set it), never the
|
|
436
|
+
// model's; unreadable → "no target set".
|
|
437
|
+
let retire = {};
|
|
438
|
+
let targets;
|
|
439
|
+
if (project) {
|
|
440
|
+
const { isLead, ruleObject } = await import('../commands/team.js');
|
|
441
|
+
const teamDeps = { print: () => { }, identity: () => ({ name: project.name ?? args.author.name }) };
|
|
442
|
+
try {
|
|
443
|
+
const { readProjectFile } = await import('../register/store.js');
|
|
444
|
+
if (isLead(readProjectFile(project.projectDir), teamDeps)) {
|
|
445
|
+
retire = {
|
|
446
|
+
lead: true,
|
|
447
|
+
rule: async (x) => {
|
|
448
|
+
ruleObject({ type: x.type, name: x.name, classification: 'retire', reason: x.reason }, teamDeps);
|
|
449
|
+
ruledHere += 1;
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
retire = {};
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
// Project view Task 5: the view is built once here and answers each card's target.
|
|
459
|
+
const { registerFor } = await import('../register/picture.js');
|
|
460
|
+
const { readProjectFile } = await import('../register/store.js');
|
|
461
|
+
const { projectView } = await import('@cspeach/register-core');
|
|
462
|
+
const view = projectView(registerFor(project.projectDir, project.outboxDir).register, readProjectFile(project.projectDir));
|
|
463
|
+
targets = (key) => view.object(key.trim().toUpperCase())?.target;
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
targets = undefined;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
436
469
|
if (journey && joined) {
|
|
437
470
|
// Guided journey: [Yes] [Keep as is] [Skip] per row.
|
|
438
471
|
const r = await decideCleanCoreRows(extract, {
|
|
439
472
|
card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log, ...(rules ? { rules } : {}),
|
|
473
|
+
...retire, ...(targets ? { targets } : {}),
|
|
440
474
|
});
|
|
441
475
|
extract = r.extract;
|
|
442
476
|
tally = r.tally;
|
|
@@ -449,7 +483,9 @@ export async function runSaveCommand(args) {
|
|
|
449
483
|
}
|
|
450
484
|
else {
|
|
451
485
|
extract = await confirmCleanCoreRows(extract, {
|
|
452
|
-
|
|
486
|
+
// B5: the free-text "What did you check?" goes with `confirm` only — never the headless blanket-'y' prompt.
|
|
487
|
+
...(args.confirm ? { confirm: args.confirm, ask: args.prompt } : {}), log: args.log, joined, ...(rules ? { rules } : {}),
|
|
488
|
+
...retire, ...(targets ? { targets } : {}),
|
|
453
489
|
});
|
|
454
490
|
}
|
|
455
491
|
}
|
|
@@ -540,7 +576,8 @@ export async function runSaveCommand(args) {
|
|
|
540
576
|
}
|
|
541
577
|
else if (res?.file)
|
|
542
578
|
args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
|
|
543
|
-
|
|
579
|
+
// A ruling made on a row card changes the list too, with no writer row beside it (Task 6).
|
|
580
|
+
if (res?.file || ruledHere > 0) {
|
|
544
581
|
const { regenerateWorkingList } = await import('../register/workbooks.js');
|
|
545
582
|
// The name the team project knows this laptop by (init --lead), as the team verbs use.
|
|
546
583
|
const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
|
|
@@ -578,7 +615,7 @@ export async function runSaveCommand(args) {
|
|
|
578
615
|
else if (journey.page?.length) {
|
|
579
616
|
// One triage per page: a row of the page the triage left out is put off
|
|
580
617
|
// until "carry on", so the journey moves on instead of asking it again — and says so.
|
|
581
|
-
const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped]);
|
|
618
|
+
const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped, ...tally.ruled]);
|
|
582
619
|
const left = journey.page.filter((k) => !answered.has(k.toUpperCase()));
|
|
583
620
|
if (left.length) {
|
|
584
621
|
journey.leaveForLater(left);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Decision rules for the joined team project (step 2, D6): which rule decides
|
|
3
3
|
* each object, read from the same register and findings team_findings shows,
|
|
4
4
|
* with the project's own rules or the defaults. The row cards use it to say
|
|
5
|
-
* "
|
|
5
|
+
* "Matches your rule: <note>" or "Differs from rule <id>: …" (never a rule id
|
|
6
|
+
* inside a recorded reason, project view Task 4). Read only; never writes the
|
|
6
7
|
* rules to the file.
|
|
7
8
|
*/
|
|
8
9
|
import { evaluateRules, readDecisionRules } from '@cspeach/register-core';
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* — set by `cspeach team export --always` or the journey's report card. No
|
|
10
10
|
* hidden state. Default off.
|
|
11
11
|
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import { csvText, exportSet } from '@cspeach/register-core';
|
|
14
15
|
import { HELD } from './workbooks.js';
|
|
@@ -21,6 +22,23 @@ export function exportsOn(project) {
|
|
|
21
22
|
const e = project.exports;
|
|
22
23
|
return !!e && typeof e === 'object' && e.csv === true;
|
|
23
24
|
}
|
|
25
|
+
/** True only when the switch was turned off on purpose (`team export --never`): files already there stay as they are. */
|
|
26
|
+
export function exportsOff(project) {
|
|
27
|
+
const e = project.exports;
|
|
28
|
+
return !!e && typeof e === 'object' && e.csv === false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* G5: an export already in `dir` (its manifest.json is there). A report then
|
|
32
|
+
* rewrites it, so the CSV files a person's tools read never go stale.
|
|
33
|
+
*/
|
|
34
|
+
export function exportsPresent(dir) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.statSync(path.join(dir, 'manifest.json')).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
24
42
|
/** Set the switch on or off: the whole project file, then renamed. Every other field stays as it is. */
|
|
25
43
|
export function setExports(projectDir, on) {
|
|
26
44
|
const project = readProjectFile(projectDir);
|