@agentproto/apps 0.20.2 → 0.20.3

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.20.2",
3
+ "version": "0.20.3",
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",
@@ -175,7 +175,7 @@
175
175
  "zod": "^4.6.5",
176
176
  "@agentproto/agent": "0.2.5",
177
177
  "@agentproto/app-client": "0.4.1",
178
- "@agentproto/app-kit": "1.6.1",
178
+ "@agentproto/app-kit": "1.6.2",
179
179
  "@agentproto/workflow": "0.7.1"
180
180
  },
181
181
  "peerDependencies": {
@@ -265,6 +265,12 @@ it when the item ends; `reviewCleanup` removes them all after the map, and
265
265
  entry.mjs's `finally` block removes them once more on failure or cancel — a
266
266
  `finally` step always runs.
267
267
 
268
+ A review worktree sits outside every app zone, so a reviewer spawned there
269
+ gets two extra READ-ONLY zones for its own session: the worktree and the
270
+ repo's git dir (`git log/show/diff` read the object store). Both are checked
271
+ against git's own worktree back-pointer first, and both are read-only, so a
272
+ reviewer still cannot stash, checkout or commit in the shared repo.
273
+
268
274
  ## Out of scope, on purpose
269
275
 
270
276
  Recording a verdict via `branch_gc_verdict` never reclaims a branch by
@@ -258,13 +258,30 @@ export function formatNameList(names, cap = REPORT_LIST_CAP) {
258
258
  return names.length > cap ? `${shown}, … and ${names.length - cap} more` : shown
259
259
  }
260
260
 
261
- /** Distinct error messages of a tolerant fan-out's rejected items, most
261
+ /** `error` with the per-candidate parts — its review worktree path, branch
262
+ * name and tip sha, then any other full sha — replaced by placeholders, so
263
+ * one systemic failure hitting N candidates groups as ONE reason instead of
264
+ * N "distinct" ones that differ only by `…/repo-<sha>`. */
265
+ export function normalizeReviewError(error, candidate) {
266
+ let out = String(error ?? "")
267
+ const swap = (needle, placeholder) => {
268
+ if (typeof needle === "string" && needle.length > 0) out = out.split(needle).join(placeholder)
269
+ }
270
+ swap(candidate?.reviewWorktree, "<review worktree>")
271
+ swap(candidate?.sha, "<sha>")
272
+ swap(candidate?.name, "<branch>")
273
+ return out.replace(/\b[0-9a-f]{40}\b/g, "<sha>")
274
+ }
275
+
276
+ /** Distinct error messages of a tolerant fan-out's rejected items (normalized
277
+ * by {@link normalizeReviewError} against `reviewCandidates[index]`), most
262
278
  * frequent first: `[{ error, count }]`. */
263
- export function tallyReviewErrors(review) {
279
+ export function tallyReviewErrors(review, reviewCandidates) {
264
280
  const counts = new Map()
265
281
  for (const r of Array.isArray(review?.results) ? review.results : []) {
266
282
  if (r?.status !== "rejected") continue
267
- counts.set(r.error, (counts.get(r.error) ?? 0) + 1)
283
+ const error = normalizeReviewError(r.error, r.item ?? reviewCandidates?.[r.index])
284
+ counts.set(error, (counts.get(error) ?? 0) + 1)
268
285
  }
269
286
  return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count)
270
287
  }
@@ -363,7 +380,7 @@ export function buildReport(b) {
363
380
  const reviewOutcome = Array.isArray(review)
364
381
  ? { succeeded: review.length, failed: 0, skipped: 0 }
365
382
  : { succeeded: 0, failed: 0, skipped: 0, ...(review ?? {}) }
366
- const reviewErrors = tallyReviewErrors(review)
383
+ const reviewErrors = tallyReviewErrors(review, reviewCandidates)
367
384
  const gaps = b.steps.gaps ?? []
368
385
  const verdicts = collectCandidateVerdicts(reviewCandidates, b.steps.branchGcVerify)
369
386
  const applyMerged = b.input?.applyMerged === true
@@ -392,7 +392,7 @@ steps:
392
392
  - id: memoryWriteQueue
393
393
  kind: transform
394
394
  name: Verdict memory events to append
395
- description: Entry-based — buildMemoryWriteQueue (a ledger write, never a session action).
395
+ description: Entry-based — buildMemoryWriteQueue (a ledger write, never a session action; empty unless apply).
396
396
 
397
397
  - id: memoryWrite
398
398
  kind: map
@@ -465,8 +465,9 @@ Every rule below is a pure function in `cron-rules.mjs`, pinned by
465
465
  why (`n live, m busy, k terminal, j excluded`).
466
466
  - **Host saturation (9).** If `host_load` is critical, the report lists
467
467
  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
468
+ - **Verdict memory (10).** On an `apply: true` pass each verdict is written to
469
+ the app's `app_state` ledger (a dry run reads the ledger but never writes it,
470
+ 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
471
  fingerprint for `stableVerdictPasses` passes is served from cache and not
471
472
  re-judged. Operator disagreements are recorded as examples.
472
473
 
@@ -478,10 +479,10 @@ as observed, never nudged.
478
479
  ## Safety
479
480
 
480
481
  - `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.
482
+ runs over an empty list, and a dry run writes nothing else either: the
483
+ append-only verdict-memory ledger (`app_state_append`) is read but only
484
+ appended to on an `apply: true` pass, so a dry run can never influence a later
485
+ real close. Set `appId: ""` to disable the memory entirely.
485
486
  - `session_wrapup_apply` re-classifies each id right before acting and always
486
487
  refuses `keep`-class ids; this workflow never feeds it one.
487
488
  - Rules only ever close `close`/`stuck` ids; a `keepAlive` session is never
@@ -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 ?? []) {
@@ -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.
@@ -1210,7 +1234,8 @@ export default {
1210
1234
  ],
1211
1235
  },
1212
1236
  // Verdict memory write-back (item 10) — a ledger append, never a session
1213
- // action; best-effort (an uninstalled app just yields no memory).
1237
+ // action; empty unless `apply`; best-effort (an uninstalled app just
1238
+ // yields no memory).
1214
1239
  { id: "memoryWriteQueue", kind: "transform", compute: b => buildMemoryWriteQueue(b.steps.finalVerdicts, { ...b.steps.settings, appId: b.steps.memoryApp?.appId ?? "" }) },
1215
1240
  {
1216
1241
  id: "memoryWrite",