cyberlegion 0.3.1 → 0.3.2
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/dist/cli.mjs +3 -2
- package/dist/index.d.mts +15 -1
- package/dist/index.mjs +1 -1
- package/dist/{watch-BZmEAqUo.mjs → watch-D33fQcUI.mjs} +329 -76
- package/{LICENSE → license} +1 -1
- package/package.json +4 -5
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as bumpLastSeen, B as registerStanding, C as nudgeUnit, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, G as resolveStandingOwner, H as resolvePresence, I as prune, J as listAgentDefs, K as touch, L as realExec, M as clearPresence, N as detectHarness, O as DELIVERY_DOORBELL, P as listAgents, R as reconcile, S as focusUnit, V as resolveAgent, W as resolveSelfId, Y as resolveAgentDef, _ as install, a as emit, b as resolveSpawnLaunch, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, j as claimPresence, k as wakeRecipient, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as normalizeMuxEnv, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as readUnit, x as clearUnit, z as register } from "./watch-
|
|
2
|
+
import { A as bumpLastSeen, B as registerStanding, C as nudgeUnit, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, G as resolveStandingOwner, H as resolvePresence, I as prune, J as listAgentDefs, K as touch, L as realExec, M as clearPresence, N as detectHarness, O as DELIVERY_DOORBELL, P as listAgents, R as reconcile, S as focusUnit, V as resolveAgent, W as resolveSelfId, Y as resolveAgentDef, _ as install, a as emit, b as resolveSpawnLaunch, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, j as claimPresence, k as wakeRecipient, l as toonObject, m as readAck, n as awaitReply, o as fail, p as peek, q as normalizeMuxEnv, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, w as readUnit, x as clearUnit, z as register } from "./watch-D33fQcUI.mjs";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { Command, Option } from "commander";
|
|
5
6
|
import { currentPane, probeMultiplexer } from "cyber-mux";
|
|
@@ -74,7 +75,7 @@ function readCommandOutput(format, result) {
|
|
|
74
75
|
}
|
|
75
76
|
//#endregion
|
|
76
77
|
//#region src/cli.ts
|
|
77
|
-
const VERSION = "
|
|
78
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
78
79
|
function ctxOf(opts) {
|
|
79
80
|
return {
|
|
80
81
|
store: new FileStore(resolveRoot({ space: opts.space })),
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Exec, Exec as Exec$1, MuxAdapter, MuxPlacement, MuxProbe, NudgeOptions, probeMultiplexer } from "cyber-mux";
|
|
2
2
|
import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
|
|
3
|
-
|
|
4
3
|
//#region src/store/store.d.ts
|
|
5
4
|
interface Message {
|
|
6
5
|
id: string;
|
|
@@ -103,6 +102,15 @@ interface Store {
|
|
|
103
102
|
setMainPane(pane: string | null): void;
|
|
104
103
|
/** The hub's currently bound main pane, or undefined when none is bound. */
|
|
105
104
|
getMainPane(): string | undefined;
|
|
105
|
+
/** Run `fn` while holding an exclusive, named advisory lock scoped to this store's root —
|
|
106
|
+
* mutual exclusion for a genuine read-modify-write (read current state, decide, write back),
|
|
107
|
+
* which an atomic single-file write alone can't make safe against a concurrent racer. Blocks
|
|
108
|
+
* until acquired or throws `LockTimeoutError` (`store/lock.ts`) after a bounded wait; never
|
|
109
|
+
* steals a lock a live holder still holds — only one abandoned by a confirmed-dead process.
|
|
110
|
+
* `setMainPane` uses this internally; callers doing their own load-mutate-save against this
|
|
111
|
+
* store (e.g. a standing record's presence rebind) should hold the SAME named lock around that
|
|
112
|
+
* whole sequence, not just around the final write. */
|
|
113
|
+
withLock<T>(name: string, fn: () => T): T;
|
|
106
114
|
}
|
|
107
115
|
//#endregion
|
|
108
116
|
//#region src/identity.d.ts
|
|
@@ -364,6 +372,11 @@ declare const paths: {
|
|
|
364
372
|
dataDir: (root: string, id: string) => string;
|
|
365
373
|
briefFile: (root: string, id: string) => string;
|
|
366
374
|
mainPaneFile: (root: string) => string;
|
|
375
|
+
/** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
|
|
376
|
+
* id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
|
|
377
|
+
* string becoming a filename). */
|
|
378
|
+
messageFile: (root: string, toId: string, msgId: string) => string;
|
|
379
|
+
messageReadFile: (root: string, toId: string, msgId: string) => string;
|
|
367
380
|
};
|
|
368
381
|
//#endregion
|
|
369
382
|
//#region src/session.d.ts
|
|
@@ -453,6 +466,7 @@ declare class FileStore implements Store {
|
|
|
453
466
|
readBrief(agentId: string): string | undefined;
|
|
454
467
|
setMainPane(pane: string | null): void;
|
|
455
468
|
getMainPane(): string | undefined;
|
|
469
|
+
withLock<T>(name: string, fn: () => T): T;
|
|
456
470
|
}
|
|
457
471
|
//#endregion
|
|
458
472
|
//#region src/wake/await.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as resolveProjectLocalRoot, A as bumpLastSeen, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, I as prune, J as listAgentDefs, K as touch, L as realExec, N as detectHarness, P as listAgents, Q as projectRoot, T as spawn, U as resolveRecipient, V as resolveAgent, W as resolveSelfId, X as ensureMarker, Y as resolveAgentDef, Z as paths, _ as install, a as emit, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, l as toonObject, n as awaitReply, o as fail, p as peek, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, y as realizeLaunch, z as register } from "./watch-
|
|
1
|
+
import { $ as resolveProjectLocalRoot, A as bumpLastSeen, D as selectSessionAdapter, E as spawnAndWake, F as loadAgent, I as prune, J as listAgentDefs, K as touch, L as realExec, N as detectHarness, P as listAgents, Q as projectRoot, T as spawn, U as resolveRecipient, V as resolveAgent, W as resolveSelfId, X as ensureMarker, Y as resolveAgentDef, Z as paths, _ as install, a as emit, c as toonList, d as deleteMessage, et as resolveRoot, f as inbox, g as send, h as resolveBody, i as injectInbox, l as toonObject, n as awaitReply, o as fail, p as peek, r as FileStore, s as nextStep, t as watchMail, u as ack, v as decommission, y as realizeLaunch, z as register } from "./watch-D33fQcUI.mjs";
|
|
2
2
|
import { probeMultiplexer } from "cyber-mux";
|
|
3
3
|
import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
|
|
4
4
|
export { FileStore, ack, assertDistinctFromPrimary, awaitReply, bumpLastSeen, decommission, deleteMessage, detectHarness, emit, ensureMarker, fail, gitWorktreeAdapter, inbox, injectInbox, install, listAgentDefs, listAgents, loadAgent, nextStep, paths, peek, probeMultiplexer, projectRoot, prune, realExec, realizeLaunch, register, resolveAgent, resolveAgentDef, resolveBody, resolvePrimaryRoot, resolveProjectLocalRoot, resolveRecipient, resolveRoot, resolveSelfId, selectSessionAdapter, send, spawn, spawnAndWake, toonList, toonObject, touch, watchMail };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
3
|
import { callerPane, currentPane, herdrMuxAdapter, nodeExec, nudge, probeMultiplexer, tmuxMuxAdapter } from "cyber-mux";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { assertDistinctFromPrimary, gitWorktreeAdapter, resolvePrimaryRoot } from "cyber-mux/worktree";
|
|
@@ -75,16 +75,55 @@ function ensureMarker(root) {
|
|
|
75
75
|
const marker = join(root, MARKER_FILE);
|
|
76
76
|
if (!existsSync(marker)) writeFileSync(marker, `${JSON.stringify({ version: 1 }, null, 2)}\n`);
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when an agent/message id destined to become a filename PATH SEGMENT fails
|
|
80
|
+
* `assertSafeId` — see that function's doc for why this rejects rather than encodes.
|
|
81
|
+
*/
|
|
82
|
+
var InvalidIdError = class extends Error {
|
|
83
|
+
kind;
|
|
84
|
+
value;
|
|
85
|
+
constructor(kind, value) {
|
|
86
|
+
super(`invalid ${kind} ${JSON.stringify(value)} — must not be empty, ".", "..", or contain a path separator`);
|
|
87
|
+
this.kind = kind;
|
|
88
|
+
this.value = value;
|
|
89
|
+
this.name = "InvalidIdError";
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const UNSAFE_ID = /[\\/\0]/;
|
|
93
|
+
/**
|
|
94
|
+
* Filename-safety guard for any id that becomes a bare PATH SEGMENT under the hub root — agent ids
|
|
95
|
+
* (`agentFile`/`inboxDir`/`dataDir`/`briefFile`) and message ids (`messageFile`). Both are
|
|
96
|
+
* effectively user/peer-controlled: an agent id round-trips a caller-supplied `--handle`-adjacent
|
|
97
|
+
* value in some flows, and nothing upstream of the store validates it before it reaches a `join()`.
|
|
98
|
+
*
|
|
99
|
+
* REJECT, don't encode: unlike `sanitizePane` (which deliberately encodes, because a tmux/herdr pane
|
|
100
|
+
* locator is an opaque, internally-produced token that only needs to survive as a *lookup key*, not
|
|
101
|
+
* round-trip as an identity), an agent/message id is a PRIMARY KEY — `getAgent(id)` must return
|
|
102
|
+
* exactly what `putAgent` stored under that same `id`. Silently encoding two different malformed ids
|
|
103
|
+
* onto the same sanitized filename would let one caller's write silently clobber or read another's
|
|
104
|
+
* record — a worse failure than a loud, immediate refusal. So: empty, `.`, `..`, and any embedded
|
|
105
|
+
* path separator (POSIX `/`, Windows `\`) or NUL byte are rejected outright, which also rejects an
|
|
106
|
+
* absolute path outright (an absolute path always contains a separator).
|
|
107
|
+
*/
|
|
108
|
+
function assertSafeId(id, kind) {
|
|
109
|
+
if (!id || id === "." || id === ".." || UNSAFE_ID.test(id)) throw new InvalidIdError(kind, id);
|
|
110
|
+
return id;
|
|
111
|
+
}
|
|
78
112
|
const paths = {
|
|
79
113
|
agentsDir: (root) => join(root, "agents"),
|
|
80
|
-
agentFile: (root, id) => join(root, "agents", `${id}.json`),
|
|
114
|
+
agentFile: (root, id) => join(root, "agents", `${assertSafeId(id, "agent id")}.json`),
|
|
81
115
|
panesDir: (root) => join(root, "panes"),
|
|
82
116
|
paneFile: (root, pane) => join(root, "panes", `${sanitizePane(pane)}.id`),
|
|
83
|
-
inboxDir: (root, id) => join(root, "inbox", id),
|
|
84
|
-
inboxReadDir: (root, id) => join(root, "inbox", id, "read"),
|
|
85
|
-
dataDir: (root, id) => join(root, "data", id),
|
|
86
|
-
briefFile: (root, id) => join(root, "data", id, "brief.md"),
|
|
87
|
-
mainPaneFile: (root) => join(root, "main-pane.id")
|
|
117
|
+
inboxDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id")),
|
|
118
|
+
inboxReadDir: (root, id) => join(root, "inbox", assertSafeId(id, "agent id"), "read"),
|
|
119
|
+
dataDir: (root, id) => join(root, "data", assertSafeId(id, "agent id")),
|
|
120
|
+
briefFile: (root, id) => join(root, "data", assertSafeId(id, "agent id"), "brief.md"),
|
|
121
|
+
mainPaneFile: (root) => join(root, "main-pane.id"),
|
|
122
|
+
/** A message's file path within `toId`'s unread/read inbox dir, keyed by its own collision-free
|
|
123
|
+
* id — validated the same as an agent id (it's the same class of risk: a peer- or CLI-controlled
|
|
124
|
+
* string becoming a filename). */
|
|
125
|
+
messageFile: (root, toId, msgId) => join(paths.inboxDir(root, toId), `${assertSafeId(msgId, "message id")}.json`),
|
|
126
|
+
messageReadFile: (root, toId, msgId) => join(paths.inboxReadDir(root, toId), `${assertSafeId(msgId, "message id")}.json`)
|
|
88
127
|
};
|
|
89
128
|
/**
|
|
90
129
|
* Where a spawned unit's own git worktree is checked out by default — a sibling of the primary
|
|
@@ -441,14 +480,18 @@ function resolveStandingOwner(store, handle) {
|
|
|
441
480
|
* multiplexer never touches the pointer. Last claim wins: a plain overwrite, no merge.
|
|
442
481
|
*/
|
|
443
482
|
function claimPresence(ctx, handle) {
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
483
|
+
const standingId = resolveStandingOwner(ctx.store, handle);
|
|
484
|
+
if (!loadAgent(ctx.store, standingId)) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
446
485
|
if (probeMultiplexer(ctx.exec ?? realExec, normalizeMuxEnv(ctx.env ?? process.env)).mux === "none") throw new Error("claiming a presence needs a multiplexer to open panes");
|
|
447
486
|
const selfId = resolveSelfId(ctx);
|
|
448
487
|
if (!selfId) throw new Error("no identity in this session — run `cyberlegion unit register` first");
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
488
|
+
return ctx.store.withLock(`presence:${standingId}`, () => {
|
|
489
|
+
const rec = loadAgent(ctx.store, standingId);
|
|
490
|
+
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
491
|
+
rec.presence = selfId;
|
|
492
|
+
saveAgent(ctx.store, rec);
|
|
493
|
+
return rec;
|
|
494
|
+
});
|
|
452
495
|
}
|
|
453
496
|
/**
|
|
454
497
|
* Unbind a standing owner's presence. The unknown-handle throw (via `resolveStandingOwner`) wins
|
|
@@ -457,13 +500,17 @@ function claimPresence(ctx, handle) {
|
|
|
457
500
|
* reporting a clear it never performed.
|
|
458
501
|
*/
|
|
459
502
|
function clearPresence(ctx, handle) {
|
|
460
|
-
const
|
|
461
|
-
if (!
|
|
462
|
-
|
|
463
|
-
rec
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
503
|
+
const standingId = resolveStandingOwner(ctx.store, handle);
|
|
504
|
+
if (!loadAgent(ctx.store, standingId)) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
505
|
+
return ctx.store.withLock(`presence:${standingId}`, () => {
|
|
506
|
+
const rec = loadAgent(ctx.store, standingId);
|
|
507
|
+
if (!rec) throw new Error(`no standing owner "${handle}" — run 'cyberlegion unit register --standing --handle ${handle}'`);
|
|
508
|
+
if (rec.presence !== void 0) {
|
|
509
|
+
rec.presence = void 0;
|
|
510
|
+
saveAgent(ctx.store, rec);
|
|
511
|
+
}
|
|
512
|
+
return rec;
|
|
513
|
+
});
|
|
467
514
|
}
|
|
468
515
|
/**
|
|
469
516
|
* The live-only presence rule, keyed off a standing record the caller ALREADY HOLDS — the pointer
|
|
@@ -532,7 +579,7 @@ function touch(ctx) {
|
|
|
532
579
|
const id = resolveSelfId(ctx);
|
|
533
580
|
if (id) bumpLastSeen(ctx, id);
|
|
534
581
|
}
|
|
535
|
-
const STALE_MS =
|
|
582
|
+
const STALE_MS = 9e5;
|
|
536
583
|
/** The per-mux session adapters `prune` consults for pane liveness — each answers with its own
|
|
537
584
|
* backend primitive so a herdr pane is never probed with a tmux query, and vice versa. */
|
|
538
585
|
const PANE_ADAPTERS = {
|
|
@@ -778,25 +825,8 @@ function selectSessionAdapter(env, exec = realExec) {
|
|
|
778
825
|
}
|
|
779
826
|
//#endregion
|
|
780
827
|
//#region src/workspace-label.ts
|
|
781
|
-
/**
|
|
782
|
-
* Workspace labels — the short, human-scannable name a `workspace` placement opens under.
|
|
783
|
-
*
|
|
784
|
-
* A unit's own visible space is what a human scans to find a session, so a bare backend default
|
|
785
|
-
* ("workspace 4") costs a search every time. A label is `<code>-<subject>`, capped at 30 characters
|
|
786
|
-
* INCLUDING the code, so it stays readable in a sidebar/status bar at a glance.
|
|
787
|
-
*
|
|
788
|
-
* The code is a NieR YoRHa unit class picked from the brief's leading action word in one fixed
|
|
789
|
-
* order, so the same brief always yields the same code:
|
|
790
|
-
* - `A2-` — the action tears down or reverts (A2 is the rogue/attack unit)
|
|
791
|
-
* - `9S-` — the action is read-only recon (9S is the Scanner)
|
|
792
|
-
* - `2B-` — everything else: build-and-change work, the default (2B is the battle unit)
|
|
793
|
-
*/
|
|
794
|
-
/** Total label budget, code included. */
|
|
795
|
-
const LABEL_CAP = 30;
|
|
796
|
-
/** Every code is the same width, so the subject budget is a constant. */
|
|
797
|
-
const CODE_WIDTH = 3;
|
|
798
828
|
/** Leading actions that tear down or revert — the `A2-` class, checked first. */
|
|
799
|
-
const TEARDOWN_ACTIONS = new Set([
|
|
829
|
+
const TEARDOWN_ACTIONS = /* @__PURE__ */ new Set([
|
|
800
830
|
"clean",
|
|
801
831
|
"cleanup",
|
|
802
832
|
"decommission",
|
|
@@ -817,7 +847,7 @@ const TEARDOWN_ACTIONS = new Set([
|
|
|
817
847
|
"unregister"
|
|
818
848
|
]);
|
|
819
849
|
/** Leading actions that only look and report — the `9S-` class, checked after teardown. */
|
|
820
|
-
const RECON_ACTIONS = new Set([
|
|
850
|
+
const RECON_ACTIONS = /* @__PURE__ */ new Set([
|
|
821
851
|
"analyze",
|
|
822
852
|
"assess",
|
|
823
853
|
"audit",
|
|
@@ -852,7 +882,7 @@ const RECON_ACTIONS = new Set([
|
|
|
852
882
|
* dropped from it (the code already says "build"), an unrecognized leading word is kept (it is the
|
|
853
883
|
* subject's own first noun, and dropping it would strip real signal).
|
|
854
884
|
*/
|
|
855
|
-
const BUILD_ACTIONS = new Set([
|
|
885
|
+
const BUILD_ACTIONS = /* @__PURE__ */ new Set([
|
|
856
886
|
"add",
|
|
857
887
|
"backfill",
|
|
858
888
|
"build",
|
|
@@ -882,7 +912,7 @@ const BUILD_ACTIONS = new Set([
|
|
|
882
912
|
"write"
|
|
883
913
|
]);
|
|
884
914
|
/** Dropped when it leads the subject — an article carries no identifying signal. */
|
|
885
|
-
const LEADING_ARTICLES = new Set([
|
|
915
|
+
const LEADING_ARTICLES = /* @__PURE__ */ new Set([
|
|
886
916
|
"a",
|
|
887
917
|
"an",
|
|
888
918
|
"the"
|
|
@@ -910,7 +940,7 @@ function deriveWorkspaceLabel(input) {
|
|
|
910
940
|
subjectWords = leadIsAction ? briefWords.slice(1) : briefWords.slice();
|
|
911
941
|
if (subjectWords[0] && LEADING_ARTICLES.has(subjectWords[0])) subjectWords.shift();
|
|
912
942
|
}
|
|
913
|
-
const subject = fitWords(subjectWords,
|
|
943
|
+
const subject = fitWords(subjectWords, 27) || input.id.slice(0, 6);
|
|
914
944
|
return `${code}${subject}`;
|
|
915
945
|
}
|
|
916
946
|
/** The brief's first line with any content — a leading blank line is not the subject. */
|
|
@@ -962,7 +992,7 @@ const RESET_MAP = {
|
|
|
962
992
|
* honest fresh-context command for these, so they are refused explicitly rather than lumped in
|
|
963
993
|
* with a harness that is merely unmapped.
|
|
964
994
|
*/
|
|
965
|
-
const FALSE_FRIEND_HARNESSES = new Set(["gemini"]);
|
|
995
|
+
const FALSE_FRIEND_HARNESSES = /* @__PURE__ */ new Set(["gemini"]);
|
|
966
996
|
/**
|
|
967
997
|
* Resolve `harness`'s own fresh-context command from the reset map. String-keyed (not `Harness`)
|
|
968
998
|
* so it also guards a harness present in `LAUNCH_MAP`/`identity.ts`'s `Harness` union but not yet
|
|
@@ -1199,7 +1229,7 @@ async function nudgeUnit(ctx, ref, options = {}) {
|
|
|
1199
1229
|
function readUnit(ctx, ref, options = {}) {
|
|
1200
1230
|
const { agent, target } = paneTargetOf(ctx, ref);
|
|
1201
1231
|
const exec = ctx.exec ?? realExec;
|
|
1202
|
-
const output = selectSessionAdapter(ctx.env ?? process.env, exec).read(exec, target, { lines: options.lines });
|
|
1232
|
+
const output = selectSessionAdapter(ctx.env ?? process.env, exec).read(exec, target, { lines: options.lines }).text;
|
|
1203
1233
|
return {
|
|
1204
1234
|
agent,
|
|
1205
1235
|
pane: target.id,
|
|
@@ -1592,18 +1622,236 @@ function injectInbox(ctx, event) {
|
|
|
1592
1622
|
} };
|
|
1593
1623
|
}
|
|
1594
1624
|
//#endregion
|
|
1625
|
+
//#region src/store/errors.ts
|
|
1626
|
+
/** A record file exists but its content didn't parse as JSON — a torn write (crash mid-`writeFileSync`,
|
|
1627
|
+
* pre-atomic-write code path) or on-disk tampering. Carries the file path and the original parse
|
|
1628
|
+
* error so a caller can report exactly what's broken and where, rather than "Unexpected token" with
|
|
1629
|
+
* no location. */
|
|
1630
|
+
var CorruptRecordError = class extends Error {
|
|
1631
|
+
file;
|
|
1632
|
+
constructor(file, cause) {
|
|
1633
|
+
super(`corrupt record at "${file}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
1634
|
+
this.file = file;
|
|
1635
|
+
this.name = "CorruptRecordError";
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
//#endregion
|
|
1639
|
+
//#region src/store/process-liveness.ts
|
|
1640
|
+
/**
|
|
1641
|
+
* Probe whether `pid` is alive using `process.kill(pid, 0)` — the POSIX "does this process exist"
|
|
1642
|
+
* idiom; it sends no signal, it only tests deliverability. `ESRCH` (no such process) is the only
|
|
1643
|
+
* errno that means "definitely dead". Every other errno — `EPERM` above all, common under sandboxes
|
|
1644
|
+
* and containers where a genuinely live process can be unsignalable by this one, or any process
|
|
1645
|
+
* owned by a different uid — reads as `'unknown'`, never `'dead'`: collapsing `EPERM` into "dead" is
|
|
1646
|
+
* exactly the defect this three-state return exists to make structurally impossible to reintroduce
|
|
1647
|
+
* at a new call site. Same-pid is trivially `'alive'` (a process checking its own prior identity,
|
|
1648
|
+
* e.g. after a crash-and-restart that reused nothing).
|
|
1649
|
+
*/
|
|
1650
|
+
function probeProcess(pid) {
|
|
1651
|
+
if (pid === process.pid) return "alive";
|
|
1652
|
+
try {
|
|
1653
|
+
process.kill(pid, 0);
|
|
1654
|
+
return "alive";
|
|
1655
|
+
} catch (err) {
|
|
1656
|
+
return err.code === "ESRCH" ? "dead" : "unknown";
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/store/lock.ts
|
|
1661
|
+
var LockTimeoutError = class extends Error {
|
|
1662
|
+
name;
|
|
1663
|
+
constructor(name) {
|
|
1664
|
+
super(`timed out waiting for lock "${name}"`);
|
|
1665
|
+
this.name = name;
|
|
1666
|
+
this.name = "LockTimeoutError";
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
function locksDir(root) {
|
|
1670
|
+
return join(root, "locks");
|
|
1671
|
+
}
|
|
1672
|
+
function lockDirFor(root, name) {
|
|
1673
|
+
return join(locksDir(root), `${name}.lock`);
|
|
1674
|
+
}
|
|
1675
|
+
/** Read a lock dir's recorded holder. Absent/corrupt/mid-acquire (holder.json not written yet) all
|
|
1676
|
+
* read as "unknown" — never grounds to steal. An ambiguous read must never look like a green light. */
|
|
1677
|
+
function readHolder(dir) {
|
|
1678
|
+
try {
|
|
1679
|
+
return JSON.parse(readFileSync(join(dir, "holder.json"), "utf8"));
|
|
1680
|
+
} catch {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
/** Locking's policy on `probeProcess`'s `'unknown'` state: treat it exactly like `'alive'` — i.e.
|
|
1685
|
+
* NOT `'dead'`. The one invariant that must never break here is stealing a lock a live holder still
|
|
1686
|
+
* holds, and `'unknown'` means "cannot rule out alive", so it can only ever be safe to fold it into
|
|
1687
|
+
* the "do not steal" side. This is a local policy decision, not `probeProcess`'s — a different call
|
|
1688
|
+
* site (a staleness reaper, say) folding `'unknown'` into "not provably alive" instead would be
|
|
1689
|
+
* reading the SAME three states toward the opposite default, which is exactly the bug class this
|
|
1690
|
+
* three-state type exists to force each call site to decide explicitly (see process-liveness.ts). */
|
|
1691
|
+
function heldByLiveOrUnknownPid(pid) {
|
|
1692
|
+
return probeProcess(pid) !== "dead";
|
|
1693
|
+
}
|
|
1694
|
+
function sleepSync(ms) {
|
|
1695
|
+
if (ms <= 0) return;
|
|
1696
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Attempt to reclaim an abandoned lock dir without ever discarding a LIVE one out from under its
|
|
1700
|
+
* holder — the exact defect agmsg/firstmate's lock suites exist to catch (evidence.md items 3-4).
|
|
1701
|
+
*
|
|
1702
|
+
* `mkdirSync` on the lock path itself already arbitrates ordinary contention (EEXIST = someone else
|
|
1703
|
+
* got there first); the harder race is a STEAL: two processes independently deciding the same lock
|
|
1704
|
+
* looks dead. `renameSync` gives the same exclusivity `mkdirSync` gives for a fresh path, but for an
|
|
1705
|
+
* EXISTING one — of any number of processes racing to rename the SAME source path away, exactly one
|
|
1706
|
+
* succeeds and the rest get ENOENT. So the rename-away is the sole arbiter of "who gets to attempt
|
|
1707
|
+
* the steal", not the earlier staleness read.
|
|
1708
|
+
*
|
|
1709
|
+
* That still leaves one race: the content we read as stale might not be the content we actually
|
|
1710
|
+
* capture, if the true holder released and a NEW, live holder re-acquired at this same path between
|
|
1711
|
+
* our staleness read and our rename. So after winning the rename, re-check the pid we ACTUALLY
|
|
1712
|
+
* captured (not the one from the earlier read) — if it's alive, this was a live lock we grabbed by
|
|
1713
|
+
* accident; put it back immediately (best-effort — if a third party has since re-mkdir'd the path,
|
|
1714
|
+
* the invariant we're protecting already holds, so just drop our capture) and refuse to finalize.
|
|
1715
|
+
*/
|
|
1716
|
+
function tryReclaimStale(dir) {
|
|
1717
|
+
const grave = `${dir}.stale-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1718
|
+
try {
|
|
1719
|
+
renameSync(dir, grave);
|
|
1720
|
+
} catch (err) {
|
|
1721
|
+
if (err.code === "ENOENT") return false;
|
|
1722
|
+
throw err;
|
|
1723
|
+
}
|
|
1724
|
+
const captured = readHolder(grave);
|
|
1725
|
+
if (captured && heldByLiveOrUnknownPid(captured.pid)) {
|
|
1726
|
+
try {
|
|
1727
|
+
renameSync(grave, dir);
|
|
1728
|
+
} catch {
|
|
1729
|
+
rmSync(grave, {
|
|
1730
|
+
recursive: true,
|
|
1731
|
+
force: true
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
rmSync(grave, {
|
|
1737
|
+
recursive: true,
|
|
1738
|
+
force: true
|
|
1739
|
+
});
|
|
1740
|
+
return true;
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Acquire an advisory, mkdir-based lock at `root/locks/<name>.lock`. mkdir (not O_EXCL on a file) is
|
|
1744
|
+
* the primitive here because a lock also needs to carry its holder metadata (pid + timestamp) for
|
|
1745
|
+
* staleness detection, and a directory gives that a natural home (`holder.json` inside it) without a
|
|
1746
|
+
* second file to keep in sync; `mkdirSync` on a non-existent path is exactly as atomic as an
|
|
1747
|
+
* exclusive file create on every filesystem this tool targets (POSIX; NTFS via Node's Win32 mkdir).
|
|
1748
|
+
*
|
|
1749
|
+
* Blocks (busy-retries) until acquired or `timeoutMs` elapses, reclaiming a dead holder's lock along
|
|
1750
|
+
* the way (see `tryReclaimStale`) but NEVER a live one — contention with a live holder always waits
|
|
1751
|
+
* it out or times out, never steals.
|
|
1752
|
+
*/
|
|
1753
|
+
function acquireLock(root, name, opts = {}) {
|
|
1754
|
+
const retryDelayMs = opts.retryDelayMs ?? 20;
|
|
1755
|
+
const timeoutMs = opts.timeoutMs ?? 5e3;
|
|
1756
|
+
const dir = lockDirFor(root, name);
|
|
1757
|
+
mkdirSync(locksDir(root), { recursive: true });
|
|
1758
|
+
const deadline = Date.now() + timeoutMs;
|
|
1759
|
+
for (;;) try {
|
|
1760
|
+
mkdirSync(dir);
|
|
1761
|
+
writeFileSync(join(dir, "holder.json"), JSON.stringify({
|
|
1762
|
+
pid: process.pid,
|
|
1763
|
+
acquiredAt: Date.now()
|
|
1764
|
+
}));
|
|
1765
|
+
return { release: () => rmSync(dir, {
|
|
1766
|
+
recursive: true,
|
|
1767
|
+
force: true
|
|
1768
|
+
}) };
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
if (err.code !== "EEXIST") throw err;
|
|
1771
|
+
const holder = readHolder(dir);
|
|
1772
|
+
if (holder && !heldByLiveOrUnknownPid(holder.pid) && tryReclaimStale(dir)) continue;
|
|
1773
|
+
if (Date.now() > deadline) throw new LockTimeoutError(name);
|
|
1774
|
+
sleepSync(retryDelayMs);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
/** Acquire `name`, run `fn`, and always release — the shape every genuine read-modify-write in the
|
|
1778
|
+
* store should use (`setMainPane`, `identity.ts`'s `claimPresence`/`clearPresence` today). */
|
|
1779
|
+
function withLock(root, name, fn, opts) {
|
|
1780
|
+
const handle = acquireLock(root, name, opts);
|
|
1781
|
+
try {
|
|
1782
|
+
return fn();
|
|
1783
|
+
} finally {
|
|
1784
|
+
handle.release();
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
//#endregion
|
|
1595
1788
|
//#region src/store/file-store.ts
|
|
1789
|
+
/** Parse a record file's content, wrapping a `JSON.parse` failure in a typed, file-named
|
|
1790
|
+
* `CorruptRecordError` instead of letting a bare `SyntaxError` bubble up from deep inside
|
|
1791
|
+
* `listInbox`/`listAgents`/`getAgent` with no indication of WHICH file broke. A file that doesn't
|
|
1792
|
+
* exist is a caller error — callers check existence first, so this only ever sees files known to
|
|
1793
|
+
* be present but possibly torn (a crash mid-write predating the atomic-write fix, or on-disk
|
|
1794
|
+
* tampering) — see evidence.md item 5, "no silent success on a broken store". */
|
|
1795
|
+
function readJsonRecord(file) {
|
|
1796
|
+
const raw = readFileSync(file, "utf8");
|
|
1797
|
+
try {
|
|
1798
|
+
return JSON.parse(raw);
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
throw new CorruptRecordError(file, err);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1596
1803
|
function readMessages(dir) {
|
|
1597
1804
|
if (!existsSync(dir)) return [];
|
|
1598
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) =>
|
|
1805
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f)));
|
|
1806
|
+
}
|
|
1807
|
+
/** Write `data` to `file` crash-safely: write to a sibling temp file first, then `renameSync` into
|
|
1808
|
+
* place. `rename` is atomic within a filesystem (POSIX guarantees this; Node's Win32 rename is too
|
|
1809
|
+
* for same-volume paths, which every path here is — always under the same store root), so any
|
|
1810
|
+
* reader either sees the OLD complete content or the NEW complete content, never a truncated
|
|
1811
|
+
* in-between. Before this fix, `putMessage`/`putAgent`/`putPaneIndex`/`setMainPane` called
|
|
1812
|
+
* `writeFileSync` directly on the final path — only `ackMessage`'s move (a rename of an already-
|
|
1813
|
+
* complete file) was atomic. A process crashing mid-`writeFileSync`, or a reader racing a writer on
|
|
1814
|
+
* a large record, could hand `JSON.parse` a truncated file and throw an uncaught `SyntaxError` deep
|
|
1815
|
+
* inside `listInbox`/`listAgents`/`getAgent`.
|
|
1816
|
+
*
|
|
1817
|
+
* Deliberately no `fsync` before the rename: this tool defends against a process CRASHING mid-write
|
|
1818
|
+
* (the actual, observed risk in a daemonless multi-process CLI), where the page cache alone is
|
|
1819
|
+
* sufficient, not against a power-loss/OS-crash losing unflushed pages, which cyberlegion doesn't
|
|
1820
|
+
* claim to survive today (no writer here holds data the user can't just re-send). Add fsync if that
|
|
1821
|
+
* durability bar ever changes.
|
|
1822
|
+
*
|
|
1823
|
+
* The temp name embeds pid + a counter so two writers to the SAME final path never collide on their
|
|
1824
|
+
* own temp files mid-write (each writer's temp file is unique to it). */
|
|
1825
|
+
let tmpCounter = 0;
|
|
1826
|
+
function writeFileAtomic(file, data) {
|
|
1827
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1828
|
+
const tmp = `${file}.${process.pid}.${tmpCounter++}.tmp`;
|
|
1829
|
+
writeFileSync(tmp, data);
|
|
1830
|
+
renameSync(tmp, file);
|
|
1599
1831
|
}
|
|
1600
1832
|
function writeJson(file, data) {
|
|
1601
|
-
|
|
1602
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1833
|
+
writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
1603
1834
|
}
|
|
1604
1835
|
function writeText(file, text) {
|
|
1605
|
-
|
|
1606
|
-
|
|
1836
|
+
writeFileAtomic(file, text);
|
|
1837
|
+
}
|
|
1838
|
+
/** Build a path via `build()` for a READ-only lookup, treating a syntactically-invalid id
|
|
1839
|
+
* (`InvalidIdError`) the same as "not found" rather than throwing. Reads are the wrong place to
|
|
1840
|
+
* enforce id-shape: `resolveAgent`/`resolveRecipient` (identity.ts) speculatively probe an arbitrary
|
|
1841
|
+
* ref — which may legitimately be a worktree BRANCH NAME containing `/` — as a candidate id before
|
|
1842
|
+
* falling back to handle/branch lookup, so `getAgent('cyberlegion/unit-abc')` must fail soft, not
|
|
1843
|
+
* throw, or that fallback chain breaks. The traversal risk this guards against (an id escaping the
|
|
1844
|
+
* store root) only bites on a WRITE — nothing is ever created or overwritten by a probe that finds
|
|
1845
|
+
* nothing — so a hard reject stays reserved for the write paths below (`putAgent`, `putMessage`,
|
|
1846
|
+
* `putPaneIndex`, `writeBrief`), where a malformed id would otherwise put a stray file wherever it
|
|
1847
|
+
* pointed. */
|
|
1848
|
+
function readPathOrUndefined(build) {
|
|
1849
|
+
try {
|
|
1850
|
+
return build();
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
if (err instanceof InvalidIdError) return void 0;
|
|
1853
|
+
throw err;
|
|
1854
|
+
}
|
|
1607
1855
|
}
|
|
1608
1856
|
/** The on-disk `Store` implementation — current per-writer sharded `.json` layout (ADR-0020):
|
|
1609
1857
|
* one file per message/agent, collision-free filenames, ack = atomic rename into `read/`. */
|
|
@@ -1616,33 +1864,33 @@ var FileStore = class {
|
|
|
1616
1864
|
ensureMarker(this.root);
|
|
1617
1865
|
}
|
|
1618
1866
|
putMessage(toId, msg) {
|
|
1619
|
-
|
|
1620
|
-
mkdirSync(dir, { recursive: true });
|
|
1621
|
-
writeFileSync(join(dir, `${msg.id}.json`), `${JSON.stringify(msg, null, 2)}\n`);
|
|
1867
|
+
writeFileAtomic(paths.messageFile(this.root, toId, msg.id), `${JSON.stringify(msg, null, 2)}\n`);
|
|
1622
1868
|
}
|
|
1623
1869
|
listInbox(id) {
|
|
1870
|
+
const inbox = readPathOrUndefined(() => paths.inboxDir(this.root, id));
|
|
1871
|
+
const read = readPathOrUndefined(() => paths.inboxReadDir(this.root, id));
|
|
1624
1872
|
return {
|
|
1625
|
-
unread: readMessages(
|
|
1626
|
-
read: readMessages(
|
|
1873
|
+
unread: inbox ? readMessages(inbox) : [],
|
|
1874
|
+
read: read ? readMessages(read) : []
|
|
1627
1875
|
};
|
|
1628
1876
|
}
|
|
1629
1877
|
ackMessage(id, msgId) {
|
|
1630
|
-
const src =
|
|
1631
|
-
if (!existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
|
1632
|
-
const msg =
|
|
1633
|
-
const
|
|
1634
|
-
mkdirSync(
|
|
1635
|
-
renameSync(src,
|
|
1878
|
+
const src = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
1879
|
+
if (!src || !existsSync(src)) throw new Error(`"${msgId}" is not an unread message in this inbox`);
|
|
1880
|
+
const msg = readJsonRecord(src);
|
|
1881
|
+
const dest = paths.messageReadFile(this.root, id, msgId);
|
|
1882
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1883
|
+
renameSync(src, dest);
|
|
1636
1884
|
return msg;
|
|
1637
1885
|
}
|
|
1638
1886
|
removeMessage(id, msgId) {
|
|
1639
|
-
const unreadFile =
|
|
1640
|
-
if (existsSync(unreadFile)) {
|
|
1887
|
+
const unreadFile = readPathOrUndefined(() => paths.messageFile(this.root, id, msgId));
|
|
1888
|
+
if (unreadFile && existsSync(unreadFile)) {
|
|
1641
1889
|
rmSync(unreadFile);
|
|
1642
1890
|
return;
|
|
1643
1891
|
}
|
|
1644
|
-
const readFile =
|
|
1645
|
-
if (existsSync(readFile)) {
|
|
1892
|
+
const readFile = readPathOrUndefined(() => paths.messageReadFile(this.root, id, msgId));
|
|
1893
|
+
if (readFile && existsSync(readFile)) {
|
|
1646
1894
|
rmSync(readFile);
|
|
1647
1895
|
return;
|
|
1648
1896
|
}
|
|
@@ -1652,14 +1900,14 @@ var FileStore = class {
|
|
|
1652
1900
|
writeJson(paths.agentFile(this.root, rec.id), rec);
|
|
1653
1901
|
}
|
|
1654
1902
|
getAgent(id) {
|
|
1655
|
-
const file = paths.agentFile(this.root, id);
|
|
1656
|
-
if (!existsSync(file)) return void 0;
|
|
1657
|
-
return
|
|
1903
|
+
const file = readPathOrUndefined(() => paths.agentFile(this.root, id));
|
|
1904
|
+
if (!file || !existsSync(file)) return void 0;
|
|
1905
|
+
return readJsonRecord(file);
|
|
1658
1906
|
}
|
|
1659
1907
|
listAgents() {
|
|
1660
1908
|
const dir = paths.agentsDir(this.root);
|
|
1661
1909
|
if (!existsSync(dir)) return [];
|
|
1662
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) =>
|
|
1910
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => readJsonRecord(join(dir, f))).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1663
1911
|
}
|
|
1664
1912
|
removeAgent(id) {
|
|
1665
1913
|
rmSync(paths.agentFile(this.root, id), { force: true });
|
|
@@ -1692,21 +1940,26 @@ var FileStore = class {
|
|
|
1692
1940
|
writeText(paths.briefFile(this.root, agentId), text);
|
|
1693
1941
|
}
|
|
1694
1942
|
readBrief(agentId) {
|
|
1695
|
-
const file = paths.briefFile(this.root, agentId);
|
|
1696
|
-
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1943
|
+
const file = readPathOrUndefined(() => paths.briefFile(this.root, agentId));
|
|
1944
|
+
return file && existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1697
1945
|
}
|
|
1698
1946
|
setMainPane(pane) {
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1947
|
+
withLock(this.root, "main-pane", () => {
|
|
1948
|
+
const file = paths.mainPaneFile(this.root);
|
|
1949
|
+
if (pane) {
|
|
1950
|
+
writeText(file, pane);
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
rmSync(file, { force: true });
|
|
1954
|
+
});
|
|
1705
1955
|
}
|
|
1706
1956
|
getMainPane() {
|
|
1707
1957
|
const file = paths.mainPaneFile(this.root);
|
|
1708
1958
|
return existsSync(file) ? readFileSync(file, "utf8").trim() : void 0;
|
|
1709
1959
|
}
|
|
1960
|
+
withLock(name, fn) {
|
|
1961
|
+
return withLock(this.root, name, fn);
|
|
1962
|
+
}
|
|
1710
1963
|
};
|
|
1711
1964
|
//#endregion
|
|
1712
1965
|
//#region src/wake/await.ts
|
package/{LICENSE → license}
RENAMED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyberlegion",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Harness-agnostic agent session spawning and messaging over the filesystem",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/cyberuni/
|
|
15
|
+
"url": "git+https://github.com/cyberuni/cyberlegion.git",
|
|
16
16
|
"directory": "packages/cyberlegion"
|
|
17
17
|
},
|
|
18
18
|
"license": "MIT",
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"commander": "^14.0.3",
|
|
37
|
-
"cyber-mux": "0.
|
|
37
|
+
"cyber-mux": "0.5.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "^24.10.1",
|
|
41
41
|
"tsdown": "^0.22.0",
|
|
42
42
|
"tsx": "^4.22.3",
|
|
43
|
-
"typescript": "^
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
44
|
"vitest": "^4.1.7"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsdown",
|
|
55
55
|
"check:metaphor-free": "tsx src/check-metaphor-free.ts",
|
|
56
|
-
"check:spec": "sdd-check-specs",
|
|
57
56
|
"dev": "tsx src/cli.ts",
|
|
58
57
|
"test": "vitest run src",
|
|
59
58
|
"test:watch": "vitest",
|