@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
2
|
+
import { STEPS, stepOf } from '../journey/clean-core.js';
|
|
1
3
|
const WORD = {
|
|
2
4
|
keep: 'keep', exception: 'accepted exception', fix: 'fix', rebuild: 'rebuild beside the core', retire: 'retire',
|
|
3
5
|
};
|
|
@@ -5,11 +7,42 @@ export function rowWords(r) {
|
|
|
5
7
|
const parts = [WORD[r.disposition]];
|
|
6
8
|
if (r.level)
|
|
7
9
|
parts.push(`level ${r.level}`);
|
|
10
|
+
// Step 2 (F3): a target is said only when the proposal named one; the record keeps the legacy A, which is no target.
|
|
8
11
|
if (r.disposition === 'fix' || r.disposition === 'rebuild')
|
|
9
|
-
parts.push(`target ${r.targetLevel
|
|
12
|
+
parts.push(r.targetLevel ? `target ${r.targetLevel}` : 'no target set');
|
|
10
13
|
return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
|
|
11
14
|
}
|
|
12
15
|
const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
|
|
16
|
+
/** What a recorded reason keeps (records.ts). */
|
|
17
|
+
const MAX_ROW_REASON = 300;
|
|
18
|
+
const RULE_ROW = {
|
|
19
|
+
keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire',
|
|
20
|
+
};
|
|
21
|
+
/** A "Rule <id>: " the reason starts with — written by the model, so code checks it. */
|
|
22
|
+
const RULE_PREFIX = /^\s*rule\s+[A-Za-z0-9_-]{1,20}\s*:\s*/i;
|
|
23
|
+
/**
|
|
24
|
+
* The row read against the rule that decides its object. Agrees (same
|
|
25
|
+
* disposition): the reason starts "Rule <id>: " (the rule's note when the row
|
|
26
|
+
* gave none). Differs: the row stays the model's, and `differs` is the fact
|
|
27
|
+
* line "Differs from rule <id>: <why>". No rule: a "Rule <id>:" the model
|
|
28
|
+
* wrote is taken off — the row is CSPeach's judgement. The person still
|
|
29
|
+
* answers the card either way.
|
|
30
|
+
*/
|
|
31
|
+
export function ruleRow(r, p) {
|
|
32
|
+
const own = (r.reason ?? '').replace(RULE_PREFIX, '').trim();
|
|
33
|
+
if (!p)
|
|
34
|
+
return { row: own === r.reason ? r : { ...r, reason: own } };
|
|
35
|
+
const id = shown(p.ruleId);
|
|
36
|
+
const why = shownUpTo(p.why, 200);
|
|
37
|
+
// Agrees = same disposition, and the same target when the rule itself names one (owner ruling, review 9 #7).
|
|
38
|
+
const targetDiffers = p.targetFrom === 'rule' && p.target !== undefined && r.targetLevel !== p.target;
|
|
39
|
+
if (RULE_ROW[p.disposition] !== r.disposition || targetDiffers) {
|
|
40
|
+
return { row: own === r.reason ? r : { ...r, reason: own }, differs: `Differs from rule ${id}: ${why}` };
|
|
41
|
+
}
|
|
42
|
+
// Prefix + reason stay within the 300 the record keeps (records.ts would cut the end); cut by code point.
|
|
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('') } };
|
|
45
|
+
}
|
|
13
46
|
export async function confirmCleanCoreRows(ex, a) {
|
|
14
47
|
if (!a.joined) {
|
|
15
48
|
// Review Focus 4: the file is still saved; say why nothing is recorded.
|
|
@@ -21,8 +54,9 @@ export async function confirmCleanCoreRows(ex, a) {
|
|
|
21
54
|
return ex;
|
|
22
55
|
}
|
|
23
56
|
const rows = [];
|
|
24
|
-
for (const
|
|
25
|
-
const
|
|
57
|
+
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 answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?${differs ? ` (${differs})` : ''}`, { default: 'n' });
|
|
26
60
|
rows.push({ ...r, confirmed: answer === 'y' });
|
|
27
61
|
}
|
|
28
62
|
const yes = rows.filter((r) => r.confirmed).length;
|
|
@@ -34,3 +68,158 @@ export async function confirmCleanCoreRows(ex, a) {
|
|
|
34
68
|
items: ex.items.map((i) => ({ ...i, status: byId.get(i.id) ? 'confirmed' : 'declined' })),
|
|
35
69
|
};
|
|
36
70
|
}
|
|
71
|
+
export const KEEP_QUESTION = 'Why keep it?';
|
|
72
|
+
/** "Yes to the remaining n shown" is offered once this many rows are answered. */
|
|
73
|
+
const OFFER_REST_AFTER = 3;
|
|
74
|
+
const MAX_KEEP_REASON = 300;
|
|
75
|
+
/** A long reason wraps onto at most this many rows of the card. */
|
|
76
|
+
export const FACT_ROWS = 5;
|
|
77
|
+
const keyOf = (r) => `${r.objectType.trim().toUpperCase()}|${r.objectName.trim().toUpperCase()}`;
|
|
78
|
+
/** The person's reason as one plain line: escapes, control characters and line breaks go; at most 300 characters. */
|
|
79
|
+
export function cleanKeepReason(raw) {
|
|
80
|
+
// Cut by code point, so an emoji at the edge is never split in half.
|
|
81
|
+
return Array.from(shownUpTo(raw ?? '', Number.POSITIVE_INFINITY)).slice(0, MAX_KEEP_REASON).join('').trim();
|
|
82
|
+
}
|
|
83
|
+
/** 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: '' });
|
|
86
|
+
}
|
|
87
|
+
export async function decideCleanCoreRows(ex, j) {
|
|
88
|
+
const all = ex.content.adjudications;
|
|
89
|
+
const ruled = all.map((r) => (j.rules ? ruleRow(r, j.rules(keyOf(r))) : { row: r, differs: undefined }));
|
|
90
|
+
const rows = ruled.map(({ row }) => ({ ...row, confirmed: false }));
|
|
91
|
+
const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false };
|
|
92
|
+
const footnote = `Step ${stepOf('DECIDE')} of ${STEPS}`;
|
|
93
|
+
let answered = 0;
|
|
94
|
+
rowLoop: for (let i = 0; i < rows.length; i++) {
|
|
95
|
+
const r = rows[i];
|
|
96
|
+
const type = shown(r.objectType);
|
|
97
|
+
const name = shown(r.objectName);
|
|
98
|
+
const left = rows.length - i;
|
|
99
|
+
const why = shownUpTo(r.reason ?? '', MAX_KEEP_REASON);
|
|
100
|
+
let canKeep = true;
|
|
101
|
+
let settled = false;
|
|
102
|
+
for (let tries = 0; tries < 20 && !settled; tries++) {
|
|
103
|
+
const offerRest = answered >= OFFER_REST_AFTER && left >= 2;
|
|
104
|
+
const res = await j.card({
|
|
105
|
+
id: 'journey-row',
|
|
106
|
+
tag: 'QUESTION',
|
|
107
|
+
title: `Record ${type} ${name}: ${proposalWords({ ...r, objectType: type, objectName: name })}?`,
|
|
108
|
+
facts: [...(why ? [`Why: ${why}`] : []), ...(ruled[i].differs ? [ruled[i].differs] : []), `Row ${i + 1} of ${rows.length}.`],
|
|
109
|
+
choices: [
|
|
110
|
+
{ value: 'yes', label: 'Yes' },
|
|
111
|
+
...(canKeep ? [{ value: 'keep', label: 'Keep as is' }] : []),
|
|
112
|
+
{ value: 'skip', label: 'Skip' },
|
|
113
|
+
...(offerRest ? [{ value: 'all', label: `Yes to the remaining ${left} shown` }] : []),
|
|
114
|
+
],
|
|
115
|
+
initial: 0,
|
|
116
|
+
aliases: { y: 'yes', ...(canKeep ? { k: 'keep' } : {}), s: 'skip', ...(offerRest ? { a: 'all' } : {}) },
|
|
117
|
+
footnote,
|
|
118
|
+
// The reason is the point of the card: it wraps, never cut to one row.
|
|
119
|
+
wrapFacts: FACT_ROWS,
|
|
120
|
+
});
|
|
121
|
+
if (res.cancelled || res.value === null) {
|
|
122
|
+
tally.cancelled = true;
|
|
123
|
+
break rowLoop;
|
|
124
|
+
}
|
|
125
|
+
if (res.value === 'all' && offerRest) {
|
|
126
|
+
for (let k = i; k < rows.length; k++) {
|
|
127
|
+
rows[k] = { ...rows[k], confirmed: true };
|
|
128
|
+
tally.yes.push(keyOf(rows[k]));
|
|
129
|
+
}
|
|
130
|
+
break rowLoop;
|
|
131
|
+
}
|
|
132
|
+
if (res.value === 'yes') {
|
|
133
|
+
rows[i] = { ...r, confirmed: true };
|
|
134
|
+
tally.yes.push(keyOf(r));
|
|
135
|
+
settled = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (res.value === 'skip') {
|
|
139
|
+
tally.skipped.push(keyOf(r));
|
|
140
|
+
j.skip([keyOf(r)]);
|
|
141
|
+
settled = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (res.value === 'keep' && canKeep) {
|
|
145
|
+
if (!r.level) {
|
|
146
|
+
j.log(`No level is known for ${type} ${name}, so it cannot be kept as is. Choose Yes or Skip.`);
|
|
147
|
+
canKeep = false;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let said;
|
|
151
|
+
try {
|
|
152
|
+
said = await j.ask(KEEP_QUESTION);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
tally.cancelled = true;
|
|
156
|
+
break rowLoop;
|
|
157
|
+
}
|
|
158
|
+
const reason = cleanKeepReason(said);
|
|
159
|
+
if (!reason) {
|
|
160
|
+
j.log(`A keep needs a reason. Nothing was recorded for ${type} ${name} yet.`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, confirmed: true };
|
|
164
|
+
tally.kept.push({ key: keyOf(r), type: r.objectType.toUpperCase(), name: r.objectName.toUpperCase(), level: r.level });
|
|
165
|
+
settled = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Asked over and over with no usable answer: a Skip, never "answered" with nothing recorded.
|
|
169
|
+
if (!settled) {
|
|
170
|
+
j.log('No reason given — skipped for now.');
|
|
171
|
+
tally.skipped.push(keyOf(r));
|
|
172
|
+
j.skip([keyOf(r)]);
|
|
173
|
+
}
|
|
174
|
+
answered += 1;
|
|
175
|
+
}
|
|
176
|
+
const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
|
|
177
|
+
return {
|
|
178
|
+
extract: {
|
|
179
|
+
...ex,
|
|
180
|
+
content: { ...ex.content, adjudications: rows },
|
|
181
|
+
items: ex.items.map((it) => ({ ...it, status: byId.get(it.id) ? 'confirmed' : 'declined' })),
|
|
182
|
+
},
|
|
183
|
+
tally,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The lines after the rows are written: one per kept row, then the count
|
|
188
|
+
* ("4 of 5 rows recorded; 1 kept by decision; 0 skipped."). "Recorded" counts
|
|
189
|
+
* the rows recorded as proposed; a row the project refused is not counted.
|
|
190
|
+
*/
|
|
191
|
+
export function journeyRowLines(t, refused) {
|
|
192
|
+
const out = new Set(refused.map((k) => k.trim().toUpperCase()));
|
|
193
|
+
const kept = t.kept.filter((k) => !out.has(k.key));
|
|
194
|
+
const yes = t.yes.filter((k) => !out.has(k.toUpperCase())).length;
|
|
195
|
+
return [
|
|
196
|
+
...kept.map((k) => `✓ Recorded: ${shown(k.type)} ${shown(k.name)} — kept at ${k.level}, reason recorded.`),
|
|
197
|
+
`${yes} of ${t.total} rows recorded; ${kept.length} kept by decision; ${t.skipped.length} skipped.`,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The working-list line inside a journey: the person keeps "your list", never
|
|
202
|
+
* a file name, a team word or a command to type (Task 7). Null: nothing to say.
|
|
203
|
+
*/
|
|
204
|
+
export function journeyListLine(kind) {
|
|
205
|
+
if (kind === 'updated')
|
|
206
|
+
return 'Your list is updated.';
|
|
207
|
+
if (kind === 'held')
|
|
208
|
+
return 'Your list is open in another program, so it was not updated. Close it; your next decision updates it.';
|
|
209
|
+
if (kind === 'unreadable')
|
|
210
|
+
return 'Your list could not be updated: the folder could not be read right now.';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/** Inside a journey, on a laptop with no project open here (the triage is saved; nothing recorded). */
|
|
214
|
+
export const JOURNEY_NOT_IN_PROJECT_LINE = 'Not recorded: no record is kept in this folder yet.';
|
|
215
|
+
/** Page objects the triage did not answer, in one line: up to five named, then how many more. */
|
|
216
|
+
export function leftOutLine(keys) {
|
|
217
|
+
const named = keys.slice(0, 5).map((k) => {
|
|
218
|
+
const [type = '', name = ''] = k.split('|');
|
|
219
|
+
return `${shown(type)} ${shown(name)}`.trim();
|
|
220
|
+
});
|
|
221
|
+
const more = keys.length - named.length;
|
|
222
|
+
const one = keys.length === 1;
|
|
223
|
+
return `${keys.length.toLocaleString('en-US')} ${one ? 'object' : 'objects'} on this page ${one ? 'was' : 'were'} not in the triage: `
|
|
224
|
+
+ `${named.join(', ')}${more > 0 ? ` (and ${more.toLocaleString('en-US')} more)` : ''}. Left for later — say carry on to look at them again.`;
|
|
225
|
+
}
|
|
@@ -63,10 +63,10 @@ function realOrNull(p) {
|
|
|
63
63
|
/**
|
|
64
64
|
* The real path of the first candidate that exists, is a regular file, stays
|
|
65
65
|
* inside `<base>/<first two segments of rel>/` after links are followed, and
|
|
66
|
-
* is not denylisted relative to `cwd
|
|
66
|
+
* is not denylisted relative to its own deny root (default `cwd`). null otherwise.
|
|
67
67
|
*/
|
|
68
68
|
function firstContained(cands, cwd) {
|
|
69
|
-
for (const { base, rel, keep } of cands) {
|
|
69
|
+
for (const { base, rel, keep, denyRoot } of cands) {
|
|
70
70
|
const realBase = realOrNull(path.resolve(base));
|
|
71
71
|
const real = realOrNull(path.join(path.resolve(base), ...rel.split('/')));
|
|
72
72
|
if (!realBase || !real)
|
|
@@ -76,7 +76,7 @@ function firstContained(cands, cwd) {
|
|
|
76
76
|
const inner = path.relative(folder, real);
|
|
77
77
|
if (inner === '' || inner.startsWith('..') || path.isAbsolute(inner))
|
|
78
78
|
continue;
|
|
79
|
-
if (isDenylistedPath(real, cwd).blocked)
|
|
79
|
+
if (isDenylistedPath(real, denyRoot ?? cwd).blocked)
|
|
80
80
|
continue;
|
|
81
81
|
try {
|
|
82
82
|
if (!statSync(real).isFile())
|
|
@@ -109,7 +109,12 @@ export async function resolveDetailFile(detailPath, domain, cwd) {
|
|
|
109
109
|
const segs = rel.split('/');
|
|
110
110
|
if (segs[0].toLowerCase() === '.cspeach') {
|
|
111
111
|
try {
|
|
112
|
-
|
|
112
|
+
// Judged against the folder that holds the workspace `.cspeach` — the
|
|
113
|
+
// seat work root for a team folder (Task 14 fix round 1), so the seat
|
|
114
|
+
// opening applies there and a team folder on another drive or share
|
|
115
|
+
// cannot make the path look foreign; the start folder for a solo folder.
|
|
116
|
+
const ws = await workspaceFolderFor(cwd);
|
|
117
|
+
cands.push({ base: ws, rel: segs.slice(1).join('/'), keep: 1, denyRoot: path.dirname(ws) });
|
|
113
118
|
}
|
|
114
119
|
catch { /* no workspace — project root only */ }
|
|
115
120
|
}
|
|
@@ -48,6 +48,9 @@ export function extractCleanCoreDecision(markdown) {
|
|
|
48
48
|
throw new Error(`${id}: a row needs an object type and a name`);
|
|
49
49
|
if (disposition === 'exception' && !reason)
|
|
50
50
|
throw new Error(`${id}: an accepted exception needs a reason`);
|
|
51
|
+
// Spec 2026-10-06 §7: a retire is a candidate the lead confirms; it never travels without what was checked.
|
|
52
|
+
if (disposition === 'retire' && !reason)
|
|
53
|
+
throw new Error(`${id}: a retire needs a reason: say what was checked`);
|
|
51
54
|
const moves = disposition === 'fix' || disposition === 'rebuild';
|
|
52
55
|
rows.push({
|
|
53
56
|
id, objectType: type.toUpperCase(), objectName: name.toUpperCase(), level: letter(level, id), disposition,
|
|
@@ -1,115 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One-time
|
|
2
|
+
* One-time `.abapforge/` → `.cspeach/` output-namespace migration offer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* customer A and customer B no longer has both customers' artefacts
|
|
8
|
-
* piling into one folder.
|
|
9
|
-
*
|
|
10
|
-
* For users with files already in `~/CSPeach/`, the switch needs to ask
|
|
11
|
-
* once: keep the old global, move into the new project, or leave both
|
|
12
|
-
* alone. The answer is recorded in `config.migration_prompted`; the
|
|
13
|
-
* prompt fires exactly once.
|
|
14
|
-
*
|
|
15
|
-
* Three branches:
|
|
16
|
-
* - "move": renameSync every artefact from ~/CSPeach/ into the
|
|
17
|
-
* resolved project workspace. Old folder cleaned.
|
|
18
|
-
* - "keep_global": set workspace_folder = '~/CSPeach', preserving the
|
|
19
|
-
* pre-2026-05-08 behaviour for users who liked it.
|
|
20
|
-
* - "leave": do nothing — old folder stays as-is, new artefacts
|
|
21
|
-
* go to the project workspace. User can manually
|
|
22
|
-
* copy whatever they want later.
|
|
23
|
-
*
|
|
24
|
-
* The prompt is interactive (via the supplied async prompt callback);
|
|
25
|
-
* one-shot mode skips it entirely (caller passes a no-op).
|
|
4
|
+
* The old first-run workspace prompt (2026-05-08, which could pin
|
|
5
|
+
* `workspace_folder`) was removed 2026-10-08: files now save in the folder
|
|
6
|
+
* you start in. See workspace-retire.ts for the offer that retires the key.
|
|
26
7
|
*/
|
|
27
8
|
import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs';
|
|
28
9
|
import { join } from 'node:path';
|
|
29
|
-
import os from 'node:os';
|
|
30
10
|
import { loadConfig, saveConfig } from '../config/loader.js';
|
|
31
|
-
import { defaultWorkspaceFor } from './workspace.js';
|
|
32
11
|
import { OUTPUT_ROOT, LEGACY_ROOT } from './output-paths.js';
|
|
33
|
-
/**
|
|
34
|
-
* Run the migration prompt if and only if it hasn't run before AND
|
|
35
|
-
* there's actually something to migrate. No-op otherwise. Always sets
|
|
36
|
-
* `migration_prompted: true` after running so the next invocation
|
|
37
|
-
* skips this whole module.
|
|
38
|
-
*/
|
|
39
|
-
export async function maybePromptWorkspaceMigration(deps) {
|
|
40
|
-
const cfg = await loadConfig();
|
|
41
|
-
if (cfg.migration_prompted === true)
|
|
42
|
-
return;
|
|
43
|
-
// If user already pinned workspace_folder, they've already chosen —
|
|
44
|
-
// mark prompted, skip the dialog. The pin survives.
|
|
45
|
-
if (cfg.workspace_folder !== undefined && cfg.workspace_folder.trim().length > 0) {
|
|
46
|
-
await saveConfig({ ...cfg, migration_prompted: true });
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const oldWorkspace = join(os.homedir(), 'CSPeach');
|
|
50
|
-
if (!existsSync(oldWorkspace)) {
|
|
51
|
-
await saveConfig({ ...cfg, migration_prompted: true });
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
let oldFiles;
|
|
55
|
-
try {
|
|
56
|
-
oldFiles = readdirSync(oldWorkspace).filter((f) => f.endsWith('.cspeach.json') || f.endsWith('.txt') || f.endsWith('.md'));
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
await saveConfig({ ...cfg, migration_prompted: true });
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (oldFiles.length === 0) {
|
|
63
|
-
await saveConfig({ ...cfg, migration_prompted: true });
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const newWorkspace = defaultWorkspaceFor(process.cwd());
|
|
67
|
-
let choice;
|
|
68
|
-
try {
|
|
69
|
-
choice = await deps.prompt({
|
|
70
|
-
fileCount: oldFiles.length,
|
|
71
|
-
oldWorkspace,
|
|
72
|
-
newWorkspace,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
// User Ctrl+C'd the prompt — leave migration_prompted unset so the
|
|
77
|
-
// dialog re-fires next session. Better than silently committing to
|
|
78
|
-
// a default they never saw.
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (choice === 'move') {
|
|
82
|
-
try {
|
|
83
|
-
mkdirSync(newWorkspace, { recursive: true });
|
|
84
|
-
for (const f of oldFiles) {
|
|
85
|
-
const src = join(oldWorkspace, f);
|
|
86
|
-
const dst = join(newWorkspace, f);
|
|
87
|
-
if (existsSync(dst)) {
|
|
88
|
-
deps.log(`(skipping ${f} — already exists in new workspace)`);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
renameSync(src, dst);
|
|
92
|
-
}
|
|
93
|
-
deps.log(`Moved ${oldFiles.length} file(s) → ${newWorkspace}`);
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
deps.log(`Migration failed: ${err.message}. Old folder left untouched.`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
else if (choice === 'keep_global') {
|
|
100
|
-
await saveConfig({
|
|
101
|
-
...cfg,
|
|
102
|
-
workspace_folder: oldWorkspace,
|
|
103
|
-
migration_prompted: true,
|
|
104
|
-
});
|
|
105
|
-
deps.log(`Workspace pinned to ${oldWorkspace}.`);
|
|
106
|
-
return; // saveConfig already done
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
deps.log(`Leaving ${oldWorkspace} alone. New artefacts go to ${newWorkspace}.`);
|
|
110
|
-
}
|
|
111
|
-
await saveConfig({ ...cfg, migration_prompted: true });
|
|
112
|
-
}
|
|
113
12
|
/**
|
|
114
13
|
* Recursively move every entry under `srcDir` into `dstDir`, MERGING into
|
|
115
14
|
* existing directories rather than clobbering them. Files that already exist
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
// (Review Focus 4).
|
|
11
11
|
// - renderLaneBlock is the <lane_decision> context block for the plan-creation
|
|
12
12
|
// turn and every resumed phase. It never carries the file path.
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
13
15
|
import { cardLine } from '../lane/card.js';
|
|
14
16
|
import { canonicalLanePath } from '../lane/paths.js';
|
|
15
17
|
import { getAppFlow } from '../lane/app-flow.js';
|
|
16
|
-
import { laneRootFor } from '../lane/roots.js';
|
|
18
|
+
import { cliHomeRefusal, laneRootFor } from '../lane/roots.js';
|
|
17
19
|
import { LaneShaMismatchError, readPinnedLaneDecision } from '../lane/store.js';
|
|
18
20
|
/** Shown on the plan-creation turn (and at save) when the file no longer matches the card. */
|
|
19
21
|
export const LANE_CHANGED_AT_CREATE = 'The lane decision file changed after you confirmed it. Run /abap-app again.';
|
|
@@ -37,24 +39,72 @@ export function validateLaneRecord(rec) {
|
|
|
37
39
|
return null;
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
42
|
+
* 2026-10-08 — the folder that holds the plan file's `.cspeach`: the root its
|
|
43
|
+
* relative pins (`from.lane.path`, `from.specGap.path`) were written against.
|
|
44
|
+
* Undefined when there is no plan path, or the plan does not sit directly in
|
|
45
|
+
* a `.cspeach` folder (then it adds no root). Task 14 — also undefined when
|
|
46
|
+
* that folder is the home folder, holds a CLI home as its `.cspeach`, or sits
|
|
47
|
+
* in a CLI home (`cspeachRoot()` or `~/.cspeach`), real paths compared —
|
|
48
|
+
* except exactly the seat working root `<cspeachRoot()>/work/<id>`
|
|
49
|
+
* (cliHomeRefusal, the lane root rule).
|
|
43
50
|
*/
|
|
44
|
-
export
|
|
45
|
-
|
|
51
|
+
export function planRootOf(planPath) {
|
|
52
|
+
if (!planPath)
|
|
53
|
+
return undefined;
|
|
54
|
+
const dir = path.dirname(path.resolve(planPath));
|
|
55
|
+
if (path.basename(dir).toLowerCase() !== '.cspeach')
|
|
56
|
+
return undefined;
|
|
57
|
+
const root = path.dirname(dir);
|
|
58
|
+
return cliHomeRefusal(root) === null ? root : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** Candidate roots for a plan's pins: the plan's own root first, then the current rule. */
|
|
61
|
+
export async function planPinRoots(cwd, planPath) {
|
|
62
|
+
const roots = [planRootOf(planPath), await laneRootFor(cwd)];
|
|
63
|
+
return roots.filter((r, i, a) => !!r && a.indexOf(r) === i);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Pinned read + re-validation. Never throws. `cwd` is the caller's folder.
|
|
67
|
+
* The file is read relative to the plan file's own root (`planPath`, the
|
|
68
|
+
* folder holding its `.cspeach`) first, then the lane root for cwd (the
|
|
69
|
+
* folder that holds the working `.cspeach`). The first root that HAS the file
|
|
70
|
+
* decides: a changed (sha mismatch) or refused file there is final — never
|
|
71
|
+
* fall through to another root past it.
|
|
72
|
+
*/
|
|
73
|
+
export async function readPlanLane(cwd, ref, planPath) {
|
|
74
|
+
const rel = canonicalLanePath(ref.path);
|
|
75
|
+
const roots = await planPinRoots(cwd, planPath);
|
|
76
|
+
let lastErr;
|
|
77
|
+
for (let i = 0; i < roots.length; i++) {
|
|
78
|
+
const root = roots[i];
|
|
79
|
+
const isLast = i === roots.length - 1;
|
|
80
|
+
// Only a root without the file is skipped; any other outcome there is final.
|
|
81
|
+
if (!isLast && !(await entryExists(path.resolve(root, rel))))
|
|
82
|
+
continue;
|
|
83
|
+
let rec;
|
|
84
|
+
try {
|
|
85
|
+
rec = await readPinnedLaneDecision(root, rel, ref.sha256);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e instanceof LaneShaMismatchError)
|
|
89
|
+
return { ok: false, mismatch: true, message: e.message };
|
|
90
|
+
lastErr = e;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const bad = validateLaneRecord(rec);
|
|
94
|
+
if (bad)
|
|
95
|
+
return { ok: false, mismatch: false, message: `The lane decision file ${cardLine(rel)} is not valid: ${bad}` };
|
|
96
|
+
return { ok: true, rec };
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, mismatch: false, message: cardLine(lastErr instanceof Error ? lastErr.message : String(lastErr)) };
|
|
99
|
+
}
|
|
100
|
+
async function entryExists(abs) {
|
|
46
101
|
try {
|
|
47
|
-
|
|
102
|
+
await fs.lstat(abs);
|
|
103
|
+
return true;
|
|
48
104
|
}
|
|
49
|
-
catch
|
|
50
|
-
|
|
51
|
-
return { ok: false, mismatch: true, message: e.message };
|
|
52
|
-
return { ok: false, mismatch: false, message: cardLine(e instanceof Error ? e.message : String(e)) };
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
53
107
|
}
|
|
54
|
-
const bad = validateLaneRecord(rec);
|
|
55
|
-
if (bad)
|
|
56
|
-
return { ok: false, mismatch: false, message: `The lane decision file ${cardLine(canonicalLanePath(ref.path))} is not valid: ${bad}` };
|
|
57
|
-
return { ok: true, rec };
|
|
58
108
|
}
|
|
59
109
|
/**
|
|
60
110
|
* Controller ruling R5 — per-lane whitelist of delegate x layer. A phase whose
|
|
@@ -160,11 +210,11 @@ export function checkRequiredDeploy(content, rec) {
|
|
|
160
210
|
* re-validate it and check the stored phases against it. Lane-less plans pass
|
|
161
211
|
* with no record. On failure, `lines` is what to print; the plan is paused.
|
|
162
212
|
*/
|
|
163
|
-
export async function loadPlanLaneForRun(cwd, content) {
|
|
213
|
+
export async function loadPlanLaneForRun(cwd, content, planPath) {
|
|
164
214
|
const ref = content.from.lane;
|
|
165
215
|
if (!ref)
|
|
166
216
|
return { ok: true };
|
|
167
|
-
const lr = await readPlanLane(cwd, ref);
|
|
217
|
+
const lr = await readPlanLane(cwd, ref, planPath);
|
|
168
218
|
if (!lr.ok) {
|
|
169
219
|
return { ok: false, lines: [lr.mismatch ? laneChangedOnResume(ref.path) : `${lr.message} The plan is paused.`] };
|
|
170
220
|
}
|
|
@@ -26,6 +26,7 @@ import { readProjectFile } from './status.js';
|
|
|
26
26
|
import { buildPromotedFromSnapshot } from './promote.js';
|
|
27
27
|
import { assertRealPathContained } from '../tools/_filesystem-shared.js';
|
|
28
28
|
import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
|
|
29
|
+
import { planRootOf } from './plan-lane.js';
|
|
29
30
|
/** A spec-gap envelope file name (`...spec-gap-<4hex>-v<N>[-n].cspeach.json`). */
|
|
30
31
|
const SPEC_GAP_FILE_RE = /-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/i;
|
|
31
32
|
/** Path relative to `root` ('/'-separated) when inside it, else absolute. */
|
|
@@ -64,25 +65,30 @@ async function containedIn(abs, root) {
|
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* Find and read the plan's spec-gap file. Candidates: `from.specGap.path`,
|
|
67
|
-
* then the lane decision's `specGap.path`; each resolved against the
|
|
68
|
+
* then the lane decision's `specGap.path`; each resolved against the plan
|
|
69
|
+
* file's own root (the folder holding its `.cspeach`, 2026-10-08), the project
|
|
68
70
|
* folder and cwd. A candidate is used only when it is a spec-gap file name,
|
|
69
|
-
* sits (real path) inside cwd
|
|
71
|
+
* sits (real path) inside cwd, the workspace folder, the project folder or
|
|
72
|
+
* the plan's root, parses as a spec-gap
|
|
70
73
|
* envelope, and — when the plan holds a promotedFrom snapshot — is the SAME
|
|
71
74
|
* envelope (id match). Returns undefined when none qualifies.
|
|
72
75
|
*/
|
|
73
|
-
async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
|
|
76
|
+
async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord, planPath) {
|
|
74
77
|
const recorded = [content.from.specGap?.path, laneRecord?.specGap?.path]
|
|
75
78
|
.filter((p) => typeof p === 'string' && p.trim() !== '');
|
|
76
79
|
if (recorded.length === 0)
|
|
77
80
|
return undefined;
|
|
78
81
|
const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
|
|
79
82
|
const ws = await workspaceFolderFor(cwd).catch(() => undefined);
|
|
83
|
+
const planRoot = planRootOf(planPath);
|
|
84
|
+
const bases = [planRoot, root, path.resolve(cwd)].filter((b, i, a) => !!b && a.indexOf(b) === i);
|
|
80
85
|
for (const rec of recorded) {
|
|
81
86
|
if (!SPEC_GAP_FILE_RE.test(path.basename(rec)))
|
|
82
87
|
continue;
|
|
83
|
-
for (const base of
|
|
88
|
+
for (const base of bases) {
|
|
84
89
|
const abs = path.resolve(base, rec);
|
|
85
|
-
if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root))
|
|
90
|
+
if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root)) &&
|
|
91
|
+
!(await containedIn(abs, planRoot)))
|
|
86
92
|
continue;
|
|
87
93
|
let env;
|
|
88
94
|
try {
|
|
@@ -95,7 +101,7 @@ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
|
|
|
95
101
|
continue;
|
|
96
102
|
if (promotedFrom && promotedFrom.id !== env.id)
|
|
97
103
|
continue;
|
|
98
|
-
return { env, shownPath: relOrAbs(root, abs) };
|
|
104
|
+
return { env, shownPath: relOrAbs(base === planRoot ? planRoot : root, abs) };
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
return undefined;
|
|
@@ -109,7 +115,7 @@ export async function resolvePlanSources(args) {
|
|
|
109
115
|
const pf = args.envelope.promotedFrom && args.envelope.promotedFrom.artefactType === 'spec-gap'
|
|
110
116
|
? args.envelope.promotedFrom : null;
|
|
111
117
|
try {
|
|
112
|
-
const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord);
|
|
118
|
+
const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord, args.planPath);
|
|
113
119
|
if (live) {
|
|
114
120
|
const snap = await buildPromotedFromSnapshot(live.env);
|
|
115
121
|
const createdSha = args.content.from.specGap?.sha256 ?? pf?.sha256;
|