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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// A conservative lexical editor for one named, immutable string export. This
|
|
2
|
+
// does not evaluate JavaScript or print it back out: only the literal's byte
|
|
3
|
+
// span changes. Unlike the status editor, groups may contain regex literals.
|
|
4
|
+
// Ambiguous lexical contexts are refused rather than guessed.
|
|
5
|
+
const VALUES = ['untracked', 'tracked', 'local'];
|
|
6
|
+
|
|
7
|
+
function manual(reason) {
|
|
8
|
+
throw new Error(`${reason} Edit promptTracking manually in your config using one direct declaration, for example: export const promptTracking = 'untracked'; Allowed values: 'untracked', 'tracked', 'local'. Then rerun runlist config show. No config changes were made.`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REGEX_PREFIX = new Set([
|
|
12
|
+
'return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof',
|
|
13
|
+
'instanceof', 'in', 'of', 'new', 'else', 'do', 'default',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function stringEnd(text, start) {
|
|
17
|
+
const quote = text[start];
|
|
18
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
19
|
+
if (text[i] === '\\') { i++; continue; }
|
|
20
|
+
if (text[i] === quote) return i + 1;
|
|
21
|
+
if (quote === '`' && text[i] === '$' && text[i + 1] === '{') {
|
|
22
|
+
i = groupEnd(text, i + 1) - 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
manual('Unterminated string.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function regexEnd(text, start) {
|
|
29
|
+
let characterClass = false;
|
|
30
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
31
|
+
if (text[i] === '\\') { i++; continue; }
|
|
32
|
+
if (text[i] === '[') characterClass = true;
|
|
33
|
+
if (text[i] === ']') characterClass = false;
|
|
34
|
+
if (text[i] === '/' && !characterClass) {
|
|
35
|
+
i++;
|
|
36
|
+
while (/[A-Za-z]/.test(text[i] ?? '')) i++;
|
|
37
|
+
return i;
|
|
38
|
+
}
|
|
39
|
+
if (text[i] === '\n' || text[i] === '\r') break;
|
|
40
|
+
}
|
|
41
|
+
manual('Cannot safely locate a regular expression.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function triviaEnd(text, start) {
|
|
45
|
+
let i = start;
|
|
46
|
+
while (i < text.length) {
|
|
47
|
+
if (/\s/.test(text[i])) { i++; continue; }
|
|
48
|
+
if (text.startsWith('//', i) || (i === 0 && text.startsWith('#!', i))) {
|
|
49
|
+
const end = text.indexOf('\n', i);
|
|
50
|
+
i = end < 0 ? text.length : end + 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (text.startsWith('/*', i)) {
|
|
54
|
+
const end = text.indexOf('*/', i + 2);
|
|
55
|
+
if (end < 0) manual('Unterminated comment.');
|
|
56
|
+
i = end + 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tokenAt(text, start, previous) {
|
|
65
|
+
const c = text[start];
|
|
66
|
+
let end;
|
|
67
|
+
let kind = 'punctuation';
|
|
68
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
69
|
+
end = stringEnd(text, start);
|
|
70
|
+
kind = c === '`' ? 'template' : 'string';
|
|
71
|
+
} else if ('{[('.includes(c)) {
|
|
72
|
+
end = groupEnd(text, start);
|
|
73
|
+
kind = 'group';
|
|
74
|
+
} else if (c === '/') {
|
|
75
|
+
// A slash after a closing group needs statement/expression grammar to
|
|
76
|
+
// disambiguate (if (...) /re/ vs fn(...) / divisor). Refuse that rare case.
|
|
77
|
+
if (previous?.kind === 'group') manual('A slash after a bracket group is ambiguous to the setting editor.');
|
|
78
|
+
const expressionEnd = previous && (
|
|
79
|
+
['string', 'template', 'regex', 'number'].includes(previous.kind)
|
|
80
|
+
|| (previous.kind === 'identifier' && !REGEX_PREFIX.has(previous.raw))
|
|
81
|
+
|| ['++', '--', '.', '?.'].includes(previous.raw)
|
|
82
|
+
);
|
|
83
|
+
end = expressionEnd ? start + (text[start + 1] === '=' ? 2 : 1) : regexEnd(text, start);
|
|
84
|
+
if (!expressionEnd) kind = 'regex';
|
|
85
|
+
} else if (/[A-Za-z_$]/.test(c)) {
|
|
86
|
+
end = start + 1;
|
|
87
|
+
while (/[A-Za-z0-9_$]/.test(text[end] ?? '')) end++;
|
|
88
|
+
kind = 'identifier';
|
|
89
|
+
} else if (/[0-9]/.test(c)) {
|
|
90
|
+
end = start + 1;
|
|
91
|
+
while (/[A-Za-z0-9_.]/.test(text[end] ?? '')) end++;
|
|
92
|
+
kind = 'number';
|
|
93
|
+
} else {
|
|
94
|
+
if (c === '\\' || c.charCodeAt(0) > 127) manual('Escaped or Unicode identifiers need a manual setting edit.');
|
|
95
|
+
end = start + 1;
|
|
96
|
+
if (['++', '--', '=>', '?.'].includes(text.slice(start, start + 2))) end++;
|
|
97
|
+
}
|
|
98
|
+
return { start, end, raw: text.slice(start, end), kind };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function groupEnd(text, start) {
|
|
102
|
+
const close = { '{': '}', '[': ']', '(': ')' }[text[start]];
|
|
103
|
+
let i = start + 1;
|
|
104
|
+
let previous;
|
|
105
|
+
while ((i = triviaEnd(text, i)) < text.length) {
|
|
106
|
+
if (text[i] === close) return i + 1;
|
|
107
|
+
if ('}])'.includes(text[i])) manual('Unbalanced brackets.');
|
|
108
|
+
previous = tokenAt(text, i, previous);
|
|
109
|
+
i = previous.end;
|
|
110
|
+
}
|
|
111
|
+
manual('Unterminated bracket group.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renderPromptTrackingSetting(content, value) {
|
|
115
|
+
if (!VALUES.includes(value)) throw new Error("promptTracking must be 'untracked', 'tracked', or 'local'.");
|
|
116
|
+
const tokens = [];
|
|
117
|
+
let i = 0;
|
|
118
|
+
while ((i = triviaEnd(content, i)) < content.length) {
|
|
119
|
+
const token = tokenAt(content, i, tokens.at(-1));
|
|
120
|
+
tokens.push(token);
|
|
121
|
+
i = token.end;
|
|
122
|
+
}
|
|
123
|
+
let literal;
|
|
124
|
+
for (let n = 0; n < tokens.length; n++) {
|
|
125
|
+
const token = tokens[n];
|
|
126
|
+
if (token.raw === 'export') {
|
|
127
|
+
const next = tokens[n + 1];
|
|
128
|
+
if (next?.raw === '*' || next?.raw === 'default' || next?.raw.startsWith('{')) {
|
|
129
|
+
manual('Export lists, reexports, and default exports are outside the supported setting shape.');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (token.raw !== 'promptTracking') continue;
|
|
133
|
+
// Requiring every top-level occurrence to be this binding also refuses
|
|
134
|
+
// mutable assignments, aliases, and multi-declaration bindings.
|
|
135
|
+
if (tokens[n - 2]?.raw !== 'export' || tokens[n - 1]?.raw !== 'const' || tokens[n + 1]?.raw !== '=') {
|
|
136
|
+
manual('promptTracking is not a simple named const export.');
|
|
137
|
+
}
|
|
138
|
+
if (literal) manual('Multiple promptTracking declarations are ambiguous.');
|
|
139
|
+
const candidate = tokens[n + 2];
|
|
140
|
+
if (candidate?.kind !== 'string' || !VALUES.includes(candidate.raw.slice(1, -1))) {
|
|
141
|
+
manual('promptTracking uses a computed or unsupported value.');
|
|
142
|
+
}
|
|
143
|
+
const after = tokens[n + 3];
|
|
144
|
+
// Semicolonless declarations are safe only at EOF or before another
|
|
145
|
+
// explicit module declaration, separated by a newline.
|
|
146
|
+
if (after && after.raw !== ';' && !(
|
|
147
|
+
['export', 'import'].includes(after.raw) && /[\r\n]/.test(content.slice(candidate.end, after.start))
|
|
148
|
+
)) manual('promptTracking has a computed initializer or ambiguous declaration boundary.');
|
|
149
|
+
literal = candidate;
|
|
150
|
+
}
|
|
151
|
+
if (literal) {
|
|
152
|
+
const quote = literal.raw[0];
|
|
153
|
+
return {
|
|
154
|
+
content: content.slice(0, literal.start) + quote + value + quote + content.slice(literal.end),
|
|
155
|
+
previousValue: literal.raw.slice(1, -1),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
159
|
+
return {
|
|
160
|
+
content: content + (content && !content.endsWith('\n') ? newline : '') + `export const promptTracking = '${value}';${newline}`,
|
|
161
|
+
previousValue: 'local',
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -19,7 +19,14 @@ const REPLACE_KEYS = new Set([
|
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
21
|
const DEFAULTS = {
|
|
22
|
+
// 'plans' (the full plan/doc/prompt system) or 'handoffs': session handoffs
|
|
23
|
+
// only, written by the first `runlist baton` in a repository with no config.
|
|
24
|
+
// Handoffs mode pairs with a `root` that is the prompt folder itself, so the
|
|
25
|
+
// scan never reaches the project's own docs; this flag only changes wording
|
|
26
|
+
// and refuses plan verbs. `runlist setup` flips it to 'plans'.
|
|
27
|
+
mode: 'plans',
|
|
22
28
|
root: '.',
|
|
29
|
+
promptTracking: 'untracked',
|
|
23
30
|
archiveDir: 'archived',
|
|
24
31
|
excludeDirs: [],
|
|
25
32
|
externalBodyLinkRoots: [],
|
|
@@ -474,24 +481,46 @@ export function locateConfig(cwd, explicitConfigPath) {
|
|
|
474
481
|
return {configPath,configDir,repoRoot};
|
|
475
482
|
}
|
|
476
483
|
|
|
477
|
-
|
|
478
|
-
|
|
484
|
+
// `options.exports` stands in for a config file that does not exist yet (a
|
|
485
|
+
// dry-run of the handoffs config baton would create); `options.overrides`
|
|
486
|
+
// replaces exports of the file that does (setup previewing a planning upgrade).
|
|
487
|
+
// Both are internal: neither writes anything.
|
|
488
|
+
export async function resolveConfig(cwd, explicitConfigPath, options = {}) {
|
|
489
|
+
const located = locateConfig(cwd, explicitConfigPath);
|
|
490
|
+
const virtual = Boolean(options.exports && explicitConfigPath);
|
|
491
|
+
const {configPath} = located;
|
|
492
|
+
let {configDir, repoRoot} = located;
|
|
493
|
+
if (virtual) {
|
|
494
|
+
configDir = path.dirname(configPath);
|
|
495
|
+
repoRoot = locateConfig(configDir).repoRoot;
|
|
496
|
+
}
|
|
479
497
|
|
|
480
498
|
let userConfig = {};
|
|
481
499
|
let hooks = {};
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
500
|
+
const fileExists = Boolean(configPath && existsSync(configPath));
|
|
501
|
+
let promptTrackingSource = fileExists || virtual ? 'legacy-default' : 'default';
|
|
502
|
+
|
|
503
|
+
if (fileExists || virtual) {
|
|
504
|
+
let mod = options.exports;
|
|
505
|
+
if (!virtual) {
|
|
506
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
507
|
+
try {
|
|
508
|
+
mod = await import(configUrl);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
|
|
511
|
+
}
|
|
490
512
|
}
|
|
513
|
+
if (options.overrides) mod = { ...mod, ...options.overrides };
|
|
491
514
|
|
|
492
515
|
for (const [key, value] of Object.entries(mod)) {
|
|
493
516
|
if (key === 'default') continue;
|
|
494
|
-
if (
|
|
517
|
+
if (key === 'promptTracking') {
|
|
518
|
+
if (!['untracked', 'tracked', 'local'].includes(value)) {
|
|
519
|
+
throw new Error(`Config: promptTracking must be 'untracked', 'tracked', or 'local' (${configPath}).`);
|
|
520
|
+
}
|
|
521
|
+
userConfig[key] = value;
|
|
522
|
+
promptTrackingSource = 'config';
|
|
523
|
+
} else if (typeof value === 'function') {
|
|
495
524
|
hooks[key] = value;
|
|
496
525
|
} else {
|
|
497
526
|
userConfig[key] = value;
|
|
@@ -500,12 +529,14 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
500
529
|
}
|
|
501
530
|
|
|
502
531
|
// Backwards compat: `readme` config key maps to `index`
|
|
503
|
-
|
|
532
|
+
const legacyIndex = Boolean(userConfig.readme && !userConfig.index);
|
|
533
|
+
if (legacyIndex) {
|
|
504
534
|
userConfig.index = userConfig.readme;
|
|
505
535
|
delete userConfig.readme;
|
|
506
536
|
}
|
|
507
537
|
|
|
508
538
|
const config = deepMerge(DEFAULTS, userConfig);
|
|
539
|
+
if (promptTrackingSource === 'legacy-default') config.promptTracking = 'local';
|
|
509
540
|
// Replacement links remain repairable even with custom reference-field lists
|
|
510
541
|
// or after the repository turns off goal display.
|
|
511
542
|
config.referenceFields.unidirectional = [...new Set([
|
|
@@ -633,6 +664,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
633
664
|
}
|
|
634
665
|
}
|
|
635
666
|
|
|
667
|
+
let mode = 'plans';
|
|
668
|
+
if (config.mode === 'handoffs' || config.mode === 'plans') mode = config.mode;
|
|
669
|
+
else earlyWarnings.push(`Config: mode must be 'plans' or 'handoffs'; ignoring \`${config.mode}\`.`);
|
|
670
|
+
|
|
636
671
|
const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
|
|
637
672
|
|
|
638
673
|
return {
|
|
@@ -644,6 +679,16 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
644
679
|
configDir,
|
|
645
680
|
configPath: configPath ?? null,
|
|
646
681
|
configFound: Boolean(configPath),
|
|
682
|
+
mode,
|
|
683
|
+
planning: mode !== 'handoffs',
|
|
684
|
+
promptTracking: config.promptTracking,
|
|
685
|
+
promptTrackingSource,
|
|
686
|
+
settingSources: {
|
|
687
|
+
mode: Object.hasOwn(userConfig, 'mode') ? 'config' : 'default',
|
|
688
|
+
root: Object.hasOwn(userConfig, 'root') ? 'config' : 'default',
|
|
689
|
+
'index.path': Object.hasOwn(userConfig.index ?? {}, 'path') ? (legacyIndex ? 'legacy-readme' : 'config') : 'default',
|
|
690
|
+
archiveDir: Object.hasOwn(userConfig, 'archiveDir') ? 'config' : 'default',
|
|
691
|
+
},
|
|
647
692
|
archiveDir: config.archiveDir,
|
|
648
693
|
excludeDirs: new Set(config.excludeDirs),
|
|
649
694
|
externalBodyLinkRoots: Array.isArray(config.externalBodyLinkRoots) ? config.externalBodyLinkRoots : [],
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as inventory from './agent-hosts.mjs';
|
|
5
|
+
import { shellQuote } from './util.mjs';
|
|
6
|
+
import { sanitizeTelemetryText } from './journal.mjs';
|
|
7
|
+
import { installationReport } from './installation.mjs';
|
|
8
|
+
|
|
9
|
+
const HOSTS = ['claude', 'codex', 'opencode'];
|
|
10
|
+
const valueFlags = new Set(['--repair', '--claude-config', '--claude-bin']);
|
|
11
|
+
const flags = new Set(['--hosts', '--json', '--apply', '--yes']);
|
|
12
|
+
|
|
13
|
+
export function hostDoctorOptions(argv, { dryRun = false } = {}) {
|
|
14
|
+
const options = { repairs: [], claudeConfigDirs: [], claudeBin: undefined, json: false, apply: false, dryRun };
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const arg = argv[i];
|
|
17
|
+
if (flags.has(arg)) {
|
|
18
|
+
if (arg === '--json') options.json = true;
|
|
19
|
+
if (arg === '--apply' || arg === '--yes') options.apply = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!valueFlags.has(arg) || !argv[i + 1] || argv[i + 1].startsWith('--')) {
|
|
23
|
+
throw new Error(`Unsupported host diagnostic argument: ${arg}. Use doctor --hosts [--repair <host>] [--apply] [--json].`);
|
|
24
|
+
}
|
|
25
|
+
const value = argv[++i];
|
|
26
|
+
if (arg === '--repair') {
|
|
27
|
+
if (!HOSTS.includes(value)) throw new Error('--repair must name claude, codex, or opencode. Repeat it to select more hosts.');
|
|
28
|
+
options.repairs.push(value);
|
|
29
|
+
} else if (arg === '--claude-config') {
|
|
30
|
+
if (!path.isAbsolute(value)) throw new Error('--claude-config requires an absolute profile directory.');
|
|
31
|
+
options.claudeConfigDirs.push(path.resolve(value));
|
|
32
|
+
} else {
|
|
33
|
+
if (options.claudeBin) throw new Error('--claude-bin may be supplied once.');
|
|
34
|
+
options.claudeBin = path.resolve(value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
options.repairs = [...new Set(options.repairs)];
|
|
38
|
+
options.claudeConfigDirs = [...new Set(options.claudeConfigDirs)];
|
|
39
|
+
if (options.apply && !options.repairs.length) throw new Error('doctor --hosts --apply requires explicitly selected --repair <host> targets.');
|
|
40
|
+
return options;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function commandFor(action, options) {
|
|
44
|
+
const argv = ['runlist', 'doctor', '--hosts', '--repair', action.host, '--apply'];
|
|
45
|
+
if (action.configDir) argv.push('--claude-config', action.configDir);
|
|
46
|
+
if (options.claudeBin) argv.push('--claude-bin', options.claudeBin);
|
|
47
|
+
return argv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function planHostRepairs(report, options) {
|
|
51
|
+
const actions = [];
|
|
52
|
+
for (const host of options.repairs) {
|
|
53
|
+
const info = report.hosts[host], executable = report.executables[host]?.selected;
|
|
54
|
+
if (host === 'claude') {
|
|
55
|
+
const profiles = info.profiles.filter(profile => !options.claudeConfigDirs.length
|
|
56
|
+
|| options.claudeConfigDirs.some(dir => (profile.configDirs ?? [profile.configDir]).includes(dir)));
|
|
57
|
+
for (const profile of profiles) {
|
|
58
|
+
const configDir = options.claudeConfigDirs.find(dir => (profile.configDirs ?? [profile.configDir]).includes(dir)) ?? profile.configDir;
|
|
59
|
+
const blocked = !executable ? 'Claude executable unavailable; install/select the intended host binary first.'
|
|
60
|
+
: !profile.repair.targetable ? profile.repair.reason
|
|
61
|
+
: !profile.installed && profiles.length > 1 && !options.claudeConfigDirs.length
|
|
62
|
+
? 'Select --claude-config explicitly to install into an additional missing profile.' : null;
|
|
63
|
+
const action = { host, id: `claude:${profile.storeKey}`, configDir, storeKey: profile.storeKey,
|
|
64
|
+
profileDirs: profile.configDirs ?? [profile.configDir], executable: executable?.path, kind: profile.installed ? 'update' : 'install',
|
|
65
|
+
status: blocked ? 'blocked' : 'planned', reason: blocked,
|
|
66
|
+
description: `${profile.installed ? 'Refresh installed' : 'Install missing'} Claude integration in ${configDir}; shared store updated once` };
|
|
67
|
+
if (profile.state === 'current') {
|
|
68
|
+
action.kind = 'none'; action.status = 'current';
|
|
69
|
+
action.reason = null;
|
|
70
|
+
action.description = `Claude files already match the registered catalog in ${configDir}; saved enablement and live loading are separate`;
|
|
71
|
+
if (!executable) action.remedy = 'The Claude executable is unavailable; install/select the host separately to check activation.';
|
|
72
|
+
}
|
|
73
|
+
if (blocked && action.kind !== 'none') {
|
|
74
|
+
const native = [...new Map((report.executables.claude.candidates ?? []).filter(candidate => candidate.kind.startsWith('native'))
|
|
75
|
+
.map(candidate => [candidate.resolvedPath ?? candidate.path, candidate])).values()];
|
|
76
|
+
const safeSource = profile.state !== 'foreign' && profile.registryState !== 'unreadable' && profile.knownMarketplacesState !== 'unreadable'
|
|
77
|
+
&& !profile.settings?.some(settings => settings.configDir === configDir && settings.state === 'unreadable');
|
|
78
|
+
action.nextCommands = safeSource ? native.map(candidate => ['runlist', 'doctor', '--hosts', '--repair', 'claude', '--claude-config', configDir, '--claude-bin', candidate.path]) : [];
|
|
79
|
+
action.remedy = action.nextCommands.length ? 'Preview with the intended native binary and profile below; these commands do not apply changes.'
|
|
80
|
+
: 'Inspect the registry/marketplace/settings and intended profile with the host plugin manager; restore readable, managed records before retrying. No automatic replacement is offered.';
|
|
81
|
+
} else if (action.kind !== 'none') action.command = commandFor(action, options);
|
|
82
|
+
actions.push(action);
|
|
83
|
+
}
|
|
84
|
+
if (!profiles.length) actions.push({ host, id: 'claude:unresolved', status: 'blocked', reason: 'No selected Claude profile could be resolved.' });
|
|
85
|
+
} else {
|
|
86
|
+
const blocked = !executable ? `${host} executable unavailable; install the host separately first.`
|
|
87
|
+
: !info.repair.targetable ? info.repair.reason : null;
|
|
88
|
+
const action = { host, id: host, kind: info.state === 'missing' ? 'install' : 'update',
|
|
89
|
+
status: blocked ? 'blocked' : 'planned', reason: blocked,
|
|
90
|
+
description: `${info.state === 'missing' ? 'Install missing' : 'Refresh managed'} ${host} integration; preserve unmarked files` };
|
|
91
|
+
if (host === 'opencode' && info.state === 'current') {
|
|
92
|
+
action.kind = 'none'; action.status = 'current'; action.reason = null;
|
|
93
|
+
action.description = 'OpenCode generated file already matches this CLI candidate; no file repair is needed. Live loading remains unknown.';
|
|
94
|
+
}
|
|
95
|
+
if (host === 'codex' && info.state === 'current') action.description = 'Verify the installed Codex copy; the marketplace source already matches this CLI candidate';
|
|
96
|
+
if (action.status === 'blocked') action.remedy = 'Keep the unmarked/unreadable content. Inspect it and use the host plugin manager or move your own file aside before explicitly installing a managed integration; nothing is overwritten automatically.';
|
|
97
|
+
else if (action.kind !== 'none') action.command = commandFor(action, options);
|
|
98
|
+
actions.push(action);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return actions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function runRepair(action, { options, config, env }) {
|
|
105
|
+
// Reuse the existing verbs in a fresh process without importing project JS.
|
|
106
|
+
// User values travel on stdin as data, never interpolated into shell code.
|
|
107
|
+
const script = `import { readFileSync } from 'node:fs';
|
|
108
|
+
import { runInstall } from ${JSON.stringify(new URL('./install.mjs', import.meta.url).href)};
|
|
109
|
+
import { runUpdate } from ${JSON.stringify(new URL('./update.mjs', import.meta.url).href)};
|
|
110
|
+
const request = JSON.parse(readFileSync(0, 'utf8'));
|
|
111
|
+
if (request.kind === 'install') await runInstall([request.host], request.config, { claudeBin: request.claudeBin });
|
|
112
|
+
else await runUpdate(request.argv, request.config);`;
|
|
113
|
+
const argv = ['--host', action.host];
|
|
114
|
+
if (action.configDir) argv.push('--claude-config', action.configDir);
|
|
115
|
+
if (options.claudeBin) argv.push('--claude-bin', options.claudeBin);
|
|
116
|
+
return spawnSync(process.execPath, ['--input-type=module', '-e', script], {
|
|
117
|
+
cwd: config.repoRoot, input: JSON.stringify({ kind: action.kind, host: action.host, argv,
|
|
118
|
+
config: { repoRoot: config.repoRoot }, claudeBin: action.host === 'claude' ? options.claudeBin ?? action.executable : undefined }),
|
|
119
|
+
encoding: 'utf8', timeout: 180_000, maxBuffer: 4 * 1024 * 1024,
|
|
120
|
+
env: { ...env, ...(action.configDir ? { CLAUDE_CONFIG_DIR: action.configDir } : {}), RUNLIST_JOURNAL: '0', DOTMD_JOURNAL: '0' },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function targetState(report, action) {
|
|
125
|
+
if (action.host !== 'claude') return report.hosts[action.host].state;
|
|
126
|
+
return report.hosts.claude.profiles.find(profile => (profile.configDirs ?? [profile.configDir]).includes(action.configDir))?.state ?? 'unknown';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function render(report, out, json) {
|
|
130
|
+
if (json) { out.write(`${JSON.stringify(report, null, 2)}\n`); return; }
|
|
131
|
+
const { cli, node, updateEffect } = report.runtime;
|
|
132
|
+
out.write(`CLI launcher: ${cli.launcher?.path ?? 'unknown'} → ${cli.launcher?.resolvedPath ?? 'unknown'}\n`);
|
|
133
|
+
out.write(`Node: ${node.version} · ${node.path} · prefix ${node.prefix}\nnpm on PATH: ${node.npm ?? 'not found'}\n`);
|
|
134
|
+
out.write(`Update scope: ${updateEffect}\n\n`);
|
|
135
|
+
const format = inventory.formatAgentHostReport;
|
|
136
|
+
if (format) out.write(format(report));
|
|
137
|
+
else {
|
|
138
|
+
out.write(`runlist host health — CLI ${report.candidate.version ?? 'unknown'} (${report.candidate.kind})\n`);
|
|
139
|
+
for (const host of HOSTS) out.write(`${host}: ${report.hosts[host].state}; live loading unknown\n`);
|
|
140
|
+
}
|
|
141
|
+
if (!report.repairs.selectedHosts.length) {
|
|
142
|
+
out.write('\nRepairs are explicit: runlist doctor --hosts --repair <host> previews; add --apply to execute.\n');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
out.write(`\nRepairs: ${report.repairs.mode}\n`);
|
|
146
|
+
for (const action of report.repairs.actions) {
|
|
147
|
+
out.write(` ${action.status}: ${action.description ?? action.id}${action.reason ? ` — ${action.reason}` : ''}\n`);
|
|
148
|
+
if (action.command) out.write(` ${action.command.map(shellQuote).join(' ')}\n`);
|
|
149
|
+
if (action.remedy) out.write(` ${action.remedy}\n`);
|
|
150
|
+
for (const command of action.nextCommands ?? []) out.write(` ${command.map(shellQuote).join(' ')}\n`);
|
|
151
|
+
}
|
|
152
|
+
for (const item of report.repairs.outstanding) out.write(` Outstanding: ${item}\n`);
|
|
153
|
+
if (report.repairs.failed.length) out.write(`Failed repairs: ${report.repairs.failed.length}; completed: ${report.repairs.completed.length}. Other selected hosts were still attempted.\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function runDoctorHosts(argv, config = { repoRoot: process.cwd() }, opts = {}) {
|
|
157
|
+
const options = hostDoctorOptions(argv, opts), env = opts.env ?? process.env;
|
|
158
|
+
const reportOptions = { home: opts.home ?? os.homedir(), env, ...(opts.root ? { root: opts.root } : {}),
|
|
159
|
+
claudeConfigDirs: options.claudeConfigDirs, claudeBin: options.claudeBin };
|
|
160
|
+
const read = opts.inventory ?? inventory.agentHostReport;
|
|
161
|
+
const before = read(config, reportOptions);
|
|
162
|
+
const installation = installationReport(config, reportOptions);
|
|
163
|
+
const runtime = { cli: installation.cli, node: installation.node, project: installation.project, updateEffect: installation.updateEffect };
|
|
164
|
+
const actions = planHostRepairs(before, options);
|
|
165
|
+
const repairs = { mode: options.apply && !options.dryRun ? 'apply' : options.repairs.length ? 'preview' : 'none',
|
|
166
|
+
selectedHosts: options.repairs, actions, completed: [], failed: [], outstanding: [] };
|
|
167
|
+
let report = { ...before, runtime, repairs };
|
|
168
|
+
let attempted = false;
|
|
169
|
+
if (repairs.mode === 'apply') {
|
|
170
|
+
for (const action of actions) {
|
|
171
|
+
if (action.status === 'blocked') { repairs.failed.push({ id: action.id, reason: action.reason }); continue; }
|
|
172
|
+
if (action.kind === 'none') { repairs.completed.push({ id: action.id, host: action.host, sourceState: 'current', action: 'no change needed', installedOrLoaded: 'not attested by filesystem inventory' }); continue; }
|
|
173
|
+
try {
|
|
174
|
+
attempted = true;
|
|
175
|
+
const result = await (opts.run ?? runRepair)(action, { options, config, env });
|
|
176
|
+
action.output = sanitizeTelemetryText(`${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`);
|
|
177
|
+
if (result.status !== 0) throw new Error(`Repair command exited ${result.status ?? 'unknown'}${result.error ? `: ${result.error.message}` : ''}`);
|
|
178
|
+
const after = read(config, reportOptions), state = targetState(after, action);
|
|
179
|
+
action.sourceState = state;
|
|
180
|
+
if (['missing', 'stale', 'foreign'].includes(state)) throw new Error(`Repair command exited successfully but source state remains ${state}.`);
|
|
181
|
+
action.status = 'completed';
|
|
182
|
+
repairs.completed.push({ id: action.id, host: action.host, sourceState: state, installedOrLoaded: 'not attested by filesystem inventory' });
|
|
183
|
+
if (state === 'unknown') repairs.outstanding.push(`${action.host}: command completed but source parity remains unknown; inspect the host's installed copy.`);
|
|
184
|
+
} catch (error) { action.status = 'failed'; action.reason = error.message; repairs.failed.push({ id: action.id, reason: error.message }); }
|
|
185
|
+
}
|
|
186
|
+
report = { ...read(config, reportOptions), readOnly: !attempted, runtime, repairs };
|
|
187
|
+
if (repairs.failed.length) process.exitCode = 1;
|
|
188
|
+
for (const host of options.repairs) repairs.outstanding.push(...(report.hosts[host].requirements ?? [`Restart/reload ${host}; live activation remains unknown.`]));
|
|
189
|
+
}
|
|
190
|
+
render(report, opts.out ?? process.stdout, options.json);
|
|
191
|
+
return report;
|
|
192
|
+
}
|
package/src/doctor.mjs
CHANGED
|
@@ -85,7 +85,8 @@ function runDoctorTransactions(argv, config, opts = {}) {
|
|
|
85
85
|
const mark = clearedIds.has(item.id) ? green('✓') : blocking ? yellow('!') : dim('·');
|
|
86
86
|
const note = clearedIds.has(item.id)
|
|
87
87
|
? 'cleared'
|
|
88
|
-
: item.
|
|
88
|
+
: item.ownerLiveness === 'live' || item.lockHeld ? 'in progress; rerun later once it finishes'
|
|
89
|
+
: item.resolvable ? 'resolvable' : (item.reason ?? item.status);
|
|
89
90
|
process.stdout.write(` ${mark} ${item.id} [${item.status}] ${item.operation} — owner ${item.ownerLiveness}, ${note}\n`);
|
|
90
91
|
for (const participant of item.participants) {
|
|
91
92
|
process.stdout.write(dim(` ${participant.state.padEnd(9)} ${toRepoPath(participant.path, config.repoRoot)}\n`));
|
|
@@ -97,15 +98,17 @@ function runDoctorTransactions(argv, config, opts = {}) {
|
|
|
97
98
|
|
|
98
99
|
const resolvable = report.filter(item => item.resolvable && !clearedIds.has(item.id));
|
|
99
100
|
if (cleared.length) {
|
|
100
|
-
process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'}
|
|
101
|
+
process.stdout.write(green(`\n✓ Cleared ${cleared.length} transaction${cleared.length === 1 ? '' : 's'} with verified recovery or cleanup evidence.\n`));
|
|
101
102
|
}
|
|
102
103
|
if (resolvable.length) {
|
|
103
|
-
process.stdout.write(`\n${resolvable.length} resolvable —
|
|
104
|
+
process.stdout.write(`\n${resolvable.length} resolvable — recovery or cleanup evidence is verified.\n`);
|
|
104
105
|
process.stdout.write(dim('Run `runlist doctor --transactions --apply` to clear them (no document content is touched).\n'));
|
|
105
106
|
}
|
|
106
|
-
const
|
|
107
|
+
const inProgress = report.filter(item => item.ownerLiveness === 'live' || item.lockHeld);
|
|
108
|
+
if (inProgress.length) process.stdout.write(dim(`\n${inProgress.length} in progress — another runlist process is mid-move; rerun later once it finishes.\n`));
|
|
109
|
+
const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && !inProgress.includes(item));
|
|
107
110
|
if (stuck.length) {
|
|
108
|
-
process.stdout.write(yellow(`\n${stuck.length} need manual review — generations
|
|
111
|
+
process.stdout.write(yellow(`\n${stuck.length} need manual review — generations or retained artifacts could not be verified.\n`));
|
|
109
112
|
process.stdout.write(dim('Inspect the participant paths above against the artifacts in each transaction directory.\n'));
|
|
110
113
|
}
|
|
111
114
|
}
|
|
@@ -171,7 +174,7 @@ async function runDoctorClaims(argv, config, opts = {}) {
|
|
|
171
174
|
return;
|
|
172
175
|
}
|
|
173
176
|
if (claims.length === 0) {
|
|
174
|
-
process.stdout.write(green('✓') + ' No plans are claimed — nothing can be wedged by ownership.\n');
|
|
177
|
+
process.stdout.write(green('✓') + (config.mode === 'handoffs' ? ' Nothing is claimed.\n' : ' No plans are claimed — nothing can be wedged by ownership.\n'));
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -287,6 +290,7 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
287
290
|
? dim('[preview — run with --apply to write]')
|
|
288
291
|
: dim('[applying changes]');
|
|
289
292
|
process.stdout.write(bold('runlist doctor') + ' ' + modeNote + '\n\n');
|
|
293
|
+
process.stdout.write(dim('Agent integrations: runlist doctor --hosts (read-only); select --repair <host> to preview host repairs.\n\n'));
|
|
290
294
|
if (dryRun) {
|
|
291
295
|
const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
|
|
292
296
|
.filter(name => typeof config.hooks?.[name] === 'function');
|
|
@@ -447,6 +451,11 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
447
451
|
const skillDriftWarnings = checkSkillDrift(config);
|
|
448
452
|
const result = {
|
|
449
453
|
cliVersion: cliPackage?.version ?? null,
|
|
454
|
+
promptTracking: {
|
|
455
|
+
value: config.promptTracking ?? 'local',
|
|
456
|
+
source: config.promptTrackingSource ?? 'legacy-default',
|
|
457
|
+
configPath: config.configPath ?? null,
|
|
458
|
+
},
|
|
450
459
|
installations: installationReport(config),
|
|
451
460
|
packageDependency: depVersion,
|
|
452
461
|
claudeCommandWarnings,
|
|
@@ -465,6 +474,7 @@ function runDoctorProject(config, { json = false } = {}) {
|
|
|
465
474
|
process.stdout.write(formatInstallationReport(result.installations)+'\n');
|
|
466
475
|
process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
|
|
467
476
|
process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
|
|
477
|
+
process.stdout.write(`- prompt tracking: ${result.promptTracking.value} (source: ${result.promptTracking.source}) — inspect/change with runlist config; ignore files and Git state are unchanged\n`);
|
|
468
478
|
process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
|
|
469
479
|
if (deprecatedCommandMentions.length) {
|
|
470
480
|
process.stdout.write(`- docs mentioning deprecated commands: ${deprecatedCommandMentions.length}\n`);
|
package/src/export.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { buildIndex } from './index.mjs';
|
|
|
5
5
|
import { buildGraph } from './graph.mjs';
|
|
6
6
|
import { resolveDocPath, resolveRefPath, toRepoPath, capitalize, die } from './util.mjs';
|
|
7
7
|
import { allocateOutputIdentities } from './output-identity.mjs';
|
|
8
|
+
import { assertInsideHandoffs } from './handoffs-scope.mjs';
|
|
9
|
+
import { assertOutsideLegacyPromptFolder } from './managed-path.mjs';
|
|
8
10
|
|
|
9
11
|
export function runExport(argv, config, opts = {}) {
|
|
10
12
|
const positional = [];
|
|
@@ -28,6 +30,14 @@ export function runExport(argv, config, opts = {}) {
|
|
|
28
30
|
positional.push(argv[i]);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// Before any config and where planning is off, nothing is written into the
|
|
34
|
+
// root prompts/ (src/naming.mjs), an export file or folder included.
|
|
35
|
+
const destination = output ?? (format === 'html' ? (existsSync('dotmd-export') ? 'dotmd-export' : 'runlist-export') : null);
|
|
36
|
+
if (destination) {
|
|
37
|
+
try { assertOutsideLegacyPromptFolder(destination, config, 'Export output'); }
|
|
38
|
+
catch (err) { die(err.message); }
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
if (!['md', 'html', 'json'].includes(format)) {
|
|
32
42
|
die(`Invalid format: ${format}\nValid: md, html, json`);
|
|
33
43
|
}
|
|
@@ -37,6 +47,7 @@ export function runExport(argv, config, opts = {}) {
|
|
|
37
47
|
|
|
38
48
|
if (positional[0]) {
|
|
39
49
|
// Single doc + deps mode
|
|
50
|
+
assertInsideHandoffs(positional[0], config);
|
|
40
51
|
const filePath = resolveDocPath(positional[0], config);
|
|
41
52
|
if (!filePath) die(`File not found: ${positional[0]}`);
|
|
42
53
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
package/src/git.mjs
CHANGED
|
@@ -41,7 +41,7 @@ function ensureGit() {
|
|
|
41
41
|
gitChecked = true;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function literalGitPathspec(filePath) {
|
|
44
|
+
export function literalGitPathspec(filePath) {
|
|
45
45
|
return filePath === '.' ? ':(top,literal)' : `:(top,literal)${filePath}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -508,13 +508,69 @@ export function gitMv(source, target, repoRoot) {
|
|
|
508
508
|
|
|
509
509
|
export function isTracked(source, repoRoot) {
|
|
510
510
|
const relSource = path.isAbsolute(source) ? path.relative(repoRoot, source) : source;
|
|
511
|
-
const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relSource], {
|
|
511
|
+
const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', literalGitPathspec(relSource)], {
|
|
512
512
|
cwd: repoRoot,
|
|
513
513
|
encoding: 'utf8',
|
|
514
514
|
});
|
|
515
515
|
return result.status === 0;
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
+
// A path-limited commit needs both sides of a staged move: the old path is
|
|
519
|
+
// absent from the index but still in HEAD. Never offer to add that missing
|
|
520
|
+
// path, or commit a missing path that Git has never known about.
|
|
521
|
+
export function gitCommitPaths(paths, repoRoot, options = {}) {
|
|
522
|
+
const unique = [...new Set(paths)];
|
|
523
|
+
if (!unique.length) return { paths: [], add: [] };
|
|
524
|
+
const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
|
|
525
|
+
const runGit = options.runGit ?? spawnSync;
|
|
526
|
+
const spawnOptions = { cwd: repoRoot, encoding: 'utf8' };
|
|
527
|
+
// On incomplete classification, preserve the original plain commit hint.
|
|
528
|
+
// Partial results must never drop a tracked move source or propose staging.
|
|
529
|
+
const fallback = () => ({ paths: unique, add: [] });
|
|
530
|
+
try {
|
|
531
|
+
assertSafeGitPaths(unique);
|
|
532
|
+
const indexed = new Set();
|
|
533
|
+
for (let offset = 0; offset < unique.length; offset += maxPathsPerBatch) {
|
|
534
|
+
const batch = unique.slice(offset, offset + maxPathsPerBatch);
|
|
535
|
+
const listed = runGit('git', ['ls-files', '-z', '--', ...batch.map(literalGitPathspec)], spawnOptions);
|
|
536
|
+
if (listed.error || listed.status !== 0) return fallback();
|
|
537
|
+
for (const file of listed.stdout.split('\0').filter(Boolean)) indexed.add(file);
|
|
538
|
+
}
|
|
539
|
+
const missing = new Set(unique.filter(file => !indexed.has(file) && !existsSync(path.resolve(repoRoot, file))));
|
|
540
|
+
const inHead = new Set();
|
|
541
|
+
if (missing.size) {
|
|
542
|
+
const missingPaths = [...missing];
|
|
543
|
+
for (let offset = 0; offset < missingPaths.length; offset += maxPathsPerBatch) {
|
|
544
|
+
const batch = missingPaths.slice(offset, offset + maxPathsPerBatch);
|
|
545
|
+
const head = runGit('git', ['ls-tree', '-r', '--name-only', '-z', 'HEAD', '--', ...batch.map(literalGitPathspec)], spawnOptions);
|
|
546
|
+
if (head.error || head.status !== 0) {
|
|
547
|
+
// An unborn HEAD is normal; other failures cannot prove paths absent.
|
|
548
|
+
const revision = runGit('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], spawnOptions);
|
|
549
|
+
if (!head.error && !revision.error && revision.status === 1) break;
|
|
550
|
+
return fallback();
|
|
551
|
+
}
|
|
552
|
+
for (const file of head.stdout.split('\0').filter(Boolean)) inHead.add(file);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const candidates = unique.filter(file => !indexed.has(file) && !missing.has(file));
|
|
556
|
+
const ignored = new Set();
|
|
557
|
+
if (candidates.length) {
|
|
558
|
+
// NUL input preserves filename boundaries and treats brackets literally;
|
|
559
|
+
// check-ignore still parses leading ':' pathspec magic.
|
|
560
|
+
const result = runGit('git', ['check-ignore', '--stdin', '-z'], {
|
|
561
|
+
...spawnOptions, input: candidates.join('\0') + '\0',
|
|
562
|
+
});
|
|
563
|
+
if (result.error || ![0, 1].includes(result.status)) return fallback();
|
|
564
|
+
if (result.status === 0) for (const file of result.stdout.split('\0').filter(Boolean)) ignored.add(file);
|
|
565
|
+
}
|
|
566
|
+
const add = candidates.filter(file => !ignored.has(file));
|
|
567
|
+
const committable = new Set([...indexed, ...inHead, ...add]);
|
|
568
|
+
return { paths: unique.filter(file => committable.has(file)), add };
|
|
569
|
+
} catch {
|
|
570
|
+
return fallback();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
518
574
|
export function gitIndexLocations(repoRoot) {
|
|
519
575
|
const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: repoRoot, encoding: 'utf8' });
|
|
520
576
|
if (result.error || result.status !== 0) throw new Error(`Could not locate Git directory: ${result.error?.message || result.stderr.trim()}`);
|