yam-flow 2.3.0 → 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/src/bin/yam.ts CHANGED
@@ -33,6 +33,12 @@ import {
33
33
  verifyUeyeAssetManifest,
34
34
  verifyUeyeRevisionHistory
35
35
  } from '../lib/ueye-artifacts.js';
36
+ import {
37
+ INSTALL_LOCK_NAME,
38
+ INSTALL_RECEIPT_NAME,
39
+ inspectSkillInstallation,
40
+ installSkillSetTransactional
41
+ } from '../lib/skill-installation.js';
36
42
 
37
43
  type AnyRecord = Record<string, any>;
38
44
 
@@ -78,8 +84,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
78
84
  const PROJECT_PACK = 'yam.project.md';
79
85
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
80
86
  const PACK_STALE_DAYS = 30;
81
- const YAM_LITE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run lite`;
82
- 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`;
83
90
  const REQUIRED_PACK_SECTIONS = [
84
91
  'Product Direction',
85
92
  'UI Direction',
@@ -199,75 +206,73 @@ async function rmrf(target) {
199
206
  await fsp.rm(target, { recursive: true, force: true });
200
207
  }
201
208
 
202
- async function copyDir(source, target) {
203
- await fsp.mkdir(target, { recursive: true });
204
- const entries = await fsp.readdir(source, { withFileTypes: true });
205
- for (const entry of entries) {
206
- const from = path.join(source, entry.name);
207
- const to = path.join(target, entry.name);
208
- if (entry.isDirectory()) {
209
- await copyDir(from, to);
210
- } else if (entry.isFile()) {
211
- await fsp.copyFile(from, to);
212
- }
213
- }
214
- }
215
-
216
- async function installSkill(skill) {
217
- const source = path.join(ROOT, 'skills', skill);
218
- const target = path.join(DEST, skill);
219
- const references = path.join(ROOT, 'references');
220
-
221
- if (!await exists(path.join(source, 'SKILL.md'))) {
222
- throw new Error(`missing skill source: ${source}`);
223
- }
224
-
225
- await rmrf(target);
226
- await fsp.mkdir(target, { recursive: true });
227
- await fsp.copyFile(path.join(source, 'SKILL.md'), path.join(target, 'SKILL.md'));
228
- await copyDir(references, path.join(target, 'references'));
229
- }
230
-
231
209
  async function install() {
232
- await fsp.mkdir(DEST, { recursive: true });
233
- for (const skill of SKILLS) {
234
- await installSkill(skill);
235
- }
236
- for (const legacySkill of LEGACY_SKILLS) {
237
- await rmrf(path.join(DEST, legacySkill));
238
- }
239
- for (const retiredSkill of RETIRED_SKILLS) {
240
- await rmrf(path.join(DEST, retiredSkill));
241
- }
242
- if (CODEX_MIRROR !== DEST && fs.existsSync(CODEX_MIRROR)) {
243
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
244
- await rmrf(path.join(CODEX_MIRROR, skill));
245
- }
246
- }
210
+ const result = await installSkillSetTransactional({
211
+ sourceRoot: ROOT,
212
+ destination: DEST,
213
+ codexMirror: CODEX_MIRROR,
214
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
215
+ version: VERSION,
216
+ skills: SKILLS,
217
+ legacySkills: LEGACY_SKILLS,
218
+ retiredSkills: RETIRED_SKILLS
219
+ });
247
220
  console.log(`yam installed to ${DEST}`);
221
+ console.log(`install receipt: ${result.receiptPath}`);
222
+ console.log(`integrity: ${result.installedFiles} files, sha256:${result.sourceDigest}`);
223
+ for (const warning of result.cleanupWarnings) console.warn(`warning: ${warning}`);
248
224
  console.log('Restart Codex to reload skills.');
249
225
  }
250
226
 
251
227
  async function uninstall() {
228
+ const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
229
+ if (await exists(lockPath)) {
230
+ throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
231
+ }
252
232
  for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
253
233
  await rmrf(path.join(DEST, skill));
254
234
  if (CODEX_MIRROR !== DEST) {
255
235
  await rmrf(path.join(CODEX_MIRROR, skill));
256
236
  }
257
237
  }
238
+ await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
258
239
  console.log(`yam removed from ${DEST}`);
259
240
  if (CODEX_MIRROR !== DEST) console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
260
241
  console.log('Restart Codex to unload skills.');
261
242
  }
262
243
 
263
244
  async function status({ quiet = false } = {}) {
264
- let missing = 0;
265
- for (const skill of SKILLS) {
266
- const ok = await exists(path.join(DEST, skill, 'SKILL.md')) && await exists(path.join(DEST, skill, 'references'));
267
- if (!quiet) console.log(`${ok ? 'ok ' : 'missing'} ${skill}`);
268
- if (!ok) missing += 1;
245
+ const report = await inspectSkillInstallation({
246
+ sourceRoot: ROOT,
247
+ destination: DEST,
248
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
249
+ version: VERSION,
250
+ skills: SKILLS
251
+ });
252
+ if (!quiet) {
253
+ for (const skill of report.skills) {
254
+ const label = skill.status === 'ok' ? 'ok ' : skill.status === 'missing' ? 'missing' : 'drift ';
255
+ console.log(`${label} ${skill.skill}`);
256
+ }
257
+ if (report.receiptStatus === 'ok' && report.receipt) {
258
+ console.log(`ok install receipt ${report.receipt.package.name}@${report.receipt.package.version}`);
259
+ console.log(` sha256:${report.receipt.integrity.source_digest}`);
260
+ } else {
261
+ console.log(`${report.receiptStatus === 'missing' ? 'missing' : 'drift '} install receipt`);
262
+ }
263
+ if (report.issues.length) {
264
+ console.log('');
265
+ console.log('Install integrity issues:');
266
+ for (const issue of report.issues.slice(0, 12)) console.log(`- ${issue}`);
267
+ if (report.issues.length > 12) console.log(`- ... ${report.issues.length - 12} more issue(s)`);
268
+ if (report.recoveryArtifacts.length) {
269
+ console.log('Confirm no install is running, then inspect and preserve the transaction backup before retrying.');
270
+ } else {
271
+ console.log('Run `yam install` to restore the package-bundled skill set.');
272
+ }
273
+ }
269
274
  }
270
- return missing;
275
+ return report.ok ? 0 : Math.max(1, report.issues.length);
271
276
  }
272
277
 
273
278
  async function list() {
@@ -316,6 +321,7 @@ async function verify({ quiet = false } = {}) {
316
321
 
317
322
  if (!manifest) return failOrReport('yam verify', issues, quiet);
318
323
  if (manifest.name !== 'yam') issues.push('manifest name must be yam');
324
+ if (manifest.version !== VERSION) issues.push(`manifest version must match package version: ${manifest.version || 'missing'} != ${VERSION}`);
319
325
  if (manifest.defaultHooks !== false) issues.push('manifest defaultHooks must be false');
320
326
  if (!Array.isArray(manifest.routes) || manifest.routes.length !== SKILLS.length) {
321
327
  issues.push(`manifest routes must contain ${SKILLS.length} routes`);
@@ -751,16 +757,27 @@ Notes:
751
757
  async function studyNoteCheck(args = []) {
752
758
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
753
759
  const dir = path.resolve(firstPositional(args) || process.cwd());
754
- const changedFiles = await gitChangedFiles(dir);
755
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);
756
769
  const needsStudyNote = changedFiles.length > 0;
757
770
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
758
771
  const checks = [
759
772
  studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
760
773
  studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
761
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'),
762
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'),
763
- 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')
764
781
  ];
765
782
  for (const requirement of hygieneRequirements) {
766
783
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -772,14 +789,13 @@ async function studyNoteCheck(args = []) {
772
789
  project: dir,
773
790
  changed_files: changedFiles,
774
791
  changed_file_count: changedFiles.length,
775
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
792
+ report_source: reportSource,
776
793
  checks,
777
794
  blockers,
778
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'),
779
796
  truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
780
797
  };
781
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
782
- if (blockers.length) process.exitCode = 1;
798
+ return result;
783
799
  }
784
800
 
785
801
  function firstPositional(args = []) {
@@ -1023,6 +1039,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1023
1039
  const codexHome = path.join(os.homedir(), '.codex');
1024
1040
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1025
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');
1026
1044
  const tmux = await findExecutable('tmux');
1027
1045
  const packageInfo = await projectPackageInfo(dir);
1028
1046
  const pack = await findProjectPack(dir);
@@ -1041,7 +1059,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1041
1059
  const rows = [
1042
1060
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1043
1061
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1044
- 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'),
1045
1064
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1046
1065
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1047
1066
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1415,7 +1434,8 @@ Usage:
1415
1434
  yam hook run <lite|study-note>
1416
1435
 
1417
1436
  Notes:
1418
- 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.
1419
1439
  `);
1420
1440
  }
1421
1441
 
@@ -1445,11 +1465,11 @@ function hookPathFor(parsed) {
1445
1465
  }
1446
1466
 
1447
1467
  function isYamLiteHook(handler: AnyRecord = {}) {
1448
- 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 || ''));
1449
1469
  }
1450
1470
 
1451
1471
  function isYamStudyNoteHook(handler: AnyRecord = {}) {
1452
- 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 || ''));
1453
1473
  }
1454
1474
 
1455
1475
  function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
@@ -1459,7 +1479,8 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
1459
1479
  function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1460
1480
  const next = { ...config };
1461
1481
  for (const event of Object.keys(next)) {
1462
- const entries = Array.isArray(next[event]) ? next[event] : [];
1482
+ if (!Array.isArray(next[event])) continue;
1483
+ const entries = next[event];
1463
1484
  const keptEntries = [];
1464
1485
  for (const entry of entries) {
1465
1486
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1474,28 +1495,42 @@ function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1474
1495
 
1475
1496
  function withYamHook(config: AnyRecord = {}, profile = 'lite') {
1476
1497
  const next = stripYamHooks(config, profile);
1477
- const event = 'UserPromptSubmit';
1478
- const entry = {
1479
- hooks: [
1480
- {
1481
- type: 'command',
1482
- command: profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND,
1483
- timeout: 5
1484
- }
1485
- ]
1486
- };
1487
- 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
+ }
1488
1511
  return next;
1489
1512
  }
1490
1513
 
1491
1514
  async function hookStatus(args = []) {
1492
1515
  const parsed = parseHookArgs(args);
1493
1516
  const target = hookPathFor(parsed);
1494
- const config = await readJsonOrDefault(target, {});
1495
- console.log(`yam-lite hook: ${hookConfigHasProfile(config, 'lite') ? 'enabled' : 'disabled'}`);
1496
- 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);
1497
1531
  console.log(`scope: ${parsed.mode}`);
1498
1532
  console.log(`file: ${target}`);
1533
+ if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
1499
1534
  }
1500
1535
 
1501
1536
  function hookConfigHasYamLite(config = {}) {
@@ -1508,6 +1543,160 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1508
1543
  }));
1509
1544
  }
1510
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
+
1511
1700
  async function hookEnable(args = []) {
1512
1701
  const parsed = parseHookArgs(args);
1513
1702
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1516,23 +1705,45 @@ async function hookEnable(args = []) {
1516
1705
  return;
1517
1706
  }
1518
1707
  const target = hookPathFor(parsed);
1519
- 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
+ );
1520
1723
  const next = withYamHook(current, parsed.profile);
1521
1724
  await fsp.mkdir(path.dirname(target), { recursive: true });
1522
- if (await exists(target)) {
1725
+ if (loaded.exists) {
1523
1726
  const backup = `${target}.yam-backup-${timestampId()}`;
1524
1727
  await fsp.copyFile(target, backup);
1525
1728
  console.log(`backup: ${backup}`);
1526
1729
  }
1527
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1730
+ await writeHookConfig(target, next);
1528
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`);
1529
1733
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1530
1734
  }
1531
1735
 
1532
1736
  async function hookDisable(args = []) {
1533
1737
  const parsed = parseHookArgs(args);
1534
1738
  const target = hookPathFor(parsed);
1535
- 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;
1536
1747
  if (!hookConfigHasProfile(current, parsed.profile)) {
1537
1748
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1538
1749
  return;
@@ -1541,7 +1752,7 @@ async function hookDisable(args = []) {
1541
1752
  if (Object.keys(next).length === 0) {
1542
1753
  await rmrf(target);
1543
1754
  } else {
1544
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1755
+ await writeHookConfig(target, next);
1545
1756
  }
1546
1757
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1547
1758
  }
@@ -1555,6 +1766,10 @@ async function hookRun(args = []) {
1555
1766
  const input = await readStdinJson();
1556
1767
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1557
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
+ }
1558
1773
  const prompt = extractPrompt(input);
1559
1774
  const additionalContext = profile === 'study-note'
1560
1775
  ? await buildStudyNoteHookContext({ cwd })
@@ -1620,6 +1835,7 @@ async function buildStudyNoteHookContext({ cwd }) {
1620
1835
  ];
1621
1836
  if (!changedFiles.length) {
1622
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.');
1623
1839
  return lines.join('\n');
1624
1840
  }
1625
1841
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1629,10 +1845,31 @@ async function buildStudyNoteHookContext({ cwd }) {
1629
1845
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1630
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.');
1631
1847
  }
1632
- 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.');
1633
1849
  return lines.join('\n');
1634
1850
  }
1635
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
+
1636
1873
  function yamLiteRouteHint(prompt = '') {
1637
1874
  const text = String(prompt || '').toLowerCase();
1638
1875
  if (!text) return '';