@agentproto/apps 0.21.1 → 0.21.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentproto/apps",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "@agentproto/apps — a home for ready-made agentproto apps (teams of agents + their workflows), declared with @agentproto/app-kit. Import a team (code-team, content-team, …) and use any of its agents/workflows from your own host: in-process via toMastraAgents(), or emit its manifests to disk.",
5
5
  "keywords": [
6
6
  "agentproto",
@@ -173,9 +173,9 @@
173
173
  },
174
174
  "dependencies": {
175
175
  "zod": "^4.6.5",
176
- "@agentproto/app-client": "0.4.1",
177
- "@agentproto/app-kit": "1.7.1",
178
176
  "@agentproto/agent": "0.2.5",
177
+ "@agentproto/app-client": "0.4.1",
178
+ "@agentproto/app-kit": "1.7.2",
179
179
  "@agentproto/workflow": "0.7.1"
180
180
  },
181
181
  "peerDependencies": {
@@ -261,7 +261,7 @@ steps:
261
261
  - id: relabelQueue
262
262
  kind: transform
263
263
  name: Terminal sessions missing an outcome (relabel candidates)
264
- description: Entry-based — buildRelabelQueue. PR numbers come from the session row.
264
+ description: Entry-based — buildRelabelQueue. PR numbers (and their owner/repo) come from the session row.
265
265
 
266
266
  - id: relabelEvidenceQueue
267
267
  kind: transform
@@ -284,7 +284,7 @@ steps:
284
284
  - id: relabelFinal
285
285
  kind: transform
286
286
  name: Relabel proposals with their evidence
287
- description: Entry-based — applyRelabelEvidence (merged PR/worktree or opened PR → done).
287
+ description: Entry-based — applyRelabelEvidence. `done` only when the session's own PR is merged and its last message leaves nothing pending; an open PR or pending work → needs-follow-up; a shared worktree's PR credits nobody.
288
288
 
289
289
  - id: evidence
290
290
  kind: map
@@ -463,7 +463,8 @@ Every rule below is a pure function in `cron-rules.mjs`, pinned by
463
463
  `session-steward-cron-rules.test.ts`:
464
464
 
465
465
  - **Loop (1).** `tool_calls_list` per busy session: the same argv verbatim ≥3
466
- in 10 min, distinct/total < 0.2, or the same file read ≥4 → `looping`, a
466
+ in 10 min, distinct/total < 0.2, or the same file read ≥4 (a recursive `rg`/`grep` over a
467
+ directory, or its pattern, is not a file read) → `looping`, a
467
468
  sub-case of `active`. The proposed action is an **interrupt nudge**, never a
468
469
  close. Useful loops (watch, test/type-check re-runs, `git status`, `gh pr`
469
470
  polling) are excluded.
@@ -64,6 +64,7 @@ export function isUsefulLoopCommand(signature) {
64
64
  }
65
65
 
66
66
  const READ_TOOLS = new Set(["read", "cat", "rg", "grep", "sed", "head", "tail", "less", "view", "readfile"])
67
+ const FILE_EXT = /\.(md|ts|tsx|js|mjs|cjs|json|txt|py|go|rs|sql|yml|yaml|toml|log|sh)$/
67
68
  const READ_VERB_WORD = /^["']?(cat|rg|grep|sed|head|tail|less|view)["']?$/
68
69
 
69
70
  /**
@@ -83,15 +84,29 @@ export function readTargetOf(record) {
83
84
  const segments = command !== undefined && !readTool ? command.split(/&&|\|\||\||;/) : [undefined]
84
85
  for (const seg of segments) {
85
86
  let tokens = seg !== undefined ? seg.trim().split(/\s+/) : command !== undefined ? command.split(/\s+/) : args
87
+ let searchVerb = tool === "rg" || tool === "grep"
86
88
  if (seg !== undefined) {
87
89
  const first = tokens.findIndex((t) => !t.includes("=") || t.startsWith("-"))
88
90
  if (first < 0 || !READ_VERB_WORD.test(tokens[first])) continue
91
+ searchVerb = /^["']?(rg|grep)["']?$/.test(tokens[first])
89
92
  tokens = tokens.slice(first + 1)
90
93
  }
94
+ // `rg`/`grep` take a pattern first, then search roots: the pattern is not
95
+ // a file, and a root without an extension is a directory (a recursive
96
+ // search of one tree is not "the same file re-read").
97
+ let patternPending = searchVerb
91
98
  for (const raw of tokens) {
92
99
  const t = raw.replace(/^['"]|['"]$/g, "")
93
100
  if (!t || t.startsWith("-") || t.includes("=")) continue
94
- if (t.includes("/") || /\.(md|ts|tsx|js|mjs|cjs|json|txt|py|go|rs|sql|yml|yaml|toml)$/.test(t)) return t
101
+ if (patternPending) {
102
+ patternPending = false
103
+ continue
104
+ }
105
+ if (searchVerb) {
106
+ if (FILE_EXT.test(t)) return t
107
+ continue
108
+ }
109
+ if (t.includes("/") || FILE_EXT.test(t)) return t
95
110
  }
96
111
  if (seg === undefined) return args[0] ?? null
97
112
  }
@@ -209,7 +224,8 @@ const minutesSince = (ts, nowMs) => {
209
224
 
210
225
  /**
211
226
  * A session that never actually ran: explicit 0 in AND 0 out. `undefined`
212
- * tokens (not reported) is NOT "never ran" — only a hard 0/0 is. A session
227
+ * tokens (not reported) is NOT "never ran" — only a hard 0/0 with no cost,
228
+ * context use or completed turn is. A session
213
229
  * that is busy, still starting/provisioning, or has a prompt queued for its
214
230
  * first turn is merely young, not stuck. With `opts.nowMs`, the session must
215
231
  * also be at least `opts.idleMinutes` old (`startedAt`) AND idle
@@ -217,6 +233,9 @@ const minutesSince = (ts, nowMs) => {
217
233
  */
218
234
  export function isNeverRan(session, opts = {}) {
219
235
  if (!(session?.tokensIn === 0 && session?.tokensOut === 0)) return false
236
+ // Some adapters (opencode) report 0/0 tokens for a session that ran several
237
+ // turns; cost, context use or a completed turn prove it ran.
238
+ if (session.costUsd > 0 || session.contextUsed > 0 || session.lastTurnReason === "completed") return false
220
239
  if (session.busy === true || session.status === "starting" || session.provisioning) return false
221
240
  if (Array.isArray(session.pendingPrompts) && session.pendingPrompts.length > 0) return false
222
241
  if (typeof opts.nowMs === "number") {
@@ -256,7 +275,7 @@ export function isCommitOrPrCall(record) {
256
275
 
257
276
  /**
258
277
  * The done fast-path: the last tool call is `message_parent(kind:done)` AND
259
- * the window shows a commit/PR (or the worktree/PR state or the derived
278
+ * the window shows a commit/PR (or a MERGED worktree PR or the derived
260
279
  * outcome already proves one). That is `done` without spending a judge.
261
280
  * Anything short of both halves returns `{ done: false }`.
262
281
  */
@@ -267,9 +286,9 @@ export function detectFastPathDone(input = {}) {
267
286
  if (!doneMessage) return { done: false, reason: "no message_parent(kind:done)" }
268
287
  const prState = input.worktree?.pr?.state
269
288
  const merged = prState === "merged" || prState === "MERGED"
270
- const opened = prState === "open" || prState === "OPEN"
271
289
  const outcomePrs = Array.isArray(input.outcome?.pullRequests) ? input.outcome.pullRequests.length : 0
272
- const hasCommitOrPr = calls.some(isCommitOrPrCall) || merged || opened || outcomePrs > 0
290
+ // An OPEN worktree PR is work awaiting review, not proof of completion.
291
+ const hasCommitOrPr = calls.some(isCommitOrPrCall) || merged || outcomePrs > 0
273
292
  if (!hasCommitOrPr) return { done: false, reason: "message_parent(kind:done) but no commit/PR" }
274
293
  return { done: true, reason: merged || outcomePrs > 0 ? "message_parent(kind:done) + merged/opened PR" : "message_parent(kind:done) + commit" }
275
294
  }
@@ -296,82 +315,194 @@ export function prNumbersOf(session) {
296
315
 
297
316
  /** Relabel verdict for "no positive evidence either way". */
298
317
  export const UNKNOWN_VERDICT = "unknown"
318
+ /** Relabel verdict for "a PR/outcome exists but work remains" (report only —
319
+ * the steward never closes on it, and a terminal session has nothing to flag). */
320
+ export const FOLLOW_UP_VERDICT = "needs-follow-up"
299
321
 
300
- const fmtPrs = nums => nums.map(n => `#${n}`).join(", ")
301
322
  const isMergedState = state => state === "merged" || state === "MERGED"
323
+ const isOpenState = state => state === "open" || state === "OPEN"
324
+
325
+ /** `owner/repo` out of a GitHub PR URL; undefined when it names no repo. */
326
+ export function repoOfPrUrl(url) {
327
+ const m = /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/\d+/.exec(String(url ?? ""))
328
+ return m ? m[1] : undefined
329
+ }
330
+
331
+ /** `owner/repo#N` when the repo is known, else `#N` — a bare number is
332
+ * ambiguous across repos (G3: `#504` was agentik-studio's, not agentproto's). */
333
+ export const fmtPr = (n, repo) => (repo ? `${repo}#${n}` : `#${n}`)
334
+ const fmtPrs = (nums, repos) => nums.map(n => fmtPr(n, repos?.[n])).join(", ")
335
+
336
+ /** owner/repo per PR number from the session's `outcome.artifacts` PR refs
337
+ * (`…github.com/OWNER/REPO/pull/N`) — the only list-row field that carries
338
+ * a repo. Plain object (journal-safe). */
339
+ export function prReposOf(session) {
340
+ const repos = {}
341
+ for (const a of Array.isArray(session?.outcome?.artifacts) ? session.outcome.artifacts : []) {
342
+ if (a?.type !== "pr") continue
343
+ const m = /\/pull\/(\d+)/.exec(String(a.ref ?? ""))
344
+ const repo = repoOfPrUrl(a.ref)
345
+ if (m && repo) repos[Number(m[1])] = repo
346
+ }
347
+ return repos
348
+ }
302
349
 
303
350
  /**
304
351
  * A terminal session that still carries no derived outcome and no wrapup
305
352
  * flag is a relabel CANDIDATE — visible instead of invisible, as the log
306
- * asks. The proposed verdict is `done` when the session's own record shows a
307
- * PR (merged, or merely opened — the PR is the hand-off), else `unknown`: a
308
- * session with no PR is as likely finished as abandoned, and only evidence
309
- * ({@link refineRelabel}) can say which.
310
- * `reason` carries the evidence (`PR #1738 merged`, `PRs #1738, #1740 opened`).
353
+ * asks. A PR is never proof the session finished, so the proposal is
354
+ * deliberately cautious: `done` only when the session's OWN record shows its
355
+ * PR merged (a worktree PR it never recorded is shared with sibling sessions
356
+ * and credits nobody); otherwise `unknown`. {@link refineRelabel} sharpens
357
+ * it with evidence and runs the remaining-work check.
311
358
  */
312
359
  export function terminalRelabelCandidate(session) {
313
360
  if (!TERMINAL_STATUSES.has(String(session?.status ?? ""))) return { candidate: false, reason: "not terminal" }
314
361
  if (session?.outcome?.verdict) return { candidate: false, reason: "outcome already recorded" }
315
362
  if (session?.wrapupFlag) return { candidate: false, reason: "already flagged" }
316
363
  const prs = prNumbersOf(session)
364
+ const repos = prReposOf(session)
317
365
  const wt = session?.worktree?.pr
318
- // A worktree can be shared by several sessions, so its merged PR is not
319
- // proof THIS one finished: a session whose last turn errored is not credited
320
- // with it, and a PR the session did not record itself is labelled as the
321
- // worktree's.
322
- const ownMerged = Number.isInteger(wt?.number) && prs.includes(wt.number)
323
- if (isMergedState(wt?.state) && (ownMerged || !session?.lastTurnErroredAt)) {
324
- if (Number.isInteger(wt.number)) {
325
- return { candidate: true, proposedVerdict: "done", reason: `PR ${fmtPrs([wt.number])} merged${prs.includes(wt.number) ? "" : " (worktree)"}`, prs }
326
- }
327
- return { candidate: true, proposedVerdict: "done", reason: prs.length > 0 ? `PR ${fmtPrs(prs)} merged` : "PR merged", prs }
366
+ if (isMergedState(wt?.state) && Number.isInteger(wt?.number) && prs.includes(wt.number)) {
367
+ return { candidate: true, proposedVerdict: "done", reason: `PR ${fmtPr(wt.number, repos[wt.number])} merged`, prs, repos }
328
368
  }
329
369
  if (prs.length > 0) {
330
- return { candidate: true, proposedVerdict: "done", reason: `PR${prs.length > 1 ? "s" : ""} ${fmtPrs(prs)} opened`, prs }
370
+ return { candidate: true, proposedVerdict: UNKNOWN_VERDICT, reason: `PR${prs.length > 1 ? "s" : ""} ${fmtPrs(prs, repos)} recorded, state unknown`, prs, repos }
331
371
  }
332
- return { candidate: true, proposedVerdict: UNKNOWN_VERDICT, reason: "terminal, no PR recorded — outcome unknown", prs }
372
+ return { candidate: true, proposedVerdict: UNKNOWN_VERDICT, reason: "terminal, no PR recorded — outcome unknown", prs, repos }
333
373
  }
334
374
 
335
375
  /**
336
- * Sharpen one relabel proposal with its `session_evidence` answer: a merged
337
- * PR / merged worktree (`pullRequests.merged`, `worktree.pr.state`) →
338
- * `done` with `PR #N merged`; an open or recorded PR the list row did not
339
- * carry → `done`. Anything else leaves the proposal untouched.
376
+ * Sharpen one relabel proposal with its `session_evidence` answer.
377
+ * - the worktree PR is merged AND the session recorded it → `done`;
378
+ * - merged but NOT recorded by this session (a shared worktree) → `unknown`;
379
+ * - open and the session's own → `needs-follow-up` (awaiting review/merge);
380
+ * open and not its own → `unknown`;
381
+ * - no PR: `abandoned` only on positive evidence (errored / never ran).
382
+ * Whatever lands on `done` then goes through the remaining-work check on the
383
+ * last assistant turn: a question or announced next step → `needs-follow-up`.
340
384
  */
341
385
  export function refineRelabel(item, evidence) {
342
386
  if (!evidence) return item
343
387
  const wt = evidence.worktree?.pr
344
- const state = wt?.state ?? evidence.pullRequests?.state ?? null
388
+ const state = wt?.state ?? null
345
389
  const known = Array.isArray(item?.prs) ? item.prs : []
346
- // `pullRequests.merged` and `worktree.pr` describe the whole worktree, which
347
- // sibling sessions share: a session that recorded no PR of its own and whose
348
- // last turn errored is not credited with a sibling's merge.
349
- const ownPr = known.length > 0 || (evidence.pullRequests?.opened ?? 0) > 0
350
- const errored = typeof evidence.lastTurnError === "string" && evidence.lastTurnError.trim() !== ""
351
- const credited = ownPr || !errored
352
- const merged = credited && (isMergedState(state) || (evidence.pullRequests?.merged ?? 0) > 0)
353
- if (merged) {
354
- const n = Number.isInteger(wt?.number) ? wt.number : known.length === 1 ? known[0] : undefined
355
- const others = known.filter(k => k !== n)
356
- // A PR number the session did not record itself is the worktree's.
357
- const shared = n !== undefined && !known.includes(n) ? " (worktree)" : ""
358
- const reason = (n !== undefined ? `PR #${n} merged${shared}` : "PR merged") + (others.length > 0 && n !== undefined ? `; also opened ${fmtPrs(others)}` : "")
359
- return { ...item, proposedVerdict: "done", reason }
360
- }
361
- if (item?.proposedVerdict === "done") return item
362
- if (credited && (state === "open" || state === "OPEN")) {
363
- return { ...item, proposedVerdict: "done", reason: Number.isInteger(wt?.number) ? `PR #${wt.number} open` : "PR open" }
364
- }
365
- const opened = evidence.pullRequests?.opened ?? 0
366
- if (opened > 0) return { ...item, proposedVerdict: "done", reason: `${opened} PR${opened > 1 ? "s" : ""} opened` }
367
- // No PR: `abandoned` needs positive evidence the session did not finish.
368
- if (item?.proposedVerdict === UNKNOWN_VERDICT) {
390
+ const n = Number.isInteger(wt?.number) ? wt.number : undefined
391
+ const repos = { ...(item?.repos ?? {}) }
392
+ const wtRepo = repoOfPrUrl(wt?.url)
393
+ if (n !== undefined && wtRepo) repos[n] = wtRepo
394
+ const label = n !== undefined ? fmtPr(n, repos[n]) : "PR"
395
+ const own = n !== undefined && known.includes(n)
396
+ let next = item
397
+ if (isMergedState(state) && n !== undefined) {
398
+ next = own
399
+ ? { ...item, proposedVerdict: "done", reason: `PR ${label} merged` }
400
+ : { ...item, proposedVerdict: UNKNOWN_VERDICT, reason: `worktree PR ${label} merged, not recorded by this session (shared worktree)` }
401
+ } else if (isOpenState(state) && n !== undefined) {
402
+ next = own
403
+ ? { ...item, proposedVerdict: FOLLOW_UP_VERDICT, reason: `PR ${label} open — awaiting review/merge` }
404
+ : { ...item, proposedVerdict: UNKNOWN_VERDICT, reason: `worktree PR ${label} open, not recorded by this session` }
405
+ } else if ((evidence.pullRequests?.opened ?? 0) > 0) {
406
+ const opened = evidence.pullRequests.opened
407
+ next = { ...item, proposedVerdict: UNKNOWN_VERDICT, reason: `${opened} PR${opened > 1 ? "s" : ""} opened, state unknown` }
408
+ } else if (item?.proposedVerdict === UNKNOWN_VERDICT) {
369
409
  if (typeof evidence.lastTurnError === "string" && evidence.lastTurnError.trim()) {
370
- return { ...item, proposedVerdict: "abandoned", reason: `no PR, last turn errored: ${evidence.lastTurnError.trim().slice(0, 80)}` }
410
+ next = { ...item, proposedVerdict: "abandoned", reason: `no PR, last turn errored: ${evidence.lastTurnError.trim().slice(0, 80)}` }
411
+ } else if (evidence.turnsCompleted === 0) {
412
+ next = { ...item, proposedVerdict: "abandoned", reason: "no PR, no turn ever completed" }
413
+ }
414
+ }
415
+ if (Object.keys(repos).length > 0) next = { ...next, repos }
416
+ if (next.proposedVerdict === "done") {
417
+ const turns = Array.isArray(evidence.turns) ? evidence.turns : []
418
+ const lastAssistant = [...turns].reverse().find(t => t?.role === "assistant")
419
+ const pending = remainingWork({ lastAssistantText: lastAssistant?.text })
420
+ if (pending.length > 0) {
421
+ return { ...next, proposedVerdict: FOLLOW_UP_VERDICT, reason: `${next.reason}; remaining work: ${pending.join(", ")}` }
371
422
  }
372
- if (evidence.turnsCompleted === 0) return { ...item, proposedVerdict: "abandoned", reason: "no PR, no turn ever completed" }
373
423
  }
374
- return item
424
+ return next
425
+ }
426
+
427
+ // ── remaining-work detection (G11) ───────────────────────────────────────
428
+ //
429
+ // A PR, a merged worktree or an ended parent says the work MOVED ON, never
430
+ // that THIS session finished: a child whose last message asks something or
431
+ // announces a next step has an orphaned obligation once its parent is gone.
432
+ // Closing it silently drops that. These checks therefore turn a would-be
433
+ // close / `done` into a FLAG; they are conservative on purpose (a false
434
+ // positive costs one flag, a false negative drops a question).
435
+
436
+ /** A `?` that ends a sentence, a quote or a bracket — not a URL query (`?a=1`). */
437
+ const QUESTION_MARK = /\?(?=\s|$|["'»”’)\]*_`])/
438
+ /** How far back from the end of the message a `?` still counts as "the
439
+ * question this session left open". */
440
+ const QUESTION_WINDOW_CHARS = 240
441
+
442
+ /** Deferred-work phrasing, EN + FR. `kind` names the signal in the flag note. */
443
+ const PENDING_PATTERNS = [
444
+ { kind: "open question", re: /\b(open\s+question|unanswered|question\s+ouverte|sans\s+réponse|j'?ai\s+posé\s+la\s+question|the\s+question\s+i\s+(?:put|sent|asked))\b/i },
445
+ { kind: "asks the user", re: /\b(should\s+i|should\s+we|shall\s+i|shall\s+we|do\s+you\s+want|would\s+you\s+like|dois-je|faut-il|je\s+corrige|voulez-vous|veux-tu)\b/i },
446
+ { kind: "waiting", re: /\b(waiting\s+(?:for|on)|i'?ll\s+report|i\s+will\s+report|en\s+attente|j'?attends|dans\s+l'?attente)\b/i },
447
+ { kind: "next step", re: /\b(next\s+steps?|to\s+do\s+next|follow[- ]?ups?|todo|à\s+faire|reste\s+à|prochaines?\s+étapes?|il\s+faudrait|avant\s+le\s+prochain|(?:i|we)\s+(?:still\s+)?(?:need|have)\s+to)\b|\breste\s*:/i },
448
+ { kind: "recommendation", re: /\b(recommandation|je\s+recommande|i\s+recommend|i'?d\s+recommend|recommendation)\b/i },
449
+ ]
450
+ /** "nothing left to do", "rien à faire", "no follow-up" — a negation right
451
+ * before a marker makes it a clean sign-off, not a pending item. */
452
+ const NEGATION_BEFORE = /\b(nothing|no|none|rien|aucune?|n'?ai\s+rien|ne\s+reste\s+rien|no\s+further|no\s+more)\b[^.!?]{0,30}$/i
453
+
454
+ /** The last sentence announces an action rather than reporting a result
455
+ * ("Now drive it with the built CLI.") — the transcript stops mid-work.
456
+ * A sentence that also states an outcome ("Now it works.") does not count. */
457
+ const ANNOUNCES_NEXT = /^(?:now|next|then|ensuite|maintenant|puis|let\s+me(?!\s+know)|let'?s|i'?ll|i\s+will|je\s+vais|on\s+va)\b/i
458
+ const STATES_OUTCOME = /\b(is|are|was|were|works?|passes?|passed|green|done|merged|fixed|complete[d]?|ready|ok|terminé|fini|fonctionne)\b/i
459
+
460
+ function announcesNextAction(flat) {
461
+ const sentences = flat.split(/(?<=[.!?])\s+/).filter(Boolean)
462
+ const last = sentences.at(-1)?.trim()
463
+ return !!last && last.length <= 160 && ANNOUNCES_NEXT.test(last) && !STATES_OUTCOME.test(last)
464
+ }
465
+
466
+ /**
467
+ * Why a session's LAST assistant message still owes something, or `null`
468
+ * when it reads as a clean final report. Reasons: `question to the user`,
469
+ * `announced next action`, `open question`, `asks the user`, `waiting`, `next step`,
470
+ * `recommendation`.
471
+ * The tail the plan carries is ~600 chars, whitespace-flattened.
472
+ */
473
+ export function pendingWorkReason(text) {
474
+ if (typeof text !== "string" || text.trim().length === 0) return null
475
+ const flat = text.replace(/\s+/g, " ").trim()
476
+ const recent = flat.slice(-QUESTION_WINDOW_CHARS)
477
+ if (QUESTION_MARK.test(recent)) return "question to the user"
478
+ if (announcesNextAction(flat)) return "announced next action"
479
+ for (const { kind, re } of PENDING_PATTERNS) {
480
+ const m = re.exec(flat)
481
+ if (!m) continue
482
+ if (NEGATION_BEFORE.test(flat.slice(Math.max(0, m.index - 40), m.index))) continue
483
+ return kind
484
+ }
485
+ return null
486
+ }
487
+
488
+ /** Boolean form of {@link pendingWorkReason}. */
489
+ export const hasPendingWork = text => pendingWorkReason(text) !== null
490
+
491
+ /** PR states that mean "still waiting on someone" (not merged, not closed). */
492
+ const isOpenPrState = state => state === "open" || state === "OPEN"
493
+
494
+ /**
495
+ * The remaining-work check for one session: reasons it must NOT be closed
496
+ * or labelled `done`. Inputs are whatever the caller has — the last
497
+ * assistant text and/or the worktree's PR state; missing inputs add no
498
+ * reason. An empty list means nothing pending was found.
499
+ */
500
+ export function remainingWork({ lastAssistantText, prState } = {}) {
501
+ const reasons = []
502
+ const pending = pendingWorkReason(lastAssistantText)
503
+ if (pending) reasons.push(pending)
504
+ if (isOpenPrState(prState)) reasons.push("open PR awaiting review/merge")
505
+ return reasons
375
506
  }
376
507
 
377
508
  // ── self-exclusion (mission item 7) ──────────────────────────────────────
@@ -577,15 +708,26 @@ export function saturationHeader(hostLoad) {
577
708
  * Why there is nothing to do, stated explicitly instead of an empty report:
578
709
  * how many live sessions were seen, how many were busy, how many were
579
710
  * terminal (and whether any still need a relabel), and how many were
580
- * excluded (self / same cron job / pinned / pty / keepAlive).
711
+ * excluded — with a per-reason breakdown (`counts.excludedByReason`) so the
712
+ * report says exactly WHY (G6), e.g. `6 excluded (2 same_cron_job_as_caller,
713
+ * 3 pty, 1 archived)`.
581
714
  */
582
715
  export function explainZeroCandidates(counts = {}) {
583
716
  const n = (v) => (typeof v === "number" ? v : 0)
717
+ const byReason = counts.excludedByReason
718
+ let excluded = `${n(counts.excluded)} excluded`
719
+ const breakdown = []
720
+ if (byReason && typeof byReason === "object") {
721
+ for (const [reason, count] of Object.entries(byReason)) {
722
+ if (count > 0) breakdown.push(`${count} ${reason}`)
723
+ }
724
+ }
725
+ if (breakdown.length > 0) excluded += ` (${breakdown.join(", ")})`
584
726
  return (
585
727
  `0 candidates: ${n(counts.live)} live (` +
586
728
  `${n(counts.busy)} busy, ${n(counts.idle)} idle), ` +
587
729
  `${n(counts.terminal)} terminal (${n(counts.terminalRelabel)} need a relabel), ` +
588
- `${n(counts.excluded)} excluded (self/cron/pinned/pty/keepAlive)`
730
+ excluded
589
731
  )
590
732
  }
591
733
 
@@ -49,6 +49,7 @@ import {
49
49
  saturationHeader,
50
50
  shouldRejudge,
51
51
  refineRelabel,
52
+ remainingWork,
52
53
  terminalRelabelCandidate,
53
54
  verdictMemoryEvent,
54
55
  NUDGE_CONTINUE,
@@ -139,11 +140,28 @@ function policyOf(settings) {
139
140
  return { userOrigins: settings?.userOrigins, closableOrigins: settings?.closableOrigins }
140
141
  }
141
142
 
143
+ /** Why a would-be close must not happen: the remaining-work check over a plan
144
+ * entry — its last assistant message (`signals.lastAssistantTail`) and its
145
+ * worktree's PR still open (`signals.worktreePrOpen`). A merged worktree or an
146
+ * ended parent only says the work moved on, never that THIS session finished
147
+ * (G11: 7 children of an ended parent were closed `done` while their last
148
+ * message asked the parent a question that nobody can answer any more). */
149
+ export function remainingWorkOf(entry) {
150
+ return remainingWork({
151
+ lastAssistantText: entry?.signals?.lastAssistantTail,
152
+ prState: entry?.signals?.worktreePrOpen === true ? "open" : undefined,
153
+ })
154
+ }
155
+
142
156
  /** `decideAction` over one candidate entry, with the run's policy folded in.
143
157
  * Used by both the apply-queue builders and the report, so the action shown
144
- * and the action executed can never drift. */
145
- export function decideFor(entry, planClass, verdict, confidence, settings) {
146
- return decideAction({
158
+ * and the action executed can never drift. A rule or judged `close` whose
159
+ * session still has remaining work becomes a `flag` (`remaining` lists why):
160
+ * the operator sees the question instead of the session being closed. A
161
+ * `stuck` session never ran, so it has nothing to owe; a session that itself
162
+ * declared `STEWARD: DONE` (`opts.declared`) answered the question. */
163
+ export function decideFor(entry, planClass, verdict, confidence, settings, opts = {}) {
164
+ const d = decideAction({
147
165
  session: entry,
148
166
  planClass,
149
167
  verdict,
@@ -152,6 +170,19 @@ export function decideFor(entry, planClass, verdict, confidence, settings) {
152
170
  policy: policyOf(settings),
153
171
  minConfidence: settings?.minConfidence,
154
172
  })
173
+ if (d.action !== "close" || planClass === "stuck" || opts.declared === true) return d
174
+ const remaining = remainingWorkOf(entry)
175
+ if (remaining.length === 0) return d
176
+ const reason = `flag (remaining work: ${remaining.join(", ")})`
177
+ return { action: "flag", reason: settings?.apply === true ? reason : `${reason} (dry run)`, remaining }
178
+ }
179
+
180
+ /** The flag note for a decision that downgraded a close: the reason plus the
181
+ * session's own last words, so the operator sees the question it left open. */
182
+ function flagNote(entry, d, extra) {
183
+ const tail = typeof entry?.signals?.lastAssistantTail === "string" ? entry.signals.lastAssistantTail.trim().slice(-300) : ""
184
+ const quoted = d.remaining && tail ? ` — last message: «${tail}»` : ""
185
+ return `${d.reason}${extra ? ` — ${extra}` : ""}${quoted}`
155
186
  }
156
187
 
157
188
  // ── plan → candidates ────────────────────────────────────────────────────
@@ -190,7 +221,7 @@ export function buildRuleApplyQueue(candidates, settings) {
190
221
  queue.push({
191
222
  sessionId: e.sessionId,
192
223
  verdict: d.action === "close" ? closeVerdict : "needs-input",
193
- note: d.action === "close" ? closeNote(e) : d.reason,
224
+ note: d.action === "close" ? closeNote(e) : flagNote(e, d),
194
225
  })
195
226
  }
196
227
  }
@@ -528,14 +559,14 @@ export function buildJudgedApplyQueue(finalVerdicts, settings) {
528
559
  const queue = []
529
560
  for (const r of finalVerdicts ?? []) {
530
561
  if (r.malformed) continue
531
- const d = decideFor(r.entry, "judge", r.verdict, r.confidence, settings)
562
+ const d = decideFor(r.entry, "judge", r.verdict, r.confidence, settings, { declared: r.source === "declared" })
532
563
  if (d.action === "skip") continue
533
564
  const isFlagVerdict = r.verdict === "blocked" || r.verdict === "needs-input"
534
565
  queue.push({
535
566
  sessionId: r.entry.sessionId,
536
567
  verdict: d.action === "close" ? r.verdict : isFlagVerdict ? r.verdict : "needs-input",
537
568
  judgedBy: r.source === "declared" ? `steward-ask:${r.entry.sessionId}` : r.judgedBy ?? r.judgeSessionId ?? "steward-judge",
538
- note: d.action === "close" ? r.reason : `${d.reason}${r.reason ? ` — ${r.reason}` : ""}`,
569
+ note: d.action === "close" ? r.reason : flagNote(r.entry, d, r.reason),
539
570
  })
540
571
  }
541
572
  return queue
@@ -614,8 +645,10 @@ export function buildReport(b) {
614
645
  `| ${cls} | ${cell(e.label ?? e.sessionId)} | ${cell(originCell(e, s))} | ${e.idleMinutes ?? "?"} min | ${fmtMB(e.rssBytes)} | ` +
615
646
  `${cell(verdict)} | ${conf === undefined ? "—" : conf.toFixed(2)} | ${cell(reason)} | ${cell(action)} |`,
616
647
  )
648
+ let withRemainingWork = 0
617
649
  for (const e of c.close) {
618
650
  const d = decideFor(e, "close", "done", 1, s)
651
+ if (d.remaining) withRemainingWork++
619
652
  row("close", e, "done (rules)", undefined, (e.reasons ?? []).join("; "), actionCell(e.sessionId, d, applied))
620
653
  }
621
654
  for (const e of c.stuck) {
@@ -623,7 +656,8 @@ export function buildReport(b) {
623
656
  row("stuck", e, "abandoned (rules)", undefined, (e.reasons ?? []).join("; ") || "stuck starting, never ran", actionCell(e.sessionId, d, applied))
624
657
  }
625
658
  for (const r of verdicts) {
626
- const d = decideFor(r.entry, "judge", r.verdict, r.confidence, s)
659
+ const d = decideFor(r.entry, "judge", r.verdict, r.confidence, s, { declared: r.source === "declared" })
660
+ if (d.remaining) withRemainingWork++
627
661
  const by = r.source === "declared" ? " (declared)" : r.source === "jev" ? " (jev)" : r.source === "judged" ? " (agent)" : ""
628
662
  const reason = r.jevFallback ? `${r.reason} [jev failed: ${r.jevFallback} → agent judge]` : r.reason
629
663
  row("judge", r.entry, `${r.verdict}${by}`, r.confidence, reason, actionCell(r.entry.sessionId, d, applied))
@@ -645,6 +679,7 @@ export function buildReport(b) {
645
679
  `- candidates: close=${c.close.length} stuck=${c.stuck.length} judge=${verdicts.length}` +
646
680
  (c.judgeOverflow?.length ? ` (+${c.judgeOverflow.length} not judged)` : ""),
647
681
  )
682
+ if (withRemainingWork > 0) lines.push(`- ${withRemainingWork} would-be close(s) downgraded to a flag: the session still has remaining work (open question, announced next step, open PR)`)
648
683
  if (verdicts.length > 0) lines.push(`- verdicts: ${Object.entries(counts).map(([k, n]) => `${k}=${n}`).join(" ")}`)
649
684
  const bySource = { jev: 0, agent: 0 }
650
685
  let fallbacks = 0
@@ -781,7 +816,7 @@ export function scanLive(liveSessions, settings, nowMs) {
781
816
  const endedAt = s.endedAt ?? s.lastActivityAt ?? s.startedAt
782
817
  const endedMs = endedAt ? Date.parse(endedAt) : Number.NaN
783
818
  if (Number.isFinite(endedMs) && nowMs - endedMs <= relabelWindowMs) {
784
- terminalRelabel.push({ sessionId: id, origin: s.origin, originClass, label, proposedVerdict: cand.proposedVerdict, reason: cand.reason, prs: cand.prs ?? [], endedAt, endedMs })
819
+ terminalRelabel.push({ sessionId: id, origin: s.origin, originClass, label, proposedVerdict: cand.proposedVerdict, reason: cand.reason, prs: cand.prs ?? [], repos: cand.repos ?? {}, endedAt, endedMs })
785
820
  }
786
821
  }
787
822
  continue
@@ -811,6 +846,10 @@ export function scanLive(liveSessions, settings, nowMs) {
811
846
  relabelTotal,
812
847
  neverRan: neverRan.length,
813
848
  excluded: excluded.length,
849
+ excludedByReason: excluded.reduce((acc, e) => {
850
+ acc[e.reason] = (acc[e.reason] ?? 0) + 1
851
+ return acc
852
+ }, {}),
814
853
  }
815
854
  return { busy, idle, terminal, terminalRelabel, neverRan, excluded, loopQueue, stallInputs, counts }
816
855
  }
@@ -67,6 +67,25 @@ the `userOrigins` / `closableOrigins` inputs:
67
67
  A trailing `*` in either list is a prefix wildcard. The report carries the
68
68
  origin column and the retained action in dry run as well as apply.
69
69
 
70
+ ## Remaining work (never close a session that still owes something)
71
+
72
+ A merged worktree, an open or merged PR, or an ended parent says the work
73
+ moved on — not that *this* session finished. Before any rule or judged
74
+ `close`, `decideFor` (`entry.mjs`) runs `remainingWork`
75
+ (`cron-rules.mjs`) over the plan entry's `lastAssistantTail` and
76
+ `worktreePrOpen` signals. A last message that asks a question, proposes a
77
+ next step, announces its next action ("Now drive it…"), recommends something, or waits on someone — or a still-open
78
+ worktree PR — turns the close into a `needs-input` flag whose note quotes the
79
+ session's own last words (`flag (remaining work: …)`). Only a clean final
80
+ report closes. Exceptions: a `stuck` session (it never ran) and a session that
81
+ itself declared `STEWARD: DONE`. Not covered yet: red CI and unanswered review
82
+ comments (the daemon exposes neither).
83
+
84
+ The terminal-session relabel (report only) follows the same principle: `done`
85
+ only when the session's *own* recorded PR is merged and nothing is pending; an
86
+ open PR or pending work is `needs-follow-up`; a worktree PR the session never
87
+ recorded (shared worktree) credits nobody. PRs are reported as `owner/repo#N`.
88
+
70
89
  ## Running it
71
90
 
72
91
  ```bash