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/dist/bin/yam.js 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';
@@ -9,7 +10,7 @@ import process from 'node:process';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  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
12
  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';
13
+ import { inspectSkillInstallation, installSkillSetTransactional, uninstallSkillSetSafely } from '../lib/skill-installation.js';
13
14
  function errorMessage(error) {
14
15
  return error instanceof Error ? error.message : String(error);
15
16
  }
@@ -50,8 +51,16 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
50
51
  const PROJECT_PACK = 'yam.project.md';
51
52
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
52
53
  const PACK_STALE_DAYS = 30;
53
- const YAM_LITE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run lite`;
54
- const YAM_STUDY_NOTE_HOOK_COMMAND = `node ${path.join(ROOT, 'dist', 'bin', 'yam.js')} hook run study-note`;
54
+ const INSTRUCTION_DUPLICATION_LIMITS = {
55
+ maxFiles: 24,
56
+ maxBytesPerFile: 128000,
57
+ maxGroups: 8,
58
+ maxDepth: 6,
59
+ minDirectiveChars: 40
60
+ };
61
+ const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
62
+ const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
63
+ const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
55
64
  const REQUIRED_PACK_SECTIONS = [
56
65
  'Product Direction',
57
66
  'UI Direction',
@@ -115,7 +124,7 @@ Usage:
115
124
  yam list
116
125
  yam status
117
126
  yam verify
118
- yam detect [dir]
127
+ yam detect [dir] [--json]
119
128
  yam pack [dir]
120
129
  yam context pressure [dir] [--json]
121
130
  yam cleanup scan [dir] [--json]
@@ -144,7 +153,7 @@ Usage:
144
153
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
145
154
  yam template <project|ueye|mission|proof|tuning>
146
155
  yam tune-log [dir]
147
- yam install
156
+ yam install [--replace-user-skill name]
148
157
  yam uninstall
149
158
  yam doctor [--json]
150
159
  yam examples
@@ -168,7 +177,32 @@ async function exists(file) {
168
177
  async function rmrf(target) {
169
178
  await fsp.rm(target, { recursive: true, force: true });
170
179
  }
171
- async function install() {
180
+ function installReplacementSkills(args = []) {
181
+ const skills = [];
182
+ for (let index = 0; index < args.length; index += 1) {
183
+ const arg = String(args[index] || '');
184
+ if (arg === '--replace-user-skill') {
185
+ const skill = String(args[index + 1] || '');
186
+ if (!skill || skill.startsWith('--')) {
187
+ throw new Error('missing skill name after --replace-user-skill');
188
+ }
189
+ skills.push(skill);
190
+ index += 1;
191
+ continue;
192
+ }
193
+ if (arg.startsWith('--replace-user-skill=')) {
194
+ const skill = arg.slice('--replace-user-skill='.length);
195
+ if (!skill)
196
+ throw new Error('missing skill name after --replace-user-skill=');
197
+ skills.push(skill);
198
+ continue;
199
+ }
200
+ throw new Error(`unknown yam install argument: ${arg}`);
201
+ }
202
+ return [...new Set(skills)];
203
+ }
204
+ async function install(args = []) {
205
+ const replaceSkills = installReplacementSkills(args);
172
206
  const result = await installSkillSetTransactional({
173
207
  sourceRoot: ROOT,
174
208
  destination: DEST,
@@ -176,6 +210,7 @@ async function install() {
176
210
  packageName: String(PACKAGE_JSON.name || 'yam-flow'),
177
211
  version: VERSION,
178
212
  skills: SKILLS,
213
+ replaceSkills,
179
214
  legacySkills: LEGACY_SKILLS,
180
215
  retiredSkills: RETIRED_SKILLS
181
216
  });
@@ -186,21 +221,22 @@ async function install() {
186
221
  console.warn(`warning: ${warning}`);
187
222
  console.log('Restart Codex to reload skills.');
188
223
  }
189
- async function uninstall() {
190
- const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
191
- if (await exists(lockPath)) {
192
- throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
193
- }
194
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
195
- await rmrf(path.join(DEST, skill));
196
- if (CODEX_MIRROR !== DEST) {
197
- await rmrf(path.join(CODEX_MIRROR, skill));
198
- }
199
- }
200
- await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
224
+ async function uninstall(args = []) {
225
+ if (args.length)
226
+ throw new Error(`unknown yam uninstall argument: ${args[0]}`);
227
+ const result = await uninstallSkillSetSafely({
228
+ destination: DEST,
229
+ codexMirror: CODEX_MIRROR,
230
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
231
+ skills: SKILLS
232
+ });
201
233
  console.log(`yam removed from ${DEST}`);
202
- if (CODEX_MIRROR !== DEST)
203
- console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
234
+ console.log(`removed receipt: ${result.receiptPath}`);
235
+ for (const warning of result.cleanupWarnings)
236
+ console.warn(`warning: ${warning}`);
237
+ if (CODEX_MIRROR !== DEST) {
238
+ console.log(`unowned legacy, retired, and Codex mirror entries were preserved`);
239
+ }
204
240
  console.log('Restart Codex to unload skills.');
205
241
  }
206
242
  async function status({ quiet = false } = {}) {
@@ -396,12 +432,21 @@ async function doctor(args = []) {
396
432
  console.log('yam doctor: ok');
397
433
  console.log('No hooks, automations, or global config are required.');
398
434
  console.log('Skill install state is reported by `yam status`.');
435
+ if (report.preservedUnprovenSkillEntries.length) {
436
+ console.log(`Preserved unproven legacy/retired/mirror entries: ${report.preservedUnprovenSkillEntries.length}`);
437
+ }
399
438
  console.log(`yam-lite hook: ${report.yamLiteHook} (optional)`);
400
439
  return;
401
440
  }
402
441
  console.log('yam doctor: issues');
403
442
  for (const issue of report.issues)
404
443
  console.log(`- ${issue}`);
444
+ if (report.preservedUnprovenSkillEntries.length) {
445
+ console.log('');
446
+ console.log('Preserved unproven skill entries:');
447
+ for (const entry of report.preservedUnprovenSkillEntries)
448
+ console.log(`- ${entry}`);
449
+ }
405
450
  if (report.nextActions.length) {
406
451
  console.log('');
407
452
  console.log('Next actions:');
@@ -412,6 +457,7 @@ async function doctor(args = []) {
412
457
  }
413
458
  async function buildDoctorReport() {
414
459
  const issues = [];
460
+ const preservedUnprovenSkillEntries = [];
415
461
  for (const skill of SKILLS) {
416
462
  if (!await exists(path.join(ROOT, 'skills', skill, 'SKILL.md')))
417
463
  issues.push(`missing source skill: ${skill}`);
@@ -425,17 +471,22 @@ async function buildDoctorReport() {
425
471
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'timeto')))
426
472
  issues.push('unexpected legacy timeto automation');
427
473
  for (const legacySkill of LEGACY_SKILLS) {
428
- if (await exists(path.join(DEST, legacySkill, 'SKILL.md')))
429
- issues.push(`unexpected legacy installed skill: ${legacySkill}`);
430
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, legacySkill, 'SKILL.md'))) {
431
- issues.push(`unexpected legacy mirror skill: ${legacySkill}`);
474
+ if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) {
475
+ preservedUnprovenSkillEntries.push(`legacy-named destination entry: ${legacySkill}`);
432
476
  }
433
477
  }
434
478
  for (const retiredSkill of RETIRED_SKILLS) {
435
- if (await exists(path.join(DEST, retiredSkill, 'SKILL.md')))
436
- issues.push(`unexpected retired installed skill: ${retiredSkill}`);
437
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, retiredSkill, 'SKILL.md'))) {
438
- issues.push(`unexpected retired mirror skill: ${retiredSkill}`);
479
+ if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) {
480
+ preservedUnprovenSkillEntries.push(`retired-name destination entry: ${retiredSkill}`);
481
+ }
482
+ }
483
+ if (CODEX_MIRROR !== DEST) {
484
+ const mirrorSkillNames = [...new Set([...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS])];
485
+ for (const skill of mirrorSkillNames) {
486
+ if (!await exists(path.join(CODEX_MIRROR, skill, 'SKILL.md')))
487
+ continue;
488
+ const nameKind = SKILLS.includes(skill) ? 'active-name' : LEGACY_SKILLS.includes(skill) ? 'legacy-named' : 'retired-name';
489
+ preservedUnprovenSkillEntries.push(`${nameKind} Codex mirror entry: ${skill}`);
439
490
  }
440
491
  }
441
492
  const verifyIssues = await verify({ quiet: true });
@@ -448,6 +499,7 @@ async function buildDoctorReport() {
448
499
  generatedAt: new Date().toISOString(),
449
500
  ok: issues.length === 0,
450
501
  issues,
502
+ preservedUnprovenSkillEntries,
451
503
  nextActions: nextActionDetails.map((action) => action.next_action),
452
504
  nextActionDetails,
453
505
  yamLiteHook: hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled'
@@ -467,8 +519,6 @@ function doctorNextActionDetails(issues = []) {
467
519
  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'));
468
520
  else if (/unexpected .*automation/i.test(issue))
469
521
  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'));
470
- else if (/legacy|retired/i.test(issue))
471
- actions.push(nextActionDetail('replace-old-skills', 'warning', issue, 'run `yam install` to replace old skill entries', 'yam install'));
472
522
  else if (/verify reported/i.test(issue))
473
523
  actions.push(nextActionDetail('run-verify', 'error', issue, 'run `npm run verify` and fix the reported package boundary or metadata issue', 'npm run verify'));
474
524
  }
@@ -750,16 +800,32 @@ Notes:
750
800
  async function studyNoteCheck(args = []) {
751
801
  const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
752
802
  const dir = path.resolve(firstPositional(args) || process.cwd());
753
- const changedFiles = await gitChangedFiles(dir);
754
803
  const reportText = await readStudyNoteReportText(flags, dir);
755
- const needsStudyNote = changedFiles.length > 0;
804
+ const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
805
+ const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
806
+ printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
807
+ if (result.blockers.length)
808
+ process.exitCode = 1;
809
+ }
810
+ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
811
+ const changedFileDetection = gitChangedFileSnapshot(dir);
812
+ const changedFiles = changedFileDetection.files;
813
+ const changeScopeAvailable = changedFileDetection.available;
814
+ const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
815
+ const scopeAwareStatus = (passes) => changeScopeAvailable ? (!needsStudyNote || passes ? 'pass' : 'fail') : 'partial';
756
816
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
757
817
  const checks = [
758
- studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
759
- studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
760
- 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'),
761
- studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
762
- studyNoteGuardCheck('verification_or_limits', !needsStudyNote || /(verification|verified|checked|limits|uncertain|검증|확인|한계|모르는)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked and what remains uncertain')
818
+ studyNoteGuardCheck('changed_files', changeScopeAvailable ? (needsStudyNote ? 'pass' : 'skipped') : 'partial', changeScopeAvailable
819
+ ? (needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected')
820
+ : 'Git changed-file scope is unavailable; do not treat this project as clean and determine Study Note need manually'),
821
+ studyNoteGuardCheck('study_note_present', scopeAwareStatus(hasStudyNoteMarker(reportText)), 'final report should include a Study Note when project artifacts changed'),
822
+ studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText)), 'Study Note should explain what the touched code/artifact does'),
823
+ 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'),
824
+ 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'),
825
+ studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText)), 'Study Note should describe the expected behavior or result'),
826
+ 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'),
827
+ studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText)), 'Study Note should say what was checked'),
828
+ 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')
763
829
  ];
764
830
  for (const requirement of hygieneRequirements) {
765
831
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -769,17 +835,20 @@ async function studyNoteCheck(args = []) {
769
835
  schema: 'yam.study-note-guard.v1',
770
836
  generated_at: new Date().toISOString(),
771
837
  project: dir,
838
+ changed_file_detection: changedFileDetection,
772
839
  changed_files: changedFiles,
773
840
  changed_file_count: changedFiles.length,
774
- report_source: flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided',
841
+ report_source: reportSource,
775
842
  checks,
776
843
  blockers,
777
- next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
778
- truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
844
+ next_action: blockers[0] || (!changeScopeAvailable
845
+ ? 'Git change scope is unavailable; inspect changed artifacts manually and include a Study Note when anything changed'
846
+ : needsStudyNote
847
+ ? 'Study Note guard passed for the supplied report text'
848
+ : 'no Study Note required because no changed files were detected'),
849
+ truth_status: blockers.length ? 'blocked' : !changeScopeAvailable ? 'partial' : needsStudyNote ? 'verified' : 'skipped'
779
850
  };
780
- printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
781
- if (blockers.length)
782
- process.exitCode = 1;
851
+ return result;
783
852
  }
784
853
  function firstPositional(args = []) {
785
854
  for (let index = 0; index < args.length; index += 1) {
@@ -795,14 +864,38 @@ function firstPositional(args = []) {
795
864
  }
796
865
  return '';
797
866
  }
798
- async function gitChangedFiles(dir) {
799
- const output = runReadOnlyCommandIn(dir, 'git', ['status', '--short']);
800
- return output
801
- .split(/\r?\n/)
802
- .filter(Boolean)
803
- .map((line) => line.replace(/^..\s+/, '').replace(/^.* -> /, ''))
804
- .map((line) => line.trim())
805
- .filter((file) => file && !file.startsWith('dist/') && !file.endsWith('.tgz'));
867
+ function gitChangedFileSnapshot(dir) {
868
+ const result = spawnSync('git', ['status', '--short', '-z', '--untracked-files=all'], {
869
+ cwd: dir,
870
+ encoding: 'utf8',
871
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
872
+ timeout: 30000
873
+ });
874
+ if (result.status !== 0) {
875
+ return {
876
+ available: false,
877
+ source: 'git status --short -z --untracked-files=all',
878
+ files: []
879
+ };
880
+ }
881
+ const records = String(result.stdout || '').split('\0');
882
+ const files = [];
883
+ for (let index = 0; index < records.length; index += 1) {
884
+ const record = records[index];
885
+ if (!record || record.length < 4)
886
+ continue;
887
+ const status = record.slice(0, 2);
888
+ const file = record.slice(3);
889
+ if (/[RC]/.test(status))
890
+ index += 1;
891
+ if (file && !file.startsWith('dist/') && !file.endsWith('.tgz'))
892
+ files.push(file);
893
+ }
894
+ return {
895
+ available: true,
896
+ source: 'git status --short -z --untracked-files=all',
897
+ files
898
+ };
806
899
  }
807
900
  async function readStudyNoteReportText(flags, dir) {
808
901
  if (flags.text)
@@ -892,12 +985,17 @@ async function buildCleanupScanReport(targetDir = process.cwd()) {
892
985
  for (const artifact of await staleYamArtifacts(dir)) {
893
986
  findings.push(cleanupFinding('low', artifact.file, artifact.reason, 'archive or refresh only if it misleads the current work'));
894
987
  }
988
+ const instructionDuplicationBudget = await buildInstructionDuplicationBudget(dir);
989
+ if (instructionDuplicationBudget.duplicate_group_count > 0) {
990
+ findings.push(cleanupFinding('low', 'AGENTS.md/SKILL.md directive duplication budget', `${instructionDuplicationBudget.duplicate_group_count} normalized directive group(s) repeat across separate instruction surfaces; exact normalized matches are advisory and may be intentional`, 'review the reported source files and consolidate only repetition that causes drift or conflicting maintenance'));
991
+ }
895
992
  return {
896
993
  schema: 'yam.cleanup-scan.v1',
897
994
  generated_at: new Date().toISOString(),
898
995
  project: dir,
899
996
  destructive: false,
900
997
  findings,
998
+ instruction_duplication_budget: instructionDuplicationBudget,
901
999
  risk_level: overallCleanupRisk(findings),
902
1000
  safe_next_action: findings.length ? 'review findings and make explicit user-approved cleanup decisions' : 'no cleanup action needed',
903
1001
  truth_status: 'partial'
@@ -951,6 +1049,162 @@ async function staleYamArtifacts(dir) {
951
1049
  }
952
1050
  return artifacts;
953
1051
  }
1052
+ async function buildInstructionDuplicationBudget(rootDir = process.cwd(), limits = INSTRUCTION_DUPLICATION_LIMITS) {
1053
+ const root = path.resolve(rootDir || process.cwd());
1054
+ const inventory = await collectInstructionFiles(root, limits);
1055
+ const directives = new Map();
1056
+ let candidateDirectiveCount = 0;
1057
+ for (const relativeFile of inventory.files) {
1058
+ const absolute = path.join(root, relativeFile);
1059
+ const text = await readBoundedUtf8File(absolute, limits.maxBytesPerFile);
1060
+ for (const directive of instructionDirectives(text, relativeFile, limits.minDirectiveChars)) {
1061
+ candidateDirectiveCount += 1;
1062
+ directives.set(directive.normalized, [...(directives.get(directive.normalized) || []), directive]);
1063
+ }
1064
+ }
1065
+ const allGroups = [...directives.entries()]
1066
+ .map(([normalized, occurrences]) => {
1067
+ const surfaces = [...new Set(occurrences.map((item) => item.file))];
1068
+ if (surfaces.length < 2)
1069
+ return null;
1070
+ return {
1071
+ directive_digest: createHash('sha256').update(normalized).digest('hex').slice(0, 16),
1072
+ directive_preview: redactSensitiveText(String(occurrences[0]?.text || '')).slice(0, 180),
1073
+ occurrence_count: occurrences.length,
1074
+ surface_count: surfaces.length,
1075
+ surfaces,
1076
+ occurrences: occurrences.slice(0, 8).map((item) => ({
1077
+ file: item.file,
1078
+ line: item.line
1079
+ }))
1080
+ };
1081
+ })
1082
+ .filter(Boolean)
1083
+ .sort((left, right) => (right.surface_count - left.surface_count
1084
+ || right.occurrence_count - left.occurrence_count
1085
+ || left.directive_digest.localeCompare(right.directive_digest)));
1086
+ const duplicateGroups = allGroups.slice(0, limits.maxGroups);
1087
+ return {
1088
+ schema: 'yam.instruction-duplication-budget.v1',
1089
+ scope: 'project AGENTS.md and SKILL.md files only',
1090
+ advisory_only: true,
1091
+ hard_gate: false,
1092
+ destructive: false,
1093
+ normalization: 'exact normalized markdown directive text; no semantic similarity inference',
1094
+ limits: {
1095
+ max_files: limits.maxFiles,
1096
+ max_bytes_per_file: limits.maxBytesPerFile,
1097
+ max_groups: limits.maxGroups,
1098
+ max_depth: limits.maxDepth,
1099
+ min_directive_chars: limits.minDirectiveChars
1100
+ },
1101
+ files_scanned: inventory.files.length,
1102
+ scanned_files: inventory.files,
1103
+ candidate_directive_count: candidateDirectiveCount,
1104
+ duplicate_group_count: allGroups.length,
1105
+ duplicate_groups: duplicateGroups,
1106
+ file_limit_reached: inventory.fileLimitReached,
1107
+ group_limit_reached: allGroups.length > duplicateGroups.length,
1108
+ truth_status: 'partial',
1109
+ note: allGroups.length
1110
+ ? 'repetition may be intentional; review before consolidating'
1111
+ : 'no cross-surface exact normalized directive repetition found within the bounded scan'
1112
+ };
1113
+ }
1114
+ async function collectInstructionFiles(root, limits) {
1115
+ const files = [];
1116
+ let fileLimitReached = false;
1117
+ async function walk(current, depth) {
1118
+ if (depth > limits.maxDepth || fileLimitReached)
1119
+ return;
1120
+ let entries = [];
1121
+ try {
1122
+ entries = await fsp.readdir(current, { withFileTypes: true });
1123
+ }
1124
+ catch {
1125
+ return;
1126
+ }
1127
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1128
+ for (const entry of entries) {
1129
+ const absolute = path.join(current, entry.name);
1130
+ const relative = path.relative(root, absolute);
1131
+ if (shouldSkipScanPath(relative, entry.name) || entry.isSymbolicLink())
1132
+ continue;
1133
+ if (entry.isDirectory()) {
1134
+ await walk(absolute, depth + 1);
1135
+ if (fileLimitReached)
1136
+ return;
1137
+ continue;
1138
+ }
1139
+ if (!entry.isFile() || !['AGENTS.md', 'SKILL.md'].includes(entry.name))
1140
+ continue;
1141
+ if (files.length >= limits.maxFiles) {
1142
+ fileLimitReached = true;
1143
+ return;
1144
+ }
1145
+ files.push(relative);
1146
+ }
1147
+ }
1148
+ await walk(root, 0);
1149
+ return { files, fileLimitReached };
1150
+ }
1151
+ async function readBoundedUtf8File(file, maxBytes) {
1152
+ const handle = await fsp.open(file, 'r');
1153
+ try {
1154
+ const buffer = Buffer.alloc(maxBytes);
1155
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1156
+ return buffer.subarray(0, bytesRead).toString('utf8');
1157
+ }
1158
+ finally {
1159
+ await handle.close();
1160
+ }
1161
+ }
1162
+ function instructionDirectives(text = '', relativeFile = '', minChars = 40) {
1163
+ const directives = [];
1164
+ const lines = String(text || '').split(/\r?\n/);
1165
+ let inFence = false;
1166
+ let inFrontmatter = lines[0]?.trim() === '---';
1167
+ for (let index = 0; index < lines.length; index += 1) {
1168
+ const raw = lines[index];
1169
+ const trimmed = raw.trim();
1170
+ if (index === 0 && inFrontmatter)
1171
+ continue;
1172
+ if (inFrontmatter) {
1173
+ if (trimmed === '---')
1174
+ inFrontmatter = false;
1175
+ continue;
1176
+ }
1177
+ if (/^(```|~~~)/.test(trimmed)) {
1178
+ inFence = !inFence;
1179
+ continue;
1180
+ }
1181
+ if (inFence || !trimmed || /^#{1,6}\s/.test(trimmed))
1182
+ continue;
1183
+ const match = trimmed.match(/^(?:[-*+]|\d+[.)])\s+(.+)$/);
1184
+ if (!match)
1185
+ continue;
1186
+ const normalized = normalizeInstructionDirective(match[1]);
1187
+ if (normalized.length < minChars)
1188
+ continue;
1189
+ directives.push({
1190
+ file: relativeFile,
1191
+ line: index + 1,
1192
+ text: match[1].trim().slice(0, 240),
1193
+ normalized
1194
+ });
1195
+ }
1196
+ return directives;
1197
+ }
1198
+ function normalizeInstructionDirective(value = '') {
1199
+ return String(value || '')
1200
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1201
+ .replace(/[`*_~]/g, '')
1202
+ .replace(/<[^>]+>/g, ' ')
1203
+ .replace(/\s+/g, ' ')
1204
+ .replace(/[.;:,!?。,、;:!?]+$/g, '')
1205
+ .trim()
1206
+ .toLowerCase();
1207
+ }
954
1208
  function printCleanupScanReport(report) {
955
1209
  console.log('yam cleanup scan');
956
1210
  console.log(`Project: ${report.project}`);
@@ -1017,6 +1271,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1017
1271
  const codexHome = path.join(os.homedir(), '.codex');
1018
1272
  const pluginCache = path.join(codexHome, 'plugins', 'cache');
1019
1273
  const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
1274
+ const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
1275
+ const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
1020
1276
  const tmux = await findExecutable('tmux');
1021
1277
  const packageInfo = await projectPackageInfo(dir);
1022
1278
  const pack = await findProjectPack(dir);
@@ -1035,7 +1291,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
1035
1291
  const rows = [
1036
1292
  readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
1037
1293
  readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
1038
- readinessRow('yam-lite hook', hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled', 'optional UserPromptSubmit guide'),
1294
+ readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
1295
+ readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
1039
1296
  readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
1040
1297
  readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
1041
1298
  readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
@@ -1413,7 +1670,8 @@ Usage:
1413
1670
  yam hook run <lite|study-note>
1414
1671
 
1415
1672
  Notes:
1416
- hooks are opt-in and advisory-only. They do not run tmux, subagents, or proof gates.
1673
+ hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
1674
+ Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
1417
1675
  `);
1418
1676
  }
1419
1677
  function parseHookArgs(args = []) {
@@ -1444,10 +1702,10 @@ function hookPathFor(parsed) {
1444
1702
  return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
1445
1703
  }
1446
1704
  function isYamLiteHook(handler = {}) {
1447
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run lite');
1705
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
1448
1706
  }
1449
1707
  function isYamStudyNoteHook(handler = {}) {
1450
- return handler?.type === 'command' && String(handler.command || '').includes('yam.js hook run study-note');
1708
+ return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
1451
1709
  }
1452
1710
  function isYamHookProfile(handler = {}, profile = 'lite') {
1453
1711
  return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
@@ -1455,7 +1713,9 @@ function isYamHookProfile(handler = {}, profile = 'lite') {
1455
1713
  function stripYamHooks(config = {}, profile = 'lite') {
1456
1714
  const next = { ...config };
1457
1715
  for (const event of Object.keys(next)) {
1458
- const entries = Array.isArray(next[event]) ? next[event] : [];
1716
+ if (!Array.isArray(next[event]))
1717
+ continue;
1718
+ const entries = next[event];
1459
1719
  const keptEntries = [];
1460
1720
  for (const entry of entries) {
1461
1721
  const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
@@ -1472,27 +1732,42 @@ function stripYamHooks(config = {}, profile = 'lite') {
1472
1732
  }
1473
1733
  function withYamHook(config = {}, profile = 'lite') {
1474
1734
  const next = stripYamHooks(config, profile);
1475
- const event = 'UserPromptSubmit';
1476
- const entry = {
1477
- hooks: [
1478
- {
1479
- type: 'command',
1480
- command: profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND,
1481
- timeout: 5
1482
- }
1483
- ]
1484
- };
1485
- next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1735
+ const command = hookCommandForProfile(profile);
1736
+ for (const event of hookEventsForProfile(profile)) {
1737
+ const entry = {
1738
+ hooks: [
1739
+ {
1740
+ type: 'command',
1741
+ command,
1742
+ timeout: 5
1743
+ }
1744
+ ]
1745
+ };
1746
+ next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
1747
+ }
1486
1748
  return next;
1487
1749
  }
1488
1750
  async function hookStatus(args = []) {
1489
1751
  const parsed = parseHookArgs(args);
1490
1752
  const target = hookPathFor(parsed);
1491
- const config = await readJsonOrDefault(target, {});
1492
- console.log(`yam-lite hook: ${hookConfigHasProfile(config, 'lite') ? 'enabled' : 'disabled'}`);
1493
- console.log(`yam-study-note hook: ${hookConfigHasProfile(config, 'study-note') ? 'enabled' : 'disabled'}`);
1753
+ const loaded = await readHookConfig(target);
1754
+ if (loaded.error) {
1755
+ console.log('yam-lite hook: broken');
1756
+ console.log('yam-study-note hook: broken');
1757
+ console.log(` - hook config unreadable: ${loaded.error}`);
1758
+ console.log(`scope: ${parsed.mode}`);
1759
+ console.log(`file: ${target}`);
1760
+ process.exitCode = 1;
1761
+ return;
1762
+ }
1763
+ const lite = await inspectHookProfile(loaded.config, 'lite');
1764
+ const studyNote = await inspectHookProfile(loaded.config, 'study-note');
1765
+ printHookProfileStatus('lite', lite);
1766
+ printHookProfileStatus('study-note', studyNote);
1494
1767
  console.log(`scope: ${parsed.mode}`);
1495
1768
  console.log(`file: ${target}`);
1769
+ if (lite.state === 'broken' || studyNote.state === 'broken')
1770
+ process.exitCode = 1;
1496
1771
  }
1497
1772
  function hookConfigHasYamLite(config = {}) {
1498
1773
  return hookConfigHasProfile(config, 'lite');
@@ -1502,6 +1777,169 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
1502
1777
  return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
1503
1778
  }));
1504
1779
  }
1780
+ function hookCommandForProfile(profile = 'lite') {
1781
+ return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
1782
+ }
1783
+ function hookEventsForProfile(profile = 'lite') {
1784
+ return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
1785
+ }
1786
+ function hookProfileEntries(config = {}, profile = 'lite') {
1787
+ const matches = [];
1788
+ for (const [event, entries] of Object.entries(config)) {
1789
+ if (!Array.isArray(entries))
1790
+ continue;
1791
+ for (const entry of entries) {
1792
+ if (!Array.isArray(entry?.hooks))
1793
+ continue;
1794
+ for (const handler of entry.hooks) {
1795
+ if (isYamHookProfile(handler, profile))
1796
+ matches.push({ event, handler });
1797
+ }
1798
+ }
1799
+ }
1800
+ return matches;
1801
+ }
1802
+ function splitHookCommand(command = '') {
1803
+ const words = [];
1804
+ let current = '';
1805
+ let quote = '';
1806
+ let escaped = false;
1807
+ let started = false;
1808
+ for (const char of String(command)) {
1809
+ if (escaped) {
1810
+ current += char;
1811
+ escaped = false;
1812
+ started = true;
1813
+ continue;
1814
+ }
1815
+ if (char === '\\' && quote !== "'") {
1816
+ escaped = true;
1817
+ started = true;
1818
+ continue;
1819
+ }
1820
+ if (quote) {
1821
+ if (char === quote)
1822
+ quote = '';
1823
+ else
1824
+ current += char;
1825
+ started = true;
1826
+ continue;
1827
+ }
1828
+ if (char === '"' || char === "'") {
1829
+ quote = char;
1830
+ started = true;
1831
+ continue;
1832
+ }
1833
+ if (/\s/.test(char)) {
1834
+ if (started) {
1835
+ words.push(current);
1836
+ current = '';
1837
+ started = false;
1838
+ }
1839
+ continue;
1840
+ }
1841
+ current += char;
1842
+ started = true;
1843
+ }
1844
+ if (escaped || quote)
1845
+ return [];
1846
+ if (started)
1847
+ words.push(current);
1848
+ return words;
1849
+ }
1850
+ function parseYamHookCommand(command = '', profile = 'lite') {
1851
+ const words = splitHookCommand(command);
1852
+ if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
1853
+ const nodeName = path.basename(words[0]).toLowerCase();
1854
+ if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
1855
+ return { executable: words[0], script: words[1] };
1856
+ }
1857
+ }
1858
+ if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
1859
+ const yamName = path.basename(words[0]).toLowerCase();
1860
+ if (/^yam(?:\.cmd|\.exe)?$/.test(yamName))
1861
+ return { executable: words[0], script: '' };
1862
+ }
1863
+ return null;
1864
+ }
1865
+ async function hookTargetExists(target = '', executable = false) {
1866
+ if (!target)
1867
+ return false;
1868
+ if (!path.isAbsolute(target))
1869
+ return executable ? Boolean(await findExecutable(target)) : false;
1870
+ try {
1871
+ await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
1872
+ return true;
1873
+ }
1874
+ catch {
1875
+ return false;
1876
+ }
1877
+ }
1878
+ async function inspectHookHandler(handler = {}, profile = 'lite') {
1879
+ const parsed = parseYamHookCommand(String(handler.command || ''), profile);
1880
+ if (!parsed)
1881
+ return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
1882
+ if (!await hookTargetExists(parsed.executable, true)) {
1883
+ return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
1884
+ }
1885
+ if (parsed.script && !await hookTargetExists(parsed.script)) {
1886
+ return { ok: false, issue: `hook target is missing: ${parsed.script}` };
1887
+ }
1888
+ return { ok: true, issue: '' };
1889
+ }
1890
+ async function inspectHookProfile(config = {}, profile = 'lite') {
1891
+ const entries = hookProfileEntries(config, profile);
1892
+ if (!entries.length)
1893
+ return { state: 'disabled', issues: [], entries };
1894
+ const issues = [];
1895
+ for (const event of hookEventsForProfile(profile)) {
1896
+ const count = entries.filter((entry) => entry.event === event).length;
1897
+ if (count === 0)
1898
+ issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
1899
+ if (count > 1)
1900
+ issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
1901
+ }
1902
+ for (const entry of entries) {
1903
+ if (!hookEventsForProfile(profile).includes(entry.event)) {
1904
+ issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
1905
+ }
1906
+ const health = await inspectHookHandler(entry.handler, profile);
1907
+ if (!health.ok)
1908
+ issues.push(`${entry.event}: ${health.issue}`);
1909
+ }
1910
+ return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
1911
+ }
1912
+ function printHookProfileStatus(profile, health) {
1913
+ console.log(`yam-${profile} hook: ${health.state}`);
1914
+ for (const issue of health.issues)
1915
+ console.log(` - ${issue}`);
1916
+ }
1917
+ async function readHookConfig(target) {
1918
+ try {
1919
+ const value = JSON.parse(await fsp.readFile(target, 'utf8'));
1920
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
1921
+ return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
1922
+ }
1923
+ return { config: value, exists: true, error: '' };
1924
+ }
1925
+ catch (error) {
1926
+ if (error.code === 'ENOENT')
1927
+ return { config: {}, exists: false, error: '' };
1928
+ return { config: {}, exists: true, error: errorMessage(error) };
1929
+ }
1930
+ }
1931
+ async function writeHookConfig(target, config) {
1932
+ await fsp.mkdir(path.dirname(target), { recursive: true });
1933
+ const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
1934
+ try {
1935
+ await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
1936
+ await fsp.rename(temporary, target);
1937
+ }
1938
+ catch (error) {
1939
+ await rmrf(temporary).catch(() => { });
1940
+ throw error;
1941
+ }
1942
+ }
1505
1943
  async function hookEnable(args = []) {
1506
1944
  const parsed = parseHookArgs(args);
1507
1945
  if (!['lite', 'study-note'].includes(parsed.profile)) {
@@ -1510,22 +1948,43 @@ async function hookEnable(args = []) {
1510
1948
  return;
1511
1949
  }
1512
1950
  const target = hookPathFor(parsed);
1513
- const current = await readJsonOrDefault(target, {});
1951
+ const loaded = await readHookConfig(target);
1952
+ if (loaded.error) {
1953
+ console.error(`Cannot update unreadable hook config: ${target}`);
1954
+ console.error(loaded.error);
1955
+ process.exitCode = 1;
1956
+ return;
1957
+ }
1958
+ const current = loaded.config;
1959
+ const existing = hookProfileEntries(current, parsed.profile);
1960
+ const expectedEvents = hookEventsForProfile(parsed.profile);
1961
+ const expectedCommand = hookCommandForProfile(parsed.profile);
1962
+ const needsMigration = existing.length > 0 && (existing.length !== expectedEvents.length
1963
+ || existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand));
1514
1964
  const next = withYamHook(current, parsed.profile);
1515
1965
  await fsp.mkdir(path.dirname(target), { recursive: true });
1516
- if (await exists(target)) {
1966
+ if (loaded.exists) {
1517
1967
  const backup = `${target}.yam-backup-${timestampId()}`;
1518
1968
  await fsp.copyFile(target, backup);
1519
1969
  console.log(`backup: ${backup}`);
1520
1970
  }
1521
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1971
+ await writeHookConfig(target, next);
1522
1972
  console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
1973
+ if (needsMigration)
1974
+ console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
1523
1975
  console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
1524
1976
  }
1525
1977
  async function hookDisable(args = []) {
1526
1978
  const parsed = parseHookArgs(args);
1527
1979
  const target = hookPathFor(parsed);
1528
- const current = await readJsonOrDefault(target, {});
1980
+ const loaded = await readHookConfig(target);
1981
+ if (loaded.error) {
1982
+ console.error(`Cannot update unreadable hook config: ${target}`);
1983
+ console.error(loaded.error);
1984
+ process.exitCode = 1;
1985
+ return;
1986
+ }
1987
+ const current = loaded.config;
1529
1988
  if (!hookConfigHasProfile(current, parsed.profile)) {
1530
1989
  console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
1531
1990
  return;
@@ -1535,7 +1994,7 @@ async function hookDisable(args = []) {
1535
1994
  await rmrf(target);
1536
1995
  }
1537
1996
  else {
1538
- await fsp.writeFile(target, `${JSON.stringify(next, null, 2)}\n`);
1997
+ await writeHookConfig(target, next);
1539
1998
  }
1540
1999
  console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
1541
2000
  }
@@ -1548,6 +2007,10 @@ async function hookRun(args = []) {
1548
2007
  const input = await readStdinJson();
1549
2008
  const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
1550
2009
  const cwd = String(input?.cwd || process.cwd());
2010
+ if (profile === 'study-note' && event === 'Stop') {
2011
+ console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
2012
+ return;
2013
+ }
1551
2014
  const prompt = extractPrompt(input);
1552
2015
  const additionalContext = profile === 'study-note'
1553
2016
  ? await buildStudyNoteHookContext({ cwd })
@@ -1611,12 +2074,19 @@ async function buildYamLiteContext({ cwd, prompt }) {
1611
2074
  }
1612
2075
  async function buildStudyNoteHookContext({ cwd }) {
1613
2076
  const dir = path.resolve(cwd || process.cwd());
1614
- const changedFiles = await gitChangedFiles(dir);
2077
+ const changedFileDetection = gitChangedFileSnapshot(dir);
2078
+ const changedFiles = changedFileDetection.files;
1615
2079
  const lines = [
1616
2080
  '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.'
1617
2081
  ];
2082
+ if (!changedFileDetection.available) {
2083
+ lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2084
+ lines.push('Determine manually whether artifacts changed and include the Study Note when they did; the Stop hook will keep this uncertainty visible.');
2085
+ return lines.join('\n');
2086
+ }
1618
2087
  if (!changedFiles.length) {
1619
2088
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
2089
+ lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
1620
2090
  return lines.join('\n');
1621
2091
  }
1622
2092
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
@@ -1626,9 +2096,36 @@ async function buildStudyNoteHookContext({ cwd }) {
1626
2096
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
1627
2097
  lines.push('Report whether the change avoided dumping unrelated logic into page.tsx, one-off CSS into global.css, or structured product data into broad DB jsonb.');
1628
2098
  }
1629
- lines.push('This hook is advisory; it does not generate or edit the Study Note.');
2099
+ lines.push('This prompt reminder does not generate or edit the Study Note; the paired Stop hook requests one correction pass if the final response is incomplete.');
1630
2100
  return lines.join('\n');
1631
2101
  }
2102
+ async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
2103
+ const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
2104
+ const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2105
+ if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2106
+ return {
2107
+ continue: true,
2108
+ 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.'
2109
+ };
2110
+ }
2111
+ if (!result.blockers.length)
2112
+ return { continue: true };
2113
+ const summary = result.blockers.slice(0, 4).join('; ');
2114
+ if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
2115
+ return {
2116
+ continue: true,
2117
+ systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
2118
+ };
2119
+ }
2120
+ return {
2121
+ decision: 'block',
2122
+ reason: [
2123
+ 'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
2124
+ summary,
2125
+ '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.'
2126
+ ].join(' ')
2127
+ };
2128
+ }
1632
2129
  function yamLiteRouteHint(prompt = '') {
1633
2130
  const text = String(prompt || '').toLowerCase();
1634
2131
  if (!text)
@@ -4100,7 +4597,10 @@ function pickScript(scripts = {}, groups = []) {
4100
4597
  async function detectProject(targetDir = process.cwd(), { quiet = false } = {}) {
4101
4598
  const dir = path.resolve(targetDir || process.cwd());
4102
4599
  const packageJson = path.join(dir, 'package.json');
4600
+ const changedFileDetection = gitChangedFileSnapshot(dir);
4601
+ const changedFiles = changedFileDetection.files;
4103
4602
  const result = {
4603
+ schema: 'yam.project-detection.v1',
4104
4604
  dir,
4105
4605
  packageJson: await exists(packageJson),
4106
4606
  packageManager: 'npm',
@@ -4111,12 +4611,26 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4111
4611
  test: null,
4112
4612
  build: null
4113
4613
  },
4114
- frameworkChecklist: null
4614
+ frameworkChecklist: null,
4615
+ changedFileDetection,
4616
+ changedFiles,
4617
+ verificationRecommendations: [],
4618
+ verificationCommandPlan: []
4115
4619
  };
4116
4620
  if (!result.packageJson) {
4621
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4622
+ packageManager: result.packageManager,
4623
+ scripts: {},
4624
+ commands: result.commands,
4625
+ changedFiles,
4626
+ changedFilesAvailable: changedFileDetection.available,
4627
+ packageJson: false
4628
+ });
4629
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4117
4630
  if (!quiet) {
4118
4631
  console.log(`No package.json found in ${dir}`);
4119
4632
  console.log('Suggested verification: use project pack or local framework conventions.');
4633
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4120
4634
  }
4121
4635
  return result;
4122
4636
  }
@@ -4129,10 +4643,238 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4129
4643
  result.commands.test = runCommand(result.packageManager, pickScript(scripts, ['test', 'spec']));
4130
4644
  result.commands.build = runCommand(result.packageManager, pickScript(scripts, ['build']));
4131
4645
  result.frameworkChecklist = await detectFrameworkChecklist(dir, pkg);
4646
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4647
+ packageManager: result.packageManager,
4648
+ scripts,
4649
+ commands: result.commands,
4650
+ changedFiles,
4651
+ changedFilesAvailable: changedFileDetection.available,
4652
+ packageJson: true
4653
+ });
4654
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4132
4655
  if (!quiet)
4133
4656
  printDetection(result);
4134
4657
  return result;
4135
4658
  }
4659
+ async function detectCommand(args = []) {
4660
+ const flags = parseSimpleFlags(args, new Set(['json']));
4661
+ const dir = path.resolve(firstPositional(args) || process.cwd());
4662
+ const result = await detectProject(dir, { quiet: true });
4663
+ if (flags.json) {
4664
+ console.log(JSON.stringify(result, null, 2));
4665
+ return;
4666
+ }
4667
+ printDetection(result);
4668
+ }
4669
+ function buildChangedFileVerificationRecommendations({ packageManager = 'npm', scripts = {}, commands = {}, changedFiles = [], changedFilesAvailable = true, packageJson = false }) {
4670
+ const safeFallbackCommands = uniqueStrings([
4671
+ commands.typecheck,
4672
+ commands.lint,
4673
+ commands.test,
4674
+ commands.build
4675
+ ]);
4676
+ if (!changedFilesAvailable) {
4677
+ return [{
4678
+ matched_rule: 'git-status-unavailable',
4679
+ confidence: 'low',
4680
+ reason: 'Git change status was unavailable, so yam cannot prove that the project has no changed files',
4681
+ fallback: packageJson
4682
+ ? 'run all available non-publish typecheck, lint, test, and build checks or use project-pack conventions'
4683
+ : 'use yam.project.md or local framework conventions; no Git change scope or executable package scripts were detected',
4684
+ files: [],
4685
+ suggested_commands: safeFallbackCommands
4686
+ }];
4687
+ }
4688
+ if (!changedFiles.length) {
4689
+ return [{
4690
+ matched_rule: 'no-changes',
4691
+ confidence: 'high',
4692
+ reason: 'git reports no dirty, staged, or untracked project files',
4693
+ fallback: 'rerun `yam detect --json` after files change; use project-pack conventions if Git is unavailable',
4694
+ files: [],
4695
+ suggested_commands: []
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
+ const grouped = new Map();
4735
+ for (const rawFile of changedFiles) {
4736
+ const file = String(rawFile || '').replaceAll('\\', '/');
4737
+ const rule = rules.find((candidate) => candidate.matches(file)) || rules[rules.length - 1];
4738
+ grouped.set(rule.id, [...(grouped.get(rule.id) || []), file]);
4739
+ }
4740
+ const recommendations = [];
4741
+ for (const rule of rules) {
4742
+ const files = grouped.get(rule.id) || [];
4743
+ if (!files.length)
4744
+ continue;
4745
+ let commandEvidence = rule.commandNames.map((name) => ({
4746
+ name,
4747
+ files,
4748
+ basis: 'rule-wide'
4749
+ }));
4750
+ if (rule.id === 'src-lib') {
4751
+ commandEvidence = [
4752
+ { name: 'typecheck', files, basis: 'rule-wide' },
4753
+ ...focusedSmokeScriptEvidenceForFiles(scripts, files)
4754
+ ];
4755
+ }
4756
+ else if (rule.id === 'scripts') {
4757
+ const directEvidence = files.flatMap((file) => (directScriptNamesForFile(scripts, file).map((name) => ({
4758
+ name,
4759
+ files: [file],
4760
+ basis: 'direct-script-wrapper'
4761
+ }))));
4762
+ commandEvidence = directEvidence.length
4763
+ ? directEvidence
4764
+ : focusedSmokeScriptNames(scripts).slice(0, 1).map((name) => ({
4765
+ name,
4766
+ files,
4767
+ basis: 'group-fallback'
4768
+ }));
4769
+ }
4770
+ else if (rule.id === 'unknown-safe-fallback') {
4771
+ commandEvidence = safeFallbackCommands.map((command) => ({
4772
+ command,
4773
+ files,
4774
+ basis: 'safe-fallback'
4775
+ }));
4776
+ }
4777
+ const commandSources = commandEvidence
4778
+ .map((evidence) => ({
4779
+ command: evidence.command || scriptCommandIfPresent(packageManager, scripts, evidence.name),
4780
+ files: [...new Set(evidence.files || [])].sort(),
4781
+ basis: evidence.basis
4782
+ }))
4783
+ .filter((evidence) => evidence.command);
4784
+ const suggestedCommands = uniqueStrings(commandSources.map((evidence) => evidence.command));
4785
+ recommendations.push({
4786
+ matched_rule: rule.id,
4787
+ confidence: rule.confidence,
4788
+ reason: rule.reason,
4789
+ fallback: rule.fallback,
4790
+ files: [...new Set(files)].sort(),
4791
+ suggested_commands: suggestedCommands,
4792
+ command_sources: commandSources
4793
+ });
4794
+ }
4795
+ return recommendations;
4796
+ }
4797
+ function focusedSmokeScriptNames(scripts = {}) {
4798
+ return Object.keys(scripts)
4799
+ .filter((name) => /(?:^|[-:])(?:smoke|test)(?:$|[-:])/i.test(name) || /smoke/i.test(String(scripts[name] || '')))
4800
+ .sort();
4801
+ }
4802
+ function focusedSmokeScriptEvidenceForFiles(scripts = {}, files = []) {
4803
+ const candidates = focusedSmokeScriptNames(scripts);
4804
+ const selected = [];
4805
+ for (const file of files) {
4806
+ const stem = path.posix.basename(String(file || '').replaceAll('\\', '/')).replace(/\.[^.]+$/, '');
4807
+ const direct = candidates.find((name) => {
4808
+ const command = String(scripts[name] || '');
4809
+ return name.includes(stem)
4810
+ || command.includes(stem)
4811
+ || (stem === 'skill-installation' && /install.*smoke|smoke.*install/i.test(`${name} ${command}`))
4812
+ || (['trust-kernel', 'ueye-artifacts'].includes(stem) && /cli.*smoke|smoke.*cli/i.test(`${name} ${command}`));
4813
+ });
4814
+ if (direct)
4815
+ selected.push({
4816
+ name: direct,
4817
+ files: [file],
4818
+ basis: 'focused-file-match'
4819
+ });
4820
+ }
4821
+ if (!selected.length && candidates.length) {
4822
+ selected.push({
4823
+ name: candidates[0],
4824
+ files,
4825
+ basis: 'group-fallback'
4826
+ });
4827
+ }
4828
+ return selected;
4829
+ }
4830
+ function directScriptNamesForFile(scripts = {}, changedFile = '') {
4831
+ const normalized = String(changedFile || '').replaceAll('\\', '/');
4832
+ const basename = path.posix.basename(normalized);
4833
+ return Object.entries(scripts)
4834
+ .filter(([, command]) => {
4835
+ const text = String(command || '').replaceAll('\\', '/');
4836
+ return text.includes(normalized) || text.includes(`/${basename}`) || text.includes(` ${basename}`);
4837
+ })
4838
+ .map(([name]) => name)
4839
+ .sort();
4840
+ }
4841
+ function scriptCommandIfPresent(packageManager, scripts = {}, name = '') {
4842
+ return name && Object.prototype.hasOwnProperty.call(scripts, name)
4843
+ ? runCommand(packageManager, name)
4844
+ : null;
4845
+ }
4846
+ function uniqueStrings(values = []) {
4847
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
4848
+ }
4849
+ function buildVerificationCommandPlan(recommendations = []) {
4850
+ const commands = new Map();
4851
+ for (const recommendation of recommendations) {
4852
+ const sources = recommendation.command_sources?.length
4853
+ ? recommendation.command_sources
4854
+ : (recommendation.suggested_commands || []).map((command) => ({
4855
+ command,
4856
+ files: recommendation.files || [],
4857
+ basis: 'recommendation-fallback'
4858
+ }));
4859
+ for (const source of sources) {
4860
+ const current = commands.get(source.command) || {
4861
+ command: source.command,
4862
+ matched_rules: [],
4863
+ files: [],
4864
+ provenance: []
4865
+ };
4866
+ current.matched_rules = uniqueStrings([...current.matched_rules, recommendation.matched_rule]);
4867
+ current.files = uniqueStrings([...current.files, ...(source.files || [])]).sort();
4868
+ current.provenance.push({
4869
+ matched_rule: recommendation.matched_rule,
4870
+ files: [...new Set(source.files || [])].sort(),
4871
+ basis: source.basis
4872
+ });
4873
+ commands.set(source.command, current);
4874
+ }
4875
+ }
4876
+ return [...commands.values()];
4877
+ }
4136
4878
  function printDetection(result) {
4137
4879
  console.log(`Project: ${result.dir}`);
4138
4880
  console.log(`Package manager: ${result.packageManager}`);
@@ -4146,6 +4888,7 @@ function printDetection(result) {
4146
4888
  console.log(`- $quick: ${result.commands.typecheck || result.commands.lint || result.commands.test || result.commands.build || 'L1 inspected; no command detected'}`);
4147
4889
  console.log(`- $ueye: ${result.commands.typecheck || result.commands.build || 'Browser/screenshot check; no command detected'}`);
4148
4890
  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}`}`);
4891
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4149
4892
  if (result.frameworkChecklist?.detected) {
4150
4893
  console.log('');
4151
4894
  console.log(`Framework checklist: ${result.frameworkChecklist.framework}`);
@@ -4154,6 +4897,28 @@ function printDetection(result) {
4154
4897
  }
4155
4898
  }
4156
4899
  }
4900
+ function printChangedFileVerificationRecommendations(recommendations = [], commandPlan = []) {
4901
+ console.log('');
4902
+ console.log('Changed-file verification recommendations (advisory; commands were not run):');
4903
+ for (const recommendation of recommendations) {
4904
+ console.log(`- ${recommendation.matched_rule} [confidence: ${recommendation.confidence}]`);
4905
+ console.log(` files: ${recommendation.files.length ? recommendation.files.join(', ') : '(none)'}`);
4906
+ console.log(` reason: ${recommendation.reason}`);
4907
+ console.log(` commands: ${recommendation.suggested_commands.length ? recommendation.suggested_commands.join(' && ') : '(none detected)'}`);
4908
+ console.log(` fallback: ${recommendation.fallback}`);
4909
+ }
4910
+ console.log('');
4911
+ console.log('Verification command plan (deduplicated; advisory; commands were not run):');
4912
+ if (!commandPlan.length) {
4913
+ console.log('- (none detected)');
4914
+ return;
4915
+ }
4916
+ for (const item of commandPlan) {
4917
+ console.log(`- ${item.command}`);
4918
+ console.log(` rules: ${item.matched_rules.join(', ')}`);
4919
+ console.log(` files: ${item.files.join(', ') || '(none)'}`);
4920
+ }
4921
+ }
4157
4922
  function budget(routeArg = '') {
4158
4923
  const normalized = normalizeRoute(routeArg);
4159
4924
  const entries = normalized ? [[normalized, ROUTE_BUDGETS[normalized]]] : Object.entries(ROUTE_BUDGETS);
@@ -4532,20 +5297,43 @@ function findSensitivePattern(text = '') {
4532
5297
  function showPath() {
4533
5298
  console.log(`root: ${ROOT}`);
4534
5299
  console.log(`skills: ${DEST}`);
4535
- console.log(`codex mirror cleanup: ${CODEX_MIRROR}`);
5300
+ console.log(`codex mirror (preserved without an ownership receipt): ${CODEX_MIRROR}`);
5301
+ }
5302
+ function showRequestedHelp(args) {
5303
+ if (!args.some((arg) => arg === '--help' || arg === '-h'))
5304
+ return false;
5305
+ const commandUsage = {
5306
+ context: contextUsage,
5307
+ cleanup: cleanupUsage,
5308
+ 'study-note': studyNoteUsage,
5309
+ tools: toolsUsage,
5310
+ hook: hookUsage,
5311
+ loop: loopUsage,
5312
+ ueye: ueyeUsage,
5313
+ media: mediaUsage,
5314
+ runtime: runtimeUsage,
5315
+ mission: missionUsage,
5316
+ benchmark: benchmarkUsage,
5317
+ memory: memoryUsage
5318
+ };
5319
+ (commandUsage[args[0]] || usage)();
5320
+ return true;
4536
5321
  }
4537
5322
  async function main() {
4538
- const command = process.argv[2] || 'help';
4539
- if (command === 'help' || command === '--help' || command === '-h')
5323
+ const args = process.argv.slice(2);
5324
+ const command = args[0] || 'help';
5325
+ if (showRequestedHelp(args))
5326
+ return;
5327
+ if (command === 'help')
4540
5328
  return usage();
4541
5329
  if (command === 'install')
4542
- return install();
5330
+ return install(args.slice(1));
4543
5331
  if (command === 'uninstall')
4544
- return uninstall();
5332
+ return uninstall(args.slice(1));
4545
5333
  if (command === 'version')
4546
5334
  return console.log(VERSION);
4547
5335
  if (command === 'detect')
4548
- return detectProject(process.argv[3]);
5336
+ return detectCommand(args.slice(1));
4549
5337
  if (command === 'pack')
4550
5338
  return inspectProjectPack(process.argv[3]);
4551
5339
  if (command === 'context')