@sabaiway/agent-workflow-kit 14.24.0 → 14.25.1
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/CHANGELOG.md +42 -0
- package/README.md +15 -4
- package/SKILL.md +1 -1
- package/bin/install.mjs +15 -15
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +5 -1
- package/references/modes/mcp.md +9 -4
- package/references/modes/recommendations.md +17 -9
- package/references/modes/upgrade.md +3 -2
- package/tools/init-preflight.mjs +92 -0
- package/tools/init-project.mjs +400 -0
- package/tools/mcp.mjs +80 -20
- package/tools/payload-prune.mjs +5 -0
- package/tools/recommendations.mjs +54 -62
- package/tools/write-lanes.mjs +171 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { closeSync, constants, lstatSync, openSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const AGENT_SESSION_MARKERS = Object.freeze([
|
|
5
|
+
'CLAUDECODE',
|
|
6
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
7
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
8
|
+
'SANDBOX_RUNTIME',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const OWNER_WRITE_BIT = 0o200;
|
|
12
|
+
const PROBE_FLAGS = constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
13
|
+
const WRITE_REFUSAL_CODES = new Set(['EROFS', 'EPERM', 'EACCES']);
|
|
14
|
+
|
|
15
|
+
const throwPathError = (path, error) => {
|
|
16
|
+
throw Object.assign(new Error(`${path}: ${error?.code ?? ''} ${error?.message ?? String(error)}`, {
|
|
17
|
+
cause: error,
|
|
18
|
+
}), { code: error?.code });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const readStat = (path, io) => {
|
|
22
|
+
try {
|
|
23
|
+
return io.lstat(path);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
26
|
+
throwPathError(path, error);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const openProbe = (path, io) => {
|
|
31
|
+
try {
|
|
32
|
+
return { descriptor: io.openSync(path, PROBE_FLAGS), cause: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (WRITE_REFUSAL_CODES.has(error?.code)) {
|
|
35
|
+
return { cause: `${path}: a write-open answered ${error.code}` };
|
|
36
|
+
}
|
|
37
|
+
throwPathError(path, error);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const probePath = (path, io) => {
|
|
42
|
+
const stat = readStat(path, io);
|
|
43
|
+
if (stat === null) return null;
|
|
44
|
+
if (stat.isCharacterDevice() || stat.isBlockDevice()) {
|
|
45
|
+
return `${path}: a device-node mask`;
|
|
46
|
+
}
|
|
47
|
+
const canProbe = stat.isFile()
|
|
48
|
+
&& (stat.mode & OWNER_WRITE_BIT) !== 0
|
|
49
|
+
&& typeof io.geteuid === 'function'
|
|
50
|
+
&& stat.uid === io.geteuid();
|
|
51
|
+
if (!canProbe) return null;
|
|
52
|
+
const opened = openProbe(path, io);
|
|
53
|
+
if (opened.cause !== null) return opened.cause;
|
|
54
|
+
try {
|
|
55
|
+
return null;
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
io.closeSync(opened.descriptor);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throwPathError(path, error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const preflightInit = ({ root = null, home, env = process.env, io = {} }) => {
|
|
66
|
+
for (const marker of AGENT_SESSION_MARKERS) {
|
|
67
|
+
if (typeof env[marker] === 'string' && env[marker].length > 0) {
|
|
68
|
+
return { ok: false, cause: `marker ${marker} is set` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const probes = {
|
|
72
|
+
lstat: lstatSync,
|
|
73
|
+
openSync,
|
|
74
|
+
closeSync,
|
|
75
|
+
geteuid: process.geteuid,
|
|
76
|
+
...io,
|
|
77
|
+
};
|
|
78
|
+
const paths = [join(home, '.claude/settings.json')];
|
|
79
|
+
if (root !== null) {
|
|
80
|
+
paths.push(
|
|
81
|
+
join(root, '.claude/settings.json'),
|
|
82
|
+
join(root, '.claude/settings.local.json'),
|
|
83
|
+
join(root, '.mcp.json'),
|
|
84
|
+
join(root, '.git/config'),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
for (const path of paths) {
|
|
88
|
+
const cause = probePath(path, probes);
|
|
89
|
+
if (cause !== null) return { ok: false, cause };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, cause: null };
|
|
92
|
+
};
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolveGitHooksPath } from './commit-guard.mjs';
|
|
8
|
+
import { dirCovers } from './declared-paths.mjs';
|
|
9
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
10
|
+
import { GIT_MAX_BUFFER } from './git-env.mjs';
|
|
11
|
+
import { preflightInit } from './init-preflight.mjs';
|
|
12
|
+
import { buildRecommendations, hostFacts } from './recommendations.mjs';
|
|
13
|
+
import { compareSemver } from './semver-lite.mjs';
|
|
14
|
+
import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
15
|
+
import { LANE_CONSOLE, VARIANT_LANES, consoleArgv, isProtectedPath, laneOf, routeLine, writesOf } from './write-lanes.mjs';
|
|
16
|
+
|
|
17
|
+
const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
|
|
18
|
+
const APPLY_PROMPT = 'apply? y/N: ';
|
|
19
|
+
const RUN_NONE = 'y applies every preview above (a sudo install asks once more on its own line); run none of their commands yourself';
|
|
20
|
+
const RESTART_LINE = 'restart the agent from this folder: init changed its configuration';
|
|
21
|
+
const SETTINGS = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json', '.git/config'];
|
|
22
|
+
const FILE_DIRS = ['.claude/agents', '.claude/hooks'];
|
|
23
|
+
// The record key whose state is the sorted *.json names of its directory, so a created file is a change.
|
|
24
|
+
const JSON_NAMES = '*.json';
|
|
25
|
+
const DANGLING = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
|
|
26
|
+
const INSTALL_VARIANT = 'sandbox-provision.installable';
|
|
27
|
+
const DEFAULT_IO = { lstat: lstatSync, stat: statSync, readlink: readlinkSync, readFile: readFileSync, readdir: readdirSync,
|
|
28
|
+
openSync, closeSync, geteuid: process.geteuid };
|
|
29
|
+
const isYes = (answer) => answer === 'y' || answer === 'Y';
|
|
30
|
+
|
|
31
|
+
const readStat = (path, io) => {
|
|
32
|
+
try {
|
|
33
|
+
return io.lstat(path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const findProject = ({ cwd, lstat = lstatSync }) => {
|
|
41
|
+
const candidates = [];
|
|
42
|
+
const visit = (root) => {
|
|
43
|
+
const io = { lstat };
|
|
44
|
+
if (readStat(join(root, 'docs/ai'), io)?.isDirectory()) {
|
|
45
|
+
const shape = readStat(join(root, 'docs/ai/.workflow-version'), io) ? 'stamped' : 'pre-versioned';
|
|
46
|
+
candidates.push({ root, shape });
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(root);
|
|
49
|
+
if (!readStat(join(root, '.git'), io) && parent !== root) visit(parent);
|
|
50
|
+
};
|
|
51
|
+
visit(resolve(cwd));
|
|
52
|
+
return { candidates };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readEntries = (dir, io) => readStat(dir, io)?.isDirectory() ? io.readdir(dir).sort() : [];
|
|
56
|
+
const collectPaths = ({ root, home, hooksDir }, io) => {
|
|
57
|
+
const paths = [join(home, '.claude/settings.json')];
|
|
58
|
+
if (root === null) return paths;
|
|
59
|
+
const docs = join(root, 'docs/ai');
|
|
60
|
+
paths.push(...SETTINGS.map((path) => join(root, path)), join(docs, '.workflow-version'), join(docs, JSON_NAMES));
|
|
61
|
+
for (const dir of [...FILE_DIRS.map((path) => join(root, path)), hooksDir]) {
|
|
62
|
+
paths.push(dir, ...readEntries(dir, io).map((name) => join(dir, name)));
|
|
63
|
+
}
|
|
64
|
+
paths.push(...readEntries(docs, io).filter((name) => name.endsWith('.json')).map((name) => join(docs, name)));
|
|
65
|
+
return paths;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fileState = (path, io) => `file:${Buffer.from(io.readFile(path)).toString('hex')}`;
|
|
69
|
+
const otherState = (stat) => `other:${stat.mode}:${stat.rdev}`;
|
|
70
|
+
const entryState = (path, io) => {
|
|
71
|
+
const stat = io.lstat(path);
|
|
72
|
+
if (stat.isDirectory()) return 'directory';
|
|
73
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}`;
|
|
74
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
75
|
+
};
|
|
76
|
+
// A directory reads as its sorted entries with each entry's own state, one level deep, so a changed file in it is a change.
|
|
77
|
+
const statState = (path, stat, io) => {
|
|
78
|
+
if (stat.isDirectory()) return `directory:${JSON.stringify(io.readdir(path).sort().map((name) => [name, entryState(join(path, name), io)]))}`;
|
|
79
|
+
return stat.isFile() ? fileState(path, io) : otherState(stat);
|
|
80
|
+
};
|
|
81
|
+
const followedState = (path, io) => {
|
|
82
|
+
try {
|
|
83
|
+
return statState(path, io.stat(path), io);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (DANGLING.has(error.code)) return 'absent';
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const readState = (path, io) => {
|
|
90
|
+
if (basename(path) === JSON_NAMES) {
|
|
91
|
+
const names = readStat(dirname(path), io)?.isDirectory() ? io.readdir(dirname(path)).filter((name) => name.endsWith('.json')) : null;
|
|
92
|
+
return names === null ? 'absent' : `names:${JSON.stringify(names.sort())}`;
|
|
93
|
+
}
|
|
94
|
+
const stat = readStat(path, io);
|
|
95
|
+
if (stat === null) return 'absent';
|
|
96
|
+
if (stat.isSymbolicLink()) return `link:${io.readlink(path)}:${followedState(path, io)}`;
|
|
97
|
+
return statState(path, stat, io);
|
|
98
|
+
};
|
|
99
|
+
const stateOf = (path, io) => {
|
|
100
|
+
try {
|
|
101
|
+
return readState(path, io);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const recordPaths = (paths, io) => new Map([...new Set(paths)].map((path) => [path, stateOf(path, io)]));
|
|
107
|
+
const findChanged = (record, io) => [...record].find(([path, state]) => {
|
|
108
|
+
try {
|
|
109
|
+
return readState(path, io) !== state;
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
})?.[0];
|
|
114
|
+
// After an apply a path that cannot be read is recorded as such, so the next comparison stops the batch on it.
|
|
115
|
+
const refreshState = (path, io) => {
|
|
116
|
+
try {
|
|
117
|
+
return readState(path, io);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return `unreadable:${error.code ?? error.message}`;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const DIRECTORY = 'directory:';
|
|
123
|
+
const entriesOf = (state) => (state === 'absent' ? [] : JSON.parse(state.slice(DIRECTORY.length)));
|
|
124
|
+
// A recorded parent moves only by the written entry, so an entry created or removed beside it still stops the batch.
|
|
125
|
+
const refreshParent = (record, path, io) => {
|
|
126
|
+
const [parent, name, current] = [dirname(path), basename(path), refreshState(dirname(path), io)];
|
|
127
|
+
const recorded = record.get(parent);
|
|
128
|
+
if (!(recorded === 'absent' || recorded.startsWith(DIRECTORY)) || !current.startsWith(DIRECTORY)) return;
|
|
129
|
+
const merged = [...entriesOf(recorded).filter(([entry]) => entry !== name), ...entriesOf(current).filter(([entry]) => entry === name)];
|
|
130
|
+
record.set(parent, DIRECTORY + JSON.stringify(merged.sort(([a], [b]) => (a < b ? -1 : 1))));
|
|
131
|
+
};
|
|
132
|
+
const refreshWrites = (record, paths, io) => {
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
record.set(path, refreshState(path, io));
|
|
135
|
+
if (record.has(dirname(path))) refreshParent(record, path, io);
|
|
136
|
+
if (readStat(path, io)?.isDirectory()) {
|
|
137
|
+
for (const watched of record.keys()) {
|
|
138
|
+
if (watched.startsWith(path + sep)) record.set(watched, refreshState(watched, io));
|
|
139
|
+
}
|
|
140
|
+
for (const child of readEntries(path, io).map((name) => join(path, name))) record.set(child, refreshState(child, io));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const findMasks = (paths, facts, io, print) => {
|
|
146
|
+
const masks = new Set();
|
|
147
|
+
const root = facts.root ?? facts.cwd;
|
|
148
|
+
for (const path of new Set(paths)) {
|
|
149
|
+
if (!isProtectedPath(path, { root, home: facts.home, hooksDir: join(root, '.git/hooks') })) continue;
|
|
150
|
+
const stat = readStat(path, io);
|
|
151
|
+
if (!stat?.isFile() || stat.size !== 0 || (stat.mode & 0o222) !== 0) continue;
|
|
152
|
+
masks.add(path);
|
|
153
|
+
print(`${path}: an empty read-only file — a running agent command's mask (run init again once it ends) or litter a sandbox left (remove it, then run init again)`);
|
|
154
|
+
}
|
|
155
|
+
return masks;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const launch = async (context, argv, stdio) => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await context.spawn(argv, { cwd: context.facts.root ?? context.cwd, env: context.spawnEnv, stdio });
|
|
161
|
+
if (!result || typeof result !== 'object') return { status: null, stdout: '', stderr: 'empty answer', valid: false };
|
|
162
|
+
return {
|
|
163
|
+
status: result.status ?? null,
|
|
164
|
+
stdout: String(result.stdout ?? ''),
|
|
165
|
+
stderr: String(result.stderr ?? result.error?.message ?? result.signal ?? ''),
|
|
166
|
+
valid: typeof result.status === 'number' && !result.error && !result.signal,
|
|
167
|
+
};
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { status: null, stdout: '', stderr: String(error.code ?? error.message ?? error), valid: false };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Outside a project only the host-scoped variants reach the queue: an apply-only one or one judged over a claude dir.
|
|
174
|
+
const reachesQueue = (variant, facts) => {
|
|
175
|
+
if (facts.root !== null) return true;
|
|
176
|
+
const argv = consoleArgv(variant, facts);
|
|
177
|
+
return Boolean(argv) && (argv.preview === null || argv.preview.includes('--claude-dir'));
|
|
178
|
+
};
|
|
179
|
+
const isMasked = (variant, context) => (writesOf(variant, context.facts) ?? [])
|
|
180
|
+
.some((write) => [...context.masks].some((mask) => dirCovers(write, mask)));
|
|
181
|
+
|
|
182
|
+
// context.items keeps the console items of the advisor's latest return, before the queue's filters.
|
|
183
|
+
const getQueue = async (context) => {
|
|
184
|
+
const { items } = await context.recommend(context.facts.root, { keySet: context.keySet });
|
|
185
|
+
context.items = items.filter(({ variant }) => laneOf(variant) === LANE_CONSOLE);
|
|
186
|
+
return context.items.filter(({ variant }) => reachesQueue(variant, context.facts) && !isMasked(variant, context));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const printNotPending = (context) => {
|
|
190
|
+
const plain = routeLine('velocity-core');
|
|
191
|
+
const variants = Object.keys(VARIANT_LANES).filter((variant) => laneOf(variant) === LANE_CONSOLE && routeLine(variant)?.length > plain.length);
|
|
192
|
+
const keys = new Set();
|
|
193
|
+
for (const variant of variants) {
|
|
194
|
+
const key = variant.split('.')[0];
|
|
195
|
+
if (keys.has(key)) continue;
|
|
196
|
+
keys.add(key);
|
|
197
|
+
if (!reachesQueue(variant, context.facts) || context.items.some((item) => item.key === key)) continue;
|
|
198
|
+
const preview = consoleArgv(variant, context.facts)?.preview;
|
|
199
|
+
const index = preview?.indexOf('--claude-dir') ?? -1;
|
|
200
|
+
const judged = index < 0 ? '' : ` (judged ${preview[index + 1]})`;
|
|
201
|
+
context.print(`not pending in this console: ${key}${judged}`);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const printNote = (context, key) => {
|
|
206
|
+
const lines = String(context.io.readFile(join(context.facts.toolsDir, '../references/modes/recommendations.md'), 'utf8')).split('\n');
|
|
207
|
+
const start = lines.findIndex((line) => line.startsWith(`- \`${key}\` — `));
|
|
208
|
+
if (start < 0) return;
|
|
209
|
+
const end = lines.findIndex((line, index) => index > start && !line.startsWith(' ') && !line.startsWith('\t'));
|
|
210
|
+
for (const line of lines.slice(start, end < 0 ? lines.length : end)) context.print(line);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const previewQueue = async (context, queue) => {
|
|
214
|
+
const batch = [];
|
|
215
|
+
for (const item of queue) {
|
|
216
|
+
const argv = consoleArgv(item.variant, context.facts);
|
|
217
|
+
if (!argv?.preview) continue;
|
|
218
|
+
context.print(`preview: ${item.variant}`);
|
|
219
|
+
const result = await launch(context, argv.preview, 'pipe');
|
|
220
|
+
const success = result.valid && (result.status === 0 || (result.status === 3 && result.stdout.includes('status=missing-binaries')));
|
|
221
|
+
if (!success) {
|
|
222
|
+
context.print(`preview refused (exit ${result.status}): ${item.variant}`);
|
|
223
|
+
if (result.stdout) context.print(result.stdout);
|
|
224
|
+
if (result.stderr) context.print(result.stderr);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
context.print(result.stdout);
|
|
228
|
+
const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
|
|
229
|
+
const facts = { ...context.facts, tuple };
|
|
230
|
+
batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
|
|
231
|
+
printNote(context, item.key);
|
|
232
|
+
}
|
|
233
|
+
for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
|
|
234
|
+
context.print(`listed, not applied here: ${item.variant} — ${item.what}`);
|
|
235
|
+
}
|
|
236
|
+
return batch;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const isConfiguration = (path, root) => root !== null && (
|
|
240
|
+
SETTINGS.slice(0, 3).some((rel) => path === join(root, rel))
|
|
241
|
+
|| ['.claude/agents', '.claude/hooks'].some((rel) => path === join(root, rel) || path.startsWith(join(root, rel) + sep))
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// The record is the state the first preview reads: taken before any preview, compared before each apply.
|
|
245
|
+
const takeRecord = (context, queue) => {
|
|
246
|
+
const previewable = queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview);
|
|
247
|
+
if (previewable.length === 0) return new Map();
|
|
248
|
+
const paths = [...collectPaths(context.facts, context.io), ...previewable.flatMap(({ variant }) => writesOf(variant, context.facts))];
|
|
249
|
+
try {
|
|
250
|
+
return recordPaths(paths, context.io);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
for (const item of previewable) context.print(`not applied: ${item.variant} — ${error.message}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const runPass = async (context, queue) => {
|
|
258
|
+
const record = takeRecord(context, queue);
|
|
259
|
+
if (record === null) return { code: 1, successes: 0, variants: [] };
|
|
260
|
+
const batch = await previewQueue(context, queue);
|
|
261
|
+
if (batch.length === 0) return { code: 0, successes: 0, variants: [] };
|
|
262
|
+
const before = new Map(record);
|
|
263
|
+
const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
|
|
264
|
+
context.print(RUN_NONE);
|
|
265
|
+
if (!isYes(await context.terminal.ask(APPLY_PROMPT))) return outcome;
|
|
266
|
+
for (const [index, item] of batch.entries()) {
|
|
267
|
+
if (item.variant === INSTALL_VARIANT && !isYes(await context.terminal.ask('the sandbox install may ask for your sudo password: y runs it, Enter skips: '))) continue;
|
|
268
|
+
const changed = findChanged(record, context.io);
|
|
269
|
+
if (changed) {
|
|
270
|
+
for (const later of batch.slice(index)) context.print(`not applied: ${later.variant} — ${changed} changed outside this batch`);
|
|
271
|
+
outcome.code = 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
|
|
275
|
+
if (result.stdout) context.print(result.stdout);
|
|
276
|
+
const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));
|
|
277
|
+
if (!success) {
|
|
278
|
+
context.print(`apply failed (exit ${result.status}): ${item.variant}`);
|
|
279
|
+
if (result.stderr) context.print(result.stderr);
|
|
280
|
+
for (const later of batch.slice(index + 1)) context.print(`not applied: ${later.variant} — an earlier apply failed`);
|
|
281
|
+
outcome.code = 1;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
outcome.successes += 1;
|
|
285
|
+
refreshWrites(record, item.writes, context.io);
|
|
286
|
+
}
|
|
287
|
+
if (outcome.successes > 0) {
|
|
288
|
+
const afterPaths = [...collectPaths(context.facts, context.io), ...record.keys()];
|
|
289
|
+
context.restart ||= afterPaths.some((path) => isConfiguration(path, context.facts.root)
|
|
290
|
+
&& refreshState(path, context.io) !== (before.get(path) ?? 'absent'));
|
|
291
|
+
}
|
|
292
|
+
return outcome;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const selectRoot = async (context) => {
|
|
296
|
+
const { candidates } = findProject({ cwd: context.cwd, lstat: context.io.lstat });
|
|
297
|
+
if (candidates.length === 0) {
|
|
298
|
+
context.print("no agent-workflow project here — the project items apply when init runs from that project's folder");
|
|
299
|
+
return { root: null };
|
|
300
|
+
}
|
|
301
|
+
if (candidates.length === 1) return candidates[0];
|
|
302
|
+
candidates.forEach(({ root }, index) => context.print(` ${index + 1}. ${root}`));
|
|
303
|
+
const answer = await context.terminal.ask(`which project? 1-${candidates.length}, Enter skips: `);
|
|
304
|
+
return candidates.find((_, index) => String(index + 1) === answer) ?? null;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const gateStamp = (candidate, context) => {
|
|
308
|
+
if (candidate.root === null) return null;
|
|
309
|
+
const stamp = (() => {
|
|
310
|
+
try {
|
|
311
|
+
return String(context.io.readFile(join(candidate.root, 'docs/ai/.workflow-version'), 'utf8')).trim();
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
if (candidate.shape === 'stamped' && stamp === EXPECTED_WORKFLOW_VERSION) return candidate.root;
|
|
317
|
+
if (compareSemver(stamp, EXPECTED_WORKFLOW_VERSION) === 1) {
|
|
318
|
+
context.print(`this project's stamp ${stamp} is ahead of this kit's lineage ${EXPECTED_WORKFLOW_VERSION} — update the kit, then init again`);
|
|
319
|
+
} else {
|
|
320
|
+
context.print('run /agent-workflow-kit upgrade, then init again');
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export const runInitProject = async (deps) => {
|
|
326
|
+
const { terminal } = deps;
|
|
327
|
+
if (terminal.isTTY !== true) {
|
|
328
|
+
terminal.print("project step skipped: no terminal — run init in a console from the project's folder");
|
|
329
|
+
return { code: 0 };
|
|
330
|
+
}
|
|
331
|
+
const io = { ...DEFAULT_IO, ...deps.io };
|
|
332
|
+
const print = (line) => terminal.print(line);
|
|
333
|
+
if (!preflightInit({ ...deps, root: null, io }).ok) {
|
|
334
|
+
print(INSIDE_LINE);
|
|
335
|
+
return { code: 0 };
|
|
336
|
+
}
|
|
337
|
+
const spawnEnv = { ...deps.env };
|
|
338
|
+
delete spawnEnv.GIT_DIR;
|
|
339
|
+
delete spawnEnv.GIT_WORK_TREE;
|
|
340
|
+
const context = { ...deps, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
|
|
341
|
+
const candidate = await selectRoot(context);
|
|
342
|
+
if (candidate === null) return { code: 0 };
|
|
343
|
+
if (!preflightInit({ ...deps, root: candidate.root, io }).ok) {
|
|
344
|
+
print(INSIDE_LINE);
|
|
345
|
+
return { code: 0 };
|
|
346
|
+
}
|
|
347
|
+
const root = gateStamp(candidate, context);
|
|
348
|
+
// The hooks dir git itself reports (a linked worktree's or core.hooksPath), as the advisor reads it.
|
|
349
|
+
const hooksDir = root === null ? null : (deps.gitHooksPath ?? resolveGitHooksPath)(root) ?? join(root, '.git/hooks');
|
|
350
|
+
context.facts = { toolsDir: deps.toolsDir, root, env: deps.env, home: deps.home, platform: deps.platform, tuple: undefined, hooksDir };
|
|
351
|
+
context.recommend ??= (root, facts) => buildRecommendations({ cwd: root ?? deps.cwd,
|
|
352
|
+
deps: { ...io, env: deps.env, getenv: deps.env, home: deps.home, platform: deps.platform,
|
|
353
|
+
jevHost: { ...hostFacts(), home: deps.home, platform: deps.platform }, ...facts } });
|
|
354
|
+
const queue = await getQueue(context);
|
|
355
|
+
const maskPaths = [...collectPaths(context.facts, io), ...queue.flatMap(({ variant }) => writesOf(variant, context.facts) ?? [])];
|
|
356
|
+
context.masks = findMasks(maskPaths, { ...context.facts, cwd: deps.cwd }, io, print);
|
|
357
|
+
const state = { queue: queue.filter(({ variant }) => !isMasked(variant, context)) };
|
|
358
|
+
for (const item of state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview === null)) {
|
|
359
|
+
print(item.what);
|
|
360
|
+
print(`it writes: ${writesOf(item.variant, context.facts).join(', ')}`);
|
|
361
|
+
printNote(context, item.key);
|
|
362
|
+
if (!isYes(await terminal.ask('y runs its command on this terminal, Enter skips: '))) continue;
|
|
363
|
+
const result = await launch(context, consoleArgv(item.variant, context.facts).apply, 'inherit');
|
|
364
|
+
if (result.valid && result.status === 0) {
|
|
365
|
+
context.keySet = true;
|
|
366
|
+
state.queue = await getQueue(context);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
state.queue = state.queue.filter(({ variant }) => consoleArgv(variant, context.facts)?.preview !== null);
|
|
370
|
+
printNotPending(context);
|
|
371
|
+
const first = await runPass(context, state.queue);
|
|
372
|
+
const outcome = { code: first.code };
|
|
373
|
+
if (first.code === 0 && first.successes > 0) {
|
|
374
|
+
const next = (await getQueue(context)).filter(({ variant }) => !first.variants.includes(variant) && consoleArgv(variant, context.facts)?.preview);
|
|
375
|
+
outcome.code = (await runPass(context, next)).code;
|
|
376
|
+
}
|
|
377
|
+
if (context.restart) print(RESTART_LINE);
|
|
378
|
+
return outcome;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
export const main = async (argv = process.argv.slice(2), deps = {}) => {
|
|
382
|
+
if (argv.length > 0) {
|
|
383
|
+
(deps.errlog ?? console.error)('Usage: node init-project.mjs');
|
|
384
|
+
return 2;
|
|
385
|
+
}
|
|
386
|
+
const readline = deps.terminal ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
387
|
+
const terminal = deps.terminal ?? { isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
388
|
+
ask: (prompt) => readline.question(prompt), print: (line) => console.log(line) };
|
|
389
|
+
try {
|
|
390
|
+
const result = await runInitProject({ cwd: process.cwd(), env: process.env, home: homedir(), platform: process.platform,
|
|
391
|
+
toolsDir: dirname(fileURLToPath(import.meta.url)), terminal,
|
|
392
|
+
spawn: (args, options) => spawnSync(process.execPath, args.slice(1), { ...options, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER }),
|
|
393
|
+
...deps });
|
|
394
|
+
return result.code;
|
|
395
|
+
} finally {
|
|
396
|
+
readline?.close();
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
if (isDirectRun(import.meta.url)) process.exitCode = await main();
|