atris 3.52.0 → 3.53.0

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.
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // Mission ledger compaction. Every mission save appends a full snapshot to
4
+ // .atris/state/missions.jsonl, so a mission saved N times costs N rows and
5
+ // every reader pays for the whole history (2,417 rows for 302 missions when
6
+ // this shipped). Worse, old snapshots keep referencing old run receipts, so
7
+ // the daily runs-prune can never reclaim them. Compacting to one row per
8
+ // mission keeps exactly what every reader reconstructs anyway.
9
+ //
10
+ // Readers' contract (loadMissionMap in commands/mission.js): the surviving
11
+ // row per id must carry the LATEST state but the FIRST display number ever
12
+ // assigned, and first-appearance order must hold so numbering stays stable.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { displayNumber } = require('./short-name');
17
+
18
+ const DEFAULT_MIN_ROWS = 64;
19
+ const DEFAULT_RATIO = 4;
20
+
21
+ function parseLines(text) {
22
+ return String(text || '')
23
+ .split(/\r?\n/)
24
+ .map((line) => line.trim())
25
+ .filter(Boolean)
26
+ .map((line) => {
27
+ try {
28
+ return JSON.parse(line);
29
+ } catch {
30
+ return null;
31
+ }
32
+ });
33
+ }
34
+
35
+ // Latest snapshot per id, first-seen order, first assigned display number.
36
+ // Rows without an id are not mission snapshots (cloud mission receipts land
37
+ // here as {cloud: true, ...}); they pass through untouched, in order, after
38
+ // the compacted snapshots so latest-row readers still find them.
39
+ function compactRows(rows) {
40
+ const order = [];
41
+ const latest = new Map();
42
+ const firstNumber = new Map();
43
+ const passthrough = [];
44
+ for (const row of rows) {
45
+ if (!row) continue;
46
+ if (!row.id) {
47
+ passthrough.push(row);
48
+ continue;
49
+ }
50
+ if (!latest.has(row.id)) order.push(row.id);
51
+ if (!firstNumber.has(row.id) && displayNumber(row.n)) firstNumber.set(row.id, displayNumber(row.n));
52
+ latest.set(row.id, row);
53
+ }
54
+ const compacted = order.map((id) => {
55
+ const row = { ...latest.get(id) };
56
+ if (firstNumber.has(id)) row.n = firstNumber.get(id);
57
+ else delete row.n;
58
+ return row;
59
+ });
60
+ return compacted.concat(passthrough);
61
+ }
62
+
63
+ // Compact when history outweighs live records by `ratio`, and never bother
64
+ // below `minRows`. Returns a receipt either way; writes atomically so a
65
+ // concurrent reader sees the old file or the new one, never a torn one.
66
+ function compactMissionLedger(file, options = {}) {
67
+ const minRows = Number.isFinite(options.minRows) ? options.minRows : DEFAULT_MIN_ROWS;
68
+ const ratio = Number.isFinite(options.ratio) ? options.ratio : DEFAULT_RATIO;
69
+ // Cheap stat gate so the every-save hook does not re-read a small healthy
70
+ // ledger: below this size compaction cannot be worth a full read.
71
+ const minBytes = Number.isFinite(options.minBytes) ? options.minBytes : 262144;
72
+ if (options.force !== true) {
73
+ try {
74
+ if (fs.statSync(file).size < minBytes) {
75
+ return { compacted: false, reason: 'below_threshold', rows_before: 0, rows_after: 0 };
76
+ }
77
+ } catch {
78
+ return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
79
+ }
80
+ }
81
+ let text = '';
82
+ try {
83
+ text = fs.readFileSync(file, 'utf8');
84
+ } catch {
85
+ return { compacted: false, reason: 'missing', rows_before: 0, rows_after: 0 };
86
+ }
87
+ const rows = parseLines(text);
88
+ const valid = rows.filter((row) => row && row.id);
89
+ const uniqueIds = new Set(valid.map((row) => row.id)).size;
90
+ const receipt = {
91
+ rows_before: rows.length,
92
+ unique_missions: uniqueIds,
93
+ bytes_before: Buffer.byteLength(text),
94
+ };
95
+ const force = options.force === true;
96
+ if (!force && (rows.length < minRows || rows.length < uniqueIds * ratio)) {
97
+ return { ...receipt, compacted: false, reason: 'below_threshold', rows_after: rows.length };
98
+ }
99
+ const compacted = compactRows(rows.filter(Boolean));
100
+ const nextText = compacted.length
101
+ ? `${compacted.map((row) => JSON.stringify(row)).join('\n')}\n`
102
+ : '';
103
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(tmp, nextText, 'utf8');
106
+ // The rename replaces the whole file, so an append that landed after our
107
+ // read would be silently lost. Re-check right before the swap and stand
108
+ // down if anyone wrote in between; the next save retries compaction.
109
+ try {
110
+ if (fs.statSync(file).size !== receipt.bytes_before) {
111
+ fs.unlinkSync(tmp);
112
+ return { ...receipt, compacted: false, reason: 'concurrent_write', rows_after: rows.length };
113
+ }
114
+ } catch {
115
+ fs.unlinkSync(tmp);
116
+ return { ...receipt, compacted: false, reason: 'missing', rows_after: 0 };
117
+ }
118
+ fs.renameSync(tmp, file);
119
+ return {
120
+ ...receipt,
121
+ compacted: true,
122
+ rows_after: compacted.length,
123
+ bytes_after: Buffer.byteLength(nextText),
124
+ };
125
+ }
126
+
127
+ module.exports = { compactMissionLedger, compactRows };
@@ -121,7 +121,34 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
121
121
  ...(options.env || {}),
122
122
  },
123
123
  stdio: ['ignore', 'pipe', 'pipe'],
124
+ // Own process group so a timeout can kill the whole tree: the tick
125
+ // spawns engine workers through a shell, and killing only the direct
126
+ // child leaves them running (and billing) for hours after the timeout.
127
+ detached: process.platform !== 'win32',
124
128
  });
129
+ const killTree = (signal) => {
130
+ try {
131
+ if (process.platform !== 'win32' && proc.pid) process.kill(-proc.pid, signal);
132
+ else proc.kill(signal);
133
+ } catch {
134
+ try { proc.kill(signal); } catch {}
135
+ }
136
+ };
137
+ // Detached children survive the operator's Ctrl-C (they left our process
138
+ // group), so relay the signal to the whole tree ourselves, then let the
139
+ // default exit happen by re-raising after removing our handler.
140
+ const relaySignal = (signal) => {
141
+ killTree('SIGTERM');
142
+ setTimeout(() => killTree('SIGKILL'), 2000).unref();
143
+ process.removeListener(signal, relaySignal);
144
+ process.kill(process.pid, signal);
145
+ };
146
+ process.once('SIGINT', relaySignal);
147
+ process.once('SIGTERM', relaySignal);
148
+ const detachSignalRelay = () => {
149
+ process.removeListener('SIGINT', relaySignal);
150
+ process.removeListener('SIGTERM', relaySignal);
151
+ };
125
152
  let stdout = '';
126
153
  let stderr = '';
127
154
  let done = false;
@@ -130,6 +157,7 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
130
157
  const finish = (status) => {
131
158
  if (done) return;
132
159
  done = true;
160
+ detachSignalRelay();
133
161
  clearTimeout(timer);
134
162
  if (progressTimer) clearInterval(progressTimer);
135
163
  let payload = null;
@@ -146,8 +174,8 @@ function runCliJsonStreaming(cliPath, args, options = {}) {
146
174
  };
147
175
  const timer = setTimeout(() => {
148
176
  killedByTimeout = true;
149
- try { proc.kill('SIGTERM'); } catch {}
150
- setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
177
+ killTree('SIGTERM');
178
+ setTimeout(() => killTree('SIGKILL'), 3000).unref();
151
179
  }, options.timeoutMs || 660000);
152
180
  progressTimer = options.onProgress
153
181
  ? setInterval(() => {
package/lib/next-moves.js CHANGED
@@ -120,12 +120,17 @@ function readRoadmapOpenItems(root) {
120
120
  }));
121
121
  }
122
122
 
123
- function readActiveTasks(root) {
123
+ // One parse of tasks.projection.json, shared by every reader that needs the
124
+ // task list, so a single nextMoves() call does not re-read the same big file.
125
+ function readTasksProjection(root) {
124
126
  const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
125
127
  if (!text) return [];
126
128
  let proj;
127
129
  try { proj = JSON.parse(text); } catch { return []; }
128
- const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
130
+ return Array.isArray(proj && proj.tasks) ? proj.tasks : [];
131
+ }
132
+
133
+ function readActiveTasks(root, tasks = readTasksProjection(root)) {
129
134
  return tasks
130
135
  .filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
131
136
  .filter((t) => !isInternalNextMoveTask(t))
@@ -147,12 +152,7 @@ function isInternalNextMoveTask(task) {
147
152
  return tags.includes('agent-xp') || /^mission xp\s*:/i.test(title);
148
153
  }
149
154
 
150
- function readHandledTaskTitles(root) {
151
- const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
152
- if (!text) return [];
153
- let proj;
154
- try { proj = JSON.parse(text); } catch { return []; }
155
- const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
155
+ function readHandledTaskTitles(root, tasks = readTasksProjection(root)) {
156
156
  return tasks
157
157
  .filter((t) => t && t.title && ['review', 'done'].includes(String(t.status || '').toLowerCase()))
158
158
  .map((t) => String(t.title).trim())
@@ -297,8 +297,8 @@ function missionShortLabel(mission) {
297
297
  return shortRecordLabel(mission, missionVisibleTitle(mission), { wordLimit: 3 });
298
298
  }
299
299
 
300
- function readActionableMissionCards(root) {
301
- return Array.from(latestMissionRecords(root).values())
300
+ function readActionableMissionCards(root, missions = latestMissionRecords(root)) {
301
+ return Array.from(missions.values())
302
302
  .filter((mission) => {
303
303
  const status = String(mission?.status || '').toLowerCase();
304
304
  if (['complete', 'completed', 'stopped', 'paused'].includes(status)) return false;
@@ -324,8 +324,8 @@ function readActionableMissionCards(root) {
324
324
  });
325
325
  }
326
326
 
327
- function readActiveMissions(root) {
328
- return Array.from(latestMissionRecords(root).values())
327
+ function readActiveMissions(root, missions = latestMissionRecords(root)) {
328
+ return Array.from(missions.values())
329
329
  .filter((m) => m && missionVisibleTitle(m) && !['complete', 'stopped', 'paused'].includes(String(m.status || '').toLowerCase()))
330
330
  .map((m) => ({
331
331
  title: missionVisibleTitle(m),
@@ -397,9 +397,8 @@ function wishHasReview(wish) {
397
397
  return Boolean(wish && (wish.reviewed || (Array.isArray(wish.reviews) && wish.reviews.length)));
398
398
  }
399
399
 
400
- function readUnreviewedShippedWishCards(root) {
400
+ function readUnreviewedShippedWishCards(root, missions = latestMissionRecords(root)) {
401
401
  const latest = latestWishEventMap(root);
402
- const missions = latestMissionRecords(root);
403
402
  return readWishes(root)
404
403
  .filter((wish) => {
405
404
  if (!wish || !wish.id || wishHasReview(wish)) return false;
@@ -430,12 +429,7 @@ function readUnreviewedShippedWishCards(root) {
430
429
  });
431
430
  }
432
431
 
433
- function activeEndgameTaskCount(root) {
434
- const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
435
- if (!text) return 0;
436
- let proj;
437
- try { proj = JSON.parse(text); } catch { return 0; }
438
- const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
432
+ function activeEndgameTaskCount(root, tasks = readTasksProjection(root)) {
439
433
  return tasks.filter((t) => {
440
434
  if (!t) return false;
441
435
  const status = String(t.status || '').toLowerCase();
@@ -444,10 +438,10 @@ function activeEndgameTaskCount(root) {
444
438
  }).length;
445
439
  }
446
440
 
447
- function todoKeepsEndgameActive(root) {
441
+ function todoKeepsEndgameActive(root, tasks = readTasksProjection(root)) {
448
442
  const text = safeRead(path.join(root, 'atris', 'TODO.md'));
449
443
  if (!text) return true;
450
- if (activeEndgameTaskCount(root) > 0) return true;
444
+ if (activeEndgameTaskCount(root, tasks) > 0) return true;
451
445
 
452
446
  let sawEndgameHeader = false;
453
447
  let section = '';
@@ -464,8 +458,8 @@ function todoKeepsEndgameActive(root) {
464
458
  return !sawEndgameHeader;
465
459
  }
466
460
 
467
- function readEndgameMove(root) {
468
- if (!todoKeepsEndgameActive(root)) return [];
461
+ function readEndgameMove(root, tasks = readTasksProjection(root)) {
462
+ if (!todoKeepsEndgameActive(root, tasks)) return [];
469
463
  let state = null;
470
464
  try { state = JSON.parse(safeRead(path.join(root, 'atris', 'brain', 'state.json'))); } catch { state = null; }
471
465
  const horizon = String(state?.endgame?.horizon || '').trim();
@@ -508,17 +502,21 @@ function todayInboxItems(root) {
508
502
  return inboxItemsFrom(safeRead(file));
509
503
  }
510
504
 
511
- function gatherCandidates(root = process.cwd()) {
505
+ // Parse each shared big file (tasks projection, missions log) exactly once per
506
+ // call and thread the result to every reader that needs it.
507
+ function gatherCandidates(root = process.cwd(), shared = {}) {
508
+ const tasks = shared.tasks || readTasksProjection(root);
509
+ const missions = shared.missions || latestMissionRecords(root);
512
510
  return [
513
511
  ...readWaitingWishCards(root),
514
- ...readActionableMissionCards(root),
512
+ ...readActionableMissionCards(root, missions),
515
513
  ...readDreamCards(root),
516
514
  ...readRoadmapOpenItems(root),
517
- ...readActiveMissions(root),
518
- ...readEndgameMove(root),
519
- ...readActiveTasks(root),
515
+ ...readActiveMissions(root, missions),
516
+ ...readEndgameMove(root, tasks),
517
+ ...readActiveTasks(root, tasks),
520
518
  ...latestInboxItems(root),
521
- ...readUnreviewedShippedWishCards(root),
519
+ ...readUnreviewedShippedWishCards(root, missions),
522
520
  ];
523
521
  }
524
522
 
@@ -595,10 +593,11 @@ function parkNextCard(root, card, stamp) {
595
593
 
596
594
  function nextCards(root = process.cwd(), limit = 1, options = {}) {
597
595
  const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
598
- const handledTaskTitles = readHandledTaskTitles(root);
596
+ const tasks = readTasksProjection(root);
597
+ const handledTaskTitles = readHandledTaskTitles(root, tasks);
599
598
  const parkedIds = options.includeParked ? [] : parkedNextIds(root);
600
599
  const skipIds = Array.isArray(options.skipIds) ? options.skipIds : [];
601
- return pickNextMoves(gatherCandidates(root), {
600
+ return pickNextMoves(gatherCandidates(root, { tasks }), {
602
601
  limit,
603
602
  killedIds: [...killedIds, ...parkedIds, ...skipIds],
604
603
  killedTitles,
@@ -836,8 +835,9 @@ function pickRoadmapSeed(root = process.cwd()) {
836
835
  // so the ranking inputs live in one place.
837
836
  function nextMoves(root = process.cwd(), limit = 3) {
838
837
  const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
839
- const handledTaskTitles = readHandledTaskTitles(root);
840
- const picked = pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
838
+ const tasks = readTasksProjection(root);
839
+ const handledTaskTitles = readHandledTaskTitles(root, tasks);
840
+ const picked = pickNextMoves(gatherCandidates(root, { tasks }), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
841
841
  return addMissionOnlyFallback(picked, limit, root);
842
842
  }
843
843
 
@@ -119,7 +119,15 @@ function addGrant({ command, workspaceRoot, createdVia = 'cli', expiresInDays =
119
119
 
120
120
  function revokeGrant(grantId, file) {
121
121
  const store = loadGrants(file);
122
- const grant = store.grants.find(g => g.grant_id === grantId || String(g.grant_id).startsWith(String(grantId)));
122
+ const ref = String(grantId || '');
123
+ let grant = store.grants.find(g => g.grant_id === ref);
124
+ if (!grant && ref) {
125
+ // Prefix refs are a convenience, but an ambiguous prefix must never
126
+ // revoke an arbitrary grant — require exactly one match.
127
+ const matches = store.grants.filter(g => String(g.grant_id).startsWith(ref));
128
+ if (matches.length > 1) return { ok: false, reason: `"${ref}" matches ${matches.length} grants; use more characters` };
129
+ grant = matches[0];
130
+ }
123
131
  if (!grant) return { ok: false, reason: 'grant not found' };
124
132
  grant.status = 'revoked';
125
133
  grant.sync = { ...(grant.sync || {}), revoked_at: new Date().toISOString() };
@@ -283,5 +291,6 @@ module.exports = {
283
291
  revokeGrant,
284
292
  matchGrant,
285
293
  recordUse,
294
+ commandIsGrantable,
286
295
  syncGrants,
287
296
  };
@@ -67,10 +67,10 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
67
67
  model: 'grok-4.6',
68
68
  commandTemplate: '{bin} --always-approve -p {prompt}',
69
69
  }),
70
- droid: Object.freeze({
71
- bin: 'droid',
72
- model: '',
73
- commandTemplate: '{bin} exec {prompt}',
70
+ agy: Object.freeze({
71
+ bin: 'agy',
72
+ model: 'gemini-3.7-flash-high',
73
+ commandTemplate: '{bin} --mode accept-edits {modelFlag} -p {prompt}',
74
74
  }),
75
75
  });
76
76
 
@@ -80,6 +80,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
80
80
  const RUNNER_PROFILE_ALIASES = Object.freeze({
81
81
  'atris2-fast': 'atris-fast',
82
82
  'atris-2-fast': 'atris-fast',
83
+ antigravity: 'agy',
83
84
  });
84
85
 
85
86
  // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
package/lib/task-db.js CHANGED
@@ -1965,11 +1965,17 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
1965
1965
  return {
1966
1966
  visibleRows: rows,
1967
1967
  hiddenDoneCount: 0,
1968
+ hiddenArchivedCount: 0,
1968
1969
  };
1969
1970
  }
1970
1971
  const visibleRows = [];
1971
1972
  let shownDone = 0;
1972
1973
  let hiddenDoneCount = 0;
1974
+ // Archived rows get the same recency cap as done rows. Without it the
1975
+ // projection carries every archived task forever (full events/messages) and
1976
+ // the file balloons; `--history` or a taskId still returns everything.
1977
+ let shownArchived = 0;
1978
+ let hiddenArchivedCount = 0;
1973
1979
  for (const row of rows) {
1974
1980
  if (row.status === 'done') {
1975
1981
  if (shownDone < doneLimit) {
@@ -1980,9 +1986,18 @@ function selectProjectionRows(rows, { taskId, includeHistory, doneLimit }) {
1980
1986
  }
1981
1987
  continue;
1982
1988
  }
1989
+ if (row.status === 'archived') {
1990
+ if (shownArchived < doneLimit) {
1991
+ visibleRows.push(row);
1992
+ shownArchived += 1;
1993
+ } else {
1994
+ hiddenArchivedCount += 1;
1995
+ }
1996
+ continue;
1997
+ }
1983
1998
  visibleRows.push(row);
1984
1999
  }
1985
- return { visibleRows, hiddenDoneCount };
2000
+ return { visibleRows, hiddenDoneCount, hiddenArchivedCount };
1986
2001
  }
1987
2002
 
1988
2003
  function taskProjection(db, {
@@ -2004,7 +2019,7 @@ function taskProjection(db, {
2004
2019
  display_id: row.display_id,
2005
2020
  legacy_ref: row.legacy_ref,
2006
2021
  }]));
2007
- const { visibleRows, hiddenDoneCount } = selectProjectionRows(rows, {
2022
+ const { visibleRows, hiddenDoneCount, hiddenArchivedCount } = selectProjectionRows(rows, {
2008
2023
  taskId,
2009
2024
  includeHistory,
2010
2025
  doneLimit: Math.max(0, Number(doneLimit) || 0),
@@ -2028,6 +2043,7 @@ function taskProjection(db, {
2028
2043
  full_task_count: rows.length,
2029
2044
  visible_task_count: visibleRows.length,
2030
2045
  hidden_done_count: hiddenDoneCount,
2046
+ hidden_archived_count: hiddenArchivedCount,
2031
2047
  done_limit: includeHistory ? null : Math.max(0, Number(doneLimit) || 0),
2032
2048
  event_limit: includeHistory ? null : Math.max(0, Number(eventLimit) || 0),
2033
2049
  message_limit: includeHistory ? null : Math.max(0, Number(messageLimit) || 0),
package/lib/task-proof.js CHANGED
@@ -80,7 +80,11 @@ function taskProofLooksExecuted(proof) {
80
80
  function tailText(text, max = 400) {
81
81
  const trimmed = String(text || '').trim();
82
82
  if (trimmed.length <= max) return trimmed;
83
- return `...${trimmed.slice(-max)}`;
83
+ // Keep the head as well as the tail: failures name their cause in the first
84
+ // lines (missing dependency, bad import), which a tail-only cut always loses.
85
+ const head = Math.floor(max / 2);
86
+ const tail = max - head;
87
+ return `${trimmed.slice(0, head)}\n...\n${trimmed.slice(-tail)}`;
84
88
  }
85
89
 
86
90
  // Run a verifier command and turn its real result into proof. This is the
@@ -112,6 +116,7 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
112
116
  }
113
117
 
114
118
  module.exports = {
119
+ tailText,
115
120
  taskProofLooksMeaningful,
116
121
  taskProofLooksExecuted,
117
122
  taskProofState,
@@ -14,7 +14,11 @@ const { spawnSync } = require('child_process');
14
14
  function tailText(text, max = 400) {
15
15
  const trimmed = String(text || '').trim();
16
16
  if (trimmed.length <= max) return trimmed;
17
- return `...${trimmed.slice(-max)}`;
17
+ // Keep the head as well as the tail: failures name their cause in the first
18
+ // lines (missing dependency, bad import), which a tail-only cut always loses.
19
+ const head = Math.floor(max / 2);
20
+ const tail = max - head;
21
+ return `${trimmed.slice(0, head)}\n...\n${trimmed.slice(-tail)}`;
18
22
  }
19
23
 
20
24
  function gitCommitHash(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.52.0",
3
+ "version": "3.53.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "cli/*.py",
14
14
  "commands/",
15
15
  "decks/",
16
+ "scripts/outbound-artifact-gate.js",
16
17
  "scripts/agent_worktree.py",
17
18
  "scripts/det/",
18
19
  "utils/",