yam-flow 2.8.0 → 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.
@@ -48,6 +48,12 @@ Study Note:
48
48
  - Limits: this does not infer requirements from prose; the agent or user still has to provide them.
49
49
  ```
50
50
 
51
+ ## Security check in Yam note
52
+
53
+ When documenting commit, push, or deployment security checks, include a separate `-- Security check --` block using `references/security-check.md` and `templates/security-check-note.md`. Every SEC ID must have a concrete Korean security explanation, its status, and supporting evidence or an explicit limitation. Keep this block outside the Study Note / Next step pair so their required adjacency is preserved.
54
+
55
+ The security CLI appends this block to the configured note without adding a `yam note` command or changing `yam.study-note.v1`. Git hooks require explicit setup; remote CI/deployment enforcement requires project configuration.
56
+
51
57
  ## When Nothing Changed
52
58
 
53
59
  For pure Q&A, planning, scouting, or review with no changed artifact, do not force a Study Note. Say `No code or project artifact changed` when that prevents ambiguity.
@@ -73,6 +73,10 @@ Stop condition:
73
73
  - Stop earlier when the same blocker repeats, a required tool/auth/runtime is unavailable, or a user decision is needed.
74
74
  - Do not keep retrying the same failure; classify the cause, give a recovery hint, and hand off.
75
75
 
76
+ ## Commit, push and deployment security gate
77
+
78
+ Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
79
+
76
80
  ## Final Response
77
81
 
78
82
  Include:
@@ -120,6 +120,10 @@ Good mission prompts include:
120
120
 
121
121
  If the user invokes `$mission` without an approved plan, ask for the plan or propose a compact plan first.
122
122
 
123
+ ## Commit, push and deployment security gate
124
+
125
+ Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
126
+
123
127
  ## Final Response
124
128
 
125
129
  Report:
@@ -74,6 +74,10 @@ Use `references/study-note.md` when any code, config, docs, or project artifact
74
74
  Use `references/final-report.md` to close with remaining tasks and fix-first items when useful.
75
75
  Use `references/token-budget-reporter.md` when a run needs measured budget feedback.
76
76
 
77
+ ## Commit, push and deployment security gate
78
+
79
+ Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
80
+
77
81
  ## Final Response
78
82
 
79
83
  Keep it compact:
@@ -182,6 +182,10 @@ Always consider:
182
182
  - Interaction affordance.
183
183
  - Consistency with the project's visual language.
184
184
 
185
+ ## Commit, push and deployment security gate
186
+
187
+ Before performing a requested commit, push or deployment, follow `references/security-check.md`: validate stage-bound evidence with `yam security check`, require a successful persisted Security check note, and stop the operation when the gate blocks. If the project is not configured, report that blocker rather than claim security approval. Do not overwrite existing hooks or treat supplied evidence as an automatic vulnerability scan.
188
+
185
189
  ## Final Response
186
190
 
187
191
  Report:
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 { selectHookNode, probeHookDiscovery, inspectHookObservation, recordHookObservation } from '../lib/hook-readiness.js';
4
+ import { readMemoryDirectory } from '../lib/memory-records.js';
5
+ import { runSecurityCommand } from '../lib/security-integration.js';
3
6
  import { createHash } from 'node:crypto';
4
7
  import fs from 'node:fs';
5
8
  import fsp from 'node:fs/promises';
@@ -148,8 +151,8 @@ const INSTRUCTION_DUPLICATION_LIMITS = {
148
151
  minDirectiveChars: 40
149
152
  };
150
153
  const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
151
- const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
152
- 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`;
153
156
  const REQUIRED_PACK_SECTIONS = [
154
157
  'Product Direction',
155
158
  'UI Direction',
@@ -224,6 +227,7 @@ Usage:
224
227
  yam proof [dir|--from file] [--route route] [--truth status] [--command text] [--evidence text]
225
228
  yam proof write [dir] [--format json|md] [--out file] [--route route] [--truth status] [--command text]
226
229
  yam study-note check [dir] [--report file|--text text] [--json]
230
+ yam security <init|check|hooks|status> [--stage commit|push|ci|deploy] [--json]
227
231
  yam next-step <report|verify> [--spec file|--receipt file] [--json]
228
232
  yam loop report [--route route] [--intent text] [--stage id:status:note] [--evidence text] [--json]
229
233
  yam scout receipt <create|verify> [options]
@@ -249,7 +253,7 @@ Usage:
249
253
  yam safety [text...]
250
254
  yam memory <init|add|list|summary|resolve> [dir] [options]
251
255
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
252
- yam template <project|ueye|mission|proof|tuning>
256
+ yam template <project|ueye|mission|proof|tuning|security|security-ci>
253
257
  yam tune-log [dir]
254
258
  yam install [--dry-run] [--json] [--replace-user-skill name]
255
259
  yam uninstall
@@ -570,6 +574,7 @@ async function verify({ quiet = false } = {}) {
570
574
  'context-reuse.md',
571
575
  'markdown-management.md',
572
576
  'study-note.md',
577
+ 'security-check.md',
573
578
  'final-report.md',
574
579
  'token-budget-reporter.md',
575
580
  'memory.md'
@@ -585,7 +590,7 @@ async function verify({ quiet = false } = {}) {
585
590
  if (!hasHeading(projectTemplate, section)) issues.push(`project template missing section: ${section}`);
586
591
  }
587
592
  }
588
- for (const template of ['ueye-review.md', 'ueye-comparison.md', 'mission-plan.md', 'runtime-proof.md', 'tuning-log.md']) {
593
+ for (const template of ['ueye-review.md', 'ueye-comparison.md', 'mission-plan.md', 'runtime-proof.md', 'tuning-log.md', 'security-check-note.md', 'security-gate.yml']) {
589
594
  if (!await exists(path.join(ROOT, 'templates', template))) issues.push(`missing template: ${template}`);
590
595
  }
591
596
  for (const module of ['trust-kernel.js', 'ueye-artifacts.js']) {
@@ -2011,6 +2016,7 @@ async function hook(args = []) {
2011
2016
  const subcommand = args[0] || 'status';
2012
2017
  if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return hookUsage();
2013
2018
  if (subcommand === 'status') return hookStatus(args.slice(1));
2019
+ if (subcommand === 'migrate') return hookMigrate(args.slice(1));
2014
2020
  if (subcommand === 'enable') return hookEnable(args.slice(1));
2015
2021
  if (subcommand === 'disable') return hookDisable(args.slice(1));
2016
2022
  if (subcommand === 'run') return hookRun(args.slice(1));
@@ -2022,14 +2028,17 @@ function hookUsage() {
2022
2028
  console.log(`yam hook
2023
2029
 
2024
2030
  Usage:
2025
- yam hook status [--global|--project dir]
2026
- 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]
2027
2034
  yam hook disable [lite|study-note] [--global|--project dir]
2028
2035
  yam hook run <lite|study-note>
2029
2036
 
2030
2037
  Notes:
2031
2038
  hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
2032
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.
2033
2042
  `);
2034
2043
  }
2035
2044
 
@@ -2054,7 +2063,7 @@ function parseHookArgs(args = []) {
2054
2063
  }
2055
2064
 
2056
2065
  function hookPathFor(parsed) {
2057
- 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');
2058
2067
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
2059
2068
  }
2060
2069
 
@@ -2070,19 +2079,42 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
2070
2079
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
2071
2080
  }
2072
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
+
2073
2105
  function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
2074
- const next = { ...config };
2075
- for (const event of Object.keys(next)) {
2076
- if (!Array.isArray(next[event])) continue;
2077
- 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];
2078
2110
  const keptEntries = [];
2079
2111
  for (const entry of entries) {
2080
2112
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
2081
2113
  const rest = { ...entry, hooks };
2082
2114
  if (hooks.length > 0) keptEntries.push(rest);
2083
2115
  }
2084
- if (keptEntries.length > 0) next[event] = keptEntries;
2085
- else delete next[event];
2116
+ if (keptEntries.length > 0) next.hooks[event] = keptEntries;
2117
+ else delete next.hooks[event];
2086
2118
  }
2087
2119
  return next;
2088
2120
  }
@@ -2100,7 +2132,7 @@ function withYamHook(config: AnyRecord = {}, profile = 'lite') {
2100
2132
  }
2101
2133
  ]
2102
2134
  };
2103
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
2135
+ next.hooks[event] = [...(Array.isArray(next.hooks[event]) ? next.hooks[event] : []), entry];
2104
2136
  }
2105
2137
  return next;
2106
2138
  }
@@ -2110,6 +2142,11 @@ async function hookStatus(args = []) {
2110
2142
  const target = hookPathFor(parsed);
2111
2143
  const loaded = await readHookConfig(target);
2112
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
+ }
2113
2150
  console.log('yam-lite hook: broken');
2114
2151
  console.log('yam-study-note hook: broken');
2115
2152
  console.log(` - hook config unreadable: ${loaded.error}`);
@@ -2120,10 +2157,28 @@ async function hookStatus(args = []) {
2120
2157
  }
2121
2158
  const lite = await inspectHookProfile(loaded.config, 'lite');
2122
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
+ }
2123
2175
  printHookProfileStatus('lite', lite);
2124
2176
  printHookProfileStatus('study-note', studyNote);
2125
2177
  console.log(`scope: ${parsed.mode}`);
2126
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)`);
2127
2182
  if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
2128
2183
  }
2129
2184
 
@@ -2132,7 +2187,7 @@ function hookConfigHasYamLite(config = {}) {
2132
2187
  }
2133
2188
 
2134
2189
  function hookConfigHasProfile(config = {}, profile = 'lite') {
2135
- 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) => {
2136
2191
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
2137
2192
  }));
2138
2193
  }
@@ -2147,7 +2202,7 @@ function hookEventsForProfile(profile = 'lite') {
2147
2202
 
2148
2203
  function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
2149
2204
  const matches = [];
2150
- for (const [event, entries] of Object.entries(config)) {
2205
+ for (const [event, entries] of Object.entries(hookEventMap(config))) {
2151
2206
  if (!Array.isArray(entries)) continue;
2152
2207
  for (const entry of entries) {
2153
2208
  if (!Array.isArray(entry?.hooks)) continue;
@@ -2206,6 +2261,7 @@ function splitHookCommand(command = '') {
2206
2261
 
2207
2262
  function parseYamHookCommand(command = '', profile = 'lite') {
2208
2263
  const words = splitHookCommand(command);
2264
+ if (words.at(-1) === '--observe') words.pop();
2209
2265
  if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
2210
2266
  const nodeName = path.basename(words[0]).toLowerCase();
2211
2267
  if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
@@ -2246,6 +2302,7 @@ async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
2246
2302
  const entries = hookProfileEntries(config, profile);
2247
2303
  if (!entries.length) return { state: 'disabled', issues: [], entries };
2248
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');
2249
2306
  for (const event of hookEventsForProfile(profile)) {
2250
2307
  const count = entries.filter((entry) => entry.event === event).length;
2251
2308
  if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
@@ -2272,6 +2329,9 @@ async function readHookConfig(target) {
2272
2329
  if (!value || Array.isArray(value) || typeof value !== 'object') {
2273
2330
  return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
2274
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
+ }
2275
2335
  return { config: value, exists: true, error: '' };
2276
2336
  } catch (error) {
2277
2337
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
@@ -2291,8 +2351,34 @@ async function writeHookConfig(target, config) {
2291
2351
  }
2292
2352
  }
2293
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
+
2294
2376
  async function hookEnable(args = []) {
2295
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}`;
2296
2382
  if (!['lite', 'study-note'].includes(parsed.profile)) {
2297
2383
  console.error('Only lite and study-note hooks are supported: yam hook enable <lite|study-note>');
2298
2384
  process.exitCode = 1;
@@ -2323,6 +2409,7 @@ async function hookEnable(args = []) {
2323
2409
  }
2324
2410
  await writeHookConfig(target, next);
2325
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)`);
2326
2413
  if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
2327
2414
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
2328
2415
  }
@@ -2343,7 +2430,7 @@ async function hookDisable(args = []) {
2343
2430
  return;
2344
2431
  }
2345
2432
  const next = stripYamHooks(current, parsed.profile);
2346
- if (Object.keys(next).length === 0) {
2433
+ if (Object.keys(next).length === 1 && Object.keys(next.hooks).length === 0) {
2347
2434
  await rmrf(target);
2348
2435
  } else {
2349
2436
  await writeHookConfig(target, next);
@@ -2371,6 +2458,7 @@ async function hookRun(args = []) {
2371
2458
  }
2372
2459
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2373
2460
  const cwd = String(input?.cwd || process.cwd());
2461
+ if (args.includes('--observe')) await recordHookObservation(cwd, profile, String(event), YAM_HOOK_ENTRYPOINT).catch(() => {});
2374
2462
  if (profile === 'study-note' && event === 'Stop') {
2375
2463
  console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2376
2464
  return;
@@ -6069,11 +6157,13 @@ async function printTemplate(name = '') {
6069
6157
  mission: 'mission-plan.md',
6070
6158
  proof: 'runtime-proof.md',
6071
6159
  runtime: 'runtime-proof.md',
6072
- tuning: 'tuning-log.md'
6160
+ tuning: 'tuning-log.md',
6161
+ security: 'security-check-note.md',
6162
+ 'security-ci': 'security-gate.yml'
6073
6163
  };
6074
6164
  const file = map[key];
6075
6165
  if (!file) {
6076
- console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning>');
6166
+ console.error('usage: yam template <project|ueye|ueye-comparison|mission|proof|tuning|security|security-ci>');
6077
6167
  process.exitCode = 1;
6078
6168
  return;
6079
6169
  }
@@ -6104,7 +6194,7 @@ async function memory(args = []) {
6104
6194
  }
6105
6195
  if (subcommand === 'summary' || subcommand === 'summarize') {
6106
6196
  const parsed = parseMemoryArgs(args.slice(1));
6107
- return memorySummary(parsed.dir);
6197
+ return memorySummary(parsed.dir, { json: args.includes('--json') });
6108
6198
  }
6109
6199
  if (subcommand === 'resolve') return memoryResolve(args.slice(1));
6110
6200
  console.error(`unknown memory command: ${subcommand}`);
@@ -6118,7 +6208,7 @@ Usage:
6118
6208
  yam memory init [dir]
6119
6209
  yam memory add [dir] --kind <kind> --summary <text> [--evidence <text>] [--action <text>] [--source <text>]
6120
6210
  yam memory list [dir] [--json]
6121
- yam memory summary [dir]
6211
+ yam memory summary [dir] [--json]
6122
6212
  yam memory resolve [dir] <id> [--note <text>]
6123
6213
 
6124
6214
  Kinds:
@@ -6193,7 +6283,14 @@ async function memoryAdd(args = []) {
6193
6283
  }
6194
6284
 
6195
6285
  async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6196
- 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
+ }
6197
6294
  if (json) {
6198
6295
  console.log(JSON.stringify(records, null, 2));
6199
6296
  return;
@@ -6208,9 +6305,16 @@ async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6208
6305
  }
6209
6306
  }
6210
6307
 
6211
- async function memorySummary(targetDir = process.cwd()) {
6308
+ async function memorySummary(targetDir = process.cwd(), { json = false } = {}) {
6212
6309
  const dir = memoryDir(targetDir);
6213
- 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;
6214
6318
  await fsp.mkdir(dir, { recursive: true });
6215
6319
  const active = records.filter((record) => record.status !== 'resolved');
6216
6320
  const resolved = records.filter((record) => record.status === 'resolved');
@@ -6241,7 +6345,8 @@ async function memorySummary(targetDir = process.cwd()) {
6241
6345
 
6242
6346
  const target = path.join(dir, 'summary.md');
6243
6347
  await fsp.writeFile(target, `${lines.join('\n').trim()}\n`);
6244
- 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}`);
6245
6350
  }
6246
6351
 
6247
6352
  async function memoryResolve(args = []) {
@@ -6300,20 +6405,7 @@ function memoryDir(targetDir = process.cwd()) {
6300
6405
 
6301
6406
  async function readMemoryRecords(targetDir = process.cwd()) {
6302
6407
  const recordsDir = path.join(memoryDir(targetDir), 'records');
6303
- if (!await exists(recordsDir)) return [];
6304
- const entries = await fsp.readdir(recordsDir, { withFileTypes: true });
6305
- const records = [];
6306
- for (const entry of entries) {
6307
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
6308
- const file = path.join(recordsDir, entry.name);
6309
- try {
6310
- const record = await readJson(file);
6311
- records.push(record);
6312
- } catch (error) {
6313
- records.push({ id: entry.name.replace(/\.json$/, ''), kind: 'invalid', status: 'invalid', summary: `invalid JSON: ${errorMessage(error)}` });
6314
- }
6315
- }
6316
- return records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
6408
+ return readMemoryDirectory(recordsDir);
6317
6409
  }
6318
6410
 
6319
6411
  function timestampId() {
@@ -6349,6 +6441,7 @@ function showRequestedHelp(args: string[]) {
6349
6441
  }
6350
6442
 
6351
6443
  const commandUsage: Record<string, () => void> = {
6444
+ security: () => console.log('yam security <init|check|hooks enable|status> [--stage commit|push|ci|deploy] [--policy file --policy-digest sha256 --evidence file --note file]\nSee references/security-check.md for exact bindings and CI/deploy trust configuration.'),
6352
6445
  context: contextUsage,
6353
6446
  cleanup: cleanupUsage,
6354
6447
  'study-note': studyNoteUsage,
@@ -6375,6 +6468,16 @@ async function main() {
6375
6468
  const command = args[0] || 'help';
6376
6469
  if (showRequestedHelp(args)) return;
6377
6470
  if (command === 'help') return usage();
6471
+ if (command === 'security') {
6472
+ if (args.length === 1 || ['help', '--help', '-h'].includes(args[1])) {
6473
+ console.log('yam security <init|check|hooks enable|status> [--stage commit|push|ci|deploy] [--policy file --policy-digest sha256 --evidence file --note file]\nSee references/security-check.md for exact bindings and CI/deploy trust configuration.');
6474
+ return;
6475
+ }
6476
+ const result = await runSecurityCommand(args.slice(1), { cwd: process.cwd(), cliPath: fileURLToPath(import.meta.url) });
6477
+ console.log(JSON.stringify(result, null, 2));
6478
+ if (!result.ok) process.exitCode = 1;
6479
+ return;
6480
+ }
6378
6481
  if (command === 'install') return install(args.slice(1));
6379
6482
  if (command === 'uninstall') return uninstall(args.slice(1));
6380
6483
  if (command === 'version') return console.log(VERSION);
@@ -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) {