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/README.md +30 -4
- package/assets/opencode/plugin.js +29 -14
- package/bin/dotmd.mjs +23 -6
- package/package.json +2 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +21 -0
- package/runlist.config.example.mjs +5 -0
- package/src/atomic-mutation.mjs +10 -2
- package/src/baton.mjs +140 -49
- package/src/codex-integration.mjs +81 -0
- package/src/commands.mjs +1 -1
- package/src/config.mjs +9 -6
- package/src/export.mjs +1 -1
- package/src/guard.mjs +60 -7
- package/src/hub-membership.mjs +1 -1
- package/src/index-file.mjs +20 -5
- package/src/init.mjs +6 -6
- package/src/install.mjs +50 -2
- package/src/lifecycle.mjs +26 -7
- package/src/migrate-prompts.mjs +2 -2
- package/src/new.mjs +1 -1
- package/src/update.mjs +12 -1
package/src/baton.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { readFileSync, existsSync,
|
|
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 \`
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 =
|
|
242
|
-
promptRepoPath = prepared.repoPath;
|
|
331
|
+
createdSlug = promptName;
|
|
332
|
+
promptRepoPath = replacement?.oldRepoPath ?? prepared.repoPath;
|
|
243
333
|
} else {
|
|
244
|
-
const
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
301
|
-
const
|
|
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
|
-
|
|
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}
|
|
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
|
|
151
|
-
// deny
|
|
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:
|
|
671
|
-
indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:
|
|
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: {
|
|
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
|
|
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:
|
|
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:
|
|
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
|
}
|
package/src/hub-membership.mjs
CHANGED
|
@@ -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}\` (
|
|
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
|
}
|
package/src/index-file.mjs
CHANGED
|
@@ -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
|
|
10
|
-
const
|
|
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 +
|
|
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
|
|
134
|
-
const
|
|
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.' });
|