@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.
@@ -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 = new DatabaseSync(dataPath, { readOnly: true });
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 = new DatabaseSync(registryDbPath, { readOnly: true });
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 = new DatabaseSync(eventDbPath, { readOnly: true });
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.86";
11
+ const VERSION = "0.7.87";
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
- export function updateHarnessDocument(filePath: string, block: string, defaultHeader: string): boolean {
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
  }
@@ -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 = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
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 = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
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
- writeFileSync(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
240
+ this.write(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n");
227
241
  } else {
228
- writeFileSync(this.historyFile(id), line);
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 { dir, metadata, skill } = this.paths("candidate", id);
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
- mkdirSync(dir, { recursive: true });
323
- writeFileSync(skill, content);
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
- writeFileSync(promotedPaths.skill, skillContent);
437
- writeFileSync(promotedPaths.metadata, metadataContent);
452
+ this.write(promotedPaths.skill, skillContent);
453
+ this.write(promotedPaths.metadata, metadataContent);
438
454
 
439
- const patchesDir = join(this.root, "patches");
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
- writeFileSync(patchPath, patch, "utf8");
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
+ }
@@ -58,6 +58,14 @@ function tasksDirectory(repoRoot: string): string {
58
58
  return resolve(repoRoot, ".kxm", "tasks");
59
59
  }
60
60
 
61
+ export function goalFilePath(repoRoot: string, goalId: string): string {
62
+ return join(goalsDirectory(repoRoot), `${goalId}.yaml`);
63
+ }
64
+
65
+ export function taskFilePath(repoRoot: string, taskId: string): string {
66
+ return join(tasksDirectory(repoRoot), `${taskId}.yaml`);
67
+ }
68
+
61
69
  export function createGoal(
62
70
  repoRoot: string,
63
71
  input: {
@@ -66,9 +74,8 @@ export function createGoal(
66
74
  successMetrics?: string[] | undefined;
67
75
  targetDate?: string | undefined;
68
76
  },
77
+ options: { dryRun?: boolean | undefined } = {},
69
78
  ): GoalRecord {
70
- const dir = goalsDirectory(repoRoot);
71
- mkdirSync(dir, { recursive: true });
72
79
 
73
80
  const id = `goal_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
74
81
  const now = new Date().toISOString();
@@ -84,8 +91,9 @@ export function createGoal(
84
91
  updatedAt: now,
85
92
  };
86
93
 
87
- const filePath = join(dir, `${id}.yaml`);
88
- writeFileSync(filePath, stringify(record).trim() + "\n", "utf8");
94
+ if (options.dryRun) return record;
95
+ mkdirSync(goalsDirectory(repoRoot), { recursive: true });
96
+ writeFileSync(goalFilePath(repoRoot, id), stringify(record).trim() + "\n", "utf8");
89
97
  return record;
90
98
  }
91
99
 
@@ -123,9 +131,8 @@ export function createTask(
123
131
  issueKey: string;
124
132
  } | undefined;
125
133
  },
134
+ options: { dryRun?: boolean | undefined } = {},
126
135
  ): TaskRecord {
127
- const dir = tasksDirectory(repoRoot);
128
- mkdirSync(dir, { recursive: true });
129
136
 
130
137
  const id = `task_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
131
138
  const now = new Date().toISOString();
@@ -151,8 +158,9 @@ export function createTask(
151
158
  updatedAt: now,
152
159
  };
153
160
 
154
- const filePath = join(dir, `${id}.yaml`);
155
- writeFileSync(filePath, stringify(record).trim() + "\n", "utf8");
161
+ if (options.dryRun) return record;
162
+ mkdirSync(tasksDirectory(repoRoot), { recursive: true });
163
+ writeFileSync(taskFilePath(repoRoot, id), stringify(record).trim() + "\n", "utf8");
156
164
  return record;
157
165
  }
158
166
 
@@ -201,7 +209,7 @@ export function updateTaskStatus(
201
209
  repoRoot: string,
202
210
  taskId: string,
203
211
  status: TaskStatus,
204
- options: { workflowRunId?: string } = {},
212
+ options: { workflowRunId?: string; dryRun?: boolean | undefined } = {},
205
213
  ): TaskRecord {
206
214
  const task = getTask(repoRoot, taskId);
207
215
  if (!task) {
@@ -212,8 +220,7 @@ export function updateTaskStatus(
212
220
  if (options.workflowRunId) task.workflowRunId = options.workflowRunId;
213
221
  task.updatedAt = new Date().toISOString();
214
222
 
215
- const filePath = join(tasksDirectory(repoRoot), `${taskId}.yaml`);
216
- writeFileSync(filePath, stringify(task).trim() + "\n", "utf8");
223
+ if (!options.dryRun) writeFileSync(taskFilePath(repoRoot, taskId), stringify(task).trim() + "\n", "utf8");
217
224
  return task;
218
225
  }
219
226
 
@@ -223,6 +230,7 @@ export function syncTaskWithTracker(
223
230
  options: {
224
231
  mockRemoteState?: string;
225
232
  commentReceipt?: string;
233
+ dryRun?: boolean | undefined;
226
234
  } = {},
227
235
  ): TaskRecord {
228
236
  const task = getTask(repoRoot, taskId);
@@ -238,7 +246,6 @@ export function syncTaskWithTracker(
238
246
  task.trackerSync.lastSyncedAt = now;
239
247
  task.updatedAt = now;
240
248
 
241
- const filePath = join(tasksDirectory(repoRoot), `${taskId}.yaml`);
242
- writeFileSync(filePath, stringify(task).trim() + "\n", "utf8");
249
+ if (!options.dryRun) writeFileSync(taskFilePath(repoRoot, taskId), stringify(task).trim() + "\n", "utf8");
243
250
  return task;
244
251
  }
@@ -326,11 +326,14 @@ export function addWorkflowDefinition(
326
326
  repoRoot?: string | undefined;
327
327
  userConfigDir?: string | undefined;
328
328
  overwrite?: boolean | undefined;
329
+ dryRun?: boolean | undefined;
329
330
  } = {},
330
331
  ): { id: string; filePath: string; scope: "global" | "local" } {
331
332
  const scope = options.scope ?? "local";
332
333
  const repoRoot = options.repoRoot ?? process.cwd();
333
- const dir = ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
334
+ const dir = options.dryRun
335
+ ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
336
+ : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
334
337
  const filePath = join(dir, `${workflowId}.yaml`);
335
338
 
336
339
  if (existsSync(filePath) && !options.overwrite) {
@@ -338,7 +341,7 @@ export function addWorkflowDefinition(
338
341
  }
339
342
 
340
343
  const payload = typeof content === "string" ? content : stringify(content);
341
- writeFileSync(filePath, payload, "utf8");
344
+ if (!options.dryRun) writeFileSync(filePath, payload, "utf8");
342
345
  return { id: workflowId, filePath, scope };
343
346
  }
344
347
 
@@ -348,6 +351,7 @@ export function removeWorkflowDefinition(
348
351
  scope?: "global" | "local" | undefined;
349
352
  repoRoot?: string | undefined;
350
353
  userConfigDir?: string | undefined;
354
+ dryRun?: boolean | undefined;
351
355
  } = {},
352
356
  ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
353
357
  const scope = options.scope ?? "local";
@@ -359,6 +363,7 @@ export function removeWorkflowDefinition(
359
363
  throw new Error(`workflow_not_found: workflow '${workflowId}' not found in ${scope} directory (${filePath})`);
360
364
  }
361
365
 
366
+ if (options.dryRun) return { id: workflowId, removed: false, filePath, scope };
362
367
  rmSync(filePath);
363
368
  return { id: workflowId, removed: true, filePath, scope };
364
369
  }
@@ -370,6 +375,7 @@ export function modifyWorkflowDefinition(
370
375
  scope?: "global" | "local" | undefined;
371
376
  repoRoot?: string | undefined;
372
377
  userConfigDir?: string | undefined;
378
+ dryRun?: boolean | undefined;
373
379
  } = {},
374
380
  ): { id: string; workflow: Record<string, unknown>; filePath: string; scope: "global" | "local" } {
375
381
  const target = getWorkflowDefinition(workflowId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
@@ -384,9 +390,11 @@ export function modifyWorkflowDefinition(
384
390
 
385
391
  const scope = options.scope ?? target.scope;
386
392
  const repoRoot = options.repoRoot ?? process.cwd();
387
- const dir = ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
393
+ const dir = options.dryRun
394
+ ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
395
+ : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
388
396
  const filePath = join(dir, `${workflowId}.yaml`);
389
397
 
390
- writeFileSync(filePath, stringify(updated), "utf8");
398
+ if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
391
399
  return { id: workflowId, workflow: updated, filePath, scope };
392
400
  }