@selesai/code 0.13.7 → 0.13.8

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.
@@ -13,8 +13,6 @@ import { claimLeases, releaseLeases, writeAtomic } from "../scripts/lib/gates.mj
13
13
 
14
14
  const HERE = dirname(fileURLToPath(import.meta.url));
15
15
  const GATE_CHECK = join(HERE, "..", "scripts", "gate-check.mjs");
16
- const STOP_HOOK = join(HERE, "..", "scripts", "stop-hook.mjs");
17
- const INSTALL = join(HERE, "..", "scripts", "install-hooks.mjs");
18
16
  const tests = [];
19
17
  const test = (name, fn) => tests.push({ name, fn });
20
18
 
@@ -107,57 +105,6 @@ test("leases: an explicit release cleans orphaned leases after a scope directory
107
105
  } finally { s.cleanup(); }
108
106
  });
109
107
 
110
- test("hook: repeated 64-writer bursts are serialized without lost increments", async () => {
111
- const s = sandbox();
112
- try {
113
- s.write("GATES.md", "- [ ] G1: pending\n EVIDENCE: pending\n");
114
- const payload = JSON.stringify({ cwd: s.dir, session_id: "one-session" });
115
- for (let round = 1; round <= 3; round++) {
116
- rmSync(s.path(".unlazy-hook-state.json"), { force: true });
117
- const results = await Promise.all(Array.from({ length: 64 }, () =>
118
- run(STOP_HOOK, [], { cwd: s.dir, stdin: payload })));
119
- const crashed = results.filter((result) => result.code !== 0);
120
- assert(crashed.length === 0,
121
- "round " + round + ": " + crashed.length + " hook process(es) failed\n" +
122
- [...new Set(crashed.map((result) => "exit=" + result.code + "\n" + result.out.trim()))]
123
- .slice(0, 4).join("\n---\n"));
124
- const failedUpdates = results
125
- .filter((result) => result.out.includes("could not update the serialized hook state"))
126
- .map((result) => result.out.trim());
127
- assert(failedUpdates.length === 0,
128
- "round " + round + ": " + failedUpdates.length + " hook(s) failed open on the state update\n" +
129
- [...new Set(failedUpdates)].slice(0, 4).join("\n---\n"));
130
- const state = JSON.parse(s.read(".unlazy-hook-state.json"));
131
- const sessions = Object.values(state.sessions);
132
- assert(sessions.length === 1, "round " + round + ": expected one session, got " + sessions.length);
133
- assert(sessions[0].blocks === 64, "round " + round + ": expected 64 blocks, got " + sessions[0].blocks);
134
- }
135
- } finally { s.cleanup(); }
136
- });
137
-
138
- test("hook: sessions remain isolated and completion/no-gates clears stale state", async () => {
139
- const s = sandbox();
140
- try {
141
- s.write("GATES.md", "- [ ] G1: pending\n EVIDENCE: pending\n");
142
- for (const id of ["alpha", "beta"]) {
143
- const payload = JSON.stringify({ cwd: s.dir, session_id: id });
144
- await Promise.all(Array.from({ length: 3 }, () => run(STOP_HOOK, [], { cwd: s.dir, stdin: payload })));
145
- }
146
- let state = JSON.parse(s.read(".unlazy-hook-state.json"));
147
- assert(Object.keys(state.sessions).length === 2, "sessions were mixed: " + JSON.stringify(state));
148
- assert(Object.values(state.sessions).every((value) => value.blocks === 3), "session counters were not isolated");
149
-
150
- s.write("GATES.md", "- [x] G1: done\n EVIDENCE: measured\n");
151
- await run(STOP_HOOK, [], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir, session_id: "alpha" }) });
152
- state = JSON.parse(s.read(".unlazy-hook-state.json"));
153
- assert(Object.keys(state.sessions).length === 1, "completed alpha state was not cleared");
154
-
155
- rmSync(s.path("GATES.md"));
156
- await run(STOP_HOOK, [], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir, session_id: "beta" }) });
157
- assert(!existsSync(s.path(".unlazy-hook-state.json")), "no-gates path did not clear final state");
158
- } finally { s.cleanup(); }
159
- });
160
-
161
108
  test("atomic writer: predictable pre-created temp links are never followed", async () => {
162
109
  if (process.platform === "win32") return;
163
110
  const s = sandbox();
@@ -216,141 +163,3 @@ test("status log: a FIFO is rejected without blocking the logger", async () => {
216
163
  has(result.out, "cannot append status");
217
164
  } finally { s.cleanup(); }
218
165
  });
219
-
220
- test("installer: malformed settings shapes are refused without mutation", async () => {
221
- const fixtures = [
222
- "[]\n",
223
- JSON.stringify({ hooks: [] }, null, 2) + "\n",
224
- JSON.stringify({ hooks: { Stop: {} } }, null, 2) + "\n",
225
- JSON.stringify({ hooks: { Stop: [{ hooks: {} }] } }, null, 2) + "\n",
226
- JSON.stringify({ hooks: { Stop: [{ hooks: [null] }] } }, null, 2) + "\n",
227
- ];
228
- for (const fixture of fixtures) {
229
- const s = sandbox();
230
- try {
231
- s.write(".claude/settings.local.json", fixture);
232
- const result = await run(INSTALL, [], { cwd: s.dir });
233
- assert(result.code === 1, "invalid shape should fail\n" + result.out);
234
- assert(s.read(".claude/settings.local.json") === fixture, "invalid settings were mutated");
235
- } finally { s.cleanup(); }
236
- }
237
- });
238
-
239
- test("installer: a FIFO settings target is rejected without blocking", async () => {
240
- if (process.platform === "win32") return;
241
- const s = sandbox();
242
- try {
243
- mkdirSync(s.path(".claude"), { recursive: true });
244
- const target = s.path(".claude/settings.local.json");
245
- const made = spawnSync("mkfifo", [target], { encoding: "utf8" });
246
- assert(made.status === 0, "could not create FIFO fixture: " + made.stderr);
247
- const started = Date.now();
248
- const result = await run(INSTALL, [], { cwd: s.dir, timeoutMs: 2000 });
249
- assert(result.code === 1, "FIFO installer target did not fail closed\n" + result.out);
250
- assert(Date.now() - started < 1800, "FIFO installer validation blocked");
251
- has(result.out, "Refusing to touch");
252
- } finally { s.cleanup(); }
253
- });
254
-
255
- test("installer: uninstall preserves a sibling in the same matcher group and writes a backup", async () => {
256
- const s = sandbox();
257
- try {
258
- const original = JSON.stringify({
259
- hooks: {
260
- Stop: [{
261
- matcher: "",
262
- hooks: [
263
- { type: "command", command: "node other-tool.mjs" },
264
- { type: "command", command: "node /tmp/unlazy/scripts/stop-hook.mjs --unlazy" },
265
- ],
266
- }],
267
- },
268
- }, null, 2) + "\n";
269
- s.write(".claude/settings.local.json", original);
270
- const result = await run(INSTALL, ["--uninstall"], { cwd: s.dir });
271
- assert(result.code === 0, result.out);
272
- const after = JSON.parse(s.read(".claude/settings.local.json"));
273
- assert(after.hooks.Stop.length === 1, "matcher group was removed");
274
- assert(after.hooks.Stop[0].matcher === "", "matcher metadata was lost");
275
- assert(after.hooks.Stop[0].hooks.length === 1, "wrong handler count");
276
- has(after.hooks.Stop[0].hooks[0].command, "other-tool.mjs");
277
- assert(s.read(".claude/settings.local.json.unlazy.bak") === original, "backup did not preserve original bytes");
278
- } finally { s.cleanup(); }
279
- });
280
-
281
- test("installer: marker substrings do not claim an unrelated stop hook", async () => {
282
- const s = sandbox();
283
- try {
284
- const unrelated = [
285
- "node /opt/other/stop-hook.mjs --unlazy-helper",
286
- "node /opt/unlazy/scripts/stop-hook.mjs --unlazy-helper",
287
- "node " + JSON.stringify(STOP_HOOK) + " --unlazy-helper",
288
- "node " + JSON.stringify(STOP_HOOK) + " \"--unlazy-helper\"",
289
- ];
290
- s.write(".claude/settings.local.json", JSON.stringify({
291
- hooks: { Stop: [{ hooks: unrelated.map((command) => ({ type: "command", command, timeout: 20 })) }] },
292
- }, null, 2) + "\n");
293
- const result = await run(INSTALL, ["--uninstall"], { cwd: s.dir });
294
- assert(result.code === 0, result.out);
295
- has(result.out, "Nothing to remove");
296
- const after = JSON.parse(s.read(".claude/settings.local.json"));
297
- assert(after.hooks.Stop[0].hooks.map((hook) => hook.command).join("\n") === unrelated.join("\n"),
298
- "unrelated hook was removed");
299
- } finally { s.cleanup(); }
300
- });
301
-
302
- test("installer: a matching command with broken managed fields is repaired", async () => {
303
- const s = sandbox();
304
- try {
305
- const installed = await run(INSTALL, [], { cwd: s.dir });
306
- assert(installed.code === 0, installed.out);
307
- const settings = JSON.parse(s.read(".claude/settings.local.json"));
308
- const handler = settings.hooks.Stop[0].hooks[0];
309
- handler.type = "prompt";
310
- handler.timeout = 1;
311
- s.write(".claude/settings.local.json", JSON.stringify(settings, null, 2) + "\n");
312
- const repaired = await run(INSTALL, [], { cwd: s.dir });
313
- assert(repaired.code === 0, repaired.out);
314
- assert(!repaired.out.includes("Already installed"), "broken handler was treated as current");
315
- const after = JSON.parse(s.read(".claude/settings.local.json"));
316
- const managed = after.hooks.Stop.flatMap((group) => group.hooks)
317
- .filter((item) => typeof item.command === "string" && item.command.includes("--unlazy"));
318
- assert(managed.length === 1, "repair did not leave exactly one managed handler");
319
- assert(managed[0].type === "command", "repair did not restore command type");
320
- assert(managed[0].timeout === 20, "repair did not restore timeout");
321
- } finally { s.cleanup(); }
322
- });
323
-
324
- test("installer: scope input is validated and local state warning is explicit", async () => {
325
- const s = sandbox();
326
- try {
327
- const invalid = await run(INSTALL, ["--scope", "bad;echo"], { cwd: s.dir });
328
- assert(invalid.code === 2, invalid.out);
329
- assert(!existsSync(s.path(".claude/settings.local.json")), "invalid scope wrote settings");
330
- const good = await run(INSTALL, ["--scope", "api"], { cwd: s.dir });
331
- assert(good.code === 0, good.out);
332
- has(good.out, ".unlazy/");
333
- has(good.out, ".unlazy-hook-state.json");
334
- const settings = JSON.parse(s.read(".claude/settings.local.json"));
335
- const command = settings.hooks.Stop[0].hooks[0].command;
336
- has(command, "--scope api");
337
- } finally { s.cleanup(); }
338
- });
339
-
340
- let passed = 0;
341
- const failures = [];
342
- for (const item of tests) {
343
- try {
344
- await item.fn();
345
- passed++;
346
- console.log("ok " + item.name);
347
- } catch (error) {
348
- failures.push(item.name);
349
- console.log("FAIL " + item.name + "\n " + String(error.message).replace(/\n/g, "\n "));
350
- }
351
- }
352
- console.log("\n" + passed + "/" + tests.length + " passed");
353
- if (failures.length) {
354
- console.log("failed: " + failures.join(", "));
355
- process.exit(1);
356
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.7",
3
+ "version": "0.13.8",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,206 +0,0 @@
1
- #!/usr/bin/env node
2
- // Install or remove unlazy's Claude Code Stop hook. Zero dependencies. Node 16+.
3
-
4
- import {
5
- closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync,
6
- openSync, readFileSync,
7
- } from "node:fs";
8
- import { join } from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- import { homedir } from "node:os";
11
- import { validateScopeId, writeAtomic } from "./lib/gates.mjs";
12
-
13
- const HELP = `usage: install-hooks.mjs [--scope ID] [--shared | --global] [--uninstall]
14
-
15
- default .claude/settings.local.json in the current project
16
- --shared .claude/settings.json in the current project
17
- --global ~/.claude/settings.json
18
- --scope ID pin the hook to one validated .unlazy/ID pipeline
19
- --uninstall remove only unlazy handlers, preserving sibling handlers`;
20
-
21
- function usage(message) {
22
- console.error("install-hooks: " + message);
23
- console.error("run install-hooks.mjs --help for usage");
24
- process.exit(2);
25
- }
26
-
27
- const args = process.argv.slice(2);
28
- const known = new Set(["--scope", "--shared", "--global", "--uninstall", "--help", "-h"]);
29
- let scope = null;
30
- let shared = false;
31
- let global = false;
32
- let uninstall = false;
33
- for (let index = 0; index < args.length; index++) {
34
- const arg = args[index];
35
- if (!known.has(arg)) usage("unknown option " + arg);
36
- if (arg === "--help" || arg === "-h") { console.log(HELP); process.exit(0); }
37
- if (arg === "--scope") {
38
- if (scope !== null) usage("duplicate --scope");
39
- scope = args[++index];
40
- if (!scope) usage("--scope needs a pipeline id");
41
- continue;
42
- }
43
- if (arg === "--shared") shared = true;
44
- if (arg === "--global") global = true;
45
- if (arg === "--uninstall") uninstall = true;
46
- }
47
- if (shared && global) usage("--shared and --global are mutually exclusive");
48
- if (scope) {
49
- const error = validateScopeId(scope);
50
- if (error) usage(error);
51
- }
52
-
53
- const script = fileURLToPath(new URL("./stop-hook.mjs", import.meta.url));
54
- const MANAGED_MARKER = "--unlazy-hook-v2";
55
- const target = global
56
- ? join(homedir(), ".claude", "settings.json")
57
- : join(process.cwd(), ".claude", shared ? "settings.json" : "settings.local.json");
58
- const backup = target + ".unlazy.bak";
59
-
60
- const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
61
- let raw = "";
62
- let settings = {};
63
- if (existsSync(target)) {
64
- let fd = null;
65
- try {
66
- const noFollow = process.platform === "win32" ? 0 : (fsConstants.O_NOFOLLOW || 0);
67
- fd = openSync(target, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0) | noFollow);
68
- const opened = fstatSync(fd);
69
- const named = lstatSync(target);
70
- if (!opened.isFile() || !named.isFile() || named.isSymbolicLink() ||
71
- opened.nlink !== 1 || named.nlink !== 1 ||
72
- opened.dev !== named.dev || opened.ino !== named.ino) {
73
- throw new Error("target must be one unchanged regular file, not a link or directory");
74
- }
75
- raw = readFileSync(fd, "utf8");
76
- const after = lstatSync(target);
77
- if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 ||
78
- after.dev !== opened.dev || after.ino !== opened.ino) {
79
- throw new Error("target changed while it was read");
80
- }
81
- settings = JSON.parse(raw);
82
- } catch (error) {
83
- console.error("Refusing to touch " + target + ": " + error.message);
84
- process.exit(1);
85
- } finally {
86
- if (fd !== null) try { closeSync(fd); } catch { /* ignore */ }
87
- }
88
- }
89
-
90
- function validateSettings(value) {
91
- if (!isObject(value)) return "settings root must be a JSON object";
92
- if (value.hooks !== undefined && !isObject(value.hooks)) return "hooks must be a JSON object";
93
- if (value.hooks && value.hooks.Stop !== undefined && !Array.isArray(value.hooks.Stop)) return "hooks.Stop must be an array";
94
- const groups = (value.hooks && value.hooks.Stop) || [];
95
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
96
- const group = groups[groupIndex];
97
- if (!isObject(group)) return "hooks.Stop[" + groupIndex + "] must be an object";
98
- if (!Array.isArray(group.hooks)) return "hooks.Stop[" + groupIndex + "].hooks must be an array";
99
- for (let hookIndex = 0; hookIndex < group.hooks.length; hookIndex++) {
100
- if (!isObject(group.hooks[hookIndex])) {
101
- return "hooks.Stop[" + groupIndex + "].hooks[" + hookIndex + "] must be an object";
102
- }
103
- }
104
- }
105
- return null;
106
- }
107
-
108
- const shapeError = validateSettings(settings);
109
- if (shapeError) {
110
- console.error("Refusing to touch " + target + ": " + shapeError + ".");
111
- process.exit(1);
112
- }
113
-
114
- const quote = (value) => {
115
- if (process.platform === "win32") return '"' + String(value).replace(/"/g, '""') + '"';
116
- return "'" + String(value).replace(/'/g, "'\\''") + "'";
117
- };
118
- const command = quote(process.execPath) + " " + quote(script) + " " + MANAGED_MARKER +
119
- (scope ? " --scope " + scope : "");
120
-
121
- const isOurHandler = (handler) => {
122
- if (!isObject(handler) || typeof handler.command !== "string") return false;
123
- const value = handler.command;
124
- const token = (name) => new RegExp("(?:^|\\s)" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?=\\s|$)").test(value);
125
- // An exact foreign --unlazy-* option is positive evidence that this is not
126
- // our legacy entry, even when it happens to invoke the same script path.
127
- const foreignUnlazyOption = value.split(/\s+/).some((part) => {
128
- const quoted = part.length >= 2 &&
129
- ((part[0] === '"' && part[part.length - 1] === '"') ||
130
- (part[0] === "'" && part[part.length - 1] === "'"));
131
- const option = quoted ? part.slice(1, -1) : part;
132
- return option.startsWith("--unlazy-") && option !== MANAGED_MARKER;
133
- });
134
- if (foreignUnlazyOption) return false;
135
- if (token(MANAGED_MARKER)) return true;
136
- // Migrate v2.0/v2.1 handlers only when both the exact old marker and the
137
- // packaged scripts/stop-hook.mjs shape are present. Substrings such as
138
- // --unlazy-helper and unrelated same-named scripts are never ownership.
139
- const packagedStopHook = /(?:^|[\s"'])[^\s"']*[\\/]scripts[\\/]stop-hook\.mjs(?=$|[\s"'])/i.test(value);
140
- const namedLegacyInstall = /[\\/]unlazy[\\/]scripts[\\/]stop-hook\.mjs(?=$|[\s"'])/i.test(value);
141
- // Upstream v2.0 shipped before any marker and is recognizable only at its
142
- // conventional Claude skill location. Keep that narrow compatibility path,
143
- // while marker-era moved installs require the exact standalone old token.
144
- const conventionalV2Path = /[\\/]\.claude[\\/]skills[\\/]unlazy[\\/]scripts[\\/]stop-hook\.mjs(?=$|[\s"'])/i.test(value);
145
- return value.includes(script) || conventionalV2Path ||
146
- (token("--unlazy") && (namedLegacyInstall || packagedStopHook));
147
- };
148
-
149
- const originalGroups = (settings.hooks && settings.hooks.Stop) || [];
150
- let removed = 0;
151
- const keptGroups = [];
152
- for (const group of originalGroups) {
153
- const hooks = group.hooks.filter((handler) => {
154
- if (!isOurHandler(handler)) return true;
155
- removed++;
156
- return false;
157
- });
158
- if (hooks.length) keptGroups.push({ ...group, hooks });
159
- }
160
-
161
- function nextSettings(groups) {
162
- const result = { ...settings };
163
- const hooks = { ...(settings.hooks || {}) };
164
- if (groups.length) hooks.Stop = groups;
165
- else delete hooks.Stop;
166
- if (Object.keys(hooks).length) result.hooks = hooks;
167
- else delete result.hooks;
168
- return result;
169
- }
170
-
171
- function persist(value) {
172
- try {
173
- if (raw) writeAtomic(backup, raw);
174
- writeAtomic(target, JSON.stringify(value, null, 2) + "\n");
175
- } catch (error) {
176
- console.error("Could not update " + target + ": " + error.message);
177
- process.exit(1);
178
- }
179
- }
180
-
181
- if (uninstall) {
182
- if (!removed) {
183
- console.log("Nothing to remove: no unlazy Stop hook found in " + target);
184
- process.exit(0);
185
- }
186
- persist(nextSettings(keptGroups));
187
- console.log("Removed unlazy Stop hook (" + removed + " handler(s)) from " + target + ". Sibling handlers were preserved.");
188
- process.exit(0);
189
- }
190
-
191
- const alreadyCurrent = removed === 1 && originalGroups.some((group) =>
192
- group.hooks.some((handler) => isOurHandler(handler) && handler.command === command &&
193
- handler.type === "command" && handler.timeout === 20));
194
- if (alreadyCurrent) {
195
- console.log("Already installed in " + target + " (idempotent, nothing changed).");
196
- process.exit(0);
197
- }
198
-
199
- const entry = { hooks: [{ type: "command", command, timeout: 20 }] };
200
- persist(nextSettings([...keptGroups, entry]));
201
- console.log("Installed unlazy Stop hook into " + target + "\n" +
202
- " command: " + command + "\n" +
203
- " scope: " + (scope ? scope : "resolved per session/pipeline") + "\n" +
204
- " backup: " + (raw ? backup : "not needed for a new file") + "\n" +
205
- " note: keep .unlazy/ and .unlazy-hook-state.json ignored; they are local coordination state." +
206
- (shared ? "\n warning: shared settings contain this machine's absolute Node and skill paths." : ""));
@@ -1,174 +0,0 @@
1
- #!/usr/bin/env node
2
- // Claude Code Stop hook for one unlazy pipeline. Zero dependencies. Node 16+.
3
-
4
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
5
- import { resolve } from "node:path";
6
- import {
7
- UNLAZY_DIR, gateState, hookStatePath, parseGates, qualify, resolveTarget,
8
- sha256, validateScopeId, withFileLock, writeAtomic,
9
- } from "./lib/gates.mjs";
10
- import { dispatchStatus } from "./lib/dispatch.mjs";
11
-
12
- const MAX_BLOCKS = 6;
13
- const safeHostText = (value, max = 500) => String(value)
14
- .replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, " ")
15
- .replace(/\s+/g, " ")
16
- .trim()
17
- .slice(0, max);
18
-
19
- function normalizeHookState(value) {
20
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== 1 ||
21
- !value.sessions || typeof value.sessions !== "object" || Array.isArray(value.sessions)) {
22
- return { schema: 1, sessions: {} };
23
- }
24
- const sessions = {};
25
- for (const [key, current] of Object.entries(value.sessions)) {
26
- if (!/^[a-f0-9]{24}$/.test(key) || !current || typeof current !== "object" || Array.isArray(current) ||
27
- !/^[a-f0-9]{24}$/.test(String(current.hash || "")) ||
28
- !Number.isInteger(current.blocks) || current.blocks < 0 ||
29
- typeof current.updatedAt !== "string" || Number.isNaN(Date.parse(current.updatedAt))) continue;
30
- sessions[key] = current;
31
- }
32
- return { schema: 1, sessions };
33
- }
34
-
35
- const args = process.argv.slice(2);
36
- const scopeIndex = args.indexOf("--scope");
37
- const scopeArg = scopeIndex === -1 ? null : args[scopeIndex + 1];
38
-
39
- const allow = (message) => {
40
- if (message) console.log(JSON.stringify({ systemMessage: message }));
41
- process.exit(0);
42
- };
43
-
44
- if (scopeIndex !== -1 && (!scopeArg || validateScopeId(scopeArg))) {
45
- allow("unlazy: installed hook has an invalid --scope value; not blocking.");
46
- }
47
-
48
- let payload = {};
49
- try { payload = JSON.parse(readFileSync(0, "utf8") || "{}"); }
50
- catch { allow(null); }
51
-
52
- const root = resolve(typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd());
53
- const sessionId = payload.session_id || payload.sessionId || "anonymous";
54
- const sessionKey = sha256(String(sessionId)).slice(0, 24);
55
- const target = resolveTarget({ root, scope: scopeArg, sessionId });
56
-
57
- if (target.ambiguous) {
58
- allow("unlazy: " + target.ambiguous.length + " pipelines under " + UNLAZY_DIR +
59
- "/ (" + target.ambiguous.join(", ") + ") and none bound to this session; not blocking.");
60
- }
61
- if (target.error && !target.ambiguous) allow("unlazy: " + safeHostText(target.error) + "; not blocking.");
62
-
63
- const statePath = hookStatePath(root, target.scope);
64
-
65
- async function clearSessionState() {
66
- if (!existsSync(statePath)) return;
67
- try {
68
- await withFileLock(root, statePath, () => {
69
- let state = { schema: 1, sessions: {} };
70
- try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* replace invalid local state */ }
71
- state = normalizeHookState(state);
72
- delete state.sessions[sessionKey];
73
- if (!Object.keys(state.sessions).length) {
74
- try { unlinkSync(statePath); } catch { /* already absent */ }
75
- } else writeAtomic(statePath, JSON.stringify(state, null, 2) + "\n", { root });
76
- }, { timeoutMs: 10000 });
77
- } catch {
78
- // State cleanup must never trap a session after the gates are complete.
79
- }
80
- }
81
-
82
- const dispatch = dispatchStatus(root, target.scope);
83
-
84
- if (!target.files.length && !dispatch.blocking.length && !dispatch.abandoned.length) {
85
- await clearSessionState();
86
- allow(null);
87
- }
88
-
89
- const unmet = [...dispatch.blocking];
90
- const invalid = [];
91
- const handoffs = [...dispatch.abandoned];
92
- const handoffMessage = () => {
93
- if (!handoffs.length) return "";
94
- const shown = handoffs.slice(0, 5).join(", ") +
95
- (handoffs.length > 5 ? ", +" + (handoffs.length - 5) + " more" : "");
96
- return " HANDOFF REQUIRED: " + handoffs.length + " abandoned item(s): " + safeHostText(shown) + ".";
97
- };
98
- // The loop guard compares resolved gate state between stops, not raw bytes.
99
- // Byte comparison counted any edit as progress: a comment, a reflowed line, or
100
- // the checker rewriting an evidence line with a fresh PATH hash. That rearmed
101
- // the guard indefinitely, so the six-block release could only ever fire for an
102
- // agent doing literally nothing, which is the one case least in need of it.
103
- // Dispatch issue strings encode only canonical state and counts, not raw JSON
104
- // bytes or timestamps, so metadata-only edits do not reset the same guard.
105
- const resolved = [...dispatch.resolved];
106
- for (const file of [...target.files].sort()) {
107
- let text;
108
- try { text = readFileSync(file, "utf8"); }
109
- catch (error) {
110
- invalid.push(qualify(file, "PARSE") + " unreadable: " + safeHostText(error.message));
111
- resolved.push(qualify(file, "PARSE") + "=unreadable");
112
- continue;
113
- }
114
- const doc = parseGates(text);
115
- if (doc.errors.length) {
116
- invalid.push(qualify(file, "PARSE") + " " + doc.errors.slice(0, 2).map((error) => safeHostText(error)).join("; "));
117
- // Record only that the ledger is invalid. Diagnostic text carries line
118
- // numbers, which shift on an unrelated edit and would restore byte coupling.
119
- resolved.push(qualify(file, "PARSE") + "=invalid");
120
- continue;
121
- }
122
- for (const gate of doc.gates) {
123
- const state = gateState(gate, doc.abandoned);
124
- resolved.push(qualify(file, gate.id) + "=" + state);
125
- if (state === "unmet" || state === "unmet-no-evidence") unmet.push(qualify(file, gate.id));
126
- else if (state === "abandoned") handoffs.push(qualify(file, gate.id));
127
- }
128
- }
129
-
130
- if (!unmet.length && !invalid.length) {
131
- await clearSessionState();
132
- if (!handoffs.length) allow(null);
133
- const where = target.scope ? " [scope " + target.scope + "]" : "";
134
- allow("unlazy" + where + ":" + handoffMessage());
135
- }
136
-
137
- const progressHash = sha256(resolved.sort().join("\0")).slice(0, 24);
138
- let sessionState;
139
- try {
140
- sessionState = await withFileLock(root, statePath, () => {
141
- let state = { schema: 1, sessions: {} };
142
- try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* new or corrupt local state */ }
143
- state = normalizeHookState(state);
144
- let current = state.sessions[sessionKey];
145
- if (!current || current.hash !== progressHash) current = { hash: progressHash, blocks: 0 };
146
- current.blocks += 1;
147
- current.updatedAt = new Date().toISOString();
148
- state.sessions[sessionKey] = current;
149
- // Bound abandoned session debris without mixing counters between sessions.
150
- const entries = Object.entries(state.sessions).sort((a, b) => String(b[1].updatedAt).localeCompare(String(a[1].updatedAt)));
151
- state.sessions = Object.fromEntries(entries.slice(0, 64));
152
- writeAtomic(statePath, JSON.stringify(state, null, 2) + "\n", { root });
153
- return current;
154
- }, { timeoutMs: 10000 });
155
- } catch (error) {
156
- allow("unlazy: could not update the serialized hook state (" + safeHostText(error.message) + "); not blocking to avoid a trap.");
157
- }
158
-
159
- const where = target.scope ? " [scope " + target.scope + "]" : "";
160
- const outstanding = [...invalid, ...unmet].map((item) => safeHostText(item));
161
- if (sessionState.blocks > MAX_BLOCKS) {
162
- allow("unlazy: releasing after " + MAX_BLOCKS + " blocks without gate progress" + where +
163
- "; " + outstanding.length + " item(s) remain (" + outstanding.slice(0, 4).join(", ") + ")." +
164
- handoffMessage());
165
- }
166
-
167
- const list = outstanding.slice(0, 5).join(", ") + (outstanding.length > 5 ? ", +" + (outstanding.length - 5) + " more" : "");
168
- console.log(JSON.stringify({
169
- decision: "block",
170
- reason: "unlazy" + where + ": " + outstanding.length + " gate/ledger/dispatch item(s) need work: " + list +
171
- ". Run gate-check.mjs --status to inspect without execution. To run inherited CHECK lines, inspect them and use --approve. " +
172
- "Use ABANDON: <id> <non-blank reason> only when a gate is genuinely impossible." + handoffMessage(),
173
- }));
174
- process.exit(0);