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/atomic-mutation.mjs
CHANGED
|
@@ -286,7 +286,7 @@ function validateTransactionArtifact(candidate, directory, label) {
|
|
|
286
286
|
function validateParticipantPath(participant, repoRoot, options, label) {
|
|
287
287
|
if (!participant || typeof participant.path !== 'string' || !path.isAbsolute(participant.path)
|
|
288
288
|
|| !['managed', 'generated'].includes(participant.policy)
|
|
289
|
-
|| !['source', 'target', 'referrer', 'ownership', 'creation', 'deletion'].includes(participant.label)) {
|
|
289
|
+
|| !['source', 'target', 'referrer', 'ownership', 'handoff', 'creation', 'deletion'].includes(participant.label)) {
|
|
290
290
|
throw new MutationConflictError(`Invalid transaction participant ${label}.`);
|
|
291
291
|
}
|
|
292
292
|
const txRoot = transactionRoot(repoRoot, options);
|
|
@@ -295,11 +295,27 @@ function validateParticipantPath(participant, repoRoot, options, label) {
|
|
|
295
295
|
throw new MutationConflictError(`Transaction participant targets transaction/lock state: ${participant.path}`);
|
|
296
296
|
}
|
|
297
297
|
if (participant.policy === 'managed' && options.config) {
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
try {
|
|
299
|
+
if (existsSync(participant.path)) authorizeManagedSource(participant.path, options.config, { kind: 'Transaction participant' });
|
|
300
|
+
else authorizeManagedDestination(participant.path, options.config, { kind: 'Transaction participant', recovery: true });
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// A handoffs-only project manages its prompt folder alone, so a move
|
|
303
|
+
// reaching past it is the carry `runlist setup` makes when it turns
|
|
304
|
+
// planning on, stopped part-way; setup finishes it under its own roots.
|
|
305
|
+
if (options.config.mode === 'handoffs') err.message += '\n`runlist setup` stopped part-way through turning planning on here; run `runlist setup --yes` to finish the upgrade.';
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
300
308
|
} else {
|
|
301
309
|
const authorized = safeGeneratedPath(participant.path, repoRoot, options, 'Transaction generated participant');
|
|
302
310
|
if (options.config) {
|
|
311
|
+
// Setup carries the saved prompt/plan paths in pending handoffs in the
|
|
312
|
+
// same transaction as the document. Keep this permission as narrow as
|
|
313
|
+
// ownership state; arbitrary generated repository files remain refused.
|
|
314
|
+
if (participant.label === 'handoff') {
|
|
315
|
+
const root = path.join(stateDir(repoRoot), 'handoffs');
|
|
316
|
+
if (path.dirname(authorized) !== root || !authorized.endsWith('.json')) throw new MutationConflictError(`Generated handoff participant is outside handoff state: ${participant.path}`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
303
319
|
const ownershipRoot = path.join(stateDir(repoRoot), 'ownership');
|
|
304
320
|
if (!contained(ownershipRoot, authorized)) throw new MutationConflictError(`Generated transaction participant is outside session ownership state: ${participant.path}`);
|
|
305
321
|
if (participant.label !== 'ownership') throw new MutationConflictError(`Session-generated participant must be classified as ownership: ${participant.path}`);
|
|
@@ -328,7 +344,7 @@ function validateManifest(manifest, manifestPath, directory, repoRoot, options)
|
|
|
328
344
|
validateGeneration(participant.new, directory, `participants[${index}].new`);
|
|
329
345
|
const shape = `${participant.old.exists ? 1 : 0}${participant.new.exists ? 1 : 0}`;
|
|
330
346
|
const expectedShapes = {
|
|
331
|
-
source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'],
|
|
347
|
+
source: ['10'], target: ['01'], referrer: ['11'], creation: ['01'], deletion: ['10'], ownership: ['01', '10'], handoff: ['11'],
|
|
332
348
|
}[participant.label];
|
|
333
349
|
if (!expectedShapes.includes(shape)) throw new MutationConflictError(`Invalid generation shape for transaction ${participant.label} participant.`);
|
|
334
350
|
if (!Array.isArray(participant.transientHashes) || !participant.transientHashes.every(hash => typeof hash === 'string' && /^[a-f\d]{64}$/.test(hash))) {
|
|
@@ -580,6 +596,60 @@ export function recoverAbandonedTransactions(repoRoot, options = {}) {
|
|
|
580
596
|
return recovered;
|
|
581
597
|
}
|
|
582
598
|
|
|
599
|
+
// Keep inspection and the locked doctor apply on the same conservative verdict.
|
|
600
|
+
function transactionResolution(manifest, directory) {
|
|
601
|
+
const present = file => {
|
|
602
|
+
try { lstatSync(file); return true; }
|
|
603
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
604
|
+
};
|
|
605
|
+
const participants = manifest.participants.map(item => ({ path: item.path, label: item.label, state: participantState(item) }));
|
|
606
|
+
const states = new Set(participants.map(item => item.state));
|
|
607
|
+
const uniform = states.size === 1 && (states.has('old') || states.has('new'));
|
|
608
|
+
const retainedGitPaths = [...new Set([
|
|
609
|
+
...manifest.gitIndex.retainedPaths,
|
|
610
|
+
manifest.gitIndex.prepared?.tempPath,
|
|
611
|
+
].filter(item => item && present(item)))];
|
|
612
|
+
const retainedDirectories = manifest.createdDirectories.filter(item => present(item.path)).map(item => item.path);
|
|
613
|
+
const knownArtifacts = new Set([
|
|
614
|
+
'manifest.json', ...manifest.participants.flatMap(item => [item.old.artifact, item.new.artifact]),
|
|
615
|
+
...manifest.recoveryArtifacts,
|
|
616
|
+
].filter(Boolean).map(item => path.basename(item)));
|
|
617
|
+
const otherArtifacts = readdirSync(directory).some(name => {
|
|
618
|
+
const artifact = path.join(directory, name);
|
|
619
|
+
return !knownArtifacts.has(name) || !lstatSync(artifact).isFile();
|
|
620
|
+
});
|
|
621
|
+
// Completion durably records commit or rollback. Later edits, consumption,
|
|
622
|
+
// or deletion need not match the historical participants to retire evidence.
|
|
623
|
+
const complete = manifest.status === 'complete';
|
|
624
|
+
const verifiedDirectories = complete
|
|
625
|
+
&& manifest.createdDirectories.filter(item => retainedDirectories.includes(item.path)).every(item => {
|
|
626
|
+
try {
|
|
627
|
+
const stat = lstatSync(item.path);
|
|
628
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !item.identity
|
|
629
|
+
|| stat.dev !== item.identity.dev || stat.ino !== item.identity.ino) return false;
|
|
630
|
+
const entries = readdirSync(item.path);
|
|
631
|
+
if (entries.includes(path.basename(item.marker))) {
|
|
632
|
+
if (!lstatSync(item.marker).isFile() || readFileSync(item.marker, 'utf8') !== `${item.token}\n`) return false;
|
|
633
|
+
} else if (!['removing', 'removed'].includes(item.markerState)) return false;
|
|
634
|
+
return true;
|
|
635
|
+
} catch { return false; }
|
|
636
|
+
});
|
|
637
|
+
const resolvable = (complete ? verifiedDirectories : uniform && retainedDirectories.length === 0)
|
|
638
|
+
&& retainedGitPaths.length === 0 && !otherArtifacts;
|
|
639
|
+
return { participants, generation: uniform ? [...states][0] : null, retainedGitPaths, retainedDirectories, resolvable,
|
|
640
|
+
reason: resolvable ? null : !complete && !uniform ? 'Canonical files do not agree on one generation' : 'Retained artifacts need manual review' };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Shared by setup's preview and refusal, using the doctor's verdict and reason.
|
|
644
|
+
export function transactionResolutionAdvice(item) {
|
|
645
|
+
if (item.ownerLiveness === 'live' || item.lockHeld) return 'Another runlist process is mid-move; rerun setup once it finishes';
|
|
646
|
+
if (item.resolvable) return 'Run runlist doctor --transactions --apply, then rerun setup';
|
|
647
|
+
const retained = item.retainedGitPaths?.length ? ` Retained Git artifacts: ${item.retainedGitPaths.join(', ')}.` : '';
|
|
648
|
+
const reason = (item.reason ?? 'Recovery has not established a complete generation').replace(/\.+$/, '');
|
|
649
|
+
const generation = item.status === 'complete' ? '' : 'restore one complete generation, ';
|
|
650
|
+
return `${reason}.${retained} Manually inspect the canonical files and recovery artifacts in ${item.directory}; ${generation}preserve any unverified artifacts, then remove that transaction directory and rerun setup`;
|
|
651
|
+
}
|
|
652
|
+
|
|
583
653
|
// Report on every transaction manifest without mutating anything, so a wedged
|
|
584
654
|
// repo can be diagnosed. `recoverAbandonedTransactions` deliberately refuses to
|
|
585
655
|
// guess and throws; that left no way to even SEE the state short of reading
|
|
@@ -592,7 +662,7 @@ export function inspectTransactions(repoRoot, options = {}) {
|
|
|
592
662
|
const report = [];
|
|
593
663
|
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
594
664
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
595
|
-
report.push({ id: entry.name, readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
|
|
665
|
+
report.push({ id: entry.name, directory: path.join(root, entry.name), readable: false, reason: 'Unsafe entry in transaction root', resolvable: false });
|
|
596
666
|
continue;
|
|
597
667
|
}
|
|
598
668
|
const directory = path.join(root, entry.name);
|
|
@@ -603,20 +673,20 @@ export function inspectTransactions(repoRoot, options = {}) {
|
|
|
603
673
|
report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err?.code === 'ENOENT' ? 'No manifest' : `Unreadable manifest: ${err.message}`, resolvable: false });
|
|
604
674
|
continue;
|
|
605
675
|
}
|
|
606
|
-
|
|
676
|
+
let resolution;
|
|
677
|
+
try {
|
|
678
|
+
validateManifest(manifest, manifestPath, directory, repoRoot, options);
|
|
679
|
+
resolution = transactionResolution(manifest, directory);
|
|
680
|
+
}
|
|
607
681
|
catch (err) {
|
|
608
|
-
report.push({ id: entry.name, directory, manifestPath, readable: false, reason: err.message, resolvable: false });
|
|
682
|
+
report.push({ id: entry.name, directory, manifestPath, status: manifest?.status, readable: false, reason: err.message, resolvable: false });
|
|
609
683
|
continue;
|
|
610
684
|
}
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
// generation: that IS the atomicity the transaction existed to guarantee,
|
|
617
|
-
// so the manifest is bookkeeping for work that is fully done or fully
|
|
618
|
-
// undone. Anything mixed, unknown, or with retained artifacts stays put.
|
|
619
|
-
const uniform = states.size === 1 && (states.has('old') || states.has('new'));
|
|
685
|
+
const lockHeld = manifest.participants.some(item => {
|
|
686
|
+
const lockPath = lockPathFor(canonicalPath(item.path), repoRoot);
|
|
687
|
+
return existsSync(lockPath) && processOwnerLiveness(lockOwner(lockPath)) !== 'dead';
|
|
688
|
+
});
|
|
689
|
+
const ownerLiveness = processOwnerLiveness(manifest.owner);
|
|
620
690
|
report.push({
|
|
621
691
|
id: manifest.id,
|
|
622
692
|
directory,
|
|
@@ -627,35 +697,54 @@ export function inspectTransactions(repoRoot, options = {}) {
|
|
|
627
697
|
result: manifest.result,
|
|
628
698
|
operation: manifest.operation,
|
|
629
699
|
createdAt: manifest.createdAt,
|
|
630
|
-
ownerLiveness
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
retainedDirectories,
|
|
635
|
-
resolvable: uniform && retainedGitPaths.length === 0 && retainedDirectories.length === 0,
|
|
636
|
-
reason: uniform
|
|
637
|
-
? (retainedGitPaths.length || retainedDirectories.length ? 'Retained artifacts need manual review' : null)
|
|
638
|
-
: 'Canonical files do not agree on one generation',
|
|
700
|
+
ownerLiveness,
|
|
701
|
+
lockHeld,
|
|
702
|
+
...resolution,
|
|
703
|
+
...(lockHeld || ownerLiveness === 'live' ? { resolvable: false, reason: 'Another runlist process is mid-move; rerun later once it finishes' } : {}),
|
|
639
704
|
});
|
|
640
705
|
}
|
|
641
706
|
return report;
|
|
642
707
|
}
|
|
643
708
|
|
|
644
709
|
// Clear the manifests inspectTransactions marked resolvable. Touches no
|
|
645
|
-
// canonical file
|
|
646
|
-
//
|
|
710
|
+
// canonical file. Complete manifests need only verified cleanup evidence;
|
|
711
|
+
// non-complete manifests still require one uniform generation.
|
|
647
712
|
export function resolveTransactions(repoRoot, options = {}) {
|
|
648
713
|
const cleared = [];
|
|
649
714
|
for (const item of inspectTransactions(repoRoot, options)) {
|
|
650
715
|
if (!item.resolvable) continue;
|
|
651
|
-
let manifest;
|
|
652
|
-
try {
|
|
716
|
+
let manifest, manifestContent;
|
|
717
|
+
try {
|
|
718
|
+
manifestContent = readFileSync(item.manifestPath, 'utf8');
|
|
719
|
+
manifest = JSON.parse(manifestContent);
|
|
720
|
+
} catch { continue; }
|
|
653
721
|
withPathLocks(manifest.participants.map(entry => entry.path), { repoRoot, ...options }, () => {
|
|
654
722
|
// Re-check under the lock: nothing may have moved since inspection.
|
|
655
|
-
|
|
656
|
-
|
|
723
|
+
try {
|
|
724
|
+
if (readFileSync(item.manifestPath, 'utf8') !== manifestContent) return;
|
|
725
|
+
validateManifest(manifest, item.manifestPath, item.directory, repoRoot, options);
|
|
726
|
+
} catch { return; }
|
|
727
|
+
const verdict = transactionResolution(manifest, item.directory);
|
|
728
|
+
if (!verdict.resolvable) return;
|
|
729
|
+
// As on commit, remove verified markers deepest first, and remove only
|
|
730
|
+
// folders that become empty. A crash leaves the marker state resumable.
|
|
731
|
+
for (const entry of [...manifest.createdDirectories].sort((a, b) => b.path.length - a.path.length)) {
|
|
732
|
+
if (!verdict.retainedDirectories.includes(entry.path)) continue;
|
|
733
|
+
if (existsSync(entry.marker)) {
|
|
734
|
+
entry.markerState = 'removing';
|
|
735
|
+
durableJson(item.manifestPath, manifest, options);
|
|
736
|
+
unlinkSync(entry.marker);
|
|
737
|
+
fsyncDirectory(entry.path, options, 'transaction-directory-marker-delete');
|
|
738
|
+
entry.markerState = 'removed';
|
|
739
|
+
durableJson(item.manifestPath, manifest, options);
|
|
740
|
+
}
|
|
741
|
+
if (readdirSync(entry.path).length === 0) {
|
|
742
|
+
rmdirSync(entry.path);
|
|
743
|
+
fsyncDirectory(path.dirname(entry.path), options, 'transaction-directory-delete');
|
|
744
|
+
}
|
|
745
|
+
}
|
|
657
746
|
cleanupTransactionDirectory(item.directory, manifest, options);
|
|
658
|
-
cleared.push({ id: item.id, generation:
|
|
747
|
+
cleared.push({ id: item.id, generation: verdict.generation });
|
|
659
748
|
});
|
|
660
749
|
}
|
|
661
750
|
return cleared;
|
|
@@ -699,7 +788,7 @@ function beginMoveManifest({ repoRoot, transactionId, operation, sessionId, sour
|
|
|
699
788
|
add(targetPath, { exists: false }, { exists: true, content: targetContent, mode: source.identity.mode }, 'target');
|
|
700
789
|
for (const item of updates) add(item.path,
|
|
701
790
|
{ exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode },
|
|
702
|
-
{ exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
|
|
791
|
+
{ exists: true, content: item.content, mode: item.snapshot.identity.mode }, item.label === 'handoff' ? 'handoff' : item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'referrer');
|
|
703
792
|
for (const item of creations) add(item.path, { exists: false }, { exists: true, content: item.content, mode: item.mode ?? (0o666 & ~process.umask()) }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'creation');
|
|
704
793
|
for (const item of deletions) add(item.path,
|
|
705
794
|
{ exists: true, content: item.snapshot.content, hash: item.snapshot.hash, mode: item.snapshot.identity.mode }, { exists: false }, item.label === 'ownership' || !String(item.path).endsWith('.md') ? 'ownership' : 'deletion');
|
|
@@ -1209,8 +1298,10 @@ export function replaceSnapshot(snapshot, newContent, options = {}) {
|
|
|
1209
1298
|
return locked ? commit() : withPathLocks([snapshot.path], { repoRoot, ...options }, commit);
|
|
1210
1299
|
}
|
|
1211
1300
|
|
|
1301
|
+
// `locked` means the caller already holds filePath's lock, taken in one sorted
|
|
1302
|
+
// withPathLocks set with the other paths its decision depends on.
|
|
1212
1303
|
export function mutateFile(filePath, options, render) {
|
|
1213
|
-
|
|
1304
|
+
const mutate = () => {
|
|
1214
1305
|
options.testHooks?.beforeMutationSnapshot?.(filePath);
|
|
1215
1306
|
const snapshot = snapshotFile(filePath);
|
|
1216
1307
|
const rendered = render(snapshot.content, snapshot);
|
|
@@ -1222,7 +1313,8 @@ export function mutateFile(filePath, options, render) {
|
|
|
1222
1313
|
if (err.committedSnapshot) restoreUpdate(err.committedSnapshot, snapshot, err);
|
|
1223
1314
|
throw err;
|
|
1224
1315
|
}
|
|
1225
|
-
}
|
|
1316
|
+
};
|
|
1317
|
+
return options.locked ? mutate() : withPathLocks([filePath], options, mutate);
|
|
1226
1318
|
}
|
|
1227
1319
|
|
|
1228
1320
|
export function createFileExclusive(filePath, content, options) {
|
|
@@ -1244,6 +1336,16 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1244
1336
|
options.testHooks?.afterPublicationBeforePathOpen?.('create-hardlink', filePath);
|
|
1245
1337
|
} catch (err) {
|
|
1246
1338
|
if (!['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EXDEV', 'EMLINK'].includes(err?.code)) throw err;
|
|
1339
|
+
// A file that is read as code (a config every command imports) must
|
|
1340
|
+
// never be seen holding reservation data, not even after a crash
|
|
1341
|
+
// between the reservation and the rename, so such a caller refuses
|
|
1342
|
+
// instead of falling back.
|
|
1343
|
+
if (options.requireAtomicPublish) {
|
|
1344
|
+
const refusal = new Error(`This filesystem cannot create ${path.resolve(filePath)} atomically (hard links unsupported: ${err.code}).`);
|
|
1345
|
+
refusal.code = 'ERUNLIST_ATOMIC_PUBLISH_UNSUPPORTED';
|
|
1346
|
+
refusal.cause = err;
|
|
1347
|
+
throw refusal;
|
|
1348
|
+
}
|
|
1247
1349
|
// Portable fallback: reserve with O_EXCL, then replace only our own
|
|
1248
1350
|
// reservation. This may expose the reservation briefly, but can never
|
|
1249
1351
|
// overwrite a competing creator's file.
|
|
@@ -1282,6 +1384,9 @@ export function createFileExclusive(filePath, content, options) {
|
|
|
1282
1384
|
|
|
1283
1385
|
export function moveFileAtomic(sourcePath, targetPath, render, options) {
|
|
1284
1386
|
const { repoRoot, finalize, rollbackFinalize, testHooks, updates = [], creations = [], deletions = [], guards = [] } = options;
|
|
1387
|
+
for (const item of updates.filter(item => item.label === 'handoff')) {
|
|
1388
|
+
validateParticipantPath({ path: path.resolve(item.path), policy: 'generated', label: 'handoff' }, repoRoot, options, 'handoff update');
|
|
1389
|
+
}
|
|
1285
1390
|
recoverAbandonedTransactions(repoRoot, options);
|
|
1286
1391
|
return withPathLocks([sourcePath, targetPath, ...updates.map(item => item.path), ...creations.map(item => item.path), ...deletions.map(item => item.path), ...guards.map(item => item.path)], options, () => {
|
|
1287
1392
|
testHooks?.afterTransactionPhase?.('lock', { sourcePath, targetPath });
|
package/src/baton.mjs
CHANGED
|
@@ -3,15 +3,17 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath, nowIso, shellQuote } from './util.mjs';
|
|
5
5
|
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
-
import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput, slugify } from './new.mjs';
|
|
6
|
+
import { preparePromptDocument, promptDirectory, runNew, readBodyInput, readPipedBodyInput, slugify, unconfiguredPromptHint } from './new.mjs';
|
|
7
7
|
import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, renderLifecycleMutation, runSet } from './lifecycle.mjs';
|
|
8
8
|
import { green, dim, yellow } from './color.mjs';
|
|
9
9
|
import { handoffNotDoneLine, recordPendingHandoff, runBatonDone } from './handoff-record.mjs';
|
|
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 {
|
|
13
|
+
import { builtinHandoffTemplate, parentPromptName } from './prompt-handoff.mjs';
|
|
14
14
|
import { readTemplateStore, templateFile } from './template-store.mjs';
|
|
15
|
+
import { isGitIgnored, gitCommitPaths, literalGitPathspec } from './git.mjs';
|
|
16
|
+
import { assertPublishedPromptFolder, carriedOnto, carryLegacyPrompts, existsAfterCarry, isHandoffsMode, planHandoffsConfig, publishHandoffsConfig, readsAsHandoffsOnly } from './handoffs-mode.mjs';
|
|
15
17
|
|
|
16
18
|
// `runlist baton` is the one-command handoff: save the resume prompt AND release
|
|
17
19
|
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
@@ -41,6 +43,9 @@ const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @
|
|
|
41
43
|
runlist baton @/tmp/draft.md # hand off the plan this session owns
|
|
42
44
|
runlist baton <plan-file> @/tmp/draft.md # hand off a named plan
|
|
43
45
|
runlist baton <slug> @/tmp/draft.md # no plan: save resume-<slug>, change nothing else`;
|
|
46
|
+
// Where planning is off (or there is no config yet) a name is the only form.
|
|
47
|
+
const HANDOFFS_BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
|
|
48
|
+
runlist baton <slug> @/tmp/draft.md # saves resume-<slug>; pick a short slug naming the work`;
|
|
44
49
|
|
|
45
50
|
// A handoff that lands beside a pending one leaves two prompts for the same
|
|
46
51
|
// work, and the next session picks whichever sorts first. Baton used to step to
|
|
@@ -73,34 +78,41 @@ function refusePendingHandoff(pending) {
|
|
|
73
78
|
die(`Nothing saved: a handoff for this is already pending:\n${lines}\nInspect it with \`runlist prompts show ${slug}\`. If it is current, keep it and do not hand off again. If it is stale, re-run baton with \`--replace\` to preserve the old handoff under archived/.`);
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
// `projection` is a carry not yet made (src/handoffs-mode.mjs): its targets
|
|
82
|
+
// count as taken, and the folder may not exist yet to authorize against.
|
|
83
|
+
function archivedCopyPath(promptPath, config, projection = null) {
|
|
77
84
|
const dir = path.join(path.dirname(promptPath), config.archiveDir);
|
|
78
85
|
const ext = path.extname(promptPath);
|
|
79
86
|
const stem = path.basename(promptPath, ext);
|
|
80
87
|
let candidate = path.join(dir, `${stem}${ext}`);
|
|
81
|
-
for (let n = 2;
|
|
82
|
-
return authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
|
|
88
|
+
for (let n = 2; existsAfterCarry(projection, candidate); n++) candidate = path.join(dir, `${stem}-${n}${ext}`);
|
|
89
|
+
return projection ? candidate : authorizeManagedDestination(candidate, config, { kind: 'Baton replacement archive' }).path;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
function commitCommand(config, message, paths) {
|
|
86
|
-
const fallback = ['git', 'commit', '-m', message, '--', ...paths];
|
|
87
|
-
let argv = fallback;
|
|
88
93
|
if (typeof config.hooks.batonCommitCommand === 'function') {
|
|
89
94
|
try {
|
|
90
95
|
const custom = config.hooks.batonCommitCommand(message, [...paths]);
|
|
91
96
|
if (!Array.isArray(custom) || custom.length === 0 || !custom.every(part => typeof part === 'string' && part.length > 0)) {
|
|
92
97
|
throw new Error('return a nonempty string argv array');
|
|
93
98
|
}
|
|
94
|
-
|
|
99
|
+
return custom.map(shellQuote).join(' ');
|
|
95
100
|
} catch (err) { warn(`batonCommitCommand failed (${err.message}); printing the default git command.`); }
|
|
96
101
|
}
|
|
97
|
-
|
|
102
|
+
const files = gitCommitPaths(paths, config.repoRoot);
|
|
103
|
+
if (!files.paths.length) return null;
|
|
104
|
+
// Keep ordinary command output stable, while protecting filenames that Git
|
|
105
|
+
// would otherwise interpret as pathspec magic (shell quoting is not enough).
|
|
106
|
+
const pathspec = file => /[*?[\\]/.test(file) || file.startsWith(':') ? literalGitPathspec(file) : file;
|
|
107
|
+
const command = ['git', 'commit', '-m', message, '--', ...files.paths.map(pathspec)].map(shellQuote).join(' ');
|
|
108
|
+
if (!files.add.length) return command;
|
|
109
|
+
return `${['git', 'add', '--', ...files.add.map(pathspec)].map(shellQuote).join(' ')} && ${command}`;
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
// With no plan named, --done finds this session's record; a named plan is
|
|
101
113
|
// echoed back so the command works from any session.
|
|
102
114
|
function explicitPlanDone(planArg, record) {
|
|
103
|
-
return planArg ? `runlist baton --done ${record.plan}` : 'runlist baton --done';
|
|
115
|
+
return planArg ? `runlist baton --done ${shellQuote(record.plan)}` : 'runlist baton --done';
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
// Is this positional a filesystem reference (must resolve, typos die) or a
|
|
@@ -123,6 +135,8 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
123
135
|
let subagent = false;
|
|
124
136
|
let parentPrompt = null;
|
|
125
137
|
let template = false;
|
|
138
|
+
let nextStep = null;
|
|
139
|
+
let currentState = null;
|
|
126
140
|
const positionals = [];
|
|
127
141
|
for (let i = 0; i < argv.length; i++) {
|
|
128
142
|
const a = argv[i];
|
|
@@ -132,6 +146,11 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
132
146
|
if (a === '--force') { force = true; continue; }
|
|
133
147
|
if (a === '--replace') { replace = true; continue; }
|
|
134
148
|
if (a === '--done') { done = true; continue; }
|
|
149
|
+
if (a === '--next-step' || a === '--current-state') {
|
|
150
|
+
if (i + 1 >= argv.length) die(`${a} needs a value: runlist baton --done [<plan-file>] ${a} "<text>"`);
|
|
151
|
+
if (a === '--next-step') nextStep = argv[++i]; else currentState = argv[++i];
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
135
154
|
if (a === '--subagent') { subagent = true; continue; }
|
|
136
155
|
if (a === '--template') { template = true; continue; }
|
|
137
156
|
if (a === '--parent') {
|
|
@@ -145,6 +164,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
145
164
|
die(`Unknown flag for \`runlist baton\`: ${a}`);
|
|
146
165
|
}
|
|
147
166
|
if (parentPrompt && !subagent) die('--parent requires --subagent.');
|
|
167
|
+
for (const [flag, value] of [['--next-step', nextStep], ['--current-state', currentState]]) {
|
|
168
|
+
if (value !== null && !done) die(`${flag} only goes with --done: save the prompt first, then run \`runlist baton --done [<plan-file>] ${flag} "<text>"\`.`);
|
|
169
|
+
}
|
|
148
170
|
if (template) {
|
|
149
171
|
if (done || replace || force || statusFlag || note !== null || positionals.length || bodyFlag !== null) {
|
|
150
172
|
die('Use `runlist baton --template` or `runlist baton --subagent --template [--parent resume-name]` to print a draft without saving.');
|
|
@@ -153,7 +175,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
153
175
|
const name = subagent ? 'subagent' : 'resume';
|
|
154
176
|
const key = `handoff:${name}`;
|
|
155
177
|
const override = config ? readTemplateStore(config).data.templates[key]?.source : null;
|
|
156
|
-
const source = override ??
|
|
178
|
+
const source = override ?? builtinHandoffTemplate(key, { planning: !readsAsHandoffsOnly(config) });
|
|
157
179
|
const draft = source.replaceAll('{{parent_prompt}}', parentPrompt ?? '<resume-parent-name>');
|
|
158
180
|
process.stdout.write(json ? JSON.stringify({ operation: 'baton-template', name, origin: override ? templateFile : 'Built-in', parentPrompt, template: draft }, null, 2) + '\n' : draft);
|
|
159
181
|
return;
|
|
@@ -170,8 +192,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
170
192
|
// The second half of a plan handoff: the prompt is already saved, and this
|
|
171
193
|
// confirms the plan was brought up to date after it (src/handoff-record.mjs).
|
|
172
194
|
if (done) {
|
|
173
|
-
if (
|
|
174
|
-
|
|
195
|
+
if (bodyArg !== null || bodyFlag !== null) die('`runlist baton --done` takes no prompt body; the prompt was saved by the first baton call.');
|
|
196
|
+
if (isHandoffsMode(config)) die('Nothing to confirm: planning is off in this project, so the prompt baton saved is the whole handoff.');
|
|
197
|
+
return runBatonDone(planArg, config, { json, dryRun, nextStep, currentState });
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
// Body FIRST — it's the common failure (`new prompt` without a body was the
|
|
@@ -183,7 +206,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
183
206
|
// Auto-consume piped/redirected stdin, same probe as `runlist new`.
|
|
184
207
|
body = readPipedBodyInput();
|
|
185
208
|
}
|
|
186
|
-
if (!body || !body.trim()) die(BODY_USAGE);
|
|
209
|
+
if (!body || !body.trim()) die(isHandoffsMode(config) || !config.configFound ? HANDOFFS_BODY_USAGE : BODY_USAGE);
|
|
187
210
|
const draft = extractFrontmatter(body);
|
|
188
211
|
const draftFm = parseSimpleFrontmatter(draft.frontmatter);
|
|
189
212
|
if (!subagent && asString(draftFm.parent_prompt)) die('parent_prompt requires --subagent; a resume handoff cannot be its own child.');
|
|
@@ -222,6 +245,9 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
222
245
|
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
223
246
|
} else if (owned.owned?.length > 1) {
|
|
224
247
|
die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' runlist baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
|
|
248
|
+
} else if (isHandoffsMode(config) || !config.configFound) {
|
|
249
|
+
// No plans here: the only thing missing is a name, which the session picks.
|
|
250
|
+
die('Baton needs a short name for the handoff — a few words naming the work:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>');
|
|
225
251
|
} else {
|
|
226
252
|
const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
|
|
227
253
|
die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? runlist baton <plan-file> @/tmp/draft.md${diagnostics}`);
|
|
@@ -241,9 +267,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
241
267
|
const slugBase = `${handoffPrefix}${unprefixed}`;
|
|
242
268
|
let replacement = null;
|
|
243
269
|
let alternatePromptPath = null;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
// With a carry planned but not made (`projection`), the check runs against
|
|
271
|
+
// docs/prompts/ as the carry will leave it, so a refusal comes before
|
|
272
|
+
// anything moves and dry-run answers as apply will.
|
|
273
|
+
const resolvePending = (projection = null) => {
|
|
274
|
+
const target = projection ? { filePath: path.join(projection.promptDir, `${slugBase}.md`) } : preparePromptDocument(slugBase, body, config, { dryRun: true });
|
|
275
|
+
const carriedHere = projection ? carriedOnto(projection, target.filePath) : null;
|
|
276
|
+
const pending = !projection ? pendingHandoffs(target.filePath, planPath, config)
|
|
277
|
+
: carriedHere ? [carriedHere.from] : existsSync(target.filePath) ? [toRepoPath(target.filePath, config.repoRoot)] : [];
|
|
247
278
|
if (planPath) {
|
|
248
279
|
const alternateSlug = `${subagent ? 'resume-' : 'subagent-'}${unprefixed}`;
|
|
249
280
|
alternatePromptPath = preparePromptDocument(alternateSlug, body, config, { dryRun: true }).filePath;
|
|
@@ -260,12 +291,12 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
260
291
|
const detail = pending.length ? `Found ${pending.length}:\n${pending.map(p => ` ${p}`).join('\n')}` : 'No pending handoff matches this work.';
|
|
261
292
|
die(`Nothing replaced: --replace requires exactly one pending handoff. ${detail}`);
|
|
262
293
|
}
|
|
263
|
-
const oldRepoPath = pending[0];
|
|
294
|
+
const oldRepoPath = carriedHere ? carriedHere.to : pending[0];
|
|
264
295
|
if (subagent && /^resume-/i.test(path.basename(oldRepoPath))) {
|
|
265
296
|
die(`Nothing replaced: ${oldRepoPath} is a parent handoff. Inspect it with \`runlist prompts show ${path.basename(oldRepoPath, '.md')}\`; use a separate task slug for the child handoff, or omit --subagent to refresh the parent handoff.`);
|
|
266
297
|
}
|
|
267
|
-
const oldPath = authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
|
|
268
|
-
const oldRaw = readFileSync(oldPath, 'utf8');
|
|
298
|
+
const oldPath = carriedHere ? carriedHere.target : authorizeManagedSource(path.resolve(config.repoRoot, oldRepoPath), config, { kind: 'Baton replacement source' }).path;
|
|
299
|
+
const oldRaw = carriedHere ? carriedHere.expectedSource : readFileSync(oldPath, 'utf8');
|
|
269
300
|
const { frontmatter, body: oldBody } = extractFrontmatter(oldRaw);
|
|
270
301
|
const fm = parseSimpleFrontmatter(frontmatter);
|
|
271
302
|
if (asString(fm.type) !== 'prompt' || asString(fm.status) !== 'pending') {
|
|
@@ -280,7 +311,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
280
311
|
parentPrompt ??= priorParent;
|
|
281
312
|
replacement = {
|
|
282
313
|
oldPath, oldRepoPath, oldRaw, priorParent,
|
|
283
|
-
archivedPath: archivedCopyPath(oldPath, config),
|
|
314
|
+
archivedPath: archivedCopyPath(oldPath, config, projection),
|
|
284
315
|
alreadyCurrent: oldBody.trim() === draft.body.trim()
|
|
285
316
|
&& Object.entries(draftFm).every(([key, value]) => key === 'parent_prompt' || JSON.stringify(fm[key]) === JSON.stringify(value))
|
|
286
317
|
&& (priorParent ?? null) === parentPrompt
|
|
@@ -289,6 +320,57 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
289
320
|
};
|
|
290
321
|
};
|
|
291
322
|
|
|
323
|
+
// Zero setup: a prompt saved in a repository with no config creates the
|
|
324
|
+
// handoffs-only config first (src/handoffs-mode.mjs). Prompts an older
|
|
325
|
+
// version saved in the root prompts/ move to docs/prompts/ first. Every check
|
|
326
|
+
// that can refuse the save runs against docs/prompts/ as that carry leaves
|
|
327
|
+
// it, before anything moves or is written, and dry-run stops at the same
|
|
328
|
+
// point. Plan mode is left alone: a plan saved before setup belongs to
|
|
329
|
+
// `runlist setup`.
|
|
330
|
+
let handoffsCreated = null;
|
|
331
|
+
let handoffsLine = null;
|
|
332
|
+
if (!planPath && !config.configFound) {
|
|
333
|
+
const quiet = { write() {} };
|
|
334
|
+
const setup = await planHandoffsConfig(process.cwd(), config, { dryRun, out: json ? quiet : process.stderr });
|
|
335
|
+
if (setup.previewStopped) {
|
|
336
|
+
const result = { operation: 'baton', parentPrompt, dryRun: true, disposition: 'would-change', wouldChange: true, mode: 'slug', status: null,
|
|
337
|
+
repositoryFiles: [], sessionFiles: [], generatedFiles: [], deferredGeneratedFiles: [], prompt: null, plan: null, planMovement: null,
|
|
338
|
+
replacement: null, claimRelease: null, handoffDone: true, savedPrompt: null, handoffsSetup: null, pendingRecovery: true };
|
|
339
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
config = setup.config;
|
|
343
|
+
resolvePending(setup);
|
|
344
|
+
await carryLegacyPrompts(setup, { dryRun, out: json ? quiet : process.stderr });
|
|
345
|
+
handoffsCreated = setup.created;
|
|
346
|
+
if (dryRun) {
|
|
347
|
+
handoffsLine = setup.dryRunLine;
|
|
348
|
+
if (handoffsLine && !json) process.stderr.write(`${handoffsLine}\n`);
|
|
349
|
+
const prompt = replacement?.oldRepoPath ?? path.posix.join(setup.promptRoot, `${slugBase}.md`);
|
|
350
|
+
const archivedPrompt = replacement && !replacement.alreadyCurrent ? toRepoPath(replacement.archivedPath, config.repoRoot) : null;
|
|
351
|
+
const result = { operation: 'baton', parentPrompt, dryRun: true, disposition: replacement?.alreadyCurrent ? 'already-current' : 'would-change', wouldChange: true, mode: 'slug', status: null,
|
|
352
|
+
repositoryFiles: [], sessionFiles: [prompt, ...(archivedPrompt ? [archivedPrompt] : [])], generatedFiles: [], deferredGeneratedFiles: [], prompt, plan: null, planMovement: null,
|
|
353
|
+
replacement: replacement ? { previousPrompt: replacement.oldRepoPath, archivedPrompt, pendingPrompt: prompt, alreadyCurrent: replacement.alreadyCurrent } : null,
|
|
354
|
+
claimRelease: null, handoffDone: true, savedPrompt: path.basename(prompt, '.md'), handoffsSetup: handoffsCreated };
|
|
355
|
+
if (json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
356
|
+
else {
|
|
357
|
+
if (archivedPrompt) process.stderr.write(`${dim('[dry-run]')} Would preserve old handoff: ${replacement.oldRepoPath} → ${archivedPrompt}\n`);
|
|
358
|
+
process.stderr.write(`${dim('[dry-run]')} Would ${replacement ? (replacement.alreadyCurrent ? 'keep' : 'replace') : 'create'}: ${prompt}\n`);
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
if (!setup.legacy) {
|
|
363
|
+
const published = await publishHandoffsConfig(setup, { testHooks: opts.testHooks });
|
|
364
|
+
config = published.config;
|
|
365
|
+
assertPublishedPromptFolder(setup, published, promptDirectory(config));
|
|
366
|
+
handoffsCreated = published.created;
|
|
367
|
+
handoffsLine = published.line;
|
|
368
|
+
if (handoffsLine && !json) process.stderr.write(`${handoffsLine}\n`);
|
|
369
|
+
}
|
|
370
|
+
// The carry is made: check again against the files themselves below.
|
|
371
|
+
replacement = null;
|
|
372
|
+
}
|
|
373
|
+
|
|
292
374
|
let repoPath = null;
|
|
293
375
|
let oldStatus = null;
|
|
294
376
|
let ownershipPath = null;
|
|
@@ -351,6 +433,14 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
351
433
|
else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
|
|
352
434
|
} else {
|
|
353
435
|
resolvePending();
|
|
436
|
+
// The skill's rule is "own an in-session plan → no slug"; a session that
|
|
437
|
+
// picked a slug anyway is told its plan stays claimed. Child handoffs under
|
|
438
|
+
// a parent's plan use a slug on purpose.
|
|
439
|
+
if (!subagent && config.configFound && !isHandoffsMode(config)) {
|
|
440
|
+
let owned = [];
|
|
441
|
+
try { owned = listOwnedPlans(config, authoritativeSessionId()); } catch { /* unreadable claims: say nothing */ }
|
|
442
|
+
if (owned.length) warn(`This session owns ${owned.map(record => record.plan).join(', ')}, which stays in-session: this saves ${slugBase} without handing ${owned.length === 1 ? 'it' : 'them'} off. To hand ${owned.length === 1 ? 'the plan' : 'a plan'} off with the prompt, run \`runlist baton ${owned.length === 1 ? '' : '<plan-file> '}@<draft-file>\` with no slug.`);
|
|
443
|
+
}
|
|
354
444
|
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
355
445
|
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
356
446
|
}
|
|
@@ -397,7 +487,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
397
487
|
newResult = { sessionFiles: [replacement.oldRepoPath, ...(replacement.alreadyCurrent ? [] : [toRepoPath(replacement.archivedPath, config.repoRoot)])] };
|
|
398
488
|
} else {
|
|
399
489
|
try {
|
|
400
|
-
newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true });
|
|
490
|
+
newResult = await runNew(['prompt', slugBase, '--body', body], config, { parentPrompt, dryRun, deferIndex: true, handoffsPlanned: true });
|
|
401
491
|
} catch (err) {
|
|
402
492
|
// Lost a race with another baton between the pending check and the write.
|
|
403
493
|
if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
|
|
@@ -493,6 +583,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
493
583
|
claimRelease: planPath ? { wouldRelease: wouldReleaseClaim, released: !dryRun && wouldReleaseClaim } : null,
|
|
494
584
|
handoffDone: !pendingHandoff,
|
|
495
585
|
savedPrompt: createdSlug,
|
|
586
|
+
handoffsSetup: handoffsCreated,
|
|
496
587
|
};
|
|
497
588
|
|
|
498
589
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
@@ -501,26 +592,29 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
501
592
|
return operationResult;
|
|
502
593
|
}
|
|
503
594
|
const verdict = replacement?.alreadyCurrent ? '✓ Handoff kept' : pendingHandoff ? '✓ Prompt saved' : replacement ? '✓ Baton replaced' : '✓ Baton passed';
|
|
595
|
+
if (!handoffsLine && !dryRun && (planPath || replacement)) process.stderr.write(unconfiguredPromptHint(config));
|
|
504
596
|
process.stderr.write(`\n${prefix}${green(verdict)}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
505
597
|
if (archivedPromptRepoPath) process.stderr.write(dim(`${prefix}Previous handoff preserved: ${archivedPromptRepoPath}\n`));
|
|
506
598
|
if (planGuard) {
|
|
507
599
|
process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
|
|
508
600
|
process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
|
|
509
601
|
}
|
|
510
|
-
if (statusChanged) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if (
|
|
517
|
-
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
518
|
-
} else {
|
|
602
|
+
if (statusChanged && isGitIgnored(planPath, config.repoRoot)) {
|
|
603
|
+
// Check the source even after a move: an ignored plan must not produce a
|
|
604
|
+
// commit hint for its destination or rewritten references, including hooks.
|
|
605
|
+
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
606
|
+
} else if (statusChanged) {
|
|
607
|
+
const command = commitCommand(config, `baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}`, operationResult.repositoryFiles);
|
|
608
|
+
if (command) {
|
|
519
609
|
process.stderr.write(`${prefix}Commit the repository files (session files stay OUT of the pathspec):\n`);
|
|
520
|
-
process.stderr.write(`${prefix} ${
|
|
610
|
+
process.stderr.write(`${prefix} ${command}\n`);
|
|
611
|
+
} else {
|
|
612
|
+
process.stderr.write(dim('No repository files to commit — no commit needed.\n'));
|
|
521
613
|
}
|
|
522
614
|
}
|
|
523
|
-
|
|
615
|
+
// Baton leaves the shared index alone so a handoff never conflicts with other work; nothing
|
|
616
|
+
// here needs the user, so it stays out of the normal output (--json always carries it).
|
|
617
|
+
if (opts.verbose && operationResult.deferredGeneratedFiles.length) process.stderr.write(dim(`Index not refreshed by baton: ${operationResult.deferredGeneratedFiles.join(', ')} (the next runlist command that rebuilds it picks up the change)\n`));
|
|
524
618
|
if (pendingHandoff) process.stderr.write(yellow(handoffNotDoneLine(pendingHandoff, explicitPlanDone(planArg, pendingHandoff))) + '\n');
|
|
525
619
|
// The name again, last, so a piped `| tail` still shows what was saved.
|
|
526
620
|
process.stderr.write(`${prefix}Saved prompt: ${createdSlug}\n`);
|