runlist 0.99.1 → 0.100.0
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/GETTING_STARTED.md +249 -0
- package/README.md +433 -51
- package/bin/runlist.mjs +404 -53
- package/package.json +3 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/bin/consume-prompt.mjs +45 -0
- package/plugins/runlist-codex/bin/runlist-hook +29 -11
- package/plugins/runlist-codex/bin/session-start.mjs +16 -0
- package/plugins/runlist-codex/bin/subagent-start.mjs +16 -0
- package/plugins/runlist-codex/hooks/hooks.json +11 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +27 -11
- package/plugins/runlist-codex/skills/runlist-setup/SKILL.md +109 -0
- package/runlist.config.example.mjs +6 -0
- package/src/agent-context.mjs +8 -2
- package/src/agent-hosts.mjs +305 -0
- package/src/app-create.mjs +11 -1
- package/src/atomic-mutation.mjs +140 -35
- package/src/baton.mjs +127 -33
- package/src/codex-integration.mjs +57 -7
- package/src/commands.mjs +92 -38
- package/src/config-command.mjs +115 -0
- package/src/config-setting.mjs +163 -0
- package/src/config.mjs +57 -12
- package/src/doctor-hosts.mjs +192 -0
- package/src/doctor.mjs +16 -6
- package/src/export.mjs +11 -0
- package/src/git.mjs +58 -2
- package/src/guard.mjs +41 -13
- package/src/handoff-record.mjs +290 -30
- package/src/handoffs-help.mjs +143 -0
- package/src/handoffs-mode.mjs +486 -0
- package/src/handoffs-scope.mjs +98 -0
- package/src/help.mjs +72 -0
- package/src/host-integration.mjs +7 -1
- package/src/hud.mjs +174 -29
- package/src/index.mjs +11 -4
- package/src/init.mjs +255 -160
- package/src/install.mjs +35 -18
- package/src/installation.mjs +110 -27
- package/src/journal.mjs +2 -2
- package/src/lifecycle.mjs +22 -11
- package/src/managed-path.mjs +68 -4
- package/src/migrate.mjs +7 -0
- package/src/naming.mjs +26 -0
- package/src/new.mjs +202 -91
- package/src/pickup-card.mjs +1 -1
- package/src/plan-changes.mjs +49 -0
- package/src/prompt-handoff.mjs +16 -0
- package/src/prompts.mjs +101 -27
- package/src/rename.mjs +13 -0
- package/src/setup-carry.mjs +291 -0
- package/src/setup-waiting.mjs +20 -0
- package/src/setup-worker.mjs +257 -0
- package/src/setup.mjs +404 -0
- package/src/statuses.mjs +5 -2
- package/src/update.mjs +239 -79
- package/src/use.mjs +4 -2
- package/src/validate.mjs +7 -0
package/src/prompts.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { agentSessionSource, asString, toRepoPath, die, resolveDocPath, resolveRefPath, isArchivedPath } from './util.mjs';
|
|
5
|
-
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
5
|
+
import { buildIndex, collectDocFiles, parseDocFile, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { runQuery } from './query.mjs';
|
|
7
7
|
import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive } from './lifecycle.mjs';
|
|
8
8
|
import { runNew } from './new.mjs';
|
|
9
9
|
import { green, dim, yellow } from './color.mjs';
|
|
10
10
|
import { authorizeManagedSource } from './managed-path.mjs';
|
|
11
|
+
import { isHandoffsMode, managesFile, outsideHandoffs, outsideHandoffsLine } from './handoffs-scope.mjs';
|
|
11
12
|
import {
|
|
12
13
|
authoritativeSessionId,
|
|
13
14
|
classifyPlanPickup,
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
} from './pickup.mjs';
|
|
18
19
|
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
19
20
|
import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
|
|
20
|
-
import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
|
|
21
|
+
import { formatPlanChanges, planChangesSince, summarizePlanChanges } from './plan-changes.mjs';
|
|
21
22
|
import { groupPrompts, parentPromptName } from './prompt-handoff.mjs';
|
|
22
23
|
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
23
24
|
|
|
@@ -36,6 +37,7 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
36
37
|
const sub = argv[0];
|
|
37
38
|
|
|
38
39
|
if (sub && REMOVED_SUBCOMMANDS.has(sub)) {
|
|
40
|
+
if (isHandoffsMode(config)) die(`\`runlist prompts ${sub}\` was removed — a prompt is pending or archived, and nothing else.\nTo set one aside, \`runlist prompts archive <file>\`.`);
|
|
39
41
|
die(`\`runlist prompts ${sub}\` was removed — a prompt is pending or archived, and nothing else.\n`
|
|
40
42
|
+ 'A prompt directs work, so parking one instead of archiving it leaves work state outside the\n'
|
|
41
43
|
+ 'plan that owns it. Lift its content into that plan, then `runlist prompts archive <file>`.');
|
|
@@ -121,29 +123,60 @@ function childHandoffs(repoPath, config, index = null) {
|
|
|
121
123
|
if (!children.length) return { paths: [], notice: '' };
|
|
122
124
|
const parent = path.basename(repoPath, '.md');
|
|
123
125
|
const names = children.map(doc => `- \`${doc.path}\``).join('\n');
|
|
124
|
-
return { paths: children.map(doc => doc.path), notice: `\n\n## Saved child handoffs\n\n${children.length} child handoff(s) remain pending:\n\n${names}\n\nInspect a child with \`rl prompts show <name>\`. Review its current state and authorization before resuming its lane. Fold completed results into the owning plan, then archive the completed handoff explicitly.\n\nPass \`--parent ${parent}\` when saving another child, or pass \`RUNLIST_PARENT_PROMPT=${parent}\` into its environment.\n` };
|
|
126
|
+
return { paths: children.map(doc => doc.path), notice: `\n\n## Saved child handoffs\n\n${children.length} child handoff(s) remain pending:\n\n${names}\n\nInspect a child with \`rl prompts show <name>\`. Review its current state and authorization before resuming its lane. ${isHandoffsMode(config) ? 'Fold completed results into the parent\'s work' : 'Fold completed results into the owning plan'}, then archive the completed handoff explicitly.\n\nPass \`--parent ${parent}\` when saving another child, or pass \`RUNLIST_PARENT_PROMPT=${parent}\` into its environment.\n` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readPromptHeader(file) {
|
|
130
|
+
let fd;
|
|
131
|
+
try {
|
|
132
|
+
fd = openSync(file, 'r');
|
|
133
|
+
const byte = Buffer.alloc(1), bytes = [];
|
|
134
|
+
let line = '', lines = 0;
|
|
135
|
+
while (bytes.length < 16 * 1024 && readSync(fd, byte, 0, 1, null)) {
|
|
136
|
+
bytes.push(byte[0]);
|
|
137
|
+
line += String.fromCharCode(byte[0]);
|
|
138
|
+
if (byte[0] !== 10) continue;
|
|
139
|
+
const marker = line.trimEnd() === '---';
|
|
140
|
+
if (lines++ === 0 && !marker) return '';
|
|
141
|
+
if (lines > 1 && marker) return Buffer.from(bytes).toString('utf8');
|
|
142
|
+
line = '';
|
|
143
|
+
}
|
|
144
|
+
} catch { /* an unreadable or disappearing header cannot supply metadata */ }
|
|
145
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
146
|
+
return '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function promptListIndex(config) {
|
|
150
|
+
const docs = [];
|
|
151
|
+
// Discovery stays inside the configured roots. Type filtering happens on
|
|
152
|
+
// the header below, including in handoffs mode, without its whole-file read.
|
|
153
|
+
for (const file of collectDocFiles({ ...config, mode: 'planning' })) {
|
|
154
|
+
const source = readPromptHeader(file);
|
|
155
|
+
const fm = parseSimpleFrontmatter(extractFrontmatter(source).frontmatter);
|
|
156
|
+
if (asString(fm.type) === 'prompt') {
|
|
157
|
+
docs.push({ ...parseDocFile(file, config, { fast: true, source }), plan: asString(fm.plan) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { docs };
|
|
125
161
|
}
|
|
126
162
|
|
|
127
163
|
function runPromptsList(argv, config, opts = {}) {
|
|
128
|
-
// Listing prompts renders slug / status / updated / target — nothing any
|
|
129
|
-
// validating pass produces, and `runQuery` never reads warnings either. A
|
|
130
|
-
// full `buildIndex` here parsed every body in the repo through the ref,
|
|
131
|
-
// git-staleness and hub passes just to print a small prompt list,
|
|
132
|
-
// and then `pendingPromptsOldestFirst` scanned the tree a second time. One
|
|
133
|
-
// fast index, shared, on the same terms hud reads on.
|
|
134
|
-
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
135
164
|
const hasStatusFlag = argv.includes('--status');
|
|
136
165
|
const includeArchived = argv.includes('--include-archived');
|
|
137
166
|
const sub = argv[0];
|
|
138
167
|
const json = argv.includes('--json');
|
|
139
168
|
|
|
169
|
+
const hasPositionalFilter = argv.some(a => !a.startsWith('-') && a !== 'list');
|
|
170
|
+
const defaultList = !json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status'
|
|
171
|
+
&& !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all');
|
|
172
|
+
const index = defaultList && !opts.verbose ? promptListIndex(config) : buildIndex(config, { fast: true, invokeHooks: false });
|
|
173
|
+
|
|
140
174
|
if (opts.verbose && !json) {
|
|
141
175
|
renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived });
|
|
142
176
|
return;
|
|
143
177
|
}
|
|
144
178
|
|
|
145
|
-
|
|
146
|
-
if (!json && !hasStatusFlag && !includeArchived && !hasPositionalFilter && sub !== 'status' && !argv.some(a => a.startsWith('--sort') || a.startsWith('--limit') || a === '--all')) {
|
|
179
|
+
if (defaultList) {
|
|
147
180
|
renderPromptQueueList(index, config);
|
|
148
181
|
return;
|
|
149
182
|
}
|
|
@@ -180,37 +213,67 @@ function renderPromptQueueList(index, config) {
|
|
|
180
213
|
process.stdout.write(dim(`${prompts.length} prompts · ${summary}`) + '\n\n');
|
|
181
214
|
|
|
182
215
|
const groups = groupPrompts(prompts);
|
|
216
|
+
const freshness = promptFreshness(prompts, config);
|
|
183
217
|
const isAgentHandoff = doc => !path.basename(doc.path).startsWith('resume-')
|
|
184
218
|
&& (doc.parentPrompt || /^(?:subagent|agent|session)-/.test(path.basename(doc.path)));
|
|
185
219
|
const standalone = groups.filter(group => isAgentHandoff(group.doc));
|
|
186
220
|
const visible = groups.filter(group => !isAgentHandoff(group.doc));
|
|
187
221
|
const maxSlug = Math.min(36, Math.max(0, ...visible.map(({ doc }) => path.basename(doc.path, '.md').length)));
|
|
188
|
-
const nextPath = queue[0]?.doc.path;
|
|
189
222
|
const childCount = children => `[+${children.length} subagent]`;
|
|
223
|
+
const childFreshness = children => {
|
|
224
|
+
const states = new Map();
|
|
225
|
+
for (const child of children) {
|
|
226
|
+
const state = freshness.get(child).change;
|
|
227
|
+
states.set(state, (states.get(state) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
const ages = children.map(child => freshness.get(child).age).filter(Number.isFinite);
|
|
230
|
+
const age = ages.length === children.length ? `${Math.min(...ages)}-${Math.max(...ages)}d old` : 'age unknown';
|
|
231
|
+
return `${age}; targets: ${[...states].map(([state, count]) => `${count} ${state}`).join(', ')}`;
|
|
232
|
+
};
|
|
190
233
|
for (const { doc: p, children } of visible) {
|
|
191
234
|
const slug = path.basename(p.path, '.md').padEnd(maxSlug);
|
|
192
|
-
const marker = p.path === nextPath ? green('[NEXT]') : ' ';
|
|
193
235
|
const status = `[${(p.status ?? 'unknown').toUpperCase()}]`;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
process.stdout.write(`
|
|
236
|
+
const badge = children.length ? ` ${dim(childCount(children))}` : '';
|
|
237
|
+
process.stdout.write(` ${slug} ${status}${badge} · ${freshness.get(p).text}\n`);
|
|
238
|
+
if (children.length) process.stdout.write(dim(` children: ${childFreshness(children)}\n`));
|
|
197
239
|
}
|
|
198
240
|
if (standalone.length) {
|
|
199
241
|
const children = standalone.map(group => group.doc);
|
|
200
|
-
|
|
201
|
-
process.stdout.write(` ${marker} ${childCount(children)}\n`);
|
|
242
|
+
process.stdout.write(` ${childCount(children)} · ${childFreshness(children)}\n`);
|
|
202
243
|
}
|
|
203
244
|
if (standalone.length || visible.some(group => group.children.length)) {
|
|
204
245
|
process.stdout.write(dim('\nUse `rl prompts --verbose` to expand subagent prompts.\n'));
|
|
205
246
|
}
|
|
206
247
|
}
|
|
207
248
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
249
|
+
function promptFreshness(prompts, config) {
|
|
250
|
+
const resolved = new Map();
|
|
251
|
+
const targets = prompts.map(doc => {
|
|
252
|
+
if (!doc.plan) return findPromptTarget(doc, config);
|
|
253
|
+
const key = `${path.dirname(doc.path)}\0${doc.plan}`;
|
|
254
|
+
if (!resolved.has(key)) resolved.set(key, findPromptTarget(doc, config));
|
|
255
|
+
return resolved.get(key);
|
|
256
|
+
});
|
|
257
|
+
const changes = summarizePlanChanges(prompts.map((doc, i) => ({ plan: targets[i], since: doc.created })), config);
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
return new Map(prompts.map((doc, i) => {
|
|
260
|
+
const time = doc.created ? new Date(doc.created).getTime() : NaN;
|
|
261
|
+
const age = Number.isFinite(time) && time <= now ? Math.floor((now - time) / 86_400_000) : null;
|
|
262
|
+
const target = targets[i]?.replace(/\s/g, ' ');
|
|
263
|
+
return [doc, { age, change: changes[i], text: `${age === null ? 'age unknown' : `${age}d old`} · ${target ?? 'target unknown'}: ${changes[i]}` }];
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Explicit baton links win, then related_plans and parent_plan. Verbose
|
|
268
|
+
// listings also retain the first body link fallback from the full index.
|
|
212
269
|
// Returns a repo-relative display path or null.
|
|
213
270
|
function findPromptTarget(promptDoc, config) {
|
|
271
|
+
if (promptDoc.plan) {
|
|
272
|
+
const promptPath = path.resolve(config.repoRoot, promptDoc.path);
|
|
273
|
+
const fallbackRoot = promptDoc.plan.startsWith(config.docsRootPrefix || 'docs/') ? config.repoRoot : path.dirname(promptPath);
|
|
274
|
+
return linkedPlanRepoPath(promptDoc.plan, promptPath, config)
|
|
275
|
+
?? toRepoPath(path.resolve(fallbackRoot, promptDoc.plan), config.repoRoot);
|
|
276
|
+
}
|
|
214
277
|
const refs = promptDoc.refFields ?? {};
|
|
215
278
|
const fmTargets = [...(refs.related_plans ?? []), ...(refs.parent_plan ?? [])];
|
|
216
279
|
for (const t of fmTargets) {
|
|
@@ -245,6 +308,11 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
245
308
|
if (!hasStatusFlag && !includeArchived) {
|
|
246
309
|
prompts = prompts.filter(d => d.status !== 'archived');
|
|
247
310
|
}
|
|
311
|
+
prompts = prompts.map(doc => {
|
|
312
|
+
const header = readPromptHeader(path.resolve(config.repoRoot, doc.path));
|
|
313
|
+
const fm = parseSimpleFrontmatter(extractFrontmatter(header).frontmatter);
|
|
314
|
+
return { ...doc, plan: asString(fm.plan) };
|
|
315
|
+
});
|
|
248
316
|
if (prompts.length === 0) {
|
|
249
317
|
process.stdout.write('No prompts.\n');
|
|
250
318
|
return;
|
|
@@ -256,6 +324,7 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
256
324
|
for (const p of prompts) counts[p.status ?? 'unknown'] = (counts[p.status ?? 'unknown'] ?? 0) + 1;
|
|
257
325
|
const summary = Object.entries(counts).map(([s, n]) => `${n} ${s}`).join(' · ');
|
|
258
326
|
process.stdout.write(`${prompts.length} prompt${prompts.length === 1 ? '' : 's'} · ${summary}\n\n`);
|
|
327
|
+
const freshness = promptFreshness(prompts, config);
|
|
259
328
|
|
|
260
329
|
const render = (p, child) => {
|
|
261
330
|
const slug = path.basename(p.path, '.md');
|
|
@@ -263,7 +332,7 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
263
332
|
const status = (p.status ?? 'unknown').toUpperCase();
|
|
264
333
|
const arrow = target ? ` ${dim('→')} ${target}` : ` ${dim('→ (no target plan)')}`;
|
|
265
334
|
const indent = child ? ' - ' : ' ';
|
|
266
|
-
process.stdout.write(`${indent}${green(slug)} [${status}]\n${child ? ' ' : ''}${arrow}\n`);
|
|
335
|
+
process.stdout.write(`${indent}${green(slug)} [${status}] · ${freshness.get(p).text}\n${child ? ' ' : ''}${arrow}\n`);
|
|
267
336
|
};
|
|
268
337
|
for (const {doc,children} of groupPrompts(prompts)) {
|
|
269
338
|
render(doc, false);
|
|
@@ -305,12 +374,17 @@ async function runPromptsNext(argv, config, opts = {}) {
|
|
|
305
374
|
// helpful message (no match / ambiguous match).
|
|
306
375
|
export function resolvePromptInput(input, config, options = {}) {
|
|
307
376
|
const dieOnMiss = options.dieOnMiss !== false;
|
|
377
|
+
// A handoffs-only project's other Markdown is never a prompt to resolve.
|
|
378
|
+
if (outsideHandoffs(input, config)) {
|
|
379
|
+
if (dieOnMiss) die(outsideHandoffsLine(input));
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
308
382
|
const direct = resolveDocPath(input, config);
|
|
309
|
-
if (direct) return direct;
|
|
383
|
+
if (direct && managesFile(direct, config)) return direct;
|
|
310
384
|
|
|
311
385
|
if (!input.endsWith('.md')) {
|
|
312
386
|
const withExt = resolveDocPath(input + '.md', config);
|
|
313
|
-
if (withExt) return withExt;
|
|
387
|
+
if (withExt && managesFile(withExt, config)) return withExt;
|
|
314
388
|
}
|
|
315
389
|
|
|
316
390
|
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
package/src/rename.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
|
|
|
11
11
|
import { moveFileAtomic } from './atomic-mutation.mjs';
|
|
12
12
|
import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
|
|
13
13
|
import { reportMovedCodeRefs } from './code-refs.mjs';
|
|
14
|
+
import { isHandoffsMode, readsAsPrompt } from './handoffs-scope.mjs';
|
|
15
|
+
import { promptDirectory } from './new.mjs';
|
|
16
|
+
import { PROMPT_DIR } from './naming.mjs';
|
|
14
17
|
|
|
15
18
|
export async function runRename(argv, config, opts = {}) {
|
|
16
19
|
const { dryRun } = opts;
|
|
@@ -35,6 +38,16 @@ export async function runRename(argv, config, opts = {}) {
|
|
|
35
38
|
} else {
|
|
36
39
|
newPath = path.join(path.dirname(oldPath), newInput.endsWith('.md') ? newInput : `${newInput}.md`);
|
|
37
40
|
}
|
|
41
|
+
// One prompt location (src/naming.mjs): before any config and where
|
|
42
|
+
// planning is off, a prompt moves within the prompt folder at the project's
|
|
43
|
+
// top and nowhere else, so a rename never starts a second docs/prompts/.
|
|
44
|
+
if ((!config.configFound || isHandoffsMode(config)) && readsAsPrompt(oldPath)) {
|
|
45
|
+
const folder = config.configFound ? promptDirectory(config) : path.join(config.repoRoot, ...PROMPT_DIR.split('/'));
|
|
46
|
+
const relative = path.relative(folder, newPath);
|
|
47
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
48
|
+
die(`Nothing renamed: prompts live in ${toRepoPath(folder, config.repoRoot)}/, and ${toRepoPath(newPath, config.repoRoot)} is outside it. Choose a name inside ${toRepoPath(folder, config.repoRoot)}/.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
38
51
|
if (existsSync(newPath)) die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
|
|
39
52
|
newPath = authorizeManagedDestination(newPath, config, { root: sourceAuthorization.root, kind: 'Rename destination' }).path;
|
|
40
53
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promptDirectory, planDirectory } from './new.mjs';
|
|
4
|
+
import { archiveBaseFor } from './lifecycle.mjs';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
7
|
+
import { authorizeManagedSource, authorizeManagedSweep, authorizeRepoGeneratedPath, inLegacyPromptFolder } from './managed-path.mjs';
|
|
8
|
+
import { moveFileAtomic } from './atomic-mutation.mjs';
|
|
9
|
+
import { configuredReferenceFields, planReferenceMove } from './reference-planner.mjs';
|
|
10
|
+
import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
|
|
11
|
+
import { listPendingHandoffs } from './handoff-record.mjs';
|
|
12
|
+
import { captureGitIndexGeneration, isTracked } from './git.mjs';
|
|
13
|
+
import { toRepoPath } from './util.mjs';
|
|
14
|
+
import { locateConfig, resolveConfig } from './config.mjs';
|
|
15
|
+
import { CONFIG_FILENAMES, LEGACY_PROMPT_DIR, RETIRED_PROMPT_STATUSES, rootIsWorkingFolder } from './naming.mjs';
|
|
16
|
+
import { statusMetadataFor } from './status-metadata.mjs';
|
|
17
|
+
|
|
18
|
+
function markdownFiles(directory, config) {
|
|
19
|
+
authorizeRepoGeneratedPath(directory, config, { kind: 'Setup carry-over folder' });
|
|
20
|
+
if (!existsSync(directory)) return [];
|
|
21
|
+
if (!lstatSync(directory).isDirectory()) throw new Error(`Carry-over folder must be a regular directory: ${directory}`);
|
|
22
|
+
return readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.endsWith('.md'))
|
|
23
|
+
.map(entry => path.join(directory, entry.name)).sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function directories(config, type) {
|
|
27
|
+
const live = type === 'prompt' ? promptDirectory(config) : planDirectory(config);
|
|
28
|
+
const archive = path.join(archiveBaseFor(path.join(live, 'document.md'), config.docsRoot, type, config), config.archiveDir);
|
|
29
|
+
return [live, archive];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Live/archive pairs to carry from: the resolver's pre-config folders.
|
|
33
|
+
function sourceDirectories(config, type) {
|
|
34
|
+
return directories(config, type);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The root prompts/ older builds saved prompts in before any config. Nothing
|
|
38
|
+
// writes there now (src/naming.mjs); every prompt below it, at any depth, is
|
|
39
|
+
// carried with its folder relative to prompts/ kept.
|
|
40
|
+
export function legacyPromptRoot(config) {
|
|
41
|
+
return rootIsWorkingFolder(config) ? path.join(config.docsRoot, ...LEGACY_PROMPT_DIR.split('/')) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function legacyMarkdownFiles(root, config) {
|
|
45
|
+
authorizeRepoGeneratedPath(root, config, { kind: 'Setup carry-over folder' });
|
|
46
|
+
if (!existsSync(root)) return [];
|
|
47
|
+
if (!lstatSync(root).isDirectory()) throw new Error(`Carry-over folder must be a regular directory: ${root}`);
|
|
48
|
+
const files = [];
|
|
49
|
+
// A folder's own files before its subfolders, so live prompts are planned
|
|
50
|
+
// ahead of the archive below them, as they always were.
|
|
51
|
+
const walk = directory => {
|
|
52
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
for (const entry of entries) if (entry.isFile() && entry.name.endsWith('.md')) files.push(path.join(directory, entry.name));
|
|
54
|
+
for (const entry of entries) if (entry.isDirectory()) walk(path.join(directory, entry.name));
|
|
55
|
+
};
|
|
56
|
+
walk(root);
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function carryKey(target) {
|
|
61
|
+
return target.normalize('NFC').toLowerCase().normalize('NFC');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The project as it read before any config: the docs root is the project's
|
|
65
|
+
// top, so the folders an older runlist saved plans and prompts in are the
|
|
66
|
+
// carry's sources. Setup turning planning on in a handoffs-only project
|
|
67
|
+
// carries from here too, under the same roots a fresh setup uses.
|
|
68
|
+
export async function preConfigView(top) {
|
|
69
|
+
const view = await resolveConfig(top, path.join(top, `${CONFIG_FILENAMES[0]}.pre-config`));
|
|
70
|
+
return { ...view, configFound: false, configPath: null, mode: 'plans', planning: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only the resolver's pre-config locations qualify. An unrelated prompts
|
|
74
|
+
// library, arbitrary Markdown, and files under an existing config are not
|
|
75
|
+
// adoption candidates. Status vocabularies come from the resolved defaults.
|
|
76
|
+
export function planSetupCarry(before, after, enabled = true, { types = ['prompt', 'plan'] } = {}) {
|
|
77
|
+
const result = { moves: [], outstanding: [], remaining: [] };
|
|
78
|
+
if (before.configFound) return result;
|
|
79
|
+
const candidates = new Set();
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const targets = new Set();
|
|
82
|
+
const consider = ({ source, target, type, expectedSource }) => {
|
|
83
|
+
if (source === target) return;
|
|
84
|
+
authorizeManagedSource(source, before, { kind: 'Setup carry-over source' });
|
|
85
|
+
authorizeRepoGeneratedPath(target, after, { kind: 'Setup carry-over destination' });
|
|
86
|
+
const from = toRepoPath(source, before.repoRoot), to = toRepoPath(target, before.repoRoot);
|
|
87
|
+
if (!enabled) return;
|
|
88
|
+
const targetKey = carryKey(target);
|
|
89
|
+
if (existsSync(target) || targets.has(targetKey)) {
|
|
90
|
+
result.outstanding.push({ description: `Carry-over collision: ${from} → ${to}; ${targets.has(targetKey) ? 'collides with another file being carried' : 'destination exists'}, source left in place`, path: source });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try { if (type === 'plan') prepareOwnershipMigration(from, target, before, { sessionId: availableSessionId() }); }
|
|
94
|
+
catch (error) {
|
|
95
|
+
result.outstanding.push({ description: `Carry-over refused for ${from}: ${error.message}`, path: source });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
result.moves.push({ source, target, from, to, type, expectedSource });
|
|
99
|
+
targets.add(targetKey);
|
|
100
|
+
};
|
|
101
|
+
for (const type of types) {
|
|
102
|
+
const oldDirs = sourceDirectories(before, type);
|
|
103
|
+
const newDirs = directories(after, type);
|
|
104
|
+
for (const [i, directory] of oldDirs.entries()) {
|
|
105
|
+
if (directory === newDirs[i % 2]) continue;
|
|
106
|
+
for (const source of markdownFiles(directory, before)) {
|
|
107
|
+
candidates.add(source);
|
|
108
|
+
if (seen.has(source)) continue;
|
|
109
|
+
const expectedSource = readFileSync(source, 'utf8');
|
|
110
|
+
const parsed = parseSimpleFrontmatter(extractFrontmatter(expectedSource).frontmatter ?? '');
|
|
111
|
+
if (parsed.type !== type || !before.typeStatuses.get(type)?.has(parsed.status)) continue;
|
|
112
|
+
const archiveStatus = statusMetadataFor(before, type, parsed.status)?.archive;
|
|
113
|
+
// The shared root-level archive is also a common user folder. Its
|
|
114
|
+
// location alone is no evidence that runlist archived this document.
|
|
115
|
+
if (directory === path.join(before.repoRoot, before.archiveDir) && !archiveStatus) continue;
|
|
116
|
+
seen.add(source);
|
|
117
|
+
const archived = i % 2 === 1 || archiveStatus;
|
|
118
|
+
consider({ source, target: path.join(newDirs[archived ? 1 : 0], path.basename(source)), type, expectedSource });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// A `type: prompt` file anywhere below the root prompts/, with a status
|
|
122
|
+
// runlist gives prompts or one an older build did, is one runlist saved
|
|
123
|
+
// there; everything else there is the user's and stays.
|
|
124
|
+
const legacy = type === 'prompt' ? legacyPromptRoot(before) : null;
|
|
125
|
+
if (!legacy) continue;
|
|
126
|
+
for (const source of legacyMarkdownFiles(legacy, before)) {
|
|
127
|
+
candidates.add(source);
|
|
128
|
+
if (seen.has(source)) continue;
|
|
129
|
+
const expectedSource = readFileSync(source, 'utf8');
|
|
130
|
+
const parsed = parseSimpleFrontmatter(extractFrontmatter(expectedSource).frontmatter ?? '');
|
|
131
|
+
if (parsed.type !== 'prompt' || !(before.typeStatuses.get(type)?.has(parsed.status) || RETIRED_PROMPT_STATUSES.has(parsed.status))) continue;
|
|
132
|
+
seen.add(source);
|
|
133
|
+
const relative = path.relative(legacy, source);
|
|
134
|
+
const archivedAlready = relative.split(path.sep).slice(0, -1).includes(before.archiveDir);
|
|
135
|
+
const archiveStatus = before.typeStatuses.get(type)?.has(parsed.status) && statusMetadataFor(before, type, parsed.status)?.archive;
|
|
136
|
+
consider({ source, target: path.join(newDirs[archiveStatus && !archivedAlready ? 1 : 0], relative), type, expectedSource });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
result.remaining = [...candidates].filter(file => !result.moves.some(move => move.source === file));
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Everything left in the root prompts/ (what the carry does not move) is the
|
|
144
|
+
// user's, by its path or through a symlinked folder: the carry never rewrites
|
|
145
|
+
// it, and a link there to a carried file is reported instead.
|
|
146
|
+
function staysInLegacyFolder(file, config, carried) {
|
|
147
|
+
return !carried.has(path.resolve(file)) && inLegacyPromptFolder(file, config.repoRoot);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function carryReferenceFields(config) {
|
|
151
|
+
return [...new Set([...configuredReferenceFields(config), 'plan', 'parent_prompt'])];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Links to a carried file from Markdown runlist does not manage, which the
|
|
155
|
+
// move's reference sweep never rewrites: a README with no frontmatter that
|
|
156
|
+
// points at prompts/older.md, say, or any document that stays in the root
|
|
157
|
+
// prompts/. Reported for the user to update, never edited. Read only on the
|
|
158
|
+
// explicit save, init and setup paths.
|
|
159
|
+
export function unmanagedCarryReferences(config, moves) {
|
|
160
|
+
if (!moves.length) return [];
|
|
161
|
+
const carried = new Set(moves.map(move => path.resolve(move.source)));
|
|
162
|
+
const plain = [];
|
|
163
|
+
for (const file of collectDocFiles(config)) {
|
|
164
|
+
if (carried.has(path.resolve(file))) continue;
|
|
165
|
+
let content;
|
|
166
|
+
try { content = readFileSync(file, 'utf8'); } catch { continue; }
|
|
167
|
+
if (extractFrontmatter(content).frontmatter) {
|
|
168
|
+
if (staysInLegacyFolder(file, config, carried)) plain.push({ path: file, content });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Lent a frontmatter block so the planner reads the body; never written.
|
|
172
|
+
plain.push({ path: file, content: `---\nrunlist-probe: true\n---\n${content}` });
|
|
173
|
+
}
|
|
174
|
+
if (!plain.length) return [];
|
|
175
|
+
const referenceFields = carryReferenceFields(config);
|
|
176
|
+
const found = [];
|
|
177
|
+
for (const move of moves) {
|
|
178
|
+
const documents = [{ path: move.source, content: move.expectedSource }, ...plain];
|
|
179
|
+
const { updates } = planReferenceMove({ documents, oldPath: move.source, newPath: move.target, repoRoot: config.repoRoot, referenceFields });
|
|
180
|
+
for (const update of updates) found.push({ file: toRepoPath(update.path, config.repoRoot), from: move.from, to: move.to });
|
|
181
|
+
}
|
|
182
|
+
return found.sort((a, b) => a.file.localeCompare(b.file) || a.from.localeCompare(b.from));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Only clean parents of sources moved by this run or its recovery. An empty
|
|
186
|
+
// default folder alone does not establish that setup owns its cleanup.
|
|
187
|
+
export function cleanupSetupSourceDirectories(config, movedSources) {
|
|
188
|
+
if (config.configFound) return;
|
|
189
|
+
const legacy = legacyPromptRoot(config);
|
|
190
|
+
const legacyFolders = legacy ? movedSources.flatMap(source => {
|
|
191
|
+
const folders = [];
|
|
192
|
+
for (let directory = path.dirname(source); directory.startsWith(`${legacy}${path.sep}`); directory = path.dirname(directory)) folders.push(directory);
|
|
193
|
+
return path.dirname(source) === legacy || folders.length ? [...folders, legacy] : [];
|
|
194
|
+
}) : [];
|
|
195
|
+
const sources = [...new Set([...['prompt', 'plan'].flatMap(type => sourceDirectories(config, type)), ...legacyFolders])].filter(directory =>
|
|
196
|
+
movedSources.some(source => path.dirname(source) === directory || path.dirname(source).startsWith(`${directory}${path.sep}`)));
|
|
197
|
+
for (const directory of sources.sort((a, b) => b.length - a.length)) {
|
|
198
|
+
if (directory === config.repoRoot) continue;
|
|
199
|
+
authorizeRepoGeneratedPath(directory, config, { kind: 'Setup source folder' });
|
|
200
|
+
try { rmdirSync(directory); }
|
|
201
|
+
catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Carry before publishing the first config, so a retained move transaction is
|
|
206
|
+
// recoverable under the same (unconfigured) roots that authorized its source.
|
|
207
|
+
// Each move is transactional; setup itself remains a sequence of stages.
|
|
208
|
+
// `expectedConfig` ({ path, content }) is the handoffs-only config setup is
|
|
209
|
+
// about to upgrade: it must stay exactly as previewed, and no other config may
|
|
210
|
+
// appear, for the whole carry.
|
|
211
|
+
export function applySetupCarry(moves, config, { testHooks, expectedConfig = null } = {}) {
|
|
212
|
+
const completed = [];
|
|
213
|
+
const createdDirectories = [];
|
|
214
|
+
const expectedSources = new Map(moves.map(move => [move.source, move.expectedSource]));
|
|
215
|
+
const carried = new Set(moves.map(move => path.resolve(move.source)));
|
|
216
|
+
const configUnchanged = () => {
|
|
217
|
+
if (!expectedConfig) return !config.configFound && !locateConfig(config.configDir).configPath;
|
|
218
|
+
try { return locateConfig(config.configDir).configPath === expectedConfig.path && readFileSync(expectedConfig.path, 'utf8') === expectedConfig.content; }
|
|
219
|
+
catch { return false; }
|
|
220
|
+
};
|
|
221
|
+
const configGuards = CONFIG_FILENAMES.map(name => path.join(config.configDir, name)).map(file =>
|
|
222
|
+
expectedConfig && path.resolve(file) === path.resolve(expectedConfig.path) ? { path: file, expectedContent: expectedConfig.content } : { path: file, absent: true });
|
|
223
|
+
try {
|
|
224
|
+
const checkConfig = () => {
|
|
225
|
+
if (!configUnchanged()) throw new Error(expectedConfig ? 'The config changed after the carry-over preview; rerun setup.' : 'A config appeared after the carry-over preview; rerun setup.');
|
|
226
|
+
};
|
|
227
|
+
checkConfig();
|
|
228
|
+
for (const move of moves) {
|
|
229
|
+
if (readFileSync(move.source, 'utf8') !== move.expectedSource) throw new Error(`Carry-over source changed after preview: ${move.from}`);
|
|
230
|
+
}
|
|
231
|
+
for (const move of moves) {
|
|
232
|
+
// What stays in the root prompts/ is the user's and is never rewritten;
|
|
233
|
+
// unmanagedCarryReferences lists its links to a carried file.
|
|
234
|
+
const files = collectDocFiles(config).filter(file => !staysInLegacyFolder(file, config, carried));
|
|
235
|
+
authorizeManagedSweep(files, config, { kind: 'Setup carry-over reference source' });
|
|
236
|
+
const documents = files.map(file => ({ path: file, content: readFileSync(file, 'utf8') }));
|
|
237
|
+
const references = planReferenceMove({ documents, oldPath: move.source, newPath: move.target, repoRoot: config.repoRoot, referenceFields: carryReferenceFields(config) });
|
|
238
|
+
const ownership = move.type === 'plan' ? prepareOwnershipMigration(move.from, move.target, config, { sessionId: availableSessionId() }) : null;
|
|
239
|
+
const handoffs = listPendingHandoffs(config).flatMap(record => {
|
|
240
|
+
const expectedContent = readFileSync(record.recordFile, 'utf8');
|
|
241
|
+
const data = JSON.parse(expectedContent);
|
|
242
|
+
let changed = false;
|
|
243
|
+
for (const field of ['prompt', 'plan']) if (data[field] === move.from) { data[field] = move.to; changed = true; }
|
|
244
|
+
if (!changed) return [];
|
|
245
|
+
authorizeRepoGeneratedPath(record.recordFile, config, { kind: 'Setup handoff record' });
|
|
246
|
+
return [{ path: record.recordFile, expectedContent, content: JSON.stringify(data, null, 2) + '\n', label: 'handoff' }];
|
|
247
|
+
});
|
|
248
|
+
const tracked = isTracked(move.source, config.repoRoot);
|
|
249
|
+
// Scaffold directories belong to setup, never to the move transaction.
|
|
250
|
+
// Keep failed carry cleanup separate from transaction recovery; recovery
|
|
251
|
+
// evidence must be resolved under the old roots before setup writes config.
|
|
252
|
+
for (const directory of [path.dirname(move.target), ...(ownership ? [path.dirname(ownership.newRecordPath)] : [])]) {
|
|
253
|
+
authorizeRepoGeneratedPath(directory, config, { kind: 'Setup carry-over folder' });
|
|
254
|
+
const missing = [];
|
|
255
|
+
for (let next = directory; !existsSync(next); next = path.dirname(next)) missing.push(next);
|
|
256
|
+
for (const next of missing.reverse()) {
|
|
257
|
+
try { mkdirSync(next); }
|
|
258
|
+
catch (error) { if (error.code === 'EEXIST') continue; throw error; }
|
|
259
|
+
createdDirectories.push({ path: next, identity: lstatSync(next) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
checkConfig();
|
|
263
|
+
moveFileAtomic(move.source, move.target, references.movedContent, {
|
|
264
|
+
repoRoot: config.repoRoot, config, expectedSourceContent: expectedSources.get(move.source),
|
|
265
|
+
updates: [...references.updates, ...handoffs],
|
|
266
|
+
creations: ownership ? [{ path: ownership.newRecordPath, content: ownership.newContent, label: 'ownership' }] : [],
|
|
267
|
+
deletions: ownership ? [{ path: ownership.oldRecordPath, expectedContent: ownership.oldContent, label: 'ownership' }] : [],
|
|
268
|
+
guards: configGuards,
|
|
269
|
+
gitMove: tracked, gitIndex: tracked ? captureGitIndexGeneration(config.repoRoot) : null,
|
|
270
|
+
operation: 'move', sessionId: availableSessionId(), testHooks: { ...testHooks, beforeMoveSnapshot(info) {
|
|
271
|
+
testHooks?.beforeMoveSnapshot?.(info);
|
|
272
|
+
checkConfig();
|
|
273
|
+
} },
|
|
274
|
+
});
|
|
275
|
+
for (const update of references.updates) if (expectedSources.has(update.path)) expectedSources.set(update.path, update.content);
|
|
276
|
+
completed.push(move);
|
|
277
|
+
cleanupSetupSourceDirectories(config, [move.source]);
|
|
278
|
+
}
|
|
279
|
+
return { completed };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (configUnchanged()) {
|
|
282
|
+
for (const entry of createdDirectories.reverse()) {
|
|
283
|
+
try {
|
|
284
|
+
const stat = lstatSync(entry.path);
|
|
285
|
+
if (stat.isDirectory() && stat.dev === entry.identity.dev && stat.ino === entry.identity.ino) rmdirSync(entry.path);
|
|
286
|
+
} catch { /* Keep nonempty/replaced directories and preserve the original failure. */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { completed, error: error.message };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveConfig } from './config.mjs';
|
|
2
|
+
import { planHandoffsUpgrade } from './handoffs-mode.mjs';
|
|
3
|
+
import { planSetupCarry, preConfigView } from './setup-carry.mjs';
|
|
4
|
+
|
|
5
|
+
// In a handoffs-only project, the plans and prompts an older runlist saved
|
|
6
|
+
// before there was a config, which `runlist setup` carries in when it turns
|
|
7
|
+
// planning on (src/setup-carry.mjs): counted by setup's own carry planner, so
|
|
8
|
+
// the planning-off line says exactly what setup will bring in. The carry reads
|
|
9
|
+
// the old default folders only (plans/, the root archive, the root prompts/),
|
|
10
|
+
// never the whole project, and nothing here runs on a hook path. Counting
|
|
11
|
+
// never stops a command: anything it cannot read counts as nothing.
|
|
12
|
+
export async function documentsWaitingForSetup(config) {
|
|
13
|
+
if (config?.mode !== 'handoffs' || !config.configPath) return 0;
|
|
14
|
+
try {
|
|
15
|
+
const upgrade = planHandoffsUpgrade(config);
|
|
16
|
+
const after = await resolveConfig(config.configDir, config.configPath, { overrides: upgrade.overrides });
|
|
17
|
+
const carry = planSetupCarry(await preConfigView(config.repoRoot), after, true);
|
|
18
|
+
return carry.moves.length + carry.outstanding.length;
|
|
19
|
+
} catch { return 0; }
|
|
20
|
+
}
|