@kontextmind/kxm 0.7.86 → 0.7.88
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 +897 -500
- package/plugins/kxm/dist/core.js +2 -2
- package/plugins/kxm/dist/extension.js +72 -63
- package/plugins/kxm/dist/mcp-server.js +3 -3
- package/plugins/kxm/dist/runtime.js +44 -26
- package/plugins/kxm/dist/server.js +56 -31
- 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/commands.ts +2 -2
- package/plugins/kxm/src/config.ts +4 -3
- package/plugins/kxm/src/context/providers.ts +4 -0
- package/plugins/kxm/src/database.ts +72 -33
- package/plugins/kxm/src/hub.ts +26 -8
- 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/state.ts +4 -1
- package/plugins/kxm/src/task-manager.ts +20 -13
- package/plugins/kxm/src/workflow-manager.ts +12 -4
|
@@ -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
|
/**
|
|
@@ -44,6 +44,10 @@ export interface StateChangeProposal {
|
|
|
44
44
|
confidence: ContextItem["confidence"];
|
|
45
45
|
evidenceRefs: string[];
|
|
46
46
|
proposedBy: string;
|
|
47
|
+
/** Origin of the credential the hub verified for the proposer: an agent
|
|
48
|
+
* key is `peer` (evidence at most), the administrative token `human`.
|
|
49
|
+
* Never inferred from `proposedBy`, which is a label, not a credential. */
|
|
50
|
+
origin: "peer" | "human";
|
|
47
51
|
supersedes?: string[];
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -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
|
}
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -601,9 +601,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
601
601
|
|
|
602
602
|
/** Context callers authenticate either as a registered agent (project-
|
|
603
603
|
* scoped to their own project) or with the administrative token (single
|
|
604
|
-
* explicit project scope per request). Returns the validated project
|
|
605
|
-
*
|
|
606
|
-
function contextCallerProject(
|
|
604
|
+
* explicit project scope per request). Returns the validated project, a
|
|
605
|
+
* stable caller identity for provenance, and which credential proved it. */
|
|
606
|
+
function contextCallerProject(
|
|
607
|
+
request: IncomingMessage,
|
|
608
|
+
requested: unknown,
|
|
609
|
+
): { project: string; caller: string; credential: "agent" | "admin" } {
|
|
607
610
|
const project = requireString(requested, "project", { max: 200 });
|
|
608
611
|
const agentHeader = request.headers["x-kxm-agent-id"];
|
|
609
612
|
if (typeof agentHeader === "string" && agentHeader.trim()) {
|
|
@@ -612,14 +615,14 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
612
615
|
if (agent.project !== project) {
|
|
613
616
|
throw new ProtocolError(403, "context requests are limited to the agent's project", "context_isolation_violation");
|
|
614
617
|
}
|
|
615
|
-
return { project, caller: agent.id };
|
|
618
|
+
return { project, caller: agent.id, credential: "agent" };
|
|
616
619
|
}
|
|
617
620
|
requireAdminAuth(request);
|
|
618
621
|
const callerHeader = request.headers["x-kxm-caller-id"];
|
|
619
622
|
const caller = typeof callerHeader === "string" && callerHeader.trim()
|
|
620
623
|
? callerHeader.trim()
|
|
621
624
|
: "kxm-admin";
|
|
622
|
-
return { project, caller };
|
|
625
|
+
return { project, caller, credential: "admin" };
|
|
623
626
|
}
|
|
624
627
|
|
|
625
628
|
function requireAdminAuth(request: IncomingMessage): void {
|
|
@@ -1829,7 +1832,21 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1829
1832
|
const contextStateProposeMatch = url.pathname.match(/^\/v1\/context\/state\/propose$/);
|
|
1830
1833
|
if (method === "POST" && contextStateProposeMatch) {
|
|
1831
1834
|
const body = await readJson(request);
|
|
1832
|
-
const { project: callerProject, caller: callerId } = contextCallerProject(request, body.project);
|
|
1835
|
+
const { project: callerProject, caller: callerId, credential } = contextCallerProject(request, body.project);
|
|
1836
|
+
// Origin follows the credential, never a name: an agent key proposes as
|
|
1837
|
+
// a peer, capped at evidence by the grant floor. Human origin can claim
|
|
1838
|
+
// policy, so it takes the configured administrative token with no
|
|
1839
|
+
// loopback bypass, as promotion does.
|
|
1840
|
+
if (credential === "admin") requireConfiguredAdminAuth(request, "human-origin state proposals");
|
|
1841
|
+
if (body.proposedBy !== undefined && body.proposedBy !== callerId) {
|
|
1842
|
+
logger({ event: "security_alert", alert: "state_proposer_mismatch", project: callerProject, callerId, credential });
|
|
1843
|
+
throw new ProtocolError(
|
|
1844
|
+
403,
|
|
1845
|
+
"proposedBy must name the authenticated caller",
|
|
1846
|
+
"state_proposer_mismatch",
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
const origin = credential === "agent" ? "peer" : "human";
|
|
1833
1850
|
const proposalId = await stateProvider.propose({
|
|
1834
1851
|
schema: "kxm.state-change-proposal.v1",
|
|
1835
1852
|
project: callerProject,
|
|
@@ -1838,11 +1855,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1838
1855
|
authority: parseContextAuthority(body.authority),
|
|
1839
1856
|
confidence: parseContextConfidence(body.confidence),
|
|
1840
1857
|
evidenceRefs: boundedStringList(body.evidenceRefs, "evidenceRefs", 32),
|
|
1841
|
-
proposedBy:
|
|
1858
|
+
proposedBy: callerId,
|
|
1859
|
+
origin,
|
|
1842
1860
|
});
|
|
1843
1861
|
counters.contextRequests += 1;
|
|
1844
1862
|
publishOps(callerProject, "workflows");
|
|
1845
|
-
logger({ event: "context_state_proposed", project: callerProject, proposalId, proposedBy: callerId });
|
|
1863
|
+
logger({ event: "context_state_proposed", project: callerProject, proposalId, proposedBy: callerId, origin });
|
|
1846
1864
|
json(response, 201, { proposalId });
|
|
1847
1865
|
return;
|
|
1848
1866
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { DatabaseSync } from "./sqlite.ts";
|
|
4
|
+
import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
|
|
5
5
|
import type { AgentIdentity, AgentRecord, MessageRecord } from "./protocol.ts";
|
|
6
6
|
import type { WorkflowRun } from "./workflow.ts";
|
|
7
7
|
import { readRoutingRecords } from "./telemetry.ts";
|
|
@@ -250,7 +250,7 @@ export function loadLocalMeshSnapshot(
|
|
|
250
250
|
let plans: MeshTuiPlan[] = [];
|
|
251
251
|
if (existsSync(dataPath)) {
|
|
252
252
|
hasLegacy = true;
|
|
253
|
-
const database =
|
|
253
|
+
const database = openReadOnlyDatabase(dataPath);
|
|
254
254
|
try {
|
|
255
255
|
database.exec("PRAGMA busy_timeout = 5000");
|
|
256
256
|
// Stored rows carry identity only. Presence is derived here against the
|
|
@@ -287,7 +287,7 @@ export function loadLocalMeshSnapshot(
|
|
|
287
287
|
if (existsSync(registryDbPath)) {
|
|
288
288
|
hasKxm = true;
|
|
289
289
|
try {
|
|
290
|
-
const regDb =
|
|
290
|
+
const regDb = openReadOnlyDatabase(registryDbPath);
|
|
291
291
|
try {
|
|
292
292
|
regDb.exec("PRAGMA busy_timeout = 5000");
|
|
293
293
|
const pRows = regDb.prepare("SELECT project_key FROM projects").all() as Array<{ project_key: string }>;
|
|
@@ -315,7 +315,7 @@ export function loadLocalMeshSnapshot(
|
|
|
315
315
|
if (existsSync(eventDbPath)) {
|
|
316
316
|
hasKxm = true;
|
|
317
317
|
try {
|
|
318
|
-
const eventDb =
|
|
318
|
+
const eventDb = openReadOnlyDatabase(eventDbPath);
|
|
319
319
|
try {
|
|
320
320
|
eventDb.exec("PRAGMA busy_timeout = 5000");
|
|
321
321
|
const runRows = eventDb.prepare(`
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.88";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -245,10 +245,10 @@ export function createMemoryNote(
|
|
|
245
245
|
body?: string | undefined;
|
|
246
246
|
sourceRef?: string | undefined;
|
|
247
247
|
runId?: string | undefined;
|
|
248
|
+
dryRun?: boolean | undefined;
|
|
248
249
|
} = {},
|
|
249
250
|
): { record: MemoryRecord; path: string } {
|
|
250
251
|
const { candidatesDir } = memoryDirectories(repoRoot);
|
|
251
|
-
mkdirSync(candidatesDir, { recursive: true });
|
|
252
252
|
|
|
253
253
|
const scope: MemoryScope = options.scope ?? "project";
|
|
254
254
|
if (!VALID_SCOPES.has(scope)) {
|
|
@@ -277,6 +277,8 @@ export function createMemoryNote(
|
|
|
277
277
|
};
|
|
278
278
|
|
|
279
279
|
const targetPath = join(candidatesDir, `${id}.md`);
|
|
280
|
+
if (options.dryRun) return { record, path: targetPath };
|
|
281
|
+
mkdirSync(candidatesDir, { recursive: true });
|
|
280
282
|
writeFileSync(targetPath, formatMemoryRecord(record), "utf8");
|
|
281
283
|
return { record, path: targetPath };
|
|
282
284
|
}
|
|
@@ -316,7 +318,8 @@ export function formatHarnessMemoryBlock(records: MemoryRecord[]): string {
|
|
|
316
318
|
return contentLines.join("\n");
|
|
317
319
|
}
|
|
318
320
|
|
|
319
|
-
|
|
321
|
+
/** Returns whether the projection block changed the file; `dryRun` answers without writing. */
|
|
322
|
+
export function updateHarnessDocument(filePath: string, block: string, defaultHeader: string, dryRun = false): boolean {
|
|
320
323
|
let original = "";
|
|
321
324
|
if (existsSync(filePath)) {
|
|
322
325
|
original = readFileSync(filePath, "utf8");
|
|
@@ -340,13 +343,13 @@ export function updateHarnessDocument(filePath: string, block: string, defaultHe
|
|
|
340
343
|
}
|
|
341
344
|
|
|
342
345
|
if (updated !== original) {
|
|
343
|
-
writeFileSync(filePath, updated, "utf8");
|
|
346
|
+
if (!dryRun) writeFileSync(filePath, updated, "utf8");
|
|
344
347
|
return true;
|
|
345
348
|
}
|
|
346
349
|
return false;
|
|
347
350
|
}
|
|
348
351
|
|
|
349
|
-
export function syncHarnessMemory(repoRoot: string): { updated: string[]; created: string[] } {
|
|
352
|
+
export function syncHarnessMemory(repoRoot: string, options: { dryRun?: boolean } = {}): { updated: string[]; created: string[] } {
|
|
350
353
|
const root = resolve(repoRoot);
|
|
351
354
|
const records = loadAuthoredMemory(root);
|
|
352
355
|
const block = formatHarnessMemoryBlock(records);
|
|
@@ -358,7 +361,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
|
|
|
358
361
|
const agentsPath = join(root, "AGENTS.md");
|
|
359
362
|
const agentsHeader = "# AGENTS\n\nFollow project instructions.\n";
|
|
360
363
|
const agentsExisted = existsSync(agentsPath);
|
|
361
|
-
if (updateHarnessDocument(agentsPath, block, agentsHeader)) {
|
|
364
|
+
if (updateHarnessDocument(agentsPath, block, agentsHeader, options.dryRun)) {
|
|
362
365
|
if (agentsExisted) updated.push("AGENTS.md");
|
|
363
366
|
else created.push("AGENTS.md");
|
|
364
367
|
}
|
|
@@ -367,7 +370,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
|
|
|
367
370
|
const claudePath = join(root, "CLAUDE.md");
|
|
368
371
|
const claudeHeader = `# KXM (Claude)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nYou are the **planner / architecture critic** unless the human explicitly asks\nyou to implement. Default writer is native Grok CLI (\`grok --model grok-4.6\`) — a starting\nrotation, not a sole writer. If \`grok\` is logged out, never bill Grok through another harness;\nuse a relief route Tracking **admits**, or stop and name the limits hit. Your reviews are\nartifacts, not hub \`peer-reply\` evidence.\n`;
|
|
369
372
|
const claudeExisted = existsSync(claudePath);
|
|
370
|
-
if (updateHarnessDocument(claudePath, block, claudeHeader)) {
|
|
373
|
+
if (updateHarnessDocument(claudePath, block, claudeHeader, options.dryRun)) {
|
|
371
374
|
if (claudeExisted) updated.push("CLAUDE.md");
|
|
372
375
|
else created.push("CLAUDE.md");
|
|
373
376
|
}
|
|
@@ -376,7 +379,7 @@ export function syncHarnessMemory(repoRoot: string): { updated: string[]; create
|
|
|
376
379
|
const geminiPath = join(root, "GEMINI.md");
|
|
377
380
|
const geminiHeader = `# KXM (Gemini / Antigravity)\n\nFollow [\`AGENTS.md\`](AGENTS.md). Official phase tracking:\n[\`plans/implementation-plan.md\`](plans/implementation-plan.md#tracking-working-tree-not-a-release).\n\nGoogle goes through the \`antigravity\` **Pi provider** (Tracking \u2192 Decided,\n2026-09-15); \`agy\` stays a harness catalog/helper entry, not the admission path.\nCurrent admissions come from Tracking and \`kxm harness list\`. Starting rotation\nremains Grok.\n`;
|
|
378
381
|
const geminiExisted = existsSync(geminiPath);
|
|
379
|
-
if (updateHarnessDocument(geminiPath, block, geminiHeader)) {
|
|
382
|
+
if (updateHarnessDocument(geminiPath, block, geminiHeader, options.dryRun)) {
|
|
380
383
|
if (geminiExisted) updated.push("GEMINI.md");
|
|
381
384
|
else created.push("GEMINI.md");
|
|
382
385
|
}
|
package/plugins/kxm/src/role.ts
CHANGED
|
@@ -308,18 +308,21 @@ export function addRole(
|
|
|
308
308
|
repoRoot?: string | undefined;
|
|
309
309
|
userConfigDir?: string | undefined;
|
|
310
310
|
overwrite?: boolean | undefined;
|
|
311
|
+
dryRun?: boolean | undefined;
|
|
311
312
|
} = {},
|
|
312
313
|
): { id: string; filePath: string; scope: "global" | "local" } {
|
|
313
314
|
const scope = options.scope ?? "local";
|
|
314
315
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
315
|
-
const dir =
|
|
316
|
+
const dir = options.dryRun
|
|
317
|
+
? rolesDirectory(scope, repoRoot, options.userConfigDir)
|
|
318
|
+
: ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
|
|
316
319
|
const filePath = join(dir, `${role.id}.yaml`);
|
|
317
320
|
|
|
318
321
|
if (existsSync(filePath) && !options.overwrite) {
|
|
319
322
|
throw new Error(`role_already_exists: role '${role.id}' already exists at ${filePath}`);
|
|
320
323
|
}
|
|
321
324
|
|
|
322
|
-
writeFileSync(filePath, stringify(role), "utf8");
|
|
325
|
+
if (!options.dryRun) writeFileSync(filePath, stringify(role), "utf8");
|
|
323
326
|
return { id: role.id, filePath, scope };
|
|
324
327
|
}
|
|
325
328
|
|
|
@@ -329,6 +332,7 @@ export function removeRole(
|
|
|
329
332
|
scope?: "global" | "local" | undefined;
|
|
330
333
|
repoRoot?: string | undefined;
|
|
331
334
|
userConfigDir?: string | undefined;
|
|
335
|
+
dryRun?: boolean | undefined;
|
|
332
336
|
} = {},
|
|
333
337
|
): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
|
|
334
338
|
const scope = options.scope ?? "local";
|
|
@@ -340,6 +344,7 @@ export function removeRole(
|
|
|
340
344
|
throw new Error(`role_not_found: role '${roleId}' not found in ${scope} directory (${filePath})`);
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
if (options.dryRun) return { id: roleId, removed: false, filePath, scope };
|
|
343
348
|
rmSync(filePath);
|
|
344
349
|
return { id: roleId, removed: true, filePath, scope };
|
|
345
350
|
}
|
|
@@ -351,6 +356,7 @@ export function modifyRole(
|
|
|
351
356
|
scope?: "global" | "local" | undefined;
|
|
352
357
|
repoRoot?: string | undefined;
|
|
353
358
|
userConfigDir?: string | undefined;
|
|
359
|
+
dryRun?: boolean | undefined;
|
|
354
360
|
} = {},
|
|
355
361
|
): { id: string; role: KxmRoleDefinition; filePath: string; scope: "global" | "local" } {
|
|
356
362
|
const target = getRole(roleId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
|
|
@@ -367,10 +373,12 @@ export function modifyRole(
|
|
|
367
373
|
|
|
368
374
|
const scope = options.scope ?? target.scope;
|
|
369
375
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
370
|
-
const dir =
|
|
376
|
+
const dir = options.dryRun
|
|
377
|
+
? rolesDirectory(scope, repoRoot, options.userConfigDir)
|
|
378
|
+
: ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
|
|
371
379
|
const filePath = join(dir, `${roleId}.yaml`);
|
|
372
380
|
|
|
373
|
-
writeFileSync(filePath, stringify(updated), "utf8");
|
|
381
|
+
if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
|
|
374
382
|
return { id: roleId, role: updated, filePath, scope };
|
|
375
383
|
}
|
|
376
384
|
|
|
@@ -562,17 +570,19 @@ export function saveRoleHostsConfig(
|
|
|
562
570
|
repoRoot?: string | undefined;
|
|
563
571
|
userConfigDir?: string | undefined;
|
|
564
572
|
format?: "yaml" | "json" | undefined;
|
|
573
|
+
dryRun?: boolean | undefined;
|
|
565
574
|
} = {},
|
|
566
575
|
): { filePath: string; scope: "global" | "local" } {
|
|
567
576
|
const scope = options.scope ?? "local";
|
|
568
577
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
569
578
|
const dir = scope === "global" ? userConfigDirectory(options.userConfigDir) : repoConfigDirectory(repoRoot);
|
|
579
|
+
const format = options.format ?? "yaml";
|
|
580
|
+
const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
|
|
581
|
+
if (options.dryRun) return { filePath, scope };
|
|
570
582
|
if (!existsSync(dir)) {
|
|
571
583
|
mkdirSync(dir, { recursive: true });
|
|
572
584
|
}
|
|
573
585
|
|
|
574
|
-
const format = options.format ?? "yaml";
|
|
575
|
-
const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
|
|
576
586
|
const payload: RoleHostsConfig = {
|
|
577
587
|
schema: KXM_ROLE_HOSTS_SCHEMA,
|
|
578
588
|
seats: config.seats ?? {},
|
|
@@ -594,6 +604,7 @@ export function setRoleSeatHost(
|
|
|
594
604
|
repoRoot?: string | undefined;
|
|
595
605
|
userConfigDir?: string | undefined;
|
|
596
606
|
format?: "yaml" | "json" | undefined;
|
|
607
|
+
dryRun?: boolean | undefined;
|
|
597
608
|
} = {},
|
|
598
609
|
): { filePath: string; seatId: string; binding: RoleSeatBinding; scope: "global" | "local" } {
|
|
599
610
|
const scope = options.scope ?? "local";
|
|
@@ -624,6 +635,7 @@ export function setRoleSeatHost(
|
|
|
624
635
|
repoRoot: options.repoRoot,
|
|
625
636
|
userConfigDir: options.userConfigDir,
|
|
626
637
|
format: options.format,
|
|
638
|
+
dryRun: options.dryRun,
|
|
627
639
|
});
|
|
628
640
|
|
|
629
641
|
return { filePath: saved.filePath, seatId, binding, scope };
|
|
@@ -398,6 +398,8 @@ export function loadSessionBrief(
|
|
|
398
398
|
cost?: string;
|
|
399
399
|
sessionToken?: string;
|
|
400
400
|
ship?: SessionShipStatus;
|
|
401
|
+
/** false answers from disk and the hub without refreshing session-brief.json. */
|
|
402
|
+
writeCache?: boolean;
|
|
401
403
|
} = {},
|
|
402
404
|
): SessionBrief {
|
|
403
405
|
const paths = resolveKxmSnapshotPaths(cwd, env);
|
|
@@ -464,7 +466,7 @@ export function loadSessionBrief(
|
|
|
464
466
|
);
|
|
465
467
|
}
|
|
466
468
|
|
|
467
|
-
writeCachedSessionBrief(paths.stateDir, brief);
|
|
469
|
+
if (options.writeCache !== false) writeCachedSessionBrief(paths.stateDir, brief);
|
|
468
470
|
return brief;
|
|
469
471
|
}
|
|
470
472
|
|
|
@@ -479,6 +481,7 @@ export async function loadSessionBriefAsync(
|
|
|
479
481
|
cost?: string;
|
|
480
482
|
sessionToken?: string;
|
|
481
483
|
ship?: SessionShipStatus;
|
|
484
|
+
writeCache?: boolean;
|
|
482
485
|
} = {},
|
|
483
486
|
): Promise<SessionBrief> {
|
|
484
487
|
const paths = resolveKxmSnapshotPaths(cwd, env);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import { redactSecrets } from "./redact.ts";
|
|
6
6
|
|
|
@@ -190,17 +190,32 @@ export interface SkillLifecycleOptions {
|
|
|
190
190
|
* evals. When unset, `optimization` evaluations are rejected. */
|
|
191
191
|
allowOptimizationEvals?: boolean;
|
|
192
192
|
now?: () => string;
|
|
193
|
+
/** Validate and record every change in `planned` without touching disk. */
|
|
194
|
+
dryRun?: boolean;
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
export class SkillLifecycle {
|
|
196
198
|
private readonly root: string;
|
|
197
199
|
private readonly now: () => string;
|
|
198
200
|
private readonly allowOptimizationEvals: boolean;
|
|
201
|
+
private readonly dryRun: boolean;
|
|
202
|
+
/** What a `dryRun` lifecycle would have written or moved, in order. */
|
|
203
|
+
readonly planned: Array<{ action: "write" | "move"; target: string }> = [];
|
|
199
204
|
|
|
200
205
|
constructor(root: string, options: SkillLifecycleOptions = {}) {
|
|
201
206
|
this.root = root;
|
|
202
207
|
this.now = options.now ?? (() => new Date().toISOString());
|
|
203
208
|
this.allowOptimizationEvals = options.allowOptimizationEvals === true;
|
|
209
|
+
this.dryRun = options.dryRun === true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private write(file: string, content: string): void {
|
|
213
|
+
if (this.dryRun) {
|
|
214
|
+
this.planned.push({ action: "write", target: file });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
218
|
+
writeFileSync(file, content);
|
|
204
219
|
}
|
|
205
220
|
|
|
206
221
|
private dir(state: SkillState): string {
|
|
@@ -217,15 +232,14 @@ export class SkillLifecycle {
|
|
|
217
232
|
}
|
|
218
233
|
|
|
219
234
|
private appendHistory(id: string, record: unknown): void {
|
|
220
|
-
mkdirSync(join(this.root, "history"), { recursive: true });
|
|
221
235
|
const line = `${JSON.stringify(record)}\n`;
|
|
222
236
|
if (existsSync(this.historyFile(id))) {
|
|
223
237
|
// Bound the history file: append within the audit limit.
|
|
224
238
|
const existing = readFileSync(this.historyFile(id), "utf8");
|
|
225
239
|
const lines = existing.split("\n").filter((entry) => entry.trim());
|
|
226
|
-
|
|
240
|
+
this.write(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
|
|
227
241
|
} else {
|
|
228
|
-
|
|
242
|
+
this.write(this.historyFile(id), line);
|
|
229
243
|
}
|
|
230
244
|
}
|
|
231
245
|
|
|
@@ -252,6 +266,10 @@ export class SkillLifecycle {
|
|
|
252
266
|
if (!existsSync(fromDir)) {
|
|
253
267
|
throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${from}`);
|
|
254
268
|
}
|
|
269
|
+
if (this.dryRun) {
|
|
270
|
+
this.planned.push({ action: "move", target: `${fromDir} -> ${toDir}` });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
255
273
|
mkdirSync(this.dir(to), { recursive: true });
|
|
256
274
|
if (existsSync(toDir)) rmSync(toDir, { recursive: true, force: true });
|
|
257
275
|
renameSync(fromDir, toDir);
|
|
@@ -299,7 +317,7 @@ export class SkillLifecycle {
|
|
|
299
317
|
|
|
300
318
|
const contentSha256 = skillContentSha256(content);
|
|
301
319
|
const id = skillIdFor(name, contentSha256);
|
|
302
|
-
const {
|
|
320
|
+
const { metadata, skill } = this.paths("candidate", id);
|
|
303
321
|
if (existsSync(metadata)) {
|
|
304
322
|
throw new SkillLifecycleError(
|
|
305
323
|
"skill_candidate_exists",
|
|
@@ -319,9 +337,8 @@ export class SkillLifecycle {
|
|
|
319
337
|
createdAt: this.now(),
|
|
320
338
|
...(input.supersedes ? { supersedes: input.supersedes } : {}),
|
|
321
339
|
};
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
writeFileSync(metadata, `${JSON.stringify(record, null, 2)}\n`);
|
|
340
|
+
this.write(skill, content);
|
|
341
|
+
this.write(metadata, `${JSON.stringify(record, null, 2)}\n`);
|
|
325
342
|
this.appendHistory(id, { schema: "kxm.skill-history-event.v1", event: "candidate_created", by: createdBy, supersedes: input.supersedes, at: record.createdAt });
|
|
326
343
|
return record;
|
|
327
344
|
}
|
|
@@ -429,20 +446,17 @@ export class SkillLifecycle {
|
|
|
429
446
|
// Instead of moving directory, write promoted directory and generate patch
|
|
430
447
|
const candidatePaths = this.paths("candidate", candidateId);
|
|
431
448
|
const promotedPaths = this.paths("promoted", candidateId);
|
|
432
|
-
mkdirSync(promotedPaths.dir, { recursive: true });
|
|
433
449
|
|
|
434
450
|
const skillContent = readFileSync(candidatePaths.skill, "utf8");
|
|
435
451
|
const metadataContent = readFileSync(candidatePaths.metadata, "utf8");
|
|
436
|
-
|
|
437
|
-
|
|
452
|
+
this.write(promotedPaths.skill, skillContent);
|
|
453
|
+
this.write(promotedPaths.metadata, metadataContent);
|
|
438
454
|
|
|
439
|
-
const
|
|
440
|
-
mkdirSync(patchesDir, { recursive: true });
|
|
441
|
-
const patchPath = join(patchesDir, `${candidateId}.patch`);
|
|
455
|
+
const patchPath = join(this.root, "patches", `${candidateId}.patch`);
|
|
442
456
|
const relSkillPath = `.kxm/skills/promoted/${candidateId}/SKILL.md`;
|
|
443
457
|
const relMetaPath = `.kxm/skills/promoted/${candidateId}/metadata.json`;
|
|
444
458
|
const patch = `${createUnifiedPatch(relSkillPath, skillContent)}${createUnifiedPatch(relMetaPath, metadataContent)}`;
|
|
445
|
-
|
|
459
|
+
this.write(patchPath, patch);
|
|
446
460
|
|
|
447
461
|
this.appendHistory(candidateId, record);
|
|
448
462
|
return { ...metadata, patch, patchPath };
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* flag `readOnly`, Bun spells it `readonly`. Options are normalized here so
|
|
9
9
|
* callers keep using the Node spelling everywhere.
|
|
10
10
|
*/
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import { createRequire } from "node:module";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
12
15
|
|
|
13
16
|
export interface DatabaseSyncOptions {
|
|
14
17
|
readOnly?: boolean;
|
|
@@ -74,3 +77,18 @@ export class DatabaseSync {
|
|
|
74
77
|
this.inner.close();
|
|
75
78
|
}
|
|
76
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open a database for reading without touching its directory. A plain
|
|
83
|
+
* read-only open of a WAL database creates the `-wal` and `-shm` sidecars when
|
|
84
|
+
* they are missing and leaves them behind. With no `-wal` present, the main
|
|
85
|
+
* file already holds every committed page, so it is opened `immutable` (no
|
|
86
|
+
* locks, no sidecars); a present `-wal` means a writer or un-checkpointed
|
|
87
|
+
* frames, and the ordinary read-only open reads through them.
|
|
88
|
+
*/
|
|
89
|
+
export function openReadOnlyDatabase(path: string): DatabaseSync {
|
|
90
|
+
if (existsSync(`${path}-wal`)) return new DatabaseSync(path, { readOnly: true });
|
|
91
|
+
const uri = pathToFileURL(resolve(path));
|
|
92
|
+
uri.searchParams.set("immutable", "1");
|
|
93
|
+
return new DatabaseSync(uri.href, { readOnly: true });
|
|
94
|
+
}
|