@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 +3 -3
- package/session-steward/.agentproto/workflows/session-steward/WORKFLOW.md +4 -3
- package/session-steward/.agentproto/workflows/session-steward/cron-rules.mjs +198 -56
- package/session-steward/.agentproto/workflows/session-steward/entry.mjs +47 -8
- package/session-steward/README.md +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentproto/apps",
|
|
3
|
-
"version": "0.21.
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
-
|
|
319
|
-
|
|
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:
|
|
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
|
|
337
|
-
* PR
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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 ??
|
|
388
|
+
const state = wt?.state ?? null
|
|
345
389
|
const known = Array.isArray(item?.prs) ? item.prs : []
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
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 :
|
|
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
|