@voybio/ace-swarm 2.4.4 → 2.4.6

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +72 -532
  3. package/assets/eval-battery/battery.json +85 -0
  4. package/assets/eval-battery/fixtures/edit-target.js +3 -0
  5. package/assets/scripts/render-mcp-configs.sh +12 -0
  6. package/dist/ace-context.d.ts +18 -0
  7. package/dist/ace-context.js +161 -16
  8. package/dist/ace-server-instructions.js +36 -9
  9. package/dist/bridge-trace.d.ts +17 -0
  10. package/dist/bridge-trace.js +23 -0
  11. package/dist/cli.d.ts +9 -1
  12. package/dist/cli.js +619 -10
  13. package/dist/discovery-runtime-wrappers.d.ts +16 -1
  14. package/dist/discovery-runtime-wrappers.js +65 -9
  15. package/dist/eval-harness.d.ts +110 -0
  16. package/dist/eval-harness.js +254 -0
  17. package/dist/experience-reflector.d.ts +13 -0
  18. package/dist/experience-reflector.js +62 -0
  19. package/dist/goal-engine.d.ts +155 -0
  20. package/dist/goal-engine.js +795 -0
  21. package/dist/helpers/store-resolution.js +40 -39
  22. package/dist/hermes/session-manager.d.ts +10 -0
  23. package/dist/hermes/session-manager.js +130 -27
  24. package/dist/job-scheduler.d.ts +58 -0
  25. package/dist/job-scheduler.js +202 -2
  26. package/dist/job-worker.d.ts +87 -0
  27. package/dist/job-worker.js +182 -0
  28. package/dist/local-model-policy.d.ts +8 -2
  29. package/dist/local-model-policy.js +31 -17
  30. package/dist/local-model-runtime.d.ts +6 -0
  31. package/dist/local-model-runtime.js +130 -2
  32. package/dist/measured-classing.d.ts +66 -0
  33. package/dist/measured-classing.js +165 -0
  34. package/dist/model-bridge.d.ts +45 -0
  35. package/dist/model-bridge.js +502 -44
  36. package/dist/orchestrator-supervisor.js +1 -1
  37. package/dist/plan-proposal.d.ts +7 -0
  38. package/dist/plan-proposal.js +74 -4
  39. package/dist/playbook-adaptation.d.ts +37 -0
  40. package/dist/playbook-adaptation.js +159 -0
  41. package/dist/playbook-curator.d.ts +16 -0
  42. package/dist/playbook-curator.js +106 -0
  43. package/dist/playbook-llm-reflector.d.ts +26 -0
  44. package/dist/playbook-llm-reflector.js +157 -0
  45. package/dist/public-surface.d.ts +2 -0
  46. package/dist/public-surface.js +26 -14
  47. package/dist/safe-edit.d.ts +20 -0
  48. package/dist/safe-edit.js +355 -78
  49. package/dist/server.d.ts +11 -2
  50. package/dist/server.js +55 -5
  51. package/dist/shared.d.ts +12 -0
  52. package/dist/shared.js +13 -1
  53. package/dist/source-mutation-coordinator.d.ts +54 -0
  54. package/dist/source-mutation-coordinator.js +259 -0
  55. package/dist/status-events.d.ts +4 -1
  56. package/dist/status-events.js +41 -6
  57. package/dist/store/ace-packed-store.d.ts +28 -9
  58. package/dist/store/ace-packed-store.js +324 -57
  59. package/dist/store/bgzf.d.ts +4 -0
  60. package/dist/store/bgzf.js +94 -0
  61. package/dist/store/bootstrap-store.d.ts +4 -1
  62. package/dist/store/bootstrap-store.js +57 -3
  63. package/dist/store/cache-workspace.d.ts +8 -0
  64. package/dist/store/cache-workspace.js +67 -1
  65. package/dist/store/crc32.d.ts +2 -0
  66. package/dist/store/crc32.js +16 -0
  67. package/dist/store/fsck.d.ts +16 -0
  68. package/dist/store/fsck.js +240 -0
  69. package/dist/store/materializers/context-snapshot-materializer.d.ts +1 -0
  70. package/dist/store/materializers/context-snapshot-materializer.js +4 -1
  71. package/dist/store/materializers/projection-manager.d.ts +7 -0
  72. package/dist/store/materializers/projection-manager.js +37 -0
  73. package/dist/store/materializers/scheduler-projection-materializer.js +10 -3
  74. package/dist/store/repositories/goal-repository.d.ts +112 -0
  75. package/dist/store/repositories/goal-repository.js +157 -0
  76. package/dist/store/repositories/playbook-repository.d.ts +107 -0
  77. package/dist/store/repositories/playbook-repository.js +314 -0
  78. package/dist/store/store-artifacts.js +44 -2
  79. package/dist/store/store-authority-audit.js +1 -0
  80. package/dist/store/store-cli.d.ts +30 -0
  81. package/dist/store/store-cli.js +242 -0
  82. package/dist/store/store-snapshot.d.ts +12 -0
  83. package/dist/store/store-snapshot.js +74 -11
  84. package/dist/store/types.d.ts +1 -0
  85. package/dist/store/write-coordinator.d.ts +7 -0
  86. package/dist/store/write-coordinator.js +11 -0
  87. package/dist/todo-state.d.ts +21 -0
  88. package/dist/todo-state.js +150 -53
  89. package/dist/tools-agent.js +86 -22
  90. package/dist/tools-discovery.js +5 -3
  91. package/dist/tools-files.js +39 -18
  92. package/dist/tools-framework.js +31 -3
  93. package/dist/tools-goal.d.ts +11 -0
  94. package/dist/tools-goal.js +108 -0
  95. package/dist/tools-handoff.js +7 -11
  96. package/dist/tools-lifecycle.js +2 -0
  97. package/dist/tools-scheduler.js +44 -1
  98. package/dist/tools-skills.js +3 -2
  99. package/dist/tools-todo.js +64 -3
  100. package/dist/tools.js +2 -0
  101. package/dist/tui/chat.js +4 -0
  102. package/dist/tui/index.js +4 -1
  103. package/dist/tui/ollama.d.ts +2 -0
  104. package/dist/tui/openai-compatible.d.ts +14 -0
  105. package/dist/tui/openai-compatible.js +15 -2
  106. package/dist/tui/provider-discovery.d.ts +1 -0
  107. package/dist/tui/provider-discovery.js +2 -2
  108. package/docs/cli-reference.md +125 -0
  109. package/docs/development.md +46 -0
  110. package/docs/evaluation-safety.md +27 -0
  111. package/docs/experience-playbook.md +35 -0
  112. package/docs/hermes.md +129 -0
  113. package/docs/library-api.md +31 -0
  114. package/docs/mcp-tool-reference.md +290 -0
  115. package/docs/provider-security.md +85 -0
  116. package/docs/state-model.md +113 -0
  117. package/docs/troubleshooting.md +95 -0
  118. package/docs/tui-operator-guide.md +75 -0
  119. package/package.json +3 -1
  120. package/scripts/dev/run_hermes_crispr_live.mjs +212 -0
@@ -1,9 +1,10 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { openStore } from "./ace-packed-store.js";
4
- import { getWorkspaceStorePath, invalidateStoreSnapshotCache, readStoreBlobSync, toVirtualStorePath, } from "./store-snapshot.js";
4
+ import { getWorkspaceStorePath, invalidateStoreSnapshotCache, readStoreBlobSync, toVirtualStorePath, validateChunkBounds, } from "./store-snapshot.js";
5
5
  import { withStoreWriteCoordinator } from "./write-coordinator.js";
6
6
  import { HEADER_SIZE, HEADER_SIZE_V1, STORE_VERSION, MAGIC } from "./types.js";
7
+ import { crc32 } from "./crc32.js";
7
8
  // ── v2 header constants (mirrors ace-packed-store.ts) ─────────────────────────
8
9
  const H_KV_IDX_OFF = 16;
9
10
  const H_KV_IDX_LEN = 24;
@@ -125,6 +126,29 @@ function defaultStoreBuffer() {
125
126
  buf.set(indexBytes, kvIdxOff);
126
127
  return buf;
127
128
  }
129
+ /**
130
+ * Validates the structural invariants of a parsed v2 header before any code
131
+ * splices bytes out of the file using it. A corrupt header (e.g. kvChunkEnd
132
+ * pointing past EOF) must never be trusted to build a new file — that would
133
+ * bake a zero-filled/garbage region into the *new* store and spread
134
+ * corruption to every key, not just the bad one.
135
+ */
136
+ function validateStoreHeaderInvariants(hdr, index, fileLength) {
137
+ if (!(HEADER_SIZE <= hdr.kvChunkEnd && hdr.kvChunkEnd <= hdr.evtOffset && hdr.evtOffset <= hdr.kvIndexOffset && hdr.kvIndexOffset <= fileLength)) {
138
+ return `header region ordering invalid (kvChunkEnd=${hdr.kvChunkEnd}, evtOffset=${hdr.evtOffset}, kvIndexOffset=${hdr.kvIndexOffset}, fileLength=${fileLength})`;
139
+ }
140
+ for (const [key, entry] of Object.entries(index)) {
141
+ if (!validateChunkBounds(entry, hdr.kvChunkEnd, HEADER_SIZE)) {
142
+ return `index entry '${key}' out of KV region bounds (offset=${entry.offset}, length=${entry.length}, kvChunkEnd=${hdr.kvChunkEnd})`;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+ function quarantineCorruptStore(storePath, rawFile) {
148
+ const quarantinePath = `${storePath}.corrupt-${Date.now()}`;
149
+ writeFileSync(quarantinePath, rawFile);
150
+ return quarantinePath;
151
+ }
128
152
  function readStoreBuffer(storePath) {
129
153
  const rawFile = existsSync(storePath) ? readFileSync(storePath) : defaultStoreBuffer();
130
154
  // Buffer (from readFileSync) is a Uint8Array subclass — wrap to get a plain Uint8Array view
@@ -135,11 +159,25 @@ function readStoreBuffer(storePath) {
135
159
  return { file: def, kvChunkEnd: HEADER_SIZE, evtBytes: new Uint8Array(4), evtCount: 0, evtBaseId: 0, index: {} };
136
160
  }
137
161
  const hdr = readStoreHeader(file.subarray(0, Math.max(HEADER_SIZE, hdrSize)));
162
+ if (hdr.kvIndexOffset < 0 ||
163
+ hdr.kvIndexLength < 0 ||
164
+ hdr.kvIndexOffset + hdr.kvIndexLength > file.length ||
165
+ hdr.evtOffset < 0 ||
166
+ hdr.evtLength < 0 ||
167
+ hdr.evtOffset + hdr.evtLength > file.length) {
168
+ const quarantinePath = quarantineCorruptStore(storePath, rawFile);
169
+ throw new Error(`AcePackedStore artifact write refused: header region exceeds file bounds — quarantined corrupt store at ${quarantinePath}, original left in place. Run 'ace repair' or 'ace fsck --repair'.`);
170
+ }
138
171
  const evtBytes = hdr.evtLength > 0
139
172
  ? file.slice(hdr.evtOffset, hdr.evtOffset + hdr.evtLength)
140
173
  : new Uint8Array(4); // empty event section
141
174
  const indexRaw = file.subarray(hdr.kvIndexOffset, hdr.kvIndexOffset + hdr.kvIndexLength);
142
175
  const index = JSON.parse(DECODE.decode(indexRaw));
176
+ const invariantError = validateStoreHeaderInvariants(hdr, index, file.length);
177
+ if (invariantError) {
178
+ const quarantinePath = quarantineCorruptStore(storePath, rawFile);
179
+ throw new Error(`AcePackedStore artifact write refused: ${invariantError} — quarantined corrupt store at ${quarantinePath}, original left in place. Run 'ace repair' or 'ace fsck --repair'.`);
180
+ }
143
181
  return { file, kvChunkEnd: hdr.kvChunkEnd, evtBytes, evtCount: hdr.evtCount, evtBaseId: hdr.evtBaseId, index };
144
182
  }
145
183
  export function operationalArtifactKey(relPath) {
@@ -169,7 +207,11 @@ export function writeStoreBlobsSync(workspaceRoot, blobs) {
169
207
  const nextIndex = { ...index };
170
208
  let cursor = kvChunkEnd;
171
209
  for (const entry of encoded) {
172
- nextIndex[entry.key] = { offset: cursor, length: entry.contentBytes.length };
210
+ nextIndex[entry.key] = {
211
+ offset: cursor,
212
+ length: entry.contentBytes.length,
213
+ crc: crc32(entry.contentBytes),
214
+ };
173
215
  cursor += 4 + entry.contentBytes.length;
174
216
  }
175
217
  const indexBytes = TEXT.encode(JSON.stringify(nextIndex));
@@ -31,6 +31,7 @@ const OFFLINE_SYNC_HELPER_SOURCES = new Set([
31
31
  "src/astgrep-index.ts",
32
32
  "src/index-store.ts",
33
33
  "src/tools-files.ts",
34
+ "src/source-mutation-coordinator.ts",
34
35
  ]);
35
36
  const TOOL_RUNTIME_SOURCE_HINT = /(?:^|\/)(?:tools-[^/]+|tools-framework|runtime-[^/]+|tracker-[^/]+|workspace-manager|status-events|job-scheduler|run-ledger|runtime-profile|handoff-registry|astgrep-index)\.ts$/;
36
37
  const TASK_KEY_TO_FILE = {
@@ -0,0 +1,30 @@
1
+ export interface StoreLsEntry {
2
+ /** Logical path as a user would recognize it (e.g. "agent-state/kanban.json"), or the raw key with --keys. */
3
+ path: string;
4
+ key: string;
5
+ bytes: number;
6
+ }
7
+ export declare function listStoreEntries(workspaceRoot: string, prefix?: string, useRawKeys?: boolean): StoreLsEntry[];
8
+ export declare function runLsCommand(workspaceRoot: string, args: string[]): Promise<void>;
9
+ export interface ReadTarget {
10
+ content: string;
11
+ source: "virtual" | "store" | "disk";
12
+ key?: string;
13
+ divergent: boolean;
14
+ diskContent?: string;
15
+ storeContent?: string;
16
+ storeKey?: string;
17
+ }
18
+ /**
19
+ * Resolves a path to its content using the documented precedence:
20
+ * 1. `.ace#`-style virtual path -> direct store key read.
21
+ * 2. Logical path -> resolveStoreFallbackKeysForPath, first hit wins.
22
+ * 3. Raw store key (namespace-prefixed) -> direct store key read.
23
+ * 4. Workspace disk file.
24
+ * When both a disk copy and a store copy exist and differ, the result is
25
+ * flagged `divergent` with both variants attached so the caller can warn
26
+ * and honor an explicit `preferSource` override.
27
+ */
28
+ export declare function resolveReadTarget(workspaceRoot: string, inputPath: string, preferSource?: "disk" | "store"): ReadTarget | undefined;
29
+ export declare function runReadCommand(workspaceRoot: string, args: string[]): Promise<void>;
30
+ //# sourceMappingURL=store-cli.d.ts.map
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `ace ls` / `ace read` (aliases: `ace get`, `ace cat`) — the human read
3
+ * surface for the ACEPACK store. Precondition for hiding generated
4
+ * artifacts by default (Phase 2): once content only lives in the store,
5
+ * these are the only way a human sees it without opening the binary file.
6
+ *
7
+ * Read contract: content is either the complete decoded block whose byte
8
+ * length equals the index entry's length (see `validateChunkBounds` /
9
+ * `decodeChunkSync` in store-snapshot.ts), or resolution fails loudly.
10
+ * Reads are length-driven, never delimiter-driven — a block containing the
11
+ * literal text "EOF" mid-content is read in full, not truncated at it.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { resolveStoreFallbackKeysForPath, resolveWorkspaceReadPath } from "../helpers.js";
16
+ import { getWorkspaceStorePath, listStoreEntrySizesSync, listStoreKeysSync, readStoreBlobSync } from "./store-snapshot.js";
17
+ function logicalPathForKey(key) {
18
+ const artifactPrefix = "state/artifacts/";
19
+ if (key.startsWith(artifactPrefix))
20
+ return key.slice(artifactPrefix.length);
21
+ const snapshotPrefix = "state/memory/context_snapshots/";
22
+ if (key.startsWith(snapshotPrefix))
23
+ return `agent-state/context-snapshots/${key.slice(snapshotPrefix.length)}`;
24
+ if (key === "state/source-edit-events.ndjson")
25
+ return "agent-state/source-edit-events.ndjson";
26
+ if (key === "state/source-edit-events-archive.ndjson")
27
+ return "agent-state/source-edit-events-archive.ndjson";
28
+ return key;
29
+ }
30
+ function logicalPrefixToKeyPrefix(prefix) {
31
+ if (!prefix)
32
+ return "";
33
+ const snapshotPrefix = "agent-state/context-snapshots";
34
+ if (prefix.startsWith(snapshotPrefix)) {
35
+ return `state/memory/context_snapshots/${prefix.slice(snapshotPrefix.length).replace(/^\//, "")}`;
36
+ }
37
+ if (prefix.startsWith("agent-state")) {
38
+ return `state/artifacts/${prefix}`;
39
+ }
40
+ return prefix;
41
+ }
42
+ function sectionForKey(key) {
43
+ if (key.startsWith("state/artifacts/") || key.startsWith("state/memory/context_snapshots/"))
44
+ return "operational";
45
+ if (key.startsWith("knowledge/"))
46
+ return "knowledge";
47
+ return "state";
48
+ }
49
+ export function listStoreEntries(workspaceRoot, prefix = "", useRawKeys = false) {
50
+ const rawPrefix = useRawKeys ? prefix : logicalPrefixToKeyPrefix(prefix);
51
+ const keys = new Set(listStoreKeysSync(workspaceRoot, rawPrefix));
52
+ if (!useRawKeys) {
53
+ const sourceEditLogicalPath = "agent-state/source-edit-events.ndjson";
54
+ if (sourceEditLogicalPath.startsWith(prefix || "agent-state") ||
55
+ (prefix || "agent-state").startsWith(sourceEditLogicalPath)) {
56
+ for (const key of listStoreKeysSync(workspaceRoot, "state/source-edit-events.ndjson")) {
57
+ keys.add(key);
58
+ }
59
+ }
60
+ const sourceEditArchiveLogicalPath = "agent-state/source-edit-events-archive.ndjson";
61
+ if (sourceEditArchiveLogicalPath.startsWith(prefix || "agent-state") ||
62
+ (prefix || "agent-state").startsWith(sourceEditArchiveLogicalPath)) {
63
+ for (const key of listStoreKeysSync(workspaceRoot, "state/source-edit-events-archive.ndjson")) {
64
+ keys.add(key);
65
+ }
66
+ }
67
+ }
68
+ const sizeByKey = new Map(listStoreEntrySizesSync(workspaceRoot, "").map((entry) => [entry.key, entry.length]));
69
+ return [...keys].map((key) => ({
70
+ path: useRawKeys ? key : logicalPathForKey(key),
71
+ key,
72
+ bytes: sizeByKey.get(key) ?? 0,
73
+ }));
74
+ }
75
+ export async function runLsCommand(workspaceRoot, args) {
76
+ const positional = args.filter((a) => !a.startsWith("--"));
77
+ const prefixArg = positional[0] ?? "";
78
+ const useRawKeys = args.includes("--keys");
79
+ const asJson = args.includes("--json");
80
+ const storePath = getWorkspaceStorePath(workspaceRoot);
81
+ if (!existsSync(storePath)) {
82
+ console.error(`No store found at ${storePath}. Run 'ace init' first.`);
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ const entries = listStoreEntries(workspaceRoot, prefixArg, useRawKeys);
87
+ if (asJson) {
88
+ console.log(JSON.stringify(entries, null, 2));
89
+ return;
90
+ }
91
+ if (entries.length === 0) {
92
+ console.log(prefixArg ? `No store entries under '${prefixArg}'.` : "Store is empty.");
93
+ return;
94
+ }
95
+ const sections = { operational: [], knowledge: [], state: [] };
96
+ for (const entry of entries)
97
+ sections[sectionForKey(entry.key)].push(entry);
98
+ const labels = {
99
+ operational: "Operational artifacts",
100
+ knowledge: "Knowledge",
101
+ state: "State",
102
+ };
103
+ for (const section of ["operational", "knowledge", "state"]) {
104
+ const list = sections[section];
105
+ if (list.length === 0)
106
+ continue;
107
+ console.log(`\n${labels[section]} (${list.length}):`);
108
+ for (const entry of [...list].sort((a, b) => a.path.localeCompare(b.path))) {
109
+ console.log(` ${entry.path} (${entry.bytes}B)`);
110
+ }
111
+ }
112
+ }
113
+ function tryDiskRead(workspaceRoot, inputPath) {
114
+ try {
115
+ const abs = resolveWorkspaceReadPath(inputPath);
116
+ if (existsSync(abs) && statSync(abs).isFile()) {
117
+ return readFileSync(abs, "utf-8");
118
+ }
119
+ }
120
+ catch {
121
+ // not a resolvable disk path — not a divergence candidate
122
+ }
123
+ return undefined;
124
+ }
125
+ /**
126
+ * Resolves a path to its content using the documented precedence:
127
+ * 1. `.ace#`-style virtual path -> direct store key read.
128
+ * 2. Logical path -> resolveStoreFallbackKeysForPath, first hit wins.
129
+ * 3. Raw store key (namespace-prefixed) -> direct store key read.
130
+ * 4. Workspace disk file.
131
+ * When both a disk copy and a store copy exist and differ, the result is
132
+ * flagged `divergent` with both variants attached so the caller can warn
133
+ * and honor an explicit `preferSource` override.
134
+ */
135
+ export function resolveReadTarget(workspaceRoot, inputPath, preferSource) {
136
+ if (inputPath.includes(".ace#")) {
137
+ const marker = ".ace#";
138
+ const idx = inputPath.indexOf(marker);
139
+ const key = inputPath.slice(idx + marker.length);
140
+ const content = readStoreBlobSync(workspaceRoot, key);
141
+ if (typeof content !== "string")
142
+ return undefined;
143
+ return { content, source: "virtual", key, divergent: false };
144
+ }
145
+ const fallbackKeys = resolveStoreFallbackKeysForPath(inputPath);
146
+ for (const key of fallbackKeys) {
147
+ const storeContent = readStoreBlobSync(workspaceRoot, key);
148
+ if (typeof storeContent !== "string")
149
+ continue;
150
+ const diskContent = tryDiskRead(workspaceRoot, inputPath);
151
+ const divergent = typeof diskContent === "string" && diskContent !== storeContent;
152
+ const useDisk = divergent && preferSource === "disk";
153
+ return {
154
+ content: useDisk ? diskContent : storeContent,
155
+ source: useDisk ? "disk" : "store",
156
+ key,
157
+ storeKey: key,
158
+ divergent,
159
+ diskContent,
160
+ storeContent,
161
+ };
162
+ }
163
+ if (/^(state|knowledge|topology|meta)\//.test(inputPath)) {
164
+ const content = readStoreBlobSync(workspaceRoot, inputPath);
165
+ if (typeof content === "string") {
166
+ return { content, source: "store", key: inputPath, storeKey: inputPath, divergent: false };
167
+ }
168
+ }
169
+ const diskOnly = tryDiskRead(workspaceRoot, inputPath);
170
+ if (typeof diskOnly === "string") {
171
+ return { content: diskOnly, source: "disk", divergent: false };
172
+ }
173
+ return undefined;
174
+ }
175
+ const VALUE_FLAGS = new Set(["--out", "--prefer"]);
176
+ function parsePositionalArgs(args) {
177
+ const positional = [];
178
+ for (let i = 0; i < args.length; i++) {
179
+ const arg = args[i];
180
+ if (arg.startsWith("--")) {
181
+ if (VALUE_FLAGS.has(arg))
182
+ i++; // skip the flag's value
183
+ continue;
184
+ }
185
+ positional.push(arg);
186
+ }
187
+ return positional;
188
+ }
189
+ export async function runReadCommand(workspaceRoot, args) {
190
+ const positional = parsePositionalArgs(args);
191
+ const inputPath = positional[0];
192
+ if (!inputPath) {
193
+ console.error("Usage: ace read <path> [--out <file>] [--source] [--prefer disk|store] [--json]");
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ const outIdx = args.indexOf("--out");
198
+ const outFile = outIdx >= 0 ? args[outIdx + 1] : undefined;
199
+ const preferIdx = args.indexOf("--prefer");
200
+ const preferRaw = preferIdx >= 0 ? args[preferIdx + 1] : undefined;
201
+ if (preferIdx >= 0 && preferRaw !== "disk" && preferRaw !== "store") {
202
+ console.error(`ace read: --prefer expects disk|store, got: ${preferRaw ?? "(missing)"}`);
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ const preferSource = preferRaw === "disk" || preferRaw === "store" ? preferRaw : undefined;
207
+ const showSource = args.includes("--source");
208
+ const asJson = args.includes("--json");
209
+ const result = resolveReadTarget(workspaceRoot, inputPath, preferSource);
210
+ if (!result) {
211
+ console.error(`ace read: not found: ${inputPath}`);
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ if (result.divergent) {
216
+ console.error(`⚠ store/disk copies differ for '${inputPath}' (use --prefer disk|store) — showing ${result.source}`);
217
+ }
218
+ if (asJson) {
219
+ console.log(JSON.stringify({
220
+ path: inputPath,
221
+ source: result.source,
222
+ key: result.key,
223
+ divergent: result.divergent,
224
+ content: result.content,
225
+ }, null, 2));
226
+ return;
227
+ }
228
+ if (outFile) {
229
+ const absOut = resolve(outFile);
230
+ mkdirSync(dirname(absOut), { recursive: true });
231
+ writeFileSync(absOut, result.content, "utf-8");
232
+ console.log(`Wrote ${Buffer.byteLength(result.content, "utf-8")} bytes to ${absOut}`);
233
+ return;
234
+ }
235
+ if (showSource) {
236
+ console.error(`# source=${result.source}${result.key ? ` key=${result.key}` : ""}`);
237
+ }
238
+ process.stdout.write(result.content);
239
+ if (!result.content.endsWith("\n"))
240
+ process.stdout.write("\n");
241
+ }
242
+ //# sourceMappingURL=store-cli.js.map
@@ -1,3 +1,11 @@
1
+ import { type ChunkIndexEntry } from "./types.js";
2
+ /**
3
+ * Bounds-checks an index entry against the file it was read from. Every
4
+ * index-driven read must pass through this before slicing — JS `slice`
5
+ * silently clamps at EOF instead of erroring, which previously let a
6
+ * corrupt/short index produce truncated content that looked plausible.
7
+ */
8
+ export declare function validateChunkBounds(entry: ChunkIndexEntry, fileLength: number, minOffset?: number): boolean;
1
9
  export declare function invalidateStoreSnapshotCache(storePath?: string): void;
2
10
  export declare function getCanonicalWorkspaceStorePath(workspaceRoot: string): string;
3
11
  export declare function getLegacyWorkspaceStorePath(workspaceRoot: string): string;
@@ -9,6 +17,10 @@ export declare function readStoreJsonSync<T>(workspaceRoot: string, key: string)
9
17
  export declare function readStoreJsonByPathSync<T>(storePath: string, key: string): T | undefined;
10
18
  export declare function listStoreKeysSync(workspaceRoot: string, prefix?: string): string[];
11
19
  export declare function listStoreKeysByPathSync(storePath: string, prefix?: string): string[];
20
+ export declare function listStoreEntrySizesSync(workspaceRoot: string, prefix?: string): Array<{
21
+ key: string;
22
+ length: number;
23
+ }>;
12
24
  export declare function getStoreStatSync(storePath: string): {
13
25
  mtimeMs: number;
14
26
  size: number;
@@ -1,7 +1,56 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { HEADER_SIZE, MAGIC } from "./types.js";
3
+ import { HEADER_SIZE, HEADER_SIZE_V1, MAGIC } from "./types.js";
4
+ import { crc32 } from "./crc32.js";
4
5
  const DECODE = new TextDecoder();
6
+ /**
7
+ * Bounds-checks an index entry against the file it was read from. Every
8
+ * index-driven read must pass through this before slicing — JS `slice`
9
+ * silently clamps at EOF instead of erroring, which previously let a
10
+ * corrupt/short index produce truncated content that looked plausible.
11
+ */
12
+ export function validateChunkBounds(entry, fileLength, minOffset = HEADER_SIZE_V1) {
13
+ if (!Number.isFinite(entry.offset) || !Number.isFinite(entry.length))
14
+ return false;
15
+ if (entry.offset < minOffset || entry.length < 0)
16
+ return false;
17
+ return entry.offset + 4 + entry.length <= fileLength;
18
+ }
19
+ function warnStoreReadOutOfBounds(key, entry, fileLength, reason) {
20
+ console.error(JSON.stringify({
21
+ event: "store_read_out_of_bounds",
22
+ key,
23
+ offset: entry.offset,
24
+ length: entry.length,
25
+ fileSize: fileLength,
26
+ reason,
27
+ }));
28
+ }
29
+ /**
30
+ * Decodes a chunk only if its bounds are valid AND the length prefix stored
31
+ * at `entry.offset` agrees with the index's `entry.length`. Any mismatch
32
+ * (index corruption, short read of a partially-visible file) returns
33
+ * `undefined` instead of silently-wrong or cross-section-bled content.
34
+ */
35
+ function decodeChunkSync(file, entry, key) {
36
+ if (!validateChunkBounds(entry, file.length)) {
37
+ warnStoreReadOutOfBounds(key, entry, file.length, "out_of_bounds");
38
+ return undefined;
39
+ }
40
+ const prefixed = bytesToUint32(file, entry.offset);
41
+ if (prefixed !== entry.length) {
42
+ warnStoreReadOutOfBounds(key, entry, file.length, "length_prefix_mismatch");
43
+ return undefined;
44
+ }
45
+ const start = entry.offset + 4;
46
+ const end = start + entry.length;
47
+ const chunk = file.slice(start, end);
48
+ if (typeof entry.crc === "number" && crc32(chunk) !== entry.crc) {
49
+ warnStoreReadOutOfBounds(key, entry, file.length, "crc_mismatch");
50
+ return undefined;
51
+ }
52
+ return DECODE.decode(chunk);
53
+ }
5
54
  let cached;
6
55
  export function invalidateStoreSnapshotCache(storePath) {
7
56
  if (!cached)
@@ -28,19 +77,27 @@ function readHeader(bytes) {
28
77
  };
29
78
  }
30
79
  function loadSnapshot(storePath) {
31
- if (!existsSync(storePath))
80
+ if (!existsSync(storePath)) {
81
+ invalidateStoreSnapshotCache(storePath);
32
82
  return undefined;
83
+ }
33
84
  const stats = statSync(storePath);
34
85
  if (cached &&
35
86
  cached.path === storePath &&
36
87
  cached.mtimeMs === stats.mtimeMs &&
37
- cached.size === stats.size) {
88
+ cached.size === stats.size &&
89
+ cached.ino === stats.ino) {
38
90
  return cached;
39
91
  }
40
92
  try {
41
93
  const file = readFileSync(storePath);
42
- if (file.length < HEADER_SIZE)
43
- return cached?.path === storePath ? cached : undefined;
94
+ // Never serve a stale cached snapshot as a fallback for an unreadable or
95
+ // short file — that would silently return deleted/old data. Missing is
96
+ // the safe answer; the caller already treats undefined as "not present".
97
+ if (file.length < HEADER_SIZE) {
98
+ invalidateStoreSnapshotCache(storePath);
99
+ return undefined;
100
+ }
44
101
  const { indexOffset, indexLength } = readHeader(file);
45
102
  const indexBytes = file.slice(indexOffset, indexOffset + indexLength);
46
103
  const parsed = JSON.parse(DECODE.decode(indexBytes));
@@ -48,6 +105,7 @@ function loadSnapshot(storePath) {
48
105
  path: storePath,
49
106
  mtimeMs: stats.mtimeMs,
50
107
  size: stats.size,
108
+ ino: stats.ino,
51
109
  file,
52
110
  index: new Map(Object.entries(parsed)),
53
111
  };
@@ -81,18 +139,14 @@ export function readStoreBlobSync(workspaceRoot, key) {
81
139
  const entry = snapshot?.index.get(key);
82
140
  if (!snapshot || !entry)
83
141
  return undefined;
84
- const start = entry.offset + 4;
85
- const end = start + entry.length;
86
- return DECODE.decode(snapshot.file.slice(start, end));
142
+ return decodeChunkSync(snapshot.file, entry, key);
87
143
  }
88
144
  export function readStoreBlobByPathSync(storePath, key) {
89
145
  const snapshot = loadSnapshot(storePath);
90
146
  const entry = snapshot?.index.get(key);
91
147
  if (!snapshot || !entry)
92
148
  return undefined;
93
- const start = entry.offset + 4;
94
- const end = start + entry.length;
95
- return DECODE.decode(snapshot.file.slice(start, end));
149
+ return decodeChunkSync(snapshot.file, entry, key);
96
150
  }
97
151
  export function readStoreJsonSync(workspaceRoot, key) {
98
152
  const raw = readStoreBlobSync(workspaceRoot, key);
@@ -132,6 +186,15 @@ export function listStoreKeysByPathSync(storePath, prefix = "") {
132
186
  .filter((key) => key.startsWith(prefix))
133
187
  .sort((a, b) => a.localeCompare(b));
134
188
  }
189
+ export function listStoreEntrySizesSync(workspaceRoot, prefix = "") {
190
+ const snapshot = loadSnapshot(getWorkspaceStorePath(workspaceRoot));
191
+ if (!snapshot)
192
+ return [];
193
+ return [...snapshot.index.entries()]
194
+ .filter(([key]) => key.startsWith(prefix))
195
+ .map(([key, entry]) => ({ key, length: entry.length }))
196
+ .sort((a, b) => a.key.localeCompare(b.key));
197
+ }
135
198
  export function getStoreStatSync(storePath) {
136
199
  const snapshot = loadSnapshot(storePath);
137
200
  if (!snapshot)
@@ -46,6 +46,7 @@ export declare const HEADER_SIZE_V1 = 64;
46
46
  export interface ChunkIndexEntry {
47
47
  offset: number;
48
48
  length: number;
49
+ crc?: number;
49
50
  }
50
51
  /** Zarrita AsyncReadable protocol */
51
52
  export interface AsyncReadable {
@@ -59,6 +59,13 @@ export declare class StoreSyncWriteBlockedByAsyncLeaseError extends Error {
59
59
  currentAsyncOperationLabels: string[];
60
60
  });
61
61
  }
62
+ export type StoreWriteLockClassification = "active" | "stale" | "unreadable";
63
+ /**
64
+ * Classifies a write-lock directory for maintenance tools. "active" leases
65
+ * must never be removed; "stale" (expired past TTL+grace) and "unreadable"
66
+ * (no/invalid lease.json) are safe repair targets.
67
+ */
68
+ export declare function classifyStoreWriteLockDir(lockPath: string, staleGraceMs?: number): StoreWriteLockClassification;
62
69
  export declare function withStoreWriteCoordinator<T>(storePath: string, fn: () => Promise<T>, options?: StoreWriteCoordinatorOptions): Promise<T>;
63
70
  export declare function withStoreWriteCoordinatorSync<T>(storePath: string, fn: () => T, options?: StoreWriteCoordinatorOptions): T;
64
71
  export declare function resolveStoreWriteLeasePath(workspaceRoot: string): string;
@@ -159,6 +159,17 @@ function leaseIsStale(lease, staleGraceMs) {
159
159
  return true;
160
160
  return Date.now() > expiresAt + staleGraceMs;
161
161
  }
162
+ /**
163
+ * Classifies a write-lock directory for maintenance tools. "active" leases
164
+ * must never be removed; "stale" (expired past TTL+grace) and "unreadable"
165
+ * (no/invalid lease.json) are safe repair targets.
166
+ */
167
+ export function classifyStoreWriteLockDir(lockPath, staleGraceMs = DEFAULT_STALE_GRACE_MS) {
168
+ const lease = readLeaseRecord(lockPath);
169
+ if (!lease)
170
+ return "unreadable";
171
+ return leaseIsStale(lease, staleGraceMs) ? "stale" : "active";
172
+ }
162
173
  function canRecoverStaleLease(lease, staleGraceMs) {
163
174
  if (!leaseIsStale(lease, staleGraceMs))
164
175
  return false;
@@ -14,6 +14,14 @@ export interface TodoStateFile {
14
14
  order: string[];
15
15
  nodes: Record<string, TodoNode>;
16
16
  }
17
+ export interface TodoAppendResult {
18
+ ok: boolean;
19
+ path: string;
20
+ todo_path: string;
21
+ node_id?: string;
22
+ section?: string;
23
+ error?: string;
24
+ }
17
25
  export interface TodoTransitionResult {
18
26
  ok: boolean;
19
27
  path: string;
@@ -38,4 +46,17 @@ export declare function syncTodoStateSafe(todoContent?: string): Promise<{
38
46
  export declare function readTodoState(): TodoStateFile;
39
47
  export declare function transitionTodoNode(nodeId: string, targetStatus: TodoNodeStatus): TodoTransitionResult;
40
48
  export declare function transitionTodoNodeSafe(nodeId: string, targetStatus: TodoNodeStatus): Promise<TodoTransitionResult>;
49
+ /**
50
+ * Atomically append a new TODO item under a section, without requiring the
51
+ * caller to read the whole board and resubmit it via update_todo(new_content).
52
+ * That read-modify-write dance is what let concurrent agents silently clobber
53
+ * each other's appended items: the read and write are separate MCP calls, so
54
+ * a lock around the write alone cannot close the race. Here the read, insert,
55
+ * and write all happen inside one TODO_MARKDOWN_REL lock acquisition.
56
+ */
57
+ export declare function appendTodoItemSafe(title: string, options?: {
58
+ section?: string;
59
+ depends_on?: string[];
60
+ id?: string;
61
+ }): Promise<TodoAppendResult>;
41
62
  //# sourceMappingURL=todo-state.d.ts.map