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.
@@ -0,0 +1,215 @@
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
+
8
+ const MAX_BYTES = 256 * 1024;
9
+ const sha = (text: string) => createHash('sha256').update(text).digest('hex');
10
+
11
+ /** Select an upgrade-stable alias only when it resolves to the running Node binary. */
12
+ export async function selectHookNode(executable = process.execPath, searchPath = process.env.PATH || '') {
13
+ const real = await fsp.realpath(executable);
14
+ const running = await fsp.stat(real);
15
+ const candidates = searchPath.split(path.delimiter).filter((dir) => path.isAbsolute(dir))
16
+ .map((dir) => path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node'));
17
+ for (const candidate of candidates) {
18
+ try {
19
+ const canonical = await fsp.realpath(candidate);
20
+ const stat = await fsp.stat(canonical);
21
+ await fsp.access(candidate, fs.constants.X_OK);
22
+ if (stat.isFile() && stat.dev === running.dev && stat.ino === running.ino && canonical === real) {
23
+ return { executable: candidate, canonical, selection: 'verified_runtime_alias', dev: stat.dev, ino: stat.ino };
24
+ }
25
+ } catch { /* An unverified PATH candidate is never executed. */ }
26
+ }
27
+ return { executable: real, canonical: real, selection: 'current_runtime_fallback', dev: running.dev, ino: running.ino };
28
+ }
29
+
30
+ export function summarizeHookDiscovery(value: unknown, cwd: string, commands: Record<string, string[]>, configPath?: string) {
31
+ const input = value as { data?: Array<{ cwd?: string; hooks?: Array<Record<string, unknown>>; errors?: unknown[]; warnings?: unknown[] }> };
32
+ if (!Array.isArray(input?.data)) return { state: 'unknown', reason: 'unsupported_response_schema', profiles: {} };
33
+ const entry = input.data.find((row) => row.cwd === cwd);
34
+ if (!entry || !Array.isArray(entry.hooks) || !Array.isArray(entry.errors) || !Array.isArray(entry.warnings)) {
35
+ return { state: 'unknown', reason: 'missing_cwd_or_unsupported_response', profiles: {} };
36
+ }
37
+ const profiles = Object.fromEntries(Object.entries(commands).map(([profile, expected]) => {
38
+ const matches = entry.hooks.filter((hook) => hook.handlerType === 'command' && expected.includes(String(hook.command))
39
+ && (!configPath || hook.sourcePath === configPath));
40
+ const approved = matches.filter((hook) => hook.enabled === true && ['trusted', 'managed'].includes(String(hook.trustStatus)));
41
+ const expectedEvents = profile === 'study-note' ? ['userPromptSubmit', 'stop'] : ['userPromptSubmit'];
42
+ return [profile, { state: matches.length ? 'discovered' : 'not_discovered', enabled_trusted: approved.length,
43
+ eligible_event_coverage: expectedEvents.every((event) => approved.filter((hook) => hook.eventName === event).length === 1),
44
+ discovered_count: matches.length, trust_statuses: [...new Set(matches.map((hook) => String(hook.trustStatus)))],
45
+ execution_observed: false }];
46
+ }));
47
+ return { state: entry.errors.length ? 'partial' : 'queried', reason: entry.errors.length ? 'host_discovery_errors' : '',
48
+ profiles, warning_count: entry.warnings.length, error_count: entry.errors.length };
49
+ }
50
+
51
+ /** Opt-in discovery in a new app-server process, not the active desktop session. */
52
+ export async function probeHookDiscovery(options: { executable: string; cwd: string; commands: Record<string, string[]>; configPath?: string; timeoutMs?: number }) {
53
+ const timeoutMs = Math.min(Math.max(options.timeoutMs || 5000, 100), 10000);
54
+ return new Promise<Record<string, unknown>>((resolve) => {
55
+ let finished = false;
56
+ let stopping = false;
57
+ let failure = '';
58
+ let result: Record<string, unknown>;
59
+ let buffer = '';
60
+ let total = 0;
61
+ let timer: NodeJS.Timeout;
62
+ let killTimer: NodeJS.Timeout;
63
+ const child = spawn(options.executable, ['app-server'], { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
64
+ const stop = (reason = '') => {
65
+ if (stopping) return;
66
+ stopping = true;
67
+ failure = reason;
68
+ child.stdin.end();
69
+ child.kill('SIGTERM');
70
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
71
+ };
72
+ const send = (value: unknown) => { if (!stopping) 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) stop('output_limit'); });
76
+ child.stdout.on('data', (chunk) => {
77
+ total += chunk.length;
78
+ if (total > MAX_BYTES) return stop('output_limit');
79
+ buffer += chunk.toString('utf8');
80
+ let index: number;
81
+ while (!stopping && (index = buffer.indexOf('\n')) >= 0) {
82
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
83
+ let message;
84
+ try { message = JSON.parse(line); } catch { stop('invalid_protocol_json'); break; }
85
+ if (message.id === 0) {
86
+ if (message.error) { stop('initialization_rejected'); break; }
87
+ send({ method: 'initialized', params: {} });
88
+ send({ method: 'hooks/list', id: 1, params: { cwds: [options.cwd] } });
89
+ } else if (message.id === 1) {
90
+ if (message.error) { stop('hooks_list_unsupported_or_rejected'); break; }
91
+ result = summarizeHookDiscovery(message.result, options.cwd, options.commands, options.configPath);
92
+ stop();
93
+ } else if (message.id != null && message.method) {
94
+ stop('unexpected_server_request');
95
+ }
96
+ }
97
+ });
98
+ child.on('close', (_code, signal) => {
99
+ if (finished) return;
100
+ finished = true;
101
+ clearTimeout(timer); clearTimeout(killTimer);
102
+ resolve({ ...(result || { state: 'unknown', profiles: {} }), ...(failure ? { state: 'unknown', reason: failure } : {}),
103
+ host_context: 'separate_app_server_probe', active_session_loaded: 'not_measured',
104
+ execution_observed: false, process_exit_observed: true, termination_signal: signal || null,
105
+ truth_status: 'partial', reason: failure || (result ? result.reason : 'host_exited_before_response') });
106
+ });
107
+ timer = setTimeout(() => stop('probe_timeout'), timeoutMs);
108
+ send({ method: 'initialize', id: 0, params: { clientInfo: { name: 'yam_hook_probe', version: '1' }, capabilities: { experimentalApi: true } } });
109
+ });
110
+ }
111
+
112
+ async function safeObservationDirectory(cwd: string, create: boolean) {
113
+ const root = await fsp.realpath(cwd);
114
+ let current = root;
115
+ const identities: Array<{ target: string; dev: number; ino: number }> = [];
116
+ for (const part of ['', '.yam', 'hooks']) {
117
+ if (part) current = path.join(current, part);
118
+ if (create && part) await fsp.mkdir(current, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
119
+ const stat = await fsp.lstat(current);
120
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe_observation_directory');
121
+ identities.push({ target: current, dev: stat.dev, ino: stat.ino });
122
+ }
123
+ return { directory: current, identities };
124
+ }
125
+
126
+ async function checkParents(identities: Array<{ target: string; dev: number; ino: number }>) {
127
+ for (const identity of identities) {
128
+ const stat = await fsp.lstat(identity.target);
129
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) throw new Error('observation_parent_changed');
130
+ }
131
+ }
132
+
133
+ async function configurationDigest(cwd: string) {
134
+ const paths = [path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json'), path.join(await fsp.realpath(cwd), '.codex', 'hooks.json')];
135
+ const values = [];
136
+ for (const target of paths) {
137
+ try {
138
+ const before = await fsp.lstat(target);
139
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error('unsafe_hook_configuration');
140
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
141
+ try {
142
+ const stat = await handle.stat();
143
+ if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > MAX_BYTES) throw new Error('hook_configuration_changed');
144
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
145
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
146
+ if (bytesRead > MAX_BYTES) throw new Error('hook_configuration_limit');
147
+ values.push(sha(buffer.subarray(0, bytesRead).toString('utf8')));
148
+ } finally { await handle.close(); }
149
+ } catch (error) {
150
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
151
+ values.push('absent');
152
+ }
153
+ }
154
+ return sha(JSON.stringify(values));
155
+ }
156
+
157
+ export async function recordHookObservation(cwd: string, profile: string, event: string, entrypoint: string) {
158
+ if (!['lite', 'study-note'].includes(profile) || !['UserPromptSubmit', 'Stop'].includes(event)) throw new Error('unsupported_observation');
159
+ const { directory, identities } = await safeObservationDirectory(cwd, true);
160
+ const value = { schema: 'yam.hook-observation.v1', profile, event, observed_at: new Date().toISOString(),
161
+ cwd_digest: sha(await fsp.realpath(cwd)), entrypoint_digest: sha(await fsp.readFile(entrypoint, 'utf8')),
162
+ configuration_digest: await configurationDigest(cwd),
163
+ host_authenticated: false, evidence_kind: 'local_entrypoint_invocation' };
164
+ const target = path.join(directory, `${profile}.json`);
165
+ const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0;
166
+ const before = await fsp.lstat(target).catch((error) => { if (error.code === 'ENOENT') return null; throw error; });
167
+ if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)) throw new Error('unsafe_observation_file');
168
+ const handle = await fsp.open(target, fs.constants.O_WRONLY | noFollow | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
169
+ try {
170
+ const stat = await handle.stat();
171
+ const pathname = await fsp.lstat(target);
172
+ if (!stat.isFile() || stat.nlink !== 1 || pathname.isSymbolicLink() || pathname.dev !== stat.dev || pathname.ino !== stat.ino
173
+ || (before && (before.dev !== stat.dev || before.ino !== stat.ino))) throw new Error('unsafe_observation_file');
174
+ await checkParents(identities);
175
+ await handle.chmod(0o600);
176
+ await handle.truncate(0);
177
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
178
+ await checkParents(identities);
179
+ const after = await fsp.lstat(target);
180
+ if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino) throw new Error('observation_identity_changed');
181
+ } finally { await handle.close(); }
182
+ }
183
+
184
+ export async function inspectHookObservation(cwd: string, profile: string, entrypoint: string) {
185
+ try {
186
+ if (!['lite', 'study-note'].includes(profile)) throw new Error('unsupported_profile');
187
+ const { directory, identities } = await safeObservationDirectory(cwd, false);
188
+ const target = path.join(directory, `${profile}.json`);
189
+ const before = await fsp.lstat(target);
190
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || before.nlink !== 1) throw new Error('unsafe_observation_file');
191
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
192
+ let value;
193
+ try {
194
+ const stat = await handle.stat();
195
+ if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) throw new Error('observation_identity_changed');
196
+ const data = Buffer.alloc(4097); const { bytesRead } = await handle.read(data, 0, data.length, 0);
197
+ if (bytesRead > 4096) throw new Error('observation_limit');
198
+ value = JSON.parse(data.subarray(0, bytesRead).toString('utf8'));
199
+ await checkParents(identities);
200
+ } finally { await handle.close(); }
201
+ const age = Date.now() - Date.parse(value.observed_at);
202
+ if (value.schema !== 'yam.hook-observation.v1' || value.profile !== profile || !['UserPromptSubmit', 'Stop'].includes(value.event)
203
+ || value.host_authenticated !== false || value.evidence_kind !== 'local_entrypoint_invocation'
204
+ || !Number.isFinite(age) || age < 0 || age > 24 * 60 * 60 * 1000
205
+ || value.cwd_digest !== sha(await fsp.realpath(cwd)) || value.configuration_digest !== await configurationDigest(cwd)
206
+ || value.entrypoint_digest !== sha(await fsp.readFile(entrypoint, 'utf8'))) {
207
+ return { state: 'unknown', reason: 'stale_or_mismatched_observation', truth_status: 'partial', host_authenticated: false };
208
+ }
209
+ return { state: 'entrypoint_observed', observed_at: value.observed_at, event: value.event,
210
+ host_authenticated: false, truth_status: 'partial', reason: 'local_same_user_evidence_not_host_attestation' };
211
+ } catch (error) {
212
+ return { state: 'not_observed', reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'no_observation' : 'observation_unreadable_or_unsafe',
213
+ host_authenticated: false, truth_status: 'partial' };
214
+ }
215
+ }
@@ -0,0 +1,109 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export type MemoryReadState = 'missing' | 'empty' | 'complete' | 'unreadable' | 'invalid' | 'incomplete';
5
+ export interface MemoryRecord {
6
+ schemaVersion: number;
7
+ id: string;
8
+ kind: string;
9
+ status: string;
10
+ summary: string;
11
+ evidence?: string;
12
+ action?: string;
13
+ source?: string;
14
+ createdAt?: string;
15
+ [key: string]: unknown;
16
+ }
17
+ export interface MemoryReadReport {
18
+ schema: 'yam.memory-read.v1';
19
+ state: MemoryReadState;
20
+ complete: boolean;
21
+ records: MemoryRecord[];
22
+ diagnostics: Array<{ operation: string; code: string; record_index?: number }>;
23
+ diagnostics_truncated: boolean;
24
+ }
25
+ export type MemoryReadIo = Pick<typeof fsp, 'access' | 'readdir' | 'readFile'>;
26
+ const kinds = new Set(['wrong_decision', 'repeat_mistake', 'direction_change', 'lesson', 'risk', 'command']);
27
+ const safeCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOTDIR', 'EISDIR', 'EMFILE', 'ENFILE', 'ELOOP']);
28
+
29
+ function errorCode(error: unknown): string {
30
+ const code = (error as { code?: unknown })?.code;
31
+ return typeof code === 'string' && safeCodes.has(code) ? code : 'READ_FAILED';
32
+ }
33
+
34
+ function isMemoryRecord(value: unknown): value is MemoryRecord {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
36
+ const r = value as Record<string, unknown>;
37
+ return r.schemaVersion === 1 && typeof r.id === 'string' && r.id.length > 0
38
+ && typeof r.kind === 'string' && kinds.has(r.kind)
39
+ && (r.status === 'active' || r.status === 'resolved')
40
+ && typeof r.summary === 'string' && r.summary.trim().length > 0
41
+ && ['evidence', 'action', 'source', 'createdAt', 'resolvedAt', 'resolution'].every((key) => r[key] === undefined || typeof r[key] === 'string');
42
+ }
43
+
44
+ /** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
45
+ export async function readMemoryDirectory(recordsDir: string, io: MemoryReadIo = fsp): Promise<MemoryReadReport> {
46
+ const report: MemoryReadReport = {
47
+ schema: 'yam.memory-read.v1', state: 'complete', complete: true,
48
+ records: [], diagnostics: [], diagnostics_truncated: false
49
+ };
50
+ const diagnose = (operation: string, code: string, record_index?: number) => {
51
+ if (report.diagnostics.length < 20) report.diagnostics.push({ operation, code, ...(record_index === undefined ? {} : { record_index }) });
52
+ else report.diagnostics_truncated = true;
53
+ };
54
+ try {
55
+ await io.access(recordsDir);
56
+ } catch (error) {
57
+ const code = errorCode(error);
58
+ report.state = code === 'ENOENT' ? 'missing' : 'unreadable';
59
+ report.complete = false;
60
+ diagnose('access', code);
61
+ return report;
62
+ }
63
+ let entries;
64
+ try {
65
+ entries = await io.readdir(recordsDir, { withFileTypes: true });
66
+ } catch (error) {
67
+ // An existing directory disappearing mid-read is incomplete, not an empty memory.
68
+ report.state = errorCode(error) === 'ENOENT' ? 'incomplete' : 'unreadable';
69
+ report.complete = false;
70
+ diagnose('readdir', errorCode(error));
71
+ return report;
72
+ }
73
+ let invalid = 0;
74
+ let unreadable = 0;
75
+ const candidates = entries.filter((entry) => entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name));
76
+ for (const [index, entry] of candidates.entries()) {
77
+ if (!entry.isFile()) {
78
+ unreadable += 1;
79
+ diagnose('record_type', 'NOT_REGULAR_FILE', index);
80
+ continue;
81
+ }
82
+ let text: string;
83
+ try {
84
+ text = await io.readFile(path.join(recordsDir, entry.name), 'utf8');
85
+ } catch (error) {
86
+ unreadable += 1;
87
+ diagnose('read', errorCode(error), index);
88
+ continue;
89
+ }
90
+ let record: unknown;
91
+ try { record = JSON.parse(text); } catch {
92
+ invalid += 1;
93
+ diagnose('parse', 'INVALID_JSON', index);
94
+ continue;
95
+ }
96
+ if (!isMemoryRecord(record)) {
97
+ invalid += 1;
98
+ diagnose('validate', 'INVALID_RECORD', index);
99
+ continue;
100
+ }
101
+ report.records.push(record);
102
+ }
103
+ report.records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
104
+ report.complete = invalid + unreadable === 0;
105
+ report.state = report.complete ? (report.records.length ? 'complete' : 'empty')
106
+ : report.records.length || (invalid && unreadable) ? 'incomplete'
107
+ : unreadable ? 'unreadable' : 'invalid';
108
+ return report;
109
+ }
@@ -0,0 +1,134 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
8
+ const MAX_FILES = 4000;
9
+ const MAX_BYTES = 2 * 1024 * 1024;
10
+ type Snapshot = { root: string; files: Record<string, string> };
11
+ type Policy = { include: string[]; exclude: string[]; strict: boolean };
12
+
13
+ function git(dir: string, args: string[]) {
14
+ const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 750, maxBuffer: MAX_BYTES,
15
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
16
+ if (result.status !== 0) throw new Error('git_scope_unavailable');
17
+ return result.stdout;
18
+ }
19
+
20
+ function snapshot(dir: string): Snapshot {
21
+ const root = fs.realpathSync(git(dir, ['rev-parse', '--show-toplevel']).trim());
22
+ const config = policy(root);
23
+ const files: Record<string, string> = Object.create(null);
24
+ let bytesRead = 0;
25
+ const tracked = git(root, ['ls-files', '--stage', '-z']).split('\0').filter(Boolean);
26
+ for (const row of tracked) {
27
+ const tab = row.indexOf('\t');
28
+ if (selected(row.slice(tab + 1), config)) files[row.slice(tab + 1)] = hash(row.slice(0, tab));
29
+ }
30
+ const rows = git(root, ['status', '--short', '-z', '--untracked-files=all']).split('\0');
31
+ for (let i = 0; i < rows.length; i++) {
32
+ const row = rows[i];
33
+ if (!row) continue;
34
+ const name = row.slice(3);
35
+ if (/[RC]/.test(row.slice(0, 2))) i++;
36
+ if (!selected(name, config)) continue;
37
+ const target = path.resolve(root, name);
38
+ if (!target.startsWith(root + path.sep)) throw new Error('unsafe_path');
39
+ // Do not follow symlinks, including symlinked parent directories.
40
+ let parent = path.dirname(target);
41
+ while (parent !== root) {
42
+ try { if (fs.lstatSync(parent).isSymbolicLink()) throw new Error('unsafe_path'); }
43
+ catch (error: any) { if (error.code !== 'ENOENT') throw error; }
44
+ parent = path.dirname(parent);
45
+ }
46
+ let content = 'missing';
47
+ try {
48
+ const stat = fs.lstatSync(target);
49
+ if (stat.isSymbolicLink()) content = 'link:' + hash(fs.readlinkSync(target));
50
+ else if (stat.isFile() && (bytesRead += stat.size) <= MAX_BYTES) content = hash(fs.readFileSync(target));
51
+ else throw new Error('scope_limit');
52
+ } catch (error: any) { if (error.code !== 'ENOENT') throw error; }
53
+ files[name] = hash(JSON.stringify([row.slice(0, 2), files[name] || '', content]));
54
+ }
55
+ if (Object.keys(files).length > MAX_FILES) throw new Error('scope_limit');
56
+ return { root, files };
57
+ }
58
+
59
+ function policy(root: string): Policy {
60
+ const target = path.join(root, '.yam', 'study-note.json');
61
+ let value: any = {};
62
+ if (fs.existsSync(target)) {
63
+ if (fs.lstatSync(path.dirname(target)).isSymbolicLink() || fs.lstatSync(target).isSymbolicLink()
64
+ || fs.statSync(target).size > 16384) throw new Error('invalid_policy');
65
+ value = JSON.parse(fs.readFileSync(target, 'utf8'));
66
+ if (!value || !['advisory', 'strict', undefined].includes(value.mode)) throw new Error('invalid_policy');
67
+ }
68
+ const list = (key: string) => {
69
+ const result = value[key] || [];
70
+ if (!Array.isArray(result) || result.length > 100 || result.some((x: any) => typeof x !== 'string'
71
+ || !x || x.startsWith('/') || x.includes('..') || /[\\\0]/.test(x) || x.replace(/\/\*\*$/, '').includes('*'))) throw new Error('invalid_policy');
72
+ return result;
73
+ };
74
+ return { include: list('include'), exclude: list('exclude'), strict: value.mode === 'strict' };
75
+ }
76
+
77
+ const matches = (name: string, pattern: string) => pattern.endsWith('/**')
78
+ ? name.startsWith(pattern.slice(0, -2)) : name === pattern;
79
+ function selected(name: string, config: Policy) {
80
+ if (config.include.some(p => matches(name, p))) return true;
81
+ if (config.exclude.some(p => matches(name, p))) return false;
82
+ return !/^(dist\/|\.yam\/(mission|security|logs|screenshots)\/)/.test(name)
83
+ && !/\.tgz$/.test(name);
84
+ }
85
+
86
+ function statePath(root: string, session: string) {
87
+ const dir = path.join(os.tmpdir(), `yam-study-note-${process.getuid?.() ?? 'user'}`);
88
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
89
+ const stat = fs.lstatSync(dir);
90
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())
91
+ || (process.platform !== 'win32' && (stat.mode & 0o077))) throw new Error('unsafe_state');
92
+ return path.join(dir, hash(root + '\0' + session) + '.json');
93
+ }
94
+
95
+ /** Hash-only, bounded scope evidence; not a security attestation. */
96
+ export function studyNoteTurnScope(input: any, cwd: string, begin = false) {
97
+ try {
98
+ const session = String(input.session_id || input.sessionId || input.thread_id || '');
99
+ const turn = String(input.turn_id || input.turnId || '');
100
+ if (!session || session.length > 512 || turn.length > 512) throw new Error('session_unavailable');
101
+ const current = snapshot(cwd);
102
+ const config = policy(current.root);
103
+ const target = statePath(current.root, session);
104
+ if (begin) {
105
+ const prompt = String(input.prompt || input.user_prompt || '');
106
+ // Deep/Mission alone do not make a read-only task strict. Risk intent or explicit strict does.
107
+ const strict = config.strict || /(?:study-note\s+strict|strict\s+study.note|commit|push|deploy|publish|security|migration|database|\bDB\b|커밋|푸시|배포|보안|마이그레이션|데이터베이스)/i.test(prompt);
108
+ const state = { schema: 'yam.study-note-baseline.v1', created: Date.now(), turn, strict, ...current };
109
+ try {
110
+ const previous = fs.lstatSync(target);
111
+ if (!previous.isFile() || previous.isSymbolicLink() || previous.nlink !== 1
112
+ || (process.getuid && previous.uid !== process.getuid())) throw new Error('unsafe_state');
113
+ } catch (error: any) { if (error.code !== 'ENOENT') throw error; }
114
+ const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0), 0o600);
115
+ try { fs.writeFileSync(fd, JSON.stringify(state)); } finally { fs.closeSync(fd); }
116
+ return { available: true, files: [], source: 'turn_baseline', mode: strict ? 'strict' : 'advisory', reason: '' };
117
+ }
118
+ const stat = fs.lstatSync(target);
119
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw new Error('invalid_baseline');
120
+ const before = JSON.parse(fs.readFileSync(target, 'utf8'));
121
+ if (before.schema !== 'yam.study-note-baseline.v1' || before.root !== current.root
122
+ || !before.files || typeof before.files !== 'object' || Array.isArray(before.files)
123
+ || !Number.isFinite(before.created) || typeof before.strict !== 'boolean'
124
+ || Object.keys(before.files).length > MAX_FILES
125
+ || Object.values(before.files).some(value => typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value))
126
+ || Date.now() - before.created > 24 * 60 * 60 * 1000 || before.created > Date.now()
127
+ || (turn && before.turn !== turn)) throw new Error('stale_baseline');
128
+ const files = [...new Set([...Object.keys(before.files), ...Object.keys(current.files)])]
129
+ .filter(name => before.files[name] !== current.files[name] && selected(name, config));
130
+ return { available: true, files, source: 'turn_baseline', mode: config.strict || before.strict ? 'strict' : 'advisory', reason: '' };
131
+ } catch {
132
+ return { available: false, files: [], source: 'turn_baseline', mode: 'advisory', reason: 'baseline_unavailable; inspect changed artifacts manually' };
133
+ }
134
+ }
package/yam.manifest.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "yam",
3
- "version": "2.8.1",
3
+ "version": "2.8.3",
4
4
  "principles": [
5
5
  "Direction before execution.",
6
+ "Keep the current branch; new/switched branches, worktrees and PRs require explicit user direction or applicable project rules. Protection blockers require reporting, not silent workflow changes or bypass.",
6
7
  "Start fast.",
7
8
  "Deepen deliberately.",
8
9
  "Basic direction fit and honest verification always.",
@@ -13,12 +14,12 @@
13
14
  "Context-reuse first.",
14
15
  "End with remaining tasks and fix-first items.",
15
16
  "When code, config, release metadata, documentation, or project artifacts changed, include a Study Note even if no yam skill was explicitly invoked.",
16
- "When the opt-in Study Note hook is enabled, check the final response at Codex Stop and request one bounded correction pass instead of relying only on prompt-time advice.",
17
+ "When the opt-in Study Note hook is enabled, compare against a hash-only turn baseline; ordinary work is advisory and selected risky changes request at most one strict correction pass.",
17
18
  "Explain what changed, what role it has, where it runs or is read, what was wrong or missing when relevant, before/after behavior, expected behavior, and one useful syntax or structure insight in short non-specialist language.",
18
19
  "When relevant, include architecture hygiene: avoid dumping unrelated logic into page.tsx, one-off component CSS into global.css, or structured product data into broad DB jsonb blobs when better project boundaries exist.",
19
20
  "Use handoff notes for fix-first items, remaining tasks, recommended next direction, requirement coverage, blocker kind, failure cause, recovery hint, safe retry, evidence level, and owner scope when useful.",
20
21
  "Use bounded heavy verification for serious claims, with explicit stop conditions instead of endless proof loops.",
21
- "After changed artifacts, put an evidence-bound Next step immediately after Study Note with a whole-process scan, critical opinion, recommendations, and fix-first-before-planned ordering."
22
+ "After changed artifacts, put Next step immediately after Study Note; ordinary work stays brief, while strict reports use evidence-bound whole-process scanning and fix-first-before-planned ordering."
22
23
  ],
23
24
  "defaultHooks": false,
24
25
  "routes": [