@mutmutco/kilo-plugin 3.83.0 → 3.85.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/package.json +1 -1
- package/scripts/hook-io.mjs +6 -1
- package/scripts/hook-run.mjs +258 -14
- package/scripts/hook-trace.mjs +57 -14
- package/scripts/pretooluse-shell-gates.mjs +6 -2
- package/scripts/secret-redact.mjs +19 -11
- package/scripts/vault-edit-gate.mjs +10 -10
- package/server.mjs +8 -3
- package/skills/hotfix/SKILL.md +5 -1
- package/skills/rcand/SKILL.md +6 -2
- package/skills/release/SKILL.md +33 -3
package/package.json
CHANGED
package/scripts/hook-io.mjs
CHANGED
|
@@ -6,10 +6,15 @@ import { createInterface } from 'node:readline';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Read all stdin lines and JSON-parse them.
|
|
9
|
+
* #4118: the shared runner now runs a gate IN-PROCESS and has already drained stdin, so it passes the
|
|
10
|
+
* buffered payload in. Parsing it here keeps ONE contract — same throw on unreadable input, so every
|
|
11
|
+
* gate's fail-open/fail-closed branch is reached identically whether it was spawned or imported.
|
|
12
|
+
* @param {Buffer|string} [buffered] Payload already read by the caller; stdin is read when absent.
|
|
9
13
|
* @returns {Promise<unknown>} Parsed JSON payload.
|
|
10
14
|
* @throws {Error} On readline or JSON parse failure.
|
|
11
15
|
*/
|
|
12
|
-
export async function readHookInput() {
|
|
16
|
+
export async function readHookInput(buffered) {
|
|
17
|
+
if (buffered !== undefined) return JSON.parse(Buffer.isBuffer(buffered) ? buffered.toString('utf8') : String(buffered));
|
|
13
18
|
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
14
19
|
const lines = [];
|
|
15
20
|
for await (const line of rl) lines.push(line);
|
package/scripts/hook-run.mjs
CHANGED
|
@@ -6,11 +6,56 @@
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { dirname, join, resolve } from 'node:path';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
10
|
import { hookGate, hookSurface } from './hook-policy.mjs';
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
14
|
+
// #4118: every fire used to be THREE process boots — launcher, this runner, then a SECOND node for the
|
|
15
|
+
// gate script. The gate is ordinary ESM in the same tree, so it is imported here instead; measured
|
|
16
|
+
// against the live 4,002-fire log that second boot was ~70ms of the ~226ms median, on every fire of
|
|
17
|
+
// all three gates. The gate's own logic was 10-25ms of it.
|
|
18
|
+
//
|
|
19
|
+
// Losing the child process also loses the isolation it provided, so the boundary is rebuilt explicitly
|
|
20
|
+
// and the five things the spawn used to guarantee are each restored by hand:
|
|
21
|
+
// - stdout/stderr are captured (the child's pipes) rather than reaching the host raw, and the
|
|
22
|
+
// captured stdout is then REDUCED to the one decision envelope before it leaves this runner (see
|
|
23
|
+
// decisionEnvelope below — capture alone never bought that, and did not on base either: an injected
|
|
24
|
+
// `process.stdout.write` inside a gate reaches the host ahead of the deny, byte-identical spawned or
|
|
25
|
+
// imported, and the host's JSON.parse fails on the stream). What is guaranteed is narrower than "a
|
|
26
|
+
// stray write can never be read as a decision": stdout that cannot be reduced to exactly ONE decision
|
|
27
|
+
// envelope is not forwarded at all, and on a fail-closed gate that is a gate FAULT (#4016) — the
|
|
28
|
+
// fallback deny runs. So on a gate's DENY path a stray write can suppress the gate's own answer (the
|
|
29
|
+
// fallback's reason replaces it) but cannot weaken the outcome. On a gate's ALLOW path it is not
|
|
30
|
+
// contained: with no carrier of the gate's own to outrank it, a stray write carrying a well-formed
|
|
31
|
+
// allow envelope is elected and forwarded, turning "no decision, defer to the host's prompt" into an
|
|
32
|
+
// explicit auto-approve — measured, and unchanged from base and from 8730324a, so this is the shape
|
|
33
|
+
// #4016 did NOT close. Measured on vault-edit and command-ladder, all ten fault
|
|
34
|
+
// shapes in the #4016 matrix deny, and four consecutive fires of the unterminated-stray shape all deny
|
|
35
|
+
// — the gate still records its own success, so that shape never reaches the #2598 breaker threshold.
|
|
36
|
+
// The breaker is otherwise unchanged: three consecutive GENUINE crashes still degrade fail-open behind
|
|
37
|
+
// its warning banner;
|
|
38
|
+
// - process.exit is trapped (the child's exit code) so a gate that exits mid-run yields a STATUS
|
|
39
|
+
// instead of taking the whole hook process — and with it the fail-closed fallback — down with it;
|
|
40
|
+
// - unhandled rejections and uncaught exceptions are trapped for the same reason: those are the two
|
|
41
|
+
// faults an `await` cannot see, and untrapped they kill the RUNNER (see trapAsyncFaults below);
|
|
42
|
+
// - process.env is set to the gate's effective env before the import, because the gates read
|
|
43
|
+
// surface-dependent modes at MODULE LOAD, and restored after;
|
|
44
|
+
// - the import is cache-busted per fire so a second run in one process re-reads those module-level
|
|
45
|
+
// constants exactly as a fresh child would.
|
|
46
|
+
// Anything that escapes all of that lands in the catch as status 1, which is the same signal a crashed
|
|
47
|
+
// child gave: fail-CLOSED for command-ladder and vault-edit (deny-gate-crash.mjs), fail-open for
|
|
48
|
+
// secret-output. See scripts/hook-policy.mjs for the per-gate posture.
|
|
49
|
+
let fireCount = 0;
|
|
50
|
+
|
|
51
|
+
/** Thrown in place of a gate's process.exit so the runner keeps ownership of the exit code. */
|
|
52
|
+
class GateExit extends Error {
|
|
53
|
+
constructor(code) {
|
|
54
|
+
super(`gate exited ${code}`);
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
14
59
|
export function parseHookArgv(argv) {
|
|
15
60
|
const parsed = { surface: '', gate: '' };
|
|
16
61
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -41,14 +86,28 @@ export function payloadMeta(input) {
|
|
|
41
86
|
}
|
|
42
87
|
}
|
|
43
88
|
|
|
89
|
+
/** Codex's spelling of the one shell tool. The redactor's UPDATABLE_TOOLS is the Claude vocabulary, so
|
|
90
|
+
* the secret-output matcher can only equal it once the host name is translated — untranslated, a Codex
|
|
91
|
+
* `shell` detection took the "cannot be masked for this TOOL" arm and blamed the tool for what is a
|
|
92
|
+
* HOST limit (#4118). Translated for secret-output ONLY: the PreToolUse shell gates match these names
|
|
93
|
+
* directly and pick a dialect from them (scripts/pretooluse-shell-gates.mjs). */
|
|
94
|
+
export const CODEX_SHELL_TOOLS = Object.freeze(['shell', 'local_shell']);
|
|
95
|
+
|
|
44
96
|
function normalizeInput(surface, gate, input) {
|
|
45
|
-
|
|
97
|
+
const codexShell = surface === 'codex' && gate === 'secret-output';
|
|
98
|
+
if (surface !== 'cursor' && !codexShell) return input;
|
|
46
99
|
try {
|
|
47
100
|
const payload = JSON.parse(Buffer.from(input).toString('utf8'));
|
|
48
101
|
if (!payload || typeof payload !== 'object') return input;
|
|
49
|
-
if (payload.tool_name === 'Shell') {
|
|
102
|
+
if (payload.tool_name === 'Shell' || (codexShell && CODEX_SHELL_TOOLS.includes(payload.tool_name))) {
|
|
103
|
+
// Keep the name the HOST fired for the trace to read back. Translated-only, activity.jsonl logged
|
|
104
|
+
// BOTH Codex spellings as `tool: "PowerShell"` — a tool Codex does not have, and the two names
|
|
105
|
+
// became indistinguishable in the one log doctor, the Stop summary and this gate's own
|
|
106
|
+
// justification are counted from. secret-redact.mjs prefers this field when stamping `tool`.
|
|
107
|
+
payload.mmi_host_tool_name = payload.tool_name;
|
|
50
108
|
payload.tool_name = process.platform === 'win32' ? 'PowerShell' : 'Bash';
|
|
51
109
|
}
|
|
110
|
+
if (surface !== 'cursor') return Buffer.from(JSON.stringify(payload));
|
|
52
111
|
if (!payload.session_id && typeof payload.conversation_id === 'string') {
|
|
53
112
|
payload.session_id = payload.conversation_id;
|
|
54
113
|
}
|
|
@@ -61,6 +120,43 @@ function normalizeInput(surface, gate, input) {
|
|
|
61
120
|
}
|
|
62
121
|
}
|
|
63
122
|
|
|
123
|
+
/** Split a gate's stdout into the ONE decision envelope and everything else. Every gate writes at most
|
|
124
|
+
* one envelope and always as a single line of JSON (pretooluse-shell-gates.mjs, vault-edit-gate.mjs,
|
|
125
|
+
* secret-redact.mjs) — the same shape .kilo-plugin/server.mjs already reads back off this launcher.
|
|
126
|
+
* Stray text is moved to stderr rather than dropped: still a symptom worth seeing, just not somewhere a
|
|
127
|
+
* decision can be read from.
|
|
128
|
+
*
|
|
129
|
+
* #4016: "the first line beginning with `{`" was the wrong test and measurably fails OPEN on a
|
|
130
|
+
* fail-CLOSED gate in two shapes. With a `.env` Write driven through vault-edit and the write injected at
|
|
131
|
+
* the gate entry: a stray line that is ITSELF JSON (`{"mmi_debug":"noise"}`) was elected the envelope and
|
|
132
|
+
* the real deny went to stderr — the host then parses a well-formed NON-decision and allows, which base
|
|
133
|
+
* could not do because base's stream did not parse at all; and a stray write with no trailing newline
|
|
134
|
+
* merges with the envelope into one line that starts with `h`, so no line matched, stdout came back EMPTY
|
|
135
|
+
* and the deny was gone. So the line is elected by PARSING it, `hookSpecificOutput` carriers first, and
|
|
136
|
+
* the result reports `malformed` when stdout exists but cannot be reduced to exactly one — which
|
|
137
|
+
* runPolicyGate treats as a gate fault, i.e. the fail-closed fallback for vault-edit/command-ladder. */
|
|
138
|
+
function decisionEnvelope(stdout) {
|
|
139
|
+
if (!stdout.trim()) return { envelope: '', stray: '', malformed: false };
|
|
140
|
+
const lines = stdout.split(/\r?\n/);
|
|
141
|
+
const objects = [];
|
|
142
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
143
|
+
if (!lines[index].trim()) continue;
|
|
144
|
+
let value;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(lines[index]);
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) objects.push({ index, value });
|
|
151
|
+
}
|
|
152
|
+
const carriers = objects.filter(({ value }) => value.hookSpecificOutput !== undefined);
|
|
153
|
+
const candidates = carriers.length ? carriers : objects;
|
|
154
|
+
if (candidates.length !== 1) return { envelope: '', stray: stdout, malformed: true };
|
|
155
|
+
const at = candidates[0].index;
|
|
156
|
+
const stray = lines.filter((_, index) => index !== at).join('\n').trim();
|
|
157
|
+
return { envelope: `${lines[at]}\n`, stray: stray ? `${stray}\n` : '', malformed: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
64
160
|
function adaptOutput(surface, stdout) {
|
|
65
161
|
if (surface !== 'cursor' || !stdout.trim()) return stdout;
|
|
66
162
|
try {
|
|
@@ -84,12 +180,12 @@ function childEnv(surface, input, env) {
|
|
|
84
180
|
};
|
|
85
181
|
}
|
|
86
182
|
|
|
87
|
-
function inlineDeny(surface, gate, root) {
|
|
183
|
+
function inlineDeny(surface, gate, root, fault = `its fail-closed fallback is missing from the plugin install at ${root}`) {
|
|
88
184
|
return `${JSON.stringify({
|
|
89
185
|
hookSpecificOutput: {
|
|
90
186
|
hookEventName: 'PreToolUse',
|
|
91
187
|
permissionDecision: 'deny',
|
|
92
|
-
permissionDecisionReason: `MMI ${gate} gate could not run on ${surface} and
|
|
188
|
+
permissionDecisionReason: `MMI ${gate} gate could not run on ${surface} and ${fault}. Reinstall the MMI plugin, or set MMI_GATES_FAIL_OPEN=1 to proceed unguarded.`,
|
|
93
189
|
},
|
|
94
190
|
})}\n`;
|
|
95
191
|
}
|
|
@@ -110,7 +206,126 @@ function invoke(target, args, input, env, timeoutMs) {
|
|
|
110
206
|
};
|
|
111
207
|
}
|
|
112
208
|
|
|
113
|
-
|
|
209
|
+
/** Point process.env at the gate's effective env for one in-process run, then put it back. */
|
|
210
|
+
function applyProcessEnv(env) {
|
|
211
|
+
if (env === process.env) return () => {};
|
|
212
|
+
const prior = { ...process.env };
|
|
213
|
+
const sync = (target) => {
|
|
214
|
+
for (const key of Object.keys(process.env)) if (!(key in target)) delete process.env[key];
|
|
215
|
+
for (const [key, value] of Object.entries(target)) {
|
|
216
|
+
if (value === undefined) delete process.env[key];
|
|
217
|
+
else if (process.env[key] !== value) process.env[key] = value;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
sync(env);
|
|
221
|
+
return () => sync(prior);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Buffer everything the gate writes, the way the child's pipes used to. */
|
|
225
|
+
function captureStdio() {
|
|
226
|
+
const chunks = { stdout: [], stderr: [] };
|
|
227
|
+
const prior = { stdout: process.stdout.write, stderr: process.stderr.write };
|
|
228
|
+
const sink = (name) => function write(chunk, encoding, callback) {
|
|
229
|
+
chunks[name].push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
230
|
+
const done = typeof encoding === 'function' ? encoding : callback;
|
|
231
|
+
if (typeof done === 'function') done();
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
process.stdout.write = sink('stdout');
|
|
235
|
+
process.stderr.write = sink('stderr');
|
|
236
|
+
return () => {
|
|
237
|
+
process.stdout.write = prior.stdout;
|
|
238
|
+
process.stderr.write = prior.stderr;
|
|
239
|
+
return { stdout: chunks.stdout.join(''), stderr: chunks.stderr.join('') };
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The child's `timeout` option: a gate that never settles must not hold the turn open. The timer is
|
|
244
|
+
* deliberately NOT unref'd — an unref'd timer lets the event loop drain when the gate settles nothing
|
|
245
|
+
* and holds no handle, and the runner then exits 0 with no output, which is fail-OPEN. clearTimeout in
|
|
246
|
+
* the `finally` is what keeps it from holding the loop open on the ordinary path. */
|
|
247
|
+
function withTimeout(promise, timeoutMs) {
|
|
248
|
+
if (!timeoutMs) return promise;
|
|
249
|
+
return new Promise((settle, fail) => {
|
|
250
|
+
const timer = setTimeout(() => fail(new Error(`gate exceeded ${timeoutMs}ms`)), timeoutMs);
|
|
251
|
+
promise.then(settle, fail).finally(() => clearTimeout(timer));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The child process absorbed a gate's ASYNC faults — a detached rejection or a throw from a timer
|
|
257
|
+
* crashed the child, the runner read status 1, and the fail-closed fallback ran. In-process those two
|
|
258
|
+
* faults reach the runner's own default handler and kill it: measured against 1326c2db, a detached
|
|
259
|
+
* rejection inside vault-edit-gate left the runner at exit 1 with NO stdout, and a hook that exits 1
|
|
260
|
+
* is a non-blocking error on Claude Code, so the tool call proceeds unguarded. `await` cannot see
|
|
261
|
+
* either fault, so the only place to catch them is the process handlers, for the length of the run.
|
|
262
|
+
* Prior handlers are saved and restored: a host that embeds runPolicyGate keeps its own.
|
|
263
|
+
*/
|
|
264
|
+
function trapAsyncFaults() {
|
|
265
|
+
let onFault = () => {};
|
|
266
|
+
const fault = new Promise((_, fail) => {
|
|
267
|
+
onFault = (err) => fail(err instanceof Error ? err : new Error(String(err)));
|
|
268
|
+
});
|
|
269
|
+
// A fault that lands after the gate already settled has nothing racing it; swallow it here rather
|
|
270
|
+
// than let the rejection this function created become the next unhandled rejection.
|
|
271
|
+
fault.catch(() => {});
|
|
272
|
+
const prior = {
|
|
273
|
+
unhandledRejection: process.rawListeners('unhandledRejection'),
|
|
274
|
+
uncaughtException: process.rawListeners('uncaughtException'),
|
|
275
|
+
};
|
|
276
|
+
for (const event of Object.keys(prior)) {
|
|
277
|
+
process.removeAllListeners(event);
|
|
278
|
+
process.on(event, onFault);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
fault,
|
|
282
|
+
release() {
|
|
283
|
+
for (const [event, listeners] of Object.entries(prior)) {
|
|
284
|
+
process.removeAllListeners(event);
|
|
285
|
+
for (const listener of listeners) process.on(event, listener);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function invokeInProcess(target, input, env, timeoutMs) {
|
|
292
|
+
const restoreEnv = applyProcessEnv(env);
|
|
293
|
+
const drain = captureStdio();
|
|
294
|
+
const faults = trapAsyncFaults();
|
|
295
|
+
const priorExit = process.exit;
|
|
296
|
+
process.exit = (code) => {
|
|
297
|
+
throw new GateExit(typeof code === 'number' ? code : 0);
|
|
298
|
+
};
|
|
299
|
+
let status = 0;
|
|
300
|
+
let error;
|
|
301
|
+
let legacy = false;
|
|
302
|
+
try {
|
|
303
|
+
const gateModule = await Promise.race([
|
|
304
|
+
faults.fault,
|
|
305
|
+
import(`${pathToFileURL(target).href}?mmi-hook-fire=${(fireCount += 1)}`),
|
|
306
|
+
]);
|
|
307
|
+
// A gate from before #4118 is still a runnable script with its own self-run guard. Treating the
|
|
308
|
+
// missing export as a crash made every Edit/Write/Bash deny under the version skew the plugin's
|
|
309
|
+
// own `upgrade.mechanism: reinstall` creates (installed cache older than the tree); the spawn is
|
|
310
|
+
// the compatibility path, not a fallback for failure.
|
|
311
|
+
if (typeof gateModule.runHookGate !== 'function') legacy = true;
|
|
312
|
+
else await withTimeout(Promise.race([faults.fault, gateModule.runHookGate({ input })]), timeoutMs);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err instanceof GateExit) status = err.code;
|
|
315
|
+
else {
|
|
316
|
+
status = 1;
|
|
317
|
+
error = err;
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
process.exit = priorExit;
|
|
321
|
+
faults.release();
|
|
322
|
+
}
|
|
323
|
+
const { stdout, stderr } = drain();
|
|
324
|
+
restoreEnv();
|
|
325
|
+
return { status, stdout, stderr, error, legacy };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0), env = process.env, here = HERE }) {
|
|
114
329
|
const gate = hookGate(gateId);
|
|
115
330
|
hookSurface(surface);
|
|
116
331
|
const root = pluginRoot(surface, env, here);
|
|
@@ -122,24 +337,48 @@ export function runPolicyGate({ surface, gate: gateId, input = Buffer.alloc(0),
|
|
|
122
337
|
if (!existsSync(target)) {
|
|
123
338
|
result = { status: 1, stdout: '', stderr: `[mmi-${surface}-hook] ${gate.script} not found under ${root}\n` };
|
|
124
339
|
} else {
|
|
125
|
-
result =
|
|
340
|
+
result = await invokeInProcess(target, normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
341
|
+
if (result.legacy) result = invoke(target, [], normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
126
342
|
if (result.status === 0 || result.status === 2) {
|
|
127
|
-
|
|
343
|
+
const { envelope, stray, malformed } = decisionEnvelope(result.stdout);
|
|
344
|
+
if (!malformed) {
|
|
345
|
+
return { ...result, stdout: adaptOutput(surface, envelope), stderr: `${result.stderr}${stray}` };
|
|
346
|
+
}
|
|
347
|
+
// #4016: the gate exited cleanly but its stdout does not reduce to one decision. The runner cannot
|
|
348
|
+
// tell "allowed, then printed junk" from "denied, and the deny got mangled" — the second is exactly
|
|
349
|
+
// what an unterminated stray write produces — so this is a gate fault, not an allow.
|
|
350
|
+
result = { ...result, status: 1, stdout: '', stderr: `${result.stderr}${stray}` };
|
|
351
|
+
result.stderr += `[mmi-${surface}-hook] ${gate.script} wrote stdout with no single decision envelope\n`;
|
|
352
|
+
} else {
|
|
353
|
+
result.stderr += `[mmi-${surface}-hook] ${gate.script} exited ${result.status}`;
|
|
354
|
+
result.stderr += result.error ? `: ${result.error.message}\n` : '\n';
|
|
128
355
|
}
|
|
129
|
-
result.stderr += `[mmi-${surface}-hook] ${gate.script} exited ${result.status}\n`;
|
|
130
356
|
}
|
|
131
357
|
|
|
132
358
|
if (gate.failure === 'open') return { ...result, status: 0 };
|
|
133
359
|
|
|
360
|
+
// The fallback deliberately keeps its own process. Whatever just failed did so INSIDE this one, so a
|
|
361
|
+
// fail-closed deny must not be computed by the same module registry and globals that produced it.
|
|
134
362
|
const crashGate = join(root, 'scripts', 'deny-gate-crash.mjs');
|
|
135
363
|
if (!existsSync(crashGate)) {
|
|
136
364
|
return { status: 0, stdout: inlineDeny(surface, gate.fallbackGate, root), stderr: result.stderr };
|
|
137
365
|
}
|
|
138
366
|
const fallback = invoke(crashGate, [gate.fallbackGate], normalizedInput, effectiveEnv, gate.timeoutMs);
|
|
367
|
+
const { envelope, stray, malformed } = decisionEnvelope(fallback.stdout);
|
|
368
|
+
// An EMPTY fallback stdout is intentional (breaker open, or MMI_GATES_FAIL_OPEN) and stays empty;
|
|
369
|
+
// `malformed` is the other case — the fallback itself emitted something unreadable — and that must not
|
|
370
|
+
// become an allow either (#4016).
|
|
371
|
+
if (malformed) {
|
|
372
|
+
return {
|
|
373
|
+
status: 0,
|
|
374
|
+
stdout: inlineDeny(surface, gate.fallbackGate, root, 'its fail-closed fallback wrote no readable decision'),
|
|
375
|
+
stderr: `${result.stderr}${fallback.stderr}${stray}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
139
378
|
return {
|
|
140
379
|
...fallback,
|
|
141
|
-
stdout: adaptOutput(surface,
|
|
142
|
-
stderr: `${result.stderr}${fallback.stderr}`,
|
|
380
|
+
stdout: adaptOutput(surface, envelope),
|
|
381
|
+
stderr: `${result.stderr}${fallback.stderr}${stray}`,
|
|
143
382
|
};
|
|
144
383
|
}
|
|
145
384
|
|
|
@@ -151,11 +390,11 @@ function readStdin() {
|
|
|
151
390
|
}
|
|
152
391
|
}
|
|
153
392
|
|
|
154
|
-
export function main(argv = process.argv.slice(2)) {
|
|
393
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
155
394
|
const { surface, gate } = parseHookArgv(argv);
|
|
156
395
|
let result;
|
|
157
396
|
try {
|
|
158
|
-
result = runPolicyGate({ surface, gate, input: readStdin() });
|
|
397
|
+
result = await runPolicyGate({ surface, gate, input: readStdin() });
|
|
159
398
|
} catch (error) {
|
|
160
399
|
process.stderr.write(`[mmi-hook] ${error.message}\n`);
|
|
161
400
|
process.exit(1);
|
|
@@ -167,4 +406,9 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
167
406
|
|
|
168
407
|
// Codex may import this file from a replacement cache while process.argv[1] still names the deleted
|
|
169
408
|
// cache path captured when the session loaded. Match the stable relative suffix, not URL identity.
|
|
170
|
-
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('scripts/hook-run.mjs'))
|
|
409
|
+
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('scripts/hook-run.mjs')) {
|
|
410
|
+
main().catch((error) => {
|
|
411
|
+
process.stderr.write(`[mmi-hook] ${error && error.message}\n`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
});
|
|
414
|
+
}
|
package/scripts/hook-trace.mjs
CHANGED
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
// back to a per-cwd tmpdir path outside a repo so the trace still works anywhere. Fail-soft always:
|
|
13
13
|
// the trace must never crash a turn or block a tool call.
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
18
17
|
import { tmpdir } from 'node:os';
|
|
19
18
|
|
|
20
19
|
const DEFAULT_SURFACE = 'claude';
|
|
@@ -54,10 +53,60 @@ function fallbackPath(cwd) {
|
|
|
54
53
|
return join(tmpdir(), 'mmi-cli', hash, 'hooks', 'activity.jsonl');
|
|
55
54
|
}
|
|
56
55
|
|
|
56
|
+
// #4118: resolving the repo-local path used to shell out to `git rev-parse --git-path` — a fresh
|
|
57
|
+
// process on EVERY hook fire, 4,002 of them in the sampled window, so memoising inside the process
|
|
58
|
+
// would have saved nothing. Re-measured with the exact call it replaced, 25 samples in each of three
|
|
59
|
+
// checkouts: median 24.4/25.2/27.3ms, max 48.3/49.0/51.3ms. (An earlier note here quoted the ~49ms tail
|
|
60
|
+
// as the per-fire rate; it is roughly twice the median.) The answer is a pure function of the .git
|
|
61
|
+
// layout, so it is read off the filesystem instead. This is the resolution cli/src/repo-runtime-state.ts
|
|
62
|
+
// already performs for the CLI half of the same log; the two mirrors now agree by construction rather
|
|
63
|
+
// than by two different mechanisms happening to land on the same path.
|
|
64
|
+
//
|
|
65
|
+
// Two layouts, both of which `--git-path` answers identically: a `.git` DIRECTORY (ordinary checkout),
|
|
66
|
+
// and a `.git` FILE naming a private gitdir (a linked worktree — this org's normal working state —
|
|
67
|
+
// or a submodule, where the named path may be relative). Unlike the CLI mirror this walks UP, because a
|
|
68
|
+
// hook's cwd is the session's cwd and may sit below the repo root. A bare repo, where the cwd IS the
|
|
69
|
+
// gitdir, is not resolved: hooks do not run there, and the tmpdir fallback below still gives a log.
|
|
70
|
+
const GIT_DIR_MEMO = new Map();
|
|
71
|
+
|
|
72
|
+
function gitDir(cwd, env) {
|
|
73
|
+
if (env.GIT_DIR) return resolve(cwd, env.GIT_DIR);
|
|
74
|
+
if (GIT_DIR_MEMO.has(cwd)) return GIT_DIR_MEMO.get(cwd);
|
|
75
|
+
let dir = resolve(cwd);
|
|
76
|
+
let found = '';
|
|
77
|
+
for (;;) {
|
|
78
|
+
const dotGit = join(dir, '.git');
|
|
79
|
+
let stats;
|
|
80
|
+
try {
|
|
81
|
+
stats = statSync(dotGit);
|
|
82
|
+
} catch {
|
|
83
|
+
stats = null;
|
|
84
|
+
}
|
|
85
|
+
if (stats?.isDirectory()) {
|
|
86
|
+
found = dotGit;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (stats?.isFile()) {
|
|
90
|
+
try {
|
|
91
|
+
const named = /^gitdir:\s*(.+)$/im.exec(readFileSync(dotGit, 'utf8').trim())?.[1]?.trim();
|
|
92
|
+
if (named) found = isAbsolute(named) ? named : resolve(dir, named);
|
|
93
|
+
} catch {
|
|
94
|
+
/* unreadable .git file — fall through to the tmpdir fallback */
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const parent = dirname(dir);
|
|
99
|
+
if (parent === dir) break;
|
|
100
|
+
dir = parent;
|
|
101
|
+
}
|
|
102
|
+
GIT_DIR_MEMO.set(cwd, found);
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
|
|
57
106
|
/**
|
|
58
107
|
* Resolve the hook-activity log path for a working directory.
|
|
59
|
-
* Prefers the repo-local gitignored state dir (`.git/mmi-runtime/hooks/activity.jsonl
|
|
60
|
-
*
|
|
108
|
+
* Prefers the repo-local gitignored state dir (`.git/mmi-runtime/hooks/activity.jsonl`, resolved from
|
|
109
|
+
* the .git layout by `gitDir` above); falls back to a per-cwd tmpdir path so the trace still works
|
|
61
110
|
* outside a repo. Env overrides: `MMI_HOOK_ACTIVITY_LOG` (full path, wins outright) and
|
|
62
111
|
* `MMI_HOOK_ACTIVITY_CWD` (resolve against this directory instead of the hook process's own cwd —
|
|
63
112
|
* the Kimi launcher stamps it from the hook payload because Kimi runs plugin hooks with cwd = the
|
|
@@ -70,15 +119,9 @@ function fallbackPath(cwd) {
|
|
|
70
119
|
export function activityLogPath(cwd = process.cwd(), env = process.env) {
|
|
71
120
|
if (env.MMI_HOOK_ACTIVITY_LOG) return env.MMI_HOOK_ACTIVITY_LOG;
|
|
72
121
|
const effectiveCwd = env.MMI_HOOK_ACTIVITY_CWD || cwd;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
['-C', effectiveCwd, 'rev-parse', '--path-format=absolute', '--git-path', 'mmi-runtime/hooks/activity.jsonl'],
|
|
77
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, windowsHide: true },
|
|
78
|
-
).trim();
|
|
79
|
-
} catch {
|
|
80
|
-
return fallbackPath(effectiveCwd);
|
|
81
|
-
}
|
|
122
|
+
const dir = gitDir(effectiveCwd, env);
|
|
123
|
+
if (dir && existsSync(dir)) return join(dir, 'mmi-runtime', 'hooks', 'activity.jsonl');
|
|
124
|
+
return fallbackPath(effectiveCwd);
|
|
82
125
|
}
|
|
83
126
|
|
|
84
127
|
/**
|
|
@@ -361,10 +361,12 @@ function runCommandLadder(input, { stdout = process.stdout, stderr = process.std
|
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
|
|
364
|
+
/** #4118: exported as `runHookGate` too — the uniform in-process entry hook-run.mjs imports instead of
|
|
365
|
+
* booting a second node. `input` is the buffered payload when the runner already drained stdin. */
|
|
366
|
+
export async function runPreToolUseShellGates({ input: buffered, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
365
367
|
let input;
|
|
366
368
|
try {
|
|
367
|
-
input = await readHookInput();
|
|
369
|
+
input = await readHookInput(buffered);
|
|
368
370
|
} catch {
|
|
369
371
|
// Unreadable/absent payload = out-of-contract host (Cursor's Claude-plugin import, #2992):
|
|
370
372
|
// fail open without counting a crash. Post-parse crashes below stay fail-closed (#2598).
|
|
@@ -407,6 +409,8 @@ export async function runPreToolUseShellGates({ stdout = process.stdout, stderr
|
|
|
407
409
|
}
|
|
408
410
|
}
|
|
409
411
|
|
|
412
|
+
export { runPreToolUseShellGates as runHookGate };
|
|
413
|
+
|
|
410
414
|
if (
|
|
411
415
|
process.argv[1] &&
|
|
412
416
|
(process.argv[1].endsWith('pretooluse-shell-gates.mjs') ||
|
|
@@ -444,11 +444,20 @@ export function postToolUseRedactDecision(input) {
|
|
|
444
444
|
return { toolName, redactable: false, hookSpecificOutput: null };
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
|
|
447
|
+
/** The tool name the HOST actually fired, for the trace. hook-run.mjs rewrites a host's shell spelling
|
|
448
|
+
* into the Claude vocabulary UPDATABLE_TOOLS is written in (#4118) and stamps the original beside it;
|
|
449
|
+
* logging the rewritten name recorded Codex `shell` and `local_shell` as identical "PowerShell" rows. */
|
|
450
|
+
function tracedTool(input) {
|
|
451
|
+
return typeof input?.mmi_host_tool_name === 'string' ? input.mmi_host_tool_name : input?.tool_name;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
|
|
455
|
+
* booting a second node. `input` is the buffered payload when the runner already drained stdin. */
|
|
456
|
+
export async function runHookGate({ input: buffered } = {}) {
|
|
448
457
|
let input;
|
|
449
458
|
try {
|
|
450
459
|
const { readHookInput } = await import('./hook-io.mjs');
|
|
451
|
-
input = await readHookInput();
|
|
460
|
+
input = await readHookInput(buffered);
|
|
452
461
|
} catch {
|
|
453
462
|
appendHookActivity({
|
|
454
463
|
event: 'PostToolUse',
|
|
@@ -456,7 +465,7 @@ async function main() {
|
|
|
456
465
|
outcome: 'failed',
|
|
457
466
|
action: 'could not read hook input',
|
|
458
467
|
});
|
|
459
|
-
|
|
468
|
+
return;
|
|
460
469
|
}
|
|
461
470
|
|
|
462
471
|
const raw = JSON.stringify(input);
|
|
@@ -466,9 +475,9 @@ async function main() {
|
|
|
466
475
|
script: 'secret-redact',
|
|
467
476
|
outcome: 'ran',
|
|
468
477
|
action: 'clean (pre-filter)',
|
|
469
|
-
tool: input
|
|
478
|
+
tool: tracedTool(input),
|
|
470
479
|
});
|
|
471
|
-
|
|
480
|
+
return;
|
|
472
481
|
}
|
|
473
482
|
|
|
474
483
|
try {
|
|
@@ -500,7 +509,7 @@ async function main() {
|
|
|
500
509
|
if (decision && !decision.redactable) {
|
|
501
510
|
process.stderr.write(`[mmi-hook] secret-redact: a secret-shaped value was detected in ${decision.toolName || 'tool'} output and cannot be masked for this tool (no updatedToolOutput channel) — treat the transcript as exposed.\n`);
|
|
502
511
|
}
|
|
503
|
-
appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: input
|
|
512
|
+
appendHookActivity({ event: 'PostToolUse', script: 'secret-redact', outcome, action, tool: tracedTool(input) });
|
|
504
513
|
} catch (err) {
|
|
505
514
|
// #2610: the redactor stays fail-open (PostToolUse — the bytes already left the tool, blocking buys
|
|
506
515
|
// nothing; see #2598 rescope), but its death must be LOUD. Record a greppable crash marker so a
|
|
@@ -511,10 +520,9 @@ async function main() {
|
|
|
511
520
|
outcome: 'failed',
|
|
512
521
|
action: 'redactor crashed during scan',
|
|
513
522
|
error: err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err),
|
|
514
|
-
tool: input
|
|
523
|
+
tool: tracedTool(input),
|
|
515
524
|
});
|
|
516
525
|
}
|
|
517
|
-
process.exit(0);
|
|
518
526
|
}
|
|
519
527
|
|
|
520
528
|
if (
|
|
@@ -522,9 +530,9 @@ if (
|
|
|
522
530
|
(process.argv[1].endsWith('secret-redact.mjs') ||
|
|
523
531
|
process.argv[1].replace(/\\/g, '/').endsWith('scripts/secret-redact.mjs'))
|
|
524
532
|
) {
|
|
525
|
-
// #2610: a rejection from
|
|
526
|
-
// swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
|
|
527
|
-
|
|
533
|
+
// #2610: a rejection from runHookGate() itself (e.g. the dynamic import blew up before the inner try) must
|
|
534
|
+
// not be swallowed silently either — record a crash marker, then keep the fail-open exit-0 contract.
|
|
535
|
+
runHookGate().then(() => process.exit(0)).catch((err) => {
|
|
528
536
|
appendHookActivity({
|
|
529
537
|
event: 'PostToolUse',
|
|
530
538
|
script: 'secret-redact',
|
|
@@ -36,22 +36,24 @@ export function analyze(input) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/** Shared entry (#4118): awaitable and exit-free so hook-run.mjs can import it in-process instead of
|
|
40
|
+
* booting a second node. `input` is the buffered payload when the runner already drained stdin. */
|
|
41
|
+
export async function runHookGate({ input } = {}) {
|
|
42
|
+
let parsed;
|
|
41
43
|
try {
|
|
42
|
-
|
|
44
|
+
parsed = await readHookInput(input);
|
|
43
45
|
} catch {
|
|
44
46
|
// Unreadable/absent payload = out-of-contract host (#2992): fail open without counting a crash.
|
|
45
47
|
const res = handleMissingHookInput(GATE_NAME);
|
|
46
48
|
if (res.stdout) process.stdout.write(res.stdout);
|
|
47
49
|
if (res.stderr) process.stderr.write(res.stderr);
|
|
48
|
-
|
|
50
|
+
return;
|
|
49
51
|
}
|
|
50
52
|
recordGateSuccess(GATE_NAME);
|
|
51
53
|
|
|
52
54
|
const { block, reason } = analyze({
|
|
53
|
-
toolName:
|
|
54
|
-
filePaths: editedPaths(
|
|
55
|
+
toolName: parsed?.tool_name,
|
|
56
|
+
filePaths: editedPaths(parsed),
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
appendHookActivity({
|
|
@@ -59,7 +61,7 @@ async function main() {
|
|
|
59
61
|
script: GATE_NAME,
|
|
60
62
|
outcome: block ? (MODE === 'observe' ? 'observe' : 'deny') : 'ran',
|
|
61
63
|
action: block ? reason : 'clean',
|
|
62
|
-
tool:
|
|
64
|
+
tool: parsed?.tool_name,
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
if (block) {
|
|
@@ -76,8 +78,6 @@ async function main() {
|
|
|
76
78
|
process.stdout.write(decision + '\n');
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
|
-
|
|
80
|
-
process.exit(0);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
if (
|
|
@@ -85,7 +85,7 @@ if (
|
|
|
85
85
|
(process.argv[1].endsWith('vault-edit-gate.mjs') ||
|
|
86
86
|
process.argv[1].replace(/\\/g, '/').endsWith('scripts/vault-edit-gate.mjs'))
|
|
87
87
|
) {
|
|
88
|
-
|
|
88
|
+
runHookGate().then(() => process.exit(0)).catch(() => {
|
|
89
89
|
const res = handleGateCrash(GATE_NAME);
|
|
90
90
|
if (res.stdout) process.stdout.write(res.stdout);
|
|
91
91
|
if (res.stderr) process.stderr.write(res.stderr);
|
package/server.mjs
CHANGED
|
@@ -44,10 +44,14 @@ export function nodeBin() {
|
|
|
44
44
|
// Kilo tool names → the Claude vocabulary the shared gates already understand.
|
|
45
45
|
const SHELL_TOOLS = new Set(['bash', 'shell', 'powershell']);
|
|
46
46
|
const EDIT_TOOLS = new Set(['write', 'edit', 'patch', 'apply_patch']);
|
|
47
|
+
// #4118: only the tools the redactor can actually MASK. secret-redact.mjs replaces output through
|
|
48
|
+
// `updatedToolOutput`, which the harness offers for the shell/search family and nothing else — for
|
|
49
|
+
// write/edit/patch/read it returned `redactable: false` and printed a warning, so those entries bought a
|
|
50
|
+
// gate spawn per tool call and never masked a byte. Kilo's own map is the matcher here (there are no
|
|
51
|
+
// manifest hook rules), so this is the same narrowing hooks/hooks.json makes for Claude.
|
|
47
52
|
const REDACT_TOOL_NAME = {
|
|
48
53
|
bash: 'Bash', shell: 'Bash', powershell: 'Bash',
|
|
49
|
-
|
|
50
|
-
read: 'Read', grep: 'Grep', glob: 'Glob',
|
|
54
|
+
grep: 'Grep', glob: 'Glob',
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
/** Idempotent first-run provisioning of the non-plugin payload (~/.kilo/{skills,command,agent}) behind
|
|
@@ -207,7 +211,8 @@ export default {
|
|
|
207
211
|
}
|
|
208
212
|
},
|
|
209
213
|
// PostToolUse equivalent with a WORKING rewrite channel: secret-redact.mjs's updatedToolOutput is
|
|
210
|
-
// applied to output.output.
|
|
214
|
+
// applied to output.output. A tool absent from REDACT_TOOL_NAME is one the redactor cannot mask,
|
|
215
|
+
// and is skipped rather than gated for a warning nobody could act on (#4118).
|
|
211
216
|
'tool.execute.after': async (input, output) => {
|
|
212
217
|
const tool = typeof input.tool === 'string' ? input.tool : '';
|
|
213
218
|
const translated = REDACT_TOOL_NAME[tool];
|
package/skills/hotfix/SKILL.md
CHANGED
|
@@ -38,7 +38,11 @@ mmi-cli doctor --no-repo-writes
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Stop on a red authority or CLI-version result. The worktree must be clean; move scratch into `tmp/` or
|
|
41
|
-
gitignore it rather than widening the hotfix diff.
|
|
41
|
+
gitignore it rather than widening the hotfix diff. A TRACKED path named in a `working tree must be clean
|
|
42
|
+
before …` refusal is not scratch: read both `git status --porcelain` columns, treat every state except
|
|
43
|
+
exactly ` M` as real work to commit or stash, and for ` M` discard only when
|
|
44
|
+
`git diff HEAD --numstat -- <paths>` is empty, and only with `git checkout -- <paths>`. Plain
|
|
45
|
+
`git diff --numstat` prints nothing for a merely staged change, so it cannot make that call (#4004).
|
|
42
46
|
|
|
43
47
|
## 1. Start from the merged development fix
|
|
44
48
|
|
package/skills/rcand/SKILL.md
CHANGED
|
@@ -43,8 +43,12 @@ primary checkout.
|
|
|
43
43
|
## Step 1 — development ahead of rc?
|
|
44
44
|
|
|
45
45
|
Preconditions: on `development`, clean tree. The clean-tree check rejects UNTRACKED scratch too, not just
|
|
46
|
-
modified tracked files — if `--apply` stops with `working tree must be clean before …`, run `git status
|
|
47
|
-
|
|
46
|
+
modified tracked files — if `--apply` stops with `working tree must be clean before …`, run `git status
|
|
47
|
+
--porcelain` on the paths it named and read both columns. Gitignore the `??` scratch (or move it to a
|
|
48
|
+
gitignored path like `tmp/`). Treat every tracked state except exactly ` M` as real work to commit or stash;
|
|
49
|
+
for ` M`, discard only when `git diff HEAD --numstat -- <paths>` is empty, and only with
|
|
50
|
+
`git checkout -- <paths>`. Plain `git diff --numstat` prints nothing for a merely staged change, so it
|
|
51
|
+
cannot tell real work from line-ending churn (#1472, #4004).
|
|
48
52
|
```bash
|
|
49
53
|
git fetch origin
|
|
50
54
|
git rev-list --count origin/rc..origin/development
|
package/skills/release/SKILL.md
CHANGED
|
@@ -72,9 +72,22 @@ breaks here: the new worktree's branch is never literally named `development`/`r
|
|
|
72
72
|
development` inside it fails outright when `development` is already checked out in the primary checkout (git
|
|
73
73
|
worktrees cannot have the same branch checked out twice). If you are in such a worktree, exit it first and
|
|
74
74
|
run the release from the primary checkout.
|
|
75
|
-
The clean-tree check rejects UNTRACKED scratch too, not just modified tracked files
|
|
76
|
-
with `working tree must be clean before …`, run `git status`
|
|
77
|
-
|
|
75
|
+
The clean-tree check rejects UNTRACKED scratch too, not just modified tracked files. When `--apply` or
|
|
76
|
+
`--resume` stops with `working tree must be clean before …`, run `git status --porcelain` on the paths it
|
|
77
|
+
named and read BOTH status columns before touching anything (#1472, #4004):
|
|
78
|
+
|
|
79
|
+
- `??` — untracked scratch. Gitignore it, or move it to a gitignored path like `tmp/`, then retry.
|
|
80
|
+
- exactly ` M` (blank staged column) — the only state that can be churn rather than work. Test it with
|
|
81
|
+
`git diff HEAD --numstat -- <paths>`: non-empty is a real edit to commit or stash; empty means the
|
|
82
|
+
worktree normalizes straight back to HEAD (an LF↔CRLF rewrite, say), and `git checkout -- <paths>`
|
|
83
|
+
then clears the refusal without discarding anything.
|
|
84
|
+
- anything else — `M `, `MM`, `A`, `D`, `R`, `T`, or any `U` conflict — is real work or an exceptional
|
|
85
|
+
index state. Commit, stash or resolve it; do not try to classify it.
|
|
86
|
+
|
|
87
|
+
Do not substitute plain `git diff --numstat` for the `HEAD` form. It compares the worktree against the
|
|
88
|
+
INDEX, so a change that is merely staged prints nothing at all, and reading that emptiness as
|
|
89
|
+
"line-ending churn" throws the change away. The remedy is the index form of `git checkout` for the same
|
|
90
|
+
reason: `git checkout HEAD -- <paths>` would overwrite a staged edit that was misread as churn.
|
|
78
91
|
|
|
79
92
|
Full-track repos:
|
|
80
93
|
```bash
|
|
@@ -471,6 +484,19 @@ mmi-cli org project sync-info --apply # omit --apply for the read-only plan
|
|
|
471
484
|
channel — #3630 took SessionStart off the hook surface) runs the
|
|
472
485
|
same fast-forward first thing, so a stale local `development`/`main`/`rc` self-heals to origin before you
|
|
473
486
|
work — no manual `git pull`. Nothing here ever blocks or fails the release.
|
|
487
|
+
- **Read the `checkout:` clause — it does not always say "returned" (#4006).** The report always prints
|
|
488
|
+
one, and restoration is deliberately skipped rather than forced when the repo is not in a fit state.
|
|
489
|
+
A release leaves the repo on `main` until the train moves it back, so a non-`returned` outcome means
|
|
490
|
+
you are still there and must return by hand once the cause is cleared:
|
|
491
|
+
- `checkout restoration skipped: working tree changed after release` — the tree went dirty DURING the
|
|
492
|
+
train, so the train declined to move you. Diagnose those paths with the Step 0 clean-tree rules,
|
|
493
|
+
then `git checkout <start branch>` yourself. The release itself already shipped; this is a
|
|
494
|
+
deliberate, harmless decline, not a failed release.
|
|
495
|
+
- `checkout restoration failed while returning to …` / `origin/<branch> fast-forward failed` — the
|
|
496
|
+
checkout or the pull errored. Read the appended git message; the release is unaffected.
|
|
497
|
+
|
|
498
|
+
`--resume` prints no `checkout:` clause at all, so after a resumed release check `git branch
|
|
499
|
+
--show-current` rather than assuming you were moved back.
|
|
474
500
|
|
|
475
501
|
## Step 6 — collect deploy verdict + report
|
|
476
502
|
|
|
@@ -478,6 +504,10 @@ Collect the backgrounded prod-deploy watch from Step 4 (it has typically finishe
|
|
|
478
504
|
healthy (the central deploy workflow smoke step / a health check); **red** → report the failure prominently and flag
|
|
479
505
|
that the release shipped on a failed deploy (re-run just the deploy — `main` is already correct).
|
|
480
506
|
|
|
507
|
+
**This distribution verifier is Hub-only.** It assumes the Hub release fold has already committed its
|
|
508
|
+
generated artifacts. Non-Hub `registry-publish` repositories must not run `release-distribution.mjs
|
|
509
|
+
verify`: use their own release-workflow evidence and the publish-visibility contract below.
|
|
510
|
+
|
|
481
511
|
Hub releases always carry a distribution bump (the Step 1b fold), so the **publish workflow**
|
|
482
512
|
(`publish.yml`) ships every registry-declared public npm artifact on the GitHub Release from Step 4 —
|
|
483
513
|
don't publish by hand.
|