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/src/bin/yam.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { studyNoteTurnScope } from '../lib/study-note-scope.js';
4
+ import { selectHookNode, probeHookDiscovery, inspectHookObservation, recordHookObservation } from '../lib/hook-readiness.js';
5
+ import { readMemoryDirectory } from '../lib/memory-records.js';
3
6
  import { runSecurityCommand } from '../lib/security-integration.js';
4
7
  import { createHash } from 'node:crypto';
5
8
  import fs from 'node:fs';
@@ -149,8 +152,8 @@ const INSTRUCTION_DUPLICATION_LIMITS = {
149
152
  minDirectiveChars: 40
150
153
  };
151
154
  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`;
155
+ let YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
156
+ let YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
154
157
  const REQUIRED_PACK_SECTIONS = [
155
158
  'Product Direction',
156
159
  'UI Direction',
@@ -986,8 +989,8 @@ async function studyNoteCheck(args = []) {
986
989
  if (result.blockers.length) process.exitCode = 1;
987
990
  }
988
991
 
989
- async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
990
- const changedFileDetection = gitChangedFileSnapshot(dir);
992
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided', scope: any = null) {
993
+ const changedFileDetection = scope || gitChangedFileSnapshot(dir);
991
994
  const changedFiles = changedFileDetection.files;
992
995
  const changeScopeAvailable = changedFileDetection.available;
993
996
  const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
@@ -1022,6 +1025,13 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
1022
1025
  for (const requirement of hygieneRequirements) {
1023
1026
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(studyNoteText, requirement), requirement.note));
1024
1027
  }
1028
+ if (scope?.mode === 'advisory') {
1029
+ const ordinary = new Set(['changed_files', 'study_note_present', 'role_or_responsibility', 'verification', 'limits_or_uncertainty', 'next_step_present', 'single_study_note_next_step_pair', 'study_note_then_next_step']);
1030
+ for (const check of checks) if (!ordinary.has(check.id)) {
1031
+ check.status = 'skipped';
1032
+ check.truth_status = 'skipped';
1033
+ }
1034
+ }
1025
1035
  const blockers = checks.filter((check) => check.status === 'fail').map((check) => `${check.id}: ${check.next_action}`);
1026
1036
  const result = {
1027
1037
  schema: 'yam.study-note-guard.v1',
@@ -1108,7 +1118,7 @@ function hasNextStepMarker(text = '') {
1108
1118
 
1109
1119
  function reportSectionMarkers(text = '') {
1110
1120
  return String(text || '').split(/\r?\n/).map((line, index) => {
1111
- const trimmed = line.trim();
1121
+ const trimmed = line.trim().replace(/^\*\*(.*?)\*\*\s*:?[ \t]*$/, '$1');
1112
1122
  const heading = trimmed.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
1113
1123
  const named = trimmed.match(/^(study note|study-note|학습\s*노트|스터디\s*노트|next step|next-step|다음\s*(?:단계|할\s*일|작업))\s*:?[ \t]*$/i);
1114
1124
  const rawLabel = heading?.[1] || named?.[1] || '';
@@ -1234,7 +1244,7 @@ function studyNoteHygieneRequirements(files = []) {
1234
1244
  if (files.some((file) => /(^|\/)global\.css$/i.test(file))) {
1235
1245
  requirements.push({ id: 'global_css_hygiene', terms: ['global.css', 'token', 'component', 'scoped', 'module', 'utility'], note: 'Study Note should say whether CSS belongs globally or should move to tokens/scoped/component styles' });
1236
1246
  }
1237
- if (files.some((file) => /(migration|schema|db|database|sql|prisma|drizzle|supabase)/i.test(file))) {
1247
+ if (files.some((file) => /\.(sql|prisma)$/i.test(file) || /(^|\/)(migrations?|schema|database|drizzle|supabase)\//i.test(file) && /\.(ts|js)$/i.test(file))) {
1238
1248
  requirements.push({ id: 'jsonb_hygiene', terms: ['jsonb', 'column', 'table', 'constraint', 'index', 'schema', 'relation'], note: 'Study Note should say whether structured product data belongs in typed columns/tables/constraints/indexes instead of broad DB jsonb' });
1239
1249
  }
1240
1250
  return requirements;
@@ -2014,6 +2024,7 @@ async function hook(args = []) {
2014
2024
  const subcommand = args[0] || 'status';
2015
2025
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return hookUsage();
2016
2026
  if (subcommand === 'status') return hookStatus(args.slice(1));
2027
+ if (subcommand === 'migrate') return hookMigrate(args.slice(1));
2017
2028
  if (subcommand === 'enable') return hookEnable(args.slice(1));
2018
2029
  if (subcommand === 'disable') return hookDisable(args.slice(1));
2019
2030
  if (subcommand === 'run') return hookRun(args.slice(1));
@@ -2025,14 +2036,17 @@ function hookUsage() {
2025
2036
  console.log(`yam hook
2026
2037
 
2027
2038
  Usage:
2028
- yam hook status [--global|--project dir]
2029
- yam hook enable <lite|study-note> [--global|--project dir]
2039
+ yam hook status [--global|--project dir] [--probe] [--json]
2040
+ yam hook migrate [--global|--project dir]
2041
+ yam hook enable <lite|study-note> [--global|--project dir] [--observe]
2030
2042
  yam hook disable [lite|study-note] [--global|--project dir]
2031
2043
  yam hook run <lite|study-note>
2032
2044
 
2033
2045
  Notes:
2034
2046
  hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
2035
2047
  Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
2048
+ --probe queries hooks/list in a separate bounded Codex app-server; active session loading stays unknown.
2049
+ --observe opts into private local entrypoint observations without storing prompt content; not host attestation.
2036
2050
  `);
2037
2051
  }
2038
2052
 
@@ -2057,7 +2071,7 @@ function parseHookArgs(args = []) {
2057
2071
  }
2058
2072
 
2059
2073
  function hookPathFor(parsed) {
2060
- if (parsed.mode === 'global') return path.join(os.homedir(), '.codex', 'hooks.json');
2074
+ if (parsed.mode === 'global') return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
2061
2075
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
2062
2076
  }
2063
2077
 
@@ -2073,19 +2087,42 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
2073
2087
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
2074
2088
  }
2075
2089
 
2090
+ const CODEX_HOOK_EVENTS = new Set(['SessionStart', 'SessionEnd', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt']);
2091
+
2092
+ function hookEventMap(config: AnyRecord = {}) {
2093
+ const nested = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks) ? config.hooks : {};
2094
+ const events = { ...nested };
2095
+ for (const [event, entries] of Object.entries(config)) {
2096
+ if (CODEX_HOOK_EVENTS.has(event) && Array.isArray(entries)) {
2097
+ events[event] = [...(Array.isArray(events[event]) ? events[event] : []), ...entries];
2098
+ }
2099
+ }
2100
+ return events;
2101
+ }
2102
+
2103
+ function canonicalHookConfig(config: AnyRecord = {}) {
2104
+ if (config.hooks != null && (typeof config.hooks !== 'object' || Array.isArray(config.hooks))) throw new Error('hooks must be an event object; existing file preserved');
2105
+ const next = { ...config, hooks: hookEventMap(config) };
2106
+ for (const event of CODEX_HOOK_EVENTS) {
2107
+ if (event in config && !Array.isArray(config[event])) throw new Error('legacy hook event must be an array; existing file preserved');
2108
+ delete next[event];
2109
+ }
2110
+ return next;
2111
+ }
2112
+
2076
2113
  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];
2114
+ const next = canonicalHookConfig(config);
2115
+ for (const event of Object.keys(next.hooks)) {
2116
+ if (!Array.isArray(next.hooks[event])) continue;
2117
+ const entries = next.hooks[event];
2081
2118
  const keptEntries = [];
2082
2119
  for (const entry of entries) {
2083
2120
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
2084
2121
  const rest = { ...entry, hooks };
2085
2122
  if (hooks.length > 0) keptEntries.push(rest);
2086
2123
  }
2087
- if (keptEntries.length > 0) next[event] = keptEntries;
2088
- else delete next[event];
2124
+ if (keptEntries.length > 0) next.hooks[event] = keptEntries;
2125
+ else delete next.hooks[event];
2089
2126
  }
2090
2127
  return next;
2091
2128
  }
@@ -2103,7 +2140,7 @@ function withYamHook(config: AnyRecord = {}, profile = 'lite') {
2103
2140
  }
2104
2141
  ]
2105
2142
  };
2106
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
2143
+ next.hooks[event] = [...(Array.isArray(next.hooks[event]) ? next.hooks[event] : []), entry];
2107
2144
  }
2108
2145
  return next;
2109
2146
  }
@@ -2113,6 +2150,11 @@ async function hookStatus(args = []) {
2113
2150
  const target = hookPathFor(parsed);
2114
2151
  const loaded = await readHookConfig(target);
2115
2152
  if (loaded.error) {
2153
+ if (args.includes('--json')) {
2154
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, configuration: 'unreadable', loaded: 'unknown', execution_observed: false, truth_status: 'partial' }, null, 2));
2155
+ process.exitCode = 1;
2156
+ return;
2157
+ }
2116
2158
  console.log('yam-lite hook: broken');
2117
2159
  console.log('yam-study-note hook: broken');
2118
2160
  console.log(` - hook config unreadable: ${loaded.error}`);
@@ -2123,10 +2165,28 @@ async function hookStatus(args = []) {
2123
2165
  }
2124
2166
  const lite = await inspectHookProfile(loaded.config, 'lite');
2125
2167
  const studyNote = await inspectHookProfile(loaded.config, 'study-note');
2168
+ const cwd = path.resolve(parsed.projectDir || process.cwd());
2169
+ const commands = { lite: lite.entries.map((entry) => String(entry.handler.command)), 'study-note': studyNote.entries.map((entry) => String(entry.handler.command)) };
2170
+ const host = args.includes('--probe')
2171
+ ? await probeHookDiscovery({ executable: await findExecutable('codex') || 'codex', cwd, commands, configPath: target })
2172
+ : { state: 'not_measured', active_session_loaded: 'not_measured', execution_observed: false, truth_status: 'partial' };
2173
+ const profiles = {};
2174
+ for (const [profile, health] of [['lite', lite], ['study-note', studyNote]] as const) {
2175
+ profiles[profile] = { configured: health.state, issues: health.issues, loaded: 'unknown',
2176
+ execution: await inspectHookObservation(cwd, profile, YAM_HOOK_ENTRYPOINT) };
2177
+ }
2178
+ if (args.includes('--json')) {
2179
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, profiles, host, truth_status: 'partial' }, null, 2));
2180
+ if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
2181
+ return;
2182
+ }
2126
2183
  printHookProfileStatus('lite', lite);
2127
2184
  printHookProfileStatus('study-note', studyNote);
2128
2185
  console.log(`scope: ${parsed.mode}`);
2129
2186
  console.log(`file: ${target}`);
2187
+ console.log('active Codex session loaded: unknown (configuration is not runtime evidence)');
2188
+ console.log(`host discovery: ${host.state}`);
2189
+ for (const profile of ['lite', 'study-note']) console.log(`yam-${profile} execution: ${profiles[profile].execution.state} (local entrypoint evidence, not host attestation)`);
2130
2190
  if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
2131
2191
  }
2132
2192
 
@@ -2135,7 +2195,7 @@ function hookConfigHasYamLite(config = {}) {
2135
2195
  }
2136
2196
 
2137
2197
  function hookConfigHasProfile(config = {}, profile = 'lite') {
2138
- return Object.values(config).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2198
+ return Object.values(hookEventMap(config)).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2139
2199
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
2140
2200
  }));
2141
2201
  }
@@ -2150,7 +2210,7 @@ function hookEventsForProfile(profile = 'lite') {
2150
2210
 
2151
2211
  function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
2152
2212
  const matches = [];
2153
- for (const [event, entries] of Object.entries(config)) {
2213
+ for (const [event, entries] of Object.entries(hookEventMap(config))) {
2154
2214
  if (!Array.isArray(entries)) continue;
2155
2215
  for (const entry of entries) {
2156
2216
  if (!Array.isArray(entry?.hooks)) continue;
@@ -2209,6 +2269,7 @@ function splitHookCommand(command = '') {
2209
2269
 
2210
2270
  function parseYamHookCommand(command = '', profile = 'lite') {
2211
2271
  const words = splitHookCommand(command);
2272
+ if (words.at(-1) === '--observe') words.pop();
2212
2273
  if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
2213
2274
  const nodeName = path.basename(words[0]).toLowerCase();
2214
2275
  if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
@@ -2249,6 +2310,7 @@ async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
2249
2310
  const entries = hookProfileEntries(config, profile);
2250
2311
  if (!entries.length) return { state: 'disabled', issues: [], entries };
2251
2312
  const issues = [];
2313
+ 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
2314
  for (const event of hookEventsForProfile(profile)) {
2253
2315
  const count = entries.filter((entry) => entry.event === event).length;
2254
2316
  if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
@@ -2275,6 +2337,9 @@ async function readHookConfig(target) {
2275
2337
  if (!value || Array.isArray(value) || typeof value !== 'object') {
2276
2338
  return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
2277
2339
  }
2340
+ if (value.hooks != null && (typeof value.hooks !== 'object' || Array.isArray(value.hooks))) {
2341
+ return { config: {}, exists: true, error: 'hooks must be an event object' };
2342
+ }
2278
2343
  return { config: value, exists: true, error: '' };
2279
2344
  } catch (error) {
2280
2345
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
@@ -2294,8 +2359,34 @@ async function writeHookConfig(target, config) {
2294
2359
  }
2295
2360
  }
2296
2361
 
2362
+ async function hookMigrate(args = []) {
2363
+ const parsed = parseHookArgs(args);
2364
+ const target = hookPathFor(parsed);
2365
+ const loaded = await readHookConfig(target);
2366
+ if (loaded.error) { console.error('hook migration blocked: unreadable configuration'); process.exitCode = 1; return; }
2367
+ if (!loaded.exists || !Object.keys(loaded.config).some((key) => CODEX_HOOK_EVENTS.has(key))) {
2368
+ console.log('hook migration: no legacy event layout found'); return;
2369
+ }
2370
+ const before = await fsp.lstat(target);
2371
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error('hook migration blocked: unsafe config file');
2372
+ const original = await fsp.readFile(target, 'utf8');
2373
+ if (JSON.stringify(JSON.parse(original)) !== JSON.stringify(loaded.config)) throw new Error('hook configuration changed; retry migration');
2374
+ const next = canonicalHookConfig(loaded.config);
2375
+ const backup = `${target}.yam-backup-${timestampId()}`;
2376
+ await fsp.copyFile(target, backup, fs.constants.COPYFILE_EXCL);
2377
+ const after = await fsp.lstat(target);
2378
+ if (after.dev !== before.dev || after.ino !== before.ino || after.mtimeMs !== before.mtimeMs
2379
+ || await fsp.readFile(target, 'utf8') !== original) throw new Error('hook configuration changed; backup preserved, retry migration');
2380
+ await writeHookConfig(target, next);
2381
+ console.log(`hook migration: wrapped event groups; handlers and metadata preserved\nbackup: ${backup}\nReview changed definitions in Codex /hooks before enabling execution.`);
2382
+ }
2383
+
2297
2384
  async function hookEnable(args = []) {
2298
2385
  const parsed = parseHookArgs(args);
2386
+ const node = await selectHookNode();
2387
+ const observe = args.includes('--observe') ? ' --observe' : '';
2388
+ YAM_LITE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite${observe}`;
2389
+ YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note${observe}`;
2299
2390
  if (!['lite', 'study-note'].includes(parsed.profile)) {
2300
2391
  console.error('Only lite and study-note hooks are supported: yam hook enable <lite|study-note>');
2301
2392
  process.exitCode = 1;
@@ -2326,6 +2417,7 @@ async function hookEnable(args = []) {
2326
2417
  }
2327
2418
  await writeHookConfig(target, next);
2328
2419
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
2420
+ console.log(`node launcher: ${node.selection}; alias identity matched the current runtime (no PATH candidate executed)`);
2329
2421
  if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
2330
2422
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
2331
2423
  }
@@ -2346,7 +2438,7 @@ async function hookDisable(args = []) {
2346
2438
  return;
2347
2439
  }
2348
2440
  const next = stripYamHooks(current, parsed.profile);
2349
- if (Object.keys(next).length === 0) {
2441
+ if (Object.keys(next).length === 1 && Object.keys(next.hooks).length === 0) {
2350
2442
  await rmrf(target);
2351
2443
  } else {
2352
2444
  await writeHookConfig(target, next);
@@ -2374,13 +2466,14 @@ async function hookRun(args = []) {
2374
2466
  }
2375
2467
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2376
2468
  const cwd = String(input?.cwd || process.cwd());
2469
+ if (args.includes('--observe')) await recordHookObservation(cwd, profile, String(event), YAM_HOOK_ENTRYPOINT).catch(() => {});
2377
2470
  if (profile === 'study-note' && event === 'Stop') {
2378
2471
  console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2379
2472
  return;
2380
2473
  }
2381
2474
  const prompt = extractPrompt(input);
2382
2475
  const additionalContext = profile === 'study-note'
2383
- ? await buildStudyNoteHookContext({ cwd })
2476
+ ? await buildStudyNoteTurnContext(input, cwd)
2384
2477
  : await buildYamLiteContext({ cwd, prompt });
2385
2478
  const output = {
2386
2479
  continue: true,
@@ -2412,6 +2505,7 @@ function extractPrompt(input: AnyRecord = {}) {
2412
2505
  async function buildYamLiteContext({ cwd, prompt }) {
2413
2506
  const lines = [
2414
2507
  'yam-lite guide active: keep project direction visible, preserve momentum, and deepen when scope, risk, or user intent calls for it.',
2508
+ 'Git workflow: keep the current branch; create/switch branches, worktrees or PRs only on explicit user request or applicable project rule. Commit/push, release bumps, branch-name prefixes and protection alone do not authorize them. If blocked, report and ask; do not silently change workflow or protections.',
2415
2509
  'Default: do a basic direction-fit and honest-verification check; avoid broad reading for small work and never claim proof without evidence.'
2416
2510
  ];
2417
2511
  const pack = await findProjectPack(cwd).catch(() => null);
@@ -2427,38 +2521,10 @@ async function buildYamLiteContext({ cwd, prompt }) {
2427
2521
  return lines.join('\n');
2428
2522
  }
2429
2523
 
2430
- async function buildStudyNoteHookContext({ cwd }) {
2431
- const dir = path.resolve(cwd || process.cwd());
2432
- const changedFileDetection = gitChangedFileSnapshot(dir);
2433
- const changedFiles = changedFileDetection.files;
2434
- const lines = [
2435
- 'yam Study Note guard active: if this turn changes code, config, release metadata, docs, or project artifacts, include a Study Note followed immediately by Next step in the final response.'
2436
- ];
2437
- if (!changedFileDetection.available) {
2438
- lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2439
- lines.push('Determine manually whether artifacts changed and include Study Note followed by Next step when they did; the Stop hook will keep this uncertainty visible.');
2440
- return lines.join('\n');
2441
- }
2442
- if (!changedFiles.length) {
2443
- lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add Study Note followed immediately by Next step before final.');
2444
- lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
2445
- return lines.join('\n');
2446
- }
2447
- lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
2448
- lines.push('Study Note minimum: touched code/artifact, role, execution point, before/after change, expected behavior, one syntax/structure insight, verification, and limits.');
2449
- lines.push('Next step minimum: quick whole-process scan, current situation, forward outlook, critical opinion, improvement recommendation, ordered fix-first then planned actions, evidence, ownership, blocker status, safe retry, side effects, and truth status.');
2450
- const hygiene = studyNoteHygieneRequirements(changedFiles);
2451
- if (hygiene.length) {
2452
- lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
2453
- lines.push('Report whether the change avoided dumping unrelated logic into page.tsx, one-off CSS into global.css, or structured product data into broad DB jsonb.');
2454
- }
2455
- lines.push('This prompt reminder does not generate or edit either section; the paired Stop hook requests one correction pass if the final response is incomplete or ordered incorrectly.');
2456
- return lines.join('\n');
2457
- }
2458
-
2459
2524
  async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd()) {
2460
2525
  const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
2461
- const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2526
+ const scope = studyNoteTurnScope(input, cwd);
2527
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message', scope);
2462
2528
  if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2463
2529
  return {
2464
2530
  continue: true,
@@ -2466,7 +2532,11 @@ async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd
2466
2532
  };
2467
2533
  }
2468
2534
  if (!result.blockers.length) return { continue: true };
2469
- const summary = result.blockers.slice(0, 4).join('; ');
2535
+ const failed = result.checks.filter(check => check.status === 'fail').map(check => check.id);
2536
+ const summary = failed.includes('study_note_present') ? 'study_note_present' : failed.join(', ');
2537
+ if (scope.mode !== 'strict') {
2538
+ return { continue: true, systemMessage: `yam Study Note advisory — missing fields: ${summary}. Keep ordinary work concise; no correction gate applied.` };
2539
+ }
2470
2540
  if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
2471
2541
  return {
2472
2542
  continue: true,
@@ -2476,13 +2546,18 @@ async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd
2476
2546
  return {
2477
2547
  decision: 'block',
2478
2548
  reason: [
2479
- 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
2480
- summary,
2481
- 'Revise the final response to include: touched artifact, role, execution point, before/after change, expected behavior, one syntax/structure insight, verification, limits, and any relevant architecture hygiene.'
2549
+ `yam Study Note strict — missing fields: ${summary}.`,
2550
+ 'Revise only the missing fields in Study Note / Next step; this is report validation, not a security scan.'
2482
2551
  ].join(' ')
2483
2552
  };
2484
2553
  }
2485
2554
 
2555
+ async function buildStudyNoteTurnContext(input: AnyRecord, cwd: string) {
2556
+ const scope = studyNoteTurnScope(input, cwd, true);
2557
+ if (!scope.available) return 'yam Study Note: turn scope is unavailable; do not treat this as a clean project. Inspect changed artifacts manually; no strict block will be applied without a baseline.';
2558
+ return `yam Study Note turn baseline saved (hash-only). Mode: ${scope.mode}. Only changes since this prompt count; known generated logs, mission/security receipts and screenshots are excluded unless explicitly included. Include a concise Study Note followed by Next step when product artifacts change. ${scope.mode === 'strict' ? 'Risk work uses the detailed report contract and one correction pass.' : 'Ordinary work is advisory: a short explanation, verification/limits and remaining work (or none) suffice.'}`;
2559
+ }
2560
+
2486
2561
  function yamLiteRouteHint(prompt = '') {
2487
2562
  const text = String(prompt || '').toLowerCase();
2488
2563
  if (!text) return '';
@@ -6109,7 +6184,7 @@ async function memory(args = []) {
6109
6184
  }
6110
6185
  if (subcommand === 'summary' || subcommand === 'summarize') {
6111
6186
  const parsed = parseMemoryArgs(args.slice(1));
6112
- return memorySummary(parsed.dir);
6187
+ return memorySummary(parsed.dir, { json: args.includes('--json') });
6113
6188
  }
6114
6189
  if (subcommand === 'resolve') return memoryResolve(args.slice(1));
6115
6190
  console.error(`unknown memory command: ${subcommand}`);
@@ -6123,7 +6198,7 @@ Usage:
6123
6198
  yam memory init [dir]
6124
6199
  yam memory add [dir] --kind <kind> --summary <text> [--evidence <text>] [--action <text>] [--source <text>]
6125
6200
  yam memory list [dir] [--json]
6126
- yam memory summary [dir]
6201
+ yam memory summary [dir] [--json]
6127
6202
  yam memory resolve [dir] <id> [--note <text>]
6128
6203
 
6129
6204
  Kinds:
@@ -6198,7 +6273,14 @@ async function memoryAdd(args = []) {
6198
6273
  }
6199
6274
 
6200
6275
  async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6201
- const records = await readMemoryRecords(targetDir);
6276
+ const report = await readMemoryRecords(targetDir);
6277
+ const records = report.records;
6278
+ if (!report.complete) {
6279
+ if (report.state !== 'missing') process.exitCode = 1;
6280
+ if (json) console.log(JSON.stringify(report, null, 2));
6281
+ else console.error(`memory read ${report.state}: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6282
+ return;
6283
+ }
6202
6284
  if (json) {
6203
6285
  console.log(JSON.stringify(records, null, 2));
6204
6286
  return;
@@ -6213,9 +6295,16 @@ async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6213
6295
  }
6214
6296
  }
6215
6297
 
6216
- async function memorySummary(targetDir = process.cwd()) {
6298
+ async function memorySummary(targetDir = process.cwd(), { json = false } = {}) {
6217
6299
  const dir = memoryDir(targetDir);
6218
- const records = await readMemoryRecords(targetDir);
6300
+ const report = await readMemoryRecords(targetDir);
6301
+ if (!report.complete) {
6302
+ process.exitCode = 1;
6303
+ if (json) console.log(JSON.stringify({ ...report, summary_written: false }, null, 2));
6304
+ else console.error(`memory summary blocked (${report.state}); existing summary preserved: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6305
+ return;
6306
+ }
6307
+ const records = report.records;
6219
6308
  await fsp.mkdir(dir, { recursive: true });
6220
6309
  const active = records.filter((record) => record.status !== 'resolved');
6221
6310
  const resolved = records.filter((record) => record.status === 'resolved');
@@ -6246,7 +6335,8 @@ async function memorySummary(targetDir = process.cwd()) {
6246
6335
 
6247
6336
  const target = path.join(dir, 'summary.md');
6248
6337
  await fsp.writeFile(target, `${lines.join('\n').trim()}\n`);
6249
- console.log(`memory summary written: ${target}`);
6338
+ if (json) console.log(JSON.stringify({ ...report, summary_written: true, path: target }, null, 2));
6339
+ else console.log(`memory summary written: ${target}`);
6250
6340
  }
6251
6341
 
6252
6342
  async function memoryResolve(args = []) {
@@ -6305,20 +6395,7 @@ function memoryDir(targetDir = process.cwd()) {
6305
6395
 
6306
6396
  async function readMemoryRecords(targetDir = process.cwd()) {
6307
6397
  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 || '')));
6398
+ return readMemoryDirectory(recordsDir);
6322
6399
  }
6323
6400
 
6324
6401
  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) {