@kontextmind/kxm 0.7.103 → 0.7.105
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/CHANGELOG.md +39 -8
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contributing/test-matrix.md +11 -6
- package/docs/operations/backup-and-restore.md +59 -27
- package/docs/operations/deploy.md +1 -1
- package/docs/reference/cli-reference.md +73 -55
- package/docs/reference/config-reference.md +1 -1
- package/docs/reference/workflow-definitions.md +4 -4
- package/docs/start/first-workflow.md +6 -2
- package/docs/start/quickstart-claude-code.md +11 -1
- package/docs/templates/runbook.md +2 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +1802 -1011
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +322 -292
- package/plugins/kxm/dist/runtime.js +427 -351
- package/plugins/kxm/dist/server.js +78 -78
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +14 -7
- package/plugins/kxm/src/cli/project.ts +132 -30
- package/plugins/kxm/src/cli/system.ts +19 -1
- package/plugins/kxm/src/cli/tasks.ts +55 -31
- package/plugins/kxm/src/cli/workflows.ts +52 -54
- package/plugins/kxm/src/cli.ts +8 -6
- package/plugins/kxm/src/database.ts +193 -65
- package/plugins/kxm/src/engine.ts +79 -3
- package/plugins/kxm/src/harness.ts +6 -5
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-paths.ts +50 -0
- package/plugins/kxm/src/runtime-store.ts +22 -53
- package/plugins/kxm/src/runtime-supervisor.ts +38 -17
- package/plugins/kxm/src/suggest.ts +132 -79
- package/plugins/kxm/src/workflow-manager.ts +42 -19
- package/schemas/backup-manifest.schema.json +15 -0
|
@@ -1,34 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
|
|
5
5
|
import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { kxmProjectRunEventsPath, projectRuntimeKey } from "./runtime-paths.ts";
|
|
7
7
|
import { deriveKxmSyncEvent, kxmSyncEventBytes, KxmSyncRedactor } from "./sync-transform.ts";
|
|
8
8
|
|
|
9
9
|
/* ------------------------------------------------------------------ *
|
|
10
10
|
* Runtime registry (per-user, platform state root)
|
|
11
11
|
* ------------------------------------------------------------------ */
|
|
12
12
|
|
|
13
|
-
export
|
|
14
|
-
stateRoot: string;
|
|
15
|
-
runtimeDir: string;
|
|
16
|
-
registryDb: string;
|
|
17
|
-
projectsDir: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function kxmRuntimePaths(options: { stateRoot?: string; env?: NodeJS.ProcessEnv; homeDir?: string } = {}): KxmRuntimePaths {
|
|
21
|
-
const stateRoot = options.stateRoot
|
|
22
|
-
? resolve(options.stateRoot)
|
|
23
|
-
: kxmUserStateRoot({ ...(options.env ? { env: options.env } : {}), ...(options.homeDir ? { homeDir: options.homeDir } : {}) });
|
|
24
|
-
const runtimeDir = join(stateRoot, "runtime");
|
|
25
|
-
return {
|
|
26
|
-
stateRoot,
|
|
27
|
-
runtimeDir,
|
|
28
|
-
registryDb: join(runtimeDir, "registry.db"),
|
|
29
|
-
projectsDir: join(runtimeDir, "projects"),
|
|
30
|
-
};
|
|
31
|
-
}
|
|
13
|
+
export { kxmRuntimePaths, projectRuntimeKey, kxmProjectRunEventsPath, type KxmRuntimePaths } from "./runtime-paths.ts";
|
|
32
14
|
|
|
33
15
|
function runtimeIssue(phase: KxmConfigIssue["phase"], code: string, file: string, message: string): KxmConfigIssue {
|
|
34
16
|
return { phase, code, file, message };
|
|
@@ -38,24 +20,6 @@ export function runtimeError(code: string, file: string, message: string): KxmCo
|
|
|
38
20
|
return new KxmConfigError([runtimeIssue("semantic", code, file, message)]);
|
|
39
21
|
}
|
|
40
22
|
|
|
41
|
-
export function projectRuntimeKey(projectRoot: string): string {
|
|
42
|
-
// Canonicalize through the filesystem like the repository binding store so
|
|
43
|
-
// reaching a project through a link cannot mint a second key for it.
|
|
44
|
-
let canonical: string;
|
|
45
|
-
try {
|
|
46
|
-
canonical = realpathSync.native(resolve(projectRoot));
|
|
47
|
-
} catch {
|
|
48
|
-
canonical = resolve(projectRoot);
|
|
49
|
-
}
|
|
50
|
-
const folded = process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
|
|
51
|
-
return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
55
|
-
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
56
|
-
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
23
|
/**
|
|
60
24
|
* Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
|
|
61
25
|
* runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
|
|
@@ -141,6 +105,24 @@ CREATE TABLE projects (
|
|
|
141
105
|
) STRICT;
|
|
142
106
|
`;
|
|
143
107
|
|
|
108
|
+
/** The supervisor singleton row, from any connection to a registry, including a read-only one. */
|
|
109
|
+
export function readKxmSupervisorRecord(database: DatabaseSync): KxmSupervisorRecord | undefined {
|
|
110
|
+
const row = database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
|
|
111
|
+
| { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
|
|
112
|
+
| undefined;
|
|
113
|
+
return row
|
|
114
|
+
? {
|
|
115
|
+
runtimeId: row.runtime_id,
|
|
116
|
+
pid: row.pid,
|
|
117
|
+
port: row.port,
|
|
118
|
+
tokenHash: row.token_hash,
|
|
119
|
+
startedAt: row.started_at,
|
|
120
|
+
heartbeatAt: row.heartbeat_at,
|
|
121
|
+
state: row.state,
|
|
122
|
+
}
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
144
126
|
export class KxmRuntimeRegistry {
|
|
145
127
|
readonly path: string;
|
|
146
128
|
private readonly database: DatabaseSync;
|
|
@@ -228,20 +210,7 @@ export class KxmRuntimeRegistry {
|
|
|
228
210
|
}
|
|
229
211
|
|
|
230
212
|
private readSupervisorRow(): KxmSupervisorRecord | undefined {
|
|
231
|
-
|
|
232
|
-
| { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
|
|
233
|
-
| undefined;
|
|
234
|
-
return row
|
|
235
|
-
? {
|
|
236
|
-
runtimeId: row.runtime_id,
|
|
237
|
-
pid: row.pid,
|
|
238
|
-
port: row.port,
|
|
239
|
-
tokenHash: row.token_hash,
|
|
240
|
-
startedAt: row.started_at,
|
|
241
|
-
heartbeatAt: row.heartbeat_at,
|
|
242
|
-
state: row.state,
|
|
243
|
-
}
|
|
244
|
-
: undefined;
|
|
213
|
+
return readKxmSupervisorRecord(this.database);
|
|
245
214
|
}
|
|
246
215
|
|
|
247
216
|
supervisor(): KxmSupervisorRecord | undefined {
|
|
@@ -8,12 +8,15 @@ import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project
|
|
|
8
8
|
import {
|
|
9
9
|
KxmRuntimeRegistry,
|
|
10
10
|
projectRuntimeKey,
|
|
11
|
+
readKxmSupervisorRecord,
|
|
11
12
|
runtimeError,
|
|
12
13
|
verifyKxmDriveReceipt,
|
|
13
14
|
kxmRuntimePaths,
|
|
14
15
|
type KxmRunEventStore,
|
|
15
16
|
type KxmRuntimePaths,
|
|
17
|
+
type KxmSupervisorRecord,
|
|
16
18
|
} from "./runtime-store.ts";
|
|
19
|
+
import { openReadOnlyDatabase } from "./sqlite.ts";
|
|
17
20
|
import {
|
|
18
21
|
acceptKxmRun,
|
|
19
22
|
cancelKxmRun,
|
|
@@ -122,26 +125,44 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
|
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
|
|
129
|
+
if (!record) return { running: false };
|
|
130
|
+
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
131
|
+
// an unrelated process. A stale heartbeat is authoritative.
|
|
132
|
+
const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
|
|
133
|
+
const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
|
|
134
|
+
const alive = record.state === "running" && fresh && processAlive(record.pid);
|
|
135
|
+
return {
|
|
136
|
+
running: alive,
|
|
137
|
+
runtimeId: record.runtimeId,
|
|
138
|
+
pid: record.pid,
|
|
139
|
+
port: record.port,
|
|
140
|
+
state: alive ? record.state : "dead",
|
|
141
|
+
heartbeatAt: record.heartbeatAt,
|
|
142
|
+
startedAt: record.startedAt,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Is the supervisor up? `readOnly` gives the same answer without opening the
|
|
148
|
+
* registry for writing: the ordinary open takes the write lock and, as the last
|
|
149
|
+
* connection, checkpoints the WAL on close. `kxm restore` asks this way, dry run
|
|
150
|
+
* or not, because a project-scoped restore never otherwise touches the registry.
|
|
151
|
+
*/
|
|
152
|
+
export function kxmSupervisorStatus(paths: KxmRuntimePaths, options: { readOnly?: boolean } = {}): KxmSupervisorStatus {
|
|
126
153
|
if (!existsSync(paths.registryDb)) return { running: false };
|
|
154
|
+
if (options.readOnly) {
|
|
155
|
+
const database = openReadOnlyDatabase(paths.registryDb);
|
|
156
|
+
try {
|
|
157
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
158
|
+
return supervisorStatusOf(readKxmSupervisorRecord(database));
|
|
159
|
+
} finally {
|
|
160
|
+
database.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
127
163
|
const registry = new KxmRuntimeRegistry(paths.registryDb);
|
|
128
164
|
try {
|
|
129
|
-
|
|
130
|
-
if (!record) return { running: false };
|
|
131
|
-
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
132
|
-
// an unrelated process. A stale heartbeat is authoritative.
|
|
133
|
-
const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
|
|
134
|
-
const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
|
|
135
|
-
const alive = record.state === "running" && fresh && processAlive(record.pid);
|
|
136
|
-
return {
|
|
137
|
-
running: alive,
|
|
138
|
-
runtimeId: record.runtimeId,
|
|
139
|
-
pid: record.pid,
|
|
140
|
-
port: record.port,
|
|
141
|
-
state: alive ? record.state : "dead",
|
|
142
|
-
heartbeatAt: record.heartbeatAt,
|
|
143
|
-
startedAt: record.startedAt,
|
|
144
|
-
};
|
|
165
|
+
return supervisorStatusOf(registry.supervisor());
|
|
145
166
|
} finally {
|
|
146
167
|
registry.close();
|
|
147
168
|
}
|
|
@@ -1,156 +1,209 @@
|
|
|
1
|
+
import { BUILTIN_HARNESS_IDS, oneShotWriterArgs, type HarnessStatus } from "./harness.ts";
|
|
2
|
+
import { WORKFLOW_TEMPLATES } from "./workflow-manager.ts";
|
|
3
|
+
|
|
1
4
|
export interface RoleSpec {
|
|
5
|
+
agent: string;
|
|
2
6
|
harness: string;
|
|
3
|
-
model: string;
|
|
4
7
|
role: string;
|
|
5
8
|
}
|
|
6
9
|
|
|
10
|
+
export type SuggestionExecution = {
|
|
11
|
+
supported: true;
|
|
12
|
+
prerequisites: string[];
|
|
13
|
+
shell: "powershell" | "posix";
|
|
14
|
+
createCommand: string;
|
|
15
|
+
driveCommand: string;
|
|
16
|
+
statusCommand: string;
|
|
17
|
+
receiptCommand: string;
|
|
18
|
+
} | {
|
|
19
|
+
supported: false;
|
|
20
|
+
error: "harness_unavailable" | "live_write_unsupported" | "workflow_already_exists";
|
|
21
|
+
reason: string;
|
|
22
|
+
nextSteps: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
7
25
|
export interface SuggestionResult {
|
|
8
26
|
workflowId: string;
|
|
27
|
+
template: string;
|
|
9
28
|
area: string;
|
|
10
29
|
confidence: number;
|
|
11
30
|
reasons: string[];
|
|
12
31
|
suggestedSkills: string[];
|
|
13
|
-
roles:
|
|
14
|
-
|
|
15
|
-
writer: RoleSpec;
|
|
16
|
-
critics: RoleSpec[];
|
|
17
|
-
verifier: { kind: "witness"; command: string };
|
|
18
|
-
};
|
|
32
|
+
roles: RoleSpec[];
|
|
33
|
+
/** Installs a definition; it does not create or execute a run. */
|
|
19
34
|
suggestedCommand: string;
|
|
35
|
+
execution: SuggestionExecution;
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
interface WorkflowPattern {
|
|
23
39
|
id: string;
|
|
40
|
+
template: string;
|
|
24
41
|
area: string;
|
|
25
42
|
keywords: string[];
|
|
26
43
|
skills: string[];
|
|
27
|
-
defaultCommand: (prompt: string) => string;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
const WORKFLOW_PATTERNS: WorkflowPattern[] = [
|
|
31
47
|
{
|
|
32
|
-
id: "
|
|
48
|
+
id: "bug-fix",
|
|
49
|
+
template: "implement-and-verify",
|
|
33
50
|
area: "software-engineering",
|
|
34
51
|
keywords: ["fix", "bug", "flaky", "failure", "timeout", "error", "repro", "crash", "broken", "hang"],
|
|
35
52
|
skills: ["kxm-workflow", "kxm-runs", "kxm-context-memory"],
|
|
36
|
-
defaultCommand: (p) => `kxm run software-engineering/bug-fix "${p}"`,
|
|
37
53
|
},
|
|
38
54
|
{
|
|
39
|
-
id: "
|
|
55
|
+
id: "feature-implementation",
|
|
56
|
+
template: "implement-and-verify",
|
|
40
57
|
area: "software-engineering",
|
|
41
58
|
keywords: ["feature", "implement", "add", "build", "create", "develop", "support", "endpoint", "ui", "tui"],
|
|
42
59
|
skills: ["kxm-workflow", "kxm-peer", "kxm-context-memory"],
|
|
43
|
-
defaultCommand: (p) => `kxm run software-engineering/feature-implementation "${p}"`,
|
|
44
60
|
},
|
|
45
61
|
{
|
|
46
|
-
id: "
|
|
62
|
+
id: "refactoring",
|
|
63
|
+
template: "dual-critic-review",
|
|
47
64
|
area: "software-engineering",
|
|
48
65
|
keywords: ["refactor", "cleanup", "reorganize", "modularize", "deduplicate", "split", "simplify", "deprecate"],
|
|
49
66
|
skills: ["kxm-workflow", "kxm-runs"],
|
|
50
|
-
defaultCommand: (p) => `kxm run software-engineering/refactoring "${p}"`,
|
|
51
67
|
},
|
|
52
68
|
{
|
|
53
|
-
id: "
|
|
69
|
+
id: "vulnerability-remediation",
|
|
70
|
+
template: "dual-critic-review",
|
|
54
71
|
area: "security-reliability",
|
|
55
72
|
keywords: ["cve", "vulnerability", "security", "exploit", "sanitize", "leak", "secret", "injection", "redact", "auth"],
|
|
56
73
|
skills: ["kxm-workflow", "kxm-definitions"],
|
|
57
|
-
defaultCommand: (p) => `kxm run security-reliability/vulnerability-remediation "${p}"`,
|
|
58
74
|
},
|
|
59
75
|
{
|
|
60
|
-
id: "
|
|
76
|
+
id: "reliability-hardening",
|
|
77
|
+
template: "dual-critic-review",
|
|
61
78
|
area: "security-reliability",
|
|
62
79
|
keywords: ["idempotency", "retry", "circuit-breaker", "cas", "lock", "concurrency", "deadlock", "race", "crash-recovery"],
|
|
63
80
|
skills: ["kxm-workflow", "kxm-peer"],
|
|
64
|
-
defaultCommand: (p) => `kxm run security-reliability/reliability-hardening "${p}"`,
|
|
65
81
|
},
|
|
66
82
|
{
|
|
67
|
-
id: "
|
|
83
|
+
id: "pipeline-migration",
|
|
84
|
+
template: "implement-and-verify",
|
|
68
85
|
area: "data-analytics",
|
|
69
86
|
keywords: ["database", "sqlite", "migration", "pipeline", "schema", "transform", "table", "wal", "foreign", "cascading"],
|
|
70
87
|
skills: ["kxm-runs", "kxm-context-memory"],
|
|
71
|
-
defaultCommand: (p) => `kxm run data-analytics/pipeline-migration "${p}"`,
|
|
72
88
|
},
|
|
73
89
|
{
|
|
74
|
-
id: "
|
|
90
|
+
id: "architecture-spike",
|
|
91
|
+
template: "spec-and-plan",
|
|
75
92
|
area: "research-strategy",
|
|
76
93
|
keywords: ["spike", "investigate", "prototype", "research", "feasibility", "benchmark", "explore", "evaluate"],
|
|
77
94
|
skills: ["kxm-session", "kxm-context-memory", "kxm-routing-improve"],
|
|
78
|
-
defaultCommand: (p) => `kxm run research-strategy/architecture-spike "${p}"`,
|
|
79
95
|
},
|
|
80
96
|
];
|
|
81
97
|
|
|
98
|
+
type TemplateStep = { id: string; kind: string; agent?: string; repositories?: Record<string, string> };
|
|
99
|
+
type SuggestionHarness = Pick<HarnessStatus, "id" | "detected" | "authenticated" | "dispatch">;
|
|
100
|
+
|
|
82
101
|
export function suggestWorkflowAndRoles(
|
|
83
102
|
prompt: string,
|
|
84
|
-
options: {
|
|
85
|
-
availableHarnesses?: Array<{ harness: string; auth?: string | undefined }> | undefined;
|
|
86
|
-
} = {},
|
|
103
|
+
options: { availableHarnesses?: readonly SuggestionHarness[] | undefined } = {},
|
|
87
104
|
): SuggestionResult {
|
|
88
105
|
const normalized = prompt.toLowerCase();
|
|
89
106
|
const words = normalized.split(/\W+/).filter(Boolean);
|
|
90
|
-
|
|
91
|
-
let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[
|
|
92
|
-
let bestScore =
|
|
93
|
-
const
|
|
107
|
+
const claudeOnly = /\bclaude(?:\s+code)?(?:[\s-]+only|\s+exclusively)\b|\b(?:only|exclusively)(?:\s+(?:use|using|with|via))?\s+claude(?:\s+code)?\b/i.test(prompt);
|
|
108
|
+
let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[1]!;
|
|
109
|
+
let bestScore = 0;
|
|
110
|
+
const reasons: string[] = [];
|
|
94
111
|
|
|
95
112
|
for (const pattern of WORKFLOW_PATTERNS) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (words.includes(keyword) || normalized.includes(keyword)) {
|
|
100
|
-
score += 1;
|
|
101
|
-
matchedWords.push(keyword);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (score > bestScore) {
|
|
105
|
-
bestScore = score;
|
|
113
|
+
const matchedWords = pattern.keywords.filter((keyword) => words.includes(keyword) || normalized.includes(keyword));
|
|
114
|
+
if (matchedWords.length > bestScore) {
|
|
115
|
+
bestScore = matchedWords.length;
|
|
106
116
|
bestPattern = pattern;
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
reasons.length = 0;
|
|
118
|
+
reasons.push(`Matched keywords: ${matchedWords.join(", ")}`);
|
|
109
119
|
}
|
|
110
120
|
}
|
|
121
|
+
if (bestScore === 0) reasons.push("Default fallback: general feature implementation workflow");
|
|
122
|
+
if (claudeOnly) reasons.push("Explicit Claude-only constraint; other harnesses are excluded");
|
|
111
123
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const activeHarnesses = new Set(
|
|
118
|
-
(options.availableHarnesses ?? [])
|
|
119
|
-
.filter((h) => h.auth === "active" || h.auth === "ready" || h.auth === "configured")
|
|
120
|
-
.map((h) => h.harness.toLowerCase()),
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const hasClaude = activeHarnesses.size === 0 || activeHarnesses.has("claude");
|
|
124
|
-
const hasGrok = activeHarnesses.size === 0 || activeHarnesses.has("grok");
|
|
125
|
-
const hasCodex = activeHarnesses.size === 0 || activeHarnesses.has("codex");
|
|
126
|
-
|
|
127
|
-
const planner: RoleSpec = hasClaude
|
|
128
|
-
? { harness: "claude", model: "fable", role: "planner" }
|
|
129
|
-
: { harness: "codex", model: "gpt-5.6-sol", role: "planner" };
|
|
130
|
-
|
|
131
|
-
const writer: RoleSpec = hasGrok
|
|
132
|
-
? { harness: "grok", model: "grok-4.6", role: "writer" }
|
|
133
|
-
: (activeHarnesses.has("agy")
|
|
134
|
-
? { harness: "agy", model: "gemini-2.5-pro", role: "writer" }
|
|
135
|
-
: { harness: "pi", model: "qwen/qwen3-coder-plus", role: "writer" });
|
|
136
|
-
|
|
137
|
-
const critics: RoleSpec[] = [
|
|
138
|
-
{ harness: "claude", model: "fable", role: "reviewer-arch" },
|
|
139
|
-
{ harness: "codex", model: "gpt-5.6-sol", role: "reviewer-cli" },
|
|
140
|
-
];
|
|
141
|
-
|
|
142
|
-
return {
|
|
124
|
+
const template = WORKFLOW_TEMPLATES[bestPattern.template]!;
|
|
125
|
+
const steps = template.steps as TemplateStep[];
|
|
126
|
+
const base = {
|
|
143
127
|
workflowId: bestPattern.id,
|
|
128
|
+
template: bestPattern.template,
|
|
144
129
|
area: bestPattern.area,
|
|
145
130
|
confidence: bestScore > 0 ? Math.min(1, 0.5 + bestScore * 0.15) : 0.5,
|
|
146
|
-
reasons
|
|
131
|
+
reasons,
|
|
147
132
|
suggestedSkills: bestPattern.skills,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
133
|
+
suggestedCommand: `kxm workflow add ${bestPattern.id} --template ${bestPattern.template}`,
|
|
134
|
+
};
|
|
135
|
+
const writeStep = steps.find((step) => step.kind === "agent" && Object.values(step.repositories ?? {}).includes("write"));
|
|
136
|
+
|
|
137
|
+
const available = (options.availableHarnesses ?? []).filter((entry) =>
|
|
138
|
+
BUILTIN_HARNESS_IDS.includes(entry.id) && entry.detected && entry.authenticated === true
|
|
139
|
+
&& entry.dispatch?.supported === true && entry.dispatch.status === "yes"
|
|
140
|
+
&& (!claudeOnly || entry.id === "claude"));
|
|
141
|
+
const selected = available.find((entry) => entry.id === "claude") ?? available[0];
|
|
142
|
+
if (!selected) {
|
|
143
|
+
const relevant = (options.availableHarnesses ?? []).filter((entry) => !claudeOnly || entry.id === "claude");
|
|
144
|
+
const detail = relevant.map((entry) => `${entry.id}: ${entry.dispatch?.reason ?? (!entry.detected ? "not_detected" : entry.authenticated !== true ? "authentication_unverified" : "dispatch_unavailable")}`).join("; ");
|
|
145
|
+
return {
|
|
146
|
+
...base,
|
|
147
|
+
roles: [],
|
|
148
|
+
execution: {
|
|
149
|
+
supported: false,
|
|
150
|
+
error: "harness_unavailable",
|
|
151
|
+
reason: `${claudeOnly ? "Claude-only execution requires Claude Code" : "Execution requires a supported harness"} detected, authenticated, and ready for dispatch. ${detail || "No verified harness inventory is available."}`,
|
|
152
|
+
nextSteps: claudeOnly
|
|
153
|
+
? ["Install Claude Code if missing, authenticate with claude auth login, then confirm claude auth status and kxm harness list. Other harnesses will not be substituted."]
|
|
154
|
+
: ["Install and authenticate a supported harness, then run kxm harness list to check detection, authentication, and dispatch readiness. Pi requires a configured model/provider auth context."],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (writeStep && !oneShotWriterArgs(selected.id)) {
|
|
160
|
+
return {
|
|
161
|
+
...base,
|
|
162
|
+
roles: [],
|
|
163
|
+
execution: {
|
|
164
|
+
supported: false,
|
|
165
|
+
error: "live_write_unsupported",
|
|
166
|
+
reason: `${bestPattern.template} requires repository writes at step ${writeStep.id}, but the selected ${selected.id} harness has no audited live writer profile. No other harness will be substituted.`,
|
|
167
|
+
nextSteps: [
|
|
168
|
+
`Run the implementation directly in ${claudeOnly ? "Claude Code" : selected.id} in the target worktree, and run the repository's actual verification command.`,
|
|
169
|
+
`For read-only KXM planning, try kxm suggest "Investigate an architecture spike${claudeOnly ? " using Claude only" : ""}"; the shipped spec-and-plan template does not implement changes.`,
|
|
170
|
+
"Installing a definition or creating a run does not execute it. Simulation is not evidence of a bug fix or workflow completion.",
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const roles: RoleSpec[] = [];
|
|
177
|
+
for (const step of steps) {
|
|
178
|
+
if (!step.agent) continue;
|
|
179
|
+
const role = roles.find((entry) => entry.agent === step.agent);
|
|
180
|
+
if (role) role.role += `, ${step.id}`;
|
|
181
|
+
else roles.push({ agent: step.agent, harness: selected.id, role: step.id });
|
|
182
|
+
}
|
|
183
|
+
const shell = process.platform === "win32" ? "powershell" : "posix";
|
|
184
|
+
const quotedPrompt = shell === "powershell"
|
|
185
|
+
? `'${prompt.replace(/['\u2018-\u201b]/g, "$&$&")}'`
|
|
186
|
+
: `'${prompt.replace(/'/g, "'\\''")}'`;
|
|
187
|
+
return {
|
|
188
|
+
...base,
|
|
189
|
+
roles,
|
|
190
|
+
execution: {
|
|
191
|
+
supported: true,
|
|
192
|
+
prerequisites: [
|
|
193
|
+
"Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
|
|
194
|
+
...roles.map((role) => `Set harness: ${role.harness} and model: <your authenticated compatible model selector> in .kxm/agents/${role.agent}.yaml. Admit that exact selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
|
|
195
|
+
...(writeStep ? [
|
|
196
|
+
`Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
|
|
197
|
+
`If .kxm/roster.yaml exists, it must be readable kxm.developer-roster.v1 with a lineup.writer route matching harness: ${selected.id} and the selected writer model (model or vendor/model), status: admitted, and permissions containing edit.`,
|
|
198
|
+
"Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
|
|
199
|
+
] : []),
|
|
200
|
+
`Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
|
|
201
|
+
],
|
|
202
|
+
shell,
|
|
203
|
+
createCommand: `kxm run ${bestPattern.id} -- ${quotedPrompt}`,
|
|
204
|
+
driveCommand: "kxm runs drive <runId> --wait",
|
|
205
|
+
statusCommand: "kxm runs status <runId>",
|
|
206
|
+
receiptCommand: "kxm runs receipt <runId>",
|
|
153
207
|
},
|
|
154
|
-
suggestedCommand: bestPattern.defaultCommand(prompt.replace(/"/g, '\\"')),
|
|
155
208
|
};
|
|
156
209
|
}
|
|
@@ -5,9 +5,28 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { stringify } from "yaml";
|
|
10
10
|
import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
|
|
11
|
+
import { compileKxmWorkflow } from "./engine-compile.ts";
|
|
12
|
+
import { KxmConfigError, KxmSchemaRegistry, kxmResourceIdentifier, parseRestrictedYaml } from "./project-config.ts";
|
|
13
|
+
|
|
14
|
+
function assertWorkflowId(workflowId: string): void {
|
|
15
|
+
if (!kxmResourceIdentifier(workflowId)) {
|
|
16
|
+
throw new Error(`workflow_id_invalid: '${workflowId}' must be a flat lowercase slug of at most 64 characters, starting with a letter and using letters, digits, single hyphens or underscores; paths and platform-reserved names are not allowed (use 'bug-fix', not 'software-engineering/bug-fix')`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let workflowSchemaRegistry: KxmSchemaRegistry | undefined;
|
|
21
|
+
|
|
22
|
+
function workflowPayload(workflowId: string, content: Record<string, unknown> | string, filePath: string): string {
|
|
23
|
+
const payload = typeof content === "string" ? content : stringify(content);
|
|
24
|
+
const value = parseRestrictedYaml(payload, filePath);
|
|
25
|
+
const issues = (workflowSchemaRegistry ??= new KxmSchemaRegistry()).validate("workflow", value, filePath);
|
|
26
|
+
if (issues.length > 0) throw new KxmConfigError(issues);
|
|
27
|
+
compileKxmWorkflow({ id: workflowId, value, logicalPath: filePath });
|
|
28
|
+
return payload;
|
|
29
|
+
}
|
|
11
30
|
|
|
12
31
|
export interface WorkflowDefSummary {
|
|
13
32
|
id: string;
|
|
@@ -197,9 +216,7 @@ export function parseWorkflowFile(filePath: string): Record<string, unknown> | u
|
|
|
197
216
|
if (!existsSync(filePath)) return undefined;
|
|
198
217
|
try {
|
|
199
218
|
const raw = readFileSync(filePath, "utf8");
|
|
200
|
-
|
|
201
|
-
if (!parsed || typeof parsed !== "object") return undefined;
|
|
202
|
-
return parsed;
|
|
219
|
+
return parseRestrictedYaml(raw, filePath);
|
|
203
220
|
} catch {
|
|
204
221
|
return undefined;
|
|
205
222
|
}
|
|
@@ -218,11 +235,11 @@ export function listWorkflowDefinitions(options: {
|
|
|
218
235
|
const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
|
|
219
236
|
if (scopeFilter !== "global" && existsSync(localDir)) {
|
|
220
237
|
for (const entry of readdirSync(localDir)) {
|
|
221
|
-
|
|
238
|
+
const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
|
|
239
|
+
if (id && kxmResourceIdentifier(id)) {
|
|
222
240
|
const filePath = join(localDir, entry);
|
|
223
241
|
const def = parseWorkflowFile(filePath);
|
|
224
242
|
if (def) {
|
|
225
|
-
const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
|
|
226
243
|
localDefs.set(id, { def, filePath });
|
|
227
244
|
}
|
|
228
245
|
}
|
|
@@ -232,11 +249,11 @@ export function listWorkflowDefinitions(options: {
|
|
|
232
249
|
const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
|
|
233
250
|
if (scopeFilter !== "local" && existsSync(globalDir)) {
|
|
234
251
|
for (const entry of readdirSync(globalDir)) {
|
|
235
|
-
|
|
252
|
+
const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
|
|
253
|
+
if (id && kxmResourceIdentifier(id)) {
|
|
236
254
|
const filePath = join(globalDir, entry);
|
|
237
255
|
const def = parseWorkflowFile(filePath);
|
|
238
256
|
if (def) {
|
|
239
|
-
const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
|
|
240
257
|
globalDefs.set(id, { def, filePath });
|
|
241
258
|
}
|
|
242
259
|
}
|
|
@@ -248,7 +265,7 @@ export function listWorkflowDefinitions(options: {
|
|
|
248
265
|
const steps = Array.isArray(def.steps) ? def.steps : [];
|
|
249
266
|
for (const step of steps) {
|
|
250
267
|
if (step && typeof step === "object") {
|
|
251
|
-
const role = (step as Record<string, unknown>).
|
|
268
|
+
const role = (step as Record<string, unknown>).agent;
|
|
252
269
|
if (typeof role === "string" && !roles.includes(role)) {
|
|
253
270
|
roles.push(role);
|
|
254
271
|
}
|
|
@@ -298,6 +315,7 @@ export function getWorkflowDefinition(
|
|
|
298
315
|
userConfigDir?: string | undefined;
|
|
299
316
|
} = {},
|
|
300
317
|
): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined {
|
|
318
|
+
assertWorkflowId(workflowId);
|
|
301
319
|
const scope = options.scope ?? "all";
|
|
302
320
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
303
321
|
|
|
@@ -331,19 +349,21 @@ export function addWorkflowDefinition(
|
|
|
331
349
|
dryRun?: boolean | undefined;
|
|
332
350
|
} = {},
|
|
333
351
|
): { id: string; filePath: string; scope: "global" | "local" } {
|
|
352
|
+
assertWorkflowId(workflowId);
|
|
334
353
|
const scope = options.scope ?? "local";
|
|
335
354
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
336
|
-
const dir = options.
|
|
337
|
-
? workflowsDirectory(scope, repoRoot, options.userConfigDir)
|
|
338
|
-
: ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
355
|
+
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
339
356
|
const filePath = join(dir, `${workflowId}.yaml`);
|
|
340
357
|
|
|
341
358
|
if (existsSync(filePath) && !options.overwrite) {
|
|
342
359
|
throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`);
|
|
343
360
|
}
|
|
344
361
|
|
|
345
|
-
const payload =
|
|
346
|
-
if (!options.dryRun)
|
|
362
|
+
const payload = workflowPayload(workflowId, content, filePath);
|
|
363
|
+
if (!options.dryRun) {
|
|
364
|
+
ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
365
|
+
writeFileSync(filePath, payload, "utf8");
|
|
366
|
+
}
|
|
347
367
|
return { id: workflowId, filePath, scope };
|
|
348
368
|
}
|
|
349
369
|
|
|
@@ -356,6 +376,7 @@ export function removeWorkflowDefinition(
|
|
|
356
376
|
dryRun?: boolean | undefined;
|
|
357
377
|
} = {},
|
|
358
378
|
): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
|
|
379
|
+
assertWorkflowId(workflowId);
|
|
359
380
|
const scope = options.scope ?? "local";
|
|
360
381
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
361
382
|
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
@@ -392,11 +413,13 @@ export function modifyWorkflowDefinition(
|
|
|
392
413
|
|
|
393
414
|
const scope = options.scope ?? target.scope;
|
|
394
415
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
395
|
-
const dir = options.
|
|
396
|
-
? workflowsDirectory(scope, repoRoot, options.userConfigDir)
|
|
397
|
-
: ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
416
|
+
const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
398
417
|
const filePath = join(dir, `${workflowId}.yaml`);
|
|
399
418
|
|
|
400
|
-
|
|
419
|
+
const payload = workflowPayload(workflowId, updated, filePath);
|
|
420
|
+
if (!options.dryRun) {
|
|
421
|
+
ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
|
|
422
|
+
writeFileSync(filePath, payload, "utf8");
|
|
423
|
+
}
|
|
401
424
|
return { id: workflowId, workflow: updated, filePath, scope };
|
|
402
425
|
}
|
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
"type": "string",
|
|
24
24
|
"maxLength": 1024
|
|
25
25
|
},
|
|
26
|
+
"scope": {
|
|
27
|
+
"description": "project: the checkout's stores and its own Runtime event store. all-projects: also the shared Runtime registry and every project's event store. Absent on manifests written before backups were scoped.",
|
|
28
|
+
"enum": ["project", "all-projects"]
|
|
29
|
+
},
|
|
30
|
+
"stateRoot": {
|
|
31
|
+
"description": "The user state root the Runtime stores were copied from.",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"minLength": 1,
|
|
34
|
+
"maxLength": 1024
|
|
35
|
+
},
|
|
36
|
+
"runtimeProjectKey": {
|
|
37
|
+
"description": "The Runtime store key of the checkout the backup ran from.",
|
|
38
|
+
"type": "string",
|
|
39
|
+
"pattern": "^[0-9a-f]{24}$"
|
|
40
|
+
},
|
|
26
41
|
"stores": {
|
|
27
42
|
"type": "array",
|
|
28
43
|
"items": {
|