session-orchestrator 4.1.0 → 4.2.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-plan/SKILL.md +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/README.md +8 -8
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +115 -48
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +1 -0
- package/docs/components.md +2 -2
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +4 -1
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +28 -3
- package/hooks/_lib/vcs-create-matcher.mjs +214 -16
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/pre-bash-issue-budget.mjs +123 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +4 -4
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/file-lock.mjs +47 -5
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +76 -3
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/maintenance-due-banner.mjs +440 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/quality-gate.mjs +13 -6
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +10 -3
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-unwired-features.mjs +8 -7
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +61 -13
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/discovery/probes-ui.md +1 -1
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/evolve/SKILL.md +2 -2
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/reconcile/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +23 -65
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
- package/skills/session-end/session-metrics-write.md +31 -12
- package/skills/session-plan/SKILL.md +56 -48
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +18 -2
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/wave-executor/SKILL.md +42 -12
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
- package/skills/wave-executor/references/wave-loop-review.md +1 -1
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
|
@@ -86,6 +86,8 @@ export function validateSessionConfig(config) {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
validateIssueBudget(config['issue-budget'], errors);
|
|
90
|
+
|
|
89
91
|
validateVaultIntegration(config['vault-integration'], errors);
|
|
90
92
|
validateVaultSync(config['vault-sync'], errors);
|
|
91
93
|
|
|
@@ -124,6 +126,83 @@ export function validateSessionConfig(config) {
|
|
|
124
126
|
return { ok: true, config, warnings };
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Validate the `issue-budget` block's cap, including the per-session-type
|
|
131
|
+
* override form (#session-shape) — `12 (feature: 6)` parses to
|
|
132
|
+
* `{ default: 12, feature: 6 }` and lands on `max-per-session-raw`, while
|
|
133
|
+
* `max-per-session` stays the resolved NUMBER every consumer reads.
|
|
134
|
+
*
|
|
135
|
+
* Mirrors `validateAgentsPerWave` in shape, with two deliberate differences:
|
|
136
|
+
* the floor is 0 (`max-per-session: 0` is a valid "no issue may be created"
|
|
137
|
+
* setting) and the override key set is free-form, because the session-type
|
|
138
|
+
* vocabulary is owned by session-start, not by this schema.
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} block — `config['issue-budget']`
|
|
141
|
+
* @param {Array<{path: string, rule: string, message: string}>} errors
|
|
142
|
+
*/
|
|
143
|
+
function validateIssueBudget(block, errors) {
|
|
144
|
+
if (block === undefined || block === null) return;
|
|
145
|
+
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path: 'issue-budget',
|
|
148
|
+
rule: 'object',
|
|
149
|
+
message: `issue-budget must be an object (got ${JSON.stringify(block)})`,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resolved = block['max-per-session'];
|
|
155
|
+
if (resolved !== undefined && (!Number.isInteger(resolved) || resolved < 0)) {
|
|
156
|
+
errors.push({
|
|
157
|
+
path: 'issue-budget.max-per-session',
|
|
158
|
+
rule: 'integer>=0',
|
|
159
|
+
message: `issue-budget.max-per-session must be an integer >= 0 (got ${JSON.stringify(resolved)})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const raw = block['max-per-session-raw'];
|
|
164
|
+
if (raw === undefined || raw === null) return;
|
|
165
|
+
|
|
166
|
+
if (Number.isInteger(raw)) {
|
|
167
|
+
if (raw < 0) {
|
|
168
|
+
errors.push({
|
|
169
|
+
path: 'issue-budget.max-per-session-raw',
|
|
170
|
+
rule: 'integer>=0',
|
|
171
|
+
message: `issue-budget.max-per-session-raw must be an integer >= 0 (got ${raw})`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
178
|
+
const defaultVal = raw['default'];
|
|
179
|
+
if (!Number.isInteger(defaultVal) || defaultVal < 0) {
|
|
180
|
+
errors.push({
|
|
181
|
+
path: 'issue-budget.max-per-session-raw.default',
|
|
182
|
+
rule: 'integer>=0',
|
|
183
|
+
message: `issue-budget.max-per-session-raw.default must be an integer >= 0 (got ${JSON.stringify(defaultVal)})`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
187
|
+
if (k === 'default') continue;
|
|
188
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
189
|
+
errors.push({
|
|
190
|
+
path: `issue-budget.max-per-session-raw.${k}`,
|
|
191
|
+
rule: 'integer>=0',
|
|
192
|
+
message: `issue-budget.max-per-session-raw.${k} must be an integer >= 0 (got ${JSON.stringify(v)})`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
errors.push({
|
|
200
|
+
path: 'issue-budget.max-per-session-raw',
|
|
201
|
+
rule: 'integer-or-object',
|
|
202
|
+
message: `issue-budget.max-per-session-raw must be an integer >= 0 or an object with numeric entries (got ${JSON.stringify(raw)})`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
127
206
|
function validateAgentsPerWave(value, errors) {
|
|
128
207
|
if (Number.isInteger(value)) {
|
|
129
208
|
if (value < 2) {
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* and the state-lock / staging-fence / session-lock blocks of session-lock.mjs).
|
|
7
7
|
* Each copy independently re-implemented the same skeleton:
|
|
8
8
|
*
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4.
|
|
13
|
-
* 5.
|
|
9
|
+
* 1. exclusive acquisition guard via `linkSync(tmp, lock + '.acquire')`;
|
|
10
|
+
* 2. atomic create-or-fail via `linkSync(tmp, lock)` (POSIX mutex);
|
|
11
|
+
* 3. on EEXIST → read + parse the existing body;
|
|
12
|
+
* 4. same-host + dead-PID (or unparseable) → guarded override + WARN;
|
|
13
|
+
* 5. release the acquisition guard; live/cross-host holders wait until a deadline;
|
|
14
|
+
* 6. owner-guarded primary-lock release.
|
|
14
15
|
*
|
|
15
16
|
* This module is the single home for that skeleton. It is a near-PURE primitive:
|
|
16
17
|
* it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
* opinion of its own — every divergence between the five copies is a knob.
|
|
28
29
|
* - Cross-host locks are NEVER auto-overridden (PSA-003 hard invariant).
|
|
29
30
|
* - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
|
|
31
|
+
* - #1284 serializes acquisition and takeover: a stale observation must not
|
|
32
|
+
* replace a different process's newly acquired lock.
|
|
30
33
|
*
|
|
31
34
|
* No external dependencies — Node 20+ stdlib + io.mjs only.
|
|
32
35
|
*/
|
|
@@ -258,6 +261,21 @@ function serializeBody(body, indent) {
|
|
|
258
261
|
* overridden via writeJsonAtomicSync and a WARN is emitted. A live holder or a
|
|
259
262
|
* cross-host body returns `{ acquired: false, reason: 'held' }`.
|
|
260
263
|
*
|
|
264
|
+
* Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
|
|
265
|
+
* from before create/read through any takeover. This prevents a waiter from
|
|
266
|
+
* reading an old holder, observing its exit, then replacing a newer holder.
|
|
267
|
+
* All contenders must use this guarded implementation; legacy writers that
|
|
268
|
+
* ignore the sibling guard cannot participate safely in the same protocol.
|
|
269
|
+
*
|
|
270
|
+
* Crash-liveness tradeoff: the guard is held only for this synchronous pass,
|
|
271
|
+
* not for the caller's critical section. If its owner dies during the pass or
|
|
272
|
+
* cleanup fails, the guard remains and attempts return `held` immediately;
|
|
273
|
+
* withFileLock's normal deadline bounds polling. Even a dead-PID or malformed
|
|
274
|
+
* guard is NEVER stolen, because stale-guard replacement would repeat the same
|
|
275
|
+
* race. Recovery requires quiescing every process that can acquire this lock,
|
|
276
|
+
* verifying the guard is abandoned, then explicitly removing only that sibling.
|
|
277
|
+
* The primary lock retains its existing stale/host/owner protections.
|
|
278
|
+
*
|
|
261
279
|
* The `signalVanished` knob reproduces memory-proposals/store.mjs's distinct
|
|
262
280
|
* third state: when the lock file disappears between the EEXIST and the read
|
|
263
281
|
* (concurrent release race), `{ acquired: false, reason: 'vanished' }` is
|
|
@@ -284,6 +302,30 @@ function serializeBody(body, indent) {
|
|
|
284
302
|
* | { acquired: false, reason: 'held'|'vanished'|'fs-error', existing?: object|null, error?: string }}
|
|
285
303
|
*/
|
|
286
304
|
export function tryAcquireFileLock(lockPath, opts = {}) {
|
|
305
|
+
const guardPath = `${lockPath}.acquire`;
|
|
306
|
+
const guard = createExclusive(guardPath, {
|
|
307
|
+
pid: process.pid,
|
|
308
|
+
host: os.hostname(),
|
|
309
|
+
acquiredAt: new Date().toISOString(),
|
|
310
|
+
kind: 'acquisition-guard',
|
|
311
|
+
}, { indent: 2, tmpPrefix: `${opts.tmpPrefix ?? '.file.lock'}.acquire` });
|
|
312
|
+
if (!guard.ok) {
|
|
313
|
+
return guard.reason === 'exists'
|
|
314
|
+
? { acquired: false, reason: 'held', existing: null }
|
|
315
|
+
: { acquired: false, reason: 'fs-error', error: guard.error };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
return tryAcquireGuardedFileLock(lockPath, opts);
|
|
320
|
+
} finally {
|
|
321
|
+
// Only this pass can own/remove this guard; no acquisition path replaces
|
|
322
|
+
// it. An unlink failure deliberately leaves subsequent attempts blocked.
|
|
323
|
+
try { fs.unlinkSync(guardPath); } catch { /* fail closed; see recovery above */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Caller must hold the acquisition guard throughout this synchronous pass. */
|
|
328
|
+
function tryAcquireGuardedFileLock(lockPath, opts) {
|
|
287
329
|
const {
|
|
288
330
|
staleCheck = 'pid',
|
|
289
331
|
staleMs,
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* issue-budget-reconcile.mjs — close-time cross-check between what a session
|
|
3
|
+
* RECORDED as created and what the issue-budget ledger CHARGED (#1163).
|
|
4
|
+
*
|
|
5
|
+
* ## Why a cross-check exists at all
|
|
6
|
+
*
|
|
7
|
+
* The cap is enforced by a PreToolUse hook, and a hook only sees the routes it
|
|
8
|
+
* matches. Every unmatched route is a silent zero: nothing errors, no ledger
|
|
9
|
+
* line is written, and the absence is indistinguishable from a session that
|
|
10
|
+
* created nothing. Measured 2026-09-09 on a session record with **26** issues
|
|
11
|
+
* in `issues_created` and NO counter file under either candidate key — the
|
|
12
|
+
* hook had not run for a single one of those creations, and nothing said so.
|
|
13
|
+
*
|
|
14
|
+
* So this module compares two independently-produced numbers and names the
|
|
15
|
+
* disagreement:
|
|
16
|
+
*
|
|
17
|
+
* recorded — `record.issues_created.length` from the session record
|
|
18
|
+
* charged — `count` summed over the ledgers
|
|
19
|
+
* exempt — `exempt` summed over the ledgers
|
|
20
|
+
* escaped — `max(0, recorded - charged - exempt)`
|
|
21
|
+
*
|
|
22
|
+
* ## Why BOTH ledger keys are read
|
|
23
|
+
*
|
|
24
|
+
* The accounting key is SEMANTIC when `current-session.json` verified the raw
|
|
25
|
+
* id, and RAW otherwise (`resolveIssueBudgetSessionId`). Which of the two a
|
|
26
|
+
* given session's file is named after therefore depends on a condition that can
|
|
27
|
+
* change mid-session — measured in one consumer repo: 25 of 36 counter files
|
|
28
|
+
* keyed semantic, 11 keyed raw. Reading only one key reports a phantom
|
|
29
|
+
* "escaped" for every session that used the other.
|
|
30
|
+
*
|
|
31
|
+
* ## Why `found` is tracked explicitly
|
|
32
|
+
*
|
|
33
|
+
* `readBudgetState` returns a ZEROED state for a missing file — by design, so
|
|
34
|
+
* the cap never inherits a foreign session's spend. That makes "no ledger" and
|
|
35
|
+
* "a real zero" byte-identical in the return value, and reading the zero as
|
|
36
|
+
* "all good" is exactly the #1163 failure this module exists to surface. So the
|
|
37
|
+
* file's existence is measured separately, per key, before the read.
|
|
38
|
+
*
|
|
39
|
+
* FAIL-OPEN BY CONTRACT: nothing here throws. A close-time cross-check that
|
|
40
|
+
* aborts the close is strictly worse than one that reports `no-ledger`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
readBudgetState,
|
|
47
|
+
budgetStatePath,
|
|
48
|
+
budgetStateRel,
|
|
49
|
+
loadIssueBudgetConfig,
|
|
50
|
+
} from './issue-budget.mjs';
|
|
51
|
+
|
|
52
|
+
/** Event name for the reconciliation record. Plain literal, greppable. */
|
|
53
|
+
export const ISSUE_BUDGET_RECONCILED_EVENT = 'orchestrator.issue_budget.reconciled';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read one candidate ledger, recording whether its file was actually there.
|
|
57
|
+
*
|
|
58
|
+
* ## Why the path is carried TWICE
|
|
59
|
+
*
|
|
60
|
+
* `abs_path` is the operator-facing answer to "which file did you look at?" and
|
|
61
|
+
* belongs in the local WARN text. `path` is repo-RELATIVE and is the only half
|
|
62
|
+
* that may travel: this record is copied verbatim into the
|
|
63
|
+
* `orchestrator.issue_budget.reconciled` event, which the optional Clank webhook
|
|
64
|
+
* ships off-host. An absolute ledger path names the operator's home directory
|
|
65
|
+
* and the repo's private slug, neither of which the receiving side needs to
|
|
66
|
+
* interpret the verdict. The split is in the FIELD NAMES so a future consumer
|
|
67
|
+
* cannot pick the leaking one by accident.
|
|
68
|
+
*
|
|
69
|
+
* ## Why `corrupt` is separate from `found`
|
|
70
|
+
*
|
|
71
|
+
* `readBudgetState` normalises a present-but-unreadable file to a ZEROED state
|
|
72
|
+
* (fail-open by contract). So `{"count":"3"}` — an existing ledger whose shape
|
|
73
|
+
* is wrong — reads as `found: true, charged: 0`, which the verdict logic would
|
|
74
|
+
* otherwise call `escaped` and answer with the escape-route list. That is the
|
|
75
|
+
* wrong diagnosis for a merely corrupt file, so the raw shape is inspected here
|
|
76
|
+
* and reported on its own flag.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} repoRoot
|
|
79
|
+
* @param {string|null|undefined} sessionId
|
|
80
|
+
* @param {'semantic'|'raw'} key
|
|
81
|
+
* @returns {{ key: string, sessionId: string|null, path: string|null, abs_path: string|null,
|
|
82
|
+
* found: boolean, corrupt: boolean, charged: number, exempt: number,
|
|
83
|
+
* overflow: number }}
|
|
84
|
+
*/
|
|
85
|
+
function _readLedger(repoRoot, sessionId, key) {
|
|
86
|
+
const id = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
87
|
+
if (id === null) {
|
|
88
|
+
return {
|
|
89
|
+
key,
|
|
90
|
+
sessionId: null,
|
|
91
|
+
path: null,
|
|
92
|
+
abs_path: null,
|
|
93
|
+
found: false,
|
|
94
|
+
corrupt: false,
|
|
95
|
+
charged: 0,
|
|
96
|
+
exempt: 0,
|
|
97
|
+
overflow: 0,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
let file = null;
|
|
101
|
+
let rel = null;
|
|
102
|
+
let found;
|
|
103
|
+
let corrupt = false;
|
|
104
|
+
let state = { count: 0, exempt: 0, overflow: [] };
|
|
105
|
+
try {
|
|
106
|
+
file = budgetStatePath(repoRoot, id);
|
|
107
|
+
rel = budgetStateRel(id);
|
|
108
|
+
found = existsSync(file);
|
|
109
|
+
state = readBudgetState(repoRoot, id);
|
|
110
|
+
if (found) corrupt = !_ledgerShapeIsReadable(file);
|
|
111
|
+
} catch {
|
|
112
|
+
// Unreadable ledger — reported as not found rather than thrown (fail-open).
|
|
113
|
+
found = false;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
sessionId: id,
|
|
118
|
+
path: rel,
|
|
119
|
+
abs_path: file,
|
|
120
|
+
found,
|
|
121
|
+
corrupt,
|
|
122
|
+
charged: Number.isInteger(state?.count) ? state.count : 0,
|
|
123
|
+
exempt: Number.isInteger(state?.exempt) ? state.exempt : 0,
|
|
124
|
+
overflow: Array.isArray(state?.overflow) ? state.overflow.length : 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Is the ledger file on disk a readable counter shape?
|
|
130
|
+
*
|
|
131
|
+
* Read directly rather than through `readBudgetState`, whose normalisation is
|
|
132
|
+
* exactly what hides this. Fail-open: an unreadable file is reported as corrupt,
|
|
133
|
+
* never thrown.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} file
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
function _ledgerShapeIsReadable(file) {
|
|
139
|
+
try {
|
|
140
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
141
|
+
return Number.isInteger(raw?.count);
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cross-check a closing session's recorded issue creations against the
|
|
149
|
+
* issue-budget ledger(s).
|
|
150
|
+
*
|
|
151
|
+
* The two ledgers are SUMMED rather than preferred one over the other: a
|
|
152
|
+
* session whose key flipped mid-session legitimately has spend under both, and
|
|
153
|
+
* preferring one would under-report exactly that case. When both keys resolve
|
|
154
|
+
* to the same string, only one is read (no double count).
|
|
155
|
+
*
|
|
156
|
+
* Verdicts:
|
|
157
|
+
* `no-ledger` — `recorded > 0` and NO file existed under either key. The
|
|
158
|
+
* hook never ran for a single create; the count is not
|
|
159
|
+
* merely low, it is absent.
|
|
160
|
+
* `corrupt-ledger` — a ledger file EXISTS but its shape is unreadable (bad
|
|
161
|
+
* JSON, or a `count` that is not an integer). Its `charged`
|
|
162
|
+
* is therefore unknown, not zero — so no escape claim may be
|
|
163
|
+
* made from it, and the fix is the file, not the matcher.
|
|
164
|
+
* `escaped` — a ledger exists but `recorded > charged + exempt`. Some
|
|
165
|
+
* creations took a route the matcher does not see.
|
|
166
|
+
* `stale-record` — a ledger exists with spend, and the record claims none.
|
|
167
|
+
* The record, not the ledger, is the suspect half.
|
|
168
|
+
* `match` — everything the record claims is accounted for.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ repoRoot: string,
|
|
171
|
+
* record?: { issues_created?: unknown } | null,
|
|
172
|
+
* sessionId?: string|null,
|
|
173
|
+
* rawSessionId?: string|null,
|
|
174
|
+
* config?: { "max-per-session": number, mode: string, overflow: string },
|
|
175
|
+
* now?: string }} opts
|
|
176
|
+
* @returns {{ recorded: number, charged: number, exempt: number, overflow: number,
|
|
177
|
+
* escaped: number, sources: object[], verdict: string,
|
|
178
|
+
* max: number|null, mode: string|null }}
|
|
179
|
+
*/
|
|
180
|
+
export function reconcileIssueBudget({
|
|
181
|
+
repoRoot,
|
|
182
|
+
record = null,
|
|
183
|
+
sessionId = null,
|
|
184
|
+
rawSessionId = null,
|
|
185
|
+
config = null,
|
|
186
|
+
now = new Date().toISOString(),
|
|
187
|
+
} = {}) {
|
|
188
|
+
const empty = {
|
|
189
|
+
recorded: 0,
|
|
190
|
+
charged: 0,
|
|
191
|
+
exempt: 0,
|
|
192
|
+
overflow: 0,
|
|
193
|
+
escaped: 0,
|
|
194
|
+
sources: [],
|
|
195
|
+
verdict: 'match',
|
|
196
|
+
max: null,
|
|
197
|
+
mode: null,
|
|
198
|
+
at: now,
|
|
199
|
+
};
|
|
200
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') return empty;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const created = record?.issues_created;
|
|
204
|
+
const recorded = Array.isArray(created) ? created.length : 0;
|
|
205
|
+
|
|
206
|
+
const sources = [_readLedger(repoRoot, sessionId, 'semantic')];
|
|
207
|
+
if (
|
|
208
|
+
typeof rawSessionId === 'string' &&
|
|
209
|
+
rawSessionId.length > 0 &&
|
|
210
|
+
rawSessionId !== sessionId
|
|
211
|
+
) {
|
|
212
|
+
sources.push(_readLedger(repoRoot, rawSessionId, 'raw'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const charged = sources.reduce((n, s) => n + s.charged, 0);
|
|
216
|
+
const exempt = sources.reduce((n, s) => n + s.exempt, 0);
|
|
217
|
+
const overflow = sources.reduce((n, s) => n + s.overflow, 0);
|
|
218
|
+
const anyFound = sources.some((s) => s.found);
|
|
219
|
+
const escaped = Math.max(0, recorded - charged - exempt);
|
|
220
|
+
|
|
221
|
+
const anyCorrupt = sources.some((s) => s.corrupt);
|
|
222
|
+
|
|
223
|
+
let verdict = 'match';
|
|
224
|
+
if (recorded > 0 && !anyFound) verdict = 'no-ledger';
|
|
225
|
+
// Precedence over `escaped`: a corrupt ledger's `charged` is UNKNOWN, so the
|
|
226
|
+
// escape arithmetic that would otherwise fire is built on a zero nobody
|
|
227
|
+
// measured. Naming the corruption first sends the operator at the file.
|
|
228
|
+
else if (anyCorrupt) verdict = 'corrupt-ledger';
|
|
229
|
+
else if (escaped > 0) verdict = 'escaped';
|
|
230
|
+
else if (anyFound && recorded === 0 && charged > 0) verdict = 'stale-record';
|
|
231
|
+
|
|
232
|
+
let cfg = config;
|
|
233
|
+
if (!cfg) {
|
|
234
|
+
try {
|
|
235
|
+
cfg = loadIssueBudgetConfig(repoRoot);
|
|
236
|
+
} catch {
|
|
237
|
+
cfg = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
recorded,
|
|
243
|
+
charged,
|
|
244
|
+
exempt,
|
|
245
|
+
overflow,
|
|
246
|
+
escaped,
|
|
247
|
+
sources,
|
|
248
|
+
verdict,
|
|
249
|
+
max: typeof cfg?.['max-per-session'] === 'number' ? cfg['max-per-session'] : null,
|
|
250
|
+
mode: typeof cfg?.mode === 'string' ? cfg.mode : null,
|
|
251
|
+
at: now,
|
|
252
|
+
};
|
|
253
|
+
} catch {
|
|
254
|
+
// Never throws — a close-time cross-check that aborts the close is worse
|
|
255
|
+
// than one that reports nothing.
|
|
256
|
+
return empty;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Emit the reconciliation record to the repo's event ledger.
|
|
262
|
+
*
|
|
263
|
+
* Follows `_emitEvaluated` in `scripts/lib/express-path.mjs`: `events.mjs` is
|
|
264
|
+
* imported LAZILY (a static import would pull `platform.mjs`, which walks the
|
|
265
|
+
* filesystem at module load, into every consumer of this module), `repoRoot` is
|
|
266
|
+
* passed EXPLICITLY and a missing one SKIPS the emit rather than falling
|
|
267
|
+
* through to the ambient `SO_PROJECT_DIR` — writing a synthetic record into the
|
|
268
|
+
* operator's real fleet ledger is unrecoverable, a skipped record is not — and
|
|
269
|
+
* the whole thing is best-effort: the verdict above is authoritative whether or
|
|
270
|
+
* not the ledger accepted the line.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} repoRoot
|
|
273
|
+
* @param {ReturnType<typeof reconcileIssueBudget>} result
|
|
274
|
+
* @returns {Promise<void>}
|
|
275
|
+
*/
|
|
276
|
+
export async function emitIssueBudgetReconciled(repoRoot, result) {
|
|
277
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
278
|
+
process.stderr.write(
|
|
279
|
+
`issue-budget-reconcile: skipped ${ISSUE_BUDGET_RECONCILED_EVENT} — no repoRoot given; ` +
|
|
280
|
+
'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const { emitEvent, sessionAttribution } = await import('./events.mjs');
|
|
286
|
+
await emitEvent(
|
|
287
|
+
ISSUE_BUDGET_RECONCILED_EVENT,
|
|
288
|
+
{
|
|
289
|
+
verdict: result.verdict,
|
|
290
|
+
recorded: result.recorded,
|
|
291
|
+
charged: result.charged,
|
|
292
|
+
exempt: result.exempt,
|
|
293
|
+
overflow: result.overflow,
|
|
294
|
+
escaped: result.escaped,
|
|
295
|
+
// REPO-RELATIVE `path` only (`s.path`, from `budgetStateRel`). The
|
|
296
|
+
// absolute path stays in `result.sources[].abs_path` for the local WARN
|
|
297
|
+
// text: this payload travels verbatim over the optional Clank webhook,
|
|
298
|
+
// where `/Users/<operator>/Projects/<private-slug>/…` is owner data the
|
|
299
|
+
// receiver has no use for.
|
|
300
|
+
ledgers: (result.sources ?? []).map((s) => ({
|
|
301
|
+
key: s.key,
|
|
302
|
+
path: s.path,
|
|
303
|
+
found: s.found,
|
|
304
|
+
corrupt: s.corrupt === true,
|
|
305
|
+
charged: s.charged,
|
|
306
|
+
exempt: s.exempt,
|
|
307
|
+
})),
|
|
308
|
+
...sessionAttribution(repoRoot),
|
|
309
|
+
},
|
|
310
|
+
{ repoRoot },
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
// Best-effort telemetry.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Human-readable line(s) for session-end's Final Report.
|
|
319
|
+
*
|
|
320
|
+
* `match` gets ONE info line — a cross-check that prints a paragraph when it
|
|
321
|
+
* found nothing trains the operator to skip it. Every other verdict has to say
|
|
322
|
+
* what is missing AND where it looked, because the first question on reading
|
|
323
|
+
* "no ledger" is always "which path did you check?".
|
|
324
|
+
*
|
|
325
|
+
* @param {ReturnType<typeof reconcileIssueBudget>} result
|
|
326
|
+
* @returns {string}
|
|
327
|
+
*/
|
|
328
|
+
export function formatIssueBudgetReconcileWarn(result) {
|
|
329
|
+
const r = result ?? {};
|
|
330
|
+
// ABSOLUTE paths here on purpose: this text is local (session-end's Final
|
|
331
|
+
// Report), and the first question on reading it is always "which file?" —
|
|
332
|
+
// which a repo-relative path answers only after the reader guesses the root.
|
|
333
|
+
// The travelling copy (the event payload) carries the relative form instead.
|
|
334
|
+
const paths = (r.sources ?? [])
|
|
335
|
+
.map(
|
|
336
|
+
(s) =>
|
|
337
|
+
` ${s.key}: ${s.abs_path ?? '(no session key resolved)'}` +
|
|
338
|
+
`${s.found ? '' : ' — MISSING'}${s.corrupt ? ' — CORRUPT' : ''}`,
|
|
339
|
+
)
|
|
340
|
+
.join('\n');
|
|
341
|
+
|
|
342
|
+
if (r.verdict === 'match') {
|
|
343
|
+
return (
|
|
344
|
+
`ℹ issue-budget: ${r.recorded} recorded / ${r.charged} charged / ${r.exempt} exempt — reconciled.`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (r.verdict === 'no-ledger') {
|
|
349
|
+
return [
|
|
350
|
+
`⚠ issue-budget: ${r.recorded} issue(s) recorded for this session and NO counter file exists.`,
|
|
351
|
+
'A missing ledger does not mean the cap was under-used — it means the hook never ran for a',
|
|
352
|
+
'single one of those creations, so the cap was silently OFF for this session.',
|
|
353
|
+
'Looked up (both accounting keys — semantic and raw):',
|
|
354
|
+
paths,
|
|
355
|
+
'Known routes that reach issue creation WITHOUT the hook charging it:',
|
|
356
|
+
' - `glab api` / `gh api` POST to an `/issues` path — MATCHED since #1163, so a fresh',
|
|
357
|
+
' no-ledger verdict is no longer explained by this one.',
|
|
358
|
+
' - `bash -c "…"` and other interpreter payloads (the matcher does not recurse into them).',
|
|
359
|
+
' - a create inside a command substitution `$( … )` (never becomes its own statement).',
|
|
360
|
+
' - `xargs`-driven creation (the operand list is expanded after the hook runs).',
|
|
361
|
+
' - foreign-channel agents (a `cursor:`/remote-dispatch role spawns a binary no hook sees).',
|
|
362
|
+
'Verify with: ls -l the paths above, and re-check `issue-budget.mode` in the Session Config.',
|
|
363
|
+
].join('\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (r.verdict === 'corrupt-ledger') {
|
|
367
|
+
// ONE line by design: a corrupt file is a file problem, and printing the
|
|
368
|
+
// escape-route list here would send the operator hunting a matcher gap that
|
|
369
|
+
// this verdict has no evidence for.
|
|
370
|
+
return (
|
|
371
|
+
`⚠ issue-budget: a counter file exists but its shape is unreadable — the charged count is ` +
|
|
372
|
+
`UNKNOWN, not 0 (${r.recorded} recorded):\n${paths}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (r.verdict === 'escaped') {
|
|
377
|
+
return [
|
|
378
|
+
`⚠ issue-budget: ${r.escaped} issue(s) escaped the cap — ${r.recorded} recorded but only`,
|
|
379
|
+
`${r.charged} charged + ${r.exempt} exempt across the ledgers:`,
|
|
380
|
+
paths,
|
|
381
|
+
'The counter is therefore an UNDERCOUNT for this session; the cap allowed more creations',
|
|
382
|
+
'than it believes it did. Same escape routes as above (`bash -c`, `$( )`, `xargs`,',
|
|
383
|
+
'foreign-channel agents).',
|
|
384
|
+
].join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return [
|
|
388
|
+
`⚠ issue-budget: the ledger charged ${r.charged} (+${r.exempt} exempt) but the session record`,
|
|
389
|
+
'lists no created issues — the RECORD is the suspect half here, not the counter:',
|
|
390
|
+
paths,
|
|
391
|
+
].join('\n');
|
|
392
|
+
}
|
|
@@ -34,6 +34,8 @@ import path from 'node:path';
|
|
|
34
34
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
35
35
|
import { resolveInstructionFile } from './common.mjs';
|
|
36
36
|
import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
37
|
+
import { parseStateMd } from './state-md/yaml-parser.mjs';
|
|
38
|
+
import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Legacy single-slot counter file, relative to the repo root.
|
|
@@ -148,24 +150,95 @@ export function classifyExemption(command) {
|
|
|
148
150
|
return { exempt: false, reason: null };
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the effective cap for one session type out of a parsed config.
|
|
155
|
+
*
|
|
156
|
+
* Precedence is `raw[sessionType] ?? raw.default ?? cfg['max-per-session']` —
|
|
157
|
+
* an unknown or absent session type falls through to the default, never to a
|
|
158
|
+
* hard-coded number, and a config parsed before the override syntax existed
|
|
159
|
+
* (no `max-per-session-raw` key at all) still resolves via the last term.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ "max-per-session"?: number, "max-per-session-raw"?: number|Record<string, number> }} cfg
|
|
162
|
+
* @param {string|null|undefined} sessionType — e.g. `feature`, `deep`, `housekeeping`
|
|
163
|
+
* @returns {number} non-negative integer cap
|
|
164
|
+
*/
|
|
165
|
+
export function resolveMaxPerSession(cfg, sessionType) {
|
|
166
|
+
const fallback =
|
|
167
|
+
Number.isInteger(cfg?.['max-per-session']) && cfg['max-per-session'] >= 0
|
|
168
|
+
? cfg['max-per-session']
|
|
169
|
+
: 12;
|
|
170
|
+
const raw = cfg?.['max-per-session-raw'];
|
|
171
|
+
if (Number.isInteger(raw) && raw >= 0) return raw;
|
|
172
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
173
|
+
const key = typeof sessionType === 'string' ? sessionType.trim().toLowerCase() : '';
|
|
174
|
+
const override = key === '' ? undefined : raw[key];
|
|
175
|
+
if (Number.isInteger(override) && override >= 0) return override;
|
|
176
|
+
if (Number.isInteger(raw.default) && raw.default >= 0) return raw.default;
|
|
177
|
+
}
|
|
178
|
+
return fallback;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read `session-type:` from the active STATE.md frontmatter.
|
|
183
|
+
*
|
|
184
|
+
* Never throws: a missing STATE.md, unparseable frontmatter, or an absent /
|
|
185
|
+
* non-string `session-type` all yield `null`, which `resolveMaxPerSession`
|
|
186
|
+
* reads as "use the default". The hook path runs on every Bash call, so a
|
|
187
|
+
* broken STATE.md must degrade to today's behaviour rather than fail.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} repoRoot
|
|
190
|
+
* @returns {string|null}
|
|
191
|
+
*/
|
|
192
|
+
export function readSessionTypeFromStateMd(repoRoot) {
|
|
193
|
+
try {
|
|
194
|
+
const filePath = resolveStateMdPath(repoRoot);
|
|
195
|
+
if (!existsSync(filePath)) return null;
|
|
196
|
+
const parsed = parseStateMd(readFileSync(filePath, 'utf8'));
|
|
197
|
+
const value = parsed?.frontmatter?.['session-type'];
|
|
198
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
151
204
|
/**
|
|
152
205
|
* Load the `issue-budget` config from the repo's instruction file
|
|
153
206
|
* (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
|
|
154
207
|
* `parseSessionConfig()` is deliberately NOT used here so the hook path does
|
|
155
208
|
* not pay for host-path resolution and owner.yaml I/O on every Bash call.
|
|
156
209
|
*
|
|
210
|
+
* `max-per-session` is returned ALREADY RESOLVED for the current session type
|
|
211
|
+
* (read from the active STATE.md frontmatter), so every existing consumer that
|
|
212
|
+
* treats it as a plain number keeps working and automatically gains the
|
|
213
|
+
* per-session-type override. The unresolved value stays available under
|
|
214
|
+
* `max-per-session-raw`, and `session-type-resolved` records which type the
|
|
215
|
+
* resolution used (`null` when STATE.md is absent or carries no session-type).
|
|
216
|
+
*
|
|
157
217
|
* @param {string} repoRoot
|
|
158
|
-
* @returns {{ "max-per-session": number, mode: string, overflow: string }}
|
|
218
|
+
* @returns {{ "max-per-session": number, "max-per-session-raw": number|Record<string, number>, "session-type-resolved": string|null, mode: string, overflow: string }}
|
|
159
219
|
*/
|
|
160
220
|
export function loadIssueBudgetConfig(repoRoot) {
|
|
161
|
-
const defaults = {
|
|
221
|
+
const defaults = {
|
|
222
|
+
'max-per-session': 12,
|
|
223
|
+
'max-per-session-raw': 12,
|
|
224
|
+
'session-type-resolved': null,
|
|
225
|
+
mode: 'strict',
|
|
226
|
+
overflow: 'collect-issue',
|
|
227
|
+
};
|
|
228
|
+
let parsed;
|
|
162
229
|
try {
|
|
163
230
|
const resolved = resolveInstructionFile(repoRoot);
|
|
164
231
|
if (!resolved) return defaults;
|
|
165
|
-
|
|
232
|
+
parsed = _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
|
|
166
233
|
} catch {
|
|
167
234
|
return defaults;
|
|
168
235
|
}
|
|
236
|
+
const sessionType = readSessionTypeFromStateMd(repoRoot);
|
|
237
|
+
return {
|
|
238
|
+
...parsed,
|
|
239
|
+
'max-per-session': resolveMaxPerSession(parsed, sessionType),
|
|
240
|
+
'session-type-resolved': sessionType,
|
|
241
|
+
};
|
|
169
242
|
}
|
|
170
243
|
|
|
171
244
|
/**
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
* pipeline).
|
|
21
21
|
*
|
|
22
22
|
* Both emitters are best-effort and try/catch-wrapped — same posture as
|
|
23
|
-
* `emitReconcileCompleted` in `scripts/lib/reconcile/engine.mjs`
|
|
24
|
-
* `decideAndRecordAutoDialectic` in `scripts/lib/auto-dialectic.mjs` — because
|
|
23
|
+
* `emitReconcileCompleted` in `scripts/lib/reconcile/engine.mjs` — because
|
|
25
24
|
* `emitEvent()` THROWS `EventValidationError` on a malformed record, and a
|
|
26
25
|
* telemetry failure must never break the `/evolve` pipeline itself.
|
|
27
26
|
*
|