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/host-integration.mjs
CHANGED
|
@@ -113,6 +113,11 @@ export function opencodeStatus(opts = {}) {
|
|
|
113
113
|
const installed = generated?.version ?? oldGenerated?.version ?? null;
|
|
114
114
|
const legacyGenerated = Boolean(oldGenerated);
|
|
115
115
|
const legacyBanner = generated?.marker === LEGACY_GENERATED_MARKER || oldGenerated?.marker === LEGACY_GENERATED_MARKER;
|
|
116
|
+
let sourceParity = { state: 'unknown' };
|
|
117
|
+
if (installed && !legacyBanner && !legacyGenerated) {
|
|
118
|
+
try { sourceParity = { state: readFileSync(filePath, 'utf8') === renderOpencodePlugin(version ?? installed) ? 'match' : 'mismatch' }; }
|
|
119
|
+
catch { /* An unreadable file cannot establish parity. */ }
|
|
120
|
+
}
|
|
116
121
|
return {
|
|
117
122
|
path: filePath,
|
|
118
123
|
legacyPath,
|
|
@@ -121,7 +126,8 @@ export function opencodeStatus(opts = {}) {
|
|
|
121
126
|
// Generated legacy files migrate to runlist.js. Unmarked files remain user-owned.
|
|
122
127
|
foreign: (currentExists && !generated) || (!currentExists && oldExists && !oldGenerated),
|
|
123
128
|
foreignPath: currentExists && !generated ? filePath : oldExists && !oldGenerated ? legacyPath : null,
|
|
124
|
-
|
|
129
|
+
sourceParity,
|
|
130
|
+
stale: installed !== null && ((version !== undefined && installed !== version) || legacyBanner || legacyGenerated || sourceParity.state !== 'match'),
|
|
125
131
|
legacyBanner,
|
|
126
132
|
legacyGenerated,
|
|
127
133
|
};
|
package/src/hud.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
|
|
1
2
|
import { flagsHudLine, openFlags } from './flags.mjs';
|
|
2
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { asString, currentSessionId, isArchivedPath, relTime, toRepoPath } from './util.mjs';
|
|
6
|
+
import { asString, currentSessionId, isArchivedPath, relTime, shellQuote, toRepoPath } from './util.mjs';
|
|
6
7
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
7
8
|
import { promptDirectory } from './new.mjs';
|
|
8
9
|
import { dim, yellow } from './color.mjs';
|
|
@@ -13,6 +14,7 @@ import { findOwnedPlan } from './baton.mjs';
|
|
|
13
14
|
import { listOwnedPlans, surveyOwnershipClaims } from './pickup.mjs';
|
|
14
15
|
import { handoffNotDoneLine, pendingHandoffsForSession } from './handoff-record.mjs';
|
|
15
16
|
import { actionablePromptStatuses, comparePromptDocs } from './status-metadata.mjs';
|
|
17
|
+
import { handoffsAnchor, isHandoffsMode, wouldCreateHandoffsConfig } from './handoffs-mode.mjs';
|
|
16
18
|
|
|
17
19
|
export { actionablePromptStatuses } from './status-metadata.mjs';
|
|
18
20
|
|
|
@@ -80,12 +82,14 @@ export function findActionablePromptsInPromptDir(config) {
|
|
|
80
82
|
export function buildHudFast(config) {
|
|
81
83
|
let prompts = [];
|
|
82
84
|
let owned = null;
|
|
85
|
+
let hasOwnedPlans = true; // Unknown ownership must not advertise a fresh session.
|
|
83
86
|
try { prompts = findActionablePromptsInPromptDir(config); } catch { /* hud must not fail */ }
|
|
84
87
|
try {
|
|
85
88
|
const records = listOwnedPlans(config);
|
|
89
|
+
hasOwnedPlans = records.length > 0 || Boolean(records.diagnostics?.length);
|
|
86
90
|
if (!records.diagnostics?.length && records.length === 1) owned = { path: records[0].plan, title: null, via: 'ownership' };
|
|
87
91
|
} catch { /* hud must not fail */ }
|
|
88
|
-
return { owned, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
|
|
92
|
+
return { owned, hasOwnedPlans, prompts, misuseRecap: buildMisuseRecap(config), deadClaimsLine: deadClaimsHudLine(config) };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
// A claim whose session is provably gone wedges `set`, `archive` and `baton` on
|
|
@@ -213,7 +217,7 @@ const MISUSE_CORRECTIONS = {
|
|
|
213
217
|
'edit-status': 'never hand-edit `status:`; use `runlist set <status> <file>`',
|
|
214
218
|
'cat-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
|
|
215
219
|
'read-prompt': 'consume prompts with `runlist use <file>`; peek without consuming via `runlist prompts show <file>`',
|
|
216
|
-
'commit-prompt': '
|
|
220
|
+
'commit-prompt': 'follow the project prompt Git policy: `runlist config get promptTracking`',
|
|
217
221
|
};
|
|
218
222
|
|
|
219
223
|
export function buildMisuseRecap(config, now = Date.now()) {
|
|
@@ -292,10 +296,18 @@ export function buildHud(config) {
|
|
|
292
296
|
// again and again: the read verbs, the three wrong-moves the guard exists to
|
|
293
297
|
// stop, and a pointer for the rare child handoff. Statuses are not listed —
|
|
294
298
|
// `runlist set` names the valid ones when given a wrong one.
|
|
299
|
+
// Handoffs-only projects have no plans and no status lines to guard; a child
|
|
300
|
+
// needs only the prompt rules and its own handoff form.
|
|
301
|
+
const HANDOFFS_SUBAGENT_PRIMER = [
|
|
302
|
+
'runlist keeps session handoffs (saved prompts) here; planning is off.',
|
|
303
|
+
'Do NOT cat/read a saved prompt file (`runlist prompts show <name>` peeks, `runlist use <name>` consumes); follow `runlist config get promptTracking` for Git.',
|
|
304
|
+
'Return completed results to the parent. Unfinished handoff: `runlist baton --subagent <task-slug> @<draft-file> --parent resume-<parent>`.',
|
|
305
|
+
].join('\n');
|
|
306
|
+
|
|
295
307
|
const SUBAGENT_PRIMER = [
|
|
296
|
-
'runlist manages
|
|
297
|
-
'Do NOT: cat/read a docs/prompts/*.md (
|
|
298
|
-
|
|
308
|
+
'runlist manages plans/docs/prompts. Orient: `runlist plans`. Read: `runlist show <file>`, then `runlist show <file> --section "<heading>"`, never whole.',
|
|
309
|
+
'Do NOT: cat/read a docs/prompts/*.md (`runlist prompts show <name>` peeks, `runlist use <name>` consumes); ignore promptTracking (`runlist config get promptTracking`); hand-edit status (`runlist set <status> <file>`).',
|
|
310
|
+
"Return completed results to the parent. Unfinished handoff: `runlist baton --subagent <task-slug> @<draft-file> --parent resume-<parent>`. Use a distinct non-plan slug under a parent-owned plan; preserve its claim. `runlist baton --template` prints an outline; inspect a pending prompt before --replace.",
|
|
299
311
|
].join('\n');
|
|
300
312
|
|
|
301
313
|
// The plugin's SessionStart/SubagentStart hooks fire in EVERY repo (it's enabled
|
|
@@ -315,9 +327,32 @@ const SUBAGENT_PRIMER = [
|
|
|
315
327
|
// clause that opens as a question never counts, and "baton" counts only as a
|
|
316
328
|
// verb (opening the clause, after "and"/"then", or "do a baton"), never as the
|
|
317
329
|
// subject of a sentence ("baton prints its name first").
|
|
318
|
-
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;
|
|
319
|
-
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]*)*`;
|
|
330
|
+
const HANDOFF_PHRASE = /\bhand[\s-]?off\b|\bresume[\s-]prompt\b|\bsave (?:a |the )?resume\b|\bpick (?:it|this|that) (?:back )?up (?:next time|next session|later|tomorrow)\b/i;
|
|
331
|
+
const LEAD_WORDS = String.raw`(?:(?:ok(?:ay)?|alright|right|anyway|hey|now|then|and|also|but|so|yes|yep|great|good|cool|thanks|please|pls|go ahead and|let'?s|lets|time to)\b[,\s]*)*`;
|
|
320
332
|
const POLITE = String.raw`(?:(?:can|could|would|will) you (?:please )?(?:mind )?)`;
|
|
333
|
+
const SESSION_REQUEST_START = String.raw`(?:^|,\s*)${LEAD_WORDS}${POLITE}?(?:(?:I want to|let'?s|lets|please) )?`;
|
|
334
|
+
// Save forms name the session's work and a continuation time, or explicitly
|
|
335
|
+
// end the workday. "Save where we are" is itself a session snapshot request;
|
|
336
|
+
// require a request at the clause start, including after a context-setting
|
|
337
|
+
// comma, and a clause end (also before a semicolon continuation).
|
|
338
|
+
// Keep the suffixes narrow: progress indicators and session objects are code.
|
|
339
|
+
const SESSION_SAVE = [
|
|
340
|
+
new RegExp(String.raw`${SESSION_REQUEST_START}save where we are\s*[.!?;]?$`, 'i'),
|
|
341
|
+
/\bsave (?:where we are|our progress) (?:for (?:later|tomorrow|now|today|next (?:time|session))|so (?:I|we) can continue (?:later|tomorrow|next (?:time|session)))\b/i,
|
|
342
|
+
new RegExp(String.raw`${SESSION_REQUEST_START}wrap up for (?:today|the day|tonight)\s*[.!?;]?$`, 'i'),
|
|
343
|
+
/\bstop here for now(?: and|,)? (?:continue|pick (?:it|this) up) (?:tomorrow|later|next (?:time|session))\s*[.!?;]?$/i,
|
|
344
|
+
new RegExp(String.raw`${SESSION_REQUEST_START}stop (?:here )?for (?:today|the day|tonight)\s*[.!?;]?$`, 'i'),
|
|
345
|
+
/\b(?:let'?s|lets|I think we can) call it a day\s*[.!?;]?$/i,
|
|
346
|
+
/\b(?:we'll|we will|I'll|I will|let'?s|lets) continue (?:in (?:a |the )?new session|(?:this )?tomorrow)\s*[.!?;]?$/i,
|
|
347
|
+
new RegExp(String.raw`${SESSION_REQUEST_START}save where we're at for tomorrow\s*[.!?;]?$`, 'i'),
|
|
348
|
+
new RegExp(String.raw`${SESSION_REQUEST_START}(?:let'?s|lets) pause here and continue tomorrow\s*[.!?;]?$`, 'i'),
|
|
349
|
+
];
|
|
350
|
+
// Strip all explicit load/resume forms before the broad handoff save matcher.
|
|
351
|
+
const LOAD_HANDOFF = String.raw`\b(?:load|resume|open|use) (?:my|the|our|that) handoff\b`;
|
|
352
|
+
// A continuation can start another clause with "and then". Other words after
|
|
353
|
+
// stopped/left off describe code or another activity, rather than saved work.
|
|
354
|
+
const PICKUP_END = String.raw`(?: (?:yesterday|last (?:time|session))(?!'s)\b)?(?=\s*(?:$|[.!?;,]|and then\b))`;
|
|
355
|
+
const PICKUP_PHRASE = String.raw`(?:(?:pick up|continue|resume) (?:from )?where (?:we|I|you) (?:left off|stopped)\b${PICKUP_END}|(?:continue|resume|pick up) from (?:last (?:time|session)|yesterday)(?!'s)\b|${LOAD_HANDOFF}(?=\s*(?:$|[.!?;,]|and\b)))`;
|
|
321
356
|
const BATON_AS_NOUN = String.raw`(?:(?:do|doing|make|write|leave|save|pass|run|drop) (?:a |the |your |another |one more )?)`;
|
|
322
357
|
// A word here after "baton" makes it the subject or a noun ("baton is",
|
|
323
358
|
// "baton prints", "baton command"), not a request.
|
|
@@ -326,21 +361,66 @@ const BATON_REQUEST = [
|
|
|
326
361
|
new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
|
|
327
362
|
new RegExp(String.raw`(?:\band|\bthen|&)\s+(?:please )?${BATON_AS_NOUN}?batons?\b${NOT_A_VERB}`, 'i'),
|
|
328
363
|
new RegExp(String.raw`\b(?:do|make|write|leave|save|pass) (?:a |your |another |one more )baton\b`, 'i'),
|
|
364
|
+
new RegExp(String.raw`\bcontext is getting tight,\s+(?:please )?baton\b${NOT_A_VERB}`, 'i'),
|
|
329
365
|
];
|
|
330
366
|
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
367
|
|
|
332
368
|
// The harness hands background-task events to UserPromptSubmit as if typed.
|
|
333
369
|
// A finished agent's report that names its handoff file is not a request.
|
|
334
|
-
const SYSTEM_EVENT =
|
|
370
|
+
const SYSTEM_EVENT = /\[SYSTEM NOTIFICATION\b/i;
|
|
371
|
+
const T3_RELAY_HEADER = /^[ \t]*Delegated task \S+(?: \([^\n]*?\))?(?: \[[a-z_]+\]| reached a terminal state)[:.]/im;
|
|
372
|
+
// This question explicitly asks to recover earlier work, rather than asking
|
|
373
|
+
// how the handoff mechanism works. Other question-led clauses stay silent.
|
|
374
|
+
const PICKUP_QUESTION = new RegExp(String.raw`^${LEAD_WORDS}(?:hmm[,\s]+)?(?:what was I working on (?:in the )?last session|where were we)[?.!]*$`, 'i');
|
|
375
|
+
const PICKUP_REQUEST = new RegExp(String.raw`^${LEAD_WORDS}${POLITE}?(?:please )?${PICKUP_PHRASE}`, 'i');
|
|
376
|
+
|
|
377
|
+
function requestText(prompt) {
|
|
378
|
+
if (typeof prompt !== 'string' || SYSTEM_EVENT.test(prompt)) return '';
|
|
379
|
+
prompt = prompt.replaceAll('’', "'");
|
|
380
|
+
prompt = prompt.replace(/<task-notification\b[^>]*>[\s\S]*?<\/task-notification>/gi, ' ');
|
|
381
|
+
let fence = null;
|
|
382
|
+
const prose = prompt.split('\n').map(line => {
|
|
383
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
384
|
+
if (fence) {
|
|
385
|
+
if (marker && marker[0] === fence[0] && marker.length >= fence.length
|
|
386
|
+
&& /^\s*$/.test(line.slice(line.indexOf(marker) + marker.length))) fence = null;
|
|
387
|
+
return '';
|
|
388
|
+
}
|
|
389
|
+
if (marker) { fence = marker; return ''; }
|
|
390
|
+
if (/^(?: {4}|\t)|^ {0,3}>/.test(line)) return '';
|
|
391
|
+
return line;
|
|
392
|
+
}).join('\n');
|
|
393
|
+
// T3 reports can contain blank lines and further relay headers. Everything
|
|
394
|
+
// from the first prose header onward belongs to the relay; only prior prose
|
|
395
|
+
// is the user's. A header inside code or a quote is not a relay envelope.
|
|
396
|
+
// Claude notifications have explicit closing tags instead.
|
|
397
|
+
const relay = T3_RELAY_HEADER.exec(prose);
|
|
398
|
+
const userProse = relay ? prose.slice(0, relay.index) : prose;
|
|
399
|
+
return maskInlineCodeSpans(userProse).replace(/"[^"\n]*"/g, ' ');
|
|
400
|
+
}
|
|
335
401
|
|
|
336
402
|
function clauses(prompt) {
|
|
337
403
|
return prompt.split(/(?<=[.!?;\n])/).map(clause => clause.trim()).filter(Boolean);
|
|
338
404
|
}
|
|
339
405
|
|
|
340
406
|
export function isHandoffAsk(prompt) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
407
|
+
return clauses(requestText(prompt)).some(clause => !QUESTION_START.test(clause)
|
|
408
|
+
&& (BATON_REQUEST.some(re => re.test(clause)) || SESSION_SAVE.some(re => re.test(clause))
|
|
409
|
+
// A request to load a handoff is the opposite of saving one.
|
|
410
|
+
|| HANDOFF_PHRASE.test(clause.replace(new RegExp(LOAD_HANDOFF, 'gi'), ''))));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Before a repository has any runlist config the hook only answers an explicit
|
|
414
|
+
// request to save the session: a baton, or one of the session-save phrases. The
|
|
415
|
+
// bare word "handoff" is common in code, and nothing here is runlist's yet.
|
|
416
|
+
export function isExplicitSaveAsk(prompt) {
|
|
417
|
+
return clauses(requestText(prompt)).some(clause => !QUESTION_START.test(clause)
|
|
418
|
+
&& (BATON_REQUEST.some(re => re.test(clause)) || SESSION_SAVE.some(re => re.test(clause))));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function isPickupAsk(prompt) {
|
|
422
|
+
return clauses(requestText(prompt)).some(clause => PICKUP_QUESTION.test(clause)
|
|
423
|
+
|| (!QUESTION_START.test(clause) && PICKUP_REQUEST.test(clause)));
|
|
344
424
|
}
|
|
345
425
|
|
|
346
426
|
// The repo's own definition of baton (`templates.prompt.baton` in its config),
|
|
@@ -351,7 +431,21 @@ export function repoBatonDefinition(config) {
|
|
|
351
431
|
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
|
352
432
|
}
|
|
353
433
|
|
|
434
|
+
const RESUME_DRAFT = 'Write the resume first (current work and exact next move, decisions and waiting approvals, durable evidence paths, unverified claims and authorization boundaries; not a recap). `runlist baton --template` prints the checkout’s editable outline. Draft in a temporary file; keep working material at durable repo-relative paths.';
|
|
435
|
+
const BATON_TAIL = 'Baton prints the prompt name it saved as its last line; tell the user that name. No `--help` needed.';
|
|
436
|
+
|
|
437
|
+
// Handoffs only — a repository with no config yet, or one in handoffs mode —
|
|
438
|
+
// has no plans, so the form is always a name the session picks itself.
|
|
439
|
+
export function buildHandoffsOnlyContext(config, { firstHandoff = false } = {}) {
|
|
440
|
+
const command = 'Save it with `runlist baton <slug> @<file>`, choosing a short slug that names the work (it saves resume-<slug>).';
|
|
441
|
+
const first = firstHandoff ? ' There is no runlist config here yet: baton also sets one up for handoffs (runlist.config.mjs, prompts in docs/prompts/) unless it finds runlist documents saved before setup, and prints what it did; relay that.' : '';
|
|
442
|
+
const definition = repoBatonDefinition(config);
|
|
443
|
+
if (definition) return `[runlist] Baton: ${command}${first} Where the definition below names a command, use this form. ${BATON_TAIL}\nThis repo defines baton (templates.prompt.baton in its runlist config):\n${definition}`;
|
|
444
|
+
return `[runlist] Baton: ${RESUME_DRAFT} ${command}${first} ${BATON_TAIL}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
354
447
|
export function buildHandoffContext(config) {
|
|
448
|
+
if (isHandoffsMode(config)) return buildHandoffsOnlyContext(config);
|
|
355
449
|
// Ownership records name their plans and are checked against those files
|
|
356
450
|
// alone; building the index here cost 9s in a repo of ~5k docs, past the
|
|
357
451
|
// hook's timeout.
|
|
@@ -360,43 +454,66 @@ export function buildHandoffContext(config) {
|
|
|
360
454
|
const records = listOwnedPlans(config);
|
|
361
455
|
if (!records.diagnostics?.length) ownedPaths = records.map(record => record.plan);
|
|
362
456
|
} catch { /* fall through to the no-plan form */ }
|
|
363
|
-
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';
|
|
457
|
+
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 body first, then run';
|
|
364
458
|
let who;
|
|
365
459
|
let command;
|
|
366
460
|
if (ownedPaths.length === 1) {
|
|
367
461
|
who = `this session owns ${ownedPaths[0]}.`;
|
|
368
|
-
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\`.`;
|
|
462
|
+
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 --next-step "<next move>"\`.`;
|
|
369
463
|
} else if (ownedPaths.length > 1) {
|
|
370
464
|
who = `this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off.`;
|
|
371
|
-
command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file
|
|
465
|
+
command = `Save it with \`runlist baton <plan-file> @<file>\`. ${done} \`runlist baton --done <plan-file> --next-step "<next move>"\`.`;
|
|
372
466
|
} else {
|
|
373
467
|
who = 'this session owns no plan.';
|
|
374
468
|
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.';
|
|
375
469
|
}
|
|
376
|
-
const tail =
|
|
470
|
+
const tail = BATON_TAIL;
|
|
377
471
|
const definition = repoBatonDefinition(config);
|
|
378
472
|
if (definition) {
|
|
379
473
|
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}`;
|
|
380
474
|
}
|
|
381
|
-
|
|
382
|
-
return `[runlist] Baton: ${who} ${draft} ${command} ${tail}`;
|
|
475
|
+
return `[runlist] Baton: ${who} ${RESUME_DRAFT} ${command} ${tail}`;
|
|
383
476
|
}
|
|
384
477
|
|
|
385
|
-
export async function runPromptSubmitHud(config, { readStdin } = {}) {
|
|
386
|
-
|
|
478
|
+
export async function runPromptSubmitHud(config, { readStdin, cwd = process.cwd() } = {}) {
|
|
479
|
+
// With no config, only a Git project can take a first handoff (baton refuses
|
|
480
|
+
// elsewhere); everything else stays silent, as in any repo runlist isn't in.
|
|
481
|
+
const configured = isRunlistRepo(config);
|
|
482
|
+
if (!configured && handoffsAnchor(cwd).refusal) return;
|
|
387
483
|
let prompt = '';
|
|
388
484
|
try {
|
|
389
485
|
const raw = await readStdin();
|
|
390
486
|
if (raw && raw.trim()) prompt = JSON.parse(raw).prompt ?? '';
|
|
391
487
|
} catch { return; }
|
|
392
|
-
if (!
|
|
393
|
-
|
|
488
|
+
if (!configured) {
|
|
489
|
+
if (!isExplicitSaveAsk(prompt)) return;
|
|
490
|
+
// Plans claimed before any config exist only where someone planned without
|
|
491
|
+
// setup; the claim records are the cheap way to know, as in a configured repo.
|
|
492
|
+
let owns = false;
|
|
493
|
+
try { owns = listOwnedPlans(config).length > 0; } catch { /* no claims to read */ }
|
|
494
|
+
process.stdout.write((owns ? buildHandoffContext(config) : buildHandoffsOnlyContext(config, { firstHandoff: wouldCreateHandoffsConfig(cwd, config) })) + '\n');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Saving wins when both intents are explicit: save now, resume later.
|
|
498
|
+
if (isHandoffAsk(prompt)) {
|
|
499
|
+
process.stdout.write(buildHandoffContext(config) + '\n');
|
|
500
|
+
} else if (isPickupAsk(prompt)) {
|
|
501
|
+
let prompts;
|
|
502
|
+
try { prompts = findActionablePromptsInPromptDir(config); } catch { return; }
|
|
503
|
+
if (prompts.length) process.stdout.write(`[runlist] Pickup: ${pendingPromptsLine(prompts, { pickup: true, planning: !isHandoffsMode(config) })}\n`);
|
|
504
|
+
}
|
|
394
505
|
}
|
|
395
506
|
|
|
396
507
|
// A count, not names: a queue of dozens printed a dozen names nobody asked
|
|
397
508
|
// about, and `runlist prompts` lists them when the user names one.
|
|
398
|
-
export function pendingPromptsLine(prompts) {
|
|
509
|
+
export function pendingPromptsLine(prompts, { pickup = false, planning = true } = {}) {
|
|
399
510
|
const n = prompts.length;
|
|
511
|
+
if (pickup) {
|
|
512
|
+
const names = n <= 5 ? ` (${prompts.join(', ')})` : '';
|
|
513
|
+
const entry = hasPickupCommand() ? '`/pickup` or `runlist use <name>`' : '`runlist use <name>`';
|
|
514
|
+
const effect = planning ? 'archives the prompt and claims its linked plan' : 'archives the prompt and prints it';
|
|
515
|
+
return `If the user is asking to resume saved work: ${n} pending prompt${n === 1 ? '' : 's'}${names}; ${entry} consumes one (${effect}).${n > 1 ? ' Ask which one; never consume the oldest by default.' : ''}`;
|
|
516
|
+
}
|
|
400
517
|
return `${n} pending prompt${n === 1 ? '' : 's'}: resume one only when the user asks for it (\`runlist prompts\` lists them, \`runlist use <name>\` resumes one, \`runlist prompts show <name>\` peeks); never consume the oldest by default.`;
|
|
401
518
|
}
|
|
402
519
|
|
|
@@ -404,6 +521,30 @@ function isRunlistRepo(config) {
|
|
|
404
521
|
return Boolean(config?.configFound);
|
|
405
522
|
}
|
|
406
523
|
|
|
524
|
+
function hasPickupCommand() {
|
|
525
|
+
// Codex also exports CLAUDE_PLUGIN_ROOT for compatible hooks, but its plugin
|
|
526
|
+
// has no slash commands. Offer the command only when it is actually shipped.
|
|
527
|
+
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
528
|
+
return Boolean(root?.trim() && existsSync(path.join(root, 'commands', 'pickup.md')));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Directory entries only: any Markdown file is history, including held and
|
|
532
|
+
// archived prompts. Resolve the type directory via the same loader as hud;
|
|
533
|
+
// recursive listing includes config.archiveDir without assuming its spelling.
|
|
534
|
+
function handoffIntroLine(config, hud) {
|
|
535
|
+
// OpenCode injects hud into its system prompt every turn (plugin.js),
|
|
536
|
+
// including when RUNLIST_SESSION_ID overrides the host's identity source.
|
|
537
|
+
if (process.env.OPENCODE || process.env.OPENCODE_PID || hud.prompts.length || hud.hasOwnedPlans) return null;
|
|
538
|
+
try {
|
|
539
|
+
const dir = promptDirectory(config);
|
|
540
|
+
if (existsSync(dir) && readdirSync(dir, { withFileTypes: true, recursive: true })
|
|
541
|
+
.some(entry => entry.isFile() && entry.name.endsWith('.md'))) return null;
|
|
542
|
+
} catch { return null; } // Unreadable history cannot establish a fresh repo.
|
|
543
|
+
const pickup = hasPickupCommand()
|
|
544
|
+
? '/pickup resumes it next time' : 'the next session lists saved handoffs at start';
|
|
545
|
+
return `[runlist] Handoffs: say "baton" to save where you are before you stop; ${pickup}.`;
|
|
546
|
+
}
|
|
547
|
+
|
|
407
548
|
export function runHud(argv, config) {
|
|
408
549
|
const json = argv.includes('--json');
|
|
409
550
|
|
|
@@ -415,7 +556,7 @@ export function runHud(argv, config) {
|
|
|
415
556
|
// need the operator-facing machinery, just the verbs and the guardrails.
|
|
416
557
|
if (argv.includes('--subagent')) {
|
|
417
558
|
if (!runlistRepo) return; // silent in repos that don't use runlist
|
|
418
|
-
process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
|
|
559
|
+
process.stdout.write(dim(isHandoffsMode(config) ? HANDOFFS_SUBAGENT_PRIMER : SUBAGENT_PRIMER) + '\n');
|
|
419
560
|
if (drift) process.stdout.write(yellow(drift) + '\n');
|
|
420
561
|
return;
|
|
421
562
|
}
|
|
@@ -433,8 +574,8 @@ export function runHud(argv, config) {
|
|
|
433
574
|
const hud = buildHudFast(config);
|
|
434
575
|
|
|
435
576
|
// SessionStart contract: ONLY signals that carry a direct instruction for
|
|
436
|
-
// this session,
|
|
437
|
-
// status list: the runlist skill carries the verbs, and `runlist set` names
|
|
577
|
+
// this session, plus a one-line introduction until the first handoff. No
|
|
578
|
+
// command sheet or status list: the runlist skill carries the verbs, and `runlist set` names
|
|
438
579
|
// the valid statuses when given a wrong one. Passive state (error counts,
|
|
439
580
|
// previous-self / fleet / recent-rejections) stays suppressed — it nudged
|
|
440
581
|
// agents into phantom follow-up work and lives in its own commands and
|
|
@@ -450,6 +591,8 @@ export function runHud(argv, config) {
|
|
|
450
591
|
// Global in-session counts never provide a fallback.
|
|
451
592
|
// - an unfinished handoff, a repeat-offense misuse rule, and plans held by
|
|
452
593
|
// sessions that are gone: each names the command that clears it.
|
|
594
|
+
const intro = handoffIntroLine(config, hud);
|
|
595
|
+
if (intro) process.stdout.write(dim(intro) + '\n');
|
|
453
596
|
const queue = [];
|
|
454
597
|
if (hud.prompts.length > 0) queue.push(pendingPromptsLine(hud.prompts));
|
|
455
598
|
const flagsLine = flagsHudLine(config);
|
|
@@ -459,9 +602,11 @@ export function runHud(argv, config) {
|
|
|
459
602
|
process.stdout.write(yellow(`[runlist] in-session (yours): ${hud.owned.path} — continue it; hand off with \`runlist baton @/tmp/draft.md\` before stopping.`) + '\n');
|
|
460
603
|
}
|
|
461
604
|
// A handoff this session started and did not finish (a compaction or a stop
|
|
462
|
-
// between saving the prompt and updating the plan).
|
|
463
|
-
|
|
464
|
-
|
|
605
|
+
// between saving the prompt and updating the plan). Where planning is off
|
|
606
|
+
// there is no plan to update: a record carried in from before stays on disk
|
|
607
|
+
// for `runlist setup`, and the hook asks for nothing it cannot do.
|
|
608
|
+
if (!isHandoffsMode(config)) try {
|
|
609
|
+
for (const record of pendingHandoffsForSession(config)) process.stdout.write(yellow(`[runlist] ${handoffNotDoneLine(record, `runlist baton --done ${shellQuote(record.plan)}`)}`) + '\n');
|
|
465
610
|
} catch { /* advisory */ }
|
|
466
611
|
if (hud.misuseRecap) process.stdout.write(yellow(`[runlist] ${hud.misuseRecap}`) + '\n');
|
|
467
612
|
if (hud.deadClaimsLine) process.stdout.write(yellow(hud.deadClaimsLine) + '\n');
|
package/src/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
6
6
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
|
|
7
7
|
import { findLexicalDocsRoot } from './managed-path.mjs';
|
|
8
|
+
import { managesFile, outsideHandoffs, outsideHandoffsLine } from './handoffs-scope.mjs';
|
|
8
9
|
import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
|
|
9
10
|
import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
|
|
10
11
|
import { checkIndex } from './index-file.mjs';
|
|
@@ -233,7 +234,9 @@ export function collectDocFiles(config, { onError = null } = {}) {
|
|
|
233
234
|
for (const root of roots) {
|
|
234
235
|
walkMarkdownFiles(root, files, config.excludeDirs, skipPaths, seen, onError);
|
|
235
236
|
}
|
|
236
|
-
|
|
237
|
+
// Every scanning verb collects here, so a handoffs-only project's other
|
|
238
|
+
// Markdown (src/handoffs-scope.mjs) drops out of all of them at once.
|
|
239
|
+
return files.filter(file => managesFile(file, config)).sort((a, b) => a.localeCompare(b));
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
// Shared resolver for CLI file arguments — the single path every file-taking
|
|
@@ -248,10 +251,10 @@ export function collectDocFiles(config, { onError = null } = {}) {
|
|
|
248
251
|
export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
|
|
249
252
|
if (!input) return null;
|
|
250
253
|
const direct = resolveDocPath(input, config);
|
|
251
|
-
if (direct) return direct;
|
|
254
|
+
if (direct && managesFile(direct, config)) return direct;
|
|
252
255
|
if (!input.endsWith('.md')) {
|
|
253
256
|
const withExt = resolveDocPath(input + '.md', config);
|
|
254
|
-
if (withExt) return withExt;
|
|
257
|
+
if (withExt && managesFile(withExt, config)) return withExt;
|
|
255
258
|
}
|
|
256
259
|
|
|
257
260
|
const slug = input.replace(/\.md$/, '');
|
|
@@ -269,7 +272,11 @@ export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
|
|
|
269
272
|
// `File not found` + the searched roots + up-to-3 did-you-mean candidates
|
|
270
273
|
// matched on basename and printed as repo-relative paths. Exported so verbs
|
|
271
274
|
// with their own resolution (e.g. interactive pickers) can reuse the message.
|
|
272
|
-
|
|
275
|
+
// In a handoffs-only project a file that is not one of its prompts was not
|
|
276
|
+
// mistyped, so it hears that planning is off instead (src/handoffs-scope.mjs).
|
|
277
|
+
export function docArgMissMessage(input, config, files) {
|
|
278
|
+
if (outsideHandoffs(input, config)) return outsideHandoffsLine(input);
|
|
279
|
+
files ??= collectDocFiles(config);
|
|
273
280
|
const roots = config.docsRoots || [config.docsRoot];
|
|
274
281
|
const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
|
|
275
282
|
const slug = String(input).split('/').pop().replace(/\.md$/, '');
|