@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
package/dist/session/store.js
CHANGED
|
@@ -7,6 +7,7 @@ import { sessionsDir, pinnedSessionsDir } from '../config/paths.js';
|
|
|
7
7
|
import { SCHEMA_VERSION } from './schema.js';
|
|
8
8
|
import { userPromptText, isUserPrompt, promptPreviewText } from './user-prompt.js';
|
|
9
9
|
import { stripUnsignedThinking, dropTrailingThinkingOnly } from '../agent/repair-partial.js';
|
|
10
|
+
import { normDir, sessionMatchesFolder } from './project-match.js';
|
|
10
11
|
export function makeSessionId() {
|
|
11
12
|
return crypto.randomBytes(4).toString('hex');
|
|
12
13
|
}
|
|
@@ -71,6 +72,9 @@ function validateSchema(parsed) {
|
|
|
71
72
|
// Phase 4 chunk 4A: agentMode defaults to 'skill' for sessions written
|
|
72
73
|
// before this chunk landed. Mirrors the lastUserPrompt back-compat above.
|
|
73
74
|
parsed.agentMode ??= 'skill';
|
|
75
|
+
// 2026-10-08: sessions are listed per folder. Older files have no folder.
|
|
76
|
+
parsed.project_dir ??= null;
|
|
77
|
+
parsed.project_id ??= null;
|
|
74
78
|
// Phase 1 cost tracking (2026-05-16): cache_creation_input_tokens added
|
|
75
79
|
// to usage. Pre-2026-05-16 sessions don't have it; default to 0 so the
|
|
76
80
|
// cost calculator can safely treat usage as a TokenCounts shape.
|
|
@@ -130,11 +134,17 @@ export const RESUME_PICKER_LIMIT = 20;
|
|
|
130
134
|
async function recentSessionFiles(dir, limit) {
|
|
131
135
|
// Merge note — Task 18 reader-* child sessions are skipped by NAME here, so a
|
|
132
136
|
// burst of reader files can never crowd real sessions out of the window.
|
|
133
|
-
const files =
|
|
134
|
-
.filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
|
|
137
|
+
const files = await sessionFileNames(dir);
|
|
135
138
|
const cap = Math.max(limit * 3, 50);
|
|
136
139
|
if (files.length <= cap)
|
|
137
140
|
return files;
|
|
141
|
+
return (await newestFirst(dir, files)).slice(0, cap);
|
|
142
|
+
}
|
|
143
|
+
async function sessionFileNames(dir) {
|
|
144
|
+
return (await fs.readdir(dir).catch(() => []))
|
|
145
|
+
.filter((f) => f.endsWith('.json') && !f.includes('-reader-'));
|
|
146
|
+
}
|
|
147
|
+
async function newestFirst(dir, files) {
|
|
138
148
|
const stamped = await Promise.all(files.map(async (f) => {
|
|
139
149
|
try {
|
|
140
150
|
return { f, t: (await fs.stat(path.join(dir, f))).mtimeMs };
|
|
@@ -144,78 +154,113 @@ async function recentSessionFiles(dir, limit) {
|
|
|
144
154
|
}
|
|
145
155
|
}));
|
|
146
156
|
stamped.sort((a, b) => b.t - a.t);
|
|
147
|
-
return stamped.
|
|
157
|
+
return stamped.map((x) => x.f);
|
|
158
|
+
}
|
|
159
|
+
/** 2026-10-08 — every session file, newest first by mtime (no cap): `--continue` / `--resume` scans. */
|
|
160
|
+
async function allSessionFilesNewestFirst(dir) {
|
|
161
|
+
return newestFirst(dir, await sessionFileNames(dir));
|
|
162
|
+
}
|
|
163
|
+
/** One picker row from a session file; null for a reader child or a corrupt file. */
|
|
164
|
+
async function summaryOf(file, filename) {
|
|
165
|
+
try {
|
|
166
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
167
|
+
const parsed = JSON.parse(raw);
|
|
168
|
+
if (isReaderSessionId(parsed.id))
|
|
169
|
+
return null; // Task 18 — internal child, never resumed
|
|
170
|
+
const messages = parsed.messages ?? [];
|
|
171
|
+
// First user message that's a plain string (not a tool_result array) — the
|
|
172
|
+
// most recent prompt text that initiated a turn. We pick the LAST such
|
|
173
|
+
// message because the picker wants the freshest turn's prompt, not the
|
|
174
|
+
// first one of the session.
|
|
175
|
+
let promptSnippet = null;
|
|
176
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
177
|
+
const m = messages[i];
|
|
178
|
+
const text = m ? userPromptText(m) : null;
|
|
179
|
+
if (text !== null) {
|
|
180
|
+
// Strip <session_context>...</session_context> wrapper that the
|
|
181
|
+
// first turn injects, leaving only the user-typed prompt visible
|
|
182
|
+
// in the picker.
|
|
183
|
+
// (fix round 3: every injected block, e.g. <project_context>, and
|
|
184
|
+
// newlines — they broke the picker's table row.)
|
|
185
|
+
const stripped = promptPreviewText(text);
|
|
186
|
+
promptSnippet = (stripped || '<empty>').slice(0, 80);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
|
|
191
|
+
return {
|
|
192
|
+
id: parsed.id,
|
|
193
|
+
startedAt: parsed.started_at,
|
|
194
|
+
lastTurnAt: parsed.last_turn_at,
|
|
195
|
+
skill: parsed.skill,
|
|
196
|
+
sapSystem: parsed.sap_system,
|
|
197
|
+
filename,
|
|
198
|
+
interrupted: parsed.turnInterrupted === true,
|
|
199
|
+
interruptedReason: parsed.turnInterruptedReason ?? null,
|
|
200
|
+
promptSnippet,
|
|
201
|
+
userTurnCount,
|
|
202
|
+
projectDir: parsed.project_dir ?? null,
|
|
203
|
+
projectId: parsed.project_id ?? null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null; // skip corrupt
|
|
208
|
+
}
|
|
148
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The most recent sessions, newest first by last_turn_at.
|
|
212
|
+
* - `here`: only the sessions of that folder (sessionMatchesFolder).
|
|
213
|
+
* - `scanAll` (with `here`): walk every file newest-first instead of the
|
|
214
|
+
* startup window, stopping once `limit` matches are found
|
|
215
|
+
* (`--continue`, `--resume`). Without `here` the window is always used.
|
|
216
|
+
*/
|
|
149
217
|
export async function listSessions(limit = 10, opts = {}) {
|
|
150
218
|
// NOTE: bypasses validateSchema() for performance — reads JSON directly via
|
|
151
219
|
// JSON.parse + cast. Any new SessionState field accessed here must be
|
|
152
220
|
// defaulted at the read site (e.g. `?? 'skill'`) because pre-chunk-4A
|
|
153
221
|
// serialised sessions on disk lack the field.
|
|
154
222
|
const dir = sessionsDir();
|
|
155
|
-
const
|
|
223
|
+
const scanAll = !!opts.here && opts.scanAll === true;
|
|
224
|
+
const files = scanAll ? await allSessionFilesNewestFirst(dir) : await recentSessionFiles(dir, limit);
|
|
225
|
+
const hereDirNorm = opts.here ? normDir(opts.here.dir) : undefined; // once per call, not per file
|
|
156
226
|
const results = [];
|
|
157
227
|
for (const f of files) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const text = m ? userPromptText(m) : null;
|
|
172
|
-
if (text !== null) {
|
|
173
|
-
// Strip <session_context>...</session_context> wrapper that the
|
|
174
|
-
// first turn injects, leaving only the user-typed prompt visible
|
|
175
|
-
// in the picker.
|
|
176
|
-
// (fix round 3: every injected block, e.g. <project_context>, and
|
|
177
|
-
// newlines — they broke the picker's table row.)
|
|
178
|
-
const stripped = promptPreviewText(text);
|
|
179
|
-
promptSnippet = (stripped || '<empty>').slice(0, 80);
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const userTurnCount = messages.filter((m) => m && isUserPrompt(m)).length;
|
|
184
|
-
results.push({
|
|
185
|
-
id: parsed.id,
|
|
186
|
-
startedAt: parsed.started_at,
|
|
187
|
-
lastTurnAt: parsed.last_turn_at,
|
|
188
|
-
skill: parsed.skill,
|
|
189
|
-
sapSystem: parsed.sap_system,
|
|
190
|
-
filename: f,
|
|
191
|
-
interrupted: parsed.turnInterrupted === true,
|
|
192
|
-
interruptedReason: parsed.turnInterruptedReason ?? null,
|
|
193
|
-
promptSnippet,
|
|
194
|
-
userTurnCount,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
catch { /* skip corrupt */ }
|
|
228
|
+
const s = await summaryOf(path.join(dir, f), f);
|
|
229
|
+
if (!s)
|
|
230
|
+
continue;
|
|
231
|
+
if (opts.here && !sessionMatchesFolder(s, opts.here, hereDirNorm))
|
|
232
|
+
continue;
|
|
233
|
+
// Round 6 review M1 — the picker drops 0-turn sessions (unless interrupted)
|
|
234
|
+
// BEFORE the limit, so a pile of start-then-quit files cannot crowd out
|
|
235
|
+
// the sessions worth resuming.
|
|
236
|
+
if (opts.skipEmpty && !(s.userTurnCount > 0 || s.interrupted))
|
|
237
|
+
continue;
|
|
238
|
+
results.push(s);
|
|
239
|
+
if (scanAll && results.length >= limit)
|
|
240
|
+
break;
|
|
198
241
|
}
|
|
199
242
|
results.sort((a, b) => b.lastTurnAt.localeCompare(a.lastTurnAt));
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
243
|
+
return results.slice(0, limit);
|
|
244
|
+
}
|
|
245
|
+
/** 2026-10-08 — the latest non-empty session of this folder (`--continue`), or null. */
|
|
246
|
+
export async function latestSessionHere(here, opts = {}) {
|
|
247
|
+
const [s] = await listSessions(1, { here, skipEmpty: true, scanAll: opts.scanAll ?? false });
|
|
248
|
+
return s ?? null;
|
|
205
249
|
}
|
|
206
250
|
/**
|
|
207
251
|
* Count interrupted sessions for the startup banner — among the sessions the
|
|
208
252
|
* `--resume` picker can show (RESUME_PICKER_LIMIT most recent), so the
|
|
209
253
|
* "! N unfinished sessions" notice never points at sessions the picker does
|
|
210
254
|
* not list (fix round 3, item 11d). Returns 0 on any IO failure.
|
|
255
|
+
* 2026-10-08 — with `here`, only that folder's sessions count.
|
|
211
256
|
*/
|
|
212
|
-
export async function countInterruptedSessions() {
|
|
257
|
+
export async function countInterruptedSessions(here) {
|
|
213
258
|
try {
|
|
214
259
|
// Round 8 item 7 — a turn the USER stopped (Ctrl+C, /exit mid-turn) is
|
|
215
260
|
// not unfinished work: counting it kept "! 1 unfinished session" on every
|
|
216
261
|
// start until that session happened to finish a turn. The picker still
|
|
217
262
|
// lists it as "Stopped by you".
|
|
218
|
-
return (await listSessions(RESUME_PICKER_LIMIT))
|
|
263
|
+
return (await listSessions(RESUME_PICKER_LIMIT, here ? { here } : {}))
|
|
219
264
|
.filter((s) => s.interrupted && !isUserStopReason(s.interruptedReason)).length;
|
|
220
265
|
}
|
|
221
266
|
catch {
|
package/dist/skill-catalog.js
CHANGED
|
@@ -168,8 +168,8 @@ export const SKILL_CATALOG = [
|
|
|
168
168
|
{
|
|
169
169
|
name: 'abap-cca',
|
|
170
170
|
category: 'Upgrade / Migrate',
|
|
171
|
-
description: '
|
|
172
|
-
whenToUse: `pick this
|
|
171
|
+
description: 'Reads the estate inventory the lead pulled; proposes scope, areas, retire candidates and next commands; read-only.',
|
|
172
|
+
whenToUse: `pick this after cspeach team discover, to choose packages, lanes and a first focus (not /abap-migrate — that's per object)`,
|
|
173
173
|
asks: 'which package or namespace? e.g. "Z*" or "package ZTEST_LAS"',
|
|
174
174
|
},
|
|
175
175
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-10-
|
|
3
|
+
// Generated: 2026-10-09T00:51:14.599Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-app": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { promises as fsPromises, realpathSync, existsSync } from 'node:fs';
|
|
12
12
|
import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
|
|
13
|
+
import { SAFE_PROJECT_ID } from '../projects/project-folder.js';
|
|
13
14
|
/**
|
|
14
15
|
* Phase 3 sensitive in-root directory prefixes — refused by all filesystem
|
|
15
16
|
* tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
|
|
@@ -190,21 +191,21 @@ function relIsDenylisted(rel) {
|
|
|
190
191
|
* so the caller must fail closed — `{ failClosed: true }`. Returns null only
|
|
191
192
|
* when nothing up to the drive root exists.
|
|
192
193
|
*/
|
|
193
|
-
function longNameAbs(absPath) {
|
|
194
|
-
let cur =
|
|
194
|
+
function longNameAbs(absPath, p = path.win32) {
|
|
195
|
+
let cur = p.resolve(absPath);
|
|
195
196
|
const tail = [];
|
|
196
197
|
for (;;) {
|
|
197
198
|
try {
|
|
198
199
|
const real = realpathSync.native(cur);
|
|
199
|
-
return { abs: tail.length ?
|
|
200
|
+
return { abs: tail.length ? p.join(real, ...tail) : real };
|
|
200
201
|
}
|
|
201
202
|
catch (err) {
|
|
202
203
|
if (err?.code !== 'ENOENT')
|
|
203
204
|
return { failClosed: true };
|
|
204
|
-
const parent =
|
|
205
|
+
const parent = p.dirname(cur);
|
|
205
206
|
if (parent === cur)
|
|
206
207
|
return null;
|
|
207
|
-
tail.unshift(
|
|
208
|
+
tail.unshift(p.basename(cur));
|
|
208
209
|
cur = parent;
|
|
209
210
|
}
|
|
210
211
|
}
|
|
@@ -259,6 +260,140 @@ function isUnderCliHome(absPath, p, native) {
|
|
|
259
260
|
}
|
|
260
261
|
return false;
|
|
261
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* The segments of `abs` below `<cspeachRoot()>/work/<SAFE id>/.cspeach/`
|
|
265
|
+
* (lowercased on win32), or null when `abs` is not there under the spelled or
|
|
266
|
+
* the real seat. Null also for ANY segment below the seat that is empty, `.`,
|
|
267
|
+
* `..`, carries ':' (stream), '~' (possible 8.3 name), a trailing dot/space,
|
|
268
|
+
* is `.git`, or is a Windows device name — fail closed, never decoded.
|
|
269
|
+
* Pure string logic on the seat's two spellings; callers pass both the spelled
|
|
270
|
+
* and the real form of the path.
|
|
271
|
+
*/
|
|
272
|
+
function seatTail(abs, p, isWin) {
|
|
273
|
+
const cmp = seatSegs(abs, p, isWin);
|
|
274
|
+
if (!cmp || cmp.length < 4 || cmp[0] !== 'work' || !SAFE_PROJECT_ID.test(cmp[1]) || cmp[2] !== '.cspeach')
|
|
275
|
+
return null;
|
|
276
|
+
return cmp.slice(3);
|
|
277
|
+
}
|
|
278
|
+
/** The checked segments of `abs` below the seat (see seatTail), or null. */
|
|
279
|
+
function seatSegs(abs, p, isWin) {
|
|
280
|
+
const seat = cspeachRoot();
|
|
281
|
+
const seats = [p.resolve(seat)];
|
|
282
|
+
const realSeat = realDirOrNull(seat);
|
|
283
|
+
if (realSeat && !seats.includes(realSeat))
|
|
284
|
+
seats.push(realSeat);
|
|
285
|
+
for (const s of seats) {
|
|
286
|
+
const rel = p.relative(s, abs).replace(/\\/g, '/');
|
|
287
|
+
if (rel === '' || !relIsInside(p, rel))
|
|
288
|
+
continue;
|
|
289
|
+
const segs = rel.split('/');
|
|
290
|
+
const bad = (seg) => seg === '' || seg === '.' || seg === '..' || seg.includes(':') || seg.includes('~')
|
|
291
|
+
|| ntfsCanonicalSegment(seg) !== seg || seg.toLowerCase() === '.git' || (isWin && isWindowsReservedName(seg));
|
|
292
|
+
if (segs.some(bad))
|
|
293
|
+
return null;
|
|
294
|
+
return isWin ? segs.map((x) => x.toLowerCase()) : segs;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Fix round 1 (security review) — the anchor. The REAL path of `root`, and
|
|
300
|
+
* the real `absPath` relative to it ('/'-separated), when the real root is
|
|
301
|
+
* exactly a seat work root `<cspeachRoot()>/work/<SAFE id>` AND the real
|
|
302
|
+
* form of `absPath` sits inside it; else null. The seat opening applies only
|
|
303
|
+
* then: a session started in the home folder, its parent, a team folder or
|
|
304
|
+
* the seat itself never reaches a seat file through the file tools.
|
|
305
|
+
*/
|
|
306
|
+
function workRootFor(root, absPath, platform) {
|
|
307
|
+
const r = spelledAndReal(root, platform);
|
|
308
|
+
const a = spelledAndReal(absPath, platform);
|
|
309
|
+
if (!r || !a)
|
|
310
|
+
return null;
|
|
311
|
+
const realRoot = r.forms[1];
|
|
312
|
+
const segs = seatSegs(realRoot, r.p, r.isWin);
|
|
313
|
+
if (!segs || segs.length !== 2 || segs[0] !== 'work' || !SAFE_PROJECT_ID.test(segs[1]))
|
|
314
|
+
return null;
|
|
315
|
+
const rel = r.p.relative(realRoot, a.forms[1]).replace(/\\/g, '/');
|
|
316
|
+
return rel !== '' && relIsInside(r.p, rel) ? { root: realRoot, rel } : null;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Both forms of `absPath` — as spelled, and real (nearest existing ancestor
|
|
320
|
+
* realpath'd, which also expands 8.3 names; missing tail re-attached) — or
|
|
321
|
+
* null when the real form cannot be known (any lookup error but "does not
|
|
322
|
+
* exist", or a platform other than the one we run on): fail closed.
|
|
323
|
+
*/
|
|
324
|
+
function spelledAndReal(absPath, platform) {
|
|
325
|
+
if (platform !== process.platform)
|
|
326
|
+
return null;
|
|
327
|
+
const isWin = platform === 'win32';
|
|
328
|
+
const p = isWin ? path.win32 : path.posix;
|
|
329
|
+
const spelled = p.resolve(absPath);
|
|
330
|
+
const real = longNameAbs(spelled, p);
|
|
331
|
+
if (!real || 'failClosed' in real)
|
|
332
|
+
return null;
|
|
333
|
+
return { p, isWin, forms: [spelled, real.abs] };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Task 14 (2026-10-08) — the ONE opening in the CLI home for the file tools:
|
|
337
|
+
* true only when `absPath`, spelled AND real, is strictly inside
|
|
338
|
+
* `<cspeachRoot()>/work/<id>/.cspeach/<sub>/` with `<id>` matching
|
|
339
|
+
* SAFE_PROJECT_ID and `<sub>` in CSPEACH_WORK_SUBDIRS; no segment below the
|
|
340
|
+
* seat carries ':' / '~' / a trailing dot or space / `.git` / a device name;
|
|
341
|
+
* the basename is not a `*.cspeach.json` envelope and not `.env*`. A link
|
|
342
|
+
* anywhere on the way (a junction to the seat, to sessions, out of the seat)
|
|
343
|
+
* fails it, because the real form must match too. Only the seat CSPEACH_HOME
|
|
344
|
+
* chose opens — never `~/.cspeach` when CSPEACH_HOME names another seat.
|
|
345
|
+
*/
|
|
346
|
+
export function isSeatWorkPath(absPath, platform = process.platform) {
|
|
347
|
+
const both = spelledAndReal(absPath, platform);
|
|
348
|
+
if (!both)
|
|
349
|
+
return false;
|
|
350
|
+
return both.forms.every((form) => {
|
|
351
|
+
const tail = seatTail(form, both.p, both.isWin);
|
|
352
|
+
if (!tail || tail.length < 2 || !CSPEACH_WORK_SUBDIRS.includes(tail[0]))
|
|
353
|
+
return false;
|
|
354
|
+
const base = tail[tail.length - 1];
|
|
355
|
+
return !base.toLowerCase().endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Task 14 — the READ-ONLY seat shapes: a saved result directly in the seat
|
|
360
|
+
* working folder, `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json`,
|
|
361
|
+
* or a lane decision, `<cspeachRoot()>/work/<id>/.cspeach/lane/<name>.json`.
|
|
362
|
+
* Same rules as isSeatWorkPath (spelled AND real, fail closed). The write
|
|
363
|
+
* tools never consult this.
|
|
364
|
+
*/
|
|
365
|
+
export function isSeatReadOnlyFile(absPath, platform = process.platform) {
|
|
366
|
+
const both = spelledAndReal(absPath, platform);
|
|
367
|
+
if (!both)
|
|
368
|
+
return false;
|
|
369
|
+
return both.forms.every((form) => {
|
|
370
|
+
const tail = seatTail(form, both.p, both.isWin);
|
|
371
|
+
if (!tail)
|
|
372
|
+
return false;
|
|
373
|
+
const base = tail[tail.length - 1];
|
|
374
|
+
if (BLOCKED_BASENAME_RE.test(base))
|
|
375
|
+
return false;
|
|
376
|
+
if (tail.length === 1)
|
|
377
|
+
return /\.cspeach\.json$/i.test(base);
|
|
378
|
+
return tail.length === 2 && tail[0] === 'lane' && /\.json$/i.test(base);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* True when `absPath` — spelled or real (nearest existing ancestor) — is the
|
|
383
|
+
* CLI home (`cspeachRoot()` or `~/.cspeach`) or below it. Fails closed: a path
|
|
384
|
+
* whose real form cannot be known counts as inside.
|
|
385
|
+
*/
|
|
386
|
+
export function isCliHomePath(absPath, platform = process.platform) {
|
|
387
|
+
const isWin = platform === 'win32';
|
|
388
|
+
const p = isWin ? path.win32 : path.posix;
|
|
389
|
+
const native = platform === process.platform;
|
|
390
|
+
if (isUnderCliHome(p.resolve(absPath), p, native))
|
|
391
|
+
return true;
|
|
392
|
+
const both = spelledAndReal(absPath, platform);
|
|
393
|
+
if (!both)
|
|
394
|
+
return true;
|
|
395
|
+
return isUnderCliHome(both.forms[1], p, native);
|
|
396
|
+
}
|
|
262
397
|
/**
|
|
263
398
|
* True if `realAbsPath` is denylisted relative to root — either it falls under
|
|
264
399
|
* one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
|
|
@@ -286,6 +421,17 @@ function isUnderCliHome(absPath, p, native) {
|
|
|
286
421
|
* also when the CLI runs from `C:\Users`, `C:\` or `/home`, where it is not a
|
|
287
422
|
* root-level `.cspeach`. The carve-out does not apply there.
|
|
288
423
|
*
|
|
424
|
+
* ONE ANCHORED EXCEPTION (Task 14, 2026-10-08): a team seat's working files,
|
|
425
|
+
* `<cspeachRoot()>/work/<SAFE id>/.cspeach/<cca|upgrades|modernize|tests>/…`
|
|
426
|
+
* (isSeatWorkPath: spelled AND real path, no stream / '~' / trailing dot or
|
|
427
|
+
* space / .git / device-name segment, no *.cspeach.json, no .env*), are
|
|
428
|
+
* readable and writable ONLY when the REAL root is exactly that path's work
|
|
429
|
+
* root `<cspeachRoot()>/work/<id>` (workRootFor). The path is then judged
|
|
430
|
+
* relative to the real work root alone, with every rule below still applied.
|
|
431
|
+
* Any other root — the home folder, its parent, a team folder, the seat, a
|
|
432
|
+
* sibling work root — keeps the whole CLI home blocked, so a project run from
|
|
433
|
+
* the home folder gets nothing.
|
|
434
|
+
*
|
|
289
435
|
* WINDOWS NAME ALIASES (SEC fix, `.git::$INDEX_ALLOCATION`). NTFS opens the
|
|
290
436
|
* same object under spellings that do not byte-match the denylist:
|
|
291
437
|
* - stream suffixes: `.git::$INDEX_ALLOCATION`, `.git:$I30:$INDEX_ALLOCATION`,
|
|
@@ -338,10 +484,19 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
338
484
|
relFromRoot = realRel;
|
|
339
485
|
}
|
|
340
486
|
}
|
|
487
|
+
// The CLI home is protected whatever the root — except exactly the seat
|
|
488
|
+
// working subfolders (isSeatWorkPath), and only when the real root IS that
|
|
489
|
+
// path's work root (workRootFor). Then the path is judged relative to the
|
|
490
|
+
// real work root alone (a root spelled through a link, on another drive or
|
|
491
|
+
// a share, cannot change the verdict).
|
|
492
|
+
if (isUnderCliHome(realAbsPath, p, native)) {
|
|
493
|
+
const work = isSeatWorkPath(realAbsPath, platform) ? workRootFor(root, realAbsPath, platform) : null;
|
|
494
|
+
if (work === null)
|
|
495
|
+
return { blocked: true, relFromRoot };
|
|
496
|
+
relFromRoot = work.rel;
|
|
497
|
+
rels.splice(0, rels.length, work.rel);
|
|
498
|
+
}
|
|
341
499
|
const blocked = { blocked: true, relFromRoot };
|
|
342
|
-
// The CLI home is protected whatever the root.
|
|
343
|
-
if (isUnderCliHome(realAbsPath, p, native))
|
|
344
|
-
return blocked;
|
|
345
500
|
for (const rel of rels) {
|
|
346
501
|
const segments = rel.split('/');
|
|
347
502
|
// (1) win32: an NTFS stream / stream-type suffix anywhere → refuse.
|
|
@@ -389,7 +544,11 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
389
544
|
*
|
|
390
545
|
* Narrow by construction — true only when ALL hold:
|
|
391
546
|
* - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
|
|
392
|
-
* whatever the root (a project run from the home folder gets nothing)
|
|
547
|
+
* whatever the root (a project run from the home folder gets nothing) —
|
|
548
|
+
* with ONE anchored exception (Task 14): the seat shapes
|
|
549
|
+
* `<cspeachRoot()>/work/<id>/.cspeach/<name>.cspeach.json` and
|
|
550
|
+
* `.cspeach/lane/<name>.json` (isSeatReadOnlyFile), only when the REAL
|
|
551
|
+
* root is exactly that file's work root `<cspeachRoot()>/work/<id>`;
|
|
393
552
|
* - it sits inside the spelled root, at exactly one of the two shapes above
|
|
394
553
|
* (no deeper nesting, no other `.cspeach` file such as conventions.toml);
|
|
395
554
|
* - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
|
|
@@ -401,8 +560,11 @@ export function isReadOnlyProjectFile(realAbsPath, root, platform = process.plat
|
|
|
401
560
|
const isWin = platform === 'win32';
|
|
402
561
|
const native = platform === process.platform;
|
|
403
562
|
const p = isWin ? path.win32 : path.posix;
|
|
404
|
-
|
|
405
|
-
|
|
563
|
+
// Under the CLI home only the two read-only seat shapes (Task 14), and only
|
|
564
|
+
// when the real root IS that file's work root (fix round 1).
|
|
565
|
+
if (isUnderCliHome(realAbsPath, p, native)) {
|
|
566
|
+
return isSeatReadOnlyFile(realAbsPath, platform) && workRootFor(root, realAbsPath, platform) !== null;
|
|
567
|
+
}
|
|
406
568
|
const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
407
569
|
if (!relIsInside(p, rel) || rel === '')
|
|
408
570
|
return false;
|
|
@@ -483,7 +645,7 @@ export function isInsideRoot(target, root) {
|
|
|
483
645
|
* Real form of a folder that may not exist yet: nearest existing ancestor
|
|
484
646
|
* realpath'd, missing tail re-attached. null on any non-ENOENT error.
|
|
485
647
|
*/
|
|
486
|
-
function realOfNearestOrNull(abs) {
|
|
648
|
+
export function realOfNearestOrNull(abs) {
|
|
487
649
|
let cur = abs;
|
|
488
650
|
const tail = [];
|
|
489
651
|
for (;;) {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/_team-work-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* Task 15 (2026-10-08 sessions/folders) — in a TEAM folder (a shared folder
|
|
4
|
+
* holding cspeach-team.json) the skills' personal working files,
|
|
5
|
+
* `.cspeach/<cca|upgrades|modernize|tests>/…`, belong to the person's seat
|
|
6
|
+
* work root `<CSPEACH_HOME>/work/<projectId>`, not to the shared folder.
|
|
7
|
+
*
|
|
8
|
+
* teamWorkTarget is a PURE path mapper: it picks the root a file tool should
|
|
9
|
+
* work against and hands back the same relative path. It opens nothing on its
|
|
10
|
+
* own — every tool still runs its full pipeline (resolveSafePath containment,
|
|
11
|
+
* real-path checks, denylist, write checks) against the root it returns, and
|
|
12
|
+
* the seat opening in the denylist applies only when that root is exactly the
|
|
13
|
+
* work root.
|
|
14
|
+
*
|
|
15
|
+
* solo / home folder → { root: cwd, path: userPath } (unchanged)
|
|
16
|
+
* team folder, `.cspeach/<sub>` → { root: workRoot, path: <same relative path> }
|
|
17
|
+
* team folder, anything else → { root: cwd, path: userPath } (unchanged)
|
|
18
|
+
*
|
|
19
|
+
* The `.cspeach/<sub>` test compares each segment the way Windows opens it
|
|
20
|
+
* (case-insensitive, `:stream` and trailing dots/spaces dropped), so an alias
|
|
21
|
+
* spelling never slips a working file into the shared folder; in the seat the
|
|
22
|
+
* denylist then refuses the alias outright.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { CSPEACH_WORK_SUBDIRS } from './_filesystem-shared.js';
|
|
27
|
+
import { resolveProjectFolderSync } from '../projects/project-folder.js';
|
|
28
|
+
/** A segment as NTFS resolves it for a name compare, lowercased. */
|
|
29
|
+
function nameKey(seg) {
|
|
30
|
+
const colon = seg.indexOf(':');
|
|
31
|
+
return (colon === -1 ? seg : seg.slice(0, colon)).replace(/[. ]+$/, '').toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
/** The seat work root when `cwd` is a team folder, else null. */
|
|
34
|
+
export function teamWorkRoot(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
const pf = resolveProjectFolderSync(cwd);
|
|
37
|
+
return pf.kind === 'team' ? pf.workRoot : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function teamWorkTarget(cwd, userPath) {
|
|
44
|
+
const same = { root: cwd, path: userPath };
|
|
45
|
+
if (typeof userPath !== 'string' || userPath.includes('\0'))
|
|
46
|
+
return same;
|
|
47
|
+
const workRoot = teamWorkRoot(cwd);
|
|
48
|
+
if (workRoot === null)
|
|
49
|
+
return same;
|
|
50
|
+
const absRoot = path.resolve(cwd);
|
|
51
|
+
const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
|
|
52
|
+
const rel = path.relative(absRoot, abs);
|
|
53
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
54
|
+
return same;
|
|
55
|
+
return isWorkRel(rel) ? { root: workRoot, path: rel } : same;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Ruling 27 — the READ twin of teamWorkTarget, for file_read and glob only.
|
|
59
|
+
* Besides the working files it maps the two read-only shapes a save leaves in
|
|
60
|
+
* the seat — `.cspeach/<name>.cspeach.json` and `.cspeach/lane/<name>.json` —
|
|
61
|
+
* so reading your own saved results looks where they were saved, as in a solo
|
|
62
|
+
* folder. Writes never use this: those shapes stay CLI-owned.
|
|
63
|
+
*/
|
|
64
|
+
export function teamReadTarget(cwd, userPath) {
|
|
65
|
+
const same = { root: cwd, path: userPath };
|
|
66
|
+
const work = teamWorkTarget(cwd, userPath);
|
|
67
|
+
if (work.root !== cwd || typeof userPath !== 'string' || userPath.includes('\0'))
|
|
68
|
+
return work;
|
|
69
|
+
const workRoot = teamWorkRoot(cwd);
|
|
70
|
+
if (workRoot === null)
|
|
71
|
+
return same;
|
|
72
|
+
const absRoot = path.resolve(cwd);
|
|
73
|
+
const abs = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(absRoot, userPath);
|
|
74
|
+
const rel = path.relative(absRoot, abs);
|
|
75
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
76
|
+
return same;
|
|
77
|
+
if (!isReadOnlyRel(rel))
|
|
78
|
+
return same;
|
|
79
|
+
// Final fix wave, item 1 — a save made before this release sits in the
|
|
80
|
+
// shared <team>/.cspeach. The seat copy comes first; only when the seat has
|
|
81
|
+
// none and the shared one exists is the shared one read (read-only: the
|
|
82
|
+
// write tools never use this function).
|
|
83
|
+
if (!existsSync(path.join(workRoot, rel)) && existsSync(path.join(absRoot, rel)))
|
|
84
|
+
return same;
|
|
85
|
+
return { root: workRoot, path: rel };
|
|
86
|
+
}
|
|
87
|
+
/** True for `.cspeach/<name>.cspeach.json` or `.cspeach/lane/<name>.json` (either separator). */
|
|
88
|
+
export function isReadOnlyRel(rel) {
|
|
89
|
+
const segs = rel.split(/[\\/]+/);
|
|
90
|
+
if (nameKey(segs[0] ?? '') !== '.cspeach')
|
|
91
|
+
return false;
|
|
92
|
+
if (segs.length === 2)
|
|
93
|
+
return /\.cspeach\.json$/i.test(nameKey(segs[1]));
|
|
94
|
+
return segs.length === 3 && nameKey(segs[1]) === 'lane' && /\.json$/i.test(nameKey(segs[2]));
|
|
95
|
+
}
|
|
96
|
+
/** True when a root-relative path (either separator) is `.cspeach/<work-subdir>` or below. */
|
|
97
|
+
export function isWorkRel(rel) {
|
|
98
|
+
const segs = rel.split(/[\\/]+/);
|
|
99
|
+
return segs.length >= 2 && nameKey(segs[0]) === '.cspeach'
|
|
100
|
+
&& CSPEACH_WORK_SUBDIRS.includes(nameKey(segs[1]));
|
|
101
|
+
}
|
|
102
|
+
/** Final fix wave, item 3 — the refusal for a write into the shared `.cspeach`. */
|
|
103
|
+
export const SHARED_CSPEACH_REFUSAL = 'In a team folder, working files go to your own folder; this path points into the shared .cspeach.';
|
|
104
|
+
/**
|
|
105
|
+
* True when `cwd` is a team folder and the REAL landing path of a write
|
|
106
|
+
* (links followed, 8.3 names expanded) is inside the shared `<team>/.cspeach`
|
|
107
|
+
* — whatever spelling, junction or short name got it there. Only refuses;
|
|
108
|
+
* never maps anything.
|
|
109
|
+
*/
|
|
110
|
+
export function pointsIntoSharedCspeach(cwd, realTarget) {
|
|
111
|
+
if (teamWorkRoot(cwd) === null)
|
|
112
|
+
return false;
|
|
113
|
+
const absRoot = path.resolve(cwd);
|
|
114
|
+
const realOf = (p) => { try {
|
|
115
|
+
return realpathSync.native(p);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
} };
|
|
120
|
+
const relUnder = (base) => {
|
|
121
|
+
const rel = path.relative(base, realTarget);
|
|
122
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) ? rel : null;
|
|
123
|
+
};
|
|
124
|
+
const realShared = realOf(path.join(absRoot, '.cspeach'));
|
|
125
|
+
if (realShared !== null && relUnder(realShared) !== null)
|
|
126
|
+
return true;
|
|
127
|
+
for (const base of new Set([absRoot, realOf(absRoot) ?? absRoot])) {
|
|
128
|
+
const rel = relUnder(base);
|
|
129
|
+
if (rel !== null && rel !== '' && nameKey(rel.split(/[\\/]+/)[0]) === '.cspeach')
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
package/dist/tools/approval.js
CHANGED
|
@@ -284,8 +284,14 @@ registerTool({
|
|
|
284
284
|
sapCfg = cfg.sap[ctx.sapAlias];
|
|
285
285
|
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
286
286
|
}
|
|
287
|
+
// 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
|
|
288
|
+
// yes. Live, auto_approve="low" let the model's own "low" create package
|
|
289
|
+
// ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
|
|
290
|
+
// above is separate and unchanged (a plan the person approved may carry
|
|
291
|
+
// its one new transport).
|
|
292
|
+
const touchesInfra = changes.some((c) => INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase()));
|
|
287
293
|
// Auto-approve caps — never auto-approve at high, cap change count per risk level.
|
|
288
|
-
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
294
|
+
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && !touchesInfra && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
289
295
|
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
290
296
|
if (autoApproveAllowed) {
|
|
291
297
|
const ids = [];
|