@selesai/code 0.13.7 → 0.13.9
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 +11 -0
- package/dist/core/settings-manager.d.ts +11 -0
- package/dist/core/settings-manager.js +48 -0
- package/dist/extensions/auto-session-name.test.ts +13 -1
- package/dist/extensions/auto-session-name.ts +8 -4
- package/dist/extensions/ponytail/package.json +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +130 -4
- package/dist/modes/rpc/rpc-client.js +156 -4
- package/dist/modes/rpc/rpc-mode.js +484 -1
- package/dist/modes/rpc/rpc-types.d.ts +249 -1
- package/dist/skills/unlazy/CHANGELOG.md +9 -0
- package/dist/skills/unlazy/CONTRIBUTING.md +6 -9
- package/dist/skills/unlazy/README.md +9 -23
- package/dist/skills/unlazy/SECURITY.md +7 -17
- package/dist/skills/unlazy/SKILL.md +4 -12
- package/dist/skills/unlazy/references/dispatch.md +14 -17
- package/dist/skills/unlazy/references/gates.md +3 -3
- package/dist/skills/unlazy/references/orchestration.md +0 -1
- package/dist/skills/unlazy/references/parallel.md +4 -16
- package/dist/skills/unlazy/references/token-economy.md +1 -1
- package/dist/skills/unlazy/research/validation-protocol.md +1 -1
- package/dist/skills/unlazy/scripts/gate-check.mjs +4 -19
- package/dist/skills/unlazy/scripts/gate-lint.mjs +1 -1
- package/dist/skills/unlazy/scripts/lib/gates.mjs +1 -5
- package/dist/skills/unlazy/templates/PLAN.md +1 -1
- package/dist/skills/unlazy/tests/dispatch-tests.mjs +9 -194
- package/dist/skills/unlazy/tests/hardening-tests.mjs +2 -5
- package/dist/skills/unlazy/tests/lint-tests.mjs +1 -1
- package/dist/skills/unlazy/tests/run-tests.mjs +1 -195
- package/dist/skills/unlazy/tests/self-check.mjs +0 -13
- package/dist/skills/unlazy/tests/stress-tests.mjs +0 -191
- package/docs/rpc.md +1 -1
- package/package.json +1 -1
- package/dist/skills/unlazy/scripts/install-hooks.mjs +0 -206
- package/dist/skills/unlazy/scripts/stop-hook.mjs +0 -174
|
@@ -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);
|