@vibe-commit/capture 0.1.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/package.json +50 -0
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the failure policy's decisions are remembered — CR-018.
|
|
3
|
+
*
|
|
4
|
+
* `policy.ts` decides; this module persists. The split is what keeps every
|
|
5
|
+
* classification and both backlog caps testable without a filesystem.
|
|
6
|
+
*
|
|
7
|
+
* ## The binding key — `CR-017d`
|
|
8
|
+
*
|
|
9
|
+
* `(repoKey, sessionId)`, and the repo half is REQUIRED. `CR-018` shipped this
|
|
10
|
+
* module keyed on the session alone, deliberately and one dimension short; D58
|
|
11
|
+
* names what that costs: *"The server key gains repo; the client offset state did
|
|
12
|
+
* not, so a `prefix_sha256` resync would duplicate repo 1's turns into repo 2."*
|
|
13
|
+
* The key lives in the path (`sessionStatePath`, which refuses to produce one
|
|
14
|
+
* without a repo), so there is no bucket for a caller that cannot say which repo
|
|
15
|
+
* it is in. `files` is untouched — it is already keyed by `FileKey`, which is the
|
|
16
|
+
* `file_key` of the server's four-column key.
|
|
17
|
+
*
|
|
18
|
+
* What `CR-018` needed, and the only reason this module exists a wave early, is
|
|
19
|
+
* that its three classes are *defined* in terms of an offset: `later` holds one,
|
|
20
|
+
* `never` advances one, and neither means anything without somewhere to keep it.
|
|
21
|
+
* `CR-016` left a bootstrap that re-sent from byte 0 every time and said,
|
|
22
|
+
* correctly, not to build a backlog policy on that. This is the alternative it was
|
|
23
|
+
* pointing at, kept as thin as the policy allows.
|
|
24
|
+
*
|
|
25
|
+
* ## Failure posture
|
|
26
|
+
*
|
|
27
|
+
* Every read failure yields the empty state. That direction is a RESYNC — the
|
|
28
|
+
* client re-sends from offset 0 and the server is idempotent on
|
|
29
|
+
* `(repo, session, offset)` — rather than a silent skip, which would be data
|
|
30
|
+
* loss. The one thing lost with it is a recorded `fatal` stop, and a revoked
|
|
31
|
+
* credential simply 401s again on the next attempt and re-stops.
|
|
32
|
+
*
|
|
33
|
+
* Two hooks racing in one session can lose an update to each other. The
|
|
34
|
+
* consequence is a re-send from a stale offset, which the server treats as a
|
|
35
|
+
* resync — so this is left unlocked rather than paying for a lockfile on the
|
|
36
|
+
* hook's wall-clock budget.
|
|
37
|
+
*/
|
|
38
|
+
import { type SessionKey } from "./paths.js";
|
|
39
|
+
import { type FileState } from "./policy.js";
|
|
40
|
+
/** The terminal stop set by the `fatal` class. */
|
|
41
|
+
export interface Stop {
|
|
42
|
+
readonly at: string;
|
|
43
|
+
/**
|
|
44
|
+
* Which credential was refused. Not the credential — a one-way digest of it
|
|
45
|
+
* (`credentialFingerprint`, `post.ts`).
|
|
46
|
+
*
|
|
47
|
+
* This is what makes "stopped until the user reconnects" hold WITHOUT this
|
|
48
|
+
* module knowing anything about `connect`: reconnecting mints a different
|
|
49
|
+
* credential, its fingerprint does not match, and capture resumes on its own.
|
|
50
|
+
* A stop keyed on the session alone would either wedge capture until the
|
|
51
|
+
* session ended or need a clearing call from a file this task does not own.
|
|
52
|
+
*/
|
|
53
|
+
readonly fingerprint: string;
|
|
54
|
+
}
|
|
55
|
+
export interface SessionState {
|
|
56
|
+
/** Monotonic per session, across the main transcript and every sub-agent's. */
|
|
57
|
+
readonly seq: number;
|
|
58
|
+
readonly stop: Stop | null;
|
|
59
|
+
/**
|
|
60
|
+
* Keyed by `FileKey` — `main`, or `agent-<id>` for a delegated transcript.
|
|
61
|
+
*
|
|
62
|
+
* `CR-124` (W6, D76) fills the `agent-*` half. The comment here credited
|
|
63
|
+
* `CR-020` for a wave; that was wrong — `CR-020`'s finding (D57 §DX7) is
|
|
64
|
+
* SessionEnd timing only and says nothing about sub-agent files, which is how
|
|
65
|
+
* the work stayed believed-owned while nothing produced it.
|
|
66
|
+
*
|
|
67
|
+
* The SHAPE needed no change to accommodate them, which is why this is a
|
|
68
|
+
* comment fix and not a migration.
|
|
69
|
+
*/
|
|
70
|
+
readonly files: Readonly<Record<string, FileState>>;
|
|
71
|
+
}
|
|
72
|
+
export declare const EMPTY_SESSION_STATE: SessionState;
|
|
73
|
+
export declare function loadSessionState(home: string, key: SessionKey): SessionState;
|
|
74
|
+
/** The most recent delivery for one repo, across every session and file. */
|
|
75
|
+
export interface LastSend {
|
|
76
|
+
/** Epoch ms. */
|
|
77
|
+
readonly at: number;
|
|
78
|
+
/** `seq` of the session that delivery belongs to. */
|
|
79
|
+
readonly seq: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* When did this REPO last deliver anything, and at what `seq` — `CR-021`.
|
|
83
|
+
*
|
|
84
|
+
* A scan, deliberately, and not a second index. `status` is interactive and has
|
|
85
|
+
* no `session_id`, so it cannot address a session file the way a hook can; it
|
|
86
|
+
* knows only the repo. The alternative — a per-repo "latest" pointer written on
|
|
87
|
+
* every successful send — is a second source of truth for a fact the session
|
|
88
|
+
* files already hold, and it would be the one that goes stale.
|
|
89
|
+
*
|
|
90
|
+
* The directory holds one small JSON per session for one repo, read once per
|
|
91
|
+
* `status` invocation on a human's keypress. There is no budget here worth
|
|
92
|
+
* trading correctness for.
|
|
93
|
+
*
|
|
94
|
+
* Null means nothing has ever been delivered for this repo. That is distinct
|
|
95
|
+
* from `at: 0`, which a pre-`CR-021` file parses to — both render as "nothing
|
|
96
|
+
* recorded yet" rather than as a date.
|
|
97
|
+
*/
|
|
98
|
+
export declare function lastSendForRepo(home: string, repoKey: string): LastSend | null;
|
|
99
|
+
/** Persist. Returns false on any failure — the caller must not throw from a hook. */
|
|
100
|
+
export declare function saveSessionState(home: string, key: SessionKey, next: SessionState): boolean;
|
|
101
|
+
export declare function fileState(session: SessionState, fileKey: string): FileState;
|
|
102
|
+
export declare function withFileState(session: SessionState, fileKey: string, next: FileState): SessionState;
|
|
103
|
+
/**
|
|
104
|
+
* Is this session stopped for THIS credential?
|
|
105
|
+
*
|
|
106
|
+
* A stop recorded against a different fingerprint does not apply: that is the
|
|
107
|
+
* user having reconnected.
|
|
108
|
+
*/
|
|
109
|
+
export declare function isStopped(session: SessionState, fingerprint: string): boolean;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the failure policy's decisions are remembered — CR-018.
|
|
3
|
+
*
|
|
4
|
+
* `policy.ts` decides; this module persists. The split is what keeps every
|
|
5
|
+
* classification and both backlog caps testable without a filesystem.
|
|
6
|
+
*
|
|
7
|
+
* ## The binding key — `CR-017d`
|
|
8
|
+
*
|
|
9
|
+
* `(repoKey, sessionId)`, and the repo half is REQUIRED. `CR-018` shipped this
|
|
10
|
+
* module keyed on the session alone, deliberately and one dimension short; D58
|
|
11
|
+
* names what that costs: *"The server key gains repo; the client offset state did
|
|
12
|
+
* not, so a `prefix_sha256` resync would duplicate repo 1's turns into repo 2."*
|
|
13
|
+
* The key lives in the path (`sessionStatePath`, which refuses to produce one
|
|
14
|
+
* without a repo), so there is no bucket for a caller that cannot say which repo
|
|
15
|
+
* it is in. `files` is untouched — it is already keyed by `FileKey`, which is the
|
|
16
|
+
* `file_key` of the server's four-column key.
|
|
17
|
+
*
|
|
18
|
+
* What `CR-018` needed, and the only reason this module exists a wave early, is
|
|
19
|
+
* that its three classes are *defined* in terms of an offset: `later` holds one,
|
|
20
|
+
* `never` advances one, and neither means anything without somewhere to keep it.
|
|
21
|
+
* `CR-016` left a bootstrap that re-sent from byte 0 every time and said,
|
|
22
|
+
* correctly, not to build a backlog policy on that. This is the alternative it was
|
|
23
|
+
* pointing at, kept as thin as the policy allows.
|
|
24
|
+
*
|
|
25
|
+
* ## Failure posture
|
|
26
|
+
*
|
|
27
|
+
* Every read failure yields the empty state. That direction is a RESYNC — the
|
|
28
|
+
* client re-sends from offset 0 and the server is idempotent on
|
|
29
|
+
* `(repo, session, offset)` — rather than a silent skip, which would be data
|
|
30
|
+
* loss. The one thing lost with it is a recorded `fatal` stop, and a revoked
|
|
31
|
+
* credential simply 401s again on the next attempt and re-stops.
|
|
32
|
+
*
|
|
33
|
+
* Two hooks racing in one session can lose an update to each other. The
|
|
34
|
+
* consequence is a re-send from a stale offset, which the server treats as a
|
|
35
|
+
* resync — so this is left unlocked rather than paying for a lockfile on the
|
|
36
|
+
* hook's wall-clock budget.
|
|
37
|
+
*/
|
|
38
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { repoSessionsDir, sessionStatePath } from "./paths.js";
|
|
41
|
+
import { EMPTY_FILE_STATE } from "./policy.js";
|
|
42
|
+
export const EMPTY_SESSION_STATE = { seq: 0, stop: null, files: {} };
|
|
43
|
+
export function loadSessionState(home, key) {
|
|
44
|
+
const path = sessionStatePath(home, key);
|
|
45
|
+
// No repo, no key, no read. Falling back to a session-only file here is the
|
|
46
|
+
// whole of D58: it is the one path on which two repos share an offset ledger.
|
|
47
|
+
if (path === null)
|
|
48
|
+
return EMPTY_SESSION_STATE;
|
|
49
|
+
return readStateAt(path);
|
|
50
|
+
}
|
|
51
|
+
/** One state file, or the empty state. Every failure yields empty — a RESYNC. */
|
|
52
|
+
function readStateAt(path) {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = readFileSync(path, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return EMPTY_SESSION_STATE;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return parseSessionState(JSON.parse(raw));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return EMPTY_SESSION_STATE;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* When did this REPO last deliver anything, and at what `seq` — `CR-021`.
|
|
69
|
+
*
|
|
70
|
+
* A scan, deliberately, and not a second index. `status` is interactive and has
|
|
71
|
+
* no `session_id`, so it cannot address a session file the way a hook can; it
|
|
72
|
+
* knows only the repo. The alternative — a per-repo "latest" pointer written on
|
|
73
|
+
* every successful send — is a second source of truth for a fact the session
|
|
74
|
+
* files already hold, and it would be the one that goes stale.
|
|
75
|
+
*
|
|
76
|
+
* The directory holds one small JSON per session for one repo, read once per
|
|
77
|
+
* `status` invocation on a human's keypress. There is no budget here worth
|
|
78
|
+
* trading correctness for.
|
|
79
|
+
*
|
|
80
|
+
* Null means nothing has ever been delivered for this repo. That is distinct
|
|
81
|
+
* from `at: 0`, which a pre-`CR-021` file parses to — both render as "nothing
|
|
82
|
+
* recorded yet" rather than as a date.
|
|
83
|
+
*/
|
|
84
|
+
export function lastSendForRepo(home, repoKey) {
|
|
85
|
+
const dir = repoSessionsDir(home, repoKey);
|
|
86
|
+
if (dir === null)
|
|
87
|
+
return null;
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(dir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// No directory means nothing was ever written for this repo.
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
let best = null;
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.endsWith(".json"))
|
|
99
|
+
continue;
|
|
100
|
+
const session = readStateAt(join(dir, entry));
|
|
101
|
+
for (const file of Object.values(session.files)) {
|
|
102
|
+
if (file.lastSentAt <= 0)
|
|
103
|
+
continue;
|
|
104
|
+
if (best === null || file.lastSentAt > best.at) {
|
|
105
|
+
best = { at: file.lastSentAt, seq: session.seq };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
}
|
|
111
|
+
/** Persist. Returns false on any failure — the caller must not throw from a hook. */
|
|
112
|
+
export function saveSessionState(home, key, next) {
|
|
113
|
+
const path = sessionStatePath(home, key);
|
|
114
|
+
// Refusing to write is safe in the direction this module already fails: the
|
|
115
|
+
// next invocation resyncs from 0. Writing under a placeholder key is not.
|
|
116
|
+
if (path === null)
|
|
117
|
+
return false;
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
120
|
+
writeFileSync(path, `${JSON.stringify(next)}\n`, { mode: 0o600 });
|
|
121
|
+
// `writeFileSync`'s `mode` is ignored when the file already exists.
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function fileState(session, fileKey) {
|
|
130
|
+
return session.files[fileKey] ?? EMPTY_FILE_STATE;
|
|
131
|
+
}
|
|
132
|
+
export function withFileState(session, fileKey, next) {
|
|
133
|
+
return { ...session, files: { ...session.files, [fileKey]: next } };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Is this session stopped for THIS credential?
|
|
137
|
+
*
|
|
138
|
+
* A stop recorded against a different fingerprint does not apply: that is the
|
|
139
|
+
* user having reconnected.
|
|
140
|
+
*/
|
|
141
|
+
export function isStopped(session, fingerprint) {
|
|
142
|
+
return session.stop !== null && session.stop.fingerprint === fingerprint;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read back a persisted state defensively.
|
|
146
|
+
*
|
|
147
|
+
* Hand-written rather than trusted, for the same reason `consent.ts` validates
|
|
148
|
+
* its allow list: this file lives in the user's home, is edited by nothing but
|
|
149
|
+
* us, and would still be believed if something else wrote nonsense into it. A
|
|
150
|
+
* bogus `sentOffset` would silently skip real turns.
|
|
151
|
+
*/
|
|
152
|
+
function parseSessionState(parsed) {
|
|
153
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
154
|
+
return EMPTY_SESSION_STATE;
|
|
155
|
+
}
|
|
156
|
+
const o = parsed;
|
|
157
|
+
const files = {};
|
|
158
|
+
const rawFiles = o.files;
|
|
159
|
+
if (rawFiles !== null && typeof rawFiles === "object" && !Array.isArray(rawFiles)) {
|
|
160
|
+
for (const [key, value] of Object.entries(rawFiles)) {
|
|
161
|
+
const state = parseFileState(value);
|
|
162
|
+
if (state !== null)
|
|
163
|
+
files[key] = state;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { seq: nonNegative(o.seq), stop: parseStop(o.stop), files };
|
|
167
|
+
}
|
|
168
|
+
function parseStop(value) {
|
|
169
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
170
|
+
return null;
|
|
171
|
+
const o = value;
|
|
172
|
+
if (typeof o.at !== "string" || typeof o.fingerprint !== "string")
|
|
173
|
+
return null;
|
|
174
|
+
if (o.fingerprint === "")
|
|
175
|
+
return null;
|
|
176
|
+
return { at: o.at, fingerprint: o.fingerprint };
|
|
177
|
+
}
|
|
178
|
+
function parseFileState(value) {
|
|
179
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
180
|
+
return null;
|
|
181
|
+
const o = value;
|
|
182
|
+
const sentOffset = nonNegative(o.sentOffset);
|
|
183
|
+
const backlog = [];
|
|
184
|
+
if (Array.isArray(o.backlog)) {
|
|
185
|
+
let cursor = sentOffset;
|
|
186
|
+
for (const entry of o.backlog) {
|
|
187
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
188
|
+
continue;
|
|
189
|
+
const e = entry;
|
|
190
|
+
const from = nonNegative(e.from);
|
|
191
|
+
const to = nonNegative(e.to);
|
|
192
|
+
const at = nonNegative(e.at);
|
|
193
|
+
// Contiguity is an invariant of the writer, so a file that breaks it has
|
|
194
|
+
// been tampered with or half-written. Stop at the break rather than trust
|
|
195
|
+
// the rest: a non-contiguous backlog makes `pendingBytes` a fiction.
|
|
196
|
+
if (from !== cursor || to <= from)
|
|
197
|
+
break;
|
|
198
|
+
backlog.push({ from, to, at });
|
|
199
|
+
cursor = to;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
sentOffset,
|
|
204
|
+
backlog,
|
|
205
|
+
gapBytes: nonNegative(o.gapBytes),
|
|
206
|
+
gapCount: nonNegative(o.gapCount),
|
|
207
|
+
// Absent in any file written before `CR-021`. `nonNegative` yields 0, which
|
|
208
|
+
// this codebase reads as "never sent" rather than as 1 January 1970.
|
|
209
|
+
lastSentAt: nonNegative(o.lastSentAt),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function nonNegative(value) {
|
|
213
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
214
|
+
? Math.floor(value)
|
|
215
|
+
: 0;
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.js","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `systemMessage` channel — D57 (plan §DX3), style guide §10.1.
|
|
3
|
+
*
|
|
4
|
+
* The only sanctioned way a hook speaks to a human. It renders to the user
|
|
5
|
+
* **without** entering the agent's context, which is why it is safe and why it
|
|
6
|
+
* must stay short.
|
|
7
|
+
*
|
|
8
|
+
* FOUR states — `CR-023d` added the fourth, and it inverts an assumption the
|
|
9
|
+
* other three shared.
|
|
10
|
+
*
|
|
11
|
+
* **"stdout empty on the happy path" is no longer universally true.** Every
|
|
12
|
+
* populated-stdout case before `capture_confirmed` was a FAILURE signal, so a
|
|
13
|
+
* consumer could read "any `systemMessage`" as "something is wrong". That
|
|
14
|
+
* consumer is now wrong. This is an inversion, not a shape-preserving addition,
|
|
15
|
+
* which is why it is called out here rather than left to the type.
|
|
16
|
+
*
|
|
17
|
+
* **The fourth state is keyed per `(repo, day)`, the other three per session.**
|
|
18
|
+
* `PreCompact` mints a new `session_id` mid-task, so a per-session key would fire
|
|
19
|
+
* the one positive message twice in an afternoon and read as a bug; a key with no
|
|
20
|
+
* day at all would fire once ever, which under-communicates on a daily tool. See
|
|
21
|
+
* `repoDayNoticePath`.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ Two hard constraints from DESIGN.md §13.7/§13.9:
|
|
24
|
+
* - stdout carries **exactly one** well-formed JSON object, or nothing.
|
|
25
|
+
* **If construction fails for ANY reason → empty stdout. The fallback is
|
|
26
|
+
* always silence.**
|
|
27
|
+
* - **No SGR escapes inside the string.** It is a JSON field rendered by Claude
|
|
28
|
+
* Code's own UI; embedded escapes are undefined behaviour.
|
|
29
|
+
*/
|
|
30
|
+
export type NoticeState = "not_connected" | "credential_revoked" | "unsupported_runtime"
|
|
31
|
+
/** The only POSITIVE state, and the only one not keyed on the session. */
|
|
32
|
+
| "capture_confirmed";
|
|
33
|
+
export interface NoticeContext {
|
|
34
|
+
readonly home: string;
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
/** `process.versions.node`, for the unsupported-runtime state. */
|
|
37
|
+
readonly nodeVersion: string;
|
|
38
|
+
/**
|
|
39
|
+
* `resolveProjectKey()`'s git toplevel — half of `capture_confirmed`'s key.
|
|
40
|
+
*
|
|
41
|
+
* Null for the three session-keyed states, which is not a placeholder: two of
|
|
42
|
+
* them can fire BEFORE the consent gate has resolved a project at all
|
|
43
|
+
* (`unsupported_runtime` fires before anything else runs). `capture_confirmed`
|
|
44
|
+
* cannot fire without one and refuses rather than falling back — the same
|
|
45
|
+
* discipline `CR-017d` applied to the state key, for the same reason.
|
|
46
|
+
*/
|
|
47
|
+
readonly repoKey: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Injected so the day boundary is reachable from a test. Following
|
|
50
|
+
* `CommandContext.now`'s precedent; a module that read the clock directly
|
|
51
|
+
* could only be tested by waiting for midnight.
|
|
52
|
+
*/
|
|
53
|
+
readonly now: () => Date;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The UTC calendar day, `YYYY-MM-DD`.
|
|
57
|
+
*
|
|
58
|
+
* UTC and not local: a local key silently depends on the machine's timezone and
|
|
59
|
+
* changes twice a year at a DST boundary, which would fire the message a second
|
|
60
|
+
* time on one day in spring and skip it on one in autumn. It also matches how
|
|
61
|
+
* this file already timestamps (`new Date().toISOString()`).
|
|
62
|
+
*/
|
|
63
|
+
export declare function utcDay(now: Date): string;
|
|
64
|
+
/** The one-line text for a state. Never contains an escape sequence. */
|
|
65
|
+
export declare function noticeText(state: NoticeState, nodeVersion: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Where this state's once-per-key marker lives, or null if it cannot have one.
|
|
68
|
+
*
|
|
69
|
+
* The whole difference between the four states is here: three are keyed on the
|
|
70
|
+
* session, and `capture_confirmed` is keyed on `(repo, day)`.
|
|
71
|
+
*
|
|
72
|
+
* Null when `capture_confirmed` has no repo. **Refuse, do not guess** — a
|
|
73
|
+
* fallback key would be shared by every repo on the machine, so the first repo
|
|
74
|
+
* to capture today would silence all the others. Null becomes silence, which is
|
|
75
|
+
* this channel's fallback everywhere else too.
|
|
76
|
+
*/
|
|
77
|
+
export declare function noticeClaimPath(ctx: NoticeContext, state: NoticeState): string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Claim the once-per-KEY slot for `state`.
|
|
80
|
+
*
|
|
81
|
+
* `wx` is doing real work here: it creates-or-fails atomically, so two hooks
|
|
82
|
+
* firing concurrently under the same key cannot both win. A read-then-write
|
|
83
|
+
* check would let both through and print the message twice. That mattered for
|
|
84
|
+
* one session; it matters more now, because `capture_confirmed`'s key spans a
|
|
85
|
+
* whole day and therefore many more concurrent hooks.
|
|
86
|
+
*
|
|
87
|
+
* Returns false on ANY failure, including an unwritable home. A message we cannot
|
|
88
|
+
* record having sent is a message we do not send — repeating it every turn is
|
|
89
|
+
* worse than losing it once.
|
|
90
|
+
*/
|
|
91
|
+
export declare function claimSessionNotice(ctx: NoticeContext, state: NoticeState): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Render the hook's stdout payload for a state, or null.
|
|
94
|
+
*
|
|
95
|
+
* Null means "write nothing", and every failure resolves to null. The caller
|
|
96
|
+
* writes the string verbatim and adds a trailing newline; it never builds JSON
|
|
97
|
+
* itself, so there is exactly one place this contract can be broken.
|
|
98
|
+
*/
|
|
99
|
+
export declare function renderNotice(ctx: NoticeContext, state: NoticeState): string | null;
|
|
100
|
+
/** True if `text` holds any C0 control character (including ESC) or DEL. */
|
|
101
|
+
export declare function hasControlCharacter(text: string): boolean;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `systemMessage` channel — D57 (plan §DX3), style guide §10.1.
|
|
3
|
+
*
|
|
4
|
+
* The only sanctioned way a hook speaks to a human. It renders to the user
|
|
5
|
+
* **without** entering the agent's context, which is why it is safe and why it
|
|
6
|
+
* must stay short.
|
|
7
|
+
*
|
|
8
|
+
* FOUR states — `CR-023d` added the fourth, and it inverts an assumption the
|
|
9
|
+
* other three shared.
|
|
10
|
+
*
|
|
11
|
+
* **"stdout empty on the happy path" is no longer universally true.** Every
|
|
12
|
+
* populated-stdout case before `capture_confirmed` was a FAILURE signal, so a
|
|
13
|
+
* consumer could read "any `systemMessage`" as "something is wrong". That
|
|
14
|
+
* consumer is now wrong. This is an inversion, not a shape-preserving addition,
|
|
15
|
+
* which is why it is called out here rather than left to the type.
|
|
16
|
+
*
|
|
17
|
+
* **The fourth state is keyed per `(repo, day)`, the other three per session.**
|
|
18
|
+
* `PreCompact` mints a new `session_id` mid-task, so a per-session key would fire
|
|
19
|
+
* the one positive message twice in an afternoon and read as a bug; a key with no
|
|
20
|
+
* day at all would fire once ever, which under-communicates on a daily tool. See
|
|
21
|
+
* `repoDayNoticePath`.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ Two hard constraints from DESIGN.md §13.7/§13.9:
|
|
24
|
+
* - stdout carries **exactly one** well-formed JSON object, or nothing.
|
|
25
|
+
* **If construction fails for ANY reason → empty stdout. The fallback is
|
|
26
|
+
* always silence.**
|
|
27
|
+
* - **No SGR escapes inside the string.** It is a JSON field rendered by Claude
|
|
28
|
+
* Code's own UI; embedded escapes are undefined behaviour.
|
|
29
|
+
*/
|
|
30
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { dirname } from "node:path";
|
|
32
|
+
import { SYSTEM_MESSAGE, URLS } from "./copy/index.js";
|
|
33
|
+
import { repoDayNoticePath, sessionNoticePath } from "./paths.js";
|
|
34
|
+
import { NODE_FLOOR_TEXT } from "./runtime.js";
|
|
35
|
+
/**
|
|
36
|
+
* The UTC calendar day, `YYYY-MM-DD`.
|
|
37
|
+
*
|
|
38
|
+
* UTC and not local: a local key silently depends on the machine's timezone and
|
|
39
|
+
* changes twice a year at a DST boundary, which would fire the message a second
|
|
40
|
+
* time on one day in spring and skip it on one in autumn. It also matches how
|
|
41
|
+
* this file already timestamps (`new Date().toISOString()`).
|
|
42
|
+
*/
|
|
43
|
+
export function utcDay(now) {
|
|
44
|
+
return now.toISOString().slice(0, 10);
|
|
45
|
+
}
|
|
46
|
+
/** The one-line text for a state. Never contains an escape sequence. */
|
|
47
|
+
export function noticeText(state, nodeVersion) {
|
|
48
|
+
switch (state) {
|
|
49
|
+
case "not_connected":
|
|
50
|
+
return SYSTEM_MESSAGE.notConnected;
|
|
51
|
+
case "credential_revoked":
|
|
52
|
+
return SYSTEM_MESSAGE.credentialRevoked;
|
|
53
|
+
case "unsupported_runtime":
|
|
54
|
+
return SYSTEM_MESSAGE.unsupportedRuntime(NODE_FLOOR_TEXT, nodeVersion);
|
|
55
|
+
case "capture_confirmed":
|
|
56
|
+
return SYSTEM_MESSAGE.captureConfirmed(URLS.dashboard);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Where this state's once-per-key marker lives, or null if it cannot have one.
|
|
61
|
+
*
|
|
62
|
+
* The whole difference between the four states is here: three are keyed on the
|
|
63
|
+
* session, and `capture_confirmed` is keyed on `(repo, day)`.
|
|
64
|
+
*
|
|
65
|
+
* Null when `capture_confirmed` has no repo. **Refuse, do not guess** — a
|
|
66
|
+
* fallback key would be shared by every repo on the machine, so the first repo
|
|
67
|
+
* to capture today would silence all the others. Null becomes silence, which is
|
|
68
|
+
* this channel's fallback everywhere else too.
|
|
69
|
+
*/
|
|
70
|
+
export function noticeClaimPath(ctx, state) {
|
|
71
|
+
if (state !== "capture_confirmed") {
|
|
72
|
+
return sessionNoticePath(ctx.home, ctx.sessionId, state);
|
|
73
|
+
}
|
|
74
|
+
if (ctx.repoKey === null || ctx.repoKey === "")
|
|
75
|
+
return null;
|
|
76
|
+
return repoDayNoticePath(ctx.home, ctx.repoKey, utcDay(ctx.now()), state);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Claim the once-per-KEY slot for `state`.
|
|
80
|
+
*
|
|
81
|
+
* `wx` is doing real work here: it creates-or-fails atomically, so two hooks
|
|
82
|
+
* firing concurrently under the same key cannot both win. A read-then-write
|
|
83
|
+
* check would let both through and print the message twice. That mattered for
|
|
84
|
+
* one session; it matters more now, because `capture_confirmed`'s key spans a
|
|
85
|
+
* whole day and therefore many more concurrent hooks.
|
|
86
|
+
*
|
|
87
|
+
* Returns false on ANY failure, including an unwritable home. A message we cannot
|
|
88
|
+
* record having sent is a message we do not send — repeating it every turn is
|
|
89
|
+
* worse than losing it once.
|
|
90
|
+
*/
|
|
91
|
+
export function claimSessionNotice(ctx, state) {
|
|
92
|
+
const path = noticeClaimPath(ctx, state);
|
|
93
|
+
if (path === null)
|
|
94
|
+
return false;
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
97
|
+
writeFileSync(path, `${JSON.stringify({ at: new Date().toISOString() })}\n`, {
|
|
98
|
+
flag: "wx",
|
|
99
|
+
mode: 0o600,
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Render the hook's stdout payload for a state, or null.
|
|
109
|
+
*
|
|
110
|
+
* Null means "write nothing", and every failure resolves to null. The caller
|
|
111
|
+
* writes the string verbatim and adds a trailing newline; it never builds JSON
|
|
112
|
+
* itself, so there is exactly one place this contract can be broken.
|
|
113
|
+
*/
|
|
114
|
+
export function renderNotice(ctx, state) {
|
|
115
|
+
try {
|
|
116
|
+
const text = noticeText(state, ctx.nodeVersion);
|
|
117
|
+
// A message carrying an escape sequence or a newline is undefined behaviour
|
|
118
|
+
// in Claude Code's renderer, so it is dropped rather than sent — silence is
|
|
119
|
+
// the fallback. Tested by char code, not by a regex holding a literal
|
|
120
|
+
// control character: DESIGN.md §13.2 puts every escape sequence in one
|
|
121
|
+
// module, and a literal one here would be both a violation and unreadable.
|
|
122
|
+
if (hasControlCharacter(text))
|
|
123
|
+
return null;
|
|
124
|
+
if (!claimSessionNotice(ctx, state))
|
|
125
|
+
return null;
|
|
126
|
+
return JSON.stringify({ systemMessage: text });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** True if `text` holds any C0 control character (including ESC) or DEL. */
|
|
133
|
+
export function hasControlCharacter(text) {
|
|
134
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
135
|
+
const code = text.charCodeAt(i);
|
|
136
|
+
if (code < 0x20 || code === 0x7f)
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=system_message.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"system_message.js","sourceRoot":"","sources":["../src/system_message.ts"],"names":[],"mappings":"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"}
|