@fitlab-ai/agent-infra 0.7.0 → 0.7.1

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 (73) hide show
  1. package/bin/cli.ts +1 -1
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/lib/builtin-tuis.js +45 -0
  4. package/dist/lib/defaults.json +3 -0
  5. package/dist/lib/init.js +62 -23
  6. package/dist/lib/prompt.js +49 -1
  7. package/dist/lib/sandbox/commands/enter.js +1 -1
  8. package/dist/lib/sandbox/commands/list-running.js +58 -13
  9. package/dist/lib/sandbox/commands/rebuild.js +3 -11
  10. package/dist/lib/sandbox/commands/rm.js +2 -0
  11. package/dist/lib/sandbox/image-prune.js +18 -0
  12. package/dist/lib/sandbox/task-resolver.js +18 -0
  13. package/dist/lib/update.js +59 -18
  14. package/lib/builtin-tuis.ts +55 -0
  15. package/lib/defaults.json +3 -0
  16. package/lib/init.ts +87 -35
  17. package/lib/prompt.ts +54 -1
  18. package/lib/sandbox/commands/enter.ts +1 -1
  19. package/lib/sandbox/commands/list-running.ts +69 -16
  20. package/lib/sandbox/commands/rebuild.ts +3 -12
  21. package/lib/sandbox/commands/rm.ts +3 -0
  22. package/lib/sandbox/image-prune.ts +23 -0
  23. package/lib/sandbox/task-resolver.ts +23 -1
  24. package/lib/update.ts +71 -30
  25. package/package.json +1 -1
  26. package/templates/.agents/README.en.md +32 -0
  27. package/templates/.agents/README.zh-CN.md +32 -0
  28. package/templates/.agents/rules/task-short-id.en.md +141 -0
  29. package/templates/.agents/rules/task-short-id.zh-CN.md +124 -0
  30. package/templates/.agents/scripts/task-short-id.js +713 -0
  31. package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -0
  32. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
  33. package/templates/.agents/skills/block-task/SKILL.en.md +12 -0
  34. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +12 -1
  35. package/templates/.agents/skills/cancel-task/SKILL.en.md +12 -0
  36. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +12 -1
  37. package/templates/.agents/skills/check-task/SKILL.en.md +4 -0
  38. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +4 -1
  39. package/templates/.agents/skills/close-codescan/SKILL.en.md +11 -0
  40. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +11 -0
  41. package/templates/.agents/skills/close-dependabot/SKILL.en.md +11 -0
  42. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +11 -0
  43. package/templates/.agents/skills/code-task/SKILL.en.md +4 -0
  44. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -1
  45. package/templates/.agents/skills/commit/SKILL.en.md +4 -0
  46. package/templates/.agents/skills/commit/SKILL.zh-CN.md +4 -0
  47. package/templates/.agents/skills/complete-task/SKILL.en.md +12 -0
  48. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +12 -1
  49. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -0
  50. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -0
  51. package/templates/.agents/skills/create-task/SKILL.en.md +14 -0
  52. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +14 -1
  53. package/templates/.agents/skills/import-codescan/SKILL.en.md +14 -0
  54. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +14 -0
  55. package/templates/.agents/skills/import-dependabot/SKILL.en.md +14 -0
  56. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +14 -0
  57. package/templates/.agents/skills/import-issue/SKILL.en.md +14 -0
  58. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +14 -0
  59. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -0
  60. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -1
  61. package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
  62. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
  63. package/templates/.agents/skills/review-analysis/SKILL.en.md +4 -0
  64. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +4 -1
  65. package/templates/.agents/skills/review-code/SKILL.en.md +4 -0
  66. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -1
  67. package/templates/.agents/skills/review-plan/SKILL.en.md +4 -0
  68. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +4 -1
  69. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
  70. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
  71. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
  72. package/templates/.agents/templates/task.en.md +1 -0
  73. package/templates/.agents/templates/task.zh-CN.md +1 -0
@@ -0,0 +1,713 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
+ const SHORT_ID_RE = /^#\d+$/;
7
+ const REGISTRY_NAME = ".short-ids.json";
8
+ const LOCK_NAME = ".short-ids.json.lock";
9
+ const DEFAULT_LOCK_TIMEOUT_MS = 5000;
10
+ // Kept in sync with lib/defaults.json's task.shortIdLength. Used when there is
11
+ // no `--short-id-length` flag and no readable `task.shortIdLength` in
12
+ // .agents/.airc.json (e.g. the project upgraded but hasn't re-run
13
+ // ai update-agent-infra to backfill the field).
14
+ const DEFAULT_SHORT_ID_LENGTH = 2;
15
+
16
+ // process.stdout.write / process.stderr.write are non-blocking when the
17
+ // destination is a pipe (e.g. when spawned via child_process.spawnSync). On
18
+ // some platforms (notably macOS) the Node process can exit before the buffer
19
+ // flushes, leaving the parent with empty stdout. Use fs.writeSync to guarantee
20
+ // synchronous, fully-flushed writes — this is critical because the parent
21
+ // CLI/test code relies on stdout to carry the resolved task id / short id.
22
+ function writeStdout(text) {
23
+ fs.writeSync(1, text);
24
+ }
25
+
26
+ function writeStderr(text) {
27
+ fs.writeSync(2, text);
28
+ }
29
+
30
+ function usage() {
31
+ return [
32
+ "Usage: task-short-id.js <subcommand> [args]",
33
+ "",
34
+ "Subcommands:",
35
+ " alloc <task-id> Allocate short id for a task; writes registry + short_id to task.md",
36
+ " release <task-id> Release short id (idempotent; exit 0 if not present)",
37
+ " resolve <#N> Resolve short id to full task id",
38
+ " list Print registry JSON",
39
+ " list --verify Read-only check; exit 1 if active dir / registry / task.md disagree",
40
+ "",
41
+ "Options:",
42
+ " --active-dir <path> Override active dir (default: <repo>/.agents/workspace/active)",
43
+ " --short-id-length N Override configured width (default: from .airc.json or 2)"
44
+ ].join("\n");
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ const args = { positional: [], activeDir: null, shortIdLength: null, verify: false, help: false };
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const a = argv[i];
51
+ if (a === "--active-dir") {
52
+ args.activeDir = argv[++i];
53
+ } else if (a === "--short-id-length") {
54
+ args.shortIdLength = Number(argv[++i]);
55
+ } else if (a === "--verify") {
56
+ args.verify = true;
57
+ } else if (a === "-h" || a === "--help") {
58
+ args.help = true;
59
+ } else if (a.startsWith("--")) {
60
+ throw new Error(`Unknown option: ${a}`);
61
+ } else {
62
+ args.positional.push(a);
63
+ }
64
+ }
65
+ return args;
66
+ }
67
+
68
+ function findRepoRoot(start) {
69
+ let dir = path.resolve(start || process.cwd());
70
+ for (;;) {
71
+ if (fs.existsSync(path.join(dir, ".agents", ".airc.json"))) return dir;
72
+ const parent = path.dirname(dir);
73
+ if (parent === dir) return null;
74
+ dir = parent;
75
+ }
76
+ }
77
+
78
+ function readShortIdLength(repoRoot, override) {
79
+ if (typeof override === "number" && Number.isFinite(override) && override >= 1) {
80
+ return override;
81
+ }
82
+ if (!repoRoot) return DEFAULT_SHORT_ID_LENGTH;
83
+ try {
84
+ const cfgPath = path.join(repoRoot, ".agents", ".airc.json");
85
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
86
+ const v = cfg && cfg.task && cfg.task.shortIdLength;
87
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1) return v;
88
+ } catch {
89
+ // ignore
90
+ }
91
+ return DEFAULT_SHORT_ID_LENGTH;
92
+ }
93
+
94
+ function readRegistry(registryPath) {
95
+ if (!fs.existsSync(registryPath)) {
96
+ return { version: 1, ids: {} };
97
+ }
98
+ let raw;
99
+ try {
100
+ raw = fs.readFileSync(registryPath, "utf8");
101
+ } catch (e) {
102
+ writeStderr(`Error: cannot read registry ${registryPath}: ${e.message}\n`);
103
+ process.exit(2);
104
+ }
105
+ try {
106
+ const data = JSON.parse(raw);
107
+ if (!data || typeof data !== "object" || !data.ids || typeof data.ids !== "object") {
108
+ writeStderr(`Error: registry ${registryPath} has invalid schema\n`);
109
+ process.exit(2);
110
+ }
111
+ if (data.version !== 1) data.version = 1;
112
+ return data;
113
+ } catch (e) {
114
+ writeStderr(`Error: registry ${registryPath} is not valid JSON: ${e.message}\n`);
115
+ process.exit(2);
116
+ }
117
+ }
118
+
119
+ function writeRegistryAtomic(data, registryPath) {
120
+ const tmpPath = `${registryPath}.tmp.${process.pid}`;
121
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
122
+ fs.renameSync(tmpPath, registryPath);
123
+ }
124
+
125
+ function withRegistryLock(activeDir, fn, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
126
+ fs.mkdirSync(activeDir, { recursive: true });
127
+ const lockDir = path.join(activeDir, LOCK_NAME);
128
+ const start = Date.now();
129
+ for (;;) {
130
+ try {
131
+ fs.mkdirSync(lockDir, { recursive: false });
132
+ break;
133
+ } catch (e) {
134
+ if (e.code !== "EEXIST") throw e;
135
+ if (Date.now() - start > timeoutMs) {
136
+ writeStderr(`Error: registry lock timeout after ${timeoutMs}ms\n`);
137
+ process.exit(3);
138
+ }
139
+ const elapsed = Date.now() - start;
140
+ const wait = Math.min(500, 50 * Math.pow(2, Math.floor(elapsed / 200)));
141
+ const deadline = Date.now() + wait;
142
+ while (Date.now() < deadline) {
143
+ /* busy wait, ms-scale */
144
+ }
145
+ }
146
+ }
147
+ // Register cleanup that runs even on process.exit (which skips try/finally).
148
+ const cleanup = () => {
149
+ try {
150
+ fs.rmdirSync(lockDir);
151
+ } catch {
152
+ /* lock-dir already removed */
153
+ }
154
+ };
155
+ process.once("exit", cleanup);
156
+ try {
157
+ return fn();
158
+ } finally {
159
+ process.removeListener("exit", cleanup);
160
+ cleanup();
161
+ }
162
+ }
163
+
164
+ function writeTaskMdShortId(taskMdPath, shortId) {
165
+ const content = fs.readFileSync(taskMdPath, "utf8");
166
+ let updated;
167
+ if (/^short_id:.*$/m.test(content)) {
168
+ updated = content.replace(/^short_id:.*$/m, `short_id: ${shortId}`);
169
+ } else {
170
+ updated = content.replace(/^(id:.*)$/m, `$1\nshort_id: ${shortId}`);
171
+ }
172
+ fs.writeFileSync(taskMdPath, updated);
173
+ }
174
+
175
+ function padShortId(n, shortIdLength) {
176
+ return String(n).padStart(shortIdLength, "0");
177
+ }
178
+
179
+ function allocateMinFreeInt(registry, shortIdLength) {
180
+ const maxN = Math.pow(10, shortIdLength) - 1;
181
+ for (let n = 1; n <= maxN; n += 1) {
182
+ if (!registry.ids[padShortId(n, shortIdLength)]) return n;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function parseShortIdArg(arg, shortIdLength) {
188
+ const re = new RegExp(`^#\\d{${shortIdLength}}$`);
189
+ if (!re.test(arg)) {
190
+ const example =
191
+ shortIdLength === 1 ? "'#1'" : shortIdLength === 2 ? "'#01'" : `'#${"0".repeat(shortIdLength - 1)}1'`;
192
+ writeStderr(
193
+ `Error: invalid short id format '${arg}', ` +
194
+ `expected #${"N".repeat(shortIdLength)} (${shortIdLength}-digit zero-padded; e.g. ${example})\n`
195
+ );
196
+ process.exit(1);
197
+ }
198
+ const key = arg.slice(1);
199
+ if (Number(key) === 0) {
200
+ writeStderr(
201
+ `Error: short id '${arg}' is invalid (#${"0".repeat(shortIdLength)} is reserved)\n`
202
+ );
203
+ process.exit(1);
204
+ }
205
+ return key;
206
+ }
207
+
208
+ function planTransaction(registry, activeDir, shortIdLength) {
209
+ const maxN = Math.pow(10, shortIdLength) - 1;
210
+
211
+ // A1: active task id set
212
+ const activeTaskIds = new Set(
213
+ fs
214
+ .readdirSync(activeDir)
215
+ .filter((d) => TASK_ID_RE.test(d))
216
+ .filter((d) => fs.existsSync(path.join(activeDir, d, "task.md")))
217
+ );
218
+
219
+ // A2: stale entries
220
+ const pendingRegistryDeletes = [];
221
+ for (const [key, taskId] of Object.entries(registry.ids)) {
222
+ if (!activeTaskIds.has(taskId)) pendingRegistryDeletes.push(key);
223
+ }
224
+
225
+ const projectedIds = { ...registry.ids };
226
+ for (const key of pendingRegistryDeletes) delete projectedIds[key];
227
+
228
+ // A3: duplicate key detection (after stale cleanup)
229
+ const taskIdToKey = new Map();
230
+ for (const [key, taskId] of Object.entries(projectedIds)) {
231
+ if (taskIdToKey.has(taskId)) {
232
+ const existingKey = taskIdToKey.get(taskId);
233
+ writeStderr(
234
+ `Error: duplicate registry entries for taskId ${taskId} at keys [#${existingKey}, #${key}]; manual resolution required\n`
235
+ );
236
+ process.exit(2);
237
+ }
238
+ taskIdToKey.set(taskId, key);
239
+ }
240
+
241
+ // A4: classify each active task
242
+ const plannedRegistryWrites = [];
243
+ const plannedTaskMdWrites = [];
244
+ const pendingAlloc = [];
245
+
246
+ for (const taskId of activeTaskIds) {
247
+ const taskMdPath = path.join(activeDir, taskId, "task.md");
248
+ const originalStat = fs.statSync(taskMdPath);
249
+ const originalContent = fs.readFileSync(taskMdPath, "utf8");
250
+ const existing = originalContent.match(/^short_id:\s*(#\d+)\s*$/m);
251
+
252
+ if (existing) {
253
+ const declared = existing[1];
254
+ const n = declared.slice(1);
255
+ if (projectedIds[n] === taskId) continue; // 4a
256
+ if (taskIdToKey.has(taskId)) {
257
+ const registryKey = taskIdToKey.get(taskId);
258
+ writeStderr(
259
+ `Inconsistent: task ${taskId} declares ${declared} but registry holds it at #${registryKey}\n`
260
+ );
261
+ process.exit(2);
262
+ }
263
+ if (projectedIds[n] && projectedIds[n] !== taskId) {
264
+ writeStderr(
265
+ `Inconsistent: task ${taskId} declares ${declared} but registry maps ${declared} to ${projectedIds[n]}\n`
266
+ );
267
+ process.exit(2);
268
+ }
269
+ // 4d
270
+ plannedRegistryWrites.push({ key: n, taskId });
271
+ projectedIds[n] = taskId;
272
+ taskIdToKey.set(taskId, n);
273
+ continue;
274
+ }
275
+
276
+ if (taskIdToKey.has(taskId)) {
277
+ // 4e
278
+ const registryKey = taskIdToKey.get(taskId);
279
+ plannedTaskMdWrites.push({
280
+ taskMdPath,
281
+ originalContent,
282
+ originalAtime: originalStat.atime,
283
+ originalMtime: originalStat.mtime,
284
+ shortId: `#${registryKey}`,
285
+ kind: "4e"
286
+ });
287
+ continue;
288
+ }
289
+
290
+ // 4f: deferred
291
+ pendingAlloc.push({
292
+ taskId,
293
+ taskMdPath,
294
+ originalContent,
295
+ originalAtime: originalStat.atime,
296
+ originalMtime: originalStat.mtime
297
+ });
298
+ }
299
+
300
+ // A5: capacity pre-check
301
+ const availableSlots = maxN - Object.keys(projectedIds).length;
302
+ if (pendingAlloc.length > availableSlots) {
303
+ writeStderr(
304
+ `Error: cold-start migration needs ${pendingAlloc.length} short id(s) but only ${availableSlots} ` +
305
+ `slot(s) available (capacity=${maxN}, in-use after stale-cleanup=${Object.keys(projectedIds).length}). ` +
306
+ `Archive some active tasks (complete-task / cancel-task / block-task) ` +
307
+ `or raise task.shortIdLength in .agents/.airc.json.\n`
308
+ );
309
+ process.exit(2);
310
+ }
311
+
312
+ pendingAlloc.sort((a, b) => a.taskId.localeCompare(b.taskId));
313
+
314
+ for (const item of pendingAlloc) {
315
+ const n = allocateMinFreeInt({ ids: projectedIds }, shortIdLength);
316
+ if (n === null) {
317
+ throw new Error("Internal invariant: pendingAlloc capacity check failed");
318
+ }
319
+ const key = padShortId(n, shortIdLength);
320
+ projectedIds[key] = item.taskId;
321
+ taskIdToKey.set(item.taskId, key);
322
+ plannedRegistryWrites.push({ key, taskId: item.taskId });
323
+ plannedTaskMdWrites.push({
324
+ taskMdPath: item.taskMdPath,
325
+ originalContent: item.originalContent,
326
+ originalAtime: item.originalAtime,
327
+ originalMtime: item.originalMtime,
328
+ shortId: `#${key}`,
329
+ kind: "4f"
330
+ });
331
+ }
332
+
333
+ // Build transaction object
334
+ const tx = {
335
+ _registry: registry,
336
+ _activeDir: activeDir,
337
+ _registrySnapshot: { ...registry.ids },
338
+ _pendingRegistryDeletes: pendingRegistryDeletes,
339
+ _plannedRegistryWrites: plannedRegistryWrites,
340
+ _plannedTaskMdWrites: plannedTaskMdWrites,
341
+ _projectedIds: projectedIds,
342
+ _taskIdToKey: taskIdToKey,
343
+ _shortIdLength: shortIdLength,
344
+ _maxN: maxN,
345
+
346
+ planAlloc(taskId) {
347
+ const taskMdPath = path.join(activeDir, taskId, "task.md");
348
+ if (!fs.existsSync(taskMdPath)) {
349
+ throw new Error(`planAlloc: task.md not found for ${taskId}`);
350
+ }
351
+ if (this._taskIdToKey.has(taskId)) {
352
+ return this._taskIdToKey.get(taskId);
353
+ }
354
+ const inUse = Object.keys(this._projectedIds).length;
355
+ if (inUse >= this._maxN) {
356
+ throw new Error(
357
+ `Error: short id width exhausted (current shortIdLength=${this._shortIdLength}, ` +
358
+ `${inUse}/${this._maxN} slots in use). Archive some active tasks or raise task.shortIdLength.`
359
+ );
360
+ }
361
+ const n = allocateMinFreeInt({ ids: this._projectedIds }, this._shortIdLength);
362
+ const key = padShortId(n, this._shortIdLength);
363
+ this._projectedIds[key] = taskId;
364
+ this._taskIdToKey.set(taskId, key);
365
+ this._plannedRegistryWrites.push({ key, taskId });
366
+ const originalStat = fs.statSync(taskMdPath);
367
+ const originalContent = fs.readFileSync(taskMdPath, "utf8");
368
+ // If task.md already declares the same short id (e.g. R-alloc replay), skip writing.
369
+ const existing = originalContent.match(/^short_id:\s*(#\d+)\s*$/m);
370
+ if (!existing || existing[1] !== `#${key}`) {
371
+ this._plannedTaskMdWrites.push({
372
+ taskMdPath,
373
+ originalContent,
374
+ originalAtime: originalStat.atime,
375
+ originalMtime: originalStat.mtime,
376
+ shortId: `#${key}`,
377
+ kind: "caller-alloc"
378
+ });
379
+ }
380
+ return key; // zero-padded; matches registry key
381
+ },
382
+
383
+ planRelease(taskId) {
384
+ const key = this._taskIdToKey.get(taskId);
385
+ if (!key) return; // idempotent
386
+ this._plannedRegistryWrites = this._plannedRegistryWrites.filter(
387
+ (w) => w.taskId !== taskId
388
+ );
389
+ this._plannedTaskMdWrites = this._plannedTaskMdWrites.filter(
390
+ (w) => path.basename(path.dirname(w.taskMdPath)) !== taskId
391
+ );
392
+ this._pendingRegistryDeletes.push(key);
393
+ delete this._projectedIds[key];
394
+ this._taskIdToKey.delete(taskId);
395
+ },
396
+
397
+ commit(registryPath) {
398
+ // B1: apply registry mutation in memory
399
+ for (const key of this._pendingRegistryDeletes) delete this._registry.ids[key];
400
+ for (const { key, taskId } of this._plannedRegistryWrites) {
401
+ this._registry.ids[key] = taskId;
402
+ }
403
+
404
+ const completedWrites = [];
405
+ const rollback = (reason) => {
406
+ for (const done of completedWrites.reverse()) {
407
+ try {
408
+ fs.writeFileSync(done.taskMdPath, done.originalContent);
409
+ fs.utimesSync(done.taskMdPath, done.originalAtime, done.originalMtime);
410
+ } catch {
411
+ /* best-effort */
412
+ }
413
+ }
414
+ this._registry.ids = this._registrySnapshot;
415
+ const tail =
416
+ completedWrites.length > 0
417
+ ? `; rolled back ${completedWrites.length} prior task.md write(s)`
418
+ : "";
419
+ throw new Error(`${reason}${tail}`);
420
+ };
421
+
422
+ // B2: write task.md per plan
423
+ for (const write of this._plannedTaskMdWrites) {
424
+ try {
425
+ writeTaskMdShortId(write.taskMdPath, write.shortId);
426
+ completedWrites.push(write);
427
+ } catch (e) {
428
+ rollback(`Failed to write short_id to ${write.taskMdPath}: ${e.message}`);
429
+ }
430
+ }
431
+
432
+ // B3: atomic registry persistence
433
+ try {
434
+ writeRegistryAtomic(this._registry, registryPath);
435
+ } catch (e) {
436
+ rollback(`Failed to persist registry to ${registryPath}: ${e.message}`);
437
+ }
438
+ }
439
+ };
440
+
441
+ return tx;
442
+ }
443
+
444
+ function verifyRegistry(registry, activeDir) {
445
+ const activeTaskIds = new Set(
446
+ fs
447
+ .readdirSync(activeDir)
448
+ .filter((d) => TASK_ID_RE.test(d))
449
+ .filter((d) => fs.existsSync(path.join(activeDir, d, "task.md")))
450
+ );
451
+ const registryTaskIds = new Set(Object.values(registry.ids));
452
+ const taskmdShortIds = new Map();
453
+ for (const taskId of activeTaskIds) {
454
+ const taskMdPath = path.join(activeDir, taskId, "task.md");
455
+ const content = fs.readFileSync(taskMdPath, "utf8");
456
+ const m = content.match(/^short_id:\s*(#\d+)\s*$/m);
457
+ taskmdShortIds.set(taskId, m ? m[1] : null);
458
+ }
459
+ const missing_in_registry = [];
460
+ for (const taskId of activeTaskIds) {
461
+ if (!registryTaskIds.has(taskId)) {
462
+ missing_in_registry.push({ taskId, declared: taskmdShortIds.get(taskId) });
463
+ }
464
+ }
465
+ const missing_in_taskmd = [];
466
+ for (const [key, taskId] of Object.entries(registry.ids)) {
467
+ if (!activeTaskIds.has(taskId)) continue;
468
+ const declared = taskmdShortIds.get(taskId);
469
+ if (declared === null) {
470
+ missing_in_taskmd.push({ taskId, expected: `#${key}` });
471
+ } else if (declared !== `#${key}`) {
472
+ missing_in_taskmd.push({ taskId, expected: `#${key}`, declared });
473
+ }
474
+ }
475
+ const orphans_in_registry = [];
476
+ for (const [key, taskId] of Object.entries(registry.ids)) {
477
+ if (!activeTaskIds.has(taskId)) {
478
+ orphans_in_registry.push({ key: `#${key}`, taskId });
479
+ }
480
+ }
481
+ const taskIdToKeys = new Map();
482
+ for (const [key, taskId] of Object.entries(registry.ids)) {
483
+ if (!taskIdToKeys.has(taskId)) taskIdToKeys.set(taskId, []);
484
+ taskIdToKeys.get(taskId).push(key);
485
+ }
486
+ const duplicate_registry_keys = [];
487
+ for (const [taskId, keys] of taskIdToKeys) {
488
+ if (keys.length > 1) {
489
+ duplicate_registry_keys.push({ taskId, keys: keys.map((k) => `#${k}`) });
490
+ }
491
+ }
492
+ return {
493
+ missing_in_registry,
494
+ missing_in_taskmd,
495
+ orphans_in_registry,
496
+ duplicate_registry_keys
497
+ };
498
+ }
499
+
500
+ function cmdAlloc(taskId, activeDir, registryPath, shortIdLength) {
501
+ if (!TASK_ID_RE.test(taskId)) {
502
+ writeStderr(`Error: invalid task id format '${taskId}'\n`);
503
+ process.exit(1);
504
+ }
505
+ return withRegistryLock(activeDir, () => {
506
+ const taskMdPath = path.join(activeDir, taskId, "task.md");
507
+ if (!fs.existsSync(taskMdPath)) {
508
+ writeStderr(`Error: task ${taskId} not found in ${activeDir} (no task.md)\n`);
509
+ process.exit(1);
510
+ }
511
+ const registry = readRegistry(registryPath);
512
+ const tx = planTransaction(registry, activeDir, shortIdLength);
513
+ let shortId;
514
+ try {
515
+ shortId = tx.planAlloc(taskId);
516
+ } catch (e) {
517
+ writeStderr(`${e.message}\n`);
518
+ process.exit(2);
519
+ }
520
+ try {
521
+ tx.commit(registryPath);
522
+ } catch (e) {
523
+ writeStderr(`${e.message}\n`);
524
+ process.exit(1);
525
+ }
526
+ // shortId is already zero-padded (returned by tx.planAlloc; matches registry key)
527
+ writeStdout(`#${shortId}\n`);
528
+ });
529
+ }
530
+
531
+ function cmdRelease(taskId, activeDir, registryPath, shortIdLength) {
532
+ if (!TASK_ID_RE.test(taskId)) {
533
+ writeStderr(`Error: invalid task id format '${taskId}'\n`);
534
+ process.exit(1);
535
+ }
536
+ return withRegistryLock(activeDir, () => {
537
+ const registry = readRegistry(registryPath);
538
+ const tx = planTransaction(registry, activeDir, shortIdLength);
539
+ tx.planRelease(taskId);
540
+ try {
541
+ tx.commit(registryPath);
542
+ } catch (e) {
543
+ writeStderr(`${e.message}\n`);
544
+ process.exit(1);
545
+ }
546
+ // idempotent exit 0
547
+ });
548
+ }
549
+
550
+ function cmdResolve(shortIdArg, activeDir, registryPath, shortIdLength) {
551
+ // Strict width match + reserved key check; on invalid arg, parseShortIdArg writes full
552
+ // stderr (including "expected #NN (N-digit zero-padded; e.g. '#01')") and exits 1.
553
+ const key = parseShortIdArg(shortIdArg, shortIdLength);
554
+ return withRegistryLock(activeDir, () => {
555
+ const registry = readRegistry(registryPath);
556
+ const tx = planTransaction(registry, activeDir, shortIdLength);
557
+ const taskId = tx._projectedIds[key];
558
+ if (!taskId) {
559
+ const hasPendingMutations =
560
+ tx._plannedRegistryWrites.length > 0 ||
561
+ tx._pendingRegistryDeletes.length > 0 ||
562
+ tx._plannedTaskMdWrites.length > 0;
563
+ if (hasPendingMutations) {
564
+ try {
565
+ tx.commit(registryPath);
566
+ } catch (e) {
567
+ writeStderr(`${e.message}\n`);
568
+ process.exit(1);
569
+ }
570
+ }
571
+ if (Object.keys(tx._projectedIds).length === 0) {
572
+ writeStderr(
573
+ `Error: short id '#${key}' not found; active task registry is empty.\n`
574
+ );
575
+ } else {
576
+ writeStderr(
577
+ `Error: short id '#${key}' not found in active task registry ` +
578
+ `(it may have been cleaned up after archival; check 'task-short-id.js list').\n`
579
+ );
580
+ }
581
+ process.exit(1);
582
+ }
583
+ try {
584
+ tx.commit(registryPath);
585
+ } catch (e) {
586
+ writeStderr(`${e.message}\n`);
587
+ process.exit(1);
588
+ }
589
+ writeStdout(`${taskId}\n`);
590
+ });
591
+ }
592
+
593
+ function cmdList(activeDir, registryPath, verify) {
594
+ if (!verify) {
595
+ const registry = readRegistry(registryPath);
596
+ writeStdout(`${JSON.stringify(registry, null, 2)}\n`);
597
+ return;
598
+ }
599
+ const registry = readRegistry(registryPath);
600
+ if (!fs.existsSync(activeDir)) {
601
+ writeStdout("");
602
+ return;
603
+ }
604
+ const diff = verifyRegistry(registry, activeDir);
605
+ const hasIssues =
606
+ diff.missing_in_registry.length > 0 ||
607
+ diff.missing_in_taskmd.length > 0 ||
608
+ diff.orphans_in_registry.length > 0 ||
609
+ diff.duplicate_registry_keys.length > 0;
610
+ if (hasIssues) {
611
+ writeStdout(`${JSON.stringify(diff, null, 2)}\n`);
612
+ process.exit(1);
613
+ }
614
+ // consistent: empty stdout, exit 0
615
+ }
616
+
617
+ function main(argv) {
618
+ let args;
619
+ try {
620
+ args = parseArgs(argv);
621
+ } catch (e) {
622
+ writeStderr(`${e.message}\n${usage()}\n`);
623
+ process.exit(1);
624
+ }
625
+ if (args.help || args.positional.length === 0) {
626
+ writeStdout(`${usage()}\n`);
627
+ return;
628
+ }
629
+ const subcommand = args.positional[0];
630
+ const repoRoot = findRepoRoot(process.cwd());
631
+ const activeDir = args.activeDir
632
+ ? path.resolve(args.activeDir)
633
+ : repoRoot
634
+ ? path.join(repoRoot, ".agents", "workspace", "active")
635
+ : null;
636
+ if (!activeDir) {
637
+ writeStderr(
638
+ `Error: cannot locate active dir (no .agents/.airc.json found above ${process.cwd()})\n`
639
+ );
640
+ process.exit(2);
641
+ }
642
+ const shortIdLength = readShortIdLength(repoRoot, args.shortIdLength);
643
+ const registryPath = path.join(activeDir, REGISTRY_NAME);
644
+
645
+ switch (subcommand) {
646
+ case "alloc":
647
+ if (!args.positional[1]) {
648
+ writeStderr(`Usage: alloc <task-id>\n`);
649
+ process.exit(1);
650
+ }
651
+ return cmdAlloc(args.positional[1], activeDir, registryPath, shortIdLength);
652
+ case "release":
653
+ if (!args.positional[1]) {
654
+ writeStderr(`Usage: release <task-id>\n`);
655
+ process.exit(1);
656
+ }
657
+ return cmdRelease(args.positional[1], activeDir, registryPath, shortIdLength);
658
+ case "resolve":
659
+ if (!args.positional[1]) {
660
+ writeStderr(`Usage: resolve <#N>\n`);
661
+ process.exit(1);
662
+ }
663
+ return cmdResolve(args.positional[1], activeDir, registryPath, shortIdLength);
664
+ case "list":
665
+ return cmdList(activeDir, registryPath, args.verify);
666
+ default:
667
+ writeStderr(`Unknown subcommand: ${subcommand}\n${usage()}\n`);
668
+ process.exit(1);
669
+ }
670
+ }
671
+
672
+ // Compare canonicalized (symlink-resolved) paths so this script still runs as a
673
+ // CLI when invoked through a temp-dir symlink (notably /var/folders on macOS,
674
+ // which is a symlink to /private/var/folders; process.argv[1] keeps the
675
+ // symlinked path while import.meta.url is auto-resolved to the realpath).
676
+ const isCli = (() => {
677
+ const entry = process.argv[1];
678
+ if (!entry) return false;
679
+ try {
680
+ const realEntry = fs.realpathSync(entry);
681
+ const realModule = fs.realpathSync(fileURLToPath(import.meta.url));
682
+ return realEntry === realModule;
683
+ } catch {
684
+ return false;
685
+ }
686
+ })();
687
+
688
+ if (isCli) {
689
+ main(process.argv.slice(2));
690
+ }
691
+
692
+ export {
693
+ TASK_ID_RE,
694
+ SHORT_ID_RE,
695
+ REGISTRY_NAME,
696
+ parseArgs,
697
+ findRepoRoot,
698
+ readShortIdLength,
699
+ readRegistry,
700
+ writeRegistryAtomic,
701
+ withRegistryLock,
702
+ writeTaskMdShortId,
703
+ padShortId,
704
+ parseShortIdArg,
705
+ allocateMinFreeInt,
706
+ planTransaction,
707
+ verifyRegistry,
708
+ cmdAlloc,
709
+ cmdRelease,
710
+ cmdResolve,
711
+ cmdList,
712
+ main
713
+ };