runlist 0.96.0 → 0.97.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/README.md +170 -43
- package/assets/app/app.css +189 -3
- package/assets/app/app.mjs +49 -31
- package/assets/app/document-create.mjs +3 -2
- package/assets/app/document-lifecycle.mjs +17 -8
- package/assets/app/document-stages.mjs +3 -1
- package/assets/app/document-views.mjs +23 -1
- package/assets/app/editor-navigation.mjs +16 -23
- package/assets/app/filing-navigation.mjs +18 -16
- package/assets/app/git-changes.mjs +11 -2
- package/assets/app/index.html +34 -23
- package/assets/app/library-navigation.mjs +29 -16
- package/assets/app/record-navigation.mjs +68 -23
- package/assets/app/recovery-center.mjs +8 -7
- package/assets/app/settings.mjs +48 -10
- package/assets/app/template-editor.mjs +22 -12
- package/assets/app/transport.mjs +1 -2
- package/bin/runlist.mjs +64 -15
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
- package/src/app.mjs +9 -4
- package/src/baton.mjs +49 -11
- package/src/brief.mjs +145 -0
- package/src/commands.mjs +4 -3
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/extractors.mjs +7 -14
- package/src/flags.mjs +5 -3
- package/src/hud.mjs +32 -63
- package/src/index.mjs +2 -0
- package/src/install.mjs +6 -4
- package/src/installation.mjs +117 -0
- package/src/new.mjs +27 -7
- package/src/prompt-handoff.mjs +132 -0
- package/src/prompts.mjs +112 -16
- package/src/section.mjs +46 -11
- package/src/show.mjs +132 -16
- package/src/template-store.mjs +16 -5
- package/src/update.mjs +7 -28
- package/src/util.mjs +7 -0
|
@@ -16,7 +16,9 @@ Use this skill in repositories with `runlist.config.mjs` or `dotmd.config.mjs`.
|
|
|
16
16
|
- Change status with `runlist set <status> <file> [--note "why"]`; this runs validation, lifecycle hooks, ref repairs, and index updates. Never hand-edit a `status:` field.
|
|
17
17
|
- Close a shipped plan with `runlist archive <file>`; use `partial`, `active`, `awaiting`, or `blocked` when those match the actual state. Valid statuses are repository and type specific: `runlist statuses list --type plan`.
|
|
18
18
|
- For a handoff, write a concrete resume draft, then run `runlist baton [<plan-or-slug>] @<draft-file>`. An owned plan is released in the same operation. If a handoff for that work is pending, inspect it with `runlist prompts show <file>`; keep it if current or pass `--replace` with the new draft. Replacement archives the old text and refreshes exactly one pending prompt.
|
|
19
|
-
- **Subagent handoffs:** use `runlist baton --subagent <task-slug> @/tmp/draft.md` to save `subagent-<task-slug>`. Under a parent-owned plan, choose a distinct task slug that does not resolve to a plan; do not release the parent's claim. A child owning a dedicated plan can use `runlist baton --subagent <plan-file> @/tmp/draft.md`. Parent-session batons keep the default `resume-*` naming. Matching prefixes are kept once; conflicting prefixes are refused. Existing prompts keep their names, and `--subagent --replace` refuses a parent-prefixed prompt.
|
|
19
|
+
- **Subagent handoffs:** use `runlist baton --subagent <task-slug> @/tmp/draft.md` to save `subagent-<task-slug>`. Under a parent-owned plan, choose a distinct task slug that does not resolve to a plan; do not release the parent's claim. A child owning a dedicated plan can use `runlist baton --subagent <plan-file> @/tmp/draft.md`. Add `--parent resume-<session-name>` to link the child beneath its parent in `rl prompts`; the parent may be saved later. Parent-session batons keep the default `resume-*` naming. Matching prefixes are kept once; conflicting prefixes are refused. Existing prompts keep their names, and `--subagent --replace` refuses a parent-prefixed prompt.
|
|
20
|
+
- **Draft outlines:** `runlist baton --template` prints the resume outline. The desktop Templates editor saves resume/subagent outlines in `runlist.templates.json`; repository baton instructions still apply.
|
|
21
|
+
- **Child draft:** `runlist baton --subagent --template --parent resume-<name>` prints the saved child outline (built-in: Task, State, Next, Evidence and Boundaries). Fill the draft; its `parent_prompt` travels on save. A launcher can pass `RUNLIST_PARENT_PROMPT` into the child environment instead. Keep full records at durable repo-relative paths with commits and checked results; temporary scratch paths are supplementary. Completed work is a result for the parent to record, not another restart. Repair old links without replacing text: `runlist prompts link <child...> --parent resume-<name>`. Showing or consuming a parent surfaces its pending children without consuming them.
|
|
20
22
|
- Consume a saved prompt with `runlist use <prompt-file>` (or bare `runlist use` for the oldest). This archives and claims before printing the body. For read-only triage, use `runlist prompts show <file>` or `runlist prompts show --all`; never open pending prompts with a file reader or `cat`.
|
|
21
23
|
- Pending prompts are session-local. Commit the tracked plan or source files named by baton, not `docs/prompts/*.md`.
|
|
22
24
|
- Create a document with `runlist new <type> <name> @<draft-file>`. A draft starting with frontmatter can supply scaffold fields; `type` is fixed by the command.
|
package/src/app.mjs
CHANGED
|
@@ -7,6 +7,14 @@ import { SourceEditError } from './source-editor.mjs';
|
|
|
7
7
|
import { resolveDocArg } from './index.mjs';
|
|
8
8
|
|
|
9
9
|
const assets = new URL('../assets/app/', import.meta.url);
|
|
10
|
+
const staticFiles = new Map([
|
|
11
|
+
'app.mjs','theme.mjs','transport.mjs','shared.mjs','document-create.mjs',
|
|
12
|
+
'quick-navigation.mjs','recovery-center.mjs','settings.mjs','document-lifecycle.mjs',
|
|
13
|
+
'record-navigation.mjs','library-navigation.mjs','outline.mjs','editor-navigation.mjs',
|
|
14
|
+
'template-editor.mjs','block-model.mjs','block-editor.mjs','git-changes.mjs','git-commit.mjs',
|
|
15
|
+
'document-views.mjs','document-stages.mjs','document-yardstick.mjs',
|
|
16
|
+
'filing-navigation.mjs','semantic-search.mjs','stage-source.mjs','app.css',
|
|
17
|
+
].map(file => [`/${file}`, [file, file.endsWith('.css') ? 'text/css' : 'text/javascript']]));
|
|
10
18
|
const ttl = 12 * 60 * 60 * 1000;
|
|
11
19
|
const secret = () => randomBytes(32).toString('base64url');
|
|
12
20
|
function problem(code,message) { throw new SourceEditError(code,message); }
|
|
@@ -40,10 +48,7 @@ export async function startApp({ config, port = 5173, initialPath = null, actor
|
|
|
40
48
|
if (!origin || ![new URL(origin).host, `localhost:${server.address().port}`].includes(req.headers.host)) problem('forbidden', 'Invalid local application host.');
|
|
41
49
|
const url = new URL(req.url, origin);
|
|
42
50
|
if (req.method === 'GET' && url.pathname === '/') return send(200, readFileSync(new URL('index.html', assets)), 'text/html; charset=utf-8');
|
|
43
|
-
|
|
44
|
-
staticFiles['/git-changes.mjs']=['git-changes.mjs','text/javascript'];
|
|
45
|
-
staticFiles['/git-commit.mjs']=['git-commit.mjs','text/javascript'];
|
|
46
|
-
if (req.method === 'GET' && staticFiles[url.pathname]) { const [file, type] = staticFiles[url.pathname]; return send(200, readFileSync(new URL(file, assets)), `${type}; charset=utf-8`); }
|
|
51
|
+
if (req.method === 'GET' && staticFiles.has(url.pathname)) { const [file, type] = staticFiles.get(url.pathname); return send(200, readFileSync(new URL(file, assets)), `${type}; charset=utf-8`); }
|
|
47
52
|
const requestOrigin = req.headers.origin;
|
|
48
53
|
const permittedOrigins = [origin, `http://localhost:${server.address().port}`];
|
|
49
54
|
if (requestOrigin && !permittedOrigins.includes(requestOrigin)) problem('forbidden', 'Cross-origin requests are refused.');
|
package/src/baton.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso, shellQuote } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
6
|
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput, slugify } from './new.mjs';
|
|
7
7
|
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
|
|
@@ -10,6 +10,8 @@ import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handof
|
|
|
10
10
|
import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
|
|
11
11
|
import { mutateFileSet } from './atomic-mutation.mjs';
|
|
12
12
|
import { assertPlanMutationAuthorized, authoritativeSessionId, listOwnedPlans, readPlanOwnership } from './pickup.mjs';
|
|
13
|
+
import { HANDOFF_TEMPLATES, parentPromptName } from './prompt-handoff.mjs';
|
|
14
|
+
import { readTemplateStore, templateFile } from './template-store.mjs';
|
|
13
15
|
|
|
14
16
|
// `runlist baton` is the one-command handoff: save the resume prompt AND release
|
|
15
17
|
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
@@ -80,11 +82,6 @@ function archivedCopyPath(promptPath, config) {
|
|
|
80
82
|
return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
function shellQuote(value) {
|
|
84
|
-
const raw = String(value);
|
|
85
|
-
return /^[A-Za-z0-9_./:@+-]+$/.test(raw) ? raw : `"${raw.replace(/["\\$`]/g, '\\$&')}"`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
85
|
function commitCommand(config, message, paths) {
|
|
89
86
|
const fallback = ['git', 'commit', '-m', message, '--', ...paths];
|
|
90
87
|
let argv = fallback;
|
|
@@ -124,6 +121,8 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
124
121
|
let replace = false;
|
|
125
122
|
let done = false;
|
|
126
123
|
let subagent = false;
|
|
124
|
+
let parentPrompt = null;
|
|
125
|
+
let template = false;
|
|
127
126
|
const positionals = [];
|
|
128
127
|
for (let i = 0; i < argv.length; i++) {
|
|
129
128
|
const a = argv[i];
|
|
@@ -134,10 +133,31 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
134
133
|
if (a === '--replace') { replace = true; continue; }
|
|
135
134
|
if (a === '--done') { done = true; continue; }
|
|
136
135
|
if (a === '--subagent') { subagent = true; continue; }
|
|
136
|
+
if (a === '--template') { template = true; continue; }
|
|
137
|
+
if (a === '--parent') {
|
|
138
|
+
const value = argv[++i];
|
|
139
|
+
try { parentPrompt = parentPromptName(value); }
|
|
140
|
+
catch { die('--parent requires a resume prompt name, e.g. --parent resume-session-name.'); }
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
137
143
|
if (a === '--json') continue;
|
|
138
144
|
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
139
145
|
die(`Unknown flag for \`runlist baton\`: ${a}`);
|
|
140
146
|
}
|
|
147
|
+
if (parentPrompt && !subagent) die('--parent requires --subagent.');
|
|
148
|
+
if (template) {
|
|
149
|
+
if (done || replace || force || statusFlag || note !== null || positionals.length || bodyFlag !== null) {
|
|
150
|
+
die('Use `runlist baton --template` or `runlist baton --subagent --template [--parent resume-name]` to print a draft without saving.');
|
|
151
|
+
}
|
|
152
|
+
if (subagent && !parentPrompt && process.env.RUNLIST_PARENT_PROMPT) parentPrompt = parentPromptName(process.env.RUNLIST_PARENT_PROMPT);
|
|
153
|
+
const name = subagent ? 'subagent' : 'resume';
|
|
154
|
+
const key = `handoff:${name}`;
|
|
155
|
+
const override = config ? readTemplateStore(config).data.templates[key]?.source : null;
|
|
156
|
+
const source = override ?? HANDOFF_TEMPLATES[key].source;
|
|
157
|
+
const draft = source.replaceAll('{{parent_prompt}}', parentPrompt ?? '<resume-parent-name>');
|
|
158
|
+
process.stdout.write(json ? JSON.stringify({ operation: 'baton-template', name, origin: override ? templateFile : 'Built-in', parentPrompt, template: draft }, null, 2) + '\n' : draft);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
141
161
|
|
|
142
162
|
let planArg = null;
|
|
143
163
|
let bodyArg = null;
|
|
@@ -164,6 +184,13 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
164
184
|
body = readPipedBodyInput();
|
|
165
185
|
}
|
|
166
186
|
if (!body || !body.trim()) die(BODY_USAGE);
|
|
187
|
+
const draft = extractFrontmatter(body);
|
|
188
|
+
const draftFm = parseSimpleFrontmatter(draft.frontmatter);
|
|
189
|
+
if (!subagent && asString(draftFm.parent_prompt)) die('parent_prompt requires --subagent; a resume handoff cannot be its own child.');
|
|
190
|
+
if (subagent && !parentPrompt) {
|
|
191
|
+
const draftParent = asString(draftFm.parent_prompt);
|
|
192
|
+
if (draftParent) parentPrompt = parentPromptName(draftParent);
|
|
193
|
+
}
|
|
167
194
|
|
|
168
195
|
// Resolve what's being handed off. Two modes:
|
|
169
196
|
// plan mode — a plan is released alongside the prompt (one status flip).
|
|
@@ -249,10 +276,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
249
276
|
if (!planPath && priorPlanRef) {
|
|
250
277
|
die(`Nothing replaced: ${oldRepoPath} links a plan (${priorPlanRef}). Use \`runlist baton <plan-file> @<draft-file> --replace\` so the plan link and release stay together.`);
|
|
251
278
|
}
|
|
279
|
+
const priorParent = asString(fm.parent_prompt);
|
|
280
|
+
parentPrompt ??= priorParent;
|
|
252
281
|
replacement = {
|
|
253
|
-
oldPath, oldRepoPath, oldRaw,
|
|
282
|
+
oldPath, oldRepoPath, oldRaw, priorParent,
|
|
254
283
|
archivedPath: archivedCopyPath(oldPath, config),
|
|
255
|
-
alreadyCurrent: oldBody.trim() === body.trim()
|
|
284
|
+
alreadyCurrent: oldBody.trim() === draft.body.trim()
|
|
285
|
+
&& Object.entries(draftFm).every(([key, value]) => key === 'parent_prompt' || JSON.stringify(fm[key]) === JSON.stringify(value))
|
|
286
|
+
&& (priorParent ?? null) === parentPrompt
|
|
256
287
|
&& (!planPath || (priorPlanPath && path.resolve(priorPlanPath) === path.resolve(planPath))),
|
|
257
288
|
canonicalPath: target.filePath,
|
|
258
289
|
};
|
|
@@ -324,6 +355,12 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
324
355
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
325
356
|
}
|
|
326
357
|
|
|
358
|
+
if (subagent) {
|
|
359
|
+
if (!parentPrompt && process.env.RUNLIST_PARENT_PROMPT) parentPrompt = parentPromptName(process.env.RUNLIST_PARENT_PROMPT);
|
|
360
|
+
if (replacement && (replacement.priorParent ?? null) !== parentPrompt) replacement.alreadyCurrent = false;
|
|
361
|
+
if (!parentPrompt) warn('Subagent handoff has no parent link. Pass --parent resume-name, include parent_prompt in the draft, or inherit RUNLIST_PARENT_PROMPT. Use `runlist prompts link <child> --parent <resume-name>` to repair an existing handoff.');
|
|
362
|
+
}
|
|
363
|
+
|
|
327
364
|
const preparedReplacement = prepared => {
|
|
328
365
|
if (!replacement || replacement.alreadyCurrent) return { updates: [], creations: [], guards: [] };
|
|
329
366
|
let archivedContent = renderLifecycleMutation(replacement.oldRaw,
|
|
@@ -353,14 +390,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
353
390
|
try {
|
|
354
391
|
if (!planPath) {
|
|
355
392
|
const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
|
|
356
|
-
const prepared = preparePromptDocument(promptName, body, config, { dryRun });
|
|
393
|
+
const prepared = preparePromptDocument(promptName, body, config, { parentPrompt, dryRun });
|
|
357
394
|
if (replacement) {
|
|
358
395
|
const mutation = preparedReplacement(prepared);
|
|
359
396
|
if (!dryRun && !replacement.alreadyCurrent) mutateFileSet(mutation, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
360
397
|
newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
|
|
361
398
|
} else {
|
|
362
399
|
try {
|
|
363
|
-
newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
|
|
400
|
+
newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true });
|
|
364
401
|
} catch (err) {
|
|
365
402
|
// Lost a race with another baton between the pending check and the write.
|
|
366
403
|
if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
@@ -371,7 +408,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
371
408
|
createdSlug = path.basename(promptRepoPath, '.md');
|
|
372
409
|
} else {
|
|
373
410
|
const promptName = replacement ? path.basename(replacement.oldPath, '.md') : slugBase;
|
|
374
|
-
const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, dryRun });
|
|
411
|
+
const prepared = preparePromptDocument(promptName, body, config, { plan: repoPath, parentPrompt, dryRun });
|
|
375
412
|
const mutation = preparedReplacement(prepared);
|
|
376
413
|
const setArgs = [status, planPath];
|
|
377
414
|
if (force) setArgs.push('--force');
|
|
@@ -434,6 +471,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
434
471
|
}
|
|
435
472
|
const operationResult = {
|
|
436
473
|
operation: 'baton',
|
|
474
|
+
parentPrompt,
|
|
437
475
|
dryRun: Boolean(dryRun),
|
|
438
476
|
disposition: replacement?.alreadyCurrent && !statusChanged && !wouldReleaseClaim ? 'already-current' : dryRun ? 'would-change' : 'applied',
|
|
439
477
|
wouldChange: Boolean(dryRun),
|
package/src/brief.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
2
|
+
import { walkSections, matchSections, walkChecklist } from './section.mjs';
|
|
3
|
+
|
|
4
|
+
// A document in a few KB, for an agent to read in place of the whole file: a
|
|
5
|
+
// heading outline and the open checklist boxes, both with file line numbers,
|
|
6
|
+
// and one section on request. Pure: callers read the file and render.
|
|
7
|
+
|
|
8
|
+
export const LIMITS = {
|
|
9
|
+
outline: 60, // headings shown; the deepest levels drop first
|
|
10
|
+
boxes: 30, // open boxes shown without --all
|
|
11
|
+
width: 140, // a heading, box or frontmatter line is cut to this
|
|
12
|
+
listItems: 8, // a frontmatter list longer than this is collapsed
|
|
13
|
+
textLines: 4, // and a multi-line frontmatter value cut to this many lines
|
|
14
|
+
sectionLines: 500, // lines of one section read
|
|
15
|
+
sectionBytes: 60 * 1024,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Several files at once is a survey; each one gets less.
|
|
19
|
+
export const SURVEY_LIMITS = { ...LIMITS, outline: 20, boxes: 10 };
|
|
20
|
+
|
|
21
|
+
export const cut = (text, width = LIMITS.width) =>
|
|
22
|
+
text.length > width ? `${text.slice(0, width - 3)}...` : text;
|
|
23
|
+
|
|
24
|
+
// The file split into lines, without the empty one a final newline leaves.
|
|
25
|
+
function fileLines(text) {
|
|
26
|
+
const lines = text.split('\n');
|
|
27
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Headings with file coordinates: `line` is the heading, `end` the last line of
|
|
32
|
+
// its section (the line before the next heading at the same or a higher level).
|
|
33
|
+
function headingsOf(body, bodyLineOffset, lastLine) {
|
|
34
|
+
return walkSections(body).map(s => ({
|
|
35
|
+
line: s.lineStart + bodyLineOffset,
|
|
36
|
+
end: Math.min(s.lineEnd + bodyLineOffset, lastLine),
|
|
37
|
+
level: s.level,
|
|
38
|
+
heading: s.heading,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Drop the deepest heading levels until the outline fits, then cut the tail.
|
|
43
|
+
function boundOutline(headings, cap) {
|
|
44
|
+
let shown = headings;
|
|
45
|
+
while (shown.length > cap) {
|
|
46
|
+
const deepest = Math.max(...shown.map(h => h.level));
|
|
47
|
+
if (deepest <= 1) break;
|
|
48
|
+
shown = shown.filter(h => h.level < deepest);
|
|
49
|
+
}
|
|
50
|
+
shown = shown.slice(0, cap);
|
|
51
|
+
return { shown, omitted: headings.length - shown.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Frontmatter as written, with long values cut and long lists collapsed.
|
|
55
|
+
export function collapseFrontmatter(frontmatter, limits = LIMITS) {
|
|
56
|
+
if (!frontmatter) return [];
|
|
57
|
+
const out = [];
|
|
58
|
+
let listRun = 0;
|
|
59
|
+
let textRun = 0;
|
|
60
|
+
const flush = () => {
|
|
61
|
+
if (listRun > limits.listItems) out.push(` (+${listRun - limits.listItems} more)`);
|
|
62
|
+
if (textRun > limits.textLines) out.push(` (+${textRun - limits.textLines} more lines)`);
|
|
63
|
+
listRun = 0;
|
|
64
|
+
textRun = 0;
|
|
65
|
+
};
|
|
66
|
+
for (const line of frontmatter.split('\n')) {
|
|
67
|
+
if (/^\s+-\s/.test(line)) {
|
|
68
|
+
if (++listRun <= limits.listItems) out.push(cut(line, limits.width));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\s+\S/.test(line)) {
|
|
72
|
+
if (++textRun <= limits.textLines) out.push(cut(line, limits.width));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
flush();
|
|
76
|
+
out.push(cut(line, limits.width));
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The brief of one document's raw text: its size, a bounded outline and the
|
|
84
|
+
* open boxes. `all` lists every open box. Counts are of what sits outside code
|
|
85
|
+
* fences, so a template's example checklist is not reported as work.
|
|
86
|
+
*/
|
|
87
|
+
export function buildBrief(raw, { all = false, limits = LIMITS } = {}) {
|
|
88
|
+
const text = normalizeEol(raw);
|
|
89
|
+
const { frontmatter, body, bodyLineOffset } = extractFrontmatter(text);
|
|
90
|
+
const lines = fileLines(text);
|
|
91
|
+
const headings = headingsOf(body, bodyLineOffset, lines.length);
|
|
92
|
+
const { shown, omitted } = boundOutline(headings, limits.outline);
|
|
93
|
+
const boxes = walkChecklist(body).map(b => ({ ...b, line: b.line + bodyLineOffset }));
|
|
94
|
+
const open = boxes.filter(b => b.open).map(({ line, text: t }) => ({ line, text: t }));
|
|
95
|
+
const listed = all ? open : open.slice(0, limits.boxes);
|
|
96
|
+
return {
|
|
97
|
+
lines: lines.length,
|
|
98
|
+
bytes: Buffer.byteLength(text),
|
|
99
|
+
frontmatterLines: frontmatter ? bodyLineOffset : 0,
|
|
100
|
+
outline: shown,
|
|
101
|
+
outlineOmitted: omitted,
|
|
102
|
+
boxes: { open: open.length, done: boxes.length - open.length },
|
|
103
|
+
open: listed,
|
|
104
|
+
openOmitted: open.length - listed.length,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One section of a document by heading: its lines from `from` (default the
|
|
110
|
+
* heading) to the section's end, cut at `limits.sectionLines` lines or
|
|
111
|
+
* `limits.sectionBytes`. `next` is the first line not printed when cut.
|
|
112
|
+
* Returns { found: false, headings } when nothing matches.
|
|
113
|
+
*/
|
|
114
|
+
export function readSection(raw, query, { from = null, limits = LIMITS } = {}) {
|
|
115
|
+
const text = normalizeEol(raw);
|
|
116
|
+
const { body, bodyLineOffset } = extractFrontmatter(text);
|
|
117
|
+
const lines = fileLines(text);
|
|
118
|
+
const headings = headingsOf(body, bodyLineOffset, lines.length);
|
|
119
|
+
const matches = matchSections(headings, query);
|
|
120
|
+
if (!matches.length) return { found: false, query, headings };
|
|
121
|
+
|
|
122
|
+
const [match, ...others] = matches;
|
|
123
|
+
const start = from == null ? match.line : Math.max(from, match.line);
|
|
124
|
+
if (start > match.end) {
|
|
125
|
+
return { found: true, past: true, query, heading: match, from: start, others };
|
|
126
|
+
}
|
|
127
|
+
const rows = [];
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
for (let n = start; n <= match.end; n++) {
|
|
130
|
+
bytes += Buffer.byteLength(lines[n - 1]) + 1;
|
|
131
|
+
if (rows.length >= limits.sectionLines || (rows.length && bytes > limits.sectionBytes)) break;
|
|
132
|
+
rows.push({ line: n, text: lines[n - 1] });
|
|
133
|
+
}
|
|
134
|
+
const to = rows.length ? rows[rows.length - 1].line : start - 1;
|
|
135
|
+
return {
|
|
136
|
+
found: true,
|
|
137
|
+
query,
|
|
138
|
+
heading: match,
|
|
139
|
+
from: start,
|
|
140
|
+
to,
|
|
141
|
+
next: to < match.end ? to + 1 : null,
|
|
142
|
+
rows,
|
|
143
|
+
others,
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -109,7 +109,7 @@ const definitions = [
|
|
|
109
109
|
form('use <name>', { subcommands: ['use'], args: positionals(1, 1) }),
|
|
110
110
|
form('cap <gb>', { subcommands: ['cap'], args: positionals(1, 1) }),
|
|
111
111
|
]),
|
|
112
|
-
command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json')] })]),
|
|
112
|
+
command('show', none, 'read', [form('<file...>', { args: positionals(1, Infinity), options: [flag('--json'), flag('--card'), flag('--all'), flag('--frontmatter'), value('--section', '-s'), value('--from')] })]),
|
|
113
113
|
command('xref', mutates('the flags log under the state directory with --check --flag; otherwise read-only'), 'read', [
|
|
114
114
|
form('<file...> | --check [--flag]', { args: positionals(0, Infinity), options: [flag('--json'), flag('--check'), flag('--flag')] }),
|
|
115
115
|
]),
|
|
@@ -134,13 +134,14 @@ const definitions = [
|
|
|
134
134
|
form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] }),
|
|
135
135
|
form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
|
|
136
136
|
form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
|
|
137
|
+
form('link <child...> --parent <resume-name>', { subcommands: ['link'], args: positionals(1, Infinity), options: [value('--parent'), flag('--json')] }),
|
|
137
138
|
form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
|
|
138
139
|
form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
|
|
139
140
|
form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
|
|
140
141
|
], { aliases: ['prompt'] }),
|
|
141
142
|
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
143
|
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('--done'), flag('--subagent'), flag('--json')] })]),
|
|
144
|
+
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'), value('--parent'), flag('--template'), flag('--force'), flag('--replace'), flag('--done'), flag('--subagent'), flag('--json')] })]),
|
|
144
145
|
command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
|
|
145
146
|
form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
|
|
146
147
|
form('next <hub>', { subcommands: ['next'], args: positionals(1, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files')] }),
|
|
@@ -180,7 +181,7 @@ const definitions = [
|
|
|
180
181
|
]),
|
|
181
182
|
command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
|
|
182
183
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
183
|
-
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
184
|
+
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--prompts'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
184
185
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
185
186
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
|
186
187
|
form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
|
package/src/config.mjs
CHANGED
|
@@ -88,14 +88,10 @@ const DEFAULTS = {
|
|
|
88
88
|
skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
|
|
89
89
|
skipWarningsFor: ['archived', 'partial', 'queued-after'],
|
|
90
90
|
terminalStatuses: ['archived', 'deprecated', 'reference'],
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
// list stays readable. Prompts were removed from this map 2026-08-30 along
|
|
96
|
-
// with their held/shelved statuses — `archived` is the only directory a
|
|
97
|
-
// prompt ever moves into.
|
|
98
|
-
filedStatuses: { paused: 'held' },
|
|
91
|
+
// Optional status → directory filing. Ordinary status changes keep the
|
|
92
|
+
// current path by default; archive remains a separate move. Repositories
|
|
93
|
+
// can opt in here or with per-status `filed: true` / `filed: 'held'`.
|
|
94
|
+
filedStatuses: {},
|
|
99
95
|
// Types whose archive nests under their own type dir (<typeDir>/<archiveDir>,
|
|
100
96
|
// e.g. docs/prompts/archived/) instead of the shared <root>/<archiveDir>.
|
|
101
97
|
// Prompts are session-local churn — keeping their archive out of the shared
|
|
@@ -465,14 +461,24 @@ function deepMerge(defaults, overrides, parentPath = '') {
|
|
|
465
461
|
return result;
|
|
466
462
|
}
|
|
467
463
|
|
|
468
|
-
|
|
464
|
+
// Locate repository data without importing or executing its JavaScript config.
|
|
465
|
+
export function locateConfig(cwd, explicitConfigPath) {
|
|
469
466
|
const configPath = explicitConfigPath
|
|
470
467
|
? path.resolve(cwd, explicitConfigPath)
|
|
471
468
|
: findConfigFile(cwd);
|
|
469
|
+
const configDir = configPath && existsSync(configPath) ? path.dirname(configPath) : cwd;
|
|
470
|
+
let repoRoot = configDir;
|
|
471
|
+
for (let dir = configDir; dir !== path.parse(dir).root; dir = path.dirname(dir)) {
|
|
472
|
+
if (existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
|
|
473
|
+
}
|
|
474
|
+
return {configPath,configDir,repoRoot};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function resolveConfig(cwd, explicitConfigPath) {
|
|
478
|
+
const {configPath,configDir,repoRoot} = locateConfig(cwd, explicitConfigPath);
|
|
472
479
|
|
|
473
480
|
let userConfig = {};
|
|
474
481
|
let hooks = {};
|
|
475
|
-
let configDir = cwd;
|
|
476
482
|
|
|
477
483
|
if (configPath && existsSync(configPath)) {
|
|
478
484
|
const configUrl = pathToFileURL(configPath).href;
|
|
@@ -483,8 +489,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
483
489
|
die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
|
|
484
490
|
}
|
|
485
491
|
|
|
486
|
-
configDir = path.dirname(configPath);
|
|
487
|
-
|
|
488
492
|
for (const [key, value] of Object.entries(mod)) {
|
|
489
493
|
if (key === 'default') continue;
|
|
490
494
|
if (typeof value === 'function') {
|
|
@@ -523,20 +527,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
523
527
|
}
|
|
524
528
|
}
|
|
525
529
|
|
|
526
|
-
// Find repo root by walking up looking for .git
|
|
527
|
-
let repoRoot = configDir;
|
|
528
|
-
{
|
|
529
|
-
let dir = configDir;
|
|
530
|
-
const fsRoot = path.parse(dir).root;
|
|
531
|
-
while (dir !== fsRoot) {
|
|
532
|
-
if (existsSync(path.join(dir, '.git'))) {
|
|
533
|
-
repoRoot = dir;
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
dir = path.dirname(dir);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
530
|
// Resolve document types
|
|
541
531
|
const typesConfig = config.types ?? {};
|
|
542
532
|
const validTypes = new Set(Object.keys(typesConfig));
|
package/src/doctor.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { installationReport, formatInstallationReport } from './installation.mjs';
|
|
3
|
+
import { diagnosePromptParents } from './prompt-handoff.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
6
|
import { runLint } from './lint.mjs';
|
|
@@ -235,6 +237,17 @@ function runDoctorSession(argv) {
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
export function runDoctor(argv, config, opts = {}) {
|
|
240
|
+
if (argv.includes('--prompts')) {
|
|
241
|
+
const index=buildIndex(config,{fast:true,invokeHooks:false});
|
|
242
|
+
const result=diagnosePromptParents(index.docs.filter(doc=>doc.type==='prompt'),config);
|
|
243
|
+
if(argv.includes('--json'))process.stdout.write(JSON.stringify(result,null,2)+'\n');
|
|
244
|
+
else {
|
|
245
|
+
process.stdout.write(`Prompt parent links: ${result.children} child handoffs · ${result.counts.linked??0} explicitly linked\n`);
|
|
246
|
+
for(const item of result.findings)process.stdout.write(` ${item.state}: ${item.path}${item.parentPrompt?` → ${item.parentPrompt}`:''}${item.candidates.length?` (${item.candidates.join(', ')})`:''}\n`);
|
|
247
|
+
if(result.findings.length)process.stdout.write('Inspect with `runlist prompts show <child>`. Repair an existing link with `runlist prompts link <child> --parent <resume-name>`; diagnostics change no prompts.\n');
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
238
251
|
if (argv.includes('--project')) {
|
|
239
252
|
runDoctorProject(config, { json: argv.includes('--json') });
|
|
240
253
|
return;
|
|
@@ -434,6 +447,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
434
447
|
const skillDriftWarnings = checkSkillDrift(config);
|
|
435
448
|
const result = {
|
|
436
449
|
cliVersion: cliPackage?.version ?? null,
|
|
450
|
+
installations: installationReport(config),
|
|
437
451
|
packageDependency: depVersion,
|
|
438
452
|
claudeCommandWarnings,
|
|
439
453
|
deprecatedCommandMentions,
|
|
@@ -448,6 +462,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
448
462
|
}
|
|
449
463
|
|
|
450
464
|
process.stdout.write(bold('runlist doctor --project') + '\n\n');
|
|
465
|
+
process.stdout.write(formatInstallationReport(result.installations)+'\n');
|
|
451
466
|
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
452
467
|
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
453
468
|
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
package/src/extractors.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
|
|
2
|
+
import { walkChecklist } from './section.mjs';
|
|
2
3
|
|
|
3
4
|
export function extractFirstHeading(body) {
|
|
4
5
|
return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
@@ -80,22 +81,14 @@ export function extractBodyLinks(body) {
|
|
|
80
81
|
return links;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// Boxes inside a code fence are examples, not work, so they are not counted;
|
|
85
|
+
// `runlist show` lists the open ones by the same rule (walkChecklist).
|
|
83
86
|
export function extractChecklistCounts(body) {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
let open = 0;
|
|
87
|
-
|
|
88
|
-
for (const match of matches) {
|
|
89
|
-
if (match[1].toLowerCase() === 'x') {
|
|
90
|
-
completed += 1;
|
|
91
|
-
} else {
|
|
92
|
-
open += 1;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
87
|
+
const boxes = walkChecklist(body);
|
|
88
|
+
const open = boxes.filter(b => b.open).length;
|
|
96
89
|
return {
|
|
97
|
-
completed,
|
|
90
|
+
completed: boxes.length - open,
|
|
98
91
|
open,
|
|
99
|
-
total:
|
|
92
|
+
total: boxes.length,
|
|
100
93
|
};
|
|
101
94
|
}
|
package/src/flags.mjs
CHANGED
|
@@ -221,13 +221,15 @@ function placeLabel(flag, config) {
|
|
|
221
221
|
const severityLabel = s => (s === 'problem' ? red(s) : s === 'warn' ? yellow(s) : dim(s));
|
|
222
222
|
|
|
223
223
|
// One line for the session-start banner, or null when nothing is open.
|
|
224
|
-
|
|
224
|
+
// A count for the session-start line; `runlist flags` lists them. Printing the
|
|
225
|
+
// first few flags' text put problems unrelated to the session's task in front
|
|
226
|
+
// of every session, cut mid-sentence.
|
|
227
|
+
export function flagsHudLine(config) {
|
|
225
228
|
let open;
|
|
226
229
|
try { open = openFlags(config); } catch { return null; }
|
|
227
230
|
if (open.length === 0) return null;
|
|
228
231
|
const problems = open.filter(f => f.severity === 'problem').length;
|
|
229
|
-
|
|
230
|
-
return `[runlist] ${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: ${items}. List: \`runlist flags\`.`;
|
|
232
|
+
return `${open.length} open flag${open.length === 1 ? '' : 's'}${problems ? `, ${problems} problem${problems === 1 ? '' : 's'}` : ''}, for awareness: \`runlist flags\` lists them.`;
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
export function runFlags(argv, config) {
|