@agentproto/apps 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/agents-overview/panel.mjs +21 -8
  2. package/dist/agents-overview/panel.mjs.map +1 -1
  3. package/dist/agents-overview.mjs +21 -8
  4. package/dist/agents-overview.mjs.map +1 -1
  5. package/dist/bin/sync.mjs +19 -2
  6. package/dist/bin/sync.mjs.map +1 -1
  7. package/dist/bureau-sessions/panel.mjs +21 -8
  8. package/dist/bureau-sessions/panel.mjs.map +1 -1
  9. package/dist/bureau-sessions.mjs +21 -8
  10. package/dist/bureau-sessions.mjs.map +1 -1
  11. package/dist/index.mjs +63 -20
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/live-session/panel.mjs +21 -8
  14. package/dist/live-session/panel.mjs.map +1 -1
  15. package/dist/live-session.mjs +21 -8
  16. package/dist/live-session.mjs.map +1 -1
  17. package/dist/ops-panel/ui.d.ts +1 -1
  18. package/dist/ops-panel/ui.d.ts.map +1 -1
  19. package/dist/ops-panel.mjs +19 -2
  20. package/dist/ops-panel.mjs.map +1 -1
  21. package/dist/panel-bridge.d.ts.map +1 -1
  22. package/dist/review-panel/panel.d.ts +1 -1
  23. package/dist/review-panel/panel.d.ts.map +1 -1
  24. package/dist/review-panel/panel.generated.d.ts +1 -1
  25. package/dist/review-panel/panel.generated.d.ts.map +1 -1
  26. package/dist/review-panel/panel.mjs +1 -1
  27. package/dist/review-panel/panel.mjs.map +1 -1
  28. package/dist/review-panel.mjs +1 -1
  29. package/dist/review-panel.mjs.map +1 -1
  30. package/dist/session-chat/panel.mjs +21 -8
  31. package/dist/session-chat/panel.mjs.map +1 -1
  32. package/dist/session-chat.mjs +21 -8
  33. package/dist/session-chat.mjs.map +1 -1
  34. package/dist/session-story/panel.mjs +21 -8
  35. package/dist/session-story/panel.mjs.map +1 -1
  36. package/dist/session-story.mjs +21 -8
  37. package/dist/session-story.mjs.map +1 -1
  38. package/dist/sessions-panel/panel.mjs +21 -8
  39. package/dist/sessions-panel/panel.mjs.map +1 -1
  40. package/dist/sessions-panel.mjs +21 -8
  41. package/dist/sessions-panel.mjs.map +1 -1
  42. package/dist/store/panel.d.ts +1 -1
  43. package/dist/store/panel.d.ts.map +1 -1
  44. package/dist/store/panel.generated.d.ts +1 -1
  45. package/dist/store/panel.generated.d.ts.map +1 -1
  46. package/dist/store/panel.mjs +1 -1
  47. package/dist/store/panel.mjs.map +1 -1
  48. package/dist/store.mjs +1 -1
  49. package/dist/store.mjs.map +1 -1
  50. package/dist/work-board/panel.d.ts +1 -1
  51. package/dist/work-board/panel.d.ts.map +1 -1
  52. package/dist/work-board/panel.generated.d.ts +1 -1
  53. package/dist/work-board/panel.generated.d.ts.map +1 -1
  54. package/dist/work-board/panel.mjs +21 -8
  55. package/dist/work-board/panel.mjs.map +1 -1
  56. package/dist/work-board.mjs +21 -8
  57. package/dist/work-board.mjs.map +1 -1
  58. package/package.json +2 -2
  59. package/repo-maintenance/.agentproto/workflows/maintain/WORKFLOW.md +23 -1
  60. package/repo-maintenance/.agentproto/workflows/maintain/entry.mjs +101 -9
  61. package/session-steward/.agentproto/workflows/session-steward/WORKFLOW.md +14 -10
  62. package/session-steward/.agentproto/workflows/session-steward/cron-rules.mjs +62 -19
  63. package/session-steward/.agentproto/workflows/session-steward/entry.mjs +40 -11
  64. package/session-steward/.agentproto/workflows/session-steward/origin-policy.mjs +2 -2
  65. package/session-steward/README.md +1 -1
  66. package/session-steward/routines/session-steward-hourly/ROUTINE.md +3 -3
@@ -91,11 +91,11 @@ inputs:
91
91
  type: array
92
92
  description: >-
93
93
  Origins that may be closed under the current rules. A trailing `*` is a
94
- prefix wildcard. Default `["cron:*", "gate"]`. Executors (a session with
94
+ prefix wildcard. Default `["cron:*", "gate", "workflow", "review"]`. Executors (a session with
95
95
  a `parentSessionId`) are closable regardless.
96
96
  items:
97
97
  type: string
98
- default: ["cron:*", "gate"]
98
+ default: ["cron:*", "gate", "workflow", "review"]
99
99
  outputs: {}
100
100
  steps:
101
101
  - id: modelRoles
@@ -172,6 +172,7 @@ steps:
172
172
  sessionIds: [$item.sessionId]
173
173
  verdict: $item.verdict
174
174
  note: $item.note
175
+ wait: true
175
176
 
176
177
  - id: installedApps
177
178
  kind: tool
@@ -388,11 +389,12 @@ steps:
388
389
  verdict: $item.verdict
389
390
  judgedBy: $item.judgedBy
390
391
  note: $item.note
392
+ wait: true
391
393
 
392
394
  - id: memoryWriteQueue
393
395
  kind: transform
394
396
  name: Verdict memory events to append
395
- description: Entry-based — buildMemoryWriteQueue (a ledger write, never a session action).
397
+ description: Entry-based — buildMemoryWriteQueue (a ledger write, never a session action; empty unless apply).
396
398
 
397
399
  - id: memoryWrite
398
400
  kind: map
@@ -465,8 +467,9 @@ Every rule below is a pure function in `cron-rules.mjs`, pinned by
465
467
  why (`n live, m busy, k terminal, j excluded`).
466
468
  - **Host saturation (9).** If `host_load` is critical, the report lists
467
469
  orphans and big non-session processes FIRST — report only, no action.
468
- - **Verdict memory (10).** Each verdict is written to the app's `app_state`
469
- ledger; a session judged the same verdict on an unchanged evidence
470
+ - **Verdict memory (10).** On an `apply: true` pass each verdict is written to
471
+ the app's `app_state` ledger (a dry run reads the ledger but never writes it,
472
+ so its verdicts cannot be served as cached ones to a later real pass); a session judged the same verdict on an unchanged evidence
470
473
  fingerprint for `stableVerdictPasses` passes is served from cache and not
471
474
  re-judged. Operator disagreements are recorded as examples.
472
475
 
@@ -478,10 +481,10 @@ as observed, never nudged.
478
481
  ## Safety
479
482
 
480
483
  - `apply: false` (the default) mutates no SESSION: every session-mutating map
481
- runs over an empty list. The one write a dry run performs is the append-only
482
- verdict-memory ledger (`app_state_append`) — it never touches a session and
483
- is what lets the cache accumulate across passes. Set `appId: ""` to disable
484
- it entirely.
484
+ runs over an empty list, and a dry run writes nothing else either: the
485
+ append-only verdict-memory ledger (`app_state_append`) is read but only
486
+ appended to on an `apply: true` pass, so a dry run can never influence a later
487
+ real close. Set `appId: ""` to disable the memory entirely.
485
488
  - `session_wrapup_apply` re-classifies each id right before acting and always
486
489
  refuses `keep`-class ids; this workflow never feeds it one.
487
490
  - Rules only ever close `close`/`stuck` ids; a `keepAlive` session is never
@@ -494,7 +497,8 @@ as observed, never nudged.
494
497
  `origin`/`parentSessionId` runs through the pure `decideAction`
495
498
  (`origin-policy.mjs`): a `userOrigins` match (`chat-starter`, `vscode` by
496
499
  default) or a root with no origin and no parent is FLAG-ONLY, even with
497
- `apply: true` and a confident `done` verdict. `cron:*`, `gate`, and
500
+ `apply: true` and a confident `done` verdict. `cron:*`, `gate`, `workflow`
501
+ (workflow-step sessions), `review` (reviewer lanes) and
498
502
  executors (a session with a `parentSessionId`) stay closeable. Both lists
499
503
  are workflow inputs; a trailing `*` is a prefix wildcard.
500
504
  - The report carries an `origin` column and the retained action (e.g.
@@ -64,7 +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 READ_COMMAND = /\b(cat|rg|grep|sed|head|tail|less|view)\b/
67
+ const READ_VERB_WORD = /^["']?(cat|rg|grep|sed|head|tail|less|view)["']?$/
68
68
 
69
69
  /**
70
70
  * The file a read-like call targeted, or `null`. Best-effort: an in-agent
@@ -76,15 +76,26 @@ export function readTargetOf(record) {
76
76
  const tool = String(record?.tool ?? "").toLowerCase()
77
77
  const command = str(record?.command)
78
78
  const args = Array.isArray(record?.args) ? record.args.map(String) : []
79
- const isRead = READ_TOOLS.has(tool) || (command !== undefined && READ_COMMAND.test(command))
80
- if (!isRead) return null
81
- const tokens = command !== undefined ? command.split(/\s+/) : args
82
- for (const raw of tokens) {
83
- const t = raw.replace(/^['"]|['"]$/g, "")
84
- if (!t || t.startsWith("-") || t.includes("=")) continue
85
- if (t.includes("/") || /\.(md|ts|tsx|js|mjs|cjs|json|txt|py|go|rs|sql|yml|yaml|toml)$/.test(t)) return t
79
+ const readTool = READ_TOOLS.has(tool)
80
+ if (!readTool && command === undefined) return null
81
+ // A shell line is several commands: only a segment that STARTS with a read
82
+ // verb reads a file. `cd <dir> &&`, `git log | head` and `--grep=…` do not.
83
+ const segments = command !== undefined && !readTool ? command.split(/&&|\|\||\||;/) : [undefined]
84
+ for (const seg of segments) {
85
+ let tokens = seg !== undefined ? seg.trim().split(/\s+/) : command !== undefined ? command.split(/\s+/) : args
86
+ if (seg !== undefined) {
87
+ const first = tokens.findIndex((t) => !t.includes("=") || t.startsWith("-"))
88
+ if (first < 0 || !READ_VERB_WORD.test(tokens[first])) continue
89
+ tokens = tokens.slice(first + 1)
90
+ }
91
+ for (const raw of tokens) {
92
+ const t = raw.replace(/^['"]|['"]$/g, "")
93
+ 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
95
+ }
96
+ if (seg === undefined) return args[0] ?? null
86
97
  }
87
- return args[0] ?? null
98
+ return null
88
99
  }
89
100
 
90
101
  function topEntry(counts) {
@@ -115,7 +126,11 @@ export function detectLoop(records, opts = {}) {
115
126
  const ts = Date.parse(r?.ts)
116
127
  return Number.isFinite(ts) && ts <= nowMs + 1000 && nowMs - ts <= windowMs
117
128
  })
118
- const calls = inWindow.filter((r) => !isUsefulLoopCommand(callSignature(r)))
129
+ // A record with no command and no args (an in-agent `read`/`edit` call) has
130
+ // nothing but its tool name to compare, so three of them in ten minutes is
131
+ // normal work, not a loop — leave it out of every repetition signal.
132
+ const informative = inWindow.filter((r) => str(r?.command) !== undefined || (Array.isArray(r?.args) && r.args.length > 0))
133
+ const calls = informative.filter((r) => !isUsefulLoopCommand(callSignature(r)))
119
134
  const counts = new Map()
120
135
  const reads = new Map()
121
136
  for (const r of calls) {
@@ -146,7 +161,8 @@ export function detectLoop(records, opts = {}) {
146
161
  ratio: Math.round(ratio * 100) / 100,
147
162
  maxVerbatim,
148
163
  maxReads,
149
- usefulExcluded: inWindow.length - total,
164
+ usefulExcluded: informative.length - total,
165
+ anonymousExcluded: inWindow.length - informative.length,
150
166
  topCommand: top ? top.key : null,
151
167
  topCommandCount: top ? top.count : 0,
152
168
  },
@@ -278,6 +294,9 @@ export function prNumbersOf(session) {
278
294
  return [...nums].sort((a, b) => a - b)
279
295
  }
280
296
 
297
+ /** Relabel verdict for "no positive evidence either way". */
298
+ export const UNKNOWN_VERDICT = "unknown"
299
+
281
300
  const fmtPrs = nums => nums.map(n => `#${n}`).join(", ")
282
301
  const isMergedState = state => state === "merged" || state === "MERGED"
283
302
 
@@ -285,7 +304,9 @@ const isMergedState = state => state === "merged" || state === "MERGED"
285
304
  * A terminal session that still carries no derived outcome and no wrapup
286
305
  * flag is a relabel CANDIDATE — visible instead of invisible, as the log
287
306
  * asks. The proposed verdict is `done` when the session's own record shows a
288
- * PR (merged, or merely opened — the PR is the hand-off), else `abandoned`.
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.
289
310
  * `reason` carries the evidence (`PR #1738 merged`, `PRs #1738, #1740 opened`).
290
311
  */
291
312
  export function terminalRelabelCandidate(session) {
@@ -294,14 +315,21 @@ export function terminalRelabelCandidate(session) {
294
315
  if (session?.wrapupFlag) return { candidate: false, reason: "already flagged" }
295
316
  const prs = prNumbersOf(session)
296
317
  const wt = session?.worktree?.pr
297
- if (isMergedState(wt?.state)) {
298
- const n = Number.isInteger(wt.number) ? [wt.number] : prs
299
- return { candidate: true, proposedVerdict: "done", reason: n.length > 0 ? `PR ${fmtPrs(n)} merged` : "PR merged", prs }
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 }
300
328
  }
301
329
  if (prs.length > 0) {
302
330
  return { candidate: true, proposedVerdict: "done", reason: `PR${prs.length > 1 ? "s" : ""} ${fmtPrs(prs)} opened`, prs }
303
331
  }
304
- return { candidate: true, proposedVerdict: "abandoned", reason: "terminal, no outcome recorded", prs }
332
+ return { candidate: true, proposedVerdict: UNKNOWN_VERDICT, reason: "terminal, no PR recorded — outcome unknown", prs }
305
333
  }
306
334
 
307
335
  /**
@@ -315,19 +343,34 @@ export function refineRelabel(item, evidence) {
315
343
  const wt = evidence.worktree?.pr
316
344
  const state = wt?.state ?? evidence.pullRequests?.state ?? null
317
345
  const known = Array.isArray(item?.prs) ? item.prs : []
318
- const merged = isMergedState(state) || (evidence.pullRequests?.merged ?? 0) > 0
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)
319
353
  if (merged) {
320
354
  const n = Number.isInteger(wt?.number) ? wt.number : known.length === 1 ? known[0] : undefined
321
355
  const others = known.filter(k => k !== n)
322
- const reason = (n !== undefined ? `PR #${n} merged` : "PR merged") + (others.length > 0 && n !== undefined ? `; also opened ${fmtPrs(others)}` : "")
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)}` : "")
323
359
  return { ...item, proposedVerdict: "done", reason }
324
360
  }
325
361
  if (item?.proposedVerdict === "done") return item
326
- if (state === "open" || state === "OPEN") {
362
+ if (credited && (state === "open" || state === "OPEN")) {
327
363
  return { ...item, proposedVerdict: "done", reason: Number.isInteger(wt?.number) ? `PR #${wt.number} open` : "PR open" }
328
364
  }
329
365
  const opened = evidence.pullRequests?.opened ?? 0
330
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) {
369
+ 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)}` }
371
+ }
372
+ if (evidence.turnsCompleted === 0) return { ...item, proposedVerdict: "abandoned", reason: "no PR, no turn ever completed" }
373
+ }
331
374
  return item
332
375
  }
333
376
 
@@ -12,9 +12,10 @@
12
12
  // RE-CLASSIFIES each id itself immediately before acting and refuses
13
13
  // `keep`-class ids outright. On top of that, this workflow:
14
14
  // - mutates no SESSION unless `apply` is true (every session-mutating map
15
- // runs over an empty list otherwise). The one dry-run write is the
16
- // append-only verdict-memory ledger (`app_state_append`) — never a
17
- // session, and disableable with `appId: ""`;
15
+ // runs over an empty list otherwise). A dry run writes nothing at all:
16
+ // the append-only verdict-memory ledger (`app_state_append`) is read but
17
+ // only appended to when `apply` is true, so a dry run's verdicts can
18
+ // never be served as cached verdicts to a later real pass;
18
19
  // - only ever feeds `close`/`stuck` ids to the rules pass — a `keepAlive`
19
20
  // session can only ever be `judge` class, so rules never close it;
20
21
  // - drops the caller's own session from every candidate list;
@@ -664,6 +665,9 @@ export function buildReport(b) {
664
665
  } else if (memory instanceof Map && memory.size > 0) {
665
666
  lines.push(`- verdict memory: ${memory.size} session(s) known` + (cachedCount > 0 ? `, ${cachedCount} served from cache` : ""))
666
667
  }
668
+ if (!s.apply && b.steps.memoryApp?.appId) {
669
+ lines.push("- verdict memory was read but not written (dry run)")
670
+ }
667
671
 
668
672
  return lines.join("\n")
669
673
  }
@@ -804,6 +808,24 @@ export function mergeNeverRan(candidates, scan) {
804
808
  }
805
809
  }
806
810
 
811
+ /** A rule-certain `close` whose session's last turn errored did not finish —
812
+ * parentEnded / merged-worktree only says the parent moved on. Closing it
813
+ * would record `done` on a failed run, so demote it to the judge list. */
814
+ export function demoteErroredCloses(candidates, scan) {
815
+ const errored = new Set((scan?.idle ?? []).filter(r => r?.lastTurnErroredAt).map(r => r.sessionId))
816
+ const close = candidates?.close ?? []
817
+ const demoted = close.filter(e => errored.has(e.sessionId))
818
+ if (demoted.length === 0) return candidates
819
+ return {
820
+ ...candidates,
821
+ close: close.filter(e => !errored.has(e.sessionId)),
822
+ judge: [
823
+ ...(candidates?.judge ?? []),
824
+ ...demoted.map(e => ({ ...e, class: "judge", reasons: [...(e.reasons ?? []), "last turn errored — not auto-closed"] })),
825
+ ],
826
+ }
827
+ }
828
+
807
829
  /** `tool_calls_list` map item → the loop verdict + stats for one session. */
808
830
  export function analyzeLoopItem(b) {
809
831
  const item = b.item ?? {}
@@ -923,10 +945,12 @@ export function applyRelabelEvidence(relabelQueue, evidenceResult) {
923
945
  return (relabelQueue ?? []).map(r => (bySession.has(r.sessionId) ? refineRelabel(r, bySession.get(r.sessionId)) : r))
924
946
  }
925
947
 
926
- /** The `app_state` events to append for this pass's verdicts. The memory is
927
- * written on every pass (it is a ledger, never a session action) so streaks
928
- * accumulate and the cache can engage. */
948
+ /** The `app_state` events to append for this pass's verdicts. Written only on
949
+ * an `apply` pass (so streaks accumulate and the cache can engage); a dry run
950
+ * reads memory but never writes it, or its verdicts would be reused by the
951
+ * next real pass instead of being re-judged. */
929
952
  export function buildMemoryWriteQueue(finalVerdicts, settings) {
953
+ if (!settings?.apply) return []
930
954
  if (!settings?.appId) return []
931
955
  const out = []
932
956
  for (const r of finalVerdicts ?? []) {
@@ -976,7 +1000,7 @@ export default {
976
1000
  appId: { type: "string", description: `Installed app whose \`app_state\` ledger holds the verdict memory. Default ${DEFAULT_APP_ID}.` },
977
1001
  stableVerdictPasses: { type: "number", description: `Consecutive passes on an unchanged evidence fingerprint before the judge cache stops re-judging. Default ${DEFAULT_STABLE_VERDICT_PASSES}.`, default: DEFAULT_STABLE_VERDICT_PASSES },
978
1002
  userOrigins: { type: "array", description: `Origins that are ALWAYS flag-only, never closed (a human is in the loop). Trailing \`*\` is a prefix wildcard. Default ${JSON.stringify(DEFAULT_USER_ORIGINS)}.`, items: { type: "string" }, default: DEFAULT_USER_ORIGINS },
979
- closableOrigins: { type: "array", description: `Origins that may be closed under the current rules (cron jobs, gates). Trailing \`*\` is a prefix wildcard. Executors (a session with a parentSessionId) are closable regardless. Default ${JSON.stringify(DEFAULT_CLOSABLE_ORIGINS)}.`, items: { type: "string" }, default: DEFAULT_CLOSABLE_ORIGINS },
1003
+ closableOrigins: { type: "array", description: `Origins that may be closed under the current rules (cron jobs, gates, workflow steps, reviewer lanes). Trailing \`*\` is a prefix wildcard. Executors (a session with a parentSessionId) are closable regardless. Default ${JSON.stringify(DEFAULT_CLOSABLE_ORIGINS)}.`, items: { type: "string" }, default: DEFAULT_CLOSABLE_ORIGINS },
980
1004
  },
981
1005
  outputs: {},
982
1006
  steps: [
@@ -1000,7 +1024,7 @@ export default {
1000
1024
  { id: "liveSessions", kind: "tool", tool: "session_list", inputs: { full: true } },
1001
1025
  { id: "scan", kind: "transform", compute: b => scanLive(b.steps.liveSessions, b.steps.settings, Date.now()) },
1002
1026
  // Never-ran 0/0 sessions are `stuck` immediately, never judged (item 3).
1003
- { id: "candidatesPlus", kind: "transform", compute: b => mergeNeverRan(b.steps.candidates, b.steps.scan) },
1027
+ { id: "candidatesPlus", kind: "transform", compute: b => demoteErroredCloses(mergeNeverRan(b.steps.candidates, b.steps.scan), b.steps.scan) },
1004
1028
  { id: "ruleApplyQueue", kind: "transform", compute: b => buildRuleApplyQueue(b.steps.candidatesPlus, b.steps.settings) },
1005
1029
  {
1006
1030
  // Empty unless `apply` — a dry run dispatches no apply call at all.
@@ -1014,7 +1038,10 @@ export default {
1014
1038
  id: "autoApplyOne",
1015
1039
  kind: "tool",
1016
1040
  tool: "session_wrapup_apply",
1017
- inputs: { sessionIds: ["$item.sessionId"], verdict: "$item.verdict", note: "$item.note" },
1041
+ // `wait: true` — past its 25 s default `waitMs` the tool returns a
1042
+ // bare `{ jobId, status: "running" }` and the report would show no
1043
+ // outcome for a session that did get closed.
1044
+ inputs: { sessionIds: ["$item.sessionId"], verdict: "$item.verdict", note: "$item.note", wait: true },
1018
1045
  },
1019
1046
  ],
1020
1047
  },
@@ -1205,12 +1232,14 @@ export default {
1205
1232
  id: "judgedApplyOne",
1206
1233
  kind: "tool",
1207
1234
  tool: "session_wrapup_apply",
1208
- inputs: { sessionIds: ["$item.sessionId"], verdict: "$item.verdict", judgedBy: "$item.judgedBy", note: "$item.note" },
1235
+ // `wait: true`: same as autoApplyOne.
1236
+ inputs: { sessionIds: ["$item.sessionId"], verdict: "$item.verdict", judgedBy: "$item.judgedBy", note: "$item.note", wait: true },
1209
1237
  },
1210
1238
  ],
1211
1239
  },
1212
1240
  // Verdict memory write-back (item 10) — a ledger append, never a session
1213
- // action; best-effort (an uninstalled app just yields no memory).
1241
+ // action; empty unless `apply`; best-effort (an uninstalled app just
1242
+ // yields no memory).
1214
1243
  { id: "memoryWriteQueue", kind: "transform", compute: b => buildMemoryWriteQueue(b.steps.finalVerdicts, { ...b.steps.settings, appId: b.steps.memoryApp?.appId ?? "" }) },
1215
1244
  {
1216
1245
  id: "memoryWrite",
@@ -12,7 +12,7 @@ export const DEFAULT_USER_ORIGINS = ["chat-starter", "vscode"]
12
12
  /** Origins the steward MAY close under the current rules. `cron:*` matches
13
13
  * every cron-spawned job (`origin: "cron:<jobId>"`); `gate` matches
14
14
  * supervision-gate sessions. */
15
- export const DEFAULT_CLOSABLE_ORIGINS = ["cron:*", "gate"]
15
+ export const DEFAULT_CLOSABLE_ORIGINS = ["cron:*", "gate", "workflow", "review"]
16
16
 
17
17
  /** The reason shown (and recorded) when a would-be close is bounded by origin. */
18
18
  export const USER_ORIGIN_REASON = "flag (origine utilisateur)"
@@ -46,7 +46,7 @@ export function resolveOriginPolicy(policy) {
46
46
  /** The origin class of a candidate:
47
47
  * - `"user"` — human-launched: a `userOrigins` match, OR a root with no
48
48
  * origin and no parent. FLAG ONLY, never close.
49
- * - `"closable"` — a `closableOrigins` match (cron:*, gate) or an executor
49
+ * - `"closable"` — a `closableOrigins` match (cron:*, gate, workflow, review) or an executor
50
50
  * (has a `parentSessionId`). Close allowed under the current rules.
51
51
  * `userOrigins` wins over both `closableOrigins` and the executor rule, so a
52
52
  * `vscode` executor is still user-origin. An unrecognized root origin is
@@ -53,7 +53,7 @@ the `userOrigins` / `closableOrigins` inputs:
53
53
  `origin` and no `parentSessionId` (a human launched it). A would-be close —
54
54
  even a rule-certain `close`/`stuck`, even a confident `done` — is recorded
55
55
  as a `needs-input` flag with reason `flag (origine utilisateur)`.
56
- - **Close allowed:** `cron:*`, `gate`, and executors (a session with a
56
+ - **Close allowed:** `cron:*`, `gate`, `workflow` (step sessions), `review` (reviewer lanes), and executors (a session with a
57
57
  `parentSessionId`).
58
58
 
59
59
  A trailing `*` in either list is a prefix wildcard. The report carries the
@@ -23,10 +23,10 @@ target:
23
23
  askSessions: false
24
24
  # Origin policy (the committed default): never close a human's session.
25
25
  # `chat-starter`/`vscode` (and any root with no origin and no parent) are
26
- # FLAG-ONLY; `cron:*` jobs, `gate` sessions, and executors (a session with
26
+ # FLAG-ONLY; `cron:*` jobs, `gate`, `workflow` and `review` sessions, and executors (a session with
27
27
  # a parentSessionId) stay closeable. A trailing `*` is a prefix wildcard.
28
28
  userOrigins: ["chat-starter", "vscode"]
29
- closableOrigins: ["cron:*", "gate"]
29
+ closableOrigins: ["cron:*", "gate", "workflow", "review"]
30
30
  retry:
31
31
  max_attempts: 1
32
32
  backoff: fixed
@@ -68,7 +68,7 @@ The steward bounds every action by the candidate's `origin` (pure
68
68
  `origin` and no `parentSessionId` (a human launched it). A would-be close —
69
69
  even a rule-certain `close`/`stuck`, even a confident `done` — becomes a
70
70
  `needs-input` flag with reason `flag (origine utilisateur)`.
71
- - **Close allowed:** `cron:*` (any cron job), `gate`, and executors (a
71
+ - **Close allowed:** `cron:*` (any cron job), `gate`, `workflow` (workflow-step sessions), `review` (reviewer lanes), and executors (a
72
72
  session with a `parentSessionId`).
73
73
 
74
74
  Both lists are workflow inputs (`userOrigins`, `closableOrigins`); the values