@cspeach/cli 1.1.21 → 1.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/tool-dispatch.js +4 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +4 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +2 -1
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl.js +4 -0
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap/standalone-profile.js +1 -1
- package/dist/sap/system-info.js +50 -27
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skills/bundled-skills.js +118 -118
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/local-build.js +11 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/dist/upgrade.js +4 -2
- package/package.json +1 -1
- package/dist/commands/project.js +0 -301
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
package/dist/commands/project.js
DELETED
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `cspeach project …` — the Project Register from the terminal
|
|
3
|
-
* (spec: docs/superpowers/specs/2026-09-18-project-register-folder-mode.md §6).
|
|
4
|
-
*
|
|
5
|
-
* init <folder> lead: pull the baseline from SAP, write the project folder
|
|
6
|
-
* join <folder> everyone: start recording against a project
|
|
7
|
-
* lane … lead: who owns which packages
|
|
8
|
-
* status [folder] anyone: the register as text
|
|
9
|
-
* outbox what this laptop has recorded, and where
|
|
10
|
-
* rule … lead: settle a disagreement
|
|
11
|
-
* leave stop recording
|
|
12
|
-
*
|
|
13
|
-
* Every path written here is one a person typed. Nothing is written to a
|
|
14
|
-
* network location on CSPeach's own initiative, and none of this is reachable
|
|
15
|
-
* from a model tool.
|
|
16
|
-
*/
|
|
17
|
-
import fs from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import { buildRegister, objectKey } from '@cspeach/register-core';
|
|
20
|
-
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
21
|
-
import { BASELINE_FILE, PROJECT_FILE, RegisterError, activeProject, collectRecords, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, writeLanes, writerIdFor, } from '../register/store.js';
|
|
22
|
-
import { writeRecord } from '../register/records.js';
|
|
23
|
-
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
24
|
-
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
25
|
-
function flags(args) {
|
|
26
|
-
const positional = [];
|
|
27
|
-
const opts = {};
|
|
28
|
-
for (let i = 0; i < args.length; i++) {
|
|
29
|
-
const a = args[i];
|
|
30
|
-
if (a.startsWith('--')) {
|
|
31
|
-
opts[a.slice(2)] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : 'true';
|
|
32
|
-
}
|
|
33
|
-
else
|
|
34
|
-
positional.push(a);
|
|
35
|
-
}
|
|
36
|
-
return { positional, opts };
|
|
37
|
-
}
|
|
38
|
-
const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
39
|
-
/** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
|
|
40
|
-
function matches(pattern, value) {
|
|
41
|
-
const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
|
|
42
|
-
return re.test(value);
|
|
43
|
-
}
|
|
44
|
-
export async function runProjectCommand(args, deps) {
|
|
45
|
-
const [sub, ...rest] = args;
|
|
46
|
-
try {
|
|
47
|
-
switch (sub) {
|
|
48
|
-
case 'init': return await init(rest, deps);
|
|
49
|
-
case 'join': return join(rest, deps);
|
|
50
|
-
case 'lane': return lane(rest, deps);
|
|
51
|
-
case 'status': return status(rest, deps);
|
|
52
|
-
case 'outbox': return outbox(rest, deps);
|
|
53
|
-
case 'rule': return rule(rest, deps);
|
|
54
|
-
case 'leave': return leave(deps);
|
|
55
|
-
default:
|
|
56
|
-
deps.print('Usage: cspeach project init|join|lane|status|outbox|rule|leave (cspeach --help for details)');
|
|
57
|
-
return sub ? 1 : 0;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch (e) {
|
|
61
|
-
if (e instanceof RegisterError || e instanceof BaselineError) {
|
|
62
|
-
deps.print(`✗ ${e.message}`);
|
|
63
|
-
return 1;
|
|
64
|
-
}
|
|
65
|
-
throw e;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function requireLead(project, deps) {
|
|
69
|
-
const me = deps.identity().name;
|
|
70
|
-
if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
|
|
71
|
-
throw new RegisterError(`Only the project lead (${project.lead.name}) changes this. You are ${me}.`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
function joinedOrThrow() {
|
|
75
|
-
const joined = activeProject();
|
|
76
|
-
if (!joined)
|
|
77
|
-
throw new RegisterError('This laptop has not joined a project. Run: cspeach project join <folder>');
|
|
78
|
-
return joined;
|
|
79
|
-
}
|
|
80
|
-
// ── init ────────────────────────────────────────────────────────────────────
|
|
81
|
-
async function init(args, deps) {
|
|
82
|
-
const { positional, opts } = flags(args);
|
|
83
|
-
const folder = positional[0];
|
|
84
|
-
if (!folder)
|
|
85
|
-
throw new RegisterError('Usage: cspeach project init <folder> --name "<name>" [--type clean-core|upgrade|build] [--packages ZFI_*,ZSD_*] [--include-local]');
|
|
86
|
-
const dir = path.resolve(folder);
|
|
87
|
-
if (fs.existsSync(path.join(dir, PROJECT_FILE)))
|
|
88
|
-
throw new RegisterError(`${dir} already holds a project. Pick an empty folder.`);
|
|
89
|
-
const name = opts.name ?? path.basename(dir);
|
|
90
|
-
const type = (opts.type ?? 'clean-core');
|
|
91
|
-
if (!TYPES.includes(type))
|
|
92
|
-
throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
|
|
93
|
-
if (!deps.connect)
|
|
94
|
-
throw new RegisterError('init needs a SAP connection to pull the object list.');
|
|
95
|
-
const { sql, system } = await deps.connect(opts['sap-alias']);
|
|
96
|
-
deps.print('Reading the package list from SAP…');
|
|
97
|
-
const all = await listCustomPackages(sql);
|
|
98
|
-
const patterns = list(opts.packages);
|
|
99
|
-
let chosen = all.filter((p) => patterns.some((pat) => matches(pat, p.package)));
|
|
100
|
-
if (!patterns.length && deps.choosePackages) {
|
|
101
|
-
const picked = new Set(await deps.choosePackages(all));
|
|
102
|
-
chosen = all.filter((p) => picked.has(p.package));
|
|
103
|
-
}
|
|
104
|
-
const local = all.find((p) => p.package === LOCAL_PACKAGE)?.objects ?? 0;
|
|
105
|
-
if (!opts['include-local'])
|
|
106
|
-
chosen = chosen.filter((p) => p.package !== LOCAL_PACKAGE);
|
|
107
|
-
if (!chosen.length) {
|
|
108
|
-
throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
|
|
109
|
-
}
|
|
110
|
-
deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
|
|
111
|
-
const pull = await pullBaseline(sql, { packages: chosen });
|
|
112
|
-
if (!pull.objects.length)
|
|
113
|
-
throw new RegisterError('Those packages hold no code objects. Nothing was written.');
|
|
114
|
-
const now = (deps.now ?? (() => new Date()))();
|
|
115
|
-
const projectId = projectIdFor(name);
|
|
116
|
-
const baselineText = `${JSON.stringify({ kind: 'baseline', formatVersion: 1, projectId, objects: pull.objects }, null, 1)}\n`;
|
|
117
|
-
const project = {
|
|
118
|
-
kind: 'project', formatVersion: 1, projectId, name, type, lead: { name: deps.identity().name },
|
|
119
|
-
...(system ? { system } : {}),
|
|
120
|
-
...(opts['atc-variant'] ? { atcVariant: opts['atc-variant'] } : {}),
|
|
121
|
-
baseline: { file: BASELINE_FILE.replace(/\\/g, '/'), sha256: sha256(baselineText), takenAt: now.toISOString(), objectCount: pull.objects.length },
|
|
122
|
-
setAside: { local: opts['include-local'] ? 0 : local, generated: pull.setAside.generated, otherTypes: pull.setAside.otherTypes },
|
|
123
|
-
createdAt: now.toISOString(),
|
|
124
|
-
};
|
|
125
|
-
fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
|
|
126
|
-
fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
|
|
127
|
-
fs.writeFileSync(path.join(dir, PROJECT_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
128
|
-
writeLanes(dir, projectId, []);
|
|
129
|
-
deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
|
|
130
|
-
deps.print(` Set aside, not in scope: ${project.setAside.local} local ($TMP), ${pull.setAside.generated} generated, ${pull.setAside.otherTypes} non-code entries.`);
|
|
131
|
-
deps.print(` Written to ${dir}`);
|
|
132
|
-
deps.print(' Next: cspeach project lane add <id> --owner "<name>" --packages "ZFI_*" then each person: cspeach project join <folder>');
|
|
133
|
-
return join([dir], deps);
|
|
134
|
-
}
|
|
135
|
-
// ── join / leave ────────────────────────────────────────────────────────────
|
|
136
|
-
function join(args, deps) {
|
|
137
|
-
const { positional, opts } = flags(args);
|
|
138
|
-
if (!positional[0])
|
|
139
|
-
throw new RegisterError('Usage: cspeach project join <project folder> [--outbox <folder>]');
|
|
140
|
-
const projectDir = path.resolve(positional[0]);
|
|
141
|
-
const project = readProjectFile(projectDir);
|
|
142
|
-
const objects = readBaseline(projectDir).length; // also verifies the checksum
|
|
143
|
-
const state = loadState();
|
|
144
|
-
const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
|
|
145
|
-
const writerId = writerIdFor(deps.identity().name);
|
|
146
|
-
fs.mkdirSync(path.join(outboxDir, 'writers', writerId, 'records'), { recursive: true });
|
|
147
|
-
state.projects[project.projectId] = { projectDir, outboxDir, writerId };
|
|
148
|
-
state.active = project.projectId;
|
|
149
|
-
saveState(state);
|
|
150
|
-
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
151
|
-
deps.print(` Your results are recorded on this laptop, in ${outboxDir}`);
|
|
152
|
-
deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the project folder,');
|
|
153
|
-
deps.print(' or keep the outbox inside your own synced folder: cspeach project join <folder> --outbox <folder>');
|
|
154
|
-
return 0;
|
|
155
|
-
}
|
|
156
|
-
function leave(deps) {
|
|
157
|
-
const state = loadState();
|
|
158
|
-
if (!state.active) {
|
|
159
|
-
deps.print('This laptop is not recording against a project.');
|
|
160
|
-
return 0;
|
|
161
|
-
}
|
|
162
|
-
const was = state.active;
|
|
163
|
-
state.active = null;
|
|
164
|
-
saveState(state);
|
|
165
|
-
deps.print(`✓ Stopped recording against ${was}. Files already written stay where they are.`);
|
|
166
|
-
return 0;
|
|
167
|
-
}
|
|
168
|
-
// ── lane ────────────────────────────────────────────────────────────────────
|
|
169
|
-
function lane(args, deps) {
|
|
170
|
-
const [verb, ...rest] = args;
|
|
171
|
-
const { positional, opts } = flags(rest);
|
|
172
|
-
const joined = joinedOrThrow();
|
|
173
|
-
const project = readProjectFile(joined.projectDir);
|
|
174
|
-
const lanes = readLanes(joined.projectDir);
|
|
175
|
-
if (verb === 'list' || !verb) {
|
|
176
|
-
if (!lanes.length)
|
|
177
|
-
deps.print('No lanes yet. cspeach project lane add <id> --owner "<name>" --packages "ZFI_*"');
|
|
178
|
-
for (const l of lanes)
|
|
179
|
-
deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${[...l.packages, ...l.namePatterns.map((n) => `name:${n}`)].join(', ')}`);
|
|
180
|
-
return 0;
|
|
181
|
-
}
|
|
182
|
-
requireLead(project, deps);
|
|
183
|
-
const id = positional[0];
|
|
184
|
-
if (!id)
|
|
185
|
-
throw new RegisterError('Usage: cspeach project lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
|
|
186
|
-
if (verb === 'remove') {
|
|
187
|
-
writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
|
|
188
|
-
deps.print(`✓ Lane ${id} removed.`);
|
|
189
|
-
return 0;
|
|
190
|
-
}
|
|
191
|
-
if (verb !== 'add')
|
|
192
|
-
throw new RegisterError('lane: use add, remove or list.');
|
|
193
|
-
const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
|
|
194
|
-
if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
|
|
195
|
-
throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
|
|
196
|
-
}
|
|
197
|
-
writeLanes(joined.projectDir, project.projectId, [...lanes.filter((l) => l.id !== id), next]);
|
|
198
|
-
const reg = registerFor(joined.projectDir, joined.outboxDir);
|
|
199
|
-
const mine = reg.register.lanes.find((l) => l.id === id);
|
|
200
|
-
deps.print(`✓ Lane ${id}: ${next.owner}, ${mine.objects} objects.`);
|
|
201
|
-
if (reg.register.totals.overlapping)
|
|
202
|
-
deps.print(` ! ${reg.register.totals.overlapping} objects now sit in more than one lane.`);
|
|
203
|
-
if (reg.register.totals.unowned)
|
|
204
|
-
deps.print(` ${reg.register.totals.unowned} objects still have no owner.`);
|
|
205
|
-
return 0;
|
|
206
|
-
}
|
|
207
|
-
// ── status ──────────────────────────────────────────────────────────────────
|
|
208
|
-
function registerFor(projectDir, outboxDir) {
|
|
209
|
-
const project = readProjectFile(projectDir);
|
|
210
|
-
const collected = collectRecords(projectDir);
|
|
211
|
-
// This laptop's own outbox counts too, so a developer sees their work before handing it in.
|
|
212
|
-
const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
|
|
213
|
-
const register = buildRegister({
|
|
214
|
-
project, baseline: readBaseline(projectDir), lanes: readLanes(projectDir),
|
|
215
|
-
records: [...collected.records, ...own.records],
|
|
216
|
-
});
|
|
217
|
-
return { register, files: collected.files + own.files, unreadable: collected.unreadable + own.unreadable };
|
|
218
|
-
}
|
|
219
|
-
const STATE_WORDS = [
|
|
220
|
-
['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
|
|
221
|
-
['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
|
|
222
|
-
['regressed', 'regressed'], ['retired', 'retired'],
|
|
223
|
-
];
|
|
224
|
-
function status(args, deps) {
|
|
225
|
-
const { positional } = flags(args);
|
|
226
|
-
const joined = activeProject();
|
|
227
|
-
const projectDir = positional[0] ? path.resolve(positional[0]) : joined?.projectDir;
|
|
228
|
-
if (!projectDir)
|
|
229
|
-
throw new RegisterError('Usage: cspeach project status [<project folder>]');
|
|
230
|
-
const project = readProjectFile(projectDir);
|
|
231
|
-
const { register: r, files, unreadable } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
|
|
232
|
-
const t = r.totals;
|
|
233
|
-
deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
|
|
234
|
-
deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. ${t.byState.verified} verified, ${t.byState.clean} clean.`);
|
|
235
|
-
deps.print(r.lastMeasuredAt ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC.` : 'Not measured yet: "verified" and "clean" need an ATC run.');
|
|
236
|
-
deps.print('');
|
|
237
|
-
for (const [state, word] of STATE_WORDS)
|
|
238
|
-
if (t.byState[state])
|
|
239
|
-
deps.print(` ${String(t.byState[state]).padStart(6)} ${word}`);
|
|
240
|
-
deps.print('');
|
|
241
|
-
if (r.lanes.length) {
|
|
242
|
-
deps.print('Lane Owner Objects Done Last record');
|
|
243
|
-
for (const l of r.lanes) {
|
|
244
|
-
const done = l.byState.verified + l.byState.clean + l.byState.retired;
|
|
245
|
-
deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} ${String(done).padStart(5)} ${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
|
|
246
|
-
}
|
|
247
|
-
deps.print('');
|
|
248
|
-
}
|
|
249
|
-
if (t.unowned)
|
|
250
|
-
deps.print(`! ${t.unowned} objects have no owner.`);
|
|
251
|
-
if (t.overlapping)
|
|
252
|
-
deps.print(`! ${t.overlapping} objects sit in more than one lane.`);
|
|
253
|
-
if (t.conflicts) {
|
|
254
|
-
deps.print(`! ${t.conflicts} objects have conflicting decisions. The lead settles each with: cspeach project rule <TYPE> <NAME> <decision>`);
|
|
255
|
-
for (const o of r.objects.filter((x) => x.decision?.conflict).slice(0, 10)) {
|
|
256
|
-
deps.print(` ${o.key}: ${Object.entries(o.decision.conflict).map(([w, c]) => `${w} says ${c}`).join(', ')}`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const s = r.skipped;
|
|
260
|
-
if (unreadable)
|
|
261
|
-
deps.print(` ${unreadable} of ${files} files could not be read yet (still syncing, or damaged).`);
|
|
262
|
-
if (s.rowsNotInBaseline)
|
|
263
|
-
deps.print(` ${s.rowsNotInBaseline} rows named objects that are not in this project: ${r.unknownKeys.slice(0, 5).join(', ')}`);
|
|
264
|
-
if (s.otherProject)
|
|
265
|
-
deps.print(` ${s.otherProject} files belong to another project and were ignored.`);
|
|
266
|
-
if (s.rulingsNotFromLead)
|
|
267
|
-
deps.print(` ${s.rulingsNotFromLead} rulings were not from the lead and were ignored.`);
|
|
268
|
-
return 0;
|
|
269
|
-
}
|
|
270
|
-
// ── outbox / rule ───────────────────────────────────────────────────────────
|
|
271
|
-
function outbox(_args, deps) {
|
|
272
|
-
const joined = joinedOrThrow();
|
|
273
|
-
const dir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
274
|
-
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
|
|
275
|
-
deps.print(`Records this laptop has written for ${joined.projectId}: ${files.length}`);
|
|
276
|
-
deps.print(` ${dir}`);
|
|
277
|
-
for (const f of files.slice(-15)) {
|
|
278
|
-
let rows = '?';
|
|
279
|
-
try {
|
|
280
|
-
rows = String(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')).rows.length);
|
|
281
|
-
}
|
|
282
|
-
catch { /* listed anyway */ }
|
|
283
|
-
deps.print(` ${f} (${rows} rows)`);
|
|
284
|
-
}
|
|
285
|
-
deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
|
|
286
|
-
return 0;
|
|
287
|
-
}
|
|
288
|
-
function rule(args, deps) {
|
|
289
|
-
const { positional, opts } = flags(args);
|
|
290
|
-
const [type, name, classification] = positional;
|
|
291
|
-
if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
|
|
292
|
-
throw new RegisterError(`Usage: cspeach project rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
|
|
293
|
-
}
|
|
294
|
-
const joined = joinedOrThrow();
|
|
295
|
-
requireLead(readProjectFile(joined.projectDir), deps);
|
|
296
|
-
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(opts.reason ? { reason: opts.reason } : {}) }], deps.identity(), { dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.() });
|
|
297
|
-
if (!res.file)
|
|
298
|
-
throw new RegisterError(`${objectKey(type, name)} is not in this project's baseline.`);
|
|
299
|
-
deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
|
|
300
|
-
return 0;
|
|
301
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/local-files.ts
|
|
2
|
-
/**
|
|
3
|
-
* Startup wiring for the persisted `local_files` config toggle.
|
|
4
|
-
*
|
|
5
|
-
* When `cfg.local_files === true`, the otherwise flag-gated filesystem tools
|
|
6
|
-
* (file_read/file_write/file_edit/glob/grep) are force-enabled via the
|
|
7
|
-
* in-memory override in `_flag.ts`, BEFORE the first listTools()/turn. This is
|
|
8
|
-
* what makes /abap-fiori-build runnable — it writes local app files.
|
|
9
|
-
*
|
|
10
|
-
* The toggle is PERSISTED (config.toml), not re-consented each session, so
|
|
11
|
-
* `applyLocalFilesConfig` returns an announce line the entry point prints
|
|
12
|
-
* whenever the tools are live — the user must always see that local file
|
|
13
|
-
* access is on this session (trust-visibility requirement). The FS tools'
|
|
14
|
-
* own cwd-sandbox (writes confined to the project root; .cspeach/.env/.git
|
|
15
|
-
* denylisted) still applies regardless of this toggle.
|
|
16
|
-
*/
|
|
17
|
-
import { enableToolFlags } from './_flag.js';
|
|
18
|
-
/** The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ). */
|
|
19
|
-
export const LOCAL_FILE_TOOLS = ['file_read', 'file_write', 'file_edit', 'glob', 'grep'];
|
|
20
|
-
/**
|
|
21
|
-
* Apply the persisted `local_files` toggle. When on, force-enables the FS
|
|
22
|
-
* tools and returns a one-line announce string (undefined when off, so the
|
|
23
|
-
* caller prints nothing). `cwd` is surfaced so the user sees exactly which
|
|
24
|
-
* directory tree read/write/edit operate under.
|
|
25
|
-
*/
|
|
26
|
-
export function applyLocalFilesConfig(localFiles, cwd) {
|
|
27
|
-
if (localFiles !== true)
|
|
28
|
-
return undefined;
|
|
29
|
-
enableToolFlags([...LOCAL_FILE_TOOLS]);
|
|
30
|
-
return `local file tools enabled (read/write/edit under ${cwd}) — cspeach config set local_files off to disable`;
|
|
31
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/subagent/schedule_create.ts
|
|
2
|
-
/**
|
|
3
|
-
* schedule_create — Phase 3 / Chunk 3E.
|
|
4
|
-
*
|
|
5
|
-
* Persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
|
|
6
|
-
* Filename is ALWAYS crypto.randomUUID — never derived from user
|
|
7
|
-
* input. Closes the path-injection class of attack on the schedules
|
|
8
|
-
* directory.
|
|
9
|
-
*
|
|
10
|
-
* IMPORTANT: this tool persists schedules. It does NOT execute them.
|
|
11
|
-
* A future chunk (Phase 4 or later) ships the actual runner. The
|
|
12
|
-
* tool description and the response body both surface this caveat
|
|
13
|
-
* so the agent doesn't assume the schedule will fire.
|
|
14
|
-
*
|
|
15
|
-
* Flag-gated: invisible to listTools() unless
|
|
16
|
-
* CSPEACH_TOOL_SCHEDULE_CREATE=on. isMutating: true (writes filesystem).
|
|
17
|
-
*/
|
|
18
|
-
import { promises as fs } from 'node:fs';
|
|
19
|
-
import * as os from 'node:os';
|
|
20
|
-
import * as path from 'node:path';
|
|
21
|
-
import { randomUUID } from 'node:crypto';
|
|
22
|
-
import { registerTool } from '../index.js';
|
|
23
|
-
const NOT_EXECUTED_NOTE = 'Persisted only — the schedule runner has not shipped yet. ' +
|
|
24
|
-
'See Phase 4+ for execution.';
|
|
25
|
-
// Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
|
|
26
|
-
let schedulesDirOverride = null;
|
|
27
|
-
/** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
|
|
28
|
-
export function __setSchedulesDirForTests(dir) {
|
|
29
|
-
schedulesDirOverride = dir;
|
|
30
|
-
}
|
|
31
|
-
function getSchedulesDir() {
|
|
32
|
-
return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
|
|
33
|
-
}
|
|
34
|
-
// Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
|
|
35
|
-
const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
36
|
-
export async function scheduleCreateHandler(args, _ctx) {
|
|
37
|
-
if (args.kind !== 'oneoff' && args.kind !== 'cron') {
|
|
38
|
-
return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
|
|
39
|
-
}
|
|
40
|
-
if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
|
|
41
|
-
return { content: 'error: when is required (non-empty string)', is_error: true };
|
|
42
|
-
}
|
|
43
|
-
if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
|
|
44
|
-
return { content: 'error: command is required (non-empty string)', is_error: true };
|
|
45
|
-
}
|
|
46
|
-
if (args.kind === 'oneoff') {
|
|
47
|
-
if (!ISO_PATTERN.test(args.when)) {
|
|
48
|
-
return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
|
|
49
|
-
}
|
|
50
|
-
const t = Date.parse(args.when);
|
|
51
|
-
if (Number.isNaN(t)) {
|
|
52
|
-
return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
|
|
53
|
-
}
|
|
54
|
-
// Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
|
|
55
|
-
// returns a valid timestamp that round-trips to March 2 — without this check, the
|
|
56
|
-
// envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
|
|
57
|
-
const roundTrip = new Date(t).toISOString();
|
|
58
|
-
// Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
|
|
59
|
-
const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
|
|
60
|
-
if (norm(roundTrip) !== norm(args.when)) {
|
|
61
|
-
return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
|
|
62
|
-
}
|
|
63
|
-
if (t <= Date.now()) {
|
|
64
|
-
return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
// cron: just check it has 5 whitespace-separated fields.
|
|
69
|
-
const fields = args.when.trim().split(/\s+/);
|
|
70
|
-
if (fields.length !== 5) {
|
|
71
|
-
return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const dir = getSchedulesDir();
|
|
75
|
-
await fs.mkdir(dir, { recursive: true });
|
|
76
|
-
// Defence-in-depth: resolve the directory and verify it sits inside
|
|
77
|
-
// the cspeach home (or the test override). Refuses if a symlink
|
|
78
|
-
// points the dir at /etc/, /tmp/host-controlled/, etc.
|
|
79
|
-
const realDir = await fs.realpath(dir);
|
|
80
|
-
// In test mode the override dir IS the root (self-containment is trivial —
|
|
81
|
-
// mkdtemp gives us a unique dir that acts as both schedules-dir and its own
|
|
82
|
-
// root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
|
|
83
|
-
// is the parent. Asymmetric on purpose.
|
|
84
|
-
const expectedRoot = schedulesDirOverride !== null
|
|
85
|
-
? path.resolve(schedulesDirOverride)
|
|
86
|
-
: path.join(os.homedir(), '.cspeach');
|
|
87
|
-
const rel = path.relative(expectedRoot, realDir);
|
|
88
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
89
|
-
return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
|
|
90
|
-
}
|
|
91
|
-
const id = randomUUID();
|
|
92
|
-
const envelope = {
|
|
93
|
-
id,
|
|
94
|
-
kind: args.kind,
|
|
95
|
-
when: args.when,
|
|
96
|
-
command: args.command,
|
|
97
|
-
createdAt: new Date().toISOString(),
|
|
98
|
-
note: NOT_EXECUTED_NOTE,
|
|
99
|
-
};
|
|
100
|
-
const target = path.join(realDir, `${id}.json`);
|
|
101
|
-
await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
102
|
-
return {
|
|
103
|
-
content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
|
|
104
|
-
`path: ${target}\n` +
|
|
105
|
-
`id: ${id}\n` +
|
|
106
|
-
`kind: ${args.kind}\n` +
|
|
107
|
-
`when: ${args.when}\n` +
|
|
108
|
-
`command: ${args.command}\n` +
|
|
109
|
-
`note: ${NOT_EXECUTED_NOTE}`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
registerTool({
|
|
113
|
-
name: 'schedule_create',
|
|
114
|
-
description: 'Persist a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
|
|
115
|
-
'Filename is a fresh UUID — never derived from input. NOTE: this tool only PERSISTS the ' +
|
|
116
|
-
'schedule. No runner is shipped yet — the agent should not assume the command will fire ' +
|
|
117
|
-
'automatically. A future chunk will ship the runner.',
|
|
118
|
-
isMutating: true,
|
|
119
|
-
category: 'subagent',
|
|
120
|
-
flagGated: true,
|
|
121
|
-
input_schema: {
|
|
122
|
-
type: 'object',
|
|
123
|
-
properties: {
|
|
124
|
-
kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
|
|
125
|
-
when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
|
|
126
|
-
command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
|
|
127
|
-
},
|
|
128
|
-
required: ['kind', 'when', 'command'],
|
|
129
|
-
},
|
|
130
|
-
handler: (args, ctx) => scheduleCreateHandler(args, ctx),
|
|
131
|
-
});
|
package/dist/ui/error-emitter.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
import { shouldUseInk } from '../renderer/tty.js';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
export const errorEmitter = new EventEmitter();
|
|
5
|
-
/**
|
|
6
|
-
* Emit an error. In Ink mode, the main App subscribes and pushes the error
|
|
7
|
-
* into its errorStack state for overlay rendering. In non-TTY / one-shot,
|
|
8
|
-
* we fall back to chalk.red on stderr — never mount a second Ink root.
|
|
9
|
-
*/
|
|
10
|
-
export function emitError(err) {
|
|
11
|
-
if (shouldUseInk()) {
|
|
12
|
-
errorEmitter.emit('error', err);
|
|
13
|
-
}
|
|
14
|
-
else {
|
|
15
|
-
console.error(chalk.red(`[${err.code}] ${err.title}`));
|
|
16
|
-
if (err.body)
|
|
17
|
-
console.error(chalk.red(` ${err.body}`));
|
|
18
|
-
if (err.hint)
|
|
19
|
-
console.error(chalk.dim(` ${err.hint}`));
|
|
20
|
-
}
|
|
21
|
-
}
|
package/dist/ui/header.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { useSapState } from './sap-state-store.js';
|
|
4
|
-
import { formatSpendSegment } from './status-line.js';
|
|
5
|
-
import { CLI_VERSION } from '../lib/version.js';
|
|
6
|
-
const PEACH = '#FFCBA4';
|
|
7
|
-
/**
|
|
8
|
-
* ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
|
|
9
|
-
* bottom-anchored borderless statusline mounted in App between TurnStatus
|
|
10
|
-
* and Footer. This component is no longer rendered anywhere: Phase D6
|
|
11
|
-
* removed the persistent top Header (inline mode can't pin the frame top),
|
|
12
|
-
* and the statusline took over the SapStateStore display role. Kept for
|
|
13
|
-
* reference, not deleted.
|
|
14
|
-
*
|
|
15
|
-
* Single-line rich header (Phase D3, 2026-05-16) — back to one line per
|
|
16
|
-
* Laeeq's feedback that the two-line variant felt worse. Uses tighter
|
|
17
|
-
* separators and shorter labels so it fits cleanly on typical terminals
|
|
18
|
-
* (100+ cols). On a narrower terminal Ink will wrap rather than truncate,
|
|
19
|
-
* accepted as a degradation case.
|
|
20
|
-
*
|
|
21
|
-
* Layout:
|
|
22
|
-
* 🍑 CSPeach 0.6.5 │ S4H │ S.LAEEQ │ tr none │ 28k tok │ $0.54 │ approval-gated
|
|
23
|
-
*/
|
|
24
|
-
export function Header({ writeMode } = {}) {
|
|
25
|
-
const s = useSapState();
|
|
26
|
-
const isAdvisory = writeMode === 'advisory-only';
|
|
27
|
-
const modeLabel = isAdvisory
|
|
28
|
-
? 'ADVISORY'
|
|
29
|
-
: writeMode === 'approval-gated'
|
|
30
|
-
? 'approval-gated'
|
|
31
|
-
: writeMode === 'auto'
|
|
32
|
-
? 'auto'
|
|
33
|
-
: undefined;
|
|
34
|
-
const modeColor = isAdvisory ? 'yellow' : 'green';
|
|
35
|
-
const tokDisplay = s.tokenUsageToday >= 1000
|
|
36
|
-
? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
|
|
37
|
-
: `${s.tokenUsageToday} tok`;
|
|
38
|
-
// Shared with <StatusLine /> so the credits/dollar switch has one home.
|
|
39
|
-
const spendDisplay = formatSpendSegment(s);
|
|
40
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
|
|
41
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
4
|
-
/**
|
|
5
|
-
* Medium-confidence banner rendered inside the Ink frame. Auto-settles to
|
|
6
|
-
* the classifier's top pick after 800ms, unless the user presses Tab — in
|
|
7
|
-
* which case we open the CommandPalette with alternates.
|
|
8
|
-
*/
|
|
9
|
-
export function InkClassifierRoute({ skill, confidence, onSettle, onOpenPalette }) {
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
const t = setTimeout(onSettle, 800);
|
|
12
|
-
return () => clearTimeout(t);
|
|
13
|
-
}, [onSettle]);
|
|
14
|
-
useInput((_input, key) => {
|
|
15
|
-
if (key.tab)
|
|
16
|
-
onOpenPalette();
|
|
17
|
-
});
|
|
18
|
-
return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\\u2504 Suggest /", skill, " (confidence ", Math.round(confidence * 100), "%) \\u2014 Tab to pick"] }) }));
|
|
19
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
function severityColor(s) {
|
|
4
|
-
return s === 'error' ? 'red' : s === 'warning' ? 'yellow' : 'blue';
|
|
5
|
-
}
|
|
6
|
-
export function RichErrorBox({ code, message, hint, severity = 'error' }) {
|
|
7
|
-
const color = severityColor(severity);
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: color, children: code }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: message }) }), hint && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) }))] }));
|
|
9
|
-
}
|
package/dist/ui/sidebar.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { useSapState } from './sap-state-store.js';
|
|
4
|
-
export function Sidebar() {
|
|
5
|
-
const s = useSapState();
|
|
6
|
-
// Phase A** (2026-05-16) — dropped the "/ of $100" pseudo-budget. We
|
|
7
|
-
// don't know the user's actual API spending limit; showing a fake
|
|
8
|
-
// cap trained the colour signal to be meaningless. Now just shows the
|
|
9
|
-
// raw spend. Colour signal removed alongside.
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 30, children: [_jsx(Text, { bold: true, color: "cyan", children: s.system || 'no system' }), _jsxs(Text, { color: "gray", children: ["user: ", _jsx(Text, { color: "white", children: s.user || '—' })] }), _jsxs(Text, { color: "gray", children: ["transport: ", _jsx(Text, { color: "yellow", children: s.transport ?? 'none' })] }), _jsxs(Text, { color: "gray", children: ["locks: ", s.locksHeld.length === 0 ? _jsx(Text, { dimColor: true, children: "(none)" })
|
|
11
|
-
: s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2)] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
|
|
12
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
/**
|
|
3
|
-
* Singleton channel from `runTurn` (producer) to `<App>` (consumer) for
|
|
4
|
-
* status-row updates in Ink mode. Phase A #1 (2026-05-16) — the prior
|
|
5
|
-
* version of the REPL claimed Ink rendered its own `<StatusRow>` but
|
|
6
|
-
* never imported the component. As a result Ink users saw NO cost,
|
|
7
|
-
* token, or session info ever. This emitter is the missing wire.
|
|
8
|
-
*
|
|
9
|
-
* Producer (repl.tsx, end-of-turn): emits an `update` event with the
|
|
10
|
-
* latest StatusRowData. Consumer (App.tsx): subscribes in a useEffect,
|
|
11
|
-
* stores in state, renders `<StatusRow data={...} />` above `<Footer>`.
|
|
12
|
-
*
|
|
13
|
-
* Why a separate emitter (not chunkEmitter): chunkEmitter pipes strings
|
|
14
|
-
* into Body. StatusRow needs structured data (numbers + dollar amounts)
|
|
15
|
-
* and lives in a fixed region — passing it through chunks would mean
|
|
16
|
-
* sentinel parsing for what is naturally typed state. Matches the
|
|
17
|
-
* approvalEmitter / errorEmitter / coachingPickerEmitter pattern.
|
|
18
|
-
*/
|
|
19
|
-
export const statusEmitter = new EventEmitter();
|