yam-flow 2.8.1 → 2.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -21,7 +21,8 @@
21
21
  19. Treat security as the first project lens: identify sensitive surfaces early and guide safer defaults before convenience.
22
22
  20. Keep final reports short and concrete, but always include remaining tasks or state that none are meaningful.
23
23
  21. When relevant, include architecture hygiene in the Study Note: avoid dumping unrelated logic into `page.tsx`, one-off component CSS into `global.css`, or structured product data into broad DB `jsonb` blobs when existing project boundaries, scoped styles, typed tables, constraints, or indexes fit better.
24
- 22. When an artifact changed, put `Next step` immediately after Study Note. Build it from a quick whole-process scan with current situation, forward outlook, a concrete critical opinion, improvement recommendations, and ordered fix-first then planned actions with evidence and ownership; do not merely rename remaining tasks.
24
+ 22. When an artifact changed, put `Next step` immediately after Study Note. Ordinary advisory work may use a short concrete action or state that none remains. For risky changes, explicit strict reports and structured receipts, build it from a quick whole-process scan with current situation, forward outlook, a concrete critical opinion, improvement recommendations, and ordered fix-first then planned actions with evidence and ownership. Keep report validation separate from actual security verification.
25
+ 23. Work on the current branch by default. Create or switch branches, create worktrees, or create pull requests only when the user explicitly requests that action or an applicable project rule explicitly requires it. A commit/push request, release version bump, route selection, branch-name prefix, or branch protection alone is not authorization. If protection blocks the requested operation, report the blocker and ask for direction; do not silently choose a PR workflow, bypass checks, or change protection settings.
25
26
 
26
27
  ## Security check gate
27
28
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to yam-flow are documented here.
4
4
 
5
+ ## v2.8.3 - 2026-10-06
6
+
7
+ ### Improved
8
+
9
+ - Study Note hooks compare bounded hash-only turn baselines instead of repeatedly treating pre-existing dirty files as new work; already-dirty edits, staging, commits, renames and deletions remain visible.
10
+ - Ordinary work uses advisory completion with concise notes; risky intent or explicit strict policy uses one bounded correction pass only when selected artifacts changed. Missing session or stale scope stays partial and requests manual inspection.
11
+ - Known generated outputs are excluded before content reads, with project include rules taking precedence over excludes; independent commit/push/deploy security gates remain unchanged.
12
+ - Standalone bold report headings are accepted, correction messages list missing fields, and migration documentation no longer triggers database hygiene based only on its name.
13
+ - Report guidance and Quick route now distinguish ordinary advisory handoffs from detailed strict reports.
14
+ - All routes and lite hook guidance keep the current branch by default; new/switched branches, worktrees and PRs require explicit user direction or applicable project rules. Protection blockers are reported without silently changing workflow or settings.
15
+
16
+ ### Verification
17
+
18
+ - Turn-scope, generated/include precedence, advisory/strict, stale/corrupt baseline and symlink safety fixtures; hook latency and packaged CLI regressions.
19
+
20
+ ## v2.8.2 - 2026-10-02
21
+
22
+ ### Improved
23
+
24
+ - Hook configuration now uses Codex's required top-level `hooks` wrapper. `yam hook migrate` backs up legacy files and preserves commands and unrelated handlers without automatically approving Codex trust.
25
+ - Hook status can now separate configured health, Codex `hooks/list` discovery, and opt-in local entrypoint observations; Node launcher selection is bound to the running executable identity.
26
+ - External yam update checks distinguish exact-version npm distribution propagation (`pending_distribution`) from network, authorization, or metadata failures.
27
+ - Memory listing and summary preserve the difference between missing, empty, invalid, unreadable, and incomplete record storage; incomplete reads cannot overwrite an existing summary.
28
+ - Added bounded Markdown skill-content checks for frontmatter identity, symlinks, personal absolute paths, download-to-shell patterns, and high-signal secrets.
29
+
5
30
  ## v2.8.1 - 2026-10-01
6
31
 
7
32
  ### Security
package/COMMANDS.md CHANGED
@@ -153,6 +153,8 @@ yam memory add /path/to/project --kind repeat_mistake --summary "UI work was dec
153
153
  yam memory summary /path/to/project
154
154
  ```
155
155
 
156
+ `yam memory list --json` keeps the normal complete result as an array and returns a bounded `yam.memory-read.v1` envelope for missing, invalid, unreadable, or incomplete storage. `yam memory summary --json` refuses to overwrite the existing summary unless the record directory was read completely.
157
+
156
158
  ## Token Budget
157
159
 
158
160
  ```bash
@@ -172,7 +174,7 @@ yam update apply --component yam --json
172
174
  yam update apply --all --json
173
175
  ```
174
176
 
175
- `yam update check` is read-only. Its component result preserves the registry artifact source receipt: npm integrity/`gitHead` for yam, PyPI release-file SHA-256 values for Scrapling, and the Git-pinned manifest commit for Insane Search. Every `apply` needs explicit authorization, pins the official npm registry or isolated PyPI index, uses a concurrent-run lock, records a component receipt, and stops on failed/manual results. `--all` runs yam last. A yam install verifies the effective executable identity before any yam-side install command and repeats that check during rollback. This identity contract currently supports POSIX symlink layouts; Windows npm `.cmd` shims fail closed as unverified. Insane Search requires a clean tracked local manifest whose bytes match Git and matching pre/post-add commits, is never remove-first, and never edits `.codex/plugins/cache` directly.
177
+ `yam update check` is read-only. Its component result preserves the registry artifact source receipt: npm integrity/`gitHead` for yam, PyPI release-file SHA-256 values for Scrapling, and the Git-pinned manifest commit for Insane Search. Yam also compares `latest` with exact-version metadata and probes the official exact tarball URL; a 404 is `pending_distribution`, while network/auth/metadata mismatch is `check_failed`. The bounded HEAD probe confirms availability only and does not hash tarball bytes. Every `apply` needs explicit authorization, pins the official npm registry or isolated PyPI index, uses a concurrent-run lock, records a component receipt, and stops on failed/manual results. `--all` runs yam last. A yam install verifies the effective executable identity before any yam-side install command and repeats that check during rollback. This identity contract currently supports POSIX symlink layouts; Windows npm `.cmd` shims fail closed as unverified. Insane Search requires a clean tracked local manifest whose bytes match Git and matching pre/post-add commits, is never remove-first, and never edits `.codex/plugins/cache` directly.
176
178
 
177
179
  ## Release Supply Chain
178
180
 
@@ -327,15 +329,18 @@ Supported report shapes:
327
329
 
328
330
  `lite` is advisory-only. `study-note` runs the read-only Study Note Guard against the final assistant message and can request one correction pass; neither profile runs verification or forces routes.
329
331
 
332
+ `yam hook migrate [--global|--project dir]` wraps legacy top-level events in Codex's `hooks` object without changing commands. It backs up the original, refuses unsafe files, and is a no-op for canonical configs. Review changed definitions in Codex `/hooks` before execution; yam does not approve trust.
333
+
330
334
  ```bash
331
335
  yam hook status --global
336
+ yam hook migrate --global
332
337
  yam hook enable lite --global
333
338
  yam hook enable study-note --global
334
339
  yam hook disable lite --global
335
340
  yam hook disable study-note --global
336
341
  ```
337
342
 
338
- `yam hook status` exits nonzero for unreadable configs, stale paths, missing targets, duplicate handlers, or incomplete event coverage. Re-enable the affected profile to back up the file, preserve unrelated hooks, and migrate its command and events.
343
+ `yam hook status` exits nonzero for unreadable configs, stale paths, missing targets, duplicate handlers, or incomplete event coverage. Add `--json` for `yam.hook-readiness.v1`, or `--probe` to query `hooks/list` in a separate bounded Codex app-server. The probe separates host discovery from local configuration and never claims that the active Desktop session loaded the hook. Add `--observe` to `yam hook enable` to record a bounded local entrypoint observation; it contains only hashes and event metadata and remains partial same-user evidence. Re-enable the affected profile to back up the file, preserve unrelated hooks, and migrate its command and events.
339
344
 
340
345
  ## Security check
341
346
 
package/README.md CHANGED
@@ -39,13 +39,14 @@ See `ROADMAP.md` for remaining implementation stages.
39
39
  - No automatic Team routing.
40
40
  - No automatic proof loops.
41
41
  - No automatic tmux.
42
+ - Keep the current branch by default. New/switched branches, worktrees and PRs require explicit user direction or an applicable project rule; commit/push, release bumps and protection rules alone do not authorize them. Report protection blockers before changing workflow or settings.
42
43
  - No false completion claims: verification, cleanup, and visual checks must match actual evidence.
43
44
  - `yam` is not lightweight-only; it is progressive: fast direction, broader judgment when useful, stronger proof as scope/risk grows.
44
45
  - Every route should check project direction and use the L0-L5 Verification Ladder honestly.
45
46
  - Small work stays small, but serious work is allowed to become serious.
46
47
  - Security-sensitive work is never treated as ordinary polish.
47
48
  - When code, config, release metadata, documentation, or project artifacts changed, final reports include a Study Note even if no yam skill was invoked.
48
- - A changed-artifact report puts `Next step` immediately after Study Note. It rescans the whole process, states the situation and outlook, adds a critical opinion and improvement recommendations, then orders fix-first work before planned work.
49
+ - A changed-artifact report puts `Next step` immediately after Study Note. Ordinary advisory work may state the next concrete action or no remaining work. Strict reports rescan the whole process, state situation/outlook, add critical opinion and recommendations, then order fix-first work before planned work.
49
50
  - Useful Study Notes explain what the changed code or artifact does, what role it has, what was wrong or missing, what changed, what behavior is expected, one small syntax/structure insight, what was verified, and what remains uncertain.
50
51
  - Research and reference scans should keep source boundaries clear, then rework good ideas into yam style.
51
52
  - Token economy is part of quality.
@@ -74,6 +75,8 @@ yam memory add . --kind lesson --summary "Keep UI checks visual before declaring
74
75
  yam memory summary .
75
76
  ```
76
77
 
78
+ `yam memory list --json` distinguishes missing, empty, invalid, unreadable, and incomplete record directories. A blocked `yam memory summary --json` exits nonzero and preserves the previous summary instead of treating an access failure as an empty memory set.
79
+
77
80
  Memory writes to `.yam/memory/` only when you run the command. Routes should prefer `.yam/memory/summary.md` and should not read every record by default.
78
81
 
79
82
  ## Lite Hook
@@ -91,6 +94,8 @@ It only adds short advisory context. It does not run verification, tmux, browser
91
94
 
92
95
  ## Study Note Hook
93
96
 
97
+ Codex hook files use a top-level `hooks` object containing event groups (`{"hooks":{"Stop":[...]}}`). For older yam files with events at the top level, run `yam hook migrate --global`: this creates a backup and changes only the layout, preserving existing commands and unrelated handlers. Review the changed definitions in Codex `/hooks`; discovery alone does not authorize execution, and yam never approves hook trust automatically.
98
+
94
99
  Enable this profile when you want Codex to check changed work at final-response time as well as remind the agent at prompt time.
95
100
 
96
101
  ```bash
@@ -98,11 +103,15 @@ yam hook enable study-note --global
98
103
  yam hook status --global
99
104
  ```
100
105
 
101
- The profile installs `UserPromptSubmit` and `Stop` handlers. At `Stop`, yam checks the latest assistant message with the same read-only Study Note Guard used by `yam study-note check`. If changed artifacts lack the required role, execution point, before/after, expected behavior, syntax/structure, verification, limits, or relevant architecture hygiene, Codex receives one correction prompt. A second failed check warns but does not loop forever. If Git change scope is unavailable, the guard reports partial truth and keeps a manual-inspection warning visible instead of claiming the project is clean. The hook never writes the Study Note or runs verification for the agent.
106
+ The profile installs `UserPromptSubmit` and `Stop` handlers. The prompt saves a bounded, hash-only baseline of index and dirty-file content in a private OS temporary directory, keyed by canonical repository and session (and checked against a turn ID when supplied). Stop inspects only subsequent changes, including edits to already-dirty files, staging, commits, renames and deletions. Missing/stale scope stays partial with manual-inspection guidance; it never means a clean project.
107
+
108
+ Ordinary hook work is advisory: a short Study Note (role, verification and limits) followed by Next step, including “no remaining work”, is sufficient. An explicit `study-note strict` request, project strict policy, or commit/push/deploy/DB/security intent enables the detailed contract **only when selected artifacts changed**. Deep/Mission names alone do not enable strict. Strict requests at most one correction; messages list missing fields. `yam study-note check` remains an explicit strict whole-tree check for compatibility. Neither mode performs security verification; commit/push/deploy security gates remain separate and unchanged.
109
+
110
+ Known generated `.yam/mission/`, `.yam/security/`, `.yam/logs/`, `.yam/screenshots/`, `dist/` and `.tgz` outputs are excluded from automatic turn reporting. This is not a blanket `.yam` exclusion. Optional `.yam/study-note.json` accepts `mode: "advisory" | "strict"`, `include` and `exclude` arrays of repository-relative exact paths or `directory/**` prefixes; include wins. Example: `{"include":[".yam/security/review.json"],"exclude":["fixtures/generated/**"]}`. Baselines expire after 24 hours and contain paths/digests, not source text or prompts. Without a turn ID the latest baseline for the session is used; overlapping turns cannot be independently attributed. Bounded snapshots (4,000 files, 2 MiB dirty content) fall back to manual inspection. The hook never writes the report, changes global configuration or bypasses a security gate.
102
111
 
103
112
  Hook stdin is limited to 1 MiB. Malformed JSON or an oversized payload produces a valid fail-open response without echoing the rejected content, deriving hook context from it, or mutating the workspace. Other CLI commands that read piped text use a 4 MiB limit and fail closed when it is exceeded.
104
113
 
105
- `yam hook status` reports stale or missing command targets as `broken` and exits nonzero. Run `yam hook enable <profile> --global` again to create a timestamped backup, preserve unrelated hooks, and migrate that profile to the current installed path and event coverage. Restart Codex or open a new task after changing hooks.
114
+ `yam hook status` reports stale or missing command targets as `broken` and exits nonzero. Run `yam hook enable <profile> --global` again to create a timestamped backup, preserve unrelated hooks, and migrate that profile to the current installed path and event coverage. For runtime-oriented diagnosis, use `yam hook status --global --json --probe`; this separates configured hooks, discovery from a new bounded Codex app-server, and local entrypoint observations. The active Desktop session remains `not_measured` unless the host provides independent evidence. `yam hook enable <profile> --global --observe` records only a local hash-bound event observation; it is partial evidence and not host attestation. Restart Codex or open a new task after changing hooks.
106
115
 
107
116
  ## Install
108
117
 
package/dist/bin/yam.js 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';
@@ -71,8 +74,8 @@ const INSTRUCTION_DUPLICATION_LIMITS = {
71
74
  minDirectiveChars: 40
72
75
  };
73
76
  const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
74
- const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
75
- const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
77
+ let YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
78
+ let YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
76
79
  const REQUIRED_PACK_SECTIONS = [
77
80
  'Product Direction',
78
81
  'UI Direction',
@@ -932,8 +935,8 @@ async function studyNoteCheck(args = []) {
932
935
  if (result.blockers.length)
933
936
  process.exitCode = 1;
934
937
  }
935
- async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
936
- const changedFileDetection = gitChangedFileSnapshot(dir);
938
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided', scope = null) {
939
+ const changedFileDetection = scope || gitChangedFileSnapshot(dir);
937
940
  const changedFiles = changedFileDetection.files;
938
941
  const changeScopeAvailable = changedFileDetection.available;
939
942
  const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
@@ -964,6 +967,14 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
964
967
  for (const requirement of hygieneRequirements) {
965
968
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(studyNoteText, requirement), requirement.note));
966
969
  }
970
+ if (scope?.mode === 'advisory') {
971
+ 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']);
972
+ for (const check of checks)
973
+ if (!ordinary.has(check.id)) {
974
+ check.status = 'skipped';
975
+ check.truth_status = 'skipped';
976
+ }
977
+ }
967
978
  const blockers = checks.filter((check) => check.status === 'fail').map((check) => `${check.id}: ${check.next_action}`);
968
979
  const result = {
969
980
  schema: 'yam.study-note-guard.v1',
@@ -1052,7 +1063,7 @@ function hasNextStepMarker(text = '') {
1052
1063
  }
1053
1064
  function reportSectionMarkers(text = '') {
1054
1065
  return String(text || '').split(/\r?\n/).map((line, index) => {
1055
- const trimmed = line.trim();
1066
+ const trimmed = line.trim().replace(/^\*\*(.*?)\*\*\s*:?[ \t]*$/, '$1');
1056
1067
  const heading = trimmed.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
1057
1068
  const named = trimmed.match(/^(study note|study-note|학습\s*노트|스터디\s*노트|next step|next-step|다음\s*(?:단계|할\s*일|작업))\s*:?[ \t]*$/i);
1058
1069
  const rawLabel = heading?.[1] || named?.[1] || '';
@@ -1176,7 +1187,7 @@ function studyNoteHygieneRequirements(files = []) {
1176
1187
  if (files.some((file) => /(^|\/)global\.css$/i.test(file))) {
1177
1188
  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' });
1178
1189
  }
1179
- if (files.some((file) => /(migration|schema|db|database|sql|prisma|drizzle|supabase)/i.test(file))) {
1190
+ if (files.some((file) => /\.(sql|prisma)$/i.test(file) || /(^|\/)(migrations?|schema|database|drizzle|supabase)\//i.test(file) && /\.(ts|js)$/i.test(file))) {
1180
1191
  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' });
1181
1192
  }
1182
1193
  return requirements;
@@ -1944,6 +1955,8 @@ async function hook(args = []) {
1944
1955
  return hookUsage();
1945
1956
  if (subcommand === 'status')
1946
1957
  return hookStatus(args.slice(1));
1958
+ if (subcommand === 'migrate')
1959
+ return hookMigrate(args.slice(1));
1947
1960
  if (subcommand === 'enable')
1948
1961
  return hookEnable(args.slice(1));
1949
1962
  if (subcommand === 'disable')
@@ -1957,14 +1970,17 @@ function hookUsage() {
1957
1970
  console.log(`yam hook
1958
1971
 
1959
1972
  Usage:
1960
- yam hook status [--global|--project dir]
1961
- yam hook enable <lite|study-note> [--global|--project dir]
1973
+ yam hook status [--global|--project dir] [--probe] [--json]
1974
+ yam hook migrate [--global|--project dir]
1975
+ yam hook enable <lite|study-note> [--global|--project dir] [--observe]
1962
1976
  yam hook disable [lite|study-note] [--global|--project dir]
1963
1977
  yam hook run <lite|study-note>
1964
1978
 
1965
1979
  Notes:
1966
1980
  hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
1967
1981
  Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
1982
+ --probe queries hooks/list in a separate bounded Codex app-server; active session loading stays unknown.
1983
+ --observe opts into private local entrypoint observations without storing prompt content; not host attestation.
1968
1984
  `);
1969
1985
  }
1970
1986
  function parseHookArgs(args = []) {
@@ -1991,7 +2007,7 @@ function parseHookArgs(args = []) {
1991
2007
  }
1992
2008
  function hookPathFor(parsed) {
1993
2009
  if (parsed.mode === 'global')
1994
- return path.join(os.homedir(), '.codex', 'hooks.json');
2010
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
1995
2011
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
1996
2012
  }
1997
2013
  function isYamLiteHook(handler = {}) {
@@ -2003,12 +2019,34 @@ function isYamStudyNoteHook(handler = {}) {
2003
2019
  function isYamHookProfile(handler = {}, profile = 'lite') {
2004
2020
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
2005
2021
  }
2022
+ const CODEX_HOOK_EVENTS = new Set(['SessionStart', 'SessionEnd', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit', 'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt']);
2023
+ function hookEventMap(config = {}) {
2024
+ const nested = config.hooks && typeof config.hooks === 'object' && !Array.isArray(config.hooks) ? config.hooks : {};
2025
+ const events = { ...nested };
2026
+ for (const [event, entries] of Object.entries(config)) {
2027
+ if (CODEX_HOOK_EVENTS.has(event) && Array.isArray(entries)) {
2028
+ events[event] = [...(Array.isArray(events[event]) ? events[event] : []), ...entries];
2029
+ }
2030
+ }
2031
+ return events;
2032
+ }
2033
+ function canonicalHookConfig(config = {}) {
2034
+ if (config.hooks != null && (typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
2035
+ throw new Error('hooks must be an event object; existing file preserved');
2036
+ const next = { ...config, hooks: hookEventMap(config) };
2037
+ for (const event of CODEX_HOOK_EVENTS) {
2038
+ if (event in config && !Array.isArray(config[event]))
2039
+ throw new Error('legacy hook event must be an array; existing file preserved');
2040
+ delete next[event];
2041
+ }
2042
+ return next;
2043
+ }
2006
2044
  function stripYamHooks(config = {}, profile = 'lite') {
2007
- const next = { ...config };
2008
- for (const event of Object.keys(next)) {
2009
- if (!Array.isArray(next[event]))
2045
+ const next = canonicalHookConfig(config);
2046
+ for (const event of Object.keys(next.hooks)) {
2047
+ if (!Array.isArray(next.hooks[event]))
2010
2048
  continue;
2011
- const entries = next[event];
2049
+ const entries = next.hooks[event];
2012
2050
  const keptEntries = [];
2013
2051
  for (const entry of entries) {
2014
2052
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -2017,9 +2055,9 @@ function stripYamHooks(config = {}, profile = 'lite') {
2017
2055
  keptEntries.push(rest);
2018
2056
  }
2019
2057
  if (keptEntries.length > 0)
2020
- next[event] = keptEntries;
2058
+ next.hooks[event] = keptEntries;
2021
2059
  else
2022
- delete next[event];
2060
+ delete next.hooks[event];
2023
2061
  }
2024
2062
  return next;
2025
2063
  }
@@ -2036,7 +2074,7 @@ function withYamHook(config = {}, profile = 'lite') {
2036
2074
  }
2037
2075
  ]
2038
2076
  };
2039
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
2077
+ next.hooks[event] = [...(Array.isArray(next.hooks[event]) ? next.hooks[event] : []), entry];
2040
2078
  }
2041
2079
  return next;
2042
2080
  }
@@ -2045,6 +2083,11 @@ async function hookStatus(args = []) {
2045
2083
  const target = hookPathFor(parsed);
2046
2084
  const loaded = await readHookConfig(target);
2047
2085
  if (loaded.error) {
2086
+ if (args.includes('--json')) {
2087
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, configuration: 'unreadable', loaded: 'unknown', execution_observed: false, truth_status: 'partial' }, null, 2));
2088
+ process.exitCode = 1;
2089
+ return;
2090
+ }
2048
2091
  console.log('yam-lite hook: broken');
2049
2092
  console.log('yam-study-note hook: broken');
2050
2093
  console.log(` - hook config unreadable: ${loaded.error}`);
@@ -2055,10 +2098,30 @@ async function hookStatus(args = []) {
2055
2098
  }
2056
2099
  const lite = await inspectHookProfile(loaded.config, 'lite');
2057
2100
  const studyNote = await inspectHookProfile(loaded.config, 'study-note');
2101
+ const cwd = path.resolve(parsed.projectDir || process.cwd());
2102
+ const commands = { lite: lite.entries.map((entry) => String(entry.handler.command)), 'study-note': studyNote.entries.map((entry) => String(entry.handler.command)) };
2103
+ const host = args.includes('--probe')
2104
+ ? await probeHookDiscovery({ executable: await findExecutable('codex') || 'codex', cwd, commands, configPath: target })
2105
+ : { state: 'not_measured', active_session_loaded: 'not_measured', execution_observed: false, truth_status: 'partial' };
2106
+ const profiles = {};
2107
+ for (const [profile, health] of [['lite', lite], ['study-note', studyNote]]) {
2108
+ profiles[profile] = { configured: health.state, issues: health.issues, loaded: 'unknown',
2109
+ execution: await inspectHookObservation(cwd, profile, YAM_HOOK_ENTRYPOINT) };
2110
+ }
2111
+ if (args.includes('--json')) {
2112
+ console.log(JSON.stringify({ schema: 'yam.hook-readiness.v1', scope: parsed.mode, profiles, host, truth_status: 'partial' }, null, 2));
2113
+ if (lite.state === 'broken' || studyNote.state === 'broken')
2114
+ process.exitCode = 1;
2115
+ return;
2116
+ }
2058
2117
  printHookProfileStatus('lite', lite);
2059
2118
  printHookProfileStatus('study-note', studyNote);
2060
2119
  console.log(`scope: ${parsed.mode}`);
2061
2120
  console.log(`file: ${target}`);
2121
+ console.log('active Codex session loaded: unknown (configuration is not runtime evidence)');
2122
+ console.log(`host discovery: ${host.state}`);
2123
+ for (const profile of ['lite', 'study-note'])
2124
+ console.log(`yam-${profile} execution: ${profiles[profile].execution.state} (local entrypoint evidence, not host attestation)`);
2062
2125
  if (lite.state === 'broken' || studyNote.state === 'broken')
2063
2126
  process.exitCode = 1;
2064
2127
  }
@@ -2066,7 +2129,7 @@ function hookConfigHasYamLite(config = {}) {
2066
2129
  return hookConfigHasProfile(config, 'lite');
2067
2130
  }
2068
2131
  function hookConfigHasProfile(config = {}, profile = 'lite') {
2069
- return Object.values(config).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2132
+ return Object.values(hookEventMap(config)).some((entries) => Array.isArray(entries) && entries.some((entry) => {
2070
2133
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
2071
2134
  }));
2072
2135
  }
@@ -2078,7 +2141,7 @@ function hookEventsForProfile(profile = 'lite') {
2078
2141
  }
2079
2142
  function hookProfileEntries(config = {}, profile = 'lite') {
2080
2143
  const matches = [];
2081
- for (const [event, entries] of Object.entries(config)) {
2144
+ for (const [event, entries] of Object.entries(hookEventMap(config))) {
2082
2145
  if (!Array.isArray(entries))
2083
2146
  continue;
2084
2147
  for (const entry of entries) {
@@ -2142,6 +2205,8 @@ function splitHookCommand(command = '') {
2142
2205
  }
2143
2206
  function parseYamHookCommand(command = '', profile = 'lite') {
2144
2207
  const words = splitHookCommand(command);
2208
+ if (words.at(-1) === '--observe')
2209
+ words.pop();
2145
2210
  if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
2146
2211
  const nodeName = path.basename(words[0]).toLowerCase();
2147
2212
  if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
@@ -2185,6 +2250,8 @@ async function inspectHookProfile(config = {}, profile = 'lite') {
2185
2250
  if (!entries.length)
2186
2251
  return { state: 'disabled', issues: [], entries };
2187
2252
  const issues = [];
2253
+ if (Object.keys(config).some((event) => CODEX_HOOK_EVENTS.has(event)))
2254
+ issues.push('legacy hook config requires the top-level hooks wrapper; rerun yam hook enable to migrate with backup');
2188
2255
  for (const event of hookEventsForProfile(profile)) {
2189
2256
  const count = entries.filter((entry) => entry.event === event).length;
2190
2257
  if (count === 0)
@@ -2213,6 +2280,9 @@ async function readHookConfig(target) {
2213
2280
  if (!value || Array.isArray(value) || typeof value !== 'object') {
2214
2281
  return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
2215
2282
  }
2283
+ if (value.hooks != null && (typeof value.hooks !== 'object' || Array.isArray(value.hooks))) {
2284
+ return { config: {}, exists: true, error: 'hooks must be an event object' };
2285
+ }
2216
2286
  return { config: value, exists: true, error: '' };
2217
2287
  }
2218
2288
  catch (error) {
@@ -2233,8 +2303,41 @@ async function writeHookConfig(target, config) {
2233
2303
  throw error;
2234
2304
  }
2235
2305
  }
2306
+ async function hookMigrate(args = []) {
2307
+ const parsed = parseHookArgs(args);
2308
+ const target = hookPathFor(parsed);
2309
+ const loaded = await readHookConfig(target);
2310
+ if (loaded.error) {
2311
+ console.error('hook migration blocked: unreadable configuration');
2312
+ process.exitCode = 1;
2313
+ return;
2314
+ }
2315
+ if (!loaded.exists || !Object.keys(loaded.config).some((key) => CODEX_HOOK_EVENTS.has(key))) {
2316
+ console.log('hook migration: no legacy event layout found');
2317
+ return;
2318
+ }
2319
+ const before = await fsp.lstat(target);
2320
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)
2321
+ throw new Error('hook migration blocked: unsafe config file');
2322
+ const original = await fsp.readFile(target, 'utf8');
2323
+ if (JSON.stringify(JSON.parse(original)) !== JSON.stringify(loaded.config))
2324
+ throw new Error('hook configuration changed; retry migration');
2325
+ const next = canonicalHookConfig(loaded.config);
2326
+ const backup = `${target}.yam-backup-${timestampId()}`;
2327
+ await fsp.copyFile(target, backup, fs.constants.COPYFILE_EXCL);
2328
+ const after = await fsp.lstat(target);
2329
+ if (after.dev !== before.dev || after.ino !== before.ino || after.mtimeMs !== before.mtimeMs
2330
+ || await fsp.readFile(target, 'utf8') !== original)
2331
+ throw new Error('hook configuration changed; backup preserved, retry migration');
2332
+ await writeHookConfig(target, next);
2333
+ console.log(`hook migration: wrapped event groups; handlers and metadata preserved\nbackup: ${backup}\nReview changed definitions in Codex /hooks before enabling execution.`);
2334
+ }
2236
2335
  async function hookEnable(args = []) {
2237
2336
  const parsed = parseHookArgs(args);
2337
+ const node = await selectHookNode();
2338
+ const observe = args.includes('--observe') ? ' --observe' : '';
2339
+ YAM_LITE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite${observe}`;
2340
+ YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(node.executable)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note${observe}`;
2238
2341
  if (!['lite', 'study-note'].includes(parsed.profile)) {
2239
2342
  console.error('Only lite and study-note hooks are supported: yam hook enable <lite|study-note>');
2240
2343
  process.exitCode = 1;
@@ -2263,6 +2366,7 @@ async function hookEnable(args = []) {
2263
2366
  }
2264
2367
  await writeHookConfig(target, next);
2265
2368
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
2369
+ console.log(`node launcher: ${node.selection}; alias identity matched the current runtime (no PATH candidate executed)`);
2266
2370
  if (needsMigration)
2267
2371
  console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
2268
2372
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
@@ -2283,7 +2387,7 @@ async function hookDisable(args = []) {
2283
2387
  return;
2284
2388
  }
2285
2389
  const next = stripYamHooks(current, parsed.profile);
2286
- if (Object.keys(next).length === 0) {
2390
+ if (Object.keys(next).length === 1 && Object.keys(next.hooks).length === 0) {
2287
2391
  await rmrf(target);
2288
2392
  }
2289
2393
  else {
@@ -2313,13 +2417,15 @@ async function hookRun(args = []) {
2313
2417
  }
2314
2418
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
2315
2419
  const cwd = String(input?.cwd || process.cwd());
2420
+ if (args.includes('--observe'))
2421
+ await recordHookObservation(cwd, profile, String(event), YAM_HOOK_ENTRYPOINT).catch(() => { });
2316
2422
  if (profile === 'study-note' && event === 'Stop') {
2317
2423
  console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2318
2424
  return;
2319
2425
  }
2320
2426
  const prompt = extractPrompt(input);
2321
2427
  const additionalContext = profile === 'study-note'
2322
- ? await buildStudyNoteHookContext({ cwd })
2428
+ ? await buildStudyNoteTurnContext(input, cwd)
2323
2429
  : await buildYamLiteContext({ cwd, prompt });
2324
2430
  const output = {
2325
2431
  continue: true,
@@ -2348,6 +2454,7 @@ function extractPrompt(input = {}) {
2348
2454
  async function buildYamLiteContext({ cwd, prompt }) {
2349
2455
  const lines = [
2350
2456
  'yam-lite guide active: keep project direction visible, preserve momentum, and deepen when scope, risk, or user intent calls for it.',
2457
+ '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.',
2351
2458
  'Default: do a basic direction-fit and honest-verification check; avoid broad reading for small work and never claim proof without evidence.'
2352
2459
  ];
2353
2460
  const pack = await findProjectPack(cwd).catch(() => null);
@@ -2367,37 +2474,10 @@ async function buildYamLiteContext({ cwd, prompt }) {
2367
2474
  lines.push(routeHint);
2368
2475
  return lines.join('\n');
2369
2476
  }
2370
- async function buildStudyNoteHookContext({ cwd }) {
2371
- const dir = path.resolve(cwd || process.cwd());
2372
- const changedFileDetection = gitChangedFileSnapshot(dir);
2373
- const changedFiles = changedFileDetection.files;
2374
- const lines = [
2375
- '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.'
2376
- ];
2377
- if (!changedFileDetection.available) {
2378
- lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2379
- 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.');
2380
- return lines.join('\n');
2381
- }
2382
- if (!changedFiles.length) {
2383
- 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.');
2384
- lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
2385
- return lines.join('\n');
2386
- }
2387
- lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
2388
- lines.push('Study Note minimum: touched code/artifact, role, execution point, before/after change, expected behavior, one syntax/structure insight, verification, and limits.');
2389
- 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.');
2390
- const hygiene = studyNoteHygieneRequirements(changedFiles);
2391
- if (hygiene.length) {
2392
- lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
2393
- 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.');
2394
- }
2395
- 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.');
2396
- return lines.join('\n');
2397
- }
2398
2477
  async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
2399
2478
  const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
2400
- const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2479
+ const scope = studyNoteTurnScope(input, cwd);
2480
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message', scope);
2401
2481
  if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2402
2482
  return {
2403
2483
  continue: true,
@@ -2406,7 +2486,11 @@ async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
2406
2486
  }
2407
2487
  if (!result.blockers.length)
2408
2488
  return { continue: true };
2409
- const summary = result.blockers.slice(0, 4).join('; ');
2489
+ const failed = result.checks.filter(check => check.status === 'fail').map(check => check.id);
2490
+ const summary = failed.includes('study_note_present') ? 'study_note_present' : failed.join(', ');
2491
+ if (scope.mode !== 'strict') {
2492
+ return { continue: true, systemMessage: `yam Study Note advisory — missing fields: ${summary}. Keep ordinary work concise; no correction gate applied.` };
2493
+ }
2410
2494
  if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
2411
2495
  return {
2412
2496
  continue: true,
@@ -2416,12 +2500,17 @@ async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
2416
2500
  return {
2417
2501
  decision: 'block',
2418
2502
  reason: [
2419
- 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
2420
- summary,
2421
- '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.'
2503
+ `yam Study Note strict — missing fields: ${summary}.`,
2504
+ 'Revise only the missing fields in Study Note / Next step; this is report validation, not a security scan.'
2422
2505
  ].join(' ')
2423
2506
  };
2424
2507
  }
2508
+ async function buildStudyNoteTurnContext(input, cwd) {
2509
+ const scope = studyNoteTurnScope(input, cwd, true);
2510
+ if (!scope.available)
2511
+ 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.';
2512
+ 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.'}`;
2513
+ }
2425
2514
  function yamLiteRouteHint(prompt = '') {
2426
2515
  const text = String(prompt || '').toLowerCase();
2427
2516
  if (!text)
@@ -6079,7 +6168,7 @@ async function memory(args = []) {
6079
6168
  }
6080
6169
  if (subcommand === 'summary' || subcommand === 'summarize') {
6081
6170
  const parsed = parseMemoryArgs(args.slice(1));
6082
- return memorySummary(parsed.dir);
6171
+ return memorySummary(parsed.dir, { json: args.includes('--json') });
6083
6172
  }
6084
6173
  if (subcommand === 'resolve')
6085
6174
  return memoryResolve(args.slice(1));
@@ -6093,7 +6182,7 @@ Usage:
6093
6182
  yam memory init [dir]
6094
6183
  yam memory add [dir] --kind <kind> --summary <text> [--evidence <text>] [--action <text>] [--source <text>]
6095
6184
  yam memory list [dir] [--json]
6096
- yam memory summary [dir]
6185
+ yam memory summary [dir] [--json]
6097
6186
  yam memory resolve [dir] <id> [--note <text>]
6098
6187
 
6099
6188
  Kinds:
@@ -6163,7 +6252,17 @@ async function memoryAdd(args = []) {
6163
6252
  console.log(`memory added: ${id}`);
6164
6253
  }
6165
6254
  async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6166
- const records = await readMemoryRecords(targetDir);
6255
+ const report = await readMemoryRecords(targetDir);
6256
+ const records = report.records;
6257
+ if (!report.complete) {
6258
+ if (report.state !== 'missing')
6259
+ process.exitCode = 1;
6260
+ if (json)
6261
+ console.log(JSON.stringify(report, null, 2));
6262
+ else
6263
+ console.error(`memory read ${report.state}: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6264
+ return;
6265
+ }
6167
6266
  if (json) {
6168
6267
  console.log(JSON.stringify(records, null, 2));
6169
6268
  return;
@@ -6178,9 +6277,18 @@ async function memoryList(targetDir = process.cwd(), { json = false } = {}) {
6178
6277
  console.log(` action: ${record.action}`);
6179
6278
  }
6180
6279
  }
6181
- async function memorySummary(targetDir = process.cwd()) {
6280
+ async function memorySummary(targetDir = process.cwd(), { json = false } = {}) {
6182
6281
  const dir = memoryDir(targetDir);
6183
- const records = await readMemoryRecords(targetDir);
6282
+ const report = await readMemoryRecords(targetDir);
6283
+ if (!report.complete) {
6284
+ process.exitCode = 1;
6285
+ if (json)
6286
+ console.log(JSON.stringify({ ...report, summary_written: false }, null, 2));
6287
+ else
6288
+ console.error(`memory summary blocked (${report.state}); existing summary preserved: ${JSON.stringify(report.diagnostics)}${report.diagnostics_truncated ? ' (diagnostics truncated)' : ''}`);
6289
+ return;
6290
+ }
6291
+ const records = report.records;
6184
6292
  await fsp.mkdir(dir, { recursive: true });
6185
6293
  const active = records.filter((record) => record.status !== 'resolved');
6186
6294
  const resolved = records.filter((record) => record.status === 'resolved');
@@ -6212,7 +6320,10 @@ async function memorySummary(targetDir = process.cwd()) {
6212
6320
  }
6213
6321
  const target = path.join(dir, 'summary.md');
6214
6322
  await fsp.writeFile(target, `${lines.join('\n').trim()}\n`);
6215
- console.log(`memory summary written: ${target}`);
6323
+ if (json)
6324
+ console.log(JSON.stringify({ ...report, summary_written: true, path: target }, null, 2));
6325
+ else
6326
+ console.log(`memory summary written: ${target}`);
6216
6327
  }
6217
6328
  async function memoryResolve(args = []) {
6218
6329
  const parsed = parseMemoryArgs(args);
@@ -6267,23 +6378,7 @@ function memoryDir(targetDir = process.cwd()) {
6267
6378
  }
6268
6379
  async function readMemoryRecords(targetDir = process.cwd()) {
6269
6380
  const recordsDir = path.join(memoryDir(targetDir), 'records');
6270
- if (!await exists(recordsDir))
6271
- return [];
6272
- const entries = await fsp.readdir(recordsDir, { withFileTypes: true });
6273
- const records = [];
6274
- for (const entry of entries) {
6275
- if (!entry.isFile() || !entry.name.endsWith('.json'))
6276
- continue;
6277
- const file = path.join(recordsDir, entry.name);
6278
- try {
6279
- const record = await readJson(file);
6280
- records.push(record);
6281
- }
6282
- catch (error) {
6283
- records.push({ id: entry.name.replace(/\.json$/, ''), kind: 'invalid', status: 'invalid', summary: `invalid JSON: ${errorMessage(error)}` });
6284
- }
6285
- }
6286
- return records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
6381
+ return readMemoryDirectory(recordsDir);
6287
6382
  }
6288
6383
  function timestampId() {
6289
6384
  const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.(\d{3})Z$/, '$1z');