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/dist/bin/yam.js CHANGED
@@ -9,6 +9,7 @@ import process from 'node:process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { TRUTH_STATUSES, buildLoopReport, buildMediaGenerationProof, buildMissionCompletionGate, buildMissionPatchEnvelope, buildMissionSubagentReceipt, buildRollbackHint, buildRuntimeBackendEvidence, buildStudyNote, buildUeyeDesignCompletionGate, buildUeyeVisualProvenance, buildUeyeRunReport, buildUeyeSurfaceContext, buildYamCompletionProof, detectDbSafetyText as detectTrustDbSafetyText, isTruthStatus } from '../lib/trust-kernel.js';
11
11
  import { archiveUeyeRevision, inspectImageFile, upsertUeyeAsset, verifyUeyeAssetManifest, verifyUeyeRevisionHistory } from '../lib/ueye-artifacts.js';
12
+ import { INSTALL_LOCK_NAME, INSTALL_RECEIPT_NAME, inspectSkillInstallation, installSkillSetTransactional } from '../lib/skill-installation.js';
12
13
  function errorMessage(error) {
13
14
  return error instanceof Error ? error.message : String(error);
14
15
  }
@@ -49,8 +50,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
49
50
  const PROJECT_PACK = 'yam.project.md';
50
51
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
51
52
  const PACK_STALE_DAYS = 30;
52
- const YAM_LITE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run lite`;
53
- 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`;
54
56
  const REQUIRED_PACK_SECTIONS = [
55
57
  'Product Direction',
56
58
  'UI Direction',
@@ -167,73 +169,77 @@ async function exists(file) {
167
169
  async function rmrf(target) {
168
170
  await fsp.rm(target, { recursive: true, force: true });
169
171
  }
170
- async function copyDir(source, target) {
171
- await fsp.mkdir(target, { recursive: true });
172
- const entries = await fsp.readdir(source, { withFileTypes: true });
173
- for (const entry of entries) {
174
- const from = path.join(source, entry.name);
175
- const to = path.join(target, entry.name);
176
- if (entry.isDirectory()) {
177
- await copyDir(from, to);
178
- }
179
- else if (entry.isFile()) {
180
- await fsp.copyFile(from, to);
181
- }
182
- }
183
- }
184
- async function installSkill(skill) {
185
- const source = path.join(ROOT, 'skills', skill);
186
- const target = path.join(DEST, skill);
187
- const references = path.join(ROOT, 'references');
188
- if (!await exists(path.join(source, 'SKILL.md'))) {
189
- throw new Error(`missing skill source: ${source}`);
190
- }
191
- await rmrf(target);
192
- await fsp.mkdir(target, { recursive: true });
193
- await fsp.copyFile(path.join(source, 'SKILL.md'), path.join(target, 'SKILL.md'));
194
- await copyDir(references, path.join(target, 'references'));
195
- }
196
172
  async function install() {
197
- await fsp.mkdir(DEST, { recursive: true });
198
- for (const skill of SKILLS) {
199
- await installSkill(skill);
200
- }
201
- for (const legacySkill of LEGACY_SKILLS) {
202
- await rmrf(path.join(DEST, legacySkill));
203
- }
204
- for (const retiredSkill of RETIRED_SKILLS) {
205
- await rmrf(path.join(DEST, retiredSkill));
206
- }
207
- if (CODEX_MIRROR !== DEST && fs.existsSync(CODEX_MIRROR)) {
208
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
209
- await rmrf(path.join(CODEX_MIRROR, skill));
210
- }
211
- }
173
+ const result = await installSkillSetTransactional({
174
+ sourceRoot: ROOT,
175
+ destination: DEST,
176
+ codexMirror: CODEX_MIRROR,
177
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
178
+ version: VERSION,
179
+ skills: SKILLS,
180
+ legacySkills: LEGACY_SKILLS,
181
+ retiredSkills: RETIRED_SKILLS
182
+ });
212
183
  console.log(`yam installed to ${DEST}`);
184
+ console.log(`install receipt: ${result.receiptPath}`);
185
+ console.log(`integrity: ${result.installedFiles} files, sha256:${result.sourceDigest}`);
186
+ for (const warning of result.cleanupWarnings)
187
+ console.warn(`warning: ${warning}`);
213
188
  console.log('Restart Codex to reload skills.');
214
189
  }
215
190
  async function uninstall() {
191
+ const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
192
+ if (await exists(lockPath)) {
193
+ throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
194
+ }
216
195
  for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
217
196
  await rmrf(path.join(DEST, skill));
218
197
  if (CODEX_MIRROR !== DEST) {
219
198
  await rmrf(path.join(CODEX_MIRROR, skill));
220
199
  }
221
200
  }
201
+ await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
222
202
  console.log(`yam removed from ${DEST}`);
223
203
  if (CODEX_MIRROR !== DEST)
224
204
  console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
225
205
  console.log('Restart Codex to unload skills.');
226
206
  }
227
207
  async function status({ quiet = false } = {}) {
228
- let missing = 0;
229
- for (const skill of SKILLS) {
230
- const ok = await exists(path.join(DEST, skill, 'SKILL.md')) && await exists(path.join(DEST, skill, 'references'));
231
- if (!quiet)
232
- console.log(`${ok ? 'ok ' : 'missing'} ${skill}`);
233
- if (!ok)
234
- missing += 1;
208
+ const report = await inspectSkillInstallation({
209
+ sourceRoot: ROOT,
210
+ destination: DEST,
211
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
212
+ version: VERSION,
213
+ skills: SKILLS
214
+ });
215
+ if (!quiet) {
216
+ for (const skill of report.skills) {
217
+ const label = skill.status === 'ok' ? 'ok ' : skill.status === 'missing' ? 'missing' : 'drift ';
218
+ console.log(`${label} ${skill.skill}`);
219
+ }
220
+ if (report.receiptStatus === 'ok' && report.receipt) {
221
+ console.log(`ok install receipt ${report.receipt.package.name}@${report.receipt.package.version}`);
222
+ console.log(` sha256:${report.receipt.integrity.source_digest}`);
223
+ }
224
+ else {
225
+ console.log(`${report.receiptStatus === 'missing' ? 'missing' : 'drift '} install receipt`);
226
+ }
227
+ if (report.issues.length) {
228
+ console.log('');
229
+ console.log('Install integrity issues:');
230
+ for (const issue of report.issues.slice(0, 12))
231
+ console.log(`- ${issue}`);
232
+ if (report.issues.length > 12)
233
+ console.log(`- ... ${report.issues.length - 12} more issue(s)`);
234
+ if (report.recoveryArtifacts.length) {
235
+ console.log('Confirm no install is running, then inspect and preserve the transaction backup before retrying.');
236
+ }
237
+ else {
238
+ console.log('Run `yam install` to restore the package-bundled skill set.');
239
+ }
240
+ }
235
241
  }
236
- return missing;
242
+ return report.ok ? 0 : Math.max(1, report.issues.length);
237
243
  }
238
244
  async function list() {
239
245
  const manifest = await loadManifest();
@@ -279,6 +285,8 @@ async function verify({ quiet = false } = {}) {
279
285
  return failOrReport('yam verify', issues, quiet);
280
286
  if (manifest.name !== 'yam')
281
287
  issues.push('manifest name must be yam');
288
+ if (manifest.version !== VERSION)
289
+ issues.push(`manifest version must match package version: ${manifest.version || 'missing'} != ${VERSION}`);
282
290
  if (manifest.defaultHooks !== false)
283
291
  issues.push('manifest defaultHooks must be false');
284
292
  if (!Array.isArray(manifest.routes) || manifest.routes.length !== SKILLS.length) {
@@ -743,16 +751,27 @@ Notes:
743
751
  async function studyNoteCheck(args = []) {
744
752
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
745
753
  const dir = path.resolve(firstPositional(args) || process.cwd());
746
- const changedFiles = await gitChangedFiles(dir);
747
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);
748
763
  const needsStudyNote = changedFiles.length > 0;
749
764
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
750
765
  const checks = [
751
766
  studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
752
767
  studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
753
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'),
754
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'),
755
- 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')
756
775
  ];
757
776
  for (const requirement of hygieneRequirements) {
758
777
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -764,15 +783,13 @@ async function studyNoteCheck(args = []) {
764
783
  project: dir,
765
784
  changed_files: changedFiles,
766
785
  changed_file_count: changedFiles.length,
767
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
786
+ report_source: reportSource,
768
787
  checks,
769
788
  blockers,
770
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'),
771
790
  truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
772
791
  };
773
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
774
- if (blockers.length)
775
- process.exitCode = 1;
792
+ return result;
776
793
  }
777
794
  function firstPositional(args = []) {
778
795
  for (let index = 0; index < args.length; index += 1) {
@@ -1010,6 +1027,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1010
1027
  const codexHome = path.join(os.homedir(), '.codex');
1011
1028
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1012
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');
1013
1032
  const tmux = await findExecutable('tmux');
1014
1033
  const packageInfo = await projectPackageInfo(dir);
1015
1034
  const pack = await findProjectPack(dir);
@@ -1028,7 +1047,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1028
1047
  const rows = [
1029
1048
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1030
1049
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1031
- 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'),
1032
1052
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1033
1053
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1034
1054
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1406,7 +1426,8 @@ Usage:
1406
1426
  yam hook run <lite|study-note>
1407
1427
 
1408
1428
  Notes:
1409
- 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.
1410
1431
  `);
1411
1432
  }
1412
1433
  function parseHookArgs(args = []) {
@@ -1437,10 +1458,10 @@ function hookPathFor(parsed) {
1437
1458
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
1438
1459
  }
1439
1460
  function isYamLiteHook(handler = {}) {
1440
- 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 || ''));
1441
1462
  }
1442
1463
  function isYamStudyNoteHook(handler = {}) {
1443
- 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 || ''));
1444
1465
  }
1445
1466
  function isYamHookProfile(handler = {}, profile = 'lite') {
1446
1467
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
@@ -1448,7 +1469,9 @@ function isYamHookProfile(handler = {}, profile = 'lite') {
1448
1469
  function stripYamHooks(config = {}, profile = 'lite') {
1449
1470
  const next = { ...config };
1450
1471
  for (const event of Object.keys(next)) {
1451
- const entries = Array.isArray(next[event]) ? next[event] : [];
1472
+ if (!Array.isArray(next[event]))
1473
+ continue;
1474
+ const entries = next[event];
1452
1475
  const keptEntries = [];
1453
1476
  for (const entry of entries) {
1454
1477
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1465,27 +1488,42 @@ function stripYamHooks(config = {}, profile = 'lite') {
1465
1488
  }
1466
1489
  function withYamHook(config = {}, profile = 'lite') {
1467
1490
  const next = stripYamHooks(config, profile);
1468
- const event = 'UserPromptSubmit';
1469
- const entry = {
1470
- hooks: [
1471
- {
1472
- type: 'command',
1473
- command: profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND,
1474
- timeout: 5
1475
- }
1476
- ]
1477
- };
1478
- 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
+ }
1479
1504
  return next;
1480
1505
  }
1481
1506
  async function hookStatus(args = []) {
1482
1507
  const parsed = parseHookArgs(args);
1483
1508
  const target = hookPathFor(parsed);
1484
- const config = await readJsonOrDefault(target, {});
1485
- console.log(`yam-lite hook: ${hookConfigHasProfile(config, 'lite') ? 'enabled' : 'disabled'}`);
1486
- 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);
1487
1523
  console.log(`scope: ${parsed.mode}`);
1488
1524
  console.log(`file: ${target}`);
1525
+ if (lite.state === 'broken' || studyNote.state === 'broken')
1526
+ process.exitCode = 1;
1489
1527
  }
1490
1528
  function hookConfigHasYamLite(config = {}) {
1491
1529
  return hookConfigHasProfile(config, 'lite');
@@ -1495,6 +1533,169 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1495
1533
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
1496
1534
  }));
1497
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
+ }
1498
1699
  async function hookEnable(args = []) {
1499
1700
  const parsed = parseHookArgs(args);
1500
1701
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1503,22 +1704,43 @@ async function hookEnable(args = []) {
1503
1704
  return;
1504
1705
  }
1505
1706
  const target = hookPathFor(parsed);
1506
- 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));
1507
1720
  const next = withYamHook(current, parsed.profile);
1508
1721
  await fsp.mkdir(path.dirname(target), { recursive: true });
1509
- if (await exists(target)) {
1722
+ if (loaded.exists) {
1510
1723
  const backup = `${target}.yam-backup-${timestampId()}`;
1511
1724
  await fsp.copyFile(target, backup);
1512
1725
  console.log(`backup: ${backup}`);
1513
1726
  }
1514
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1727
+ await writeHookConfig(target, next);
1515
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`);
1516
1731
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1517
1732
  }
1518
1733
  async function hookDisable(args = []) {
1519
1734
  const parsed = parseHookArgs(args);
1520
1735
  const target = hookPathFor(parsed);
1521
- 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;
1522
1744
  if (!hookConfigHasProfile(current, parsed.profile)) {
1523
1745
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1524
1746
  return;
@@ -1528,7 +1750,7 @@ async function hookDisable(args = []) {
1528
1750
  await rmrf(target);
1529
1751
  }
1530
1752
  else {
1531
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1753
+ await writeHookConfig(target, next);
1532
1754
  }
1533
1755
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1534
1756
  }
@@ -1541,6 +1763,10 @@ async function hookRun(args = []) {
1541
1763
  const input = await readStdinJson();
1542
1764
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1543
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
+ }
1544
1770
  const prompt = extractPrompt(input);
1545
1771
  const additionalContext = profile === 'study-note'
1546
1772
  ? await buildStudyNoteHookContext({ cwd })
@@ -1610,6 +1836,7 @@ async function buildStudyNoteHookContext({ cwd }) {
1610
1836
  ];
1611
1837
  if (!changedFiles.length) {
1612
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.');
1613
1840
  return lines.join('\n');
1614
1841
  }
1615
1842
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1619,9 +1846,30 @@ async function buildStudyNoteHookContext({ cwd }) {
1619
1846
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1620
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.');
1621
1848
  }
1622
- 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.');
1623
1850
  return lines.join('\n');
1624
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
+ }
1625
1873
  function yamLiteRouteHint(prompt = '') {
1626
1874
  const text = String(prompt || '').toLowerCase();
1627
1875
  if (!text)
@@ -0,0 +1,76 @@
1
+ export declare const INSTALL_RECEIPT_NAME = ".yam-flow-install-receipt.json";
2
+ export declare const INSTALL_LOCK_NAME = ".yam-flow-install.lock";
3
+ export declare const INSTALL_RECEIPT_SCHEMA = "yam.install-receipt.v1";
4
+ export declare const INSTALL_TRANSACTION_PREFIX = ".yam-flow-install-";
5
+ export type InstallFileDigest = {
6
+ path: string;
7
+ bytes: number;
8
+ sha256: string;
9
+ };
10
+ export type InstallReceipt = {
11
+ schema: typeof INSTALL_RECEIPT_SCHEMA;
12
+ package: {
13
+ name: string;
14
+ version: string;
15
+ };
16
+ source: {
17
+ kind: 'package-bundled-skills';
18
+ identity: string;
19
+ };
20
+ destination: string;
21
+ installed_at: string;
22
+ transaction_id: string;
23
+ skills: string[];
24
+ integrity: {
25
+ algorithm: 'sha256';
26
+ file_count: number;
27
+ source_digest: string;
28
+ files: InstallFileDigest[];
29
+ };
30
+ };
31
+ export type InstallFailpointEvent = {
32
+ phase: 'after-stage' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
33
+ skill?: string;
34
+ };
35
+ export type TransactionalInstallOptions = {
36
+ sourceRoot: string;
37
+ destination: string;
38
+ codexMirror?: string;
39
+ packageName: string;
40
+ version: string;
41
+ skills: string[];
42
+ legacySkills?: string[];
43
+ retiredSkills?: string[];
44
+ now?: () => Date;
45
+ transactionId?: string;
46
+ failpoint?: (event: InstallFailpointEvent) => void | Promise<void>;
47
+ };
48
+ export type InstallationInspectionOptions = Pick<TransactionalInstallOptions, 'sourceRoot' | 'destination' | 'packageName' | 'version' | 'skills'> & {
49
+ ignoreRecoveryArtifacts?: boolean;
50
+ };
51
+ export type SkillInstallStatus = {
52
+ skill: string;
53
+ status: 'ok' | 'missing' | 'drift';
54
+ issues: string[];
55
+ };
56
+ export type InstallationInspection = {
57
+ ok: boolean;
58
+ destination: string;
59
+ receiptPath: string;
60
+ receiptStatus: 'ok' | 'missing' | 'drift';
61
+ receipt?: InstallReceipt;
62
+ sourceDigest?: string;
63
+ recoveryArtifacts: string[];
64
+ skills: SkillInstallStatus[];
65
+ issues: string[];
66
+ };
67
+ export type TransactionalInstallResult = {
68
+ receipt: InstallReceipt;
69
+ receiptPath: string;
70
+ installedFiles: number;
71
+ sourceDigest: string;
72
+ cleanupWarnings: string[];
73
+ };
74
+ export declare function findInstallRecoveryArtifacts(root: string): Promise<string[]>;
75
+ export declare function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection>;
76
+ export declare function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult>;