yam-flow 2.8.1 → 2.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +25 -0
- package/COMMANDS.md +7 -2
- package/README.md +12 -3
- package/dist/bin/yam.js +171 -76
- package/dist/lib/design-production.d.ts +6 -6
- package/dist/lib/external-updates.d.ts +6 -1
- package/dist/lib/external-updates.js +93 -6
- package/dist/lib/hook-readiness.d.ts +54 -0
- package/dist/lib/hook-readiness.js +259 -0
- package/dist/lib/memory-records.d.ts +29 -0
- package/dist/lib/memory-records.js +92 -0
- package/dist/lib/study-note-scope.d.ts +8 -0
- package/dist/lib/study-note-scope.js +165 -0
- package/dist/lib/verification-closure.d.ts +1 -1
- package/package.json +8 -3
- package/references/context-reuse.md +1 -0
- package/references/final-report.md +3 -1
- package/references/next-step.md +2 -0
- package/references/security-check.md +2 -0
- package/references/study-note.md +4 -0
- package/skills/deep/SKILL.md +1 -0
- package/skills/mission/SKILL.md +1 -0
- package/skills/question/SKILL.md +1 -0
- package/skills/quick/SKILL.md +2 -1
- package/skills/scout/SKILL.md +1 -0
- package/skills/ueye/SKILL.md +1 -0
- package/src/bin/yam.ts +151 -74
- package/src/lib/external-updates.ts +87 -6
- package/src/lib/hook-readiness.ts +215 -0
- package/src/lib/memory-records.ts +109 -0
- package/src/lib/study-note-scope.ts +134 -0
- package/yam.manifest.json +4 -3
|
@@ -129,7 +129,7 @@ export declare function createPlanReviewCanvas(input: {
|
|
|
129
129
|
visual_correctness: string;
|
|
130
130
|
implementation_correctness: string;
|
|
131
131
|
};
|
|
132
|
-
truth_status: "
|
|
132
|
+
truth_status: "partial" | "verified";
|
|
133
133
|
}>;
|
|
134
134
|
export declare function addPlanReviewComment(input: {
|
|
135
135
|
root: string;
|
|
@@ -147,7 +147,7 @@ export declare function addPlanReviewComment(input: {
|
|
|
147
147
|
visual_correctness: string;
|
|
148
148
|
implementation_correctness: string;
|
|
149
149
|
};
|
|
150
|
-
truth_status: "
|
|
150
|
+
truth_status: "partial" | "verified";
|
|
151
151
|
}>;
|
|
152
152
|
export declare function closePlanReviewCanvas(input: {
|
|
153
153
|
root: string;
|
|
@@ -165,7 +165,7 @@ export declare function closePlanReviewCanvas(input: {
|
|
|
165
165
|
visual_correctness: string;
|
|
166
166
|
implementation_correctness: string;
|
|
167
167
|
};
|
|
168
|
-
truth_status: "
|
|
168
|
+
truth_status: "partial" | "verified";
|
|
169
169
|
}>;
|
|
170
170
|
export declare function readPlanReviewSession(file: string): Promise<PlanReviewSession>;
|
|
171
171
|
export declare function createDesignRevisionState(input: {
|
|
@@ -228,7 +228,7 @@ export declare function verifyFinalGalleryManifest(input: {
|
|
|
228
228
|
implementation_correctness: string;
|
|
229
229
|
};
|
|
230
230
|
next_action: string;
|
|
231
|
-
truth_status: "
|
|
231
|
+
truth_status: "partial" | "blocked";
|
|
232
232
|
}>;
|
|
233
233
|
export declare function finalizeDesignProductionPhase(input: {
|
|
234
234
|
root: string;
|
|
@@ -283,7 +283,7 @@ export declare function finalizeDesignProductionPhase(input: {
|
|
|
283
283
|
server: false;
|
|
284
284
|
background_service: false;
|
|
285
285
|
};
|
|
286
|
-
phase_evidence_status: "complete" | "
|
|
286
|
+
phase_evidence_status: "complete" | "incomplete" | "bounded_stop";
|
|
287
287
|
ready: boolean;
|
|
288
288
|
ready_to_claim_correctness: boolean;
|
|
289
289
|
next_action: string;
|
|
@@ -301,6 +301,6 @@ export declare function verifyDesignProductionPhaseReceipt(input: {
|
|
|
301
301
|
receipt_digest: string;
|
|
302
302
|
ready_to_claim_correctness: boolean;
|
|
303
303
|
next_action: string;
|
|
304
|
-
truth_status: "
|
|
304
|
+
truth_status: "partial" | "blocked";
|
|
305
305
|
}>;
|
|
306
306
|
export {};
|
|
@@ -22,6 +22,7 @@ export interface ExternalUpdatePaths {
|
|
|
22
22
|
export interface ExternalUpdateDependencies {
|
|
23
23
|
run?: (command: string, args: string[], options?: ExternalCommandOptions) => Promise<ExternalCommandResult> | ExternalCommandResult;
|
|
24
24
|
fetchJson?: (url: string) => Promise<any>;
|
|
25
|
+
probeArtifact?: (url: string) => Promise<number>;
|
|
25
26
|
now?: () => Date;
|
|
26
27
|
homeDir?: string;
|
|
27
28
|
env?: NodeJS.ProcessEnv;
|
|
@@ -33,7 +34,7 @@ export interface ExternalComponentCheck {
|
|
|
33
34
|
installed_version: string;
|
|
34
35
|
latest_version: string;
|
|
35
36
|
update_available: boolean;
|
|
36
|
-
status: 'up_to_date' | 'update_available' | 'not_installed' | 'check_failed';
|
|
37
|
+
status: 'up_to_date' | 'update_available' | 'not_installed' | 'pending_distribution' | 'check_failed';
|
|
37
38
|
source: string;
|
|
38
39
|
source_revision?: {
|
|
39
40
|
local: string;
|
|
@@ -132,4 +133,8 @@ export declare function applyExternalUpdates(currentYamVersion: string, selectio
|
|
|
132
133
|
truth_status: string;
|
|
133
134
|
next_action: string;
|
|
134
135
|
}>;
|
|
136
|
+
export declare class RegistryHttpError extends Error {
|
|
137
|
+
readonly status: number;
|
|
138
|
+
constructor(status: number, url: string);
|
|
139
|
+
}
|
|
135
140
|
export {};
|
|
@@ -22,7 +22,7 @@ export async function checkExternalUpdates(currentYamVersion, dependencies = {})
|
|
|
22
22
|
checkScrapling(deps),
|
|
23
23
|
checkInsaneSearch(deps)
|
|
24
24
|
]);
|
|
25
|
-
const failed = checks.filter((item) => item.status === 'check_failed');
|
|
25
|
+
const failed = checks.filter((item) => item.status === 'check_failed' || item.status === 'pending_distribution');
|
|
26
26
|
return {
|
|
27
27
|
schema: 'yam.external-update-check.v1',
|
|
28
28
|
generated_at: deps.now().toISOString(),
|
|
@@ -212,6 +212,7 @@ async function resolveDependencies(input) {
|
|
|
212
212
|
return {
|
|
213
213
|
run,
|
|
214
214
|
fetchJson: input.fetchJson || defaultFetchJson,
|
|
215
|
+
probeArtifact: input.probeArtifact || defaultProbeArtifact,
|
|
215
216
|
now: input.now || (() => new Date()),
|
|
216
217
|
homeDir,
|
|
217
218
|
env,
|
|
@@ -267,18 +268,57 @@ async function acquireApplyLock(deps) {
|
|
|
267
268
|
return handle;
|
|
268
269
|
}
|
|
269
270
|
async function checkYam(currentYamVersion, deps) {
|
|
271
|
+
let pending;
|
|
270
272
|
try {
|
|
271
273
|
const data = await deps.fetchJson(YAM_REGISTRY_URL);
|
|
272
274
|
const latest = requireStableVersion(data?.version, 'yam registry version');
|
|
273
275
|
const installed = requireStableVersion(currentYamVersion, 'installed yam version');
|
|
274
276
|
const integrity = requireIntegrityValue(data?.dist?.integrity, 'yam registry integrity');
|
|
275
277
|
const revision = requireGitRevision(data?.gitHead, 'yam registry gitHead');
|
|
278
|
+
if (data?.name !== 'yam-flow')
|
|
279
|
+
throw new Error('yam registry package name mismatch');
|
|
280
|
+
const tarball = requireYamTarball(data?.dist?.tarball, latest);
|
|
281
|
+
const exactUrl = `${NPM_REGISTRY}yam-flow/${latest}`;
|
|
282
|
+
pending = {
|
|
283
|
+
...componentCheck('yam', installed, latest, 'npm:yam-flow'),
|
|
284
|
+
status: 'pending_distribution',
|
|
285
|
+
error: 'pending_distribution: exact-version metadata or tarball returned HTTP 404; retry the explicit update later',
|
|
286
|
+
source_receipt: {
|
|
287
|
+
canonical_url: YAM_REGISTRY_URL,
|
|
288
|
+
retrieved_at: deps.now().toISOString(),
|
|
289
|
+
access_path: 'npm latest observed; exact-version distribution readiness incomplete',
|
|
290
|
+
version: latest,
|
|
291
|
+
revision,
|
|
292
|
+
integrity: [integrity]
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
let exact;
|
|
296
|
+
try {
|
|
297
|
+
exact = await deps.fetchJson(exactUrl);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error instanceof RegistryHttpError && error.status === 404)
|
|
301
|
+
return pending;
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (exact?.name !== 'yam-flow' || data?.name !== 'yam-flow'
|
|
305
|
+
|| requireStableVersion(exact?.version, 'yam exact registry version') !== latest
|
|
306
|
+
|| requireIntegrityValue(exact?.dist?.integrity, 'yam exact registry integrity') !== integrity
|
|
307
|
+
|| requireGitRevision(exact?.gitHead, 'yam exact registry gitHead') !== revision
|
|
308
|
+
|| requireYamTarball(exact?.dist?.tarball, latest) !== tarball) {
|
|
309
|
+
throw new Error('yam latest and exact-version registry metadata disagree');
|
|
310
|
+
}
|
|
311
|
+
const artifactStatus = await deps.probeArtifact(tarball);
|
|
312
|
+
if (artifactStatus === 404)
|
|
313
|
+
return pending;
|
|
314
|
+
if (artifactStatus !== 200)
|
|
315
|
+
throw new Error(`yam tarball availability check failed: HTTP ${artifactStatus}`);
|
|
276
316
|
return {
|
|
277
317
|
...componentCheck('yam', installed, latest, 'npm:yam-flow'),
|
|
278
318
|
source_receipt: {
|
|
279
319
|
canonical_url: YAM_REGISTRY_URL,
|
|
280
320
|
retrieved_at: deps.now().toISOString(),
|
|
281
|
-
access_path: 'npm
|
|
321
|
+
access_path: 'npm latest + exact-version metadata + bounded no-redirect tarball HEAD (availability only)',
|
|
282
322
|
version: latest,
|
|
283
323
|
revision,
|
|
284
324
|
integrity: [integrity]
|
|
@@ -446,7 +486,7 @@ function failedCheck(component, installed, source, error) {
|
|
|
446
486
|
}
|
|
447
487
|
async function applyYam(currentYamVersion, deps) {
|
|
448
488
|
const check = await checkYam(currentYamVersion, deps);
|
|
449
|
-
if (check.status === 'check_failed')
|
|
489
|
+
if (check.status === 'check_failed' || check.status === 'pending_distribution')
|
|
450
490
|
return persistFailureFromCheck(check, deps);
|
|
451
491
|
if (!check.update_available) {
|
|
452
492
|
return persistReceipt(baseReceipt(check, 'up_to_date', [], {
|
|
@@ -1378,15 +1418,62 @@ async function defaultRun(command, args, options = {}) {
|
|
|
1378
1418
|
}
|
|
1379
1419
|
async function defaultFetchJson(url) {
|
|
1380
1420
|
const response = await fetch(url, {
|
|
1421
|
+
redirect: 'error',
|
|
1422
|
+
credentials: 'omit',
|
|
1381
1423
|
headers: {
|
|
1382
1424
|
accept: 'application/json',
|
|
1383
1425
|
'user-agent': 'yam-flow external-update-check'
|
|
1384
1426
|
},
|
|
1385
1427
|
signal: AbortSignal.timeout(30000)
|
|
1386
1428
|
});
|
|
1387
|
-
if (!response.ok)
|
|
1388
|
-
|
|
1389
|
-
|
|
1429
|
+
if (!response.ok) {
|
|
1430
|
+
await response.body?.cancel();
|
|
1431
|
+
throw new RegistryHttpError(response.status, url);
|
|
1432
|
+
}
|
|
1433
|
+
const reader = response.body?.getReader();
|
|
1434
|
+
if (!reader)
|
|
1435
|
+
throw new Error(`empty registry response for ${url}`);
|
|
1436
|
+
const chunks = [];
|
|
1437
|
+
let size = 0;
|
|
1438
|
+
try {
|
|
1439
|
+
while (true) {
|
|
1440
|
+
const chunk = await reader.read();
|
|
1441
|
+
if (chunk.done)
|
|
1442
|
+
break;
|
|
1443
|
+
size += chunk.value.byteLength;
|
|
1444
|
+
if (size > 1024 * 1024)
|
|
1445
|
+
throw new Error('registry metadata exceeds 1 MiB limit');
|
|
1446
|
+
chunks.push(chunk.value);
|
|
1447
|
+
}
|
|
1448
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1449
|
+
}
|
|
1450
|
+
finally {
|
|
1451
|
+
await reader.cancel().catch(() => undefined);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
export class RegistryHttpError extends Error {
|
|
1455
|
+
status;
|
|
1456
|
+
constructor(status, url) {
|
|
1457
|
+
super(`HTTP ${status} for ${url}`);
|
|
1458
|
+
this.status = status;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
function requireYamTarball(value, version) {
|
|
1462
|
+
const expected = `${NPM_REGISTRY}yam-flow/-/yam-flow-${version}.tgz`;
|
|
1463
|
+
if (value !== expected)
|
|
1464
|
+
throw new Error('yam tarball must be the exact-version artifact on the official npm registry');
|
|
1465
|
+
return expected;
|
|
1466
|
+
}
|
|
1467
|
+
async function defaultProbeArtifact(url) {
|
|
1468
|
+
const response = await fetch(url, {
|
|
1469
|
+
method: 'HEAD',
|
|
1470
|
+
redirect: 'error',
|
|
1471
|
+
credentials: 'omit',
|
|
1472
|
+
headers: { 'user-agent': 'yam-flow external-update-check' },
|
|
1473
|
+
signal: AbortSignal.timeout(10000)
|
|
1474
|
+
});
|
|
1475
|
+
await response.body?.cancel();
|
|
1476
|
+
return response.status;
|
|
1390
1477
|
}
|
|
1391
1478
|
function requireStableVersion(value, label) {
|
|
1392
1479
|
const version = String(value || '').trim();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Select an upgrade-stable alias only when it resolves to the running Node binary. */
|
|
2
|
+
export declare function selectHookNode(executable?: string, searchPath?: string): Promise<{
|
|
3
|
+
executable: string;
|
|
4
|
+
canonical: string;
|
|
5
|
+
selection: string;
|
|
6
|
+
dev: number;
|
|
7
|
+
ino: number;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function summarizeHookDiscovery(value: unknown, cwd: string, commands: Record<string, string[]>, configPath?: string): {
|
|
10
|
+
state: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
profiles: {};
|
|
13
|
+
warning_count?: undefined;
|
|
14
|
+
error_count?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
state: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
profiles: {
|
|
19
|
+
[k: string]: {
|
|
20
|
+
state: string;
|
|
21
|
+
enabled_trusted: number;
|
|
22
|
+
eligible_event_coverage: boolean;
|
|
23
|
+
discovered_count: number;
|
|
24
|
+
trust_statuses: string[];
|
|
25
|
+
execution_observed: boolean;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
warning_count: number;
|
|
29
|
+
error_count: number;
|
|
30
|
+
};
|
|
31
|
+
/** Opt-in discovery in a new app-server process, not the active desktop session. */
|
|
32
|
+
export declare function probeHookDiscovery(options: {
|
|
33
|
+
executable: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
commands: Record<string, string[]>;
|
|
36
|
+
configPath?: string;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}): Promise<Record<string, unknown>>;
|
|
39
|
+
export declare function recordHookObservation(cwd: string, profile: string, event: string, entrypoint: string): Promise<void>;
|
|
40
|
+
export declare function inspectHookObservation(cwd: string, profile: string, entrypoint: string): Promise<{
|
|
41
|
+
state: string;
|
|
42
|
+
reason: string;
|
|
43
|
+
truth_status: string;
|
|
44
|
+
host_authenticated: boolean;
|
|
45
|
+
observed_at?: undefined;
|
|
46
|
+
event?: undefined;
|
|
47
|
+
} | {
|
|
48
|
+
state: string;
|
|
49
|
+
observed_at: any;
|
|
50
|
+
event: any;
|
|
51
|
+
host_authenticated: boolean;
|
|
52
|
+
truth_status: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
}>;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
const MAX_BYTES = 256 * 1024;
|
|
8
|
+
const sha = (text) => createHash('sha256').update(text).digest('hex');
|
|
9
|
+
/** Select an upgrade-stable alias only when it resolves to the running Node binary. */
|
|
10
|
+
export async function selectHookNode(executable = process.execPath, searchPath = process.env.PATH || '') {
|
|
11
|
+
const real = await fsp.realpath(executable);
|
|
12
|
+
const running = await fsp.stat(real);
|
|
13
|
+
const candidates = searchPath.split(path.delimiter).filter((dir) => path.isAbsolute(dir))
|
|
14
|
+
.map((dir) => path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node'));
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
const canonical = await fsp.realpath(candidate);
|
|
18
|
+
const stat = await fsp.stat(canonical);
|
|
19
|
+
await fsp.access(candidate, fs.constants.X_OK);
|
|
20
|
+
if (stat.isFile() && stat.dev === running.dev && stat.ino === running.ino && canonical === real) {
|
|
21
|
+
return { executable: candidate, canonical, selection: 'verified_runtime_alias', dev: stat.dev, ino: stat.ino };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch { /* An unverified PATH candidate is never executed. */ }
|
|
25
|
+
}
|
|
26
|
+
return { executable: real, canonical: real, selection: 'current_runtime_fallback', dev: running.dev, ino: running.ino };
|
|
27
|
+
}
|
|
28
|
+
export function summarizeHookDiscovery(value, cwd, commands, configPath) {
|
|
29
|
+
const input = value;
|
|
30
|
+
if (!Array.isArray(input?.data))
|
|
31
|
+
return { state: 'unknown', reason: 'unsupported_response_schema', profiles: {} };
|
|
32
|
+
const entry = input.data.find((row) => row.cwd === cwd);
|
|
33
|
+
if (!entry || !Array.isArray(entry.hooks) || !Array.isArray(entry.errors) || !Array.isArray(entry.warnings)) {
|
|
34
|
+
return { state: 'unknown', reason: 'missing_cwd_or_unsupported_response', profiles: {} };
|
|
35
|
+
}
|
|
36
|
+
const profiles = Object.fromEntries(Object.entries(commands).map(([profile, expected]) => {
|
|
37
|
+
const matches = entry.hooks.filter((hook) => hook.handlerType === 'command' && expected.includes(String(hook.command))
|
|
38
|
+
&& (!configPath || hook.sourcePath === configPath));
|
|
39
|
+
const approved = matches.filter((hook) => hook.enabled === true && ['trusted', 'managed'].includes(String(hook.trustStatus)));
|
|
40
|
+
const expectedEvents = profile === 'study-note' ? ['userPromptSubmit', 'stop'] : ['userPromptSubmit'];
|
|
41
|
+
return [profile, { state: matches.length ? 'discovered' : 'not_discovered', enabled_trusted: approved.length,
|
|
42
|
+
eligible_event_coverage: expectedEvents.every((event) => approved.filter((hook) => hook.eventName === event).length === 1),
|
|
43
|
+
discovered_count: matches.length, trust_statuses: [...new Set(matches.map((hook) => String(hook.trustStatus)))],
|
|
44
|
+
execution_observed: false }];
|
|
45
|
+
}));
|
|
46
|
+
return { state: entry.errors.length ? 'partial' : 'queried', reason: entry.errors.length ? 'host_discovery_errors' : '',
|
|
47
|
+
profiles, warning_count: entry.warnings.length, error_count: entry.errors.length };
|
|
48
|
+
}
|
|
49
|
+
/** Opt-in discovery in a new app-server process, not the active desktop session. */
|
|
50
|
+
export async function probeHookDiscovery(options) {
|
|
51
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs || 5000, 100), 10000);
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let finished = false;
|
|
54
|
+
let stopping = false;
|
|
55
|
+
let failure = '';
|
|
56
|
+
let result;
|
|
57
|
+
let buffer = '';
|
|
58
|
+
let total = 0;
|
|
59
|
+
let timer;
|
|
60
|
+
let killTimer;
|
|
61
|
+
const child = spawn(options.executable, ['app-server'], { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
62
|
+
const stop = (reason = '') => {
|
|
63
|
+
if (stopping)
|
|
64
|
+
return;
|
|
65
|
+
stopping = true;
|
|
66
|
+
failure = reason;
|
|
67
|
+
child.stdin.end();
|
|
68
|
+
child.kill('SIGTERM');
|
|
69
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
|
|
70
|
+
};
|
|
71
|
+
const send = (value) => { if (!stopping)
|
|
72
|
+
child.stdin.write(`${JSON.stringify(value)}\n`); };
|
|
73
|
+
child.stdin.on('error', () => stop('protocol_stdin_failed'));
|
|
74
|
+
child.on('error', () => { failure = 'host_unavailable'; });
|
|
75
|
+
child.stderr.on('data', (chunk) => { total += chunk.length; if (total > MAX_BYTES)
|
|
76
|
+
stop('output_limit'); });
|
|
77
|
+
child.stdout.on('data', (chunk) => {
|
|
78
|
+
total += chunk.length;
|
|
79
|
+
if (total > MAX_BYTES)
|
|
80
|
+
return stop('output_limit');
|
|
81
|
+
buffer += chunk.toString('utf8');
|
|
82
|
+
let index;
|
|
83
|
+
while (!stopping && (index = buffer.indexOf('\n')) >= 0) {
|
|
84
|
+
const line = buffer.slice(0, index);
|
|
85
|
+
buffer = buffer.slice(index + 1);
|
|
86
|
+
let message;
|
|
87
|
+
try {
|
|
88
|
+
message = JSON.parse(line);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
stop('invalid_protocol_json');
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (message.id === 0) {
|
|
95
|
+
if (message.error) {
|
|
96
|
+
stop('initialization_rejected');
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
send({ method: 'initialized', params: {} });
|
|
100
|
+
send({ method: 'hooks/list', id: 1, params: { cwds: [options.cwd] } });
|
|
101
|
+
}
|
|
102
|
+
else if (message.id === 1) {
|
|
103
|
+
if (message.error) {
|
|
104
|
+
stop('hooks_list_unsupported_or_rejected');
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
result = summarizeHookDiscovery(message.result, options.cwd, options.commands, options.configPath);
|
|
108
|
+
stop();
|
|
109
|
+
}
|
|
110
|
+
else if (message.id != null && message.method) {
|
|
111
|
+
stop('unexpected_server_request');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
child.on('close', (_code, signal) => {
|
|
116
|
+
if (finished)
|
|
117
|
+
return;
|
|
118
|
+
finished = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
clearTimeout(killTimer);
|
|
121
|
+
resolve({ ...(result || { state: 'unknown', profiles: {} }), ...(failure ? { state: 'unknown', reason: failure } : {}),
|
|
122
|
+
host_context: 'separate_app_server_probe', active_session_loaded: 'not_measured',
|
|
123
|
+
execution_observed: false, process_exit_observed: true, termination_signal: signal || null,
|
|
124
|
+
truth_status: 'partial', reason: failure || (result ? result.reason : 'host_exited_before_response') });
|
|
125
|
+
});
|
|
126
|
+
timer = setTimeout(() => stop('probe_timeout'), timeoutMs);
|
|
127
|
+
send({ method: 'initialize', id: 0, params: { clientInfo: { name: 'yam_hook_probe', version: '1' }, capabilities: { experimentalApi: true } } });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function safeObservationDirectory(cwd, create) {
|
|
131
|
+
const root = await fsp.realpath(cwd);
|
|
132
|
+
let current = root;
|
|
133
|
+
const identities = [];
|
|
134
|
+
for (const part of ['', '.yam', 'hooks']) {
|
|
135
|
+
if (part)
|
|
136
|
+
current = path.join(current, part);
|
|
137
|
+
if (create && part)
|
|
138
|
+
await fsp.mkdir(current, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
139
|
+
throw error; });
|
|
140
|
+
const stat = await fsp.lstat(current);
|
|
141
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
142
|
+
throw new Error('unsafe_observation_directory');
|
|
143
|
+
identities.push({ target: current, dev: stat.dev, ino: stat.ino });
|
|
144
|
+
}
|
|
145
|
+
return { directory: current, identities };
|
|
146
|
+
}
|
|
147
|
+
async function checkParents(identities) {
|
|
148
|
+
for (const identity of identities) {
|
|
149
|
+
const stat = await fsp.lstat(identity.target);
|
|
150
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino)
|
|
151
|
+
throw new Error('observation_parent_changed');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function configurationDigest(cwd) {
|
|
155
|
+
const paths = [path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json'), path.join(await fsp.realpath(cwd), '.codex', 'hooks.json')];
|
|
156
|
+
const values = [];
|
|
157
|
+
for (const target of paths) {
|
|
158
|
+
try {
|
|
159
|
+
const before = await fsp.lstat(target);
|
|
160
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES)
|
|
161
|
+
throw new Error('unsafe_hook_configuration');
|
|
162
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
163
|
+
try {
|
|
164
|
+
const stat = await handle.stat();
|
|
165
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > MAX_BYTES)
|
|
166
|
+
throw new Error('hook_configuration_changed');
|
|
167
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
168
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
169
|
+
if (bytesRead > MAX_BYTES)
|
|
170
|
+
throw new Error('hook_configuration_limit');
|
|
171
|
+
values.push(sha(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'ENOENT')
|
|
179
|
+
throw error;
|
|
180
|
+
values.push('absent');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return sha(JSON.stringify(values));
|
|
184
|
+
}
|
|
185
|
+
export async function recordHookObservation(cwd, profile, event, entrypoint) {
|
|
186
|
+
if (!['lite', 'study-note'].includes(profile) || !['UserPromptSubmit', 'Stop'].includes(event))
|
|
187
|
+
throw new Error('unsupported_observation');
|
|
188
|
+
const { directory, identities } = await safeObservationDirectory(cwd, true);
|
|
189
|
+
const value = { schema: 'yam.hook-observation.v1', profile, event, observed_at: new Date().toISOString(),
|
|
190
|
+
cwd_digest: sha(await fsp.realpath(cwd)), entrypoint_digest: sha(await fsp.readFile(entrypoint, 'utf8')),
|
|
191
|
+
configuration_digest: await configurationDigest(cwd),
|
|
192
|
+
host_authenticated: false, evidence_kind: 'local_entrypoint_invocation' };
|
|
193
|
+
const target = path.join(directory, `${profile}.json`);
|
|
194
|
+
const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0;
|
|
195
|
+
const before = await fsp.lstat(target).catch((error) => { if (error.code === 'ENOENT')
|
|
196
|
+
return null; throw error; });
|
|
197
|
+
if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1))
|
|
198
|
+
throw new Error('unsafe_observation_file');
|
|
199
|
+
const handle = await fsp.open(target, fs.constants.O_WRONLY | noFollow | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
|
|
200
|
+
try {
|
|
201
|
+
const stat = await handle.stat();
|
|
202
|
+
const pathname = await fsp.lstat(target);
|
|
203
|
+
if (!stat.isFile() || stat.nlink !== 1 || pathname.isSymbolicLink() || pathname.dev !== stat.dev || pathname.ino !== stat.ino
|
|
204
|
+
|| (before && (before.dev !== stat.dev || before.ino !== stat.ino)))
|
|
205
|
+
throw new Error('unsafe_observation_file');
|
|
206
|
+
await checkParents(identities);
|
|
207
|
+
await handle.chmod(0o600);
|
|
208
|
+
await handle.truncate(0);
|
|
209
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
210
|
+
await checkParents(identities);
|
|
211
|
+
const after = await fsp.lstat(target);
|
|
212
|
+
if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino)
|
|
213
|
+
throw new Error('observation_identity_changed');
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export async function inspectHookObservation(cwd, profile, entrypoint) {
|
|
220
|
+
try {
|
|
221
|
+
if (!['lite', 'study-note'].includes(profile))
|
|
222
|
+
throw new Error('unsupported_profile');
|
|
223
|
+
const { directory, identities } = await safeObservationDirectory(cwd, false);
|
|
224
|
+
const target = path.join(directory, `${profile}.json`);
|
|
225
|
+
const before = await fsp.lstat(target);
|
|
226
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || before.nlink !== 1)
|
|
227
|
+
throw new Error('unsafe_observation_file');
|
|
228
|
+
const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
|
|
229
|
+
let value;
|
|
230
|
+
try {
|
|
231
|
+
const stat = await handle.stat();
|
|
232
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096)
|
|
233
|
+
throw new Error('observation_identity_changed');
|
|
234
|
+
const data = Buffer.alloc(4097);
|
|
235
|
+
const { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
236
|
+
if (bytesRead > 4096)
|
|
237
|
+
throw new Error('observation_limit');
|
|
238
|
+
value = JSON.parse(data.subarray(0, bytesRead).toString('utf8'));
|
|
239
|
+
await checkParents(identities);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await handle.close();
|
|
243
|
+
}
|
|
244
|
+
const age = Date.now() - Date.parse(value.observed_at);
|
|
245
|
+
if (value.schema !== 'yam.hook-observation.v1' || value.profile !== profile || !['UserPromptSubmit', 'Stop'].includes(value.event)
|
|
246
|
+
|| value.host_authenticated !== false || value.evidence_kind !== 'local_entrypoint_invocation'
|
|
247
|
+
|| !Number.isFinite(age) || age < 0 || age > 24 * 60 * 60 * 1000
|
|
248
|
+
|| value.cwd_digest !== sha(await fsp.realpath(cwd)) || value.configuration_digest !== await configurationDigest(cwd)
|
|
249
|
+
|| value.entrypoint_digest !== sha(await fsp.readFile(entrypoint, 'utf8'))) {
|
|
250
|
+
return { state: 'unknown', reason: 'stale_or_mismatched_observation', truth_status: 'partial', host_authenticated: false };
|
|
251
|
+
}
|
|
252
|
+
return { state: 'entrypoint_observed', observed_at: value.observed_at, event: value.event,
|
|
253
|
+
host_authenticated: false, truth_status: 'partial', reason: 'local_same_user_evidence_not_host_attestation' };
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return { state: 'not_observed', reason: error.code === 'ENOENT' ? 'no_observation' : 'observation_unreadable_or_unsafe',
|
|
257
|
+
host_authenticated: false, truth_status: 'partial' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
export type MemoryReadState = 'missing' | 'empty' | 'complete' | 'unreadable' | 'invalid' | 'incomplete';
|
|
3
|
+
export interface MemoryRecord {
|
|
4
|
+
schemaVersion: number;
|
|
5
|
+
id: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
status: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
evidence?: string;
|
|
10
|
+
action?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface MemoryReadReport {
|
|
16
|
+
schema: 'yam.memory-read.v1';
|
|
17
|
+
state: MemoryReadState;
|
|
18
|
+
complete: boolean;
|
|
19
|
+
records: MemoryRecord[];
|
|
20
|
+
diagnostics: Array<{
|
|
21
|
+
operation: string;
|
|
22
|
+
code: string;
|
|
23
|
+
record_index?: number;
|
|
24
|
+
}>;
|
|
25
|
+
diagnostics_truncated: boolean;
|
|
26
|
+
}
|
|
27
|
+
export type MemoryReadIo = Pick<typeof fsp, 'access' | 'readdir' | 'readFile'>;
|
|
28
|
+
/** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
|
|
29
|
+
export declare function readMemoryDirectory(recordsDir: string, io?: MemoryReadIo): Promise<MemoryReadReport>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const kinds = new Set(['wrong_decision', 'repeat_mistake', 'direction_change', 'lesson', 'risk', 'command']);
|
|
4
|
+
const safeCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOTDIR', 'EISDIR', 'EMFILE', 'ENFILE', 'ELOOP']);
|
|
5
|
+
function errorCode(error) {
|
|
6
|
+
const code = error?.code;
|
|
7
|
+
return typeof code === 'string' && safeCodes.has(code) ? code : 'READ_FAILED';
|
|
8
|
+
}
|
|
9
|
+
function isMemoryRecord(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
return false;
|
|
12
|
+
const r = value;
|
|
13
|
+
return r.schemaVersion === 1 && typeof r.id === 'string' && r.id.length > 0
|
|
14
|
+
&& typeof r.kind === 'string' && kinds.has(r.kind)
|
|
15
|
+
&& (r.status === 'active' || r.status === 'resolved')
|
|
16
|
+
&& typeof r.summary === 'string' && r.summary.trim().length > 0
|
|
17
|
+
&& ['evidence', 'action', 'source', 'createdAt', 'resolvedAt', 'resolution'].every((key) => r[key] === undefined || typeof r[key] === 'string');
|
|
18
|
+
}
|
|
19
|
+
/** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
|
|
20
|
+
export async function readMemoryDirectory(recordsDir, io = fsp) {
|
|
21
|
+
const report = {
|
|
22
|
+
schema: 'yam.memory-read.v1', state: 'complete', complete: true,
|
|
23
|
+
records: [], diagnostics: [], diagnostics_truncated: false
|
|
24
|
+
};
|
|
25
|
+
const diagnose = (operation, code, record_index) => {
|
|
26
|
+
if (report.diagnostics.length < 20)
|
|
27
|
+
report.diagnostics.push({ operation, code, ...(record_index === undefined ? {} : { record_index }) });
|
|
28
|
+
else
|
|
29
|
+
report.diagnostics_truncated = true;
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
await io.access(recordsDir);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = errorCode(error);
|
|
36
|
+
report.state = code === 'ENOENT' ? 'missing' : 'unreadable';
|
|
37
|
+
report.complete = false;
|
|
38
|
+
diagnose('access', code);
|
|
39
|
+
return report;
|
|
40
|
+
}
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await io.readdir(recordsDir, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// An existing directory disappearing mid-read is incomplete, not an empty memory.
|
|
47
|
+
report.state = errorCode(error) === 'ENOENT' ? 'incomplete' : 'unreadable';
|
|
48
|
+
report.complete = false;
|
|
49
|
+
diagnose('readdir', errorCode(error));
|
|
50
|
+
return report;
|
|
51
|
+
}
|
|
52
|
+
let invalid = 0;
|
|
53
|
+
let unreadable = 0;
|
|
54
|
+
const candidates = entries.filter((entry) => entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
for (const [index, entry] of candidates.entries()) {
|
|
56
|
+
if (!entry.isFile()) {
|
|
57
|
+
unreadable += 1;
|
|
58
|
+
diagnose('record_type', 'NOT_REGULAR_FILE', index);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = await io.readFile(path.join(recordsDir, entry.name), 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
unreadable += 1;
|
|
67
|
+
diagnose('read', errorCode(error), index);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let record;
|
|
71
|
+
try {
|
|
72
|
+
record = JSON.parse(text);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
invalid += 1;
|
|
76
|
+
diagnose('parse', 'INVALID_JSON', index);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!isMemoryRecord(record)) {
|
|
80
|
+
invalid += 1;
|
|
81
|
+
diagnose('validate', 'INVALID_RECORD', index);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
report.records.push(record);
|
|
85
|
+
}
|
|
86
|
+
report.records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
|
|
87
|
+
report.complete = invalid + unreadable === 0;
|
|
88
|
+
report.state = report.complete ? (report.records.length ? 'complete' : 'empty')
|
|
89
|
+
: report.records.length || (invalid && unreadable) ? 'incomplete'
|
|
90
|
+
: unreadable ? 'unreadable' : 'invalid';
|
|
91
|
+
return report;
|
|
92
|
+
}
|