@cspeach/cli 1.1.29 → 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 +127 -11
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +39 -13
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.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 +652 -46
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- 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 +21 -1
- package/dist/projects/clean-core-confirm.js +192 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +82 -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 +115 -15
- 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/validate.js +2 -1
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -120
- package/dist/register/atc-file.js +9 -1
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +82 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +125 -0
- package/dist/register/picture.js +25 -0
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +57 -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 +221 -0
- package/dist/register/status-file/register-model.js +341 -0
- package/dist/register/status-file/register-parts.js +555 -0
- package/dist/register/status-file/register-status-file.js +189 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +22 -0
- package/dist/register/status-html.js +19 -0
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +124 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +127 -0
- 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/journey-entry.js +25 -0
- package/dist/repl.js +158 -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 +45 -45
- package/dist/tools/_filesystem-shared.js +190 -21
- 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 +145 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +203 -5
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +2 -2
- 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
package/dist/lane/app-chain.js
CHANGED
|
@@ -64,12 +64,14 @@ export function cleanSpecToken(raw) {
|
|
|
64
64
|
const NOT_STARTED = 'The app flow did not start.';
|
|
65
65
|
/**
|
|
66
66
|
* Resolve the /abap-app spec token to a stable path (forward slashes):
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
67
|
+
* relative to the folder you started in; absolute only for a spec in the
|
|
68
|
+
* working `.cspeach` folder when that sits outside it (a team seat's work
|
|
69
|
+
* folder). 2026-10-08: a configured workspace_folder is ignored. The token is looked up
|
|
70
|
+
* with resolveAtTokenAsync — the same lookup the @-attachment expansion uses —
|
|
71
|
+
* so the pinned spec and the analysed spec are the same file. Refused, with
|
|
72
|
+
* one plain line, when the message names no file, the name is ambiguous, the
|
|
73
|
+
* file is outside this folder (also through a link), sits in a protected
|
|
74
|
+
* place, or does not exist. The
|
|
73
75
|
* stored value is what app_lane_resolve's `sameFile` compares, and that check
|
|
74
76
|
* (like readSpecText) uses the same containment (lane/roots.ts).
|
|
75
77
|
*/
|
|
@@ -80,7 +82,7 @@ export async function resolveAppSpec(cwd, message) {
|
|
|
80
82
|
if (tok === '')
|
|
81
83
|
return { ok: false, line: `Name the spec file with @, for example: /abap-app @specs/my-app.docx\n${NOT_STARTED}` };
|
|
82
84
|
const shown = cardLine(tok);
|
|
83
|
-
const outside = { ok: false, line: `The spec file must be inside this folder
|
|
85
|
+
const outside = { ok: false, line: `The spec file must be inside this folder: ${shown}. ${NOT_STARTED}` };
|
|
84
86
|
const missing = { ok: false, line: `Spec file not found: ${shown}. ${NOT_STARTED}` };
|
|
85
87
|
if (tok.includes('\0'))
|
|
86
88
|
return outside;
|
package/dist/lane/chain-token.js
CHANGED
|
@@ -9,16 +9,18 @@
|
|
|
9
9
|
// optional `-N` collision suffix saveProject adds);
|
|
10
10
|
// - the path has no `"` and no control character (it rides in a command);
|
|
11
11
|
// - the real file exists inside realpath(cwd) or realpath(workspace folder),
|
|
12
|
-
// so a link out of the save root is refused
|
|
12
|
+
// so a link out of the save root is refused;
|
|
13
|
+
// - Task 14: a path in the CLI home is accepted ONLY in the read-only seat
|
|
14
|
+
// shape (`<seat>/work/<id>/.cspeach/<name>.cspeach.json`, isSeatReadOnlyFile)
|
|
15
|
+
// — the seat is reached deliberately, never by absence of a guard.
|
|
13
16
|
// Round trip: parseFromFlag reads `@"…"` / `@\S+`; resolveAtTokenAsync takes a
|
|
14
17
|
// path with a separator or an absolute path verbatim; coerceDocFromFlagToAttachment
|
|
15
18
|
// leaves `.cspeach.json` alone.
|
|
16
19
|
import { promises as fs } from 'node:fs';
|
|
17
20
|
import path from 'node:path';
|
|
18
|
-
import { assertRealPathContained } from '../tools/_filesystem-shared.js';
|
|
19
|
-
import { workspaceFolderFor } from './roots.js';
|
|
20
|
-
|
|
21
|
-
export const SPEC_GAP_NAME_RE = /^[a-z0-9-]*-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/;
|
|
21
|
+
import { assertRealPathContained, isCliHomePath, isSeatReadOnlyFile } from '../tools/_filesystem-shared.js';
|
|
22
|
+
import { workspaceFolderFor, SPEC_GAP_NAME_RE } from './roots.js';
|
|
23
|
+
export { SPEC_GAP_NAME_RE };
|
|
22
24
|
// eslint-disable-next-line no-control-regex
|
|
23
25
|
const UNSAFE = /["\u0000-\u001f\u007f-\u009f]/;
|
|
24
26
|
/** A segment after whitespace (or at the start) that begins with `--`. */
|
|
@@ -47,6 +49,8 @@ export async function chainFromToken(cwd, savedPath) {
|
|
|
47
49
|
const ws = await workspaceFolderFor(cwd).catch(() => undefined);
|
|
48
50
|
if (!(await containedIn(abs, root)) && !(await containedIn(abs, ws)))
|
|
49
51
|
return undefined;
|
|
52
|
+
if (isCliHomePath(abs) && !isSeatReadOnlyFile(abs))
|
|
53
|
+
return undefined;
|
|
50
54
|
const rel = path.relative(root, abs);
|
|
51
55
|
let p = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : abs;
|
|
52
56
|
if (!path.isAbsolute(p) && !p.includes('/'))
|
package/dist/lane/roots.js
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
// App lane routing — final fix wave I3. The ONE root the lane decision folder
|
|
2
2
|
// hangs off, and the spec containment rule, shared by every caller:
|
|
3
3
|
//
|
|
4
|
-
// - laneRootFor(cwd): the folder that
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// - laneRootFor(cwd): the folder that holds the working `.cspeach` — the
|
|
5
|
+
// folder you start in, or the seat work root (`<CSPEACH_HOME>/work/<id>`)
|
|
6
|
+
// for a team folder, or ~/CSPeach when you start in the home folder
|
|
7
|
+
// (2026-10-08: no git walk, workspace_folder ignored; never the CLI home).
|
|
8
|
+
// The lane write, every pinned read and the relative `from.lane.path`
|
|
9
|
+
// (`.cspeach/lane/<id>.json`) all use it.
|
|
10
10
|
// - containSpecPath(cwd, p): a spec is accepted inside cwd OR the workspace
|
|
11
11
|
// folder, string-contained AND realpath-contained (no link escapes), and
|
|
12
12
|
// refused when ANY of those roots (or the lane root) would denylist it.
|
|
13
|
-
import { promises as fs } from 'node:fs';
|
|
13
|
+
import { promises as fs, realpathSync } from 'node:fs';
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { resolveWorkspacePathFor } from '../projects/workspace.js';
|
|
17
|
-
import {
|
|
18
|
-
import { cspeachRoot } from '../config/paths.js';
|
|
19
|
-
import { isDenylistedPath, PathOutsideRootError } from '../tools/_filesystem-shared.js';
|
|
20
|
-
/**
|
|
17
|
+
import { resolveProjectFolderSync, SAFE_PROJECT_ID } from '../projects/project-folder.js';
|
|
18
|
+
import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
|
|
19
|
+
import { isDenylistedPath, isSeatReadOnlyFile, PathOutsideRootError } from '../tools/_filesystem-shared.js';
|
|
20
|
+
/** `<slug>-spec-gap-<4 hex>-v<N>[-<n>].cspeach.json` — titleSlug is `[a-z0-9-]`, possibly empty. */
|
|
21
|
+
export const SPEC_GAP_NAME_RE = /^[a-z0-9-]*-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/;
|
|
22
|
+
/** The workspace folder (the working `.cspeach`) for this cwd, absolute. */
|
|
21
23
|
export async function workspaceFolderFor(cwd) {
|
|
22
24
|
try {
|
|
23
25
|
return path.resolve(await resolveWorkspacePathFor(cwd));
|
|
@@ -28,48 +30,96 @@ export async function workspaceFolderFor(cwd) {
|
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* The folder whose `.cspeach/lane/` (and `.cspeach/conventions.toml`, R18)
|
|
31
|
-
* belong to this project.
|
|
32
|
-
* - R17: a configured `workspace_folder` is the root ITSELF
|
|
33
|
-
* (`<workspace_folder>/.cspeach/lane`), never its parent — so `~/CSPeach`
|
|
34
|
-
* never puts lane files in the CLI home `~/.cspeach`.
|
|
35
|
-
* - Default workspace (`<git-root or cwd>/.cspeach`): the git root (or cwd).
|
|
33
|
+
* belong to this project: the working root for this start folder.
|
|
36
34
|
*/
|
|
37
35
|
export async function laneRootFor(cwd) {
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
configured = (await loadConfig()).workspace_folder?.trim() || undefined;
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
configured = undefined;
|
|
44
|
-
}
|
|
45
|
-
if (configured)
|
|
46
|
-
return workspaceFolderFor(cwd);
|
|
47
|
-
return path.dirname(await workspaceFolderFor(cwd));
|
|
36
|
+
return resolveProjectFolderSync(cwd).workRoot;
|
|
48
37
|
}
|
|
49
38
|
/**
|
|
50
|
-
* R17 —
|
|
51
|
-
*
|
|
39
|
+
* R17 — why a lane root (or a plan's pin root) may not be used, or null when
|
|
40
|
+
* it may. Refused: the home folder; a root whose `.cspeach` is a CLI home;
|
|
41
|
+
* the CLI home itself or anything under it — with ONE exception: a team
|
|
42
|
+
* seat's private working root `<cspeachRoot()>/work/<project id>`, exactly.
|
|
43
|
+
*
|
|
44
|
+
* Task 14 — compared on REAL paths of the nearest existing ancestor (a root
|
|
45
|
+
* that does not exist yet, spelled through a link or an 8.3 name, cannot
|
|
46
|
+
* slip past); BOTH homes are guarded (`cspeachRoot()` and `~/.cspeach`);
|
|
47
|
+
* the hard refusals run BEFORE the work-root allow; a path whose real form
|
|
48
|
+
* cannot be known is refused. The message names the seat folder.
|
|
52
49
|
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
50
|
+
export function cliHomeRefusal(root) {
|
|
51
|
+
const seats = [cspeachRoot(), defaultCspeachRoot()].filter((s, i, a) => a.indexOf(s) === i);
|
|
52
|
+
const msg = (seat) => `the lane folder would be inside the cspeach home folder (${seat}). Start CSPeach in your project folder.`;
|
|
53
|
+
const rootReal = caseKey(realForm(root));
|
|
54
|
+
const dot = caseKey(realForm(path.join(root, '.cspeach')));
|
|
55
|
+
const home = caseKey(realForm(os.homedir()));
|
|
56
|
+
if (rootReal === null || dot === null || home === null)
|
|
57
|
+
return msg(seats[0]);
|
|
58
|
+
// Hard refusals first: the home folder, or a `.cspeach` that is a CLI home.
|
|
59
|
+
if (rootReal === home)
|
|
60
|
+
return msg(defaultCspeachRoot());
|
|
61
|
+
const seatKeys = seats.map((s) => [s, caseKey(realForm(s))]);
|
|
62
|
+
for (const [s, k] of seatKeys) {
|
|
63
|
+
if (k === null || dot === k)
|
|
64
|
+
return msg(s);
|
|
65
|
+
}
|
|
66
|
+
// 2026-10-08 — a team member's private working root, exactly
|
|
67
|
+
// <cspeachRoot()>/work/<id> (never under ~/.cspeach when CSPEACH_HOME moved it).
|
|
68
|
+
// Its `.cspeach` must be its own (not a link elsewhere in the seat).
|
|
69
|
+
const chosen = seatKeys[0][1];
|
|
70
|
+
const rel = path.relative(chosen, rootReal);
|
|
71
|
+
const parts = rel.split(path.sep);
|
|
72
|
+
if (!path.isAbsolute(rel) && parts.length === 2 && parts[0] === 'work' && SAFE_PROJECT_ID.test(parts[1])) {
|
|
73
|
+
return dot === caseKey(path.join(rootReal, '.cspeach')) ? null : msg(seats[0]);
|
|
74
|
+
}
|
|
75
|
+
// Anything else in a CLI home — the root, or a `.cspeach` that links into one.
|
|
76
|
+
for (const [s, k] of seatKeys) {
|
|
77
|
+
if (rootReal === k || inside(rootReal, k) || inside(dot, k))
|
|
78
|
+
return msg(s);
|
|
66
79
|
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/** Throws when `root` may not be a lane root (see cliHomeRefusal). */
|
|
83
|
+
export async function assertNotCliHome(root) {
|
|
84
|
+
const refusal = cliHomeRefusal(root);
|
|
85
|
+
if (refusal)
|
|
86
|
+
throw new Error(refusal);
|
|
67
87
|
}
|
|
68
88
|
const inside = (p, root) => {
|
|
69
89
|
const rel = path.relative(root, p);
|
|
70
90
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
71
91
|
};
|
|
72
92
|
const realOr = async (p) => fs.realpath(p).catch(() => path.resolve(p));
|
|
93
|
+
/**
|
|
94
|
+
* Task 14 — the real form of a folder that may not exist yet: the nearest
|
|
95
|
+
* EXISTING ancestor realpath'd (links followed, 8.3 names expanded), the
|
|
96
|
+
* missing tail re-attached. Null — the caller refuses — on any lookup error
|
|
97
|
+
* other than "does not exist", or (win32) a missing segment with '~' (a
|
|
98
|
+
* possible 8.3 name we cannot decode).
|
|
99
|
+
*/
|
|
100
|
+
function realForm(p) {
|
|
101
|
+
let cur = path.resolve(p);
|
|
102
|
+
const tail = [];
|
|
103
|
+
for (;;) {
|
|
104
|
+
try {
|
|
105
|
+
const r = realpathSync.native(cur);
|
|
106
|
+
return tail.length ? path.join(r, ...tail) : r;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
if (e?.code !== 'ENOENT')
|
|
110
|
+
return null;
|
|
111
|
+
const parent = path.dirname(cur);
|
|
112
|
+
if (parent === cur)
|
|
113
|
+
return null;
|
|
114
|
+
const name = path.basename(cur);
|
|
115
|
+
if (process.platform === 'win32' && name.includes('~'))
|
|
116
|
+
return null;
|
|
117
|
+
tail.unshift(name);
|
|
118
|
+
cur = parent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const caseKey = (p) => (p === null ? null : process.platform === 'win32' ? p.toLowerCase() : p);
|
|
73
123
|
export class SpecProtectedError extends Error {
|
|
74
124
|
constructor(specPath) {
|
|
75
125
|
super(`spec file is in a protected location and cannot be read: ${specPath}`);
|
|
@@ -95,6 +145,12 @@ export async function containSpecPath(cwd, specPath) {
|
|
|
95
145
|
const realRoots = await Promise.all(roots.map(realOr));
|
|
96
146
|
if (!realRoots.some((r) => inside(real, r)))
|
|
97
147
|
throw new PathOutsideRootError(specPath, cwd);
|
|
148
|
+
// Ruling 14 (Task 14) — the one explicit opening into the seat: a spec-gap
|
|
149
|
+
// ENVELOPE saved directly in <seat>/work/<id>/.cspeach, read-only (the
|
|
150
|
+
// read-only seat shape, judged on the spelled and the real path). Anything
|
|
151
|
+
// else in the seat (a raw .md, other envelopes, lane files) stays refused.
|
|
152
|
+
if (SPEC_GAP_NAME_RE.test(path.basename(real)) && isSeatReadOnlyFile(abs) && isSeatReadOnlyFile(real))
|
|
153
|
+
return { abs, real };
|
|
98
154
|
const guards = [...realRoots, await realOr(await laneRootFor(cwd))];
|
|
99
155
|
for (const r of guards) {
|
|
100
156
|
if (inside(real, r) && isDenylistedPath(real, r).blocked)
|
package/dist/one-shot.js
CHANGED
|
@@ -68,12 +68,15 @@ import './tools/project/playbook_get.js';
|
|
|
68
68
|
import './tools/project/convention_get.js';
|
|
69
69
|
import './tools/project/cca_merge.js';
|
|
70
70
|
import './tools/project/upgrade_merge_progress.js';
|
|
71
|
+
import './tools/project/team_findings.js';
|
|
72
|
+
import './tools/project/team_estate.js';
|
|
71
73
|
import './tools/subagent/_background-shared.js';
|
|
72
74
|
import './tools/subagent/background_run.js';
|
|
73
75
|
import './tools/subagent/monitor_emit.js';
|
|
74
76
|
import './tools/subagent/schedule_draft_create.js';
|
|
75
77
|
import './tools/subagent/agent_run.js';
|
|
76
78
|
import './tools/subagent/read_agent.js';
|
|
79
|
+
import { folderTag } from './session/project-match.js';
|
|
77
80
|
export async function runOneShot(prompt, opts) {
|
|
78
81
|
applyColorMode(); // NO_COLOR (piece 2 §1.2)
|
|
79
82
|
const cfg = await loadConfig();
|
|
@@ -191,8 +194,25 @@ export async function runOneShot(prompt, opts) {
|
|
|
191
194
|
}
|
|
192
195
|
adt = new AdtClient(conn);
|
|
193
196
|
}
|
|
194
|
-
const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
|
|
197
|
+
const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg), folderTag(process.cwd()));
|
|
195
198
|
await saveSession(session);
|
|
199
|
+
// Guided clean-core journey (Task 3) — one-shot is headless: a journey
|
|
200
|
+
// sentence prints its next step and queues nothing; no model call. Only a
|
|
201
|
+
// START sentence (or the explicit /abap-clean-core start form): a bare
|
|
202
|
+
// "continue" / "carry on" keeps its old meaning here (final fix wave).
|
|
203
|
+
{
|
|
204
|
+
const { parseJourneyStart } = await import('./journey/trigger.js');
|
|
205
|
+
const journey = parseJourneyStart(prompt);
|
|
206
|
+
if (journey) {
|
|
207
|
+
const { runJourneyEntry } = await import('./repl/journey-entry.js');
|
|
208
|
+
await runJourneyEntry(journey, {
|
|
209
|
+
session, cwd: process.cwd(), adt, sapAlias: alias, standalone, headless: true,
|
|
210
|
+
emit: (line) => writeStdout(line + '\n'),
|
|
211
|
+
signal: new AbortController().signal,
|
|
212
|
+
});
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
196
216
|
const parsed = parseShortcut(prompt);
|
|
197
217
|
const body = parsed.prompt;
|
|
198
218
|
let skill;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { shown, shownUpTo } from '../register/atc-file.js';
|
|
2
|
+
import { STEPS, stepOf } from '../journey/clean-core.js';
|
|
3
|
+
const WORD = {
|
|
4
|
+
keep: 'keep', exception: 'accepted exception', fix: 'fix', rebuild: 'rebuild beside the core', retire: 'retire',
|
|
5
|
+
};
|
|
6
|
+
export function rowWords(r) {
|
|
7
|
+
const parts = [WORD[r.disposition]];
|
|
8
|
+
if (r.level)
|
|
9
|
+
parts.push(`level ${r.level}`);
|
|
10
|
+
if (r.disposition === 'fix' || r.disposition === 'rebuild')
|
|
11
|
+
parts.push(`target ${r.targetLevel ?? 'A'}`);
|
|
12
|
+
return `${parts.join(', ')}${r.reason ? `, because: ${r.reason}` : ''}`;
|
|
13
|
+
}
|
|
14
|
+
const DECIDE_HINT = 'cspeach team decide <TYPE> <NAME> <decision>';
|
|
15
|
+
export async function confirmCleanCoreRows(ex, a) {
|
|
16
|
+
if (!a.joined) {
|
|
17
|
+
// Review Focus 4: the file is still saved; say why nothing is recorded.
|
|
18
|
+
a.log('Not recorded for a team project: this laptop has not joined one. To record these decisions, join with: cspeach team join <folder>');
|
|
19
|
+
return ex;
|
|
20
|
+
}
|
|
21
|
+
if (!a.confirm) {
|
|
22
|
+
a.log(`Not recorded for the team project: each row needs a yes, and this session cannot ask. Record them with: ${DECIDE_HINT}`);
|
|
23
|
+
return ex;
|
|
24
|
+
}
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (const r of ex.content.adjudications) {
|
|
27
|
+
const answer = await a.confirm(`Record ${r.objectType} ${r.objectName}: ${rowWords(r)}?`, { default: 'n' });
|
|
28
|
+
rows.push({ ...r, confirmed: answer === 'y' });
|
|
29
|
+
}
|
|
30
|
+
const yes = rows.filter((r) => r.confirmed).length;
|
|
31
|
+
a.log(`${yes} of ${rows.length} rows confirmed. To record a row differently: ${DECIDE_HINT}`);
|
|
32
|
+
const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
|
|
33
|
+
return {
|
|
34
|
+
...ex,
|
|
35
|
+
content: { ...ex.content, adjudications: rows },
|
|
36
|
+
items: ex.items.map((i) => ({ ...i, status: byId.get(i.id) ? 'confirmed' : 'declined' })),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export const KEEP_QUESTION = 'Why keep it?';
|
|
40
|
+
/** "Yes to the remaining n shown" is offered once this many rows are answered. */
|
|
41
|
+
const OFFER_REST_AFTER = 3;
|
|
42
|
+
const MAX_KEEP_REASON = 300;
|
|
43
|
+
/** A long reason wraps onto at most this many rows of the card. */
|
|
44
|
+
export const FACT_ROWS = 5;
|
|
45
|
+
const keyOf = (r) => `${r.objectType.trim().toUpperCase()}|${r.objectName.trim().toUpperCase()}`;
|
|
46
|
+
/** The person's reason as one plain line: escapes, control characters and line breaks go; at most 300 characters. */
|
|
47
|
+
export function cleanKeepReason(raw) {
|
|
48
|
+
// Cut by code point, so an emoji at the edge is never split in half.
|
|
49
|
+
return Array.from(shownUpTo(raw ?? '', Number.POSITIVE_INFINITY)).slice(0, MAX_KEEP_REASON).join('').trim();
|
|
50
|
+
}
|
|
51
|
+
/** The proposal in words, without the reason (the reason is a fact line on the card). */
|
|
52
|
+
function proposalWords(r) {
|
|
53
|
+
return rowWords({ ...r, reason: '' });
|
|
54
|
+
}
|
|
55
|
+
export async function decideCleanCoreRows(ex, j) {
|
|
56
|
+
const all = ex.content.adjudications;
|
|
57
|
+
const rows = all.map((r) => ({ ...r, confirmed: false }));
|
|
58
|
+
const tally = { total: all.length, yes: [], kept: [], skipped: [], cancelled: false };
|
|
59
|
+
const footnote = `Step ${stepOf('DECIDE')} of ${STEPS}`;
|
|
60
|
+
let answered = 0;
|
|
61
|
+
rowLoop: for (let i = 0; i < rows.length; i++) {
|
|
62
|
+
const r = rows[i];
|
|
63
|
+
const type = shown(r.objectType);
|
|
64
|
+
const name = shown(r.objectName);
|
|
65
|
+
const left = rows.length - i;
|
|
66
|
+
const why = shownUpTo(r.reason ?? '', MAX_KEEP_REASON);
|
|
67
|
+
let canKeep = true;
|
|
68
|
+
let settled = false;
|
|
69
|
+
for (let tries = 0; tries < 20 && !settled; tries++) {
|
|
70
|
+
const offerRest = answered >= OFFER_REST_AFTER && left >= 2;
|
|
71
|
+
const res = await j.card({
|
|
72
|
+
id: 'journey-row',
|
|
73
|
+
tag: 'QUESTION',
|
|
74
|
+
title: `Record ${type} ${name}: ${proposalWords({ ...r, objectType: type, objectName: name })}?`,
|
|
75
|
+
facts: [...(why ? [`Why: ${why}`] : []), `Row ${i + 1} of ${rows.length}.`],
|
|
76
|
+
choices: [
|
|
77
|
+
{ value: 'yes', label: 'Yes' },
|
|
78
|
+
...(canKeep ? [{ value: 'keep', label: 'Keep as is' }] : []),
|
|
79
|
+
{ value: 'skip', label: 'Skip' },
|
|
80
|
+
...(offerRest ? [{ value: 'all', label: `Yes to the remaining ${left} shown` }] : []),
|
|
81
|
+
],
|
|
82
|
+
initial: 0,
|
|
83
|
+
aliases: { y: 'yes', ...(canKeep ? { k: 'keep' } : {}), s: 'skip', ...(offerRest ? { a: 'all' } : {}) },
|
|
84
|
+
footnote,
|
|
85
|
+
// The reason is the point of the card: it wraps, never cut to one row.
|
|
86
|
+
wrapFacts: FACT_ROWS,
|
|
87
|
+
});
|
|
88
|
+
if (res.cancelled || res.value === null) {
|
|
89
|
+
tally.cancelled = true;
|
|
90
|
+
break rowLoop;
|
|
91
|
+
}
|
|
92
|
+
if (res.value === 'all' && offerRest) {
|
|
93
|
+
for (let k = i; k < rows.length; k++) {
|
|
94
|
+
rows[k] = { ...rows[k], confirmed: true };
|
|
95
|
+
tally.yes.push(keyOf(rows[k]));
|
|
96
|
+
}
|
|
97
|
+
break rowLoop;
|
|
98
|
+
}
|
|
99
|
+
if (res.value === 'yes') {
|
|
100
|
+
rows[i] = { ...r, confirmed: true };
|
|
101
|
+
tally.yes.push(keyOf(r));
|
|
102
|
+
settled = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (res.value === 'skip') {
|
|
106
|
+
tally.skipped.push(keyOf(r));
|
|
107
|
+
j.skip([keyOf(r)]);
|
|
108
|
+
settled = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (res.value === 'keep' && canKeep) {
|
|
112
|
+
if (!r.level) {
|
|
113
|
+
j.log(`No level is known for ${type} ${name}, so it cannot be kept as is. Choose Yes or Skip.`);
|
|
114
|
+
canKeep = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let said;
|
|
118
|
+
try {
|
|
119
|
+
said = await j.ask(KEEP_QUESTION);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
tally.cancelled = true;
|
|
123
|
+
break rowLoop;
|
|
124
|
+
}
|
|
125
|
+
const reason = cleanKeepReason(said);
|
|
126
|
+
if (!reason) {
|
|
127
|
+
j.log(`A keep needs a reason. Nothing was recorded for ${type} ${name} yet.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
rows[i] = { ...r, disposition: 'exception', targetLevel: null, reason, confirmed: true };
|
|
131
|
+
tally.kept.push({ key: keyOf(r), type: r.objectType.toUpperCase(), name: r.objectName.toUpperCase(), level: r.level });
|
|
132
|
+
settled = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Asked over and over with no usable answer: a Skip, never "answered" with nothing recorded.
|
|
136
|
+
if (!settled) {
|
|
137
|
+
j.log('No reason given — skipped for now.');
|
|
138
|
+
tally.skipped.push(keyOf(r));
|
|
139
|
+
j.skip([keyOf(r)]);
|
|
140
|
+
}
|
|
141
|
+
answered += 1;
|
|
142
|
+
}
|
|
143
|
+
const byId = new Map(rows.map((r) => [r.id, r.confirmed]));
|
|
144
|
+
return {
|
|
145
|
+
extract: {
|
|
146
|
+
...ex,
|
|
147
|
+
content: { ...ex.content, adjudications: rows },
|
|
148
|
+
items: ex.items.map((it) => ({ ...it, status: byId.get(it.id) ? 'confirmed' : 'declined' })),
|
|
149
|
+
},
|
|
150
|
+
tally,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The lines after the rows are written: one per kept row, then the count
|
|
155
|
+
* ("4 of 5 rows recorded; 1 kept by decision; 0 skipped."). "Recorded" counts
|
|
156
|
+
* the rows recorded as proposed; a row the project refused is not counted.
|
|
157
|
+
*/
|
|
158
|
+
export function journeyRowLines(t, refused) {
|
|
159
|
+
const out = new Set(refused.map((k) => k.trim().toUpperCase()));
|
|
160
|
+
const kept = t.kept.filter((k) => !out.has(k.key));
|
|
161
|
+
const yes = t.yes.filter((k) => !out.has(k.toUpperCase())).length;
|
|
162
|
+
return [
|
|
163
|
+
...kept.map((k) => `✓ Recorded: ${shown(k.type)} ${shown(k.name)} — kept at ${k.level}, reason recorded.`),
|
|
164
|
+
`${yes} of ${t.total} rows recorded; ${kept.length} kept by decision; ${t.skipped.length} skipped.`,
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The working-list line inside a journey: the person keeps "your list", never
|
|
169
|
+
* a file name, a team word or a command to type (Task 7). Null: nothing to say.
|
|
170
|
+
*/
|
|
171
|
+
export function journeyListLine(kind) {
|
|
172
|
+
if (kind === 'updated')
|
|
173
|
+
return 'Your list is updated.';
|
|
174
|
+
if (kind === 'held')
|
|
175
|
+
return 'Your list is open in another program, so it was not updated. Close it; your next decision updates it.';
|
|
176
|
+
if (kind === 'unreadable')
|
|
177
|
+
return 'Your list could not be updated: the folder could not be read right now.';
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
/** Inside a journey, on a laptop with no project open here (the triage is saved; nothing recorded). */
|
|
181
|
+
export const JOURNEY_NOT_IN_PROJECT_LINE = 'Not recorded: no record is kept in this folder yet.';
|
|
182
|
+
/** Page objects the triage did not answer, in one line: up to five named, then how many more. */
|
|
183
|
+
export function leftOutLine(keys) {
|
|
184
|
+
const named = keys.slice(0, 5).map((k) => {
|
|
185
|
+
const [type = '', name = ''] = k.split('|');
|
|
186
|
+
return `${shown(type)} ${shown(name)}`.trim();
|
|
187
|
+
});
|
|
188
|
+
const more = keys.length - named.length;
|
|
189
|
+
const one = keys.length === 1;
|
|
190
|
+
return `${keys.length.toLocaleString('en-US')} ${one ? 'object' : 'objects'} on this page ${one ? 'was' : 'were'} not in the triage: `
|
|
191
|
+
+ `${named.join(', ')}${more > 0 ? ` (and ${more.toLocaleString('en-US')} more)` : ''}. Left for later — say carry on to look at them again.`;
|
|
192
|
+
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):clean-core-manifest\s*\n([\s\S]*?)\n?\s*-->/;
|
|
2
|
+
const DISPOSITIONS = ['keep', 'exception', 'fix', 'rebuild', 'retire'];
|
|
3
|
+
const MODES = ['triage', 'judge', 'adjudicate', 'advise'];
|
|
4
|
+
const MAX_REASON = 300;
|
|
5
|
+
const letter = (raw, id) => {
|
|
6
|
+
const v = raw.trim().toUpperCase();
|
|
7
|
+
if (v === '' || v === '-')
|
|
8
|
+
return null;
|
|
9
|
+
if (v === 'A' || v === 'B' || v === 'C' || v === 'D')
|
|
10
|
+
return v;
|
|
11
|
+
throw new Error(`${id}: a level is one of A, B, C, D or - (got "${raw.trim()}")`);
|
|
12
|
+
};
|
|
13
|
+
export function extractCleanCoreDecision(markdown) {
|
|
14
|
+
const m = MANIFEST_RE.exec(markdown);
|
|
15
|
+
if (!m)
|
|
16
|
+
throw new Error('No <!-- cspeach:clean-core-manifest --> block found in skill output');
|
|
17
|
+
const kv = {};
|
|
18
|
+
const rows = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let inTable = false;
|
|
21
|
+
let blankInTable = false;
|
|
22
|
+
for (const raw of (m[1] ?? '').split('\n')) {
|
|
23
|
+
const line = raw.replace(/\r$/, '');
|
|
24
|
+
// Inside the table a blank line does not end it: a row after one is refused
|
|
25
|
+
// below, so no row can slip out of the table into the settings.
|
|
26
|
+
if (!line.trim()) {
|
|
27
|
+
if (inTable)
|
|
28
|
+
blankInTable = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (inTable) {
|
|
32
|
+
// `adjudications:` is the last setting; an unindented line here is a broken row.
|
|
33
|
+
if (!/^\s+/.test(line))
|
|
34
|
+
throw new Error(`"${line.trim()}": a row must be indented under adjudications:`);
|
|
35
|
+
const cells = line.trim().split('|').map((c) => c.trim());
|
|
36
|
+
const [id = '', type = '', name = '', level = '', word = '', target = ''] = cells;
|
|
37
|
+
if (blankInTable)
|
|
38
|
+
throw new Error(`${id}: a row comes after a blank line inside adjudications:`);
|
|
39
|
+
// Rows are matched to their cards by id; two rows with one id would share one answer.
|
|
40
|
+
if (seen.has(id))
|
|
41
|
+
throw new Error(`${id}: two rows have this id`);
|
|
42
|
+
seen.add(id);
|
|
43
|
+
const reason = cells.slice(6).join(' | ').trim().slice(0, MAX_REASON);
|
|
44
|
+
const disposition = word.toLowerCase();
|
|
45
|
+
if (!DISPOSITIONS.includes(disposition))
|
|
46
|
+
throw new Error(`${id}: "${word}" is not one of ${DISPOSITIONS.join(', ')}`);
|
|
47
|
+
if (!type || !name)
|
|
48
|
+
throw new Error(`${id}: a row needs an object type and a name`);
|
|
49
|
+
if (disposition === 'exception' && !reason)
|
|
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`);
|
|
54
|
+
const moves = disposition === 'fix' || disposition === 'rebuild';
|
|
55
|
+
rows.push({
|
|
56
|
+
id, objectType: type.toUpperCase(), objectName: name.toUpperCase(), level: letter(level, id), disposition,
|
|
57
|
+
targetLevel: moves ? letter(target, id) : null, reason, confirmed: false,
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const colon = line.indexOf(':');
|
|
62
|
+
if (colon === -1)
|
|
63
|
+
continue;
|
|
64
|
+
const key = line.slice(0, colon).trim();
|
|
65
|
+
const value = line.slice(colon + 1).trim();
|
|
66
|
+
if (key === 'adjudications' && !value) {
|
|
67
|
+
inTable = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
kv[key] = value;
|
|
71
|
+
}
|
|
72
|
+
if (kv.artefact !== 'clean-core-decision')
|
|
73
|
+
throw new Error(`Manifest artefact mismatch: expected 'clean-core-decision', got '${kv.artefact}'`);
|
|
74
|
+
if (!rows.length)
|
|
75
|
+
throw new Error('clean-core-decision manifest has no rows');
|
|
76
|
+
const mode = MODES.includes(kv.input_mode ?? '') ? kv.input_mode : 'triage';
|
|
77
|
+
return {
|
|
78
|
+
title: kv.title || 'Clean-core decisions',
|
|
79
|
+
content: { inputMode: mode, scope: kv.scope ?? '', adjudications: rows },
|
|
80
|
+
items: rows.map((r) => ({ id: r.id, status: 'proposed', answer: null, answeredBy: null, answeredAt: null, comments: [] })),
|
|
81
|
+
};
|
|
82
|
+
}
|