atris 3.57.3 → 3.57.4

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.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: engines
3
- description: "Dispatch work to an installed terminal agent or named Atris engine profile. Supports Atris Fast, Claude, Codex, Cursor, Fable, Composer, Haiku, Devin, Grok, and Antigravity (agy). Triggers on: use codex, use cursor, use devin, use grok, use agy, use antigravity, use fable, use claude, use atris, engine, dispatch to, worker agent, second opinion build."
4
- version: 1.4.1
3
+ description: "Dispatch work to an installed terminal agent or named Atris engine profile. Supports Atris Fast, Claude, Codex, Cursor, Fable, Composer, Haiku, Devin, Grok, and Antigravity (agy). Triggers on: use codex, use cursor, use devin, use grok, use agy, use antigravity, use gemini, gemini session, use fable, use claude, use atris, engine, dispatch to, worker agent, second opinion build."
4
+ version: 1.4.2
5
5
  tags:
6
6
  - engines
7
7
  - claude
@@ -14,6 +14,7 @@ tags:
14
14
  - grok
15
15
  - agy
16
16
  - antigravity
17
+ - gemini
17
18
  - atris
18
19
  - orchestration
19
20
  ---
@@ -50,7 +51,7 @@ Raw spawns are not the default because they skip Atris receipts, watch, and coac
50
51
  | Haiku | `claude -p "<prompt>" --model claude-haiku-4-5` | Fast validation and bounded read-only checks. |
51
52
  | Devin | `devin -p --permission-mode dangerous -- "<prompt>"` (run from the target repo) | Default permission mode is read-only for writes — build work NEEDS `--permission-mode dangerous`, so only run it in an isolated worktree. Also `devin cloud` for sessions that outlive this machine. Supports `--model swe-1.7` |
52
53
  | Grok | `grok --always-approve -p "<prompt>"` (run from the target repo) | Headless single-turn via `-p`; default model grok-4.6. Very fast on lookups (~5-10s, reads MAP first). Great for quick second opinions; use `--best-of-n <N>` for tricky bounded builds. Uses grok.com login |
53
- | Antigravity | `agy --mode accept-edits -p "<prompt>"` | `agy` executor profile. Use `--mode plan --sandbox` for read-only review and `--model <id>` to pin a model. |
54
+ | Antigravity | `agy --mode accept-edits --add-dir "$PWD" -p "<prompt>"` (run from the target repo) | `agy` executor profile; also answers to "gemini". **`--add-dir` is mandatory for writes** — without it agy edits its own scratch folder (`~/.gemini/antigravity-cli/scratch/`) and the project never changes, which looks like a silent failure (verified live 2026-08-28). Use `--mode plan --sandbox` for read-only review, `--model <id>` to pin a model, and `--dangerously-skip-permissions` if a build still stalls on an approval prompt. |
54
55
 
55
56
  Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `cursor-agent` are allowlisted in `~/.claude/settings.json` so fresh and one-shot sessions can dispatch without a human approval click. A cold session that gets "requires approval" on an engine command means that allowlist regressed.
56
57
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: x-search
3
3
  description: "X/Twitter search via xAI Grok API. Use when user wants to search tweets, monitor topics, find viral posts, or run social listening. Costs 5 credits per search. Triggers on x search, tweet search, twitter search, social listening, revenue intel, viral tweets."
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  tags:
6
6
  - x-search
7
7
  - social
@@ -21,15 +21,19 @@ login
21
21
  |
22
22
  x-search QUERY (5 credits)
23
23
  |
24
- write one Apply (claimable)
24
+ stdout only unless --save
25
+ |
26
+ rich ephemeral --> one apply next-step, no files
27
+ --save + rich --> brief + pack + one Apply
28
+ --save + thin --> refuse, no files, exit 2
25
29
  |
26
30
  stop
27
31
  ```
28
32
 
29
33
  1. Login: stored login, then `atris login --agent` if needed. Never paste tokens. Never `/auth/cli`.
30
- 2. Search: `atris x-search "QUERY"`. Always billed (5 credits). Empty or failed search refunds.
31
- 3. One Apply: name a product change plus a receipt that can fail. If missing, the CLI writes a fill-this stub and a next-line. Search still exits 0.
32
- 4. Stop. Empty or failed search does not owe an Apply.
34
+ 2. Search: `atris x-search "QUERY"`. Always billed (5 credits). Prints to stdout. Rich ephemeral prints one apply next-step and writes nothing. Empty or failed search refunds and writes nothing.
35
+ 3. `--save` only when you want a brief. Rich results (a number-with-units or a named mechanism) file the brief, mint `atris/experiments/x-search-<slug>/`, and write one Apply that names that pack and the keep rule. Thin `--save` prints `thin: no number or named mechanism. no brief.` and writes nothing.
36
+ 4. Stop. Thin, empty, or failed search does not print the ephemeral apply next-step.
33
37
 
34
38
  ## Customer path (preferred)
35
39
 
@@ -38,6 +42,7 @@ Logged-in customers should use the CLI. Same auth and billing as `atris youtube
38
42
  ```bash
39
43
  atris x-search "MCP agents"
40
44
  atris x-search "MCP agents" --limit 5 --days 2
45
+ atris x-search "MCP agents" --save
41
46
  atris x-search person --name "Leah Bonvissuto" --handle leahbon
42
47
  atris x-search --help
43
48
  ```
@@ -245,6 +250,7 @@ npm install -g atris && atris login
245
250
 
246
251
  # Preferred: CLI
247
252
  atris x-search "AI agents" --limit 10 --days 7
253
+ atris x-search "AI agents" --save
248
254
  atris x-search person --name "John Doe" --handle johndoe --company Acme
249
255
 
250
256
  # Raw API (debug)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: youtube
3
3
  description: "YouTube discovery and learning. Get watch permalinks with atris youtube search QUERY (free, local ytsearch/yt-dlp). On 429 the CLI already retries; use cached rows if printed, else STOP. Never run --paid after a 429. --paid only when the user explicitly asked to buy permalinks (5 credits). After a URL is picked, atris youtube notes URL (free, ephemeral unless --save). atris youtube process only to store knowledge (5 credits). Never paste tokens. Never /auth/cli. Mint with atris login --agent from a stored login. Never summarize a video from model memory. Triggers on: youtube search, find videos, paid youtube search, any youtube.com or youtu.be link, youtube, video, watch this, notes on this."
4
- version: 2.11.0
4
+ version: 2.12.0
5
5
  tags:
6
6
  - youtube
7
7
  - research
@@ -24,8 +24,10 @@ search QUERY (free)
24
24
  do not run --paid
25
25
  |
26
26
  pick a URL --> notes URL (free, ephemeral unless --save)
27
- | --save files the brief + apply stub
27
+ | rich ephemeral prints one apply next-step, no files
28
+ | --save files brief + pack-named apply when notes have a number or named mechanism; thin --save refuses
28
29
  | or teach URL [--section N] (one chapter: claim numbers, named mechanisms, one check; free unless --save)
30
+ | next --section refuses until recap/skip
29
31
  |
30
32
  write one Apply (claimable) before process
31
33
  |
@@ -39,8 +41,8 @@ never paste tokens, never /auth/cli
39
41
  1. Get watch permalinks: `atris youtube search QUERY` (free).
40
42
  2. If 429: wait/retry is already in the CLI. If it prints cached rows, use those. If it prints `youtube rate-limited local search. do not use --paid as a fallback; retry later.`, STOP. Do not run `--paid`.
41
43
  3. `--paid` only when the user explicitly asked to buy permalinks. The CLI hard-refuses `--paid` when the free cache still has a fresh same-query hit.
42
- 4. `atris youtube notes URL` after a URL is picked (free). Notes is ephemeral unless `--save`.
43
- 5. Write one Apply (change + receipt) before `atris youtube process`. Process still requires a filled Apply (so you `--save`, fill Apply, then process).
44
+ 4. `atris youtube notes URL` after a URL is picked (free). Notes is ephemeral unless `--save`. Rich ephemeral prints one apply next-step and writes no files. Rich `--save` files the brief and mints `atris/experiments/notes-<id>/`; thin `--save` (no number-with-units and no named mechanism) refuses with no brief and exit 2. Do not auto `--paid`.
45
+ 5. Write one Apply (change + receipt) before `atris youtube process`. Process still requires a filled Apply (so you `--save` a rich brief, fill Apply, then process).
44
46
  6. Never paste tokens. Never `/auth/cli`. Mint with `atris login --agent` from a stored login.
45
47
 
46
48
  If the user says "find videos", "search youtube", or "get youtube links" → search. If they say "learn from", "notes on", "alpha", or "rabbit hole" → notes. If they say "process", "store", "add to knowledge" → process. "Buy permalinks" or "paid search" is the only ask that unlocks `--paid`.
package/ax CHANGED
@@ -4020,7 +4020,10 @@ async function runSelfTest(options = {}) {
4020
4020
  try {
4021
4021
  await chat({
4022
4022
  mode: 'fast',
4023
- cwd: os.tmpdir(),
4023
+ // A stray .atris under os.tmpdir() (left by a concurrent run) makes the
4024
+ // walk-up detector treat temp as a workspace, flipping this offline
4025
+ // check into local mode. Pin a path with no workspace ancestor.
4026
+ cwd: '/nonexistent/ax-non-workspace',
4024
4027
  input: Readable.from(['hi\n', 'exit\n']),
4025
4028
  output: chatOutput,
4026
4029
  runtimeHealth: async () => {
package/bin/atris.js CHANGED
@@ -583,7 +583,7 @@ function showHelpAll() {
583
583
  console.log(' revisions - show possible human revisions after atris-assisted landings');
584
584
  console.log(' search - Search workspace memory (atris search <keyword>)');
585
585
  console.log(' clean - Housekeeping (stale tasks, archive journals, broken refs)');
586
- console.log(' close - track open loops with deadlines and daily escalation');
586
+ console.log(' close - keep ten active loops and park the rest with their history');
587
587
  console.log(' harvest - Find bugs and next actions from receipts, run logs, and thinking');
588
588
  console.log(' verify - Validate work is done (tests, MAP.md, changes)');
589
589
  console.log(' slop - Deterministic slop detector: frontend/prose tells, dead code (slop dead --exports)');
package/commands/close.js CHANGED
@@ -23,6 +23,7 @@ const FAILED_TASK_BATCH_PREFIX = 'tasks:failed:';
23
23
  const PROBE_TIMEOUT_MS = 60 * 1000;
24
24
  const PROBE_HOLD_GAP_MS = 60 * 60 * 1000;
25
25
  const PROBE_STOP_AFTER_FAILED_CHECKS = 2;
26
+ const MAX_OPEN_FLAGS = 10;
26
27
 
27
28
  function ledgerPath(cwd = process.cwd()) {
28
29
  return path.join(cwd, LEDGER_RELATIVE_PATH);
@@ -144,6 +145,15 @@ function foldEvents(events, options = {}) {
144
145
  flag.why = event.why || '';
145
146
  } else if (event.kind === 'snoozed') {
146
147
  flag.snoozed_until = event.until || null;
148
+ } else if (event.kind === 'parked') {
149
+ flag.status = 'parked';
150
+ flag.parked_at = event.parked_at || eventTime(event);
151
+ flag.why = normalizeSpaces(event.why || 'parked by request');
152
+ } else if (event.kind === 'reopened') {
153
+ flag.status = 'open';
154
+ flag.reopened_at = eventTime(event);
155
+ delete flag.parked_at;
156
+ delete flag.why;
147
157
  } else if (event.kind === 'escalated') {
148
158
  const day = event.day || (event.at ? String(event.at).slice(0, 10) : null);
149
159
  if (day && !flag.escalated_days.includes(day)) flag.escalated_days.push(day);
@@ -223,6 +233,12 @@ function openFlags(cwd = process.cwd(), options = {}) {
223
233
  return foldEvents(readEvents(cwd), options).filter((flag) => flag.status === 'open');
224
234
  }
225
235
 
236
+ function parkedFlags(cwd = process.cwd(), options = {}) {
237
+ return foldEvents(readEvents(cwd), options)
238
+ .filter((flag) => flag.status === 'parked')
239
+ .sort((a, b) => (parseDate(b.parked_at) || 0) - (parseDate(a.parked_at) || 0));
240
+ }
241
+
226
242
  function ownerWaitRank(flag) {
227
243
  return /^(you|me|operator|human|keshav)$/.test(String(flag.owner || '').toLowerCase()) ? 0 : 1;
228
244
  }
@@ -264,6 +280,16 @@ function listLine(flag) {
264
280
  return parts.join(', ');
265
281
  }
266
282
 
283
+ function parkedLine(flag) {
284
+ const what = trimSentenceEnd(flag.what.toLowerCase());
285
+ const day = flag.parked_at ? String(flag.parked_at).slice(0, 10) : 'unknown date';
286
+ return `${what}, parked ${day}, ${trimSentenceEnd(flag.why || 'parked by request').toLowerCase()}`;
287
+ }
288
+
289
+ function migratedParkLine(flag) {
290
+ return `parked ${trimSentenceEnd(flag.what).toLowerCase()}: ${trimSentenceEnd(flag.why).toLowerCase()}.`;
291
+ }
292
+
267
293
  function sweepLine(flag) {
268
294
  const what = trimSentenceEnd(flag.what.toLowerCase());
269
295
  const closeCondition = trimSentenceEnd(flag.close_condition.toLowerCase());
@@ -325,9 +351,12 @@ function publicFlag(flag) {
325
351
  }
326
352
 
327
353
  function printHelp() {
328
- console.log('usage: atris close <add|list|done|dissolve|snooze|sweep|scan|check>');
329
- console.log('add "<what>" [--owner x] [--lane life|business|code] [--ttl 7] [--when "<condition>"] [--source y] [--probe "<cmd that fails now>"]');
354
+ console.log('usage: atris close <add|list|park|parked|reopen|done|dissolve|snooze|sweep|scan|check>');
355
+ console.log('add "<what>" [--owner x] [--lane life|business|code] [--ttl 7] [--when "<condition>"] [--source y] [--probe "<cmd that fails now>"] [--replace <id>]');
330
356
  console.log('list [--json] [--lane x]');
357
+ console.log('park <id> [--why "..."]');
358
+ console.log('parked [--json]');
359
+ console.log('reopen <id> [--replace <id>]');
331
360
  console.log('done <id> [--proof "..."] (a probed incident closes only after its probe holds)');
332
361
  console.log('check [id] [--json] (run live probes; failing probe = old theory still live)');
333
362
  console.log('dissolve <id> --why "..."');
@@ -336,6 +365,58 @@ function printHelp() {
336
365
  console.log('scan [--json]');
337
366
  }
338
367
 
368
+ function appendParkedEvent(flag, cwd, now, why, options = {}) {
369
+ const at = now.toISOString();
370
+ const event = {
371
+ kind: 'parked',
372
+ at,
373
+ id: flag.id,
374
+ parked_at: at,
375
+ why: normalizeSpaces(why || 'parked by request'),
376
+ };
377
+ if (options.automatic) event.automatic = true;
378
+ appendEvent(event, cwd);
379
+ return {
380
+ id: flag.id,
381
+ what: flag.what,
382
+ parked_at: at,
383
+ why: event.why,
384
+ };
385
+ }
386
+
387
+ function slotCapError(flags) {
388
+ const fullest = flags
389
+ .slice()
390
+ .sort(compareOpenFlags)
391
+ .slice(0, 3)
392
+ .map((flag) => trimSentenceEnd(flag.what).toLowerCase())
393
+ .join(', ');
394
+ const names = fullest ? ` the fullest loops are ${fullest}.` : '';
395
+ return new Error(`ten open loops is the cap.${names} park one first or use --replace <id>`);
396
+ }
397
+
398
+ function makeRoomForOpenFlag(cwd, now, replaceId, incomingWhat) {
399
+ const flags = openFlags(cwd, { now });
400
+ const hasReplacement = replaceId !== undefined;
401
+ if (hasReplacement && (replaceId === true || !normalizeSpaces(replaceId))) {
402
+ throw new Error('--replace needs an id');
403
+ }
404
+ if (!hasReplacement) {
405
+ if (flags.length >= MAX_OPEN_FLAGS) throw slotCapError(flags);
406
+ return null;
407
+ }
408
+ if (flags.length > MAX_OPEN_FLAGS) throw slotCapError(flags);
409
+
410
+ const replacement = flags.find((flag) => flag.id === replaceId);
411
+ if (!replacement) throw new Error(`${replaceId} is not open`);
412
+ return appendParkedEvent(
413
+ replacement,
414
+ cwd,
415
+ now,
416
+ `replaced to make room for ${trimSentenceEnd(incomingWhat).toLowerCase()}`
417
+ );
418
+ }
419
+
339
420
  function commandAdd(args, context = {}) {
340
421
  const { positional, options } = parseArgs(args);
341
422
  const what = normalizeSpaces(positional.join(' '));
@@ -366,6 +447,8 @@ function commandAdd(args, context = {}) {
366
447
  probeExit = result.exit_code;
367
448
  }
368
449
 
450
+ makeRoomForOpenFlag(context.cwd, now, options.replace, what);
451
+
369
452
  const openedAt = now.toISOString();
370
453
  const event = {
371
454
  kind: 'opened',
@@ -461,21 +544,88 @@ function commandCheck(args, context = {}) {
461
544
  return anyFailing ? 1 : 0;
462
545
  }
463
546
 
547
+ function hasRunnableNextAction(flag) {
548
+ if (flag.probe) return true;
549
+ return /\b(accept|add|approve|book|build|call|check|choose|close|confirm|deploy|dissolve|email|fix|merge|pay|publish|reply|resolve|retry|review|run|send|ship|sign|test|update|verify|write)\b/.test(canonicalWhat(flag.what));
550
+ }
551
+
552
+ function hasMachineCheckableClose(flag) {
553
+ if (flag.probe) return true;
554
+ if (canonicalWhat(flag.close_condition).includes('source store resolves')) return true;
555
+ return /^(experiment|lesson|mission|pulse|task|usage|watch):/.test(canonicalWhat(flag.source));
556
+ }
557
+
558
+ function migrationReason(flag, duplicateIds) {
559
+ if (ownerWaitRank(flag) === 0) return 'waiting on a human';
560
+ if (!hasRunnableNextAction(flag)) return 'no runnable next action';
561
+ if (!hasMachineCheckableClose(flag)) return 'no machine-checkable close condition';
562
+ if (duplicateIds.has(flag.id)) return 'duplicates another open loop';
563
+ return '';
564
+ }
565
+
566
+ function compareOldestFlags(a, b) {
567
+ const aOpened = parseDate(a.opened_at) || 0;
568
+ const bOpened = parseDate(b.opened_at) || 0;
569
+ if (aOpened !== bOpened) return aOpened - bOpened;
570
+ return a.id.localeCompare(b.id);
571
+ }
572
+
573
+ function migrateOpenSlots(cwd = process.cwd(), now = new Date(), options = {}) {
574
+ const flags = openFlags(cwd, { now });
575
+ const excess = flags.length - MAX_OPEN_FLAGS;
576
+ const shouldWrite = options.dryRun !== true && options.write !== false && options.readOnly !== true;
577
+ if (excess <= 0 || !shouldWrite) return [];
578
+
579
+ const byWhat = new Map();
580
+ for (const flag of flags) {
581
+ const key = canonicalWhat(flag.what);
582
+ if (!byWhat.has(key)) byWhat.set(key, []);
583
+ byWhat.get(key).push(flag);
584
+ }
585
+ const duplicateIds = new Set(
586
+ Array.from(byWhat.values())
587
+ .filter((group) => group.length > 1)
588
+ .flat()
589
+ .map((flag) => flag.id)
590
+ );
591
+
592
+ const preferred = [];
593
+ const fallback = [];
594
+ for (const flag of flags.slice().sort(compareOldestFlags)) {
595
+ const why = migrationReason(flag, duplicateIds);
596
+ if (why) preferred.push({ flag, why });
597
+ else fallback.push({ flag, why: 'oldest loop above the active slot cap' });
598
+ }
599
+
600
+ return preferred.concat(fallback).slice(0, excess).map(({ flag, why }) => (
601
+ appendParkedEvent(flag, cwd, now, why, { automatic: true })
602
+ ));
603
+ }
604
+
464
605
  function commandList(args, context = {}) {
465
606
  const { options } = parseArgs(args);
466
607
  const lane = options.lane ? String(options.lane).toLowerCase() : null;
467
608
  if (lane && !VALID_LANES.has(lane)) throw new Error('lane must be business, life, or code');
468
609
 
610
+ const now = context.now ? new Date(context.now) : new Date();
611
+ const autoClosed = autoCloseResolvedTaskReviewFlags(context.cwd, now);
612
+ const autoParked = migrateOpenSlots(context.cwd, now);
469
613
  let flags = openFlags(context.cwd, { now: context.now });
470
614
  if (lane) flags = flags.filter((flag) => flag.lane === lane);
471
615
  flags.sort(compareOpenFlags);
472
616
 
473
617
  if (options.json) {
474
- printJson({ open: flags.map(publicFlag) });
618
+ const payload = { open: flags.map(publicFlag) };
619
+ if (autoClosed.length > 0) payload.auto_closed = autoClosed.map(publicTaskReviewClosed);
620
+ if (autoParked.length > 0) payload.auto_parked = autoParked;
621
+ printJson(payload);
475
622
  return 0;
476
623
  }
477
624
 
625
+ autoClosed.forEach((item) => console.log(taskReviewClosedLine(item)));
626
+ autoParked.forEach((flag) => console.log(migratedParkLine(flag)));
478
627
  if (flags.length === 0) {
628
+ if (autoClosed.length > 0) return 0;
479
629
  console.log('nothing open.');
480
630
  return 0;
481
631
  }
@@ -484,6 +634,50 @@ function commandList(args, context = {}) {
484
634
  return 0;
485
635
  }
486
636
 
637
+ function commandPark(args, context = {}) {
638
+ const { positional, options } = parseArgs(args);
639
+ const id = positional[0];
640
+ if (!id) throw new Error('id is required');
641
+ const flag = requireOpenFlag(id, context);
642
+ const now = context.now ? new Date(context.now) : new Date();
643
+ const why = options.why === true ? undefined : options.why;
644
+ appendParkedEvent(flag, context.cwd, now, why);
645
+ console.log(`parked ${id}`);
646
+ return 0;
647
+ }
648
+
649
+ function commandParked(args, context = {}) {
650
+ const { options } = parseArgs(args);
651
+ const flags = parkedFlags(context.cwd, { now: context.now });
652
+ if (options.json) {
653
+ printJson({ parked: flags.map(publicFlag) });
654
+ return 0;
655
+ }
656
+ if (flags.length === 0) {
657
+ console.log('nothing parked.');
658
+ return 0;
659
+ }
660
+ flags.forEach((flag) => console.log(parkedLine(flag)));
661
+ return 0;
662
+ }
663
+
664
+ function commandReopen(args, context = {}) {
665
+ const { positional, options } = parseArgs(args);
666
+ const id = positional[0];
667
+ if (!id) throw new Error('id is required');
668
+ const flag = parkedFlags(context.cwd, { now: context.now }).find((item) => item.id === id);
669
+ if (!flag) throw new Error(`${id} is not parked`);
670
+ const now = context.now ? new Date(context.now) : new Date();
671
+ makeRoomForOpenFlag(context.cwd, now, options.replace, flag.what);
672
+ appendEvent({
673
+ kind: 'reopened',
674
+ at: now.toISOString(),
675
+ id,
676
+ }, context.cwd);
677
+ console.log(`reopened ${id}`);
678
+ return 0;
679
+ }
680
+
487
681
  function requireOpenFlag(id, context = {}) {
488
682
  const flag = openFlags(context.cwd, { now: context.now }).find((item) => item.id === id);
489
683
  if (!flag) throw new Error(`${id} is not open`);
@@ -559,6 +753,8 @@ function commandSnooze(args, context = {}) {
559
753
  // one escalated event per overdue flag per day, return the resulting state.
560
754
  function sweepState(cwd = process.cwd(), now = new Date(), options = {}) {
561
755
  const today = isoDateOnly(now);
756
+ const autoClosed = autoCloseResolvedTaskReviewFlags(cwd, now);
757
+ const autoParked = migrateOpenSlots(cwd, now, options);
562
758
  let flags = openFlags(cwd, { now });
563
759
  const overdue = flags.filter((flag) => flag.overdue).sort(compareSweepFlags);
564
760
  const escalatedToday = [];
@@ -578,24 +774,39 @@ function sweepState(cwd = process.cwd(), now = new Date(), options = {}) {
578
774
  }
579
775
 
580
776
  flags = openFlags(cwd, { now });
581
- return { open: flags.length, overdue, escalated_today: escalatedToday };
777
+ const result = { open: flags.length, overdue, escalated_today: escalatedToday };
778
+ if (autoClosed.length > 0) result.auto_closed = autoClosed;
779
+ if (autoParked.length > 0) result.auto_parked = autoParked;
780
+ return result;
582
781
  }
583
782
 
584
783
  function commandSweep(args, context = {}) {
585
784
  const { options } = parseArgs(args);
586
785
  const now = context.now ? new Date(context.now) : new Date();
587
- const { open, overdue, escalated_today: escalatedToday } = sweepState(context.cwd, now);
786
+ const {
787
+ open,
788
+ overdue,
789
+ escalated_today: escalatedToday,
790
+ auto_closed: autoClosed = [],
791
+ auto_parked: autoParked = [],
792
+ } = sweepState(context.cwd, now);
588
793
 
589
794
  if (options.json) {
590
- printJson({
795
+ const payload = {
591
796
  open,
592
797
  overdue: overdue.length,
593
798
  escalated_today: escalatedToday,
594
- });
799
+ };
800
+ if (autoClosed.length > 0) payload.auto_closed = autoClosed.map(publicTaskReviewClosed);
801
+ if (autoParked.length > 0) payload.auto_parked = autoParked;
802
+ printJson(payload);
595
803
  return 0;
596
804
  }
597
805
 
806
+ autoClosed.forEach((item) => console.log(taskReviewClosedLine(item)));
807
+ autoParked.forEach((flag) => console.log(migratedParkLine(flag)));
598
808
  if (overdue.length === 0) {
809
+ if (autoClosed.length > 0) return 0;
599
810
  console.log('nothing is overdue.');
600
811
  return 0;
601
812
  }
@@ -830,7 +1041,11 @@ function taskSource(task) {
830
1041
 
831
1042
  function taskResolved(task) {
832
1043
  const status = normalizeSpaces(task && task.status).toLowerCase();
833
- return status === 'done' || status === 'accepted';
1044
+ const review = reviewObject(task);
1045
+ const metadata = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
1046
+ const approval = normalizeSpaces(review.approval_status || metadata.approval_status).toLowerCase();
1047
+ return ['done', 'accepted', 'closed'].includes(status)
1048
+ || ['accepted', 'approved', 'done', 'closed'].includes(approval);
834
1049
  }
835
1050
 
836
1051
  function reviewObject(task) {
@@ -938,7 +1153,15 @@ function shortText(text, maxLength = 70) {
938
1153
  return clipped || value.slice(0, maxLength).trim();
939
1154
  }
940
1155
 
941
- function appendOpenedFromSource({ what, owner = 'operator', lane = 'code', ttlDays = 3, source }, cwd, now) {
1156
+ function appendOpenedFromSource({
1157
+ what,
1158
+ owner = 'operator',
1159
+ lane = 'code',
1160
+ ttlDays = 3,
1161
+ source,
1162
+ probe,
1163
+ closeCondition = 'the source store resolves it',
1164
+ }, cwd, now) {
942
1165
  const event = {
943
1166
  kind: 'opened',
944
1167
  at: now.toISOString(),
@@ -948,25 +1171,79 @@ function appendOpenedFromSource({ what, owner = 'operator', lane = 'code', ttlDa
948
1171
  lane: VALID_LANES.has(lane) ? lane : 'code',
949
1172
  opened_at: now.toISOString(),
950
1173
  ttl_days: Number(ttlDays) || 3,
951
- close_condition: 'the source store resolves it',
1174
+ close_condition: normalizeSpaces(closeCondition) || 'the source store resolves it',
952
1175
  source,
953
1176
  };
1177
+ const normalizedProbe = normalizeSpaces(probe || '');
1178
+ if (normalizedProbe) event.probe = normalizedProbe;
954
1179
  appendEvent(event, cwd);
955
1180
  return event;
956
1181
  }
957
1182
 
958
- function appendClosedFromSource(flag, cwd, now, note) {
1183
+ function upsertSourceFlag(candidate, cwd = process.cwd(), options = {}) {
1184
+ const now = options.now ? new Date(options.now) : new Date();
1185
+ const source = normalizeSpaces(candidate && candidate.source);
1186
+ if (!source) throw new Error('source is required');
1187
+
1188
+ const flags = openFlags(cwd, { now });
1189
+ const existing = flags.find((flag) => flag.source === source);
1190
+ if (!existing && flags.length >= MAX_OPEN_FLAGS) {
1191
+ return { filed: false, refreshed: false, reason: 'full' };
1192
+ }
1193
+
1194
+ const event = appendOpenedFromSource({ ...candidate, source }, cwd, now);
1195
+ return { filed: true, refreshed: Boolean(existing), event };
1196
+ }
1197
+
1198
+ function appendClosedFromSource(flag, cwd, now, note, proof = RESOLVED_IN_SOURCE_PROOF) {
959
1199
  const event = {
960
1200
  kind: 'closed',
961
1201
  at: now.toISOString(),
962
1202
  id: flag.id,
963
- proof: RESOLVED_IN_SOURCE_PROOF,
1203
+ proof,
964
1204
  };
965
1205
  if (note) event.note = note;
966
1206
  appendEvent(event, cwd);
967
1207
  return event;
968
1208
  }
969
1209
 
1210
+ function taskReviewFlag(flag) {
1211
+ return flag && flag.source && flag.source.startsWith('task:')
1212
+ && canonicalWhat(flag.what).includes('has waited in review too long');
1213
+ }
1214
+
1215
+ function autoCloseResolvedTaskReviewFlags(cwd = process.cwd(), now = new Date()) {
1216
+ const tasksById = latestById(readTaskProjection(cwd));
1217
+ if (tasksById.size === 0) return [];
1218
+
1219
+ const closed = [];
1220
+ const flags = foldEvents(readEvents(cwd), { now })
1221
+ .filter((flag) => flag.status === 'open' || flag.status === 'parked')
1222
+ .filter(taskReviewFlag);
1223
+ for (const flag of flags) {
1224
+ const watchedTaskId = flag.source.slice('task:'.length);
1225
+ const task = tasksById.get(watchedTaskId);
1226
+ if (!task || !taskResolved(task)) continue;
1227
+ const ref = taskRef(task) || watchedTaskId;
1228
+ const proof = `task ${ref.toLowerCase()} is closed in task projection`;
1229
+ const event = appendClosedFromSource(flag, cwd, now, null, proof);
1230
+ closed.push({ flag, event, task_ref: ref });
1231
+ }
1232
+ return closed;
1233
+ }
1234
+
1235
+ function taskReviewClosedLine(item) {
1236
+ return `auto-closed review reminder for task ${item.task_ref.toLowerCase()}.`;
1237
+ }
1238
+
1239
+ function publicTaskReviewClosed(item) {
1240
+ return {
1241
+ id: item.event.id,
1242
+ task_ref: item.task_ref,
1243
+ proof: item.event.proof,
1244
+ };
1245
+ }
1246
+
970
1247
  function scanState(cwd = process.cwd(), options = {}) {
971
1248
  const now = options.now ? new Date(options.now) : new Date();
972
1249
  const tasks = readTaskProjection(cwd);
@@ -1256,6 +1533,9 @@ function run(args = [], context = {}) {
1256
1533
  }
1257
1534
  if (subcommand === 'add') return commandAdd(rest, context);
1258
1535
  if (subcommand === 'list') return commandList(rest, context);
1536
+ if (subcommand === 'park') return commandPark(rest, context);
1537
+ if (subcommand === 'parked') return commandParked(rest, context);
1538
+ if (subcommand === 'reopen') return commandReopen(rest, context);
1259
1539
  if (subcommand === 'done') return commandDone(rest, context);
1260
1540
  if (subcommand === 'dissolve') return commandDissolve(rest, context);
1261
1541
  if (subcommand === 'snooze') return commandSnooze(rest, context);
@@ -1277,6 +1557,8 @@ module.exports = {
1277
1557
  closeIdForWhat,
1278
1558
  foldEvents,
1279
1559
  openFlags,
1560
+ parkedFlags,
1561
+ upsertSourceFlag,
1280
1562
  ledgerPath,
1281
1563
  readEvents,
1282
1564
  sweepLine,
@@ -978,11 +978,6 @@ function loadMissionMap(root = process.cwd()) {
978
978
  return map;
979
979
  }
980
980
 
981
- function hasLocalMissionState(root = process.cwd()) {
982
- return readJsonLines(statePaths(root).missionsJsonl)
983
- .some((mission) => mission && mission.id && mission.cloud !== true);
984
- }
985
-
986
981
  const LIVE_MISSION_TICK_MS = 24 * 60 * 60 * 1000;
987
982
 
988
983
  function isLiveInFlightMission(mission, now = Date.now(), root = process.cwd()) {
@@ -1024,19 +1019,15 @@ function speakMissionDoor(args = [], root = resolveWorkspaceRoot()) {
1024
1019
  if (live) {
1025
1020
  return statusMission(asJson ? [live.id, '--json'] : [live.id]);
1026
1021
  }
1027
- if (isFreshWorkspace(root) || hasLocalMissionState(root)) {
1028
- const code = speakFirstMinute({
1029
- root,
1030
- fresh: isFreshWorkspace(root),
1031
- asJson,
1032
- });
1033
- process.exitCode = code;
1034
- return code;
1035
- }
1036
- return require('./human-missions').currentMissionCommand(args, {
1022
+ // Nothing running in this folder: same two spoken lines as bare atris.
1023
+ // A finished or account-global mission from another room is not the live card.
1024
+ const code = speakFirstMinute({
1037
1025
  root,
1038
- fallbackOnMissing: true,
1026
+ fresh: isFreshWorkspace(root),
1027
+ asJson,
1039
1028
  });
1029
+ process.exitCode = code;
1030
+ return code;
1040
1031
  }
1041
1032
 
1042
1033
  function terminalNextAction(status) {