@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
|
@@ -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;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// src/projects/project-folder.ts
|
|
2
|
+
/**
|
|
3
|
+
* The ONE rule for where a person's project files go (spec 2026-10-08 §3.1).
|
|
4
|
+
*
|
|
5
|
+
* solo → <cwd>/.cspeach (the folder you start in)
|
|
6
|
+
* team → <CSPEACH_HOME>/work/<projectId>/.cspeach (private to this seat; the
|
|
7
|
+
* shared team files stay in the team folder, untouched)
|
|
8
|
+
* home → ~/CSPeach/.cspeach (never the CLI home itself)
|
|
9
|
+
*
|
|
10
|
+
* No git walk. `workspace_folder` is ignored. Markers describe the folder;
|
|
11
|
+
* they never move the save location to another folder the person can see.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import toml from '@iarna/toml';
|
|
18
|
+
import { cspeachRoot, configFile } from '../config/paths.js';
|
|
19
|
+
export const SAFE_PROJECT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
+
const TEAM_FILE = 'cspeach-team.json';
|
|
21
|
+
const ID_FILE = 'project.json';
|
|
22
|
+
const real = (p) => { try {
|
|
23
|
+
return realpathSync.native(p);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return path.resolve(p);
|
|
27
|
+
} };
|
|
28
|
+
const key = (p) => { const r = real(p).replace(/[\\/]+$/, ''); return process.platform === 'win32' ? r.toLowerCase() : r; };
|
|
29
|
+
/** The team project id when `cwd` holds a valid cspeach-team.json, else null. */
|
|
30
|
+
export function teamProjectId(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(path.join(cwd, TEAM_FILE), 'utf8');
|
|
33
|
+
const p = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
34
|
+
if (p?.kind !== 'team-project' || typeof p.projectId !== 'string')
|
|
35
|
+
return null;
|
|
36
|
+
return SAFE_PROJECT_ID.test(p.projectId) ? p.projectId : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function readSoloProjectId(cwd) {
|
|
43
|
+
try {
|
|
44
|
+
const p = JSON.parse(readFileSync(path.join(cwd, '.cspeach', ID_FILE), 'utf8'));
|
|
45
|
+
return typeof p?.id === 'string' && /^[0-9a-f]{8}$/.test(p.id) ? p.id : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function resolveProjectFolderSync(cwd) {
|
|
52
|
+
const projectDir = path.resolve(cwd);
|
|
53
|
+
const teamId = existsSync(path.join(projectDir, TEAM_FILE)) ? teamProjectId(projectDir) : null;
|
|
54
|
+
if (teamId) {
|
|
55
|
+
const workRoot = path.join(cspeachRoot(), 'work', teamId);
|
|
56
|
+
return { kind: 'team', projectDir, workRoot, workDir: path.join(workRoot, '.cspeach'), projectId: teamId };
|
|
57
|
+
}
|
|
58
|
+
const isHome = key(projectDir) === key(os.homedir()) || key(path.join(projectDir, '.cspeach')) === key(cspeachRoot());
|
|
59
|
+
if (isHome) {
|
|
60
|
+
const workRoot = path.join(os.homedir(), 'CSPeach');
|
|
61
|
+
return { kind: 'home', projectDir, workRoot, workDir: path.join(workRoot, '.cspeach'), projectId: readSoloProjectId(workRoot) };
|
|
62
|
+
}
|
|
63
|
+
return { kind: 'solo', projectDir, workRoot: projectDir, workDir: path.join(projectDir, '.cspeach'), projectId: readSoloProjectId(projectDir) };
|
|
64
|
+
}
|
|
65
|
+
export async function resolveProjectFolder(cwd) {
|
|
66
|
+
return resolveProjectFolderSync(cwd);
|
|
67
|
+
}
|
|
68
|
+
/** Create `<workDir>/project.json` on first use (tmp + rename); return the id. */
|
|
69
|
+
export function ensureProjectId(workDir, now = () => new Date(), rand = (n) => randomBytes(n).toString('hex')) {
|
|
70
|
+
const file = path.join(workDir, ID_FILE);
|
|
71
|
+
const existing = readSoloProjectId(path.dirname(workDir));
|
|
72
|
+
if (existing)
|
|
73
|
+
return existing;
|
|
74
|
+
mkdirSync(workDir, { recursive: true });
|
|
75
|
+
const id = rand(4);
|
|
76
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify({ id, createdAt: now().toISOString() }, null, 2)}\n`);
|
|
77
|
+
renameSync(`${file}.tmp`, file);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
/** The pre-1.1.31 git-root walk — kept ONLY to find old files (read-only, this release). */
|
|
81
|
+
function oldGitRoot(startDir) {
|
|
82
|
+
let dir = startDir;
|
|
83
|
+
for (let i = 0; i < 200; i++) {
|
|
84
|
+
if (existsSync(path.join(dir, '.git')))
|
|
85
|
+
return dir;
|
|
86
|
+
const parent = path.dirname(dir);
|
|
87
|
+
if (parent === dir)
|
|
88
|
+
return null;
|
|
89
|
+
dir = parent;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const holdsEnvelopes = (dir) => {
|
|
94
|
+
try {
|
|
95
|
+
return readdirSync(dir).some((n) => n.endsWith('.cspeach.json'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
function oldWorkspaceFolder() {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = toml.parse(readFileSync(configFile(), 'utf8'));
|
|
104
|
+
const v = parsed.workspace_folder?.trim();
|
|
105
|
+
if (!v)
|
|
106
|
+
return null;
|
|
107
|
+
return v === '~' || v.startsWith('~/') ? path.join(os.homedir(), v.slice(v === '~' ? 1 : 2)) : path.resolve(v);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Old save locations that still hold project files. Read-only use only. */
|
|
114
|
+
export function legacyDirsFor(cwd) {
|
|
115
|
+
const pf = resolveProjectFolderSync(cwd);
|
|
116
|
+
const mine = key(pf.workDir);
|
|
117
|
+
const out = [];
|
|
118
|
+
const add = (d) => {
|
|
119
|
+
if (d && key(d) !== mine && holdsEnvelopes(d) && !out.some((o) => key(o) === key(d)))
|
|
120
|
+
out.push(d);
|
|
121
|
+
};
|
|
122
|
+
// A team folder's saves before this release went to <team>/.cspeach.
|
|
123
|
+
if (pf.kind === 'team')
|
|
124
|
+
add(path.join(pf.projectDir, '.cspeach'));
|
|
125
|
+
const g = oldGitRoot(pf.projectDir);
|
|
126
|
+
if (g && key(g) !== key(pf.projectDir))
|
|
127
|
+
add(path.join(g, '.cspeach'));
|
|
128
|
+
add(oldWorkspaceFolder());
|
|
129
|
+
add(cspeachRoot());
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
1
2
|
import { readProjectFile } from './status.js';
|
|
2
3
|
import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
|
|
3
4
|
import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
|
|
4
5
|
import { askYesNo } from './yes-no.js';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
8
|
+
import { isCliHomePath, isSeatReadOnlyFile } from '../tools/_filesystem-shared.js';
|
|
9
|
+
/**
|
|
10
|
+
* Task 14 — a `--from` source inside the CLI home is read only in an explicit
|
|
11
|
+
* shape: the read-only seat shape (`<seat>/work/<id>/.cspeach/<name>.cspeach.json`)
|
|
12
|
+
* or an envelope directly in the CLI home (the earlier save location this
|
|
13
|
+
* release still lists). Config, keys, sessions, snapshots and every other
|
|
14
|
+
* file there stay out of reach. Spelled and real paths are both judged.
|
|
15
|
+
*/
|
|
16
|
+
function cliHomeSourceAllowed(abs) {
|
|
17
|
+
if (!isCliHomePath(abs))
|
|
18
|
+
return true;
|
|
19
|
+
if (isSeatReadOnlyFile(abs))
|
|
20
|
+
return true;
|
|
21
|
+
return directHomeEnvelope(abs) && directHomeEnvelope(realOr(abs));
|
|
22
|
+
}
|
|
23
|
+
function directHomeEnvelope(p) {
|
|
24
|
+
if (p === null)
|
|
25
|
+
return false;
|
|
26
|
+
const base = path.basename(p);
|
|
27
|
+
if (!/^[^:~]+\.cspeach\.json$/i.test(base) || /[. ]$/.test(base))
|
|
28
|
+
return false;
|
|
29
|
+
const k = (x) => (process.platform === 'win32' ? x.toLowerCase() : x);
|
|
30
|
+
return [cspeachRoot(), realOr(cspeachRoot())].some((h) => h !== null && k(path.dirname(p)) === k(h));
|
|
31
|
+
}
|
|
32
|
+
function realOr(p) {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync.native(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
5
40
|
/**
|
|
6
41
|
* Per-skill predecessor map. v0.7 admits multi-source via array values —
|
|
7
42
|
* e.g. /abap-upgrade-fix accepts BOTH upgrade-scan (the existing chain
|
|
@@ -66,6 +101,10 @@ export async function runPromoteCommand(args) {
|
|
|
66
101
|
args.log(`Use a more specific @<fragment> to disambiguate.`);
|
|
67
102
|
return null;
|
|
68
103
|
}
|
|
104
|
+
if (!cliHomeSourceAllowed(path.resolve(args.cwd ?? process.cwd(), resolvedPath))) {
|
|
105
|
+
args.log(`--from: ${resolvedPath} is inside CSPeach's own folder and cannot be read.`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
69
108
|
let env;
|
|
70
109
|
try {
|
|
71
110
|
env = readProjectFile(resolvedPath);
|
|
@@ -18,6 +18,7 @@ import { saveProject } from './save.js';
|
|
|
18
18
|
import { formatSavedLines } from './saved-lines.js';
|
|
19
19
|
import { writePlanHandover } from './handover-md.js';
|
|
20
20
|
import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
|
|
21
|
+
import { resolveProjectFolderSync, ensureProjectId } from './project-folder.js';
|
|
21
22
|
import { askYesNo, isYes } from './yes-no.js';
|
|
22
23
|
import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
23
24
|
import { specGapRefForSave } from './plan-sources.js';
|
|
@@ -384,19 +385,40 @@ export async function runSaveCommand(args) {
|
|
|
384
385
|
// run confirms no row. Any further question routed through `prompt` would
|
|
385
386
|
// silently receive 'y' too: give maybeOfferSave's headless prompt a
|
|
386
387
|
// question-aware answer first.
|
|
387
|
-
|
|
388
|
+
// Inside a journey a triage is saved without the question: the journey needs the rows.
|
|
389
|
+
const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
|
|
390
|
+
const answer = journey ? 'y' : await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
|
|
388
391
|
if (!isYes(answer)) {
|
|
389
392
|
args.onNotSaved?.('declined');
|
|
390
393
|
return null;
|
|
391
394
|
}
|
|
392
395
|
// 2026-10-06 — a clean-core triage becomes team project decisions only row by
|
|
393
396
|
// row, each on its own Yes. Without a joined team project nothing is asked.
|
|
397
|
+
let tally;
|
|
394
398
|
if (spec.artefactType === 'clean-core-decision') {
|
|
395
|
-
const { confirmCleanCoreRows } = await import('./clean-core-confirm.js');
|
|
399
|
+
const { confirmCleanCoreRows, decideCleanCoreRows } = await import('./clean-core-confirm.js');
|
|
396
400
|
const { activeProject } = await import('../register/store.js');
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
401
|
+
const joined = activeProject() !== null;
|
|
402
|
+
if (journey && joined) {
|
|
403
|
+
// Guided journey: [Yes] [Keep as is] [Skip] per row.
|
|
404
|
+
const r = await decideCleanCoreRows(extract, {
|
|
405
|
+
card: journey.card, ask: journey.ask, skip: journey.skip, log: args.log,
|
|
406
|
+
});
|
|
407
|
+
extract = r.extract;
|
|
408
|
+
tally = r.tally;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (journey) {
|
|
412
|
+
// Inside a journey: no team words, no command to type (Task 7).
|
|
413
|
+
const { JOURNEY_NOT_IN_PROJECT_LINE } = await import('./clean-core-confirm.js');
|
|
414
|
+
args.log(JOURNEY_NOT_IN_PROJECT_LINE);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
extract = await confirmCleanCoreRows(extract, {
|
|
418
|
+
...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
400
422
|
}
|
|
401
423
|
let env;
|
|
402
424
|
if (planExtract && prior) {
|
|
@@ -436,6 +458,17 @@ export async function runSaveCommand(args) {
|
|
|
436
458
|
let outDir;
|
|
437
459
|
try {
|
|
438
460
|
outDir = await ensureWorkspace();
|
|
461
|
+
// 2026-10-08 — the first save writes .cspeach/project.json (solo/home);
|
|
462
|
+
// a team folder uses its team id. Never fails the save.
|
|
463
|
+
try {
|
|
464
|
+
const pf = resolveProjectFolderSync(process.cwd());
|
|
465
|
+
const projectId = pf.kind === 'team' ? pf.projectId : ensureProjectId(pf.workDir);
|
|
466
|
+
try {
|
|
467
|
+
args.onProjectId?.(projectId);
|
|
468
|
+
}
|
|
469
|
+
catch { /* tagging is best-effort */ }
|
|
470
|
+
}
|
|
471
|
+
catch { /* id file is best-effort */ }
|
|
439
472
|
}
|
|
440
473
|
catch {
|
|
441
474
|
outDir = args.cwd;
|
|
@@ -450,7 +483,9 @@ export async function runSaveCommand(args) {
|
|
|
450
483
|
writePlanHandover(path, args.log);
|
|
451
484
|
// 1.1.28 — two plain lines; the old suggested-email block is gone (saved-lines.ts).
|
|
452
485
|
const [savedLine, reviewLine] = formatSavedLines(path, args.cwd);
|
|
453
|
-
|
|
486
|
+
// Inside a journey the file is plumbing: the closing lines say what was recorded.
|
|
487
|
+
if (!journey)
|
|
488
|
+
args.log(savedLine);
|
|
454
489
|
// Project Register (2026-09-18): when this laptop has joined a project, a
|
|
455
490
|
// decision / fix record is written to the LOCAL outbox as a by-product of the
|
|
456
491
|
// save. No project joined → `activeProject()` is one missing-file read and
|
|
@@ -460,27 +495,63 @@ export async function runSaveCommand(args) {
|
|
|
460
495
|
const joined = activeProject();
|
|
461
496
|
if (joined) {
|
|
462
497
|
const { recordFromEnvelope } = await import('../register/records.js');
|
|
463
|
-
|
|
464
|
-
|
|
498
|
+
// Task 15 — detail files are read from where the skill wrote them: the
|
|
499
|
+
// seat work root in a team folder, the start folder otherwise. The
|
|
500
|
+
// record itself still goes to the shared outbox.
|
|
501
|
+
const res = recordFromEnvelope(env, path, joined, { name: joined.name ?? args.author.name }, resolveProjectFolderSync(args.cwd).workRoot);
|
|
502
|
+
if (tally) {
|
|
503
|
+
const { journeyRowLines } = await import('./clean-core-confirm.js');
|
|
504
|
+
for (const line of journeyRowLines(tally, res?.refused ?? []))
|
|
505
|
+
args.log(line);
|
|
506
|
+
}
|
|
507
|
+
else if (res?.file)
|
|
465
508
|
args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
|
|
466
509
|
if (res?.file) {
|
|
467
510
|
const { regenerateWorkingList } = await import('../register/workbooks.js');
|
|
468
511
|
// The name the team project knows this laptop by (init --lead), as the team verbs use.
|
|
469
512
|
const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
|
|
470
|
-
if (
|
|
513
|
+
if (journey) {
|
|
514
|
+
const { journeyListLine } = await import('./clean-core-confirm.js');
|
|
515
|
+
const line = journeyListLine(sheet.kind);
|
|
516
|
+
if (line)
|
|
517
|
+
args.log(line);
|
|
518
|
+
}
|
|
519
|
+
else if (sheet.line)
|
|
471
520
|
args.log(sheet.line.trimStart());
|
|
472
521
|
}
|
|
473
522
|
if (res?.refused.length) {
|
|
474
|
-
args.log(`Not recorded (${res.refused.length} not in the team project's object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
|
|
523
|
+
args.log(`Not recorded (${res.refused.length} not in ${tally ? 'your project' : 'the team project'}'s object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
|
|
475
524
|
}
|
|
476
525
|
}
|
|
477
526
|
}
|
|
478
527
|
catch (e) {
|
|
479
|
-
|
|
528
|
+
if (journey) {
|
|
529
|
+
const { notRecordedLine } = await import('../journey/run.js');
|
|
530
|
+
args.log(notRecordedLine(e));
|
|
531
|
+
}
|
|
532
|
+
else
|
|
533
|
+
args.log(`(team project record not written: ${e instanceof Error ? e.message : String(e)})`);
|
|
480
534
|
}
|
|
481
535
|
if (planExtract && prior) {
|
|
482
536
|
args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
|
|
483
537
|
}
|
|
484
|
-
|
|
538
|
+
if (!journey)
|
|
539
|
+
args.log(reviewLine);
|
|
540
|
+
if (journey && tally) {
|
|
541
|
+
if (tally.cancelled) {
|
|
542
|
+
journey.pause();
|
|
543
|
+
}
|
|
544
|
+
else if (journey.page?.length) {
|
|
545
|
+
// One triage per page: a row of the page the triage left out is put off
|
|
546
|
+
// until "carry on", so the journey moves on instead of asking it again — and says so.
|
|
547
|
+
const answered = new Set([...tally.yes, ...tally.kept.map((k) => k.key), ...tally.skipped]);
|
|
548
|
+
const left = journey.page.filter((k) => !answered.has(k.toUpperCase()));
|
|
549
|
+
if (left.length) {
|
|
550
|
+
journey.leaveForLater(left);
|
|
551
|
+
const { leftOutLine } = await import('./clean-core-confirm.js');
|
|
552
|
+
args.log(leftOutLine(left));
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
485
556
|
return path;
|
|
486
557
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Never prints a user name.
|
|
12
12
|
*/
|
|
13
|
-
import { basename, dirname,
|
|
13
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
14
|
+
import { resolveProjectFolderSync } from './project-folder.js';
|
|
14
15
|
function editorLabel() {
|
|
15
16
|
const url = process.env.CSPEACH_EDITOR_URL?.trim();
|
|
16
17
|
return url ? url : 'cspeach.dev/edit';
|
|
@@ -22,10 +23,23 @@ function sameDir(a, b) {
|
|
|
22
23
|
};
|
|
23
24
|
return norm(a) === norm(b);
|
|
24
25
|
}
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* `<file name> (in <where>)` — names the folder kind (2026-10-08): this
|
|
28
|
+
* project's .cspeach folder (solo), your own working folder (team), or the
|
|
29
|
+
* home fallback folder with a hint. Anywhere else: the folder itself.
|
|
30
|
+
*/
|
|
26
31
|
export function savedLocation(savedPath, cwd) {
|
|
27
32
|
const dir = dirname(savedPath);
|
|
28
|
-
const
|
|
33
|
+
const pf = resolveProjectFolderSync(cwd);
|
|
34
|
+
let where = dir;
|
|
35
|
+
if (sameDir(dir, pf.workDir)) {
|
|
36
|
+
if (pf.kind === 'solo')
|
|
37
|
+
where = "this project's .cspeach folder";
|
|
38
|
+
else if (pf.kind === 'team')
|
|
39
|
+
where = 'your own working folder for this team project, not shared';
|
|
40
|
+
else
|
|
41
|
+
where = `${dir} — start CSPeach in a project folder to keep projects apart`;
|
|
42
|
+
}
|
|
29
43
|
return `${basename(savedPath)} (in ${where})`;
|
|
30
44
|
}
|
|
31
45
|
export function formatSavedLines(savedPath, cwd) {
|