dotmd-cli 0.87.2 → 0.89.1

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/src/baton.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { readFileSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
1
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
- import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
7
+ import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
8
8
  import { green, dim } from './color.mjs';
9
- import { authorizeManagedSource } from './managed-path.mjs';
9
+ import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
10
+ import { mutateFileSet } from './atomic-mutation.mjs';
10
11
  import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
11
12
 
12
13
  // `dotmd baton` is the one-command handoff: save the resume prompt AND release
@@ -56,7 +57,8 @@ function pendingHandoffs(promptPath, planPath, config) {
56
57
  try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
57
58
  catch { continue; }
58
59
  const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
59
- if (linked && realpathSync(linked) === planReal) found.add(doc.path);
60
+ try { if (linked && realpathSync(linked) === planReal) found.add(doc.path); }
61
+ catch { /* stale plan link is not evidence this prompt belongs to the plan */ }
60
62
  }
61
63
  }
62
64
  return [...found];
@@ -65,7 +67,36 @@ function pendingHandoffs(promptPath, planPath, config) {
65
67
  function refusePendingHandoff(pending) {
66
68
  const lines = pending.map(p => ` ${p}`).join('\n');
67
69
  const slug = path.basename(pending[0], '.md');
68
- die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, run \`runlist prompts archive ${pending[0]}\`, then run baton again.`);
70
+ die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, re-run baton with \`--replace\` to preserve the old handoff under archived/.`);
71
+ }
72
+
73
+ function archivedCopyPath(promptPath, config) {
74
+ const dir = path.join(path.dirname(promptPath), config.archiveDir);
75
+ const ext = path.extname(promptPath);
76
+ const stem = path.basename(promptPath, ext);
77
+ let candidate = path.join(dir, `${stem}${ext}`);
78
+ for (let n = 2; existsSync(candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
79
+ return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
80
+ }
81
+
82
+ function shellQuote(value) {
83
+ const raw = String(value);
84
+ return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
85
+ }
86
+
87
+ function commitCommand(config, message, paths) {
88
+ const fallback = ['git', 'commit', '-m', message, '--', ...paths];
89
+ let argv = fallback;
90
+ if (typeof config.hooks.batonCommitCommand === 'function') {
91
+ try {
92
+ const custom = config.hooks.batonCommitCommand(message, [...paths]);
93
+ if (!Array.isArray(custom) || custom.length === 0 || !custom.every(part => typeof part === 'string' && part.length > 0)) {
94
+ throw new Error('return a nonempty string argv array');
95
+ }
96
+ argv = custom;
97
+ } catch (err) { warn(`batonCommitCommand failed (${err.message}); printing the default git command.`); }
98
+ }
99
+ return argv.map(shellQuote).join(' ');
69
100
  }
70
101
 
71
102
  // Is this positional a filesystem reference (must resolve, typos die) or a
@@ -83,6 +114,7 @@ export async function runBaton(argv, config, opts = {}) {
83
114
  let note = null;
84
115
  let bodyFlag = null;
85
116
  let force = false;
117
+ let replace = false;
86
118
  const positionals = [];
87
119
  for (let i = 0; i < argv.length; i++) {
88
120
  const a = argv[i];
@@ -90,6 +122,7 @@ export async function runBaton(argv, config, opts = {}) {
90
122
  if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
91
123
  if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
92
124
  if (a === '--force') { force = true; continue; }
125
+ if (a === '--replace') { replace = true; continue; }
93
126
  if (a === '--json') continue;
94
127
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
95
128
  die(`Unknown flag for \`runlist baton\`: ${a}`);
@@ -152,16 +185,45 @@ export async function runBaton(argv, config, opts = {}) {
152
185
 
153
186
  const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
154
187
  const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
155
- const refuseIfPending = () => {
188
+ let replacement = null;
189
+ const resolvePending = () => {
156
190
  const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
157
191
  const pending = pendingHandoffs(target.filePath, planPath, config);
158
- if (pending.length) refusePendingHandoff(pending);
192
+ if (!replace) {
193
+ if (pending.length) refusePendingHandoff(pending);
194
+ return;
195
+ }
196
+ if (pending.length !== 1) {
197
+ const detail = pending.length ? `Found ${pending.length}:\n${pending.map(p => ` ${p}`).join('\n')}` : 'No pending handoff matches this work.';
198
+ die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
199
+ }
200
+ const oldRepoPath = pending[0];
201
+ const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
202
+ const oldRaw = readFileSync(oldPath, 'utf8');
203
+ const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
204
+ const fm = parseSimpleFrontmatter(frontmatter);
205
+ if (asString(fm.type) !== 'prompt' || asString(fm.status) !== 'pending') {
206
+ die(`Nothing replaced: ${oldRepoPath} is not a pending prompt.`);
207
+ }
208
+ const priorPlanRef = asString(fm.plan);
209
+ const priorPlanPath = priorPlanRef ? resolveRefPath(priorPlanRef, path.dirname(oldPath), config.repoRoot) : null;
210
+ if (!planPath && priorPlanRef) {
211
+ die(`Nothing replaced: ${oldRepoPath} links a plan (${priorPlanRef}). Use \`runlist baton <plan-file> @<draft-file> --replace\` so the plan link and release stay together.`);
212
+ }
213
+ replacement = {
214
+ oldPath, oldRepoPath, oldRaw,
215
+ archivedPath: archivedCopyPath(oldPath, config),
216
+ alreadyCurrent: oldBody.trim() === body.trim()
217
+ && (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
218
+ canonicalPath: target.filePath,
219
+ };
159
220
  };
160
221
 
161
222
  let repoPath = null;
162
223
  let oldStatus = null;
163
224
  let ownershipPath = null;
164
225
  let planGuard = null;
226
+ let wouldReleaseClaim = false;
165
227
  if (planPath) {
166
228
  planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
167
229
  repoPath = toRepoPath(planPath, config.repoRoot);
@@ -187,6 +249,7 @@ export async function runBaton(argv, config, opts = {}) {
187
249
  const sessionId = authoritativeSessionId();
188
250
  const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
189
251
  const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
252
+ wouldReleaseClaim = ownership?.state === 'owned';
190
253
  // Baton's release only means something when there is a claim to release. A
191
254
  // plan that is neither in-session nor owned here carries a status someone
192
255
  // chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
@@ -208,16 +271,36 @@ export async function runBaton(argv, config, opts = {}) {
208
271
  if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
209
272
  }
210
273
  // Before the plan-completion step: a refusal must leave nothing changed.
211
- refuseIfPending();
274
+ resolvePending();
275
+ // A status transition may file the plan at a new path. Refreshing the
276
+ // prompt inside that transaction keeps its plan link valid even when its
277
+ // body text happened to be identical to the previous handoff.
278
+ if (replacement && status !== oldStatus) replacement.alreadyCurrent = false;
212
279
  ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
213
280
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
214
281
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
215
282
  } else {
216
- refuseIfPending();
283
+ resolvePending();
217
284
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
218
285
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
219
286
  }
220
287
 
288
+ const preparedReplacement = prepared => {
289
+ if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
290
+ let archivedContent = renderLifecycleMutation(replacement.oldRaw,
291
+ { status: 'archived', updated: nowIso() }, () => 'Archived.');
292
+ if (repoPath) archivedContent = archivedContent.replace(/^plan:[ \t]*\S.*$/m, `plan: ${repoPath}`);
293
+ return {
294
+ updates: [{ path: replacement.oldPath, expectedContent: replacement.oldRaw, content: prepared.content }],
295
+ creations: [{ path: replacement.archivedPath, content: archivedContent }],
296
+ guards: path.resolve(replacement.canonicalPath) === path.resolve(replacement.oldPath)
297
+ ? [] : [{ path: replacement.canonicalPath, absent: true }],
298
+ };
299
+ };
300
+ if (dryRun && !json && replacement && !replacement.alreadyCurrent) {
301
+ process.stdout.write(`${dim('[dry-run]')} Would preserve old handoff: ${replacement.oldRepoPath} → ${toRepoPath(replacement.archivedPath, config.repoRoot)}\n`);
302
+ }
303
+
221
304
  // Plan mode publishes the already-stamped prompt, status/history update, and
222
305
  // ownership release in one transaction. Slug mode has no plan transaction.
223
306
  let createdSlug = null;
@@ -230,29 +313,39 @@ export async function runBaton(argv, config, opts = {}) {
230
313
  if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
231
314
  try {
232
315
  if (!planPath) {
233
- const prepared = preparePromptDocument(slugBase, body, config, { dryRun });
234
- try {
235
- newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
236
- } catch (err) {
237
- // Lost a race with another baton between the pending check and the write.
238
- if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
239
- throw err;
316
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
317
+ const prepared = preparePromptDocument(promptName, body, config, { dryRun });
318
+ if (replacement) {
319
+ const mutation = preparedReplacement(prepared);
320
+ if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
321
+ newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
322
+ } else {
323
+ try {
324
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
325
+ } catch (err) {
326
+ // Lost a race with another baton between the pending check and the write.
327
+ if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
328
+ throw err;
329
+ }
240
330
  }
241
- createdSlug = slugBase;
242
- promptRepoPath = prepared.repoPath;
331
+ createdSlug = promptName;
332
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
243
333
  } else {
244
- const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
334
+ const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
335
+ const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
336
+ const mutation = preparedReplacement(prepared);
245
337
  const setArgs = [status, planPath];
246
338
  if (force) setArgs.push('--force');
247
339
  if (note && !planGuard) setArgs.push('--note', note);
248
340
  try {
249
- if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
341
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would ${replacement ? (replacement.alreadyCurrent ? 'keep' : 'replace') : 'create'}: ${replacement?.oldRepoPath ?? prepared.repoPath}\n`);
250
342
  archiveResult = await runSet(setArgs, config, {
251
343
  dryRun,
252
344
  viaBaton: true,
253
345
  testHooks: opts.testHooks,
254
- creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
255
- guards: planGuard && !dryRun ? [planGuard] : [],
346
+ additionalUpdates: dryRun ? [] : mutation.updates,
347
+ creations: dryRun ? [] : replacement ? mutation.creations : [{ path: prepared.filePath, content: prepared.content }],
348
+ guards: dryRun ? [] : [...mutation.guards, ...(planGuard ? [planGuard] : [])],
256
349
  deferIndex: true,
257
350
  });
258
351
  } catch (err) {
@@ -260,9 +353,9 @@ export async function runBaton(argv, config, opts = {}) {
260
353
  throw err;
261
354
  }
262
355
  createdSlug = prepared.slug;
263
- promptRepoPath = prepared.repoPath;
356
+ promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
264
357
  statusChanged = oldStatus !== status;
265
- if (!dryRun) {
358
+ if (!dryRun && !replacement) {
266
359
  try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
267
360
  catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
268
361
  }
@@ -271,24 +364,12 @@ export async function runBaton(argv, config, opts = {}) {
271
364
  if (json) process.stdout.write = originalStdoutWrite;
272
365
  }
273
366
 
274
- // A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
275
- // e.g. paused → docs/plans/held/). The prompt is created inside the same
276
- // transaction as that move, so its `plan:` link necessarily holds the
277
- // pre-move path and is stale the instant it lands. The move's own reference
278
- // rewrite does not cover it: `plan` is deliberately not a `referenceFields`
279
- // entry, so nothing validates or repoints it. Retarget it here.
280
- if (!dryRun && promptRepoPath && archiveResult?.newRepoPath && archiveResult.newRepoPath !== repoPath) {
281
- const promptPath = path.join(config.repoRoot, promptRepoPath);
282
- try {
283
- const { frontmatter, body } = extractFrontmatter(readFileSync(promptPath, 'utf8'));
284
- // Rewritten in place rather than through replaceFrontmatterField, which
285
- // always emits a folded block scalar — right for prose fields, wrong for
286
- // a path every other prompt carries on one line. Baton wrote this line
287
- // itself moments ago, so the single-line form is guaranteed.
288
- const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${archiveResult.newRepoPath}`);
289
- if (rewritten !== frontmatter) writeFileSync(promptPath, `---\n${rewritten}\n---\n${body}`, 'utf8');
290
- }
291
- catch (err) { warn(`Saved the prompt, but could not repoint its plan link to ${archiveResult.newRepoPath}: ${err.message}`); }
367
+ if (!dryRun && replacement && !replacement.alreadyCurrent) {
368
+ const archivedRepoPath = toRepoPath(replacement.archivedPath, config.repoRoot);
369
+ try { config.hooks.onArchive?.({ path: archivedRepoPath, oldStatus: 'pending' }, { oldPath: replacement.oldRepoPath, newPath: archivedRepoPath }); }
370
+ catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
371
+ try { config.hooks.onNew?.({ path: replacement.oldRepoPath, status: 'pending', title: createdSlug, type: 'prompt' }); }
372
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
292
373
  }
293
374
 
294
375
  const normalizeRepoPath = candidate => {
@@ -297,13 +378,14 @@ export async function runBaton(argv, config, opts = {}) {
297
378
  };
298
379
  const touched = (archiveResult?.touched ?? (planPath && statusChanged ? [repoPath] : [])).map(normalizeRepoPath);
299
380
  const ownershipRepoPath = normalizeRepoPath(ownershipPath);
300
- const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
301
- const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, ownershipRepoPath].filter(Boolean))];
381
+ const archivedPromptRepoPath = replacement && !replacement.alreadyCurrent ? toRepoPath(replacement.archivedPath, config.repoRoot) : null;
382
+ const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== archivedPromptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
383
+ const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, archivedPromptRepoPath, ownershipRepoPath].filter(Boolean))];
302
384
  const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
303
385
  const operationResult = {
304
386
  operation: 'baton',
305
387
  dryRun: Boolean(dryRun),
306
- disposition: dryRun ? 'would-change' : 'applied',
388
+ disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
307
389
  wouldChange: Boolean(dryRun),
308
390
  mode: planPath ? 'plan' : 'slug',
309
391
  status: planPath ? { from: oldStatus, to: status, changed: statusChanged } : null,
@@ -313,6 +395,14 @@ export async function runBaton(argv, config, opts = {}) {
313
395
  deferredGeneratedFiles,
314
396
  prompt: promptRepoPath,
315
397
  plan: archiveResult?.newRepoPath ?? repoPath,
398
+ planMovement: planPath ? { from: repoPath, to: archiveResult?.newRepoPath ?? repoPath } : null,
399
+ replacement: replacement ? {
400
+ previousPrompt: replacement.oldRepoPath,
401
+ archivedPrompt: archivedPromptRepoPath,
402
+ pendingPrompt: promptRepoPath,
403
+ alreadyCurrent: replacement.alreadyCurrent,
404
+ } : null,
405
+ claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
316
406
  };
317
407
 
318
408
  const prefix = dryRun ? dim('[dry-run] ') : '';
@@ -320,13 +410,14 @@ export async function runBaton(argv, config, opts = {}) {
320
410
  process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
321
411
  return operationResult;
322
412
  }
323
- process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
413
+ const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
414
+ process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
415
+ if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
324
416
  if (planGuard) {
325
417
  process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
326
418
  process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
327
419
  }
328
420
  if (statusChanged) {
329
- const pathspec = operationResult.repositoryFiles.join(' ');
330
421
  let gitignored = false;
331
422
  try {
332
423
  const { isGitIgnored } = await import('./git.mjs');
@@ -336,7 +427,7 @@ export async function runBaton(argv, config, opts = {}) {
336
427
  process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
337
428
  } else {
338
429
  process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
339
- process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
430
+ process.stderr.write(`${prefix} ${commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles)}\n`);
340
431
  }
341
432
  }
342
433
  if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
@@ -0,0 +1,81 @@
1
+ // Codex's default personal marketplace is discovered without a CLI registration.
2
+ // The npm package carries this plugin so installation does not need a Git checkout.
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'plugins', 'runlist-codex');
9
+ const NAME = 'runlist-codex';
10
+ const MARKER = '.runlist-generated.json';
11
+
12
+ export function codexPaths(homedir = os.homedir()) {
13
+ const root = path.join(homedir, '.agents', 'plugins');
14
+ // The implicit personal marketplace file lives under ~/.agents/plugins,
15
+ // but Codex resolves its `./plugins/...` sources relative to the home dir.
16
+ return { root, marketplace: path.join(root, 'marketplace.json'), plugin: path.join(homedir, 'plugins', NAME) };
17
+ }
18
+
19
+ function marketplaceAt(file) {
20
+ if (!existsSync(file)) return { name: 'personal', interface: { displayName: 'Personal' }, plugins: [] };
21
+ const data = JSON.parse(readFileSync(file, 'utf8'));
22
+ if (!data || typeof data.name !== 'string' || !Array.isArray(data.plugins)) {
23
+ throw new Error(`${file} is not a valid personal marketplace`);
24
+ }
25
+ return data;
26
+ }
27
+
28
+ export function codexStatus({ version, homedir = os.homedir() } = {}) {
29
+ const paths = codexPaths(homedir);
30
+ let installed = null;
31
+ try { installed = JSON.parse(readFileSync(path.join(paths.plugin, MARKER), 'utf8')).version; } catch { /* absent or foreign */ }
32
+ const exists = existsSync(paths.plugin);
33
+ return { ...paths, exists, foreign: exists && !installed, version: installed, stale: Boolean(installed && version && installed !== version) };
34
+ }
35
+
36
+ export function installCodexPlugin({ version, homedir = os.homedir(), dryRun = false } = {}) {
37
+ const sourceVersion = JSON.parse(readFileSync(path.join(SOURCE, '.codex-plugin', 'plugin.json'), 'utf8')).version;
38
+ if (sourceVersion !== version) throw new Error(`Codex plugin source is ${sourceVersion}, CLI is ${version}`);
39
+ const status = codexStatus({ version, homedir });
40
+ if (status.foreign) return { ...status, action: 'refused', reason: 'plugin directory exists without a runlist marker' };
41
+ let market;
42
+ try { market = marketplaceAt(status.marketplace); }
43
+ catch (err) { return { ...status, action: 'refused', reason: err.message }; }
44
+ const expected = `./plugins/${NAME}`;
45
+ const entry = market.plugins.find(p => p.name === NAME);
46
+ if (entry && (entry.source?.source !== 'local' || entry.source?.path !== expected)) {
47
+ return { ...status, action: 'refused', reason: 'marketplace entry points to a different plugin source' };
48
+ }
49
+ if (!entry) market.plugins.push({
50
+ name: NAME, source: { source: 'local', path: expected },
51
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity',
52
+ });
53
+ if (status.exists && !status.stale && entry) return { ...status, action: 'current', marketplaceName: market.name };
54
+ if (dryRun) return { ...status, action: status.exists ? 'updated' : 'installed', marketplaceName: market.name, dryRun: true };
55
+
56
+ const parent = path.dirname(status.plugin);
57
+ mkdirSync(parent, { recursive: true });
58
+ mkdirSync(path.dirname(status.marketplace), { recursive: true });
59
+ const stage = mkdtempSync(path.join(parent, '.runlist-codex-stage-'));
60
+ const backup = `${stage}-previous`;
61
+ const marketStage = `${status.marketplace}.runlist-stage-${process.pid}`;
62
+ let movedOld = false;
63
+ let published = false;
64
+ try {
65
+ cpSync(SOURCE, stage, { recursive: true });
66
+ writeFileSync(path.join(stage, MARKER), JSON.stringify({ version }) + '\n');
67
+ writeFileSync(marketStage, JSON.stringify(market, null, 2) + '\n');
68
+ if (status.exists) { renameSync(status.plugin, backup); movedOld = true; }
69
+ renameSync(stage, status.plugin);
70
+ published = true;
71
+ renameSync(marketStage, status.marketplace);
72
+ if (movedOld) rmSync(backup, { recursive: true, force: true });
73
+ } catch (err) {
74
+ if (published) rmSync(status.plugin, { recursive: true, force: true });
75
+ if (movedOld) renameSync(backup, status.plugin);
76
+ rmSync(stage, { recursive: true, force: true });
77
+ rmSync(marketStage, { force: true });
78
+ throw err;
79
+ }
80
+ return { ...codexStatus({ version, homedir }), action: status.exists ? 'updated' : 'installed', marketplaceName: market.name };
81
+ }
package/src/commands.mjs CHANGED
@@ -123,7 +123,7 @@ const definitions = [
123
123
  ], { aliases: ['prompt'] }),
124
124
  command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
125
125
  command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
126
- command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
126
+ command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--replace'), flag('--json')] })]),
127
127
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
128
128
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
129
129
  form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
package/src/config.mjs CHANGED
@@ -147,9 +147,9 @@ const DEFAULTS = {
147
147
  // and users who want usage observability flip this on (or set RUNLIST_JOURNAL=1).
148
148
  journal: false,
149
149
 
150
- // PreToolUse guard behavior. `deny: false` drops the status-edit rules from
151
- // deny (block the tool call) back to warn-only teaching context.
152
- guard: { deny: true },
150
+ // PreToolUse guard behavior. `deny: false` drops status edits to warnings;
151
+ // `promptReads: 'deny'` blocks direct reads of existing pending prompts.
152
+ guard: { deny: true, promptReads: 'warn' },
153
153
 
154
154
  presets: {
155
155
  stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
@@ -667,8 +667,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
667
667
  moduleRequiredStatuses,
668
668
 
669
669
  indexPath,
670
- indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:dotmd:start -->',
671
- indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:dotmd:end -->',
670
+ indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:runlist:start -->',
671
+ indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:runlist:end -->',
672
672
  indexSnapshot: config.index?.snapshot ?? 'status',
673
673
  archivedHighlightLimit: config.index?.archivedLimit ?? 8,
674
674
 
@@ -678,7 +678,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
678
678
  presets: config.presets,
679
679
  configuredPresetNames: new Set(Object.keys(userConfig.presets ?? {})),
680
680
  journal: config.journal === true,
681
- guard: { deny: config.guard?.deny !== false },
681
+ guard: {
682
+ deny: config.guard?.deny !== false,
683
+ promptReads: config.guard?.promptReads === 'deny' ? 'deny' : 'warn',
684
+ },
682
685
  hooks,
683
686
  configWarnings,
684
687
  };
package/src/export.mjs CHANGED
@@ -99,7 +99,7 @@ export function runExport(argv, config, opts = {}) {
99
99
  }
100
100
  }
101
101
  } else if (format === 'html') {
102
- const outDir = output ?? 'dotmd-export';
102
+ const outDir = output ?? (existsSync('dotmd-export') ? 'dotmd-export' : 'runlist-export');
103
103
  const identities = allocateOutputIdentities(index.docs);
104
104
  exportHtml(docsWithBody, config, outDir, identities, { dryRun });
105
105
  if (dryRun) {
package/src/guard.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { inspectGitCommandPaths } from './git.mjs';
5
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
6
  import { recordGuardEvent } from './journal.mjs';
6
7
  import { readEnv } from './naming.mjs';
7
8
 
@@ -241,7 +242,26 @@ const STREAM_EDITOR_INPLACE = [
241
242
  /\bg?awk\b[^|;&<>]*\binplace\b/,
242
243
  ];
243
244
 
244
- function evalBash(command, config, inspectGitPaths, baseCwd) {
245
+ function promptReadDecision(filePath, config, deps = {}) {
246
+ if (config?.guard?.promptReads !== 'deny') return 'warn';
247
+ if (!isManagedDoc(filePath, config)) return 'warn';
248
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
249
+ try {
250
+ const absolute = path.resolve(deps.gitCwd ?? process.cwd(), filePath);
251
+ const real = p => { try { return realpathSync(p); } catch { return p; } };
252
+ const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : ['docs']);
253
+ const insideRoot = roots.some(root => {
254
+ const base = real(path.resolve(config.repoRoot ?? process.cwd(), root));
255
+ const relative = path.relative(base, real(absolute));
256
+ return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
257
+ });
258
+ if (!insideRoot) return 'warn';
259
+ const fm = parseSimpleFrontmatter(extractFrontmatter(readFile(absolute)).frontmatter);
260
+ return fm.type === 'prompt' && fm.status === 'pending' ? 'deny' : 'warn';
261
+ } catch { return 'warn'; }
262
+ }
263
+
264
+ function evalBash(command, config, inspectGitPaths, baseCwd, deps = {}) {
245
265
  const segments = shellSegments(command);
246
266
  let cwd = baseCwd;
247
267
 
@@ -279,7 +299,7 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
279
299
  // Rule B — reading a prompt through the shell instead of consuming it.
280
300
  if (SHELL_READERS.has(cmd0) && promptTokens.length) {
281
301
  return {
282
- decision: 'warn',
302
+ decision: promptReadDecision(promptTokens[0], config, { ...deps, gitCwd: cwd }),
283
303
  rule: 'cat-prompt',
284
304
  detail: `${cmd0} ${promptTokens.join(' ')}`,
285
305
  reason:
@@ -301,10 +321,10 @@ function evalBash(command, config, inspectGitPaths, baseCwd) {
301
321
  return null;
302
322
  }
303
323
 
304
- function evalRead(filePath, config) {
324
+ function evalRead(filePath, config, deps = {}) {
305
325
  if (!isPromptPath(filePath, config)) return null;
306
326
  return {
307
- decision: 'warn',
327
+ decision: promptReadDecision(filePath, config, deps),
308
328
  rule: 'read-prompt',
309
329
  detail: filePath,
310
330
  reason:
@@ -314,6 +334,31 @@ function evalRead(filePath, config) {
314
334
  };
315
335
  }
316
336
 
337
+ // Codex and OpenCode expose patch edits as one string. Only +/- hunk lines
338
+ // count: context mentioning status is an anchor, not an edit. New files are
339
+ // creation, and status on a deleted file is not a hand-edited transition.
340
+ function evalPatch(command, config) {
341
+ if (typeof command !== 'string') return null;
342
+ let target = null;
343
+ let removed = [];
344
+ let added = [];
345
+ const changed = () => target && statusLines(removed.join('\n')).join('\n') !== statusLines(added.join('\n')).join('\n');
346
+ for (const line of command.split('\n')) {
347
+ const update = line.match(/^\*\*\* Update File: (.+)$/);
348
+ if (update || /^\*\*\* (?:Add|Delete) File: /.test(line) || line === '*** End Patch') {
349
+ if (changed()) return editStatusResult(target, config, target);
350
+ target = update && isManagedDoc(update[1], config) ? update[1] : null;
351
+ removed = [];
352
+ added = [];
353
+ continue;
354
+ }
355
+ if (!target || line.startsWith('***')) continue;
356
+ if (line.startsWith('+')) added.push(line.slice(1));
357
+ else if (line.startsWith('-')) removed.push(line.slice(1));
358
+ }
359
+ return changed() ? editStatusResult(target, config, target) : null;
360
+ }
361
+
317
362
  // Every `status:` line in a snippet, normalized for comparison.
318
363
  function statusLines(s) {
319
364
  if (typeof s !== 'string') return [];
@@ -359,8 +404,16 @@ export function evaluateGuard(payload, config, deps = {}) {
359
404
  const inspectGitPaths = deps.inspectGitPaths
360
405
  || ((subcommand, args, cwd) => inspectGitCommandPaths(subcommand, args, cwd ?? deps.gitCwd ?? process.cwd()));
361
406
 
362
- if (tool === 'Bash') return evalBash(input.command || '', config, inspectGitPaths, deps.gitCwd ?? process.cwd());
363
- if (tool === 'Read') return evalRead(input.file_path || '', config);
407
+ if (tool === 'Bash') return evalBash(input.command || input.cmd || '', config, inspectGitPaths, input.workdir ?? deps.gitCwd ?? process.cwd(), deps);
408
+ if (tool === 'Read') return evalRead(input.file_path || '', config, deps);
409
+ if (tool === 'apply_patch') return evalPatch(input.command || input.patchText || '', config);
410
+ if (/^mcp__.*__(?:read_file|read_text_file|read_media_file|read_multiple_files)$/.test(tool ?? '')) {
411
+ const paths = Array.isArray(input.paths) ? input.paths : [input.path ?? input.file_path];
412
+ for (const p of paths) {
413
+ const result = evalRead(p, config, deps);
414
+ if (result) return result;
415
+ }
416
+ }
364
417
  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
365
418
  return null;
366
419
  }
@@ -273,7 +273,7 @@ export function checkHubMembershipDrift(docs, config) {
273
273
  warnings.push({
274
274
  path: child.path,
275
275
  level: 'warning',
276
- message: `is ranked in the body order of \`${hub.path}\` (the list \`runlist runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
276
+ message: `is ranked in the body order of \`${hub.path}\` (used by \`runlist runlist next\`) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
277
277
  meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
278
278
  });
279
279
  }
@@ -5,15 +5,29 @@ import { formatSnapshot } from './render.mjs';
5
5
  import { authorizeRepoGeneratedPath } from './managed-path.mjs';
6
6
  import { mutateFile } from './atomic-mutation.mjs';
7
7
 
8
+ function indexMarkers(current, config) {
9
+ const start = config.indexStartMarker;
10
+ const end = config.indexEndMarker;
11
+ if (current.includes(start) && current.includes(end)) return { start, end };
12
+ // A config that omitted markers may point at an index from an older release.
13
+ // Keep updating that block in place; newly initialized indexes use runlist.
14
+ if (start === '<!-- GENERATED:runlist:start -->' && end === '<!-- GENERATED:runlist:end -->'
15
+ && current.includes('<!-- GENERATED:dotmd:start -->') && current.includes('<!-- GENERATED:dotmd:end -->')) {
16
+ return { start: '<!-- GENERATED:dotmd:start -->', end: '<!-- GENERATED:dotmd:end -->' };
17
+ }
18
+ return { start, end };
19
+ }
20
+
8
21
  export function renderIndexFile(index, config, current = readFileSync(config.indexPath, 'utf8')) {
9
- const start = current.indexOf(config.indexStartMarker);
10
- const end = current.indexOf(config.indexEndMarker);
22
+ const markers = indexMarkers(current, config);
23
+ const start = current.indexOf(markers.start);
24
+ const end = current.indexOf(markers.end);
11
25
 
12
26
  if (start === -1 || end === -1 || end < start) {
13
27
  throw new Error(`${config.indexPath} is missing generated block markers.`);
14
28
  }
15
29
 
16
- const before = current.slice(0, start + config.indexStartMarker.length);
30
+ const before = current.slice(0, start + markers.start.length);
17
31
  const after = current.slice(end);
18
32
  const generated = `\n\n${renderGeneratedBlock(index, config)}\n`;
19
33
  return `${before}${generated}${after}`;
@@ -130,8 +144,9 @@ export function checkIndex(docs, config, opts = {}) {
130
144
  }
131
145
 
132
146
  const current = readFileSync(config.indexPath, 'utf8');
133
- const start = current.indexOf(config.indexStartMarker);
134
- const end = current.indexOf(config.indexEndMarker);
147
+ const markers = indexMarkers(current, config);
148
+ const start = current.indexOf(markers.start);
149
+ const end = current.indexOf(markers.end);
135
150
 
136
151
  if (start === -1 || end === -1 || end < start) {
137
152
  errors.push({ path: config.indexPath, level: 'error', message: 'Missing generated index block markers.' });