@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 +2 -2
- package/repo-maintenance/.agentproto/workflows/maintain/WORKFLOW.md +6 -0
- package/repo-maintenance/.agentproto/workflows/maintain/entry.mjs +21 -4
- package/session-steward/.agentproto/workflows/session-steward/WORKFLOW.md +8 -7
- package/session-steward/.agentproto/workflows/session-steward/cron-rules.mjs +62 -19
- package/session-steward/.agentproto/workflows/session-steward/entry.mjs +33 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentproto/apps",
|
|
3
|
-
"version": "0.20.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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).**
|
|
469
|
-
ledger
|
|
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
|
|
482
|
-
verdict-memory ledger (`app_state_append`)
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
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
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 `
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
16
|
-
// append-only verdict-memory ledger (`app_state_append`)
|
|
17
|
-
//
|
|
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.
|
|
927
|
-
*
|
|
928
|
-
*
|
|
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
|
|
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",
|