session-orchestrator 3.22.0 → 3.23.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -27,16 +27,59 @@
|
|
|
27
27
|
* Stdlib only — the hook path must stay cheap enough to run on every Bash call.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
|
|
33
34
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
34
35
|
import { resolveInstructionFile } from './common.mjs';
|
|
35
36
|
import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Legacy single-slot counter file, relative to the repo root.
|
|
40
|
+
*
|
|
41
|
+
* Kept as the path for IDENTITY-LESS callers only, and as the one-time
|
|
42
|
+
* migration source for a session whose spend was recorded before #1141.
|
|
43
|
+
*/
|
|
38
44
|
export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
|
|
39
45
|
|
|
46
|
+
/** Directory holding the per-session counter files, relative to the repo root. */
|
|
47
|
+
export const BUDGET_STATE_DIR_REL = '.orchestrator/runtime/issue-budget';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Relative path of the counter file for one accounting session (#1141).
|
|
51
|
+
*
|
|
52
|
+
* WHY per session and not one file: the counter used to be ONE slot per
|
|
53
|
+
* WORKING COPY, keyed by whichever `sessionId` happened to write last, and
|
|
54
|
+
* `readBudgetState` zeroes the state whenever the file's `sessionId` differs
|
|
55
|
+
* from the reader's. Two concurrent sessions in one working copy therefore
|
|
56
|
+
* alternately reset each other's counter and BOTH ran with the cap silently
|
|
57
|
+
* off — measured 2026-08-23, where the live file was owned by a session that
|
|
58
|
+
* had started 11 h before the one reading it. Session identity belongs in the
|
|
59
|
+
* FILE NAME, not in a field the next writer overwrites.
|
|
60
|
+
*
|
|
61
|
+
* The name is a truncated SHA-256 rather than the id itself because session
|
|
62
|
+
* ids are operator/host-supplied strings: a semantic id contains `/`-free but
|
|
63
|
+
* unbounded text, and a raw id is a UUID. Hashing gives a fixed-length,
|
|
64
|
+
* filesystem-safe, path-traversal-free name for both shapes. 16 hex chars
|
|
65
|
+
* (64 bits) is far beyond the handful of sessions that ever share one working
|
|
66
|
+
* copy; revisit only if a repo ever needs the id to be readable from the name
|
|
67
|
+
* (it never has — every reader already knows which session it is).
|
|
68
|
+
*
|
|
69
|
+
* An identity-less caller (`null`/empty) keeps the legacy flat path: it never
|
|
70
|
+
* reads and never persists (see `readBudgetState` / `chargeIssueBudget`), so
|
|
71
|
+
* it needs a stable path only to NAME the store in messages.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|null|undefined} sessionId accounting session key
|
|
74
|
+
* @returns {string} repo-relative path
|
|
75
|
+
*/
|
|
76
|
+
export function budgetStateRel(sessionId) {
|
|
77
|
+
const key = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
78
|
+
if (key === null) return BUDGET_STATE_REL;
|
|
79
|
+
const digest = digestSha256Short(key, { length: 16 });
|
|
80
|
+
return `${BUDGET_STATE_DIR_REL}/${digest}.json`;
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
/**
|
|
41
84
|
* Resolve the accounting key for a native session id.
|
|
42
85
|
*
|
|
@@ -126,12 +169,55 @@ export function loadIssueBudgetConfig(repoRoot) {
|
|
|
126
169
|
}
|
|
127
170
|
|
|
128
171
|
/**
|
|
129
|
-
* Absolute path of the runtime counter file for a repo.
|
|
172
|
+
* Absolute path of the runtime counter file for one session in a repo.
|
|
173
|
+
*
|
|
130
174
|
* @param {string} repoRoot
|
|
175
|
+
* @param {string|null} [sessionId] accounting session key; omitted/empty
|
|
176
|
+
* yields the legacy identity-less flat path.
|
|
131
177
|
* @returns {string}
|
|
132
178
|
*/
|
|
133
|
-
export function budgetStatePath(repoRoot) {
|
|
134
|
-
return path.join(repoRoot,
|
|
179
|
+
export function budgetStatePath(repoRoot, sessionId = null) {
|
|
180
|
+
return path.join(repoRoot, budgetStateRel(sessionId));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Coerce a parsed counter file into a state object, or `null` when it does not
|
|
185
|
+
* belong to `accountingSessionId`.
|
|
186
|
+
*
|
|
187
|
+
* The owner check survives the move to per-session files: the file NAME now
|
|
188
|
+
* carries identity, but a hand-edited, hash-colliding or hand-copied file must
|
|
189
|
+
* still not hand its spend to a different session.
|
|
190
|
+
*
|
|
191
|
+
* @param {unknown} data
|
|
192
|
+
* @param {string} accountingSessionId
|
|
193
|
+
* @returns {{ sessionId: string, count: number, exempt: number, overflow: object[] }|null}
|
|
194
|
+
*/
|
|
195
|
+
function _coerceState(data, accountingSessionId) {
|
|
196
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
197
|
+
if (data.sessionId !== accountingSessionId) return null;
|
|
198
|
+
return {
|
|
199
|
+
sessionId: accountingSessionId,
|
|
200
|
+
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
201
|
+
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
202
|
+
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read a counter file and coerce it, swallowing every I/O and parse error
|
|
208
|
+
* (fail-open: an unreadable ledger must never block a creation).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} file
|
|
211
|
+
* @param {string} accountingSessionId
|
|
212
|
+
* @returns {object|null}
|
|
213
|
+
*/
|
|
214
|
+
function _readStateFile(file, accountingSessionId) {
|
|
215
|
+
if (!existsSync(file)) return null;
|
|
216
|
+
try {
|
|
217
|
+
return _coerceState(JSON.parse(readFileSync(file, 'utf8')), accountingSessionId);
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
135
221
|
}
|
|
136
222
|
|
|
137
223
|
/**
|
|
@@ -144,10 +230,18 @@ export function budgetStatePath(repoRoot) {
|
|
|
144
230
|
* but avoiding cross-session budget and overflow attribution wins over a
|
|
145
231
|
* continuity guess without a verified native identity.
|
|
146
232
|
*
|
|
147
|
-
* The counter file is
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* parked overflow records. `chargeIssueBudget` enforces that
|
|
233
|
+
* The counter file is per session since #1141, but an identity-less charge
|
|
234
|
+
* still must never PERSIST its fresh state — it would land on the shared
|
|
235
|
+
* legacy path and, before the split, silently zeroed a live session's count
|
|
236
|
+
* and deleted its parked overflow records. `chargeIssueBudget` enforces that
|
|
237
|
+
* write-side half.
|
|
238
|
+
*
|
|
239
|
+
* MIGRATION (one-time, read-only): a session that started before the per-session
|
|
240
|
+
* split has its spend in the legacy flat file. When no per-session file exists
|
|
241
|
+
* yet and the legacy file still names THIS session, seed from it — otherwise the
|
|
242
|
+
* split itself would hand every in-flight session a fresh cap, which is the very
|
|
243
|
+
* failure it exists to remove. The legacy file is never written back; the first
|
|
244
|
+
* charge after the seed persists to the per-session path.
|
|
151
245
|
*
|
|
152
246
|
* @param {string} repoRoot
|
|
153
247
|
* @param {string|null} sessionId
|
|
@@ -159,33 +253,33 @@ export function readBudgetState(repoRoot, sessionId) {
|
|
|
159
253
|
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
|
|
160
254
|
if (accountingSessionId === null) return fresh;
|
|
161
255
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
172
|
-
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
173
|
-
};
|
|
174
|
-
} catch {
|
|
175
|
-
return fresh;
|
|
176
|
-
}
|
|
256
|
+
const ownFile = budgetStatePath(repoRoot, accountingSessionId);
|
|
257
|
+
const own = _readStateFile(ownFile, accountingSessionId);
|
|
258
|
+
if (own) return own;
|
|
259
|
+
// A present-but-unusable own file is a fail-open fresh state, NOT a reason to
|
|
260
|
+
// fall back to the legacy slot — the migration seed applies only before the
|
|
261
|
+
// session has a file of its own.
|
|
262
|
+
if (existsSync(ownFile)) return fresh;
|
|
263
|
+
|
|
264
|
+
return _readStateFile(path.join(repoRoot, BUDGET_STATE_REL), accountingSessionId) ?? fresh;
|
|
177
265
|
}
|
|
178
266
|
|
|
179
267
|
/**
|
|
180
|
-
* Persist the counter file. Best-effort: a write failure
|
|
181
|
-
* creation (fail-open), it only means the count is
|
|
268
|
+
* Persist the counter file for `state.sessionId`. Best-effort: a write failure
|
|
269
|
+
* never blocks a creation (fail-open), it only means the count is
|
|
270
|
+
* under-reported.
|
|
271
|
+
*
|
|
272
|
+
* The target path is derived from `state.sessionId`, so a state object can only
|
|
273
|
+
* ever be written into its OWN session's slot. `writeJsonAtomicSync` mkdir -p's
|
|
274
|
+
* the containing directory (`io.mjs#atomicWriteWithBackup`), which is what
|
|
275
|
+
* creates `.orchestrator/runtime/issue-budget/` on first use.
|
|
182
276
|
*
|
|
183
277
|
* @param {string} repoRoot
|
|
184
278
|
* @param {object} state
|
|
185
279
|
* @returns {boolean} true on success
|
|
186
280
|
*/
|
|
187
281
|
export function writeBudgetState(repoRoot, state) {
|
|
188
|
-
const res = writeJsonAtomicSync(budgetStatePath(repoRoot), state, {
|
|
282
|
+
const res = writeJsonAtomicSync(budgetStatePath(repoRoot, state?.sessionId ?? null), state, {
|
|
189
283
|
tmpPrefix: '.issue-budget',
|
|
190
284
|
});
|
|
191
285
|
return res.ok === true;
|
|
@@ -236,7 +330,12 @@ export function chargeIssueBudget({
|
|
|
236
330
|
const max = cfg['max-per-session'];
|
|
237
331
|
const mode = cfg.mode;
|
|
238
332
|
const overflowSink = cfg.overflow;
|
|
239
|
-
const
|
|
333
|
+
const accountingSessionId =
|
|
334
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
335
|
+
// Per-session store (#1141) — the path an operator or session-end must open
|
|
336
|
+
// to find THIS session's parked overflow, so the verdict has to name the
|
|
337
|
+
// session's own slot, not the directory or the legacy flat file.
|
|
338
|
+
const overflowPath = budgetStatePath(repoRoot, accountingSessionId);
|
|
240
339
|
|
|
241
340
|
const base = { max, mode, overflowSink, overflowPath };
|
|
242
341
|
|
|
@@ -244,8 +343,6 @@ export function chargeIssueBudget({
|
|
|
244
343
|
return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
|
|
245
344
|
}
|
|
246
345
|
|
|
247
|
-
const accountingSessionId =
|
|
248
|
-
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
249
346
|
const state = readBudgetState(repoRoot, accountingSessionId);
|
|
250
347
|
state.sessionId = accountingSessionId;
|
|
251
348
|
|
|
@@ -321,3 +418,105 @@ export function formatBlockReason(v) {
|
|
|
321
418
|
`\`mode: warn\` reports without blocking, \`mode: off\` disables the gate.`,
|
|
322
419
|
].join('\n');
|
|
323
420
|
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Default age past which a per-session counter file is reaped (#1151).
|
|
424
|
+
*
|
|
425
|
+
* BV-004 ceiling: 14 days is a "nobody will ever drain this now" horizon, not a
|
|
426
|
+
* measured retention requirement. A session's overflow is drained at ITS OWN
|
|
427
|
+
* close (session-end Phase 5 Step 3b), so a file still carrying undrained
|
|
428
|
+
* overflow two weeks later belongs to a session that ended without closing —
|
|
429
|
+
* its parked items are already unreachable by the drain, which only ever reads
|
|
430
|
+
* the CURRENT session's file. Revisit if a triage workflow ever reads a foreign
|
|
431
|
+
* session's overflow after the fact.
|
|
432
|
+
*/
|
|
433
|
+
export const BUDGET_REAP_MAX_AGE_DAYS = 14;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Recognised per-session counter file name — the 16-hex digest `budgetStateRel`
|
|
437
|
+
* emits, plus `.json`. Anything else in the directory is left alone: the reaper
|
|
438
|
+
* must only ever remove files it can prove it wrote itself.
|
|
439
|
+
*/
|
|
440
|
+
const BUDGET_FILE_NAME_RE = /^[0-9a-f]{16}\.json$/;
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Remove stale per-session counter files (#1151).
|
|
444
|
+
*
|
|
445
|
+
* WHY: the per-session split (#1141) writes one file per accounting session
|
|
446
|
+
* into `.orchestrator/runtime/issue-budget/` and NOTHING ever removed them —
|
|
447
|
+
* there was not a single unlink site for that directory in the repo. Every
|
|
448
|
+
* session in a working copy therefore left a permanent 100-200 byte artefact
|
|
449
|
+
* behind. This is the missing other half of the file's lifecycle, owned by
|
|
450
|
+
* session-end Phase 5 Step 3b (the drain runs first, this sweeps after).
|
|
451
|
+
*
|
|
452
|
+
* Age is judged by MTIME, and the CURRENT session's file is exempt regardless
|
|
453
|
+
* of age. Both halves are safety, not convenience: a parallel session in the
|
|
454
|
+
* same working copy (PSA-001) owns a file this process did not write, and its
|
|
455
|
+
* freshly-touched mtime is the only evidence available here that the owner is
|
|
456
|
+
* still alive — the same age-gate discipline `session-registry.mjs`
|
|
457
|
+
* `sweepZombies` uses for in-flight claim files.
|
|
458
|
+
*
|
|
459
|
+
* Best-effort by contract: every failure path is swallowed and the file counted
|
|
460
|
+
* as kept. A reaper that throws would abort the session close it runs inside,
|
|
461
|
+
* which is a strictly worse outcome than an un-reaped counter file.
|
|
462
|
+
*
|
|
463
|
+
* @param {{
|
|
464
|
+
* repoRoot: string,
|
|
465
|
+
* sessionId?: string|null,
|
|
466
|
+
* maxAgeDays?: number,
|
|
467
|
+
* now?: number,
|
|
468
|
+
* }} opts `sessionId` is the accounting session id whose file must survive;
|
|
469
|
+
* `now` is an epoch-ms injection point for tests.
|
|
470
|
+
* @returns {{ removed: string[], kept: string[] }} absolute paths, so a caller
|
|
471
|
+
* gets both a count (`.length`) and an audit trail.
|
|
472
|
+
*/
|
|
473
|
+
export function reapStaleBudgetFiles({
|
|
474
|
+
repoRoot,
|
|
475
|
+
sessionId = null,
|
|
476
|
+
maxAgeDays = BUDGET_REAP_MAX_AGE_DAYS,
|
|
477
|
+
now = Date.now(),
|
|
478
|
+
} = {}) {
|
|
479
|
+
const removed = [];
|
|
480
|
+
const kept = [];
|
|
481
|
+
try {
|
|
482
|
+
const dir = path.join(repoRoot, BUDGET_STATE_DIR_REL);
|
|
483
|
+
const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
484
|
+
const ownFile =
|
|
485
|
+
typeof sessionId === 'string' && sessionId.length > 0
|
|
486
|
+
? budgetStatePath(repoRoot, sessionId)
|
|
487
|
+
: null;
|
|
488
|
+
|
|
489
|
+
let names;
|
|
490
|
+
try {
|
|
491
|
+
names = readdirSync(dir);
|
|
492
|
+
} catch {
|
|
493
|
+
// No directory yet (or unreadable) — nothing to reap, not an error.
|
|
494
|
+
return { removed, kept };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const name of names) {
|
|
498
|
+
// Unrecognised entries are not part of this reaper's population at all:
|
|
499
|
+
// neither removed nor reported as kept.
|
|
500
|
+
if (!BUDGET_FILE_NAME_RE.test(name)) continue;
|
|
501
|
+
const file = path.join(dir, name);
|
|
502
|
+
if (file === ownFile) {
|
|
503
|
+
kept.push(file);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
if (statSync(file).mtimeMs >= cutoff) {
|
|
508
|
+
kept.push(file);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
unlinkSync(file);
|
|
512
|
+
removed.push(file);
|
|
513
|
+
} catch {
|
|
514
|
+
// Vanished under us (a peer's own reaper), or permission-denied.
|
|
515
|
+
kept.push(file);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
// Unreachable in practice; the contract is "never throws", not "never fails".
|
|
520
|
+
}
|
|
521
|
+
return { removed, kept };
|
|
522
|
+
}
|
|
@@ -42,10 +42,20 @@ import {
|
|
|
42
42
|
* append touches disk, so a bad write can never reach the file.
|
|
43
43
|
*
|
|
44
44
|
* @param {object} validated — already validated+normalized learning entry
|
|
45
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`,
|
|
46
|
+
* a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
|
|
47
|
+
* no `source_session`, tolerated by `readLearnings()`) stays tolerated after
|
|
48
|
+
* the round-trip too — the re-validation call below runs in the same
|
|
49
|
+
* tolerant mode. This does NOT weaken the #662 guarantee: a key that WAS
|
|
50
|
+
* present on `validated` (even `undefined`) and is no longer a key on the
|
|
51
|
+
* reparsed object is genuine JSON.stringify corruption, detected by the
|
|
52
|
+
* dedicated `droppedKeys` check below and thrown regardless of
|
|
53
|
+
* `legacyTolerant`. Default `false` — `appendLearning`'s single-record path
|
|
54
|
+
* calls this with no options and is unaffected.
|
|
45
55
|
* @returns {string} the verified JSONL line (newline-terminated)
|
|
46
56
|
* @throws {ValidationError} when the serialized line does not round-trip
|
|
47
57
|
*/
|
|
48
|
-
function serializeLearningLineChecked(validated) {
|
|
58
|
+
function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}) {
|
|
49
59
|
let line;
|
|
50
60
|
try {
|
|
51
61
|
line = JSON.stringify(validated);
|
|
@@ -66,9 +76,23 @@ function serializeLearningLineChecked(validated) {
|
|
|
66
76
|
`serialized learning line does not parse back as JSON: ${err.message}`
|
|
67
77
|
);
|
|
68
78
|
}
|
|
79
|
+
if (legacyTolerant) {
|
|
80
|
+
// A key that existed on `validated` (present, even as `undefined`) but
|
|
81
|
+
// vanished from `reparsed` was DROPPED by JSON.stringify — the exact
|
|
82
|
+
// undefined/NaN/etc. corruption #662 exists to catch. A key that was
|
|
83
|
+
// never on `validated` in the first place (the #386 legacy-field case)
|
|
84
|
+
// cannot appear here, because we only iterate `validated`'s own keys.
|
|
85
|
+
const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
|
|
86
|
+
if (droppedKeys.length > 0) {
|
|
87
|
+
throw new ValidationError(
|
|
88
|
+
`learning lost field(s) during JSON round-trip serialization ` +
|
|
89
|
+
`(non-serializable value?): ${droppedKeys.join(', ')}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
69
93
|
// Re-validate the round-tripped shape — catches required fields that were
|
|
70
|
-
// present as `undefined`/`NaN` before stringify but vanished after.
|
|
71
|
-
validateLearning(reparsed);
|
|
94
|
+
// present as `undefined`/`NaN` before stringify but vanished/coerced after.
|
|
95
|
+
validateLearning(reparsed, { legacyTolerant });
|
|
72
96
|
return line + '\n';
|
|
73
97
|
}
|
|
74
98
|
|
|
@@ -203,29 +227,50 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
203
227
|
* - `backup` (default `true`): before the destructive rename, copy the current
|
|
204
228
|
* file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
|
|
205
229
|
* {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
|
|
230
|
+
* - `legacyTolerant` (default `true`, GitLab #386): this function is a
|
|
231
|
+
* ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
|
|
232
|
+
* `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
|
|
233
|
+
* `readLearnings()` first, and that reader already tolerates a legacy
|
|
234
|
+
* record missing e.g. `source_session` (WARN, pass through unchanged — see
|
|
235
|
+
* `normalizeLearning`). Before this option existed, `rewriteLearnings()`
|
|
236
|
+
* re-validated with the SAME strict gate `appendLearning()` uses for a
|
|
237
|
+
* brand-new single record, so re-writing the unchanged KEEP batch of a
|
|
238
|
+
* mechanical sweep could throw on data the reader itself had just accepted
|
|
239
|
+
* — `sweep-expired-learnings --apply` failed on ANY store holding one such
|
|
240
|
+
* record, even though the sweep never touches that record's fields. The
|
|
241
|
+
* default is `true` precisely because the sweep/prune call sites cannot be
|
|
242
|
+
* changed to opt in explicitly without touching `expiry-sweep.mjs`, which
|
|
243
|
+
* passes no `legacyTolerant`; every field that genuinely CANNOT survive a
|
|
244
|
+
* round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
|
|
245
|
+
* `NaN`) is still caught by the #662 checked serializer regardless of this
|
|
246
|
+
* flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
|
|
247
|
+
* the pre-#386 fully-strict behaviour.
|
|
206
248
|
*
|
|
207
249
|
* @param {string} filePath
|
|
208
250
|
* @param {object[]} entries
|
|
209
|
-
* @param {{dryRun?: boolean, backup?: boolean}} [opts]
|
|
251
|
+
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
|
|
210
252
|
* @returns {Promise<object[]>} validated entries (always returned, even dryRun)
|
|
211
253
|
*/
|
|
212
254
|
export async function rewriteLearnings(
|
|
213
255
|
filePath,
|
|
214
256
|
entries,
|
|
215
|
-
{ dryRun = false, backup = true } = {}
|
|
257
|
+
{ dryRun = false, backup = true, legacyTolerant = true } = {}
|
|
216
258
|
) {
|
|
217
259
|
const validated = entries.map((e) =>
|
|
218
|
-
validateLearning(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
260
|
+
validateLearning(
|
|
261
|
+
{
|
|
262
|
+
...e,
|
|
263
|
+
schema_version: e?.schema_version ?? CURRENT_SCHEMA_VERSION,
|
|
264
|
+
},
|
|
265
|
+
{ legacyTolerant }
|
|
266
|
+
)
|
|
222
267
|
);
|
|
223
268
|
// Pre-write round-trip self-validation (#662): serialize ALL entries through
|
|
224
269
|
// the checked serializer before touching disk — a single bad entry throws
|
|
225
270
|
// ValidationError and the file is left untouched (atomicity preserved because
|
|
226
271
|
// we validate the full batch first, then write once). This runs even under
|
|
227
272
|
// dryRun, so an invalid entry is still rejected on a dry probe.
|
|
228
|
-
const lines = validated.map((e) => serializeLearningLineChecked(e));
|
|
273
|
+
const lines = validated.map((e) => serializeLearningLineChecked(e, { legacyTolerant }));
|
|
229
274
|
|
|
230
275
|
// dryRun (#721): validation has run; deliberately do NOT touch disk — no
|
|
231
276
|
// rewrite, no backup — and hand the validated entries back to the caller.
|
|
@@ -59,7 +59,7 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Type capability registry (Epic #723 I1, issue #733 Teil b) — the single
|
|
62
|
-
* source of truth for every learning `type`'s TTL policy AND its
|
|
62
|
+
* source of truth for every learning `type`'s TTL policy AND its three
|
|
63
63
|
* cross-module capability axes. Before this registry existed, three modules
|
|
64
64
|
* independently hand-maintained overlapping type lists that drifted out of
|
|
65
65
|
* sync (`memory-proposals/schema.mjs` PROPOSAL_TYPES, `reconcile/eligibility.mjs`
|
|
@@ -76,39 +76,54 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
|
|
|
76
76
|
* - ruleConvertible: may appear in `reconcile/eligibility.mjs` CONVERT_TYPES
|
|
77
77
|
* (i.e. the reconcile engine may convert this type into a
|
|
78
78
|
* conditional `.claude/rules/*.md` rule proposal).
|
|
79
|
+
* - hostScoped: this type's content is genuinely chip/OS-specific, so
|
|
80
|
+
* `reconcile/emitter.mjs` may copy a record's `host_class`
|
|
81
|
+
* through as the emitted rule's `host-class` activation
|
|
82
|
+
* axis (issue #1090; derived set: that module's
|
|
83
|
+
* HOST_SPECIFIC_TYPES). `false` for every general finding,
|
|
84
|
+
* whose `host_class` merely records the machine it was
|
|
85
|
+
* authored on and must never gate the rule.
|
|
86
|
+
*
|
|
87
|
+
* Every entry declares every axis explicitly — an omitted flag would read as
|
|
88
|
+
* `undefined` (falsy) and silently opt a new type out of a capability without
|
|
89
|
+
* anyone deciding that. Guarded by a test in
|
|
90
|
+
* `tests/lib/learnings-schema-normalization.test.mjs`.
|
|
79
91
|
*/
|
|
80
92
|
export const LEARNING_TYPE_REGISTRY = Object.freeze({
|
|
81
|
-
'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false }),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
93
|
+
'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
94
|
+
// hardware-pattern is the ONLY hostScoped type today: its content IS the
|
|
95
|
+
// chip/OS, so gating the emitted rule by host-class is faithful rather than
|
|
96
|
+
// an accidental one-machine restriction (#1090).
|
|
97
|
+
'hardware-pattern': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: true }),
|
|
98
|
+
'fragile-file': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
99
|
+
'effective-sizing': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
100
|
+
'recurring-issue': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
86
101
|
// workflow-pattern / proven-pattern: flipped ruleConvertible false->true
|
|
87
102
|
// (issue #900) — the real corpus census showed a large volume of live
|
|
88
103
|
// `workflow-pattern`/`proven-pattern` records (post type-alias-normalization,
|
|
89
104
|
// see LEARNING_TYPE_ALIASES below) that carried usable file_paths but were
|
|
90
105
|
// structurally unconvertible before this flip.
|
|
91
|
-
'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
92
|
-
'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
93
|
-
'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
94
|
-
'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
|
|
106
|
+
'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
107
|
+
'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
108
|
+
'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
109
|
+
'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
95
110
|
// autonomy-verdict (#683): repo/scope readiness synthesis from autopilot
|
|
96
111
|
// effectiveness plus skill-judge signals. 90d matches the operational
|
|
97
112
|
// autopilot-effectiveness horizon it depends on. Analyzer-only — never
|
|
98
113
|
// agent-proposable.
|
|
99
|
-
'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false }),
|
|
114
|
+
'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false, hostScoped: false }),
|
|
100
115
|
// domain-regression (#638): a sidecar-sourced regression flag (metric baseline→recent
|
|
101
116
|
// delta) surfaced via /evolve extra-sources. 60d aligns with the moderate-decay tier
|
|
102
117
|
// (hardware-pattern / default) — a regression signal should age out if it stops recurring.
|
|
103
|
-
'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false }),
|
|
104
|
-
'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
105
|
-
'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
106
|
-
'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
118
|
+
'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
119
|
+
'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
120
|
+
'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
121
|
+
'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
107
122
|
// fragile-pattern / stagnation-class-frequency: 0 live instances as of
|
|
108
123
|
// 2026-07-02 (see reconcile/eligibility.mjs census) — CONVERT-eligible but
|
|
109
124
|
// NOT agent-proposable (analyzer-synthesized classes, not agent-observed).
|
|
110
|
-
'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true }),
|
|
111
|
-
'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true }),
|
|
125
|
+
'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true, hostScoped: false }),
|
|
126
|
+
'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true, hostScoped: false }),
|
|
112
127
|
});
|
|
113
128
|
|
|
114
129
|
/**
|
|
@@ -215,47 +230,99 @@ export class ValidationError extends Error {
|
|
|
215
230
|
* Throws ValidationError on contract violations. Does NOT mutate input.
|
|
216
231
|
*
|
|
217
232
|
* @param {object} entry — candidate learning
|
|
233
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`, a
|
|
234
|
+
* value `normalizeLearning()` already passes through UNCHECKED (or merely
|
|
235
|
+
* DEFAULTED, never validated) on read is no longer rejected here either:
|
|
236
|
+
* an out-of-enum `schema_version` (measured against the real EventDrop.at
|
|
237
|
+
* store: 3 live records carry `schema_version: 2`), the
|
|
238
|
+
* LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
|
|
239
|
+
* when NO `confidence` key is present, and the `scope`/`host_class`/
|
|
240
|
+
* `anonymized` shape checks (measured live: a record with `scope:
|
|
241
|
+
* "src/components/landing/mobile-sticky-cta.tsx"` — a producer bug that
|
|
242
|
+
* wrote a file path into the scope column, which `normalizeLearning` never
|
|
243
|
+
* validates and therefore never rejects). A field that IS present keeps
|
|
244
|
+
* being validated regardless when its DATA TYPE can be silently corrupted
|
|
245
|
+
* by a JSON round-trip (a present-but-malformed `confidence` still throws
|
|
246
|
+
* — see io.mjs #662): this option relaxes "you must HAVE a valid value",
|
|
247
|
+
* never "if a round-trip could have silently mangled it, that's fine too".
|
|
248
|
+
* The privacy CONTRACT (`scope: 'public'` requiring `anonymized`/
|
|
249
|
+
* `host_class` to be consistent) is NEVER relaxed — it only ever fires for
|
|
250
|
+
* a record that genuinely claims `scope: 'public'`, which is exactly the
|
|
251
|
+
* case a round-trip must not silently launder. Default `false` — every
|
|
252
|
+
* existing direct caller (including `appendLearning`'s single-record write
|
|
253
|
+
* path) is unaffected.
|
|
218
254
|
* @returns {object} normalized entry with scope/host_class/anonymized defaulted
|
|
219
255
|
*/
|
|
220
|
-
export function validateLearning(entry) {
|
|
256
|
+
export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
221
257
|
if (!entry || typeof entry !== 'object') {
|
|
222
258
|
throw new ValidationError('learning must be an object');
|
|
223
259
|
}
|
|
224
260
|
|
|
225
261
|
// schema_version: 0 (implicit/legacy), 1 (current). Both accepted.
|
|
262
|
+
// `normalizeLearning()` never validates this value on read (any value
|
|
263
|
+
// passes through as-is), so under legacyTolerant an ABSENT schema_version
|
|
264
|
+
// (defaults to the valid `0`) or one already present with SOME value is
|
|
265
|
+
// both fine — only a truly missing-AND-invalid combination cannot occur
|
|
266
|
+
// (missing defaults to 0, which is always valid), so in practice this skips
|
|
267
|
+
// the check whenever legacyTolerant is set and a non-legacy value survived
|
|
268
|
+
// a prior read (e.g. `schema_version: 2`, measured live in production).
|
|
226
269
|
const schemaVersion = entry.schema_version ?? 0;
|
|
227
|
-
if (schemaVersion !== 0 && schemaVersion !== 1) {
|
|
270
|
+
if (!legacyTolerant && schemaVersion !== 0 && schemaVersion !== 1) {
|
|
228
271
|
throw new ValidationError(
|
|
229
272
|
`schema_version must be 0 (legacy) or 1, got: ${schemaVersion}`
|
|
230
273
|
);
|
|
231
274
|
}
|
|
232
275
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
276
|
+
if (!legacyTolerant) {
|
|
277
|
+
for (const field of LEGACY_REQUIRED_FIELDS) {
|
|
278
|
+
if (!(field in entry)) {
|
|
279
|
+
throw new ValidationError(`learning missing required field: ${field}`);
|
|
280
|
+
}
|
|
236
281
|
}
|
|
237
282
|
}
|
|
238
283
|
|
|
239
|
-
|
|
240
|
-
|
|
284
|
+
// confidence carries its own type/range check in addition to the presence
|
|
285
|
+
// loop above, so it needs its own legacyTolerant gate: skip ONLY when the
|
|
286
|
+
// key is genuinely absent (mirrors the presence loop); a present-but-bad
|
|
287
|
+
// value (wrong type, out of range, or `null` after a JSON round-trip
|
|
288
|
+
// coerced a non-serializable value — see io.mjs #662) still throws under
|
|
289
|
+
// legacyTolerant, same as under strict mode.
|
|
290
|
+
if (!legacyTolerant || 'confidence' in entry) {
|
|
291
|
+
if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
|
|
292
|
+
throw new ValidationError(`confidence must be a number in [0, 1], got: ${entry.confidence}`);
|
|
293
|
+
}
|
|
241
294
|
}
|
|
242
295
|
|
|
296
|
+
// scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
|
|
297
|
+
// absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
|
|
298
|
+
// PRESENT one, so a corrupt legacy value round-trips silently today
|
|
299
|
+
// (measured live in production: a record with `scope:
|
|
300
|
+
// "src/components/landing/mobile-sticky-cta.tsx"` — evidently a producer
|
|
301
|
+
// bug that wrote a file path into the scope column). All three checks are
|
|
302
|
+
// therefore skipped under legacyTolerant. Unlike `confidence` (a number,
|
|
303
|
+
// where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
|
|
304
|
+
// across a JSON round-trip — the #662 concern), a string/string-or-null/
|
|
305
|
+
// boolean value round-trips through JSON byte-for-byte, so there is no
|
|
306
|
+
// corresponding round-trip-corruption risk to guard against here.
|
|
243
307
|
const scope = entry.scope ?? 'local';
|
|
244
|
-
if (!VALID_SCOPES.includes(scope)) {
|
|
308
|
+
if (!legacyTolerant && !VALID_SCOPES.includes(scope)) {
|
|
245
309
|
throw new ValidationError(`scope must be one of ${VALID_SCOPES.join('|')}, got: ${scope}`);
|
|
246
310
|
}
|
|
247
311
|
|
|
248
312
|
const hostClass = entry.host_class ?? null;
|
|
249
|
-
if (hostClass !== null && typeof hostClass !== 'string') {
|
|
313
|
+
if (!legacyTolerant && hostClass !== null && typeof hostClass !== 'string') {
|
|
250
314
|
throw new ValidationError(`host_class must be string or null, got: ${typeof hostClass}`);
|
|
251
315
|
}
|
|
252
316
|
|
|
253
317
|
const anonymized = entry.anonymized ?? false;
|
|
254
|
-
if (typeof anonymized !== 'boolean') {
|
|
318
|
+
if (!legacyTolerant && typeof anonymized !== 'boolean') {
|
|
255
319
|
throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
|
|
256
320
|
}
|
|
257
321
|
|
|
258
|
-
// Privacy contract
|
|
322
|
+
// Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
|
|
323
|
+
// `scope` (garbage or otherwise) is by definition not `'public'`, so this
|
|
324
|
+
// pair only ever fires for a record that genuinely claims `scope: 'public'`
|
|
325
|
+
// — exactly the case a round-trip must not silently launder.
|
|
259
326
|
if (scope === 'public' && !anonymized) {
|
|
260
327
|
throw new ValidationError(
|
|
261
328
|
'scope=public requires anonymized=true (privacy contract violation)'
|
|
@@ -68,6 +68,7 @@ import path from 'node:path';
|
|
|
68
68
|
import { enumerateCandidates } from './dispatcher/enumerate.mjs';
|
|
69
69
|
import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
70
70
|
import { emitEvent } from './events.mjs';
|
|
71
|
+
import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
|
|
71
72
|
|
|
72
73
|
const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
|
|
73
74
|
const REAPED_EVENT = 'orchestrator.session.lock.reaped';
|
|
@@ -463,7 +464,12 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
|
|
|
463
464
|
};
|
|
464
465
|
}
|
|
465
466
|
|
|
466
|
-
|
|
467
|
+
// #1072: alias-aware host identity. A raw comparison classified this
|
|
468
|
+
// machine's OWN orphaned lock as cross-host after a hostname flip, so it was
|
|
469
|
+
// never reaped and every subsequent session needed operator intervention.
|
|
470
|
+
// The invariant is unchanged for a genuinely foreign host: its name was never
|
|
471
|
+
// written into this machine's self-alias ledger, so it can never match.
|
|
472
|
+
const ownHost = hostnamesMatch(lockHostCandidate(lock), D.hostname());
|
|
467
473
|
|
|
468
474
|
// Invariant (c): cross-host leases are NEVER auto-reaped — only listed.
|
|
469
475
|
if (!ownHost) {
|