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
|
@@ -29,11 +29,14 @@
|
|
|
29
29
|
|
|
30
30
|
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
31
31
|
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
32
33
|
import path from 'node:path';
|
|
33
34
|
|
|
34
35
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
35
36
|
import { resolveInstructionFile } from './common.mjs';
|
|
36
37
|
import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
38
|
+
import { parseStateMd } from './state-md/yaml-parser.mjs';
|
|
39
|
+
import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
42
|
* Legacy single-slot counter file, relative to the repo root.
|
|
@@ -148,24 +151,95 @@ export function classifyExemption(command) {
|
|
|
148
151
|
return { exempt: false, reason: null };
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the effective cap for one session type out of a parsed config.
|
|
156
|
+
*
|
|
157
|
+
* Precedence is `raw[sessionType] ?? raw.default ?? cfg['max-per-session']` —
|
|
158
|
+
* an unknown or absent session type falls through to the default, never to a
|
|
159
|
+
* hard-coded number, and a config parsed before the override syntax existed
|
|
160
|
+
* (no `max-per-session-raw` key at all) still resolves via the last term.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ "max-per-session"?: number, "max-per-session-raw"?: number|Record<string, number> }} cfg
|
|
163
|
+
* @param {string|null|undefined} sessionType — e.g. `feature`, `deep`, `housekeeping`
|
|
164
|
+
* @returns {number} non-negative integer cap
|
|
165
|
+
*/
|
|
166
|
+
export function resolveMaxPerSession(cfg, sessionType) {
|
|
167
|
+
const fallback =
|
|
168
|
+
Number.isInteger(cfg?.['max-per-session']) && cfg['max-per-session'] >= 0
|
|
169
|
+
? cfg['max-per-session']
|
|
170
|
+
: 12;
|
|
171
|
+
const raw = cfg?.['max-per-session-raw'];
|
|
172
|
+
if (Number.isInteger(raw) && raw >= 0) return raw;
|
|
173
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
174
|
+
const key = typeof sessionType === 'string' ? sessionType.trim().toLowerCase() : '';
|
|
175
|
+
const override = key === '' ? undefined : raw[key];
|
|
176
|
+
if (Number.isInteger(override) && override >= 0) return override;
|
|
177
|
+
if (Number.isInteger(raw.default) && raw.default >= 0) return raw.default;
|
|
178
|
+
}
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read `session-type:` from the active STATE.md frontmatter.
|
|
184
|
+
*
|
|
185
|
+
* Never throws: a missing STATE.md, unparseable frontmatter, or an absent /
|
|
186
|
+
* non-string `session-type` all yield `null`, which `resolveMaxPerSession`
|
|
187
|
+
* reads as "use the default". The hook path runs on every Bash call, so a
|
|
188
|
+
* broken STATE.md must degrade to today's behaviour rather than fail.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} repoRoot
|
|
191
|
+
* @returns {string|null}
|
|
192
|
+
*/
|
|
193
|
+
export function readSessionTypeFromStateMd(repoRoot) {
|
|
194
|
+
try {
|
|
195
|
+
const filePath = resolveStateMdPath(repoRoot);
|
|
196
|
+
if (!existsSync(filePath)) return null;
|
|
197
|
+
const parsed = parseStateMd(readFileSync(filePath, 'utf8'));
|
|
198
|
+
const value = parsed?.frontmatter?.['session-type'];
|
|
199
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
151
205
|
/**
|
|
152
206
|
* Load the `issue-budget` config from the repo's instruction file
|
|
153
207
|
* (CLAUDE.md / AGENTS.md). Reads only that one file and only that one block —
|
|
154
208
|
* `parseSessionConfig()` is deliberately NOT used here so the hook path does
|
|
155
209
|
* not pay for host-path resolution and owner.yaml I/O on every Bash call.
|
|
156
210
|
*
|
|
211
|
+
* `max-per-session` is returned ALREADY RESOLVED for the current session type
|
|
212
|
+
* (read from the active STATE.md frontmatter), so every existing consumer that
|
|
213
|
+
* treats it as a plain number keeps working and automatically gains the
|
|
214
|
+
* per-session-type override. The unresolved value stays available under
|
|
215
|
+
* `max-per-session-raw`, and `session-type-resolved` records which type the
|
|
216
|
+
* resolution used (`null` when STATE.md is absent or carries no session-type).
|
|
217
|
+
*
|
|
157
218
|
* @param {string} repoRoot
|
|
158
|
-
* @returns {{ "max-per-session": number, mode: string, overflow: string }}
|
|
219
|
+
* @returns {{ "max-per-session": number, "max-per-session-raw": number|Record<string, number>, "session-type-resolved": string|null, mode: string, overflow: string }}
|
|
159
220
|
*/
|
|
160
221
|
export function loadIssueBudgetConfig(repoRoot) {
|
|
161
|
-
const defaults = {
|
|
222
|
+
const defaults = {
|
|
223
|
+
'max-per-session': 12,
|
|
224
|
+
'max-per-session-raw': 12,
|
|
225
|
+
'session-type-resolved': null,
|
|
226
|
+
mode: 'strict',
|
|
227
|
+
overflow: 'collect-issue',
|
|
228
|
+
};
|
|
229
|
+
let parsed;
|
|
162
230
|
try {
|
|
163
231
|
const resolved = resolveInstructionFile(repoRoot);
|
|
164
232
|
if (!resolved) return defaults;
|
|
165
|
-
|
|
233
|
+
parsed = _parseIssueBudget(readFileSync(resolved.path, 'utf8'));
|
|
166
234
|
} catch {
|
|
167
235
|
return defaults;
|
|
168
236
|
}
|
|
237
|
+
const sessionType = readSessionTypeFromStateMd(repoRoot);
|
|
238
|
+
return {
|
|
239
|
+
...parsed,
|
|
240
|
+
'max-per-session': resolveMaxPerSession(parsed, sessionType),
|
|
241
|
+
'session-type-resolved': sessionType,
|
|
242
|
+
};
|
|
169
243
|
}
|
|
170
244
|
|
|
171
245
|
/**
|
|
@@ -180,6 +254,27 @@ export function budgetStatePath(repoRoot, sessionId = null) {
|
|
|
180
254
|
return path.join(repoRoot, budgetStateRel(sessionId));
|
|
181
255
|
}
|
|
182
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Is this a well-formed charge record (#1347)?
|
|
259
|
+
*
|
|
260
|
+
* A record that fails this test is DROPPED rather than repaired: it can no
|
|
261
|
+
* longer prove which unit it was charged to, and a refund against an
|
|
262
|
+
* unclassifiable record is exactly the drain this ledger exists to prevent.
|
|
263
|
+
*
|
|
264
|
+
* @param {unknown} r
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
function _isChargeRecord(r) {
|
|
268
|
+
return (
|
|
269
|
+
!!r &&
|
|
270
|
+
typeof r === 'object' &&
|
|
271
|
+
!Array.isArray(r) &&
|
|
272
|
+
typeof r.key === 'string' &&
|
|
273
|
+
r.key.length > 0 &&
|
|
274
|
+
(r.unit === 'count' || r.unit === 'exempt')
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
183
278
|
/**
|
|
184
279
|
* Coerce a parsed counter file into a state object, or `null` when it does not
|
|
185
280
|
* belong to `accountingSessionId`.
|
|
@@ -200,6 +295,10 @@ function _coerceState(data, accountingSessionId) {
|
|
|
200
295
|
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
201
296
|
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
202
297
|
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
298
|
+
// #1347 CHARGE ledger — PROOF OF CHARGE, the thing a refund must present.
|
|
299
|
+
// Coerced here and NOT elsewhere: this function is the only reader, so a key
|
|
300
|
+
// it drops is a key that does not survive a round trip.
|
|
301
|
+
charged: Array.isArray(data.charged) ? data.charged.filter(_isChargeRecord) : [],
|
|
203
302
|
};
|
|
204
303
|
}
|
|
205
304
|
|
|
@@ -250,7 +349,7 @@ function _readStateFile(file, accountingSessionId) {
|
|
|
250
349
|
export function readBudgetState(repoRoot, sessionId) {
|
|
251
350
|
const accountingSessionId =
|
|
252
351
|
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
253
|
-
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
|
|
352
|
+
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [], charged: [] };
|
|
254
353
|
if (accountingSessionId === null) return fresh;
|
|
255
354
|
|
|
256
355
|
const ownFile = budgetStatePath(repoRoot, accountingSessionId);
|
|
@@ -285,6 +384,100 @@ export function writeBudgetState(repoRoot, state) {
|
|
|
285
384
|
return res.ok === true;
|
|
286
385
|
}
|
|
287
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Named ceilings for a parked overflow record (#1314, BV-004). GitLab accepts
|
|
389
|
+
* descriptions up to ~1 MB; 16 KiB covers every real issue body this repo files,
|
|
390
|
+
* and the record lives in a per-session JSON file that session-end folds into
|
|
391
|
+
* ONE collector issue. Revisit if `truncated: true` shows up in a fold.
|
|
392
|
+
* Measured in UTF-16 code units (≈ bytes for ASCII bodies).
|
|
393
|
+
*/
|
|
394
|
+
export const OVERFLOW_DESCRIPTION_MAX = 16 * 1024;
|
|
395
|
+
export const OVERFLOW_COMMAND_MAX = 2 * 1024;
|
|
396
|
+
/** A description file above GitLab's own ~1 MB limit is not read at all. */
|
|
397
|
+
const OVERFLOW_DESCRIPTION_FILE_MAX_BYTES = 1024 * 1024;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Build the structured overflow record BOTH park sites write (#1314) — the
|
|
401
|
+
* hook's chain pre-flight and `chargeIssueBudget`'s strict branch. Before this
|
|
402
|
+
* both parked only the raw command, so a body passed as `$(cat /tmp/x.md)` was
|
|
403
|
+
* lost once the temp file was gone. The description file is read NOW, at block
|
|
404
|
+
* time; any read failure yields `description: null`, silently — the park itself
|
|
405
|
+
* must never fail. Old records (`title/command/at` only) stay readable: every
|
|
406
|
+
* added key is optional.
|
|
407
|
+
*
|
|
408
|
+
* Path resolution: a leading `~` / `~/` expands to the home directory; an
|
|
409
|
+
* absolute path is used as is; a relative path resolves against `cwd` (the
|
|
410
|
+
* PreToolUse input's working directory) when that is absolute, else against
|
|
411
|
+
* `repoRoot`. When `cwdChanged` (a `cd` earlier in the same chain) a relative
|
|
412
|
+
* path is NOT resolved — the record says `descriptionUnresolved: 'cwd-changed'`
|
|
413
|
+
* instead of parking whichever file happens to sit at the guessed location.
|
|
414
|
+
* NAMED CEILING (BV-004): `~user/` is not expanded. Revisit if it shows up.
|
|
415
|
+
*
|
|
416
|
+
* @param {{ repoRoot: string, title?: string|null, description?: string|null,
|
|
417
|
+
* descriptionFile?: string|null, repo?: string|null,
|
|
418
|
+
* cwd?: string|null, cwdChanged?: boolean,
|
|
419
|
+
* command: string, at: string }} opts
|
|
420
|
+
* @returns {{ title: string|null, description: string|null, repo: string|null,
|
|
421
|
+
* command: string, at: string, truncated: boolean,
|
|
422
|
+
* descriptionUnresolved?: 'cwd-changed' }}
|
|
423
|
+
*/
|
|
424
|
+
export function buildOverflowRecord({
|
|
425
|
+
repoRoot,
|
|
426
|
+
title = null,
|
|
427
|
+
description = null,
|
|
428
|
+
descriptionFile = null,
|
|
429
|
+
repo = null,
|
|
430
|
+
cwd = null,
|
|
431
|
+
cwdChanged = false,
|
|
432
|
+
command,
|
|
433
|
+
at,
|
|
434
|
+
}) {
|
|
435
|
+
let body = typeof description === 'string' ? description : null;
|
|
436
|
+
let truncated = false;
|
|
437
|
+
let unresolved = null;
|
|
438
|
+
if (body === null && typeof descriptionFile === 'string' && descriptionFile.length > 0) {
|
|
439
|
+
try {
|
|
440
|
+
let file = descriptionFile;
|
|
441
|
+
if (file === '~' || file.startsWith('~/')) file = path.join(homedir(), file.slice(1));
|
|
442
|
+
if (!path.isAbsolute(file)) {
|
|
443
|
+
if (cwdChanged) {
|
|
444
|
+
unresolved = 'cwd-changed';
|
|
445
|
+
throw new Error('cwd-changed');
|
|
446
|
+
}
|
|
447
|
+
const base = typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : repoRoot;
|
|
448
|
+
file = path.resolve(base, file);
|
|
449
|
+
}
|
|
450
|
+
const st = statSync(file);
|
|
451
|
+
if (st.isFile() && st.size <= OVERFLOW_DESCRIPTION_FILE_MAX_BYTES) {
|
|
452
|
+
body = readFileSync(file, 'utf8');
|
|
453
|
+
} else if (st.isFile()) {
|
|
454
|
+
truncated = true;
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
body = null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (body !== null && body.length > OVERFLOW_DESCRIPTION_MAX) {
|
|
461
|
+
body = body.slice(0, OVERFLOW_DESCRIPTION_MAX);
|
|
462
|
+
// Never end on half a surrogate pair — a lone high surrogate is not text.
|
|
463
|
+
const last = body.charCodeAt(body.length - 1);
|
|
464
|
+
if (last >= 0xd800 && last <= 0xdbff) body = body.slice(0, -1);
|
|
465
|
+
truncated = true;
|
|
466
|
+
}
|
|
467
|
+
const raw = String(command);
|
|
468
|
+
if (raw.length > OVERFLOW_COMMAND_MAX) truncated = true;
|
|
469
|
+
const record = {
|
|
470
|
+
title: title ?? null,
|
|
471
|
+
description: body,
|
|
472
|
+
repo: typeof repo === 'string' && repo.length > 0 ? repo : null,
|
|
473
|
+
command: raw.slice(0, OVERFLOW_COMMAND_MAX),
|
|
474
|
+
at,
|
|
475
|
+
truncated,
|
|
476
|
+
};
|
|
477
|
+
if (unresolved !== null) record.descriptionUnresolved = unresolved;
|
|
478
|
+
return record;
|
|
479
|
+
}
|
|
480
|
+
|
|
288
481
|
/**
|
|
289
482
|
* Charge one issue creation against the session budget and return the verdict.
|
|
290
483
|
*
|
|
@@ -304,6 +497,11 @@ export function writeBudgetState(repoRoot, state) {
|
|
|
304
497
|
* sessionId?: string|null,
|
|
305
498
|
* command: string,
|
|
306
499
|
* title?: string|null,
|
|
500
|
+
* description?: string|null,
|
|
501
|
+
* descriptionFile?: string|null,
|
|
502
|
+
* repo?: string|null,
|
|
503
|
+
* cwd?: string|null,
|
|
504
|
+
* cwdChanged?: boolean,
|
|
307
505
|
* config?: { "max-per-session": number, mode: string, overflow: string },
|
|
308
506
|
* now?: string,
|
|
309
507
|
* }} opts
|
|
@@ -323,6 +521,13 @@ export function chargeIssueBudget({
|
|
|
323
521
|
sessionId = null,
|
|
324
522
|
command,
|
|
325
523
|
title = null,
|
|
524
|
+
description = null,
|
|
525
|
+
descriptionFile = null,
|
|
526
|
+
repo = null,
|
|
527
|
+
cwd = null,
|
|
528
|
+
cwdChanged = false,
|
|
529
|
+
toolCallId = null,
|
|
530
|
+
statementIndex = 0,
|
|
326
531
|
config,
|
|
327
532
|
now = new Date().toISOString(),
|
|
328
533
|
}) {
|
|
@@ -354,9 +559,26 @@ export function chargeIssueBudget({
|
|
|
354
559
|
const persist = (next) =>
|
|
355
560
|
accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
|
|
356
561
|
|
|
562
|
+
// The charge RECORD is the only thing a later refund may act on (#1347). It is
|
|
563
|
+
// written on every branch that actually increments a counter, and on NO branch
|
|
564
|
+
// that does not — which is what makes a parked (blocked) creation unrefundable
|
|
565
|
+
// by construction rather than by a heuristic in the refund hook.
|
|
566
|
+
const record = (unit) => {
|
|
567
|
+
state.charged = [
|
|
568
|
+
...state.charged,
|
|
569
|
+
{
|
|
570
|
+
id: bookingId({ toolCallId, statementIndex }),
|
|
571
|
+
key: bookingKey({ sessionId: accountingSessionId, command: String(command ?? ''), statementIndex }),
|
|
572
|
+
unit,
|
|
573
|
+
at: now,
|
|
574
|
+
},
|
|
575
|
+
].slice(-CHARGED_LEDGER_MAX);
|
|
576
|
+
};
|
|
577
|
+
|
|
357
578
|
const { exempt, reason } = classifyExemption(command);
|
|
358
579
|
if (exempt) {
|
|
359
580
|
state.exempt += 1;
|
|
581
|
+
record('exempt');
|
|
360
582
|
persist(state);
|
|
361
583
|
return {
|
|
362
584
|
...base,
|
|
@@ -369,22 +591,24 @@ export function chargeIssueBudget({
|
|
|
369
591
|
|
|
370
592
|
if (state.count < max) {
|
|
371
593
|
state.count += 1;
|
|
594
|
+
record('count');
|
|
372
595
|
persist(state);
|
|
373
596
|
return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
374
597
|
}
|
|
375
598
|
|
|
376
599
|
if (mode === 'warn') {
|
|
377
600
|
state.count += 1;
|
|
601
|
+
record('count');
|
|
378
602
|
persist(state);
|
|
379
603
|
return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
380
604
|
}
|
|
381
605
|
|
|
382
606
|
// strict — park the request, do not count it.
|
|
383
|
-
state.overflow.push(
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
607
|
+
state.overflow.push(
|
|
608
|
+
buildOverflowRecord({
|
|
609
|
+
repoRoot, title, description, descriptionFile, repo, cwd, cwdChanged, command, at: now,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
388
612
|
persist(state);
|
|
389
613
|
return {
|
|
390
614
|
...base,
|
|
@@ -395,6 +619,185 @@ export function chargeIssueBudget({
|
|
|
395
619
|
};
|
|
396
620
|
}
|
|
397
621
|
|
|
622
|
+
/**
|
|
623
|
+
* How many CHARGE records one session's counter file remembers (#1347, BV-004).
|
|
624
|
+
*
|
|
625
|
+
* A record is REMOVED only when its refund is honoured; the record of a create
|
|
626
|
+
* that SUCCEEDED stays for the rest of the session (nothing else prunes it), so
|
|
627
|
+
* the ledger holds every charge of the session, not only the outstanding ones.
|
|
628
|
+
* 64 covers a cap of 12 plus the uncapped exempt creates of a long deep
|
|
629
|
+
* session (measured this repo: ≤ 12 per session so far) and keeps the file in
|
|
630
|
+
* the low-kilobyte class. Eviction is fail-CLOSED: an evicted record
|
|
631
|
+
* makes its refund `not-charged`, i.e. the slot stays spent (the cap ends up
|
|
632
|
+
* slightly tighter), never refundable twice. Revisit if a session legitimately
|
|
633
|
+
* exceeds 64 outstanding charges — then the bound, not the direction, is wrong.
|
|
634
|
+
*/
|
|
635
|
+
export const CHARGED_LEDGER_MAX = 64;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The EXACT id of one charged statement, when the harness publishes a tool-call
|
|
639
|
+
* id for the invocation.
|
|
640
|
+
*
|
|
641
|
+
* `<tool_use_id>#<statementIndex>` — instance-unique per statement, so a chain
|
|
642
|
+
* of two creates yields two ids and a re-delivered failure of the same call
|
|
643
|
+
* yields the same one.
|
|
644
|
+
*
|
|
645
|
+
* @param {{ toolCallId?: string|null, statementIndex?: number }} opts
|
|
646
|
+
* @returns {string|null} `null` when the harness published no id
|
|
647
|
+
*/
|
|
648
|
+
export function bookingId({ toolCallId = null, statementIndex = 0 } = {}) {
|
|
649
|
+
if (typeof toolCallId !== 'string' || toolCallId.length === 0) return null;
|
|
650
|
+
return `${toolCallId}#${Number.isInteger(statementIndex) ? statementIndex : 0}`;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* The deterministic fallback key of one charged statement.
|
|
655
|
+
*
|
|
656
|
+
* Deliberately session + command text + statement index and NOTHING ELSE — in
|
|
657
|
+
* particular NOT the counter value at charge time (`countBefore`, the #1347
|
|
658
|
+
* first-cut key). That value made every retry of the same create mint a FRESH
|
|
659
|
+
* key: measured against a cap of 3, three retries of a create that was PARKED
|
|
660
|
+
* (never charged) refunded three slots and drove `count` to 0 while the parked
|
|
661
|
+
* record still sat in `overflow[]` — i.e. `max-per-session` was unbounded by
|
|
662
|
+
* retrying a denied create. The key is now stable across retries, and proof of
|
|
663
|
+
* charge comes from the record's PRESENCE rather than from the key's shape.
|
|
664
|
+
*
|
|
665
|
+
* @param {{ sessionId: string|null, command: string, statementIndex?: number }} opts
|
|
666
|
+
* @returns {string}
|
|
667
|
+
*/
|
|
668
|
+
export function bookingKey({ sessionId, command, statementIndex = 0 }) {
|
|
669
|
+
const idx = Number.isInteger(statementIndex) ? statementIndex : 0;
|
|
670
|
+
return digestSha256Short(`cmd:${sessionId ?? ''}|${command}|${idx}`, { length: 16 });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Give one booking back to the session budget (#1347) — ONLY against a recorded
|
|
675
|
+
* CHARGE.
|
|
676
|
+
*
|
|
677
|
+
* WHY THIS EXISTS: `chargeIssueBudget` books the slot in PreToolUse — BEFORE the
|
|
678
|
+
* command runs. When `glab issue create` then fails (network, rejected label,
|
|
679
|
+
* expired auth) and the coordinator retries, the SAME issue consumed two slots,
|
|
680
|
+
* and under `max-per-session: 12` + `mode: strict` that pushes a legitimate
|
|
681
|
+
* issue into overflow parking. This is the compensating half of the charge.
|
|
682
|
+
*
|
|
683
|
+
* WHY IT IS LEDGER-BASED AND NOT INFERRED: the first cut decremented whenever a
|
|
684
|
+
* failure signal arrived for an issue-create command. It could not tell a CHARGE
|
|
685
|
+
* from a NON-charge, and at `count === max` the pre-hook PARKS the create and
|
|
686
|
+
* denies WITHOUT charging while the failure event still fires — so retrying a
|
|
687
|
+
* DENIED create refunded a slot that was never spent. Presenting the charge
|
|
688
|
+
* record is the only form of proof that closes that class; every "was this
|
|
689
|
+
* really charged?" heuristic is a guess about state this module already knows.
|
|
690
|
+
*
|
|
691
|
+
* Matching: the exact `<tool_use_id>#<index>` id when both sides have one,
|
|
692
|
+
* otherwise the deterministic `bookingKey`. Both routes are consulted because
|
|
693
|
+
* the charging and refunding payloads are separate harness events and only one
|
|
694
|
+
* of them may carry a tool-call id.
|
|
695
|
+
*
|
|
696
|
+
* Decision table:
|
|
697
|
+
* mode `off` → `{ decision: 'off' }`, nothing written
|
|
698
|
+
* no accounting session id → `{ decision: 'no-session' }`, nothing written
|
|
699
|
+
* no matching charge record → `{ decision: 'noop', reason: 'not-charged' }`
|
|
700
|
+
* record with `unit: exempt` → `{ decision: 'refunded-exempt' }`, `exempt`--
|
|
701
|
+
* record with `unit: count` → `{ decision: 'refunded' }`, `count`--
|
|
702
|
+
*
|
|
703
|
+
* Three invariants:
|
|
704
|
+
* - The matched record is REMOVED, so a re-delivered failure of the same call
|
|
705
|
+
* finds nothing and is `not-charged`. Dedupe is the removal, not a second
|
|
706
|
+
* ledger.
|
|
707
|
+
* - The refund lands on the SAME counter the charge did (`count` vs `exempt`),
|
|
708
|
+
* read off the record — never re-classified from the command text, which a
|
|
709
|
+
* retry can have edited.
|
|
710
|
+
* - `overflow[]` is NEVER touched. A parked record means the creation was
|
|
711
|
+
* BLOCKED, so it was never charged and there is nothing to give back;
|
|
712
|
+
* removing it would drop the item session-end promises to file.
|
|
713
|
+
*
|
|
714
|
+
* Fail-open like every other function here: an unwritable ledger means the
|
|
715
|
+
* refund is lost (the cap stays a little tighter), never an exception.
|
|
716
|
+
*
|
|
717
|
+
* @param {{
|
|
718
|
+
* repoRoot: string,
|
|
719
|
+
* sessionId?: string|null,
|
|
720
|
+
* command: string,
|
|
721
|
+
* toolCallId?: string|null,
|
|
722
|
+
* statementIndex?: number,
|
|
723
|
+
* config?: { "max-per-session": number, mode: string, overflow: string },
|
|
724
|
+
* }} opts
|
|
725
|
+
* @returns {{
|
|
726
|
+
* decision: 'off'|'no-session'|'noop'|'refunded'|'refunded-exempt',
|
|
727
|
+
* count: number,
|
|
728
|
+
* exempt: number,
|
|
729
|
+
* max: number,
|
|
730
|
+
* mode: string,
|
|
731
|
+
* overflowCount: number,
|
|
732
|
+
* bookingId: string|null,
|
|
733
|
+
* reason: string|null,
|
|
734
|
+
* }}
|
|
735
|
+
*/
|
|
736
|
+
export function refundBooking({
|
|
737
|
+
repoRoot,
|
|
738
|
+
sessionId = null,
|
|
739
|
+
command,
|
|
740
|
+
toolCallId = null,
|
|
741
|
+
statementIndex = 0,
|
|
742
|
+
config,
|
|
743
|
+
}) {
|
|
744
|
+
const cfg = config ?? loadIssueBudgetConfig(repoRoot);
|
|
745
|
+
const base = { max: cfg['max-per-session'], mode: cfg.mode };
|
|
746
|
+
const accountingSessionId =
|
|
747
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
748
|
+
|
|
749
|
+
if (cfg.mode === 'off') {
|
|
750
|
+
return { ...base, decision: 'off', count: 0, exempt: 0, overflowCount: 0, bookingId: null, reason: null };
|
|
751
|
+
}
|
|
752
|
+
// Identity-less: `chargeIssueBudget` never PERSISTED such a charge, so there
|
|
753
|
+
// is provably nothing to refund — and writing here would hit the shared legacy
|
|
754
|
+
// slot and reset a live session's count (the #1141 hazard).
|
|
755
|
+
if (accountingSessionId === null) {
|
|
756
|
+
return { ...base, decision: 'no-session', count: 0, exempt: 0, overflowCount: 0, bookingId: null, reason: null };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const state = readBudgetState(repoRoot, accountingSessionId);
|
|
760
|
+
state.sessionId = accountingSessionId;
|
|
761
|
+
const exactId = bookingId({ toolCallId, statementIndex });
|
|
762
|
+
const key = bookingKey({
|
|
763
|
+
sessionId: accountingSessionId,
|
|
764
|
+
command: String(command ?? ''),
|
|
765
|
+
statementIndex,
|
|
766
|
+
});
|
|
767
|
+
const verdict = (decision, reason = null, matchedId = null) => ({
|
|
768
|
+
...base,
|
|
769
|
+
decision,
|
|
770
|
+
count: state.count,
|
|
771
|
+
exempt: state.exempt,
|
|
772
|
+
overflowCount: state.overflow.length,
|
|
773
|
+
bookingId: matchedId ?? exactId ?? key,
|
|
774
|
+
reason,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Match order (review MED-1, measured): the deterministic key is shared by
|
|
778
|
+
// EVERY charge of the same command text in this session, so a refund that
|
|
779
|
+
// carries a tool_use_id must match by id first and may fall back to the key
|
|
780
|
+
// only among records charged WITHOUT an id (the id-less harness / a
|
|
781
|
+
// one-sided id). Falling back onto an id-bearing record would hand the
|
|
782
|
+
// refund to an OLDER charge that may have SUCCEEDED — two charges, one
|
|
783
|
+
// delivery each, and the second delivery drained the successful create's slot.
|
|
784
|
+
let at = exactId !== null ? state.charged.findIndex((r) => r.id === exactId) : -1;
|
|
785
|
+
if (at === -1) {
|
|
786
|
+
at = state.charged.findIndex((r) => r.key === key && (exactId === null || r.id === null || r.id === undefined));
|
|
787
|
+
}
|
|
788
|
+
if (at === -1) return verdict('noop', 'not-charged');
|
|
789
|
+
|
|
790
|
+
const rec = state.charged[at];
|
|
791
|
+
state.charged = [...state.charged.slice(0, at), ...state.charged.slice(at + 1)];
|
|
792
|
+
if (rec.unit === 'exempt') {
|
|
793
|
+
if (state.exempt > 0) state.exempt -= 1;
|
|
794
|
+
} else if (state.count > 0) {
|
|
795
|
+
state.count -= 1;
|
|
796
|
+
}
|
|
797
|
+
writeBudgetState(repoRoot, state);
|
|
798
|
+
return verdict(rec.unit === 'exempt' ? 'refunded-exempt' : 'refunded', null, rec.id ?? rec.key);
|
|
799
|
+
}
|
|
800
|
+
|
|
398
801
|
/**
|
|
399
802
|
* Human-readable block message. Shared by the hook (stderr + deny JSON) and the
|
|
400
803
|
* programmatic path so an agent sees the same instruction either way.
|
|
@@ -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
|
*
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* learnings/sizing-subject.mjs — the one derivation of an `effective-sizing`
|
|
3
|
+
* learning's `subject`, keyed on `(session_type, session_profile)`.
|
|
4
|
+
*
|
|
5
|
+
* GitLab #1247. `ultradeep` is a PROFILE layered on top of an unchanged
|
|
6
|
+
* `session_type: deep` (STATE.md frontmatter key `session-profile`; see
|
|
7
|
+
* `scripts/lib/state-md.mjs` `SESSION_PROFILE_FIELD`, PRD
|
|
8
|
+
* `docs/prd/2026-09-06-ultradeep-session-profile.md`) — 7 waves vs. deep's
|
|
9
|
+
* 4-5. Before this module, the `effective-sizing` analyzer
|
|
10
|
+
* (`skills/evolve/references/evolve-analyze-mode.md`) keyed its subject on
|
|
11
|
+
* `session_type` alone (e.g. `deep-session-sizing`), so a 7-wave ultradeep
|
|
12
|
+
* session and a 5-wave deep session landed on the SAME row — the over-delivery
|
|
13
|
+
* median for one profile silently absorbed the other's.
|
|
14
|
+
*
|
|
15
|
+
* Pure, stdlib-only, no imports, no clock, no fs.
|
|
16
|
+
*
|
|
17
|
+
* ## Subject literal (chosen form, justified)
|
|
18
|
+
*
|
|
19
|
+
* `${session_type}-session-sizing` when `session_profile` is absent/null —
|
|
20
|
+
* BYTE-IDENTICAL to the pre-#1247 literal (`deep-session-sizing`,
|
|
21
|
+
* `feature-session-sizing`, per the existing `skills/evolve/SKILL.md` example
|
|
22
|
+
* row), so learnings keyed before this change still match on re-run.
|
|
23
|
+
* `${session_type}-${session_profile}-session-sizing` when a profile is
|
|
24
|
+
* present — e.g. `deep-ultradeep-session-sizing`. The profile slots BETWEEN
|
|
25
|
+
* type and the `-session-sizing` suffix (rather than, say, prefixing or
|
|
26
|
+
* appending after the suffix) so every subject stays readable as
|
|
27
|
+
* `<type>[-<profile>]-session-sizing` and a reader can strip the trailing
|
|
28
|
+
* `-session-sizing` to recover `type[-profile]` unambiguously — the same
|
|
29
|
+
* shape `session-profile` frontmatter documents ("a wave-shape variant on
|
|
30
|
+
* top of an unchanged session_type").
|
|
31
|
+
*
|
|
32
|
+
* @param {{session_type?: unknown, session_profile?: unknown}} params
|
|
33
|
+
* @returns {string} The canonical `effective-sizing` subject.
|
|
34
|
+
*/
|
|
35
|
+
export function sizingSubject({ session_type, session_profile } = {}) {
|
|
36
|
+
const type = typeof session_type === 'string' && session_type.trim().length > 0
|
|
37
|
+
? session_type.trim()
|
|
38
|
+
: 'unknown';
|
|
39
|
+
const profile =
|
|
40
|
+
typeof session_profile === 'string' && session_profile.trim().length > 0
|
|
41
|
+
? session_profile.trim()
|
|
42
|
+
: null;
|
|
43
|
+
return profile === null ? `${type}-session-sizing` : `${type}-${profile}-session-sizing`;
|
|
44
|
+
}
|
|
@@ -22,14 +22,13 @@
|
|
|
22
22
|
* NEVER → session-lock.mjs, so there is no import cycle.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import fs from 'node:fs';
|
|
26
25
|
import os from 'node:os';
|
|
27
26
|
import path from 'node:path';
|
|
28
27
|
import crypto from 'node:crypto';
|
|
29
28
|
|
|
30
|
-
import { tryAcquireFileLock } from '../file-lock.mjs';
|
|
31
|
-
import {
|
|
32
|
-
import { nowIso, delay
|
|
29
|
+
import { releaseFileLock, tryAcquireFileLock } from '../file-lock.mjs';
|
|
30
|
+
import { stableHostname } from '../host-identity.mjs';
|
|
31
|
+
import { nowIso, delay } from './lock-body.mjs';
|
|
33
32
|
|
|
34
33
|
// ---------------------------------------------------------------------------
|
|
35
34
|
// Constants
|
|
@@ -156,47 +155,23 @@ export async function acquireStagingFenceLock({
|
|
|
156
155
|
/**
|
|
157
156
|
* Release the staging-fence commit-lock IFF the holder matches.
|
|
158
157
|
*
|
|
158
|
+
* `busy` (#1349) means the shared `${lockFile}.acquire` guard stayed taken past
|
|
159
|
+
* its budget; the lock file is left untouched for its stale policy.
|
|
160
|
+
*
|
|
159
161
|
* @param {object} [opts]
|
|
160
162
|
* @param {string} [opts.repoRoot]
|
|
161
163
|
* @param {string} [opts.holder]
|
|
162
|
-
* @returns {{ ok: true } | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
|
|
164
|
+
* @returns {{ ok: true } | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
|
|
163
165
|
*/
|
|
164
166
|
export function releaseStagingFenceLock({ repoRoot, holder } = {}) {
|
|
165
167
|
const lockFile = stagingFenceLockPathFor(repoRoot);
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
return { ok: false, reason: 'fs-error', error: err.message };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const lock = parseLockBody(raw);
|
|
178
|
-
if (lock === null) {
|
|
179
|
-
return { ok: false, reason: 'not-owner' };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const ownerMatch = typeof holder === 'string' && holder.length > 0
|
|
183
|
-
? lock.holder === holder
|
|
184
|
-
// #1072: alias-aware host identity, not a raw os.hostname() comparison.
|
|
185
|
-
: lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
186
|
-
|
|
187
|
-
if (!ownerMatch) {
|
|
188
|
-
return { ok: false, reason: 'not-owner' };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
fs.unlinkSync(lockFile);
|
|
193
|
-
return { ok: true };
|
|
194
|
-
} catch (err) {
|
|
195
|
-
if (err.code === 'ENOENT') {
|
|
196
|
-
return { ok: false, reason: 'not-found' };
|
|
197
|
-
}
|
|
198
|
-
return { ok: false, reason: 'fs-error', error: err.message };
|
|
199
|
-
}
|
|
169
|
+
// Consolidated onto releaseFileLock (#1349); the `pid` staleCheck makes the
|
|
170
|
+
// takeover race unreachable today — if this lock ever moves to a lease mode,
|
|
171
|
+
// the shared guard is what keeps release safe.
|
|
172
|
+
// An empty holder falls through to releaseFileLock's PID + host owner check,
|
|
173
|
+
// exactly as the inline sequence did.
|
|
174
|
+
return releaseFileLock(lockFile, { holder });
|
|
200
175
|
}
|
|
201
176
|
|
|
202
177
|
/**
|
|
@@ -258,11 +233,17 @@ export async function withStagingFenceLock(repoRoot, fn, opts = {}) {
|
|
|
258
233
|
} catch (err) {
|
|
259
234
|
caughtError = err;
|
|
260
235
|
} finally {
|
|
236
|
+
// WARN only where OUR lock is left behind: 'fs-error' and 'busy' (the
|
|
237
|
+
// shared guard stayed taken — #1349). 'not-found'/'not-owner' stay silent.
|
|
261
238
|
const releaseResult = releaseStagingFenceLock({ repoRoot, holder });
|
|
262
239
|
if (!releaseResult.ok && releaseResult.reason === 'fs-error') {
|
|
263
240
|
console.warn(
|
|
264
241
|
`withStagingFenceLock: release failed (fs-error: ${releaseResult.error ?? 'unknown'})`,
|
|
265
242
|
);
|
|
243
|
+
} else if (!releaseResult.ok && releaseResult.reason === 'busy') {
|
|
244
|
+
console.warn(
|
|
245
|
+
'withStagingFenceLock: release failed (busy: acquire guard held) — .commit.lock left for its stale policy',
|
|
246
|
+
);
|
|
266
247
|
}
|
|
267
248
|
}
|
|
268
249
|
|