@vibe-commit/capture 0.7.0 → 0.9.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/README.md +238 -7
- package/dist/account_not_here.d.ts +15 -0
- package/dist/account_not_here.js +25 -0
- package/dist/account_not_here.js.map +1 -0
- package/dist/agents/claude_code.js +13 -0
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.js +11 -0
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.js +7 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +9 -0
- package/dist/agents/registry.js.map +1 -1
- package/dist/agents/types.d.ts +32 -0
- package/dist/agents/types.js.map +1 -1
- package/dist/bounded_id.d.ts +29 -0
- package/dist/bounded_id.js +42 -0
- package/dist/bounded_id.js.map +1 -0
- package/dist/codex_folders.d.ts +340 -0
- package/dist/codex_folders.js +1448 -0
- package/dist/codex_folders.js.map +1 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +21 -0
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/backfill.d.ts +29 -7
- package/dist/commands/backfill.js +387 -109
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/browser_credential.d.ts +4 -2
- package/dist/commands/browser_credential.js +5 -2
- package/dist/commands/browser_credential.js.map +1 -1
- package/dist/commands/connect.d.ts +10 -1
- package/dist/commands/connect.js +14 -9
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_account.d.ts +135 -0
- package/dist/commands/connect_account.js +321 -0
- package/dist/commands/connect_account.js.map +1 -0
- package/dist/commands/connect_picker.js +204 -29
- package/dist/commands/connect_picker.js.map +1 -1
- package/dist/commands/device_credential.d.ts +3 -1
- package/dist/commands/device_credential.js +1 -0
- package/dist/commands/device_credential.js.map +1 -1
- package/dist/commands/finalize.d.ts +67 -0
- package/dist/commands/finalize.js +450 -114
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/finish_mint.d.ts +28 -0
- package/dist/commands/finish_mint.js +16 -4
- package/dist/commands/finish_mint.js.map +1 -1
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.js +11 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/status.d.ts +19 -1
- package/dist/commands/status.js +123 -14
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_machine.d.ts +12 -3
- package/dist/commands/status_machine.js +111 -13
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/sync_reason.d.ts +17 -0
- package/dist/commands/sync_reason.js +25 -0
- package/dist/commands/sync_reason.js.map +1 -0
- package/dist/consent.d.ts +131 -3
- package/dist/consent.js +248 -14
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +165 -4
- package/dist/copy/strings.js +190 -7
- package/dist/copy/strings.js.map +1 -1
- package/dist/folder_grants.d.ts +44 -0
- package/dist/folder_grants.js +101 -0
- package/dist/folder_grants.js.map +1 -0
- package/dist/git.d.ts +26 -4
- package/dist/git.js +82 -11
- package/dist/git.js.map +1 -1
- package/dist/grants_sync.d.ts +165 -0
- package/dist/grants_sync.js +289 -0
- package/dist/grants_sync.js.map +1 -0
- package/dist/grants_verb.d.ts +70 -0
- package/dist/grants_verb.js +121 -0
- package/dist/grants_verb.js.map +1 -0
- package/dist/hooks/entry.d.ts +30 -50
- package/dist/hooks/entry.js +601 -561
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +31 -0
- package/dist/hooks/fire_lock.js +120 -12
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/session_grants.d.ts +59 -0
- package/dist/hooks/session_grants.js +91 -0
- package/dist/hooks/session_grants.js.map +1 -0
- package/dist/hooks/session_start.d.ts +28 -7
- package/dist/hooks/session_start.js +55 -11
- package/dist/hooks/session_start.js.map +1 -1
- package/dist/index.js +22 -5
- package/dist/index.js.map +1 -1
- package/dist/machine_state.d.ts +44 -1
- package/dist/machine_state.js +75 -12
- package/dist/machine_state.js.map +1 -1
- package/dist/oauth/discovery.js +2 -1
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/pkce.js +2 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/oauth/retire.d.ts +10 -0
- package/dist/oauth/retire.js +13 -0
- package/dist/oauth/retire.js.map +1 -1
- package/dist/paths.d.ts +54 -1
- package/dist/paths.js +88 -12
- package/dist/paths.js.map +1 -1
- package/dist/pending_revoke.d.ts +117 -0
- package/dist/pending_revoke.js +233 -0
- package/dist/pending_revoke.js.map +1 -0
- package/dist/pending_revoke_file.d.ts +55 -0
- package/dist/pending_revoke_file.js +74 -0
- package/dist/pending_revoke_file.js.map +1 -0
- package/dist/physical_path.d.ts +46 -0
- package/dist/physical_path.js +151 -0
- package/dist/physical_path.js.map +1 -0
- package/dist/picker.d.ts +82 -5
- package/dist/picker.js +183 -10
- package/dist/picker.js.map +1 -1
- package/dist/picker_checklist.d.ts +27 -3
- package/dist/picker_checklist.js +81 -6
- package/dist/picker_checklist.js.map +1 -1
- package/dist/policy.d.ts +24 -1
- package/dist/policy.js +30 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +55 -2
- package/dist/post.js +72 -22
- package/dist/post.js.map +1 -1
- package/dist/project.d.ts +89 -0
- package/dist/project.js +123 -0
- package/dist/project.js.map +1 -1
- package/dist/redact.d.ts +77 -2
- package/dist/redact.js +713 -47
- package/dist/redact.js.map +1 -1
- package/dist/session_targets.d.ts +713 -0
- package/dist/session_targets.js +2602 -0
- package/dist/session_targets.js.map +1 -0
- package/dist/spool.d.ts +12 -0
- package/dist/spool.js +29 -1
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +51 -0
- package/dist/spool_union.js +57 -0
- package/dist/spool_union.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.js +19 -1
- package/dist/state.js.map +1 -1
- package/dist/system_message.d.ts +25 -2
- package/dist/system_message.js +29 -5
- package/dist/system_message.js.map +1 -1
- package/dist/text_tail.d.ts +8 -0
- package/dist/text_tail.js +19 -0
- package/dist/text_tail.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/hooks/entry.js
CHANGED
|
@@ -33,30 +33,33 @@
|
|
|
33
33
|
* own red-proof in `test/credential.test.ts`. Do not read one as evidence for
|
|
34
34
|
* the other.
|
|
35
35
|
*/
|
|
36
|
-
import { closeSync,
|
|
37
|
-
import { basename,
|
|
36
|
+
import { closeSync, existsSync, openSync, readSync, rmSync, statSync, utimesSync, writeSync, } from "node:fs";
|
|
37
|
+
import { basename, dirname } from "node:path";
|
|
38
38
|
import { zstdCompressSync } from "node:zlib";
|
|
39
|
-
import { grantsUnreadable, isCaptureAllowed, isLocalSlug } from "../consent.js";
|
|
39
|
+
import { grantsUnreadable, hasAnyConsent, isCaptureAllowed, isLocalSlug, normalizeSlug } from "../consent.js";
|
|
40
40
|
import { endingSoon, noteCredentialExpiry } from "../credential_meta.js";
|
|
41
41
|
import { loadCredential } from "../credential.js";
|
|
42
42
|
import { EXIT } from "../exit.js";
|
|
43
|
-
import {
|
|
43
|
+
import { headRef, onceRepoSlug, onceStrictRepoSlug } from "../git.js";
|
|
44
44
|
import { redactSpan, rulesForTranscript } from "../redact.js";
|
|
45
|
-
import { authCommandFor } from "../install.js";
|
|
45
|
+
import { authCommandFor, connectCommandFor } from "../install.js";
|
|
46
46
|
import { installOnce, sessionStartPayload } from "./session_start.js";
|
|
47
47
|
import { credentialFingerprint, deliver, ingestUrl } from "../post.js";
|
|
48
|
-
import { markSkipped } from "../policy.js";
|
|
49
|
-
import {
|
|
48
|
+
import { FOLDER_REPOSITORY_FORBIDDEN, markSkipped } from "../policy.js";
|
|
49
|
+
import { dialectIsolates, evidenceSpecFor, findSubagentTranscripts, isolationOf, listTargetRecords, pastCursorOf, redactionScopeFor, resolveTargets, stopKeyOf, stopTarget, } from "../session_targets.js";
|
|
50
|
+
import { readSpoolUnion } from "../spool_union.js";
|
|
51
|
+
import { fileState, loadSessionState, pruneDeliveredEndHold, saveSessionState, withFileState } from "../state.js";
|
|
50
52
|
import { SESSION_END_EVENT, settleDelayMs, settledSize, } from "./session_end.js";
|
|
51
|
-
import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor,
|
|
52
|
-
import { claimFiring, fireLockPath } from "./fire_lock.js";
|
|
53
|
-
import { announcedSubagentFileKey,
|
|
53
|
+
import { admitsTranscript, agentForTranscript, canonicalHookEvent, dialectFor, } from "../agents/registry.js";
|
|
54
|
+
import { claimFiring, claimSessionWriter, fireLockPath, SESSION_SCOPE, STALE_MARGIN_MS } from "./fire_lock.js";
|
|
55
|
+
import { announcedSubagentFileKey, subagentFileKey, } from "../paths.js";
|
|
54
56
|
import { resolveProjectKeys } from "../project.js";
|
|
57
|
+
export { confinementRoots } from "../project.js";
|
|
55
58
|
import { startSpawnBudget } from "../spawn_budget.js";
|
|
56
59
|
import { turnStart } from "../pending_bound.js";
|
|
57
60
|
import { recordInferredSkipped } from "../inferred_tally.js";
|
|
58
61
|
import { pruneOrphanPending } from "../pending_prune.js";
|
|
59
|
-
import {
|
|
62
|
+
import { pendingModeFor, promotePending, pruneExpiredInferred, readPending } from "../spool.js";
|
|
60
63
|
import { meetsNodeFloor } from "../runtime.js";
|
|
61
64
|
import { renderNotice } from "../system_message.js";
|
|
62
65
|
/**
|
|
@@ -326,17 +329,18 @@ function finish(payload) {
|
|
|
326
329
|
* `hookBody`. Threading a claim out to those paths is not possible — the
|
|
327
330
|
* watchdog holds no reference to the body's locals.
|
|
328
331
|
*/
|
|
329
|
-
|
|
330
|
-
/** Idempotent, and never throws: the hook contract forbids saying anything. */
|
|
332
|
+
const heldClaims = [];
|
|
333
|
+
/** Idempotent, and never throws: the hook contract forbids saying anything. Releases EVERY lock this process holds. */
|
|
331
334
|
function releaseFiring() {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
const claims = heldClaims.splice(0, heldClaims.length);
|
|
336
|
+
for (const claim of claims) {
|
|
337
|
+
try {
|
|
338
|
+
claim.release();
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// A lock we could not release goes stale on its own; the bound is derived
|
|
342
|
+
// from the budget precisely so a lost release costs one invocation, once.
|
|
343
|
+
}
|
|
340
344
|
}
|
|
341
345
|
}
|
|
342
346
|
/**
|
|
@@ -416,8 +420,14 @@ function dialectBudgetMs(agentId) {
|
|
|
416
420
|
* Ordering is load-bearing in two places:
|
|
417
421
|
* 1. the Node floor is checked FIRST, because everything below it may use
|
|
418
422
|
* syntax or APIs the old runtime lacks;
|
|
419
|
-
* 2.
|
|
420
|
-
*
|
|
423
|
+
* 2. CONSENT runs BEFORE `transcript_path` is opened (D56 §D19). A gate that
|
|
424
|
+
* runs after the read has already read the thing it was gating. Since any-folder
|
|
425
|
+
* capture the question is no longer "is the one repo the turn ended in granted?"
|
|
426
|
+
* but "has this machine granted anything at all" (`hasAnyConsent`), asked before
|
|
427
|
+
* the read, and then, per target, "is THIS repo (or folder) granted"
|
|
428
|
+
* (`isCaptureAllowed`), asked by the resolver. A turn that ended in a folder that
|
|
429
|
+
* is not a work tree no longer ends the hook; the session's own records may name
|
|
430
|
+
* granted repos, and the resolver decides which.
|
|
421
431
|
*/
|
|
422
432
|
async function hookBody(ctx, budgetMs, hookStartedAt) {
|
|
423
433
|
const raw = await ctx.readStdin();
|
|
@@ -428,12 +438,16 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
|
|
|
428
438
|
// session-keyed states can fire BEFORE a project has been resolved at all —
|
|
429
439
|
// `unsupported_runtime` fires before anything else runs. Only
|
|
430
440
|
// `capture_confirmed` needs one, and only it supplies one.
|
|
431
|
-
const notice = (state, repoKey = null, rejected, endsAt, cloneKey) => renderNotice({
|
|
441
|
+
const notice = (state, repoKey = null, rejected, endsAt, cloneKey, account) => renderNotice({
|
|
432
442
|
home: ctx.home,
|
|
433
443
|
sessionId: input.sessionId,
|
|
434
444
|
nodeVersion: ctx.nodeVersion,
|
|
435
445
|
repoKey,
|
|
436
446
|
...(cloneKey === undefined ? {} : { cloneKey }),
|
|
447
|
+
// State 1 names the channel's `connect` (the same rule as state 2's `auth`): a plugin-only machine has no `vibecommit` on PATH.
|
|
448
|
+
...(state === "not_connected" ? { connectCommand: connectCommandFor(ctx.env, ctx.selfPath ?? "") } : {}),
|
|
449
|
+
// State 10: the epoch keys its once-per-epoch claim; the count is the sentence.
|
|
450
|
+
...(account === undefined ? {} : { accountEpoch: account.epoch, unnamedOwners: account.owners, unnamedRepos: account.repos }),
|
|
437
451
|
now: () => new Date(),
|
|
438
452
|
// `11A`: states 2 and 5 carry a credential. The command is the channel's, and the claim is
|
|
439
453
|
// keyed by the credential, so replacing it re-arms the message.
|
|
@@ -460,204 +474,96 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
|
|
|
460
474
|
selfPath: ctx.selfPath ?? "",
|
|
461
475
|
cwd: input.cwd,
|
|
462
476
|
notice,
|
|
477
|
+
// The one bounded GET is limited by what is left of this hook's own wall clock as well as by its 1,000 ms.
|
|
478
|
+
budgetLeftMs: () => budgetMs - (Date.now() - hookStartedAt),
|
|
463
479
|
});
|
|
464
480
|
}
|
|
465
|
-
// ---
|
|
466
|
-
// The null arm is not new behaviour — `isCaptureAllowed(home, null, …)` is already
|
|
467
|
-
// false, so a `cwd` outside a work tree already stopped here. It is stated
|
|
468
|
-
// separately because `CR-017d` makes this value half the delivery key as well
|
|
469
|
-
// as the consent key, and a key the compiler cannot prove is present is one
|
|
470
|
-
// some later edit supplies a placeholder for.
|
|
471
|
-
//
|
|
472
|
-
// ⛔ THE GATE CHECKS `keys.consent` — THE GIT COMMON DIR (`D184 §1`).
|
|
473
|
-
//
|
|
474
|
-
// It is shared by a main clone and every worktree linked to it, which is the
|
|
475
|
-
// whole point. Consent was keyed on `--show-toplevel`, that differs per
|
|
476
|
-
// worktree, and absence means no — so an agent session inside a `git worktree`
|
|
477
|
-
// captured NOTHING, silently, under the house convention (`D19`) of one
|
|
478
|
-
// worktree per builder.
|
|
481
|
+
// --- WHAT DOES THIS SESSION BELONG TO? (any-folder capture, R1 · R2 · R5) ---
|
|
479
482
|
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
// `
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
// session in every repository would capture nothing, with no notice — strictly
|
|
487
|
-
// worse than the worktree blackout this change exists to fix.
|
|
483
|
+
// This replaces the consent gate that decided ONE target from the cwd the turn ENDED in. That cwd is wrong for most sessions:
|
|
484
|
+
// Claude Code's Bash tool resets the shell to the launch folder, so 93% of turn-ends were in a parent folder that is not a
|
|
485
|
+
// work tree, and the old gate returned null, silently, for every one of them (measured, 154 hooks, 143 in the parent).
|
|
486
|
+
// `session_targets.resolveTargets` now decides, from the turn-end folder AND what the session's own records name, which
|
|
487
|
+
// granted repos (and, only at the end of a repo-free session, which granted folder) this session's bytes go to. The old
|
|
488
|
+
// single-target derivation is DELETED here, not kept beside it (A4: two ways to do one task).
|
|
488
489
|
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
// the asymmetry is only visible END TO END, through the real `connect` path
|
|
492
|
-
// and a real session in a real worktree. `test/consent-key-end-to-end.test.ts`
|
|
493
|
-
// is that cell, and under a mutation that grants the toplevel here **135
|
|
494
|
-
// existing cells stay green and only those two red**.
|
|
490
|
+
// ⛔ NOTHING IS READ FOR A MACHINE THAT HAS GRANTED NOTHING. `hasAnyConsent` is checked before a transcript is opened or a byte
|
|
491
|
+
// of state is written: widening what a hook reads must never widen WHO it reads for.
|
|
495
492
|
//
|
|
496
|
-
// ⛔
|
|
497
|
-
// `
|
|
498
|
-
// collapsed delivers a payload of redaction markers. The request arrives, the
|
|
499
|
-
// byte accounting is intact, and the content is gone. Anything asserting only
|
|
500
|
-
// that a worktree session was admitted passes on an empty payload.
|
|
501
|
-
//
|
|
502
|
-
// ⚠ `resolveProjectKeys` is called once, not twice: the redaction root set
|
|
503
|
-
// needs the common dir too, and a second resolution could disagree with the
|
|
504
|
-
// one consent was checked against. It is stricter than `resolveProjectKey` in
|
|
505
|
-
// one way — the common dir must also resolve and hold a `HEAD` — which fails
|
|
506
|
-
// closed, silently, exactly as this gate already does.
|
|
507
|
-
//
|
|
508
|
-
// ⛔ THE GATE IS NOW `isCaptureAllowed` (0.7.0): the common dir OR the repo's slug in `grants.json`, an exclusion
|
|
509
|
-
// winning over both. The slug is a MEMOIZED THUNK so a machine with no `grants.json` pays no spawn for it, and so
|
|
510
|
-
// `repoSlug` below is the very resolution the gate used. `keys` is the same result the confinement roots come from.
|
|
493
|
+
// ⛔ The turn-end repo is still resolved ONCE here (`resolveProjectKeys`), for the notices that name it and for Cursor's
|
|
494
|
+
// first-`Stop` install; consent for it is the resolver's call, through the same `isCaptureAllowed`.
|
|
511
495
|
const keys = resolveProjectKeys(input.cwd);
|
|
512
496
|
const slugOf = keys === null ? null : onceRepoSlug(keys.worktree);
|
|
513
|
-
if (
|
|
514
|
-
// State 9 (0.7.0, spec review R3-14: "when a hook denies because grants.json is corrupt, have it emit one
|
|
515
|
-
// marker-gated systemMessage naming the file and `vibecommit status`"). An unreadable `grants.json` is the one
|
|
516
|
-
// denial a hook can say out loud, once per machine and day — and it must also come from here, because the
|
|
517
|
-
// plugin channel and Cursor never register `SessionStart`.
|
|
518
|
-
// Only for a repo this client WOULD have captured (a remote, so not `local:`), never for a folder that is
|
|
519
|
-
// simply not a work tree.
|
|
520
|
-
if (keys !== null && slugOf !== null && grantsUnreadable(ctx.home) && !isLocalSlug(slugOf())) {
|
|
521
|
-
return notice("grants_unreadable");
|
|
522
|
-
}
|
|
523
|
-
// Unconsented is not "not connected": say nothing at all. A machine with no
|
|
524
|
-
// credential says state 1 below; a repo the user declined stays silent,
|
|
525
|
-
// because nagging about a decision the user already made is the tool noise
|
|
526
|
-
// DX3's one-line rule exists to avoid.
|
|
497
|
+
if (!hasAnyConsent(ctx.home))
|
|
527
498
|
return null;
|
|
499
|
+
// The turn-end repo's OWN consent, decided exactly where the old gate decided it, before anything touches the transcript. It
|
|
500
|
+
// no longer ends the hook (another repo the session named may be granted); it decides the two things that were always tied
|
|
501
|
+
// to it. ⛔ THIS IS THE SAME `isCaptureAllowed` (one admission decision, and a slug thunk that spawns only when asked).
|
|
502
|
+
const turnEndAllowed = keys !== null && slugOf !== null && isCaptureAllowed(ctx.home, keys, slugOf);
|
|
503
|
+
// State 9 (0.7.0, spec review R3-14): an unreadable `grants.json` is the one denial a hook can say out loud, once per machine
|
|
504
|
+
// and day, and it must come from here because the plugin channel and Cursor never register `SessionStart`. Only for a repo
|
|
505
|
+
// this client WOULD have captured (a remote, so not `local:`), never for a folder that is not a work tree. With the file
|
|
506
|
+
// unreadable every slug-bearing repo is denied, so no target could exist and nothing is lost by saying so now.
|
|
507
|
+
if (!turnEndAllowed && keys !== null && slugOf !== null && grantsUnreadable(ctx.home) && !isLocalSlug(slugOf())) {
|
|
508
|
+
return notice("grants_unreadable");
|
|
528
509
|
}
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
// which role a site wants.
|
|
535
|
-
const projectKey = keys.worktree;
|
|
536
|
-
// ⛔ CURSOR'S FIRST-`Stop` INSTALL (0.7.0, Open Question 1). Claude Code's and Codex's `SessionStart` was measured
|
|
537
|
-
// to fire before the first prompt; Cursor's `sessionStart` was only READ out of the shipped bundle (STATIC, NOT
|
|
538
|
-
// OBSERVED — `cursor-agent` is not logged in here) and has no field a user sees. So the same marker-gated,
|
|
539
|
-
// plain-write-only install also runs on a Cursor `stop`, and its one notice rides this hook's payload below.
|
|
540
|
-
// The record makes it once per clone either way, so a `sessionStart` that does fire first loses nothing.
|
|
541
|
-
const firstStop = ctx.agentId === "cursor" && event === "Stop"
|
|
510
|
+
// ⛔ CURSOR'S FIRST-`Stop` INSTALL (0.7.0, Open Question 1). `sessionStart` was only READ out of the shipped bundle (STATIC,
|
|
511
|
+
// NOT OBSERVED) and has no field a user sees, so the same marker-gated, plain-write-only install also runs on a Cursor
|
|
512
|
+
// `stop`, and its one notice rides this hook's payload below. Once per clone either way. Where the gate always put it:
|
|
513
|
+
// before the transcript, the credential and the network.
|
|
514
|
+
const firstStop = ctx.agentId === "cursor" && event === "Stop" && turnEndAllowed && keys !== null
|
|
542
515
|
? installOnce({ env: ctx.env, home: ctx.home, selfPath: ctx.selfPath ?? "" }, keys)
|
|
543
516
|
: null;
|
|
544
|
-
const load = loadCredential({ env: ctx.env, home: ctx.home });
|
|
545
|
-
switch (load.kind) {
|
|
546
|
-
case "absent":
|
|
547
|
-
return notice("not_connected");
|
|
548
|
-
case "wrong-class":
|
|
549
|
-
case "insecure-file":
|
|
550
|
-
case "unreadable":
|
|
551
|
-
// Interactive verbs print four different fixes for these (§13.6). A hook
|
|
552
|
-
// has one channel and a one-line budget, so they collapse to the state
|
|
553
|
-
// whose fix — `vibecommit connect` — is the same for all four.
|
|
554
|
-
return notice("not_connected");
|
|
555
|
-
case "ok":
|
|
556
|
-
break;
|
|
557
|
-
}
|
|
558
517
|
// --- PATH CONFINEMENT. `/cso` finding 1, HIGH. ---
|
|
559
|
-
// `transcript_path` is attacker-influenced in the one way that matters: it
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
// being a silent arbitrary-file exfiltrator. Silent refusal, per the contract.
|
|
563
|
-
//
|
|
564
|
-
// ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5).
|
|
565
|
-
// The union is deliberately independent of `ctx.agentId`: the flag comes from
|
|
566
|
-
// an install that may have got it wrong, and a wrong SELECTION must not be able
|
|
567
|
-
// to widen the boundary — at most it picks a suboptimal redaction profile.
|
|
568
|
-
//
|
|
569
|
-
// ⛔ AND THE FILE GATE, ADDED HERE BY `CR-193` (D177 §2). The boundary answers
|
|
570
|
-
// "may this path be read"; it does not answer "is this a transcript", and
|
|
571
|
-
// under Cursor's root those are different questions — `find -type f` there
|
|
572
|
-
// returns 113 files and exactly ONE is a transcript. A narrowing over an
|
|
573
|
-
// already-computed boundary, so it can only refuse. See `admitsTranscript`.
|
|
518
|
+
// `transcript_path` is attacker-influenced in the one way that matters: it reaches a read whose result is uploaded.
|
|
519
|
+
// ⛔ THE ROOTS COME FROM THE REGISTRY, NEVER FROM `input` (`CR-183`, D164 §5), independent of `ctx.agentId`, and the file gate
|
|
520
|
+
// (`CR-193`, D177 §2) narrows them. It can only refuse. Silent refusal, per the contract.
|
|
574
521
|
if (!admitsTranscript(ctx.home, ctx.env, input.transcriptPath))
|
|
575
522
|
return null;
|
|
576
523
|
const url = ingestUrl(ctx.env);
|
|
577
|
-
// `/cso` finding 2: a rejected override is a silent no-send, not a fallback to
|
|
578
|
-
// the default. Falling back would send the credential somewhere the operator
|
|
579
|
-
// did not ask for, which is the opposite of what the check is for.
|
|
524
|
+
// `/cso` finding 2: a rejected override is a silent no-send, not a fallback to the default.
|
|
580
525
|
if (url === null)
|
|
581
526
|
return null;
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
// failing bytes) are both defined in terms of an offset and mean nothing
|
|
585
|
-
// without one. `CR-017d` widened the key that offset is kept under to
|
|
586
|
-
// `(repo, session)`; `CR-124` (W6) owns globbing
|
|
587
|
-
// `<session-dir>/subagents/agent-*.jsonl` — this sends the `main` file only.
|
|
588
|
-
//
|
|
589
|
-
// ⚠ The attribution above said `CR-020` until `CR-020d`. It was wrong, and D76
|
|
590
|
-
// says so by name: `CR-020`'s finding (D57 §DX7) is SessionEnd timing only and
|
|
591
|
-
// says nothing about sub-agent files. Corrected in passing rather than left to
|
|
592
|
-
// send the next reader after work that lives in another task.
|
|
593
|
-
//
|
|
594
|
-
// `SessionEnd` SETTLES FIRST (D57 §DX7). Every other event has a next
|
|
595
|
-
// invocation to pick up a lagging write; this one does not, so bytes missed
|
|
596
|
-
// here are the final turn, lost permanently.
|
|
597
|
-
// ⛔ RESOLVED THROUGH THE DIALECT BEFORE EITHER BRANCH READS IT (`CR-195`,
|
|
598
|
-
// D205). `input.eventName` is the agent's own spelling — Cursor sends
|
|
599
|
-
// `sessionEnd`, not `SessionEnd` — and both lines below used to compare the
|
|
600
|
-
// raw string against this client's vocabulary. That comparison did not fail
|
|
601
|
-
// loudly for Cursor; it returned `false`, which cost the final turn every
|
|
602
|
-
// session and carved no reserve.
|
|
603
|
-
//
|
|
604
|
-
// ⚠ Keyed on `ctx.agentId`, deliberately, and this is NOT the `CR-204`
|
|
605
|
-
// root-derived selection used for redaction and delegated transcripts. The
|
|
606
|
-
// event vocabulary is a property of the process that INVOKED us, and
|
|
607
|
-
// `--agent=` is the value WE write into that agent's own hook config — so it
|
|
608
|
-
// is the one selector that is not attacker-influenced input. `dialectBudgetMs`
|
|
609
|
-
// already keys the budget off it for the same reason.
|
|
527
|
+
// `SessionEnd` SETTLES FIRST (D57 §DX7): every other event has a next invocation to pick up a lagging write; this one does
|
|
528
|
+
// not. Resolved through the dialect (`CR-195`, D205): Cursor sends `sessionEnd`.
|
|
610
529
|
const isSessionEnd = event === SESSION_END_EVENT;
|
|
611
|
-
//
|
|
612
|
-
// duplicate costs nothing rather than spending the budget to discover it is
|
|
613
|
-
// redundant.
|
|
614
|
-
//
|
|
615
|
-
// ⛔ KEYED ON THE CANONICAL EVENT, NOT `input.eventName`. The two racers do
|
|
616
|
-
// not necessarily agree on the wire spelling — that is the whole shape of this
|
|
617
|
-
// defect: one invocation comes from Cursor's own config and one from Claude's,
|
|
618
|
-
// mapped, and they carry different `--agent=` flags. Resolving first is what
|
|
619
|
-
// makes both compute the SAME key; keying on the raw string would give them
|
|
620
|
-
// two keys and let both run, which is the failure this guards.
|
|
530
|
+
// --- TWO LOCKS, and they are different questions (O5). ---
|
|
621
531
|
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
532
|
+
// 1. `claimFiring`: "is this exact firing already running?" ONE FIRING, ONE PROCESS (`CR-195`/U4a): claimed BEFORE the settle
|
|
533
|
+
// so a duplicate costs nothing; keyed on the canonical event; the loser BOWS OUT. It lives beside the turn-end repo's state
|
|
534
|
+
// exactly as before, or beside the turn-end folder when the turn ended outside any work tree. ⚠ `claimFiring` returns
|
|
535
|
+
// `null` for two reasons (held by another, or no lock path at all) and only the first may bow out: an unlockable firing
|
|
536
|
+
// FAILS OPEN, because bowing out of every invocation would be a total silent outage.
|
|
537
|
+
// 2. `claimSessionWriter`: "is another writer touching THIS SESSION right now?": ONE LOCK PER `sessionId`, whatever repo each
|
|
538
|
+
// writer stands in, serialising the target record, every ledger and every spool against Stop, PreCompact, SessionEnd,
|
|
539
|
+
// `finalize` and `backfill`. A different event WAITS inside this hook's own budget and gives up with `null` (its bytes ride
|
|
540
|
+
// the next firing or the idle drain), never holding the watchdog hostage.
|
|
624
541
|
const fireKey = {
|
|
625
|
-
repoKey:
|
|
542
|
+
repoKey: SESSION_SCOPE,
|
|
626
543
|
sessionId: input.sessionId,
|
|
627
544
|
event: event ?? input.eventName,
|
|
628
545
|
};
|
|
629
|
-
// ⛔ `claimFiring` RETURNS `null` FOR TWO DIFFERENT REASONS, and conflating
|
|
630
|
-
// them would turn this guard into a silent capture outage. One means *another
|
|
631
|
-
// process holds this firing* — bow out. The other means *no lock path could be
|
|
632
|
-
// built at all*, and treating that as a duplicate would refuse EVERY
|
|
633
|
-
// invocation, permanently and silently.
|
|
634
|
-
//
|
|
635
|
-
// ⚠ It is currently unreachable — `resolveProjectKeys` returns `null` rather
|
|
636
|
-
// than an empty toplevel, and the consent gate above has already returned on
|
|
637
|
-
// that. It is distinguished anyway because the two errors are not the same
|
|
638
|
-
// size: an unlockable firing that runs is one possible duplicate, and an
|
|
639
|
-
// unlockable firing that bows out is total silence. So the lock FAILS OPEN.
|
|
640
546
|
const lockable = fireLockPath(ctx.home, fireKey) !== null;
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
// Another process holds this exact firing. ⛔ Silent and exit 0:
|
|
644
|
-
//
|
|
645
|
-
// user's turn for a duplicate they did not cause. `finish` releases nothing
|
|
646
|
-
// because we hold nothing.
|
|
547
|
+
const firing = claimFiring(ctx.home, fireKey, budgetMs);
|
|
548
|
+
if (firing === null && lockable) {
|
|
549
|
+
// Another process holds this exact firing. ⛔ Silent and exit 0: a `systemMessage` here would put a line in the user's turn
|
|
550
|
+
// for a duplicate they did not cause. `finish` releases nothing because we hold nothing.
|
|
647
551
|
return null;
|
|
648
552
|
}
|
|
553
|
+
if (firing !== null)
|
|
554
|
+
heldClaims.push(firing);
|
|
555
|
+
const writer = await claimSessionWriter(ctx.home, input.sessionId, {
|
|
556
|
+
staleAfterMs: budgetMs + STALE_MARGIN_MS,
|
|
557
|
+
deadlineAtMs: hookStartedAt + Math.floor(budgetMs * WRITER_WAIT_FRACTION),
|
|
558
|
+
wait,
|
|
559
|
+
});
|
|
560
|
+
if (writer === null)
|
|
561
|
+
return null;
|
|
562
|
+
heldClaims.push(writer);
|
|
649
563
|
const settleFor = isSessionEnd ? settleDelayMs(ctx.env, budgetMs) : 0;
|
|
650
|
-
// `CR-182`. Carved OUT of the budget, never added on top
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
// ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6 defect). The
|
|
654
|
-
// send loop measures its own consumption from `settleStart`, one line below;
|
|
655
|
-
// the watchdog has been running since `runHook`. The stdin read, the consent
|
|
656
|
-
// gate's `git rev-parse` spawn and the credential load all happened in
|
|
657
|
-
// between, and `CR-126` measured that interval at p99 392 ms — more than the
|
|
658
|
-
// whole 150 ms reserve. Passing it in is what makes the reserve a reserve
|
|
659
|
-
// against the deadline that can actually end the process, rather than against
|
|
660
|
-
// a clock that starts later than the one holding the gun.
|
|
564
|
+
// `CR-182`. Carved OUT of the budget, never added on top. ⛔ AND THE PRE-ENTRY COST IS CHARGED HERE (`CR-195/U2`, the G6
|
|
565
|
+
// defect): the stdin read, the gate's spawn, the credential load AND NOW the writer-lock wait all happened between the
|
|
566
|
+
// watchdog starting and this line, and the reserve must be measured against the clock that can actually end the process.
|
|
661
567
|
const settleStart = Date.now();
|
|
662
568
|
const sendBudget = sendBudgetMs(budgetMs, event, settleStart - hookStartedAt);
|
|
663
569
|
const eof = settleFor > 0
|
|
@@ -665,216 +571,487 @@ async function hookBody(ctx, budgetMs, hookStartedAt) {
|
|
|
665
571
|
: fileSize(input.transcriptPath);
|
|
666
572
|
if (eof === null)
|
|
667
573
|
return null;
|
|
668
|
-
// MEASURED, not assumed to be `settleFor`: the wait can overshoot under load,
|
|
669
|
-
// and the budget cares about the time actually spent.
|
|
574
|
+
// MEASURED, not assumed to be `settleFor`: the wait can overshoot under load, and the budget cares about the time spent.
|
|
670
575
|
const consumedMs = settleFor > 0 ? Date.now() - settleStart : 0;
|
|
671
|
-
|
|
672
|
-
// for any other host, else `local:<hash>`. Required by the merged server
|
|
673
|
-
// (`CR-019b`), which 400s a delta without it rather than writing a row nobody
|
|
674
|
-
// can read (D57 §OV1).
|
|
675
|
-
//
|
|
676
|
-
// ⛔ RESOLVED EXACTLY ONCE PER HOOK (`TODOS[87]`). It spawns
|
|
677
|
-
// `git remote get-url origin`, and it used to be called here AND once per
|
|
678
|
-
// sub-agent file — up to nine spawns for a value that cannot change within an
|
|
679
|
-
// invocation, since `projectKey` is fixed by the consent gate above. Passing
|
|
680
|
-
// it down rather than re-deriving it is also what keeps the two paths from
|
|
681
|
-
// ever disagreeing about which repository this is.
|
|
682
|
-
const repoSlug = slugOf();
|
|
683
|
-
// ⛔ THE ONE NEW SPAWN `CR-170` ADDS TO THIS PATH, and it is for REF MOVES,
|
|
684
|
-
// not commits. `post-commit` observes commits; it cannot see a `pull`, a
|
|
685
|
-
// `checkout` or a `reset --hard`, because those move a ref WITHOUT creating a
|
|
686
|
-
// commit and no commit hook fires. A direct look at `HEAD` is the only record.
|
|
687
|
-
//
|
|
688
|
-
// ⚠ It shares `CR-169`'s spawn budget like every other probe, so a hung git
|
|
689
|
-
// here is bounded and cannot defeat the watchdog. Null on an empty repository
|
|
690
|
-
// (`rev-parse HEAD` exits 128 there) — a state, not an error.
|
|
691
|
-
const head = headRef(projectKey);
|
|
692
|
-
// Commits OBSERVED since the last delivery. Capped, with the remainder left in
|
|
693
|
-
// the spool for the next hook rather than dropped.
|
|
694
|
-
const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
|
|
695
|
-
// ⛔ PROMOTE FIRST (`CR-195`/D208). A commit made during this session's FIRST
|
|
696
|
-
// turn was recorded uncorroborated, because `Stop` fires at the end of a turn
|
|
697
|
-
// and no state file existed when `post-commit` ran. By the time this hook is
|
|
698
|
-
// executing, that state file is this session's own — which is exactly the
|
|
699
|
-
// corroboration rung 1 requires — so the observation becomes deliverable now
|
|
700
|
-
// rather than being lost. Before this line, every first-turn commit was
|
|
701
|
-
// dropped: MEASURED as commit `f1608bc`, spooled nowhere.
|
|
702
|
-
// ⛔ The corroboration is THIS CALL SITE: `spoolKey.sessionId` is
|
|
703
|
-
// `input.sessionId`, the session whose hook is executing. See `promotePending`
|
|
704
|
-
// for why a state-file test is the wrong one and fails the first turn.
|
|
705
|
-
// ⛔ `TODOS[176]` — THE PROMOTION BOUND, applied to EVERY promotion (the first-turn cold case too): a pending line observed
|
|
706
|
-
// before the START OF THIS TURN (the first unsent prompt-like record, skipping the previous Stop's trailing records and any
|
|
707
|
-
// record without a timestamp) predates the turn and is dropped. `stale-own` = this session's state file exists and is
|
|
708
|
-
// not live, i.e. it is being RESUMED; `first-turn` = no state file yet (or a live one — the first Stop promotes before it saves). They differ only when no turn start can be found: stale-own DROPS (0.4.0's
|
|
709
|
-
// behaviour), first-turn KEEPS. See `pending_bound.ts` for the rule, the clocks and the scan cap.
|
|
710
|
-
const pendingMode = pendingModeFor(ctx.home, spoolKey);
|
|
711
|
-
const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
|
|
712
|
-
promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
|
|
713
|
-
// ⛔ `TODOS[175]` — ORPHAN PENDING FILES. After THIS session's own pending lines were promoted (or dropped by the bound), sweep the
|
|
714
|
-
// repo for pending files nobody can claim any more (older than 7 days; see `pending_prune.ts` for the rule, the tombstone race and
|
|
715
|
-
// the counts-only tally `status` announces). Stop and SessionEnd ONLY: never `post-commit` (it runs under git, on the commit
|
|
716
|
-
// path) and never `status` (readers stay read-only). Cheap (one readdir, at most 50 files) and it never throws.
|
|
717
|
-
if (event === "Stop" || isSessionEnd) {
|
|
718
|
-
pruneOrphanPending(ctx.home, projectKey, Date.now());
|
|
719
|
-
// `TODOS[177]` follow-up (VL, 2026-10-08, item 5): `.inferred.jsonl` gets the same 7-day sweep.
|
|
720
|
-
//
|
|
721
|
-
// ⛔⛔ THE RETURN VALUE IS NOT DISCARDED (VL, 2026-10-08, VG's silent-loss finding, G1: a mutant that makes this
|
|
722
|
-
// call a no-op SURVIVED all 57 tests, because the function was tested in isolation but nothing here checked it
|
|
723
|
-
// was actually WIRED). `pairs` is fed to the same durable tally `set_aside` uses, before the sweep's own
|
|
724
|
-
// tombstone-delete removes the file `status`'s live gauge would otherwise have read.
|
|
725
|
-
const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
|
|
726
|
-
if (prunedInferred.pairs > 0)
|
|
727
|
-
recordInferredSkipped(ctx.home, projectKey, "pruned", Date.now(), prunedInferred.pairs);
|
|
728
|
-
}
|
|
729
|
-
const spooled = capSpool(readSpool(ctx.home, spoolKey));
|
|
730
|
-
// ⛔ REWRITES ARE THEIR OWN FILE WITH THEIR OWN CAP (`T5`). The units differ —
|
|
731
|
-
// 41 bytes for a sha, 82 for an `ancestor:successor` pair — so one constant
|
|
732
|
-
// sized against the other would make one of the two headers wrong.
|
|
733
|
-
const rewritten = capSuccessors(readRewrites(ctx.home, spoolKey));
|
|
734
|
-
// `TODOS[177]` — INFERRED squash pairs: their own file, their own cap, their own header (`x-rewrites-inferred`), never `x-rewrites`.
|
|
735
|
-
const inferred = capInferred(readInferred(ctx.home, spoolKey));
|
|
736
|
-
// ⛔ THE REDACTION BOUNDARY, and it is NOT the consent key (`D184 §3`).
|
|
737
|
-
// Resolved here rather than at the gate so the derivation's one spawn is not
|
|
738
|
-
// paid by the invocations that return above without reading a byte.
|
|
739
|
-
const redactionRoots = confinementRoots(keys);
|
|
740
|
-
const delivery = await deliver({
|
|
576
|
+
const resolution = resolveTargets({
|
|
741
577
|
home: ctx.home,
|
|
742
578
|
env: ctx.env,
|
|
743
|
-
url,
|
|
744
|
-
credential: load.credential,
|
|
745
|
-
// The consent gate's key, reused rather than re-resolved: a second
|
|
746
|
-
// `git rev-parse` could disagree with the one consent was checked against.
|
|
747
|
-
repoKey: projectKey,
|
|
748
|
-
repoSlug,
|
|
749
579
|
sessionId: input.sessionId,
|
|
750
|
-
fileKey: "main",
|
|
751
|
-
// ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that
|
|
752
|
-
// contains it (`CR-204`, D177 §7). Deliberately the same expression the
|
|
753
|
-
// `readBody` closure below takes, so the bytes and the producer can never
|
|
754
|
-
// describe two different files.
|
|
755
580
|
transcriptPath: input.transcriptPath,
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
// is one the biggest spender walks straight through (`CR-182`).
|
|
759
|
-
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
760
|
-
// PR 4a — WHEN THE SENDS MUST STOP: `sendBudget` is measured from `settleStart`, the same origin `consumedMs` uses.
|
|
761
|
-
// A span larger than one delta goes out as several only while this lasts; the rest waits for the next event.
|
|
762
|
-
deadlineAtMs: settleStart + sendBudget,
|
|
581
|
+
turnEndCwd: input.cwd,
|
|
582
|
+
mode: isSessionEnd ? "end" : "turn",
|
|
763
583
|
nowMs: Date.now(),
|
|
764
|
-
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
// `(org_id, capture_id, commit_sha)`, so attaching these to all nine
|
|
768
|
-
// deliveries would admit nine legal, permanent rows for one commit — the
|
|
769
|
-
// objection that killed the polling design (D154).
|
|
770
|
-
head,
|
|
771
|
-
// ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`). The shas and
|
|
772
|
-
// their rungs are only safe as a pair — `parseObservedCommits` drops the
|
|
773
|
-
// ENTIRE BATCH when the two lists differ in length — and `count` is the
|
|
774
|
-
// number of spool LINES consumed, which is what the 2xx truncates by. A
|
|
775
|
-
// held entry is consumed without being sent, so `shas.length` is the wrong
|
|
776
|
-
// number for that and passing the pieces separately is how they diverge.
|
|
777
|
-
commits: spooled,
|
|
778
|
-
rewrites: rewritten.pairs,
|
|
779
|
-
inferredRewrites: inferred.pairs,
|
|
780
|
-
inferredGroups: inferred.groups,
|
|
781
|
-
}, eof,
|
|
782
|
-
// ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED.
|
|
783
|
-
// Those were one value until `D184`, and this line is where the difference
|
|
784
|
-
// becomes observable: redaction is measured against `[worktree,
|
|
785
|
-
// mainToplevel]`, never against the git common dir. If it followed the
|
|
786
|
-
// consent key there, `isInside` would fail-close every file of an external
|
|
787
|
-
// worktree to "outside the main clone" and hand the server a span of
|
|
788
|
-
// markers — a capture that succeeds, with turn counts that look right and
|
|
789
|
-
// no content in it (`D184 §3`).
|
|
790
|
-
(from, to) => readSpan(input.transcriptPath, from, to, redactionRoots, ctx.home, ctx.env), recordCutter(input.transcriptPath));
|
|
791
|
-
// --- DELEGATED WORK. `CR-124`, D76. ---
|
|
792
|
-
// Claude Code writes sub-agent transcripts to `<session>/subagents/`, which
|
|
793
|
-
// `transcript_path` never names: ~16% of transcript volume, and the work a
|
|
794
|
-
// reviewer most wants attributed. MAIN WENT FIRST, deliberately (D95) — the
|
|
795
|
-
// content most likely to matter is the content most likely to fit.
|
|
796
|
-
const subagents = await deliverSubagents({
|
|
797
|
-
ctx,
|
|
798
|
-
input,
|
|
799
|
-
url,
|
|
800
|
-
credential: load.credential,
|
|
801
|
-
projectKey,
|
|
802
|
-
redactionRoots,
|
|
803
|
-
repoSlug,
|
|
804
|
-
startedAt: settleStart,
|
|
805
|
-
sendBudget,
|
|
806
|
-
byHook: true,
|
|
584
|
+
// ⛔ The RESOLVER gets a STRICT thunk (C17 #13b): `slugOf` above falls back to `local:<hash>` on a timeout, and what the resolver is given
|
|
585
|
+
// becomes the target's sticky identity. The consent gate keeps `slugOf`.
|
|
586
|
+
...(keys !== null && slugOf !== null ? { known: { keys, slugOf: onceStrictRepoSlug(keys.worktree) } } : {}),
|
|
807
587
|
});
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
588
|
+
if (resolution.targets.length === 0) {
|
|
589
|
+
// Nothing granted was named (R8 · D11 · B): the hook stays SILENT, as it always was for an unconsented repo. A repo or
|
|
590
|
+
// folder the session named that nobody decided about is recorded BY NAME for `vibecommit status` to list.
|
|
591
|
+
//
|
|
592
|
+
// But D22 says ANY later firing drains idle sessions, and a session that names nothing granted (common in a parent folder)
|
|
593
|
+
// is exactly the firing that has the whole deadline spare. So: load the credential SILENTLY (no notice, ever: this session
|
|
594
|
+
// has nothing to say), and if it is usable, spend the leftover on others' owed bytes.
|
|
595
|
+
const quiet = loadCredential({ env: ctx.env, home: ctx.home });
|
|
596
|
+
if (quiet.kind === "ok") {
|
|
597
|
+
try {
|
|
598
|
+
await drainIdleSessions({
|
|
599
|
+
ctx,
|
|
600
|
+
url,
|
|
601
|
+
credential: quiet.credential,
|
|
602
|
+
currentSessionId: input.sessionId,
|
|
603
|
+
settleStart,
|
|
604
|
+
sendBudget,
|
|
605
|
+
staleAfterMs: budgetMs + STALE_MARGIN_MS,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// A hook cannot report an error.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
831
613
|
}
|
|
832
|
-
|
|
833
|
-
|
|
614
|
+
const load = loadCredential({ env: ctx.env, home: ctx.home });
|
|
615
|
+
switch (load.kind) {
|
|
616
|
+
case "absent":
|
|
617
|
+
return notice("not_connected");
|
|
618
|
+
case "wrong-class":
|
|
619
|
+
case "insecure-file":
|
|
620
|
+
case "unreadable":
|
|
621
|
+
// Interactive verbs print four different fixes for these (§13.6). A hook has one channel and a one-line budget, so they
|
|
622
|
+
// collapse to the state whose fix, `vibecommit connect`, is the same for all four.
|
|
623
|
+
return notice("not_connected");
|
|
624
|
+
case "ok":
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
// --- ONE SESSION, N TARGETS, ONE DEADLINE (A2). ---
|
|
628
|
+
// Least-recently-sent first, so a target with a 20 MB backlog cannot starve the others forever: it spends the firing, its
|
|
629
|
+
// `lastSentAt` moves, and the NEXT firing serves the one that waited. Each target's own sends still honour the shared
|
|
630
|
+
// deadline (`deadlineAtMs`), so the whole firing stays inside the one budget.
|
|
631
|
+
const ordered = leastRecentlySentFirst(ctx.home, input.sessionId, resolution.targets);
|
|
632
|
+
// ⛔ D95 STAYS, exactly as written, for the one case R14 protects: a session whose turns end in ONE granted work tree and
|
|
633
|
+
// which names nothing else granted. Every other session (several targets, a target found only by evidence, a folder target)
|
|
634
|
+
// has later firings that can finish it, so a shortfall at SessionEnd is OWED there, not written off (R19 · D22).
|
|
635
|
+
const writeOffAtSessionEnd = ordered.length === 1 && ordered[0].kind === "repo" && resolution.turnEndSlug === normalizeSlug(ordered[0].slug);
|
|
636
|
+
const deadline = settleStart + sendBudget;
|
|
637
|
+
const results = [];
|
|
638
|
+
for (const target of ordered) {
|
|
639
|
+
const result = await deliverTarget({
|
|
640
|
+
ctx,
|
|
641
|
+
input,
|
|
642
|
+
url,
|
|
643
|
+
credential: load.credential,
|
|
644
|
+
target,
|
|
645
|
+
eof,
|
|
646
|
+
event,
|
|
647
|
+
isSessionEnd,
|
|
648
|
+
settleStart,
|
|
649
|
+
sendBudget,
|
|
650
|
+
consumedMs: results.length === 0 ? consumedMs : Date.now() - settleStart,
|
|
651
|
+
// A target reached with the deadline already spent sends nothing this firing but is still STAMPED at SessionEnd.
|
|
652
|
+
skipSend: results.length > 0 && Date.now() >= deadline,
|
|
653
|
+
owedAtSessionEnd: !writeOffAtSessionEnd,
|
|
654
|
+
scanned: scannedOffsets(resolution.record.scan),
|
|
655
|
+
record: resolution.record,
|
|
656
|
+
});
|
|
657
|
+
results.push(result);
|
|
658
|
+
}
|
|
659
|
+
// The three classes are decided in `policy.ts` and applied in `post.ts`. Two of the four outcomes reach a human.
|
|
834
660
|
//
|
|
835
|
-
// `fatal`: a hook that stays silent about a revoked credential captures
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// tool noise D57 §DX3 exists to avoid.
|
|
661
|
+
// `fatal`: a hook that stays silent about a revoked credential captures nothing forever. `later` and `never` say nothing: a
|
|
662
|
+
// hook has one line per session and spending it on a condition that resolves itself next turn is the tool noise D57 §DX3
|
|
663
|
+
// exists to avoid. `ok`: the fourth state (`CR-023d`), and its `(repo, day)` claim is what keeps it to once.
|
|
839
664
|
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
//
|
|
847
|
-
// the file; its 401 is about a credential that is already gone. If what is on disk NOW is a
|
|
848
|
-
// different credential, there is nothing to tell the user — the stop is keyed to the old
|
|
849
|
-
// fingerprint and so does not apply to the new one.
|
|
665
|
+
// With N targets the answer is ONE: any `fatal` wins (it is credential-wide by definition), else the first target that
|
|
666
|
+
// delivered speaks. A per-target stop (C3) is never `fatal` and is never a notice: `status` names it.
|
|
667
|
+
const fatal = results.find((r) => r.stopped === false && r.delivery.kind === "attempted" && r.delivery.disposition === "fatal");
|
|
668
|
+
const payload = (() => {
|
|
669
|
+
if (fatal !== undefined && fatal.delivery.kind === "attempted") {
|
|
670
|
+
// ⛔ QUIET DURING ROTATION (`11A`). This hook loaded its credential before an `auth` replaced the file; its 401 is about a
|
|
671
|
+
// credential that is already gone. If what is on disk NOW is a different credential, there is nothing to tell the user.
|
|
850
672
|
const current = loadCredential({ env: ctx.env, home: ctx.home });
|
|
851
|
-
if (current.kind === "ok" &&
|
|
852
|
-
credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
|
|
673
|
+
if (current.kind === "ok" && credentialFingerprint(current.credential) !== credentialFingerprint(load.credential)) {
|
|
853
674
|
return null;
|
|
854
675
|
}
|
|
855
676
|
return notice("credential_revoked", null, load.credential);
|
|
856
677
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
678
|
+
const ok = results.find((r) => r.delivery.kind === "attempted" && r.delivery.disposition === "ok");
|
|
679
|
+
if (ok === undefined || ok.delivery.kind !== "attempted")
|
|
680
|
+
return null;
|
|
681
|
+
// `CR-234d` (12A): a 2xx carries where the credential stands (`X-Credential-Expires`). Remember it in the SIDECAR (never in
|
|
682
|
+
// `credentials.json`, which a hook must not rewrite, R10) and, from 14 days before the end, say so once per machine per day.
|
|
683
|
+
// It outranks the positive message: that one repeats daily, this one is the only way a person learns capture will stop.
|
|
684
|
+
if (ok.delivery.credentialExpires != null) {
|
|
685
|
+
const meta = noteCredentialExpiry(ctx.home, ctx.env, credentialFingerprint(load.credential), ok.delivery.credentialExpires, new Date());
|
|
686
|
+
const endsAt = endingSoon(meta, Date.now());
|
|
687
|
+
if (endsAt !== null) {
|
|
688
|
+
const ending = notice("credential_ending", null, load.credential, endsAt);
|
|
689
|
+
if (ending !== null)
|
|
690
|
+
return ending;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return ((firstStop?.state == null ? null : notice(firstStop.state, null, undefined, undefined, firstStop.cloneKey)) ??
|
|
694
|
+
notice("capture_confirmed", ok.target.ledgerKey));
|
|
695
|
+
})();
|
|
696
|
+
// The leftover of the one deadline goes to sessions that have had no firing for 10 minutes (R19 · D22): never throws.
|
|
697
|
+
try {
|
|
698
|
+
await drainIdleSessions({
|
|
699
|
+
ctx,
|
|
700
|
+
url,
|
|
701
|
+
credential: load.credential,
|
|
702
|
+
currentSessionId: input.sessionId,
|
|
703
|
+
settleStart,
|
|
704
|
+
sendBudget,
|
|
705
|
+
staleAfterMs: budgetMs + STALE_MARGIN_MS,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// A hook cannot report an error, and a drain that failed leaves every session exactly as it was.
|
|
710
|
+
}
|
|
711
|
+
return payload;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* How much of the hook's budget a firing may spend WAITING for another writer of the same session before it gives up. Most of it (0.8): a different event is serialised behind the holder, and the holder's own send can run to its timeout before it lets go, so a shorter
|
|
715
|
+
* window would give up on exactly the overlapping Stop/SessionEnd case (`session-end-gaps` U4a). The
|
|
716
|
+
* rest must still cover the settle, the send and (`SessionEnd`) the gap stamp. A waiter that gives up loses nothing: its bytes
|
|
717
|
+
* ride the next firing, or the idle drain.
|
|
718
|
+
*/
|
|
719
|
+
const WRITER_WAIT_FRACTION = 0.8;
|
|
720
|
+
/**
|
|
721
|
+
* Targets in the order they are served: the one that sent LEAST recently first (A2). `lastSentAt` is the latest successful send
|
|
722
|
+
* across the target's ledger's streams, 0 for "never", so a target that has never been served goes first and a target that just
|
|
723
|
+
* spent a firing on a big backlog goes last. Stable, so equal targets keep the resolver's order.
|
|
724
|
+
*/
|
|
725
|
+
function leastRecentlySentFirst(home, sessionId, targets) {
|
|
726
|
+
const lastSent = (target) => {
|
|
727
|
+
const state = loadSessionState(home, { repoKey: target.ledgerKey, sessionId });
|
|
728
|
+
return Math.max(0, ...Object.values(state.files).map((file) => file.lastSentAt));
|
|
729
|
+
};
|
|
730
|
+
return targets
|
|
731
|
+
.map((target, index) => ({ target, index, at: lastSent(target) }))
|
|
732
|
+
.sort((a, b) => a.at - b.at || a.index - b.index)
|
|
733
|
+
.map((entry) => entry.target);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Deliver ONE target of ONE session: what `hookBody` used to do inline for its single repo, now per target.
|
|
737
|
+
*
|
|
738
|
+
* promote and prune the target's pending commits, read its spool UNION, deliver `main`, then its sub-agents, then (on
|
|
739
|
+
* `SessionEnd`) stamp the honest hole. The order is the old one and every comment on it still holds: promote first (`CR-195`/
|
|
740
|
+
* D208), main before sub-agents (D95), the stamp AFTER every send because `markSkipped` sets `sentOffset = to`.
|
|
741
|
+
*
|
|
742
|
+
* ⛔ **Per target, never shared:** the ledger (`target.ledgerKey`, one per (slug, session), R5), the slug, the redaction roots
|
|
743
|
+
* and the spool sources. A session that worked in two granted repos sends each its own copy through its own ledger, so neither
|
|
744
|
+
* stream's offset can rewind the other's.
|
|
745
|
+
*/
|
|
746
|
+
async function deliverTarget(opts) {
|
|
747
|
+
const { ctx, input, url, credential, target, event, isSessionEnd, settleStart, sendBudget, consumedMs } = opts;
|
|
748
|
+
// ⛔ A FOLDER stream delivers only up to what the resolver has SCANNED (P1, A-T3 requirement b): bytes appended between the scan
|
|
749
|
+
// and this read were never checked for repo evidence, and a repo they name would end the folder rule. A repo target has no such
|
|
750
|
+
// limit: its bytes are its own whatever else the session named.
|
|
751
|
+
// C42: a REPO copy of a dialect that isolates records may deliver past the scan cursor. Every per-record decision there is exact (the record's own
|
|
752
|
+
// cwd, the path rule, the always-withheld types); the two facts the checkpoint cannot hold are learned IN-SPAN, in order (`pastCursorOf`).
|
|
753
|
+
const folderCap = target.kind === "folder" ? (opts.scanned ?? {}) : undefined;
|
|
754
|
+
const eof = folderCap === undefined ? opts.eof : Math.min(opts.eof, folderCap.main ?? 0);
|
|
755
|
+
const baseIsolation = isolationOf(target, opts.record);
|
|
756
|
+
const isolation = opts.record !== undefined && opts.scanned !== undefined && dialectIsolates(ctx.home, ctx.env, input.transcriptPath)
|
|
757
|
+
? { ...baseIsolation, past: pastCursorOf(baseIsolation, opts.scanned, { deadlineAtMs: settleStart + sendBudget }) }
|
|
758
|
+
: baseIsolation;
|
|
759
|
+
const sessionId = input.sessionId;
|
|
760
|
+
const ledgerKey = target.ledgerKey;
|
|
761
|
+
const sourceKeys = [ledgerKey, ...target.worktrees.filter((worktree) => worktree !== ledgerKey)];
|
|
762
|
+
// ⛔ PROMOTE FIRST (`CR-195`/D208), for every work tree whose spool this target reads. A commit made during this session's
|
|
763
|
+
// FIRST turn was recorded uncorroborated, because `Stop` fires at the end of a turn and no state file existed when
|
|
764
|
+
// `post-commit` ran. The corroboration is THIS CALL SITE: `sessionId` is the session whose hook is executing.
|
|
765
|
+
// ⛔ `TODOS[176]` — THE PROMOTION BOUND applies to every promotion; ⛔ `TODOS[175]` — orphan pending files are swept on Stop and
|
|
766
|
+
// SessionEnd only, never from `post-commit` and never from `status`. The `TODOS[177]` follow-up (VL, 2026-10-08): the sweep's
|
|
767
|
+
// return value is NOT discarded, it feeds the durable `set_aside` tally.
|
|
768
|
+
for (const projectKey of sourceKeys) {
|
|
769
|
+
const spoolKey = { repoKey: projectKey, sessionId: input.sessionId };
|
|
770
|
+
const pendingMode = pendingModeFor(ctx.home, spoolKey);
|
|
771
|
+
const bound = promotionBound(ctx.home, spoolKey, input.transcriptPath, Date.now());
|
|
772
|
+
promotePending(ctx.home, spoolKey, "this-session-is-running", bound, pendingMode);
|
|
773
|
+
if (event === "Stop" || isSessionEnd) {
|
|
774
|
+
pruneOrphanPending(ctx.home, projectKey, Date.now());
|
|
775
|
+
const prunedInferred = pruneExpiredInferred(ctx.home, projectKey, Date.now());
|
|
776
|
+
if (prunedInferred.pairs > 0)
|
|
777
|
+
recordInferredSkipped(ctx.home, projectKey, "pruned", Date.now(), prunedInferred.pairs);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
let delivery = { kind: "nothing-to-send" };
|
|
781
|
+
let subagents = { files: [], held: false, deliveries: [] };
|
|
782
|
+
let stopped = false;
|
|
783
|
+
if (!opts.skipSend) {
|
|
784
|
+
// ⛔ THE UNION, acknowledged per source (O7): commits, rewrites and inferred squashes of every work tree of this repo for this
|
|
785
|
+
// session. A single-worktree target reads exactly the three calls the hook always made.
|
|
786
|
+
const union = readSpoolUnion(ctx.home, sessionId, ledgerKey, target.worktrees);
|
|
787
|
+
// ⛔ THE ONE NEW SPAWN `CR-170` ADDED, for REF MOVES, not commits: a direct look at `HEAD` is the only record of a `pull`,
|
|
788
|
+
// `checkout` or `reset --hard`. Null on an empty repository. A FOLDER has no HEAD: no spawn.
|
|
789
|
+
const head = target.kind === "repo" ? headRef(ledgerKey) : null;
|
|
790
|
+
delivery = await deliver({
|
|
791
|
+
home: ctx.home,
|
|
792
|
+
env: ctx.env,
|
|
793
|
+
url,
|
|
794
|
+
credential,
|
|
795
|
+
repoKey: ledgerKey,
|
|
796
|
+
repoSlug: target.slug,
|
|
797
|
+
sessionId,
|
|
798
|
+
fileKey: "main",
|
|
799
|
+
// ⛔ THE PATH, so `deliver()` can derive `X-Agent` from the root that contains it (`CR-204`, D177 §7).
|
|
800
|
+
transcriptPath: input.transcriptPath,
|
|
801
|
+
// ⛔ `sendBudget`, not `budgetMs`: the main send is the single largest consumer on this path (`CR-182`).
|
|
802
|
+
timeoutMs: sendTimeoutMs(sendBudget, consumedMs),
|
|
803
|
+
// PR 4a: WHEN THE SENDS MUST STOP. Shared by every target of the firing, so the rest waits for the next event.
|
|
804
|
+
deadlineAtMs: settleStart + sendBudget,
|
|
805
|
+
nowMs: Date.now(),
|
|
806
|
+
byHook: true,
|
|
807
|
+
// ⛔ THE MAIN DELIVERY ONLY. Sub-agent deliveries leave all three unset: each is its own `capture_id`, and
|
|
808
|
+
// `capture_commits`' PK is `(org_id, capture_id, commit_sha)`, so attaching these to all of them would admit several
|
|
809
|
+
// legal, permanent rows for one commit (D154).
|
|
810
|
+
head,
|
|
811
|
+
// ⛔ THE WHOLE OBJECT, NOT `spooled.shas` (`T5`, `D190 §1`): shas and rungs are only safe as a pair, and `count` is the
|
|
812
|
+
// number of spool LINES consumed, which is what a 2xx truncates by.
|
|
813
|
+
commits: union.commits,
|
|
814
|
+
rewrites: union.rewrites.pairs,
|
|
815
|
+
inferredRewrites: union.inferred.pairs,
|
|
816
|
+
inferredGroups: union.inferred.groups,
|
|
817
|
+
spoolSources: union.sources,
|
|
818
|
+
}, eof,
|
|
819
|
+
// ⛔ THE ROOT SET, AND IT IS NOT THE VALUE THE CONSENT GATE APPROVED (`D184 §3`): redaction is measured against this
|
|
820
|
+
// target's own roots, never against the git common dir, or `isInside` would fail-close every file of an external worktree.
|
|
821
|
+
(from, to) => readSpan(input.transcriptPath, from, to, target.redactionRoots, ctx.home, ctx.env, isolation), recordCutter(input.transcriptPath));
|
|
822
|
+
// C3: the server refused THIS target for good. It is `never` (the span is advanced past, never held), and it is recorded as
|
|
823
|
+
// a per-target stop so the next firing does not ask again. Never credential-wide, never `fatal`.
|
|
824
|
+
if (delivery.kind === "attempted" && delivery.detail?.code === FOLDER_REPOSITORY_FORBIDDEN) {
|
|
825
|
+
stopTarget(ctx.home, sessionId, target, FOLDER_REPOSITORY_FORBIDDEN, Date.now());
|
|
826
|
+
return { target, delivery, subagents, stopped: true };
|
|
827
|
+
}
|
|
828
|
+
// --- DELEGATED WORK. `CR-124`, D76. ---
|
|
829
|
+
// Claude Code writes sub-agent transcripts to `<session>/subagents/`, which `transcript_path` never names: ~16% of
|
|
830
|
+
// transcript volume. MAIN WENT FIRST, deliberately (D95).
|
|
831
|
+
subagents = await deliverSubagents({
|
|
832
|
+
ctx,
|
|
833
|
+
input,
|
|
834
|
+
url,
|
|
835
|
+
credential,
|
|
836
|
+
projectKey: ledgerKey,
|
|
837
|
+
redactionRoots: target.redactionRoots,
|
|
838
|
+
repoSlug: target.slug,
|
|
839
|
+
startedAt: settleStart,
|
|
840
|
+
sendBudget,
|
|
841
|
+
byHook: true,
|
|
842
|
+
...(folderCap === undefined ? {} : { sizeCap: folderCap }),
|
|
843
|
+
isolation,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
subagents = { files: findSubagentFiles(ctx, input.transcriptPath), held: false, deliveries: [] };
|
|
848
|
+
}
|
|
849
|
+
// --- THE HONEST HOLE. D95, widened to `main` by `CR-182`. ---
|
|
850
|
+
// ⛔ AFTER EVERY SEND, AND IT CANNOT MOVE: `markSkipped` sets `sentOffset = to`, so stamping first would leave `nextSpan`
|
|
851
|
+
// nothing to return. ⛔ And `CR-228` (`TODOS[142]`): it writes off only what nothing explains. `held` is whether ANY delivery
|
|
852
|
+
// of this target ended `later`/`fatal`, or was refused by a standing stop. R19 · D22 adds the other case that is HELD, not
|
|
853
|
+
// written off: `owedAtSessionEnd`, a session other than the one-target in-repo case, whose later firings can finish it.
|
|
854
|
+
if (isSessionEnd) {
|
|
855
|
+
stampGaps(ctx, input, ledgerKey, [{ fileKey: "main", size: eof }, ...subagents.files], endedHeld(delivery) || subagents.held || opts.owedAtSessionEnd, Date.now());
|
|
856
|
+
}
|
|
857
|
+
return { target, delivery, subagents, stopped };
|
|
858
|
+
}
|
|
859
|
+
/** The resolver's scanned offset per stream, for the folder cap. */
|
|
860
|
+
function scannedOffsets(scan) {
|
|
861
|
+
return Object.fromEntries(Object.entries(scan).map(([fileKey, cursor]) => [fileKey, cursor.offset]));
|
|
862
|
+
}
|
|
863
|
+
/** A session with no firing of its own for this long is IDLE: another session's firing may finish its owed bytes (R19 · D22). */
|
|
864
|
+
const DRAIN_IDLE_MS = 10 * 60_000;
|
|
865
|
+
/** Session records one firing looks at. Examined ones move to the back of the line (their mtime moves), so none starves. */
|
|
866
|
+
const DRAIN_EXAMINE_MAX = 40;
|
|
867
|
+
/** A record whose transcript is gone and that owes nothing is forgotten after this long. */
|
|
868
|
+
const DRAIN_FORGET_MS = 30 * 24 * 3600_000;
|
|
869
|
+
/** Do not START another session with less than this left of the shared deadline: its first request could not finish. */
|
|
870
|
+
const DRAIN_MIN_LEFT_MS = 250;
|
|
871
|
+
/**
|
|
872
|
+
* An owed target whose transcript is GONE (Claude Code deletes transcripts ~30 days on) can never be sent: its hold becomes the
|
|
873
|
+
* honest gap now (R19 · D22), and the hold is cleared. Whatever was already delivered is untouched.
|
|
874
|
+
*/
|
|
875
|
+
function writeOffOwed(home, record, nowMs) {
|
|
876
|
+
for (const target of record.targets) {
|
|
877
|
+
const key = { repoKey: target.ledgerKey, sessionId: record.sessionId };
|
|
878
|
+
let session = loadSessionState(home, key);
|
|
879
|
+
if (session.endHold === null)
|
|
880
|
+
continue;
|
|
881
|
+
for (const [fileKey, eof] of Object.entries(session.endHold.eof)) {
|
|
882
|
+
const current = fileState(session, fileKey);
|
|
883
|
+
if (current.sentOffset < eof)
|
|
884
|
+
session = withFileState(session, fileKey, markSkipped(current, current.sentOffset, eof));
|
|
885
|
+
}
|
|
886
|
+
saveSessionState(home, key, { ...session, endHold: null, liveAt: session.liveAt });
|
|
887
|
+
void nowMs;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* ⛔ THE IDLE-SESSION DRAIN (R19 · D22 · A). What finishes a session's unsent bytes after its LAST hook.
|
|
892
|
+
*
|
|
893
|
+
* Each hook run can send a few megabytes, and a session whose last hook ran with bytes still behind (a multi-repo session at
|
|
894
|
+
* `SessionEnd`, a repo found late in a long one) has no next hook of its own. So any later `Stop`, `PreCompact` or `SessionEnd`
|
|
895
|
+
* on this machine, AFTER its own session's targets and inside the SAME one deadline, finishes sessions that have had no firing
|
|
896
|
+
* for 10 minutes: oldest first, each under THAT session's writer lock (never waited for: a busy session is skipped), consent
|
|
897
|
+
* re-checked by the resolver, and its discovery continued (≤8 MiB and ≤8 roots per firing, R16), which can add repo targets.
|
|
898
|
+
*
|
|
899
|
+
* - A session is RETIRED from the drain once its discovery is settled and nothing is behind: it costs a stat and moves to the
|
|
900
|
+
* back of the line. Discovery stopping never stops a repo target delivering owed bytes.
|
|
901
|
+
* - A folder decision is exactly R18's (`unknown` stays unknown here; backfill offers it); only an ALREADY-sticky folder target
|
|
902
|
+
* delivers, and only up to what was scanned.
|
|
903
|
+
* - An owed target whose transcript is gone becomes a gap then (`writeOffOwed`).
|
|
904
|
+
*
|
|
905
|
+
* ⛔ It spends only the LEFTOVER of the one deadline (`settleStart + sendBudget`): the deliveries below honour the same
|
|
906
|
+
* `deadlineAtMs`, and no new session starts with under `DRAIN_MIN_LEFT_MS` left. Never throws: a hook cannot say anything.
|
|
907
|
+
*/
|
|
908
|
+
async function drainIdleSessions(opts) {
|
|
909
|
+
const { ctx, url, credential, settleStart, sendBudget } = opts;
|
|
910
|
+
const deadline = settleStart + sendBudget;
|
|
911
|
+
const candidates = listTargetRecords(ctx.home)
|
|
912
|
+
.filter(({ record }) => record.sessionId !== opts.currentSessionId)
|
|
913
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
914
|
+
.slice(0, DRAIN_EXAMINE_MAX);
|
|
915
|
+
for (const { path, record } of candidates) {
|
|
916
|
+
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
917
|
+
return;
|
|
918
|
+
const now = Date.now();
|
|
919
|
+
if (now - record.firedAt < DRAIN_IDLE_MS)
|
|
920
|
+
continue;
|
|
921
|
+
// ⛔ A record the drain cannot act on (no transcript path, a transcript gone, a path confinement refuses) must still MOVE to the
|
|
922
|
+
// back of the line (C18 #7): the candidates are the oldest records by mtime, and forty of these at the front would otherwise be
|
|
923
|
+
// re-examined first on every firing and starve a live owed session behind them.
|
|
924
|
+
const rotate = () => {
|
|
925
|
+
try {
|
|
926
|
+
utimesSync(path, new Date(), new Date());
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
// Ordering only.
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
if (record.transcriptPath === null) {
|
|
933
|
+
rotate();
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const transcript = record.transcriptPath;
|
|
937
|
+
// ⛔ C28: A SESSION OF A DIALECT THAT CANNOT ISOLATE IS NEVER OWED (Cursor; Codex until its own isolation is built). It delivers only
|
|
938
|
+
// the repo each turn ends in, on that turn's own firing; there is no later firing to finish for it and no discovery to continue, so
|
|
939
|
+
// the drain neither delivers nor writes off for it. It still moves to the back of the line, and the record is forgotten on the
|
|
940
|
+
// usual schedule (a session that has not fired for that long is over).
|
|
941
|
+
if (!dialectIsolates(ctx.home, ctx.env, transcript)) {
|
|
942
|
+
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
943
|
+
try {
|
|
944
|
+
rmSync(path);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// A record we could not delete is just examined again.
|
|
869
948
|
}
|
|
870
949
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
950
|
+
else {
|
|
951
|
+
rotate();
|
|
952
|
+
}
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
// ⛔ CONFINEMENT, AGAIN (`/cso` finding 1): this path was read from a state file, not handed over by a gated hook input, and it
|
|
956
|
+
// reaches a read whose result is UPLOADED. A path admitted weeks ago can since have become a symlink out of the agent roots,
|
|
957
|
+
// and the record is only as trustworthy as the state directory. A file that is not there is not read, so it needs no gate
|
|
958
|
+
// (the write-off below touches no bytes).
|
|
959
|
+
if (existsSync(transcript) && !admitsTranscript(ctx.home, ctx.env, transcript)) {
|
|
960
|
+
rotate();
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const size = fileSize(transcript);
|
|
964
|
+
if (size === null) {
|
|
965
|
+
// ⛔ THE WRITE-OFF IS A WRITE TO THE SESSION'S LEDGERS, so it takes the session's one writer lock like every other writer
|
|
966
|
+
// (C18 #6), never waiting: a session a delivery holds right now is skipped and examined on a later firing.
|
|
967
|
+
const writeOff = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
968
|
+
if (writeOff === null) {
|
|
969
|
+
rotate();
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
writeOffOwed(ctx.home, record, now);
|
|
974
|
+
}
|
|
975
|
+
finally {
|
|
976
|
+
writeOff.release();
|
|
977
|
+
}
|
|
978
|
+
if (now - record.firedAt > DRAIN_FORGET_MS) {
|
|
979
|
+
try {
|
|
980
|
+
rmSync(path);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// A record we could not delete is just examined again.
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
rotate();
|
|
988
|
+
}
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
const live = record.targets.filter((target) => record.stopped[stopKeyOf(target)] === undefined);
|
|
992
|
+
const subFiles = findSubagentFiles(ctx, transcript);
|
|
993
|
+
const behind = live.some((target) => {
|
|
994
|
+
const state = loadSessionState(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
995
|
+
return (state.endHold !== null ||
|
|
996
|
+
fileState(state, "main").sentOffset < size ||
|
|
997
|
+
subFiles.some((file) => fileState(state, file.fileKey).sentOffset < file.size));
|
|
998
|
+
});
|
|
999
|
+
if (!behind && record.settled) {
|
|
1000
|
+
// Retired: nothing to send and nothing left to discover. Move it to the back of the line.
|
|
1001
|
+
try {
|
|
1002
|
+
utimesSync(path, new Date(), new Date());
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
// Ordering only.
|
|
1006
|
+
}
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const claim = await claimSessionWriter(ctx.home, record.sessionId, { staleAfterMs: opts.staleAfterMs, deadlineAtMs: 0, wait });
|
|
1010
|
+
// ⛔ C29 (iii): a writer is on it right now: not ours to wait for, but it MOVES to the back of the line like every other record the
|
|
1011
|
+
// drain cannot act on (C18 #7). Left in place, forty busy records at the front would be re-examined first on every firing.
|
|
1012
|
+
if (claim === null) {
|
|
1013
|
+
rotate();
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const resolution = resolveTargets({
|
|
1018
|
+
home: ctx.home,
|
|
1019
|
+
env: ctx.env,
|
|
1020
|
+
sessionId: record.sessionId,
|
|
1021
|
+
transcriptPath: transcript,
|
|
1022
|
+
turnEndCwd: null,
|
|
1023
|
+
mode: "turn",
|
|
1024
|
+
nowMs: Date.now(),
|
|
1025
|
+
drain: true,
|
|
1026
|
+
});
|
|
1027
|
+
const input = { sessionId: record.sessionId, transcriptPath: transcript, cwd: "", eventName: "Stop" };
|
|
1028
|
+
for (const target of leastRecentlySentFirst(ctx.home, record.sessionId, resolution.targets)) {
|
|
1029
|
+
if (deadline - Date.now() < DRAIN_MIN_LEFT_MS)
|
|
1030
|
+
break;
|
|
1031
|
+
await deliverTarget({
|
|
1032
|
+
ctx,
|
|
1033
|
+
input,
|
|
1034
|
+
url,
|
|
1035
|
+
credential,
|
|
1036
|
+
target,
|
|
1037
|
+
eof: size,
|
|
1038
|
+
event: null,
|
|
1039
|
+
isSessionEnd: false,
|
|
1040
|
+
settleStart,
|
|
1041
|
+
sendBudget,
|
|
1042
|
+
consumedMs: Date.now() - settleStart,
|
|
1043
|
+
skipSend: false,
|
|
1044
|
+
owedAtSessionEnd: false,
|
|
1045
|
+
scanned: scannedOffsets(resolution.record.scan),
|
|
1046
|
+
record: resolution.record,
|
|
1047
|
+
});
|
|
1048
|
+
pruneDeliveredEndHold(ctx.home, { repoKey: target.ledgerKey, sessionId: record.sessionId });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
finally {
|
|
1052
|
+
claim.release();
|
|
875
1053
|
}
|
|
876
1054
|
}
|
|
877
|
-
return null;
|
|
878
1055
|
}
|
|
879
1056
|
/**
|
|
880
1057
|
* Send whatever delegated transcripts fit in what is left of the budget.
|
|
@@ -920,10 +1097,12 @@ export async function deliverSubagents(opts) {
|
|
|
920
1097
|
// a fallback chain. Written this way so `findSubagentFiles` is byte-unchanged
|
|
921
1098
|
// and `CR-204`'s root-derived gating (pinned by `test/wire-agent.test.ts` G4)
|
|
922
1099
|
// keeps its meaning.
|
|
923
|
-
const
|
|
1100
|
+
const discovered = [
|
|
924
1101
|
...findSubagentFiles(ctx, input.transcriptPath),
|
|
925
1102
|
...announcedSubagentFiles(ctx, input),
|
|
926
1103
|
];
|
|
1104
|
+
const cap = opts.sizeCap;
|
|
1105
|
+
const files = cap === undefined ? discovered : discovered.map((f) => ({ ...f, size: Math.min(f.size, cap[f.fileKey] ?? 0) }));
|
|
927
1106
|
if (files.length === 0)
|
|
928
1107
|
return { files, held: false, deliveries: [] };
|
|
929
1108
|
// ⚠ SKIP WHAT HAS NOTHING PENDING, BEFORE THE CAP APPLIES — and this is not an
|
|
@@ -948,6 +1127,8 @@ export async function deliverSubagents(opts) {
|
|
|
948
1127
|
const consumedMs = Date.now() - startedAt;
|
|
949
1128
|
if (sendBudget - consumedMs < MIN_SUBAGENT_BUDGET_MS)
|
|
950
1129
|
break;
|
|
1130
|
+
if (opts.heartbeat !== undefined && !opts.heartbeat())
|
|
1131
|
+
break;
|
|
951
1132
|
const delivery = await deliver({
|
|
952
1133
|
home: ctx.home,
|
|
953
1134
|
env: ctx.env,
|
|
@@ -967,11 +1148,12 @@ export async function deliverSubagents(opts) {
|
|
|
967
1148
|
deadlineAtMs: startedAt + sendBudget,
|
|
968
1149
|
nowMs: Date.now(),
|
|
969
1150
|
byHook,
|
|
1151
|
+
...(opts.heartbeat === undefined ? {} : { beforeRequest: opts.heartbeat }),
|
|
970
1152
|
}, file.size,
|
|
971
1153
|
// The SAME reader as the main transcript, so `CR-024d`'s redaction and
|
|
972
1154
|
// `/cso` finding 1's confinement both apply. A delegated agent reading a
|
|
973
1155
|
// third party's file is exactly as out-of-tree as the main thread doing it.
|
|
974
|
-
(from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env), recordCutter(file.path));
|
|
1156
|
+
(from, to) => readSpan(file.path, from, to, redactionRoots, ctx.home, ctx.env, opts.isolation ?? { kind: "repo", ownRoots: redactionRoots }, file.fileKey), recordCutter(file.path));
|
|
975
1157
|
deliveries.push(delivery);
|
|
976
1158
|
if (endedHeld(delivery))
|
|
977
1159
|
held = true;
|
|
@@ -1107,59 +1289,10 @@ heldThisInvocation, nowMs) {
|
|
|
1107
1289
|
* hook contract.
|
|
1108
1290
|
*/
|
|
1109
1291
|
function findSubagentFiles(ctx, transcriptPath) {
|
|
1110
|
-
// ⛔
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
// announcing dialect delegates nothing today — stated, not disguised.
|
|
1115
|
-
//
|
|
1116
|
-
// ⛔ SELECTED BY THE CONTAINING ROOT, NOT BY `ctx.agentId` (`CR-204`,
|
|
1117
|
-
// D177 §7). Sub-agent LAYOUT is a property of the TRANSCRIPT, so the flag is
|
|
1118
|
-
// the wrong oracle for it in both directions — and both are live, not
|
|
1119
|
-
// theoretical. MEASURED on a no-flag install (`test/wire-agent.test.ts` G4):
|
|
1120
|
-
// a Codex-root transcript with a `subagents/` directory beside it had Claude's
|
|
1121
|
-
// glob run over it and UPLOADED what it found. The other direction is the one
|
|
1122
|
-
// D177 §7 names: a cross-fired hook globbing Claude's `agent-*.jsonl` under a
|
|
1123
|
-
// foreign root and silently finding nothing.
|
|
1124
|
-
//
|
|
1125
|
-
// ⚠ An unattributed path resolves `unknown`, and `dialectFor` falls back to
|
|
1126
|
-
// Claude — so it would glob. It cannot arrive here: confinement refuses
|
|
1127
|
-
// everything outside the union first, and `subagentsDir` returns null for
|
|
1128
|
-
// anything that is not a `.jsonl` anyway.
|
|
1129
|
-
if (dialectFor(agentForTranscript(ctx.home, ctx.env, transcriptPath)).delegatedTranscripts !==
|
|
1130
|
-
"discover") {
|
|
1131
|
-
return [];
|
|
1132
|
-
}
|
|
1133
|
-
const dir = subagentsDir(transcriptPath);
|
|
1134
|
-
if (dir === null)
|
|
1135
|
-
return [];
|
|
1136
|
-
const roots = transcriptRoots(ctx.home, ctx.env);
|
|
1137
|
-
let entries;
|
|
1138
|
-
try {
|
|
1139
|
-
entries = readdirSync(dir);
|
|
1140
|
-
}
|
|
1141
|
-
catch {
|
|
1142
|
-
return [];
|
|
1143
|
-
}
|
|
1144
|
-
const found = [];
|
|
1145
|
-
for (const entry of entries) {
|
|
1146
|
-
const fileKey = subagentFileKey(entry);
|
|
1147
|
-
if (fileKey === null)
|
|
1148
|
-
continue;
|
|
1149
|
-
const path = join(dir, entry);
|
|
1150
|
-
if (!isInsideAny(roots, path))
|
|
1151
|
-
continue;
|
|
1152
|
-
try {
|
|
1153
|
-
const stat = statSync(path);
|
|
1154
|
-
if (!stat.isFile() || stat.size === 0)
|
|
1155
|
-
continue;
|
|
1156
|
-
found.push({ path, fileKey: fileKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
1157
|
-
}
|
|
1158
|
-
catch {
|
|
1159
|
-
// Vanished between readdir and stat. Not ours to report.
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
return found.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
1292
|
+
// ⛔ THE ONE COPY lives in `session_targets.ts` (`findSubagentTranscripts`), shared with the evidence reader so discovery and
|
|
1293
|
+
// delivery can never disagree about which files are a session's. Every rule above this line (the dialect's mechanism, the root
|
|
1294
|
+
// that selects it, `agent-*.jsonl` and never `agent-*`, confinement of every entry) is enforced there.
|
|
1295
|
+
return findSubagentTranscripts(ctx.home, ctx.env, transcriptPath).map((file) => ({ ...file, fileKey: file.fileKey }));
|
|
1163
1296
|
}
|
|
1164
1297
|
/**
|
|
1165
1298
|
* ⛔ **THE `announce` READER — the first one there has ever been.** `CR-196`,
|
|
@@ -1248,124 +1381,11 @@ export function fileSize(path) {
|
|
|
1248
1381
|
return null;
|
|
1249
1382
|
}
|
|
1250
1383
|
}
|
|
1251
|
-
/** The
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
* ## ⛔ `rev-parse --show-toplevel` CANNOT ANSWER THIS, measured
|
|
1257
|
-
*
|
|
1258
|
-
* `D184 §4` mandates `git -C <common-dir> rev-parse --show-toplevel`. Measured
|
|
1259
|
-
* on git 2.39.5 against real repositories, that command exits **128** —
|
|
1260
|
-
* *"fatal: this operation must be run in a work tree"* — for **every shape a
|
|
1261
|
-
* consent key takes**, because a consent key is always a git DIRECTORY and
|
|
1262
|
-
* `--is-inside-work-tree` is `false` inside one:
|
|
1263
|
-
*
|
|
1264
|
-
* | `-C` | `--show-toplevel` |
|
|
1265
|
-
* |---|---|
|
|
1266
|
-
* | `<main>/.git` (main clone, and every worktree linked to it) | ⛔ ec 128 |
|
|
1267
|
-
* | `<repos>/project.git` (bare) | ⛔ ec 128 |
|
|
1268
|
-
*
|
|
1269
|
-
* Implemented literally, "a failure means no second root" would fire 100% of
|
|
1270
|
-
* the time: the set would be the singleton it is today, forever, and a
|
|
1271
|
-
* main-clone file read from a worktree session would come back a marker. The
|
|
1272
|
-
* whole change would pass its own tests while doing nothing.
|
|
1273
|
-
*
|
|
1274
|
-
* ## What answers instead, and why it is not a guess
|
|
1275
|
-
*
|
|
1276
|
-
* `git worktree list --porcelain` DOES run from a git dir and from a bare
|
|
1277
|
-
* directory, and its FIRST record is the main work tree. Bare repositories say
|
|
1278
|
-
* so in the record itself — `bare`, with no work tree of their own.
|
|
1279
|
-
*
|
|
1280
|
-
* ⛔ **The answer is then validated by CONTENT, never trusted by position:**
|
|
1281
|
-
* `realpath(<candidate>/.git)` must BE the consented common dir. That check is
|
|
1282
|
-
* independently fatal to every wrong answer this could produce — a bare repo
|
|
1283
|
-
* (`<repos>/.git` does not exist), a linked worktree returned first by some
|
|
1284
|
-
* future git (its `.git` is a FILE, so the realpath is the file, not the common
|
|
1285
|
-
* dir), a submodule work tree (same). `D184 §4`'s hazard is what it is for: in a
|
|
1286
|
-
* worktree of a bare repo the common dir is `…/repos/project.git`, whose parent
|
|
1287
|
-
* is **the folder holding every other repository on the machine**, and a root
|
|
1288
|
-
* derived by stripping a suffix would transmit every sibling repo's file
|
|
1289
|
-
* contents verbatim.
|
|
1290
|
-
*
|
|
1291
|
-
* Every failure yields NO SECOND ROOT. Never a fallback, never a parent, never a
|
|
1292
|
-
* suffix strip.
|
|
1293
|
-
*/
|
|
1294
|
-
function mainWorkTree(commonDir) {
|
|
1295
|
-
const out = gitProbe(commonDir, ["worktree", "list", "--porcelain"]);
|
|
1296
|
-
if (out === null)
|
|
1297
|
-
return null;
|
|
1298
|
-
const record = [];
|
|
1299
|
-
for (const line of out.split("\n")) {
|
|
1300
|
-
if (line === "")
|
|
1301
|
-
break;
|
|
1302
|
-
record.push(line);
|
|
1303
|
-
}
|
|
1304
|
-
// A BARE repository HAS no work tree, and git says so in the record itself.
|
|
1305
|
-
if (record.includes("bare"))
|
|
1306
|
-
return null;
|
|
1307
|
-
const first = record[0];
|
|
1308
|
-
if (first === undefined || !first.startsWith(WORKTREE_LINE))
|
|
1309
|
-
return null;
|
|
1310
|
-
const candidate = first.slice(WORKTREE_LINE.length);
|
|
1311
|
-
if (!isAbsolute(candidate))
|
|
1312
|
-
return null;
|
|
1313
|
-
try {
|
|
1314
|
-
return realpathSync(join(candidate, ".git")) === commonDir ? candidate : null;
|
|
1315
|
-
}
|
|
1316
|
-
catch {
|
|
1317
|
-
return null;
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
/**
|
|
1321
|
-
* ⛔ **The redaction confinement ROOT SET — `D184 §3`.**
|
|
1322
|
-
*
|
|
1323
|
-
* ```
|
|
1324
|
-
* isInsideAny([worktree, mainToplevel], candidate)
|
|
1325
|
-
* ```
|
|
1326
|
-
*
|
|
1327
|
-
* ## Why it is a set, and why the common dir is not in it
|
|
1328
|
-
*
|
|
1329
|
-
* `D184` moved the consent key to the git common dir so that a session inside a
|
|
1330
|
-
* `git worktree` is admitted at all. ⛔ **The confinement root MUST NOT follow
|
|
1331
|
-
* it there.** `isInside` realpaths both sides and fail-closes anything it cannot
|
|
1332
|
-
* place to *outside* — which here means REDACT — so a root at `<main>/.git`
|
|
1333
|
-
* would judge every file of an external worktree to be outside the main clone
|
|
1334
|
-
* and replace its `content`, `oldString`, `newString` and `structuredPatch` with
|
|
1335
|
-
* markers. **The capture still succeeds. The turn counts still look right. The
|
|
1336
|
-
* content is gone**, and a test asserting *"worktree sessions capture now"*
|
|
1337
|
-
* passes on an empty payload.
|
|
1338
|
-
*
|
|
1339
|
-
* Two roots, because both are genuinely the user's own tree: the worktree the
|
|
1340
|
-
* session is running in, and the main clone it was cut from — a builder in a
|
|
1341
|
-
* worktree reads the main clone's files constantly.
|
|
1342
|
-
*
|
|
1343
|
-
* ## ⛔ The `paths.ts:487-489` prohibition, answered — `D184 §5`
|
|
1344
|
-
*
|
|
1345
|
-
* > *"`roots` comes from the REGISTRY. It must never be derived from input."*
|
|
1346
|
-
*
|
|
1347
|
-
* **That prohibition is about PROVENANCE, and both members have the right one.**
|
|
1348
|
-
* The named hazard is *anything on hook stdin* — attacker-supplied. Neither of
|
|
1349
|
-
* these is: `keys` can only be produced by `resolveProjectKeys`, which is git's
|
|
1350
|
-
* own answer about the directory the session runs in; `mainToplevel` is git's
|
|
1351
|
-
* answer about the repository **the consent gate already approved**, and it is
|
|
1352
|
-
* content-validated by `mainWorkTree` above. The worktree root has exactly the
|
|
1353
|
-
* same provenance and has been used this way since `CR-024d`.
|
|
1354
|
-
*
|
|
1355
|
-
* ⚠ **Provenance is the whole argument, so this code preserves it:** nothing
|
|
1356
|
-
* here is read from stdin, the derivation is validated by content, and a failure
|
|
1357
|
-
* yields no second root rather than a fallback. An empty set would refuse
|
|
1358
|
-
* everything (`paths.ts:491-492`), which is the correct direction — but this
|
|
1359
|
-
* function cannot return one, because `keys.worktree` is always a member.
|
|
1360
|
-
*
|
|
1361
|
-
* ⚠ The dedup is cosmetic. `isInsideAny` over a repeated root is a union with
|
|
1362
|
-
* itself; it is dropped so that a main-clone session reads as the single root it
|
|
1363
|
-
* has always had, and so a cell asserting "no second root" is asserting
|
|
1364
|
-
* something.
|
|
1365
|
-
*/
|
|
1366
|
-
export function confinementRoots(keys) {
|
|
1367
|
-
const main = mainWorkTree(keys.consent);
|
|
1368
|
-
return main === null || main === keys.worktree ? [keys.worktree] : [keys.worktree, main];
|
|
1384
|
+
/** The stream a transcript path is: `main`, or the `agent-<id>` key of a delegated file under a `subagents/` directory. */
|
|
1385
|
+
function streamKeyOf(path) {
|
|
1386
|
+
if (basename(dirname(path)) !== "subagents")
|
|
1387
|
+
return "main";
|
|
1388
|
+
return subagentFileKey(basename(path)) ?? "main";
|
|
1369
1389
|
}
|
|
1370
1390
|
/**
|
|
1371
1391
|
* Read `[from, to)` of the transcript, REDACT it, and compress it.
|
|
@@ -1408,19 +1428,39 @@ export function confinementRoots(keys) {
|
|
|
1408
1428
|
* set is the ordinary main-clone case and behaves exactly as the old parameter
|
|
1409
1429
|
* did.
|
|
1410
1430
|
*
|
|
1431
|
+
* ## ⛔ `isolation` — what a TARGET's stream may contain beyond the path rule (any-folder capture)
|
|
1432
|
+
*
|
|
1433
|
+
* R6 · R17. A session that worked in N granted repos uploads N copies, so the chokepoint also knows WHOSE copy it is cutting.
|
|
1434
|
+
* For a REPO target, a record whose `cwd` resolves inside a work tree other than the target's is withheld (length-preserving, the
|
|
1435
|
+
* same markers): another repo's commands and output must not reach this repo's readers. For a FOLDER target, anything inside a
|
|
1436
|
+
* git work tree nested in the folder is withheld (exclusion roots). Records run from the launch folder are kept in every copy;
|
|
1437
|
+
* that residual (prompts and prose from the parent folder) is stated in the README and the claims register. Absent: the
|
|
1438
|
+
* structured-path rule alone, exactly as before.
|
|
1439
|
+
*
|
|
1411
1440
|
* EXPORTED for `connect`'s test capture (`CR-025`), which reads a transcript on
|
|
1412
1441
|
* a path the hook never takes. It reuses this rather than opening one itself,
|
|
1413
1442
|
* because a second reader would bypass both the confinement and the redaction —
|
|
1414
1443
|
* exactly what the chokepoint note above warns about.
|
|
1415
1444
|
*/
|
|
1416
|
-
export function readSpan(path, from, to, projectRoots, home, env
|
|
1445
|
+
export function readSpan(path, from, to, projectRoots, home, env,
|
|
1446
|
+
/** A-T4: the target's isolation (R17 record isolation for a repo, R6 exclusion roots for a folder). Absent: structured-path redaction only. */
|
|
1447
|
+
isolation,
|
|
1448
|
+
/** The stream this file is. Default: derived from the path (Claude's `subagents/agent-<id>.jsonl`); a Codex delegated file is announced, not found by directory, so its caller says. */
|
|
1449
|
+
streamKey) {
|
|
1417
1450
|
let fd;
|
|
1418
1451
|
try {
|
|
1419
1452
|
fd = openSync(path, "r");
|
|
1420
1453
|
const buf = Buffer.alloc(to - from);
|
|
1421
1454
|
const read = readSync(fd, buf, 0, buf.length, from);
|
|
1422
1455
|
const raw = read === buf.length ? buf : buf.subarray(0, read);
|
|
1423
|
-
|
|
1456
|
+
const spec = isolation === undefined ? undefined : evidenceSpecFor(home, env, path);
|
|
1457
|
+
const fileKey = streamKey ?? streamKeyOf(path);
|
|
1458
|
+
// C42: a read that reaches past the scan cursor first observes whatever lies between the observed bytes and its start, then each record as it is cut.
|
|
1459
|
+
if (spec !== undefined)
|
|
1460
|
+
isolation?.past?.begin(spec, path, fileKey, from, to);
|
|
1461
|
+
const redacted = redactSpan(raw, projectRoots, rulesForTranscript(home, env, path), isolation === undefined || spec === undefined ? undefined : redactionScopeFor(isolation, spec.cwdKeys, { fileKey, trackCalls: spec.trackCalls === true, codex: spec.codexFolders === true }), from);
|
|
1462
|
+
isolation?.past?.end(fileKey, to);
|
|
1463
|
+
return zstdCompressSync(redacted.bytes);
|
|
1424
1464
|
}
|
|
1425
1465
|
catch {
|
|
1426
1466
|
return null;
|