session-orchestrator 4.2.0 → 5.0.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.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -258,8 +258,10 @@ if (isMain) {
|
|
|
258
258
|
const inFlight = inFlightFilesFromLogFile(logPath);
|
|
259
259
|
if (inFlight.length > 0) {
|
|
260
260
|
console.error(
|
|
261
|
-
`[ci]
|
|
262
|
-
`(
|
|
261
|
+
`[ci] KILL ARTEFACT — the shard timed out or was killed before vitest wrote its ` +
|
|
262
|
+
`result file (NOT a test failure). ${inFlight.length} file(s) were merely in-flight ` +
|
|
263
|
+
`at the kill: ${inFlight.join(', ')}. Re-run the shard; if it repeats, check runner ` +
|
|
264
|
+
`load/sharding before suspecting these files.`,
|
|
263
265
|
);
|
|
264
266
|
}
|
|
265
267
|
}
|
|
@@ -182,13 +182,21 @@ function countContentLines(body) {
|
|
|
182
182
|
* dry-run mode, where there is no `mergePeerCard()` stats object to read).
|
|
183
183
|
* Pure function — no I/O.
|
|
184
184
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
185
|
+
* Resolution order:
|
|
186
|
+
* 1. non-string or empty body → 0 (the response never touched the target);
|
|
187
|
+
* 2. `<!-- BEGIN MANAGED: <name> -->` sentinels present (the grammar
|
|
188
|
+
* `scripts/lib/peer-cards/merger.mjs` parses) → their count;
|
|
189
|
+
* 3. otherwise the count of level-2 `## ` headings — the deriver's actual
|
|
190
|
+
* output contract per {@link buildPrompt} is a bare full-body
|
|
191
|
+
* replacement structured by `## ` headings, not sentinels (#1319: the
|
|
192
|
+
* old sentinel-or-1 rule reported 1 for a 12-section body). Headings
|
|
193
|
+
* inside ``` / ~~~ code fences are NOT counted;
|
|
194
|
+
* 4. a non-empty body with neither → 1 (a full-body replacement is still a
|
|
195
|
+
* real change to the target).
|
|
196
|
+
*
|
|
197
|
+
* Dry-run only. Apply mode counts `mergePeerCard()` stats instead
|
|
198
|
+
* (`replaced + appended`, i.e. merged sentinel sections) — the two modes
|
|
199
|
+
* measure different quantities on the same event field.
|
|
192
200
|
*
|
|
193
201
|
* @param {string | undefined} diffText — `diff.user` or `diff.agent` from
|
|
194
202
|
* {@link parseResponse}; `undefined` when the target was not emitted.
|
|
@@ -197,7 +205,23 @@ function countContentLines(body) {
|
|
|
197
205
|
export function countManagedSections(diffText) {
|
|
198
206
|
if (typeof diffText !== 'string' || diffText.length === 0) return 0;
|
|
199
207
|
const matches = diffText.match(/<!--\s*BEGIN\s+MANAGED:\s*[\w-]+\s*-->/g);
|
|
200
|
-
|
|
208
|
+
if (matches && matches.length > 0) return matches.length;
|
|
209
|
+
let headings = 0;
|
|
210
|
+
let fence = null; // { char, len } while inside a code fence
|
|
211
|
+
for (const line of diffText.split(/\r?\n/)) {
|
|
212
|
+
const f = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
213
|
+
if (fence) {
|
|
214
|
+
// CommonMark close: same char, at least as long, no info string.
|
|
215
|
+
if (f && f[1][0] === fence.char && f[1].length >= fence.len && f[2].trim() === '') fence = null;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (f) {
|
|
219
|
+
fence = { char: f[1][0], len: f[1].length };
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (/^## /.test(line)) headings += 1;
|
|
223
|
+
}
|
|
224
|
+
return headings > 0 ? headings : 1;
|
|
201
225
|
}
|
|
202
226
|
|
|
203
227
|
/**
|
package/scripts/emit-session.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* {"action":"appended","path":"<file>","session_id":"<id>","schema_version":2}
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
28
|
import { dirname, join } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { appendJsonl } from './lib/common.mjs';
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
deriveMemoryCleanupSignal,
|
|
34
34
|
stampMemoryCleanup,
|
|
35
35
|
} from './lib/memory-cleanup-stamp.mjs';
|
|
36
|
+
import { parseStateMd, readSessionProfile, resolveStateMdPath } from './lib/state-md.mjs';
|
|
36
37
|
import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
|
|
37
38
|
import {
|
|
38
39
|
validateSession,
|
|
@@ -153,6 +154,76 @@ async function main() {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// `session_profile` derivation (#1247 — GitLab issue "key the effective-sizing
|
|
158
|
+
// row on waves x profile"). The mandatory write path is the coordinator
|
|
159
|
+
// composing `$METRICS_ENTRY` in prose (session-metrics-write.md), which never
|
|
160
|
+
// reliably set this field — so it is read here instead, from this repo's own
|
|
161
|
+
// STATE.md frontmatter via `readSessionProfile()` (scripts/lib/state-md.mjs).
|
|
162
|
+
//
|
|
163
|
+
// Precedence: an EXPLICIT `session_profile` KEY on the incoming record (even
|
|
164
|
+
// `null`) wins and is never overwritten — same "explicit assertion beats
|
|
165
|
+
// derivation" convention as `memory_cleanup_at` above. Absence of a profile in
|
|
166
|
+
// STATE.md (readSessionProfile returns null) leaves the field OMITTED —
|
|
167
|
+
// never coerced to `''`/`'none'`/a literal `"null"` string.
|
|
168
|
+
//
|
|
169
|
+
// OWNERSHIP: STATE.md belongs to the session named in its OWN frontmatter
|
|
170
|
+
// (`session:`), which in a shared working copy need not be the session this
|
|
171
|
+
// record describes — two parallel sessions, or a `/close` run after a
|
|
172
|
+
// foreign `/plan` session left its STATE.md behind (see
|
|
173
|
+
// `.claude/rules/parallel-sessions.md`). Filing session B's waves under
|
|
174
|
+
// session A's profile is exactly the cross-contamination #1247 removes, only
|
|
175
|
+
// inverted. So the profile is adopted ONLY when the frontmatter `session`
|
|
176
|
+
// equals this record's `session_id`; on any mismatch (or an unprovable
|
|
177
|
+
// ownership, i.e. no `session` key) the field is OMITTED — never guessed.
|
|
178
|
+
//
|
|
179
|
+
// FAIL-SAFE: the read is enrichment; the session record is the load-bearing
|
|
180
|
+
// artefact. Any failure reading or parsing STATE.md (EISDIR when the path is
|
|
181
|
+
// a directory, EACCES/EPERM, a truncated file) omits the field and lets the
|
|
182
|
+
// record through — it must never be the reason `sessions.jsonl` gains no
|
|
183
|
+
// line at all.
|
|
184
|
+
if (!Object.prototype.hasOwnProperty.call(repaired, 'session_profile')) {
|
|
185
|
+
let stateMdContents = '';
|
|
186
|
+
try {
|
|
187
|
+
const stateMdPath = resolveStateMdPath(process.cwd());
|
|
188
|
+
if (existsSync(stateMdPath)) stateMdContents = readFileSync(stateMdPath, 'utf8');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
process.stderr.write(
|
|
191
|
+
`emit-session: WARN could not read STATE.md for session_profile derivation ` +
|
|
192
|
+
`(${err?.message ?? err}); omitting session_profile\n`
|
|
193
|
+
);
|
|
194
|
+
stateMdContents = '';
|
|
195
|
+
}
|
|
196
|
+
// No initialiser: both the try and the catch below assign `profile`.
|
|
197
|
+
let profile;
|
|
198
|
+
let owner = null;
|
|
199
|
+
try {
|
|
200
|
+
profile = readSessionProfile(stateMdContents);
|
|
201
|
+
const parsed = parseStateMd(stateMdContents);
|
|
202
|
+
const rawOwner = parsed?.frontmatter?.session;
|
|
203
|
+
owner = typeof rawOwner === 'string' && rawOwner.trim().length > 0 ? rawOwner.trim() : null;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`emit-session: WARN could not parse STATE.md for session_profile derivation ` +
|
|
207
|
+
`(${err?.message ?? err}); omitting session_profile\n`
|
|
208
|
+
);
|
|
209
|
+
profile = null;
|
|
210
|
+
}
|
|
211
|
+
if (profile !== null) {
|
|
212
|
+
if (owner !== null && owner === repaired.session_id) {
|
|
213
|
+
repaired = { ...repaired, session_profile: profile };
|
|
214
|
+
} else {
|
|
215
|
+
// A visible omission: silence here is indistinguishable from "STATE.md
|
|
216
|
+
// carries no profile", and a foreign STATE.md in this working copy is
|
|
217
|
+
// precisely what the operator wants to know about.
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
`emit-session: WARN STATE.md session_profile=${profile} belongs to ` +
|
|
220
|
+
`session=${owner ?? '<absent>'}, not session_id=${repaired.session_id ?? '<unknown>'}; ` +
|
|
221
|
+
`omitting session_profile\n`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
156
227
|
let validated;
|
|
157
228
|
try {
|
|
158
229
|
validated = validateSession(repaired);
|
|
@@ -8,6 +8,30 @@
|
|
|
8
8
|
* - `agent-status.jsonl` — append-only event log (one record per push).
|
|
9
9
|
* - `agent-status-current.json` — last-write-wins (LWW) map keyed by agentId.
|
|
10
10
|
*
|
|
11
|
+
* ## Source-of-truth contract (#1342)
|
|
12
|
+
*
|
|
13
|
+
* **`agent-status.jsonl` is the SOURCE OF TRUTH of this status channel. The
|
|
14
|
+
* current-map is a REBUILDABLE CACHE of it.** `pushRecord()` appends to the
|
|
15
|
+
* ledger FIRST and only then takes `agent-status.lock` to update the map, so
|
|
16
|
+
* every failure of the second half (lock timeout because a foreign-host lock is
|
|
17
|
+
* held, process death between the two writes, map corruption) leaves a ledger
|
|
18
|
+
* that is AHEAD of the cache. Before #1342 the map was read as if current in
|
|
19
|
+
* exactly those cases and the operator saw `running` for an agent that had
|
|
20
|
+
* already reported `completed`.
|
|
21
|
+
*
|
|
22
|
+
* Therefore:
|
|
23
|
+
* - `rebuildCurrentFromLedger()` folds a bounded TAIL of the ledger into the
|
|
24
|
+
* same map shape and is the authority whenever it is newer than the cache.
|
|
25
|
+
* - `readCurrentStatus()` returns PROVENANCE (`source`, `at`, `degraded`) so a
|
|
26
|
+
* consumer can never mistake a stale cache for live state. It NEVER invents
|
|
27
|
+
* a state: an agent visible only in a discarded partial line is `unknown`,
|
|
28
|
+
* not `running`.
|
|
29
|
+
* - Neither reader writes anything — a rebuild is idempotent on disk and must
|
|
30
|
+
* stay that way (it is a read path in hooks and a tmux poll loop).
|
|
31
|
+
*
|
|
32
|
+
* This contract is local to the agent-status channel. It changes nothing about
|
|
33
|
+
* STATE.md ownership or quality-gate semantics.
|
|
34
|
+
*
|
|
11
35
|
* Best-effort telemetry contract: a status push must NEVER crash or block a
|
|
12
36
|
* wave. Every exported function is no-throw and returns a structured result:
|
|
13
37
|
*
|
|
@@ -29,6 +53,7 @@
|
|
|
29
53
|
|
|
30
54
|
import fs from 'node:fs';
|
|
31
55
|
import path from 'node:path';
|
|
56
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
32
57
|
|
|
33
58
|
import { appendJsonl } from './common.mjs';
|
|
34
59
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
@@ -51,6 +76,31 @@ const POLL_MS = 100;
|
|
|
51
76
|
// envelope (keys, ts, numbers).
|
|
52
77
|
const MAX_TEXT_LEN = 256;
|
|
53
78
|
|
|
79
|
+
// Bounded rebuild window (#1342). A wave's live status traffic is a handful of
|
|
80
|
+
// records per agent, so the newest state always sits in the last few KiB —
|
|
81
|
+
// 256 KiB is ~1500 typical 170-byte records, three orders of magnitude of
|
|
82
|
+
// headroom, and it keeps the rebuild a single bounded read instead of a scan of
|
|
83
|
+
// an unbounded append-only file.
|
|
84
|
+
// CEILING: an agent whose newest record sits FURTHER back than `maxBytes` is
|
|
85
|
+
// invisible to the rebuild (it stays whatever the cache says, or absent).
|
|
86
|
+
// REVISIT-TRIGGER: if a session ever pushes more than ~1500 status records, or
|
|
87
|
+
// if a rebuilt view is observed missing a live agent, raise this constant or
|
|
88
|
+
// switch to a reverse-chunked scan that stops once every known agentId is seen.
|
|
89
|
+
const DEFAULT_REBUILD_MAX_BYTES = 256 * 1024;
|
|
90
|
+
|
|
91
|
+
// Identity fields a record may carry to bind it to a run/session/wave. Records
|
|
92
|
+
// carrying none of them are `legacy` — they are NEVER auto-assigned to the
|
|
93
|
+
// newest wave (#1342 item 4).
|
|
94
|
+
const BINDING_KEYS = ['sessionId', 'session_id', 'runId', 'run_id', 'waveKey', 'wave_key', 'wave'];
|
|
95
|
+
|
|
96
|
+
// agentIds that must never become a map key. `entries['__proto__'] = rec` on a
|
|
97
|
+
// normal object REPLACES the prototype instead of storing the record (the record
|
|
98
|
+
// vanishes AND every later lookup walks a foreign prototype), and
|
|
99
|
+
// `constructor`/`prototype` are the same class of confusion. Every map this
|
|
100
|
+
// module builds is null-prototype, so the assignment itself is safe — these keys
|
|
101
|
+
// are rejected outright so a poisoned ledger line cannot reappear as an agent.
|
|
102
|
+
const UNSAFE_MAP_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
103
|
+
|
|
54
104
|
// ---------------------------------------------------------------------------
|
|
55
105
|
// Path helpers
|
|
56
106
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +125,22 @@ function lockPathFor(repoRoot) {
|
|
|
75
125
|
// Internal utilities
|
|
76
126
|
// ---------------------------------------------------------------------------
|
|
77
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Copy a plain object into a NULL-PROTOTYPE map, dropping `UNSAFE_MAP_KEYS`.
|
|
130
|
+
* `JSON.parse` happily produces an OWN `__proto__` property, so a poisoned cache
|
|
131
|
+
* file would otherwise travel into a consumer's `entries` map.
|
|
132
|
+
* @param {Record<string, object>|null|undefined} obj
|
|
133
|
+
* @returns {Record<string, object>}
|
|
134
|
+
*/
|
|
135
|
+
function nullProtoMap(obj) {
|
|
136
|
+
const out = Object.create(null);
|
|
137
|
+
for (const k of Object.keys(obj ?? {})) {
|
|
138
|
+
if (UNSAFE_MAP_KEYS.has(k)) continue;
|
|
139
|
+
out[k] = obj[k];
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
78
144
|
/**
|
|
79
145
|
* Truncate a free-text field to MAX_TEXT_LEN so a single JSONL line stays under
|
|
80
146
|
* the PIPE_BUF floor. Non-strings pass through untouched.
|
|
@@ -175,11 +241,21 @@ function releaseLock(lockFile, _myBody) {
|
|
|
175
241
|
* @returns {Record<string, object>}
|
|
176
242
|
*/
|
|
177
243
|
function readCurrentMap(currentFile) {
|
|
244
|
+
return readCurrentMapDetailed(currentFile).entries;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Same read as `readCurrentMap`, but it reports WHY the map is empty (#1342) —
|
|
249
|
+
* "absent" and "unreadable" are different facts, and only the second one makes
|
|
250
|
+
* the ledger the authority for provenance purposes.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} currentFile
|
|
253
|
+
* @returns {{ ok: boolean, entries: Record<string, object>, reason: 'ok'|'cache-missing'|'cache-unreadable' }}
|
|
254
|
+
*/
|
|
255
|
+
function readCurrentMapDetailed(currentFile) {
|
|
256
|
+
let raw;
|
|
178
257
|
try {
|
|
179
|
-
|
|
180
|
-
const obj = JSON.parse(raw);
|
|
181
|
-
if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
|
|
182
|
-
return {};
|
|
258
|
+
raw = fs.readFileSync(currentFile, 'utf8');
|
|
183
259
|
} catch (err) {
|
|
184
260
|
if (!err || err.code !== 'ENOENT') {
|
|
185
261
|
process.stderr.write(
|
|
@@ -187,8 +263,73 @@ function readCurrentMap(currentFile) {
|
|
|
187
263
|
`(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
|
|
188
264
|
'treating as EMPTY, counts below are floors\n',
|
|
189
265
|
);
|
|
266
|
+
return { ok: false, entries: Object.create(null), reason: 'cache-unreadable' };
|
|
267
|
+
}
|
|
268
|
+
return { ok: false, entries: Object.create(null), reason: 'cache-missing' };
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const obj = JSON.parse(raw);
|
|
272
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
273
|
+
return { ok: true, entries: nullProtoMap(obj), reason: 'ok' };
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* fall through to the unreadable verdict below */
|
|
277
|
+
}
|
|
278
|
+
return { ok: false, entries: Object.create(null), reason: 'cache-unreadable' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse a record's `ts` into epoch millis. Returns null when absent/unparseable
|
|
283
|
+
* — such a record is never allowed to win a fold on timestamp grounds.
|
|
284
|
+
* @param {*} rec
|
|
285
|
+
* @returns {number|null}
|
|
286
|
+
*/
|
|
287
|
+
function recordTsMs(rec) {
|
|
288
|
+
const ms = Date.parse(rec?.ts ?? '');
|
|
289
|
+
return Number.isFinite(ms) ? ms : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Newest parsable record timestamp across a map's values, or null.
|
|
294
|
+
* @param {Record<string, object>} entries
|
|
295
|
+
* @returns {number|null}
|
|
296
|
+
*/
|
|
297
|
+
function newestTsMs(entries) {
|
|
298
|
+
let newest = null;
|
|
299
|
+
for (const rec of Object.values(entries ?? {})) {
|
|
300
|
+
const ms = recordTsMs(rec);
|
|
301
|
+
if (ms !== null && (newest === null || ms > newest)) newest = ms;
|
|
302
|
+
}
|
|
303
|
+
return newest;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Read the last `maxBytes` of a file. Returns the raw text plus whether the
|
|
308
|
+
* window cut into the file (i.e. the first line in `text` may be partial).
|
|
309
|
+
* @param {string} file
|
|
310
|
+
* @param {number} maxBytes
|
|
311
|
+
* @returns {{ text: string, size: number, cut: boolean }}
|
|
312
|
+
*/
|
|
313
|
+
function readTail(file, maxBytes) {
|
|
314
|
+
const fd = fs.openSync(file, 'r');
|
|
315
|
+
try {
|
|
316
|
+
const size = fs.fstatSync(fd).size;
|
|
317
|
+
const want = Math.min(size, maxBytes);
|
|
318
|
+
const start = size - want;
|
|
319
|
+
const buf = Buffer.allocUnsafe(want);
|
|
320
|
+
let read = 0;
|
|
321
|
+
while (read < want) {
|
|
322
|
+
const n = fs.readSync(fd, buf, read, want - read, start + read);
|
|
323
|
+
if (n <= 0) break;
|
|
324
|
+
read += n;
|
|
325
|
+
}
|
|
326
|
+
return { text: buf.subarray(0, read).toString('utf8'), size, cut: start > 0 };
|
|
327
|
+
} finally {
|
|
328
|
+
try {
|
|
329
|
+
fs.closeSync(fd);
|
|
330
|
+
} catch {
|
|
331
|
+
/* best-effort */
|
|
190
332
|
}
|
|
191
|
-
return {};
|
|
192
333
|
}
|
|
193
334
|
}
|
|
194
335
|
|
|
@@ -304,11 +445,302 @@ export async function setProgress(agentId, progress = {}, opts = {}) {
|
|
|
304
445
|
}
|
|
305
446
|
|
|
306
447
|
/**
|
|
307
|
-
*
|
|
448
|
+
* Rebuild the current-status map from a bounded TAIL of the append-only ledger
|
|
449
|
+
* (#1342). The ledger is the source of truth; this is how a consumer reads it
|
|
450
|
+
* without trusting the cache. READ-ONLY and idempotent — it never writes the
|
|
451
|
+
* ledger, the cache, or anything else. No-throw.
|
|
452
|
+
*
|
|
453
|
+
* Fold rule (identity binding): one entry per `agentId`, won by the greatest
|
|
454
|
+
* parsable `ts`; ties go to the later line. A record whose `ts` is absent or
|
|
455
|
+
* unparseable can only win when NO timestamped record exists for that agentId,
|
|
456
|
+
* and is then marked `binding: 'unknown'`. A record carrying none of the
|
|
457
|
+
* run/session/wave fields is marked `binding: 'legacy'` — never promoted into
|
|
458
|
+
* the newest wave. An older-session record therefore cannot overwrite a newer
|
|
459
|
+
* session's record for the same agentId when the timestamps say otherwise.
|
|
308
460
|
*
|
|
309
|
-
* @param {{ repoRoot?: string }} [opts]
|
|
310
|
-
* @returns {
|
|
461
|
+
* @param {{ repoRoot?: string, maxBytes?: number }} [opts]
|
|
462
|
+
* @returns {{
|
|
463
|
+
* entries: Record<string, object>,
|
|
464
|
+
* at: string|null,
|
|
465
|
+
* scannedBytes: number,
|
|
466
|
+
* fileSize: number,
|
|
467
|
+
* degraded: { reason: string, reasons: string[], partialLines: number, parseErrors: number, unboundRecords: number, tailTruncated: boolean }|null
|
|
468
|
+
* }}
|
|
469
|
+
*/
|
|
470
|
+
export function rebuildCurrentFromLedger(opts = {}) {
|
|
471
|
+
const jsonlFile = jsonlPathFor(opts?.repoRoot);
|
|
472
|
+
const maxBytes =
|
|
473
|
+
typeof opts?.maxBytes === 'number' && opts.maxBytes > 0
|
|
474
|
+
? opts.maxBytes
|
|
475
|
+
: DEFAULT_REBUILD_MAX_BYTES;
|
|
476
|
+
|
|
477
|
+
const reasons = [];
|
|
478
|
+
let partialLines = 0;
|
|
479
|
+
let parseErrors = 0;
|
|
480
|
+
let unboundRecords = 0;
|
|
481
|
+
|
|
482
|
+
let tail;
|
|
483
|
+
try {
|
|
484
|
+
tail = readTail(jsonlFile, maxBytes);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
const reason = err?.code === 'ENOENT' ? 'ledger-missing' : 'ledger-unreadable';
|
|
487
|
+
return {
|
|
488
|
+
entries: Object.create(null),
|
|
489
|
+
at: null,
|
|
490
|
+
scannedBytes: 0,
|
|
491
|
+
fileSize: 0,
|
|
492
|
+
degraded: {
|
|
493
|
+
reason,
|
|
494
|
+
reasons: [reason],
|
|
495
|
+
partialLines: 0,
|
|
496
|
+
parseErrors: 0,
|
|
497
|
+
unboundRecords: 0,
|
|
498
|
+
tailTruncated: false,
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (tail.size === 0) reasons.push('ledger-empty');
|
|
504
|
+
|
|
505
|
+
const lines = tail.text.split('\n');
|
|
506
|
+
if (tail.cut) {
|
|
507
|
+
// The window started mid-file: the FIRST line is (or may be) a fragment.
|
|
508
|
+
// Discard it and count it — never guess the state it would have carried.
|
|
509
|
+
lines.shift();
|
|
510
|
+
partialLines += 1;
|
|
511
|
+
reasons.push('tail-truncated');
|
|
512
|
+
}
|
|
513
|
+
// A trailing newline yields one empty final element; a MISSING trailing
|
|
514
|
+
// newline means the last line is an in-flight partial write.
|
|
515
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
516
|
+
lines.pop();
|
|
517
|
+
} else if (lines.length > 0 && tail.size > 0) {
|
|
518
|
+
const incomplete = lines.pop();
|
|
519
|
+
if (incomplete.trim().length > 0) {
|
|
520
|
+
partialLines += 1;
|
|
521
|
+
reasons.push('incomplete-last-line');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @type {Record<string, object>} */
|
|
526
|
+
const entries = Object.create(null);
|
|
527
|
+
/** @type {Record<string, number|null>} */
|
|
528
|
+
const bestTs = Object.create(null);
|
|
529
|
+
|
|
530
|
+
for (const line of lines) {
|
|
531
|
+
if (line.trim().length === 0) continue;
|
|
532
|
+
let rec;
|
|
533
|
+
try {
|
|
534
|
+
rec = JSON.parse(line);
|
|
535
|
+
} catch {
|
|
536
|
+
parseErrors += 1;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) {
|
|
540
|
+
parseErrors += 1;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (typeof rec.agentId !== 'string' || rec.agentId.trim().length === 0) {
|
|
544
|
+
// No usable key — it can never be attributed to an agent. Counted, dropped.
|
|
545
|
+
unboundRecords += 1;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (UNSAFE_MAP_KEYS.has(rec.agentId)) {
|
|
549
|
+
// A prototype-shaped key is not a usable map key either (see
|
|
550
|
+
// UNSAFE_MAP_KEYS) — same class as a missing agentId: counted, dropped.
|
|
551
|
+
unboundRecords += 1;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const ts = recordTsMs(rec);
|
|
556
|
+
const bound = BINDING_KEYS.some((k) => rec[k] !== undefined && rec[k] !== null);
|
|
557
|
+
const prevTs = Object.prototype.hasOwnProperty.call(bestTs, rec.agentId)
|
|
558
|
+
? bestTs[rec.agentId]
|
|
559
|
+
: undefined;
|
|
560
|
+
|
|
561
|
+
if (prevTs !== undefined) {
|
|
562
|
+
if (ts === null) continue; // untimestamped never displaces a known record
|
|
563
|
+
if (prevTs !== null && ts < prevTs) continue; // older session must not win
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
bestTs[rec.agentId] = ts;
|
|
567
|
+
entries[rec.agentId] = { ...rec, binding: ts === null ? 'unknown' : bound ? 'bound' : 'legacy' };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (parseErrors > 0) reasons.push('parse-errors');
|
|
571
|
+
if (unboundRecords > 0) reasons.push('records-without-agent-id');
|
|
572
|
+
|
|
573
|
+
const newest = newestTsMs(entries);
|
|
574
|
+
return {
|
|
575
|
+
entries,
|
|
576
|
+
at: newest === null ? null : new Date(newest).toISOString(),
|
|
577
|
+
scannedBytes: Buffer.byteLength(tail.text, 'utf8'),
|
|
578
|
+
fileSize: tail.size,
|
|
579
|
+
degraded:
|
|
580
|
+
reasons.length === 0
|
|
581
|
+
? null
|
|
582
|
+
: {
|
|
583
|
+
reason: reasons[0],
|
|
584
|
+
reasons,
|
|
585
|
+
partialLines,
|
|
586
|
+
parseErrors,
|
|
587
|
+
unboundRecords,
|
|
588
|
+
tailTruncated: tail.cut,
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Read the current status of every agent WITH PROVENANCE (#1342).
|
|
595
|
+
*
|
|
596
|
+
* Return contract:
|
|
597
|
+
* {
|
|
598
|
+
* entries: Record<agentId, record>, // the map shape the cache holds
|
|
599
|
+
* source: 'live-map' | 'rebuilt-log' | 'stale-cache' | 'absent',
|
|
600
|
+
* at: string|null, // ISO ts of the newest record in `entries`
|
|
601
|
+
* degraded?: { reason, reasons[], partialLines, parseErrors, unboundRecords, tailTruncated }
|
|
602
|
+
* }
|
|
603
|
+
*
|
|
604
|
+
* Decision rule — the fold is PER agentId, never by the two views' GLOBAL newest
|
|
605
|
+
* timestamp. A global comparison is masked by any sibling: ledger `A completed`
|
|
606
|
+
* (map write lost) followed by ledger `B running` (map write ok) makes the cache's
|
|
607
|
+
* newest equal the ledger's newest, and `A` was then served from the cache as
|
|
608
|
+
* `running` with `source: live-map` — byte-identical to the pre-#1342 defect.
|
|
609
|
+
* So for every id in `cache ∪ ledger` the record with the newer `ts` wins (an
|
|
610
|
+
* equal-ms ties retain the cache only when its ledger payload agrees):
|
|
611
|
+
* - every entry taken from the cache → `live-map`; the cache is VERIFIED
|
|
612
|
+
* against a ledger (the normal path).
|
|
613
|
+
* - ANY entry taken from the ledger, or the cache unreadable/missing while the
|
|
614
|
+
* ledger has entries → `rebuilt-log` (the #1342 defect: a failed map write
|
|
615
|
+
* used to show the OLD state unmarked).
|
|
616
|
+
* - ledger missing/unreadable/empty but the cache has entries → `stale-cache`;
|
|
617
|
+
* the cache is all we have and is explicitly marked as unverified.
|
|
618
|
+
* - neither a usable ledger NOR a cache entry → `absent`: nothing is on disk to
|
|
619
|
+
* verify against, so this is NOT `live-map`. `entries: {}`, `at: null`, and no
|
|
620
|
+
* `degraded` — a fresh repo is the normal state, not a degradation (HR-101).
|
|
621
|
+
*
|
|
622
|
+
* BREAKING (documented, #1342): this used to return the bare map. Read
|
|
623
|
+
* `.entries` for the old value — or call `readCurrentStatusEntries()`.
|
|
624
|
+
*
|
|
625
|
+
* Entry shape note: an entry taken from the LEDGER carries the extra
|
|
626
|
+
* `binding: 'bound'|'legacy'|'unknown'` field the fold assigns; an entry taken
|
|
627
|
+
* from the cache is the record as written and has none. Since the fold is
|
|
628
|
+
* per-agentId, a `rebuilt-log` view can hold BOTH kinds. A consumer must treat
|
|
629
|
+
* `binding` as optional — its ABSENCE means "from the cache", never "bound".
|
|
630
|
+
*
|
|
631
|
+
* @param {{ repoRoot?: string, maxBytes?: number }} [opts]
|
|
632
|
+
* @returns {{ entries: Record<string, object>, source: 'live-map'|'rebuilt-log'|'stale-cache'|'absent', at: string|null, degraded?: object }}
|
|
311
633
|
*/
|
|
312
634
|
export function readCurrentStatus(opts = {}) {
|
|
313
|
-
|
|
635
|
+
const cache = readCurrentMapDetailed(currentPathFor(opts?.repoRoot));
|
|
636
|
+
const rebuilt = rebuildCurrentFromLedger(opts);
|
|
637
|
+
|
|
638
|
+
const cacheNewest = newestTsMs(cache.entries);
|
|
639
|
+
const ledgerNewest = rebuilt.at === null ? null : Date.parse(rebuilt.at);
|
|
640
|
+
const ledgerUnusable =
|
|
641
|
+
rebuilt.degraded !== null &&
|
|
642
|
+
['ledger-missing', 'ledger-unreadable', 'ledger-empty'].includes(rebuilt.degraded.reason) &&
|
|
643
|
+
Object.keys(rebuilt.entries).length === 0;
|
|
644
|
+
|
|
645
|
+
const reasons = rebuilt.degraded ? [...rebuilt.degraded.reasons] : [];
|
|
646
|
+
const counts = {
|
|
647
|
+
partialLines: rebuilt.degraded?.partialLines ?? 0,
|
|
648
|
+
parseErrors: rebuilt.degraded?.parseErrors ?? 0,
|
|
649
|
+
unboundRecords: rebuilt.degraded?.unboundRecords ?? 0,
|
|
650
|
+
tailTruncated: rebuilt.degraded?.tailTruncated ?? false,
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
let source;
|
|
654
|
+
let entries;
|
|
655
|
+
let at;
|
|
656
|
+
|
|
657
|
+
const cacheIds = Object.keys(cache.entries);
|
|
658
|
+
const ledgerIds = Object.keys(rebuilt.entries);
|
|
659
|
+
|
|
660
|
+
if (ledgerUnusable && cacheIds.length === 0) {
|
|
661
|
+
// Nothing on disk at all — no ledger to verify against AND no cached entry
|
|
662
|
+
// to verify. `live-map` would claim a verification that never happened.
|
|
663
|
+
return { entries: Object.create(null), source: 'absent', at: null };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (ledgerUnusable) {
|
|
667
|
+
// No ledger to check the cache against, but the cache holds entries.
|
|
668
|
+
source = 'stale-cache';
|
|
669
|
+
entries = cache.entries;
|
|
670
|
+
at = cacheNewest;
|
|
671
|
+
if (!cache.ok && cache.reason === 'cache-unreadable') reasons.unshift(cache.reason);
|
|
672
|
+
} else {
|
|
673
|
+
// Per-agentId fold over `cache ∪ ledger` — see the decision rule above.
|
|
674
|
+
entries = Object.create(null);
|
|
675
|
+
let tookFromLedger = false;
|
|
676
|
+
for (const id of new Set([...cacheIds, ...ledgerIds])) {
|
|
677
|
+
const cached = Object.prototype.hasOwnProperty.call(cache.entries, id)
|
|
678
|
+
? cache.entries[id]
|
|
679
|
+
: undefined;
|
|
680
|
+
const logged = Object.prototype.hasOwnProperty.call(rebuilt.entries, id)
|
|
681
|
+
? rebuilt.entries[id]
|
|
682
|
+
: undefined;
|
|
683
|
+
|
|
684
|
+
if (cached === undefined) {
|
|
685
|
+
entries[id] = logged;
|
|
686
|
+
tookFromLedger = true;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (logged === undefined) {
|
|
690
|
+
entries[id] = cached;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const cachedTs = recordTsMs(cached);
|
|
694
|
+
const loggedTs = recordTsMs(logged);
|
|
695
|
+
// Two real pushes can share a millisecond. The ledger fold keeps the
|
|
696
|
+
// last append at that timestamp; a failed map write must not hide it.
|
|
697
|
+
// Ignore only the fold's derived binding marker. Cache-only metadata
|
|
698
|
+
// remains intact when the actual ledger payload agrees.
|
|
699
|
+
const conflictingTie =
|
|
700
|
+
loggedTs !== null &&
|
|
701
|
+
loggedTs === cachedTs &&
|
|
702
|
+
[...new Set([...Object.keys(logged), 'text', 'step', 'total', 'label', ...BINDING_KEYS])].some(
|
|
703
|
+
(key) => key !== 'binding' && !isDeepStrictEqual(logged[key], cached[key]),
|
|
704
|
+
);
|
|
705
|
+
if (loggedTs !== null && (cachedTs === null || loggedTs > cachedTs || conflictingTie)) {
|
|
706
|
+
entries[id] = logged;
|
|
707
|
+
tookFromLedger = true;
|
|
708
|
+
} else {
|
|
709
|
+
entries[id] = cached;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
at = newestTsMs(entries);
|
|
714
|
+
if (tookFromLedger || (!cache.ok && ledgerIds.length > 0)) {
|
|
715
|
+
source = 'rebuilt-log';
|
|
716
|
+
if (cache.reason !== 'ok') reasons.unshift(cache.reason);
|
|
717
|
+
else reasons.unshift('cache-behind-ledger');
|
|
718
|
+
} else {
|
|
719
|
+
source = 'live-map';
|
|
720
|
+
// The ledger is BEHIND the cache — it was rotated or truncated under us.
|
|
721
|
+
if (cacheNewest !== null && ledgerNewest !== null && ledgerNewest < cacheNewest) {
|
|
722
|
+
reasons.unshift('ledger-behind-cache');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const out = {
|
|
728
|
+
entries,
|
|
729
|
+
source,
|
|
730
|
+
at: at === null ? null : new Date(at).toISOString(),
|
|
731
|
+
};
|
|
732
|
+
if (reasons.length > 0) out.degraded = { reason: reasons[0], reasons, ...counts };
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Backwards-compatible accessor: the bare `Record<agentId, record>` map that
|
|
738
|
+
* `readCurrentStatus()` returned before #1342, resolved through the same
|
|
739
|
+
* provenance rule (so it, too, prefers the ledger over a stale cache).
|
|
740
|
+
*
|
|
741
|
+
* @param {{ repoRoot?: string, maxBytes?: number }} [opts]
|
|
742
|
+
* @returns {Record<string, object>}
|
|
743
|
+
*/
|
|
744
|
+
export function readCurrentStatusEntries(opts = {}) {
|
|
745
|
+
return readCurrentStatus(opts).entries;
|
|
314
746
|
}
|