@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/attention/bulk-accept.js +19 -0
- package/dist/attention/bulk-accept.js.map +1 -1
- package/dist/gc/entity-orphan.d.ts +55 -0
- package/dist/gc/entity-orphan.js +171 -0
- package/dist/gc/entity-orphan.js.map +1 -0
- package/dist/gc/index.d.ts +4 -0
- package/dist/gc/index.js +2 -0
- package/dist/gc/index.js.map +1 -1
- package/dist/gc/retire.d.ts +57 -0
- package/dist/gc/retire.js +189 -0
- package/dist/gc/retire.js.map +1 -0
- package/dist/gc/sweep.js +11 -0
- package/dist/gc/sweep.js.map +1 -1
- package/dist/gc/types.d.ts +2 -2
- package/dist/hooks/ask-user-blocked.d.ts +8 -0
- package/dist/hooks/ask-user-blocked.js +13 -0
- package/dist/hooks/ask-user-blocked.js.map +1 -0
- package/dist/hooks/post-tool-use/ask-user-blocked.d.ts +18 -0
- package/dist/hooks/post-tool-use/ask-user-blocked.js +113 -0
- package/dist/hooks/post-tool-use/ask-user-blocked.js.map +1 -0
- package/dist/hooks/post-tool-use/index.d.ts +1 -0
- package/dist/hooks/post-tool-use/index.js +1 -0
- package/dist/hooks/post-tool-use/index.js.map +1 -1
- package/dist/hooks/runners/context-threshold.js +2 -2
- package/dist/hooks/runners/context-threshold.js.map +1 -1
- package/dist/hooks/runners/gc-autotrigger.d.ts +7 -4
- package/dist/hooks/runners/gc-autotrigger.js +7 -4
- package/dist/hooks/runners/gc-autotrigger.js.map +1 -1
- package/dist/hooks/runners/stop.js +260 -115
- package/dist/hooks/runners/stop.js.map +1 -1
- package/dist/init/ingest-docs.d.ts +2 -4
- package/dist/init/ingest-docs.js +2 -4
- package/dist/init/ingest-docs.js.map +1 -1
- package/dist/mcp/errors.d.ts +1 -1
- package/dist/mcp/errors.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/schemas.d.ts +16 -94
- package/dist/mcp/schemas.js +41 -89
- package/dist/mcp/schemas.js.map +1 -1
- package/dist/mcp/tools/decision-get.js +9 -0
- package/dist/mcp/tools/decision-get.js.map +1 -1
- package/dist/mcp/tools/index.js +8 -29
- package/dist/mcp/tools/index.js.map +1 -1
- package/dist/mcp/tools/invariant-get.js +8 -0
- package/dist/mcp/tools/invariant-get.js.map +1 -1
- package/dist/mcp/tools/mission-advance.d.ts +15 -0
- package/dist/mcp/tools/mission-advance.js +36 -1
- package/dist/mcp/tools/mission-advance.js.map +1 -1
- package/dist/mcp/tools/record-decision.js +14 -1
- package/dist/mcp/tools/record-decision.js.map +1 -1
- package/dist/mcp/tools/retire-entity.d.ts +22 -0
- package/dist/mcp/tools/retire-entity.js +62 -0
- package/dist/mcp/tools/retire-entity.js.map +1 -0
- package/dist/mcp/tools/task-complete.js +28 -22
- package/dist/mcp/tools/task-complete.js.map +1 -1
- package/dist/mcp/tools/task-create.d.ts +0 -1
- package/dist/mcp/tools/task-create.js +41 -10
- package/dist/mcp/tools/task-create.js.map +1 -1
- package/dist/mcp/tools/task-journal-append.js +23 -2
- package/dist/mcp/tools/task-journal-append.js.map +1 -1
- package/dist/mcp/tools/task-reopen.d.ts +7 -0
- package/dist/mcp/tools/task-reopen.js +40 -0
- package/dist/mcp/tools/task-reopen.js.map +1 -0
- package/dist/tasks/index.d.ts +2 -2
- package/dist/tasks/index.js +1 -1
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/lifecycle.d.ts +62 -19
- package/dist/tasks/lifecycle.js +147 -42
- package/dist/tasks/lifecycle.js.map +1 -1
- package/package.json +2 -2
- package/dist/mcp/tools/align-drain.d.ts +0 -7
- package/dist/mcp/tools/align-drain.js +0 -26
- package/dist/mcp/tools/align-drain.js.map +0 -1
- package/dist/mcp/tools/archive.d.ts +0 -8
- package/dist/mcp/tools/archive.js +0 -72
- package/dist/mcp/tools/archive.js.map +0 -1
- package/dist/mcp/tools/attention-restore.d.ts +0 -14
- package/dist/mcp/tools/attention-restore.js +0 -22
- package/dist/mcp/tools/attention-restore.js.map +0 -1
- package/dist/mcp/tools/decisions-for-symbol.d.ts +0 -7
- package/dist/mcp/tools/decisions-for-symbol.js +0 -42
- package/dist/mcp/tools/decisions-for-symbol.js.map +0 -1
- package/dist/mcp/tools/get-full.d.ts +0 -7
- package/dist/mcp/tools/get-full.js +0 -46
- package/dist/mcp/tools/get-full.js.map +0 -1
- package/dist/mcp/tools/ground-get.d.ts +0 -7
- package/dist/mcp/tools/ground-get.js +0 -77
- package/dist/mcp/tools/ground-get.js.map +0 -1
- package/dist/mcp/tools/mission-close.d.ts +0 -8
- package/dist/mcp/tools/mission-close.js +0 -48
- package/dist/mcp/tools/mission-close.js.map +0 -1
- package/dist/mcp/tools/mission-reopen.d.ts +0 -13
- package/dist/mcp/tools/mission-reopen.js +0 -56
- package/dist/mcp/tools/mission-reopen.js.map +0 -1
- package/dist/mcp/tools/reject-candidate.d.ts +0 -24
- package/dist/mcp/tools/reject-candidate.js +0 -71
- package/dist/mcp/tools/reject-candidate.js.map +0 -1
- package/dist/mcp/tools/search-candidates.d.ts +0 -20
- package/dist/mcp/tools/search-candidates.js +0 -93
- package/dist/mcp/tools/search-candidates.js.map +0 -1
- package/dist/mcp/tools/supersedes-chain.d.ts +0 -6
- package/dist/mcp/tools/supersedes-chain.js +0 -66
- package/dist/mcp/tools/supersedes-chain.js.map +0 -1
- package/dist/mcp/tools/timeline.d.ts +0 -9
- package/dist/mcp/tools/timeline.js +0 -61
- 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,
|
|
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
|
|
69
|
+
const REASON_PREAMBLE = "↳ Cairn hint — surface 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
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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 limit — at 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
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
713
|
-
*
|
|
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 (
|
|
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: []
|
|
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
|
|
845
|
-
|
|
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
|
-
|
|
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
|
}
|