@isaacriehm/cairn-core 0.13.2 → 0.14.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.
Files changed (109) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/attention/bulk-accept.js +19 -0
  3. package/dist/attention/bulk-accept.js.map +1 -1
  4. package/dist/gc/entity-orphan.d.ts +55 -0
  5. package/dist/gc/entity-orphan.js +171 -0
  6. package/dist/gc/entity-orphan.js.map +1 -0
  7. package/dist/gc/index.d.ts +4 -0
  8. package/dist/gc/index.js +2 -0
  9. package/dist/gc/index.js.map +1 -1
  10. package/dist/gc/retire.d.ts +57 -0
  11. package/dist/gc/retire.js +189 -0
  12. package/dist/gc/retire.js.map +1 -0
  13. package/dist/gc/sweep.js +11 -0
  14. package/dist/gc/sweep.js.map +1 -1
  15. package/dist/gc/types.d.ts +2 -2
  16. package/dist/hooks/ask-user-blocked.d.ts +8 -0
  17. package/dist/hooks/ask-user-blocked.js +13 -0
  18. package/dist/hooks/ask-user-blocked.js.map +1 -0
  19. package/dist/hooks/post-tool-use/ask-user-blocked.d.ts +18 -0
  20. package/dist/hooks/post-tool-use/ask-user-blocked.js +113 -0
  21. package/dist/hooks/post-tool-use/ask-user-blocked.js.map +1 -0
  22. package/dist/hooks/post-tool-use/index.d.ts +1 -0
  23. package/dist/hooks/post-tool-use/index.js +1 -0
  24. package/dist/hooks/post-tool-use/index.js.map +1 -1
  25. package/dist/hooks/runners/context-threshold.js +2 -2
  26. package/dist/hooks/runners/context-threshold.js.map +1 -1
  27. package/dist/hooks/runners/gc-autotrigger.d.ts +7 -4
  28. package/dist/hooks/runners/gc-autotrigger.js +7 -4
  29. package/dist/hooks/runners/gc-autotrigger.js.map +1 -1
  30. package/dist/hooks/runners/stop.js +260 -115
  31. package/dist/hooks/runners/stop.js.map +1 -1
  32. package/dist/init/ingest-docs.d.ts +2 -4
  33. package/dist/init/ingest-docs.js +2 -4
  34. package/dist/init/ingest-docs.js.map +1 -1
  35. package/dist/mcp/errors.d.ts +1 -1
  36. package/dist/mcp/errors.js.map +1 -1
  37. package/dist/mcp/index.d.ts +1 -0
  38. package/dist/mcp/index.js +1 -0
  39. package/dist/mcp/index.js.map +1 -1
  40. package/dist/mcp/schemas.d.ts +16 -94
  41. package/dist/mcp/schemas.js +41 -89
  42. package/dist/mcp/schemas.js.map +1 -1
  43. package/dist/mcp/tools/decision-get.js +9 -0
  44. package/dist/mcp/tools/decision-get.js.map +1 -1
  45. package/dist/mcp/tools/index.js +8 -29
  46. package/dist/mcp/tools/index.js.map +1 -1
  47. package/dist/mcp/tools/invariant-get.js +8 -0
  48. package/dist/mcp/tools/invariant-get.js.map +1 -1
  49. package/dist/mcp/tools/mission-advance.d.ts +15 -0
  50. package/dist/mcp/tools/mission-advance.js +36 -1
  51. package/dist/mcp/tools/mission-advance.js.map +1 -1
  52. package/dist/mcp/tools/record-decision.js +14 -1
  53. package/dist/mcp/tools/record-decision.js.map +1 -1
  54. package/dist/mcp/tools/retire-entity.d.ts +22 -0
  55. package/dist/mcp/tools/retire-entity.js +62 -0
  56. package/dist/mcp/tools/retire-entity.js.map +1 -0
  57. package/dist/mcp/tools/task-complete.js +28 -22
  58. package/dist/mcp/tools/task-complete.js.map +1 -1
  59. package/dist/mcp/tools/task-create.d.ts +0 -1
  60. package/dist/mcp/tools/task-create.js +41 -10
  61. package/dist/mcp/tools/task-create.js.map +1 -1
  62. package/dist/mcp/tools/task-journal-append.js +23 -2
  63. package/dist/mcp/tools/task-journal-append.js.map +1 -1
  64. package/dist/mcp/tools/task-reopen.d.ts +7 -0
  65. package/dist/mcp/tools/task-reopen.js +40 -0
  66. package/dist/mcp/tools/task-reopen.js.map +1 -0
  67. package/dist/tasks/index.d.ts +2 -2
  68. package/dist/tasks/index.js +1 -1
  69. package/dist/tasks/index.js.map +1 -1
  70. package/dist/tasks/lifecycle.d.ts +62 -19
  71. package/dist/tasks/lifecycle.js +147 -42
  72. package/dist/tasks/lifecycle.js.map +1 -1
  73. package/package.json +2 -2
  74. package/dist/mcp/tools/align-drain.d.ts +0 -7
  75. package/dist/mcp/tools/align-drain.js +0 -26
  76. package/dist/mcp/tools/align-drain.js.map +0 -1
  77. package/dist/mcp/tools/archive.d.ts +0 -8
  78. package/dist/mcp/tools/archive.js +0 -72
  79. package/dist/mcp/tools/archive.js.map +0 -1
  80. package/dist/mcp/tools/attention-restore.d.ts +0 -14
  81. package/dist/mcp/tools/attention-restore.js +0 -22
  82. package/dist/mcp/tools/attention-restore.js.map +0 -1
  83. package/dist/mcp/tools/decisions-for-symbol.d.ts +0 -7
  84. package/dist/mcp/tools/decisions-for-symbol.js +0 -42
  85. package/dist/mcp/tools/decisions-for-symbol.js.map +0 -1
  86. package/dist/mcp/tools/get-full.d.ts +0 -7
  87. package/dist/mcp/tools/get-full.js +0 -46
  88. package/dist/mcp/tools/get-full.js.map +0 -1
  89. package/dist/mcp/tools/ground-get.d.ts +0 -7
  90. package/dist/mcp/tools/ground-get.js +0 -77
  91. package/dist/mcp/tools/ground-get.js.map +0 -1
  92. package/dist/mcp/tools/mission-close.d.ts +0 -8
  93. package/dist/mcp/tools/mission-close.js +0 -48
  94. package/dist/mcp/tools/mission-close.js.map +0 -1
  95. package/dist/mcp/tools/mission-reopen.d.ts +0 -13
  96. package/dist/mcp/tools/mission-reopen.js +0 -56
  97. package/dist/mcp/tools/mission-reopen.js.map +0 -1
  98. package/dist/mcp/tools/reject-candidate.d.ts +0 -24
  99. package/dist/mcp/tools/reject-candidate.js +0 -71
  100. package/dist/mcp/tools/reject-candidate.js.map +0 -1
  101. package/dist/mcp/tools/search-candidates.d.ts +0 -20
  102. package/dist/mcp/tools/search-candidates.js +0 -93
  103. package/dist/mcp/tools/search-candidates.js.map +0 -1
  104. package/dist/mcp/tools/supersedes-chain.d.ts +0 -6
  105. package/dist/mcp/tools/supersedes-chain.js +0 -66
  106. package/dist/mcp/tools/supersedes-chain.js.map +0 -1
  107. package/dist/mcp/tools/timeline.d.ts +0 -9
  108. package/dist/mcp/tools/timeline.js +0 -61
  109. package/dist/mcp/tools/timeline.js.map +0 -1
@@ -13,7 +13,7 @@
13
13
  * When nothing: emits `{ continue: true }` and Claude stops normally.
14
14
  */
15
15
  import { createHash } from "node:crypto";
16
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
18
  import { readActiveTaskSummary } from "../../context/index.js";
19
19
  import { eventsSince, } from "../../events/index.js";
@@ -22,7 +22,7 @@ import { isDeferActive, readDeferState } from "../defer.js";
22
22
  import { resolveRepoRoot } from "../../session-start/index.js";
23
23
  import { readEventsMarker, stampEventsPoll, } from "../../session/index.js";
24
24
  import { writeStatusJson } from "../../status-line/index.js";
25
- import { completeTask, findCurrentActiveTask, readTaskAttestationState, transitionTaskPhase, } from "../../tasks/index.js";
25
+ import { completeTask, findCurrentActiveTask, readTaskAttestationState, } from "../../tasks/index.js";
26
26
  import { effectivePhaseExitGate, findActiveMission, readMissionState, readRoadmap, } from "@isaacriehm/cairn-state";
27
27
  import { checkContextThreshold, renderContextThresholdHint, } from "./context-threshold.js";
28
28
  import { writePhaseReadyPending, } from "./phase-ready-surface.js";
@@ -66,7 +66,7 @@ const MAX_REASON_CHARS = 4_000;
66
66
  * CC convention, not a failure signal. One short line keeps the chat
67
67
  * tidy without dropping the cue entirely.
68
68
  */
69
- const REASON_PREAMBLE = "↳ Cairn cuerender any choice via `AskUserQuestion`.\n\n";
69
+ const REASON_PREAMBLE = "↳ Cairn hintsurface to operator at a natural stopping point. Don't interrupt productive work; quote / `AskUserQuestion` only when there's no obvious continuation.\n\n";
70
70
  function clampReason(body) {
71
71
  if (body.length === 0)
72
72
  return body;
@@ -85,6 +85,96 @@ function clampReason(body) {
85
85
  * the window or whenever the payload hash changes.
86
86
  */
87
87
  const CUE_DEBOUNCE_WINDOW_MS = 60 * 60 * 1000;
88
+ /**
89
+ * Bug-mine 0.13.8 — Phase 5 stall-cue tuning.
90
+ *
91
+ * `SESSION_ACTIVITY_WINDOW_MS` — when the transcript records any
92
+ * `tool_use` within this window, the AI is actively working; suppress
93
+ * the stalled-task surface so the cue doesn't interrupt productive
94
+ * flow. Mining showed stall cues firing while an Agent subagent was
95
+ * mid-dispatch and a research run was committing.
96
+ *
97
+ * `SESSION_STALLED_CUE_WINDOW_MS` — at most one stalled cue per
98
+ * session per hour, total (not per-task). Per-task throttle
99
+ * (`STALLED_FIRE_WINDOW_MS`) remains as a floor on top.
100
+ */
101
+ const SESSION_ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
102
+ const SESSION_STALLED_CUE_WINDOW_MS = 60 * 60 * 1000;
103
+ /**
104
+ * Walk the transcript tail and return the millisecond age of the most
105
+ * recent `tool_use` entry whose timestamp parses, or `null` when no
106
+ * such entry is found. Best-effort — any read / parse failure returns
107
+ * `null` and the caller falls through to the threshold check.
108
+ *
109
+ * Claude Code transcript entries are JSONL; assistant messages with
110
+ * tool calls carry `type: "tool_use"` on a content block. We scan
111
+ * backwards from the tail and stop at the first matching line.
112
+ */
113
+ function lastToolUseAgeMs(transcriptPath, nowMs) {
114
+ if (transcriptPath === null || transcriptPath.length === 0)
115
+ return null;
116
+ if (!existsSync(transcriptPath))
117
+ return null;
118
+ let raw;
119
+ try {
120
+ raw = readFileSync(transcriptPath, "utf8");
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ const lines = raw.split("\n");
126
+ for (let i = lines.length - 1; i >= 0; i--) {
127
+ const line = lines[i];
128
+ if (line === undefined || line.length === 0)
129
+ continue;
130
+ if (!line.includes('"tool_use"'))
131
+ continue;
132
+ let entry;
133
+ try {
134
+ entry = JSON.parse(line);
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ if (entry === null || typeof entry !== "object")
140
+ continue;
141
+ const ts = entry["timestamp"]
142
+ ?? entry["ts"];
143
+ if (typeof ts !== "string")
144
+ continue;
145
+ const parsed = Date.parse(ts);
146
+ if (Number.isNaN(parsed))
147
+ continue;
148
+ return nowMs - parsed;
149
+ }
150
+ return null;
151
+ }
152
+ function lastSessionStalledCuePath(repoRoot, sessionId) {
153
+ return join(repoRoot, ".cairn", "sessions", sessionId, "last-stalled-cue.iso");
154
+ }
155
+ function lastSessionStalledCueMs(repoRoot, sessionId) {
156
+ const path = lastSessionStalledCuePath(repoRoot, sessionId);
157
+ if (!existsSync(path))
158
+ return null;
159
+ try {
160
+ const raw = readFileSync(path, "utf8").trim();
161
+ const ms = Date.parse(raw);
162
+ return Number.isNaN(ms) ? null : ms;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ function stampSessionStalledCue(repoRoot, sessionId) {
169
+ const path = lastSessionStalledCuePath(repoRoot, sessionId);
170
+ try {
171
+ mkdirSync(dirname(path), { recursive: true });
172
+ writeFileSync(path, new Date().toISOString(), "utf8");
173
+ }
174
+ catch {
175
+ // best-effort
176
+ }
177
+ }
88
178
  function priorCuePath(repoRoot, sessionId) {
89
179
  return join(repoRoot, ".cairn", "sessions", sessionId, "last-stop-cue.json");
90
180
  }
@@ -121,6 +211,7 @@ export async function runStopHook() {
121
211
  const payload = parseHookPayload(raw);
122
212
  const sessionId = typeof payload.session_id === "string" ? payload.session_id : null;
123
213
  const cwdInput = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
214
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : null;
124
215
  const repoRoot = resolveRepoRoot(cwdInput);
125
216
  const warnings = [];
126
217
  let drained = [];
@@ -176,9 +267,6 @@ export async function runStopHook() {
176
267
  const hint = `## Cairn — ${grad.completed.length} ${noun} graduated\n\n✓ ${ids} → done. Final attestation written.`;
177
268
  reason = reason.length > 0 ? `${reason}\n\n${hint}` : hint;
178
269
  }
179
- if (grad.transitioned.length > 0) {
180
- warnings.push(`auto_graduated_review_ready:${grad.transitioned.length}`);
181
- }
182
270
  }
183
271
  catch (err) {
184
272
  warnings.push(`auto_graduate_failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -204,42 +292,73 @@ export async function runStopHook() {
204
292
  warnings.push(`pending_review_scan_failed: ${err instanceof Error ? err.message : String(err)}`);
205
293
  }
206
294
  // Stalled-task scanner — surfaces tasks stuck in phase=running
207
- // with no attestation for 30min+. Catches the failure mode where
208
- // the autonomous flow finished the work but skipped spawning the
209
- // reviewer subagent (no attestation auto-graduator never fires
210
- // task accumulates as orphaned). Only fires when no other
211
- // higher-priority surface (reviewer hint, ctx threshold) already
212
- // owns the reason channel stalled-task triage is informational
213
- // catch-up, not blocking.
295
+ // with no attestation for 2h+ (raised from 30 min in bug-mine
296
+ // 0.13.8 / Phase 5 see CONTEXT.md §2.4 for false-fire mining).
297
+ // Catches the failure mode where the autonomous flow finished
298
+ // the work but skipped spawning the reviewer subagent (no
299
+ // attestation → auto-graduator never fires task accumulates
300
+ // as orphaned). Only fires when no other higher-priority surface
301
+ // (reviewer hint, ctx threshold) already owns the reason channel
302
+ // — stalled-task triage is informational catch-up, not blocking.
303
+ //
304
+ // Three additional gates layered on top of the per-task window:
214
305
  //
215
- // Per-task suppression window: once a stalled hint fires for a
216
- // given task id, suppress re-fires on the same id for 60 min so
217
- // the operator isn't asked the same triage question every Stop
218
- // tick (bug-mine report #9same task flagged in 90s).
306
+ // 1. Session-activity gate when the transcript carries a
307
+ // `tool_use` event within the last 5 minutes, the AI is
308
+ // actively working; suppress entirely.
309
+ // 2. Per-session rate limitat most one stalled cue per
310
+ // session per hour, total (not per-task). Bug-mine showed
311
+ // three active tasks idle = three prompts per hour without
312
+ // a global cap.
313
+ // 3. Per-task throttle — 60 minute suppression window per
314
+ // task id so the operator isn't asked the same triage
315
+ // question every Stop tick (bug-mine report #9 — same
316
+ // task flagged 3× in 90s).
219
317
  if (reason.length === 0 && !isFirstTurnWarmup) {
220
318
  try {
221
- const stalled = scanStalledRunningTasks(repoRoot);
222
- const surfaced = stalled.filter((t) => !isStalledFireSuppressed(repoRoot, t.task_id));
223
- if (surfaced.length > 0) {
224
- const reviewDefer = readDeferState(repoRoot, "review");
225
- const suppressed = reviewDefer !== null &&
226
- isDeferActive(reviewDefer, new Date(), {
227
- kind: "task_ids",
228
- values: surfaced.map((t) => t.task_id),
229
- });
230
- if (suppressed) {
231
- warnings.push(`stalled_suppressed_until:${reviewDefer.deferred_at}`);
319
+ gcStalledWarnedMarkers(repoRoot);
320
+ const lastSessionCue = sessionId !== null
321
+ ? lastSessionStalledCueMs(repoRoot, sessionId)
322
+ : null;
323
+ if (lastSessionCue !== null &&
324
+ Date.now() - lastSessionCue < SESSION_STALLED_CUE_WINDOW_MS) {
325
+ warnings.push(`stalled_session_rate_limited:${new Date(lastSessionCue).toISOString()}`);
326
+ }
327
+ else {
328
+ const recentToolUseAge = lastToolUseAgeMs(transcriptPath, Date.now());
329
+ const sessionActive = recentToolUseAge !== null && recentToolUseAge < SESSION_ACTIVITY_WINDOW_MS;
330
+ if (sessionActive) {
331
+ warnings.push(`stalled_session_active:${recentToolUseAge}`);
232
332
  }
233
333
  else {
234
- reason = renderStalledTasksHint(surfaced);
235
- for (const t of surfaced)
236
- stampStalledFire(repoRoot, t.task_id);
237
- warnings.push(`stalled_running_tasks:${surfaced.length}`);
334
+ const stalled = scanStalledRunningTasks(repoRoot, Date.now(), {
335
+ currentSessionId: sessionId,
336
+ });
337
+ const surfaced = stalled.filter((t) => !isStalledFireSuppressed(repoRoot, t.task_id));
338
+ if (surfaced.length > 0) {
339
+ const reviewDefer = readDeferState(repoRoot, "review");
340
+ const suppressed = reviewDefer !== null &&
341
+ isDeferActive(reviewDefer, new Date(), {
342
+ kind: "task_ids",
343
+ values: surfaced.map((t) => t.task_id),
344
+ });
345
+ if (suppressed) {
346
+ warnings.push(`stalled_suppressed_until:${reviewDefer.deferred_at}`);
347
+ }
348
+ else {
349
+ reason = renderStalledTasksHint(surfaced);
350
+ for (const t of surfaced)
351
+ stampStalledFire(repoRoot, t.task_id);
352
+ if (sessionId !== null)
353
+ stampSessionStalledCue(repoRoot, sessionId);
354
+ warnings.push(`stalled_running_tasks:${surfaced.length}`);
355
+ }
356
+ }
357
+ else if (stalled.length > 0) {
358
+ warnings.push(`stalled_window_suppressed:${stalled.length}`);
359
+ }
238
360
  }
239
361
  }
240
- else if (stalled.length > 0) {
241
- warnings.push(`stalled_window_suppressed:${stalled.length}`);
242
- }
243
362
  }
244
363
  catch (err) {
245
364
  warnings.push(`stalled_scan_failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -452,22 +571,6 @@ function readTaskPhase(taskDir) {
452
571
  }
453
572
  return null;
454
573
  }
455
- function checkNeedsReview(specPath) {
456
- try {
457
- const raw = readFileSync(specPath, "utf8");
458
- const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\n---/);
459
- if (!fmMatch)
460
- return true;
461
- const fm = fmMatch[1] ?? "";
462
- const m = fm.match(/^needs_review:\s*(true|false)/m);
463
- if (m && m[1] === "false")
464
- return false;
465
- return true;
466
- }
467
- catch {
468
- return true;
469
- }
470
- }
471
574
  function scanPendingReviews(repoRoot) {
472
575
  const activeDir = join(repoRoot, ".cairn", "tasks", "active");
473
576
  if (!existsSync(activeDir))
@@ -492,10 +595,10 @@ function scanPendingReviews(repoRoot) {
492
595
  const attestation = join(taskDir, "attestation.yaml");
493
596
  if (existsSync(attestation))
494
597
  continue;
495
- // Finding 4: Opt-in reviewer. Default to true, skip if explicitly false.
496
- if (!checkNeedsReview(tightenedSpec))
497
- continue;
498
598
  // Phase gate — `running` / `tightening` / etc. are not review-ready.
599
+ // Reviewer is now opt-in (bug-mine 0.13.5); this surface only fires
600
+ // when an explicit reviewer subagent set phase=ready_for_review and
601
+ // ended its turn before writing attestation.yaml.
499
602
  const phase = readTaskPhase(taskDir);
500
603
  if (phase !== null && !REVIEW_READY_PHASES.has(phase))
501
604
  continue;
@@ -551,36 +654,51 @@ function stampStalledFire(repoRoot, taskId) {
551
654
  }
552
655
  }
553
656
  /**
554
- * Scan `tasks/active/` for tasks stuck in `phase: running` with no
555
- * attestation and no recent activity. The auto-graduator only fires
556
- * when a reviewer subagent has written attestation.yaml; tasks that
557
- * were committed manually (or where the autonomous flow skipped the
558
- * reviewer-spawn step) never graduate. They accumulate silently and
559
- * resurface only on a fresh session as "wait, why are these still
560
- * open?".
561
- *
562
- * Definition of stalled:
563
- * - phase = "running"
564
- * - tightened spec exists
565
- * - no `attestation.yaml`
566
- * - no `subagents/<id>/attestation.yaml` either (else the regular
567
- * auto-graduator path will transition it)
568
- * - `status.yaml` mtime > 30min ago (recent enough activity stays
569
- * under the radar to avoid spamming during in-flight work)
570
- * - upper bound 7d so we don't surface long-archived noise that
571
- * the operator already mentally retired
572
- *
573
- * Returned list drives the Stop-hook hint that asks the operator to
574
- * triage stalled tasks — close, abort, or keep open while spawning
575
- * a reviewer.
657
+ * Drop `.stalled-warned/<task-id>.iso` files for tasks that have since
658
+ * graduated (now under `tasks/done/`) or vanished entirely. Without
659
+ * this, GC residue accumulates and the marker count looks alarming
660
+ * even though every referenced task already shipped. Best-effort
661
+ * called on every Stop tick.
576
662
  */
577
- function scanStalledRunningTasks(repoRoot, nowMs = Date.now()) {
663
+ function gcStalledWarnedMarkers(repoRoot) {
664
+ const dir = join(repoRoot, ".cairn", ".stalled-warned");
665
+ if (!existsSync(dir))
666
+ return;
667
+ const activeDir = join(repoRoot, ".cairn", "tasks", "active");
668
+ let entries = [];
669
+ try {
670
+ entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
671
+ }
672
+ catch {
673
+ return;
674
+ }
675
+ for (const e of entries) {
676
+ if (!e.isFile() || !e.name.endsWith(".iso"))
677
+ continue;
678
+ const taskId = e.name.replace(/\.iso$/, "");
679
+ if (existsSync(join(activeDir, taskId)))
680
+ continue;
681
+ try {
682
+ unlinkSync(join(dir, e.name));
683
+ }
684
+ catch {
685
+ /* best-effort */
686
+ }
687
+ }
688
+ }
689
+ function scanStalledRunningTasks(repoRoot, nowMs = Date.now(), opts = { currentSessionId: null }) {
578
690
  const activeDir = join(repoRoot, ".cairn", "tasks", "active");
579
691
  if (!existsSync(activeDir))
580
692
  return [];
581
693
  const out = [];
582
- const idleThresholdMs = 30 * 60 * 1000;
694
+ const idleThresholdMs = 2 * 60 * 60 * 1000;
583
695
  const upperBoundMs = 7 * 24 * 60 * 60 * 1000;
696
+ // When a task's last journal write came from a DIFFERENT live session
697
+ // within this window, treat it as "owned by that session" and don't
698
+ // surface the stall in the current session. Matches the case where
699
+ // an operator runs two concurrent Claude Code windows on a single
700
+ // checkout, each with its own active task.
701
+ const crossSessionTakeoverMs = 90 * 60 * 1000;
584
702
  let entries;
585
703
  try {
586
704
  entries = readdirSync(activeDir, { withFileTypes: true, encoding: "utf8" });
@@ -652,6 +770,8 @@ function scanStalledRunningTasks(repoRoot, nowMs = Date.now()) {
652
770
  continue;
653
771
  let title = taskId;
654
772
  let module = null;
773
+ let lastJournalSession = null;
774
+ let blockedOnOperator = false;
655
775
  try {
656
776
  const raw = readFileSync(statusPath, "utf8");
657
777
  for (const line of raw.split(/\r?\n/)) {
@@ -661,11 +781,36 @@ function scanStalledRunningTasks(repoRoot, nowMs = Date.now()) {
661
781
  const m = line.match(/^module:\s*(.+)$/);
662
782
  if (m && m[1] !== undefined)
663
783
  module = m[1].trim().replace(/^['"]|['"]$/g, "");
784
+ const ljs = line.match(/^last_journal_session:\s*(.+)$/);
785
+ if (ljs && ljs[1] !== undefined) {
786
+ lastJournalSession = ljs[1].trim().replace(/^['"]|['"]$/g, "");
787
+ }
788
+ const bo = line.match(/^blocked_on:\s*(.+)$/);
789
+ if (bo && bo[1] !== undefined) {
790
+ const v = bo[1].trim().replace(/^['"]|['"]$/g, "").toLowerCase();
791
+ if (v === "operator")
792
+ blockedOnOperator = true;
793
+ }
664
794
  }
665
795
  }
666
796
  catch {
667
797
  // fall through with id-as-title
668
798
  }
799
+ // Session-affinity filter: another session journaled this task
800
+ // recently — they own it. Skip surfacing in the current session.
801
+ if (opts.currentSessionId !== null &&
802
+ opts.currentSessionId.length > 0 &&
803
+ lastJournalSession !== null &&
804
+ lastJournalSession !== opts.currentSessionId &&
805
+ idleMs < crossSessionTakeoverMs) {
806
+ continue;
807
+ }
808
+ // Operator-blocked task: the work itself can't progress without an
809
+ // external action (e.g. browser repro, manual config). Surfacing
810
+ // "stalled" interrupts the operator with a triage prompt for a
811
+ // task they already know is paused.
812
+ if (blockedOnOperator)
813
+ continue;
669
814
  out.push({
670
815
  task_id: taskId,
671
816
  title,
@@ -682,9 +827,9 @@ function renderStalledTasksHint(stalled) {
682
827
  const lines = [
683
828
  `## Cairn — ${stalled.length} stalled ${noun}`,
684
829
  ``,
685
- `${stalled.length} active ${noun} idle 30min+ with no attestation. ` +
830
+ `${stalled.length} active ${noun} idle 2h+ with no attestation. ` +
686
831
  `Either the autonomous flow skipped the reviewer-spawn step, or the ` +
687
- `session was interrupted mid-task. Triage before continuing:`,
832
+ `session was interrupted mid-task.`,
688
833
  ``,
689
834
  ];
690
835
  for (const t of stalled) {
@@ -692,7 +837,7 @@ function renderStalledTasksHint(stalled) {
692
837
  lines.push(`- \`${t.task_id}\` — ${t.title}${mod} (idle ${t.idle_minutes}m)`);
693
838
  }
694
839
  lines.push("");
695
- lines.push("Render this question via `AskUserQuestion` — do not skip:");
840
+ lines.push("If you reach a stopping point with no obvious continuation, surface to operator (e.g. via `AskUserQuestion`):");
696
841
  lines.push("");
697
842
  lines.push(`> ${stalled.length} stalled ${noun}. Pick once for all (or address one at a time after):`);
698
843
  lines.push(`>`);
@@ -702,26 +847,30 @@ function renderStalledTasksHint(stalled) {
702
847
  lines.push(``);
703
848
  lines.push("On [a], call `cairn_task_complete({task_id, outcome: \"succeeded\", summary: \"closing stalled task — work landed via prior session\"})` for each id above.");
704
849
  lines.push("On [b], dispatch the `reviewer` subagent for each task in turn (one task brief per Task call).");
705
- lines.push("On [c], end the turn — the prompt re-fires only when status.yaml stays idle past the next 30min mark.");
850
+ lines.push("On [c], end the turn — the prompt re-fires only when status.yaml stays idle past the next 2h mark.");
851
+ lines.push("");
852
+ lines.push("If you're actively working (Agent dispatch in flight, edits queued), ignore this hint and keep going — it will re-evaluate on the next Stop tick.");
706
853
  return lines.join("\n");
707
854
  }
708
855
  /**
709
856
  * Auto-graduate active tasks based on attestation presence.
710
857
  *
858
+ * Self-attest is the default path (bug-mine 0.13.5): the AI calls
859
+ * `cairn_task_complete({outcome, summary})` and the tool moves the
860
+ * directory itself. This auto-graduator is the fallback for the rare
861
+ * case where an opt-in reviewer subagent wrote attestation.yaml but
862
+ * ended its turn before the explicit close call.
863
+ *
711
864
  * Rules (only acts on tasks with phase=running):
712
- * 1. Task-root `attestation.yaml` exists → succeeded → tasks/done/
713
- * (reviewer subagent attested; nothing more to do)
714
- * 2. ≥1 subagents/<id>/attestation.yaml AND needs_review=false → succeeded → tasks/done/
715
- * (trivial task, no reviewer scheduled)
716
- * 3. ≥1 subagents/<id>/attestation.yaml AND needs_review=true → ready_for_review
717
- * (reviewer hasn't run yet — `scanPendingReviews` will surface a hint)
865
+ * 1. Task-root `attestation.yaml` exists → succeeded → tasks/done/
866
+ * 2. ≥1 subagents/<id>/attestation.yaml → succeeded tasks/done/
718
867
  *
719
868
  * Tasks with no attestation activity stay `running` — they're either
720
- * still in flight or stalled (Q4 surfaces stall detection separately).
869
+ * still in flight or stalled (stall detection runs separately).
721
870
  */
722
871
  function autoGraduateTasks(repoRoot) {
723
872
  const activeDir = join(repoRoot, ".cairn", "tasks", "active");
724
- const result = { completed: [], transitioned: [] };
873
+ const result = { completed: [] };
725
874
  if (!existsSync(activeDir))
726
875
  return result;
727
876
  let entries;
@@ -740,7 +889,7 @@ function autoGraduateTasks(repoRoot) {
740
889
  continue;
741
890
  if (state.phase !== "running")
742
891
  continue;
743
- if (state.rootAttestation) {
892
+ if (state.rootAttestation || state.subagentAttestations > 0) {
744
893
  const r = completeTask({
745
894
  repoRoot,
746
895
  taskId,
@@ -749,27 +898,6 @@ function autoGraduateTasks(repoRoot) {
749
898
  });
750
899
  if (r.ok)
751
900
  result.completed.push(taskId);
752
- continue;
753
- }
754
- if (state.subagentAttestations > 0) {
755
- if (!state.needsReview) {
756
- const r = completeTask({
757
- repoRoot,
758
- taskId,
759
- outcome: "succeeded",
760
- source: "cairn_stop_auto_graduate",
761
- });
762
- if (r.ok)
763
- result.completed.push(taskId);
764
- continue;
765
- }
766
- const ok = transitionTaskPhase({
767
- repoRoot,
768
- taskId,
769
- newPhase: "ready_for_review",
770
- });
771
- if (ok)
772
- result.transitioned.push(taskId);
773
901
  }
774
902
  }
775
903
  return result;
@@ -841,10 +969,27 @@ function isMissionPhaseDeferActive(repoRoot, missionId, phaseId) {
841
969
  if (typeof parsed !== "object" || parsed === null)
842
970
  return false;
843
971
  const o = parsed;
844
- if (o["mission_id"] !== missionId || o["phase_id"] !== phaseId)
845
- return false;
972
+ // Lazy clean: if the marker is for a different mission/phase or its
973
+ // until-timestamp already passed, unlink it on the read path so we
974
+ // don't keep evaluating a stale file every Stop tick. Write-side
975
+ // unlink happens on phase-advance + mission-close; this is the
976
+ // belt-and-suspenders fallback for projects with markers stranded
977
+ // pre-fix.
846
978
  const until = typeof o["deferred_until"] === "string" ? Date.parse(o["deferred_until"]) : NaN;
847
- if (Number.isNaN(until))
979
+ const expired = Number.isFinite(until) && Date.now() >= until;
980
+ const mismatch = o["mission_id"] !== missionId || o["phase_id"] !== phaseId;
981
+ if (expired || (mismatch && Number.isFinite(until) && Date.now() >= until)) {
982
+ try {
983
+ unlinkSync(path);
984
+ }
985
+ catch {
986
+ /* best-effort */
987
+ }
988
+ return false;
989
+ }
990
+ if (mismatch)
991
+ return false;
992
+ if (!Number.isFinite(until))
848
993
  return false;
849
994
  return Date.now() < until;
850
995
  }