yam-flow 2.3.1 → 2.4.0

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
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
3
4
  import fs from 'node:fs';
4
5
  import fsp from 'node:fs/promises';
5
6
  import { createRequire } from 'node:module';
@@ -34,10 +35,9 @@ import {
34
35
  verifyUeyeRevisionHistory
35
36
  } from '../lib/ueye-artifacts.js';
36
37
  import {
37
- INSTALL_LOCK_NAME,
38
- INSTALL_RECEIPT_NAME,
39
38
  inspectSkillInstallation,
40
- installSkillSetTransactional
39
+ installSkillSetTransactional,
40
+ uninstallSkillSetSafely
41
41
  } from '../lib/skill-installation.js';
42
42
 
43
43
  type AnyRecord = Record<string, any>;
@@ -84,8 +84,16 @@ 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 INSTRUCTION_DUPLICATION_LIMITS = {
88
+ maxFiles: 24,
89
+ maxBytesPerFile: 128000,
90
+ maxGroups: 8,
91
+ maxDepth: 6,
92
+ minDirectiveChars: 40
93
+ };
94
+ const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
95
+ const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
96
+ const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
89
97
  const REQUIRED_PACK_SECTIONS = [
90
98
  'Product Direction',
91
99
  'UI Direction',
@@ -150,7 +158,7 @@ Usage:
150
158
  yam list
151
159
  yam status
152
160
  yam verify
153
- yam detect [dir]
161
+ yam detect [dir] [--json]
154
162
  yam pack [dir]
155
163
  yam context pressure [dir] [--json]
156
164
  yam cleanup scan [dir] [--json]
@@ -179,7 +187,7 @@ Usage:
179
187
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
180
188
  yam template <project|ueye|mission|proof|tuning>
181
189
  yam tune-log [dir]
182
- yam install
190
+ yam install [--replace-user-skill name]
183
191
  yam uninstall
184
192
  yam doctor [--json]
185
193
  yam examples
@@ -205,7 +213,32 @@ async function rmrf(target) {
205
213
  await fsp.rm(target, { recursive: true, force: true });
206
214
  }
207
215
 
208
- async function install() {
216
+ function installReplacementSkills(args: string[] = []) {
217
+ const skills: string[] = [];
218
+ for (let index = 0; index < args.length; index += 1) {
219
+ const arg = String(args[index] || '');
220
+ if (arg === '--replace-user-skill') {
221
+ const skill = String(args[index + 1] || '');
222
+ if (!skill || skill.startsWith('--')) {
223
+ throw new Error('missing skill name after --replace-user-skill');
224
+ }
225
+ skills.push(skill);
226
+ index += 1;
227
+ continue;
228
+ }
229
+ if (arg.startsWith('--replace-user-skill=')) {
230
+ const skill = arg.slice('--replace-user-skill='.length);
231
+ if (!skill) throw new Error('missing skill name after --replace-user-skill=');
232
+ skills.push(skill);
233
+ continue;
234
+ }
235
+ throw new Error(`unknown yam install argument: ${arg}`);
236
+ }
237
+ return [...new Set(skills)];
238
+ }
239
+
240
+ async function install(args: string[] = []) {
241
+ const replaceSkills = installReplacementSkills(args);
209
242
  const result = await installSkillSetTransactional({
210
243
  sourceRoot: ROOT,
211
244
  destination: DEST,
@@ -213,6 +246,7 @@ async function install() {
213
246
  packageName: String(PACKAGE_JSON.name || 'yam-flow'),
214
247
  version: VERSION,
215
248
  skills: SKILLS,
249
+ replaceSkills,
216
250
  legacySkills: LEGACY_SKILLS,
217
251
  retiredSkills: RETIRED_SKILLS
218
252
  });
@@ -223,20 +257,20 @@ async function install() {
223
257
  console.log('Restart Codex to reload skills.');
224
258
  }
225
259
 
226
- async function uninstall() {
227
- const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
228
- if (await exists(lockPath)) {
229
- throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
230
- }
231
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
232
- await rmrf(path.join(DEST, skill));
233
- if (CODEX_MIRROR !== DEST) {
234
- await rmrf(path.join(CODEX_MIRROR, skill));
235
- }
236
- }
237
- await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
260
+ async function uninstall(args: string[] = []) {
261
+ if (args.length) throw new Error(`unknown yam uninstall argument: ${args[0]}`);
262
+ const result = await uninstallSkillSetSafely({
263
+ destination: DEST,
264
+ codexMirror: CODEX_MIRROR,
265
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
266
+ skills: SKILLS
267
+ });
238
268
  console.log(`yam removed from ${DEST}`);
239
- if (CODEX_MIRROR !== DEST) console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
269
+ console.log(`removed receipt: ${result.receiptPath}`);
270
+ for (const warning of result.cleanupWarnings) console.warn(`warning: ${warning}`);
271
+ if (CODEX_MIRROR !== DEST) {
272
+ console.log(`unowned legacy, retired, and Codex mirror entries were preserved`);
273
+ }
240
274
  console.log('Restart Codex to unload skills.');
241
275
  }
242
276
 
@@ -425,12 +459,20 @@ async function doctor(args = []) {
425
459
  console.log('yam doctor: ok');
426
460
  console.log('No hooks, automations, or global config are required.');
427
461
  console.log('Skill install state is reported by `yam status`.');
462
+ if (report.preservedUnprovenSkillEntries.length) {
463
+ console.log(`Preserved unproven legacy/retired/mirror entries: ${report.preservedUnprovenSkillEntries.length}`);
464
+ }
428
465
  console.log(`yam-lite hook: ${report.yamLiteHook} (optional)`);
429
466
  return;
430
467
  }
431
468
 
432
469
  console.log('yam doctor: issues');
433
470
  for (const issue of report.issues) console.log(`- ${issue}`);
471
+ if (report.preservedUnprovenSkillEntries.length) {
472
+ console.log('');
473
+ console.log('Preserved unproven skill entries:');
474
+ for (const entry of report.preservedUnprovenSkillEntries) console.log(`- ${entry}`);
475
+ }
434
476
  if (report.nextActions.length) {
435
477
  console.log('');
436
478
  console.log('Next actions:');
@@ -441,6 +483,7 @@ async function doctor(args = []) {
441
483
 
442
484
  async function buildDoctorReport() {
443
485
  const issues = [];
486
+ const preservedUnprovenSkillEntries = [];
444
487
  for (const skill of SKILLS) {
445
488
  if (!await exists(path.join(ROOT, 'skills', skill, 'SKILL.md'))) issues.push(`missing source skill: ${skill}`);
446
489
  }
@@ -449,15 +492,21 @@ async function buildDoctorReport() {
449
492
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'yam'))) issues.push('unexpected yam automation');
450
493
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'timeto'))) issues.push('unexpected legacy timeto automation');
451
494
  for (const legacySkill of LEGACY_SKILLS) {
452
- if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) issues.push(`unexpected legacy installed skill: ${legacySkill}`);
453
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, legacySkill, 'SKILL.md'))) {
454
- issues.push(`unexpected legacy mirror skill: ${legacySkill}`);
495
+ if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) {
496
+ preservedUnprovenSkillEntries.push(`legacy-named destination entry: ${legacySkill}`);
455
497
  }
456
498
  }
457
499
  for (const retiredSkill of RETIRED_SKILLS) {
458
- if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) issues.push(`unexpected retired installed skill: ${retiredSkill}`);
459
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, retiredSkill, 'SKILL.md'))) {
460
- issues.push(`unexpected retired mirror skill: ${retiredSkill}`);
500
+ if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) {
501
+ preservedUnprovenSkillEntries.push(`retired-name destination entry: ${retiredSkill}`);
502
+ }
503
+ }
504
+ if (CODEX_MIRROR !== DEST) {
505
+ const mirrorSkillNames = [...new Set([...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS])];
506
+ for (const skill of mirrorSkillNames) {
507
+ if (!await exists(path.join(CODEX_MIRROR, skill, 'SKILL.md'))) continue;
508
+ const nameKind = SKILLS.includes(skill) ? 'active-name' : LEGACY_SKILLS.includes(skill) ? 'legacy-named' : 'retired-name';
509
+ preservedUnprovenSkillEntries.push(`${nameKind} Codex mirror entry: ${skill}`);
461
510
  }
462
511
  }
463
512
  const verifyIssues = await verify({ quiet: true });
@@ -469,6 +518,7 @@ async function buildDoctorReport() {
469
518
  generatedAt: new Date().toISOString(),
470
519
  ok: issues.length === 0,
471
520
  issues,
521
+ preservedUnprovenSkillEntries,
472
522
  nextActions: nextActionDetails.map((action) => action.next_action),
473
523
  nextActionDetails,
474
524
  yamLiteHook: hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled'
@@ -486,7 +536,6 @@ function doctorNextActionDetails(issues = []) {
486
536
  else if (/missing truth matrix/i.test(issue)) actions.push(nextActionDetail('restore-truth-matrix', 'warning', issue, 'restore references/truth-matrix.md before publishing', 'git checkout -- references/truth-matrix.md'));
487
537
  else if (/unexpected local hooks/i.test(issue)) actions.push(nextActionDetail('remove-project-hook', 'warning', issue, 'remove project hooks unless this repo is intentionally dogfooding them', 'inspect .codex/hooks.json and remove only intentional stale hooks'));
488
538
  else if (/unexpected .*automation/i.test(issue)) actions.push(nextActionDetail('remove-stale-automation', 'warning', issue, 'remove stale yam/timeto automations before claiming clean install state', 'inspect ~/.codex/automations and remove stale entries manually'));
489
- else if (/legacy|retired/i.test(issue)) actions.push(nextActionDetail('replace-old-skills', 'warning', issue, 'run `yam install` to replace old skill entries', 'yam install'));
490
539
  else if (/verify reported/i.test(issue)) actions.push(nextActionDetail('run-verify', 'error', issue, 'run `npm run verify` and fix the reported package boundary or metadata issue', 'npm run verify'));
491
540
  }
492
541
  return uniqueNextActionDetails(actions);
@@ -756,16 +805,36 @@ Notes:
756
805
  async function studyNoteCheck(args = []) {
757
806
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
758
807
  const dir = path.resolve(firstPositional(args) || process.cwd());
759
- const changedFiles = await gitChangedFiles(dir);
760
808
  const reportText = await readStudyNoteReportText(flags, dir);
761
- const needsStudyNote = changedFiles.length > 0;
809
+ const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
810
+ const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
811
+ printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
812
+ if (result.blockers.length) process.exitCode = 1;
813
+ }
814
+
815
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
816
+ const changedFileDetection = gitChangedFileSnapshot(dir);
817
+ const changedFiles = changedFileDetection.files;
818
+ const changeScopeAvailable = changedFileDetection.available;
819
+ const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
820
+ const scopeAwareStatus = (passes) => changeScopeAvailable ? (!needsStudyNote || passes ? 'pass' : 'fail') : 'partial';
762
821
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
763
822
  const checks = [
764
- studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
765
- studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
766
- 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'),
767
- 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')
823
+ studyNoteGuardCheck(
824
+ 'changed_files',
825
+ changeScopeAvailable ? (needsStudyNote ? 'pass' : 'skipped') : 'partial',
826
+ changeScopeAvailable
827
+ ? (needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected')
828
+ : 'Git changed-file scope is unavailable; do not treat this project as clean and determine Study Note need manually'
829
+ ),
830
+ studyNoteGuardCheck('study_note_present', scopeAwareStatus(hasStudyNoteMarker(reportText)), 'final report should include a Study Note when project artifacts changed'),
831
+ studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText)), 'Study Note should explain what the touched code/artifact does'),
832
+ studyNoteGuardCheck('execution_point', scopeAwareStatus(/(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText)), 'Study Note should explain where or when the touched code/artifact runs or is read'),
833
+ studyNoteGuardCheck('before_after_or_change', scopeAwareStatus(/(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText)), 'Study Note should explain what changed from before to after'),
834
+ studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText)), 'Study Note should describe the expected behavior or result'),
835
+ studyNoteGuardCheck('syntax_or_structure', scopeAwareStatus(/(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText)), 'Study Note should include one useful syntax or structure insight'),
836
+ studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText)), 'Study Note should say what was checked'),
837
+ studyNoteGuardCheck('limits_or_uncertainty', scopeAwareStatus(/(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText)), 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
769
838
  ];
770
839
  for (const requirement of hygieneRequirements) {
771
840
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -775,16 +844,20 @@ async function studyNoteCheck(args = []) {
775
844
  schema: 'yam.study-note-guard.v1',
776
845
  generated_at: new Date().toISOString(),
777
846
  project: dir,
847
+ changed_file_detection: changedFileDetection,
778
848
  changed_files: changedFiles,
779
849
  changed_file_count: changedFiles.length,
780
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
850
+ report_source: reportSource,
781
851
  checks,
782
852
  blockers,
783
- 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
- truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
853
+ next_action: blockers[0] || (!changeScopeAvailable
854
+ ? 'Git change scope is unavailable; inspect changed artifacts manually and include a Study Note when anything changed'
855
+ : needsStudyNote
856
+ ? 'Study Note guard passed for the supplied report text'
857
+ : 'no Study Note required because no changed files were detected'),
858
+ truth_status: blockers.length ? 'blocked' : !changeScopeAvailable ? 'partial' : needsStudyNote ? 'verified' : 'skipped'
785
859
  };
786
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
787
- if (blockers.length) process.exitCode = 1;
860
+ return result;
788
861
  }
789
862
 
790
863
  function firstPositional(args = []) {
@@ -800,14 +873,35 @@ function firstPositional(args = []) {
800
873
  return '';
801
874
  }
802
875
 
803
- async function gitChangedFiles(dir) {
804
- const output = runReadOnlyCommandIn(dir, 'git', ['status', '--short']);
805
- return output
806
- .split(/\r?\n/)
807
- .filter(Boolean)
808
- .map((line) => line.replace(/^..\s+/, '').replace(/^.* -> /, ''))
809
- .map((line) => line.trim())
810
- .filter((file) => file && !file.startsWith('dist/') && !file.endsWith('.tgz'));
876
+ function gitChangedFileSnapshot(dir) {
877
+ const result = spawnSync('git', ['status', '--short', '-z', '--untracked-files=all'], {
878
+ cwd: dir,
879
+ encoding: 'utf8',
880
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
881
+ timeout: 30000
882
+ });
883
+ if (result.status !== 0) {
884
+ return {
885
+ available: false,
886
+ source: 'git status --short -z --untracked-files=all',
887
+ files: []
888
+ };
889
+ }
890
+ const records = String(result.stdout || '').split('\0');
891
+ const files = [];
892
+ for (let index = 0; index < records.length; index += 1) {
893
+ const record = records[index];
894
+ if (!record || record.length < 4) continue;
895
+ const status = record.slice(0, 2);
896
+ const file = record.slice(3);
897
+ if (/[RC]/.test(status)) index += 1;
898
+ if (file && !file.startsWith('dist/') && !file.endsWith('.tgz')) files.push(file);
899
+ }
900
+ return {
901
+ available: true,
902
+ source: 'git status --short -z --untracked-files=all',
903
+ files
904
+ };
811
905
  }
812
906
 
813
907
  async function readStudyNoteReportText(flags, dir) {
@@ -900,12 +994,22 @@ async function buildCleanupScanReport(targetDir = process.cwd()) {
900
994
  for (const artifact of await staleYamArtifacts(dir)) {
901
995
  findings.push(cleanupFinding('low', artifact.file, artifact.reason, 'archive or refresh only if it misleads the current work'));
902
996
  }
997
+ const instructionDuplicationBudget = await buildInstructionDuplicationBudget(dir);
998
+ if (instructionDuplicationBudget.duplicate_group_count > 0) {
999
+ findings.push(cleanupFinding(
1000
+ 'low',
1001
+ 'AGENTS.md/SKILL.md directive duplication budget',
1002
+ `${instructionDuplicationBudget.duplicate_group_count} normalized directive group(s) repeat across separate instruction surfaces; exact normalized matches are advisory and may be intentional`,
1003
+ 'review the reported source files and consolidate only repetition that causes drift or conflicting maintenance'
1004
+ ));
1005
+ }
903
1006
  return {
904
1007
  schema: 'yam.cleanup-scan.v1',
905
1008
  generated_at: new Date().toISOString(),
906
1009
  project: dir,
907
1010
  destructive: false,
908
1011
  findings,
1012
+ instruction_duplication_budget: instructionDuplicationBudget,
909
1013
  risk_level: overallCleanupRisk(findings),
910
1014
  safe_next_action: findings.length ? 'review findings and make explicit user-approved cleanup decisions' : 'no cleanup action needed',
911
1015
  truth_status: 'partial'
@@ -959,6 +1063,163 @@ async function staleYamArtifacts(dir) {
959
1063
  return artifacts;
960
1064
  }
961
1065
 
1066
+ async function buildInstructionDuplicationBudget(
1067
+ rootDir = process.cwd(),
1068
+ limits = INSTRUCTION_DUPLICATION_LIMITS
1069
+ ) {
1070
+ const root = path.resolve(rootDir || process.cwd());
1071
+ const inventory = await collectInstructionFiles(root, limits);
1072
+ const directives = new Map<string, AnyRecord[]>();
1073
+ let candidateDirectiveCount = 0;
1074
+ for (const relativeFile of inventory.files) {
1075
+ const absolute = path.join(root, relativeFile);
1076
+ const text = await readBoundedUtf8File(absolute, limits.maxBytesPerFile);
1077
+ for (const directive of instructionDirectives(text, relativeFile, limits.minDirectiveChars)) {
1078
+ candidateDirectiveCount += 1;
1079
+ directives.set(directive.normalized, [...(directives.get(directive.normalized) || []), directive]);
1080
+ }
1081
+ }
1082
+
1083
+ const allGroups = [...directives.entries()]
1084
+ .map(([normalized, occurrences]) => {
1085
+ const surfaces = [...new Set(occurrences.map((item) => item.file))];
1086
+ if (surfaces.length < 2) return null;
1087
+ return {
1088
+ directive_digest: createHash('sha256').update(normalized).digest('hex').slice(0, 16),
1089
+ directive_preview: redactSensitiveText(String(occurrences[0]?.text || '')).slice(0, 180),
1090
+ occurrence_count: occurrences.length,
1091
+ surface_count: surfaces.length,
1092
+ surfaces,
1093
+ occurrences: occurrences.slice(0, 8).map((item) => ({
1094
+ file: item.file,
1095
+ line: item.line
1096
+ }))
1097
+ };
1098
+ })
1099
+ .filter(Boolean)
1100
+ .sort((left, right) => (
1101
+ right.surface_count - left.surface_count
1102
+ || right.occurrence_count - left.occurrence_count
1103
+ || left.directive_digest.localeCompare(right.directive_digest)
1104
+ ));
1105
+ const duplicateGroups = allGroups.slice(0, limits.maxGroups);
1106
+ return {
1107
+ schema: 'yam.instruction-duplication-budget.v1',
1108
+ scope: 'project AGENTS.md and SKILL.md files only',
1109
+ advisory_only: true,
1110
+ hard_gate: false,
1111
+ destructive: false,
1112
+ normalization: 'exact normalized markdown directive text; no semantic similarity inference',
1113
+ limits: {
1114
+ max_files: limits.maxFiles,
1115
+ max_bytes_per_file: limits.maxBytesPerFile,
1116
+ max_groups: limits.maxGroups,
1117
+ max_depth: limits.maxDepth,
1118
+ min_directive_chars: limits.minDirectiveChars
1119
+ },
1120
+ files_scanned: inventory.files.length,
1121
+ scanned_files: inventory.files,
1122
+ candidate_directive_count: candidateDirectiveCount,
1123
+ duplicate_group_count: allGroups.length,
1124
+ duplicate_groups: duplicateGroups,
1125
+ file_limit_reached: inventory.fileLimitReached,
1126
+ group_limit_reached: allGroups.length > duplicateGroups.length,
1127
+ truth_status: 'partial',
1128
+ note: allGroups.length
1129
+ ? 'repetition may be intentional; review before consolidating'
1130
+ : 'no cross-surface exact normalized directive repetition found within the bounded scan'
1131
+ };
1132
+ }
1133
+
1134
+ async function collectInstructionFiles(root, limits) {
1135
+ const files: string[] = [];
1136
+ let fileLimitReached = false;
1137
+
1138
+ async function walk(current, depth) {
1139
+ if (depth > limits.maxDepth || fileLimitReached) return;
1140
+ let entries = [];
1141
+ try {
1142
+ entries = await fsp.readdir(current, { withFileTypes: true });
1143
+ } catch {
1144
+ return;
1145
+ }
1146
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1147
+ for (const entry of entries) {
1148
+ const absolute = path.join(current, entry.name);
1149
+ const relative = path.relative(root, absolute);
1150
+ if (shouldSkipScanPath(relative, entry.name) || entry.isSymbolicLink()) continue;
1151
+ if (entry.isDirectory()) {
1152
+ await walk(absolute, depth + 1);
1153
+ if (fileLimitReached) return;
1154
+ continue;
1155
+ }
1156
+ if (!entry.isFile() || !['AGENTS.md', 'SKILL.md'].includes(entry.name)) continue;
1157
+ if (files.length >= limits.maxFiles) {
1158
+ fileLimitReached = true;
1159
+ return;
1160
+ }
1161
+ files.push(relative);
1162
+ }
1163
+ }
1164
+
1165
+ await walk(root, 0);
1166
+ return { files, fileLimitReached };
1167
+ }
1168
+
1169
+ async function readBoundedUtf8File(file, maxBytes) {
1170
+ const handle = await fsp.open(file, 'r');
1171
+ try {
1172
+ const buffer = Buffer.alloc(maxBytes);
1173
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1174
+ return buffer.subarray(0, bytesRead).toString('utf8');
1175
+ } finally {
1176
+ await handle.close();
1177
+ }
1178
+ }
1179
+
1180
+ function instructionDirectives(text = '', relativeFile = '', minChars = 40) {
1181
+ const directives = [];
1182
+ const lines = String(text || '').split(/\r?\n/);
1183
+ let inFence = false;
1184
+ let inFrontmatter = lines[0]?.trim() === '---';
1185
+ for (let index = 0; index < lines.length; index += 1) {
1186
+ const raw = lines[index];
1187
+ const trimmed = raw.trim();
1188
+ if (index === 0 && inFrontmatter) continue;
1189
+ if (inFrontmatter) {
1190
+ if (trimmed === '---') inFrontmatter = false;
1191
+ continue;
1192
+ }
1193
+ if (/^(```|~~~)/.test(trimmed)) {
1194
+ inFence = !inFence;
1195
+ continue;
1196
+ }
1197
+ if (inFence || !trimmed || /^#{1,6}\s/.test(trimmed)) continue;
1198
+ const match = trimmed.match(/^(?:[-*+]|\d+[.)])\s+(.+)$/);
1199
+ if (!match) continue;
1200
+ const normalized = normalizeInstructionDirective(match[1]);
1201
+ if (normalized.length < minChars) continue;
1202
+ directives.push({
1203
+ file: relativeFile,
1204
+ line: index + 1,
1205
+ text: match[1].trim().slice(0, 240),
1206
+ normalized
1207
+ });
1208
+ }
1209
+ return directives;
1210
+ }
1211
+
1212
+ function normalizeInstructionDirective(value = '') {
1213
+ return String(value || '')
1214
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1215
+ .replace(/[`*_~]/g, '')
1216
+ .replace(/<[^>]+>/g, ' ')
1217
+ .replace(/\s+/g, ' ')
1218
+ .replace(/[.;:,!?。,、;:!?]+$/g, '')
1219
+ .trim()
1220
+ .toLowerCase();
1221
+ }
1222
+
962
1223
  function printCleanupScanReport(report) {
963
1224
  console.log('yam cleanup scan');
964
1225
  console.log(`Project: ${report.project}`);
@@ -1028,6 +1289,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1028
1289
  const codexHome = path.join(os.homedir(), '.codex');
1029
1290
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1030
1291
  const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
1292
+ const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
1293
+ const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
1031
1294
  const tmux = await findExecutable('tmux');
1032
1295
  const packageInfo = await projectPackageInfo(dir);
1033
1296
  const pack = await findProjectPack(dir);
@@ -1046,7 +1309,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1046
1309
  const rows = [
1047
1310
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1048
1311
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1049
- readinessRow('yam-lite hook', hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled', 'optional UserPromptSubmit guide'),
1312
+ readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
1313
+ readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
1050
1314
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1051
1315
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1052
1316
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1420,7 +1684,8 @@ Usage:
1420
1684
  yam hook run <lite|study-note>
1421
1685
 
1422
1686
  Notes:
1423
- hooks are opt-in and advisory-only. They do not run tmux, subagents, or proof gates.
1687
+ hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
1688
+ Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
1424
1689
  `);
1425
1690
  }
1426
1691
 
@@ -1450,11 +1715,11 @@ function hookPathFor(parsed) {
1450
1715
  }
1451
1716
 
1452
1717
  function isYamLiteHook(handler: AnyRecord = {}) {
1453
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run lite');
1718
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
1454
1719
  }
1455
1720
 
1456
1721
  function isYamStudyNoteHook(handler: AnyRecord = {}) {
1457
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run study-note');
1722
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
1458
1723
  }
1459
1724
 
1460
1725
  function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
@@ -1464,7 +1729,8 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
1464
1729
  function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1465
1730
  const next = { ...config };
1466
1731
  for (const event of Object.keys(next)) {
1467
- const entries = Array.isArray(next[event]) ? next[event] : [];
1732
+ if (!Array.isArray(next[event])) continue;
1733
+ const entries = next[event];
1468
1734
  const keptEntries = [];
1469
1735
  for (const entry of entries) {
1470
1736
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1479,28 +1745,42 @@ function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
1479
1745
 
1480
1746
  function withYamHook(config: AnyRecord = {}, profile = 'lite') {
1481
1747
  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];
1748
+ const command = hookCommandForProfile(profile);
1749
+ for (const event of hookEventsForProfile(profile)) {
1750
+ const entry = {
1751
+ hooks: [
1752
+ {
1753
+ type: 'command',
1754
+ command,
1755
+ timeout: 5
1756
+ }
1757
+ ]
1758
+ };
1759
+ next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1760
+ }
1493
1761
  return next;
1494
1762
  }
1495
1763
 
1496
1764
  async function hookStatus(args = []) {
1497
1765
  const parsed = parseHookArgs(args);
1498
1766
  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'}`);
1767
+ const loaded = await readHookConfig(target);
1768
+ if (loaded.error) {
1769
+ console.log('yam-lite hook: broken');
1770
+ console.log('yam-study-note hook: broken');
1771
+ console.log(` - hook config unreadable: ${loaded.error}`);
1772
+ console.log(`scope: ${parsed.mode}`);
1773
+ console.log(`file: ${target}`);
1774
+ process.exitCode = 1;
1775
+ return;
1776
+ }
1777
+ const lite = await inspectHookProfile(loaded.config, 'lite');
1778
+ const studyNote = await inspectHookProfile(loaded.config, 'study-note');
1779
+ printHookProfileStatus('lite', lite);
1780
+ printHookProfileStatus('study-note', studyNote);
1502
1781
  console.log(`scope: ${parsed.mode}`);
1503
1782
  console.log(`file: ${target}`);
1783
+ if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
1504
1784
  }
1505
1785
 
1506
1786
  function hookConfigHasYamLite(config = {}) {
@@ -1513,6 +1793,160 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1513
1793
  }));
1514
1794
  }
1515
1795
 
1796
+ function hookCommandForProfile(profile = 'lite') {
1797
+ return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
1798
+ }
1799
+
1800
+ function hookEventsForProfile(profile = 'lite') {
1801
+ return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
1802
+ }
1803
+
1804
+ function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
1805
+ const matches = [];
1806
+ for (const [event, entries] of Object.entries(config)) {
1807
+ if (!Array.isArray(entries)) continue;
1808
+ for (const entry of entries) {
1809
+ if (!Array.isArray(entry?.hooks)) continue;
1810
+ for (const handler of entry.hooks) {
1811
+ if (isYamHookProfile(handler, profile)) matches.push({ event, handler });
1812
+ }
1813
+ }
1814
+ }
1815
+ return matches;
1816
+ }
1817
+
1818
+ function splitHookCommand(command = '') {
1819
+ const words = [];
1820
+ let current = '';
1821
+ let quote = '';
1822
+ let escaped = false;
1823
+ let started = false;
1824
+ for (const char of String(command)) {
1825
+ if (escaped) {
1826
+ current += char;
1827
+ escaped = false;
1828
+ started = true;
1829
+ continue;
1830
+ }
1831
+ if (char === '\\' && quote !== "'") {
1832
+ escaped = true;
1833
+ started = true;
1834
+ continue;
1835
+ }
1836
+ if (quote) {
1837
+ if (char === quote) quote = '';
1838
+ else current += char;
1839
+ started = true;
1840
+ continue;
1841
+ }
1842
+ if (char === '"' || char === "'") {
1843
+ quote = char;
1844
+ started = true;
1845
+ continue;
1846
+ }
1847
+ if (/\s/.test(char)) {
1848
+ if (started) {
1849
+ words.push(current);
1850
+ current = '';
1851
+ started = false;
1852
+ }
1853
+ continue;
1854
+ }
1855
+ current += char;
1856
+ started = true;
1857
+ }
1858
+ if (escaped || quote) return [];
1859
+ if (started) words.push(current);
1860
+ return words;
1861
+ }
1862
+
1863
+ function parseYamHookCommand(command = '', profile = 'lite') {
1864
+ const words = splitHookCommand(command);
1865
+ if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
1866
+ const nodeName = path.basename(words[0]).toLowerCase();
1867
+ if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
1868
+ return { executable: words[0], script: words[1] };
1869
+ }
1870
+ }
1871
+ if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
1872
+ const yamName = path.basename(words[0]).toLowerCase();
1873
+ if (/^yam(?:\.cmd|\.exe)?$/.test(yamName)) return { executable: words[0], script: '' };
1874
+ }
1875
+ return null;
1876
+ }
1877
+
1878
+ async function hookTargetExists(target = '', executable = false) {
1879
+ if (!target) return false;
1880
+ if (!path.isAbsolute(target)) return executable ? Boolean(await findExecutable(target)) : false;
1881
+ try {
1882
+ await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
1883
+ return true;
1884
+ } catch {
1885
+ return false;
1886
+ }
1887
+ }
1888
+
1889
+ async function inspectHookHandler(handler: AnyRecord = {}, profile = 'lite') {
1890
+ const parsed = parseYamHookCommand(String(handler.command || ''), profile);
1891
+ if (!parsed) return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
1892
+ if (!await hookTargetExists(parsed.executable, true)) {
1893
+ return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
1894
+ }
1895
+ if (parsed.script && !await hookTargetExists(parsed.script)) {
1896
+ return { ok: false, issue: `hook target is missing: ${parsed.script}` };
1897
+ }
1898
+ return { ok: true, issue: '' };
1899
+ }
1900
+
1901
+ async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
1902
+ const entries = hookProfileEntries(config, profile);
1903
+ if (!entries.length) return { state: 'disabled', issues: [], entries };
1904
+ const issues = [];
1905
+ for (const event of hookEventsForProfile(profile)) {
1906
+ const count = entries.filter((entry) => entry.event === event).length;
1907
+ if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
1908
+ if (count > 1) issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
1909
+ }
1910
+ for (const entry of entries) {
1911
+ if (!hookEventsForProfile(profile).includes(entry.event)) {
1912
+ issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
1913
+ }
1914
+ const health = await inspectHookHandler(entry.handler, profile);
1915
+ if (!health.ok) issues.push(`${entry.event}: ${health.issue}`);
1916
+ }
1917
+ return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
1918
+ }
1919
+
1920
+ function printHookProfileStatus(profile, health) {
1921
+ console.log(`yam-${profile} hook: ${health.state}`);
1922
+ for (const issue of health.issues) console.log(` - ${issue}`);
1923
+ }
1924
+
1925
+ async function readHookConfig(target) {
1926
+ try {
1927
+ const value = JSON.parse(await fsp.readFile(target, 'utf8'));
1928
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
1929
+ return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
1930
+ }
1931
+ return { config: value, exists: true, error: '' };
1932
+ } catch (error) {
1933
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
1934
+ return { config: {}, exists: true, error: errorMessage(error) };
1935
+ }
1936
+ }
1937
+
1938
+ async function writeHookConfig(target, config) {
1939
+ await fsp.mkdir(path.dirname(target), { recursive: true });
1940
+ const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
1941
+ try {
1942
+ await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1943
+ await fsp.rename(temporary, target);
1944
+ } catch (error) {
1945
+ await rmrf(temporary).catch(() => {});
1946
+ throw error;
1947
+ }
1948
+ }
1949
+
1516
1950
  async function hookEnable(args = []) {
1517
1951
  const parsed = parseHookArgs(args);
1518
1952
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1521,23 +1955,45 @@ async function hookEnable(args = []) {
1521
1955
  return;
1522
1956
  }
1523
1957
  const target = hookPathFor(parsed);
1524
- const current = await readJsonOrDefault(target, {});
1958
+ const loaded = await readHookConfig(target);
1959
+ if (loaded.error) {
1960
+ console.error(`Cannot update unreadable hook config: ${target}`);
1961
+ console.error(loaded.error);
1962
+ process.exitCode = 1;
1963
+ return;
1964
+ }
1965
+ const current = loaded.config;
1966
+ const existing = hookProfileEntries(current, parsed.profile);
1967
+ const expectedEvents = hookEventsForProfile(parsed.profile);
1968
+ const expectedCommand = hookCommandForProfile(parsed.profile);
1969
+ const needsMigration = existing.length > 0 && (
1970
+ existing.length !== expectedEvents.length
1971
+ || existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand)
1972
+ );
1525
1973
  const next = withYamHook(current, parsed.profile);
1526
1974
  await fsp.mkdir(path.dirname(target), { recursive: true });
1527
- if (await exists(target)) {
1975
+ if (loaded.exists) {
1528
1976
  const backup = `${target}.yam-backup-${timestampId()}`;
1529
1977
  await fsp.copyFile(target, backup);
1530
1978
  console.log(`backup: ${backup}`);
1531
1979
  }
1532
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1980
+ await writeHookConfig(target, next);
1533
1981
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
1982
+ if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
1534
1983
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1535
1984
  }
1536
1985
 
1537
1986
  async function hookDisable(args = []) {
1538
1987
  const parsed = parseHookArgs(args);
1539
1988
  const target = hookPathFor(parsed);
1540
- const current = await readJsonOrDefault(target, {});
1989
+ const loaded = await readHookConfig(target);
1990
+ if (loaded.error) {
1991
+ console.error(`Cannot update unreadable hook config: ${target}`);
1992
+ console.error(loaded.error);
1993
+ process.exitCode = 1;
1994
+ return;
1995
+ }
1996
+ const current = loaded.config;
1541
1997
  if (!hookConfigHasProfile(current, parsed.profile)) {
1542
1998
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1543
1999
  return;
@@ -1546,7 +2002,7 @@ async function hookDisable(args = []) {
1546
2002
  if (Object.keys(next).length === 0) {
1547
2003
  await rmrf(target);
1548
2004
  } else {
1549
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
2005
+ await writeHookConfig(target, next);
1550
2006
  }
1551
2007
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1552
2008
  }
@@ -1560,6 +2016,10 @@ async function hookRun(args = []) {
1560
2016
  const input = await readStdinJson();
1561
2017
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1562
2018
  const cwd = String(input?.cwd || process.cwd());
2019
+ if (profile === 'study-note' && event === 'Stop') {
2020
+ console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2021
+ return;
2022
+ }
1563
2023
  const prompt = extractPrompt(input);
1564
2024
  const additionalContext = profile === 'study-note'
1565
2025
  ? await buildStudyNoteHookContext({ cwd })
@@ -1619,12 +2079,19 @@ async function buildYamLiteContext({ cwd, prompt }) {
1619
2079
 
1620
2080
  async function buildStudyNoteHookContext({ cwd }) {
1621
2081
  const dir = path.resolve(cwd || process.cwd());
1622
- const changedFiles = await gitChangedFiles(dir);
2082
+ const changedFileDetection = gitChangedFileSnapshot(dir);
2083
+ const changedFiles = changedFileDetection.files;
1623
2084
  const lines = [
1624
2085
  'yam Study Note guard active: if this turn changes code, config, release metadata, docs, or project artifacts, include a Study Note in the final response.'
1625
2086
  ];
2087
+ if (!changedFileDetection.available) {
2088
+ lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2089
+ lines.push('Determine manually whether artifacts changed and include the Study Note when they did; the Stop hook will keep this uncertainty visible.');
2090
+ return lines.join('\n');
2091
+ }
1626
2092
  if (!changedFiles.length) {
1627
2093
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
2094
+ lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
1628
2095
  return lines.join('\n');
1629
2096
  }
1630
2097
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1634,10 +2101,37 @@ async function buildStudyNoteHookContext({ cwd }) {
1634
2101
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1635
2102
  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
2103
  }
1637
- lines.push('This hook is advisory; it does not generate or edit the Study Note.');
2104
+ 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
2105
  return lines.join('\n');
1639
2106
  }
1640
2107
 
2108
+ async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd()) {
2109
+ const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
2110
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2111
+ if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2112
+ return {
2113
+ continue: true,
2114
+ systemMessage: 'yam Study Note guard could not read Git changed-file scope; do not treat this project as clean. Confirm manually whether artifacts changed and include a complete Study Note when they did.'
2115
+ };
2116
+ }
2117
+ if (!result.blockers.length) return { continue: true };
2118
+ const summary = result.blockers.slice(0, 4).join('; ');
2119
+ if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
2120
+ return {
2121
+ continue: true,
2122
+ systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
2123
+ };
2124
+ }
2125
+ return {
2126
+ decision: 'block',
2127
+ reason: [
2128
+ 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
2129
+ summary,
2130
+ '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.'
2131
+ ].join(' ')
2132
+ };
2133
+ }
2134
+
1641
2135
  function yamLiteRouteHint(prompt = '') {
1642
2136
  const text = String(prompt || '').toLowerCase();
1643
2137
  if (!text) return '';
@@ -4092,7 +4586,10 @@ function pickScript(scripts = {}, groups = []) {
4092
4586
  async function detectProject(targetDir = process.cwd(), { quiet = false } = {}) {
4093
4587
  const dir = path.resolve(targetDir || process.cwd());
4094
4588
  const packageJson = path.join(dir, 'package.json');
4589
+ const changedFileDetection = gitChangedFileSnapshot(dir);
4590
+ const changedFiles = changedFileDetection.files;
4095
4591
  const result = {
4592
+ schema: 'yam.project-detection.v1',
4096
4593
  dir,
4097
4594
  packageJson: await exists(packageJson),
4098
4595
  packageManager: 'npm',
@@ -4103,12 +4600,26 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4103
4600
  test: null,
4104
4601
  build: null
4105
4602
  },
4106
- frameworkChecklist: null
4603
+ frameworkChecklist: null,
4604
+ changedFileDetection,
4605
+ changedFiles,
4606
+ verificationRecommendations: [],
4607
+ verificationCommandPlan: []
4107
4608
  };
4108
4609
  if (!result.packageJson) {
4610
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4611
+ packageManager: result.packageManager,
4612
+ scripts: {},
4613
+ commands: result.commands,
4614
+ changedFiles,
4615
+ changedFilesAvailable: changedFileDetection.available,
4616
+ packageJson: false
4617
+ });
4618
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4109
4619
  if (!quiet) {
4110
4620
  console.log(`No package.json found in ${dir}`);
4111
4621
  console.log('Suggested verification: use project pack or local framework conventions.');
4622
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4112
4623
  }
4113
4624
  return result;
4114
4625
  }
@@ -4122,11 +4633,255 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4122
4633
  result.commands.test = runCommand(result.packageManager, pickScript(scripts, ['test', 'spec']));
4123
4634
  result.commands.build = runCommand(result.packageManager, pickScript(scripts, ['build']));
4124
4635
  result.frameworkChecklist = await detectFrameworkChecklist(dir, pkg);
4636
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4637
+ packageManager: result.packageManager,
4638
+ scripts,
4639
+ commands: result.commands,
4640
+ changedFiles,
4641
+ changedFilesAvailable: changedFileDetection.available,
4642
+ packageJson: true
4643
+ });
4644
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4125
4645
 
4126
4646
  if (!quiet) printDetection(result);
4127
4647
  return result;
4128
4648
  }
4129
4649
 
4650
+ async function detectCommand(args = []) {
4651
+ const flags = parseSimpleFlags(args, new Set(['json']));
4652
+ const dir = path.resolve(firstPositional(args) || process.cwd());
4653
+ const result = await detectProject(dir, { quiet: true });
4654
+ if (flags.json) {
4655
+ console.log(JSON.stringify(result, null, 2));
4656
+ return;
4657
+ }
4658
+ printDetection(result);
4659
+ }
4660
+
4661
+ function buildChangedFileVerificationRecommendations({
4662
+ packageManager = 'npm',
4663
+ scripts = {},
4664
+ commands = {},
4665
+ changedFiles = [],
4666
+ changedFilesAvailable = true,
4667
+ packageJson = false
4668
+ }: AnyRecord) {
4669
+ const safeFallbackCommands = uniqueStrings([
4670
+ commands.typecheck,
4671
+ commands.lint,
4672
+ commands.test,
4673
+ commands.build
4674
+ ]);
4675
+ if (!changedFilesAvailable) {
4676
+ return [{
4677
+ matched_rule: 'git-status-unavailable',
4678
+ confidence: 'low',
4679
+ reason: 'Git change status was unavailable, so yam cannot prove that the project has no changed files',
4680
+ fallback: packageJson
4681
+ ? 'run all available non-publish typecheck, lint, test, and build checks or use project-pack conventions'
4682
+ : 'use yam.project.md or local framework conventions; no Git change scope or executable package scripts were detected',
4683
+ files: [],
4684
+ suggested_commands: safeFallbackCommands
4685
+ }];
4686
+ }
4687
+ if (!changedFiles.length) {
4688
+ return [{
4689
+ matched_rule: 'no-changes',
4690
+ confidence: 'high',
4691
+ reason: 'git reports no dirty, staged, or untracked project files',
4692
+ fallback: 'rerun `yam detect --json` after files change; use project-pack conventions if Git is unavailable',
4693
+ files: [],
4694
+ suggested_commands: []
4695
+ }];
4696
+ }
4697
+
4698
+ const rules = [
4699
+ {
4700
+ id: 'package-release-metadata',
4701
+ confidence: 'high',
4702
+ matches: (file) => /^(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|yam\.manifest\.json|CHANGELOG\.md|scripts\/check-(?:package-boundary|registry-version|dist-freshness)\.mjs)$/i.test(file),
4703
+ reason: 'package or release metadata changed, so package contents and release readiness can drift',
4704
+ commandNames: ['package-boundary:check', 'release:check'],
4705
+ fallback: 'run the available typecheck/build/package checks and perform the public release workflow separately'
4706
+ },
4707
+ {
4708
+ id: 'src-lib',
4709
+ confidence: 'high',
4710
+ matches: (file) => /^src\/lib\//i.test(file),
4711
+ reason: 'shared library code changed, so compile safety and a focused behavior smoke are the narrowest honest checks',
4712
+ commandNames: ['typecheck'],
4713
+ fallback: 'run typecheck plus the closest available test/smoke; use the full safe fallback when the library crosses package boundaries'
4714
+ },
4715
+ {
4716
+ id: 'scripts',
4717
+ confidence: 'medium',
4718
+ matches: (file) => /^scripts\//i.test(file),
4719
+ reason: 'an executable verification or maintenance script changed, so its direct npm wrapper or closest smoke should run',
4720
+ commandNames: [],
4721
+ fallback: 'run the changed script in an isolated fixture, then use the full safe fallback if no direct wrapper exists'
4722
+ },
4723
+ {
4724
+ id: 'unknown-safe-fallback',
4725
+ confidence: packageJson ? 'medium' : 'low',
4726
+ matches: () => true,
4727
+ reason: 'no focused changed-file rule matched, so skipping verification would be weaker than the available project-wide safe checks',
4728
+ commandNames: [],
4729
+ fallback: packageJson
4730
+ ? 'run all available non-publish typecheck, lint, test, and build checks'
4731
+ : 'use yam.project.md or local framework conventions; no executable package scripts were detected'
4732
+ }
4733
+ ];
4734
+
4735
+ const grouped = new Map<string, string[]>();
4736
+ for (const rawFile of changedFiles) {
4737
+ const file = String(rawFile || '').replaceAll('\\', '/');
4738
+ const rule = rules.find((candidate) => candidate.matches(file)) || rules[rules.length - 1];
4739
+ grouped.set(rule.id, [...(grouped.get(rule.id) || []), file]);
4740
+ }
4741
+
4742
+ const recommendations = [];
4743
+ for (const rule of rules) {
4744
+ const files = grouped.get(rule.id) || [];
4745
+ if (!files.length) continue;
4746
+ let commandEvidence: AnyRecord[] = rule.commandNames.map((name) => ({
4747
+ name,
4748
+ files,
4749
+ basis: 'rule-wide'
4750
+ }));
4751
+ if (rule.id === 'src-lib') {
4752
+ commandEvidence = [
4753
+ { name: 'typecheck', files, basis: 'rule-wide' },
4754
+ ...focusedSmokeScriptEvidenceForFiles(scripts, files)
4755
+ ];
4756
+ } else if (rule.id === 'scripts') {
4757
+ const directEvidence = files.flatMap((file) => (
4758
+ directScriptNamesForFile(scripts, file).map((name) => ({
4759
+ name,
4760
+ files: [file],
4761
+ basis: 'direct-script-wrapper'
4762
+ }))
4763
+ ));
4764
+ commandEvidence = directEvidence.length
4765
+ ? directEvidence
4766
+ : focusedSmokeScriptNames(scripts).slice(0, 1).map((name) => ({
4767
+ name,
4768
+ files,
4769
+ basis: 'group-fallback'
4770
+ }));
4771
+ } else if (rule.id === 'unknown-safe-fallback') {
4772
+ commandEvidence = safeFallbackCommands.map((command) => ({
4773
+ command,
4774
+ files,
4775
+ basis: 'safe-fallback'
4776
+ }));
4777
+ }
4778
+ const commandSources = commandEvidence
4779
+ .map((evidence) => ({
4780
+ command: evidence.command || scriptCommandIfPresent(packageManager, scripts, evidence.name),
4781
+ files: [...new Set(evidence.files || [])].sort(),
4782
+ basis: evidence.basis
4783
+ }))
4784
+ .filter((evidence) => evidence.command);
4785
+ const suggestedCommands = uniqueStrings(commandSources.map((evidence) => evidence.command));
4786
+ recommendations.push({
4787
+ matched_rule: rule.id,
4788
+ confidence: rule.confidence,
4789
+ reason: rule.reason,
4790
+ fallback: rule.fallback,
4791
+ files: [...new Set(files)].sort(),
4792
+ suggested_commands: suggestedCommands,
4793
+ command_sources: commandSources
4794
+ });
4795
+ }
4796
+ return recommendations;
4797
+ }
4798
+
4799
+ function focusedSmokeScriptNames(scripts = {}) {
4800
+ return Object.keys(scripts)
4801
+ .filter((name) => /(?:^|[-:])(?:smoke|test)(?:$|[-:])/i.test(name) || /smoke/i.test(String(scripts[name] || '')))
4802
+ .sort();
4803
+ }
4804
+
4805
+ function focusedSmokeScriptEvidenceForFiles(scripts = {}, files = []) {
4806
+ const candidates = focusedSmokeScriptNames(scripts);
4807
+ const selected = [];
4808
+ for (const file of files) {
4809
+ const stem = path.posix.basename(String(file || '').replaceAll('\\', '/')).replace(/\.[^.]+$/, '');
4810
+ const direct = candidates.find((name) => {
4811
+ const command = String(scripts[name] || '');
4812
+ return name.includes(stem)
4813
+ || command.includes(stem)
4814
+ || (stem === 'skill-installation' && /install.*smoke|smoke.*install/i.test(`${name} ${command}`))
4815
+ || (['trust-kernel', 'ueye-artifacts'].includes(stem) && /cli.*smoke|smoke.*cli/i.test(`${name} ${command}`));
4816
+ });
4817
+ if (direct) selected.push({
4818
+ name: direct,
4819
+ files: [file],
4820
+ basis: 'focused-file-match'
4821
+ });
4822
+ }
4823
+ if (!selected.length && candidates.length) {
4824
+ selected.push({
4825
+ name: candidates[0],
4826
+ files,
4827
+ basis: 'group-fallback'
4828
+ });
4829
+ }
4830
+ return selected;
4831
+ }
4832
+
4833
+ function directScriptNamesForFile(scripts = {}, changedFile = '') {
4834
+ const normalized = String(changedFile || '').replaceAll('\\', '/');
4835
+ const basename = path.posix.basename(normalized);
4836
+ return Object.entries(scripts)
4837
+ .filter(([, command]) => {
4838
+ const text = String(command || '').replaceAll('\\', '/');
4839
+ return text.includes(normalized) || text.includes(`/${basename}`) || text.includes(` ${basename}`);
4840
+ })
4841
+ .map(([name]) => name)
4842
+ .sort();
4843
+ }
4844
+
4845
+ function scriptCommandIfPresent(packageManager, scripts = {}, name = '') {
4846
+ return name && Object.prototype.hasOwnProperty.call(scripts, name)
4847
+ ? runCommand(packageManager, name)
4848
+ : null;
4849
+ }
4850
+
4851
+ function uniqueStrings(values = []) {
4852
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
4853
+ }
4854
+
4855
+ function buildVerificationCommandPlan(recommendations = []) {
4856
+ const commands = new Map<string, AnyRecord>();
4857
+ for (const recommendation of recommendations) {
4858
+ const sources = recommendation.command_sources?.length
4859
+ ? recommendation.command_sources
4860
+ : (recommendation.suggested_commands || []).map((command) => ({
4861
+ command,
4862
+ files: recommendation.files || [],
4863
+ basis: 'recommendation-fallback'
4864
+ }));
4865
+ for (const source of sources) {
4866
+ const current = commands.get(source.command) || {
4867
+ command: source.command,
4868
+ matched_rules: [],
4869
+ files: [],
4870
+ provenance: []
4871
+ };
4872
+ current.matched_rules = uniqueStrings([...current.matched_rules, recommendation.matched_rule]);
4873
+ current.files = uniqueStrings([...current.files, ...(source.files || [])]).sort();
4874
+ current.provenance.push({
4875
+ matched_rule: recommendation.matched_rule,
4876
+ files: [...new Set(source.files || [])].sort(),
4877
+ basis: source.basis
4878
+ });
4879
+ commands.set(source.command, current);
4880
+ }
4881
+ }
4882
+ return [...commands.values()];
4883
+ }
4884
+
4130
4885
  function printDetection(result) {
4131
4886
  console.log(`Project: ${result.dir}`);
4132
4887
  console.log(`Package manager: ${result.packageManager}`);
@@ -4140,6 +4895,7 @@ function printDetection(result) {
4140
4895
  console.log(`- $quick: ${result.commands.typecheck || result.commands.lint || result.commands.test || result.commands.build || 'L1 inspected; no command detected'}`);
4141
4896
  console.log(`- $ueye: ${result.commands.typecheck || result.commands.build || 'Browser/screenshot check; no command detected'}`);
4142
4897
  console.log(`- $deep: ${[result.commands.typecheck, result.commands.lint, result.commands.test, result.commands.build].filter(Boolean).join(' && ') || `No command detected; define in ${PROJECT_PACK}`}`);
4898
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4143
4899
  if (result.frameworkChecklist?.detected) {
4144
4900
  console.log('');
4145
4901
  console.log(`Framework checklist: ${result.frameworkChecklist.framework}`);
@@ -4149,6 +4905,29 @@ function printDetection(result) {
4149
4905
  }
4150
4906
  }
4151
4907
 
4908
+ function printChangedFileVerificationRecommendations(recommendations = [], commandPlan = []) {
4909
+ console.log('');
4910
+ console.log('Changed-file verification recommendations (advisory; commands were not run):');
4911
+ for (const recommendation of recommendations) {
4912
+ console.log(`- ${recommendation.matched_rule} [confidence: ${recommendation.confidence}]`);
4913
+ console.log(` files: ${recommendation.files.length ? recommendation.files.join(', ') : '(none)'}`);
4914
+ console.log(` reason: ${recommendation.reason}`);
4915
+ console.log(` commands: ${recommendation.suggested_commands.length ? recommendation.suggested_commands.join(' && ') : '(none detected)'}`);
4916
+ console.log(` fallback: ${recommendation.fallback}`);
4917
+ }
4918
+ console.log('');
4919
+ console.log('Verification command plan (deduplicated; advisory; commands were not run):');
4920
+ if (!commandPlan.length) {
4921
+ console.log('- (none detected)');
4922
+ return;
4923
+ }
4924
+ for (const item of commandPlan) {
4925
+ console.log(`- ${item.command}`);
4926
+ console.log(` rules: ${item.matched_rules.join(', ')}`);
4927
+ console.log(` files: ${item.files.join(', ') || '(none)'}`);
4928
+ }
4929
+ }
4930
+
4152
4931
  function budget(routeArg = '') {
4153
4932
  const normalized = normalizeRoute(routeArg);
4154
4933
  const entries = normalized ? [[normalized, ROUTE_BUDGETS[normalized]]] : Object.entries(ROUTE_BUDGETS);
@@ -4536,16 +5315,39 @@ function findSensitivePattern(text = '') {
4536
5315
  function showPath() {
4537
5316
  console.log(`root: ${ROOT}`);
4538
5317
  console.log(`skills: ${DEST}`);
4539
- console.log(`codex mirror cleanup: ${CODEX_MIRROR}`);
5318
+ console.log(`codex mirror (preserved without an ownership receipt): ${CODEX_MIRROR}`);
5319
+ }
5320
+
5321
+ function showRequestedHelp(args: string[]) {
5322
+ if (!args.some((arg) => arg === '--help' || arg === '-h')) return false;
5323
+
5324
+ const commandUsage: Record<string, () => void> = {
5325
+ context: contextUsage,
5326
+ cleanup: cleanupUsage,
5327
+ 'study-note': studyNoteUsage,
5328
+ tools: toolsUsage,
5329
+ hook: hookUsage,
5330
+ loop: loopUsage,
5331
+ ueye: ueyeUsage,
5332
+ media: mediaUsage,
5333
+ runtime: runtimeUsage,
5334
+ mission: missionUsage,
5335
+ benchmark: benchmarkUsage,
5336
+ memory: memoryUsage
5337
+ };
5338
+ (commandUsage[args[0]] || usage)();
5339
+ return true;
4540
5340
  }
4541
5341
 
4542
5342
  async function main() {
4543
- const command = process.argv[2] || 'help';
4544
- if (command === 'help' || command === '--help' || command === '-h') return usage();
4545
- if (command === 'install') return install();
4546
- if (command === 'uninstall') return uninstall();
5343
+ const args = process.argv.slice(2);
5344
+ const command = args[0] || 'help';
5345
+ if (showRequestedHelp(args)) return;
5346
+ if (command === 'help') return usage();
5347
+ if (command === 'install') return install(args.slice(1));
5348
+ if (command === 'uninstall') return uninstall(args.slice(1));
4547
5349
  if (command === 'version') return console.log(VERSION);
4548
- if (command === 'detect') return detectProject(process.argv[3]);
5350
+ if (command === 'detect') return detectCommand(args.slice(1));
4549
5351
  if (command === 'pack') return inspectProjectPack(process.argv[3]);
4550
5352
  if (command === 'context') return context(process.argv.slice(3));
4551
5353
  if (command === 'cleanup') return cleanup(process.argv.slice(3));