@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
package/tools/mcp.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// would be a client error on every startup;
|
|
17
17
|
// • merge-don't-clobber — foreign servers, foreign settings keys and existing allow rules are
|
|
18
18
|
// preserved; re-apply adds nothing twice; the file's EOL is kept;
|
|
19
|
-
// • a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
|
|
19
|
+
// • without `--replace`, a same-name entry that STRUCTURALLY DIFFERS is REFUSED unwritten (key order is ignored, so a
|
|
20
20
|
// re-serialized identical entry is the SAME registration) — silently changing what an MCP server
|
|
21
21
|
// launches is exactly what consent must not slide past; the recovery is named;
|
|
22
22
|
// • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
|
|
@@ -32,11 +32,16 @@
|
|
|
32
32
|
// Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
|
|
33
33
|
// Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
lstatSync, openSync, closeSync, fstatSync, writeFileSync, ftruncateSync, fsyncSync,
|
|
37
|
+
renameSync, constants,
|
|
38
|
+
} from 'node:fs';
|
|
36
39
|
import { join, resolve } from 'node:path';
|
|
37
40
|
import { fileURLToPath } from 'node:url';
|
|
38
41
|
import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
|
|
39
42
|
import { isDirectRun } from './direct-run.mjs';
|
|
43
|
+
import { describeNonRegular } from './fs-read-nofollow.mjs';
|
|
44
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
40
45
|
import {
|
|
41
46
|
CLAUDE_DIR_REL,
|
|
42
47
|
ENABLED_KEY,
|
|
@@ -67,16 +72,16 @@ const LF = '\n';
|
|
|
67
72
|
const JSON_INDENT = 2;
|
|
68
73
|
|
|
69
74
|
export const MCP_TOOL = fileURLToPath(import.meta.url);
|
|
70
|
-
export const applyMcpCommand = (root) => `node ${q(MCP_TOOL)} --apply --cwd ${q(root)}`;
|
|
75
|
+
export const applyMcpCommand = (root, replace = false) => `node ${q(MCP_TOOL)}${replace ? ' --replace' : ''} --apply --cwd ${q(root)}`;
|
|
71
76
|
|
|
72
|
-
const USAGE = `usage: mcp [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
77
|
+
const USAGE = `usage: mcp [--dry-run | --apply] [--replace] [--cwd <dir>] [--help]
|
|
73
78
|
|
|
74
79
|
Registers this kit's stdio MCP server in ONE project: the "${SERVER_NAME}" entry in
|
|
75
80
|
${MCP_JSON_REL}, and "${ENABLED_KEY}" + the two tool allow rules in ${SETTINGS_REL}.
|
|
76
81
|
Default is --dry-run (a preview that prints the exact entry and writes nothing).
|
|
77
|
-
--apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept.
|
|
82
|
+
--apply writes: ${MCP_JSON_REL} first, then ${SETTINGS_REL}; merge-don't-clobber, EOL kept. An existing target is written in place; --replace replaces a differing entry only with --apply.
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
Without --replace, an existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
|
|
80
85
|
order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
|
|
81
86
|
mask is the usual cause — the entry to merge is printed and nothing is written.
|
|
82
87
|
Never writes settings.local.json; never commits.`;
|
|
@@ -122,7 +127,7 @@ const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
|
|
|
122
127
|
}
|
|
123
128
|
};
|
|
124
129
|
|
|
125
|
-
export const preflightMcp = ({ cwd }, deps = {}) => {
|
|
130
|
+
export const preflightMcp = ({ cwd, replace = false }, deps = {}) => {
|
|
126
131
|
const root = resolve(cwd ?? deps.cwd ?? process.cwd());
|
|
127
132
|
// The ROOT is judged BEFORE the registration is read, not merely before the first write: reading
|
|
128
133
|
// first would follow the very link this refuses, which is exactly what the shipped claim denies.
|
|
@@ -152,7 +157,7 @@ export const preflightMcp = ({ cwd }, deps = {}) => {
|
|
|
152
157
|
}
|
|
153
158
|
assertTargetUsable(registration.mcpJson, { maskedAllowed: true });
|
|
154
159
|
assertTargetUsable(registration.settings);
|
|
155
|
-
if (registration.mcpJson.differs) {
|
|
160
|
+
if (registration.mcpJson.differs && !replace) {
|
|
156
161
|
throw makeMcpError(
|
|
157
162
|
MCP_DIFFERS,
|
|
158
163
|
`${MCP_JSON_REL} already carries an "${SERVER_NAME}" server entry that STRUCTURALLY DIFFERS from this kit copy's registration — refusing to change what it launches; review that entry and remove or rename it, then re-run`,
|
|
@@ -173,8 +178,53 @@ export const planMcp = (registration) => ({
|
|
|
173
178
|
|
|
174
179
|
// ── the writer ─────────────────────────────────────────────────────────────────────────
|
|
175
180
|
|
|
176
|
-
|
|
177
|
-
const
|
|
181
|
+
const writeTarget = (root, target, body, deps, stop) => {
|
|
182
|
+
const leaf = lstatNoFollow(target.abs, deps.lstat);
|
|
183
|
+
if (target.state === STATE.ABSENT) {
|
|
184
|
+
const assertAbsent = (dst) => {
|
|
185
|
+
if (lstatNoFollow(dst, deps.lstat) !== null) {
|
|
186
|
+
throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
assertAbsent(target.abs);
|
|
190
|
+
const rename = (tmp, dst) => {
|
|
191
|
+
assertAbsent(dst);
|
|
192
|
+
(deps.rename ?? renameSync)(tmp, dst);
|
|
193
|
+
};
|
|
194
|
+
return writeContainedFileAtomic(root, target.abs, body, { ...deps, rename }, { stop, label: target.rel });
|
|
195
|
+
}
|
|
196
|
+
if (leaf?.isSymbolicLink()) {
|
|
197
|
+
throw stop(`${target.rel} is a symlink — refusing to replace it (a write would clobber the link target)`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
assertContainedRealPath(root, target.abs, deps);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw stop(String(err?.message ?? err).replace(/^\[agent-workflow-kit\] /, ''));
|
|
203
|
+
}
|
|
204
|
+
const flags = deps.constants ?? constants;
|
|
205
|
+
if (!flags.O_NOFOLLOW || !flags.O_NONBLOCK) {
|
|
206
|
+
throw stop('this platform exposes no usable O_NOFOLLOW|O_NONBLOCK open flags — refusing to open (fail closed)');
|
|
207
|
+
}
|
|
208
|
+
const fd = (deps.open ?? openSync)(target.abs, flags.O_WRONLY | flags.O_NOFOLLOW | flags.O_NONBLOCK);
|
|
209
|
+
try {
|
|
210
|
+
const stat = (deps.fstat ?? fstatSync)(fd);
|
|
211
|
+
if (!stat.isFile()) {
|
|
212
|
+
throw stop(`${target.rel} is a ${describeNonRegular(stat)} — refusing to write through it`);
|
|
213
|
+
}
|
|
214
|
+
if (leaf === null || stat.dev !== leaf.dev || stat.ino !== leaf.ino) {
|
|
215
|
+
throw stop('the leaf changed identity between lstat and open (fail closed)');
|
|
216
|
+
}
|
|
217
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
218
|
+
(deps.writeFile ?? writeFileSync)(fd, bytes);
|
|
219
|
+
(deps.ftruncate ?? ftruncateSync)(fd, bytes.length);
|
|
220
|
+
(deps.fsync ?? fsyncSync)(fd);
|
|
221
|
+
} finally {
|
|
222
|
+
(deps.close ?? closeSync)(fd);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const writeMcp = ({ cwd, dryRun = true, replace = false } = {}, deps = {}) => {
|
|
227
|
+
const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd(), replace }, deps);
|
|
178
228
|
const base = { ...preflight, dryRun, wrote: false };
|
|
179
229
|
if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
|
|
180
230
|
if (dryRun) return base;
|
|
@@ -182,7 +232,7 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
182
232
|
const { root, registration, plan } = preflight;
|
|
183
233
|
const stop = (message) => makeMcpError(MCP_SYMLINK, message);
|
|
184
234
|
if (plan.writeMcpJson) {
|
|
185
|
-
|
|
235
|
+
writeTarget(root, registration.mcpJson, plan.mcpBody, deps, stop);
|
|
186
236
|
}
|
|
187
237
|
if (plan.writeSettings) {
|
|
188
238
|
// A settings failure AFTER the entry landed leaves a STANDING registration on disk that never
|
|
@@ -194,12 +244,12 @@ export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
|
194
244
|
// The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so
|
|
195
245
|
// it belongs on the apply lane only — a preview that made a directory would not be a preview.
|
|
196
246
|
assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
|
|
197
|
-
|
|
247
|
+
writeTarget(root, registration.settings, plan.settingsBody, deps, stop);
|
|
198
248
|
} catch (err) {
|
|
199
249
|
// Reaching here always leaves a STANDING registration on disk — either this run wrote the
|
|
200
250
|
// entry (`writeMcpJson`) or the preflight found it already current (`matches`), and those two
|
|
201
251
|
// are exhaustive, so the note is unconditional rather than guarded by a branch nothing can
|
|
202
|
-
// take.
|
|
252
|
+
// take. Without --replace, a differing entry STOPs in the preflight.
|
|
203
253
|
throw Object.assign(err, { message: `${err.message}\n${HIDDEN_MODE_LINE}` });
|
|
204
254
|
}
|
|
205
255
|
}
|
|
@@ -221,7 +271,9 @@ const indented = (text) => text.trimEnd().split(LF).map((line) => ` ${line}`)
|
|
|
221
271
|
|
|
222
272
|
const mcpJsonLine = (result) => {
|
|
223
273
|
if (!result.plan.writeMcpJson) return ` - ${MCP_JSON_REL}: already current`;
|
|
224
|
-
const verb = result.
|
|
274
|
+
const verb = result.registration.mcpJson.differs
|
|
275
|
+
? (result.dryRun ? 'would replace' : 'replaced')
|
|
276
|
+
: (result.dryRun ? 'would add' : 'added');
|
|
225
277
|
return ` - ${MCP_JSON_REL}: ${verb} the "${SERVER_NAME}" stdio entry`;
|
|
226
278
|
};
|
|
227
279
|
|
|
@@ -264,24 +316,31 @@ export const formatResult = (result) => {
|
|
|
264
316
|
: 'agent-workflow MCP registration — APPLY',
|
|
265
317
|
mcpJsonLine(result),
|
|
266
318
|
settingsLine(result),
|
|
267
|
-
|
|
319
|
+
...(result.registration.mcpJson.differs ? [
|
|
320
|
+
result.dryRun ? ' the entry standing in .mcp.json now:' : ' the entry this run replaced in .mcp.json:',
|
|
321
|
+
indented(JSON.stringify(result.registration.mcpJson.existing, null, JSON_INDENT)),
|
|
322
|
+
] : []),
|
|
323
|
+
result.dryRun
|
|
324
|
+
? ' the entry this registration declares (re-serialized here; the same structured value goes into the file):'
|
|
325
|
+
: ' the entry now in .mcp.json (re-serialized here; the file holds the same structured value):',
|
|
268
326
|
indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
|
|
269
327
|
POSTURE_LINE,
|
|
270
328
|
HIDDEN_MODE_LINE,
|
|
271
329
|
];
|
|
272
|
-
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
|
|
330
|
+
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root, result.registration.mcpJson.differs)}`);
|
|
273
331
|
return lines.join(LF);
|
|
274
332
|
};
|
|
275
333
|
|
|
276
334
|
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
277
335
|
|
|
278
336
|
export const parseArgs = (argv) => {
|
|
279
|
-
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
337
|
+
const opts = { dryRunFlag: false, apply: false, replace: false, cwd: undefined, help: false };
|
|
280
338
|
for (let i = 0; i < argv.length; i += 1) {
|
|
281
339
|
const arg = argv[i];
|
|
282
340
|
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
283
341
|
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
284
342
|
else if (arg === '--apply') opts.apply = true;
|
|
343
|
+
else if (arg === '--replace') opts.replace = true;
|
|
285
344
|
else if (arg === '--cwd') {
|
|
286
345
|
i += 1;
|
|
287
346
|
// An EMPTY (or whitespace) value passes both guards above, and `resolve('')` silently means the
|
|
@@ -296,7 +355,7 @@ export const parseArgs = (argv) => {
|
|
|
296
355
|
}
|
|
297
356
|
}
|
|
298
357
|
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
299
|
-
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
358
|
+
return { help: opts.help, dryRun: !opts.apply, replace: opts.replace, cwd: opts.cwd };
|
|
300
359
|
};
|
|
301
360
|
|
|
302
361
|
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
@@ -308,10 +367,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
308
367
|
log(USAGE);
|
|
309
368
|
return EXIT_OK;
|
|
310
369
|
}
|
|
311
|
-
log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
|
|
370
|
+
log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun, replace: args.replace }, deps)));
|
|
312
371
|
return EXIT_OK;
|
|
313
372
|
} catch (err) {
|
|
314
|
-
|
|
373
|
+
const message = err?.message ?? String(err);
|
|
374
|
+
errlog(err?.code && err.name !== 'McpError' && !message.includes(err.code) ? `${err.code}: ${message}` : message);
|
|
315
375
|
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
316
376
|
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
317
377
|
}
|
package/tools/payload-prune.mjs
CHANGED
|
@@ -107,7 +107,9 @@ const readTree = (root, entry, reports, directoryOnly = false) => {
|
|
|
107
107
|
return entries;
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
// The walk lists a directory before its children, so a kept directory's line covers the directories beneath it.
|
|
110
111
|
const planSubtree = (carried, installed, orphans, reports) => {
|
|
112
|
+
const keptDirectories = [];
|
|
111
113
|
for (const [path, kind] of installed) {
|
|
112
114
|
if (carried.has(path)) {
|
|
113
115
|
const packageKind = carried.get(path);
|
|
@@ -119,7 +121,10 @@ const planSubtree = (carried, installed, orphans, reports) => {
|
|
|
119
121
|
}
|
|
120
122
|
} else if (kind === 'file') {
|
|
121
123
|
orphans.push(path);
|
|
124
|
+
} else if (kind === 'directory' && keptDirectories.some((kept) => path.startsWith(`${kept}/`))) {
|
|
125
|
+
continue;
|
|
122
126
|
} else {
|
|
127
|
+
if (kind === 'directory') keptDirectories.push(path);
|
|
123
128
|
reports.push({
|
|
124
129
|
ok: true,
|
|
125
130
|
line: `${PREFIX} kept ${path} (a ${kind} the package does not carry — yours to remove by hand)`,
|
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
SETTINGS_LOCAL_FILE,
|
|
49
49
|
} from './velocity-profile.mjs';
|
|
50
50
|
import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
|
|
51
|
-
import { deriveDoctorPlan } from './autonomy-doctor.mjs';
|
|
51
|
+
import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
|
|
52
|
+
import { laneOf, routeLine } from './write-lanes.mjs';
|
|
52
53
|
import { detectBackends, findOnPath } from './detect-backends.mjs';
|
|
53
54
|
import { isDirectRun } from './direct-run.mjs';
|
|
54
55
|
import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
|
|
@@ -60,7 +61,6 @@ import { loadDeclaration, canonicalCheckerGates, coverageProducerPrecedes, isKit
|
|
|
60
61
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
61
62
|
// The typed channel's READ-ONLY leaf only — never tools/mcp.mjs, which reaches the write core.
|
|
62
63
|
import {
|
|
63
|
-
MCP_JSON_REL,
|
|
64
64
|
SERVER_NAME as MCP_SERVER_NAME,
|
|
65
65
|
STATE as MCP_STATE,
|
|
66
66
|
readRegistration,
|
|
@@ -103,7 +103,7 @@ import {
|
|
|
103
103
|
import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
|
|
104
104
|
import { ENSURE_OPS } from './ensure-vocabulary.mjs';
|
|
105
105
|
import { composeProfileGapScreen } from './profile-gap-screen.mjs';
|
|
106
|
-
import { NO_APPLY_LINE, SKILL_PINS, claudeDirOf,
|
|
106
|
+
import { NO_APPLY_LINE, SKILL_PINS, claudeDirOf, keySet, placeOf, printable, skillLine, skillState, skillTargets } from './jev-facts.mjs';
|
|
107
107
|
|
|
108
108
|
// The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
|
|
109
109
|
const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
|
|
@@ -117,7 +117,7 @@ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_
|
|
|
117
117
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
118
118
|
const toolPath = (rel) => join(HERE, rel);
|
|
119
119
|
const q = shellQuoteArg;
|
|
120
|
-
const
|
|
120
|
+
const ABSENT_CODE = 'ENOENT';
|
|
121
121
|
const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
|
|
122
122
|
const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
|
|
123
123
|
const ENFORCEMENT_WHAT = 'the enforcement scripts are not deployed beside docs/ai (no Node evidence) \u2014 the offer copies them and installs the pre-commit hook';
|
|
@@ -155,6 +155,7 @@ export const SEVERITIES = Object.freeze({
|
|
|
155
155
|
'autonomy-policy': SEVERITY_OPTIONAL,
|
|
156
156
|
'autonomy-render': SEVERITY_ATTENTION,
|
|
157
157
|
'sandbox-provision': SEVERITY_OPTIONAL,
|
|
158
|
+
'sandbox-provision.installable': SEVERITY_OPTIONAL,
|
|
158
159
|
'review-recipe': SEVERITY_ATTENTION,
|
|
159
160
|
'gates-declaration': SEVERITY_OPTIONAL,
|
|
160
161
|
'gates-inert': SEVERITY_ATTENTION,
|
|
@@ -185,12 +186,7 @@ export const SEVERITIES = Object.freeze({
|
|
|
185
186
|
'read-lane.stale': SEVERITY_ATTENTION,
|
|
186
187
|
'read-lane.missing': SEVERITY_ATTENTION,
|
|
187
188
|
'state-block': SEVERITY_OPTIONAL,
|
|
188
|
-
// The typed channel. The base arm is an ordinary offer; `.masked` stays an offer too (nothing is
|
|
189
|
-
// broken — the kit simply cannot write through a device node, so the remedy is handed over);
|
|
190
|
-
// `.differing` reports a CONFIGURED declaration that would launch something else, which is the
|
|
191
|
-
// one state here a maintainer must actually look at.
|
|
192
189
|
'mcp-channel': SEVERITY_OPTIONAL,
|
|
193
|
-
'mcp-channel.masked': SEVERITY_OPTIONAL,
|
|
194
190
|
'mcp-channel.differing': SEVERITY_ATTENTION,
|
|
195
191
|
agents: SEVERITY_OPTIONAL,
|
|
196
192
|
// The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
|
|
@@ -276,7 +272,6 @@ export const WHATS = Object.freeze({
|
|
|
276
272
|
'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
|
|
277
273
|
'state-block': 'nothing checks the closing state block — a turn that ends on «nothing needed from you», or on a promise it never started, passes unseen',
|
|
278
274
|
'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
|
|
279
|
-
'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
|
|
280
275
|
'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
|
|
281
276
|
agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
|
|
282
277
|
'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
|
|
@@ -507,12 +502,18 @@ const probeSandboxProvision = ({ root, deps, add, skip }) => {
|
|
|
507
502
|
try {
|
|
508
503
|
const p = probeSandboxAvailability(deps);
|
|
509
504
|
if (p.available) return;
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
const
|
|
505
|
+
const isExec = deps.isExecutable ?? isExecutableFile;
|
|
506
|
+
const plan = deriveDoctorPlan({ probeResult: p, env: deps.env ?? process.env, isExec });
|
|
507
|
+
const installable = plan.tuple !== null && plan.untrusted.length === 0 && !plan.envMissing
|
|
508
|
+
&& ((deps.euid ?? (() => process.geteuid?.()))() === 0 || TRUSTED_DIRS.some((dir) => isExec(join(dir, 'sudo'))));
|
|
509
|
+
const variant = installable ? 'sandbox-provision.installable' : 'sandbox-provision';
|
|
510
|
+
const reason = truncatedTo(oneLineOf(p.reason), templateBudget(WHATS[variant]) - (installable ? plan.tuple.length : 0));
|
|
513
511
|
// The doctor reads process.cwd() (deployment-gated) and takes no --cwd flag — the one-liner
|
|
514
512
|
// pins the target project via a cd prefix (Segment B).
|
|
515
|
-
add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }),
|
|
513
|
+
add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }),
|
|
514
|
+
`cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`, variant,
|
|
515
|
+
installable ? null : 'on native Windows Claude Code runs no sandbox: this item takes no effect there',
|
|
516
|
+
);
|
|
516
517
|
} catch (err) {
|
|
517
518
|
skip('sandbox-provision', err);
|
|
518
519
|
}
|
|
@@ -884,6 +885,7 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
|
|
|
884
885
|
if (hooksPath === null) return; // not a git tree — nothing to hook
|
|
885
886
|
const hook = (() => {
|
|
886
887
|
try {
|
|
888
|
+
if (readsAbsent(deps, join(hooksPath, 'pre-commit'))) return null;
|
|
887
889
|
return String(read(join(hooksPath, 'pre-commit'), 'utf8'));
|
|
888
890
|
} catch {
|
|
889
891
|
return null;
|
|
@@ -904,7 +906,7 @@ const readEnforcementNode = (path, lstat) => {
|
|
|
904
906
|
try {
|
|
905
907
|
return lstat(path);
|
|
906
908
|
} catch (err) {
|
|
907
|
-
if (err?.code ===
|
|
909
|
+
if (err?.code === ABSENT_CODE) return null;
|
|
908
910
|
throw new Error(`${path}: ${err?.code ?? err?.message ?? err}`);
|
|
909
911
|
}
|
|
910
912
|
};
|
|
@@ -954,7 +956,7 @@ const probeReadLane = ({ root, deps, add, skip, shared }) => {
|
|
|
954
956
|
// is silently dark; surface it as attention with a place-first recovery (council R2-M2).
|
|
955
957
|
const placeRecovery = () =>
|
|
956
958
|
add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
|
|
957
|
-
if (!sg.filePlaced) {
|
|
959
|
+
if (!sg.filePlaced || readsAbsent(deps, join(root, GATE_HOOK_REL))) {
|
|
958
960
|
placeRecovery();
|
|
959
961
|
return;
|
|
960
962
|
}
|
|
@@ -1099,9 +1101,9 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
|
|
|
1099
1101
|
}),
|
|
1100
1102
|
// The writer places a missing vehicle and re-derives a placed executor whose bytes differ from the derived body (an invalid
|
|
1101
1103
|
// frontmatter or read-only tools included), so those states need no precondition; every precondition above is a refusal the apply would meet.
|
|
1102
|
-
|
|
1104
|
+
`node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
|
|
1103
1105
|
'executor-vehicle',
|
|
1104
|
-
`hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
|
|
1106
|
+
`${preconditions.length ? `precondition: ${preconditions.join('; ')}; ` : ''}hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
|
|
1105
1107
|
);
|
|
1106
1108
|
} catch (err) {
|
|
1107
1109
|
skip('executor-vehicle', err);
|
|
@@ -1234,7 +1236,17 @@ const isStateBlockGuardWired = (data) => {
|
|
|
1234
1236
|
// itself (a hook that is not there belongs to the place offers, never to a refresh arm); a symlink,
|
|
1235
1237
|
// a directory and an unreadable target all THROW into the probe's stated-skip lane.
|
|
1236
1238
|
const HOOK_CURRENCY = Object.freeze({ CURRENT: 'current', STALE: 'stale', ABSENT: 'absent' });
|
|
1239
|
+
const readsAbsent = (deps, path) => {
|
|
1240
|
+
try {
|
|
1241
|
+
(deps.lstat ?? lstatSync)(path);
|
|
1242
|
+
return false;
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
return err?.code === ABSENT_CODE;
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1237
1248
|
const readPlacedHookCurrency = (root, deps) => {
|
|
1249
|
+
if (readsAbsent(deps, join(root, GATE_HOOK_REL))) return HOOK_CURRENCY.ABSENT;
|
|
1238
1250
|
const read = deps.readRegularFileNoFollow ?? readRegularFileNoFollow;
|
|
1239
1251
|
const placed = read(join(root, GATE_HOOK_REL));
|
|
1240
1252
|
if (placed.outcome === 'absent') return HOOK_CURRENCY.ABSENT;
|
|
@@ -1521,9 +1533,6 @@ export const probeAdrStore = ({ root, deps, add, skip }) => {
|
|
|
1521
1533
|
|
|
1522
1534
|
// The typed-channel item. It asks the READ-ONLY registration leaf and nothing else — importing the
|
|
1523
1535
|
// writer would pull the atomic-write core into the advisor's graph (read-graph-purity.test.mjs).
|
|
1524
|
-
// The rendered apply is the mode's FLAGLESS preview on purpose: registering an MCP server means the
|
|
1525
|
-
// client will RUN that command, so the entry is read before it is declared and the `--apply` stays
|
|
1526
|
-
// the maintainer's separate step.
|
|
1527
1536
|
const probeMcpChannel = ({ root, deps, add, skip }) => {
|
|
1528
1537
|
try {
|
|
1529
1538
|
const registration = readRegistration(root, deps);
|
|
@@ -1538,49 +1547,16 @@ const probeMcpChannel = ({ root, deps, add, skip }) => {
|
|
|
1538
1547
|
// settings half can end the probe. A differing entry is fully observable, and an unreadable
|
|
1539
1548
|
// settings file says nothing about it — returning on the settings mask first hid it.
|
|
1540
1549
|
assertReadable(registration.mcpJson);
|
|
1541
|
-
const settingsUsable = registration.settings.state !== MCP_STATE.MASKED
|
|
1542
|
-
&& registration.settings.state !== MCP_STATE.FOREIGN
|
|
1543
|
-
&& registration.settings.state !== MCP_STATE.UNREADABLE
|
|
1544
|
-
&& registration.settings.state !== MCP_STATE.MALFORMED;
|
|
1545
1550
|
if (registration.mcpJson.differs) {
|
|
1546
|
-
// The remedy is the maintainer's edit either way; the "then run" tail is dropped where that
|
|
1547
|
-
// command could not succeed, so the item never hands over a line that exits 1.
|
|
1548
|
-
const tail = settingsUsable ? `, then run ${preview}` : '';
|
|
1549
1551
|
add(
|
|
1550
1552
|
'mcp-channel',
|
|
1551
1553
|
fillTemplate(WHATS['mcp-channel.differing'], { server: MCP_SERVER_NAME }),
|
|
1552
|
-
|
|
1554
|
+
preview,
|
|
1553
1555
|
'mcp-channel.differing',
|
|
1554
1556
|
);
|
|
1555
1557
|
return;
|
|
1556
1558
|
}
|
|
1557
1559
|
assertReadable(registration.settings);
|
|
1558
|
-
// The HAND-APPLY arm renders the MODE's own preview, so it may fire only where that command can
|
|
1559
|
-
// actually run — and the writer refuses a masked settings.json outright (it can neither write it
|
|
1560
|
-
// nor merge into what it cannot read). A masked settings half is therefore a stated SKIP: the
|
|
1561
|
-
// completeness this item decides on is unknowable, and offering a command that exits 1 is worse
|
|
1562
|
-
// than saying nothing.
|
|
1563
|
-
if (registration.settings.state === MCP_STATE.MASKED) {
|
|
1564
|
-
skip('mcp-channel', new Error(`${registration.settings.rel} is a ${registration.settings.className} here (a sandbox device mask is the usual cause) — the registration cannot be judged or written from in here; verify it outside the sandbox`));
|
|
1565
|
-
return;
|
|
1566
|
-
}
|
|
1567
|
-
if (registration.mcpJson.state === MCP_STATE.MASKED) {
|
|
1568
|
-
// The kit cannot write through the mask either, so the remedy is HAND-APPLY. But when the
|
|
1569
|
-
// settings half is already complete, the registration was almost certainly made from outside
|
|
1570
|
-
// the sandbox: what cannot be observed becomes a stated SKIP (optimality withheld), never the
|
|
1571
|
-
// same offer again on every single upgrade.
|
|
1572
|
-
if (registration.settings.complete) {
|
|
1573
|
-
skip('mcp-channel', new Error(`${registration.mcpJson.rel} is a ${registration.mcpJson.className} here (a sandbox device mask is the usual cause) and the settings half is already complete — verify the entry outside the sandbox`));
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
add(
|
|
1577
|
-
'mcp-channel',
|
|
1578
|
-
fillTemplate(WHATS['mcp-channel.masked'], { rel: registration.mcpJson.rel, className: registration.mcpJson.className }),
|
|
1579
|
-
`HAND-APPLY: ${preview}`,
|
|
1580
|
-
'mcp-channel.masked',
|
|
1581
|
-
);
|
|
1582
|
-
return;
|
|
1583
|
-
}
|
|
1584
1560
|
if (registration.registered) return;
|
|
1585
1561
|
add('mcp-channel', fillTemplate(WHATS['mcp-channel'], {}), preview);
|
|
1586
1562
|
} catch (err) {
|
|
@@ -1589,16 +1565,16 @@ const probeMcpChannel = ({ root, deps, add, skip }) => {
|
|
|
1589
1565
|
};
|
|
1590
1566
|
|
|
1591
1567
|
// The Jev offer (contract: kit/jev-guide, part jev-offer). The key is judged first, through the facts leaf's one rule over
|
|
1592
|
-
// the injected environment
|
|
1568
|
+
// the injected environment.
|
|
1593
1569
|
export const probeJevConnect = ({ root, deps, add, skip }) => {
|
|
1594
1570
|
try {
|
|
1595
1571
|
const env = deps.getenv ?? {};
|
|
1596
|
-
if (keySet(env)) return;
|
|
1572
|
+
if (deps.keySet === true || keySet(env)) return;
|
|
1597
1573
|
if (readAckValue(root, deps, ACKS_JEV_CONNECT_KEY) === JEV_CONNECT_DECLINE) return;
|
|
1598
1574
|
const host = deps.jevHost ?? {};
|
|
1599
1575
|
const place = placeOf({ env, platform: host.platform, hostname: host.hostname, container: host.container });
|
|
1600
1576
|
const decline = `node ${q(toolPath('ack-write.mjs'))} --lane jev-connect --fingerprint ${JEV_CONNECT_DECLINE} --cwd ${q(root)}`;
|
|
1601
|
-
add('jev-connect', WHATS['jev-connect'],
|
|
1577
|
+
add('jev-connect', WHATS['jev-connect'], routeLine('jev-connect'), 'jev-connect',
|
|
1602
1578
|
`${place ? `run the apply in ${place}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${decline}`);
|
|
1603
1579
|
} catch (err) {
|
|
1604
1580
|
skip('jev-connect', err);
|
|
@@ -1610,7 +1586,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
|
|
|
1610
1586
|
export const probeJevSkill = ({ root, deps, add, skip }) => {
|
|
1611
1587
|
try {
|
|
1612
1588
|
const env = deps.getenv ?? {};
|
|
1613
|
-
if (!keySet(env)) return;
|
|
1589
|
+
if (deps.keySet !== true && !keySet(env)) return;
|
|
1614
1590
|
const host = deps.jevHost ?? {};
|
|
1615
1591
|
if (typeof host.home !== 'string' || host.home === '') return;
|
|
1616
1592
|
const pins = deps.jevPins ?? SKILL_PINS;
|
|
@@ -1710,14 +1686,29 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
|
1710
1686
|
skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
|
|
1711
1687
|
return false;
|
|
1712
1688
|
}
|
|
1713
|
-
items.push({ key, variant, severity: SEVERITIES[variant], what, benefit: BENEFITS[key], apply, detail });
|
|
1689
|
+
items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key], apply: routeLine(variant) ?? apply, detail });
|
|
1714
1690
|
return true;
|
|
1715
1691
|
};
|
|
1716
1692
|
// The per-run scratch a probe uses to tell a LATER probe what it actually did. Written by exactly
|
|
1717
1693
|
// one pair today (the marker-stale ⟷ read-lane.stale precedence) and read in the frozen PROBES
|
|
1718
1694
|
// order, so the reader can never run first.
|
|
1719
1695
|
const shared = {};
|
|
1720
|
-
|
|
1696
|
+
const contentDeps = Object.create(Object.getPrototypeOf(deps), {
|
|
1697
|
+
...Object.getOwnPropertyDescriptors(deps),
|
|
1698
|
+
lstat: {
|
|
1699
|
+
enumerable: true,
|
|
1700
|
+
value: (path, ...args) => {
|
|
1701
|
+
const stat = (deps.lstat ?? deps.lstatSync ?? lstatSync)(path, ...args);
|
|
1702
|
+
if (stat.isCharacterDevice?.() || stat.isBlockDevice?.()) {
|
|
1703
|
+
throw Object.assign(new Error(`${ABSENT_CODE}: ${path}`), { code: ABSENT_CODE });
|
|
1704
|
+
}
|
|
1705
|
+
return stat;
|
|
1706
|
+
},
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
for (const probe of deps.probes ?? PROBES) {
|
|
1710
|
+
probe({ root, deps: probe === probeMasksItem ? deps : contentDeps, add, skip, shared });
|
|
1711
|
+
}
|
|
1721
1712
|
return { root, items, skips };
|
|
1722
1713
|
};
|
|
1723
1714
|
|
|
@@ -1743,6 +1734,7 @@ export const formatRecommendations = ({ items, skips }) => {
|
|
|
1743
1734
|
ordered.forEach((item, i) => {
|
|
1744
1735
|
lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
|
|
1745
1736
|
lines.push(` benefit: ${item.benefit}`);
|
|
1737
|
+
lines.push(` lane: ${item.lane}`);
|
|
1746
1738
|
if (item.detail) lines.push(` recipe: ${item.detail}`);
|
|
1747
1739
|
lines.push(` apply: ${item.apply}`);
|
|
1748
1740
|
});
|
|
@@ -1776,7 +1768,7 @@ Read-only: never writes, never commits, never runs a subscription CLI. Exit code
|
|
|
1776
1768
|
rendered (items or empty); 1 error; 2 usage.`;
|
|
1777
1769
|
|
|
1778
1770
|
// The host facts of the key and skill offers: the platform, the hostname (a throw reads as none), /.dockerenv a regular file, the home.
|
|
1779
|
-
const hostFacts = () => ({ platform: process.platform, hostname: (() => { try { return hostname(); } catch { return ''; } })(),
|
|
1771
|
+
export const hostFacts = () => ({ platform: process.platform, hostname: (() => { try { return hostname(); } catch { return ''; } })(),
|
|
1780
1772
|
container: (() => { try { return statSync('/.dockerenv').isFile(); } catch { return false; } })(),
|
|
1781
1773
|
home: (() => { try { return homedir(); } catch { return ''; } })() });
|
|
1782
1774
|
|