session-orchestrator 4.1.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-plan/SKILL.md +1 -1
- 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-plan/SKILL.md +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-plan/SKILL.md +1 -1
- 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 +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- 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/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- 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 +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- 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 +16 -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 +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- 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 +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -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/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/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- 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/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- 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-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- 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 +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- 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 +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- 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-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- 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/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-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- 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/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -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 +60 -209
- 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/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/bootstrap/standard-template.md +39 -24
- 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 +8 -4
- 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/docs-orchestrator/audience-mapping.md +1 -1
- 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/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/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- 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/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- 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 +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- 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 +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- 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
- package/templates/_shared/journey-manifest.md +0 -114
package/scripts/lib/io.mjs
CHANGED
|
@@ -248,21 +248,46 @@ export function writeStdoutLineSync(line) {
|
|
|
248
248
|
// Exports
|
|
249
249
|
// ---------------------------------------------------------------------------
|
|
250
250
|
|
|
251
|
+
/** Default stdin read timeout. Every production caller uses it. */
|
|
252
|
+
const READ_STDIN_TIMEOUT_MS = 5_000; // 5 s guard
|
|
253
|
+
|
|
251
254
|
/**
|
|
252
255
|
* Read process.stdin to EOF and parse as JSON.
|
|
256
|
+
*
|
|
257
|
+
* ## Why the timeout is a PARAMETER and not an env var
|
|
258
|
+
*
|
|
259
|
+
* The guard exists so a hook whose stdin never closes (the harness died, the
|
|
260
|
+
* pipe was inherited by a long-lived grandchild) fails instead of hanging the
|
|
261
|
+
* tool call forever. A 5-second stall is also what made the behaviour
|
|
262
|
+
* effectively untestable: the suite cannot afford to wait for it, which is why
|
|
263
|
+
* the timeout case sat as an empty `it.skip` for two years.
|
|
264
|
+
*
|
|
265
|
+
* Injecting the bound as an OPTION keeps the escape hatch out of the ambient
|
|
266
|
+
* environment: an env var would let any process that happens to export it
|
|
267
|
+
* shorten (or lengthen) the guard for every hook on the host, silently. A
|
|
268
|
+
* parameter can only be shortened by the caller that asks for it — today, only
|
|
269
|
+
* the test that proves the guard fires.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} [opts]
|
|
272
|
+
* @param {number} [opts.timeoutMs=5000] Milliseconds before the read is
|
|
273
|
+
* abandoned. Non-finite or non-positive values fall back to the 5 s
|
|
274
|
+
* default rather than disabling the guard.
|
|
253
275
|
* @returns {Promise<object|null>} Parsed JSON object, or null on empty stream.
|
|
254
276
|
* @throws {SyntaxError} If stdin contains non-empty, non-JSON data.
|
|
255
|
-
* @throws {Error} If the 1 MB size limit or
|
|
277
|
+
* @throws {Error} If the 1 MB size limit or the timeout is exceeded.
|
|
256
278
|
*/
|
|
257
|
-
export async function readStdin() {
|
|
279
|
+
export async function readStdin(opts = {}) {
|
|
258
280
|
const MAX_BYTES = 1_048_576; // 1 MB guard
|
|
259
|
-
const
|
|
281
|
+
const requested = Number(opts?.timeoutMs);
|
|
282
|
+
const TIMEOUT_MS = Number.isFinite(requested) && requested > 0
|
|
283
|
+
? requested
|
|
284
|
+
: READ_STDIN_TIMEOUT_MS;
|
|
260
285
|
|
|
261
286
|
return new Promise((resolve, reject) => {
|
|
262
287
|
const controller = new AbortController();
|
|
263
288
|
const timer = setTimeout(() => {
|
|
264
289
|
controller.abort();
|
|
265
|
-
reject(new Error(
|
|
290
|
+
reject(new Error(`io.mjs: readStdin timed out after ${TIMEOUT_MS / 1000} s`));
|
|
266
291
|
}, TIMEOUT_MS);
|
|
267
292
|
|
|
268
293
|
const chunks = [];
|
|
@@ -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
|
+
}
|