runlist 0.94.2 → 0.95.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/bin/runlist.mjs +8 -1
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/src/baton.mjs +31 -2
- package/src/commands.mjs +1 -1
- package/src/handoff-record.mjs +107 -0
- package/src/hud.mjs +75 -12
- package/src/plan-changes.mjs +72 -0
- package/src/prompts.mjs +16 -0
package/bin/runlist.mjs
CHANGED
|
@@ -1337,8 +1337,9 @@ Usage (write the resume to a file first, then pick the form that matches):
|
|
|
1337
1337
|
runlist baton @/tmp/draft.md # a plan is in-session: save resume-<plan-slug>, release the plan
|
|
1338
1338
|
runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
|
|
1339
1339
|
runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else
|
|
1340
|
+
runlist baton --done [<plan-file>] # after a plan handoff: confirm the plan was brought up to date
|
|
1340
1341
|
The resume can also come from stdin (\`-\`, or a pipe) or --message "..." for one-liners.
|
|
1341
|
-
Baton prints the prompt name it saved and, in plan mode, the git commit to run.
|
|
1342
|
+
Baton prints the prompt name it saved, as its last line too, and, in plan mode, the git commit to run.
|
|
1342
1343
|
|
|
1343
1344
|
The resume (10–20 lines): the next concrete decision plus any gotchas, and the
|
|
1344
1345
|
paths of the plans/docs it concerns — not a recap of the plan body.
|
|
@@ -1349,6 +1350,7 @@ Options:
|
|
|
1349
1350
|
--message / --body Inline body (one-liners; prefer @path or stdin)
|
|
1350
1351
|
--force Recover another session's plan (explicit path required)
|
|
1351
1352
|
--replace Replace exactly one pending handoff; archive its prior text
|
|
1353
|
+
--done Report a plan handoff done once next_step changed after the prompt was saved
|
|
1352
1354
|
--json Structured repository/session/generated file result
|
|
1353
1355
|
--dry-run, -n Preview without writing
|
|
1354
1356
|
|
|
@@ -1376,6 +1378,11 @@ cooperating transaction:
|
|
|
1376
1378
|
3. Defers the shared generated index and prints exact repository-only commit
|
|
1377
1379
|
guidance. Prompt and ownership records stay session-local and OUT of the
|
|
1378
1380
|
pathspec.
|
|
1381
|
+
4. When the plan was in-session and has a next_step field, the handoff is not
|
|
1382
|
+
done yet: bring the plan up to date (the body, then next_step), then run
|
|
1383
|
+
\`runlist baton --done\`, which refuses while next_step still reads as it
|
|
1384
|
+
did when the prompt was saved. The prompt is saved first, so a stop between
|
|
1385
|
+
the two steps loses nothing, and \`--done <plan-file>\` works from any session.
|
|
1379
1386
|
Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
|
|
1380
1387
|
this authoritative session. Journal entries and global in-session counts never
|
|
1381
1388
|
grant ownership. A live pickup-hook delivery lease blocks release and force
|
package/package.json
CHANGED
package/src/baton.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso
|
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
|
|
7
7
|
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
|
|
8
|
-
import { green, dim } from './color.mjs';
|
|
8
|
+
import { green, dim, yellow } from './color.mjs';
|
|
9
|
+
import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handoff-record.mjs';
|
|
9
10
|
import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
|
|
10
11
|
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
11
12
|
import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
|
|
@@ -99,6 +100,12 @@ function commitCommand(config, message, paths) {
|
|
|
99
100
|
return argv.map(shellQuote).join(' ');
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// With no plan named, --done finds this session's record; a named plan is
|
|
104
|
+
// echoed back so the command works from any session.
|
|
105
|
+
function explicitPlanDone(planArg, record) {
|
|
106
|
+
return planArg ? `runlist baton --done ${record.plan}` : 'runlist baton --done';
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
// Is this positional a filesystem reference (must resolve, typos die) or a
|
|
103
110
|
// bare word (may be a plan slug, may be a brand-new handoff name)?
|
|
104
111
|
function looksLikePath(arg) {
|
|
@@ -115,6 +122,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
115
122
|
let bodyFlag = null;
|
|
116
123
|
let force = false;
|
|
117
124
|
let replace = false;
|
|
125
|
+
let done = false;
|
|
118
126
|
const positionals = [];
|
|
119
127
|
for (let i = 0; i < argv.length; i++) {
|
|
120
128
|
const a = argv[i];
|
|
@@ -123,6 +131,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
123
131
|
if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
|
|
124
132
|
if (a === '--force') { force = true; continue; }
|
|
125
133
|
if (a === '--replace') { replace = true; continue; }
|
|
134
|
+
if (a === '--done') { done = true; continue; }
|
|
126
135
|
if (a === '--json') continue;
|
|
127
136
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
128
137
|
die(`Unknown flag for \`runlist baton\`: ${a}`);
|
|
@@ -136,6 +145,13 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
136
145
|
if (bodyArg === null) bodyArg = p; // trailing inline body
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// The second half of a plan handoff: the prompt is already saved, and this
|
|
149
|
+
// confirms the plan was brought up to date after it (src/handoff-record.mjs).
|
|
150
|
+
if (done) {
|
|
151
|
+
if (dryRun) die('`runlist baton --done` changes nothing but its own record; run it without --dry-run.');
|
|
152
|
+
return runBatonDone(planArg, config, { json });
|
|
153
|
+
}
|
|
154
|
+
|
|
139
155
|
// Body FIRST — it's the common failure (`new prompt` without a body was the
|
|
140
156
|
// top real-world baton error), and nothing must mutate before it's secured.
|
|
141
157
|
let body = null;
|
|
@@ -382,6 +398,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
382
398
|
const repositoryFiles = [...new Set(touched.filter(candidate => candidate && candidate !== ownershipRepoPath && candidate !== promptRepoPath && candidate !== archivedPromptRepoPath && candidate !== normalizeRepoPath(config.indexPath)))];
|
|
383
399
|
const sessionFiles = [...new Set([...(newResult?.sessionFiles ?? []), promptRepoPath, archivedPromptRepoPath, ownershipRepoPath].filter(Boolean))];
|
|
384
400
|
const deferredGeneratedFiles = [...new Set(newResult?.deferredGeneratedFiles ?? (config.indexPath ? [normalizeRepoPath(config.indexPath)] : []))];
|
|
401
|
+
// A released in-session plan is not handed off until its next_step says
|
|
402
|
+
// where the work now stands. The prompt is already saved either way.
|
|
403
|
+
let pendingHandoff = null;
|
|
404
|
+
if (planPath && !dryRun && !planGuard && (oldStatus === 'in-session' || wouldReleaseClaim)) {
|
|
405
|
+
try {
|
|
406
|
+
pendingHandoff = recordPendingHandoff(config, { planRepoPath: normalizeRepoPath(archiveResult?.newRepoPath) ?? repoPath, promptRepoPath, promptName: createdSlug });
|
|
407
|
+
} catch (err) { warn(`Could not record the pending plan update (${err.message}); bring the plan up to date anyway.`); }
|
|
408
|
+
}
|
|
385
409
|
const operationResult = {
|
|
386
410
|
operation: 'baton',
|
|
387
411
|
dryRun: Boolean(dryRun),
|
|
@@ -403,6 +427,8 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
403
427
|
alreadyCurrent: replacement.alreadyCurrent,
|
|
404
428
|
} : null,
|
|
405
429
|
claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
|
|
430
|
+
handoffDone: !pendingHandoff,
|
|
431
|
+
savedPrompt: createdSlug,
|
|
406
432
|
};
|
|
407
433
|
|
|
408
434
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
@@ -410,7 +436,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
410
436
|
process.stdout.write(JSON.stringify(operationResult, null, 2) + '\n');
|
|
411
437
|
return operationResult;
|
|
412
438
|
}
|
|
413
|
-
const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
|
|
439
|
+
const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : pendingHandoff ? '✓ Prompt saved' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
|
|
414
440
|
process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
415
441
|
if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
|
|
416
442
|
if (planGuard) {
|
|
@@ -431,5 +457,8 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
431
457
|
}
|
|
432
458
|
}
|
|
433
459
|
if (operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Generated index deferred: ${operationResult.deferredGeneratedFiles.join(', ')}\n`));
|
|
460
|
+
if (pendingHandoff) process.stderr.write(yellow(handoffNotDoneLine(pendingHandoff, explicitPlanDone(planArg, pendingHandoff))) + '\n');
|
|
461
|
+
// The name again, last, so a piped `| tail` still shows what was saved.
|
|
462
|
+
process.stderr.write(`${prefix}Saved prompt: ${createdSlug}\n`);
|
|
434
463
|
return operationResult;
|
|
435
464
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -140,7 +140,7 @@ const definitions = [
|
|
|
140
140
|
], { aliases: ['prompt'] }),
|
|
141
141
|
command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
142
142
|
command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
|
|
143
|
-
command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--replace'), flag('--json')] })]),
|
|
143
|
+
command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--replace'), flag('--done'), flag('--json')] })]),
|
|
144
144
|
command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
|
|
145
145
|
form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
|
|
146
146
|
form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { die, nowIso, toRepoPath } from './util.mjs';
|
|
5
|
+
import { stateDir } from './naming.mjs';
|
|
6
|
+
import { resolveDocArg } from './index.mjs';
|
|
7
|
+
import { authoritativeSessionId } from './pickup.mjs';
|
|
8
|
+
import { green } from './color.mjs';
|
|
9
|
+
|
|
10
|
+
// A baton that releases an in-session plan saves the prompt first, so a usage
|
|
11
|
+
// stop partway through the handoff loses nothing. The plan update that follows
|
|
12
|
+
// is the part sessions skipped: they saved the prompt, reported the handoff
|
|
13
|
+
// done, and left next_step saying what it said before the work. So baton leaves
|
|
14
|
+
// a record of the plan's next_step as it stood when the prompt was saved, and
|
|
15
|
+
// `runlist baton --done` reports the handoff done only once next_step differs.
|
|
16
|
+
// The record is local state under the runlist state directory, one per plan,
|
|
17
|
+
// so a later session (or this one after a compaction) can still confirm it.
|
|
18
|
+
|
|
19
|
+
function recordsDir(config) {
|
|
20
|
+
return path.join(stateDir(config.repoRoot), 'handoffs');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function recordPath(config, planRepoPath) {
|
|
24
|
+
return path.join(recordsDir(config), `${path.basename(planRepoPath, '.md')}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function planNextStep(absPath) {
|
|
28
|
+
const { frontmatter } = extractFrontmatter(readFileSync(absPath, 'utf8'));
|
|
29
|
+
if (!frontmatter) return { present: false, value: null };
|
|
30
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(fm, 'next_step')) return { present: false, value: null };
|
|
32
|
+
return { present: true, value: JSON.stringify(fm.next_step ?? null) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Called by baton after the prompt and the plan release are published. Returns
|
|
36
|
+
// the record when the plan carries a next_step to wait on, else null (a repo
|
|
37
|
+
// whose plans have no next_step field has nothing for --done to check).
|
|
38
|
+
export function recordPendingHandoff(config, { planRepoPath, promptRepoPath, promptName }) {
|
|
39
|
+
const abs = path.resolve(config.repoRoot, planRepoPath);
|
|
40
|
+
const { present, value } = planNextStep(abs);
|
|
41
|
+
if (!present) return null;
|
|
42
|
+
const record = {
|
|
43
|
+
plan: planRepoPath,
|
|
44
|
+
prompt: promptRepoPath,
|
|
45
|
+
name: promptName,
|
|
46
|
+
savedAt: nowIso(),
|
|
47
|
+
nextStep: value,
|
|
48
|
+
sessionId: authoritativeSessionId() ?? null,
|
|
49
|
+
};
|
|
50
|
+
mkdirSync(recordsDir(config), { recursive: true });
|
|
51
|
+
writeFileSync(recordPath(config, planRepoPath), JSON.stringify(record, null, 2) + '\n');
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listPendingHandoffs(config) {
|
|
56
|
+
const dir = recordsDir(config);
|
|
57
|
+
if (!existsSync(dir)) return [];
|
|
58
|
+
const records = [];
|
|
59
|
+
for (const name of readdirSync(dir)) {
|
|
60
|
+
if (!name.endsWith('.json')) continue;
|
|
61
|
+
try { records.push({ ...JSON.parse(readFileSync(path.join(dir, name), 'utf8')), recordFile: path.join(dir, name) }); }
|
|
62
|
+
catch { /* a torn record is not a handoff to wait on */ }
|
|
63
|
+
}
|
|
64
|
+
return records;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function pendingHandoffsForSession(config, sessionId = authoritativeSessionId()) {
|
|
68
|
+
if (!sessionId) return [];
|
|
69
|
+
return listPendingHandoffs(config).filter(record => record.sessionId === sessionId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
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 (the body, then next_step). Then run \`${doneCommand}\`.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `runlist baton --done [<plan>]`
|
|
77
|
+
export function runBatonDone(planArg, config, { json = false } = {}) {
|
|
78
|
+
let record;
|
|
79
|
+
if (planArg) {
|
|
80
|
+
const abs = resolveDocArg(planArg, config);
|
|
81
|
+
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
82
|
+
record = listPendingHandoffs(config).find(r => r.plan === repoPath)
|
|
83
|
+
?? listPendingHandoffs(config).find(r => path.basename(r.plan) === path.basename(repoPath));
|
|
84
|
+
if (!record) die(`No handoff waits on ${repoPath}: baton has not saved a prompt for it that needs the plan brought up to date.`);
|
|
85
|
+
} else {
|
|
86
|
+
const mine = pendingHandoffsForSession(config);
|
|
87
|
+
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 => ` runlist baton --done ${r.plan}`).join('\n')}`);
|
|
89
|
+
record = mine[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const abs = path.resolve(config.repoRoot, record.plan);
|
|
93
|
+
if (!existsSync(abs)) die(`${record.plan} is gone (moved or archived?). Run \`runlist baton --done <its new path>\` once it is up to date.\nSaved prompt: ${record.name}`);
|
|
94
|
+
const { present, value } = planNextStep(abs);
|
|
95
|
+
if (present && value === record.nextStep) {
|
|
96
|
+
die(`Not done: ${record.plan} next_step is the same as when the prompt was saved (${record.savedAt}). Bring the plan up to date (the body, then next_step), then run \`runlist baton --done${planArg ? ` ${record.plan}` : ''}\` again.\nSaved prompt: ${record.name}`);
|
|
97
|
+
}
|
|
98
|
+
rmSync(record.recordFile, { force: true });
|
|
99
|
+
const result = { operation: 'baton-done', plan: record.plan, prompt: record.prompt, name: record.name, nextStepChanged: true };
|
|
100
|
+
if (json) {
|
|
101
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
process.stderr.write(`${green('✓ Handoff done')}: ${record.plan} next_step was updated after the prompt was saved.\n`);
|
|
105
|
+
process.stderr.write(`Saved prompt: ${record.name}\n`);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
package/src/hud.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { readJournalEntries, journalFilePath, readMisuseEntries } from './journa
|
|
|
11
11
|
import { compareVersions } from './update.mjs';
|
|
12
12
|
import { findOwnedPlan } from './baton.mjs';
|
|
13
13
|
import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
|
|
14
|
+
import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
|
|
14
15
|
import { actionablePromptStatuses, comparePromptDocs, resolveStatusMetadata } from './status-metadata.mjs';
|
|
15
16
|
|
|
16
17
|
export { actionablePromptStatuses } from './status-metadata.mjs';
|
|
@@ -327,14 +328,47 @@ export function buildPlanStatusPrimer(config, { maxChars = 220 } = {}) {
|
|
|
327
328
|
// form for its situation before it goes looking. Sessions ran `baton --help`
|
|
328
329
|
// before nearly every baton even with the form in CLAUDE.md, because the right
|
|
329
330
|
// form depends on whether this session owns a plan, which only runlist knows.
|
|
330
|
-
|
|
331
|
+
//
|
|
332
|
+
// Only a request fires it. Matching the word anywhere answered questions about
|
|
333
|
+
// batons ("what is the deal with the baton?") with handoff steps, and sessions
|
|
334
|
+
// read that as a go to hand off. So the prompt is read clause by clause: a
|
|
335
|
+
// clause that opens as a question never counts, and "baton" counts only as a
|
|
336
|
+
// verb (opening the clause, after "and"/"then", or "do a baton"), never as the
|
|
337
|
+
// subject of a sentence ("baton prints its name first").
|
|
338
|
+
const HANDOFF_PHRASE = /\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|later|tomorrow)\b/i;
|
|
339
|
+
const LEAD_WORDS = String.raw`(?:(?:ok(?:ay)?|alright|right|now|then|and|also|but|so|yes|yep|great|good|cool|thanks|please|pls|go ahead and|let'?s|lets|time to)\b[,\s]*)*`;
|
|
340
|
+
const POLITE = String.raw`(?:(?:can|could|would|will) you (?:please )?(?:mind )?)`;
|
|
341
|
+
const BATON_AS_NOUN = String.raw`(?:(?:do|doing|make|write|leave|save|pass|run|drop) (?:a |the |your |another |one more )?)`;
|
|
342
|
+
// A word here after "baton" makes it the subject or a noun ("baton is",
|
|
343
|
+
// "baton prints", "baton command"), not a request.
|
|
344
|
+
const NOT_A_VERB = String.raw`(?!\s+(?:is|isn'?t|was|wasn'?t|are|were|does|doesn'?t|did|didn'?t|do|has|had|have|seems|should|shall|would|will|won'?t|can|can'?t|could|keeps|kept|works|worked|fails|failed|prints|printed|says|said|saves|saved|refuses|refused|output|outputs|command|commands|text|hint|hook|line|skill|tool|step|steps|definition|prompt|prompts)\b)(?!'s\b)`;
|
|
345
|
+
const BATON_REQUEST = [
|
|
346
|
+
new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
|
|
347
|
+
new RegExp(String.raw`(?:\band|\bthen|&)\s+(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
|
|
348
|
+
new RegExp(String.raw`\b(?:do|make|write|leave|save|pass) (?:a |your |another |one more )baton\b`, 'i'),
|
|
349
|
+
];
|
|
350
|
+
const QUESTION_START = new RegExp(String.raw`^${LEAD_WORDS}(?:what'?s|whats|what|why|how|when|where|who|whose|which|is|isn'?t|are|aren'?t|was|wasn'?t|were|does|doesn'?t|did|didn'?t|has|have|had|should|shall|do(?! (?:a |the |your |another |one more )?baton)|(?:can|could|would|will)(?! you\b))\b`, 'i');
|
|
331
351
|
|
|
332
352
|
// The harness hands background-task events to UserPromptSubmit as if typed.
|
|
333
353
|
// A finished agent's report that names its handoff file is not a request.
|
|
334
354
|
const SYSTEM_EVENT = /<task-notification>|\[SYSTEM NOTIFICATION\b/;
|
|
335
355
|
|
|
356
|
+
function clauses(prompt) {
|
|
357
|
+
return prompt.split(/(?<=[.!?;\n])/).map(clause => clause.trim()).filter(Boolean);
|
|
358
|
+
}
|
|
359
|
+
|
|
336
360
|
export function isHandoffAsk(prompt) {
|
|
337
|
-
|
|
361
|
+
if (typeof prompt !== 'string' || SYSTEM_EVENT.test(prompt)) return false;
|
|
362
|
+
return clauses(prompt).some(clause => !QUESTION_START.test(clause)
|
|
363
|
+
&& (BATON_REQUEST.some(re => re.test(clause)) || HANDOFF_PHRASE.test(clause)));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The repo's own definition of baton (`templates.prompt.baton` in its config),
|
|
367
|
+
// when it has one. Read from the loaded config only: the hook path never builds
|
|
368
|
+
// the index.
|
|
369
|
+
export function repoBatonDefinition(config) {
|
|
370
|
+
const text = config?.raw?.templates?.prompt?.baton;
|
|
371
|
+
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
|
338
372
|
}
|
|
339
373
|
|
|
340
374
|
export function buildHandoffContext(config) {
|
|
@@ -346,15 +380,26 @@ export function buildHandoffContext(config) {
|
|
|
346
380
|
const records = listOwnedPlans(config);
|
|
347
381
|
if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
|
|
348
382
|
} catch { /* fall through to the no-plan form */ }
|
|
349
|
-
const
|
|
350
|
-
|
|
383
|
+
const done = 'Baton does not report the handoff done until the plan\'s next_step has been updated after the prompt was saved: update the plan, then run';
|
|
384
|
+
let who;
|
|
385
|
+
let command;
|
|
351
386
|
if (ownedPaths.length === 1) {
|
|
352
|
-
|
|
387
|
+
who = `this session owns ${ownedPaths[0]}.`;
|
|
388
|
+
command = `Save the prompt with \`runlist baton @<file>\`: it releases the plan to active (\`--status paused|awaiting|partial|blocked\` and \`--note "why"\` if that fits better). ${done} \`runlist baton --done\`.`;
|
|
389
|
+
} else if (ownedPaths.length > 1) {
|
|
390
|
+
who = `this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off.`;
|
|
391
|
+
command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file>\`.`;
|
|
392
|
+
} else {
|
|
393
|
+
who = 'this session owns no plan.';
|
|
394
|
+
command = 'Save the prompt with `runlist baton <slug> @<file>` (saves resume-<slug>, changes nothing else), or `runlist baton <plan-file> @<file>` to hand off a plan by path.';
|
|
353
395
|
}
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
const tail = 'Baton prints the prompt name it saved as its last line; tell the user that name. No `--help` needed.';
|
|
397
|
+
const definition = repoBatonDefinition(config);
|
|
398
|
+
if (definition) {
|
|
399
|
+
return `[runlist] Baton: ${who} ${command} Where the definition below names a command, use this form. ${tail}\nThis repo defines baton (templates.prompt.baton in its runlist config):\n${definition}`;
|
|
356
400
|
}
|
|
357
|
-
|
|
401
|
+
const draft = 'Write the resume first (the next concrete decision, any gotchas, the plan/doc paths it concerns; not a recap) to a file in your scratchpad.';
|
|
402
|
+
return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
|
|
358
403
|
}
|
|
359
404
|
|
|
360
405
|
export async function runPromptSubmitHud(config, { readStdin } = {}) {
|
|
@@ -368,6 +413,16 @@ export async function runPromptSubmitHud(config, { readStdin } = {}) {
|
|
|
368
413
|
process.stdout.write(buildHandoffContext(config) + '\n');
|
|
369
414
|
}
|
|
370
415
|
|
|
416
|
+
// Names, not paths, and at most PROMPT_NAMES_SHOWN of them, so the line stays
|
|
417
|
+
// one line however long the queue grows.
|
|
418
|
+
const PROMPT_NAMES_SHOWN = 12;
|
|
419
|
+
export function pendingPromptsLine(prompts) {
|
|
420
|
+
const n = prompts.length;
|
|
421
|
+
const names = prompts.slice(0, PROMPT_NAMES_SHOWN).map(p => path.basename(p, '.md'));
|
|
422
|
+
const more = n > names.length ? ` (+${n - names.length} more: \`runlist prompts\`)` : '';
|
|
423
|
+
return `[runlist] ${n} pending prompt${n === 1 ? '' : 's'}: ${names.join(', ')}${more}. Resume one only when the user asks for it (\`runlist use <name>\`); never consume the oldest by default. Peek without consuming: \`runlist prompts show <name>\`.`;
|
|
424
|
+
}
|
|
425
|
+
|
|
371
426
|
function isRunlistRepo(config) {
|
|
372
427
|
return Boolean(config?.configFound);
|
|
373
428
|
}
|
|
@@ -409,21 +464,29 @@ export function runHud(argv, config) {
|
|
|
409
464
|
// `runlist hud --json`. Two signals ARE instructions and must print, because
|
|
410
465
|
// the handoff loop dies without them (sessions were saving batons that no
|
|
411
466
|
// next session ever picked up):
|
|
412
|
-
// - pending prompts:
|
|
413
|
-
//
|
|
467
|
+
// - pending prompts: earlier sessions queued work. The line lists them
|
|
468
|
+
// and says to resume one only when the user asks. It used to say
|
|
469
|
+
// "consume the oldest" unless told otherwise, but a new session nearly
|
|
470
|
+
// always has a task from the user, the hook cannot see it, and the
|
|
471
|
+
// oldest prompt is rarely the useful one.
|
|
414
472
|
// - an in-session plan attributed to this sid via durable ownership: this
|
|
415
473
|
// session (pre-compaction) owns it and should continue or hand it off.
|
|
416
474
|
// Global in-session counts never provide a fallback.
|
|
417
475
|
// The misuse recap stays for the same reason: a repeat-offense rule means
|
|
418
476
|
// the primer alone isn't landing, so name the habit to break.
|
|
419
|
-
process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<plan-or-slug>] @<draft-file> (save a resume prompt; releases the in-session plan if any)
|
|
477
|
+
process.stdout.write(dim('runlist: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<plan-or-slug>] @<draft-file> (save a resume prompt; releases the in-session plan if any)') + '\n');
|
|
420
478
|
process.stdout.write(dim(buildPlanStatusPrimer(config)) + '\n');
|
|
421
479
|
if (hud.owned && hud.owned.via === 'ownership') {
|
|
422
480
|
process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
|
|
423
481
|
}
|
|
482
|
+
// A handoff this session started and did not finish (a compaction or a stop
|
|
483
|
+
// between saving the prompt and updating the plan).
|
|
484
|
+
try {
|
|
485
|
+
for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${record.plan}`)}`) + '\n');
|
|
486
|
+
} catch { /* advisory */ }
|
|
424
487
|
if (hud.prompts.length > 0) {
|
|
425
488
|
const n = hud.prompts.length;
|
|
426
|
-
process.stdout.write(yellow(
|
|
489
|
+
process.stdout.write(yellow(pendingPromptsLine(hud.prompts)) + '\n');
|
|
427
490
|
}
|
|
428
491
|
if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
|
|
429
492
|
if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
|
+
import { isGitIgnored } from './git.mjs';
|
|
6
|
+
|
|
7
|
+
// What happened to a plan since a prompt about it was written. A resumed
|
|
8
|
+
// prompt is a snapshot: when its plan moved on since, the session needs to
|
|
9
|
+
// know which parts may be stale before it acts on them. Read-only; every git
|
|
10
|
+
// failure degrades to "unknown" rather than blocking the consume.
|
|
11
|
+
|
|
12
|
+
const SHOWN_COMMITS = 10;
|
|
13
|
+
|
|
14
|
+
function git(args, repoRoot) {
|
|
15
|
+
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
16
|
+
if (result.error || result.status !== 0) return null;
|
|
17
|
+
return result.stdout;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nextStepOf(raw) {
|
|
21
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
22
|
+
if (!frontmatter) return undefined;
|
|
23
|
+
const fm = parseSimpleFrontmatter(frontmatter);
|
|
24
|
+
return Object.prototype.hasOwnProperty.call(fm, 'next_step') ? JSON.stringify(fm.next_step ?? null) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planChangesSince(planRepoPath, since, config) {
|
|
28
|
+
const when = since ? new Date(since) : null;
|
|
29
|
+
if (!when || Number.isNaN(when.getTime())) return null;
|
|
30
|
+
const abs = path.resolve(config.repoRoot, planRepoPath);
|
|
31
|
+
if (!existsSync(abs)) return null;
|
|
32
|
+
const sinceIso = when.toISOString();
|
|
33
|
+
|
|
34
|
+
const tracked = !isGitIgnored(abs, config.repoRoot) && git(['ls-files', '--error-unmatch', '--', planRepoPath], config.repoRoot) !== null;
|
|
35
|
+
if (!tracked) {
|
|
36
|
+
// Not in git (a gitignored planning tree): the file's own clock is all there is.
|
|
37
|
+
return { plan: planRepoPath, since: sinceIso, tracked: false, commits: [], modifiedAfter: statSync(abs).mtime > when, nextStepChanged: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const log = git(['log', '--oneline', `--since=${sinceIso}`, '--', planRepoPath], config.repoRoot);
|
|
41
|
+
const commits = log ? log.split('\n').filter(Boolean) : [];
|
|
42
|
+
const current = nextStepOf(readFileSync(abs, 'utf8'));
|
|
43
|
+
let nextStepChanged = null;
|
|
44
|
+
const base = git(['log', '-1', '--format=%H', `--before=${sinceIso}`, '--', planRepoPath], config.repoRoot)?.trim();
|
|
45
|
+
if (base) {
|
|
46
|
+
const then = git(['show', `${base}:${planRepoPath}`], config.repoRoot);
|
|
47
|
+
if (then !== null) nextStepChanged = nextStepOf(then) !== current;
|
|
48
|
+
}
|
|
49
|
+
return { plan: planRepoPath, since: sinceIso, tracked: true, commits, nextStepChanged };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatPlanChanges(changes) {
|
|
53
|
+
if (!changes) return [];
|
|
54
|
+
const lines = [];
|
|
55
|
+
if (!changes.tracked) {
|
|
56
|
+
lines.push(changes.modifiedAfter
|
|
57
|
+
? `→ ${changes.plan} was edited after this prompt was written (${changes.since}); it is not in git, so read it for what changed before acting on the prompt.`
|
|
58
|
+
: `→ ${changes.plan} has not been edited since this prompt was written (${changes.since}).`);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
if (changes.commits.length === 0) {
|
|
62
|
+
lines.push(`→ ${changes.plan} has no commits since this prompt was written (${changes.since}).`);
|
|
63
|
+
} else {
|
|
64
|
+
const n = changes.commits.length;
|
|
65
|
+
lines.push(`→ ${changes.plan} changed in ${n} commit${n === 1 ? '' : 's'} since this prompt was written (${changes.since}); the prompt may be stale where they touch it:`);
|
|
66
|
+
for (const commit of changes.commits.slice(0, SHOWN_COMMITS)) lines.push(` ${commit}`);
|
|
67
|
+
if (n > SHOWN_COMMITS) lines.push(` +${n - SHOWN_COMMITS} more: git log --oneline --since=${changes.since} -- ${changes.plan}`);
|
|
68
|
+
}
|
|
69
|
+
if (changes.nextStepChanged === true) lines.push(' next_step changed since then: where the prompt and the plan disagree, the plan is newer.');
|
|
70
|
+
else if (changes.nextStepChanged === false) lines.push(' next_step is unchanged since then.');
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
package/src/prompts.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './pickup.mjs';
|
|
18
18
|
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
19
19
|
import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
|
|
20
|
+
import { formatPlanChanges, planChangesSince } from './plan-changes.mjs';
|
|
20
21
|
|
|
21
22
|
// `resume` is an alias for `use` — agents reach for "resume" when continuing a
|
|
22
23
|
// session; `use` reads as internal mechanics. Both names stay valid; the
|
|
@@ -234,6 +235,10 @@ export function resolvePromptInput(input, config, options = {}) {
|
|
|
234
235
|
|
|
235
236
|
const byBasename = prompts.filter(d => path.basename(d.path, '.md') === slug);
|
|
236
237
|
if (byBasename.length === 1) return path.resolve(config.repoRoot, byBasename[0].path);
|
|
238
|
+
// A name session start lists is the live prompt; its consumed copies under
|
|
239
|
+
// archived/ share the basename and are reached by path.
|
|
240
|
+
const live = byBasename.filter(d => d.status !== 'archived' && !isArchivedPath(d.path, config));
|
|
241
|
+
if (live.length === 1) return path.resolve(config.repoRoot, live[0].path);
|
|
237
242
|
if (byBasename.length > 1) {
|
|
238
243
|
die(`Multiple prompts match "${input}" by basename:\n${byBasename.map(d => ' ' + d.path).join('\n')}`);
|
|
239
244
|
}
|
|
@@ -345,6 +350,12 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
345
350
|
}
|
|
346
351
|
if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
|
|
347
352
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
353
|
+
if (planRef) {
|
|
354
|
+
try {
|
|
355
|
+
const linked = linkedClaim?.repoPath ?? linkedPlanRepoPath(planRef, filePath, config);
|
|
356
|
+
for (const line of formatPlanChanges(linked ? planChangesSince(linked, asString(parsed.created), config) : null)) process.stderr.write(`${line}\n`);
|
|
357
|
+
} catch { /* what changed is advice; the consume already happened */ }
|
|
358
|
+
}
|
|
348
359
|
const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
|
|
349
360
|
const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
|
|
350
361
|
const normalize = candidate => path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate.split(path.sep).join('/');
|
|
@@ -373,6 +384,11 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
373
384
|
};
|
|
374
385
|
}
|
|
375
386
|
|
|
387
|
+
function linkedPlanRepoPath(planRef, promptPath, config) {
|
|
388
|
+
const abs = resolveRefPath(planRef, path.dirname(promptPath), config.repoRoot);
|
|
389
|
+
return abs && existsSync(abs) ? toRepoPath(abs, config.repoRoot) : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
376
392
|
export async function writeConsumedBody(body, archivedPath, write = null, linkedPlan = null) {
|
|
377
393
|
const content = body.endsWith('\n') ? body : `${body}\n`;
|
|
378
394
|
try {
|