session-orchestrator 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
package/scripts/lib/io.mjs
CHANGED
|
@@ -248,21 +248,46 @@ export function writeStdoutLineSync(line) {
|
|
|
248
248
|
// Exports
|
|
249
249
|
// ---------------------------------------------------------------------------
|
|
250
250
|
|
|
251
|
+
/** Default stdin read timeout. Every production caller uses it. */
|
|
252
|
+
const READ_STDIN_TIMEOUT_MS = 5_000; // 5 s guard
|
|
253
|
+
|
|
251
254
|
/**
|
|
252
255
|
* Read process.stdin to EOF and parse as JSON.
|
|
256
|
+
*
|
|
257
|
+
* ## Why the timeout is a PARAMETER and not an env var
|
|
258
|
+
*
|
|
259
|
+
* The guard exists so a hook whose stdin never closes (the harness died, the
|
|
260
|
+
* pipe was inherited by a long-lived grandchild) fails instead of hanging the
|
|
261
|
+
* tool call forever. A 5-second stall is also what made the behaviour
|
|
262
|
+
* effectively untestable: the suite cannot afford to wait for it, which is why
|
|
263
|
+
* the timeout case sat as an empty `it.skip` for two years.
|
|
264
|
+
*
|
|
265
|
+
* Injecting the bound as an OPTION keeps the escape hatch out of the ambient
|
|
266
|
+
* environment: an env var would let any process that happens to export it
|
|
267
|
+
* shorten (or lengthen) the guard for every hook on the host, silently. A
|
|
268
|
+
* parameter can only be shortened by the caller that asks for it — today, only
|
|
269
|
+
* the test that proves the guard fires.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} [opts]
|
|
272
|
+
* @param {number} [opts.timeoutMs=5000] Milliseconds before the read is
|
|
273
|
+
* abandoned. Non-finite or non-positive values fall back to the 5 s
|
|
274
|
+
* default rather than disabling the guard.
|
|
253
275
|
* @returns {Promise<object|null>} Parsed JSON object, or null on empty stream.
|
|
254
276
|
* @throws {SyntaxError} If stdin contains non-empty, non-JSON data.
|
|
255
|
-
* @throws {Error} If the 1 MB size limit or
|
|
277
|
+
* @throws {Error} If the 1 MB size limit or the timeout is exceeded.
|
|
256
278
|
*/
|
|
257
|
-
export async function readStdin() {
|
|
279
|
+
export async function readStdin(opts = {}) {
|
|
258
280
|
const MAX_BYTES = 1_048_576; // 1 MB guard
|
|
259
|
-
const
|
|
281
|
+
const requested = Number(opts?.timeoutMs);
|
|
282
|
+
const TIMEOUT_MS = Number.isFinite(requested) && requested > 0
|
|
283
|
+
? requested
|
|
284
|
+
: READ_STDIN_TIMEOUT_MS;
|
|
260
285
|
|
|
261
286
|
return new Promise((resolve, reject) => {
|
|
262
287
|
const controller = new AbortController();
|
|
263
288
|
const timer = setTimeout(() => {
|
|
264
289
|
controller.abort();
|
|
265
|
-
reject(new Error(
|
|
290
|
+
reject(new Error(`io.mjs: readStdin timed out after ${TIMEOUT_MS / 1000} s`));
|
|
266
291
|
}, TIMEOUT_MS);
|
|
267
292
|
|
|
268
293
|
const chunks = [];
|
|
@@ -29,6 +29,7 @@
|
|
|
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';
|
|
@@ -253,6 +254,27 @@ export function budgetStatePath(repoRoot, sessionId = null) {
|
|
|
253
254
|
return path.join(repoRoot, budgetStateRel(sessionId));
|
|
254
255
|
}
|
|
255
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
|
+
|
|
256
278
|
/**
|
|
257
279
|
* Coerce a parsed counter file into a state object, or `null` when it does not
|
|
258
280
|
* belong to `accountingSessionId`.
|
|
@@ -273,6 +295,10 @@ function _coerceState(data, accountingSessionId) {
|
|
|
273
295
|
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
274
296
|
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
275
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) : [],
|
|
276
302
|
};
|
|
277
303
|
}
|
|
278
304
|
|
|
@@ -323,7 +349,7 @@ function _readStateFile(file, accountingSessionId) {
|
|
|
323
349
|
export function readBudgetState(repoRoot, sessionId) {
|
|
324
350
|
const accountingSessionId =
|
|
325
351
|
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
326
|
-
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
|
|
352
|
+
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [], charged: [] };
|
|
327
353
|
if (accountingSessionId === null) return fresh;
|
|
328
354
|
|
|
329
355
|
const ownFile = budgetStatePath(repoRoot, accountingSessionId);
|
|
@@ -358,6 +384,100 @@ export function writeBudgetState(repoRoot, state) {
|
|
|
358
384
|
return res.ok === true;
|
|
359
385
|
}
|
|
360
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
|
+
|
|
361
481
|
/**
|
|
362
482
|
* Charge one issue creation against the session budget and return the verdict.
|
|
363
483
|
*
|
|
@@ -377,6 +497,11 @@ export function writeBudgetState(repoRoot, state) {
|
|
|
377
497
|
* sessionId?: string|null,
|
|
378
498
|
* command: string,
|
|
379
499
|
* title?: string|null,
|
|
500
|
+
* description?: string|null,
|
|
501
|
+
* descriptionFile?: string|null,
|
|
502
|
+
* repo?: string|null,
|
|
503
|
+
* cwd?: string|null,
|
|
504
|
+
* cwdChanged?: boolean,
|
|
380
505
|
* config?: { "max-per-session": number, mode: string, overflow: string },
|
|
381
506
|
* now?: string,
|
|
382
507
|
* }} opts
|
|
@@ -396,6 +521,13 @@ export function chargeIssueBudget({
|
|
|
396
521
|
sessionId = null,
|
|
397
522
|
command,
|
|
398
523
|
title = null,
|
|
524
|
+
description = null,
|
|
525
|
+
descriptionFile = null,
|
|
526
|
+
repo = null,
|
|
527
|
+
cwd = null,
|
|
528
|
+
cwdChanged = false,
|
|
529
|
+
toolCallId = null,
|
|
530
|
+
statementIndex = 0,
|
|
399
531
|
config,
|
|
400
532
|
now = new Date().toISOString(),
|
|
401
533
|
}) {
|
|
@@ -427,9 +559,26 @@ export function chargeIssueBudget({
|
|
|
427
559
|
const persist = (next) =>
|
|
428
560
|
accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
|
|
429
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
|
+
|
|
430
578
|
const { exempt, reason } = classifyExemption(command);
|
|
431
579
|
if (exempt) {
|
|
432
580
|
state.exempt += 1;
|
|
581
|
+
record('exempt');
|
|
433
582
|
persist(state);
|
|
434
583
|
return {
|
|
435
584
|
...base,
|
|
@@ -442,22 +591,24 @@ export function chargeIssueBudget({
|
|
|
442
591
|
|
|
443
592
|
if (state.count < max) {
|
|
444
593
|
state.count += 1;
|
|
594
|
+
record('count');
|
|
445
595
|
persist(state);
|
|
446
596
|
return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
447
597
|
}
|
|
448
598
|
|
|
449
599
|
if (mode === 'warn') {
|
|
450
600
|
state.count += 1;
|
|
601
|
+
record('count');
|
|
451
602
|
persist(state);
|
|
452
603
|
return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
453
604
|
}
|
|
454
605
|
|
|
455
606
|
// strict — park the request, do not count it.
|
|
456
|
-
state.overflow.push(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
607
|
+
state.overflow.push(
|
|
608
|
+
buildOverflowRecord({
|
|
609
|
+
repoRoot, title, description, descriptionFile, repo, cwd, cwdChanged, command, at: now,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
461
612
|
persist(state);
|
|
462
613
|
return {
|
|
463
614
|
...base,
|
|
@@ -468,6 +619,185 @@ export function chargeIssueBudget({
|
|
|
468
619
|
};
|
|
469
620
|
}
|
|
470
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
|
+
|
|
471
801
|
/**
|
|
472
802
|
* Human-readable block message. Shared by the hook (stderr + deny JSON) and the
|
|
473
803
|
* programmatic path so an agent sees the same instruction either way.
|
|
@@ -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
|
|