runlist 0.94.1 → 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/README.md CHANGED
@@ -46,14 +46,13 @@ Stage filters, grouping and editing are included. Optional grepmax semantic
46
46
  controls are experimental; live retrieval depends on a compatible external
47
47
  provider. Local chat and push are deferred.
48
48
 
49
- The previous Apple Silicon GUI was **0.94.0-rc.1**, signed and accepted by Apple.
50
- Its public installers have been withdrawn during a privacy cleanup. A clean
51
- replacement has not yet been built. Existing installations can still be opened.
52
- The desktop app is separate from CLI 0.94.1,
49
+ The Apple Silicon GUI is **[0.94.1](https://github.com/reowens/runlist/releases/tag/desktop-v0.94.1)**,
50
+ signed, accepted by Apple and stapled. It replaces the installers withdrawn
51
+ during the privacy cleanup. The desktop app is separate from CLI 0.94.1,
53
52
  which includes optional plan-filing checks and the owner-written product yardstick,
54
53
  corrected hub-category inheritance, updates for every saved Claude plugin scope,
55
54
  and `runlist desktop install`.
56
- Updating the CLI does not replace the GUI's bundled engine; this GUI reports 0.94.0-rc.1.
55
+ Updating the CLI does not replace the GUI's bundled engine; this GUI reports 0.94.1.
57
56
  Earlier development packages and their qualification results are historical;
58
57
  see the [desktop validation record](desktop/VALIDATION.md).
59
58
 
@@ -85,7 +84,7 @@ Deferred work awaiting selection:
85
84
  older distributions, real-version upgrades, fresh/minimum-version and Intel
86
85
  Macs, plus native rendering, focus, accessibility and whole-window memory.
87
86
 
88
- Mac GUI downloads are temporarily unavailable pending a clean replacement.
87
+ Mac GUI 0.94.1 is available from the [desktop release](https://github.com/reowens/runlist/releases/tag/desktop-v0.94.1).
89
88
  CLI 0.94.1 includes the optional product yardstick.
90
89
  The supported desktop
91
90
  release is Apple Silicon macOS 13.5 or newer. Broader platform work and
@@ -96,11 +95,11 @@ command-line tools. The published Node.js CLI remains cross-platform.
96
95
 
97
96
  ### Optional desktop GUI
98
97
 
99
- The CLI works on its own. Public GUI downloads are temporarily unavailable
100
- during a privacy cleanup; a clean replacement has not yet been built. On an
101
- Apple Silicon Mac (macOS 13.5 or newer), open an existing installation with:
98
+ The CLI works on its own. On an Apple Silicon Mac (macOS 13.5 or newer),
99
+ install the optional GUI and open it with:
102
100
 
103
101
  ```bash
102
+ runlist desktop install
104
103
  runlist desktop
105
104
  ```
106
105
 
@@ -110,8 +109,8 @@ without opening it. The app bundles its runtime and reads local files through
110
109
  private pipes, with no listening server. Choose your checkout folder in the app.
111
110
  No checkout configuration is loaded by these CLI commands.
112
111
 
113
- The GUI is versioned separately from the CLI. There is currently no public Mac
114
- installer available to `runlist desktop install`.
112
+ The GUI is versioned separately from the CLI. `runlist desktop install` selects
113
+ the public Mac 0.94.1 installer.
115
114
  Windows, Linux and Intel Mac GUI downloads are not yet available through this
116
115
  command. An existing app is kept as-is. To replace it using a downloaded signed
117
116
  DMG, quit Runlist and run:
@@ -274,7 +273,7 @@ needs no installed Node, browser access link or listening server. CLI 0.92.0 pro
274
273
  The GUI remains optional:
275
274
 
276
275
  ```bash
277
- runlist desktop install # requires an available public installer
276
+ runlist desktop install # install the public Mac GUI
278
277
  runlist desktop # open the installed GUI
279
278
  runlist desktop install --from /path/to/Runlist.dmg # install a local signed DMG
280
279
  ```
@@ -283,12 +282,11 @@ The installer currently supports Apple Silicon Macs. It verifies the published
283
282
  download checksum, Runlist developer signature and Apple approval, and installs
284
283
  without opening the app. An existing app is kept unless `--from` supplies a
285
284
  replacement; replacement keeps a rollback copy. `--dry-run` uses no network and
286
- changes no files. Public Mac downloads have been withdrawn during a privacy
287
- cleanup; a clean replacement is pending. `--from` still works with a local
288
- approved installer. These commands ship
285
+ changes no files. The public Mac 0.94.1 download replaces the withdrawn
286
+ installers. These commands ship
289
287
  in [CLI 0.92.0](https://github.com/reowens/runlist/releases/tag/v0.92.0) and can also
290
288
  be used from this checkout as `node bin/runlist.mjs desktop …`. The GUI remains
291
- separately versioned at **0.94.0-rc.1**; npm installation does not download it.
289
+ separately versioned at **0.94.1**; npm installation does not download it.
292
290
 
293
291
  The optional browser document app is available from this source checkout (unreleased):
294
292
 
@@ -830,14 +828,14 @@ local model or server. GUI controls are implemented in the source checkout:
830
828
  Product goal shows the saved statement, delivery and assessment, with reviewed
831
829
  Serves/Clear/Close/Fold actions and retained recovery. Structured plans display
832
830
  the comparison with an explanation when assessment changes are unavailable.
833
- These controls are included in the public desktop package 0.94.0-rc.1;
831
+ These controls are included in the public desktop package 0.94.1;
834
832
  updating the CLI alone does not replace an existing app.
835
833
 
836
834
  ### Plan Homes And Filing Checks
837
835
 
838
836
  This policy is available in CLI 0.93.0. From a source checkout, invoke the
839
837
  commands below as `node bin/runlist.mjs …`. The separately versioned GUI
840
- 0.94.0-rc.1 also includes a read-only Filing view. The correction
838
+ The desktop app also includes a read-only Filing view. The correction
841
839
  for category inheritance from status-less hub indexes is included in CLI
842
840
  0.93.1.
843
841
 
@@ -882,7 +880,7 @@ is labeled and suppresses absence/uniqueness findings. Path/type-scoped output
882
880
  keeps the full hub/ancestor evidence while reporting only selected plans.
883
881
  The source checkout also includes a read-only Filing view: open it to scan,
884
882
  filter 50-plan pages, and inspect exact hub rows in 20-row evidence pages. Saved
885
- edits invalidate the report. This view is included in public desktop package 0.94.0-rc.1.
883
+ edits invalidate the report. This view is included in public desktop package 0.94.1.
886
884
  The policy is off when `filing` is unset, false, or `{ enabled: false }`.
887
885
  It can replace a repository's older “any link counts” coverage script once
888
886
  that repository enables it and retires its custom check.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.94.1",
3
+ "version": "0.95.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.94.1",
3
+ "version": "0.95.0",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
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/hub.mjs CHANGED
@@ -25,6 +25,10 @@ export function isCoordinationHub(doc) {
25
25
  // use to promote a roadmap above the Runlists section; here a roadmap counts as
26
26
  // a coordination hub so all the existing held-out plumbing covers it for free.
27
27
  if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
28
+ // Any other declared `execution_mode` has answered: the name is only a fallback
29
+ // for hubs that predate the field, so a plan ABOUT runlists that declares
30
+ // `implementation` stays a plan. Same rule as the slug nudge in validate.mjs.
31
+ if (doc.executionMode) return false;
28
32
  const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
29
33
  return base === 'runlist' || base.endsWith('-runlist');
30
34
  }
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
- const HANDOFF_ASK = /\bbaton\b|\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;
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
- return typeof prompt === 'string' && !SYSTEM_EVENT.test(prompt) && HANDOFF_ASK.test(prompt);
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 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.';
350
- const tail = 'Baton prints the prompt name it saved; tell the user that name. No `--help` needed.';
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
- return `[runlist] Baton: this session owns ${ownedPaths[0]}. ${draft} Then run \`runlist baton @<file>\`: it saves the prompt, releases the plan to active (\`--status paused|awaiting|partial|blocked\` and \`--note "why"\` if that fits better), and prints the commit to run. ${tail}`;
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
- if (ownedPaths.length > 1) {
355
- return `[runlist] Baton: this session owns ${ownedPaths.length} plans (${ownedPaths.join(', ')}), so name the one to hand off. ${draft} Then run \`runlist baton <plan-file> @<file>\`. ${tail}`;
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
- return `[runlist] Baton: this session owns no plan. ${draft} Then run \`runlist baton <slug> @<file>\` (saves resume-<slug>, changes nothing else), or \`runlist baton <plan-file> @<file>\` to hand off a plan by path. ${tail}`;
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: the previous session queued work for THIS one;
413
- // consuming it is the very next action.
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) (use [no-arg] → oldest pending prompt)') + '\n');
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(`[runlist] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`runlist use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`runlist prompts show <file>\`; list: \`runlist prompts\`.`) + '\n');
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 {
@@ -0,0 +1,19 @@
1
+ // Neutral publication checks; no private vocabulary or credentials.
2
+ export const PRIVATE_ROOT = /^(?:docs|\.runlist|\.dotmd|\.gmax|\.claude\/logs|desktop\/releases)(?:\/|$)/;
3
+ export const CREDENTIAL_FILE = /(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|[^/]*\.(?:pem|p12|pfx|key))$/;
4
+
5
+
6
+ export function inspectBaselineText(text, file) {
7
+ const findings = [];
8
+ const fixture = /^(?:test|desktop\/test)\//.test(file);
9
+ for (const [index, line] of text.split('\n').entries()) {
10
+ const paths = line.match(/\/(?:Users|home)\/[a-z0-9._-]+/g) ?? [];
11
+ if (paths.some(value => !fixture || !/\/(?:test|me|u|user|example)$/i.test(value))) {
12
+ findings.push({ file, line: index + 1, category: 'personal absolute path' });
13
+ }
14
+ if (file.endsWith('.json') && !fixture && /"email"\s*:/.test(line)) {
15
+ findings.push({ file, line: index + 1, category: 'contact address in public metadata' });
16
+ }
17
+ }
18
+ return findings;
19
+ }
package/src/validate.mjs CHANGED
@@ -540,6 +540,7 @@ export function checkRoadmapHubExecutionMode(docs, config) {
540
540
  if (!d) return false;
541
541
  if (d.executionMode === 'coordination' || d.executionMode === 'roadmap') return true;
542
542
  if (Array.isArray(d.refFields?.runlist) && d.refFields.runlist.length > 0) return true;
543
+ if (d.executionMode) return false;
543
544
  const b = (d.path.split('/').pop() || '').replace(/\.md$/, '');
544
545
  return b === 'runlist' || b.endsWith('-runlist');
545
546
  };