@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
|
@@ -16,8 +16,6 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
|
|
17
17
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
const GATE_CHECK = join(HERE, "..", "scripts", "gate-check.mjs");
|
|
19
|
-
const STOP_HOOK = join(HERE, "..", "scripts", "stop-hook.mjs");
|
|
20
|
-
const INSTALL = join(HERE, "..", "scripts", "install-hooks.mjs");
|
|
21
19
|
const filter = process.argv[2] || "";
|
|
22
20
|
const APPROVAL_ROOT = mkdtempSync(join(tmpdir(), "unlazy-test-approvals-"));
|
|
23
21
|
|
|
@@ -43,7 +41,7 @@ function sandbox() {
|
|
|
43
41
|
|
|
44
42
|
function run(script, args, opts = {}) {
|
|
45
43
|
return new Promise((res) => {
|
|
46
|
-
const actions = new Set(["--status", "--claim", "--release", "--list-scopes", "--log", "--
|
|
44
|
+
const actions = new Set(["--status", "--claim", "--release", "--list-scopes", "--log", "--help", "-h"]);
|
|
47
45
|
const needsApproval = script === GATE_CHECK && !opts.noApprove && !args.some((arg) => actions.has(arg));
|
|
48
46
|
const actualArgs = needsApproval && !args.includes("--approve") ? ["--approve", ...args] : args;
|
|
49
47
|
const child = execFile(process.execPath, [script, ...actualArgs], {
|
|
@@ -338,198 +336,6 @@ test("status log: appends, and appends survive concurrency", async () => {
|
|
|
338
336
|
} finally { s.cleanup(); }
|
|
339
337
|
});
|
|
340
338
|
|
|
341
|
-
test("hook: does not block on a pipeline this session does not own", async () => {
|
|
342
|
-
const s = sandbox();
|
|
343
|
-
try {
|
|
344
|
-
// api is finished; web has never been started. Ending the api session must
|
|
345
|
-
// not be blocked by web's gates.
|
|
346
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n- [x] G1: done\n EVIDENCE: measured, 8/8 passed\n");
|
|
347
|
-
s.write(".unlazy/web/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "not started", null, null));
|
|
348
|
-
const r = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir }) });
|
|
349
|
-
assertLacks(r.out, '"decision":"block"', "hook");
|
|
350
|
-
assert(r.code === 0, "hook should exit 0");
|
|
351
|
-
} finally { s.cleanup(); }
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("hook: blocks on its own scope, naming qualified ids", async () => {
|
|
355
|
-
const s = sandbox();
|
|
356
|
-
try {
|
|
357
|
-
s.write(".unlazy/api/gates/leaf-7.md", "# Gates\n\n" + gate("G3", "unfinished", null, null));
|
|
358
|
-
s.write(".unlazy/web/gates/leaf-1.md", "# Gates\n\n- [x] G1: done\n EVIDENCE: proven\n");
|
|
359
|
-
const r = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir }) });
|
|
360
|
-
assertHas(r.out, '"decision":"block"');
|
|
361
|
-
assertHas(r.out, "leaf-7:G3");
|
|
362
|
-
assertHas(r.out, "[scope api]");
|
|
363
|
-
} finally { s.cleanup(); }
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
test("hook: abandonment allows Stop but reports an explicit bounded handoff", async () => {
|
|
367
|
-
const s = sandbox();
|
|
368
|
-
try {
|
|
369
|
-
s.write(".unlazy/api/gates/leaf-7.md", [
|
|
370
|
-
"# Gates",
|
|
371
|
-
"",
|
|
372
|
-
"- [ ] G3: impossible",
|
|
373
|
-
" EVIDENCE: pending",
|
|
374
|
-
"",
|
|
375
|
-
"ABANDON: G3 secret-looking reason that must stay ledger-local",
|
|
376
|
-
"",
|
|
377
|
-
].join("\n"));
|
|
378
|
-
const r = await run(STOP_HOOK, ["--scope", "api"], {
|
|
379
|
-
cwd: s.dir,
|
|
380
|
-
stdin: JSON.stringify({ cwd: s.dir, session_id: "handoff-test" }),
|
|
381
|
-
});
|
|
382
|
-
assertLacks(r.out, '"decision":"block"');
|
|
383
|
-
assertHas(r.out, "HANDOFF REQUIRED");
|
|
384
|
-
assertHas(r.out, "leaf-7:G3");
|
|
385
|
-
assertLacks(r.out, "secret-looking reason");
|
|
386
|
-
} finally { s.cleanup(); }
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
test("hook: unresolvable scope allows the stop instead of blocking blindly", async () => {
|
|
390
|
-
const s = sandbox();
|
|
391
|
-
try {
|
|
392
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "a", null, null));
|
|
393
|
-
s.write(".unlazy/web/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "b", null, null));
|
|
394
|
-
const r = await run(STOP_HOOK, [], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir }) });
|
|
395
|
-
assertLacks(r.out, '"decision":"block"', "hook");
|
|
396
|
-
assertHas(r.out, "2 pipelines");
|
|
397
|
-
} finally { s.cleanup(); }
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
test("hook: session binding resolves the scope among several", async () => {
|
|
401
|
-
const s = sandbox();
|
|
402
|
-
try {
|
|
403
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "a", null, null));
|
|
404
|
-
s.write(".unlazy/web/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "b", null, null));
|
|
405
|
-
const bind = await run(GATE_CHECK, ["--scope", "web", "--bind", "sess-abc"], { cwd: s.dir });
|
|
406
|
-
assertHas(bind.out, "bound session sess-abc to scope web");
|
|
407
|
-
const r = await run(STOP_HOOK, [], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir, session_id: "sess-abc" }) });
|
|
408
|
-
assertHas(r.out, '"decision":"block"');
|
|
409
|
-
assertHas(r.out, "[scope web]");
|
|
410
|
-
} finally { s.cleanup(); }
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
test("hook: each pipeline keeps its own loop-guard counter", async () => {
|
|
414
|
-
const s = sandbox();
|
|
415
|
-
try {
|
|
416
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "a", null, null));
|
|
417
|
-
s.write(".unlazy/web/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "b", null, null));
|
|
418
|
-
const stdin = JSON.stringify({ cwd: s.dir });
|
|
419
|
-
for (let i = 0; i < 7; i++) await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
420
|
-
const apiState = JSON.parse(s.read(".unlazy/api/hook-state.json"));
|
|
421
|
-
const apiSession = Object.values(apiState.sessions)[0];
|
|
422
|
-
assert(apiSession.blocks === 7, "api counter should be 7, got " + apiSession.blocks);
|
|
423
|
-
// api has exhausted its guard and now releases; web is untouched and still blocks.
|
|
424
|
-
const apiNow = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
425
|
-
assertHas(apiNow.out, "releasing after 6 blocks");
|
|
426
|
-
const webNow = await run(STOP_HOOK, ["--scope", "web"], { cwd: s.dir, stdin });
|
|
427
|
-
assertHas(webNow.out, '"decision":"block"');
|
|
428
|
-
} finally { s.cleanup(); }
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("hook: the loop guard tracks gate state, not file bytes", async () => {
|
|
432
|
-
const s = sandbox();
|
|
433
|
-
try {
|
|
434
|
-
// A cosmetic edit is not progress. Keying the guard to raw bytes let any
|
|
435
|
-
// touch of the ledger reset the counter, so an agent that keeps editing
|
|
436
|
-
// without meeting a gate is never released. Re-running the checker did the
|
|
437
|
-
// same thing by rewriting evidence text.
|
|
438
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "a", null, null) + gate("G2", "b", null, null));
|
|
439
|
-
const stdin = JSON.stringify({ cwd: s.dir });
|
|
440
|
-
for (let i = 0; i < 6; i++) {
|
|
441
|
-
const blocked = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
442
|
-
assertHas(blocked.out, '"decision":"block"');
|
|
443
|
-
}
|
|
444
|
-
s.write(".unlazy/api/gates/leaf-1.md",
|
|
445
|
-
s.read(".unlazy/api/gates/leaf-1.md") + "\n<!-- still thinking about it -->\n");
|
|
446
|
-
const afterCosmetic = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
447
|
-
assertHas(afterCosmetic.out, "releasing after 6 blocks");
|
|
448
|
-
} finally { s.cleanup(); }
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
test("hook: meeting a gate resets the loop guard", async () => {
|
|
452
|
-
const s = sandbox();
|
|
453
|
-
try {
|
|
454
|
-
// The converse of the test above: real progress must still rearm the guard,
|
|
455
|
-
// or a long run would be released while it is genuinely advancing.
|
|
456
|
-
s.write(".unlazy/api/gates/leaf-1.md", "# Gates\n\n" + gate("G1", "a", null, null) + gate("G2", "b", null, null));
|
|
457
|
-
const stdin = JSON.stringify({ cwd: s.dir });
|
|
458
|
-
for (let i = 0; i < 3; i++) await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
459
|
-
s.write(".unlazy/api/gates/leaf-1.md",
|
|
460
|
-
"# Gates\n\n- [x] G1: a\n EVIDENCE: measured 3 of 3\n\n" + gate("G2", "b", null, null));
|
|
461
|
-
for (let i = 0; i < 4; i++) {
|
|
462
|
-
const blocked = await run(STOP_HOOK, ["--scope", "api"], { cwd: s.dir, stdin });
|
|
463
|
-
assertHas(blocked.out, '"decision":"block"');
|
|
464
|
-
}
|
|
465
|
-
} finally { s.cleanup(); }
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
test("hook: no gate files anywhere means silence", async () => {
|
|
469
|
-
const s = sandbox();
|
|
470
|
-
try {
|
|
471
|
-
const r = await run(STOP_HOOK, [], { cwd: s.dir, stdin: JSON.stringify({ cwd: s.dir }) });
|
|
472
|
-
assert(r.out.trim() === "", "expected no output, got: " + r.out);
|
|
473
|
-
assert(r.code === 0, "expected exit 0");
|
|
474
|
-
} finally { s.cleanup(); }
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("install: repeated install stays a single Stop entry", async () => {
|
|
478
|
-
const s = sandbox();
|
|
479
|
-
try {
|
|
480
|
-
await run(INSTALL, ["--scope", "api"], { cwd: s.dir });
|
|
481
|
-
await run(INSTALL, ["--scope", "api"], { cwd: s.dir });
|
|
482
|
-
const cfg = JSON.parse(s.read(".claude/settings.local.json"));
|
|
483
|
-
assert(cfg.hooks.Stop.length === 1, "expected 1 Stop entry, got " + cfg.hooks.Stop.length);
|
|
484
|
-
assertHas(cfg.hooks.Stop[0].hooks[0].command, "--scope api", "hook command");
|
|
485
|
-
} finally { s.cleanup(); }
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
test("install: changing the scope replaces the entry instead of stacking", async () => {
|
|
489
|
-
const s = sandbox();
|
|
490
|
-
try {
|
|
491
|
-
await run(INSTALL, ["--scope", "api"], { cwd: s.dir });
|
|
492
|
-
await run(INSTALL, ["--scope", "web"], { cwd: s.dir });
|
|
493
|
-
const cfg = JSON.parse(s.read(".claude/settings.local.json"));
|
|
494
|
-
assert(cfg.hooks.Stop.length === 1, "expected 1 Stop entry, got " + cfg.hooks.Stop.length);
|
|
495
|
-
assertHas(cfg.hooks.Stop[0].hooks[0].command, "--scope web", "hook command");
|
|
496
|
-
} finally { s.cleanup(); }
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
test("install: uninstall removes our entry and leaves others alone", async () => {
|
|
500
|
-
const s = sandbox();
|
|
501
|
-
try {
|
|
502
|
-
s.write(".claude/settings.local.json", JSON.stringify({
|
|
503
|
-
hooks: { Stop: [{ hooks: [{ type: "command", command: "node other-tool.mjs" }] }] },
|
|
504
|
-
}, null, 2));
|
|
505
|
-
await run(INSTALL, ["--scope", "api"], { cwd: s.dir });
|
|
506
|
-
const r = await run(INSTALL, ["--uninstall"], { cwd: s.dir });
|
|
507
|
-
assertHas(r.out, "Removed unlazy Stop hook");
|
|
508
|
-
const cfg = JSON.parse(s.read(".claude/settings.local.json"));
|
|
509
|
-
assert(cfg.hooks.Stop.length === 1, "expected the unrelated hook to remain");
|
|
510
|
-
assertHas(cfg.hooks.Stop[0].hooks[0].command, "other-tool.mjs", "unrelated hook");
|
|
511
|
-
} finally { s.cleanup(); }
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
test("install: an upstream v2.0 entry is still recognised and removable", async () => {
|
|
515
|
-
const s = sandbox();
|
|
516
|
-
try {
|
|
517
|
-
s.write(".claude/settings.local.json", JSON.stringify({
|
|
518
|
-
hooks: {
|
|
519
|
-
Stop: [{
|
|
520
|
-
hooks: [{
|
|
521
|
-
type: "command",
|
|
522
|
-
command: 'node "/home/me/.claude/skills/unlazy/scripts/stop-hook.mjs"',
|
|
523
|
-
}],
|
|
524
|
-
}],
|
|
525
|
-
},
|
|
526
|
-
}, null, 2));
|
|
527
|
-
const r = await run(INSTALL, ["--uninstall"], { cwd: s.dir });
|
|
528
|
-
assertHas(r.out, "Removed unlazy Stop hook");
|
|
529
|
-
const cfg = JSON.parse(s.read(".claude/settings.local.json"));
|
|
530
|
-
assert(!cfg.hooks, "settings should be left clean, got " + JSON.stringify(cfg));
|
|
531
|
-
} finally { s.cleanup(); }
|
|
532
|
-
});
|
|
533
339
|
|
|
534
340
|
// ---------------------------------------------------------------- driver
|
|
535
341
|
|
|
@@ -19,8 +19,6 @@ const SCRIPTS = [
|
|
|
19
19
|
"scripts/gate-check.mjs",
|
|
20
20
|
"scripts/gate-lint.mjs",
|
|
21
21
|
"scripts/dispatch-check.mjs",
|
|
22
|
-
"scripts/stop-hook.mjs",
|
|
23
|
-
"scripts/install-hooks.mjs",
|
|
24
22
|
"scripts/lib/gates.mjs",
|
|
25
23
|
"scripts/lib/dispatch.mjs",
|
|
26
24
|
"scripts/lib/check-supervisor.mjs",
|
|
@@ -93,13 +91,6 @@ check("approval identity binds execution semantics", () => {
|
|
|
93
91
|
return missing.length ? "approval oracle missing source tokens: " + missing.join(", ") : null;
|
|
94
92
|
});
|
|
95
93
|
|
|
96
|
-
check("the hook resolves a scope rather than globbing the tree", () => {
|
|
97
|
-
const src = read("scripts/stop-hook.mjs");
|
|
98
|
-
if (!src.includes("resolveTarget")) return "stop-hook.mjs does not resolve a scope";
|
|
99
|
-
if (!src.includes("hookStatePath")) return "stop-hook.mjs does not use a per-scope state path";
|
|
100
|
-
return null;
|
|
101
|
-
});
|
|
102
|
-
|
|
103
94
|
check("every local resource the skill names exists", () => {
|
|
104
95
|
const skill = read("SKILL.md");
|
|
105
96
|
const missing = [];
|
|
@@ -138,13 +129,9 @@ check("request reconciliation keeps the focused solo cheap path", () => {
|
|
|
138
129
|
|
|
139
130
|
check("abandonment is terminal handoff rather than ALL MET", () => {
|
|
140
131
|
const checker = read("scripts/gate-check.mjs");
|
|
141
|
-
const hook = read("scripts/stop-hook.mjs");
|
|
142
132
|
if (!checker.includes("HANDOFF REQUIRED") || !checker.includes("totalAbandoned === 0")) {
|
|
143
133
|
return "gate checker can still classify abandonment as completion";
|
|
144
134
|
}
|
|
145
|
-
if (!hook.includes("HANDOFF REQUIRED") || !hook.includes("handoffs")) {
|
|
146
|
-
return "Stop hook does not surface bounded abandonment handoff";
|
|
147
|
-
}
|
|
148
135
|
return null;
|
|
149
136
|
});
|
|
150
137
|
|
|
@@ -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/docs/rpc.md
CHANGED
|
@@ -740,7 +740,7 @@ Each command has:
|
|
|
740
740
|
- `"path"`: Explicit path via CLI or settings
|
|
741
741
|
- `path`: Absolute file path to the command source (optional)
|
|
742
742
|
|
|
743
|
-
**Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are
|
|
743
|
+
**Note**: Built-in TUI commands (`/settings`, `/hotkeys`, etc.) are included with `source: "builtin"` and `interactiveOnly: true`. They are display-only: they are handled only in interactive mode and would not execute if sent via `prompt`.
|
|
744
744
|
|
|
745
745
|
## Events
|
|
746
746
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selesai/code",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.9",
|
|
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": {
|