yam-flow 2.3.2 → 2.5.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,11 +35,16 @@ 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
+ import {
43
+ EXTERNAL_UPDATE_COMPONENTS,
44
+ applyExternalUpdates,
45
+ checkExternalUpdates
46
+ } from '../lib/external-updates.js';
47
+ import type { ExternalUpdateComponent } from '../lib/external-updates.js';
42
48
 
43
49
  type AnyRecord = Record<string, any>;
44
50
 
@@ -84,6 +90,13 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
84
90
  const PROJECT_PACK = 'yam.project.md';
85
91
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
86
92
  const PACK_STALE_DAYS = 30;
93
+ const INSTRUCTION_DUPLICATION_LIMITS = {
94
+ maxFiles: 24,
95
+ maxBytesPerFile: 128000,
96
+ maxGroups: 8,
97
+ maxDepth: 6,
98
+ minDirectiveChars: 40
99
+ };
87
100
  const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
88
101
  const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
89
102
  const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
@@ -151,7 +164,7 @@ Usage:
151
164
  yam list
152
165
  yam status
153
166
  yam verify
154
- yam detect [dir]
167
+ yam detect [dir] [--json]
155
168
  yam pack [dir]
156
169
  yam context pressure [dir] [--json]
157
170
  yam cleanup scan [dir] [--json]
@@ -173,6 +186,9 @@ Usage:
173
186
  yam mission queue [--agent-id id] [--scope text] [--changed file] [--verification-hint text] [--json]
174
187
  yam mission receipt [--thread-id id] [--role reviewer] [--lifecycle stopped] [--outcome passed] [--evidence text] [--json]
175
188
  yam mission gate [--expected-thread id] [--receipt file] [--json]
189
+ yam update check [--json]
190
+ yam update apply --component yam|scrapling|insane-search [--json]
191
+ yam update apply --all [--json]
176
192
  yam benchmark report [--baseline n] [--current n] [--unit ms] [--target lower|higher] [--json]
177
193
  yam release report [--json]
178
194
  yam safety [text...]
@@ -180,7 +196,7 @@ Usage:
180
196
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
181
197
  yam template <project|ueye|mission|proof|tuning>
182
198
  yam tune-log [dir]
183
- yam install
199
+ yam install [--replace-user-skill name]
184
200
  yam uninstall
185
201
  yam doctor [--json]
186
202
  yam examples
@@ -206,7 +222,32 @@ async function rmrf(target) {
206
222
  await fsp.rm(target, { recursive: true, force: true });
207
223
  }
208
224
 
209
- async function install() {
225
+ function installReplacementSkills(args: string[] = []) {
226
+ const skills: string[] = [];
227
+ for (let index = 0; index < args.length; index += 1) {
228
+ const arg = String(args[index] || '');
229
+ if (arg === '--replace-user-skill') {
230
+ const skill = String(args[index + 1] || '');
231
+ if (!skill || skill.startsWith('--')) {
232
+ throw new Error('missing skill name after --replace-user-skill');
233
+ }
234
+ skills.push(skill);
235
+ index += 1;
236
+ continue;
237
+ }
238
+ if (arg.startsWith('--replace-user-skill=')) {
239
+ const skill = arg.slice('--replace-user-skill='.length);
240
+ if (!skill) throw new Error('missing skill name after --replace-user-skill=');
241
+ skills.push(skill);
242
+ continue;
243
+ }
244
+ throw new Error(`unknown yam install argument: ${arg}`);
245
+ }
246
+ return [...new Set(skills)];
247
+ }
248
+
249
+ async function install(args: string[] = []) {
250
+ const replaceSkills = installReplacementSkills(args);
210
251
  const result = await installSkillSetTransactional({
211
252
  sourceRoot: ROOT,
212
253
  destination: DEST,
@@ -214,6 +255,7 @@ async function install() {
214
255
  packageName: String(PACKAGE_JSON.name || 'yam-flow'),
215
256
  version: VERSION,
216
257
  skills: SKILLS,
258
+ replaceSkills,
217
259
  legacySkills: LEGACY_SKILLS,
218
260
  retiredSkills: RETIRED_SKILLS
219
261
  });
@@ -224,20 +266,20 @@ async function install() {
224
266
  console.log('Restart Codex to reload skills.');
225
267
  }
226
268
 
227
- async function uninstall() {
228
- const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
229
- if (await exists(lockPath)) {
230
- throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
231
- }
232
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
233
- await rmrf(path.join(DEST, skill));
234
- if (CODEX_MIRROR !== DEST) {
235
- await rmrf(path.join(CODEX_MIRROR, skill));
236
- }
237
- }
238
- await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
269
+ async function uninstall(args: string[] = []) {
270
+ if (args.length) throw new Error(`unknown yam uninstall argument: ${args[0]}`);
271
+ const result = await uninstallSkillSetSafely({
272
+ destination: DEST,
273
+ codexMirror: CODEX_MIRROR,
274
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
275
+ skills: SKILLS
276
+ });
239
277
  console.log(`yam removed from ${DEST}`);
240
- if (CODEX_MIRROR !== DEST) console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
278
+ console.log(`removed receipt: ${result.receiptPath}`);
279
+ for (const warning of result.cleanupWarnings) console.warn(`warning: ${warning}`);
280
+ if (CODEX_MIRROR !== DEST) {
281
+ console.log(`unowned legacy, retired, and Codex mirror entries were preserved`);
282
+ }
241
283
  console.log('Restart Codex to unload skills.');
242
284
  }
243
285
 
@@ -426,12 +468,20 @@ async function doctor(args = []) {
426
468
  console.log('yam doctor: ok');
427
469
  console.log('No hooks, automations, or global config are required.');
428
470
  console.log('Skill install state is reported by `yam status`.');
471
+ if (report.preservedUnprovenSkillEntries.length) {
472
+ console.log(`Preserved unproven legacy/retired/mirror entries: ${report.preservedUnprovenSkillEntries.length}`);
473
+ }
429
474
  console.log(`yam-lite hook: ${report.yamLiteHook} (optional)`);
430
475
  return;
431
476
  }
432
477
 
433
478
  console.log('yam doctor: issues');
434
479
  for (const issue of report.issues) console.log(`- ${issue}`);
480
+ if (report.preservedUnprovenSkillEntries.length) {
481
+ console.log('');
482
+ console.log('Preserved unproven skill entries:');
483
+ for (const entry of report.preservedUnprovenSkillEntries) console.log(`- ${entry}`);
484
+ }
435
485
  if (report.nextActions.length) {
436
486
  console.log('');
437
487
  console.log('Next actions:');
@@ -442,6 +492,7 @@ async function doctor(args = []) {
442
492
 
443
493
  async function buildDoctorReport() {
444
494
  const issues = [];
495
+ const preservedUnprovenSkillEntries = [];
445
496
  for (const skill of SKILLS) {
446
497
  if (!await exists(path.join(ROOT, 'skills', skill, 'SKILL.md'))) issues.push(`missing source skill: ${skill}`);
447
498
  }
@@ -450,15 +501,21 @@ async function buildDoctorReport() {
450
501
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'yam'))) issues.push('unexpected yam automation');
451
502
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'timeto'))) issues.push('unexpected legacy timeto automation');
452
503
  for (const legacySkill of LEGACY_SKILLS) {
453
- if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) issues.push(`unexpected legacy installed skill: ${legacySkill}`);
454
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, legacySkill, 'SKILL.md'))) {
455
- issues.push(`unexpected legacy mirror skill: ${legacySkill}`);
504
+ if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) {
505
+ preservedUnprovenSkillEntries.push(`legacy-named destination entry: ${legacySkill}`);
456
506
  }
457
507
  }
458
508
  for (const retiredSkill of RETIRED_SKILLS) {
459
- if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) issues.push(`unexpected retired installed skill: ${retiredSkill}`);
460
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, retiredSkill, 'SKILL.md'))) {
461
- issues.push(`unexpected retired mirror skill: ${retiredSkill}`);
509
+ if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) {
510
+ preservedUnprovenSkillEntries.push(`retired-name destination entry: ${retiredSkill}`);
511
+ }
512
+ }
513
+ if (CODEX_MIRROR !== DEST) {
514
+ const mirrorSkillNames = [...new Set([...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS])];
515
+ for (const skill of mirrorSkillNames) {
516
+ if (!await exists(path.join(CODEX_MIRROR, skill, 'SKILL.md'))) continue;
517
+ const nameKind = SKILLS.includes(skill) ? 'active-name' : LEGACY_SKILLS.includes(skill) ? 'legacy-named' : 'retired-name';
518
+ preservedUnprovenSkillEntries.push(`${nameKind} Codex mirror entry: ${skill}`);
462
519
  }
463
520
  }
464
521
  const verifyIssues = await verify({ quiet: true });
@@ -470,6 +527,7 @@ async function buildDoctorReport() {
470
527
  generatedAt: new Date().toISOString(),
471
528
  ok: issues.length === 0,
472
529
  issues,
530
+ preservedUnprovenSkillEntries,
473
531
  nextActions: nextActionDetails.map((action) => action.next_action),
474
532
  nextActionDetails,
475
533
  yamLiteHook: hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled'
@@ -487,7 +545,6 @@ function doctorNextActionDetails(issues = []) {
487
545
  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'));
488
546
  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'));
489
547
  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'));
490
- 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'));
491
548
  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'));
492
549
  }
493
550
  return uniqueNextActionDetails(actions);
@@ -765,19 +822,28 @@ async function studyNoteCheck(args = []) {
765
822
  }
766
823
 
767
824
  async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
768
- const changedFiles = await gitChangedFiles(dir);
769
- const needsStudyNote = changedFiles.length > 0;
825
+ const changedFileDetection = gitChangedFileSnapshot(dir);
826
+ const changedFiles = changedFileDetection.files;
827
+ const changeScopeAvailable = changedFileDetection.available;
828
+ const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
829
+ const scopeAwareStatus = (passes) => changeScopeAvailable ? (!needsStudyNote || passes ? 'pass' : 'fail') : 'partial';
770
830
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
771
831
  const checks = [
772
- studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
773
- studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
774
- studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
775
- studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
776
- studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
777
- studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
778
- studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
779
- studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
780
- studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
832
+ studyNoteGuardCheck(
833
+ 'changed_files',
834
+ changeScopeAvailable ? (needsStudyNote ? 'pass' : 'skipped') : 'partial',
835
+ changeScopeAvailable
836
+ ? (needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected')
837
+ : 'Git changed-file scope is unavailable; do not treat this project as clean and determine Study Note need manually'
838
+ ),
839
+ studyNoteGuardCheck('study_note_present', scopeAwareStatus(hasStudyNoteMarker(reportText)), 'final report should include a Study Note when project artifacts changed'),
840
+ studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText)), 'Study Note should explain what the touched code/artifact does'),
841
+ 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'),
842
+ 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'),
843
+ studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText)), 'Study Note should describe the expected behavior or result'),
844
+ 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'),
845
+ studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText)), 'Study Note should say what was checked'),
846
+ 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')
781
847
  ];
782
848
  for (const requirement of hygieneRequirements) {
783
849
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -787,13 +853,18 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
787
853
  schema: 'yam.study-note-guard.v1',
788
854
  generated_at: new Date().toISOString(),
789
855
  project: dir,
856
+ changed_file_detection: changedFileDetection,
790
857
  changed_files: changedFiles,
791
858
  changed_file_count: changedFiles.length,
792
859
  report_source: reportSource,
793
860
  checks,
794
861
  blockers,
795
- next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
796
- truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
862
+ next_action: blockers[0] || (!changeScopeAvailable
863
+ ? 'Git change scope is unavailable; inspect changed artifacts manually and include a Study Note when anything changed'
864
+ : needsStudyNote
865
+ ? 'Study Note guard passed for the supplied report text'
866
+ : 'no Study Note required because no changed files were detected'),
867
+ truth_status: blockers.length ? 'blocked' : !changeScopeAvailable ? 'partial' : needsStudyNote ? 'verified' : 'skipped'
797
868
  };
798
869
  return result;
799
870
  }
@@ -811,14 +882,35 @@ function firstPositional(args = []) {
811
882
  return '';
812
883
  }
813
884
 
814
- async function gitChangedFiles(dir) {
815
- const output = runReadOnlyCommandIn(dir, 'git', ['status', '--short']);
816
- return output
817
- .split(/\r?\n/)
818
- .filter(Boolean)
819
- .map((line) => line.replace(/^..\s+/, '').replace(/^.* -> /, ''))
820
- .map((line) => line.trim())
821
- .filter((file) => file && !file.startsWith('dist/') && !file.endsWith('.tgz'));
885
+ function gitChangedFileSnapshot(dir) {
886
+ const result = spawnSync('git', ['status', '--short', '-z', '--untracked-files=all'], {
887
+ cwd: dir,
888
+ encoding: 'utf8',
889
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
890
+ timeout: 30000
891
+ });
892
+ if (result.status !== 0) {
893
+ return {
894
+ available: false,
895
+ source: 'git status --short -z --untracked-files=all',
896
+ files: []
897
+ };
898
+ }
899
+ const records = String(result.stdout || '').split('\0');
900
+ const files = [];
901
+ for (let index = 0; index < records.length; index += 1) {
902
+ const record = records[index];
903
+ if (!record || record.length < 4) continue;
904
+ const status = record.slice(0, 2);
905
+ const file = record.slice(3);
906
+ if (/[RC]/.test(status)) index += 1;
907
+ if (file && !file.startsWith('dist/') && !file.endsWith('.tgz')) files.push(file);
908
+ }
909
+ return {
910
+ available: true,
911
+ source: 'git status --short -z --untracked-files=all',
912
+ files
913
+ };
822
914
  }
823
915
 
824
916
  async function readStudyNoteReportText(flags, dir) {
@@ -911,12 +1003,22 @@ async function buildCleanupScanReport(targetDir = process.cwd()) {
911
1003
  for (const artifact of await staleYamArtifacts(dir)) {
912
1004
  findings.push(cleanupFinding('low', artifact.file, artifact.reason, 'archive or refresh only if it misleads the current work'));
913
1005
  }
1006
+ const instructionDuplicationBudget = await buildInstructionDuplicationBudget(dir);
1007
+ if (instructionDuplicationBudget.duplicate_group_count > 0) {
1008
+ findings.push(cleanupFinding(
1009
+ 'low',
1010
+ 'AGENTS.md/SKILL.md directive duplication budget',
1011
+ `${instructionDuplicationBudget.duplicate_group_count} normalized directive group(s) repeat across separate instruction surfaces; exact normalized matches are advisory and may be intentional`,
1012
+ 'review the reported source files and consolidate only repetition that causes drift or conflicting maintenance'
1013
+ ));
1014
+ }
914
1015
  return {
915
1016
  schema: 'yam.cleanup-scan.v1',
916
1017
  generated_at: new Date().toISOString(),
917
1018
  project: dir,
918
1019
  destructive: false,
919
1020
  findings,
1021
+ instruction_duplication_budget: instructionDuplicationBudget,
920
1022
  risk_level: overallCleanupRisk(findings),
921
1023
  safe_next_action: findings.length ? 'review findings and make explicit user-approved cleanup decisions' : 'no cleanup action needed',
922
1024
  truth_status: 'partial'
@@ -970,6 +1072,163 @@ async function staleYamArtifacts(dir) {
970
1072
  return artifacts;
971
1073
  }
972
1074
 
1075
+ async function buildInstructionDuplicationBudget(
1076
+ rootDir = process.cwd(),
1077
+ limits = INSTRUCTION_DUPLICATION_LIMITS
1078
+ ) {
1079
+ const root = path.resolve(rootDir || process.cwd());
1080
+ const inventory = await collectInstructionFiles(root, limits);
1081
+ const directives = new Map<string, AnyRecord[]>();
1082
+ let candidateDirectiveCount = 0;
1083
+ for (const relativeFile of inventory.files) {
1084
+ const absolute = path.join(root, relativeFile);
1085
+ const text = await readBoundedUtf8File(absolute, limits.maxBytesPerFile);
1086
+ for (const directive of instructionDirectives(text, relativeFile, limits.minDirectiveChars)) {
1087
+ candidateDirectiveCount += 1;
1088
+ directives.set(directive.normalized, [...(directives.get(directive.normalized) || []), directive]);
1089
+ }
1090
+ }
1091
+
1092
+ const allGroups = [...directives.entries()]
1093
+ .map(([normalized, occurrences]) => {
1094
+ const surfaces = [...new Set(occurrences.map((item) => item.file))];
1095
+ if (surfaces.length < 2) return null;
1096
+ return {
1097
+ directive_digest: createHash('sha256').update(normalized).digest('hex').slice(0, 16),
1098
+ directive_preview: redactSensitiveText(String(occurrences[0]?.text || '')).slice(0, 180),
1099
+ occurrence_count: occurrences.length,
1100
+ surface_count: surfaces.length,
1101
+ surfaces,
1102
+ occurrences: occurrences.slice(0, 8).map((item) => ({
1103
+ file: item.file,
1104
+ line: item.line
1105
+ }))
1106
+ };
1107
+ })
1108
+ .filter(Boolean)
1109
+ .sort((left, right) => (
1110
+ right.surface_count - left.surface_count
1111
+ || right.occurrence_count - left.occurrence_count
1112
+ || left.directive_digest.localeCompare(right.directive_digest)
1113
+ ));
1114
+ const duplicateGroups = allGroups.slice(0, limits.maxGroups);
1115
+ return {
1116
+ schema: 'yam.instruction-duplication-budget.v1',
1117
+ scope: 'project AGENTS.md and SKILL.md files only',
1118
+ advisory_only: true,
1119
+ hard_gate: false,
1120
+ destructive: false,
1121
+ normalization: 'exact normalized markdown directive text; no semantic similarity inference',
1122
+ limits: {
1123
+ max_files: limits.maxFiles,
1124
+ max_bytes_per_file: limits.maxBytesPerFile,
1125
+ max_groups: limits.maxGroups,
1126
+ max_depth: limits.maxDepth,
1127
+ min_directive_chars: limits.minDirectiveChars
1128
+ },
1129
+ files_scanned: inventory.files.length,
1130
+ scanned_files: inventory.files,
1131
+ candidate_directive_count: candidateDirectiveCount,
1132
+ duplicate_group_count: allGroups.length,
1133
+ duplicate_groups: duplicateGroups,
1134
+ file_limit_reached: inventory.fileLimitReached,
1135
+ group_limit_reached: allGroups.length > duplicateGroups.length,
1136
+ truth_status: 'partial',
1137
+ note: allGroups.length
1138
+ ? 'repetition may be intentional; review before consolidating'
1139
+ : 'no cross-surface exact normalized directive repetition found within the bounded scan'
1140
+ };
1141
+ }
1142
+
1143
+ async function collectInstructionFiles(root, limits) {
1144
+ const files: string[] = [];
1145
+ let fileLimitReached = false;
1146
+
1147
+ async function walk(current, depth) {
1148
+ if (depth > limits.maxDepth || fileLimitReached) return;
1149
+ let entries = [];
1150
+ try {
1151
+ entries = await fsp.readdir(current, { withFileTypes: true });
1152
+ } catch {
1153
+ return;
1154
+ }
1155
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1156
+ for (const entry of entries) {
1157
+ const absolute = path.join(current, entry.name);
1158
+ const relative = path.relative(root, absolute);
1159
+ if (shouldSkipScanPath(relative, entry.name) || entry.isSymbolicLink()) continue;
1160
+ if (entry.isDirectory()) {
1161
+ await walk(absolute, depth + 1);
1162
+ if (fileLimitReached) return;
1163
+ continue;
1164
+ }
1165
+ if (!entry.isFile() || !['AGENTS.md', 'SKILL.md'].includes(entry.name)) continue;
1166
+ if (files.length >= limits.maxFiles) {
1167
+ fileLimitReached = true;
1168
+ return;
1169
+ }
1170
+ files.push(relative);
1171
+ }
1172
+ }
1173
+
1174
+ await walk(root, 0);
1175
+ return { files, fileLimitReached };
1176
+ }
1177
+
1178
+ async function readBoundedUtf8File(file, maxBytes) {
1179
+ const handle = await fsp.open(file, 'r');
1180
+ try {
1181
+ const buffer = Buffer.alloc(maxBytes);
1182
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1183
+ return buffer.subarray(0, bytesRead).toString('utf8');
1184
+ } finally {
1185
+ await handle.close();
1186
+ }
1187
+ }
1188
+
1189
+ function instructionDirectives(text = '', relativeFile = '', minChars = 40) {
1190
+ const directives = [];
1191
+ const lines = String(text || '').split(/\r?\n/);
1192
+ let inFence = false;
1193
+ let inFrontmatter = lines[0]?.trim() === '---';
1194
+ for (let index = 0; index < lines.length; index += 1) {
1195
+ const raw = lines[index];
1196
+ const trimmed = raw.trim();
1197
+ if (index === 0 && inFrontmatter) continue;
1198
+ if (inFrontmatter) {
1199
+ if (trimmed === '---') inFrontmatter = false;
1200
+ continue;
1201
+ }
1202
+ if (/^(```|~~~)/.test(trimmed)) {
1203
+ inFence = !inFence;
1204
+ continue;
1205
+ }
1206
+ if (inFence || !trimmed || /^#{1,6}\s/.test(trimmed)) continue;
1207
+ const match = trimmed.match(/^(?:[-*+]|\d+[.)])\s+(.+)$/);
1208
+ if (!match) continue;
1209
+ const normalized = normalizeInstructionDirective(match[1]);
1210
+ if (normalized.length < minChars) continue;
1211
+ directives.push({
1212
+ file: relativeFile,
1213
+ line: index + 1,
1214
+ text: match[1].trim().slice(0, 240),
1215
+ normalized
1216
+ });
1217
+ }
1218
+ return directives;
1219
+ }
1220
+
1221
+ function normalizeInstructionDirective(value = '') {
1222
+ return String(value || '')
1223
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1224
+ .replace(/[`*_~]/g, '')
1225
+ .replace(/<[^>]+>/g, ' ')
1226
+ .replace(/\s+/g, ' ')
1227
+ .replace(/[.;:,!?。,、;:!?]+$/g, '')
1228
+ .trim()
1229
+ .toLowerCase();
1230
+ }
1231
+
973
1232
  function printCleanupScanReport(report) {
974
1233
  console.log('yam cleanup scan');
975
1234
  console.log(`Project: ${report.project}`);
@@ -1829,10 +2088,16 @@ async function buildYamLiteContext({ cwd, prompt }) {
1829
2088
 
1830
2089
  async function buildStudyNoteHookContext({ cwd }) {
1831
2090
  const dir = path.resolve(cwd || process.cwd());
1832
- const changedFiles = await gitChangedFiles(dir);
2091
+ const changedFileDetection = gitChangedFileSnapshot(dir);
2092
+ const changedFiles = changedFileDetection.files;
1833
2093
  const lines = [
1834
2094
  '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.'
1835
2095
  ];
2096
+ if (!changedFileDetection.available) {
2097
+ lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2098
+ lines.push('Determine manually whether artifacts changed and include the Study Note when they did; the Stop hook will keep this uncertainty visible.');
2099
+ return lines.join('\n');
2100
+ }
1836
2101
  if (!changedFiles.length) {
1837
2102
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
1838
2103
  lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
@@ -1852,6 +2117,12 @@ async function buildStudyNoteHookContext({ cwd }) {
1852
2117
  async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd()) {
1853
2118
  const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
1854
2119
  const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2120
+ if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2121
+ return {
2122
+ continue: true,
2123
+ 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.'
2124
+ };
2125
+ }
1855
2126
  if (!result.blockers.length) return { continue: true };
1856
2127
  const summary = result.blockers.slice(0, 4).join('; ');
1857
2128
  if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
@@ -3302,6 +3573,83 @@ async function mission(args = []) {
3302
3573
  return missionUsage();
3303
3574
  }
3304
3575
 
3576
+ async function updateCommand(args: string[] = []) {
3577
+ const subcommand = args[0] || 'help';
3578
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return updateUsage();
3579
+ if (subcommand === 'check') {
3580
+ const rest = args.slice(1);
3581
+ const invalid = rest.filter((arg) => arg !== '--json');
3582
+ if (invalid.length || rest.filter((arg) => arg === '--json').length > 1) {
3583
+ throw new Error(`invalid update check option: ${invalid[0] || '--json repeated'}`);
3584
+ }
3585
+ const report = await checkExternalUpdates(VERSION);
3586
+ printJsonOrHuman(report, rest.includes('--json'), 'External update check');
3587
+ if (!report.success) process.exitCode = 1;
3588
+ return;
3589
+ }
3590
+ if (subcommand !== 'apply') {
3591
+ throw new Error(`unknown update command: ${subcommand}`);
3592
+ }
3593
+
3594
+ const parsed = parseUpdateApplyArgs(args.slice(1));
3595
+ const report = await applyExternalUpdates(VERSION, {
3596
+ component: parsed.component,
3597
+ all: parsed.all
3598
+ });
3599
+ printJsonOrHuman(report, parsed.json, 'External update apply');
3600
+ if (!report.success) process.exitCode = 1;
3601
+ }
3602
+
3603
+ function parseUpdateApplyArgs(args: string[]) {
3604
+ let component: ExternalUpdateComponent | undefined;
3605
+ let all = false;
3606
+ let json = false;
3607
+ for (let index = 0; index < args.length; index += 1) {
3608
+ const arg = String(args[index] || '');
3609
+ if (arg === '--json') {
3610
+ if (json) throw new Error('--json may be supplied only once');
3611
+ json = true;
3612
+ continue;
3613
+ }
3614
+ if (arg === '--all') {
3615
+ if (all) throw new Error('--all may be supplied only once');
3616
+ all = true;
3617
+ continue;
3618
+ }
3619
+ if (arg === '--component' || arg.startsWith('--component=')) {
3620
+ if (component) throw new Error('--component may be supplied only once');
3621
+ const value = arg === '--component' ? String(args[index + 1] || '') : arg.slice('--component='.length);
3622
+ if (arg === '--component') index += 1;
3623
+ if (!EXTERNAL_UPDATE_COMPONENTS.includes(value as ExternalUpdateComponent)) {
3624
+ throw new Error('component must be one of: yam, scrapling, insane-search');
3625
+ }
3626
+ component = value as ExternalUpdateComponent;
3627
+ continue;
3628
+ }
3629
+ throw new Error(`invalid update apply option: ${arg}`);
3630
+ }
3631
+ if ((all && component) || (!all && !component)) {
3632
+ throw new Error('choose exactly one of --component yam|scrapling|insane-search or --all');
3633
+ }
3634
+ return { component, all, json };
3635
+ }
3636
+
3637
+ function updateUsage() {
3638
+ console.log(`yam update
3639
+
3640
+ Usage:
3641
+ yam update check [--json]
3642
+ yam update apply --component yam|scrapling|insane-search [--json]
3643
+ yam update apply --all [--json]
3644
+
3645
+ Safety:
3646
+ check is read-only. apply requires an explicit component or --all.
3647
+ --all updates Scrapling and Insane Search before yam, then stops at the first failed/manual component.
3648
+ Insane Search is updated only through the official Codex CLI; plugin caches are never edited directly.
3649
+ Every apply attempt records component checks, outcome, side effects, and rollback guidance.
3650
+ `);
3651
+ }
3652
+
3305
3653
  function missionUsage() {
3306
3654
  console.log(`yam mission
3307
3655
 
@@ -4324,7 +4672,10 @@ function pickScript(scripts = {}, groups = []) {
4324
4672
  async function detectProject(targetDir = process.cwd(), { quiet = false } = {}) {
4325
4673
  const dir = path.resolve(targetDir || process.cwd());
4326
4674
  const packageJson = path.join(dir, 'package.json');
4675
+ const changedFileDetection = gitChangedFileSnapshot(dir);
4676
+ const changedFiles = changedFileDetection.files;
4327
4677
  const result = {
4678
+ schema: 'yam.project-detection.v1',
4328
4679
  dir,
4329
4680
  packageJson: await exists(packageJson),
4330
4681
  packageManager: 'npm',
@@ -4335,12 +4686,26 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4335
4686
  test: null,
4336
4687
  build: null
4337
4688
  },
4338
- frameworkChecklist: null
4689
+ frameworkChecklist: null,
4690
+ changedFileDetection,
4691
+ changedFiles,
4692
+ verificationRecommendations: [],
4693
+ verificationCommandPlan: []
4339
4694
  };
4340
4695
  if (!result.packageJson) {
4696
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4697
+ packageManager: result.packageManager,
4698
+ scripts: {},
4699
+ commands: result.commands,
4700
+ changedFiles,
4701
+ changedFilesAvailable: changedFileDetection.available,
4702
+ packageJson: false
4703
+ });
4704
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4341
4705
  if (!quiet) {
4342
4706
  console.log(`No package.json found in ${dir}`);
4343
4707
  console.log('Suggested verification: use project pack or local framework conventions.');
4708
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4344
4709
  }
4345
4710
  return result;
4346
4711
  }
@@ -4354,11 +4719,255 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4354
4719
  result.commands.test = runCommand(result.packageManager, pickScript(scripts, ['test', 'spec']));
4355
4720
  result.commands.build = runCommand(result.packageManager, pickScript(scripts, ['build']));
4356
4721
  result.frameworkChecklist = await detectFrameworkChecklist(dir, pkg);
4722
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4723
+ packageManager: result.packageManager,
4724
+ scripts,
4725
+ commands: result.commands,
4726
+ changedFiles,
4727
+ changedFilesAvailable: changedFileDetection.available,
4728
+ packageJson: true
4729
+ });
4730
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4357
4731
 
4358
4732
  if (!quiet) printDetection(result);
4359
4733
  return result;
4360
4734
  }
4361
4735
 
4736
+ async function detectCommand(args = []) {
4737
+ const flags = parseSimpleFlags(args, new Set(['json']));
4738
+ const dir = path.resolve(firstPositional(args) || process.cwd());
4739
+ const result = await detectProject(dir, { quiet: true });
4740
+ if (flags.json) {
4741
+ console.log(JSON.stringify(result, null, 2));
4742
+ return;
4743
+ }
4744
+ printDetection(result);
4745
+ }
4746
+
4747
+ function buildChangedFileVerificationRecommendations({
4748
+ packageManager = 'npm',
4749
+ scripts = {},
4750
+ commands = {},
4751
+ changedFiles = [],
4752
+ changedFilesAvailable = true,
4753
+ packageJson = false
4754
+ }: AnyRecord) {
4755
+ const safeFallbackCommands = uniqueStrings([
4756
+ commands.typecheck,
4757
+ commands.lint,
4758
+ commands.test,
4759
+ commands.build
4760
+ ]);
4761
+ if (!changedFilesAvailable) {
4762
+ return [{
4763
+ matched_rule: 'git-status-unavailable',
4764
+ confidence: 'low',
4765
+ reason: 'Git change status was unavailable, so yam cannot prove that the project has no changed files',
4766
+ fallback: packageJson
4767
+ ? 'run all available non-publish typecheck, lint, test, and build checks or use project-pack conventions'
4768
+ : 'use yam.project.md or local framework conventions; no Git change scope or executable package scripts were detected',
4769
+ files: [],
4770
+ suggested_commands: safeFallbackCommands
4771
+ }];
4772
+ }
4773
+ if (!changedFiles.length) {
4774
+ return [{
4775
+ matched_rule: 'no-changes',
4776
+ confidence: 'high',
4777
+ reason: 'git reports no dirty, staged, or untracked project files',
4778
+ fallback: 'rerun `yam detect --json` after files change; use project-pack conventions if Git is unavailable',
4779
+ files: [],
4780
+ suggested_commands: []
4781
+ }];
4782
+ }
4783
+
4784
+ const rules = [
4785
+ {
4786
+ id: 'package-release-metadata',
4787
+ confidence: 'high',
4788
+ 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),
4789
+ reason: 'package or release metadata changed, so package contents and release readiness can drift',
4790
+ commandNames: ['package-boundary:check', 'release:check'],
4791
+ fallback: 'run the available typecheck/build/package checks and perform the public release workflow separately'
4792
+ },
4793
+ {
4794
+ id: 'src-lib',
4795
+ confidence: 'high',
4796
+ matches: (file) => /^src\/lib\//i.test(file),
4797
+ reason: 'shared library code changed, so compile safety and a focused behavior smoke are the narrowest honest checks',
4798
+ commandNames: ['typecheck'],
4799
+ fallback: 'run typecheck plus the closest available test/smoke; use the full safe fallback when the library crosses package boundaries'
4800
+ },
4801
+ {
4802
+ id: 'scripts',
4803
+ confidence: 'medium',
4804
+ matches: (file) => /^scripts\//i.test(file),
4805
+ reason: 'an executable verification or maintenance script changed, so its direct npm wrapper or closest smoke should run',
4806
+ commandNames: [],
4807
+ fallback: 'run the changed script in an isolated fixture, then use the full safe fallback if no direct wrapper exists'
4808
+ },
4809
+ {
4810
+ id: 'unknown-safe-fallback',
4811
+ confidence: packageJson ? 'medium' : 'low',
4812
+ matches: () => true,
4813
+ reason: 'no focused changed-file rule matched, so skipping verification would be weaker than the available project-wide safe checks',
4814
+ commandNames: [],
4815
+ fallback: packageJson
4816
+ ? 'run all available non-publish typecheck, lint, test, and build checks'
4817
+ : 'use yam.project.md or local framework conventions; no executable package scripts were detected'
4818
+ }
4819
+ ];
4820
+
4821
+ const grouped = new Map<string, string[]>();
4822
+ for (const rawFile of changedFiles) {
4823
+ const file = String(rawFile || '').replaceAll('\\', '/');
4824
+ const rule = rules.find((candidate) => candidate.matches(file)) || rules[rules.length - 1];
4825
+ grouped.set(rule.id, [...(grouped.get(rule.id) || []), file]);
4826
+ }
4827
+
4828
+ const recommendations = [];
4829
+ for (const rule of rules) {
4830
+ const files = grouped.get(rule.id) || [];
4831
+ if (!files.length) continue;
4832
+ let commandEvidence: AnyRecord[] = rule.commandNames.map((name) => ({
4833
+ name,
4834
+ files,
4835
+ basis: 'rule-wide'
4836
+ }));
4837
+ if (rule.id === 'src-lib') {
4838
+ commandEvidence = [
4839
+ { name: 'typecheck', files, basis: 'rule-wide' },
4840
+ ...focusedSmokeScriptEvidenceForFiles(scripts, files)
4841
+ ];
4842
+ } else if (rule.id === 'scripts') {
4843
+ const directEvidence = files.flatMap((file) => (
4844
+ directScriptNamesForFile(scripts, file).map((name) => ({
4845
+ name,
4846
+ files: [file],
4847
+ basis: 'direct-script-wrapper'
4848
+ }))
4849
+ ));
4850
+ commandEvidence = directEvidence.length
4851
+ ? directEvidence
4852
+ : focusedSmokeScriptNames(scripts).slice(0, 1).map((name) => ({
4853
+ name,
4854
+ files,
4855
+ basis: 'group-fallback'
4856
+ }));
4857
+ } else if (rule.id === 'unknown-safe-fallback') {
4858
+ commandEvidence = safeFallbackCommands.map((command) => ({
4859
+ command,
4860
+ files,
4861
+ basis: 'safe-fallback'
4862
+ }));
4863
+ }
4864
+ const commandSources = commandEvidence
4865
+ .map((evidence) => ({
4866
+ command: evidence.command || scriptCommandIfPresent(packageManager, scripts, evidence.name),
4867
+ files: [...new Set(evidence.files || [])].sort(),
4868
+ basis: evidence.basis
4869
+ }))
4870
+ .filter((evidence) => evidence.command);
4871
+ const suggestedCommands = uniqueStrings(commandSources.map((evidence) => evidence.command));
4872
+ recommendations.push({
4873
+ matched_rule: rule.id,
4874
+ confidence: rule.confidence,
4875
+ reason: rule.reason,
4876
+ fallback: rule.fallback,
4877
+ files: [...new Set(files)].sort(),
4878
+ suggested_commands: suggestedCommands,
4879
+ command_sources: commandSources
4880
+ });
4881
+ }
4882
+ return recommendations;
4883
+ }
4884
+
4885
+ function focusedSmokeScriptNames(scripts = {}) {
4886
+ return Object.keys(scripts)
4887
+ .filter((name) => /(?:^|[-:])(?:smoke|test)(?:$|[-:])/i.test(name) || /smoke/i.test(String(scripts[name] || '')))
4888
+ .sort();
4889
+ }
4890
+
4891
+ function focusedSmokeScriptEvidenceForFiles(scripts = {}, files = []) {
4892
+ const candidates = focusedSmokeScriptNames(scripts);
4893
+ const selected = [];
4894
+ for (const file of files) {
4895
+ const stem = path.posix.basename(String(file || '').replaceAll('\\', '/')).replace(/\.[^.]+$/, '');
4896
+ const direct = candidates.find((name) => {
4897
+ const command = String(scripts[name] || '');
4898
+ return name.includes(stem)
4899
+ || command.includes(stem)
4900
+ || (stem === 'skill-installation' && /install.*smoke|smoke.*install/i.test(`${name} ${command}`))
4901
+ || (['trust-kernel', 'ueye-artifacts'].includes(stem) && /cli.*smoke|smoke.*cli/i.test(`${name} ${command}`));
4902
+ });
4903
+ if (direct) selected.push({
4904
+ name: direct,
4905
+ files: [file],
4906
+ basis: 'focused-file-match'
4907
+ });
4908
+ }
4909
+ if (!selected.length && candidates.length) {
4910
+ selected.push({
4911
+ name: candidates[0],
4912
+ files,
4913
+ basis: 'group-fallback'
4914
+ });
4915
+ }
4916
+ return selected;
4917
+ }
4918
+
4919
+ function directScriptNamesForFile(scripts = {}, changedFile = '') {
4920
+ const normalized = String(changedFile || '').replaceAll('\\', '/');
4921
+ const basename = path.posix.basename(normalized);
4922
+ return Object.entries(scripts)
4923
+ .filter(([, command]) => {
4924
+ const text = String(command || '').replaceAll('\\', '/');
4925
+ return text.includes(normalized) || text.includes(`/${basename}`) || text.includes(` ${basename}`);
4926
+ })
4927
+ .map(([name]) => name)
4928
+ .sort();
4929
+ }
4930
+
4931
+ function scriptCommandIfPresent(packageManager, scripts = {}, name = '') {
4932
+ return name && Object.prototype.hasOwnProperty.call(scripts, name)
4933
+ ? runCommand(packageManager, name)
4934
+ : null;
4935
+ }
4936
+
4937
+ function uniqueStrings(values = []) {
4938
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
4939
+ }
4940
+
4941
+ function buildVerificationCommandPlan(recommendations = []) {
4942
+ const commands = new Map<string, AnyRecord>();
4943
+ for (const recommendation of recommendations) {
4944
+ const sources = recommendation.command_sources?.length
4945
+ ? recommendation.command_sources
4946
+ : (recommendation.suggested_commands || []).map((command) => ({
4947
+ command,
4948
+ files: recommendation.files || [],
4949
+ basis: 'recommendation-fallback'
4950
+ }));
4951
+ for (const source of sources) {
4952
+ const current = commands.get(source.command) || {
4953
+ command: source.command,
4954
+ matched_rules: [],
4955
+ files: [],
4956
+ provenance: []
4957
+ };
4958
+ current.matched_rules = uniqueStrings([...current.matched_rules, recommendation.matched_rule]);
4959
+ current.files = uniqueStrings([...current.files, ...(source.files || [])]).sort();
4960
+ current.provenance.push({
4961
+ matched_rule: recommendation.matched_rule,
4962
+ files: [...new Set(source.files || [])].sort(),
4963
+ basis: source.basis
4964
+ });
4965
+ commands.set(source.command, current);
4966
+ }
4967
+ }
4968
+ return [...commands.values()];
4969
+ }
4970
+
4362
4971
  function printDetection(result) {
4363
4972
  console.log(`Project: ${result.dir}`);
4364
4973
  console.log(`Package manager: ${result.packageManager}`);
@@ -4372,6 +4981,7 @@ function printDetection(result) {
4372
4981
  console.log(`- $quick: ${result.commands.typecheck || result.commands.lint || result.commands.test || result.commands.build || 'L1 inspected; no command detected'}`);
4373
4982
  console.log(`- $ueye: ${result.commands.typecheck || result.commands.build || 'Browser/screenshot check; no command detected'}`);
4374
4983
  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}`}`);
4984
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4375
4985
  if (result.frameworkChecklist?.detected) {
4376
4986
  console.log('');
4377
4987
  console.log(`Framework checklist: ${result.frameworkChecklist.framework}`);
@@ -4381,6 +4991,29 @@ function printDetection(result) {
4381
4991
  }
4382
4992
  }
4383
4993
 
4994
+ function printChangedFileVerificationRecommendations(recommendations = [], commandPlan = []) {
4995
+ console.log('');
4996
+ console.log('Changed-file verification recommendations (advisory; commands were not run):');
4997
+ for (const recommendation of recommendations) {
4998
+ console.log(`- ${recommendation.matched_rule} [confidence: ${recommendation.confidence}]`);
4999
+ console.log(` files: ${recommendation.files.length ? recommendation.files.join(', ') : '(none)'}`);
5000
+ console.log(` reason: ${recommendation.reason}`);
5001
+ console.log(` commands: ${recommendation.suggested_commands.length ? recommendation.suggested_commands.join(' && ') : '(none detected)'}`);
5002
+ console.log(` fallback: ${recommendation.fallback}`);
5003
+ }
5004
+ console.log('');
5005
+ console.log('Verification command plan (deduplicated; advisory; commands were not run):');
5006
+ if (!commandPlan.length) {
5007
+ console.log('- (none detected)');
5008
+ return;
5009
+ }
5010
+ for (const item of commandPlan) {
5011
+ console.log(`- ${item.command}`);
5012
+ console.log(` rules: ${item.matched_rules.join(', ')}`);
5013
+ console.log(` files: ${item.files.join(', ') || '(none)'}`);
5014
+ }
5015
+ }
5016
+
4384
5017
  function budget(routeArg = '') {
4385
5018
  const normalized = normalizeRoute(routeArg);
4386
5019
  const entries = normalized ? [[normalized, ROUTE_BUDGETS[normalized]]] : Object.entries(ROUTE_BUDGETS);
@@ -4768,16 +5401,40 @@ function findSensitivePattern(text = '') {
4768
5401
  function showPath() {
4769
5402
  console.log(`root: ${ROOT}`);
4770
5403
  console.log(`skills: ${DEST}`);
4771
- console.log(`codex mirror cleanup: ${CODEX_MIRROR}`);
5404
+ console.log(`codex mirror (preserved without an ownership receipt): ${CODEX_MIRROR}`);
5405
+ }
5406
+
5407
+ function showRequestedHelp(args: string[]) {
5408
+ if (!args.some((arg) => arg === '--help' || arg === '-h')) return false;
5409
+
5410
+ const commandUsage: Record<string, () => void> = {
5411
+ context: contextUsage,
5412
+ cleanup: cleanupUsage,
5413
+ 'study-note': studyNoteUsage,
5414
+ tools: toolsUsage,
5415
+ hook: hookUsage,
5416
+ loop: loopUsage,
5417
+ ueye: ueyeUsage,
5418
+ media: mediaUsage,
5419
+ runtime: runtimeUsage,
5420
+ mission: missionUsage,
5421
+ update: updateUsage,
5422
+ benchmark: benchmarkUsage,
5423
+ memory: memoryUsage
5424
+ };
5425
+ (commandUsage[args[0]] || usage)();
5426
+ return true;
4772
5427
  }
4773
5428
 
4774
5429
  async function main() {
4775
- const command = process.argv[2] || 'help';
4776
- if (command === 'help' || command === '--help' || command === '-h') return usage();
4777
- if (command === 'install') return install();
4778
- if (command === 'uninstall') return uninstall();
5430
+ const args = process.argv.slice(2);
5431
+ const command = args[0] || 'help';
5432
+ if (showRequestedHelp(args)) return;
5433
+ if (command === 'help') return usage();
5434
+ if (command === 'install') return install(args.slice(1));
5435
+ if (command === 'uninstall') return uninstall(args.slice(1));
4779
5436
  if (command === 'version') return console.log(VERSION);
4780
- if (command === 'detect') return detectProject(process.argv[3]);
5437
+ if (command === 'detect') return detectCommand(args.slice(1));
4781
5438
  if (command === 'pack') return inspectProjectPack(process.argv[3]);
4782
5439
  if (command === 'context') return context(process.argv.slice(3));
4783
5440
  if (command === 'cleanup') return cleanup(process.argv.slice(3));
@@ -4791,6 +5448,7 @@ async function main() {
4791
5448
  if (command === 'media') return media(process.argv.slice(3));
4792
5449
  if (command === 'runtime') return runtime(process.argv.slice(3));
4793
5450
  if (command === 'mission') return mission(process.argv.slice(3));
5451
+ if (command === 'update') return updateCommand(process.argv.slice(3));
4794
5452
  if (command === 'benchmark') return benchmark(process.argv.slice(3));
4795
5453
  if (command === 'release') return release(process.argv.slice(3));
4796
5454
  if (command === 'safety') return safety(process.argv.slice(3));