@staix/agent-mem 0.18.0 → 0.19.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/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -4
- package/package.json +3 -2
- package/src/cli.ts +27 -4
- package/src/commands-table.ts +3 -1
- package/src/hook-contract.ts +9 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +85 -0
- package/src/hook-spool.ts +45 -12
- package/src/hook-transcript.ts +189 -0
- package/src/hooks.ts +132 -61
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +24 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +4 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/search-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +38 -37
- package/src/session-brief.ts +19 -7
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
|
|
2
|
+
export const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
|
|
3
|
+
/** The version that released 0.14.0 through 0.18.0 build into the unversioned names below; it keeps them so this release shares their warm index. */
|
|
4
|
+
const SHARED_INDEX_VERSION = 3;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The derived curated index file of a profile directory, one per `INDEX_VERSION` (#199 AC4): two releases with different index versions on
|
|
8
|
+
* one home each keep their own file instead of dropping and rebuilding the other's. Version 3 keeps the released names (`index.db`, and
|
|
9
|
+
* `index-v2.db` for the v2 public-prune view, whose `v2` is that view's format, not the index version); every later version names its own,
|
|
10
|
+
* `index-v<N>.db` or `index-v2-i<N>.db`. An older version's file is left as it is.
|
|
11
|
+
*/
|
|
12
|
+
export function indexFileName(foreignPruneFormat: boolean, version: number = INDEX_VERSION): string {
|
|
13
|
+
const base = foreignPruneFormat ? "index-v2" : "index";
|
|
14
|
+
return version === SHARED_INDEX_VERSION ? `${base}.db` : foreignPruneFormat ? `${base}-i${version}.db` : `${base}-v${version}.db`;
|
|
15
|
+
}
|
package/src/memory/metrics.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { HookRssSources } from "../hook-resources.ts";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
5
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: "cap" | "deadline" | "spool_full"; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
6
7
|
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
7
8
|
|
|
8
9
|
export function metricsPath(base: string): string {
|
|
@@ -36,8 +37,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired";
|
|
40
|
-
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired"];
|
|
40
|
+
export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
41
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
41
42
|
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
42
43
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
43
44
|
const read = readCaptureDrops(base);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Checkpoints are fault seams after intent, shard marker, selector and intent removal.
|
|
3
3
|
* A caller resumes an interrupted operation from its immutable direct-profile journal. */
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import { indexFileName } from "./index-file.ts";
|
|
5
6
|
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
7
8
|
import { canonicalJSON, fsyncDirectory, hashJSON, readSelector, withProfileLock, writeProfileJSON, type BoundProfile } from "./generation.ts";
|
|
@@ -105,7 +106,7 @@ function immutable(file: string, text: string): void {
|
|
|
105
106
|
linkSync(temp, file); fsyncDirectory(base); unlinkSync(temp); fsyncDirectory(base);
|
|
106
107
|
}
|
|
107
108
|
function syncIndex(profile: BoundProfile): void {
|
|
108
|
-
const file = join(profile.dir,
|
|
109
|
+
const file = join(profile.dir, indexFileName(true)); owned(file); const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
109
110
|
try { fsyncSync(fd); } finally { closeSync(fd); } fsyncDirectory(profile.dir);
|
|
110
111
|
}
|
|
111
112
|
function nextProfile(realm: Realm, intent: Intent): BoundProfile {
|
|
@@ -17,12 +17,13 @@ const DEFAULT_TIER_WEIGHT = 0.9;
|
|
|
17
17
|
// ponytail: fixed 1.25x ceiling for conversational capture, not a learned relevance model.
|
|
18
18
|
// Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
|
|
19
19
|
export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
|
|
20
|
+
const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
|
|
20
21
|
export function captureEventWeight(event: string): number {
|
|
21
|
-
return
|
|
22
|
+
return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
|
|
22
23
|
}
|
|
23
24
|
/** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
|
|
24
25
|
export function captureEventWeightSql(eventColumn: string): string {
|
|
25
|
-
return `(CASE WHEN ${eventColumn} IN ('
|
|
26
|
+
return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
|
|
26
27
|
}
|
|
27
28
|
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
28
29
|
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
package/src/memory/shard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
|
|
2
|
+
import { indexFileName } from "./index-file.ts";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -145,7 +146,7 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
|
|
|
145
146
|
check(dir, true);
|
|
146
147
|
for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
|
|
147
148
|
for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
|
|
148
|
-
for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
|
+
for (const database of new Set(["index.db", "index-v2.db", indexFileName(false), indexFileName(true), "search.db", "mutation-lock.db"])) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/** Open one already-sealed shard without consulting the legacy home or the catalog. */
|
package/src/memory/store.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { readPublicPruneView } from "./public-prune-view.ts";
|
|
3
|
+
import { INDEX_VERSION, indexFileName } from "./index-file.ts";
|
|
2
4
|
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
3
5
|
import { cliAttribution } from "./cli-attribution.ts";
|
|
4
6
|
import { Database } from "bun:sqlite";
|
|
@@ -77,8 +79,6 @@ export type Entry = NoteEntry | OpEntry | GoalEntry;
|
|
|
77
79
|
export const GOAL_STATES = ["open", "active", "blocked", "done", "dropped"] as const;
|
|
78
80
|
export type GoalState = (typeof GOAL_STATES)[number];
|
|
79
81
|
export const DEFAULT_LEASE_MS = 4 * 3600_000;
|
|
80
|
-
/** Bumped when the index's tables change: an index with another version is dropped and rebuilt from the logs. */
|
|
81
|
-
const INDEX_VERSION = 3; // 3: suggest, confirm and reject ops (an older index skipped them)
|
|
82
82
|
export const SUGGESTED = ["contradicts", "same_topic"] as const;
|
|
83
83
|
export type Suggested = (typeof SUGGESTED)[number];
|
|
84
84
|
export interface Suggestion { id: string; at: string; from: string; to: string; rel: Suggested; status: "suggested" | "confirmed" | "rejected" }
|
|
@@ -233,6 +233,7 @@ export class MemoryStore {
|
|
|
233
233
|
this.reconcile = opts.reconcile && opts.reconcile.aliases.length ? { canonical: opts.reconcile.canonical, aliases: [...opts.reconcile.aliases] } : undefined;
|
|
234
234
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
235
235
|
const home = bootstrap?.profile.home ?? opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
236
|
+
assertFormatFloor(home, "curated");
|
|
236
237
|
if (!bootstrap && lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) throw new Error("memory home has a curated split; hosts must use openShardStore({ project }) or openShardStore({ user: true }); finish an incomplete split with amem migrate --shards --resume");
|
|
237
238
|
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
238
239
|
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
@@ -253,7 +254,7 @@ export class MemoryStore {
|
|
|
253
254
|
if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
|
|
254
255
|
let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
|
|
255
256
|
try {
|
|
256
|
-
const indexFile = join(this.dir, this.profile.foreignPruneFormat
|
|
257
|
+
const indexFile = join(this.dir, indexFileName(!!this.profile.foreignPruneFormat));
|
|
257
258
|
if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
|
258
259
|
const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
|
|
259
260
|
if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
|
|
@@ -280,7 +281,7 @@ export class MemoryStore {
|
|
|
280
281
|
// One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
|
|
281
282
|
this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
|
|
282
283
|
this.memoryBaseDir = home;
|
|
283
|
-
try { chmodSync(
|
|
284
|
+
try { chmodSync(indexFile, 0o600); } catch { /* best effort */ }
|
|
284
285
|
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
285
286
|
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
286
287
|
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
@@ -1633,6 +1634,7 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1633
1634
|
if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
|
|
1634
1635
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
1635
1636
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1637
|
+
assertFormatFloor(home, "curated");
|
|
1636
1638
|
assertCuratedShardPaths(home, opts);
|
|
1637
1639
|
// Lifecycle refusal must remain before admission creates any lease/fence file.
|
|
1638
1640
|
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { dirname, join, relative, sep } from "node:path";
|
|
@@ -284,6 +285,7 @@ function captureShardSyncBinding(store: MemoryStore, selection: boolean): string
|
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
288
|
+
assertFormatFloor(store.profile.home, "sync");
|
|
287
289
|
if (store.profile.sharded) {
|
|
288
290
|
try {
|
|
289
291
|
// Refresh only through the existing proved monotone profile rebind before pinning this attempt.
|
package/src/memory/work-lists.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./formats.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { appendFileSync, chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, readdirSync } from "node:fs";
|
|
@@ -131,6 +132,7 @@ export class WorkListStore {
|
|
|
131
132
|
|| !Number.isSafeInteger(this.busyTimeoutMs) || this.busyTimeoutMs < 0 || this.busyTimeoutMs > 3000) throw new Error("invalid work-list budget");
|
|
132
133
|
this.checkBudget();
|
|
133
134
|
const base = canonicalPath(options.base);
|
|
135
|
+
assertFormatFloor(base, "work-lists");
|
|
134
136
|
this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
|
|
135
137
|
regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
|
|
136
138
|
this.checkBudget();
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { BriefOptions } from "./session-brief.ts";
|
|
2
2
|
import type { CaptureStore } from "./memory/capture.ts";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { acceptHookProjectReceipt } from "./hook-project.ts";
|
|
4
|
+
import { writeSync } from "node:fs";
|
|
5
|
+
import { hookRssReading, hookPeakRssKB } from "./hook-resources.ts";
|
|
6
|
+
import { rssSourceCode } from "./hook-rss-wire.ts";
|
|
5
7
|
import { resolveAlias } from "./memory/aliases.ts";
|
|
6
|
-
import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
|
|
7
|
-
import { drainHookCaptures, hookCaptureScope
|
|
8
|
-
import { createStandalonePolicy } from "./memory/policy.ts";
|
|
9
|
-
import { configuredPolicyOptions } from "./memory/config.ts";
|
|
8
|
+
import { HOOK_EVENTS, HOOK_INPUT_BYTES, type HookTranscriptProgress } from "./hook-contract.ts";
|
|
9
|
+
import { drainHookCaptures, hookCaptureScope } from "./hook-spool.ts";
|
|
10
10
|
|
|
11
11
|
async function readOptions(): Promise<string> {
|
|
12
12
|
const reader = Bun.stdin.stream().getReader(), chunks: Uint8Array[] = []; let bytes = 0;
|
|
@@ -25,62 +25,63 @@ async function readOptions(): Promise<string> {
|
|
|
25
25
|
// Fixed package helper, launched and reaped by each native hook; never registered or resident.
|
|
26
26
|
if (import.meta.main) {
|
|
27
27
|
let owned = false;
|
|
28
|
-
|
|
28
|
+
let rssTelemetryValid = true;
|
|
29
|
+
const frame = (rss: number[]) => { try { writeSync(1, JSON.stringify({ rss }) + "\n"); } catch { rssTelemetryValid = false; } };
|
|
30
|
+
const progress = (transcript: HookTranscriptProgress) => { try { writeSync(1, JSON.stringify({ transcript }) + "\n"); } catch { rssTelemetryValid = false; } };
|
|
31
|
+
frame([0]);
|
|
29
32
|
const stop = (): never => {
|
|
33
|
+
if (!rssTelemetryValid) frame([4]);
|
|
34
|
+
const self = hookRssReading(); frame([3, self.kb, self.rawKB, rssSourceCode(self)]);
|
|
30
35
|
// This leader is still alive in the detached group created by hookWorker.
|
|
31
|
-
// Native Git
|
|
32
|
-
// a blocked Git cannot prevent this worker's JS deadline from firing.
|
|
36
|
+
// Native Git is owned and reaped by the CLI parent, never by this worker.
|
|
33
37
|
if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
|
|
34
38
|
process.exit(0);
|
|
35
39
|
};
|
|
36
40
|
const emit = async (result: unknown) => {
|
|
37
|
-
const body = JSON.stringify({ ...(result as object), complete: true });
|
|
38
|
-
const rssKB = hookPeakRssKB()
|
|
41
|
+
const body = JSON.stringify({ ...(result as object), complete: true, rssTelemetryValid });
|
|
42
|
+
const rssKB = hookPeakRssKB();
|
|
39
43
|
await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
|
|
40
44
|
stop();
|
|
41
45
|
};
|
|
42
46
|
try {
|
|
43
|
-
const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; deadlineAt: number; ownedGroup: boolean };
|
|
47
|
+
const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; identity?: unknown; transcript?: unknown; deadlineAt: number; ownedGroup: boolean };
|
|
44
48
|
if (!Number.isFinite(options.deadlineAt) || options.ownedGroup !== true) process.exit(0);
|
|
45
49
|
owned = true;
|
|
46
50
|
const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
const deadlineStop = () => { if (options.transcript !== undefined) progress({ stopReason: "deadline" }); stop(); };
|
|
52
|
+
if (remaining() <= 0) deadlineStop();
|
|
53
|
+
setTimeout(deadlineStop, remaining()).unref();
|
|
54
|
+
const event = options.event;
|
|
50
55
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
|
|
51
56
|
const expectedScope = hookCaptureScope(options.cwd, options.base!);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// A receipt is single-use parent IPC. This worker must never launch a native helper.
|
|
58
|
+
if (event === "SessionStart") stop();
|
|
59
|
+
let project: string | undefined;
|
|
60
|
+
try { project = resolveAlias(options.base!, acceptHookProjectReceipt(options.identity, { cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining })); } catch { /* Independently bound transcript intake can still proceed. */ }
|
|
61
|
+
if ((event === "Stop" || event === "SessionEnd") && options.transcript !== undefined
|
|
62
|
+
&& (project !== undefined || expectedScope !== undefined && hookCaptureScope(options.cwd, options.base!) === expectedScope)) {
|
|
63
|
+
try {
|
|
64
|
+
const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
|
|
65
|
+
const scanned = await scanHookTranscriptTail(options.transcript, { cwd: options.cwd, base: options.base!, policy: options.policy,
|
|
66
|
+
deadline: Math.min(performance.now() + remaining() - 20, performance.now() + 60) });
|
|
67
|
+
if (scanned) progress(scanned);
|
|
68
|
+
} catch { /* Ordinary durable capture is independent. */ }
|
|
61
69
|
}
|
|
70
|
+
if (project === undefined) stop();
|
|
62
71
|
let captured = false;
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
if (expectedScope !== undefined && (event !== "SessionStart" || !options.current)) {
|
|
72
|
+
// An empty current name selects bounded full-tier recovery; SessionStart stays in the parent.
|
|
73
|
+
if (expectedScope !== undefined) {
|
|
66
74
|
const { CaptureStore } = await import("./memory/capture.ts");
|
|
67
75
|
let store: CaptureStore | undefined;
|
|
68
76
|
try {
|
|
69
|
-
const drained = await drainHookCaptures({ ...options, base: options.base!, project
|
|
70
|
-
store ??= new CaptureStore({ memoryDir: options.base!, project
|
|
77
|
+
const drained = await drainHookCaptures({ ...options, base: options.base!, project: project!, expectedScope, timeRemaining: remaining }, (input, receipt) => {
|
|
78
|
+
store ??= new CaptureStore({ memoryDir: options.base!, project: project!, busyTimeoutMs: 0, cwd: options.cwd });
|
|
71
79
|
return store.append(input, { receipt });
|
|
72
80
|
});
|
|
73
81
|
captured = drained.captured.includes(options.current);
|
|
74
82
|
} finally { store?.close(); }
|
|
75
83
|
}
|
|
76
84
|
if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
|
|
77
|
-
|
|
78
|
-
const { sessionStartBrief } = await import("./session-brief.ts");
|
|
79
|
-
const result = await sessionStartBrief({ ...options, project,
|
|
80
|
-
timeRemaining: () => options.deadlineAt - (performance.timeOrigin + performance.now()) });
|
|
81
|
-
const valid = await policy.screen({ context: result.context });
|
|
82
|
-
if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
|
|
83
|
-
await emit({ ...result, captured, valid });
|
|
84
|
-
} else await emit({ context: "", captured, valid: true });
|
|
85
|
+
await emit({ context: "", captured, valid: true });
|
|
85
86
|
} catch { if (owned) stop(); process.exitCode = 1; }
|
|
86
87
|
}
|
package/src/session-brief.ts
CHANGED
|
@@ -152,13 +152,13 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
152
152
|
let candidateLine: string | undefined;
|
|
153
153
|
const line = pendingLine(pendingSummary(base, [project, ...aliases], (options.now ?? Date.now)()));
|
|
154
154
|
if (line !== undefined && Buffer.byteLength(line) + 1 <= remaining) { remaining -= Buffer.byteLength(line) + 1; candidateLine = line; }
|
|
155
|
-
const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean) => {
|
|
156
|
-
if (!await screen(shown.data)) return;
|
|
155
|
+
const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean): Promise<boolean> => {
|
|
156
|
+
if (!await screen(shown.data)) return false;
|
|
157
157
|
coverage[section] = shown.partial ? "partial" : coverage[section] ?? "complete";
|
|
158
158
|
const line = JSON.stringify({ section, evidence, data: shown.data });
|
|
159
159
|
const size = Buffer.byteLength(line) + 1;
|
|
160
|
-
if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return; }
|
|
161
|
-
lines.push(line); remaining -= size;
|
|
160
|
+
if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return false; }
|
|
161
|
+
lines.push(line); remaining -= size; return true;
|
|
162
162
|
};
|
|
163
163
|
const run = async (section: Section, work: () => Promise<void>) => {
|
|
164
164
|
const firstLine = lines.length, priorRemaining = remaining;
|
|
@@ -193,14 +193,15 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
193
193
|
}
|
|
194
194
|
});
|
|
195
195
|
const recent = async <T>(section: "curated" | "capture", read: (window: ReadWindow) => { rows: T[]; next?: ReadRef },
|
|
196
|
-
display: (row: T) => { data: unknown; partial: boolean }) => {
|
|
197
|
-
let after: ReadWindow["after"], selected =
|
|
196
|
+
display: (row: T) => { data: unknown; partial: boolean }, selectedBefore = 0, skipCaptureIds: ReadonlySet<number> = new Set()) => {
|
|
197
|
+
let after: ReadWindow["after"], selected = selectedBefore, complete = false;
|
|
198
198
|
for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
|
|
199
199
|
guard();
|
|
200
200
|
if (options.nativeSession && options.timeRemaining && options.timeRemaining() <= 0) throw new Error("brief deadline");
|
|
201
201
|
const page = read({ project, limit: PAGE_ROWS, after });
|
|
202
202
|
for (const row of page.rows) {
|
|
203
203
|
if ((row as { project?: unknown }).project !== project) { coverage[section] = "partial"; continue; }
|
|
204
|
+
if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
|
|
204
205
|
// #173: subagent capture stays searchable but never enters the session brief.
|
|
205
206
|
if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
|
|
206
207
|
if (!await screen(row)) continue;
|
|
@@ -249,7 +250,18 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
249
250
|
}
|
|
250
251
|
const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close());
|
|
251
252
|
if (capture.recentReadTruncated) coverage.capture = "partial";
|
|
252
|
-
|
|
253
|
+
const summaries = capture.readBriefSummaries(), summaryIds = new Set<number>();
|
|
254
|
+
if (summaries.partial) coverage.capture = "partial";
|
|
255
|
+
let selectedSummaries = 0;
|
|
256
|
+
for (const row of summaries.rows) {
|
|
257
|
+
if (row.project !== project) continue;
|
|
258
|
+
if (row.subagent === true) { summaryIds.add(row.id); continue; }
|
|
259
|
+
if (selectedSummaries >= 2) break;
|
|
260
|
+
if (!await screen(row)) { summaryIds.add(row.id); continue; }
|
|
261
|
+
summaryIds.add(row.id);
|
|
262
|
+
if (await add("capture", captureDisplay(row), false)) selectedSummaries++;
|
|
263
|
+
}
|
|
264
|
+
await recent("capture", window => capture.readPage(window), captureDisplay, selectedSummaries, summaryIds);
|
|
253
265
|
});
|
|
254
266
|
guard();
|
|
255
267
|
const context = [header(coverage, reasons), ...(candidateLine ? [candidateLine] : []), ...lines].join("\n");
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import type { SetupTarget } from "./setup.ts";
|
|
6
|
+
import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
|
|
7
|
+
import { managedAfter, managedHookEntries, mcpRecord, type ConfigImage } from "./setup-owned.ts";
|
|
8
|
+
import { resolveSetupPaths, type SetupEnvironment } from "./setup-paths.ts";
|
|
9
|
+
import { HOOK_EVENTS } from "./hook-contract.ts";
|
|
10
|
+
import { realPath } from "./memory/platform.ts";
|
|
11
|
+
|
|
12
|
+
export type SetupDriftStatus = "ok" | "attention" | "skipped" | "unavailable";
|
|
13
|
+
export interface SetupDriftEntry {
|
|
14
|
+
target: SetupTarget;
|
|
15
|
+
file: string;
|
|
16
|
+
entry: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
kind: "drift" | "unavailable" | "review-needed";
|
|
19
|
+
expectedFile?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SetupDriftReport {
|
|
22
|
+
status: SetupDriftStatus;
|
|
23
|
+
checked: number;
|
|
24
|
+
entries: SetupDriftEntry[];
|
|
25
|
+
reason?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ReceiptChange { key: string; path: string; before: ConfigImage; after: ConfigImage }
|
|
29
|
+
interface Receipt { version: 1; target: SetupTarget; phase: "prepared" | "applied"; changes: ReceiptChange[] }
|
|
30
|
+
const JOURNAL_BYTES = 12 * SETUP_CONFIG_BYTES;
|
|
31
|
+
// ponytail: at most 32 MiB of receipt/config input per doctor call; use metadata-only receipts before widening.
|
|
32
|
+
const READ_BYTES = 32 * 1024 * 1024;
|
|
33
|
+
const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
34
|
+
const plain = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
|
|
36
|
+
function safeImage(path: string, max = SETUP_CONFIG_BYTES, budget?: { remaining: number }): ConfigImage {
|
|
37
|
+
let cursor = resolve(path);
|
|
38
|
+
while (true) {
|
|
39
|
+
try { if (lstatSync(cursor).isSymbolicLink()) throw Error("unsafe"); }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
41
|
+
const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
|
|
42
|
+
}
|
|
43
|
+
let fd: number;
|
|
44
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
45
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, mode: 0o600 }; throw error; }
|
|
46
|
+
try {
|
|
47
|
+
const before = fstatSync(fd);
|
|
48
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > max || budget && before.size > budget.remaining) throw Error("unsafe");
|
|
49
|
+
if (budget) budget.remaining -= before.size;
|
|
50
|
+
const data = Buffer.alloc(before.size + 1), size = readSync(fd, data, 0, data.length, 0), after = fstatSync(fd);
|
|
51
|
+
if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== before.ino || after.dev !== before.dev) throw Error("changed");
|
|
52
|
+
return { bytes: data.subarray(0, size).toString("base64"), mode: before.mode & 0o777 };
|
|
53
|
+
} finally { closeSync(fd); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseImage(value: unknown): ConfigImage {
|
|
57
|
+
if (!plain(value) || !Number.isInteger(value.mode) || Number(value.mode) < 0 || Number(value.mode) > 0o777) throw Error("invalid receipt");
|
|
58
|
+
if (value.bytes !== null && (typeof value.bytes !== "string" || value.bytes.length > 2 * SETUP_CONFIG_BYTES || Buffer.from(value.bytes, "base64").toString("base64") !== value.bytes)) throw Error("invalid receipt");
|
|
59
|
+
return { bytes: value.bytes as string | null, mode: Number(value.mode) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseReceipt(value: unknown, target: SetupTarget, expectedPaths: readonly string[]): Receipt {
|
|
63
|
+
if (!plain(value) || value.version !== 1 || value.target !== target || !/^[a-f0-9]{64}$/.test(String(value.intent)) || !["applied", "prepared"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== expectedPaths.length && !(target === "jcode" && value.changes.length === 1)) throw Error("invalid receipt");
|
|
64
|
+
const changes: ReceiptChange[] = [];
|
|
65
|
+
for (let index = 0; index < value.changes.length; index++) {
|
|
66
|
+
const item: unknown = value.changes[index];
|
|
67
|
+
if (!plain(item) || item.key !== `${target}:${index}` || typeof item.path !== "string" || !isAbsolute(item.path) || item.path.length > 4096 || item.path.includes("\0")) throw Error("invalid receipt");
|
|
68
|
+
changes.push({ key: `${target}:${index}`, path: item.path, before: parseImage(item.before), after: parseImage(item.after) });
|
|
69
|
+
}
|
|
70
|
+
return { version: 1, target, phase: value.phase as Receipt["phase"], changes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function workspaceRoot(cwd: string): string | undefined {
|
|
74
|
+
let current = realPath(resolve(cwd));
|
|
75
|
+
while (true) {
|
|
76
|
+
try {
|
|
77
|
+
const marker = lstatSync(join(current, ".git"));
|
|
78
|
+
if (marker.isDirectory() || marker.isFile()) return realPath(current);
|
|
79
|
+
} catch { /* Continue toward the filesystem root. */ }
|
|
80
|
+
const parent = dirname(current); if (parent === current) return undefined; current = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
85
|
+
function launch(record: unknown): string | undefined {
|
|
86
|
+
if (!plain(record) || typeof record.command !== "string" || !Array.isArray(record.args) || !record.args.every(arg => typeof arg === "string")) return undefined;
|
|
87
|
+
const args = [...record.args as string[]];
|
|
88
|
+
if (args.at(-1) === "mcp") args.pop();
|
|
89
|
+
return [shellQuote(record.command), ...args.map(arg => arg === "--smol" ? arg : shellQuote(arg))].join(" ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expectedManagedHookCommands(target: SetupTarget, after: ConfigImage): { event: string; command: string }[] {
|
|
93
|
+
if (["jcode", "opencode", "gemini", "agy"].includes(target)) return [];
|
|
94
|
+
const base = launch(mcpRecord(target, after));
|
|
95
|
+
if (!base) return [];
|
|
96
|
+
if (target === "claude") return HOOK_EVENTS.map(event => ({ event, command: `${base} hook ${event}` }));
|
|
97
|
+
if (target === "pi") return [];
|
|
98
|
+
return [{ event: "SessionEnd", command: `${base} ingest ${target}${target === "codex" || target === "kimi" || target === "qwen" ? " --json" : ""}` }];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Codex stores trust in config.toml under hooks.state, keyed by hooks.json path and handler position. */
|
|
102
|
+
function codexMissingTrust(hooksPath: string, hooksImage: ConfigImage, configImage: ConfigImage, expected: { event: string; command: string }[]): string[] {
|
|
103
|
+
if (hooksImage.bytes === null || configImage.bytes === null) return [];
|
|
104
|
+
const hooksDocument: unknown = JSON.parse(Buffer.from(hooksImage.bytes, "base64").toString("utf8"));
|
|
105
|
+
const configDocument: unknown = Bun.TOML.parse(Buffer.from(configImage.bytes, "base64").toString("utf8"));
|
|
106
|
+
if (!plain(hooksDocument) || !plain(configDocument)) throw Error("invalid Codex configuration");
|
|
107
|
+
const groups: unknown[] = plain(hooksDocument.hooks) && Array.isArray(hooksDocument.hooks.SessionEnd) ? hooksDocument.hooks.SessionEnd : [];
|
|
108
|
+
const stateRoot: Record<string, unknown> = plain(configDocument.hooks) && plain(configDocument.hooks.state) ? configDocument.hooks.state : Object.create(null);
|
|
109
|
+
const wanted = new Set(expected.filter(row => row.event === "SessionEnd").map(row => row.command));
|
|
110
|
+
const missing: string[] = [];
|
|
111
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
112
|
+
const group = groups[groupIndex]; if (!plain(group) || !Array.isArray(group.hooks)) continue;
|
|
113
|
+
for (let handlerIndex = 0; handlerIndex < group.hooks.length; handlerIndex++) {
|
|
114
|
+
const handler = group.hooks[handlerIndex];
|
|
115
|
+
if (!plain(handler) || typeof handler.command !== "string" || !wanted.has(handler.command)) continue;
|
|
116
|
+
const key = `${hooksPath}:session_end:${groupIndex}:${handlerIndex}`;
|
|
117
|
+
const state = stateRoot[key];
|
|
118
|
+
// Presence is not proof that the stored digest still matches; amem only detects a missing record.
|
|
119
|
+
if (!plain(state) || typeof state.trusted_hash !== "string" || !state.trusted_hash || state.enabled === false) missing.push(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return missing;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function entry(target: SetupTarget, file: string, name: string, reason: string, kind: SetupDriftEntry["kind"]): SetupDriftEntry {
|
|
126
|
+
return { target, file, entry: name, reason, kind };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Read setup receipts and only the resolver-selected native config files. This function never writes. */
|
|
130
|
+
export function readSetupDrift(options: { home: string; env?: SetupEnvironment; cwd?: string }): SetupDriftReport {
|
|
131
|
+
const env = options.env ?? process.env;
|
|
132
|
+
const root = workspaceRoot(options.cwd ?? process.cwd());
|
|
133
|
+
let resolved: ReturnType<typeof resolveSetupPaths>;
|
|
134
|
+
try { resolved = resolveSetupPaths(env, undefined, root); }
|
|
135
|
+
catch { return { status: "unavailable", checked: 0, entries: [], reason: "setup paths could not be resolved" }; }
|
|
136
|
+
const entries: SetupDriftEntry[] = [], budget = { remaining: READ_BYTES };
|
|
137
|
+
let checked = 0, unavailable = false, anyReceipt = false;
|
|
138
|
+
for (const target of Object.keys(resolved.paths) as SetupTarget[]) {
|
|
139
|
+
const expectedPaths = resolved.paths[target];
|
|
140
|
+
if (!expectedPaths.length) continue;
|
|
141
|
+
const receiptPath = join(options.home, "setup", target === "agy" ? `agy-${hash(root)}.json` : `${target}.json`);
|
|
142
|
+
let raw: ConfigImage;
|
|
143
|
+
try { raw = safeImage(receiptPath, JOURNAL_BYTES, budget); }
|
|
144
|
+
catch {
|
|
145
|
+
entries.push(entry(target, receiptPath, "receipt", "receipt cannot be read safely or exceeds the read bound", "unavailable")); unavailable = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (raw.bytes === null) continue;
|
|
149
|
+
let receipt: Receipt;
|
|
150
|
+
try {
|
|
151
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(raw.bytes, "base64")));
|
|
152
|
+
if (plain(value) && value.target === target && value.phase === "prepared") continue;
|
|
153
|
+
anyReceipt = true; checked++;
|
|
154
|
+
receipt = parseReceipt(value, target, expectedPaths);
|
|
155
|
+
} catch {
|
|
156
|
+
entries.push(entry(target, receiptPath, "receipt", "receipt is invalid or unavailable", "unavailable")); unavailable = true; continue;
|
|
157
|
+
}
|
|
158
|
+
// Never open a manifest-selected file before the full set of paths matches the shared resolver.
|
|
159
|
+
const mismatches = receipt.changes.filter((change, index) => change.path !== expectedPaths[index]);
|
|
160
|
+
if (mismatches.length) {
|
|
161
|
+
for (const change of mismatches) entries.push({ ...entry(target, change.path, change.key,
|
|
162
|
+
"manifest path no longer matches the resolved target; referenced files were not opened", "drift"),
|
|
163
|
+
expectedFile: expectedPaths[receipt.changes.indexOf(change)] });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const liveImages: (ConfigImage | undefined)[] = [];
|
|
167
|
+
for (const change of receipt.changes) {
|
|
168
|
+
let current: ConfigImage;
|
|
169
|
+
try { current = safeImage(change.path, SETUP_CONFIG_BYTES, budget); }
|
|
170
|
+
catch {
|
|
171
|
+
liveImages.push(undefined);
|
|
172
|
+
entries.push(entry(target, change.path, change.key, "resolved configuration cannot be read safely", "unavailable")); unavailable = true; continue;
|
|
173
|
+
}
|
|
174
|
+
liveImages.push(current);
|
|
175
|
+
const isMcp = change.key === `${target}:0`;
|
|
176
|
+
if (isMcp) {
|
|
177
|
+
const name = target === "codex" ? "mcp_servers.amem" : target === "jcode" ? "servers.amem" : target === "opencode" ? "mcp.amem" : "mcpServers.amem";
|
|
178
|
+
try {
|
|
179
|
+
const expected = mcpRecord(target, change.after);
|
|
180
|
+
if (expected === undefined) throw Error("receipt has no MCP registration");
|
|
181
|
+
if (!isDeepStrictEqual(expected, mcpRecord(target, current))) entries.push(entry(target, change.path, name, "managed MCP registration is missing or changed", "drift"));
|
|
182
|
+
} catch { entries.push(entry(target, change.path, name, "managed MCP registration could not be checked", "unavailable")); unavailable = true; }
|
|
183
|
+
}
|
|
184
|
+
const hasHookSurface = target === "qwen" && change.key === `${target}:0` || target === "claude" && change.key === `${target}:1` || target === "codex" && change.key === `${target}:1` || target === "kimi" && change.key === `${target}:1`;
|
|
185
|
+
if (hasHookSurface) {
|
|
186
|
+
try {
|
|
187
|
+
const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
|
|
188
|
+
for (const row of managedHookEntries(target, change.before, change.after, current, expected)) {
|
|
189
|
+
if (!row.present) entries.push(entry(target, change.path, row.entry, "managed hook is missing or changed", "drift"));
|
|
190
|
+
}
|
|
191
|
+
} catch { entries.push(entry(target, change.path, "hooks", "managed hooks could not be checked", "unavailable")); unavailable = true; }
|
|
192
|
+
}
|
|
193
|
+
if (target === "pi" && change.key === "pi:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "session_shutdown extension", "managed hook extension is missing or changed", "drift"));
|
|
194
|
+
// #203: jcode's capture hooks are command elements in config.toml [hooks]; a pre-#203 receipt has no such change.
|
|
195
|
+
if (target === "jcode" && change.key === "jcode:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "hooks", "managed hook is missing or changed", "drift"));
|
|
196
|
+
if (target === "codex" && change.key === "codex:1") {
|
|
197
|
+
// Codex owns trust state. Doctor checks only whether an entry exists; it never writes or verifies trusted_hash.
|
|
198
|
+
const runtimePaths = resolved.redirects.some(row => row.target === "codex") ? resolved.legacyPaths.codex : expectedPaths;
|
|
199
|
+
const configPath = runtimePaths[0]!, hooksPath = runtimePaths[1]!;
|
|
200
|
+
try {
|
|
201
|
+
const configImage = configPath === receipt.changes[0]!.path ? liveImages[0] : safeImage(configPath, SETUP_CONFIG_BYTES, budget);
|
|
202
|
+
if (!configImage) throw Error("Codex config is unavailable");
|
|
203
|
+
const hookImage = hooksPath === change.path ? current : safeImage(hooksPath, SETUP_CONFIG_BYTES, budget);
|
|
204
|
+
const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
|
|
205
|
+
if (hooksPath !== change.path && (hookImage.bytes === null || configImage.bytes === null || managedHookEntries(target, change.before, change.after, hookImage, expected).some(row => !row.present))) {
|
|
206
|
+
entries.push(entry(target, hooksPath, "hooks.SessionEnd", "personal hook source is waiting for the managed runtime copy; start a new Orca session, then review the hook", "review-needed"));
|
|
207
|
+
} else {
|
|
208
|
+
for (const key of codexMissingTrust(hooksPath, hookImage, configImage, expected)) entries.push(entry(target, configPath, `hooks.state.${key}`, "Codex hook has no enabled trust record; treat it as untrusted and review it in Codex", "review-needed"));
|
|
209
|
+
}
|
|
210
|
+
} catch { entries.push(entry(target, configPath, "hooks.state", "Codex hook trust could not be checked safely", "unavailable")); unavailable = true; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const status: SetupDriftStatus = entries.some(value => value.kind === "drift" || value.kind === "review-needed") ? "attention"
|
|
215
|
+
: unavailable ? "unavailable" : anyReceipt ? "ok" : "skipped";
|
|
216
|
+
return { status, checked, entries, ...(!anyReceipt && !entries.length ? { reason: "no applied setup receipts found" } : {}) };
|
|
217
|
+
}
|