@sabaiway/agent-workflow-kit 5.11.2 → 7.0.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/CHANGELOG.md +117 -0
- package/README.md +3 -2
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-await-guard.test.mjs +176 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +61 -14
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +606 -467
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +42 -4
- package/bridges/codex-cli-bridge/SKILL.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-await-guard.test.mjs +161 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +22 -17
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +356 -363
- package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +275 -286
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/references/driving-codex.md +4 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
- package/bridges/codex-cli-bridge/setup/README.md +3 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/grounding.md +1 -1
- package/references/modes/mcp.md +37 -0
- package/references/modes/procedures.md +3 -3
- package/references/modes/recommendations.md +1 -0
- package/references/modes/uninstall.md +2 -1
- package/references/templates/agent_rules.md +4 -5
- package/tools/commands.mjs +7 -0
- package/tools/direct-run.mjs +3 -0
- package/tools/doc-parity.mjs +18 -2
- package/tools/grounding.mjs +10 -20
- package/tools/inject-methodology.mjs +2 -0
- package/tools/mcp-registration.mjs +283 -0
- package/tools/mcp-server.mjs +314 -0
- package/tools/mcp-stdio.mjs +229 -0
- package/tools/mcp.mjs +299 -0
- package/tools/procedures.mjs +7 -8
- package/tools/recommendations.mjs +90 -1
- package/tools/uninstall.mjs +356 -45
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// mcp-stdio.mjs — JSON-RPC 2.0 line framing for the kit's stdio MCP server (the transport half).
|
|
2
|
+
//
|
|
3
|
+
// The MCP stdio transport is newline-delimited JSON-RPC over stdin/stdout: one message per line, no
|
|
4
|
+
// embedded newline, UTF-8, nothing on stdout that is not a message. This module owns exactly that and
|
|
5
|
+
// nothing about tools: a bounded line reader, the request/notification/response classification, the
|
|
6
|
+
// lifecycle gate (initialize first, ping any time) and a dispatcher that turns a parsed message into
|
|
7
|
+
// ONE response object or null. Streams are INJECTED so the whole contract is testable without a process.
|
|
8
|
+
//
|
|
9
|
+
// Bounds, stated: a line longer than `maxLineBytes` is answered with -32600 (id null) and the transport
|
|
10
|
+
// then CLOSES — a null-id error cannot be correlated with the request that caused it, so a client would
|
|
11
|
+
// otherwise wait on it forever, while a server that exits is one it can restart. (The line reader itself
|
|
12
|
+
// can resync at the next newline; the transport chooses not to continue.) Bytes are buffered and decoded
|
|
13
|
+
// only per complete line, so a UTF-8 code point split across two chunks is never replaced. Dependency-
|
|
14
|
+
// free, Node >= 22, no side effects on import; no CLI of its own (mcp-server.mjs is the entry point).
|
|
15
|
+
|
|
16
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
17
|
+
// Newest first. A requested version in this set is echoed; anything else is answered with the newest,
|
|
18
|
+
// as the lifecycle spec prescribes (the client then decides whether to continue).
|
|
19
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(['2025-06-18', '2025-03-26', '2024-11-05']);
|
|
20
|
+
export const MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
export const JSONRPC_ERRORS = Object.freeze({
|
|
22
|
+
PARSE_ERROR: -32700,
|
|
23
|
+
INVALID_REQUEST: -32600,
|
|
24
|
+
METHOD_NOT_FOUND: -32601,
|
|
25
|
+
INVALID_PARAMS: -32602,
|
|
26
|
+
INTERNAL_ERROR: -32603,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const JSONRPC_VERSION = '2.0';
|
|
30
|
+
const NEWLINE = 0x0a;
|
|
31
|
+
const CARRIAGE_RETURN = 0x0d;
|
|
32
|
+
const METHOD_INITIALIZE = 'initialize';
|
|
33
|
+
const METHOD_PING = 'ping';
|
|
34
|
+
const PRE_INITIALIZE_METHODS = Object.freeze([METHOD_INITIALIZE, METHOD_PING]);
|
|
35
|
+
|
|
36
|
+
export const rpcError = (code, message) => Object.assign(new Error(message), { rpcCode: code });
|
|
37
|
+
|
|
38
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
39
|
+
// MCP's RequestId is a string or an integer, never null; and a numeric id must survive parse →
|
|
40
|
+
// serialize unchanged, or the answer no longer correlates with the request: only a safe integer does
|
|
41
|
+
// (1.5, 1e400 and friends are refused as invalid requests). The server's OWN null id on an error
|
|
42
|
+
// for an unparseable line is a different thing: a response, not a request.
|
|
43
|
+
const isValidId = (id) => typeof id === 'string' || Number.isSafeInteger(id);
|
|
44
|
+
// A client RESPONSE carries an id and exactly one of result / error; it is the one shape the server
|
|
45
|
+
// never answers. Anything else without a method is an invalid request, answered — never swallowed.
|
|
46
|
+
const isClientResponse = (message) =>
|
|
47
|
+
'id' in message && (Object.hasOwn(message, 'result') !== Object.hasOwn(message, 'error'));
|
|
48
|
+
|
|
49
|
+
export const negotiateProtocolVersion = (requested) =>
|
|
50
|
+
SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSION;
|
|
51
|
+
|
|
52
|
+
export const isNotification = (message) => typeof message.method === 'string' && !('id' in message);
|
|
53
|
+
|
|
54
|
+
// One line → { message } or { error: { code, message } } (the error is answered with id null).
|
|
55
|
+
export const parseLine = (line) => {
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
return { error: { code: JSONRPC_ERRORS.PARSE_ERROR, message: 'Parse error' } };
|
|
61
|
+
}
|
|
62
|
+
if (!isPlainObject(parsed) || parsed.jsonrpc !== JSONRPC_VERSION) {
|
|
63
|
+
return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: not a JSON-RPC 2.0 object' } };
|
|
64
|
+
}
|
|
65
|
+
if ('id' in parsed && !isValidId(parsed.id)) {
|
|
66
|
+
return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: id must be a string or an integer' } };
|
|
67
|
+
}
|
|
68
|
+
if ('method' in parsed && typeof parsed.method !== 'string') {
|
|
69
|
+
return { error: { code: JSONRPC_ERRORS.INVALID_REQUEST, message: 'Invalid Request: method must be a string' } };
|
|
70
|
+
}
|
|
71
|
+
return { message: parsed };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const errorResponse = (id, code, message) => ({ jsonrpc: JSONRPC_VERSION, id, error: { code, message } });
|
|
75
|
+
const resultResponse = (id, result) => ({ jsonrpc: JSONRPC_VERSION, id, result });
|
|
76
|
+
|
|
77
|
+
// A lossy decode would replace an invalid byte with U+FFFD and the request could then name a DIFFERENT,
|
|
78
|
+
// existing path — the substitution class both readers refuse; a line that is not UTF-8 is a parse error.
|
|
79
|
+
const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
|
|
80
|
+
|
|
81
|
+
// Bytes in, complete lines out. The pending buffer is capped: past `maxLineBytes` without a newline the
|
|
82
|
+
// reader drops bytes until the next newline, reporting the overflow ONCE, then frames normally again.
|
|
83
|
+
export const createLineReader = ({ maxLineBytes = MAX_LINE_BYTES, onLine, onOverflow, onInvalidUtf8 = () => {} }) => {
|
|
84
|
+
let pending = [];
|
|
85
|
+
let pendingBytes = 0;
|
|
86
|
+
let discarding = false;
|
|
87
|
+
|
|
88
|
+
const emit = () => {
|
|
89
|
+
const buf = Buffer.concat(pending, pendingBytes);
|
|
90
|
+
pending = [];
|
|
91
|
+
pendingBytes = 0;
|
|
92
|
+
const end = buf.length > 0 && buf[buf.length - 1] === CARRIAGE_RETURN ? buf.length - 1 : buf.length;
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = strictUtf8.decode(buf.subarray(0, end));
|
|
96
|
+
} catch {
|
|
97
|
+
onInvalidUtf8();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
onLine(text);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const feed = (chunk) => {
|
|
104
|
+
let from = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const at = chunk.indexOf(NEWLINE, from);
|
|
107
|
+
const piece = chunk.subarray(from, at === -1 ? chunk.length : at);
|
|
108
|
+
if (discarding) {
|
|
109
|
+
if (at === -1) return;
|
|
110
|
+
discarding = false;
|
|
111
|
+
} else if (pendingBytes + piece.length > maxLineBytes) {
|
|
112
|
+
pending = [];
|
|
113
|
+
pendingBytes = 0;
|
|
114
|
+
onOverflow();
|
|
115
|
+
if (at === -1) {
|
|
116
|
+
discarding = true;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
if (piece.length > 0) {
|
|
121
|
+
pending.push(piece);
|
|
122
|
+
pendingBytes += piece.length;
|
|
123
|
+
}
|
|
124
|
+
if (at === -1) return;
|
|
125
|
+
emit();
|
|
126
|
+
}
|
|
127
|
+
from = at + 1;
|
|
128
|
+
if (from >= chunk.length) return;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// A final line without a trailing newline is still a message; a discarded tail is not.
|
|
133
|
+
const end = () => {
|
|
134
|
+
if (!discarding && pendingBytes > 0) emit();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return { feed, end };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// handlers: { [method]: (params) => result } for everything beyond initialize/ping. A handler may throw
|
|
141
|
+
// rpcError(code, message) for a typed error; any other throw is an internal error WITH its message.
|
|
142
|
+
export const createDispatcher = ({ handlers = {}, serverInfo, capabilities = { tools: {} }, instructions }) => {
|
|
143
|
+
let initialized = false;
|
|
144
|
+
|
|
145
|
+
const handle = (message) => {
|
|
146
|
+
if (typeof message.method !== 'string') {
|
|
147
|
+
if (isClientResponse(message)) return null; // a response to something — never answered
|
|
148
|
+
return errorResponse(message.id ?? null, JSONRPC_ERRORS.INVALID_REQUEST, 'Invalid Request: neither a request, a notification nor a response');
|
|
149
|
+
}
|
|
150
|
+
if (isNotification(message)) return null; // initialized, cancelled, anything: silence
|
|
151
|
+
const { id, method } = message;
|
|
152
|
+
if ('params' in message && message.params !== undefined && !isPlainObject(message.params)) {
|
|
153
|
+
return errorResponse(id, JSONRPC_ERRORS.INVALID_PARAMS, `Invalid params: "${method}" params must be an object`);
|
|
154
|
+
}
|
|
155
|
+
if (!initialized && !PRE_INITIALIZE_METHODS.includes(method)) {
|
|
156
|
+
return errorResponse(id, JSONRPC_ERRORS.INVALID_REQUEST, `Invalid Request: "${method}" before initialize`);
|
|
157
|
+
}
|
|
158
|
+
if (method === METHOD_PING) return resultResponse(id, {});
|
|
159
|
+
if (method === METHOD_INITIALIZE) {
|
|
160
|
+
// The three required fields are checked BEFORE the state flips: a malformed initialize leaves the
|
|
161
|
+
// server un-initialized and is answered as invalid params, never served as a handshake.
|
|
162
|
+
const params = isPlainObject(message.params) ? message.params : null;
|
|
163
|
+
const client = params !== null && isPlainObject(params.clientInfo) ? params.clientInfo : null;
|
|
164
|
+
if (params === null || typeof params.protocolVersion !== 'string' || !isPlainObject(params.capabilities) || client === null || typeof client.name !== 'string' || typeof client.version !== 'string') {
|
|
165
|
+
return errorResponse(id, JSONRPC_ERRORS.INVALID_PARAMS, 'initialize: protocolVersion (string), capabilities (object) and clientInfo { name, version } (strings) are required');
|
|
166
|
+
}
|
|
167
|
+
initialized = true;
|
|
168
|
+
const result = { protocolVersion: negotiateProtocolVersion(params.protocolVersion), capabilities, serverInfo };
|
|
169
|
+
if (typeof instructions === 'string') result.instructions = instructions;
|
|
170
|
+
return resultResponse(id, result);
|
|
171
|
+
}
|
|
172
|
+
const handler = Object.hasOwn(handlers, method) ? handlers[method] : undefined;
|
|
173
|
+
if (typeof handler !== 'function') return errorResponse(id, JSONRPC_ERRORS.METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
174
|
+
try {
|
|
175
|
+
return resultResponse(id, handler(isPlainObject(message.params) ? message.params : {}));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (typeof err?.rpcCode === 'number') return errorResponse(id, err.rpcCode, err.message);
|
|
178
|
+
return errorResponse(id, JSONRPC_ERRORS.INTERNAL_ERROR, `Internal error: ${err?.message ?? err}`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { handle };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Serve until the input ends. `input` is an async iterable of Buffers (process.stdin, a PassThrough),
|
|
186
|
+
// `output` anything with write(string) — and, when it also has once(), its backpressure is honoured:
|
|
187
|
+
// a write that returns false stops the flush until 'drain', so a run of large answers never piles up
|
|
188
|
+
// unbounded in process memory. Every write is exactly one JSON document plus "\n" — JSON.stringify
|
|
189
|
+
// escapes every newline inside strings, so no message ever embeds one.
|
|
190
|
+
export const serveStdio = async ({ input, output, dispatcher, maxLineBytes = MAX_LINE_BYTES }) => {
|
|
191
|
+
// The reader only QUEUES framed lines (bounded by the chunk size); each line is dispatched and its
|
|
192
|
+
// answer written one at a time, with the drain wait BETWEEN lines — so a chunk holding many compact
|
|
193
|
+
// requests never has all their answers computed and held in memory at once.
|
|
194
|
+
const pending = [];
|
|
195
|
+
const reader = createLineReader({
|
|
196
|
+
maxLineBytes,
|
|
197
|
+
onOverflow: () => pending.push({ overflow: true }),
|
|
198
|
+
onInvalidUtf8: () => pending.push({ invalidUtf8: true }),
|
|
199
|
+
onLine: (line) => pending.push({ line }),
|
|
200
|
+
});
|
|
201
|
+
const answerFor = (entry) => {
|
|
202
|
+
if (entry.overflow) return errorResponse(null, JSONRPC_ERRORS.INVALID_REQUEST, `Invalid Request: line exceeds ${maxLineBytes} byte(s) — the transport is closing; restart the server`);
|
|
203
|
+
if (entry.invalidUtf8) return errorResponse(null, JSONRPC_ERRORS.PARSE_ERROR, 'Parse error: the line is not valid UTF-8');
|
|
204
|
+
if (entry.line.trim() === '') return null;
|
|
205
|
+
const parsed = parseLine(entry.line);
|
|
206
|
+
if (parsed.error) return errorResponse(null, parsed.error.code, parsed.error.message);
|
|
207
|
+
return dispatcher.handle(parsed.message);
|
|
208
|
+
};
|
|
209
|
+
// Returns true once the transport must close: a null-id overflow error cannot be correlated, so
|
|
210
|
+
// nothing after it is served — not even the rest of the same chunk.
|
|
211
|
+
const drainPending = async () => {
|
|
212
|
+
while (pending.length > 0) {
|
|
213
|
+
const entry = pending.shift();
|
|
214
|
+
const response = answerFor(entry);
|
|
215
|
+
if (response !== null) {
|
|
216
|
+
const accepted = output.write(`${JSON.stringify(response)}\n`);
|
|
217
|
+
if (accepted === false && typeof output.once === 'function') await new Promise((resolve) => output.once('drain', resolve));
|
|
218
|
+
}
|
|
219
|
+
if (entry.overflow) return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
};
|
|
223
|
+
for await (const chunk of input) {
|
|
224
|
+
reader.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
225
|
+
if (await drainPending()) return;
|
|
226
|
+
}
|
|
227
|
+
reader.end();
|
|
228
|
+
await drainPending();
|
|
229
|
+
};
|
package/tools/mcp.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp.mjs — the guarded writer behind `/agent-workflow-kit mcp`: registers the kit's stdio MCP
|
|
3
|
+
// server in ONE project, so the typed readers (`path_inventory`, `repo_search`) reach a deployed
|
|
4
|
+
// project instead of only the repo that built them. It writes exactly two files:
|
|
5
|
+
// • `.mcp.json` at the project root — the `agent-workflow` stdio entry (command `node`, args = the
|
|
6
|
+
// RUNNING kit's tools/mcp-server.mjs, absolute);
|
|
7
|
+
// • `.claude/settings.json` — `enabledMcpjsonServers: ["agent-workflow"]` plus the two allow rules
|
|
8
|
+
// derived from the server's own SERVER_NAME + TOOLS.
|
|
9
|
+
//
|
|
10
|
+
// Same family writer discipline as gate-hook.mjs, and the same reasons:
|
|
11
|
+
// • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
|
|
12
|
+
// • the ENTRY is printed BEFORE consent — a registration is a command the client will RUN, so the
|
|
13
|
+
// exact structured value is on screen at the moment the decision is made, never described in
|
|
14
|
+
// prose. It is re-serialized for the preview, so it is that VALUE and not those literal bytes;
|
|
15
|
+
// • `.mcp.json` FIRST, then settings — settings enabling a server whose entry is not yet there
|
|
16
|
+
// would be a client error on every startup;
|
|
17
|
+
// • merge-don't-clobber — foreign servers, foreign settings keys and existing allow rules are
|
|
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
|
|
20
|
+
// re-serialized identical entry is the SAME registration) — silently changing what an MCP server
|
|
21
|
+
// launches is exactly what consent must not slide past; the recovery is named;
|
|
22
|
+
// • the preflight is READ-ONLY: an absent `.claude/` is a NAMED state, and the dir is created on
|
|
23
|
+
// `--apply` only (assertCreatableDirSafe mkdirs, so it may not run in a preview);
|
|
24
|
+
// • a MASKED target (an OS sandbox injects a character device where `.mcp.json` would be — this
|
|
25
|
+
// repo is exactly that case) is not a failure: the kit hands over both paste-ready fragments,
|
|
26
|
+
// writes nothing, and exits 0. Where it cannot write, it says precisely what it would have.
|
|
27
|
+
// • never `settings.local.json`; never commits.
|
|
28
|
+
//
|
|
29
|
+
// The read half lives in mcp-registration.mjs, which the advisor and `uninstall` use — so what is
|
|
30
|
+
// there and what would be written are computed by one module, never by two that can disagree.
|
|
31
|
+
//
|
|
32
|
+
// Exit codes: 0 done / dry-run (incl. the hand-apply masked state); 1 precondition STOP; 2 usage.
|
|
33
|
+
// Dependency-free beyond the kit's own exports, Node >= 22. No side effects on import.
|
|
34
|
+
|
|
35
|
+
import { lstatSync } from 'node:fs';
|
|
36
|
+
import { join, resolve } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
import { assertCreatableDirSafe, writeContainedFileAtomic } from './atomic-write.mjs';
|
|
39
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
40
|
+
import {
|
|
41
|
+
CLAUDE_DIR_REL,
|
|
42
|
+
ENABLED_KEY,
|
|
43
|
+
MCP_JSON_REL,
|
|
44
|
+
SERVERS_KEY,
|
|
45
|
+
SERVER_NAME,
|
|
46
|
+
SETTINGS_REL,
|
|
47
|
+
STATE,
|
|
48
|
+
formatJson,
|
|
49
|
+
mergeMcpJson,
|
|
50
|
+
mergeSettings,
|
|
51
|
+
readRegistration,
|
|
52
|
+
renderFragments,
|
|
53
|
+
} from './mcp-registration.mjs';
|
|
54
|
+
import { shellQuoteArg } from './review-state.mjs';
|
|
55
|
+
|
|
56
|
+
const q = shellQuoteArg;
|
|
57
|
+
|
|
58
|
+
export const MCP_SYMLINK = 'MCP_SYMLINK';
|
|
59
|
+
export const MCP_MALFORMED = 'MCP_MALFORMED';
|
|
60
|
+
export const MCP_DIFFERS = 'MCP_DIFFERS';
|
|
61
|
+
|
|
62
|
+
const EXIT_OK = 0;
|
|
63
|
+
const EXIT_PRECONDITION = 1;
|
|
64
|
+
const EXIT_USAGE = 2;
|
|
65
|
+
const ERROR_PREFIX = '[agent-workflow-kit]';
|
|
66
|
+
const LF = '\n';
|
|
67
|
+
const JSON_INDENT = 2;
|
|
68
|
+
|
|
69
|
+
export const MCP_TOOL = fileURLToPath(import.meta.url);
|
|
70
|
+
export const applyMcpCommand = (root) => `node ${q(MCP_TOOL)} --apply --cwd ${q(root)}`;
|
|
71
|
+
|
|
72
|
+
const USAGE = `usage: mcp [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
73
|
+
|
|
74
|
+
Registers this kit's stdio MCP server in ONE project: the "${SERVER_NAME}" entry in
|
|
75
|
+
${MCP_JSON_REL}, and "${ENABLED_KEY}" + the two tool allow rules in ${SETTINGS_REL}.
|
|
76
|
+
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.
|
|
78
|
+
|
|
79
|
+
An existing "${SERVER_NAME}" entry that STRUCTURALLY DIFFERS is refused unwritten (key
|
|
80
|
+
order is ignored). Where ${MCP_JSON_REL} is a device node, FIFO or socket — an OS sandbox
|
|
81
|
+
mask is the usual cause — the entry to merge is printed and nothing is written.
|
|
82
|
+
Never writes settings.local.json; never commits.`;
|
|
83
|
+
|
|
84
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
85
|
+
|
|
86
|
+
export const makeMcpError = (code, message) =>
|
|
87
|
+
Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'McpError', code, exitCode: EXIT_PRECONDITION });
|
|
88
|
+
|
|
89
|
+
const lstatNoFollow = (path, lstat = lstatSync) => {
|
|
90
|
+
try {
|
|
91
|
+
return (lstat ?? lstatSync)(path);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err && err.code === 'ENOENT') return null;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ── preflight (READ-ONLY — it creates nothing, on either lane) ─────────────────────────
|
|
99
|
+
|
|
100
|
+
// A target we could not read or parse is never overwritten: a merge over a file whose current
|
|
101
|
+
// content is unknown is a clobber wearing a merge's name.
|
|
102
|
+
//
|
|
103
|
+
// `maskedAllowed` is TRUE for `.mcp.json` alone. That file has a sanctioned handoff — the kit hands
|
|
104
|
+
// over the entry for a human to merge from outside the sandbox — and `settings.json` has none, so a
|
|
105
|
+
// mask there is an ordinary refusal rather than a second, unplanned success path.
|
|
106
|
+
const assertTargetUsable = (target, { maskedAllowed = false } = {}) => {
|
|
107
|
+
if (target.state === STATE.MASKED) {
|
|
108
|
+
if (maskedAllowed) return;
|
|
109
|
+
throw makeMcpError(
|
|
110
|
+
MCP_SYMLINK,
|
|
111
|
+
`${target.rel} is a ${target.className} (an OS sandbox device mask is the usual cause) — this mode can neither write it nor merge into what it cannot read`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (target.state === STATE.FOREIGN) {
|
|
115
|
+
throw makeMcpError(MCP_SYMLINK, `${target.rel} is a ${target.className} — refusing to write through it`);
|
|
116
|
+
}
|
|
117
|
+
if (target.state === STATE.MALFORMED) {
|
|
118
|
+
throw makeMcpError(MCP_MALFORMED, `${target.rel} is ${target.reason} — refusing to overwrite it; fix or remove it, then re-run`);
|
|
119
|
+
}
|
|
120
|
+
if (target.state === STATE.UNREADABLE) {
|
|
121
|
+
throw makeMcpError(MCP_MALFORMED, `${target.rel} cannot be read (${target.reason}) — refusing to overwrite what was never read`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const preflightMcp = ({ cwd }, deps = {}) => {
|
|
126
|
+
const root = resolve(cwd ?? deps.cwd ?? process.cwd());
|
|
127
|
+
// The ROOT is judged BEFORE the registration is read, not merely before the first write: reading
|
|
128
|
+
// first would follow the very link this refuses, which is exactly what the shipped claim denies.
|
|
129
|
+
const rootStat = lstatNoFollow(root, deps.lstat);
|
|
130
|
+
// A target that does not exist read as "a project with two absent files", so the preview offered an
|
|
131
|
+
// apply that could only ENOENT. Both lanes refuse the same way, at the same point.
|
|
132
|
+
if (rootStat === null) {
|
|
133
|
+
throw makeMcpError(MCP_SYMLINK, `${root} does not exist — name an existing project directory`);
|
|
134
|
+
}
|
|
135
|
+
if (rootStat.isSymbolicLink()) {
|
|
136
|
+
throw makeMcpError(MCP_SYMLINK, `${root} is a symlink — refusing to register into a symlinked project root`);
|
|
137
|
+
}
|
|
138
|
+
if (!rootStat.isDirectory()) {
|
|
139
|
+
throw makeMcpError(MCP_SYMLINK, `${root} is not a directory — name an existing project directory`);
|
|
140
|
+
}
|
|
141
|
+
const registration = readRegistration(root, deps);
|
|
142
|
+
// ORDER IS THE CONTRACT. Every OBSERVABLE surface is judged first — the container, both targets'
|
|
143
|
+
// classes, then the entry itself — and only a run that survives all of them may reach the one
|
|
144
|
+
// handoff this mode has. Taking the handoff early made a mask on ANY surface swallow the refusals
|
|
145
|
+
// behind it: a differing entry and a malformed settings file both came back as a cheerful exit 0.
|
|
146
|
+
const dir = registration.claudeDir;
|
|
147
|
+
if (dir.state === STATE.FOREIGN) {
|
|
148
|
+
throw makeMcpError(MCP_SYMLINK, `${dir.rel} is a ${dir.className} — refusing to write through it`);
|
|
149
|
+
}
|
|
150
|
+
if (dir.state === STATE.UNREADABLE) {
|
|
151
|
+
throw makeMcpError(MCP_MALFORMED, `${dir.rel} cannot be inspected (${dir.reason}) — refusing to write into it`);
|
|
152
|
+
}
|
|
153
|
+
assertTargetUsable(registration.mcpJson, { maskedAllowed: true });
|
|
154
|
+
assertTargetUsable(registration.settings);
|
|
155
|
+
if (registration.mcpJson.differs) {
|
|
156
|
+
throw makeMcpError(
|
|
157
|
+
MCP_DIFFERS,
|
|
158
|
+
`${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`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const masked = registration.mcpJson.state === STATE.MASKED;
|
|
162
|
+
return { root, registration, masked, plan: planMcp(registration) };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// The plan is pure over the registration: what is missing, and the exact body each file would get.
|
|
166
|
+
// Both bodies are built even when nothing is written — they ARE the preview and the hand-apply text.
|
|
167
|
+
export const planMcp = (registration) => ({
|
|
168
|
+
writeMcpJson: !registration.mcpJson.matches,
|
|
169
|
+
writeSettings: !registration.settings.complete,
|
|
170
|
+
mcpBody: formatJson(mergeMcpJson(registration), registration.mcpJson.eol),
|
|
171
|
+
settingsBody: formatJson(mergeSettings(registration), registration.settings.eol),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── the writer ─────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export const writeMcp = ({ cwd, dryRun = true } = {}, deps = {}) => {
|
|
177
|
+
const preflight = preflightMcp({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
|
|
178
|
+
const base = { ...preflight, dryRun, wrote: false };
|
|
179
|
+
if (preflight.masked) return { ...base, fragments: renderFragments(preflight.registration) };
|
|
180
|
+
if (dryRun) return base;
|
|
181
|
+
|
|
182
|
+
const { root, registration, plan } = preflight;
|
|
183
|
+
const stop = (message) => makeMcpError(MCP_SYMLINK, message);
|
|
184
|
+
if (plan.writeMcpJson) {
|
|
185
|
+
writeContainedFileAtomic(root, registration.mcpJson.abs, plan.mcpBody, deps, { stop, label: MCP_JSON_REL });
|
|
186
|
+
}
|
|
187
|
+
if (plan.writeSettings) {
|
|
188
|
+
// The ONE write the preflight deliberately does not do: creating `.claude/` is a mutation, so it
|
|
189
|
+
// belongs on the apply lane only — a preview that made a directory would not be a preview.
|
|
190
|
+
assertCreatableDirSafe(join(root, CLAUDE_DIR_REL), deps, { stop, noun: SETTINGS_REL });
|
|
191
|
+
writeContainedFileAtomic(root, registration.settings.abs, plan.settingsBody, deps, { stop, label: SETTINGS_REL });
|
|
192
|
+
}
|
|
193
|
+
return { ...base, wrote: plan.writeMcpJson || plan.writeSettings };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ── the report ─────────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const POSTURE_LINE =
|
|
199
|
+
'trust posture: the registered server is a READ-ONLY child of your MCP client (path/type/size/line facts and literal search over this project root) — it runs OUTSIDE the Bash sandbox, as the client itself does, and exposes no write or exec API. The two allow rules make its tool calls promptless; nothing else in this project changes.';
|
|
200
|
+
|
|
201
|
+
const indented = (text) => text.trimEnd().split(LF).map((line) => ` ${line}`).join(LF);
|
|
202
|
+
|
|
203
|
+
const mcpJsonLine = (result) => {
|
|
204
|
+
if (!result.plan.writeMcpJson) return ` - ${MCP_JSON_REL}: already current`;
|
|
205
|
+
const verb = result.dryRun ? 'would add' : 'added';
|
|
206
|
+
return ` - ${MCP_JSON_REL}: ${verb} the "${SERVER_NAME}" stdio entry`;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const settingsLine = (result) => {
|
|
210
|
+
const { registration, plan, dryRun } = result;
|
|
211
|
+
if (!plan.writeSettings) return ` - ${SETTINGS_REL}: already current`;
|
|
212
|
+
const parts = [];
|
|
213
|
+
if (!registration.settings.enabled) parts.push(`"${ENABLED_KEY}" += "${SERVER_NAME}"`);
|
|
214
|
+
if (registration.settings.allowMissing.length > 0) parts.push(`allow += ${registration.settings.allowMissing.join(', ')}`);
|
|
215
|
+
return ` - ${SETTINGS_REL}: ${dryRun ? 'would set' : 'set'} ${parts.join(' · ')}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// The hand-apply text. The two halves are worded differently because the kit KNOWS different things
|
|
219
|
+
// about them: the settings body is a real merge over content it read, while the `.mcp.json` half is
|
|
220
|
+
// the entry ALONE — behind the mask this mode cannot see what that file already declares, and a
|
|
221
|
+
// whole-file body pasted as instructed would delete every server it could not see.
|
|
222
|
+
const maskedReport = (result) => {
|
|
223
|
+
const target = result.registration.mcpJson;
|
|
224
|
+
return [
|
|
225
|
+
// The CLASS is what was observed; the sandbox mask is the usual CAUSE but is not established here.
|
|
226
|
+
`agent-workflow MCP registration — HAND-APPLY: ${MCP_JSON_REL} is a ${target.className} (an OS sandbox device mask is the usual cause), so nothing was written.`,
|
|
227
|
+
` merge this entry into ${MCP_JSON_REL} under "${SERVERS_KEY}", and keep every other server it already declares (this mode cannot read them through the mask):`,
|
|
228
|
+
indented(`"${SERVER_NAME}": ${result.fragments.mcpEntry.trimEnd()}`),
|
|
229
|
+
` merge into ${SETTINGS_REL} (that file was observable — and read where present — so this body already carries what is in it):`,
|
|
230
|
+
indented(result.fragments.settings),
|
|
231
|
+
POSTURE_LINE,
|
|
232
|
+
].join(LF);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const formatResult = (result) => {
|
|
236
|
+
if (result.masked) return maskedReport(result);
|
|
237
|
+
const nothingToDo = !result.plan.writeMcpJson && !result.plan.writeSettings;
|
|
238
|
+
if (nothingToDo) {
|
|
239
|
+
return [`agent-workflow MCP registration — already registered ("${SERVER_NAME}"); nothing to do.`, POSTURE_LINE].join(LF);
|
|
240
|
+
}
|
|
241
|
+
const lines = [
|
|
242
|
+
result.dryRun
|
|
243
|
+
? 'agent-workflow MCP registration — DRY RUN (no changes; re-run with --apply)'
|
|
244
|
+
: 'agent-workflow MCP registration — APPLY',
|
|
245
|
+
mcpJsonLine(result),
|
|
246
|
+
settingsLine(result),
|
|
247
|
+
' the entry this registration declares (re-serialized here; the same structured value goes into the file):',
|
|
248
|
+
indented(JSON.stringify(result.registration.entry, null, JSON_INDENT)),
|
|
249
|
+
POSTURE_LINE,
|
|
250
|
+
];
|
|
251
|
+
if (result.dryRun) lines.push(` to apply: ${applyMcpCommand(result.root)}`);
|
|
252
|
+
return lines.join(LF);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ── CLI ────────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
export const parseArgs = (argv) => {
|
|
258
|
+
const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
|
|
259
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
260
|
+
const arg = argv[i];
|
|
261
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
262
|
+
else if (arg === '--dry-run') opts.dryRunFlag = true;
|
|
263
|
+
else if (arg === '--apply') opts.apply = true;
|
|
264
|
+
else if (arg === '--cwd') {
|
|
265
|
+
i += 1;
|
|
266
|
+
// An EMPTY (or whitespace) value passes both guards above, and `resolve('')` silently means the
|
|
267
|
+
// process cwd — so an explicit target of "" would write the registration wherever the tool
|
|
268
|
+
// happened to run. An explicit argument that names nothing is a usage error, never a default.
|
|
269
|
+
if (argv[i] === undefined || argv[i].startsWith('-') || argv[i].trim() === '') {
|
|
270
|
+
throw fail(EXIT_USAGE, '--cwd needs a directory argument');
|
|
271
|
+
}
|
|
272
|
+
opts.cwd = argv[i];
|
|
273
|
+
} else {
|
|
274
|
+
throw fail(EXIT_USAGE, `unknown argument: ${arg}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
|
|
278
|
+
return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
282
|
+
const log = deps.log ?? console.log;
|
|
283
|
+
const errlog = deps.errlog ?? console.error;
|
|
284
|
+
try {
|
|
285
|
+
const args = parseArgs(argv);
|
|
286
|
+
if (args.help) {
|
|
287
|
+
log(USAGE);
|
|
288
|
+
return EXIT_OK;
|
|
289
|
+
}
|
|
290
|
+
log(formatResult(writeMcp({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
|
|
291
|
+
return EXIT_OK;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
errlog(err?.message ?? String(err));
|
|
294
|
+
if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
|
|
295
|
+
return err?.exitCode ?? EXIT_PRECONDITION;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (isDirectRun(import.meta.url)) process.exit(main(process.argv.slice(2)));
|
package/tools/procedures.mjs
CHANGED
|
@@ -208,8 +208,8 @@ const backendSetLabel = (backends) =>
|
|
|
208
208
|
: ` → ${backends[0]}`;
|
|
209
209
|
|
|
210
210
|
// The review-loop economics block (M1 + M6's firing half) — printed when the activity engages a review
|
|
211
|
-
// backend (a slot resolving reviewed | council) and OMITTED for solo. It paraphrases the
|
|
212
|
-
// orchestration §4 canon (no rival rule): the ≤2-round architecture cap, the bar met by RAISING a
|
|
211
|
+
// backend (a slot resolving reviewed | council) and OMITTED for solo. It paraphrases the procedures.md
|
|
212
|
+
// Fold + loop step + orchestration §4 canon (no rival rule): the ≤2-round architecture cap, the bar met by RAISING a
|
|
213
213
|
// surviving major to an acceptance invariant (not exhausting prose), backend divergence = the crossover
|
|
214
214
|
// stop, the thin-plan/diff-review carve-out, a self-consistency read before every re-review, and the
|
|
215
215
|
// REQUIRED per-round structured emission {round N · finding-origin tally · per-backend verdict}. Only a
|
|
@@ -221,7 +221,7 @@ const REVIEW_RECIPES = new Set(['reviewed', 'council']);
|
|
|
221
221
|
const reviewLoopAdvice = (slots, activity) =>
|
|
222
222
|
slots.some((s) => REVIEW_RECIPES.has(s.recipe))
|
|
223
223
|
? [
|
|
224
|
-
'Review-loop economics (
|
|
224
|
+
'Review-loop economics (procedures.md Fold + loop · orchestration.md §4) — the review this recipe runs:',
|
|
225
225
|
' • Cap architecture plan-review at ≤2 rounds; the bar is met by RAISING a surviving major to an acceptance invariant (or handing it to Execute/diff-review), never by exhausting the strictest backend.',
|
|
226
226
|
' • Backend divergence (one backend grounded-ships while another keeps revising mechanics) IS the crossover stop.',
|
|
227
227
|
' • Route an all-mechanics/CI or prose-only artifact to a thin plan + diff-review; run a self-consistency read before every re-review.',
|
|
@@ -299,7 +299,7 @@ const autonomyAdvice = (activity, facts) => {
|
|
|
299
299
|
};
|
|
300
300
|
|
|
301
301
|
// The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
|
|
302
|
-
// at the point of use like reviewLoopAdvice paraphrases §
|
|
302
|
+
// at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
|
|
303
303
|
// every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
|
|
304
304
|
// which fires only when a review backend engages). It may name the kit's own GENERIC L0
|
|
305
305
|
// surfaces (the gate runner, the rotation checks, the cheap-agents vehicles) — point-of-use
|
|
@@ -368,9 +368,9 @@ const flowHalvesAdvice = (flow, probe) => {
|
|
|
368
368
|
};
|
|
369
369
|
|
|
370
370
|
// ── the declared source-size practice (D-17 U1) ────────────────────────────────────
|
|
371
|
-
// A practice the agent meets only when a gate refuses is a practice learned too late: the caps
|
|
372
|
-
// reason
|
|
373
|
-
//
|
|
371
|
+
// A practice the agent meets only when a gate refuses is a practice learned too late: the caps and
|
|
372
|
+
// their reason ride EVERY named-activity render, so the plan's Module ledger is cut to them while the
|
|
373
|
+
// plan is being written. Composed from the project's live declaration, never from constants here.
|
|
374
374
|
// Each config state speaks as itself: ABSENT renders NOTHING (a project that declares no practice must
|
|
375
375
|
// not be handed invented limits); AUTHORED and INCOMPLETE render the declared caps plus the honest
|
|
376
376
|
// "nothing is recorded yet" line — both are pre-mint states, and treating INCOMPLETE as MINTED would
|
|
@@ -406,7 +406,6 @@ const declaredPracticeAdvice = (cwd, readFile, lstat) => {
|
|
|
406
406
|
? ` recorded: ${facts.recordedFiles} file(s) carry a recorded size (debt, not permission) · aggregate ${facts.aggregateLines} line(s), EXACT — growth takes a reasoned bump, never free headroom.`
|
|
407
407
|
: unmintedRecord,
|
|
408
408
|
` why: ${SOURCE_SIZE_WHY}`,
|
|
409
|
-
' at plan time: every Step that CREATES a file names the file and its single responsibility, and the planned layout fits these caps — the gate is the backstop, never the teacher.',
|
|
410
409
|
];
|
|
411
410
|
};
|
|
412
411
|
|