yam-flow 2.8.1 → 2.8.2

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/src/bin/yam.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { selectHookNode, probeHookDiscovery, inspectHookObservation, recordHookObservation } from '../lib/hook-readiness.js';
4
+ import { readMemoryDirectory } from '../lib/memory-records.js';
3
5
  import { runSecurityCommand } from '../lib/security-integration.js';
4
6
  import { createHash } from 'node:crypto';
5
7
  import fs from 'node:fs';
@@ -149,8 +151,8 @@ const INSTRUCTION_DUPLICATION_LIMITS = {
149
151
  minDirectiveChars: 40
150
152
  };
151
153
  const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
152
- const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
153
- const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
154
+ let YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
155
+ let YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
154
156
  const REQUIRED_PACK_SECTIONS = [
155
157
  'Product Direction',
156
158
  'UI Direction',
@@ -2014,6 +2016,7 @@ async function hook(args = []) {
2014
2016
  const subcommand = args[0] || 'status';
2015
2017
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return hookUsage();
2016
2018
  if (subcommand === 'status') return hookStatus(args.slice(1));
2019
+ if (subcommand === 'migrate') return hookMigrate(args.slice(1));
2017
2020
  if (subcommand === 'enable') return hookEnable(args.slice(1));
2018
2021
  if (subcommand === 'disable') return hookDisable(args.slice(1));
2019
2022
  if (subcommand === 'run') return hookRun(args.slice(1));
@@ -2025,14 +2028,17 @@ function hookUsage() {
2025
2028
  console.log(`yam hook
2026
2029
 
2027
2030
  Usage:
2028
- yam hook status [--global|--project dir]
2029
- yam hook enable <lite|study-note> [--global|--project dir]
2031
+ yam hook status [--global|--project dir] [--probe] [--json]
2032
+ yam hook migrate [--global|--project dir]
2033
+ yam hook enable <lite|study-note> [--global|--project dir] [--observe]
2030
2034
  yam hook disable [lite|study-note] [--global|--project dir]
2031
2035
  yam hook run <lite|study-note>
2032
2036
 
2033
2037
  Notes:
2034
2038
  hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
2035
2039
  Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
2040
+ --probe queries hooks/list in a separate bounded Codex app-server; active session loading stays unknown.
2041
+ --observe opts into private local entrypoint observations without storing prompt content; not host attestation.
2036
2042
  `);
2037
2043
  }
2038
2044
 
@@ -2057,7 +2063,7 @@ function parseHookArgs(args = []) {
2057
2063
  }
2058
2064
 
2059
2065
  function hookPathFor(parsed) {
2060
- if (parsed.mode === 'global') return path.join(os.homedir(), '.codex', 'hooks.json');
2066
+ if (parsed.mode === 'global') return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
2061
2067
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
2062
2068
  }
2063
2069
 
@@ -2073,19 +2079,42 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
2073
2079
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
2074
2080
  }
2075
2081
 
2082
+ const CODEX_HOOK_EVENTS = new Set(['SessionStart', 'SessionEnd', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt']);
2083
+
2084
+ function hookEventMap(config: AnyRecord = {}) {
2085
+ const nested = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks) ? config.hooks : {};
2086
+ const events = { ...nested };
2087
+ for (const [event, entries] of Object.entries(config)) {
2088
+ if (CODEX_HOOK_EVENTS.has(event) && Array.isArray(entries)) {
2089
+ events[event] = [...(Array.isArray(events[event]) ? events[event] : []), ...entries];
2090
+ }
2091
+ }
2092
+ return events;
2093
+ }
2094
+
2095
+ function canonicalHookConfig(config: AnyRecord = {}) {
2096
+ if (config.hooks != null && (typeof config.hooks !== 'object' || Array.isArray(config.hooks))) throw new Error('hooks must be an event object; existing file preserved');
2097
+ const next = { ...config, hooks: hookEventMap(config) };
2098
+ for (const event of CODEX_HOOK_EVENTS) {
2099
+ if (event in config && !Array.isArray(config[event])) throw new Error('legacy hook event must be an array; existing file preserved');
2100
+ delete next[event];
2101
+ }
2102
+ return next;
2103
+ }
2104
+
2076
2105
  function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
2077
- const next = { ...config };
2078
- for (const event of Object.keys(next)) {
2079
- if (!Array.isArray(next[event])) continue;
2080
- const entries = next[event];
2106
+ const next = canonicalHookConfig(config);
2107
+ for (const event of Object.keys(next.hooks)) {
2108
+ if (!Array.isArray(next.hooks[event])) continue;
2109
+ const entries = next.hooks[event];
2081
2110
  const keptEntries = [];
2082
2111
  for (const entry of entries) {
2083
2112
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
2084
2113
  const rest = { ...entry, hooks };
2085
2114
  if (hooks.length > 0) keptEntries.push(rest);
2086
2115
  }
2087
- if (keptEntries.length > 0) next[event] = keptEntries;
2088
- else delete next[event];
2116
+ if (keptEntries.length > 0) next.hooks[event] = keptEntries;
2117
+ else delete next.hooks[event];
2089
2118
  }
2090
2119
  return next;
2091
2120
  }
@@ -2103,7 +2132,7 @@ function withYamHook(config: AnyRecord = {}, profile = 'lite') {
2103
2132
  }
2104
2133
  ]
2105
2134
  };
2106
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
2135
+ next.hooks[event] = [...(Array.isArray(next.hooks[event]) ? next.hooks[event] : []), entry];
2107
2136
  }
2108
2137
  return next;
2109
2138
  }
@@ -2113,6 +2142,11 @@ async function hookStatus(args = []) {
2113
2142
  const target = hookPathFor(parsed);
2114
2143
  const loaded = await readHookConfig(target);
2115
2144
  if (loaded.error) {
2145
+ if (args.includes('--json')) {
2146
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, configuration: 'unreadable', loaded: 'unknown', execution_observed: false, truth_status: 'partial' }, null, 2));
2147
+ process.exitCode = 1;
2148
+ return;
2149
+ }
2116
2150
  console.log('yam-lite hook: broken');
2117
2151
  console.log('yam-study-note hook: broken');
2118
2152
  console.log(` - hook config unreadable: ${loaded.error}`);
@@ -2123,10 +2157,28 @@ async function hookStatus(args = []) {
2123
2157
  }
2124
2158
  const lite = await inspectHookProfile(loaded.config, 'lite');
2125
2159
  const studyNote = await inspectHookProfile(loaded.config, 'study-note');
2160
+ const cwd = path.resolve(parsed.projectDir || process.cwd());
2161
+ const commands = { lite: lite.entries.map((entry) => String(entry.handler.command)), 'study-note': studyNote.entries.map((entry) => String(entry.handler.command)) };
2162
+ const host = args.includes('--probe')
2163
+ ? await probeHookDiscovery({ executable: await findExecutable('codex') || 'codex', cwd, commands, configPath: target })
2164
+ : { state: 'not_measured', active_session_loaded: 'not_measured', execution_observed: false, truth_status: 'partial' };
2165
+ const profiles = {};
2166
+ for (const [profile, health] of [['lite', lite], ['study-note', studyNote]] as const) {
2167
+ profiles[profile] = { configured: health.state, issues: health.issues, loaded: 'unknown',
2168
+ execution: await inspectHookObservation(cwd, profile, YAM_HOOK_ENTRYPOINT) };
2169
+ }
2170
+ if (args.includes('--json')) {
2171
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, profiles, host, truth_status: 'partial' }, null, 2));
2172
+ if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
2173
+ return;
2174
+ }
2126
2175
  printHookProfileStatus('lite', lite);
2127
2176
  printHookProfileStatus('study-note', studyNote);
2128
2177
  console.log(`scope: ${parsed.mode}`);
2129
2178
  console.log(`file: ${target}`);
2179
+ console.log('active Codex session loaded: unknown (configuration is not runtime evidence)');
2180
+ console.log(`host discovery: ${host.state}`);
2181
+ for (const profile of ['lite', 'study-note']) console.log(`yam-${profile} execution: ${profiles[profile].execution.state} (local entrypoint evidence, not host attestation)`);
2130
2182
  if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
2131
2183
  }
2132
2184
 
@@ -2135,7 +2187,7 @@ function hookConfigHasYamLite(config = {}) {
2135
2187
  }
2136
2188
 
2137
2189
  function hookConfigHasProfile(config = {}, profile = 'lite') {
2138
- return Object.values(config).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2190
+ return Object.values(hookEventMap(config)).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2139
2191
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
2140
2192
  }));
2141
2193
  }
@@ -2150,7 +2202,7 @@ function hookEventsForProfile(profile = 'lite') {
2150
2202
 
2151
2203
  function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
2152
2204
  const matches = [];
2153
- for (const [event, entries] of Object.entries(config)) {
2205
+ for (const [event, entries] of Object.entries(hookEventMap(config))) {
2154
2206
  if (!Array.isArray(entries)) continue;
2155
2207
  for (const entry of entries) {
2156
2208
  if (!Array.isArray(entry?.hooks)) continue;
@@ -2209,6 +2261,7 @@ function splitHookCommand(command = '') {
2209
2261
 
2210
2262
  function parseYamHookCommand(command = '', profile = 'lite') {
2211
2263
  const words = splitHookCommand(command);
2264
+ if (words.at(-1) === '--observe') words.pop();
2212
2265
  if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
2213
2266
  const nodeName = path.basename(words[0]).toLowerCase();
2214
2267
  if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
@@ -2249,6 +2302,7 @@ async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
2249
2302
  const entries = hookProfileEntries(config, profile);
2250
2303
  if (!entries.length) return { state: 'disabled', issues: [], entries };
2251
2304
  const issues = [];
2305
+ if (Object.keys(config).some((event) => CODEX_HOOK_EVENTS.has(event))) issues.push('legacy hook config requires the top-level hooks wrapper; rerun yam hook enable to migrate with backup');
2252
2306
  for (const event of hookEventsForProfile(profile)) {
2253
2307
  const count = entries.filter((entry) => entry.event === event).length;
2254
2308
  if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
@@ -2275,6 +2329,9 @@ async function readHookConfig(target) {
2275
2329
  if (!value || Array.isArray(value) || typeof value !== 'object') {
2276
2330
  return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
2277
2331
  }
2332
+ if (value.hooks != null && (typeof value.hooks !== 'object' || Array.isArray(value.hooks))) {
2333
+ return { config: {}, exists: true, error: 'hooks must be an event object' };
2334
+ }
2278
2335
  return { config: value, exists: true, error: '' };
2279
2336
  } catch (error) {
2280
2337
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
@@ -2294,8 +2351,34 @@ async function writeHookConfig(target, config) {
2294
2351
  }
2295
2352
  }
2296
2353
 
2354
+ async function hookMigrate(args = []) {
2355
+ const parsed = parseHookArgs(args);
2356
+ const target = hookPathFor(parsed);
2357
+ const loaded = await readHookConfig(target);
2358
+ if (loaded.error) { console.error('hook migration blocked: unreadable configuration'); process.exitCode = 1; return; }
2359
+ if (!loaded.exists || !Object.keys(loaded.config).some((key) => CODEX_HOOK_EVENTS.has(key))) {
2360
+ console.log('hook migration: no legacy event layout found'); return;
2361
+ }
2362
+ const before = await fsp.lstat(target);
2363
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error('hook migration blocked: unsafe config file');
2364
+ const original = await fsp.readFile(target, 'utf8');
2365
+ if (JSON.stringify(JSON.parse(original)) !== JSON.stringify(loaded.config)) throw new Error('hook configuration changed; retry migration');
2366
+ const next = canonicalHookConfig(loaded.config);
2367
+ const backup = `${target}.yam-backup-${timestampId()}`;
2368
+ await fsp.copyFile(target, backup, fs.constants.COPYFILE_EXCL);
2369
+ const after = await fsp.lstat(target);
2370
+ if (after.dev !== before.dev || after.ino !== before.ino || after.mtimeMs !== before.mtimeMs
2371
+ || await fsp.readFile(target, 'utf8') !== original) throw new Error('hook configuration changed; backup preserved, retry migration');
2372
+ await writeHookConfig(target, next);
2373
+ console.log(`hook migration: wrapped event groups; handlers and metadata preserved\nbackup: ${backup}\nReview changed definitions in Codex /hooks before enabling execution.`);
2374
+ }
2375
+
2297
2376
  async function hookEnable(args = []) {
2298
2377
  const parsed = parseHookArgs(args);
2378
+ const node = await selectHookNode();
2379
+ const observe = args.includes('--observe') ? ' --observe' : '';
2380
+ YAM_LITE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite${observe}`;
2381
+ YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note${observe}`;
2299
2382
  if (!['lite', 'study-note'].includes(parsed.profile)) {
2300
2383
  console.error('Only lite and study-note hooks are supported: yam hook enable <lite|study-note>');
2301
2384
  process.exitCode = 1;
@@ -2326,6 +2409,7 @@ async function hookEnable(args = []) {
2326
2409
  }
2327
2410
  await writeHookConfig(target, next);
2328
2411
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
2412
+ console.log(`node launcher: ${node.selection}; alias identity matched the current runtime (no PATH candidate executed)`);
2329
2413
  if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
2330
2414
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
2331
2415
  }
@@ -2346,7 +2430,7 @@ async function hookDisable(args = []) {
2346
2430
  return;
2347
2431
  }
2348
2432
  const next = stripYamHooks(current, parsed.profile);
2349
- if (Object.keys(next).length === 0) {
2433
+ if (Object.keys(next).length === 1 && Object.keys(next.hooks).length === 0) {
2350
2434
  await rmrf(target);
2351
2435
  } else {
2352
2436
  await writeHookConfig(target, next);
@@ -2374,6 +2458,7 @@ async function hookRun(args = []) {
2374
2458
  }
2375
2459
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2376
2460
  const cwd = String(input?.cwd || process.cwd());
2461
+ if (args.includes('--observe')) await recordHookObservation(cwd, profile, String(event), YAM_HOOK_ENTRYPOINT).catch(() => {});
2377
2462
  if (profile === 'study-note' && event === 'Stop') {
2378
2463
  console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2379
2464
  return;
@@ -6109,7 +6194,7 @@ async function memory(args = []) {
6109
6194
  }
6110
6195
  if (subcommand === 'summary' || subcommand === 'summarize') {
6111
6196
  const parsed = parseMemoryArgs(args.slice(1));
6112
- return memorySummary(parsed.dir);
6197
+ return memorySummary(parsed.dir, { json: args.includes('--json') });
6113
6198
  }
6114
6199
  if (subcommand === 'resolve') return memoryResolve(args.slice(1));
6115
6200
  console.error(`unknown memory command: ${subcommand}`);
@@ -6123,7 +6208,7 @@ Usage:
6123
6208
  yam memory init [dir]
6124
6209
  yam memory add [dir] --kind <kind> --summary <text> [--evidence <text>] [--action <text>] [--source <text>]
6125
6210
  yam memory list [dir] [--json]
6126
- yam memory summary [dir]
6211
+ yam memory summary [dir] [--json]
6127
6212
  yam memory resolve [dir] <id> [--note <text>]
6128
6213
 
6129
6214
  Kinds:
@@ -6198,7 +6283,14 @@ async function memoryAdd(args = []) {
6198
6283
  }
6199
6284
 
6200
6285
  async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6201
- const records = await readMemoryRecords(targetDir);
6286
+ const report = await readMemoryRecords(targetDir);
6287
+ const records = report.records;
6288
+ if (!report.complete) {
6289
+ if (report.state !== 'missing') process.exitCode = 1;
6290
+ if (json) console.log(JSON.stringify(report, null, 2));
6291
+ else console.error(`memory read ${report.state}: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6292
+ return;
6293
+ }
6202
6294
  if (json) {
6203
6295
  console.log(JSON.stringify(records, null, 2));
6204
6296
  return;
@@ -6213,9 +6305,16 @@ async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6213
6305
  }
6214
6306
  }
6215
6307
 
6216
- async function memorySummary(targetDir = process.cwd()) {
6308
+ async function memorySummary(targetDir = process.cwd(), { json = false } = {}) {
6217
6309
  const dir = memoryDir(targetDir);
6218
- const records = await readMemoryRecords(targetDir);
6310
+ const report = await readMemoryRecords(targetDir);
6311
+ if (!report.complete) {
6312
+ process.exitCode = 1;
6313
+ if (json) console.log(JSON.stringify({ ...report, summary_written: false }, null, 2));
6314
+ else console.error(`memory summary blocked (${report.state}); existing summary preserved: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6315
+ return;
6316
+ }
6317
+ const records = report.records;
6219
6318
  await fsp.mkdir(dir, { recursive: true });
6220
6319
  const active = records.filter((record) => record.status !== 'resolved');
6221
6320
  const resolved = records.filter((record) => record.status === 'resolved');
@@ -6246,7 +6345,8 @@ async function memorySummary(targetDir = process.cwd()) {
6246
6345
 
6247
6346
  const target = path.join(dir, 'summary.md');
6248
6347
  await fsp.writeFile(target, `${lines.join('\n').trim()}\n`);
6249
- console.log(`memory summary written: ${target}`);
6348
+ if (json) console.log(JSON.stringify({ ...report, summary_written: true, path: target }, null, 2));
6349
+ else console.log(`memory summary written: ${target}`);
6250
6350
  }
6251
6351
 
6252
6352
  async function memoryResolve(args = []) {
@@ -6305,20 +6405,7 @@ function memoryDir(targetDir = process.cwd()) {
6305
6405
 
6306
6406
  async function readMemoryRecords(targetDir = process.cwd()) {
6307
6407
  const recordsDir = path.join(memoryDir(targetDir), 'records');
6308
- if (!await exists(recordsDir)) return [];
6309
- const entries = await fsp.readdir(recordsDir, { withFileTypes: true });
6310
- const records = [];
6311
- for (const entry of entries) {
6312
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
6313
- const file = path.join(recordsDir, entry.name);
6314
- try {
6315
- const record = await readJson(file);
6316
- records.push(record);
6317
- } catch (error) {
6318
- records.push({ id: entry.name.replace(/\.json$/, ''), kind: 'invalid', status: 'invalid', summary: `invalid JSON: ${errorMessage(error)}` });
6319
- }
6320
- }
6321
- return records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
6408
+ return readMemoryDirectory(recordsDir);
6322
6409
  }
6323
6410
 
6324
6411
  function timestampId() {
@@ -34,6 +34,7 @@ export interface ExternalUpdatePaths {
34
34
  export interface ExternalUpdateDependencies {
35
35
  run?: (command: string, args: string[], options?: ExternalCommandOptions) => Promise<ExternalCommandResult> | ExternalCommandResult;
36
36
  fetchJson?: (url: string) => Promise<any>;
37
+ probeArtifact?: (url: string) => Promise<number>;
37
38
  now?: () => Date;
38
39
  homeDir?: string;
39
40
  env?: NodeJS.ProcessEnv;
@@ -46,7 +47,7 @@ export interface ExternalComponentCheck {
46
47
  installed_version: string;
47
48
  latest_version: string;
48
49
  update_available: boolean;
49
- status: 'up_to_date' | 'update_available' | 'not_installed' | 'check_failed';
50
+ status: 'up_to_date' | 'update_available' | 'not_installed' | 'pending_distribution' | 'check_failed';
50
51
  source: string;
51
52
  source_revision?: {
52
53
  local: string;
@@ -113,6 +114,7 @@ export interface YamInstallIdentity {
113
114
  interface ResolvedDependencies {
114
115
  run: NonNullable<ExternalUpdateDependencies['run']>;
115
116
  fetchJson: NonNullable<ExternalUpdateDependencies['fetchJson']>;
117
+ probeArtifact: NonNullable<ExternalUpdateDependencies['probeArtifact']>;
116
118
  now: NonNullable<ExternalUpdateDependencies['now']>;
117
119
  homeDir: string;
118
120
  env: NodeJS.ProcessEnv;
@@ -158,7 +160,7 @@ export async function checkExternalUpdates(
158
160
  checkScrapling(deps),
159
161
  checkInsaneSearch(deps)
160
162
  ]);
161
- const failed = checks.filter((item) => item.status === 'check_failed');
163
+ const failed = checks.filter((item) => item.status === 'check_failed' || item.status === 'pending_distribution');
162
164
  return {
163
165
  schema: 'yam.external-update-check.v1',
164
166
  generated_at: deps.now().toISOString(),
@@ -351,6 +353,7 @@ async function resolveDependencies(input: ExternalUpdateDependencies): Promise<R
351
353
  return {
352
354
  run,
353
355
  fetchJson: input.fetchJson || defaultFetchJson,
356
+ probeArtifact: input.probeArtifact || defaultProbeArtifact,
354
357
  now: input.now || (() => new Date()),
355
358
  homeDir,
356
359
  env,
@@ -409,18 +412,52 @@ async function acquireApplyLock(deps: ResolvedDependencies) {
409
412
  }
410
413
 
411
414
  async function checkYam(currentYamVersion: string, deps: ResolvedDependencies): Promise<ExternalComponentCheck> {
415
+ let pending: ExternalComponentCheck | undefined;
412
416
  try {
413
417
  const data = await deps.fetchJson(YAM_REGISTRY_URL);
414
418
  const latest = requireStableVersion(data?.version, 'yam registry version');
415
419
  const installed = requireStableVersion(currentYamVersion, 'installed yam version');
416
420
  const integrity = requireIntegrityValue(data?.dist?.integrity, 'yam registry integrity');
417
421
  const revision = requireGitRevision(data?.gitHead, 'yam registry gitHead');
422
+ if (data?.name !== 'yam-flow') throw new Error('yam registry package name mismatch');
423
+ const tarball = requireYamTarball(data?.dist?.tarball, latest);
424
+ const exactUrl = `${NPM_REGISTRY}yam-flow/${latest}`;
425
+ pending = {
426
+ ...componentCheck('yam', installed, latest, 'npm:yam-flow'),
427
+ status: 'pending_distribution',
428
+ error: 'pending_distribution: exact-version metadata or tarball returned HTTP 404; retry the explicit update later',
429
+ source_receipt: {
430
+ canonical_url: YAM_REGISTRY_URL,
431
+ retrieved_at: deps.now().toISOString(),
432
+ access_path: 'npm latest observed; exact-version distribution readiness incomplete',
433
+ version: latest,
434
+ revision,
435
+ integrity: [integrity]
436
+ }
437
+ };
438
+ let exact: any;
439
+ try {
440
+ exact = await deps.fetchJson(exactUrl);
441
+ } catch (error) {
442
+ if (error instanceof RegistryHttpError && error.status === 404) return pending;
443
+ throw error;
444
+ }
445
+ if (exact?.name !== 'yam-flow' || data?.name !== 'yam-flow'
446
+ || requireStableVersion(exact?.version, 'yam exact registry version') !== latest
447
+ || requireIntegrityValue(exact?.dist?.integrity, 'yam exact registry integrity') !== integrity
448
+ || requireGitRevision(exact?.gitHead, 'yam exact registry gitHead') !== revision
449
+ || requireYamTarball(exact?.dist?.tarball, latest) !== tarball) {
450
+ throw new Error('yam latest and exact-version registry metadata disagree');
451
+ }
452
+ const artifactStatus = await deps.probeArtifact(tarball);
453
+ if (artifactStatus === 404) return pending;
454
+ if (artifactStatus !== 200) throw new Error(`yam tarball availability check failed: HTTP ${artifactStatus}`);
418
455
  return {
419
456
  ...componentCheck('yam', installed, latest, 'npm:yam-flow'),
420
457
  source_receipt: {
421
458
  canonical_url: YAM_REGISTRY_URL,
422
459
  retrieved_at: deps.now().toISOString(),
423
- access_path: 'npm registry latest endpoint',
460
+ access_path: 'npm latest + exact-version metadata + bounded no-redirect tarball HEAD (availability only)',
424
461
  version: latest,
425
462
  revision,
426
463
  integrity: [integrity]
@@ -601,7 +638,7 @@ function failedCheck(
601
638
 
602
639
  async function applyYam(currentYamVersion: string, deps: ResolvedDependencies) {
603
640
  const check = await checkYam(currentYamVersion, deps);
604
- if (check.status === 'check_failed') return persistFailureFromCheck(check, deps);
641
+ if (check.status === 'check_failed' || check.status === 'pending_distribution') return persistFailureFromCheck(check, deps);
605
642
  if (!check.update_available) {
606
643
  return persistReceipt(baseReceipt(check, 'up_to_date', [], {
607
644
  automatic: true,
@@ -1609,14 +1646,58 @@ async function defaultRun(command: string, args: string[], options: ExternalComm
1609
1646
 
1610
1647
  async function defaultFetchJson(url: string) {
1611
1648
  const response = await fetch(url, {
1649
+ redirect: 'error',
1650
+ credentials: 'omit',
1612
1651
  headers: {
1613
1652
  accept: 'application/json',
1614
1653
  'user-agent': 'yam-flow external-update-check'
1615
1654
  },
1616
1655
  signal: AbortSignal.timeout(30000)
1617
1656
  });
1618
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
1619
- return response.json();
1657
+ if (!response.ok) {
1658
+ await response.body?.cancel();
1659
+ throw new RegistryHttpError(response.status, url);
1660
+ }
1661
+ const reader = response.body?.getReader();
1662
+ if (!reader) throw new Error(`empty registry response for ${url}`);
1663
+ const chunks: Uint8Array[] = [];
1664
+ let size = 0;
1665
+ try {
1666
+ while (true) {
1667
+ const chunk = await reader.read();
1668
+ if (chunk.done) break;
1669
+ size += chunk.value.byteLength;
1670
+ if (size > 1024 * 1024) throw new Error('registry metadata exceeds 1 MiB limit');
1671
+ chunks.push(chunk.value);
1672
+ }
1673
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
1674
+ } finally {
1675
+ await reader.cancel().catch(() => undefined);
1676
+ }
1677
+ }
1678
+
1679
+ export class RegistryHttpError extends Error {
1680
+ constructor(readonly status: number, url: string) {
1681
+ super(`HTTP ${status} for ${url}`);
1682
+ }
1683
+ }
1684
+
1685
+ function requireYamTarball(value: unknown, version: string) {
1686
+ const expected = `${NPM_REGISTRY}yam-flow/-/yam-flow-${version}.tgz`;
1687
+ if (value !== expected) throw new Error('yam tarball must be the exact-version artifact on the official npm registry');
1688
+ return expected;
1689
+ }
1690
+
1691
+ async function defaultProbeArtifact(url: string) {
1692
+ const response = await fetch(url, {
1693
+ method: 'HEAD',
1694
+ redirect: 'error',
1695
+ credentials: 'omit',
1696
+ headers: { 'user-agent': 'yam-flow external-update-check' },
1697
+ signal: AbortSignal.timeout(10000)
1698
+ });
1699
+ await response.body?.cancel();
1700
+ return response.status;
1620
1701
  }
1621
1702
 
1622
1703
  function requireStableVersion(value: unknown, label: string) {