yam-flow 2.3.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to yam-flow are documented here.
4
4
 
5
+ ## v2.3.2 - 2026-07-21
6
+
7
+ ### Added
8
+
9
+ - Added a bounded Codex `Stop` completion gate to the opt-in `study-note` hook profile. Changed work with a missing or incomplete Study Note receives one correction prompt before completion.
10
+ - Added hook health inspection for missing executables, missing script targets, incomplete event coverage, duplicate handlers, unsupported command shapes, and unreadable hook config.
11
+ - Added deterministic packaged-CLI smoke coverage for broken-path detection, nonzero health status, backup-backed migration, unrelated-hook preservation, Stop blocking/passing, and loop prevention.
12
+
13
+ ### Improved
14
+
15
+ - `yam hook status` now reports configured-but-broken profiles as `broken` and exits nonzero instead of calling a stale command `enabled`.
16
+ - Re-enabling a hook profile migrates stale absolute paths to the current installed Node and package entrypoint, adds the required events, preserves unrelated hooks, creates a timestamped backup, and writes config atomically.
17
+ - Study Note Guard checks now cover execution point, expected behavior, syntax or structure insight, verification, and limits separately.
18
+
19
+ ### Verification
20
+
21
+ - `npm run verify:self`
22
+ - `npm run dist:freshness`
23
+ - `npm run package-boundary:check`
24
+ - `npm run cli-smoke`
25
+
5
26
  ## v2.3.1 - 2026-07-21
6
27
 
7
28
  ### Added
package/COMMANDS.md CHANGED
@@ -152,7 +152,7 @@ yam tools doctor /path/to/project
152
152
  yam tools doctor /path/to/project --json
153
153
  yam context pressure /path/to/project --json
154
154
  yam cleanup scan /path/to/project --json
155
- yam study-note check /path/to/project --text "Study Note: Touched code role before/after verification limits." --json
155
+ yam study-note check /path/to/project --text "Study Note: The hook handler's role is final-report validation. It runs at Codex Stop. Before it only reminded; after it requests one correction pass. Expected behavior is a complete note. Structure insight: decision=block creates a continuation prompt. Verification checked the packaged CLI. Limits: a restarted real task remains to be observed." --json
156
156
  yam loop report --route quick --intent "fix release readiness" --stage "inspect:passed:read release report" --evidence "typecheck passed" --evidence-level local --evidence-stamp "sha256:release-report" --touched-file src/bin/yam.ts --read-file README.md --verified-file scripts/cli-smoke.mjs --skipped-check "npm publish skipped by design" --stop-condition "stop after readiness evidence is recorded" --resume-hint "rerun release report after npm auth refresh" --readiness-state blocked --covered-requirement "release report is read-only" --uncovered-requirement "npm auth verified" --blocked-kind auth_blocked --failure-cause auth_token_invalid --safe-retry "retry after npm whoami succeeds" --recovery-hint "refresh npm auth, then rerun readiness checks" --fix-first-item "npm auth must be verified before publish" --remaining-task "rerun release report after auth refresh" --recommended-direction "fix npm auth first, then publish manually" --implementation-note "keep loop report read-only" --why-this-next "auth blocks public release claims" --blocked-by "npm whoami E401" --owner-route deep --owner-scope "release readiness only" --scope-owner deep --side-effect "no publish attempted" --avoidance-note "do not retry publish before npm auth is proven" --issue-code "src/bin/yam.ts release report" --issue-role "summarizes release readiness without publishing" --issue-symptom "npm auth failure needs clearer next action" --changed-code "yam loop report" --changed-role "records loop evidence and learning note" --change-summary "added a read-only loop artifact" --why-important "it helps users learn what changed without overclaiming verification" --learning-note "fix blockers before claiming done" --json
157
157
  yam proof /path/to/project
158
158
  yam proof --route deep --truth verified --command "npm run build: pass"
@@ -206,7 +206,7 @@ Supported report shapes:
206
206
  - Verification Ladder: L0 stated, L1 inspected, L2 local check, L3 integrated, L4 release/runtime/visual proof, L5 bounded deep with stop condition and skipped/remaining work.
207
207
  - Loop report: intent, guided stage outcomes, touched/read/verified files, skipped checks, stop condition, resume hint, readiness state, evidence level/stamp, blockers, blocker kind, safe retry, next action, fix-first items, remaining tasks, recommended direction, blocked-by notes, owner route/scope, side effects, and study note.
208
208
  - Study note: non-specialist explanation of the changed code/artifact, its role, execution point, before/after behavior, expected behavior, one syntax/structure insight, verification, uncertainty, and architecture hygiene around `page.tsx`, `global.css`, and DB `jsonb`.
209
- - Study Note guard: read-only check for changed files and supplied final-report text; opt into prompt-time reminders with `yam hook enable study-note --global`.
209
+ - Study Note guard: read-only check for changed files and supplied final-report text; the opt-in `study-note` hook adds a prompt reminder and one bounded Codex `Stop` correction pass.
210
210
  - Benchmark optimization loop lite: baseline, one change, rerun, keep/revert decision.
211
211
  - Structured diagnostic next action: severity, evidence, owner route, next action, truth status.
212
212
  - Ueye continuity/comparison: previous report, current report, comparison delta, design quality, next action.
@@ -215,9 +215,9 @@ Supported report shapes:
215
215
  - Context pressure: project pack age/size, memory summary, dirty scope, scripts, instruction surfaces, and route hint.
216
216
  - Cleanup scan: advisory-only list of confusing surfaces with destructive=false.
217
217
 
218
- ## Lite Hook
218
+ ## Hooks
219
219
 
220
- Advisory-only hook. It does not run checks or force routes.
220
+ `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.
221
221
 
222
222
  ```bash
223
223
  yam hook status --global
@@ -226,3 +226,5 @@ yam hook enable study-note --global
226
226
  yam hook disable lite --global
227
227
  yam hook disable study-note --global
228
228
  ```
229
+
230
+ `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.
package/README.md CHANGED
@@ -35,6 +35,7 @@ See `ROADMAP.md` for remaining implementation stages.
35
35
 
36
36
  - No required hooks.
37
37
  - Optional `yam-lite` hook is advisory-only and off by default.
38
+ - Optional `study-note` hook adds a prompt reminder and a bounded final-response check; it is off by default.
38
39
  - No automatic Team routing.
39
40
  - No automatic proof loops.
40
41
  - No automatic tmux.
@@ -87,6 +88,19 @@ yam hook disable lite --global
87
88
 
88
89
  It only adds short advisory context. It does not run verification, tmux, browser QA, subagents, or dependency installs.
89
90
 
91
+ ## Study Note Hook
92
+
93
+ Enable this profile when you want Codex to check changed work at final-response time as well as remind the agent at prompt time.
94
+
95
+ ```bash
96
+ yam hook enable study-note --global
97
+ yam hook status --global
98
+ ```
99
+
100
+ 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. The hook never writes the Study Note or runs verification for the agent.
101
+
102
+ `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.
103
+
90
104
  ## Install
91
105
 
92
106
  Recommended:
@@ -227,7 +241,7 @@ These shapes keep reports machine-readable without turning normal work into a he
227
241
  - Release report JSON: `yam release report --json` summarizes typecheck, forbidden-name scan, package boundary, registry status, CLI smoke, dist freshness, diagnostics, readiness receipt, and final truth status.
228
242
  - Loop report JSON: `yam loop report --json` records a read-only handoff artifact: guided stages, touched/read/verified files, skipped checks, stop condition, resume hint, readiness state, requirement coverage, evidence level/stamp, blocker kind, failure cause, safe retry, recovery hint, owner route/scope, side effects, next action, fix-first items, remaining tasks, recommended direction, implementation notes, blocked-by notes, avoidance note, tool intent, truth status, and study note.
229
243
  - Study note: `yam.study-note.v1` and `references/study-note.md` keep non-specialist explanations for what code/artifact changed, what role it plays, where it runs, what changed from before to after, what behavior is expected, one syntax/structure insight, what was verified, and what remains uncertain. Missing details stay in `limits` instead of being guessed. Study Note v3 also asks whether work was dumped into `page.tsx`, `global.css`, or broad DB `jsonb` when a smaller component, style module/token, typed column, relation, or validation schema would teach and scale better.
230
- - Study Note guard: `yam study-note check` is a read-only missing-note guard. It checks changed files against supplied final-report text and can be used directly or through the opt-in `yam hook enable study-note --global` advisory hook.
244
+ - Study Note guard: `yam study-note check` is a read-only missing-note guard. The opt-in `yam hook enable study-note --global` profile reuses it for a prompt reminder and one bounded Codex `Stop` correction pass; it still does not generate or edit a report.
231
245
  - Avoidance note: loop reports may include one short mistake-to-avoid note, but durable memory stays explicit through `yam memory add`; `yam loop report` does not write `.yam/memory`.
232
246
  - Publish blocker evidence: release reports classify common npm auth, permission, OTP, immutable-version, cache, registry, and tarball failures into safe next actions.
233
247
  - Publish readiness evidence: release reports run read-only registry/auth/version probes, redact account/token details, keep npm publish outside the report, and include a `yam.release-readiness-receipt.v1` basis for the readiness judgment.
package/ROADMAP.md CHANGED
@@ -11,9 +11,10 @@ Implemented:
11
11
  - Transactional skill install with rollback, SHA-256 receipt, install lock, and status drift detection. Done.
12
12
  - CLI helpers: detect, budget, template, tune-log, version.
13
13
  - Optional advisory `yam-lite` hook with status/enable/disable. Done.
14
+ - Optional Study Note prompt reminder plus bounded Codex `Stop` completion gate, hook health, and stale-path migration. Done.
14
15
  - Opt-in project memory: init, add, list, summary, resolve.
15
16
  - Honest completion guard: no verified/cleanup/visual claims without evidence.
16
- - No hooks, automations, or global config.
17
+ - No hooks, automations, or global config mutations unless the user explicitly enables them.
17
18
  - Token economy policy.
18
19
 
19
20
  ## Next Stages
@@ -304,6 +305,8 @@ Tasks:
304
305
 
305
306
  - Add `yam hook status|enable|disable|run lite`. Done.
306
307
  - Keep `yam-lite` advisory-only through `UserPromptSubmit`. Done.
308
+ - Add Study Note `UserPromptSubmit` reminder plus one-pass `Stop` completion gate. Done.
309
+ - Detect broken hook targets and migrate stale paths without replacing unrelated hooks. Done.
307
310
  - Add `references/tool-trust-layer.md`. Done.
308
311
  - Add read-only `yam tools doctor`. Done.
309
312
  - Add `yam proof` summary command. Done.
package/dist/bin/yam.js CHANGED
@@ -50,8 +50,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
50
50
  const PROJECT_PACK = 'yam.project.md';
51
51
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
52
52
  const PACK_STALE_DAYS = 30;
53
- const YAM_LITE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run lite`;
54
- const YAM_STUDY_NOTE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run study-note`;
53
+ const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
54
+ const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
55
+ const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
55
56
  const REQUIRED_PACK_SECTIONS = [
56
57
  'Product Direction',
57
58
  'UI Direction',
@@ -750,16 +751,27 @@ Notes:
750
751
  async function studyNoteCheck(args = []) {
751
752
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
752
753
  const dir = path.resolve(firstPositional(args) || process.cwd());
753
- const changedFiles = await gitChangedFiles(dir);
754
754
  const reportText = await readStudyNoteReportText(flags, dir);
755
+ const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
756
+ const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
757
+ printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
758
+ if (result.blockers.length)
759
+ process.exitCode = 1;
760
+ }
761
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
762
+ const changedFiles = await gitChangedFiles(dir);
755
763
  const needsStudyNote = changedFiles.length > 0;
756
764
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
757
765
  const checks = [
758
766
  studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
759
767
  studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
760
768
  studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
769
+ studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
761
770
  studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
762
- studyNoteGuardCheck('verification_or_limits', !needsStudyNote || /(verification|verified|checked|limits|uncertain|검증|확인|한계|모르는)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked and what remains uncertain')
771
+ studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
772
+ studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
773
+ studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
774
+ studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
763
775
  ];
764
776
  for (const requirement of hygieneRequirements) {
765
777
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -771,15 +783,13 @@ async function studyNoteCheck(args = []) {
771
783
  project: dir,
772
784
  changed_files: changedFiles,
773
785
  changed_file_count: changedFiles.length,
774
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
786
+ report_source: reportSource,
775
787
  checks,
776
788
  blockers,
777
789
  next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
778
790
  truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
779
791
  };
780
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
781
- if (blockers.length)
782
- process.exitCode = 1;
792
+ return result;
783
793
  }
784
794
  function firstPositional(args = []) {
785
795
  for (let index = 0; index < args.length; index += 1) {
@@ -1017,6 +1027,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1017
1027
  const codexHome = path.join(os.homedir(), '.codex');
1018
1028
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1019
1029
  const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
1030
+ const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
1031
+ const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
1020
1032
  const tmux = await findExecutable('tmux');
1021
1033
  const packageInfo = await projectPackageInfo(dir);
1022
1034
  const pack = await findProjectPack(dir);
@@ -1035,7 +1047,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1035
1047
  const rows = [
1036
1048
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1037
1049
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1038
- readinessRow('yam-lite hook', hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled', 'optional UserPromptSubmit guide'),
1050
+ readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
1051
+ readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
1039
1052
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1040
1053
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1041
1054
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1413,7 +1426,8 @@ Usage:
1413
1426
  yam hook run <lite|study-note>
1414
1427
 
1415
1428
  Notes:
1416
- hooks are opt-in and advisory-only. They do not run tmux, subagents, or proof gates.
1429
+ hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
1430
+ Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
1417
1431
  `);
1418
1432
  }
1419
1433
  function parseHookArgs(args = []) {
@@ -1444,10 +1458,10 @@ function hookPathFor(parsed) {
1444
1458
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
1445
1459
  }
1446
1460
  function isYamLiteHook(handler = {}) {
1447
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run lite');
1461
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
1448
1462
  }
1449
1463
  function isYamStudyNoteHook(handler = {}) {
1450
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run study-note');
1464
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
1451
1465
  }
1452
1466
  function isYamHookProfile(handler = {}, profile = 'lite') {
1453
1467
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
@@ -1455,7 +1469,9 @@ function isYamHookProfile(handler = {}, profile = 'lite') {
1455
1469
  function stripYamHooks(config = {}, profile = 'lite') {
1456
1470
  const next = { ...config };
1457
1471
  for (const event of Object.keys(next)) {
1458
- const entries = Array.isArray(next[event]) ? next[event] : [];
1472
+ if (!Array.isArray(next[event]))
1473
+ continue;
1474
+ const entries = next[event];
1459
1475
  const keptEntries = [];
1460
1476
  for (const entry of entries) {
1461
1477
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1472,27 +1488,42 @@ function stripYamHooks(config = {}, profile = 'lite') {
1472
1488
  }
1473
1489
  function withYamHook(config = {}, profile = 'lite') {
1474
1490
  const next = stripYamHooks(config, profile);
1475
- const event = 'UserPromptSubmit';
1476
- const entry = {
1477
- hooks: [
1478
- {
1479
- type: 'command',
1480
- command: profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND,
1481
- timeout: 5
1482
- }
1483
- ]
1484
- };
1485
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1491
+ const command = hookCommandForProfile(profile);
1492
+ for (const event of hookEventsForProfile(profile)) {
1493
+ const entry = {
1494
+ hooks: [
1495
+ {
1496
+ type: 'command',
1497
+ command,
1498
+ timeout: 5
1499
+ }
1500
+ ]
1501
+ };
1502
+ next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1503
+ }
1486
1504
  return next;
1487
1505
  }
1488
1506
  async function hookStatus(args = []) {
1489
1507
  const parsed = parseHookArgs(args);
1490
1508
  const target = hookPathFor(parsed);
1491
- const config = await readJsonOrDefault(target, {});
1492
- console.log(`yam-lite hook: ${hookConfigHasProfile(config, 'lite') ? 'enabled' : 'disabled'}`);
1493
- console.log(`yam-study-note hook: ${hookConfigHasProfile(config, 'study-note') ? 'enabled' : 'disabled'}`);
1509
+ const loaded = await readHookConfig(target);
1510
+ if (loaded.error) {
1511
+ console.log('yam-lite hook: broken');
1512
+ console.log('yam-study-note hook: broken');
1513
+ console.log(` - hook config unreadable: ${loaded.error}`);
1514
+ console.log(`scope: ${parsed.mode}`);
1515
+ console.log(`file: ${target}`);
1516
+ process.exitCode = 1;
1517
+ return;
1518
+ }
1519
+ const lite = await inspectHookProfile(loaded.config, 'lite');
1520
+ const studyNote = await inspectHookProfile(loaded.config, 'study-note');
1521
+ printHookProfileStatus('lite', lite);
1522
+ printHookProfileStatus('study-note', studyNote);
1494
1523
  console.log(`scope: ${parsed.mode}`);
1495
1524
  console.log(`file: ${target}`);
1525
+ if (lite.state === 'broken' || studyNote.state === 'broken')
1526
+ process.exitCode = 1;
1496
1527
  }
1497
1528
  function hookConfigHasYamLite(config = {}) {
1498
1529
  return hookConfigHasProfile(config, 'lite');
@@ -1502,6 +1533,169 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1502
1533
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
1503
1534
  }));
1504
1535
  }
1536
+ function hookCommandForProfile(profile = 'lite') {
1537
+ return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
1538
+ }
1539
+ function hookEventsForProfile(profile = 'lite') {
1540
+ return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
1541
+ }
1542
+ function hookProfileEntries(config = {}, profile = 'lite') {
1543
+ const matches = [];
1544
+ for (const [event, entries] of Object.entries(config)) {
1545
+ if (!Array.isArray(entries))
1546
+ continue;
1547
+ for (const entry of entries) {
1548
+ if (!Array.isArray(entry?.hooks))
1549
+ continue;
1550
+ for (const handler of entry.hooks) {
1551
+ if (isYamHookProfile(handler, profile))
1552
+ matches.push({ event, handler });
1553
+ }
1554
+ }
1555
+ }
1556
+ return matches;
1557
+ }
1558
+ function splitHookCommand(command = '') {
1559
+ const words = [];
1560
+ let current = '';
1561
+ let quote = '';
1562
+ let escaped = false;
1563
+ let started = false;
1564
+ for (const char of String(command)) {
1565
+ if (escaped) {
1566
+ current += char;
1567
+ escaped = false;
1568
+ started = true;
1569
+ continue;
1570
+ }
1571
+ if (char === '\\' && quote !== "'") {
1572
+ escaped = true;
1573
+ started = true;
1574
+ continue;
1575
+ }
1576
+ if (quote) {
1577
+ if (char === quote)
1578
+ quote = '';
1579
+ else
1580
+ current += char;
1581
+ started = true;
1582
+ continue;
1583
+ }
1584
+ if (char === '"' || char === "'") {
1585
+ quote = char;
1586
+ started = true;
1587
+ continue;
1588
+ }
1589
+ if (/\s/.test(char)) {
1590
+ if (started) {
1591
+ words.push(current);
1592
+ current = '';
1593
+ started = false;
1594
+ }
1595
+ continue;
1596
+ }
1597
+ current += char;
1598
+ started = true;
1599
+ }
1600
+ if (escaped || quote)
1601
+ return [];
1602
+ if (started)
1603
+ words.push(current);
1604
+ return words;
1605
+ }
1606
+ function parseYamHookCommand(command = '', profile = 'lite') {
1607
+ const words = splitHookCommand(command);
1608
+ if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
1609
+ const nodeName = path.basename(words[0]).toLowerCase();
1610
+ if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
1611
+ return { executable: words[0], script: words[1] };
1612
+ }
1613
+ }
1614
+ if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
1615
+ const yamName = path.basename(words[0]).toLowerCase();
1616
+ if (/^yam(?:\.cmd|\.exe)?$/.test(yamName))
1617
+ return { executable: words[0], script: '' };
1618
+ }
1619
+ return null;
1620
+ }
1621
+ async function hookTargetExists(target = '', executable = false) {
1622
+ if (!target)
1623
+ return false;
1624
+ if (!path.isAbsolute(target))
1625
+ return executable ? Boolean(await findExecutable(target)) : false;
1626
+ try {
1627
+ await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
1628
+ return true;
1629
+ }
1630
+ catch {
1631
+ return false;
1632
+ }
1633
+ }
1634
+ async function inspectHookHandler(handler = {}, profile = 'lite') {
1635
+ const parsed = parseYamHookCommand(String(handler.command || ''), profile);
1636
+ if (!parsed)
1637
+ return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
1638
+ if (!await hookTargetExists(parsed.executable, true)) {
1639
+ return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
1640
+ }
1641
+ if (parsed.script && !await hookTargetExists(parsed.script)) {
1642
+ return { ok: false, issue: `hook target is missing: ${parsed.script}` };
1643
+ }
1644
+ return { ok: true, issue: '' };
1645
+ }
1646
+ async function inspectHookProfile(config = {}, profile = 'lite') {
1647
+ const entries = hookProfileEntries(config, profile);
1648
+ if (!entries.length)
1649
+ return { state: 'disabled', issues: [], entries };
1650
+ const issues = [];
1651
+ for (const event of hookEventsForProfile(profile)) {
1652
+ const count = entries.filter((entry) => entry.event === event).length;
1653
+ if (count === 0)
1654
+ issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
1655
+ if (count > 1)
1656
+ issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
1657
+ }
1658
+ for (const entry of entries) {
1659
+ if (!hookEventsForProfile(profile).includes(entry.event)) {
1660
+ issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
1661
+ }
1662
+ const health = await inspectHookHandler(entry.handler, profile);
1663
+ if (!health.ok)
1664
+ issues.push(`${entry.event}: ${health.issue}`);
1665
+ }
1666
+ return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
1667
+ }
1668
+ function printHookProfileStatus(profile, health) {
1669
+ console.log(`yam-${profile} hook: ${health.state}`);
1670
+ for (const issue of health.issues)
1671
+ console.log(` - ${issue}`);
1672
+ }
1673
+ async function readHookConfig(target) {
1674
+ try {
1675
+ const value = JSON.parse(await fsp.readFile(target, 'utf8'));
1676
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
1677
+ return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
1678
+ }
1679
+ return { config: value, exists: true, error: '' };
1680
+ }
1681
+ catch (error) {
1682
+ if (error.code === 'ENOENT')
1683
+ return { config: {}, exists: false, error: '' };
1684
+ return { config: {}, exists: true, error: errorMessage(error) };
1685
+ }
1686
+ }
1687
+ async function writeHookConfig(target, config) {
1688
+ await fsp.mkdir(path.dirname(target), { recursive: true });
1689
+ const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
1690
+ try {
1691
+ await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1692
+ await fsp.rename(temporary, target);
1693
+ }
1694
+ catch (error) {
1695
+ await rmrf(temporary).catch(() => { });
1696
+ throw error;
1697
+ }
1698
+ }
1505
1699
  async function hookEnable(args = []) {
1506
1700
  const parsed = parseHookArgs(args);
1507
1701
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1510,22 +1704,43 @@ async function hookEnable(args = []) {
1510
1704
  return;
1511
1705
  }
1512
1706
  const target = hookPathFor(parsed);
1513
- const current = await readJsonOrDefault(target, {});
1707
+ const loaded = await readHookConfig(target);
1708
+ if (loaded.error) {
1709
+ console.error(`Cannot update unreadable hook config: ${target}`);
1710
+ console.error(loaded.error);
1711
+ process.exitCode = 1;
1712
+ return;
1713
+ }
1714
+ const current = loaded.config;
1715
+ const existing = hookProfileEntries(current, parsed.profile);
1716
+ const expectedEvents = hookEventsForProfile(parsed.profile);
1717
+ const expectedCommand = hookCommandForProfile(parsed.profile);
1718
+ const needsMigration = existing.length > 0 && (existing.length !== expectedEvents.length
1719
+ || existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand));
1514
1720
  const next = withYamHook(current, parsed.profile);
1515
1721
  await fsp.mkdir(path.dirname(target), { recursive: true });
1516
- if (await exists(target)) {
1722
+ if (loaded.exists) {
1517
1723
  const backup = `${target}.yam-backup-${timestampId()}`;
1518
1724
  await fsp.copyFile(target, backup);
1519
1725
  console.log(`backup: ${backup}`);
1520
1726
  }
1521
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1727
+ await writeHookConfig(target, next);
1522
1728
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
1729
+ if (needsMigration)
1730
+ console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
1523
1731
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1524
1732
  }
1525
1733
  async function hookDisable(args = []) {
1526
1734
  const parsed = parseHookArgs(args);
1527
1735
  const target = hookPathFor(parsed);
1528
- const current = await readJsonOrDefault(target, {});
1736
+ const loaded = await readHookConfig(target);
1737
+ if (loaded.error) {
1738
+ console.error(`Cannot update unreadable hook config: ${target}`);
1739
+ console.error(loaded.error);
1740
+ process.exitCode = 1;
1741
+ return;
1742
+ }
1743
+ const current = loaded.config;
1529
1744
  if (!hookConfigHasProfile(current, parsed.profile)) {
1530
1745
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1531
1746
  return;
@@ -1535,7 +1750,7 @@ async function hookDisable(args = []) {
1535
1750
  await rmrf(target);
1536
1751
  }
1537
1752
  else {
1538
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1753
+ await writeHookConfig(target, next);
1539
1754
  }
1540
1755
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1541
1756
  }
@@ -1548,6 +1763,10 @@ async function hookRun(args = []) {
1548
1763
  const input = await readStdinJson();
1549
1764
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1550
1765
  const cwd = String(input?.cwd || process.cwd());
1766
+ if (profile === 'study-note' && event === 'Stop') {
1767
+ console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
1768
+ return;
1769
+ }
1551
1770
  const prompt = extractPrompt(input);
1552
1771
  const additionalContext = profile === 'study-note'
1553
1772
  ? await buildStudyNoteHookContext({ cwd })
@@ -1617,6 +1836,7 @@ async function buildStudyNoteHookContext({ cwd }) {
1617
1836
  ];
1618
1837
  if (!changedFiles.length) {
1619
1838
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
1839
+ lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
1620
1840
  return lines.join('\n');
1621
1841
  }
1622
1842
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1626,9 +1846,30 @@ async function buildStudyNoteHookContext({ cwd }) {
1626
1846
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1627
1847
  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.');
1628
1848
  }
1629
- lines.push('This hook is advisory; it does not generate or edit the Study Note.');
1849
+ lines.push('This prompt reminder does not generate or edit the Study Note; the paired Stop hook requests one correction pass if the final response is incomplete.');
1630
1850
  return lines.join('\n');
1631
1851
  }
1852
+ async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
1853
+ const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
1854
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
1855
+ if (!result.blockers.length)
1856
+ return { continue: true };
1857
+ const summary = result.blockers.slice(0, 4).join('; ');
1858
+ if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
1859
+ return {
1860
+ continue: true,
1861
+ systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
1862
+ };
1863
+ }
1864
+ return {
1865
+ decision: 'block',
1866
+ reason: [
1867
+ 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
1868
+ summary,
1869
+ '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.'
1870
+ ].join(' ')
1871
+ };
1872
+ }
1632
1873
  function yamLiteRouteHint(prompt = '') {
1633
1874
  const text = String(prompt || '').toLowerCase();
1634
1875
  if (!text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam-flow",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "description": "Progressive proof-first agent flow: start fast, deepen deliberately, stay honest by design.",
5
5
  "type": "module",
6
6
  "author": "0kim0bos",
@@ -53,3 +53,9 @@ Selective skill systems favor lower-context workflows.
53
53
  Minimal-core systems avoid hooks unless the rule is short and changes behavior.
54
54
 
55
55
  `yam` keeps this hook advisory-only so beginner momentum is preserved while the agent still receives a direction nudge. Deeper proof belongs in `$deep` and real team execution belongs in `$mission`, not in an always-on prompt hook.
56
+
57
+ ## Separate Study Note Profile
58
+
59
+ `study-note` is a separate opt-in profile, not part of the lite contract. It installs a `UserPromptSubmit` reminder and a Codex `Stop` handler that checks the latest assistant message with the read-only Study Note Guard. A missing or incomplete note requests one correction pass; `stop_hook_active` bounds the retry so the hook cannot create an infinite completion loop. The profile never writes the note or runs verification.
60
+
61
+ Hook status validates executable and script targets plus required event coverage. Re-enabling a broken profile creates a timestamped backup, preserves unrelated hook entries, and migrates stale commands to the current package entrypoint.
package/src/bin/yam.ts CHANGED
@@ -84,8 +84,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
84
84
  const PROJECT_PACK = 'yam.project.md';
85
85
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
86
86
  const PACK_STALE_DAYS = 30;
87
- const YAM_LITE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run lite`;
88
- const YAM_STUDY_NOTE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run study-note`;
87
+ const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
88
+ const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
89
+ const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
89
90
  const REQUIRED_PACK_SECTIONS = [
90
91
  'Product Direction',
91
92
  'UI Direction',
@@ -756,16 +757,27 @@ Notes:
756
757
  async function studyNoteCheck(args = []) {
757
758
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
758
759
  const dir = path.resolve(firstPositional(args) || process.cwd());
759
- const changedFiles = await gitChangedFiles(dir);
760
760
  const reportText = await readStudyNoteReportText(flags, dir);
761
+ const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
762
+ const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
763
+ printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
764
+ if (result.blockers.length) process.exitCode = 1;
765
+ }
766
+
767
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
768
+ const changedFiles = await gitChangedFiles(dir);
761
769
  const needsStudyNote = changedFiles.length > 0;
762
770
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
763
771
  const checks = [
764
772
  studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
765
773
  studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
766
774
  studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
775
+ studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
767
776
  studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
768
- studyNoteGuardCheck('verification_or_limits', !needsStudyNote || /(verification|verified|checked|limits|uncertain|검증|확인|한계|모르는)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked and what remains uncertain')
777
+ studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
778
+ studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
779
+ studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
780
+ studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
769
781
  ];
770
782
  for (const requirement of hygieneRequirements) {
771
783
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -777,14 +789,13 @@ async function studyNoteCheck(args = []) {
777
789
  project: dir,
778
790
  changed_files: changedFiles,
779
791
  changed_file_count: changedFiles.length,
780
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
792
+ report_source: reportSource,
781
793
  checks,
782
794
  blockers,
783
795
  next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
784
796
  truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
785
797
  };
786
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
787
- if (blockers.length) process.exitCode = 1;
798
+ return result;
788
799
  }
789
800
 
790
801
  function firstPositional(args = []) {
@@ -1028,6 +1039,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1028
1039
  const codexHome = path.join(os.homedir(), '.codex');
1029
1040
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1030
1041
  const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
1042
+ const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
1043
+ const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
1031
1044
  const tmux = await findExecutable('tmux');
1032
1045
  const packageInfo = await projectPackageInfo(dir);
1033
1046
  const pack = await findProjectPack(dir);
@@ -1046,7 +1059,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1046
1059
  const rows = [
1047
1060
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1048
1061
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1049
- readinessRow('yam-lite hook', hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled', 'optional UserPromptSubmit guide'),
1062
+ readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
1063
+ readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
1050
1064
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1051
1065
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1052
1066
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1420,7 +1434,8 @@ Usage:
1420
1434
  yam hook run <lite|study-note>
1421
1435
 
1422
1436
  Notes:
1423
- hooks are opt-in and advisory-only. They do not run tmux, subagents, or proof gates.
1437
+ hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
1438
+ Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
1424
1439
  `);
1425
1440
  }
1426
1441
 
@@ -1450,11 +1465,11 @@ function hookPathFor(parsed) {
1450
1465
  }
1451
1466
 
1452
1467
  function isYamLiteHook(handler: AnyRecord = {}) {
1453
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run lite');
1468
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
1454
1469
  }
1455
1470
 
1456
1471
  function isYamStudyNoteHook(handler: AnyRecord = {}) {
1457
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run study-note');
1472
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
1458
1473
  }
1459
1474
 
1460
1475
  function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
@@ -1464,7 +1479,8 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
1464
1479
  function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1465
1480
  const next = { ...config };
1466
1481
  for (const event of Object.keys(next)) {
1467
- const entries = Array.isArray(next[event]) ? next[event] : [];
1482
+ if (!Array.isArray(next[event])) continue;
1483
+ const entries = next[event];
1468
1484
  const keptEntries = [];
1469
1485
  for (const entry of entries) {
1470
1486
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1479,28 +1495,42 @@ function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1479
1495
 
1480
1496
  function withYamHook(config: AnyRecord = {}, profile = 'lite') {
1481
1497
  const next = stripYamHooks(config, profile);
1482
- const event = 'UserPromptSubmit';
1483
- const entry = {
1484
- hooks: [
1485
- {
1486
- type: 'command',
1487
- command: profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND,
1488
- timeout: 5
1489
- }
1490
- ]
1491
- };
1492
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1498
+ const command = hookCommandForProfile(profile);
1499
+ for (const event of hookEventsForProfile(profile)) {
1500
+ const entry = {
1501
+ hooks: [
1502
+ {
1503
+ type: 'command',
1504
+ command,
1505
+ timeout: 5
1506
+ }
1507
+ ]
1508
+ };
1509
+ next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1510
+ }
1493
1511
  return next;
1494
1512
  }
1495
1513
 
1496
1514
  async function hookStatus(args = []) {
1497
1515
  const parsed = parseHookArgs(args);
1498
1516
  const target = hookPathFor(parsed);
1499
- const config = await readJsonOrDefault(target, {});
1500
- console.log(`yam-lite hook: ${hookConfigHasProfile(config, 'lite') ? 'enabled' : 'disabled'}`);
1501
- console.log(`yam-study-note hook: ${hookConfigHasProfile(config, 'study-note') ? 'enabled' : 'disabled'}`);
1517
+ const loaded = await readHookConfig(target);
1518
+ if (loaded.error) {
1519
+ console.log('yam-lite hook: broken');
1520
+ console.log('yam-study-note hook: broken');
1521
+ console.log(` - hook config unreadable: ${loaded.error}`);
1522
+ console.log(`scope: ${parsed.mode}`);
1523
+ console.log(`file: ${target}`);
1524
+ process.exitCode = 1;
1525
+ return;
1526
+ }
1527
+ const lite = await inspectHookProfile(loaded.config, 'lite');
1528
+ const studyNote = await inspectHookProfile(loaded.config, 'study-note');
1529
+ printHookProfileStatus('lite', lite);
1530
+ printHookProfileStatus('study-note', studyNote);
1502
1531
  console.log(`scope: ${parsed.mode}`);
1503
1532
  console.log(`file: ${target}`);
1533
+ if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
1504
1534
  }
1505
1535
 
1506
1536
  function hookConfigHasYamLite(config = {}) {
@@ -1513,6 +1543,160 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1513
1543
  }));
1514
1544
  }
1515
1545
 
1546
+ function hookCommandForProfile(profile = 'lite') {
1547
+ return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
1548
+ }
1549
+
1550
+ function hookEventsForProfile(profile = 'lite') {
1551
+ return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
1552
+ }
1553
+
1554
+ function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
1555
+ const matches = [];
1556
+ for (const [event, entries] of Object.entries(config)) {
1557
+ if (!Array.isArray(entries)) continue;
1558
+ for (const entry of entries) {
1559
+ if (!Array.isArray(entry?.hooks)) continue;
1560
+ for (const handler of entry.hooks) {
1561
+ if (isYamHookProfile(handler, profile)) matches.push({ event, handler });
1562
+ }
1563
+ }
1564
+ }
1565
+ return matches;
1566
+ }
1567
+
1568
+ function splitHookCommand(command = '') {
1569
+ const words = [];
1570
+ let current = '';
1571
+ let quote = '';
1572
+ let escaped = false;
1573
+ let started = false;
1574
+ for (const char of String(command)) {
1575
+ if (escaped) {
1576
+ current += char;
1577
+ escaped = false;
1578
+ started = true;
1579
+ continue;
1580
+ }
1581
+ if (char === '\\' && quote !== "'") {
1582
+ escaped = true;
1583
+ started = true;
1584
+ continue;
1585
+ }
1586
+ if (quote) {
1587
+ if (char === quote) quote = '';
1588
+ else current += char;
1589
+ started = true;
1590
+ continue;
1591
+ }
1592
+ if (char === '"' || char === "'") {
1593
+ quote = char;
1594
+ started = true;
1595
+ continue;
1596
+ }
1597
+ if (/\s/.test(char)) {
1598
+ if (started) {
1599
+ words.push(current);
1600
+ current = '';
1601
+ started = false;
1602
+ }
1603
+ continue;
1604
+ }
1605
+ current += char;
1606
+ started = true;
1607
+ }
1608
+ if (escaped || quote) return [];
1609
+ if (started) words.push(current);
1610
+ return words;
1611
+ }
1612
+
1613
+ function parseYamHookCommand(command = '', profile = 'lite') {
1614
+ const words = splitHookCommand(command);
1615
+ if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
1616
+ const nodeName = path.basename(words[0]).toLowerCase();
1617
+ if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
1618
+ return { executable: words[0], script: words[1] };
1619
+ }
1620
+ }
1621
+ if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
1622
+ const yamName = path.basename(words[0]).toLowerCase();
1623
+ if (/^yam(?:\.cmd|\.exe)?$/.test(yamName)) return { executable: words[0], script: '' };
1624
+ }
1625
+ return null;
1626
+ }
1627
+
1628
+ async function hookTargetExists(target = '', executable = false) {
1629
+ if (!target) return false;
1630
+ if (!path.isAbsolute(target)) return executable ? Boolean(await findExecutable(target)) : false;
1631
+ try {
1632
+ await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
1633
+ return true;
1634
+ } catch {
1635
+ return false;
1636
+ }
1637
+ }
1638
+
1639
+ async function inspectHookHandler(handler: AnyRecord = {}, profile = 'lite') {
1640
+ const parsed = parseYamHookCommand(String(handler.command || ''), profile);
1641
+ if (!parsed) return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
1642
+ if (!await hookTargetExists(parsed.executable, true)) {
1643
+ return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
1644
+ }
1645
+ if (parsed.script && !await hookTargetExists(parsed.script)) {
1646
+ return { ok: false, issue: `hook target is missing: ${parsed.script}` };
1647
+ }
1648
+ return { ok: true, issue: '' };
1649
+ }
1650
+
1651
+ async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
1652
+ const entries = hookProfileEntries(config, profile);
1653
+ if (!entries.length) return { state: 'disabled', issues: [], entries };
1654
+ const issues = [];
1655
+ for (const event of hookEventsForProfile(profile)) {
1656
+ const count = entries.filter((entry) => entry.event === event).length;
1657
+ if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
1658
+ if (count > 1) issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
1659
+ }
1660
+ for (const entry of entries) {
1661
+ if (!hookEventsForProfile(profile).includes(entry.event)) {
1662
+ issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
1663
+ }
1664
+ const health = await inspectHookHandler(entry.handler, profile);
1665
+ if (!health.ok) issues.push(`${entry.event}: ${health.issue}`);
1666
+ }
1667
+ return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
1668
+ }
1669
+
1670
+ function printHookProfileStatus(profile, health) {
1671
+ console.log(`yam-${profile} hook: ${health.state}`);
1672
+ for (const issue of health.issues) console.log(` - ${issue}`);
1673
+ }
1674
+
1675
+ async function readHookConfig(target) {
1676
+ try {
1677
+ const value = JSON.parse(await fsp.readFile(target, 'utf8'));
1678
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
1679
+ return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
1680
+ }
1681
+ return { config: value, exists: true, error: '' };
1682
+ } catch (error) {
1683
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
1684
+ return { config: {}, exists: true, error: errorMessage(error) };
1685
+ }
1686
+ }
1687
+
1688
+ async function writeHookConfig(target, config) {
1689
+ await fsp.mkdir(path.dirname(target), { recursive: true });
1690
+ const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
1691
+ try {
1692
+ await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1693
+ await fsp.rename(temporary, target);
1694
+ } catch (error) {
1695
+ await rmrf(temporary).catch(() => {});
1696
+ throw error;
1697
+ }
1698
+ }
1699
+
1516
1700
  async function hookEnable(args = []) {
1517
1701
  const parsed = parseHookArgs(args);
1518
1702
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1521,23 +1705,45 @@ async function hookEnable(args = []) {
1521
1705
  return;
1522
1706
  }
1523
1707
  const target = hookPathFor(parsed);
1524
- const current = await readJsonOrDefault(target, {});
1708
+ const loaded = await readHookConfig(target);
1709
+ if (loaded.error) {
1710
+ console.error(`Cannot update unreadable hook config: ${target}`);
1711
+ console.error(loaded.error);
1712
+ process.exitCode = 1;
1713
+ return;
1714
+ }
1715
+ const current = loaded.config;
1716
+ const existing = hookProfileEntries(current, parsed.profile);
1717
+ const expectedEvents = hookEventsForProfile(parsed.profile);
1718
+ const expectedCommand = hookCommandForProfile(parsed.profile);
1719
+ const needsMigration = existing.length > 0 && (
1720
+ existing.length !== expectedEvents.length
1721
+ || existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand)
1722
+ );
1525
1723
  const next = withYamHook(current, parsed.profile);
1526
1724
  await fsp.mkdir(path.dirname(target), { recursive: true });
1527
- if (await exists(target)) {
1725
+ if (loaded.exists) {
1528
1726
  const backup = `${target}.yam-backup-${timestampId()}`;
1529
1727
  await fsp.copyFile(target, backup);
1530
1728
  console.log(`backup: ${backup}`);
1531
1729
  }
1532
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1730
+ await writeHookConfig(target, next);
1533
1731
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
1732
+ if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
1534
1733
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1535
1734
  }
1536
1735
 
1537
1736
  async function hookDisable(args = []) {
1538
1737
  const parsed = parseHookArgs(args);
1539
1738
  const target = hookPathFor(parsed);
1540
- const current = await readJsonOrDefault(target, {});
1739
+ const loaded = await readHookConfig(target);
1740
+ if (loaded.error) {
1741
+ console.error(`Cannot update unreadable hook config: ${target}`);
1742
+ console.error(loaded.error);
1743
+ process.exitCode = 1;
1744
+ return;
1745
+ }
1746
+ const current = loaded.config;
1541
1747
  if (!hookConfigHasProfile(current, parsed.profile)) {
1542
1748
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1543
1749
  return;
@@ -1546,7 +1752,7 @@ async function hookDisable(args = []) {
1546
1752
  if (Object.keys(next).length === 0) {
1547
1753
  await rmrf(target);
1548
1754
  } else {
1549
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1755
+ await writeHookConfig(target, next);
1550
1756
  }
1551
1757
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1552
1758
  }
@@ -1560,6 +1766,10 @@ async function hookRun(args = []) {
1560
1766
  const input = await readStdinJson();
1561
1767
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1562
1768
  const cwd = String(input?.cwd || process.cwd());
1769
+ if (profile === 'study-note' && event === 'Stop') {
1770
+ console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
1771
+ return;
1772
+ }
1563
1773
  const prompt = extractPrompt(input);
1564
1774
  const additionalContext = profile === 'study-note'
1565
1775
  ? await buildStudyNoteHookContext({ cwd })
@@ -1625,6 +1835,7 @@ async function buildStudyNoteHookContext({ cwd }) {
1625
1835
  ];
1626
1836
  if (!changedFiles.length) {
1627
1837
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
1838
+ lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
1628
1839
  return lines.join('\n');
1629
1840
  }
1630
1841
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1634,10 +1845,31 @@ async function buildStudyNoteHookContext({ cwd }) {
1634
1845
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1635
1846
  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.');
1636
1847
  }
1637
- lines.push('This hook is advisory; it does not generate or edit the Study Note.');
1848
+ lines.push('This prompt reminder does not generate or edit the Study Note; the paired Stop hook requests one correction pass if the final response is incomplete.');
1638
1849
  return lines.join('\n');
1639
1850
  }
1640
1851
 
1852
+ async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd()) {
1853
+ const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
1854
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
1855
+ if (!result.blockers.length) return { continue: true };
1856
+ const summary = result.blockers.slice(0, 4).join('; ');
1857
+ if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
1858
+ return {
1859
+ continue: true,
1860
+ systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
1861
+ };
1862
+ }
1863
+ return {
1864
+ decision: 'block',
1865
+ reason: [
1866
+ 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
1867
+ summary,
1868
+ '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.'
1869
+ ].join(' ')
1870
+ };
1871
+ }
1872
+
1641
1873
  function yamLiteRouteHint(prompt = '') {
1642
1874
  const text = String(prompt || '').toLowerCase();
1643
1875
  if (!text) return '';
package/yam.manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yam",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "principles": [
5
5
  "Direction before execution.",
6
6
  "Start fast.",
@@ -13,6 +13,7 @@
13
13
  "Context-reuse first.",
14
14
  "End with remaining tasks and fix-first items.",
15
15
  "When code, config, release metadata, documentation, or project artifacts changed, include a Study Note even if no yam skill was explicitly invoked.",
16
+ "When the opt-in Study Note hook is enabled, check the final response at Codex Stop and request one bounded correction pass instead of relying only on prompt-time advice.",
16
17
  "Explain what changed, what role it has, where it runs or is read, what was wrong or missing when relevant, before/after behavior, expected behavior, and one useful syntax or structure insight in short non-specialist language.",
17
18
  "When relevant, include architecture hygiene: avoid dumping unrelated logic into page.tsx, one-off component CSS into global.css, or structured product data into broad DB jsonb blobs when better project boundaries exist.",
18
19
  "Use handoff notes for fix-first items, remaining tasks, recommended next direction, requirement coverage, blocker kind, failure cause, recovery hint, safe retry, evidence level, and owner scope when useful.",