@kontextmind/kxm 0.7.86 → 0.7.87
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/.claude-plugin/marketplace.json +1 -1
- package/docs/configuration.md +2 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +895 -498
- package/plugins/kxm/dist/extension.js +70 -61
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +44 -26
- package/plugins/kxm/dist/server.js +34 -23
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/context-skills.ts +51 -6
- package/plugins/kxm/src/cli/hub.ts +39 -14
- package/plugins/kxm/src/cli/project.ts +78 -23
- package/plugins/kxm/src/cli/roles.ts +48 -4
- package/plugins/kxm/src/cli/system.ts +18 -1
- package/plugins/kxm/src/cli/tasks.ts +34 -5
- package/plugins/kxm/src/cli/types.ts +30 -0
- package/plugins/kxm/src/cli/workflows.ts +20 -4
- package/plugins/kxm/src/cli.ts +57 -0
- package/plugins/kxm/src/config.ts +4 -3
- package/plugins/kxm/src/database.ts +72 -33
- package/plugins/kxm/src/local-snapshot.ts +4 -4
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/memory.ts +10 -7
- package/plugins/kxm/src/role.ts +18 -6
- package/plugins/kxm/src/session-work.ts +4 -1
- package/plugins/kxm/src/skills.ts +29 -15
- package/plugins/kxm/src/sqlite.ts +18 -0
- package/plugins/kxm/src/task-manager.ts +20 -13
- package/plugins/kxm/src/workflow-manager.ts +12 -4
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
6
|
-
import { DatabaseSync } from "../sqlite.ts";
|
|
6
|
+
import { DatabaseSync, openReadOnlyDatabase } from "../sqlite.ts";
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_ROLES,
|
|
9
9
|
DEFAULT_ROLE_SEATS,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { discoverKxmProjectRoot } from "../project-config.ts";
|
|
21
21
|
import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
|
|
22
22
|
import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts";
|
|
23
|
-
import { print, type CliIo, type Runtime } from "./types.ts";
|
|
23
|
+
import { print, printPlan, type CliIo, type Runtime } from "./types.ts";
|
|
24
24
|
|
|
25
25
|
export interface PickCandidate {
|
|
26
26
|
id: string;
|
|
@@ -192,7 +192,12 @@ export async function cmdRoleAdd(
|
|
|
192
192
|
repoRoot: runtime.cwd,
|
|
193
193
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
194
194
|
overwrite: options.overwrite,
|
|
195
|
+
dryRun: runtime.dryRun,
|
|
195
196
|
});
|
|
197
|
+
if (runtime.dryRun) {
|
|
198
|
+
printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
196
201
|
print(
|
|
197
202
|
runtime.io,
|
|
198
203
|
runtime.json,
|
|
@@ -232,7 +237,12 @@ export async function cmdRoleAdd(
|
|
|
232
237
|
repoRoot: runtime.cwd,
|
|
233
238
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
234
239
|
overwrite: options.overwrite,
|
|
240
|
+
dryRun: runtime.dryRun,
|
|
235
241
|
});
|
|
242
|
+
if (runtime.dryRun) {
|
|
243
|
+
printPlan(runtime, { command: "role add", roleId, ...res }, [{ action: "write", target: res.filePath }], `add role '${roleId}' to ${res.scope}`);
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
236
246
|
print(
|
|
237
247
|
runtime.io,
|
|
238
248
|
runtime.json,
|
|
@@ -283,7 +293,12 @@ export async function cmdRoleRemove(
|
|
|
283
293
|
scope,
|
|
284
294
|
repoRoot: runtime.cwd,
|
|
285
295
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
296
|
+
dryRun: runtime.dryRun,
|
|
286
297
|
});
|
|
298
|
+
if (runtime.dryRun) {
|
|
299
|
+
printPlan(runtime, { command: "role remove", roleId, ...res }, [{ action: "delete", target: res.filePath }], `remove role '${roleId}' from ${res.scope}`);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
287
302
|
print(
|
|
288
303
|
runtime.io,
|
|
289
304
|
runtime.json,
|
|
@@ -377,7 +392,12 @@ export async function cmdRoleModify(
|
|
|
377
392
|
scope: options.scope,
|
|
378
393
|
repoRoot: runtime.cwd,
|
|
379
394
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
395
|
+
dryRun: runtime.dryRun,
|
|
380
396
|
});
|
|
397
|
+
if (runtime.dryRun) {
|
|
398
|
+
printPlan(runtime, { command: "role modify", roleId, ...res }, [{ action: "write", target: res.filePath }], `modify role '${roleId}' in ${res.scope}`);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
381
401
|
print(
|
|
382
402
|
runtime.io,
|
|
383
403
|
runtime.json,
|
|
@@ -482,7 +502,17 @@ export async function cmdRoleSetHost(
|
|
|
482
502
|
scope: options.scope ?? "local",
|
|
483
503
|
repoRoot: runtime.cwd,
|
|
484
504
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
505
|
+
dryRun: runtime.dryRun,
|
|
485
506
|
});
|
|
507
|
+
if (runtime.dryRun) {
|
|
508
|
+
printPlan(
|
|
509
|
+
runtime,
|
|
510
|
+
{ command: "role set-host", seatId, host, binding: result.binding, filePath: result.filePath, scope: result.scope },
|
|
511
|
+
[{ action: "write", target: result.filePath }],
|
|
512
|
+
`bind seat '${seatId}' to host '${host}'`,
|
|
513
|
+
);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
486
516
|
|
|
487
517
|
print(
|
|
488
518
|
runtime.io,
|
|
@@ -521,7 +551,12 @@ export async function cmdRoleResume(
|
|
|
521
551
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
522
552
|
if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
|
|
523
553
|
if (runtime.dryRun) {
|
|
524
|
-
|
|
554
|
+
printPlan(
|
|
555
|
+
runtime,
|
|
556
|
+
{ command: "role resume", runId, ruling: effectiveRuling },
|
|
557
|
+
[{ action: "request", target: `POST kxm-runtime /v1/runs/${runId}/signal (audit_escalation unblock)` }],
|
|
558
|
+
`resume KXM run ${runId}`,
|
|
559
|
+
);
|
|
525
560
|
return 0;
|
|
526
561
|
}
|
|
527
562
|
try {
|
|
@@ -555,7 +590,7 @@ export async function cmdRoleResume(
|
|
|
555
590
|
const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db");
|
|
556
591
|
if (existsSync(dbPath)) {
|
|
557
592
|
try {
|
|
558
|
-
const database = new DatabaseSync(dbPath);
|
|
593
|
+
const database = runtime.dryRun ? openReadOnlyDatabase(dbPath) : new DatabaseSync(dbPath);
|
|
559
594
|
try {
|
|
560
595
|
const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined;
|
|
561
596
|
if (!row) {
|
|
@@ -565,6 +600,15 @@ export async function cmdRoleResume(
|
|
|
565
600
|
const run = JSON.parse(row.record) as WorkflowRun;
|
|
566
601
|
const now = new Date().toISOString();
|
|
567
602
|
const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now);
|
|
603
|
+
if (runtime.dryRun) {
|
|
604
|
+
printPlan(
|
|
605
|
+
runtime,
|
|
606
|
+
{ command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status },
|
|
607
|
+
[{ action: "write", target: `${dbPath} (workflow_runs ${runId}, one workflow_journal decision)` }],
|
|
608
|
+
`resume workflow run ${runId} (stage: ${resumeResult.stageId})`,
|
|
609
|
+
);
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
568
612
|
|
|
569
613
|
database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run(
|
|
570
614
|
JSON.stringify(resumeResult.run),
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
import { kxmUserStateRoot } from "../bindings.ts";
|
|
89
89
|
import {
|
|
90
90
|
print,
|
|
91
|
+
printPlan,
|
|
91
92
|
printWorker,
|
|
92
93
|
gateOf,
|
|
93
94
|
type CliIo,
|
|
@@ -224,6 +225,10 @@ export async function cmdSshRun(
|
|
|
224
225
|
options: { sudo?: boolean | undefined },
|
|
225
226
|
): Promise<number> {
|
|
226
227
|
const command = commandParts.join(" ");
|
|
228
|
+
if (runtime.dryRun) {
|
|
229
|
+
printPlan(runtime, { command: "ssh run", host, remoteCommand: command, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${options.sudo ? "sudo " : ""}${command}` }], `run a command on ${host}`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
227
232
|
const receipt = executeSshRun({
|
|
228
233
|
action: "command",
|
|
229
234
|
host,
|
|
@@ -247,6 +252,10 @@ export async function cmdSshFile(
|
|
|
247
252
|
options: { content?: string | undefined; read?: boolean | undefined; append?: boolean | undefined; sudo?: boolean | undefined },
|
|
248
253
|
): Promise<number> {
|
|
249
254
|
const op = options.read ? "read" : (options.append ? "append" : "write");
|
|
255
|
+
if (runtime.dryRun) {
|
|
256
|
+
printPlan(runtime, { command: "ssh file", host, path: filePath, op, sudo: options.sudo === true }, [{ action: "ssh", target: `${host}: ${op} ${filePath}` }], `${op} ${filePath} on ${host}`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
250
259
|
const receipt = executeSshRun({
|
|
251
260
|
action: "file",
|
|
252
261
|
host,
|
|
@@ -269,6 +278,10 @@ export async function cmdSshFile(
|
|
|
269
278
|
}
|
|
270
279
|
|
|
271
280
|
export async function cmdSshClose(runtime: Runtime, host: string): Promise<number> {
|
|
281
|
+
if (runtime.dryRun) {
|
|
282
|
+
printPlan(runtime, { command: "ssh close", host }, [{ action: "ssh", target: `${host}: close the ControlMaster socket` }], `close the ControlMaster socket for ${host}`);
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
272
285
|
const closed = closeControlSocket(host);
|
|
273
286
|
if (runtime.json) {
|
|
274
287
|
print(runtime.io, runtime.json, { ok: true, closed, command: "ssh close", host }, "");
|
|
@@ -568,7 +581,11 @@ export async function cmdConfigSet(
|
|
|
568
581
|
} catch {
|
|
569
582
|
// keep string
|
|
570
583
|
}
|
|
571
|
-
setKxmConfigValue(runtime.cwd, key, parsedVal, { scope });
|
|
584
|
+
const { file } = setKxmConfigValue(runtime.cwd, key, parsedVal, { scope, dryRun: runtime.dryRun });
|
|
585
|
+
if (runtime.dryRun) {
|
|
586
|
+
printPlan(runtime, { command: "config set", key, value: parsedVal, scope }, [{ action: "write", target: file }], `set ${key} = ${value} in ${scope} config`);
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
572
589
|
print(
|
|
573
590
|
runtime.io,
|
|
574
591
|
runtime.json,
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
getTask,
|
|
12
12
|
updateTaskStatus,
|
|
13
13
|
syncTaskWithTracker,
|
|
14
|
+
goalFilePath,
|
|
15
|
+
taskFilePath,
|
|
14
16
|
type TaskStatus,
|
|
15
17
|
type TrackerType,
|
|
16
18
|
} from "../task-manager.ts";
|
|
@@ -22,8 +24,8 @@ import {
|
|
|
22
24
|
generateStudioLayout,
|
|
23
25
|
} from "../studio-layout.ts";
|
|
24
26
|
import { readSessionTokenFromDisk } from "../commands.ts";
|
|
25
|
-
import { print, type Runtime } from "./types.ts";
|
|
26
|
-
import { cmdKxmRun } from "./project.ts";
|
|
27
|
+
import { print, printPlan, type Runtime } from "./types.ts";
|
|
28
|
+
import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
|
|
27
29
|
|
|
28
30
|
export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
|
|
29
31
|
try {
|
|
@@ -74,7 +76,11 @@ export async function cmdGoalCreate(
|
|
|
74
76
|
area: options.area,
|
|
75
77
|
successMetrics: options.metric,
|
|
76
78
|
targetDate: options.targetDate,
|
|
77
|
-
});
|
|
79
|
+
}, { dryRun: runtime.dryRun });
|
|
80
|
+
if (runtime.dryRun) {
|
|
81
|
+
printPlan(runtime, { command: "goal create", goal }, [{ action: "write", target: goalFilePath(runtime.cwd, goal.id) }], `create goal ${goal.title} (the id is assigned when it is created)`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
78
84
|
print(
|
|
79
85
|
runtime.io,
|
|
80
86
|
runtime.json,
|
|
@@ -118,7 +124,11 @@ export async function cmdTaskCreate(
|
|
|
118
124
|
trackerSync: options.tracker && options.issue
|
|
119
125
|
? { tracker: options.tracker as TrackerType, issueKey: options.issue }
|
|
120
126
|
: undefined,
|
|
121
|
-
});
|
|
127
|
+
}, { dryRun: runtime.dryRun });
|
|
128
|
+
if (runtime.dryRun) {
|
|
129
|
+
printPlan(runtime, { command: "task create", task }, [{ action: "write", target: taskFilePath(runtime.cwd, task.id) }], `create task ${task.title} [${task.status}] (the id is assigned when it is created)`);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
122
132
|
print(
|
|
123
133
|
runtime.io,
|
|
124
134
|
runtime.json,
|
|
@@ -187,6 +197,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
|
|
|
187
197
|
return 1;
|
|
188
198
|
}
|
|
189
199
|
const workflow = task.assignedWorkflow ?? "default";
|
|
200
|
+
if (runtime.dryRun) {
|
|
201
|
+
const target = resolveKxmRunTarget(runtime, workflow);
|
|
202
|
+
if (typeof target === "number") return target;
|
|
203
|
+
const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
|
|
204
|
+
printPlan(
|
|
205
|
+
runtime,
|
|
206
|
+
{ command: "task run", taskId, ...target, status: started.status },
|
|
207
|
+
[
|
|
208
|
+
{ action: "request", target: "POST kxm-runtime /v1/runs (starts the Runtime supervisor if it is not running)" },
|
|
209
|
+
{ action: "write", target: taskFilePath(runtime.cwd, taskId) },
|
|
210
|
+
],
|
|
211
|
+
`run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
|
|
212
|
+
);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
190
215
|
const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
|
|
191
216
|
if (exitCode === 0) {
|
|
192
217
|
updateTaskStatus(runtime.cwd, taskId, "in_progress");
|
|
@@ -201,7 +226,11 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
|
|
|
201
226
|
|
|
202
227
|
export async function cmdTaskSync(runtime: Runtime, taskId: string): Promise<number> {
|
|
203
228
|
try {
|
|
204
|
-
const synced = syncTaskWithTracker(runtime.cwd, taskId);
|
|
229
|
+
const synced = syncTaskWithTracker(runtime.cwd, taskId, { dryRun: runtime.dryRun });
|
|
230
|
+
if (runtime.dryRun) {
|
|
231
|
+
printPlan(runtime, { command: "task sync", task: synced }, [{ action: "write", target: taskFilePath(runtime.cwd, taskId) }], `mark task ${taskId} synced with ${synced.trackerSync?.tracker} #${synced.trackerSync?.issueKey}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
205
234
|
print(
|
|
206
235
|
runtime.io,
|
|
207
236
|
runtime.json,
|
|
@@ -101,6 +101,36 @@ export function print(io: CliIo, jsonMode: boolean, payload: object, text: strin
|
|
|
101
101
|
else io.stdout(line);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** One change a command would have made had `--dry-run` not been given. */
|
|
105
|
+
export interface PlannedChange {
|
|
106
|
+
action: "write" | "delete" | "move" | "request" | "ssh";
|
|
107
|
+
target: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `--dry-run` answer of a mutating command: the envelope the real run
|
|
111
|
+
* prints, marked `dryRun: true`, plus every write, request, or remote step it
|
|
112
|
+
* would have taken. Nothing is written by the caller before or after this. */
|
|
113
|
+
export function printPlan(
|
|
114
|
+
runtime: Runtime,
|
|
115
|
+
payload: Record<string, unknown> & { command: string },
|
|
116
|
+
planned: readonly PlannedChange[],
|
|
117
|
+
text: string,
|
|
118
|
+
): void {
|
|
119
|
+
print(runtime.io, runtime.json, { ok: true, ...payload, dryRun: true, planned }, [
|
|
120
|
+
`dry run: ${text}`,
|
|
121
|
+
...(planned.length === 0 ? [" no changes"] : planned.map((change) => ` would ${change.action} ${change.target}`)),
|
|
122
|
+
].join("\n"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const DRY_RUN_UNSUPPORTED = "dry_run_unsupported";
|
|
126
|
+
|
|
127
|
+
/** For a command that cannot say what it would do without doing some of it:
|
|
128
|
+
* refuse under `--dry-run` instead of executing. */
|
|
129
|
+
export function refuseDryRun(io: CliIo, jsonMode: boolean, command: string, reason: string): number {
|
|
130
|
+
print(io, jsonMode, { ok: false, command, dryRun: true, error: DRY_RUN_UNSUPPORTED, detail: reason }, `kxm ${command} --dry-run refused: ${reason}`);
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
export function printWorker(
|
|
105
135
|
runtime: Runtime,
|
|
106
136
|
worker: Worker,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { openReadOnlyDatabase } from "../sqlite.ts";
|
|
5
5
|
import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
|
|
6
6
|
import { redactSecrets } from "../redact.ts";
|
|
7
7
|
import { postWorkflowSignal, watchGithubChecks } from "../github-watch.ts";
|
|
@@ -24,6 +24,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts
|
|
|
24
24
|
import type { WorkerOutcome } from "../envelope.ts";
|
|
25
25
|
import {
|
|
26
26
|
print,
|
|
27
|
+
printPlan,
|
|
27
28
|
printWorker,
|
|
28
29
|
gateOf,
|
|
29
30
|
parseEvidencePairs,
|
|
@@ -37,7 +38,7 @@ const CLI_NAME = "kxm";
|
|
|
37
38
|
|
|
38
39
|
export function localWorkflowSnapshot(dataPath: string, runId?: string): { runs: WorkflowRun[]; journal: WorkflowJournalEntry[] } {
|
|
39
40
|
if (!existsSync(dataPath)) throw new Error("state_database_not_found");
|
|
40
|
-
const database =
|
|
41
|
+
const database = openReadOnlyDatabase(dataPath);
|
|
41
42
|
try {
|
|
42
43
|
const rows = runId
|
|
43
44
|
? database.prepare("SELECT record FROM workflow_runs WHERE id = ?").all(runId)
|
|
@@ -265,7 +266,12 @@ export async function cmdWorkflowAdd(
|
|
|
265
266
|
repoRoot: runtime.cwd,
|
|
266
267
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
267
268
|
overwrite: options.overwrite,
|
|
269
|
+
dryRun: runtime.dryRun,
|
|
268
270
|
});
|
|
271
|
+
if (runtime.dryRun) {
|
|
272
|
+
printPlan(runtime, { command: "workflow add", workflowId, ...res }, [{ action: "write", target: res.filePath }], `add workflow '${workflowId}' to ${res.scope}`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
269
275
|
print(
|
|
270
276
|
runtime.io,
|
|
271
277
|
runtime.json,
|
|
@@ -316,7 +322,12 @@ export async function cmdWorkflowRemove(
|
|
|
316
322
|
scope,
|
|
317
323
|
repoRoot: runtime.cwd,
|
|
318
324
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
325
|
+
dryRun: runtime.dryRun,
|
|
319
326
|
});
|
|
327
|
+
if (runtime.dryRun) {
|
|
328
|
+
printPlan(runtime, { command: "workflow remove", workflowId, ...res }, [{ action: "delete", target: res.filePath }], `remove workflow '${workflowId}' from ${res.scope}`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
320
331
|
print(
|
|
321
332
|
runtime.io,
|
|
322
333
|
runtime.json,
|
|
@@ -373,8 +384,13 @@ export async function cmdWorkflowModify(
|
|
|
373
384
|
scope: options.scope,
|
|
374
385
|
repoRoot: runtime.cwd,
|
|
375
386
|
userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
|
|
387
|
+
dryRun: runtime.dryRun,
|
|
376
388
|
},
|
|
377
389
|
);
|
|
390
|
+
if (runtime.dryRun) {
|
|
391
|
+
printPlan(runtime, { command: "workflow modify", workflowId, ...res }, [{ action: "write", target: res.filePath }], `modify workflow '${workflowId}' in ${res.scope}`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
378
394
|
print(
|
|
379
395
|
runtime.io,
|
|
380
396
|
runtime.json,
|
|
@@ -520,7 +536,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
|
|
|
520
536
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
521
537
|
if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
|
|
522
538
|
if (runtime.dryRun) {
|
|
523
|
-
printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
|
|
539
|
+
printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signal to KXM run");
|
|
524
540
|
return 0;
|
|
525
541
|
}
|
|
526
542
|
const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
|
|
@@ -557,7 +573,7 @@ export async function cmdSignal(runtime: Runtime, runId: string, signalKey: stri
|
|
|
557
573
|
return 2;
|
|
558
574
|
}
|
|
559
575
|
if (runtime.dryRun) {
|
|
560
|
-
printWorker(runtime, worker, { ok: true, command: "signal", runId, signalKey, status, summary, evidence }, "would post signed signal");
|
|
576
|
+
printWorker(runtime, worker, { ok: true, command: "signal", dryRun: true, runId, signalKey, status, summary, evidence }, "would post signed signal");
|
|
561
577
|
return 0;
|
|
562
578
|
}
|
|
563
579
|
const deliveryId = String(deliveryIdFlag || `cli-signal:${randomUUID()}`);
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts"
|
|
|
30
30
|
// Submodule imports
|
|
31
31
|
import {
|
|
32
32
|
print,
|
|
33
|
+
refuseDryRun,
|
|
33
34
|
runtimeFrom,
|
|
34
35
|
redactConfiguredValues,
|
|
35
36
|
type CliContext,
|
|
@@ -172,6 +173,54 @@ const USAGE_ERROR_CODES = new Set([
|
|
|
172
173
|
"commander.optionMissingArgument",
|
|
173
174
|
]);
|
|
174
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Every command that answers `--dry-run` without changing anything: it only
|
|
178
|
+
* reads, or it prints the plan (`dryRun: true` plus `planned`) and stops.
|
|
179
|
+
* A command missing from this set is refused under `--dry-run` with
|
|
180
|
+
* `dry_run_unsupported` before its action runs, so a new command that forgets
|
|
181
|
+
* to check the flag fails closed instead of mutating. Keyed by command path.
|
|
182
|
+
*/
|
|
183
|
+
const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
|
|
184
|
+
"init", "backup", "restore", "run", "explain", "suggest", "update", "dash", "completion", "completion install",
|
|
185
|
+
"runs status", "runs drive", "runs receipt", "runs cancel", "runs list",
|
|
186
|
+
"tenant status", "models inventory-refresh", "harness list", "auth token",
|
|
187
|
+
"routes list", "routes count", "routes admit", "routes disable",
|
|
188
|
+
"runtime start", "runtime status", "runtime sync-retry", "runtime stop",
|
|
189
|
+
"trust diff", "trust check", "agent worker",
|
|
190
|
+
"session status", "session brief", "session token", "session start", "session stop",
|
|
191
|
+
"peer list", "peer send", "peer get", "peer await", "peer cancel", "peer fanout", "peer inbox", "peer reply",
|
|
192
|
+
"workflow list", "workflow get", "workflow checkpoint", "workflow record", "workflow wait", "workflow signal",
|
|
193
|
+
"workflow start", "workflow export", "workflow definitions", "workflow add", "workflow remove", "workflow modify",
|
|
194
|
+
"role list", "role get", "role add", "role remove", "role modify", "role hosts", "role set-host", "role resume",
|
|
195
|
+
"gate validate", "gate artifacts-exist", "gate degrade", "gate signal", "gate github watch",
|
|
196
|
+
"improve report",
|
|
197
|
+
"context get", "context recall", "context state", "context episode", "context promote", "context explain",
|
|
198
|
+
"context wiki-compile", "context wiki-lint",
|
|
199
|
+
"skills create", "skills evaluate", "skills promote", "skills reject", "skills list", "skills verify",
|
|
200
|
+
"memory brief", "memory note", "memory sync",
|
|
201
|
+
"routing report", "routing benchmark",
|
|
202
|
+
"ssh info", "ssh run", "ssh file", "ssh close",
|
|
203
|
+
"hub view", "hub start", "hub stop", "hub bind", "hub unbind",
|
|
204
|
+
"config get", "config set", "config list",
|
|
205
|
+
"goal create", "goal list",
|
|
206
|
+
"task create", "task list", "task get", "task run", "task sync",
|
|
207
|
+
"studio layout", "studio serve",
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
class DryRunRefused extends Error {
|
|
211
|
+
readonly code: number;
|
|
212
|
+
constructor(code: number) {
|
|
213
|
+
super("dry_run_unsupported");
|
|
214
|
+
this.code = code;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function commandPath(command: Command): string {
|
|
219
|
+
const names: string[] = [];
|
|
220
|
+
for (let current: Command | null = command; current?.parent; current = current.parent) names.unshift(current.name());
|
|
221
|
+
return names.join(" ");
|
|
222
|
+
}
|
|
223
|
+
|
|
175
224
|
function addGlobalOptions(command: Command): Command {
|
|
176
225
|
return command
|
|
177
226
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -331,6 +380,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
331
380
|
})
|
|
332
381
|
.helpCommand("help", "Show help");
|
|
333
382
|
addGlobalOptions(program);
|
|
383
|
+
program.hook("preAction", (_program, actionCommand) => {
|
|
384
|
+
const opts = actionCommand.optsWithGlobals() as { dryRun?: boolean; json?: boolean };
|
|
385
|
+
if (!opts.dryRun) return;
|
|
386
|
+
const path = commandPath(actionCommand);
|
|
387
|
+
if (DRY_RUN_COMMANDS.has(path)) return;
|
|
388
|
+
throw new DryRunRefused(refuseDryRun(ctx.io, Boolean(opts.json), path, "this command cannot plan without making changes; rerun without --dry-run"));
|
|
389
|
+
});
|
|
334
390
|
|
|
335
391
|
program.command("init").description("Create, validate, repair, or join a KXM project")
|
|
336
392
|
.option("--json", "Print machine-readable JSON")
|
|
@@ -1222,6 +1278,7 @@ export async function runCli(
|
|
|
1222
1278
|
return result.code;
|
|
1223
1279
|
} catch (error) {
|
|
1224
1280
|
if (error instanceof CommanderError) return mapCommanderError(error);
|
|
1281
|
+
if (error instanceof DryRunRefused) return error.code;
|
|
1225
1282
|
throw error;
|
|
1226
1283
|
}
|
|
1227
1284
|
}
|
|
@@ -303,8 +303,8 @@ export function setKxmConfigValue(
|
|
|
303
303
|
repoRoot: string,
|
|
304
304
|
keyPath: string,
|
|
305
305
|
value: unknown,
|
|
306
|
-
options: { scope?: "user" | "project"; userConfigDir?: string } = {},
|
|
307
|
-
):
|
|
306
|
+
options: { scope?: "user" | "project"; userConfigDir?: string; dryRun?: boolean } = {},
|
|
307
|
+
): { file: string } {
|
|
308
308
|
const targetFile = kxmConfigFileForScope(repoRoot, options.scope ?? "project", options.userConfigDir);
|
|
309
309
|
const existing = readConfigFile(targetFile);
|
|
310
310
|
const parts = configKeyParts(keyPath);
|
|
@@ -317,7 +317,8 @@ export function setKxmConfigValue(
|
|
|
317
317
|
cursor = cursor[p] as Record<string, unknown>;
|
|
318
318
|
}
|
|
319
319
|
cursor[parts[parts.length - 1]!] = value;
|
|
320
|
-
writeConfigFile(targetFile, existing);
|
|
320
|
+
if (!options.dryRun) writeConfigFile(targetFile, existing);
|
|
321
|
+
return { file: targetFile };
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
/**
|
|
@@ -649,11 +649,20 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
649
649
|
return stores;
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
-
export
|
|
652
|
+
export interface BackupPlan {
|
|
653
|
+
projectRoot: string;
|
|
654
|
+
outDir: string;
|
|
655
|
+
createdAt: string;
|
|
656
|
+
stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Which stores a backup would copy and where, without opening any of them.
|
|
660
|
+
* Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
|
|
661
|
+
export function planBackup(options: {
|
|
653
662
|
projectRoot?: string;
|
|
654
663
|
outDir?: string;
|
|
655
664
|
hubDataPath?: string;
|
|
656
|
-
} = {}):
|
|
665
|
+
} = {}): BackupPlan {
|
|
657
666
|
const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
|
|
658
667
|
const stores = discoverProjectStores(projectRoot, {
|
|
659
668
|
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
@@ -663,35 +672,43 @@ export function createBackup(options: {
|
|
|
663
672
|
throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
|
|
664
673
|
}
|
|
665
674
|
|
|
666
|
-
const
|
|
667
|
-
const timestamp =
|
|
668
|
-
const backupId = `bk_${randomBytes(8).toString("hex")}`;
|
|
675
|
+
const createdAt = new Date().toISOString();
|
|
676
|
+
const timestamp = createdAt.replace(/[:.]/g, "-");
|
|
669
677
|
const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
|
|
670
|
-
|
|
671
|
-
if (!existsSync(outDir)) {
|
|
672
|
-
mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const backedUpStores: BackupStoreRecord[] = [];
|
|
676
678
|
const usedFilenames = new Set<string>();
|
|
677
|
-
|
|
678
|
-
for (const store of stores) {
|
|
679
|
+
const planned = stores.map((store) => {
|
|
679
680
|
let filename = basename(store.sourcePath);
|
|
680
681
|
if (usedFilenames.has(filename)) {
|
|
681
682
|
const sanitizedId = store.storeId.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
682
683
|
filename = `${sanitizedId}-${filename}`;
|
|
683
684
|
}
|
|
684
685
|
usedFilenames.add(filename);
|
|
686
|
+
return { storeId: store.storeId, sourcePath: store.sourcePath, backupFile: filename };
|
|
687
|
+
});
|
|
688
|
+
return { projectRoot, outDir, createdAt, stores: planned };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function createBackup(options: {
|
|
692
|
+
projectRoot?: string;
|
|
693
|
+
outDir?: string;
|
|
694
|
+
hubDataPath?: string;
|
|
695
|
+
} = {}): { manifest: BackupManifest; outDir: string } {
|
|
696
|
+
const { projectRoot, outDir, createdAt, stores } = planBackup(options);
|
|
697
|
+
const backupId = `bk_${randomBytes(8).toString("hex")}`;
|
|
685
698
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
699
|
+
if (!existsSync(outDir)) {
|
|
700
|
+
mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const backedUpStores: BackupStoreRecord[] = [];
|
|
704
|
+
for (const store of stores) {
|
|
705
|
+
backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
|
|
689
706
|
}
|
|
690
707
|
|
|
691
708
|
const manifest: BackupManifest = {
|
|
692
709
|
schema: "kxm.backup-manifest.v1",
|
|
693
710
|
backupId,
|
|
694
|
-
createdAt
|
|
711
|
+
createdAt,
|
|
695
712
|
projectRoot,
|
|
696
713
|
stores: backedUpStores,
|
|
697
714
|
};
|
|
@@ -707,10 +724,19 @@ export function createBackup(options: {
|
|
|
707
724
|
return { manifest, outDir };
|
|
708
725
|
}
|
|
709
726
|
|
|
710
|
-
export
|
|
727
|
+
export interface RestorePlan {
|
|
728
|
+
manifestPath: string;
|
|
729
|
+
backupId: string;
|
|
730
|
+
stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Everything restore checks before it overwrites anything: the manifest, each
|
|
734
|
+
* backup file's digest, and each store's schema against this build's ceiling
|
|
735
|
+
* (from the manifest). Reads files; opens no database. */
|
|
736
|
+
export function planRestore(
|
|
711
737
|
manifestPathOrDir: string,
|
|
712
738
|
options: { projectRoot?: string } = {},
|
|
713
|
-
):
|
|
739
|
+
): RestorePlan {
|
|
714
740
|
let manifestPath = resolve(manifestPathOrDir);
|
|
715
741
|
const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
|
|
716
742
|
if (!stat) {
|
|
@@ -737,8 +763,7 @@ export function restoreBackup(
|
|
|
737
763
|
throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
|
|
738
764
|
}
|
|
739
765
|
|
|
740
|
-
const
|
|
741
|
-
|
|
766
|
+
const stores: RestorePlan["stores"] = [];
|
|
742
767
|
for (const store of manifest.stores) {
|
|
743
768
|
const backupFilePath = join(manifestDir, store.backupFile);
|
|
744
769
|
if (!existsSync(backupFilePath)) {
|
|
@@ -754,27 +779,41 @@ export function restoreBackup(
|
|
|
754
779
|
);
|
|
755
780
|
}
|
|
756
781
|
|
|
757
|
-
const
|
|
782
|
+
const maxSupportedVersion = kxmBackupCeiling(store.storeId);
|
|
783
|
+
if (store.schemaVersion > maxSupportedVersion) {
|
|
784
|
+
throw databaseError(
|
|
785
|
+
"runtime_schema_newer",
|
|
786
|
+
backupFilePath,
|
|
787
|
+
`backup store ${store.storeId} schema version ${store.schemaVersion} is newer than supported maximum ${maxSupportedVersion}`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
758
790
|
|
|
759
791
|
let targetPath = store.sourcePath;
|
|
760
792
|
if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
|
|
761
793
|
const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
|
|
762
794
|
targetPath = join(resolve(options.projectRoot), rel);
|
|
763
795
|
}
|
|
764
|
-
|
|
765
|
-
const result = restoreDatabaseFile(
|
|
766
|
-
backupFilePath,
|
|
767
|
-
targetPath,
|
|
768
|
-
store.storeId,
|
|
769
|
-
store.schemaVersion,
|
|
770
|
-
maxSupported,
|
|
771
|
-
);
|
|
772
|
-
restoredStores.push(result);
|
|
796
|
+
stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
|
|
773
797
|
}
|
|
774
798
|
|
|
799
|
+
return { manifestPath, backupId: manifest.backupId, stores };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function restoreBackup(
|
|
803
|
+
manifestPathOrDir: string,
|
|
804
|
+
options: { projectRoot?: string } = {},
|
|
805
|
+
): RestoreResult {
|
|
806
|
+
const plan = planRestore(manifestPathOrDir, options);
|
|
807
|
+
const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
|
|
808
|
+
store.backupFilePath,
|
|
809
|
+
store.targetPath,
|
|
810
|
+
store.storeId,
|
|
811
|
+
store.schemaVersion,
|
|
812
|
+
store.maxSupportedVersion,
|
|
813
|
+
));
|
|
775
814
|
return {
|
|
776
|
-
manifestPath,
|
|
777
|
-
backupId:
|
|
815
|
+
manifestPath: plan.manifestPath,
|
|
816
|
+
backupId: plan.backupId,
|
|
778
817
|
restoredStores,
|
|
779
818
|
};
|
|
780
819
|
}
|