vouchington-tooling 0.47.0 → 0.49.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 +19 -1
- package/dist/agent-blackboard/client.d.mts +3 -0
- package/dist/agent-blackboard/client.mjs +4 -1
- package/dist/agent-blackboard/index.d.mts +3 -1
- package/dist/agent-blackboard/index.mjs +2 -1
- package/dist/agent-blackboard/session-start.d.mts +28 -0
- package/dist/agent-blackboard/session-start.mjs +102 -0
- package/dist/agent-harness-identity/index.d.mts +3 -25
- package/dist/agent-harness-identity/index.mjs +3 -28
- package/dist/cli/commands/agent-blackboard-session-start.d.mts +5 -0
- package/dist/cli/commands/agent-blackboard-session-start.mjs +60 -0
- package/dist/cli/commands/agent-blackboard-snapshot.d.mts +1 -0
- package/dist/cli/commands/agent-blackboard-snapshot.mjs +29 -0
- package/dist/cli/commands/agent-blackboard.mjs +5 -29
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -2
- package/dist/mcp-server/pr-agent.mjs +3 -3
- package/dist/retrospective-transcript/as-array.d.mts +1 -0
- package/dist/retrospective-transcript/as-array.mjs +3 -0
- package/dist/retrospective-transcript/command-prefix.d.mts +1 -0
- package/dist/retrospective-transcript/command-prefix.mjs +124 -0
- package/dist/retrospective-transcript/command-segments.d.mts +2 -0
- package/dist/retrospective-transcript/command-segments.mjs +54 -0
- package/dist/retrospective-transcript/index.d.mts +5 -0
- package/dist/retrospective-transcript/index.mjs +5 -0
- package/dist/retrospective-transcript/resolve-transcript-file.d.mts +10 -0
- package/dist/retrospective-transcript/resolve-transcript-file.mjs +10 -0
- package/dist/retrospective-transcript/transcript-lines.d.mts +22 -0
- package/dist/retrospective-transcript/transcript-lines.mjs +74 -0
- package/package.json +2 -6
- package/scripts/gha/ghcr-main-history.sh +90 -0
- package/scripts/gha/ghcr-package-retention.sh +56 -7
package/README.md
CHANGED
|
@@ -105,6 +105,20 @@ python3 extracted/package/scripts/allocate-browser-safe-ports.py <count> [--poli
|
|
|
105
105
|
straddles the cutoff. CI jobs without an install should fetch the tarball instead. Tests enforce
|
|
106
106
|
that the script stays stdlib-only and reads only its two sibling JSON files.
|
|
107
107
|
|
|
108
|
+
### Configuring `ghcr-package-retention`
|
|
109
|
+
|
|
110
|
+
Packages come from the arguments, or from `GHCR_PACKAGES` (space separated) when none are given.
|
|
111
|
+
`GHCR_OWNER_TYPE` (`user` default, or `org` with `GHCR_OWNER`) picks the API root.
|
|
112
|
+
|
|
113
|
+
- Default strategy: keep the newest `KEEP_MIN` (10) versions that carry no tag starting with
|
|
114
|
+
`PROTECTED_TAG_PREFIX` (`latest`) or containing `PROTECTED_TAG_CONTAINS` (`buildcache`). It
|
|
115
|
+
deletes unless `--dry-run` is passed.
|
|
116
|
+
- `--main-history`: for images tagged `sha-<40 hex>`. Run from a full-history checkout. Deletes
|
|
117
|
+
images past the newest `GHCR_KEEP_MAIN` (30) commits reachable from `HEAD`, unreachable images
|
|
118
|
+
idle for `GHCR_UNMERGED_MIN_AGE_DAYS` (7) days, and attestation/platform attachments that
|
|
119
|
+
outlive every surviving image. A package that does not exist (HTTP 404) is skipped. This mode
|
|
120
|
+
is a dry run unless `--apply` is passed.
|
|
121
|
+
|
|
108
122
|
For persistent `pnpm-install`, v5 metadata tracks structural inputs separately from the
|
|
109
123
|
`--install-scripts` policy. A warm scripts-enabled tree can therefore toggle
|
|
110
124
|
`true → false → true` without forced reconciliation; a tree first installed with scripts disabled
|
|
@@ -165,7 +179,11 @@ pass `--root`, `--workflow-directory`, or `--action-directory` for consumer-owne
|
|
|
165
179
|
Claude-compatible transcript when `CURSOR_SESSION_ID` is set, and Grok's `updates.jsonl` session
|
|
166
180
|
layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discovery at a nondefault
|
|
167
181
|
Grok session root. Without `--session-id`, it reads those session identities from the host
|
|
168
|
-
environment.
|
|
182
|
+
environment. `resolveTranscriptFile` is the canonical resolver: it lowercases and UUID-validates
|
|
183
|
+
session ids, honors `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME`, and errors when more than one
|
|
184
|
+
transcript matches. The subpath also exports `normalizeCommandPrefix`, `splitCommandSegments`,
|
|
185
|
+
`isPackageRunner`, `asRecord`, `asArray` and `openTranscriptLines(path, { maxRecordBytes })`, which
|
|
186
|
+
streams lines (2 MiB per record by default) and throws from the iterable on an oversized record.
|
|
169
187
|
|
|
170
188
|
`retrospective-facts` keeps local Git evidence separate from GitHub PR data. `Commits ahead of
|
|
171
189
|
origin/main` is populated only from a local ancestry range; GitHub responses instead populate
|
|
@@ -45,6 +45,9 @@ export type BlackboardClientDependencies = {
|
|
|
45
45
|
resolveFrom?: string | URL;
|
|
46
46
|
};
|
|
47
47
|
export declare function resolveBlackboardConnection(env?: NodeJS.ProcessEnv): BlackboardConnection;
|
|
48
|
+
/** The upstream client package could not be resolved: a workspace-setup problem, not an outage. */
|
|
49
|
+
export declare class BlackboardNotInstalledError extends Error {
|
|
50
|
+
}
|
|
48
51
|
export declare function loadClient(dependencies?: BlackboardClientDependencies): Promise<BlackboardClientModule>;
|
|
49
52
|
export declare function isMissingModuleError(error: unknown): boolean;
|
|
50
53
|
export {};
|
|
@@ -18,6 +18,9 @@ export function resolveBlackboardConnection(env = process.env) {
|
|
|
18
18
|
throw new Error('AGENT_BLACKBOARD_TOKEN is not set');
|
|
19
19
|
return { baseUrl, token, readRetry: {} };
|
|
20
20
|
}
|
|
21
|
+
/** The upstream client package could not be resolved: a workspace-setup problem, not an outage. */
|
|
22
|
+
export class BlackboardNotInstalledError extends Error {
|
|
23
|
+
}
|
|
21
24
|
export async function loadClient(dependencies = {}) {
|
|
22
25
|
const loader = dependencies.loadClient ?? (() => defaultClientLoader(dependencies.resolveFrom));
|
|
23
26
|
try {
|
|
@@ -25,7 +28,7 @@ export async function loadClient(dependencies = {}) {
|
|
|
25
28
|
}
|
|
26
29
|
catch (error) {
|
|
27
30
|
if (isMissingModuleError(error))
|
|
28
|
-
throw new
|
|
31
|
+
throw new BlackboardNotInstalledError('agent-blackboard is not installed; install it alongside vouchington-tooling to use this integration', { cause: error });
|
|
29
32
|
throw error;
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -3,6 +3,8 @@ import { type JournalInput } from './journal.mts';
|
|
|
3
3
|
import { type BlackboardClientDependencies } from './client.mts';
|
|
4
4
|
export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mts';
|
|
5
5
|
export { assertSessionId } from './session-id.mts';
|
|
6
|
+
export { payloadSessionId, probeAdvisory, sessionStartContext, sessionStartOutput, } from './session-start.mts';
|
|
7
|
+
export type { SessionStartOptions } from './session-start.mts';
|
|
6
8
|
export { appendJournalMarkdown } from './journal.mts';
|
|
7
9
|
export type { JournalInput } from './journal.mts';
|
|
8
10
|
export { archiveBlackboardSession, ensureBlackboardSession, exportSnapshot, } from './session-tools.mts';
|
|
@@ -14,7 +16,7 @@ export declare function appendJournal(input: JournalInput & {
|
|
|
14
16
|
}): Promise<FeedbackDeliveryResult>;
|
|
15
17
|
export declare function readJournal(sessionId: string, env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<unknown[]>;
|
|
16
18
|
export declare function formatJournalEntries(sessionId: string, entries: unknown[]): string;
|
|
17
|
-
export { resolveBlackboardConnection } from './client.mts';
|
|
19
|
+
export { BlackboardNotInstalledError, resolveBlackboardConnection } from './client.mts';
|
|
18
20
|
export type { BlackboardConnection, BlackboardClientModule, BlackboardClientDependencies, } from './client.mts';
|
|
19
21
|
export type * from './feedback-types.mts';
|
|
20
22
|
export { createFeedbackEnvelope, validateFeedbackEnvelope } from './feedback-codec.mts';
|
|
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { assertSessionId } from "./session-id.mjs";
|
|
5
5
|
export { cleanupSnapshotPartitions, partitionSnapshot } from "./snapshot.mjs";
|
|
6
6
|
export { assertSessionId } from "./session-id.mjs";
|
|
7
|
+
export { payloadSessionId, probeAdvisory, sessionStartContext, sessionStartOutput, } from "./session-start.mjs";
|
|
7
8
|
export { appendJournalMarkdown } from "./journal.mjs";
|
|
8
9
|
export { archiveBlackboardSession, ensureBlackboardSession, exportSnapshot, } from "./session-tools.mjs";
|
|
9
10
|
export async function probeBlackboard(env, dependencies) {
|
|
@@ -58,7 +59,7 @@ export function formatJournalEntries(sessionId, entries) {
|
|
|
58
59
|
.map(({ createdAt, markdown }) => `## ${createdAt}\n\n${markdown}`)
|
|
59
60
|
.join('\n\n');
|
|
60
61
|
}
|
|
61
|
-
export { resolveBlackboardConnection } from "./client.mjs";
|
|
62
|
+
export { BlackboardNotInstalledError, resolveBlackboardConnection } from "./client.mjs";
|
|
62
63
|
export { createFeedbackEnvelope, validateFeedbackEnvelope } from "./feedback-codec.mjs";
|
|
63
64
|
export { writeFeedback, verifyFreshFeedback, flushFeedbackOutbox, FeedbackDeliveryError, } from "./feedback-delivery.mjs";
|
|
64
65
|
export { feedbackOutboxStatus, readFeedbackOutbox } from "./feedback-outbox.mjs";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type BlackboardClientDependencies } from './client.mts';
|
|
2
|
+
export type SessionStartOptions = {
|
|
3
|
+
/** The parsed hook payload; unparseable stdin is `{}`. */
|
|
4
|
+
payload: Record<string, unknown>;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
/** Environment variable that, when `1`, skips the probe only. Defaults to CHECK_BLACKBOARD_SKIP. */
|
|
7
|
+
skipEnv?: string;
|
|
8
|
+
/** Appended to the workspace-setup advisory: how this repository repairs its install. */
|
|
9
|
+
setupHint?: string;
|
|
10
|
+
/** Appended to the outage advisory: where this repository documents the deployment. */
|
|
11
|
+
docsPath?: string;
|
|
12
|
+
/** How this repository restores a missing journal server; appended to the sandbox advisory. */
|
|
13
|
+
restoreHint?: string;
|
|
14
|
+
/** Deadline for the probe in milliseconds. Defaults to 5000. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Called when the probe deadline passes, so a caller can force exit past a hung request. */
|
|
17
|
+
onTimeout?: () => void;
|
|
18
|
+
dependencies?: BlackboardClientDependencies;
|
|
19
|
+
/** Defaults to listing one session with the installed client. */
|
|
20
|
+
probe?: (env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies) => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
/** The first valid, URL-safe session id in the payload, or undefined. */
|
|
23
|
+
export declare function payloadSessionId(payload: Record<string, unknown>): string | undefined;
|
|
24
|
+
/** The advisory line for the deployment probe, or undefined when it is skipped or healthy. */
|
|
25
|
+
export declare function probeAdvisory(options: SessionStartOptions): Promise<string | undefined>;
|
|
26
|
+
/** The `additionalContext` for a SessionStart hook, or undefined when there is nothing to say. */
|
|
27
|
+
export declare function sessionStartContext(options: SessionStartOptions): Promise<string | undefined>;
|
|
28
|
+
export declare function sessionStartOutput(context: string): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BlackboardNotInstalledError, loadClient, resolveBlackboardConnection, } from "./client.mjs";
|
|
2
|
+
import { assertSessionId } from "./session-id.mjs";
|
|
3
|
+
const PAYLOAD_SESSION_KEYS = ['session_id', 'sessionId', 'conversation_id'];
|
|
4
|
+
/** The first valid, URL-safe session id in the payload, or undefined. */
|
|
5
|
+
export function payloadSessionId(payload) {
|
|
6
|
+
for (const key of PAYLOAD_SESSION_KEYS) {
|
|
7
|
+
const value = payload[key];
|
|
8
|
+
if (typeof value !== 'string')
|
|
9
|
+
continue;
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
if (!trimmed || trimmed.length > 256)
|
|
12
|
+
continue;
|
|
13
|
+
try {
|
|
14
|
+
assertSessionId(trimmed);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
return trimmed;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
async function listOneSession(env, dependencies) {
|
|
24
|
+
const { Sessions } = await loadClient(dependencies);
|
|
25
|
+
await new Sessions(resolveBlackboardConnection(env)).list({ limit: 1 });
|
|
26
|
+
}
|
|
27
|
+
/** Rejects when the promise has not settled by the deadline; the timer never holds the process. */
|
|
28
|
+
async function withDeadline(work, timeoutMs, onTimeout) {
|
|
29
|
+
let timer;
|
|
30
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
31
|
+
timer = setTimeout(() => {
|
|
32
|
+
onTimeout?.();
|
|
33
|
+
reject(new Error(`timed out after ${timeoutMs}ms`));
|
|
34
|
+
}, timeoutMs);
|
|
35
|
+
timer.unref();
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await Promise.race([work, deadline]);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function describe(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
/** The advisory line for the deployment probe, or undefined when it is skipped or healthy. */
|
|
48
|
+
export async function probeAdvisory(options) {
|
|
49
|
+
const env = options.env ?? process.env;
|
|
50
|
+
if (options.payload.source === 'compact')
|
|
51
|
+
return undefined;
|
|
52
|
+
if (env[options.skipEnv ?? 'CHECK_BLACKBOARD_SKIP'] === '1')
|
|
53
|
+
return undefined;
|
|
54
|
+
// The Claude Code sandbox unsets AGENT_BLACKBOARD_TOKEN and blocks egress, so a sandboxed run
|
|
55
|
+
// cannot tell "the deployment is down" from "I was never given the credential to check".
|
|
56
|
+
if (env.SANDBOX_RUNTIME)
|
|
57
|
+
return ('agent-blackboard probe skipped: it ran inside a sandbox, which withholds the deployment ' +
|
|
58
|
+
'credential and egress. This is NOT an outage and NOT a reason to stop work. Do not rerun ' +
|
|
59
|
+
'outside the sandbox to check. Journal through the journal server; search for ' +
|
|
60
|
+
'`journal_append`. If it is not connected, ' +
|
|
61
|
+
(options.restoreHint ??
|
|
62
|
+
'reconnect it from the harness (`/mcp` in Claude Code, or restart Codex)') +
|
|
63
|
+
', and tell the user.');
|
|
64
|
+
try {
|
|
65
|
+
await withDeadline((options.probe ?? listOneSession)(env, options.dependencies), options.timeoutMs ?? 5000, options.onTimeout);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const message = `sessions list probe failed: ${describe(error)}`;
|
|
69
|
+
if (error instanceof BlackboardNotInstalledError)
|
|
70
|
+
return (`agent-blackboard helpers are unavailable (${message}). This is a workspace-setup ` +
|
|
71
|
+
'problem in this worktree, not a deployment outage; AGENT_BLACKBOARD_URL and ' +
|
|
72
|
+
`AGENT_BLACKBOARD_TOKEN are not the cause and do not need checking.${hint(options.setupHint)} ` +
|
|
73
|
+
'Journaling does not need this install: search for `journal_append`, and when the server is ' +
|
|
74
|
+
'not connected stop journaling and tell the user the server is unavailable and how to fix it.');
|
|
75
|
+
return (`agent-blackboard availability assessment failed (${message}). Interactive feedback can ` +
|
|
76
|
+
'continue through the supported durable outbox; delivery remains visibly pending. Verify ' +
|
|
77
|
+
'the hosted deployment is reachable and AGENT_BLACKBOARD_URL and AGENT_BLACKBOARD_TOKEN are ' +
|
|
78
|
+
`exported and valid${options.docsPath ? ` (see ${options.docsPath})` : ''}, then start a ` +
|
|
79
|
+
'fresh session. This probe does not show whether the journal server is connected; if it is ' +
|
|
80
|
+
'not, stop journaling and tell the user the server is unavailable and how to fix it.');
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function hint(text) {
|
|
85
|
+
return text ? ` ${text}` : '';
|
|
86
|
+
}
|
|
87
|
+
/** The `additionalContext` for a SessionStart hook, or undefined when there is nothing to say. */
|
|
88
|
+
export async function sessionStartContext(options) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
const sessionId = payloadSessionId(options.payload);
|
|
91
|
+
if (sessionId)
|
|
92
|
+
lines.push(`Blackboard sessionId: ${sessionId} (pass it as \`sessionId\` to every journal tool)`);
|
|
93
|
+
const advisory = await probeAdvisory(options);
|
|
94
|
+
if (advisory !== undefined)
|
|
95
|
+
lines.push(advisory);
|
|
96
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
97
|
+
}
|
|
98
|
+
export function sessionStartOutput(context) {
|
|
99
|
+
return `${JSON.stringify({
|
|
100
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context },
|
|
101
|
+
})}\n`;
|
|
102
|
+
}
|
|
@@ -1,26 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
readonly claude: {
|
|
5
|
-
readonly compatMode: boolean;
|
|
6
|
-
readonly sessionId?: string;
|
|
7
|
-
};
|
|
8
|
-
readonly codex: {
|
|
9
|
-
readonly sessionId?: string;
|
|
10
|
-
};
|
|
11
|
-
readonly cursor: {
|
|
12
|
-
readonly agentMarker: boolean;
|
|
13
|
-
readonly sessionId?: string;
|
|
14
|
-
};
|
|
15
|
-
readonly grok: {
|
|
16
|
-
readonly agentMarker: boolean;
|
|
17
|
-
readonly hookEvent: boolean;
|
|
18
|
-
readonly sessionId?: string;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
export interface HarnessSessionIdentity {
|
|
22
|
-
readonly harness: HarnessId;
|
|
23
|
-
readonly sessionId: string;
|
|
24
|
-
}
|
|
1
|
+
import { type HarnessEnvironment } from 'agent-blackboard';
|
|
2
|
+
export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
|
|
3
|
+
export type { HarnessEnvironment, HarnessId, HarnessSessionIdentity } from 'agent-blackboard';
|
|
25
4
|
export declare function inspectHarnessEnvironment(env?: NodeJS.ProcessEnv): HarnessEnvironment;
|
|
26
|
-
export declare function selectHarnessSession(environment: HarnessEnvironment, precedence: readonly HarnessId[]): HarnessSessionIdentity | undefined;
|
|
@@ -1,30 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
return value ? { sessionId: value } : {};
|
|
4
|
-
}
|
|
1
|
+
import { inspectHarnessEnvironment as inspect } from 'agent-blackboard';
|
|
2
|
+
export { HARNESS_IDS, selectHarnessSession } from 'agent-blackboard';
|
|
5
3
|
export function inspectHarnessEnvironment(env = process.env) {
|
|
6
|
-
return
|
|
7
|
-
claude: {
|
|
8
|
-
compatMode: env.CLAUDECODE === '1',
|
|
9
|
-
...session(env.CLAUDE_CODE_SESSION_ID),
|
|
10
|
-
},
|
|
11
|
-
codex: session(env.CODEX_THREAD_ID),
|
|
12
|
-
cursor: {
|
|
13
|
-
agentMarker: Boolean(env.CURSOR_AGENT),
|
|
14
|
-
...session(env.CURSOR_SESSION_ID),
|
|
15
|
-
},
|
|
16
|
-
grok: {
|
|
17
|
-
agentMarker: Boolean(env.GROK_AGENT),
|
|
18
|
-
hookEvent: Boolean(env.GROK_HOOK_EVENT),
|
|
19
|
-
...session(env.GROK_SESSION_ID),
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
export function selectHarnessSession(environment, precedence) {
|
|
24
|
-
for (const harness of precedence) {
|
|
25
|
-
const sessionId = environment[harness].sessionId;
|
|
26
|
-
if (sessionId)
|
|
27
|
-
return { harness, sessionId };
|
|
28
|
-
}
|
|
29
|
-
return undefined;
|
|
4
|
+
return inspect(env);
|
|
30
5
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { probeBlackboard } from "../../agent-blackboard/index.mjs";
|
|
4
|
+
import { sessionStartContext, sessionStartOutput } from "../../agent-blackboard/session-start.mjs";
|
|
5
|
+
import { assertAllowed, flagsToValues } from "./agent-blackboard-flags.mjs";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
async function insideWorkTree() {
|
|
8
|
+
try {
|
|
9
|
+
await execFileAsync('git', ['rev-parse', '--git-dir']);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function readStdinPayload() {
|
|
17
|
+
if (process.stdin.isTTY)
|
|
18
|
+
return {};
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of process.stdin)
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
23
|
+
try {
|
|
24
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
25
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
26
|
+
? parsed
|
|
27
|
+
: {};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* SessionStart hook. Advisory only: it never exits nonzero for a probe or credential problem, so a
|
|
35
|
+
* broken deployment cannot stop a session. Flag misuse still exits 2.
|
|
36
|
+
*/
|
|
37
|
+
export async function runSessionStart(args) {
|
|
38
|
+
const values = flagsToValues(args);
|
|
39
|
+
assertAllowed(values, ['skip-env', 'setup-hint', 'docs-path', 'restore-hint', 'timeout-ms']);
|
|
40
|
+
const timeoutMs = values['timeout-ms'] === undefined ? undefined : Number(values['timeout-ms']);
|
|
41
|
+
if (timeoutMs !== undefined && !(Number.isInteger(timeoutMs) && timeoutMs > 0))
|
|
42
|
+
throw new Error('--timeout-ms must be a positive integer');
|
|
43
|
+
if (!(await insideWorkTree()))
|
|
44
|
+
return 0;
|
|
45
|
+
const context = await sessionStartContext({
|
|
46
|
+
payload: await readStdinPayload(),
|
|
47
|
+
dependencies: { resolveFrom: import.meta.url },
|
|
48
|
+
probe: probeBlackboard,
|
|
49
|
+
// A request that ignores the deadline would keep the process alive: exit once output is out.
|
|
50
|
+
onTimeout: () => setTimeout(() => process.exit(0), 250).unref(),
|
|
51
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
52
|
+
...(values['restore-hint'] ? { restoreHint: values['restore-hint'] } : {}),
|
|
53
|
+
...(values['skip-env'] ? { skipEnv: values['skip-env'] } : {}),
|
|
54
|
+
...(values['setup-hint'] ? { setupHint: values['setup-hint'] } : {}),
|
|
55
|
+
...(values['docs-path'] ? { docsPath: values['docs-path'] } : {}),
|
|
56
|
+
});
|
|
57
|
+
if (context !== undefined)
|
|
58
|
+
process.stdout.write(sessionStartOutput(context));
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runSnapshot(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cleanupSnapshotPartitions, partitionSnapshot } from "../../agent-blackboard/snapshot.mjs";
|
|
2
|
+
import { assertAllowed, flagsToValues, required } from "./agent-blackboard-flags.mjs";
|
|
3
|
+
export async function runSnapshot(args) {
|
|
4
|
+
const [action, ...flags] = args;
|
|
5
|
+
const values = flagsToValues(flags);
|
|
6
|
+
if (action === 'cleanup') {
|
|
7
|
+
assertAllowed(values, ['snapshot', 'partition-directory', 'receipt']);
|
|
8
|
+
if (values['partition-directory'] && !values.receipt)
|
|
9
|
+
throw new Error('--receipt is required with --partition-directory');
|
|
10
|
+
await cleanupSnapshotPartitions({
|
|
11
|
+
...(values.snapshot ? { path: values.snapshot } : {}),
|
|
12
|
+
...(values['partition-directory'] ? { directory: values['partition-directory'] } : {}),
|
|
13
|
+
...(values.receipt ? { receipt: JSON.parse(values.receipt) } : {}),
|
|
14
|
+
});
|
|
15
|
+
process.stdout.write('{"cleaned":true}\n');
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
if (action === 'partition') {
|
|
19
|
+
assertAllowed(values, ['snapshot', 'checksum', 'counts']);
|
|
20
|
+
const counts = JSON.parse(required(values, 'counts'));
|
|
21
|
+
process.stdout.write(`${JSON.stringify(await partitionSnapshot({
|
|
22
|
+
path: required(values, 'snapshot'),
|
|
23
|
+
checksum: { algorithm: 'sha256', value: required(values, 'checksum') },
|
|
24
|
+
counts,
|
|
25
|
+
}))}\n`);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('usage: agent-blackboard snapshot partition|cleanup');
|
|
29
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertAllowed, flagsToValues, required, validatedSessionId, } from "./agent-blackboard-flags.mjs";
|
|
2
|
+
import { runSnapshot } from "./agent-blackboard-snapshot.mjs";
|
|
3
|
+
import { runSessionStart } from "./agent-blackboard-session-start.mjs";
|
|
2
4
|
import { appendJournal, feedbackOutboxCounts, flushFeedbackOutbox, formatJournalEntries, probeBlackboard, readJournal, } from "../../agent-blackboard/index.mjs";
|
|
3
|
-
import { cleanupSnapshotPartitions, partitionSnapshot } from "../../agent-blackboard/snapshot.mjs";
|
|
4
5
|
const dependencies = { resolveFrom: import.meta.url };
|
|
5
6
|
let journalReader = readJournal;
|
|
6
7
|
export function setJournalReaderForTest(reader) {
|
|
@@ -17,40 +18,15 @@ export async function runAgentBlackboardCommand(args) {
|
|
|
17
18
|
return await runJournal(rest);
|
|
18
19
|
if (command === 'snapshot')
|
|
19
20
|
return await runSnapshot(rest);
|
|
20
|
-
|
|
21
|
+
if (command === 'session-start')
|
|
22
|
+
return await runSessionStart(rest);
|
|
23
|
+
throw new Error('usage: agent-blackboard probe | session-start | journal append|entries|flush|status | snapshot partition|cleanup');
|
|
21
24
|
}
|
|
22
25
|
catch (error) {
|
|
23
26
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
24
27
|
return 2;
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
|
-
async function runSnapshot(args) {
|
|
28
|
-
const [action, ...flags] = args;
|
|
29
|
-
const values = flagsToValues(flags);
|
|
30
|
-
if (action === 'cleanup') {
|
|
31
|
-
assertAllowed(values, ['snapshot', 'partition-directory', 'receipt']);
|
|
32
|
-
if (values['partition-directory'] && !values.receipt)
|
|
33
|
-
throw new Error('--receipt is required with --partition-directory');
|
|
34
|
-
await cleanupSnapshotPartitions({
|
|
35
|
-
...(values.snapshot ? { path: values.snapshot } : {}),
|
|
36
|
-
...(values['partition-directory'] ? { directory: values['partition-directory'] } : {}),
|
|
37
|
-
...(values.receipt ? { receipt: JSON.parse(values.receipt) } : {}),
|
|
38
|
-
});
|
|
39
|
-
process.stdout.write('{"cleaned":true}\n');
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
if (action === 'partition') {
|
|
43
|
-
assertAllowed(values, ['snapshot', 'checksum', 'counts']);
|
|
44
|
-
const counts = JSON.parse(required(values, 'counts'));
|
|
45
|
-
process.stdout.write(`${JSON.stringify(await partitionSnapshot({
|
|
46
|
-
path: required(values, 'snapshot'),
|
|
47
|
-
checksum: { algorithm: 'sha256', value: required(values, 'checksum') },
|
|
48
|
-
counts,
|
|
49
|
-
}))}\n`);
|
|
50
|
-
return 0;
|
|
51
|
-
}
|
|
52
|
-
throw new Error('usage: agent-blackboard snapshot partition|cleanup');
|
|
53
|
-
}
|
|
54
30
|
async function runJournal(args) {
|
|
55
31
|
const [action, ...flags] = args;
|
|
56
32
|
const { repositories, remaining } = action === 'append' ? extractRepositories(flags) : { repositories: [], remaining: flags };
|
package/dist/cli/usage.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n fresh-base Classify the worktree against a remote base branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions
|
|
1
|
+
export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n fresh-base Classify the worktree against a remote base branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions (KEEP_MIN or --main-history)\n harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out\n harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled\n gha-collaborator-trust Classify authors as write-access collaborators or not\n nuget-central-version Validate a Directory.Packages.props PackageVersion delta\n swift-semantic-equal Compare Swift sources ignoring comments and whitespace\n post-review Post one COMMENT review from a staged payload file\n stage-review-payload Validate a review payload file into a staging directory\n retrospective-transcript Format facts from Claude-compatible, Codex, or Grok transcripts\n link-skill Link one packaged skill into an explicit consumer directory\n retrospective-facts Gather immutable facts for a retrospective\n agent-blackboard Probe and journal an Agent Blackboard deployment\n mcp Serve the agent-blackboard journal tools over stdio (MCP)\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n [--median-threshold-floor <seconds>] Off unless set. Must be below the ceiling\n [--median-threshold-ceiling <seconds>] Default 360. Flags a five-sample median above this\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\nrequire-up-to-date --remote <name> --branch <name>\nfresh-base [--remote <name>] [--branch <name>] [--skip-fetch]\ngitleaks-directory-scan --config <path> [--directory <path>]\nast-grep-examples --rules <directory> --config <path>\nast-grep-pack\ngha-workspace-policy [--root <directory>] [--workflow-directory <directory>] [--action-directory <directory>]\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\ndownload-optional-run-artifacts (--name <name>... | --pattern <pattern>) --dir <directory>\nhost-pressure-diagnostics\nallocate-browser-safe-ports <count> [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\nvitest-report-attempt <write DIRECTORY SUITE|read ROOT>\nprepare-vitest-reports [primary-directory] [fallback-directory] [output-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\ncheck-cache-size <path> <max-bytes> <label>\nmake-shard-matrix <total>\nload-runner-env\nclean-workspace\ninstall-github-release --repo owner/name --version X --asset 'name-{platform}.tar.gz' --bin name [--tag-prefix PREFIX] [--expected-sha256 SHA256] [--no-checksum] [--checksums-asset NAME] [--version-flag FLAG] [--bin-dir DIR]\nrun-with-timeout <timeout-seconds> <kill-after-seconds> <command...>\nlint-links [--offline] [--config PATH] [--glob PATTERN] [files...]\nmaterialize-pr-context\nwait-for-apt-locks\ninstall-playwright-chromium-arm64 [name:archive...]\nghcr-package-retention [--main-history] [--apply|--dry-run] [<url-encoded-package>...]\nharness-admission-lane <lanes>\nharness-assert-gates <gate>...\ngha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json\nnuget-central-version <trusted-props> <candidate-props> <metadata-json> <output-props>\nswift-semantic-equal <base> <head> <file.swift>\npost-review\nstage-review-payload optional|required <source> <destination>\nretrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH] [--codex-sessions-dir PATH] [--grok-sessions-dir PATH]\nlink-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill\nretrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]\nagent-blackboard probe\nagent-blackboard session-start [--skip-env NAME] [--setup-hint TEXT] [--docs-path PATH] [--restore-hint TEXT] [--timeout-ms N] (SessionStart hook; payload on stdin)\nagent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]\nagent-blackboard journal entries --session-id UUID\nagent-blackboard journal status --session-id UUID --outbox-directory PATH\nagent-blackboard journal flush --outbox-directory PATH [--session-id UUID]\nInteractive journal append requires --outbox-directory PATH.\nagent-blackboard journal is deprecated and being phased out: agents journal through the mcp server.\nagent-blackboard snapshot partition --snapshot PATH --checksum SHA256 --counts '{\"sessions\":N,\"entries\":N,\"records\":N,\"bytes\":N}'\nagent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH --receipt JSON]\nmcp\n (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,\n session_ensure, retrospective_check, retrospective_save,\n pr_validate, pr_create, pr_update,\n snapshot_export, snapshot_cleanup, and session_archive over stdio.\n Needs the optional @modelcontextprotocol/sdk peer (and zod).\n Launch it from inside the repository's git worktree.\n";
|
|
2
2
|
export declare function printUsage(stream?: NodeJS.WritableStream): void;
|
|
3
3
|
export declare function commandNames(usage?: string): string[];
|
|
4
4
|
export declare function commandUsage(command: string, usage?: string): string;
|
package/dist/cli/usage.mjs
CHANGED
|
@@ -34,7 +34,7 @@ Commands:
|
|
|
34
34
|
materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl
|
|
35
35
|
wait-for-apt-locks Wait until apt/dpkg lock files are free
|
|
36
36
|
install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json
|
|
37
|
-
ghcr-package-retention Delete old GHCR package versions
|
|
37
|
+
ghcr-package-retention Delete old GHCR package versions (KEEP_MIN or --main-history)
|
|
38
38
|
harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out
|
|
39
39
|
harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled
|
|
40
40
|
gha-collaborator-trust Classify authors as write-access collaborators or not
|
|
@@ -106,7 +106,7 @@ lint-links [--offline] [--config PATH] [--glob PATTERN] [files...]
|
|
|
106
106
|
materialize-pr-context
|
|
107
107
|
wait-for-apt-locks
|
|
108
108
|
install-playwright-chromium-arm64 [name:archive...]
|
|
109
|
-
ghcr-package-retention <url-encoded-package>...
|
|
109
|
+
ghcr-package-retention [--main-history] [--apply|--dry-run] [<url-encoded-package>...]
|
|
110
110
|
harness-admission-lane <lanes>
|
|
111
111
|
harness-assert-gates <gate>...
|
|
112
112
|
gha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json
|
|
@@ -118,6 +118,7 @@ retrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH]
|
|
|
118
118
|
link-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill
|
|
119
119
|
retrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]
|
|
120
120
|
agent-blackboard probe
|
|
121
|
+
agent-blackboard session-start [--skip-env NAME] [--setup-hint TEXT] [--docs-path PATH] [--restore-hint TEXT] [--timeout-ms N] (SessionStart hook; payload on stdin)
|
|
121
122
|
agent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]
|
|
122
123
|
agent-blackboard journal entries --session-id UUID
|
|
123
124
|
agent-blackboard journal status --session-id UUID --outbox-directory PATH
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { HARNESS_SESSION_PRECEDENCE } from 'agent-blackboard';
|
|
1
2
|
import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
|
|
2
|
-
const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
|
|
3
3
|
/**
|
|
4
4
|
* The harness that launched this server, from the launch environment first and the MCP client's
|
|
5
5
|
* `initialize` `clientInfo.name` second. Neither comes from a tool argument.
|
|
@@ -12,11 +12,11 @@ export function deriveAgent(env, clientName) {
|
|
|
12
12
|
cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
|
|
13
13
|
grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
|
|
14
14
|
};
|
|
15
|
-
const fromEnv =
|
|
15
|
+
const fromEnv = HARNESS_SESSION_PRECEDENCE.find((id) => present[id]);
|
|
16
16
|
if (fromEnv !== undefined)
|
|
17
17
|
return fromEnv;
|
|
18
18
|
const name = clientName?.toLowerCase() ?? '';
|
|
19
|
-
return
|
|
19
|
+
return HARNESS_SESSION_PRECEDENCE.find((id) => name.includes(id));
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* The text of the `Agent:` line before the session. The agent is the derived harness; a caller
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function asArray(value: unknown): unknown[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeCommandPrefix(command: string): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
|
|
2
|
+
// A real command/subcommand token (binary name, git subcommand, short flag, script
|
|
3
|
+
// name) is always well under this length. A token that exceeds it isn't a command
|
|
4
|
+
// token at all — it's payload that got tokenized as one word because it had no
|
|
5
|
+
// internal whitespace (a `VAR={...json...}` assignment, a giant quoted printf arg).
|
|
6
|
+
// Such tokens are replaced with a redaction marker rather than truncated: truncation
|
|
7
|
+
// still leaks a prefix of whatever the token contains (a secret can sit at the front,
|
|
8
|
+
// e.g. "AWS_SECRET_ACCESS_KEY=AKIA..."), while a full-token redaction leaks nothing
|
|
9
|
+
// regardless of where sensitive content falls inside it. This intentionally also
|
|
10
|
+
// redacts long-but-benign tokens (e.g. a `cd /very/long/worktree/path` argument) —
|
|
11
|
+
// for a privacy boundary, over-redacting a legitimate token is the safe failure mode.
|
|
12
|
+
const MAX_COMMAND_TOKEN_LENGTH = 40;
|
|
13
|
+
const REDACTED_TOKEN = '…';
|
|
14
|
+
function redactOverlongToken(token) {
|
|
15
|
+
return token.length > MAX_COMMAND_TOKEN_LENGTH ? REDACTED_TOKEN : token;
|
|
16
|
+
}
|
|
17
|
+
const ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
18
|
+
// A segment's real command starts after any leading `VAR=value` assignments (e.g.
|
|
19
|
+
// "CI=1 pnpm exec vitest", "GIT_EDITOR=true git rebase --continue" — both real
|
|
20
|
+
// patterns used in real agent sessions). Without stripping these, the
|
|
21
|
+
// env-var token itself gets normalized as if it were the command, so "CI=1 pnpm exec
|
|
22
|
+
// vitest" would classify as prefix "CI=1 pnpm" instead of "pnpm exec vitest". Only
|
|
23
|
+
// strips when a token remains afterward — a segment that is *entirely*
|
|
24
|
+
// assignment-shaped tokens (e.g. a lone `OLD_POLICY_JSON={...}` token with no
|
|
25
|
+
// whitespace) has no command to recover, and is left alone so it still falls through
|
|
26
|
+
// to the general redaction path below instead of normalizing to an empty prefix.
|
|
27
|
+
function stripEnvPrefix(segment) {
|
|
28
|
+
const index = segment.findIndex((token) => !ENV_ASSIGNMENT_PATTERN.test(token));
|
|
29
|
+
return index === -1 ? segment : segment.slice(index);
|
|
30
|
+
}
|
|
31
|
+
// Git global options precede the real subcommand — "git -C <path> status" and
|
|
32
|
+
// "git status" are the same command family for policy-matching purposes (`git -h`
|
|
33
|
+
// documents `git [-C <path>] [-c <name>=<value>] ... <command> [<args>]`). `-C`/`-c`
|
|
34
|
+
// take a separate-token argument; `--git-dir=<path>`-style options fold their argument
|
|
35
|
+
// into one `=`-joined token; the rest take no argument at all. Skipping them keeps the
|
|
36
|
+
// normalized prefix on the subcommand instead of stalling on the option — without this,
|
|
37
|
+
// e.g. `git -C <worktree> status` normalizes to "git -C" and reports as an uncovered
|
|
38
|
+
// escalation/block candidate under the wrong prefix, even though `git status` is
|
|
39
|
+
// already covered by an existing narrow allow rule.
|
|
40
|
+
const GIT_GLOBAL_OPTIONS_WITH_ARG = new Set(['-C', '-c']);
|
|
41
|
+
const GIT_GLOBAL_OPTIONS_NO_ARG = new Set([
|
|
42
|
+
'-v',
|
|
43
|
+
'--version',
|
|
44
|
+
'-h',
|
|
45
|
+
'--help',
|
|
46
|
+
'--exec-path',
|
|
47
|
+
'--html-path',
|
|
48
|
+
'--man-path',
|
|
49
|
+
'--info-path',
|
|
50
|
+
'-p',
|
|
51
|
+
'--paginate',
|
|
52
|
+
'-P',
|
|
53
|
+
'--no-pager',
|
|
54
|
+
'--no-replace-objects',
|
|
55
|
+
'--bare',
|
|
56
|
+
]);
|
|
57
|
+
const GIT_GLOBAL_OPTION_WITH_INLINE_ARG = /^--[a-z-]+=/;
|
|
58
|
+
function stripGitGlobalOptions(tokens) {
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < tokens.length) {
|
|
61
|
+
const token = tokens[index];
|
|
62
|
+
if (GIT_GLOBAL_OPTIONS_WITH_ARG.has(token))
|
|
63
|
+
index += 2;
|
|
64
|
+
else if (GIT_GLOBAL_OPTIONS_NO_ARG.has(token) || GIT_GLOBAL_OPTION_WITH_INLINE_ARG.test(token))
|
|
65
|
+
index += 1;
|
|
66
|
+
else
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return tokens.slice(index);
|
|
70
|
+
}
|
|
71
|
+
// Normalizes a segment to "<leading token> <first subcommand>" (e.g. "git status",
|
|
72
|
+
// "gh pr", or just "cat" when there's no second token), with one exception that goes
|
|
73
|
+
// one token deeper because a permissions file may distinguish at that depth:
|
|
74
|
+
// package runners — "pnpm run build" vs "pnpm exec build" are different
|
|
75
|
+
// permission entries, so the run/exec target must
|
|
76
|
+
// stay out of the generic two-token rule.
|
|
77
|
+
function normalizeSegment(segment) {
|
|
78
|
+
const [first, ...rest] = segment;
|
|
79
|
+
if (!first)
|
|
80
|
+
return '';
|
|
81
|
+
// A segment that is only assignments (stripEnvPrefix left it intact) has no command;
|
|
82
|
+
// its value may be a secret of any length, so always redact it.
|
|
83
|
+
if (ENV_ASSIGNMENT_PATTERN.test(first))
|
|
84
|
+
return REDACTED_TOKEN;
|
|
85
|
+
if (isPackageRunner(first) && (rest[0] === 'run' || rest[0] === 'exec') && rest[1]) {
|
|
86
|
+
return `${redactOverlongToken(first)} ${rest[0]} ${redactOverlongToken(rest[1])}`;
|
|
87
|
+
}
|
|
88
|
+
const effectiveRest = first === 'git' ? stripGitGlobalOptions(rest) : rest;
|
|
89
|
+
return effectiveRest[0]
|
|
90
|
+
? `${redactOverlongToken(first)} ${redactOverlongToken(effectiveRest[0])}`
|
|
91
|
+
: redactOverlongToken(first);
|
|
92
|
+
}
|
|
93
|
+
// A leading "cd <path> && ..." (or "cd <path>; ...") prelude is not itself the
|
|
94
|
+
// actionable command — it's a workdir change common in Bash sessions with no separate
|
|
95
|
+
// cwd param. Without skipping it, "cd /repo && pnpm install" would normalize to "cd
|
|
96
|
+
// /repo" and both miss existing pnpm policy coverage and leak a checkout path into
|
|
97
|
+
// non-raw retro output. Only a *leading run* of cd segments is skipped — a bare `cd
|
|
98
|
+
// /repo` with nothing after it has no other segment to fall back to, so it still
|
|
99
|
+
// normalizes as-is.
|
|
100
|
+
function skipLeadingCdSegments(segments) {
|
|
101
|
+
let index = 0;
|
|
102
|
+
while (index < segments.length - 1 && stripEnvPrefix(segments[index])[0] === 'cd')
|
|
103
|
+
index++;
|
|
104
|
+
return segments.slice(index);
|
|
105
|
+
}
|
|
106
|
+
// Produces a classification key for a raw shell command, for comparison against the
|
|
107
|
+
// prefix lists in a consumer permissions file (excluded commands, allow,
|
|
108
|
+
// and deny lists). This is a privacy
|
|
109
|
+
// boundary: callers building non---raw report output must emit only this normalized
|
|
110
|
+
// prefix, never the raw `command` string.
|
|
111
|
+
//
|
|
112
|
+
// Only the first non-cd segment of a compound command (`git status && git push`) is
|
|
113
|
+
// normalized — this is a rough per-command categorization tool, not a full command
|
|
114
|
+
// parser, so a compound command is bucketed by its first actionable part.
|
|
115
|
+
//
|
|
116
|
+
// The leading token is preserved as-is (no basename stripping for a path-qualified
|
|
117
|
+
// binary like "/usr/bin/git"):
|
|
118
|
+
// permissions files may also list literal relative-path scripts (e.g. "./scripts/run")
|
|
119
|
+
// that must not be collapsed to a basename.
|
|
120
|
+
export function normalizeCommandPrefix(command) {
|
|
121
|
+
const segments = skipLeadingCdSegments(splitCommandSegments(command));
|
|
122
|
+
const segment = stripEnvPrefix(segments[0] ?? []);
|
|
123
|
+
return normalizeSegment(segment);
|
|
124
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Commands are matched by command position, not by scanning for words anywhere in
|
|
2
|
+
// the string. The string is split into segments on unquoted `;`, `&`, `|` and
|
|
3
|
+
// newlines (so `git push;gh pr view` splits even with no surrounding whitespace).
|
|
4
|
+
// Quoted spans, including any whitespace inside them, are grouped into a single
|
|
5
|
+
// word, mirroring real shell tokenization.
|
|
6
|
+
export function splitCommandSegments(command) {
|
|
7
|
+
const segments = [];
|
|
8
|
+
let current = [];
|
|
9
|
+
let word = '';
|
|
10
|
+
let quote = null;
|
|
11
|
+
const flushWord = () => {
|
|
12
|
+
if (word) {
|
|
13
|
+
current.push(word);
|
|
14
|
+
word = '';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const flushSegment = () => {
|
|
18
|
+
flushWord();
|
|
19
|
+
if (current.length) {
|
|
20
|
+
segments.push(current);
|
|
21
|
+
current = [];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
for (const char of command) {
|
|
25
|
+
if (quote) {
|
|
26
|
+
if (char === quote)
|
|
27
|
+
quote = null;
|
|
28
|
+
else
|
|
29
|
+
word += char;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === '"' || char === "'") {
|
|
33
|
+
quote = char;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === ';' || char === '&' || char === '|' || char === '\n' || char === '\r') {
|
|
37
|
+
flushSegment();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (/\s/.test(char)) {
|
|
41
|
+
flushWord();
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
word += char;
|
|
45
|
+
}
|
|
46
|
+
flushSegment();
|
|
47
|
+
return segments;
|
|
48
|
+
}
|
|
49
|
+
const PACKAGE_RUNNERS = ['npx', 'pnpm', 'pnpx', 'yarn'];
|
|
50
|
+
export function isPackageRunner(word) {
|
|
51
|
+
if (!word)
|
|
52
|
+
return false;
|
|
53
|
+
return PACKAGE_RUNNERS.some((runner) => word === runner || word.endsWith(`/${runner}`));
|
|
54
|
+
}
|
|
@@ -5,6 +5,11 @@ export { codexChildren, codexIdentity } from './codex.mts';
|
|
|
5
5
|
export { formatTranscriptFacts, formatUnavailable } from './format.mts';
|
|
6
6
|
export type { ResolveOptions } from './resolve-transcript-file.mts';
|
|
7
7
|
export { resolveTranscriptFile } from './resolve-transcript-file.mts';
|
|
8
|
+
export { asRecord } from './shared.mts';
|
|
9
|
+
export { asArray } from './as-array.mts';
|
|
10
|
+
export { normalizeCommandPrefix } from './command-prefix.mts';
|
|
11
|
+
export { isPackageRunner, splitCommandSegments } from './command-segments.mts';
|
|
12
|
+
export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from './transcript-lines.mts';
|
|
8
13
|
export type RetrospectiveTranscriptReport = {
|
|
9
14
|
markdown: string;
|
|
10
15
|
coverage: 'complete' | 'partial' | 'unavailable';
|
|
@@ -11,6 +11,11 @@ import { emptyFacts, hasMalformedInteriorRecord, parseLines, } from "./shared.mj
|
|
|
11
11
|
export { codexChildren, codexIdentity } from "./codex.mjs";
|
|
12
12
|
export { formatTranscriptFacts, formatUnavailable } from "./format.mjs";
|
|
13
13
|
export { resolveTranscriptFile } from "./resolve-transcript-file.mjs";
|
|
14
|
+
export { asRecord } from "./shared.mjs";
|
|
15
|
+
export { asArray } from "./as-array.mjs";
|
|
16
|
+
export { normalizeCommandPrefix } from "./command-prefix.mjs";
|
|
17
|
+
export { isPackageRunner, splitCommandSegments } from "./command-segments.mjs";
|
|
18
|
+
export { DEFAULT_MAX_RECORD_BYTES, openTranscriptLines } from "./transcript-lines.mjs";
|
|
14
19
|
const MALFORMED_INTERIOR = 'malformed interior transcript record';
|
|
15
20
|
function partialCapture(lines) {
|
|
16
21
|
return parseLines(lines).length !== lines.filter((line) => line.trim()).length;
|
|
@@ -15,5 +15,15 @@ type TranscriptResolution = {
|
|
|
15
15
|
error: string;
|
|
16
16
|
};
|
|
17
17
|
export declare function globFrom(root: string, pattern: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Canonical transcript resolution for consumers.
|
|
20
|
+
*
|
|
21
|
+
* - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
|
|
22
|
+
* - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
|
|
23
|
+
* (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
|
|
24
|
+
* - Returns `{ error }` when more than one transcript matches a session, rather than
|
|
25
|
+
* picking the first match, and when none matches.
|
|
26
|
+
* - An explicit `jsonlPath` skips discovery.
|
|
27
|
+
*/
|
|
18
28
|
export declare function resolveTranscriptFile(options: ResolveOptions): TranscriptResolution;
|
|
19
29
|
export {};
|
|
@@ -7,6 +7,16 @@ export const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
|
|
|
7
7
|
export function globFrom(root, pattern) {
|
|
8
8
|
return globSync(pattern, { cwd: root }).map((path) => join(root, path));
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Canonical transcript resolution for consumers.
|
|
12
|
+
*
|
|
13
|
+
* - Session ids are validated as UUIDs and lowercased (`invalid session id format` otherwise).
|
|
14
|
+
* - Roots honor `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and `GROK_HOME` from `options.env`
|
|
15
|
+
* (default `process.env`), unless an explicit `*SessionsDir`/`projectsDir` is given.
|
|
16
|
+
* - Returns `{ error }` when more than one transcript matches a session, rather than
|
|
17
|
+
* picking the first match, and when none matches.
|
|
18
|
+
* - An explicit `jsonlPath` skips discovery.
|
|
19
|
+
*/
|
|
10
20
|
export function resolveTranscriptFile(options) {
|
|
11
21
|
if (options.sessionId && !SESSION_ID.test(options.sessionId))
|
|
12
22
|
return { error: 'invalid session id format' };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const DEFAULT_MAX_RECORD_BYTES: number;
|
|
2
|
+
export type OpenTranscriptLinesOptions = {
|
|
3
|
+
/** Largest allowed record (one line, excluding its newline) in bytes. Defaults to 2 MiB. */
|
|
4
|
+
maxRecordBytes?: number;
|
|
5
|
+
};
|
|
6
|
+
export type OpenTranscriptLinesResult = {
|
|
7
|
+
lines: AsyncIterable<string>;
|
|
8
|
+
} | {
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Opens a transcript for streaming, one line at a time, with `String.split('\n')`
|
|
13
|
+
* semantics (a trailing newline yields a final empty line, `\r` is preserved).
|
|
14
|
+
*
|
|
15
|
+
* A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
|
|
16
|
+
* An open failure resolves to `{ error }`. An oversized record is NOT skipped or
|
|
17
|
+
* reported inline: the returned async iterable throws
|
|
18
|
+
* `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
|
|
19
|
+
* record, ending iteration, and the underlying stream is destroyed. Lines yielded
|
|
20
|
+
* before the oversized record remain valid.
|
|
21
|
+
*/
|
|
22
|
+
export declare function openTranscriptLines(path: string, { maxRecordBytes }?: OpenTranscriptLinesOptions): Promise<OpenTranscriptLinesResult>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
export const DEFAULT_MAX_RECORD_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
/**
|
|
4
|
+
* Opens a transcript for streaming, one line at a time, with `String.split('\n')`
|
|
5
|
+
* semantics (a trailing newline yields a final empty line, `\r` is preserved).
|
|
6
|
+
*
|
|
7
|
+
* A `maxRecordBytes` that is not a non-negative safe integer rejects with `RangeError`.
|
|
8
|
+
* An open failure resolves to `{ error }`. An oversized record is NOT skipped or
|
|
9
|
+
* reported inline: the returned async iterable throws
|
|
10
|
+
* `transcript record exceeds <maxRecordBytes> byte limit` when it reaches the
|
|
11
|
+
* record, ending iteration, and the underlying stream is destroyed. Lines yielded
|
|
12
|
+
* before the oversized record remain valid.
|
|
13
|
+
*/
|
|
14
|
+
export async function openTranscriptLines(path, { maxRecordBytes = DEFAULT_MAX_RECORD_BYTES } = {}) {
|
|
15
|
+
if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 0)
|
|
16
|
+
throw new RangeError('maxRecordBytes must be a non-negative integer');
|
|
17
|
+
const stream = createReadStream(path);
|
|
18
|
+
try {
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
stream.once('open', () => resolve());
|
|
21
|
+
stream.once('error', reject);
|
|
22
|
+
});
|
|
23
|
+
const lines = streamTranscriptLines(stream, maxRecordBytes);
|
|
24
|
+
const release = lines.return.bind(lines);
|
|
25
|
+
// The file is already open, so cancelling before the first next() must still close it;
|
|
26
|
+
// an unstarted generator never reaches its own finally block.
|
|
27
|
+
lines.return = (value) => {
|
|
28
|
+
stream.destroy();
|
|
29
|
+
return release(value);
|
|
30
|
+
};
|
|
31
|
+
return { lines };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
stream.destroy();
|
|
35
|
+
return { error: err.message };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function* streamTranscriptLines(stream, maxRecordBytes) {
|
|
39
|
+
const pending = [];
|
|
40
|
+
let pendingBytes = 0;
|
|
41
|
+
const tooLarge = () => new Error(`transcript record exceeds ${maxRecordBytes} byte limit`);
|
|
42
|
+
const line = (tail) => {
|
|
43
|
+
const bytes = pendingBytes + (tail?.byteLength ?? 0);
|
|
44
|
+
if (bytes > maxRecordBytes)
|
|
45
|
+
throw tooLarge();
|
|
46
|
+
const value = Buffer.concat(tail ? [...pending, tail] : pending, bytes).toString('utf8');
|
|
47
|
+
pending.length = 0;
|
|
48
|
+
pendingBytes = 0;
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
for await (const chunk of stream) {
|
|
53
|
+
let start = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const newline = chunk.indexOf(10, start);
|
|
56
|
+
if (newline === -1)
|
|
57
|
+
break;
|
|
58
|
+
yield line(chunk.subarray(start, newline));
|
|
59
|
+
start = newline + 1;
|
|
60
|
+
}
|
|
61
|
+
if (start < chunk.byteLength) {
|
|
62
|
+
const tail = chunk.subarray(start);
|
|
63
|
+
pendingBytes += tail.byteLength;
|
|
64
|
+
if (pendingBytes > maxRecordBytes)
|
|
65
|
+
throw tooLarge();
|
|
66
|
+
pending.push(tail);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
yield line();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
stream.destroy();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -375,6 +375,7 @@
|
|
|
375
375
|
"@types/mdast": "4.0.4",
|
|
376
376
|
"@types/unist": "3.0.3",
|
|
377
377
|
"@typescript-eslint/parser": "^8.71.1",
|
|
378
|
+
"agent-blackboard": "^0.7.0",
|
|
378
379
|
"csv-parse": "7.0.3",
|
|
379
380
|
"csv-stringify": "6.9.0",
|
|
380
381
|
"dockerfile-ast": "0.7.1",
|
|
@@ -390,22 +391,17 @@
|
|
|
390
391
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
391
392
|
"@types/picomatch": "^4.0.3",
|
|
392
393
|
"@types/spdx-expression-parse": "4.0.0",
|
|
393
|
-
"agent-blackboard": "^0.6.1",
|
|
394
394
|
"no-mistakes": "0.83.0",
|
|
395
395
|
"smol-toml": "1.9.1",
|
|
396
396
|
"typescript5": "npm:typescript@5.9.3"
|
|
397
397
|
},
|
|
398
398
|
"peerDependencies": {
|
|
399
399
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
400
|
-
"agent-blackboard": "^0.6.1",
|
|
401
400
|
"no-mistakes": "*",
|
|
402
401
|
"typescript": ">=5",
|
|
403
402
|
"vitest": ">=5"
|
|
404
403
|
},
|
|
405
404
|
"peerDependenciesMeta": {
|
|
406
|
-
"agent-blackboard": {
|
|
407
|
-
"optional": true
|
|
408
|
-
},
|
|
409
405
|
"@modelcontextprotocol/sdk": {
|
|
410
406
|
"optional": true
|
|
411
407
|
},
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sourced by ghcr-package-retention.sh for --main-history. Expects package_root, packages, apply,
|
|
3
|
+
# keep_main and min_age_days from the caller.
|
|
4
|
+
|
|
5
|
+
prune_main_history() {
|
|
6
|
+
# A zero keep count would delete every main image in one run.
|
|
7
|
+
case "$keep_main" in
|
|
8
|
+
'' | *[!0-9]*) keep_main=0 ;;
|
|
9
|
+
esac
|
|
10
|
+
if [ "$((10#$keep_main))" -lt 1 ]; then
|
|
11
|
+
echo 'ghcr-package-retention: GHCR_KEEP_MAIN must be a positive integer' >&2
|
|
12
|
+
exit 2
|
|
13
|
+
fi
|
|
14
|
+
case "$min_age_days" in
|
|
15
|
+
'' | *[!0-9]*)
|
|
16
|
+
echo 'ghcr-package-retention: GHCR_UNMERGED_MIN_AGE_DAYS must be a non-negative integer' >&2
|
|
17
|
+
exit 2
|
|
18
|
+
;;
|
|
19
|
+
esac
|
|
20
|
+
|
|
21
|
+
# A shallow history would make every older main revision look unmerged.
|
|
22
|
+
if [ "$(git rev-parse --is-shallow-repository)" != false ]; then
|
|
23
|
+
echo 'ghcr-package-retention: --main-history needs the full main history (fetch-depth: 0)' >&2
|
|
24
|
+
exit 2
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Global on purpose: the EXIT trap runs after this function returns.
|
|
28
|
+
work=$(mktemp -d)
|
|
29
|
+
trap 'rm -rf "$work"' EXIT
|
|
30
|
+
git rev-list HEAD > "$work/main-revisions"
|
|
31
|
+
local cutoff
|
|
32
|
+
cutoff=$(($(date -u +%s) - min_age_days * 86400))
|
|
33
|
+
|
|
34
|
+
local plan='
|
|
35
|
+
($revisions | split("\n") | map(select(. != "") | {key: ., value: true}) | from_entries) as $main
|
|
36
|
+
| (add // []) as $versions
|
|
37
|
+
| def tags: .metadata.container.tags;
|
|
38
|
+
def created: .created_at | fromdateiso8601;
|
|
39
|
+
def idle: [.created_at, .updated_at] | map(fromdateiso8601) | max < $cutoff;
|
|
40
|
+
def attachment: tags | all(startswith("sha256-"));
|
|
41
|
+
def canonical: tags | length == 1 and (.[0] | test("^sha-[0-9a-f]{40}$"));
|
|
42
|
+
def merged: $main[tags[0][4:]] == true;
|
|
43
|
+
($versions | map(select(canonical and merged)) | sort_by(.created_at, .id) | reverse
|
|
44
|
+
| .[$keep:] | map(. + {reason: "main"})) as $old_main
|
|
45
|
+
| ($versions | map(select(canonical and (merged | not) and idle) | . + {reason: "unmerged"}))
|
|
46
|
+
as $unmerged
|
|
47
|
+
| ($old_main + $unmerged | map(.id)) as $doomed
|
|
48
|
+
| ($versions | map(select((attachment | not) and (.id | IN($doomed[]) | not)) | created)
|
|
49
|
+
| min) as $oldest_image
|
|
50
|
+
| ($versions | map(select(attachment and idle and $oldest_image != null
|
|
51
|
+
and created < $oldest_image - 86400) | . + {reason: "orphan"})) as $orphans
|
|
52
|
+
| ($old_main + $unmerged + $orphans)[]
|
|
53
|
+
| "\(.id) \(tags | if length == 0 then "<untagged>" else join(",") end) \(.reason)"'
|
|
54
|
+
|
|
55
|
+
local deleted_total=0 package path error deletions id tags reason
|
|
56
|
+
for package in "${packages[@]}"; do
|
|
57
|
+
path="$package_root/$package/versions"
|
|
58
|
+
if ! error=$(gh api --paginate "$path?per_page=100" 2>&1 > "$work/pages"); then
|
|
59
|
+
case "$error" in
|
|
60
|
+
# A package that was never published has nothing to prune.
|
|
61
|
+
*'(HTTP 404)'*)
|
|
62
|
+
echo "· $package: no such package, skipping"
|
|
63
|
+
continue
|
|
64
|
+
;;
|
|
65
|
+
esac
|
|
66
|
+
printf '::error::Could not list %s versions: %s\n' "$package" "$error" >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
deletions=$(jq -r -s --rawfile revisions "$work/main-revisions" --argjson keep "$keep_main" \
|
|
71
|
+
--argjson cutoff "$cutoff" "$plan" "$work/pages")
|
|
72
|
+
echo "· $package: $(jq -s 'add // [] | length' "$work/pages") version(s)"
|
|
73
|
+
while read -r id tags reason; do
|
|
74
|
+
[ -n "$id" ] || continue
|
|
75
|
+
if [ "$apply" = true ]; then
|
|
76
|
+
gh api --method DELETE "$path/$id" > /dev/null < /dev/null
|
|
77
|
+
echo " deleted $id $tags ($reason)"
|
|
78
|
+
else
|
|
79
|
+
echo " would delete $id $tags ($reason)"
|
|
80
|
+
fi
|
|
81
|
+
deleted_total=$((deleted_total + 1))
|
|
82
|
+
done <<< "$deletions"
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
if [ "$apply" = true ]; then
|
|
86
|
+
echo "Deleted $deleted_total version(s)."
|
|
87
|
+
else
|
|
88
|
+
echo "Dry run: $deleted_total version(s) would be deleted. Pass --apply to delete."
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Prune GHCR package versions. Two strategies:
|
|
3
|
+
#
|
|
4
|
+
# Default (recent): keep the most recent KEEP_MIN non-protected versions, plus any version whose
|
|
5
|
+
# tags match the protected prefix/contains rules. Deletes unless --dry-run is passed.
|
|
6
|
+
#
|
|
7
|
+
# --main-history: for images published as <package>:sha-<40 hex> by merge groups and main. Run from
|
|
8
|
+
# a full-history checkout. Each version is one of:
|
|
9
|
+
# - An image, with a tag other than sha256-*. Only an image whose single tag is sha-<40 hex> is
|
|
10
|
+
# deleted: past the newest GHCR_KEEP_MAIN commits reachable from HEAD, or unreachable (an
|
|
11
|
+
# ejected or failed merge group) once created and updated GHCR_UNMERGED_MIN_AGE_DAYS ago.
|
|
12
|
+
# - An attachment: untagged or tagged only sha256-*. Attestations and their referrer indexes are
|
|
13
|
+
# pushed after their image, and platform manifests moments before it, so an attachment created
|
|
14
|
+
# over a day before the oldest surviving image, and idle for the same age, belongs to none.
|
|
15
|
+
# Packages that do not exist (HTTP 404) are skipped. Defaults to a dry run; pass --apply to delete.
|
|
16
|
+
#
|
|
17
|
+
# Packages come from the arguments, or from GHCR_PACKAGES (space separated) when none are given.
|
|
4
18
|
set -euo pipefail
|
|
5
19
|
|
|
6
20
|
KEEP_MIN="${KEEP_MIN:-10}"
|
|
@@ -8,6 +22,30 @@ OWNER_TYPE="${GHCR_OWNER_TYPE:-user}"
|
|
|
8
22
|
OWNER="${GHCR_OWNER:-}"
|
|
9
23
|
PROTECTED_PREFIX="${PROTECTED_TAG_PREFIX:-latest}"
|
|
10
24
|
PROTECTED_CONTAINS="${PROTECTED_TAG_CONTAINS:-buildcache}"
|
|
25
|
+
keep_main="${GHCR_KEEP_MAIN:-30}"
|
|
26
|
+
min_age_days="${GHCR_UNMERGED_MIN_AGE_DAYS:-7}"
|
|
27
|
+
main_history=false
|
|
28
|
+
apply=''
|
|
29
|
+
|
|
30
|
+
packages=()
|
|
31
|
+
for argument in "$@"; do
|
|
32
|
+
case "$argument" in
|
|
33
|
+
--apply) apply=true ;;
|
|
34
|
+
--dry-run) apply=false ;;
|
|
35
|
+
--main-history) main_history=true ;;
|
|
36
|
+
--*)
|
|
37
|
+
echo "ghcr-package-retention: unknown argument: $argument" >&2
|
|
38
|
+
exit 2
|
|
39
|
+
;;
|
|
40
|
+
*) packages+=("$argument") ;;
|
|
41
|
+
esac
|
|
42
|
+
done
|
|
43
|
+
if [ "${#packages[@]}" -eq 0 ] && [ -n "${GHCR_PACKAGES:-}" ]; then
|
|
44
|
+
read -r -a packages <<< "$GHCR_PACKAGES"
|
|
45
|
+
fi
|
|
46
|
+
if [ -z "$apply" ]; then
|
|
47
|
+
if [ "$main_history" = true ]; then apply=false; else apply=true; fi
|
|
48
|
+
fi
|
|
11
49
|
|
|
12
50
|
case "$KEEP_MIN" in
|
|
13
51
|
*[!0-9]* | '')
|
|
@@ -31,12 +69,19 @@ case "$OWNER_TYPE" in
|
|
|
31
69
|
;;
|
|
32
70
|
esac
|
|
33
71
|
|
|
34
|
-
if [ "
|
|
35
|
-
echo "usage: ghcr-package-retention.sh <url-encoded-package>..." >&2
|
|
72
|
+
if [ "${#packages[@]}" -eq 0 ]; then
|
|
73
|
+
echo "usage: ghcr-package-retention.sh [--main-history] [--apply|--dry-run] <url-encoded-package>..." >&2
|
|
36
74
|
exit 2
|
|
37
75
|
fi
|
|
38
76
|
|
|
39
|
-
|
|
77
|
+
if [ "$main_history" = true ]; then
|
|
78
|
+
# shellcheck source=/dev/null
|
|
79
|
+
. "$(dirname "${BASH_SOURCE[0]}")/ghcr-main-history.sh"
|
|
80
|
+
prune_main_history
|
|
81
|
+
exit 0
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
for pkg in "${packages[@]}"; do
|
|
40
85
|
echo "=== Processing $pkg ==="
|
|
41
86
|
count=0
|
|
42
87
|
while IFS= read -r version_json; do
|
|
@@ -57,8 +102,12 @@ for pkg in "$@"; do
|
|
|
57
102
|
continue
|
|
58
103
|
fi
|
|
59
104
|
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
if [ "$apply" = true ]; then
|
|
106
|
+
printf ' delete: version %s\n' "$version_id"
|
|
107
|
+
gh api -X DELETE "${package_root}/$pkg/versions/$version_id" || true
|
|
108
|
+
else
|
|
109
|
+
printf ' would delete: version %s\n' "$version_id"
|
|
110
|
+
fi
|
|
62
111
|
done < <(gh api \
|
|
63
112
|
"${package_root}/$pkg/versions?per_page=100" \
|
|
64
113
|
--jq '.[] | @json')
|