runlist 0.99.2 → 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 +414 -46
- package/bin/runlist.mjs +402 -51
- 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/new.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { regenIndex } from './lifecycle.mjs';
|
|
|
12
12
|
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
13
13
|
import { authorizeManagedDestination } from './managed-path.mjs';
|
|
14
14
|
import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
15
|
+
import { assertPublishedPromptFolder, carriedOnto, carryLegacyPrompts, isHandoffsMode, planHandoffsConfig, planningRefusal, publishHandoffsConfig } from './handoffs-mode.mjs';
|
|
16
|
+
import { PROMPT_DIR, rootIsWorkingFolder } from './naming.mjs';
|
|
15
17
|
|
|
16
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -410,6 +412,12 @@ export function titleize(s) {
|
|
|
410
412
|
|
|
411
413
|
// Where saved prompts are written: the prompt template's targetRoot when a
|
|
412
414
|
// root matches it, else the primary root plus the template's `dir`.
|
|
415
|
+
// The prompt template's routing alone, for callers that must not read the
|
|
416
|
+
// Markdown template store (hooks, settings inspection).
|
|
417
|
+
export function promptRouting(config) {
|
|
418
|
+
return { dir: rootIsWorkingFolder(config) ? PROMPT_DIR : 'prompts', targetRoot: 'prompts', ...config.raw?.templates?.prompt };
|
|
419
|
+
}
|
|
420
|
+
|
|
413
421
|
export function promptDirectory(config, template = resolveTemplate('prompt', config)) {
|
|
414
422
|
let targetRoot = config.docsRoot;
|
|
415
423
|
let routed = false;
|
|
@@ -420,6 +428,24 @@ export function promptDirectory(config, template = resolveTemplate('prompt', con
|
|
|
420
428
|
return template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
|
|
421
429
|
}
|
|
422
430
|
|
|
431
|
+
// The same default/template routing used by `new plan`, including before setup.
|
|
432
|
+
export function planDirectory(config, template = resolveTemplate('plan', config)) {
|
|
433
|
+
const roots = config.docsRoots ?? [config.docsRoot];
|
|
434
|
+
const routed = template.targetRoot && roots.find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
|
|
435
|
+
return routed || path.join(catchAllRoot(config), template.dir || '');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Only reached where no handoffs config is created for the prompt: a plan
|
|
439
|
+
// handed off before setup, or a save outside Git in a folder an older runlist
|
|
440
|
+
// saved handoffs or plans in.
|
|
441
|
+
export function unconfiguredPromptHint(config) {
|
|
442
|
+
return config.configFound ? '' : 'No runlist config here yet — run `runlist setup`; it carries this prompt over.\n';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function userNameDirIn(name) {
|
|
446
|
+
return Boolean(name && (name.includes('/') || name.includes(path.sep)));
|
|
447
|
+
}
|
|
448
|
+
|
|
423
449
|
function bindPromptParent(content, parentPrompt) {
|
|
424
450
|
if (!parentPrompt) return content;
|
|
425
451
|
const { frontmatter, body } = extractFrontmatter(content);
|
|
@@ -668,10 +694,122 @@ next_step:
|
|
|
668
694
|
`;
|
|
669
695
|
}
|
|
670
696
|
|
|
697
|
+
// Where `runlist new` puts a document: the name's own folder (read relative
|
|
698
|
+
// to the repo, or to --root), else the template's root and folder. Shared by
|
|
699
|
+
// the check a first prompt runs before the carry and by the write itself.
|
|
700
|
+
function resolveNewTarget(name, typeName, template, config, rootName, { explainRoots = true } = {}) {
|
|
701
|
+
// If name contains path separators, split into directory prefix and basename
|
|
702
|
+
let nameDir = null;
|
|
703
|
+
let namePart = name;
|
|
704
|
+
if (name.includes('/') || name.includes(path.sep)) {
|
|
705
|
+
nameDir = path.dirname(name);
|
|
706
|
+
namePart = path.basename(name, '.md');
|
|
707
|
+
} else if (name.endsWith('.md')) {
|
|
708
|
+
namePart = name.slice(0, -3);
|
|
709
|
+
}
|
|
710
|
+
// A prefix the *user* typed and one a template declares resolve differently
|
|
711
|
+
// below, so the distinction has to survive the template's assignment to nameDir.
|
|
712
|
+
const userNameDir = nameDir;
|
|
713
|
+
|
|
714
|
+
// Slugify
|
|
715
|
+
const slug = namePart.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
716
|
+
if (!slug) { die('Name resolves to empty slug: ' + name); }
|
|
717
|
+
|
|
718
|
+
// Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
|
|
719
|
+
// When the chosen root is a first-class type-container (matched by --root or targetRoot),
|
|
720
|
+
// we skip the `template.dir` join — the root already points at the right directory.
|
|
721
|
+
let targetRoot = catchAllRoot(config);
|
|
722
|
+
let routedToTypeRoot = false;
|
|
723
|
+
if (rootName) {
|
|
724
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
725
|
+
const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
|
|
726
|
+
if (!match) {
|
|
727
|
+
const available = roots.map(r => path.basename(r)).join(', ');
|
|
728
|
+
die(`Unknown root: ${rootName}\nAvailable: ${available}`);
|
|
729
|
+
}
|
|
730
|
+
targetRoot = match;
|
|
731
|
+
routedToTypeRoot = true;
|
|
732
|
+
} else if (typeof template === 'object' && template.targetRoot) {
|
|
733
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
734
|
+
const match = roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot);
|
|
735
|
+
if (match) {
|
|
736
|
+
targetRoot = match;
|
|
737
|
+
routedToTypeRoot = true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Template-declared subdirectory (e.g., prompt → 'prompts') — only relevant when we
|
|
742
|
+
// didn't already land in a type-specific root via --root or targetRoot.
|
|
743
|
+
if (typeof template === 'object' && template.dir && !nameDir && !routedToTypeRoot) {
|
|
744
|
+
nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Path — a directory prefix is read relative to the repo, because `runlist new
|
|
748
|
+
// plan docs/plans/feature` is a full repo path and has to stay one. That was
|
|
749
|
+
// the ONLY reading, which is how `--root` came to be silently ignored the
|
|
750
|
+
// moment a name contained a slash: the block above picked a root and this line
|
|
751
|
+
// threw it away, so the one flag the out-of-root error advertises could not
|
|
752
|
+
// fix the error. With an explicit --root the prefix is now read relative to
|
|
753
|
+
// that root — unless the repo-relative reading already lands inside it, so
|
|
754
|
+
// full paths keep working.
|
|
755
|
+
const allRoots = config.docsRoots ?? [config.docsRoot];
|
|
756
|
+
let baseDir;
|
|
757
|
+
if (!nameDir) baseDir = targetRoot;
|
|
758
|
+
else if (userNameDir && rootName) {
|
|
759
|
+
const repoRelative = path.resolve(config.repoRoot, nameDir);
|
|
760
|
+
baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
|
|
761
|
+
} else baseDir = path.resolve(config.repoRoot, nameDir);
|
|
762
|
+
const filePath = path.join(baseDir, slug + '.md');
|
|
763
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
764
|
+
|
|
765
|
+
// Without --root there is nothing to disambiguate with, so a prefix pointing
|
|
766
|
+
// outside every root stays an error — but it names the flag that resolves it,
|
|
767
|
+
// and the flag now works. The generic containment error underneath reports
|
|
768
|
+
// absolute paths and no remedy, which is what agents kept re-guessing at.
|
|
769
|
+
//
|
|
770
|
+
// Gated on the remedy actually working: the suggestion is only offered when
|
|
771
|
+
// `--root` would land the file inside that root. That is what keeps this off
|
|
772
|
+
// a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
|
|
773
|
+
// and the containment check below is the error that should speak — printing
|
|
774
|
+
// an untested remedy is the very defect this finding is about.
|
|
775
|
+
if (explainRoots && userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
|
|
776
|
+
const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
|
|
777
|
+
if (lexicallyInside(targetRoot, rooted)) {
|
|
778
|
+
die(`Destination is outside every configured root:\n`
|
|
779
|
+
+ ` ${repoPath}\n\n`
|
|
780
|
+
+ `A name with a \`/\` is read relative to the repo.\n`
|
|
781
|
+
+ `To place it under a root instead:\n`
|
|
782
|
+
+ ` runlist new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
|
|
783
|
+
+ ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
|
|
784
|
+
+ `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return { nameDir, namePart, userNameDir, slug, targetRoot, baseDir, filePath, repoPath };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Prompts live in the prompt folder and nowhere else where planning is off or
|
|
791
|
+
// not set up yet (one prompt location, src/naming.mjs): a name with a folder in
|
|
792
|
+
// it lands inside that folder or is refused, so nothing writes a root prompts/.
|
|
793
|
+
function assertPromptFolder(target, name, config) {
|
|
794
|
+
if (config.configFound && !isHandoffsMode(config)) return;
|
|
795
|
+
const folder = promptDirectory(config);
|
|
796
|
+
if (lexicallyInside(folder, target.filePath)) return;
|
|
797
|
+
const where = toRepoPath(folder, config.repoRoot);
|
|
798
|
+
die(`Nothing saved: prompts are saved in ${where}/, and \`${name}\` is outside it. Use a plain name (runlist new prompt ${path.basename(name, '.md')} …) or a path inside ${where}/.`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function waitingForSetup(config) {
|
|
802
|
+
const { documentsWaitingForSetup } = await import('./setup-waiting.mjs');
|
|
803
|
+
return documentsWaitingForSetup(config);
|
|
804
|
+
}
|
|
805
|
+
|
|
671
806
|
export async function runNew(argv, config, opts = {}) {
|
|
672
807
|
const { dryRun } = opts;
|
|
673
808
|
|
|
674
|
-
if (argv.find(a => !a.startsWith('-')) === 'decision')
|
|
809
|
+
if (argv.find(a => !a.startsWith('-')) === 'decision') {
|
|
810
|
+
if (isHandoffsMode(config)) die(planningRefusal('`runlist new decision`', await waitingForSetup(config)));
|
|
811
|
+
return runNewDecisionArgs(argv, config, opts);
|
|
812
|
+
}
|
|
675
813
|
|
|
676
814
|
const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
|
|
677
815
|
// Also include any custom templates from config
|
|
@@ -759,6 +897,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
759
897
|
typeName = 'plan';
|
|
760
898
|
if (runlistArg === null && !roadmap) coordination = true;
|
|
761
899
|
}
|
|
900
|
+
// A handoffs-only project scans its prompt folder alone; anything else
|
|
901
|
+
// created there would land among the prompts and outside every listing.
|
|
902
|
+
if (isHandoffsMode(config) && typeName !== 'prompt') die(planningRefusal(`\`runlist new ${typeName}\``, await waitingForSetup(config)));
|
|
762
903
|
|
|
763
904
|
if (!name) {
|
|
764
905
|
if (isInteractive()) {
|
|
@@ -861,6 +1002,10 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
861
1002
|
if (split.frontmatter) {
|
|
862
1003
|
bodyFrontmatter = split.frontmatter;
|
|
863
1004
|
bodyInput = split.body;
|
|
1005
|
+
if (Object.hasOwn(bodyFrontmatter, 'status') && !effective.has(bodyFrontmatter.status)) {
|
|
1006
|
+
process.stdout.write(`status \`${bodyFrontmatter.status}\` is not a ${typeName} status; using \`${status}\`\n`);
|
|
1007
|
+
delete bodyFrontmatter.status;
|
|
1008
|
+
}
|
|
864
1009
|
}
|
|
865
1010
|
}
|
|
866
1011
|
|
|
@@ -903,95 +1048,46 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
903
1048
|
die(`\`${typeName}\` template does not accept body input, but body was passed via ${bodyInputSource}.${hint}\n${cause}`);
|
|
904
1049
|
}
|
|
905
1050
|
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1051
|
+
// Zero setup: the first prompt saved in a repository with no config creates
|
|
1052
|
+
// a handoffs-only config (src/handoffs-mode.mjs), and prompts an older
|
|
1053
|
+
// version saved in the root prompts/ move to docs/prompts/ first. Every check
|
|
1054
|
+
// that can refuse the save runs against docs/prompts/ as that carry leaves
|
|
1055
|
+
// it, before anything moves or is written, and dry-run stops at that point.
|
|
1056
|
+
let handoffsLine = null;
|
|
1057
|
+
if (typeName === 'prompt' && !config.configFound && !opts.handoffsPlanned) {
|
|
1058
|
+
const setup = await planHandoffsConfig(process.cwd(), config, { dryRun, out: process.stdout });
|
|
1059
|
+
if (setup.previewStopped) return;
|
|
1060
|
+
config = setup.config;
|
|
1061
|
+
const target = resolveNewTarget(name, typeName, resolveTemplate('prompt', config), config, rootName, { explainRoots: false });
|
|
1062
|
+
assertPromptFolder(target, name, config);
|
|
1063
|
+
const carried = carriedOnto(setup, target.filePath);
|
|
1064
|
+
if (carried) die(`File already exists: ${carried.from} (it moves to ${carried.to} before this prompt is saved)`);
|
|
1065
|
+
if (existsSync(target.filePath)) die(`File already exists: ${target.repoPath}`);
|
|
1066
|
+
if (dryRun) {
|
|
1067
|
+
if (setup.dryRunLine) process.stdout.write(`${dim(setup.dryRunLine)}\n`);
|
|
1068
|
+
await carryLegacyPrompts(setup, { dryRun, out: process.stdout });
|
|
1069
|
+
process.stdout.write(`${dim('[dry-run]')} Would create: ${target.repoPath}\n${dim('[dry-run]')} Type: prompt\n`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
await carryLegacyPrompts(setup, { out: process.stdout });
|
|
1073
|
+
if (!setup.legacy) {
|
|
1074
|
+
const published = await publishHandoffsConfig(setup, { testHooks: opts.testHooks });
|
|
1075
|
+
config = published.config;
|
|
1076
|
+
assertPublishedPromptFolder(setup, published, promptDirectory(config));
|
|
1077
|
+
handoffsLine = published.line;
|
|
1078
|
+
if (handoffsLine) process.stdout.write(`${handoffsLine}\n`);
|
|
1079
|
+
}
|
|
914
1080
|
}
|
|
915
|
-
// A prefix the *user* typed and one a template declares resolve differently
|
|
916
|
-
// below, so the distinction has to survive the template's assignment to nameDir.
|
|
917
|
-
const userNameDir = nameDir;
|
|
918
1081
|
|
|
919
|
-
|
|
920
|
-
const slug =
|
|
921
|
-
if (
|
|
1082
|
+
const promptFolderOnly = typeName === 'prompt' && (!config.configFound || isHandoffsMode(config));
|
|
1083
|
+
const { namePart, slug, baseDir, filePath, repoPath } = resolveNewTarget(name, typeName, typeName === 'prompt' ? resolveTemplate('prompt', config) : template, config, rootName, { explainRoots: !promptFolderOnly });
|
|
1084
|
+
if (promptFolderOnly) assertPromptFolder({ filePath }, name, config);
|
|
922
1085
|
|
|
923
1086
|
// Title
|
|
924
1087
|
const docTitle = title ?? namePart.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
925
1088
|
|
|
926
|
-
// Resolve target root. Precedence: CLI --root > template.targetRoot > config.docsRoot.
|
|
927
|
-
// When the chosen root is a first-class type-container (matched by --root or targetRoot),
|
|
928
|
-
// we skip the `template.dir` join — the root already points at the right directory.
|
|
929
|
-
let targetRoot = catchAllRoot(config);
|
|
930
|
-
let routedToTypeRoot = false;
|
|
931
|
-
if (rootName) {
|
|
932
|
-
const roots = config.docsRoots || [config.docsRoot];
|
|
933
|
-
const match = roots.find(r => r.endsWith(rootName) || path.basename(r) === rootName);
|
|
934
|
-
if (!match) {
|
|
935
|
-
const available = roots.map(r => path.basename(r)).join(', ');
|
|
936
|
-
die(`Unknown root: ${rootName}\nAvailable: ${available}`);
|
|
937
|
-
}
|
|
938
|
-
targetRoot = match;
|
|
939
|
-
routedToTypeRoot = true;
|
|
940
|
-
} else if (typeof template === 'object' && template.targetRoot) {
|
|
941
|
-
const roots = config.docsRoots || [config.docsRoot];
|
|
942
|
-
const match = roots.find(r => r.endsWith(template.targetRoot) || path.basename(r) === template.targetRoot);
|
|
943
|
-
if (match) {
|
|
944
|
-
targetRoot = match;
|
|
945
|
-
routedToTypeRoot = true;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// Template-declared subdirectory (e.g., prompt → 'prompts') — only relevant when we
|
|
950
|
-
// didn't already land in a type-specific root via --root or targetRoot.
|
|
951
|
-
if (typeof template === 'object' && template.dir && !nameDir && !routedToTypeRoot) {
|
|
952
|
-
nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// Path — a directory prefix is read relative to the repo, because `runlist new
|
|
956
|
-
// plan docs/plans/feature` is a full repo path and has to stay one. That was
|
|
957
|
-
// the ONLY reading, which is how `--root` came to be silently ignored the
|
|
958
|
-
// moment a name contained a slash: the block above picked a root and this line
|
|
959
|
-
// threw it away, so the one flag the out-of-root error advertises could not
|
|
960
|
-
// fix the error. With an explicit --root the prefix is now read relative to
|
|
961
|
-
// that root — unless the repo-relative reading already lands inside it, so
|
|
962
|
-
// full paths keep working.
|
|
963
1089
|
const allRoots = config.docsRoots ?? [config.docsRoot];
|
|
964
|
-
let baseDir;
|
|
965
|
-
if (!nameDir) baseDir = targetRoot;
|
|
966
|
-
else if (userNameDir && rootName) {
|
|
967
|
-
const repoRelative = path.resolve(config.repoRoot, nameDir);
|
|
968
|
-
baseDir = lexicallyInside(targetRoot, repoRelative) ? repoRelative : path.resolve(targetRoot, nameDir);
|
|
969
|
-
} else baseDir = path.resolve(config.repoRoot, nameDir);
|
|
970
|
-
const filePath = path.join(baseDir, slug + '.md');
|
|
971
|
-
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
972
1090
|
|
|
973
|
-
// Without --root there is nothing to disambiguate with, so a prefix pointing
|
|
974
|
-
// outside every root stays an error — but it names the flag that resolves it,
|
|
975
|
-
// and the flag now works. The generic containment error underneath reports
|
|
976
|
-
// absolute paths and no remedy, which is what agents kept re-guessing at.
|
|
977
|
-
//
|
|
978
|
-
// Gated on the remedy actually working: the suggestion is only offered when
|
|
979
|
-
// `--root` would land the file inside that root. That is what keeps this off
|
|
980
|
-
// a traversal (`../escaped`, an absolute path), where `--root` fixes nothing
|
|
981
|
-
// and the containment check below is the error that should speak — printing
|
|
982
|
-
// an untested remedy is the very defect this finding is about.
|
|
983
|
-
if (userNameDir && !rootName && !allRoots.some(root => lexicallyInside(root, filePath))) {
|
|
984
|
-
const rooted = path.resolve(targetRoot, userNameDir, slug + '.md');
|
|
985
|
-
if (lexicallyInside(targetRoot, rooted)) {
|
|
986
|
-
die(`Destination is outside every configured root:\n`
|
|
987
|
-
+ ` ${repoPath}\n\n`
|
|
988
|
-
+ `A name with a \`/\` is read relative to the repo.\n`
|
|
989
|
-
+ `To place it under a root instead:\n`
|
|
990
|
-
+ ` runlist new ${typeName} ${name} --root ${path.basename(targetRoot)}\n`
|
|
991
|
-
+ ` → ${toRepoPath(rooted, config.repoRoot)}\n\n`
|
|
992
|
-
+ `Roots: ${allRoots.map(root => path.basename(root)).join(', ')}`);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
1091
|
const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
|
|
996
1092
|
|
|
997
1093
|
if (existsSync(filePath)) {
|
|
@@ -1123,14 +1219,15 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
1123
1219
|
process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
|
|
1124
1220
|
}
|
|
1125
1221
|
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
// are session-local handoff artifacts — the next session consumes them via
|
|
1129
|
-
// `runlist use`, and the prompts dir is often gitignored (the commit then fails
|
|
1130
|
-
// confusingly). Tell the agent the next step explicitly, and flag a gitignored
|
|
1131
|
-
// target for any type so "why won't this commit" never happens silently.
|
|
1222
|
+
// Prompt consumption and Git tracking are separate choices. Describe the
|
|
1223
|
+
// resolved policy, then flag an actually ignored target for any type.
|
|
1132
1224
|
if (typeName === 'prompt') {
|
|
1133
|
-
process.stdout.write(
|
|
1225
|
+
if (!handoffsLine) process.stdout.write(unconfiguredPromptHint(config));
|
|
1226
|
+
const tracking = config.promptTracking ?? 'local';
|
|
1227
|
+
const policy = tracking === 'tracked' ? 'Prompt commits are allowed.'
|
|
1228
|
+
: tracking === 'untracked' ? 'Leave untracked to reduce commit churn; git add -A can still stage it.'
|
|
1229
|
+
: 'Local policy: keep pending prompts out of Git operations.';
|
|
1230
|
+
process.stdout.write(dim(`${policy} Inspect with \`runlist config get promptTracking\`. The next session runs \`runlist use ${repoPath}\` to consume it.\n`));
|
|
1134
1231
|
}
|
|
1135
1232
|
// Teach the field-length contract at the moment the fields get written —
|
|
1136
1233
|
// learning it from a cap warning later sends sessions into hand-trim /
|
|
@@ -1170,7 +1267,9 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
1170
1267
|
// Sessions otherwise read the config file to find out.
|
|
1171
1268
|
export function newHelpForRepo(config) {
|
|
1172
1269
|
// Only types with a template: a status-only type can't be created by `new`.
|
|
1173
|
-
|
|
1270
|
+
// Where planning is off, a prompt is the one thing it creates.
|
|
1271
|
+
const promptsOnly = isHandoffsMode(config);
|
|
1272
|
+
const types = promptsOnly ? new Set(['prompt']) : new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(config.raw?.templates ?? {})]);
|
|
1174
1273
|
const rows = [];
|
|
1175
1274
|
const width = Math.max(...[...types].map(t => t.length));
|
|
1176
1275
|
for (const typeName of types) {
|
|
@@ -1194,6 +1293,7 @@ export function newHelpForRepo(config) {
|
|
|
1194
1293
|
if (statuses.length) rows.push(` ${''.padEnd(width)} statuses: ${statuses.join(', ')}`);
|
|
1195
1294
|
}
|
|
1196
1295
|
const roots = (config.docsRoots ?? [config.docsRoot]).map(r => path.basename(r));
|
|
1296
|
+
if (promptsOnly) return `This repo:\n${rows.join('\n')}\n Plans and docs come with planning; \`runlist setup\` turns it on.`;
|
|
1197
1297
|
rows.push(` ${'hub'.padEnd(width)} → a plan with a hub body (coordination unless --runlist or --roadmap)`);
|
|
1198
1298
|
rows.push(` ${'decision'.padEnd(width)} → an entry in an existing plan's decisions section`);
|
|
1199
1299
|
return `This repo:
|
|
@@ -1238,7 +1338,10 @@ function resolveBaseTemplate(name, config) {
|
|
|
1238
1338
|
return merged;
|
|
1239
1339
|
}
|
|
1240
1340
|
|
|
1241
|
-
|
|
1341
|
+
// Before any config the docs root is the working folder, so the built-in
|
|
1342
|
+
// prompt folder is spelled out in full: prompts go to docs/prompts/ there
|
|
1343
|
+
// too, never to a root prompts/ (src/naming.mjs).
|
|
1344
|
+
if (builtin) return name === 'prompt' && rootIsWorkingFolder(config) ? { ...builtin, dir: PROMPT_DIR } : builtin;
|
|
1242
1345
|
|
|
1243
1346
|
const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
|
|
1244
1347
|
die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
|
|
@@ -1251,8 +1354,12 @@ function listTemplates(config) {
|
|
|
1251
1354
|
all[k] = v;
|
|
1252
1355
|
}
|
|
1253
1356
|
|
|
1357
|
+
// Where planning is off, a prompt is the one thing `new` creates, so it is
|
|
1358
|
+
// the one type listed.
|
|
1359
|
+
const promptsOnly = isHandoffsMode(config);
|
|
1254
1360
|
process.stdout.write(bold('Available types') + '\n\n');
|
|
1255
1361
|
for (const [name, tmpl] of Object.entries(all)) {
|
|
1362
|
+
if (promptsOnly && name !== 'prompt') continue;
|
|
1256
1363
|
const desc = typeof tmpl === 'function'
|
|
1257
1364
|
? '(custom function)'
|
|
1258
1365
|
: (tmpl.description ?? '');
|
|
@@ -1261,6 +1368,10 @@ function listTemplates(config) {
|
|
|
1261
1368
|
if (desc) process.stdout.write(` ${dim(desc)}\n`);
|
|
1262
1369
|
process.stdout.write('\n');
|
|
1263
1370
|
}
|
|
1371
|
+
if (promptsOnly) {
|
|
1372
|
+
process.stdout.write(`${dim('Plans and docs come with planning; `runlist setup` turns it on.')}\n`);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1264
1375
|
process.stdout.write(` hub\n ${dim('A plan with a hub body: coordination by default, --runlist or --roadmap for the others.')}\n\n`);
|
|
1265
1376
|
process.stdout.write(` decision\n ${dim('An entry in an existing plan\'s decisions section: runlist new decision <plan> --question "…" @record.md')}\n\n`);
|
|
1266
1377
|
}
|
package/src/pickup-card.mjs
CHANGED
|
@@ -282,7 +282,7 @@ export function renderCard(card) {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
lines.push('');
|
|
285
|
-
lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`runlist
|
|
285
|
+
lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`runlist show ${card.path} --section "<heading>"\` reads one section; \`runlist set in-session ${card.path} --full\` prints everything.`));
|
|
286
286
|
return lines.join('\n') + '\n';
|
|
287
287
|
}
|
|
288
288
|
|
package/src/plan-changes.mjs
CHANGED
|
@@ -11,6 +11,55 @@ import { isGitIgnored } from './git.mjs';
|
|
|
11
11
|
|
|
12
12
|
const SHOWN_COMMITS = 10;
|
|
13
13
|
|
|
14
|
+
// List-time hints share one bounded Git window. Consume-time detail below
|
|
15
|
+
// keeps its existing next_step comparison and commit messages.
|
|
16
|
+
export function summarizePlanChanges(requests, config, { runGit = spawnSync, stat = statSync } = {}) {
|
|
17
|
+
const maxTargets = 64, maxCommits = 256;
|
|
18
|
+
const targets = new Map();
|
|
19
|
+
for (const { plan } of requests) {
|
|
20
|
+
if (!plan || targets.has(plan) || targets.size >= maxTargets || path.isAbsolute(plan)
|
|
21
|
+
|| plan.split(/[\\/]/).includes('..') || /[\0\r\n]/.test(plan)) continue;
|
|
22
|
+
try { targets.set(plan, stat(path.resolve(config.repoRoot, plan))); }
|
|
23
|
+
catch { targets.set(plan, null); }
|
|
24
|
+
}
|
|
25
|
+
const paths = [...targets].filter(([, stamp]) => stamp?.isFile()).map(([file]) => file);
|
|
26
|
+
const query = args => {
|
|
27
|
+
if (!paths.length) return null;
|
|
28
|
+
try {
|
|
29
|
+
const result = runGit('git', args, { cwd: config.repoRoot, encoding: 'utf8', timeout: 750, maxBuffer: 256 * 1024 });
|
|
30
|
+
return !result.error && result.status === 0 ? result.stdout : null;
|
|
31
|
+
} catch { return null; }
|
|
32
|
+
};
|
|
33
|
+
const pathspecs = paths.map(file => `:(top,literal)${file}`);
|
|
34
|
+
const trackedOutput = query(['ls-files', '-z', '--', ...pathspecs]);
|
|
35
|
+
const tracked = new Set((trackedOutput ?? '').split('\0'));
|
|
36
|
+
const trackedPaths = paths.filter(file => tracked.has(file));
|
|
37
|
+
const validTimes = requests.map(({ since }) => since ? new Date(since).getTime() : NaN).filter(Number.isFinite);
|
|
38
|
+
const earliest = validTimes.length ? new Date(Math.min(...validTimes)).toISOString() : null;
|
|
39
|
+
const log = trackedPaths.length && earliest
|
|
40
|
+
? query(['log', `--max-count=${maxCommits + 1}`, `--since=${earliest}`, '--format=%x00runlist:change:%ct%x00', '--name-only', '-z', '--', ...trackedPaths.map(file => `:(top,literal)${file}`)]) : null;
|
|
41
|
+
const dirtyOutput = trackedPaths.length ? query(['diff', '--name-only', '-z', 'HEAD', '--', ...trackedPaths.map(file => `:(top,literal)${file}`)]) : null;
|
|
42
|
+
const dirty = new Set((dirtyOutput ?? '').split('\0'));
|
|
43
|
+
const latest = new Map();
|
|
44
|
+
let date = null, commits = 0;
|
|
45
|
+
for (const field of (log ?? '').split('\0')) {
|
|
46
|
+
const marker = /^runlist:change:(\d+)$/.exec(field);
|
|
47
|
+
if (marker) { date = Number(marker[1]) * 1000; commits++; continue; }
|
|
48
|
+
const file = field.replace(/^\n/, '');
|
|
49
|
+
if (date !== null && tracked.has(file)) latest.set(file, Math.max(latest.get(file) ?? 0, date));
|
|
50
|
+
}
|
|
51
|
+
return requests.map(({ plan, since }) => {
|
|
52
|
+
const when = since ? new Date(since).getTime() : NaN;
|
|
53
|
+
const stamp = targets.get(plan);
|
|
54
|
+
if (!Number.isFinite(when) || !stamp?.isFile() || trackedOutput === null) return 'unknown';
|
|
55
|
+
if (!tracked.has(plan)) return stamp.mtimeMs > when ? 'edited since save (file clock)' : 'no edits since save (file clock)';
|
|
56
|
+
if ((latest.get(plan) ?? 0) > when) return 'changed since save';
|
|
57
|
+
if (dirty.has(plan)) return 'working changes (time unknown)';
|
|
58
|
+
if (log === null || dirtyOutput === null || commits > maxCommits) return 'unknown';
|
|
59
|
+
return 'no commits since save';
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
14
63
|
function git(args, repoRoot) {
|
|
15
64
|
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
16
65
|
if (result.error || result.status !== 0) return null;
|
package/src/prompt-handoff.mjs
CHANGED
|
@@ -59,6 +59,22 @@ parent_prompt: {{parent_prompt}}
|
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
// The built-in outlines without plan wording, for a project where planning is
|
|
63
|
+
// off (src/handoffs-mode.mjs). A saved override is the user's text and is used
|
|
64
|
+
// as written.
|
|
65
|
+
const HANDOFFS_ONLY_LINES = [
|
|
66
|
+
['<!-- Work to resume and owning plan path + section, or a durable brief if there is no plan. -->', '<!-- Work to resume, with the paths it concerns or a durable brief. -->'],
|
|
67
|
+
['<!-- Review linked child handoffs and record completed results in the owning plan. -->', '<!-- Review linked child handoffs and carry their completed results forward. -->'],
|
|
68
|
+
['<!-- One sentence: the bounded job and its owning plan or brief. -->', '<!-- One sentence: the bounded job and its brief. -->'],
|
|
69
|
+
["<!-- Owned files, shared claims, current authorization, and any required new go. -->", '<!-- Owned files, current authorization, and any required new go. -->'],
|
|
70
|
+
["<!-- Do not release or claim the parent's plan. This handoff grants no new authority. -->", '<!-- This handoff grants no new authority. -->'],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function builtinHandoffTemplate(key, { planning = true } = {}) {
|
|
74
|
+
const { source } = HANDOFF_TEMPLATES[key];
|
|
75
|
+
return planning ? source : HANDOFFS_ONLY_LINES.reduce((text, [from, to]) => text.replace(from, () => to), source);
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
// Explicit links win, including missing/ambiguous parents. Legacy child names
|
|
63
79
|
// may share a parent stem; the longest whole-stem match is the most specific.
|
|
64
80
|
|