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/guard.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { inspectGitCommandPaths } from './git.mjs';
|
|
|
5
5
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
6
6
|
import { recordGuardEvent } from './journal.mjs';
|
|
7
7
|
import { readEnv } from './naming.mjs';
|
|
8
|
+
import { promptDirectory, promptRouting } from './new.mjs';
|
|
9
|
+
import { isHandoffsMode, managesFile } from './handoffs-scope.mjs';
|
|
8
10
|
|
|
9
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -18,8 +20,8 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
|
|
|
18
20
|
//
|
|
19
21
|
// Two decision levels:
|
|
20
22
|
// 'deny' — block the call and feed the reason back to the model. Reserved for
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
+
// pending-prompt Git operations under the local policy, and
|
|
24
|
+
// hand-editing a `status:` field (`runlist set`
|
|
23
25
|
// is a complete substitute; config `guard: { deny: false }` drops the
|
|
24
26
|
// status rules back to warn).
|
|
25
27
|
// 'warn' — let the call proceed but inject teaching context so the agent learns
|
|
@@ -38,18 +40,37 @@ function toSlash(p) {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// A path that ends in .md and sits under a `prompts/` directory is a
|
|
41
|
-
//
|
|
43
|
+
// saved prompt regardless of which doc root it belongs to —
|
|
42
44
|
// robust across repos without needing the resolved config. Archived prompts
|
|
43
45
|
// (`…/prompts/archived/…`, the default nested archive for the prompt type) are
|
|
44
|
-
// committable history,
|
|
46
|
+
// committable history, so they are explicitly excluded — the
|
|
45
47
|
// guard must not block committing or reading them.
|
|
46
48
|
function isPromptPath(p, config) {
|
|
47
49
|
const s = toSlash(p);
|
|
48
50
|
if (typeof s !== 'string' || !s.endsWith('.md')) return false;
|
|
49
|
-
if (!/(^|\/)prompts\//.test(s)) return false;
|
|
50
51
|
const archiveDir = toSlash(config?.archiveDir || 'archived').replace(/^\/+|\/+$/g, '');
|
|
51
52
|
if (archiveDir && s.split('/').includes(archiveDir)) return false;
|
|
52
|
-
|
|
53
|
+
// Where planning is off, a saved prompt is a `type: prompt` file in the
|
|
54
|
+
// prompt folder and nothing else (src/handoffs-scope.mjs): the user's own
|
|
55
|
+
// Markdown there is not one.
|
|
56
|
+
if (isHandoffsMode(config)) return managesFile(path.resolve(config.repoRoot, s), config);
|
|
57
|
+
if (/(^|\/)prompts\//.test(s)) return true;
|
|
58
|
+
// Respect a configured prompt location too. When prompts share a whole doc
|
|
59
|
+
// root, inspect the file's type rather than treating every Markdown doc as a
|
|
60
|
+
// prompt. Directory routing uses the same resolver as prompt creation.
|
|
61
|
+
if (!config?.repoRoot || !config.docsRoot) return false;
|
|
62
|
+
try {
|
|
63
|
+
// Markdown template-store overrides change content only; routing is owned
|
|
64
|
+
// by the JS template config. Avoid reading that store on each hook token.
|
|
65
|
+
const directory = path.resolve(promptDirectory(config, promptRouting(config)));
|
|
66
|
+
const file = path.resolve(config.repoRoot, s);
|
|
67
|
+
const relative = path.relative(directory, file);
|
|
68
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
|
|
69
|
+
const roots = config.docsRoots ?? [config.docsRoot];
|
|
70
|
+
if (!roots.some(root => path.resolve(root) === directory)) return true;
|
|
71
|
+
const { frontmatter } = extractFrontmatter(readFileSync(file, 'utf8'));
|
|
72
|
+
return Boolean(frontmatter && parseSimpleFrontmatter(frontmatter).type === 'prompt');
|
|
73
|
+
} catch { return false; }
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
// Loose "is this a runlist-managed doc" test: a .md file under one of the
|
|
@@ -57,6 +78,9 @@ function isPromptPath(p, config) {
|
|
|
57
78
|
function isManagedDoc(p, config) {
|
|
58
79
|
const s = toSlash(p);
|
|
59
80
|
if (typeof s !== 'string' || !s.endsWith('.md')) return false;
|
|
81
|
+
// Where planning is off, runlist manages the prompts in the prompt folder
|
|
82
|
+
// alone, so only their status edits go through `runlist set`.
|
|
83
|
+
if (isHandoffsMode(config)) return managesFile(path.resolve(config.repoRoot, s), config);
|
|
60
84
|
const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
|
|
61
85
|
return roots.some(r => {
|
|
62
86
|
const rNorm = toSlash(r).replace(/\/+$/, '');
|
|
@@ -276,22 +300,26 @@ function evalBash(command, config, inspectGitPaths, baseCwd, deps = {}) {
|
|
|
276
300
|
continue;
|
|
277
301
|
}
|
|
278
302
|
|
|
279
|
-
// Rule A —
|
|
303
|
+
// Rule A — apply the project's prompt tracking policy only when Git says it would
|
|
280
304
|
// actually include a live prompt. This covers broad forms (`add .`, `-A`,
|
|
281
305
|
// pathless commit, commit -a) without blocking ignored or clean prompts.
|
|
282
306
|
const git = parseGitInvocation(segTokens, cwd);
|
|
283
307
|
if (git) {
|
|
284
308
|
const includedPaths = inspectGitPaths(git.subcommand, git.args, git.cwd);
|
|
285
309
|
const targets = [...new Set(includedPaths.filter(candidate => isPromptPath(candidate, config)))];
|
|
286
|
-
|
|
310
|
+
const tracking = config.promptTracking ?? 'local';
|
|
311
|
+
if (targets.length > 0 && tracking !== 'tracked') {
|
|
287
312
|
return {
|
|
288
|
-
decision: 'deny',
|
|
313
|
+
decision: tracking === 'untracked' ? 'warn' : 'deny',
|
|
289
314
|
rule: 'commit-prompt',
|
|
290
315
|
detail: `git ${git.subcommand} ${targets.join(' ')}`,
|
|
291
|
-
reason:
|
|
292
|
-
`
|
|
293
|
-
|
|
294
|
-
|
|
316
|
+
reason: tracking === 'untracked'
|
|
317
|
+
? `This project's promptTracking is untracked: leaving pending prompts (${targets.join(', ')}) out of commits is recommended to reduce commit churn. ` +
|
|
318
|
+
`Untracked does not mean ignored; broad commands such as \`git add -A\` can include them. ` +
|
|
319
|
+
`Choose files explicitly, or use \`runlist config set promptTracking tracked\` if the project wants to commit prompts.`
|
|
320
|
+
: `This project's promptTracking is local (the legacy default for existing configs). ` +
|
|
321
|
+
`Exclude pending prompts (${targets.join(', ')}) from this Git operation. ` +
|
|
322
|
+
`Inspect the policy with \`runlist config get promptTracking\`; explicitly choose tracked to allow prompt commits, or untracked for advisory warnings.`,
|
|
295
323
|
};
|
|
296
324
|
}
|
|
297
325
|
}
|
package/src/handoff-record.mjs
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
5
|
+
import { replaceFrontmatterField } from './frontmatter-fix.mjs';
|
|
6
|
+
import { renderLifecycleMutation } from './lifecycle.mjs';
|
|
7
|
+
import { mutateFile, withPathLocks } from './atomic-mutation.mjs';
|
|
8
|
+
import { commitRename } from './durable-rename.mjs';
|
|
9
|
+
import { authorizeManagedSource } from './managed-path.mjs';
|
|
10
|
+
import { DotmdError, die, isArchivedPath, nowIso, shellQuote, toRepoPath } from './util.mjs';
|
|
5
11
|
import { stateDir } from './naming.mjs';
|
|
6
|
-
import { resolveDocArg } from './index.mjs';
|
|
12
|
+
import { docArgMissMessage, resolveDocArg } from './index.mjs';
|
|
7
13
|
import { authoritativeSessionId } from './pickup.mjs';
|
|
8
|
-
import { green } from './color.mjs';
|
|
14
|
+
import { dim, green } from './color.mjs';
|
|
9
15
|
|
|
10
16
|
// A baton that releases an in-session plan saves the prompt first, so a usage
|
|
11
17
|
// stop partway through the handoff loses nothing. The plan update that follows
|
|
@@ -13,6 +19,7 @@ import { green } from './color.mjs';
|
|
|
13
19
|
// done, and left next_step saying what it said before the work. So baton leaves
|
|
14
20
|
// a record of the plan's next_step as it stood when the prompt was saved, and
|
|
15
21
|
// `runlist baton --done` reports the handoff done only once next_step differs.
|
|
22
|
+
// `--next-step` writes the new value in the same call.
|
|
16
23
|
// The record is local state under the runlist state directory, one per plan,
|
|
17
24
|
// so a later session (or this one after a compaction) can still confirm it.
|
|
18
25
|
|
|
@@ -20,15 +27,25 @@ function recordsDir(config) {
|
|
|
20
27
|
return path.join(stateDir(config.repoRoot), 'handoffs');
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
// Keyed by the plan's whole repo path: two plans sharing a file name in
|
|
31
|
+
// different directories each keep their own record. The hash keeps the name
|
|
32
|
+
// legal on every filesystem; the file name in front is only for a human
|
|
33
|
+
// listing the directory. Records written before this were keyed by the file
|
|
34
|
+
// name alone (`widget.json`); they are still read, and matched by the `plan`
|
|
35
|
+
// they carry, never by their name.
|
|
23
36
|
function recordPath(config, planRepoPath) {
|
|
24
|
-
|
|
37
|
+
const key = createHash('sha256').update(planRepoPath).digest('hex').slice(0, 16);
|
|
38
|
+
return path.join(recordsDir(config), `${path.basename(planRepoPath, '.md')}-${key}.json`);
|
|
25
39
|
}
|
|
26
40
|
|
|
27
|
-
function
|
|
28
|
-
const { frontmatter } = extractFrontmatter(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
function frontmatterOf(content) {
|
|
42
|
+
const { frontmatter } = extractFrontmatter(content);
|
|
43
|
+
return frontmatter ? parseSimpleFrontmatter(frontmatter) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nextStepOf(content) {
|
|
47
|
+
const fm = frontmatterOf(content);
|
|
48
|
+
if (!fm || !Object.prototype.hasOwnProperty.call(fm, 'next_step')) return { present: false, value: null };
|
|
32
49
|
return { present: true, value: JSON.stringify(fm.next_step ?? null) };
|
|
33
50
|
}
|
|
34
51
|
|
|
@@ -36,10 +53,13 @@ function planNextStep(absPath) {
|
|
|
36
53
|
// the record when the plan carries a next_step to wait on, else null (a repo
|
|
37
54
|
// whose plans have no next_step field has nothing for --done to check).
|
|
38
55
|
export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, promptName }) {
|
|
39
|
-
const
|
|
40
|
-
const { present, value } =
|
|
56
|
+
const content = readFileSync(path.resolve(config.repoRoot, planRepoPath), 'utf8');
|
|
57
|
+
const { present, value } = nextStepOf(content);
|
|
41
58
|
if (!present) return null;
|
|
59
|
+
// `id` names this generation of the record: savedAt has one-second
|
|
60
|
+
// resolution, so two batons for one plan within a second share it.
|
|
42
61
|
const record = {
|
|
62
|
+
id: randomUUID(),
|
|
43
63
|
plan: planRepoPath,
|
|
44
64
|
prompt: promptRepoPath,
|
|
45
65
|
name: promptName,
|
|
@@ -47,8 +67,20 @@ export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, pro
|
|
|
47
67
|
nextStep: value,
|
|
48
68
|
sessionId: authoritativeSessionId() ?? null,
|
|
49
69
|
};
|
|
70
|
+
const file = recordPath(config, planRepoPath);
|
|
71
|
+
// An older record for this same plan (the name-keyed form) is replaced, not
|
|
72
|
+
// left beside the new one to be finished separately.
|
|
73
|
+
const superseded = listPendingHandoffs(config).filter(r => r.plan === planRepoPath && r.recordFile !== file).map(r => r.recordFile);
|
|
50
74
|
mkdirSync(recordsDir(config), { recursive: true });
|
|
51
|
-
|
|
75
|
+
// Under the record's lock, like `--done`, so a confirmation that checked
|
|
76
|
+
// the previous record can never remove this one.
|
|
77
|
+
withPathLocks([file, ...superseded], { repoRoot: config.repoRoot }, () => {
|
|
78
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
79
|
+
writeFileSync(temp, JSON.stringify(record, null, 2) + '\n');
|
|
80
|
+
try { commitRename(temp, file); }
|
|
81
|
+
catch (err) { rmSync(temp, { force: true }); throw err; }
|
|
82
|
+
for (const stale of superseded) rmSync(stale, { force: true });
|
|
83
|
+
});
|
|
52
84
|
return record;
|
|
53
85
|
}
|
|
54
86
|
|
|
@@ -69,39 +101,267 @@ export function pendingHandoffsForSession(config, sessionId = authoritativeSessi
|
|
|
69
101
|
return listPendingHandoffs(config).filter(record => record.sessionId === sessionId);
|
|
70
102
|
}
|
|
71
103
|
|
|
104
|
+
// The frontmatter half of the update is a flag on --done, so the guidance never
|
|
105
|
+
// asks for a hand edit of next_step: the body is prose the session writes, the
|
|
106
|
+
// two fields are what this verb writes.
|
|
72
107
|
export function handoffNotDoneLine(record, doneCommand = 'runlist baton --done') {
|
|
73
|
-
return `Handoff not done yet: the prompt is saved, and ${record.plan} still needs bringing up to date
|
|
108
|
+
return `Handoff not done yet: the prompt is saved, and ${record.plan} still needs bringing up to date. Update the plan body first, then run \`${doneCommand} --next-step "<next move>"\` (add \`--current-state "<where it stands>"\` if that changed): it writes the frontmatter and confirms the handoff.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Whitespace runs collapse to one space: that is what the folded scalar the
|
|
112
|
+
// writer emits reads back as, so a value differing only in spacing is the same
|
|
113
|
+
// next_step and must not pass the gate.
|
|
114
|
+
function normalizeFieldValue(value) {
|
|
115
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Every completion path compares the same way: a string next_step after
|
|
119
|
+
// whitespace normalisation, anything else (a list, a boolean) as parsed.
|
|
120
|
+
function comparableNextStep(json) {
|
|
121
|
+
let value;
|
|
122
|
+
try { value = JSON.parse(json); } catch { return undefined; }
|
|
123
|
+
return JSON.stringify(typeof value === 'string' ? normalizeFieldValue(value) : value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stillAsRecorded(record, content) {
|
|
127
|
+
const { present, value } = nextStepOf(content);
|
|
128
|
+
return present && comparableNextStep(value) === comparableNextStep(record.nextStep);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One line of prose per field. A newline is refused rather than folded away:
|
|
132
|
+
// the writer emits a folded scalar, which would silently join the lines, and a
|
|
133
|
+
// next_step that needs several lines belongs in the plan body.
|
|
134
|
+
function checkedFieldValue(flag, value) {
|
|
135
|
+
if (typeof value !== 'string' || !value.trim()) die(`${flag} needs a nonempty value, e.g. ${flag} "<text>".`);
|
|
136
|
+
if (/[\r\n]/.test(value)) die(`${flag} takes one line; put anything longer in the plan body.`);
|
|
137
|
+
return normalizeFieldValue(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderFieldUpdates(raw, fields) {
|
|
141
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
142
|
+
if (!frontmatter) throw new Error('the plan has no frontmatter block');
|
|
143
|
+
let next = frontmatter;
|
|
144
|
+
for (const [key, value] of Object.entries(fields)) next = replaceFrontmatterField(next, key, value);
|
|
145
|
+
return renderLifecycleMutation(replaceFrontmatter(raw, next), { updated: nowIso() }, null);
|
|
74
146
|
}
|
|
75
147
|
|
|
76
|
-
|
|
77
|
-
|
|
148
|
+
|
|
149
|
+
function readRecord(file) {
|
|
150
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); }
|
|
151
|
+
catch { return null; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Whether the record on disk is still the generation this call read. A record
|
|
155
|
+
// written before ids existed is compared whole.
|
|
156
|
+
function sameGeneration(record, current) {
|
|
157
|
+
if (!current) return false;
|
|
158
|
+
if (record.id !== undefined) return current.id === record.id;
|
|
159
|
+
const { recordFile: _file, ...saved } = record;
|
|
160
|
+
return JSON.stringify(current) === JSON.stringify(saved);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isGone = (config, record) => !existsSync(path.resolve(config.repoRoot, record.plan));
|
|
164
|
+
|
|
165
|
+
function recordLine(config, record) {
|
|
166
|
+
return ` ${record.plan} (${isGone(config, record) ? 'gone' : 'still in place'}; record ${toRepoPath(record.recordFile, config.repoRoot)}, prompt ${record.name}, saved ${record.savedAt})`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Every command printed for a path is quoted, so it runs as shown.
|
|
170
|
+
const doneCommand = repoPath => `runlist baton --done ${shellQuote(repoPath)}`;
|
|
171
|
+
const clearCommand = record => doneCommand(record.plan);
|
|
172
|
+
const ARCHIVE_HINT = 'To archive a plan as part of a handoff, use `runlist baton --status archived`.';
|
|
173
|
+
|
|
174
|
+
// Whether a gone plan has a copy where `runlist archive` would have put it.
|
|
175
|
+
function archivedCopyExists(config, repoPath) {
|
|
176
|
+
const abs = path.resolve(config.repoRoot, repoPath);
|
|
177
|
+
return (config.docsRoots || [config.docsRoot]).some(root => {
|
|
178
|
+
const rel = path.relative(root, abs);
|
|
179
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) && existsSync(path.join(root, config.archiveDir, path.basename(abs)));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A plan whose handoff names a path it is no longer at. Writes never follow
|
|
184
|
+
// it: the fields go only to the plan at the path the handoff was saved for.
|
|
185
|
+
function goneMessage(config, record, writing) {
|
|
186
|
+
const lead = writing
|
|
187
|
+
? `${record.plan} is gone (moved or archived?), so nothing was written: --next-step and --current-state write only the plan at the path its handoff was saved for.`
|
|
188
|
+
: `${record.plan} is gone (moved or archived?).`;
|
|
189
|
+
const hint = writing && archivedCopyExists(config, record.plan) ? `\n${ARCHIVE_HINT}` : '';
|
|
190
|
+
return `${lead} Bring the plan up to date where it is now, then run \`runlist baton --done <its new path>\`; or clear this handoff by its old path: \`${clearCommand(record)}\`.${hint}\nSaved prompt: ${record.name}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A named plan finishes the record saved for that exact path. The one
|
|
194
|
+
// exception is plain --done (no fields to write) for a plan that moved after
|
|
195
|
+
// the prompt was saved: when exactly one record shares the file name and its
|
|
196
|
+
// plan is gone, that record is cleared once the plan at the new path has a
|
|
197
|
+
// different next_step. It writes no plan, so the worst a wrong guess does is
|
|
198
|
+
// clear a reminder; anything less certain is refused with the records listed.
|
|
199
|
+
function namedHandoff(config, repoPath, writing) {
|
|
200
|
+
const records = listPendingHandoffs(config);
|
|
201
|
+
const exact = records.find(r => r.plan === repoPath);
|
|
202
|
+
if (exact) return { record: exact, relocated: false };
|
|
203
|
+
const sameName = records.filter(r => typeof r.plan === 'string' && path.basename(r.plan) === path.basename(repoPath));
|
|
204
|
+
const moved = sameName.filter(r => isGone(config, r));
|
|
205
|
+
if (!sameName.length) die(`No handoff waits on ${repoPath}: baton has not saved a prompt for it that needs the plan brought up to date.`);
|
|
206
|
+
if (!moved.length) die(`No handoff waits on ${repoPath}. The handoff for a plan of the same file name is ${sameName.map(r => r.plan).join(', ')}, which is still in place; finish it there: ${clearCommand(sameName[0])}`);
|
|
207
|
+
// With several candidates, at most one was saved for this plan, and
|
|
208
|
+
// clearing it says nothing about whose the others are.
|
|
209
|
+
const clearOne = `At most one of these was saved for this plan; clear that one by its old path (only its record is removed, the others stay pending):\n${moved.map(r => ` ${clearCommand(r)}`).join('\n')}`;
|
|
210
|
+
if (writing) {
|
|
211
|
+
const recover = sameName.length === 1
|
|
212
|
+
? `If ${repoPath} is that plan, moved: bring it up to date by any means and run \`${doneCommand(repoPath)}\`, or clear the handoff by its old path: \`${clearCommand(moved[0])}\`.`
|
|
213
|
+
: clearOne;
|
|
214
|
+
const hint = isArchivedPath(repoPath, config) ? `\n${ARCHIVE_HINT}` : '';
|
|
215
|
+
die(`No handoff was saved for ${repoPath}, so nothing was written: --next-step and --current-state write only the plan at the path a handoff was saved for.\n${sameName.map(r => recordLine(config, r)).join('\n')}\n${recover}${hint}`);
|
|
216
|
+
}
|
|
217
|
+
if (sameName.length > 1) {
|
|
218
|
+
die(`No handoff was saved for ${repoPath}, and ${sameName.length} handoffs for plans of that file name could be meant:\n${sameName.map(r => recordLine(config, r)).join('\n')}\n${clearOne}`);
|
|
219
|
+
}
|
|
220
|
+
return { record: moved[0], relocated: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `runlist baton --done [<plan>] [--next-step "…"] [--current-state "…"]`
|
|
224
|
+
export function runBatonDone(planArg, config, { json = false, dryRun = false, nextStep = null, currentState = null } = {}) {
|
|
225
|
+
const fields = {};
|
|
226
|
+
if (nextStep !== null) fields.next_step = checkedFieldValue('--next-step', nextStep);
|
|
227
|
+
if (currentState !== null) fields.current_state = checkedFieldValue('--current-state', currentState);
|
|
228
|
+
const writing = Object.keys(fields).length > 0;
|
|
229
|
+
if (dryRun && !writing) die('`runlist baton --done` changes nothing but its own record; run it without --dry-run, or preview a write with --next-step.');
|
|
230
|
+
|
|
78
231
|
let record;
|
|
232
|
+
let relocated = false;
|
|
233
|
+
let abs = null;
|
|
79
234
|
if (planArg) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
235
|
+
// The argument is first taken as a path, exactly as typed: a record saved
|
|
236
|
+
// for that path is the handoff meant, whether or not the plan is still
|
|
237
|
+
// there. Resolving it as a doc first could land on another plan (another
|
|
238
|
+
// root, a slug, a file name) while the named path is gone.
|
|
239
|
+
// As for any doc argument, a path typed without `.md` names the file with it.
|
|
240
|
+
const argRepoPath = toRepoPath(path.resolve(config.repoRoot, planArg), config.repoRoot);
|
|
241
|
+
const candidates = argRepoPath.endsWith('.md') ? [argRepoPath] : [argRepoPath, `${argRepoPath}.md`];
|
|
242
|
+
const pending = listPendingHandoffs(config);
|
|
243
|
+
record = candidates.map(candidate => pending.find(r => r.plan === candidate)).find(Boolean);
|
|
244
|
+
if (!record) {
|
|
245
|
+
abs = resolveDocArg(planArg, config, { dieOnMiss: false });
|
|
246
|
+
if (!abs) die(docArgMissMessage(planArg, config));
|
|
247
|
+
({ record, relocated } = namedHandoff(config, toRepoPath(abs, config.repoRoot), writing));
|
|
248
|
+
}
|
|
85
249
|
} else {
|
|
86
250
|
const mine = pendingHandoffsForSession(config);
|
|
87
251
|
if (mine.length === 0) die('No handoff of this session waits on a plan update. Name the plan: runlist baton --done <plan-file>');
|
|
88
|
-
if (mine.length > 1) die(`This session has ${mine.length} handoffs waiting; name the plan:\n${mine.map(r => `
|
|
252
|
+
if (mine.length > 1) die(`This session has ${mine.length} handoffs waiting; name the plan:\n${mine.map(r => ` ${doneCommand(r.plan)}`).join('\n')}`);
|
|
89
253
|
record = mine[0];
|
|
90
254
|
}
|
|
91
255
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
256
|
+
abs ??= path.resolve(config.repoRoot, record.plan);
|
|
257
|
+
const planGone = !existsSync(abs);
|
|
258
|
+
// Naming the gone path itself is how a handoff whose plan moved away is
|
|
259
|
+
// cleared; with fields to write, or with no path named, it is refused.
|
|
260
|
+
if (planGone && (writing || !planArg)) die(goneMessage(config, record, writing));
|
|
261
|
+
const planRepoPath = toRepoPath(abs, config.repoRoot);
|
|
262
|
+
const again = planArg ? doneCommand(planRepoPath) : 'runlist baton --done';
|
|
263
|
+
|
|
264
|
+
if (fields.next_step !== undefined && JSON.stringify(fields.next_step) === comparableNextStep(record.nextStep)) {
|
|
265
|
+
die(`Not done: --next-step repeats the next_step ${planRepoPath} had when the prompt was saved (${record.savedAt}). Write where the work goes from here.\nSaved prompt: ${record.name}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let source = abs;
|
|
269
|
+
if (writing) {
|
|
270
|
+
try { source = authorizeManagedSource(abs, config, { kind: 'Baton --done plan' }).path; }
|
|
271
|
+
catch (err) { die(err.message); }
|
|
97
272
|
}
|
|
98
|
-
|
|
99
|
-
const
|
|
273
|
+
|
|
274
|
+
const finish = raw => {
|
|
275
|
+
const next = writing ? renderFieldUpdates(raw, fields) : raw;
|
|
276
|
+
if (stillAsRecorded(record, next)) {
|
|
277
|
+
die(`Not done: ${planRepoPath} next_step is the same as when the prompt was saved (${record.savedAt}). Update the plan body first, then run \`${again} --next-step "<next move>"\`.\nSaved prompt: ${record.name}`);
|
|
278
|
+
}
|
|
279
|
+
return next;
|
|
280
|
+
};
|
|
281
|
+
const failed = err => {
|
|
282
|
+
if (err instanceof DotmdError) throw err;
|
|
283
|
+
const verb = planGone ? 'clear the handoff for' : writing ? 'write' : 'check';
|
|
284
|
+
die(`Could not ${verb} ${planRepoPath} (${err.message}); the handoff is still pending.\nSaved prompt: ${record.name}`);
|
|
285
|
+
};
|
|
286
|
+
// Checked under the record's lock, before any plan write is published and
|
|
287
|
+
// before the record is removed: a baton that saved a newer record for this
|
|
288
|
+
// plan meanwhile is never finished by a call that read the older one.
|
|
289
|
+
const current = () => {
|
|
290
|
+
if (!sameGeneration(record, readRecord(record.recordFile))) {
|
|
291
|
+
die(`The handoff record for ${record.plan} changed while this ran (a new baton, or another --done); run \`${again}\` again.\nSaved prompt: ${record.name}`);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
let preview = null;
|
|
296
|
+
if (dryRun) {
|
|
297
|
+
try { preview = finish(readFileSync(source, 'utf8')); } catch (err) { failed(err); }
|
|
298
|
+
} else {
|
|
299
|
+
try {
|
|
300
|
+
if (planGone || relocated) {
|
|
301
|
+
// No plan is written, so no plan is locked: a relocated plan is read
|
|
302
|
+
// the way any reader would, and the old path is not touched at all.
|
|
303
|
+
if (relocated) finish(readFileSync(source, 'utf8'));
|
|
304
|
+
withPathLocks([record.recordFile], { repoRoot: config.repoRoot }, () => {
|
|
305
|
+
current();
|
|
306
|
+
// The saved path was gone when this started; a restore since then
|
|
307
|
+
// (`set active` on the archived copy) brings the plan back as it
|
|
308
|
+
// was, and that handoff is finished there, not cleared.
|
|
309
|
+
if (existsSync(path.resolve(config.repoRoot, record.plan))) {
|
|
310
|
+
die(`${record.plan} is back at the path its handoff was saved for, so the handoff was not cleared. Finish it there: \`${clearCommand(record)}\` once the plan is up to date.\nSaved prompt: ${record.name}`);
|
|
311
|
+
}
|
|
312
|
+
rmSync(record.recordFile, { force: true });
|
|
313
|
+
});
|
|
314
|
+
} else {
|
|
315
|
+
// One sorted lock set, record and plan, so the verdict is reached on
|
|
316
|
+
// the plan as it stands while the record cannot change: a refused call
|
|
317
|
+
// publishes nothing, and the record goes only after the plan passed.
|
|
318
|
+
// No other caller holds a plan lock while it takes a record lock
|
|
319
|
+
// (baton records after its plan transaction releases). Like baton
|
|
320
|
+
// itself, this leaves the shared generated index deferred.
|
|
321
|
+
withPathLocks([record.recordFile, source], { repoRoot: config.repoRoot }, () => {
|
|
322
|
+
current();
|
|
323
|
+
mutateFile(source, { repoRoot: config.repoRoot, locked: true }, finish);
|
|
324
|
+
rmSync(record.recordFile, { force: true });
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
} catch (err) { failed(err); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const result = {
|
|
331
|
+
operation: 'baton-done',
|
|
332
|
+
dryRun: Boolean(dryRun),
|
|
333
|
+
plan: planRepoPath,
|
|
334
|
+
prompt: record.prompt,
|
|
335
|
+
name: record.name,
|
|
336
|
+
nextStepChanged: !planGone,
|
|
337
|
+
planGone,
|
|
338
|
+
written: fields,
|
|
339
|
+
};
|
|
100
340
|
if (json) {
|
|
101
341
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
102
342
|
return result;
|
|
103
343
|
}
|
|
104
|
-
|
|
344
|
+
if (dryRun) {
|
|
345
|
+
const prefix = dim('[dry-run] ');
|
|
346
|
+
process.stderr.write(`${prefix}Would write ${planRepoPath}:\n`);
|
|
347
|
+
const { frontmatter } = extractFrontmatter(preview);
|
|
348
|
+
const lines = frontmatter.split('\n');
|
|
349
|
+
for (const key of Object.keys(fields)) {
|
|
350
|
+
const at = lines.findIndex(line => line.startsWith(`${key}:`));
|
|
351
|
+
for (let i = at; i >= 0 && i < lines.length && (i === at || /^[ \t]/.test(lines[i])); i++) process.stderr.write(`${prefix} ${lines[i]}\n`);
|
|
352
|
+
}
|
|
353
|
+
process.stderr.write(`${prefix}Would report the handoff done and remove its record.\n`);
|
|
354
|
+
process.stderr.write(`Saved prompt: ${record.name}\n`);
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
if (planGone) {
|
|
358
|
+
process.stderr.write(`${green('✓ Handoff cleared')}: ${planRepoPath} is gone, so there is no plan to update there; its record is removed.\n`);
|
|
359
|
+
const others = listPendingHandoffs(config).filter(r => typeof r.plan === 'string' && path.basename(r.plan) === path.basename(planRepoPath));
|
|
360
|
+
if (others.length) process.stderr.write(`Still pending, each for its own saved path: ${others.map(r => r.plan).join(', ')}.\n`);
|
|
361
|
+
} else {
|
|
362
|
+
const how = writing ? `${Object.keys(fields).join(' and ')} written` : 'next_step was updated after the prompt was saved';
|
|
363
|
+
process.stderr.write(`${green('✓ Handoff done')}: ${planRepoPath} ${how}.\n`);
|
|
364
|
+
}
|
|
105
365
|
process.stderr.write(`Saved prompt: ${record.name}\n`);
|
|
106
366
|
return result;
|
|
107
367
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Per-command help where planning is off (a handoffs-only project,
|
|
2
|
+
// src/handoffs-mode.mjs), for the commands that still work there but whose
|
|
3
|
+
// usual help describes plans. `runlist help <cmd>` and `runlist <cmd> --help`
|
|
4
|
+
// both read this through the dispatcher's one help renderer. A planning
|
|
5
|
+
// command's help there is the planning-off line itself.
|
|
6
|
+
|
|
7
|
+
const OFF = '(planning is off in this project)';
|
|
8
|
+
const SETUP = '`runlist setup` turns it on (your prompts stay where they are).';
|
|
9
|
+
|
|
10
|
+
const HELP = {
|
|
11
|
+
baton: `runlist baton — save a resume prompt for the next session ${OFF}
|
|
12
|
+
|
|
13
|
+
Usage (write the resume to a file first):
|
|
14
|
+
runlist baton <slug> @/tmp/draft.md # saves resume-<slug> in docs/prompts/
|
|
15
|
+
The resume can also come from stdin (\`-\`, or a pipe) or --message "..." for one-liners.
|
|
16
|
+
Baton prints the prompt name it saved as its last line.
|
|
17
|
+
|
|
18
|
+
The resume (10–20 lines): the next concrete decision plus any gotchas, and the
|
|
19
|
+
paths it concerns — not a recap. The next session's hud lists the prompt and
|
|
20
|
+
\`runlist use <name>\` resumes it. When a handoff for the same work is already
|
|
21
|
+
pending, baton saves nothing and names it: inspect it with
|
|
22
|
+
\`runlist prompts show <name>\`, then pass --replace to refresh its text.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--message / --body Inline body (one-liners; prefer @path or stdin)
|
|
26
|
+
--subagent Save subagent-<name> instead of resume-<name>
|
|
27
|
+
--parent <resume-name> Group this --subagent handoff under its parent in prompts listings
|
|
28
|
+
--template Print a resume draft (or --subagent draft)
|
|
29
|
+
--replace Replace exactly one pending handoff; archive its prior text
|
|
30
|
+
--json Structured result
|
|
31
|
+
--dry-run, -n Preview without writing
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
runlist baton --template > /tmp/resume.md
|
|
35
|
+
runlist baton checkout-fixes @/tmp/draft.md
|
|
36
|
+
runlist baton checkout-fixes @/tmp/new-draft.md --replace
|
|
37
|
+
runlist baton --subagent totals-review --parent resume-checkout-fixes @/tmp/child.md
|
|
38
|
+
|
|
39
|
+
Handing off a plan comes with planning; ${SETUP}`,
|
|
40
|
+
|
|
41
|
+
new: `runlist new prompt <name> [body] — save a prompt under a name of its own ${OFF}
|
|
42
|
+
|
|
43
|
+
The prompt lands in docs/prompts/<name>.md as pending; the next session's hud
|
|
44
|
+
lists it and \`runlist use <name>\` resumes it. A name with a folder in it must
|
|
45
|
+
stay inside docs/prompts/.
|
|
46
|
+
|
|
47
|
+
Body: @<file> (preferred for more than one line), - (stdin), --message "...",
|
|
48
|
+
or inline for one-liners.
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--title "..." Title (default: from the name)
|
|
52
|
+
--message / --body Inline body
|
|
53
|
+
--dry-run, -n Preview without writing
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
runlist new prompt look-at-retries @/tmp/draft.md
|
|
57
|
+
runlist new prompt cleanup --message "look at the remaining lint warnings"
|
|
58
|
+
|
|
59
|
+
Plans and docs come with planning; ${SETUP}`,
|
|
60
|
+
|
|
61
|
+
set: `runlist set <status> <prompt> — change a saved prompt's status ${OFF}
|
|
62
|
+
|
|
63
|
+
A prompt is pending (waiting for a session) or archived (consumed or set
|
|
64
|
+
aside). \`runlist set archived <prompt>\` sets one aside, as
|
|
65
|
+
\`runlist prompts archive <prompt>\` does.
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
--dry-run, -n Preview without writing
|
|
69
|
+
|
|
70
|
+
Plan statuses come with planning; ${SETUP}`,
|
|
71
|
+
|
|
72
|
+
status: `runlist status <prompt> <status> — deprecated; use \`runlist set <status> <prompt>\` ${OFF}
|
|
73
|
+
|
|
74
|
+
A prompt is pending or archived. Plan statuses come with planning; ${SETUP}`,
|
|
75
|
+
|
|
76
|
+
statuses: `runlist statuses — the prompt statuses ${OFF}
|
|
77
|
+
|
|
78
|
+
runlist statuses Table of the prompt statuses and their flags
|
|
79
|
+
runlist statuses list --json The same as JSON
|
|
80
|
+
runlist statuses add|set|remove <name> --type prompt
|
|
81
|
+
Edit the prompt statuses in runlist.config.mjs
|
|
82
|
+
|
|
83
|
+
Plan and doc statuses come with planning; ${SETUP}`,
|
|
84
|
+
|
|
85
|
+
archive: `runlist archive <prompt> — set a saved prompt aside without reading it ${OFF}
|
|
86
|
+
|
|
87
|
+
Sets the prompt to archived and moves it to docs/prompts/archived/, as
|
|
88
|
+
\`runlist prompts archive <prompt>\` does. <prompt> is a path or a name.
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--dry-run, -n Preview without writing`,
|
|
92
|
+
|
|
93
|
+
prompts: `runlist prompts — saved prompts ${OFF}
|
|
94
|
+
|
|
95
|
+
Prompts are the handoffs runlist keeps in docs/prompts/. Consuming one prints
|
|
96
|
+
its body and archives it (one-shot). Only an agent session consumes; in a plain
|
|
97
|
+
terminal \`use\` and \`next\` print the body and the command the session runs.
|
|
98
|
+
|
|
99
|
+
Subcommands:
|
|
100
|
+
list List pending prompts (default)
|
|
101
|
+
next Consume the oldest pending prompt
|
|
102
|
+
use <name> Consume a named prompt (\`resume\` is an alias)
|
|
103
|
+
show <name...> Read prompts without consuming them (\`peek\` is an alias)
|
|
104
|
+
archive <name> Set a prompt aside without reading it
|
|
105
|
+
link <child...> --parent <resume-name>
|
|
106
|
+
Attach existing child handoffs to their parent
|
|
107
|
+
new <slug> [body] Save a prompt (\`runlist new prompt <slug>\`)
|
|
108
|
+
|
|
109
|
+
A prompt is pending or archived.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
runlist prompts # pending prompts
|
|
113
|
+
runlist prompts show resume-foo # peek without consuming
|
|
114
|
+
runlist prompts show a b c # peek several by name
|
|
115
|
+
runlist prompts use resume-foo # in a session: consume it
|
|
116
|
+
runlist prompts archive old-thing # set one aside`,
|
|
117
|
+
|
|
118
|
+
briefing: `runlist briefing — the check counts ${OFF}
|
|
119
|
+
|
|
120
|
+
Shows the stale, error and warning counts, then says planning is off;
|
|
121
|
+
\`runlist prompts\` lists the saved prompts. Plan pipelines come with
|
|
122
|
+
planning; ${SETUP}
|
|
123
|
+
|
|
124
|
+
Options:
|
|
125
|
+
--json Output as JSON`,
|
|
126
|
+
|
|
127
|
+
hud: `runlist hud — what a new session is told at startup ${OFF}
|
|
128
|
+
|
|
129
|
+
Prints the pending prompts, saying to resume one only when asked
|
|
130
|
+
(\`runlist use <name>\`), and actionable notices; silent when nothing needs
|
|
131
|
+
attention. --prompt-submit reads a hook JSON payload from stdin: save requests
|
|
132
|
+
get baton instructions; pickup requests get a hint only when prompts are
|
|
133
|
+
pending. Text hooks read prompt frontmatter without building the index.
|
|
134
|
+
|
|
135
|
+
Options:
|
|
136
|
+
--prompt-submit Request-specific save/pickup hint (hook JSON on stdin)
|
|
137
|
+
--subagent Compact workflow primer for child agents
|
|
138
|
+
--json Output as JSON`,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function handoffsCommandHelp(command) {
|
|
142
|
+
return HELP[command] ?? null;
|
|
143
|
+
}
|