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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmdirSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { locateConfig, resolveConfig } from './config.mjs';
|
|
6
|
+
import { createFileExclusive, inspectTransactions, MutationConflictError, recoverAbandonedTransactions, withPathLocks } from './atomic-mutation.mjs';
|
|
7
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
8
|
+
import { authorizeRepoGeneratedPath, inLegacyPromptFolder, legacyPromptFolderPlace } from './managed-path.mjs';
|
|
9
|
+
import { CONFIG_FILENAMES, LEGACY_PROMPT_DIR, PROMPT_DIR } from './naming.mjs';
|
|
10
|
+
import { dim } from './color.mjs';
|
|
11
|
+
import { die } from './util.mjs';
|
|
12
|
+
|
|
13
|
+
// Handoffs-only mode: most people who try runlist want "save where we are" and
|
|
14
|
+
// "pick up where we left off", not a planning system. So the first prompt saved
|
|
15
|
+
// in a repository with no config creates the smallest config that makes every
|
|
16
|
+
// existing verb work — a root that IS the prompt folder, docs/prompts/ (the
|
|
17
|
+
// scan never sees the project's own docs, no generated index, no plan
|
|
18
|
+
// vocabulary to nag about) and `mode: 'handoffs'` so wording and plan verbs
|
|
19
|
+
// know planning is off. Inside that folder runlist manages only `type: prompt`
|
|
20
|
+
// files (src/handoffs-scope.mjs). `runlist setup` later rewrites those two
|
|
21
|
+
// lines to turn planning on, widening the root to docs/.
|
|
22
|
+
//
|
|
23
|
+
// Creating the config is split in two so a refused save leaves nothing behind:
|
|
24
|
+
// `planHandoffsConfig` decides everything and writes nothing (the caller runs
|
|
25
|
+
// its own checks against the proposed layout, and dry-run stops there), then
|
|
26
|
+
// `publishHandoffsConfig` writes the folder and the config. Prompts an older
|
|
27
|
+
// build saved in a root prompts/ are carried into docs/prompts/ between the two
|
|
28
|
+
// (`carryLegacyPrompts`), so the new config hides none of them.
|
|
29
|
+
|
|
30
|
+
export const HANDOFFS_CONFIG_FILE = 'runlist.config.mjs';
|
|
31
|
+
// The planning-off wording and the gate that prints it live with the scope
|
|
32
|
+
// rules (src/handoffs-scope.mjs), which the resolver and dispatcher import.
|
|
33
|
+
export { isHandoffsMode, outsideHandoffsLine, PLANNING_OFF, planningRefusal, requirePlanning } from './handoffs-scope.mjs';
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
// For wording on paths that locate a config without importing it (`baton
|
|
37
|
+
// --template`): the mode line as written, and no config at all reads as
|
|
38
|
+
// handoffs only, since nothing there plans yet.
|
|
39
|
+
export function readsAsHandoffsOnly(located) {
|
|
40
|
+
if (located?.mode) return located.mode === 'handoffs';
|
|
41
|
+
if (!located?.configPath) return true;
|
|
42
|
+
try { return /^export const mode = (['"])handoffs\1;?[ \t]*$/m.test(readFileSync(located.configPath, 'utf8')); }
|
|
43
|
+
catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function handoffsConfigSource(promptTracking = 'untracked') {
|
|
47
|
+
return `// runlist.config.mjs — created by the first \`runlist baton\` here, for session handoffs.
|
|
48
|
+
// Planning is off: runlist only manages the prompts in ${PROMPT_DIR}/.
|
|
49
|
+
// \`runlist setup\` turns planning on (adds plans and an index; prompts stay put).
|
|
50
|
+
export const mode = 'handoffs';
|
|
51
|
+
export const root = '${PROMPT_DIR}';
|
|
52
|
+
export const promptTracking = '${promptTracking}';
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The Git toplevel, found the same way config discovery finds a repo root (a
|
|
57
|
+
// `.git` entry, file or directory), without spawning git on a hook path. Only
|
|
58
|
+
// a hint: `verifyGitWorktree` is what the writing path trusts.
|
|
59
|
+
export function gitToplevel(cwd) {
|
|
60
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
61
|
+
if (existsSync(path.join(dir, '.git'))) return dir;
|
|
62
|
+
if (dir === path.parse(dir).root) return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Where an unconfigured repository may get a handoffs config, or why not.
|
|
67
|
+
// Outside Git there is no project boundary to anchor a config to (the working
|
|
68
|
+
// directory may be a home or scratch folder), and a home directory that is
|
|
69
|
+
// itself a Git repository (dotfiles) is never a project.
|
|
70
|
+
export function handoffsAnchor(cwd, { home = os.homedir() } = {}) {
|
|
71
|
+
const top = gitToplevel(cwd);
|
|
72
|
+
if (!top) return { refusal: 'Not in a Git repository, so there is no project to save a handoff in. Run this inside your project (`git init` first if it has none), or run `runlist setup` to configure this folder.' };
|
|
73
|
+
if (home && (path.resolve(top) === path.resolve(home) || samePath(top, home))) {
|
|
74
|
+
return { refusal: 'Your home directory is a Git repository; runlist will not set up handoffs there. Run this inside a project, or create runlist.config.mjs yourself.' };
|
|
75
|
+
}
|
|
76
|
+
return { top };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function samePath(a, b) {
|
|
80
|
+
try { return realpathSync(a) === realpathSync(b); } catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Git's own answer, which the `.git` walk above can get wrong: a bare
|
|
84
|
+
// repository (or a `.git` directory) nested inside a worktree, a `.git` file
|
|
85
|
+
// that is not a gitlink, and GIT_DIR / GIT_WORK_TREE pointing somewhere else
|
|
86
|
+
// all have a `.git` entry above them that is not the worktree Git would use.
|
|
87
|
+
// Only when Git says the working directory is inside a worktree whose top is
|
|
88
|
+
// that same folder does the config go there.
|
|
89
|
+
export function verifyGitWorktree(cwd, top) {
|
|
90
|
+
const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree', '--show-toplevel', '--absolute-git-dir'], { cwd, encoding: 'utf8' });
|
|
91
|
+
const [inside, gitTop, gitDir] = (result.stdout ?? '').split(/\r?\n/);
|
|
92
|
+
if (result.error) return { refusal: `Could not run git to confirm this is a project (${result.error.message}), so nothing was saved. Run \`runlist setup\` to configure this folder.` };
|
|
93
|
+
if (result.status !== 0 || inside !== 'true') {
|
|
94
|
+
const overridden = process.env.GIT_DIR || process.env.GIT_WORK_TREE ? ' GIT_DIR or GIT_WORK_TREE is set and may point elsewhere.' : '';
|
|
95
|
+
return { refusal: `This is not inside a Git working tree (a bare repository, a \`.git\` folder, or a broken \`.git\` file), so there is no project to save a handoff in.${overridden} Run this from your project's working tree, or run \`runlist setup\` to configure a folder.` };
|
|
96
|
+
}
|
|
97
|
+
if (!gitTop || !samePath(gitTop, top) || !gitDir || !samePath(gitDir, lexicalGitDir(top))) {
|
|
98
|
+
return { refusal: `Git here uses ${gitDir || 'an unknown repository'} with the working tree ${gitTop || 'unknown'}, not the .git in ${top} (GIT_DIR or GIT_WORK_TREE may point elsewhere), so baton will not choose where the config goes. Run it from the project itself without those overrides, or run \`runlist setup\` there.` };
|
|
99
|
+
}
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The repository the nearest `.git` names: the folder itself, or the target of
|
|
104
|
+
// a gitlink file (worktrees and submodules).
|
|
105
|
+
function lexicalGitDir(top) {
|
|
106
|
+
const entry = path.join(top, '.git');
|
|
107
|
+
if (isDirectory(entry)) return entry;
|
|
108
|
+
try {
|
|
109
|
+
const target = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(entry, 'utf8'))?.[1];
|
|
110
|
+
return target ? path.resolve(top, target) : null;
|
|
111
|
+
} catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isDirectory(file) {
|
|
115
|
+
try { return lstatSync(file).isDirectory(); } catch { return false; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The unconfigured default folders only (and their archives), one level deep,
|
|
119
|
+
// plus the root prompts/ older builds saved in, at any depth: cheap enough for
|
|
120
|
+
// any working directory, including a home folder. That root prompts/ counts as
|
|
121
|
+
// evidence of earlier handoffs, not as a place to write.
|
|
122
|
+
function hasDefaultFolderDocuments(config) {
|
|
123
|
+
const folders = ['plans', 'docs/plans', PROMPT_DIR].flatMap(folder => {
|
|
124
|
+
const directory = path.join(config.docsRoot, ...folder.split('/'));
|
|
125
|
+
return [directory, path.join(directory, config.archiveDir)];
|
|
126
|
+
});
|
|
127
|
+
const holdsDocument = (directory, recursive) => {
|
|
128
|
+
if (!isDirectory(directory)) return false;
|
|
129
|
+
try {
|
|
130
|
+
return readdirSync(directory, { withFileTypes: true }).some(entry => {
|
|
131
|
+
const file = path.join(directory, entry.name);
|
|
132
|
+
if (recursive && entry.isDirectory()) return holdsDocument(file, true);
|
|
133
|
+
return entry.isFile() && entry.name.endsWith('.md') && ['plan', 'prompt'].includes(readsType(file));
|
|
134
|
+
});
|
|
135
|
+
} catch { return true; }
|
|
136
|
+
};
|
|
137
|
+
return folders.some(directory => holdsDocument(directory, false))
|
|
138
|
+
|| holdsDocument(path.join(config.docsRoot, ...LEGACY_PROMPT_DIR.split('/')), true);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readsType(file) {
|
|
142
|
+
try { return parseSimpleFrontmatter(extractFrontmatter(readFileSync(file, 'utf8')).frontmatter ?? '').type; }
|
|
143
|
+
catch { return null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Whether the next prompt saved from `cwd` would create a handoffs config, for
|
|
147
|
+
// the hook's wording only: stat calls, no reads. Baton itself decides.
|
|
148
|
+
export function wouldCreateHandoffsConfig(cwd, config) {
|
|
149
|
+
return !config.configFound && !handoffsAnchor(cwd).refusal;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function carryRefusal(outstanding, nothing = 'Nothing saved') {
|
|
153
|
+
return `${nothing}: runlist keeps prompts in ${PROMPT_DIR}/, and moves the ones an older version saved in ${LEGACY_PROMPT_DIR}/ there first, but cannot move these:\n${outstanding.map(item => ` ${item.description}`).join('\n')}\nRename or remove one file of each pair, then rerun.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const promptCount = n => `${n} prompt${n === 1 ? '' : 's'}`;
|
|
157
|
+
|
|
158
|
+
function setupLine(created, dryRun) {
|
|
159
|
+
const where = `${created.config}, prompts in ${created.promptDirectory}/`;
|
|
160
|
+
return dryRun
|
|
161
|
+
? `[dry-run] Would set up runlist for handoffs here (${where}). Planning stays off; \`runlist setup\` turns it on.`
|
|
162
|
+
: `Set up runlist for handoffs here (${where}). Planning is off; \`runlist setup\` turns it on.`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decide, without writing anything new, how a prompt saved from `cwd` in a
|
|
167
|
+
* repository with no config gets one. Returns null when a config exists.
|
|
168
|
+
* Otherwise the plan: `config` is the layout the save is checked against and
|
|
169
|
+
* `carry` moves the prompts an older version saved in the root prompts/ into
|
|
170
|
+
* docs/prompts/. Plans and other runlist documents saved before setup do not
|
|
171
|
+
* stop it: they stay where they are, and the planning-off line counts the ones
|
|
172
|
+
* `runlist setup` brings in. `legacy` marks a save outside Git, where no config
|
|
173
|
+
* is created; a folder an older version saved handoffs or plans in keeps
|
|
174
|
+
* working as it did. Dies when there is no project to anchor a config to, when
|
|
175
|
+
* docs/prompts/ leads into the root prompts/, when such a prompt cannot be
|
|
176
|
+
* carried (a file of the same name is already in docs/prompts/), or when a
|
|
177
|
+
* move an earlier command left unfinished cannot be completed.
|
|
178
|
+
*
|
|
179
|
+
* That last step is the one write: a carry killed part-way leaves a
|
|
180
|
+
* transaction recorded under the unconfigured roots, and it is finished or
|
|
181
|
+
* undone under those same roots (as setup does) before anything else, so no
|
|
182
|
+
* config is ever written over it. Dry-run reports it and stops there
|
|
183
|
+
* (`previewStopped`): what the save would then do depends on that move.
|
|
184
|
+
*/
|
|
185
|
+
export async function planHandoffsConfig(cwd, config, { home, dryRun = false, out = process.stderr } = {}) {
|
|
186
|
+
if (config.configFound) return null;
|
|
187
|
+
const anchor = handoffsAnchor(cwd, { home });
|
|
188
|
+
if (anchor.refusal) {
|
|
189
|
+
// No config is created outside Git, so nothing can be hidden; a folder an
|
|
190
|
+
// older version saved handoffs or plans in keeps working as it did.
|
|
191
|
+
if (hasDefaultFolderDocuments(config)) return legacySave(withScanExclusions(config), { dryRun, out });
|
|
192
|
+
die(anchor.refusal);
|
|
193
|
+
}
|
|
194
|
+
const top = anchor.top;
|
|
195
|
+
const verified = verifyGitWorktree(cwd, top);
|
|
196
|
+
if (verified.refusal) die(verified.refusal);
|
|
197
|
+
const before = withScanExclusions(path.resolve(config.docsRoot) === path.resolve(top) ? config : await resolveConfig(top));
|
|
198
|
+
if (await finishPreConfigMoves(before, { dryRun, out })) return { previewStopped: true };
|
|
199
|
+
const configPath = path.join(top, HANDOFFS_CONFIG_FILE);
|
|
200
|
+
const promptDir = path.join(top, ...PROMPT_DIR.split('/'));
|
|
201
|
+
assertPromptFolderOutsideLegacy(promptDir, top);
|
|
202
|
+
const guardConfig = { repoRoot: top };
|
|
203
|
+
authorizeRepoGeneratedPath(promptDir, guardConfig, { kind: 'Handoffs prompt folder' });
|
|
204
|
+
authorizeRepoGeneratedPath(configPath, guardConfig, { kind: 'Handoffs config' });
|
|
205
|
+
const proposed = await resolveConfig(cwd, configPath, { exports: { mode: 'handoffs', root: PROMPT_DIR, promptTracking: 'untracked' } });
|
|
206
|
+
proposed.configWarnings = [];
|
|
207
|
+
// The root prompts/ older builds saved in, at any depth: its prompts are
|
|
208
|
+
// carried with setup's transactional carry. Anything else saved before
|
|
209
|
+
// setup stays put for `runlist setup`.
|
|
210
|
+
const carry = await planCarry(before, proposed);
|
|
211
|
+
if (carry.outstanding.length) die(carryRefusal(carry.outstanding));
|
|
212
|
+
const moved = carry.moves.map(({ from, to }) => ({ from, to }));
|
|
213
|
+
const created = { config: HANDOFFS_CONFIG_FILE, promptDirectory: PROMPT_DIR, moved, created: false };
|
|
214
|
+
return { top, cwd, promptRoot: PROMPT_DIR, promptDir, configPath, source: handoffsConfigSource(), config: proposed,
|
|
215
|
+
carry, created, promptDirExists: isDirectory(promptDir), dryRunLine: setupLine(created, true) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// docs/prompts/ must not lead into the root prompts/ (a docs/ symlinked
|
|
219
|
+
// there, say): checked before anything is carried, published or created.
|
|
220
|
+
function assertPromptFolderOutsideLegacy(promptDir, top) {
|
|
221
|
+
if (!inLegacyPromptFolder(promptDir, top)) return;
|
|
222
|
+
die(`Nothing saved: ${PROMPT_DIR}/ here ${legacyPromptFolderPlace(promptDir, top)}, and runlist no longer writes prompts there. Change the link that leads there, then rerun.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function withScanExclusions(config) {
|
|
226
|
+
return { ...config, excludeDirs: new Set([...(config.excludeDirs ?? []), '.git', 'node_modules']) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function planCarry(before, after) {
|
|
230
|
+
const { planSetupCarry, unmanagedCarryReferences } = await import('./setup-carry.mjs');
|
|
231
|
+
const carry = planSetupCarry(before, after, true, { types: ['prompt'] });
|
|
232
|
+
return { before, moves: carry.moves, outstanding: carry.outstanding, references: unmanagedCarryReferences(before, carry.moves) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A new project `runlist init` starts at `top`: the prompts an older runlist
|
|
236
|
+
// saved in the root prompts/ move into `after`'s prompt folder before its
|
|
237
|
+
// config is written, with the first save's steps (finish, plan, refuse on a
|
|
238
|
+
// collision with nothing written) under the roots the project had before.
|
|
239
|
+
// `carryLegacyPrompts` then makes the moves, or lists them on a dry-run.
|
|
240
|
+
export async function planInitCarry(top, config, after, { dryRun = false, out = process.stdout } = {}) {
|
|
241
|
+
const before = withScanExclusions(path.resolve(config.docsRoot) === path.resolve(top) ? config : await resolveConfig(top));
|
|
242
|
+
if (await finishPreConfigMoves(before, { dryRun, out, nothing: 'Nothing initialized' })) return { previewStopped: true };
|
|
243
|
+
const carry = await planCarry(before, after);
|
|
244
|
+
if (carry.outstanding.length) die(carryRefusal(carry.outstanding, 'Nothing initialized'));
|
|
245
|
+
return { carry };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The unconfigured save outside Git: prompts go to docs/prompts/ in the
|
|
249
|
+
// working folder, and the root prompts/ is carried there first like any save.
|
|
250
|
+
async function legacySave(before, { dryRun, out }) {
|
|
251
|
+
if (await finishPreConfigMoves(before, { dryRun, out })) return { previewStopped: true };
|
|
252
|
+
const { promptDirectory } = await import('./new.mjs');
|
|
253
|
+
const promptDir = promptDirectory(before);
|
|
254
|
+
assertPromptFolderOutsideLegacy(promptDir, before.repoRoot);
|
|
255
|
+
const carry = await planCarry(before, before);
|
|
256
|
+
if (carry.outstanding.length) die(carryRefusal(carry.outstanding));
|
|
257
|
+
return { legacy: true, top: before.repoRoot, promptRoot: path.relative(before.repoRoot, promptDir).split(path.sep).join('/'), promptDir,
|
|
258
|
+
config: before, carry, created: null, promptDirExists: isDirectory(promptDir), dryRunLine: null };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Moves an earlier command left unfinished in a repository with no config.
|
|
262
|
+
// Recovered under the unconfigured roots that authorized them; anything that
|
|
263
|
+
// cannot be stops the save before a config could strand it.
|
|
264
|
+
// `strict` is for a config about to be written: a move still recorded then
|
|
265
|
+
// (one needing `runlist doctor --transactions`, say) could be stranded outside
|
|
266
|
+
// the new root, so it stops the save. Otherwise only a recovery that fails
|
|
267
|
+
// stops the command, as it would stop any change.
|
|
268
|
+
export async function finishPreConfigMoves(before, { dryRun = false, out = process.stderr, nothing = 'Nothing saved', strict = true } = {}) {
|
|
269
|
+
const pending = inspectTransactions(before.repoRoot, { config: before });
|
|
270
|
+
if (!pending.length) return false;
|
|
271
|
+
if (dryRun) {
|
|
272
|
+
out.write(`${dim('[dry-run]')} Would first finish ${pending.length === 1 ? 'a move' : `${pending.length} moves`} an earlier runlist command left unfinished; nothing else is previewed until ${pending.length === 1 ? 'it is' : 'they are'} finished. Run the command without --dry-run to finish ${pending.length === 1 ? 'it' : 'them'} first.\n`);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
const sources = pending.flatMap(item => {
|
|
276
|
+
try { return JSON.parse(readFileSync(item.manifestPath, 'utf8')).participants.filter(participant => participant.label === 'source').map(participant => participant.path); }
|
|
277
|
+
catch { return []; }
|
|
278
|
+
});
|
|
279
|
+
let failure = null;
|
|
280
|
+
try { recoverAbandonedTransactions(before.repoRoot, { config: before }); }
|
|
281
|
+
catch (err) { failure = err; }
|
|
282
|
+
const remaining = strict ? inspectTransactions(before.repoRoot, { config: before }) : [];
|
|
283
|
+
if (remaining.length || failure) {
|
|
284
|
+
const reason = remaining.map(item => ` ${item.id}: ${item.reason ?? (item.ownerLiveness === 'live' || item.lockHeld ? 'another runlist process is still running it' : 'not resolvable automatically')}`).join('\n') || ` ${failure.message}`;
|
|
285
|
+
die(`${nothing}: a move an earlier runlist command left unfinished could not be completed:\n${reason}\n\`runlist doctor --transactions\` shows it; rerun once it is resolved.`);
|
|
286
|
+
}
|
|
287
|
+
// A move finished forward leaves its source folder as a completed carry
|
|
288
|
+
// would, so emptied folders go the same way.
|
|
289
|
+
const { cleanupSetupSourceDirectories } = await import('./setup-carry.mjs');
|
|
290
|
+
cleanupSetupSourceDirectories(before, sources.filter(source => !existsSync(source)));
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The carried prompt that would land on `file`, if any: the save there is a
|
|
295
|
+
// duplicate of a prompt saved before, though it has not moved yet.
|
|
296
|
+
export function carriedOnto(plan, file) {
|
|
297
|
+
return plan.carry.moves.find(move => path.resolve(move.target) === path.resolve(file)) ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Before the config: move the prompts planned above into docs/prompts/, one
|
|
301
|
+
// transactional move each, and say so in one line (dry-run lists the moves). A
|
|
302
|
+
// carry that stops leaves no config, so the next run finishes the move it was
|
|
303
|
+
// making and carries what is left in prompts/. Markdown runlist does not
|
|
304
|
+
// manage (no frontmatter) is never rewritten: a link there to a carried prompt
|
|
305
|
+
// is listed for the user to update.
|
|
306
|
+
export async function carryLegacyPrompts(plan, { dryRun = false, out = process.stderr, nothing = 'Nothing saved' } = {}) {
|
|
307
|
+
const moves = plan.carry.moves;
|
|
308
|
+
if (!moves.length) return;
|
|
309
|
+
const what = `${promptCount(moves.length)} an older runlist saved in ${LEGACY_PROMPT_DIR}/ to ${PROMPT_DIR}/`;
|
|
310
|
+
const references = plan.carry.references ?? [];
|
|
311
|
+
const referenceLines = references.map(item => ` ${item.file} links to ${item.from}; update it to ${item.to}`).join('\n');
|
|
312
|
+
if (dryRun) {
|
|
313
|
+
out.write(`${dim('[dry-run]')} Would move ${what}:\n${moves.map(move => ` ${move.from} → ${move.to}`).join('\n')}\n`);
|
|
314
|
+
if (references.length) out.write(`${dim('[dry-run]')} Would leave links in files runlist does not manage as they are:\n${referenceLines}\n`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const { applySetupCarry } = await import('./setup-carry.mjs');
|
|
318
|
+
const result = applySetupCarry(moves, plan.carry.before);
|
|
319
|
+
if (result.error) {
|
|
320
|
+
die(`${nothing}: moving ${what} stopped after ${result.completed.length} of ${moves.length} (${result.error}), and nothing else was done. Rerun the command to carry the rest; \`runlist doctor --transactions\` reports a move left unfinished.`);
|
|
321
|
+
}
|
|
322
|
+
plan.promptDirExists = true;
|
|
323
|
+
out.write(`Moved ${what}.\n`);
|
|
324
|
+
if (references.length) out.write(`Links in files runlist does not manage were left as they are:\n${referenceLines}\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Before a command changes documents in a repository with no config, the
|
|
328
|
+
// prompts an older runlist saved in the root prompts/ move to docs/prompts/,
|
|
329
|
+
// so nothing is consumed, archived or rewritten where runlist no longer writes
|
|
330
|
+
// (src/naming.mjs). Same transactional carry and refusals as a first save;
|
|
331
|
+
// files there that are not runlist prompts stay. Returns the moves made, for
|
|
332
|
+
// file arguments that named a moved prompt. Dry-run lists them and reports
|
|
333
|
+
// `preview: true`: what the command would then do depends on the move.
|
|
334
|
+
export async function carryBeforeChange(config, { dryRun = false, out = process.stderr } = {}) {
|
|
335
|
+
if (config.configFound) return null;
|
|
336
|
+
const legacy = path.join(config.repoRoot, ...LEGACY_PROMPT_DIR.split('/'));
|
|
337
|
+
if (!holdsMarkdown(legacy)) return null;
|
|
338
|
+
const before = withScanExclusions(path.resolve(config.docsRoot) === path.resolve(config.repoRoot) ? config : await resolveConfig(config.repoRoot));
|
|
339
|
+
if (await finishPreConfigMoves(before, { dryRun, out, nothing: 'Nothing changed', strict: false })) return { moves: [], preview: true };
|
|
340
|
+
const carry = await planCarry(before, before);
|
|
341
|
+
if (carry.outstanding.length) die(carryRefusal(carry.outstanding, 'Nothing changed'));
|
|
342
|
+
if (!carry.moves.length) return null;
|
|
343
|
+
await carryLegacyPrompts({ carry }, { dryRun, out, nothing: 'Nothing changed' });
|
|
344
|
+
return { moves: carry.moves, preview: dryRun };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function holdsMarkdown(directory) {
|
|
348
|
+
if (!isDirectory(directory)) return false;
|
|
349
|
+
try {
|
|
350
|
+
return readdirSync(directory, { withFileTypes: true }).some(entry =>
|
|
351
|
+
(entry.isFile() && entry.name.endsWith('.md')) || (entry.isDirectory() && holdsMarkdown(path.join(directory, entry.name))));
|
|
352
|
+
} catch { return true; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Whether `file` exists once the carry above is made: already there, or a
|
|
356
|
+
// carried prompt lands on it.
|
|
357
|
+
export function existsAfterCarry(plan, file) {
|
|
358
|
+
return existsSync(file) || Boolean(plan && carriedOnto(plan, file));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parentConfig(top) {
|
|
362
|
+
const parent = path.dirname(top);
|
|
363
|
+
return parent === top ? null : locateConfig(parent).configPath;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function manualConfig(plan) {
|
|
367
|
+
return `Nothing saved: ${plan.top} cannot take a new file atomically (its filesystem does not support hard links), and a half-written config would stop every runlist command. Create ${plan.configPath} yourself with these lines, then rerun:\n${plan.source}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Write the planned folder and config. Never overwrites or shadows a config:
|
|
372
|
+
* one that appeared here or in a parent folder since planning is used instead
|
|
373
|
+
* (a parent one that appears while ours is published wins, and ours is taken
|
|
374
|
+
* back). Returns `{ config, created, line }`, `created`/`line` null when
|
|
375
|
+
* another process's config is the one in effect; the caller then checks that
|
|
376
|
+
* the save still lands where it was validated.
|
|
377
|
+
*/
|
|
378
|
+
export async function publishHandoffsConfig(plan, { testHooks } = {}) {
|
|
379
|
+
const resolveNow = async () => ({ config: await resolveConfig(plan.cwd), created: null, line: null });
|
|
380
|
+
if (locateConfig(plan.cwd).configPath) return resolveNow();
|
|
381
|
+
const madeDirectories = [];
|
|
382
|
+
for (let next = plan.promptDir; !existsSync(next); next = path.dirname(next)) madeDirectories.unshift(next);
|
|
383
|
+
const undoDirectories = () => {
|
|
384
|
+
for (const directory of [...madeDirectories].reverse()) try { rmdirSync(directory); } catch { /* not empty, or not ours */ }
|
|
385
|
+
};
|
|
386
|
+
// The folder first: a concurrent reader that finds the config finds its root.
|
|
387
|
+
mkdirSync(plan.promptDir, { recursive: true });
|
|
388
|
+
let published = false;
|
|
389
|
+
try {
|
|
390
|
+
withPathLocks(CONFIG_FILENAMES.map(name => path.join(plan.top, name)), { repoRoot: plan.top }, () => {
|
|
391
|
+
if (locateConfig(plan.cwd).configPath) return;
|
|
392
|
+
try {
|
|
393
|
+
createFileExclusive(plan.configPath, plan.source, { repoRoot: plan.top, locked: true, requireAtomicPublish: true, testHooks });
|
|
394
|
+
published = true;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (!(err instanceof MutationConflictError)) throw err;
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
} catch (err) {
|
|
400
|
+
undoDirectories();
|
|
401
|
+
if (err?.code === 'ERUNLIST_ATOMIC_PUBLISH_UNSUPPORTED') die(manualConfig(plan));
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
if (!published) return resolveNow();
|
|
405
|
+
testHooks?.afterHandoffsConfigPublished?.(plan.configPath);
|
|
406
|
+
if (parentConfig(plan.top)) {
|
|
407
|
+
withPathLocks([plan.configPath], { repoRoot: plan.top }, () => {
|
|
408
|
+
try { if (readFileSync(plan.configPath, 'utf8') === plan.source) unlinkSync(plan.configPath); } catch { /* already gone */ }
|
|
409
|
+
});
|
|
410
|
+
undoDirectories();
|
|
411
|
+
die(`Nothing saved: a runlist config appeared in a parent folder (${parentConfig(plan.top)}) while baton was setting up ${plan.top}, so baton left that one in charge. Rerun the command.`);
|
|
412
|
+
}
|
|
413
|
+
const created = { ...plan.created, created: true };
|
|
414
|
+
return { config: await resolveConfig(plan.cwd), created, line: setupLine(created, false) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// After publishing, the save must still land where the caller validated it; a
|
|
418
|
+
// config published concurrently by someone else may put prompts elsewhere.
|
|
419
|
+
export function assertPublishedPromptFolder(plan, published, promptFolder) {
|
|
420
|
+
if (path.resolve(promptFolder) === path.resolve(plan.promptDir)) return;
|
|
421
|
+
die(`Nothing saved: another runlist config (${published.config.configPath}) took effect while baton was setting up handoffs. Rerun the command.`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Setup in a handoffs-only repository: the two exports to rewrite and the index
|
|
425
|
+
// a planning project gets, so the preview resolves the upgraded layout without
|
|
426
|
+
// writing anything first. The root widens from docs/prompts to docs, so the
|
|
427
|
+
// prompts keep their folder. Values are written as JavaScript literals and the
|
|
428
|
+
// result is checked to parse and to read back as the intended values before
|
|
429
|
+
// anything is replaced.
|
|
430
|
+
export function planHandoffsUpgrade(config) {
|
|
431
|
+
const source = readFileSync(config.configPath, 'utf8');
|
|
432
|
+
const promptRoot = path.relative(config.repoRoot, config.docsRoot).split(path.sep).join('/');
|
|
433
|
+
const root = path.posix.dirname(PROMPT_DIR);
|
|
434
|
+
const indexPath = path.posix.join(root, 'docs.md');
|
|
435
|
+
const modeLine = /^export const mode = (['"])handoffs\1;?[ \t]*$/m;
|
|
436
|
+
const rootLine = /^export const root = (?:'[^'\n]*'|"[^"\n]*");?[ \t]*$/m;
|
|
437
|
+
if ((config.docsRoots ?? []).length !== 1 || promptRoot !== PROMPT_DIR || !modeLine.test(source) || !rootLine.test(source)) {
|
|
438
|
+
throw new Error(`${config.configPath} is not the handoffs config runlist writes, so setup will not rewrite it. Set \`export const mode = 'plans'\` and a docs root yourself (see runlist.config.example.mjs), then rerun setup.`);
|
|
439
|
+
}
|
|
440
|
+
const rootSource = jsLiteral(root);
|
|
441
|
+
let content = source
|
|
442
|
+
.replace(/^\/\/ runlist\.config\.mjs — created by the first `runlist baton` here[^\n]*\n\/\/ Planning is off[^\n]*\n\/\/ `runlist setup` turns planning on[^\n]*\n/m,
|
|
443
|
+
() => '// runlist.config.mjs — document management configuration (planning turned on by `runlist setup`).\n')
|
|
444
|
+
.replace(modeLine, () => "export const mode = 'plans';")
|
|
445
|
+
.replace(rootLine, () => `export const root = ${rootSource};`);
|
|
446
|
+
const index = { path: indexPath, startMarker: '<!-- GENERATED:runlist:start -->', endMarker: '<!-- GENERATED:runlist:end -->', archivedLimit: 8 };
|
|
447
|
+
const overrides = { mode: 'plans', root };
|
|
448
|
+
if (!/^export const index\b/m.test(content)) {
|
|
449
|
+
content += `\nexport const index = {\n path: ${jsLiteral(indexPath)},\n startMarker: '${index.startMarker}',\n endMarker: '${index.endMarker}',\n archivedLimit: 8,\n};\n`;
|
|
450
|
+
overrides.index = index;
|
|
451
|
+
}
|
|
452
|
+
checkUpgradedConfig(content, { root, indexPath: overrides.index ? indexPath : null }, config.configPath);
|
|
453
|
+
return { source, content, overrides, root, promptRoot, indexPath };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Plain path characters stay in the single quotes the rest of the file uses;
|
|
457
|
+
// anything else (quotes, `$`, backslashes, non-ASCII) goes through JSON, whose
|
|
458
|
+
// string syntax is valid JavaScript.
|
|
459
|
+
export function jsLiteral(value) {
|
|
460
|
+
return /^[\w./-]*$/.test(value) ? `'${value}'` : JSON.stringify(value);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The inverse of jsLiteral for exactly what it writes, so the check below reads
|
|
464
|
+
// the values back without executing the config.
|
|
465
|
+
function readLiteral(text) {
|
|
466
|
+
const trimmed = text.trim();
|
|
467
|
+
if (/^'[\w./-]*'$/.test(trimmed)) return trimmed.slice(1, -1);
|
|
468
|
+
return JSON.parse(trimmed);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function checkUpgradedConfig(content, expected, configPath) {
|
|
472
|
+
const fail = detail => { throw new Error(`Setup did not change ${configPath}: the upgraded config would not load as intended (${detail}). Edit it by hand (see runlist.config.example.mjs), then rerun setup.`); };
|
|
473
|
+
const check = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: content, encoding: 'utf8' });
|
|
474
|
+
if (check.error || check.status !== 0) fail((check.stderr || check.error?.message || 'syntax error').trim().split('\n').find(line => /Error/.test(line)) ?? 'syntax error');
|
|
475
|
+
const roots = [...content.matchAll(/^export const root = (.*);$/gm)];
|
|
476
|
+
if (roots.length !== 1) fail('root is not set exactly once');
|
|
477
|
+
let root;
|
|
478
|
+
try { root = readLiteral(roots[0][1]); } catch (err) { fail(err.message); }
|
|
479
|
+
if (JSON.stringify(root) !== JSON.stringify(expected.root)) fail(`root reads back as ${JSON.stringify(root)}`);
|
|
480
|
+
if (expected.indexPath) {
|
|
481
|
+
const index = /^ {2}path: (.*),$/m.exec(content.slice(content.lastIndexOf('export const index')));
|
|
482
|
+
let indexPath;
|
|
483
|
+
try { indexPath = index && readLiteral(index[1]); } catch (err) { fail(err.message); }
|
|
484
|
+
if (indexPath !== expected.indexPath) fail(`index.path reads back as ${JSON.stringify(indexPath)}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { die, resolveDocPath } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
// In a handoffs-only project (src/handoffs-mode.mjs) runlist owns the prompts
|
|
7
|
+
// in its prompt folder and nothing else there. That folder is often one the
|
|
8
|
+
// project already had (a `docs/prompts/` of reference notes, say), so any
|
|
9
|
+
// other Markdown in it is the user's: never indexed, checked, searched,
|
|
10
|
+
// repaired or moved. Kept apart from handoffs-mode.mjs so the document
|
|
11
|
+
// collector, the file-argument resolver and the mutation gate can ask without
|
|
12
|
+
// importing the config writer.
|
|
13
|
+
|
|
14
|
+
export const PLANNING_OFF = 'Planning is off in this project; `runlist setup` turns it on (your prompts stay where they are).';
|
|
15
|
+
|
|
16
|
+
export function isHandoffsMode(config) {
|
|
17
|
+
return config?.mode === 'handoffs';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readsAsPrompt(file) {
|
|
21
|
+
try {
|
|
22
|
+
return parseSimpleFrontmatter(extractFrontmatter(readFileSync(file, 'utf8')).frontmatter ?? '').type === 'prompt';
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A prompt in the prompt folder: the folder alone is not enough (the user's
|
|
27
|
+
// own notes may sit there), and neither is the type (a `type: prompt` file
|
|
28
|
+
// elsewhere in the project is not one of its handoffs).
|
|
29
|
+
export function managesFile(file, config) {
|
|
30
|
+
if (config?.mode !== 'handoffs') return true;
|
|
31
|
+
const target = path.resolve(file);
|
|
32
|
+
return (config.docsRoots || [config.docsRoot]).some(root => within(root, target) || within(canonical(root), target)) && readsAsPrompt(file);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Callers pass lexical and canonical paths alike (/var and /private/var).
|
|
36
|
+
function canonical(directory) {
|
|
37
|
+
try { return realpathSync(directory); } catch { return directory; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The planning-off line, with the documents saved before setup that
|
|
41
|
+
// `runlist setup` brings in (`documentsWaitingForSetup` in
|
|
42
|
+
// src/setup-waiting.mjs counts them; never on a hook path).
|
|
43
|
+
export function planningOffLine(waiting = 0) {
|
|
44
|
+
if (!waiting) return PLANNING_OFF;
|
|
45
|
+
return `${PLANNING_OFF} ${waiting === 1 ? '1 runlist document saved before setup is' : `${waiting} runlist documents saved before setup are`} waiting for it.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function planningRefusal(what, waiting = 0) {
|
|
49
|
+
return `${what} needs planning. ${planningOffLine(waiting)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function outsideHandoffsLine(input) {
|
|
53
|
+
return `\`${input}\` is outside this project's handoffs: ${PLANNING_OFF[0].toLowerCase()}${PLANNING_OFF.slice(1)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The one gate every planning command passes through (the `planning` entries
|
|
57
|
+
// in src/commands.mjs, read by the dispatcher). A view says planning is off and
|
|
58
|
+
// succeeds, as an empty dashboard would; an action refuses, so a script never
|
|
59
|
+
// mistakes a skipped change for a made one. Returns true when the caller must
|
|
60
|
+
// stop. Planning projects pass straight through.
|
|
61
|
+
export function requirePlanning(config, what, requirement, { json = false, out = process.stdout, waiting = 0 } = {}) {
|
|
62
|
+
if (config?.mode !== 'handoffs' || !requirement) return false;
|
|
63
|
+
if (requirement === 'action') die(planningRefusal(what, waiting));
|
|
64
|
+
const message = planningOffLine(waiting);
|
|
65
|
+
out.write(json ? `${JSON.stringify({ planning: false, message, ...(waiting ? { waitingForSetup: waiting } : {}) }, null, 2)}\n` : `${message}\n`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function within(directory, file) {
|
|
70
|
+
const rel = path.relative(directory, file);
|
|
71
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isDirectory(file) {
|
|
75
|
+
try { return statSync(file).isDirectory(); } catch { return false; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Whether a file argument names something a handoffs-only project does not
|
|
79
|
+
// manage: an existing file that is not a prompt (the project's own Markdown,
|
|
80
|
+
// inside the prompt folder or not), or a path that points outside the prompt
|
|
81
|
+
// folder (`docs/plans/x.md`). A bare name, or a path into the prompt folder,
|
|
82
|
+
// is a prompt name that may just be mistyped, so it keeps the plain
|
|
83
|
+
// not-found answer with its suggestions.
|
|
84
|
+
export function outsideHandoffs(input, config) {
|
|
85
|
+
if (config?.mode !== 'handoffs' || !input) return false;
|
|
86
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
87
|
+
const existing = resolveDocPath(input, config) ?? (String(input).endsWith('.md') ? null : resolveDocPath(`${input}.md`, config));
|
|
88
|
+
if (existing) return isDirectory(existing) ? !roots.some(root => within(root, existing)) : !managesFile(existing, config);
|
|
89
|
+
if (!path.isAbsolute(input) && !/[\\/]/.test(input)) return false;
|
|
90
|
+
if (roots.some(root => within(root, path.resolve(config.repoRoot, input)))) return false;
|
|
91
|
+
if (path.isAbsolute(input)) return true;
|
|
92
|
+
return !roots.some(root => isDirectory(path.dirname(path.resolve(root, input))));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// For resolvers that find a file before asking whether runlist manages it.
|
|
96
|
+
export function assertInsideHandoffs(input, config) {
|
|
97
|
+
if (outsideHandoffs(input, config)) die(outsideHandoffsLine(input));
|
|
98
|
+
}
|
package/src/help.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { commandHelpContext } from './commands.mjs';
|
|
2
|
+
|
|
3
|
+
export function unsupportedCommandHelp(command) {
|
|
4
|
+
switch (command) {
|
|
5
|
+
case 'edit':
|
|
6
|
+
return 'Edit document text and ordinary frontmatter with your file editor.\n'
|
|
7
|
+
+ 'Change lifecycle status with `runlist set <status> <file>`.\n'
|
|
8
|
+
+ 'For ordered membership commands, read `runlist runlist --help`.';
|
|
9
|
+
case 'claims':
|
|
10
|
+
return 'Inspect ownership with `runlist doctor --claims` (read-only).\n'
|
|
11
|
+
+ 'Do not add --apply or --force merely to inspect another session\'s claim.';
|
|
12
|
+
case 'link':
|
|
13
|
+
return 'Inspect references with `runlist xref <file>`. Edit ordinary reference fields with your file editor.\n'
|
|
14
|
+
+ 'For ordered plan membership, use `runlist runlist add <hub> <child>`.\n'
|
|
15
|
+
+ 'Inspect saved prompts with `runlist prompts show <name>`; `runlist use` consumes them.';
|
|
16
|
+
default:
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function creationHelp(type) {
|
|
22
|
+
return `runlist new ${type} <slug> @<draft-file> — create a ${type} from a draft
|
|
23
|
+
|
|
24
|
+
runlist new ${type} example @/tmp/draft.md
|
|
25
|
+
runlist new ${type} example - < /tmp/draft.md
|
|
26
|
+
|
|
27
|
+
Write the draft first. Its headings become the body; opening frontmatter
|
|
28
|
+
supplies scaffold fields while the command fixes type to ${type}.
|
|
29
|
+
${type === 'prompt' ? 'A saved prompt requires a body. Git policy: `runlist config get promptTracking`.' : type === 'plan' ? 'Creation does not claim the plan. Start authorized work with \`runlist use <plan>\`.' : 'Use a doc for reference material; creating it does not claim a plan.'}
|
|
30
|
+
Change lifecycle status later with \`runlist set <status> <file>\`.
|
|
31
|
+
Repository types, starting statuses and folders: \`runlist new --list-types\`.
|
|
32
|
+
Complete options and examples: \`runlist new --help\`.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROMPT_SHOW = `runlist prompts show <name...> — inspect saved prompts without consuming
|
|
36
|
+
|
|
37
|
+
runlist prompts show resume-example
|
|
38
|
+
runlist prompts show --all --limit 5
|
|
39
|
+
|
|
40
|
+
Showing a prompt does not archive it or claim its plan; safe to repeat.
|
|
41
|
+
Survey names with \`runlist prompts\`, then select the bodies you need.
|
|
42
|
+
To resume a selected prompt, use \`runlist use <name>\` inside an agent session.
|
|
43
|
+
Use --json for structured output. Keep --all batches bounded with --limit N.
|
|
44
|
+
Complete prompt commands: \`runlist prompts --help\`.`;
|
|
45
|
+
|
|
46
|
+
const CHILD_BATON = `runlist baton --subagent <task-slug> @<draft-file> [--parent resume-<name>]
|
|
47
|
+
|
|
48
|
+
runlist baton --subagent task-example @/tmp/draft.md --parent resume-example
|
|
49
|
+
runlist baton --subagent --template --parent resume-example
|
|
50
|
+
|
|
51
|
+
Return completed results to the parent. Save unfinished work or an explicitly
|
|
52
|
+
requested handoff as subagent-<task-slug>; write the draft before saving.
|
|
53
|
+
Under a parent-owned plan, use a distinct slug that does not resolve to a plan
|
|
54
|
+
and preserve the parent's claim. A child owning its own plan may name that plan;
|
|
55
|
+
the baton releases that child's claim. --parent links the saved prompts and the
|
|
56
|
+
parent may be saved later. --template prints an outline without saving.
|
|
57
|
+
If a handoff exists, inspect it with \`runlist prompts show <name>\`; keep it if
|
|
58
|
+
current, or pass --replace with the new draft. Never consume it to replace it.
|
|
59
|
+
Complete handoff options: \`runlist baton --help\`.`;
|
|
60
|
+
|
|
61
|
+
export function focusedCommandHelp(command, argv) {
|
|
62
|
+
if (!['new', 'prompts', 'baton'].includes(command)) return null;
|
|
63
|
+
let context;
|
|
64
|
+
try { context = commandHelpContext(command, argv); }
|
|
65
|
+
catch { return { fallback: true }; }
|
|
66
|
+
if (command === 'new' && ['plan', 'doc', 'prompt'].includes(context.positional[0])) {
|
|
67
|
+
return { text: creationHelp(context.positional[0]) };
|
|
68
|
+
}
|
|
69
|
+
if (command === 'prompts' && context.subcommand === 'show') return { text: PROMPT_SHOW };
|
|
70
|
+
if (command === 'baton' && argv.includes('--subagent')) return { text: CHILD_BATON };
|
|
71
|
+
return context.positional.length ? { fallback: true } : null;
|
|
72
|
+
}
|