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/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,8 @@ 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';
14
+ import { EXTERNAL_UPDATE_COMPONENTS, applyExternalUpdates, checkExternalUpdates } from '../lib/external-updates.js';
13
15
  function errorMessage(error) {
14
16
  return error instanceof Error ? error.message : String(error);
15
17
  }
@@ -50,6 +52,13 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
50
52
  const PROJECT_PACK = 'yam.project.md';
51
53
  const LEGACY_PROJECT_PACK = 'timeto.project.md';
52
54
  const PACK_STALE_DAYS = 30;
55
+ const INSTRUCTION_DUPLICATION_LIMITS = {
56
+ maxFiles: 24,
57
+ maxBytesPerFile: 128000,
58
+ maxGroups: 8,
59
+ maxDepth: 6,
60
+ minDirectiveChars: 40
61
+ };
53
62
  const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
54
63
  const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
55
64
  const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
@@ -116,7 +125,7 @@ Usage:
116
125
  yam list
117
126
  yam status
118
127
  yam verify
119
- yam detect [dir]
128
+ yam detect [dir] [--json]
120
129
  yam pack [dir]
121
130
  yam context pressure [dir] [--json]
122
131
  yam cleanup scan [dir] [--json]
@@ -138,6 +147,9 @@ Usage:
138
147
  yam mission queue [--agent-id id] [--scope text] [--changed file] [--verification-hint text] [--json]
139
148
  yam mission receipt [--thread-id id] [--role reviewer] [--lifecycle stopped] [--outcome passed] [--evidence text] [--json]
140
149
  yam mission gate [--expected-thread id] [--receipt file] [--json]
150
+ yam update check [--json]
151
+ yam update apply --component yam|scrapling|insane-search [--json]
152
+ yam update apply --all [--json]
141
153
  yam benchmark report [--baseline n] [--current n] [--unit ms] [--target lower|higher] [--json]
142
154
  yam release report [--json]
143
155
  yam safety [text...]
@@ -145,7 +157,7 @@ Usage:
145
157
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
146
158
  yam template <project|ueye|mission|proof|tuning>
147
159
  yam tune-log [dir]
148
- yam install
160
+ yam install [--replace-user-skill name]
149
161
  yam uninstall
150
162
  yam doctor [--json]
151
163
  yam examples
@@ -169,7 +181,32 @@ async function exists(file) {
169
181
  async function rmrf(target) {
170
182
  await fsp.rm(target, { recursive: true, force: true });
171
183
  }
172
- async function install() {
184
+ function installReplacementSkills(args = []) {
185
+ const skills = [];
186
+ for (let index = 0; index < args.length; index += 1) {
187
+ const arg = String(args[index] || '');
188
+ if (arg === '--replace-user-skill') {
189
+ const skill = String(args[index + 1] || '');
190
+ if (!skill || skill.startsWith('--')) {
191
+ throw new Error('missing skill name after --replace-user-skill');
192
+ }
193
+ skills.push(skill);
194
+ index += 1;
195
+ continue;
196
+ }
197
+ if (arg.startsWith('--replace-user-skill=')) {
198
+ const skill = arg.slice('--replace-user-skill='.length);
199
+ if (!skill)
200
+ throw new Error('missing skill name after --replace-user-skill=');
201
+ skills.push(skill);
202
+ continue;
203
+ }
204
+ throw new Error(`unknown yam install argument: ${arg}`);
205
+ }
206
+ return [...new Set(skills)];
207
+ }
208
+ async function install(args = []) {
209
+ const replaceSkills = installReplacementSkills(args);
173
210
  const result = await installSkillSetTransactional({
174
211
  sourceRoot: ROOT,
175
212
  destination: DEST,
@@ -177,6 +214,7 @@ async function install() {
177
214
  packageName: String(PACKAGE_JSON.name || 'yam-flow'),
178
215
  version: VERSION,
179
216
  skills: SKILLS,
217
+ replaceSkills,
180
218
  legacySkills: LEGACY_SKILLS,
181
219
  retiredSkills: RETIRED_SKILLS
182
220
  });
@@ -187,21 +225,22 @@ async function install() {
187
225
  console.warn(`warning: ${warning}`);
188
226
  console.log('Restart Codex to reload skills.');
189
227
  }
190
- async function uninstall() {
191
- const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
192
- if (await exists(lockPath)) {
193
- throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
194
- }
195
- for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
196
- await rmrf(path.join(DEST, skill));
197
- if (CODEX_MIRROR !== DEST) {
198
- await rmrf(path.join(CODEX_MIRROR, skill));
199
- }
200
- }
201
- await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
228
+ async function uninstall(args = []) {
229
+ if (args.length)
230
+ throw new Error(`unknown yam uninstall argument: ${args[0]}`);
231
+ const result = await uninstallSkillSetSafely({
232
+ destination: DEST,
233
+ codexMirror: CODEX_MIRROR,
234
+ packageName: String(PACKAGE_JSON.name || 'yam-flow'),
235
+ skills: SKILLS
236
+ });
202
237
  console.log(`yam removed from ${DEST}`);
203
- if (CODEX_MIRROR !== DEST)
204
- console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
238
+ console.log(`removed receipt: ${result.receiptPath}`);
239
+ for (const warning of result.cleanupWarnings)
240
+ console.warn(`warning: ${warning}`);
241
+ if (CODEX_MIRROR !== DEST) {
242
+ console.log(`unowned legacy, retired, and Codex mirror entries were preserved`);
243
+ }
205
244
  console.log('Restart Codex to unload skills.');
206
245
  }
207
246
  async function status({ quiet = false } = {}) {
@@ -397,12 +436,21 @@ async function doctor(args = []) {
397
436
  console.log('yam doctor: ok');
398
437
  console.log('No hooks, automations, or global config are required.');
399
438
  console.log('Skill install state is reported by `yam status`.');
439
+ if (report.preservedUnprovenSkillEntries.length) {
440
+ console.log(`Preserved unproven legacy/retired/mirror entries: ${report.preservedUnprovenSkillEntries.length}`);
441
+ }
400
442
  console.log(`yam-lite hook: ${report.yamLiteHook} (optional)`);
401
443
  return;
402
444
  }
403
445
  console.log('yam doctor: issues');
404
446
  for (const issue of report.issues)
405
447
  console.log(`- ${issue}`);
448
+ if (report.preservedUnprovenSkillEntries.length) {
449
+ console.log('');
450
+ console.log('Preserved unproven skill entries:');
451
+ for (const entry of report.preservedUnprovenSkillEntries)
452
+ console.log(`- ${entry}`);
453
+ }
406
454
  if (report.nextActions.length) {
407
455
  console.log('');
408
456
  console.log('Next actions:');
@@ -413,6 +461,7 @@ async function doctor(args = []) {
413
461
  }
414
462
  async function buildDoctorReport() {
415
463
  const issues = [];
464
+ const preservedUnprovenSkillEntries = [];
416
465
  for (const skill of SKILLS) {
417
466
  if (!await exists(path.join(ROOT, 'skills', skill, 'SKILL.md')))
418
467
  issues.push(`missing source skill: ${skill}`);
@@ -426,17 +475,22 @@ async function buildDoctorReport() {
426
475
  if (await exists(path.join(os.homedir(), '.codex', 'automations', 'timeto')))
427
476
  issues.push('unexpected legacy timeto automation');
428
477
  for (const legacySkill of LEGACY_SKILLS) {
429
- if (await exists(path.join(DEST, legacySkill, 'SKILL.md')))
430
- issues.push(`unexpected legacy installed skill: ${legacySkill}`);
431
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, legacySkill, 'SKILL.md'))) {
432
- issues.push(`unexpected legacy mirror skill: ${legacySkill}`);
478
+ if (await exists(path.join(DEST, legacySkill, 'SKILL.md'))) {
479
+ preservedUnprovenSkillEntries.push(`legacy-named destination entry: ${legacySkill}`);
433
480
  }
434
481
  }
435
482
  for (const retiredSkill of RETIRED_SKILLS) {
436
- if (await exists(path.join(DEST, retiredSkill, 'SKILL.md')))
437
- issues.push(`unexpected retired installed skill: ${retiredSkill}`);
438
- if (CODEX_MIRROR !== DEST && await exists(path.join(CODEX_MIRROR, retiredSkill, 'SKILL.md'))) {
439
- issues.push(`unexpected retired mirror skill: ${retiredSkill}`);
483
+ if (await exists(path.join(DEST, retiredSkill, 'SKILL.md'))) {
484
+ preservedUnprovenSkillEntries.push(`retired-name destination entry: ${retiredSkill}`);
485
+ }
486
+ }
487
+ if (CODEX_MIRROR !== DEST) {
488
+ const mirrorSkillNames = [...new Set([...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS])];
489
+ for (const skill of mirrorSkillNames) {
490
+ if (!await exists(path.join(CODEX_MIRROR, skill, 'SKILL.md')))
491
+ continue;
492
+ const nameKind = SKILLS.includes(skill) ? 'active-name' : LEGACY_SKILLS.includes(skill) ? 'legacy-named' : 'retired-name';
493
+ preservedUnprovenSkillEntries.push(`${nameKind} Codex mirror entry: ${skill}`);
440
494
  }
441
495
  }
442
496
  const verifyIssues = await verify({ quiet: true });
@@ -449,6 +503,7 @@ async function buildDoctorReport() {
449
503
  generatedAt: new Date().toISOString(),
450
504
  ok: issues.length === 0,
451
505
  issues,
506
+ preservedUnprovenSkillEntries,
452
507
  nextActions: nextActionDetails.map((action) => action.next_action),
453
508
  nextActionDetails,
454
509
  yamLiteHook: hookConfigHasYamLite(globalHook) ? 'enabled' : 'disabled'
@@ -468,8 +523,6 @@ function doctorNextActionDetails(issues = []) {
468
523
  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'));
469
524
  else if (/unexpected .*automation/i.test(issue))
470
525
  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'));
471
- else if (/legacy|retired/i.test(issue))
472
- actions.push(nextActionDetail('replace-old-skills', 'warning', issue, 'run `yam install` to replace old skill entries', 'yam install'));
473
526
  else if (/verify reported/i.test(issue))
474
527
  actions.push(nextActionDetail('run-verify', 'error', issue, 'run `npm run verify` and fix the reported package boundary or metadata issue', 'npm run verify'));
475
528
  }
@@ -759,19 +812,24 @@ async function studyNoteCheck(args = []) {
759
812
  process.exitCode = 1;
760
813
  }
761
814
  async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
762
- const changedFiles = await gitChangedFiles(dir);
763
- const needsStudyNote = changedFiles.length > 0;
815
+ const changedFileDetection = gitChangedFileSnapshot(dir);
816
+ const changedFiles = changedFileDetection.files;
817
+ const changeScopeAvailable = changedFileDetection.available;
818
+ const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
819
+ const scopeAwareStatus = (passes) => changeScopeAvailable ? (!needsStudyNote || passes ? 'pass' : 'fail') : 'partial';
764
820
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
765
821
  const checks = [
766
- studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
767
- studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
768
- studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
769
- studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
770
- studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
771
- studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
772
- studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
773
- studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
774
- studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
822
+ studyNoteGuardCheck('changed_files', changeScopeAvailable ? (needsStudyNote ? 'pass' : 'skipped') : 'partial', changeScopeAvailable
823
+ ? (needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected')
824
+ : 'Git changed-file scope is unavailable; do not treat this project as clean and determine Study Note need manually'),
825
+ studyNoteGuardCheck('study_note_present', scopeAwareStatus(hasStudyNoteMarker(reportText)), 'final report should include a Study Note when project artifacts changed'),
826
+ studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText)), 'Study Note should explain what the touched code/artifact does'),
827
+ 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'),
828
+ 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'),
829
+ studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText)), 'Study Note should describe the expected behavior or result'),
830
+ 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'),
831
+ studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText)), 'Study Note should say what was checked'),
832
+ 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')
775
833
  ];
776
834
  for (const requirement of hygieneRequirements) {
777
835
  checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
@@ -781,13 +839,18 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
781
839
  schema: 'yam.study-note-guard.v1',
782
840
  generated_at: new Date().toISOString(),
783
841
  project: dir,
842
+ changed_file_detection: changedFileDetection,
784
843
  changed_files: changedFiles,
785
844
  changed_file_count: changedFiles.length,
786
845
  report_source: reportSource,
787
846
  checks,
788
847
  blockers,
789
- next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
790
- truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
848
+ next_action: blockers[0] || (!changeScopeAvailable
849
+ ? 'Git change scope is unavailable; inspect changed artifacts manually and include a Study Note when anything changed'
850
+ : needsStudyNote
851
+ ? 'Study Note guard passed for the supplied report text'
852
+ : 'no Study Note required because no changed files were detected'),
853
+ truth_status: blockers.length ? 'blocked' : !changeScopeAvailable ? 'partial' : needsStudyNote ? 'verified' : 'skipped'
791
854
  };
792
855
  return result;
793
856
  }
@@ -805,14 +868,38 @@ function firstPositional(args = []) {
805
868
  }
806
869
  return '';
807
870
  }
808
- async function gitChangedFiles(dir) {
809
- const output = runReadOnlyCommandIn(dir, 'git', ['status', '--short']);
810
- return output
811
- .split(/\r?\n/)
812
- .filter(Boolean)
813
- .map((line) => line.replace(/^..\s+/, '').replace(/^.* -> /, ''))
814
- .map((line) => line.trim())
815
- .filter((file) => file && !file.startsWith('dist/') && !file.endsWith('.tgz'));
871
+ function gitChangedFileSnapshot(dir) {
872
+ const result = spawnSync('git', ['status', '--short', '-z', '--untracked-files=all'], {
873
+ cwd: dir,
874
+ encoding: 'utf8',
875
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
876
+ timeout: 30000
877
+ });
878
+ if (result.status !== 0) {
879
+ return {
880
+ available: false,
881
+ source: 'git status --short -z --untracked-files=all',
882
+ files: []
883
+ };
884
+ }
885
+ const records = String(result.stdout || '').split('\0');
886
+ const files = [];
887
+ for (let index = 0; index < records.length; index += 1) {
888
+ const record = records[index];
889
+ if (!record || record.length < 4)
890
+ continue;
891
+ const status = record.slice(0, 2);
892
+ const file = record.slice(3);
893
+ if (/[RC]/.test(status))
894
+ index += 1;
895
+ if (file && !file.startsWith('dist/') && !file.endsWith('.tgz'))
896
+ files.push(file);
897
+ }
898
+ return {
899
+ available: true,
900
+ source: 'git status --short -z --untracked-files=all',
901
+ files
902
+ };
816
903
  }
817
904
  async function readStudyNoteReportText(flags, dir) {
818
905
  if (flags.text)
@@ -902,12 +989,17 @@ async function buildCleanupScanReport(targetDir = process.cwd()) {
902
989
  for (const artifact of await staleYamArtifacts(dir)) {
903
990
  findings.push(cleanupFinding('low', artifact.file, artifact.reason, 'archive or refresh only if it misleads the current work'));
904
991
  }
992
+ const instructionDuplicationBudget = await buildInstructionDuplicationBudget(dir);
993
+ if (instructionDuplicationBudget.duplicate_group_count > 0) {
994
+ 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'));
995
+ }
905
996
  return {
906
997
  schema: 'yam.cleanup-scan.v1',
907
998
  generated_at: new Date().toISOString(),
908
999
  project: dir,
909
1000
  destructive: false,
910
1001
  findings,
1002
+ instruction_duplication_budget: instructionDuplicationBudget,
911
1003
  risk_level: overallCleanupRisk(findings),
912
1004
  safe_next_action: findings.length ? 'review findings and make explicit user-approved cleanup decisions' : 'no cleanup action needed',
913
1005
  truth_status: 'partial'
@@ -961,6 +1053,162 @@ async function staleYamArtifacts(dir) {
961
1053
  }
962
1054
  return artifacts;
963
1055
  }
1056
+ async function buildInstructionDuplicationBudget(rootDir = process.cwd(), limits = INSTRUCTION_DUPLICATION_LIMITS) {
1057
+ const root = path.resolve(rootDir || process.cwd());
1058
+ const inventory = await collectInstructionFiles(root, limits);
1059
+ const directives = new Map();
1060
+ let candidateDirectiveCount = 0;
1061
+ for (const relativeFile of inventory.files) {
1062
+ const absolute = path.join(root, relativeFile);
1063
+ const text = await readBoundedUtf8File(absolute, limits.maxBytesPerFile);
1064
+ for (const directive of instructionDirectives(text, relativeFile, limits.minDirectiveChars)) {
1065
+ candidateDirectiveCount += 1;
1066
+ directives.set(directive.normalized, [...(directives.get(directive.normalized) || []), directive]);
1067
+ }
1068
+ }
1069
+ const allGroups = [...directives.entries()]
1070
+ .map(([normalized, occurrences]) => {
1071
+ const surfaces = [...new Set(occurrences.map((item) => item.file))];
1072
+ if (surfaces.length < 2)
1073
+ return null;
1074
+ return {
1075
+ directive_digest: createHash('sha256').update(normalized).digest('hex').slice(0, 16),
1076
+ directive_preview: redactSensitiveText(String(occurrences[0]?.text || '')).slice(0, 180),
1077
+ occurrence_count: occurrences.length,
1078
+ surface_count: surfaces.length,
1079
+ surfaces,
1080
+ occurrences: occurrences.slice(0, 8).map((item) => ({
1081
+ file: item.file,
1082
+ line: item.line
1083
+ }))
1084
+ };
1085
+ })
1086
+ .filter(Boolean)
1087
+ .sort((left, right) => (right.surface_count - left.surface_count
1088
+ || right.occurrence_count - left.occurrence_count
1089
+ || left.directive_digest.localeCompare(right.directive_digest)));
1090
+ const duplicateGroups = allGroups.slice(0, limits.maxGroups);
1091
+ return {
1092
+ schema: 'yam.instruction-duplication-budget.v1',
1093
+ scope: 'project AGENTS.md and SKILL.md files only',
1094
+ advisory_only: true,
1095
+ hard_gate: false,
1096
+ destructive: false,
1097
+ normalization: 'exact normalized markdown directive text; no semantic similarity inference',
1098
+ limits: {
1099
+ max_files: limits.maxFiles,
1100
+ max_bytes_per_file: limits.maxBytesPerFile,
1101
+ max_groups: limits.maxGroups,
1102
+ max_depth: limits.maxDepth,
1103
+ min_directive_chars: limits.minDirectiveChars
1104
+ },
1105
+ files_scanned: inventory.files.length,
1106
+ scanned_files: inventory.files,
1107
+ candidate_directive_count: candidateDirectiveCount,
1108
+ duplicate_group_count: allGroups.length,
1109
+ duplicate_groups: duplicateGroups,
1110
+ file_limit_reached: inventory.fileLimitReached,
1111
+ group_limit_reached: allGroups.length > duplicateGroups.length,
1112
+ truth_status: 'partial',
1113
+ note: allGroups.length
1114
+ ? 'repetition may be intentional; review before consolidating'
1115
+ : 'no cross-surface exact normalized directive repetition found within the bounded scan'
1116
+ };
1117
+ }
1118
+ async function collectInstructionFiles(root, limits) {
1119
+ const files = [];
1120
+ let fileLimitReached = false;
1121
+ async function walk(current, depth) {
1122
+ if (depth > limits.maxDepth || fileLimitReached)
1123
+ return;
1124
+ let entries = [];
1125
+ try {
1126
+ entries = await fsp.readdir(current, { withFileTypes: true });
1127
+ }
1128
+ catch {
1129
+ return;
1130
+ }
1131
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1132
+ for (const entry of entries) {
1133
+ const absolute = path.join(current, entry.name);
1134
+ const relative = path.relative(root, absolute);
1135
+ if (shouldSkipScanPath(relative, entry.name) || entry.isSymbolicLink())
1136
+ continue;
1137
+ if (entry.isDirectory()) {
1138
+ await walk(absolute, depth + 1);
1139
+ if (fileLimitReached)
1140
+ return;
1141
+ continue;
1142
+ }
1143
+ if (!entry.isFile() || !['AGENTS.md', 'SKILL.md'].includes(entry.name))
1144
+ continue;
1145
+ if (files.length >= limits.maxFiles) {
1146
+ fileLimitReached = true;
1147
+ return;
1148
+ }
1149
+ files.push(relative);
1150
+ }
1151
+ }
1152
+ await walk(root, 0);
1153
+ return { files, fileLimitReached };
1154
+ }
1155
+ async function readBoundedUtf8File(file, maxBytes) {
1156
+ const handle = await fsp.open(file, 'r');
1157
+ try {
1158
+ const buffer = Buffer.alloc(maxBytes);
1159
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1160
+ return buffer.subarray(0, bytesRead).toString('utf8');
1161
+ }
1162
+ finally {
1163
+ await handle.close();
1164
+ }
1165
+ }
1166
+ function instructionDirectives(text = '', relativeFile = '', minChars = 40) {
1167
+ const directives = [];
1168
+ const lines = String(text || '').split(/\r?\n/);
1169
+ let inFence = false;
1170
+ let inFrontmatter = lines[0]?.trim() === '---';
1171
+ for (let index = 0; index < lines.length; index += 1) {
1172
+ const raw = lines[index];
1173
+ const trimmed = raw.trim();
1174
+ if (index === 0 && inFrontmatter)
1175
+ continue;
1176
+ if (inFrontmatter) {
1177
+ if (trimmed === '---')
1178
+ inFrontmatter = false;
1179
+ continue;
1180
+ }
1181
+ if (/^(```|~~~)/.test(trimmed)) {
1182
+ inFence = !inFence;
1183
+ continue;
1184
+ }
1185
+ if (inFence || !trimmed || /^#{1,6}\s/.test(trimmed))
1186
+ continue;
1187
+ const match = trimmed.match(/^(?:[-*+]|\d+[.)])\s+(.+)$/);
1188
+ if (!match)
1189
+ continue;
1190
+ const normalized = normalizeInstructionDirective(match[1]);
1191
+ if (normalized.length < minChars)
1192
+ continue;
1193
+ directives.push({
1194
+ file: relativeFile,
1195
+ line: index + 1,
1196
+ text: match[1].trim().slice(0, 240),
1197
+ normalized
1198
+ });
1199
+ }
1200
+ return directives;
1201
+ }
1202
+ function normalizeInstructionDirective(value = '') {
1203
+ return String(value || '')
1204
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1205
+ .replace(/[`*_~]/g, '')
1206
+ .replace(/<[^>]+>/g, ' ')
1207
+ .replace(/\s+/g, ' ')
1208
+ .replace(/[.;:,!?。,、;:!?]+$/g, '')
1209
+ .trim()
1210
+ .toLowerCase();
1211
+ }
964
1212
  function printCleanupScanReport(report) {
965
1213
  console.log('yam cleanup scan');
966
1214
  console.log(`Project: ${report.project}`);
@@ -1830,10 +2078,16 @@ async function buildYamLiteContext({ cwd, prompt }) {
1830
2078
  }
1831
2079
  async function buildStudyNoteHookContext({ cwd }) {
1832
2080
  const dir = path.resolve(cwd || process.cwd());
1833
- const changedFiles = await gitChangedFiles(dir);
2081
+ const changedFileDetection = gitChangedFileSnapshot(dir);
2082
+ const changedFiles = changedFileDetection.files;
1834
2083
  const lines = [
1835
2084
  '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.'
1836
2085
  ];
2086
+ if (!changedFileDetection.available) {
2087
+ lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2088
+ lines.push('Determine manually whether artifacts changed and include the Study Note when they did; the Stop hook will keep this uncertainty visible.');
2089
+ return lines.join('\n');
2090
+ }
1837
2091
  if (!changedFiles.length) {
1838
2092
  lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
1839
2093
  lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
@@ -1852,6 +2106,12 @@ async function buildStudyNoteHookContext({ cwd }) {
1852
2106
  async function buildStudyNoteStopOutput(input = {}, cwd = process.cwd()) {
1853
2107
  const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
1854
2108
  const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
2109
+ if (result.truth_status === 'partial' && !result.changed_file_detection.available) {
2110
+ return {
2111
+ continue: true,
2112
+ 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.'
2113
+ };
2114
+ }
1855
2115
  if (!result.blockers.length)
1856
2116
  return { continue: true };
1857
2117
  const summary = result.blockers.slice(0, 4).join('; ');
@@ -3300,6 +3560,86 @@ async function mission(args = []) {
3300
3560
  console.error(`unknown mission command: ${subcommand}`);
3301
3561
  return missionUsage();
3302
3562
  }
3563
+ async function updateCommand(args = []) {
3564
+ const subcommand = args[0] || 'help';
3565
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h')
3566
+ return updateUsage();
3567
+ if (subcommand === 'check') {
3568
+ const rest = args.slice(1);
3569
+ const invalid = rest.filter((arg) => arg !== '--json');
3570
+ if (invalid.length || rest.filter((arg) => arg === '--json').length > 1) {
3571
+ throw new Error(`invalid update check option: ${invalid[0] || '--json repeated'}`);
3572
+ }
3573
+ const report = await checkExternalUpdates(VERSION);
3574
+ printJsonOrHuman(report, rest.includes('--json'), 'External update check');
3575
+ if (!report.success)
3576
+ process.exitCode = 1;
3577
+ return;
3578
+ }
3579
+ if (subcommand !== 'apply') {
3580
+ throw new Error(`unknown update command: ${subcommand}`);
3581
+ }
3582
+ const parsed = parseUpdateApplyArgs(args.slice(1));
3583
+ const report = await applyExternalUpdates(VERSION, {
3584
+ component: parsed.component,
3585
+ all: parsed.all
3586
+ });
3587
+ printJsonOrHuman(report, parsed.json, 'External update apply');
3588
+ if (!report.success)
3589
+ process.exitCode = 1;
3590
+ }
3591
+ function parseUpdateApplyArgs(args) {
3592
+ let component;
3593
+ let all = false;
3594
+ let json = false;
3595
+ for (let index = 0; index < args.length; index += 1) {
3596
+ const arg = String(args[index] || '');
3597
+ if (arg === '--json') {
3598
+ if (json)
3599
+ throw new Error('--json may be supplied only once');
3600
+ json = true;
3601
+ continue;
3602
+ }
3603
+ if (arg === '--all') {
3604
+ if (all)
3605
+ throw new Error('--all may be supplied only once');
3606
+ all = true;
3607
+ continue;
3608
+ }
3609
+ if (arg === '--component' || arg.startsWith('--component=')) {
3610
+ if (component)
3611
+ throw new Error('--component may be supplied only once');
3612
+ const value = arg === '--component' ? String(args[index + 1] || '') : arg.slice('--component='.length);
3613
+ if (arg === '--component')
3614
+ index += 1;
3615
+ if (!EXTERNAL_UPDATE_COMPONENTS.includes(value)) {
3616
+ throw new Error('component must be one of: yam, scrapling, insane-search');
3617
+ }
3618
+ component = value;
3619
+ continue;
3620
+ }
3621
+ throw new Error(`invalid update apply option: ${arg}`);
3622
+ }
3623
+ if ((all && component) || (!all && !component)) {
3624
+ throw new Error('choose exactly one of --component yam|scrapling|insane-search or --all');
3625
+ }
3626
+ return { component, all, json };
3627
+ }
3628
+ function updateUsage() {
3629
+ console.log(`yam update
3630
+
3631
+ Usage:
3632
+ yam update check [--json]
3633
+ yam update apply --component yam|scrapling|insane-search [--json]
3634
+ yam update apply --all [--json]
3635
+
3636
+ Safety:
3637
+ check is read-only. apply requires an explicit component or --all.
3638
+ --all updates Scrapling and Insane Search before yam, then stops at the first failed/manual component.
3639
+ Insane Search is updated only through the official Codex CLI; plugin caches are never edited directly.
3640
+ Every apply attempt records component checks, outcome, side effects, and rollback guidance.
3641
+ `);
3642
+ }
3303
3643
  function missionUsage() {
3304
3644
  console.log(`yam mission
3305
3645
 
@@ -4341,7 +4681,10 @@ function pickScript(scripts = {}, groups = []) {
4341
4681
  async function detectProject(targetDir = process.cwd(), { quiet = false } = {}) {
4342
4682
  const dir = path.resolve(targetDir || process.cwd());
4343
4683
  const packageJson = path.join(dir, 'package.json');
4684
+ const changedFileDetection = gitChangedFileSnapshot(dir);
4685
+ const changedFiles = changedFileDetection.files;
4344
4686
  const result = {
4687
+ schema: 'yam.project-detection.v1',
4345
4688
  dir,
4346
4689
  packageJson: await exists(packageJson),
4347
4690
  packageManager: 'npm',
@@ -4352,12 +4695,26 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4352
4695
  test: null,
4353
4696
  build: null
4354
4697
  },
4355
- frameworkChecklist: null
4698
+ frameworkChecklist: null,
4699
+ changedFileDetection,
4700
+ changedFiles,
4701
+ verificationRecommendations: [],
4702
+ verificationCommandPlan: []
4356
4703
  };
4357
4704
  if (!result.packageJson) {
4705
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4706
+ packageManager: result.packageManager,
4707
+ scripts: {},
4708
+ commands: result.commands,
4709
+ changedFiles,
4710
+ changedFilesAvailable: changedFileDetection.available,
4711
+ packageJson: false
4712
+ });
4713
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4358
4714
  if (!quiet) {
4359
4715
  console.log(`No package.json found in ${dir}`);
4360
4716
  console.log('Suggested verification: use project pack or local framework conventions.');
4717
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4361
4718
  }
4362
4719
  return result;
4363
4720
  }
@@ -4370,10 +4727,238 @@ async function detectProject(targetDir = process.cwd(), { quiet = false } = {})
4370
4727
  result.commands.test = runCommand(result.packageManager, pickScript(scripts, ['test', 'spec']));
4371
4728
  result.commands.build = runCommand(result.packageManager, pickScript(scripts, ['build']));
4372
4729
  result.frameworkChecklist = await detectFrameworkChecklist(dir, pkg);
4730
+ result.verificationRecommendations = buildChangedFileVerificationRecommendations({
4731
+ packageManager: result.packageManager,
4732
+ scripts,
4733
+ commands: result.commands,
4734
+ changedFiles,
4735
+ changedFilesAvailable: changedFileDetection.available,
4736
+ packageJson: true
4737
+ });
4738
+ result.verificationCommandPlan = buildVerificationCommandPlan(result.verificationRecommendations);
4373
4739
  if (!quiet)
4374
4740
  printDetection(result);
4375
4741
  return result;
4376
4742
  }
4743
+ async function detectCommand(args = []) {
4744
+ const flags = parseSimpleFlags(args, new Set(['json']));
4745
+ const dir = path.resolve(firstPositional(args) || process.cwd());
4746
+ const result = await detectProject(dir, { quiet: true });
4747
+ if (flags.json) {
4748
+ console.log(JSON.stringify(result, null, 2));
4749
+ return;
4750
+ }
4751
+ printDetection(result);
4752
+ }
4753
+ function buildChangedFileVerificationRecommendations({ packageManager = 'npm', scripts = {}, commands = {}, changedFiles = [], changedFilesAvailable = true, packageJson = false }) {
4754
+ const safeFallbackCommands = uniqueStrings([
4755
+ commands.typecheck,
4756
+ commands.lint,
4757
+ commands.test,
4758
+ commands.build
4759
+ ]);
4760
+ if (!changedFilesAvailable) {
4761
+ return [{
4762
+ matched_rule: 'git-status-unavailable',
4763
+ confidence: 'low',
4764
+ reason: 'Git change status was unavailable, so yam cannot prove that the project has no changed files',
4765
+ fallback: packageJson
4766
+ ? 'run all available non-publish typecheck, lint, test, and build checks or use project-pack conventions'
4767
+ : 'use yam.project.md or local framework conventions; no Git change scope or executable package scripts were detected',
4768
+ files: [],
4769
+ suggested_commands: safeFallbackCommands
4770
+ }];
4771
+ }
4772
+ if (!changedFiles.length) {
4773
+ return [{
4774
+ matched_rule: 'no-changes',
4775
+ confidence: 'high',
4776
+ reason: 'git reports no dirty, staged, or untracked project files',
4777
+ fallback: 'rerun `yam detect --json` after files change; use project-pack conventions if Git is unavailable',
4778
+ files: [],
4779
+ suggested_commands: []
4780
+ }];
4781
+ }
4782
+ const rules = [
4783
+ {
4784
+ id: 'package-release-metadata',
4785
+ confidence: 'high',
4786
+ 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),
4787
+ reason: 'package or release metadata changed, so package contents and release readiness can drift',
4788
+ commandNames: ['package-boundary:check', 'release:check'],
4789
+ fallback: 'run the available typecheck/build/package checks and perform the public release workflow separately'
4790
+ },
4791
+ {
4792
+ id: 'src-lib',
4793
+ confidence: 'high',
4794
+ matches: (file) => /^src\/lib\//i.test(file),
4795
+ reason: 'shared library code changed, so compile safety and a focused behavior smoke are the narrowest honest checks',
4796
+ commandNames: ['typecheck'],
4797
+ fallback: 'run typecheck plus the closest available test/smoke; use the full safe fallback when the library crosses package boundaries'
4798
+ },
4799
+ {
4800
+ id: 'scripts',
4801
+ confidence: 'medium',
4802
+ matches: (file) => /^scripts\//i.test(file),
4803
+ reason: 'an executable verification or maintenance script changed, so its direct npm wrapper or closest smoke should run',
4804
+ commandNames: [],
4805
+ fallback: 'run the changed script in an isolated fixture, then use the full safe fallback if no direct wrapper exists'
4806
+ },
4807
+ {
4808
+ id: 'unknown-safe-fallback',
4809
+ confidence: packageJson ? 'medium' : 'low',
4810
+ matches: () => true,
4811
+ reason: 'no focused changed-file rule matched, so skipping verification would be weaker than the available project-wide safe checks',
4812
+ commandNames: [],
4813
+ fallback: packageJson
4814
+ ? 'run all available non-publish typecheck, lint, test, and build checks'
4815
+ : 'use yam.project.md or local framework conventions; no executable package scripts were detected'
4816
+ }
4817
+ ];
4818
+ const grouped = new Map();
4819
+ for (const rawFile of changedFiles) {
4820
+ const file = String(rawFile || '').replaceAll('\\', '/');
4821
+ const rule = rules.find((candidate) => candidate.matches(file)) || rules[rules.length - 1];
4822
+ grouped.set(rule.id, [...(grouped.get(rule.id) || []), file]);
4823
+ }
4824
+ const recommendations = [];
4825
+ for (const rule of rules) {
4826
+ const files = grouped.get(rule.id) || [];
4827
+ if (!files.length)
4828
+ continue;
4829
+ let commandEvidence = rule.commandNames.map((name) => ({
4830
+ name,
4831
+ files,
4832
+ basis: 'rule-wide'
4833
+ }));
4834
+ if (rule.id === 'src-lib') {
4835
+ commandEvidence = [
4836
+ { name: 'typecheck', files, basis: 'rule-wide' },
4837
+ ...focusedSmokeScriptEvidenceForFiles(scripts, files)
4838
+ ];
4839
+ }
4840
+ else if (rule.id === 'scripts') {
4841
+ const directEvidence = files.flatMap((file) => (directScriptNamesForFile(scripts, file).map((name) => ({
4842
+ name,
4843
+ files: [file],
4844
+ basis: 'direct-script-wrapper'
4845
+ }))));
4846
+ commandEvidence = directEvidence.length
4847
+ ? directEvidence
4848
+ : focusedSmokeScriptNames(scripts).slice(0, 1).map((name) => ({
4849
+ name,
4850
+ files,
4851
+ basis: 'group-fallback'
4852
+ }));
4853
+ }
4854
+ else if (rule.id === 'unknown-safe-fallback') {
4855
+ commandEvidence = safeFallbackCommands.map((command) => ({
4856
+ command,
4857
+ files,
4858
+ basis: 'safe-fallback'
4859
+ }));
4860
+ }
4861
+ const commandSources = commandEvidence
4862
+ .map((evidence) => ({
4863
+ command: evidence.command || scriptCommandIfPresent(packageManager, scripts, evidence.name),
4864
+ files: [...new Set(evidence.files || [])].sort(),
4865
+ basis: evidence.basis
4866
+ }))
4867
+ .filter((evidence) => evidence.command);
4868
+ const suggestedCommands = uniqueStrings(commandSources.map((evidence) => evidence.command));
4869
+ recommendations.push({
4870
+ matched_rule: rule.id,
4871
+ confidence: rule.confidence,
4872
+ reason: rule.reason,
4873
+ fallback: rule.fallback,
4874
+ files: [...new Set(files)].sort(),
4875
+ suggested_commands: suggestedCommands,
4876
+ command_sources: commandSources
4877
+ });
4878
+ }
4879
+ return recommendations;
4880
+ }
4881
+ function focusedSmokeScriptNames(scripts = {}) {
4882
+ return Object.keys(scripts)
4883
+ .filter((name) => /(?:^|[-:])(?:smoke|test)(?:$|[-:])/i.test(name) || /smoke/i.test(String(scripts[name] || '')))
4884
+ .sort();
4885
+ }
4886
+ function focusedSmokeScriptEvidenceForFiles(scripts = {}, files = []) {
4887
+ const candidates = focusedSmokeScriptNames(scripts);
4888
+ const selected = [];
4889
+ for (const file of files) {
4890
+ const stem = path.posix.basename(String(file || '').replaceAll('\\', '/')).replace(/\.[^.]+$/, '');
4891
+ const direct = candidates.find((name) => {
4892
+ const command = String(scripts[name] || '');
4893
+ return name.includes(stem)
4894
+ || command.includes(stem)
4895
+ || (stem === 'skill-installation' && /install.*smoke|smoke.*install/i.test(`${name} ${command}`))
4896
+ || (['trust-kernel', 'ueye-artifacts'].includes(stem) && /cli.*smoke|smoke.*cli/i.test(`${name} ${command}`));
4897
+ });
4898
+ if (direct)
4899
+ selected.push({
4900
+ name: direct,
4901
+ files: [file],
4902
+ basis: 'focused-file-match'
4903
+ });
4904
+ }
4905
+ if (!selected.length && candidates.length) {
4906
+ selected.push({
4907
+ name: candidates[0],
4908
+ files,
4909
+ basis: 'group-fallback'
4910
+ });
4911
+ }
4912
+ return selected;
4913
+ }
4914
+ function directScriptNamesForFile(scripts = {}, changedFile = '') {
4915
+ const normalized = String(changedFile || '').replaceAll('\\', '/');
4916
+ const basename = path.posix.basename(normalized);
4917
+ return Object.entries(scripts)
4918
+ .filter(([, command]) => {
4919
+ const text = String(command || '').replaceAll('\\', '/');
4920
+ return text.includes(normalized) || text.includes(`/${basename}`) || text.includes(` ${basename}`);
4921
+ })
4922
+ .map(([name]) => name)
4923
+ .sort();
4924
+ }
4925
+ function scriptCommandIfPresent(packageManager, scripts = {}, name = '') {
4926
+ return name && Object.prototype.hasOwnProperty.call(scripts, name)
4927
+ ? runCommand(packageManager, name)
4928
+ : null;
4929
+ }
4930
+ function uniqueStrings(values = []) {
4931
+ return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
4932
+ }
4933
+ function buildVerificationCommandPlan(recommendations = []) {
4934
+ const commands = new Map();
4935
+ for (const recommendation of recommendations) {
4936
+ const sources = recommendation.command_sources?.length
4937
+ ? recommendation.command_sources
4938
+ : (recommendation.suggested_commands || []).map((command) => ({
4939
+ command,
4940
+ files: recommendation.files || [],
4941
+ basis: 'recommendation-fallback'
4942
+ }));
4943
+ for (const source of sources) {
4944
+ const current = commands.get(source.command) || {
4945
+ command: source.command,
4946
+ matched_rules: [],
4947
+ files: [],
4948
+ provenance: []
4949
+ };
4950
+ current.matched_rules = uniqueStrings([...current.matched_rules, recommendation.matched_rule]);
4951
+ current.files = uniqueStrings([...current.files, ...(source.files || [])]).sort();
4952
+ current.provenance.push({
4953
+ matched_rule: recommendation.matched_rule,
4954
+ files: [...new Set(source.files || [])].sort(),
4955
+ basis: source.basis
4956
+ });
4957
+ commands.set(source.command, current);
4958
+ }
4959
+ }
4960
+ return [...commands.values()];
4961
+ }
4377
4962
  function printDetection(result) {
4378
4963
  console.log(`Project: ${result.dir}`);
4379
4964
  console.log(`Package manager: ${result.packageManager}`);
@@ -4387,6 +4972,7 @@ function printDetection(result) {
4387
4972
  console.log(`- $quick: ${result.commands.typecheck || result.commands.lint || result.commands.test || result.commands.build || 'L1 inspected; no command detected'}`);
4388
4973
  console.log(`- $ueye: ${result.commands.typecheck || result.commands.build || 'Browser/screenshot check; no command detected'}`);
4389
4974
  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}`}`);
4975
+ printChangedFileVerificationRecommendations(result.verificationRecommendations, result.verificationCommandPlan);
4390
4976
  if (result.frameworkChecklist?.detected) {
4391
4977
  console.log('');
4392
4978
  console.log(`Framework checklist: ${result.frameworkChecklist.framework}`);
@@ -4395,6 +4981,28 @@ function printDetection(result) {
4395
4981
  }
4396
4982
  }
4397
4983
  }
4984
+ function printChangedFileVerificationRecommendations(recommendations = [], commandPlan = []) {
4985
+ console.log('');
4986
+ console.log('Changed-file verification recommendations (advisory; commands were not run):');
4987
+ for (const recommendation of recommendations) {
4988
+ console.log(`- ${recommendation.matched_rule} [confidence: ${recommendation.confidence}]`);
4989
+ console.log(` files: ${recommendation.files.length ? recommendation.files.join(', ') : '(none)'}`);
4990
+ console.log(` reason: ${recommendation.reason}`);
4991
+ console.log(` commands: ${recommendation.suggested_commands.length ? recommendation.suggested_commands.join(' && ') : '(none detected)'}`);
4992
+ console.log(` fallback: ${recommendation.fallback}`);
4993
+ }
4994
+ console.log('');
4995
+ console.log('Verification command plan (deduplicated; advisory; commands were not run):');
4996
+ if (!commandPlan.length) {
4997
+ console.log('- (none detected)');
4998
+ return;
4999
+ }
5000
+ for (const item of commandPlan) {
5001
+ console.log(`- ${item.command}`);
5002
+ console.log(` rules: ${item.matched_rules.join(', ')}`);
5003
+ console.log(` files: ${item.files.join(', ') || '(none)'}`);
5004
+ }
5005
+ }
4398
5006
  function budget(routeArg = '') {
4399
5007
  const normalized = normalizeRoute(routeArg);
4400
5008
  const entries = normalized ? [[normalized, ROUTE_BUDGETS[normalized]]] : Object.entries(ROUTE_BUDGETS);
@@ -4773,20 +5381,44 @@ function findSensitivePattern(text = '') {
4773
5381
  function showPath() {
4774
5382
  console.log(`root: ${ROOT}`);
4775
5383
  console.log(`skills: ${DEST}`);
4776
- console.log(`codex mirror cleanup: ${CODEX_MIRROR}`);
5384
+ console.log(`codex mirror (preserved without an ownership receipt): ${CODEX_MIRROR}`);
5385
+ }
5386
+ function showRequestedHelp(args) {
5387
+ if (!args.some((arg) => arg === '--help' || arg === '-h'))
5388
+ return false;
5389
+ const commandUsage = {
5390
+ context: contextUsage,
5391
+ cleanup: cleanupUsage,
5392
+ 'study-note': studyNoteUsage,
5393
+ tools: toolsUsage,
5394
+ hook: hookUsage,
5395
+ loop: loopUsage,
5396
+ ueye: ueyeUsage,
5397
+ media: mediaUsage,
5398
+ runtime: runtimeUsage,
5399
+ mission: missionUsage,
5400
+ update: updateUsage,
5401
+ benchmark: benchmarkUsage,
5402
+ memory: memoryUsage
5403
+ };
5404
+ (commandUsage[args[0]] || usage)();
5405
+ return true;
4777
5406
  }
4778
5407
  async function main() {
4779
- const command = process.argv[2] || 'help';
4780
- if (command === 'help' || command === '--help' || command === '-h')
5408
+ const args = process.argv.slice(2);
5409
+ const command = args[0] || 'help';
5410
+ if (showRequestedHelp(args))
5411
+ return;
5412
+ if (command === 'help')
4781
5413
  return usage();
4782
5414
  if (command === 'install')
4783
- return install();
5415
+ return install(args.slice(1));
4784
5416
  if (command === 'uninstall')
4785
- return uninstall();
5417
+ return uninstall(args.slice(1));
4786
5418
  if (command === 'version')
4787
5419
  return console.log(VERSION);
4788
5420
  if (command === 'detect')
4789
- return detectProject(process.argv[3]);
5421
+ return detectCommand(args.slice(1));
4790
5422
  if (command === 'pack')
4791
5423
  return inspectProjectPack(process.argv[3]);
4792
5424
  if (command === 'context')
@@ -4813,6 +5445,8 @@ async function main() {
4813
5445
  return runtime(process.argv.slice(3));
4814
5446
  if (command === 'mission')
4815
5447
  return mission(process.argv.slice(3));
5448
+ if (command === 'update')
5449
+ return updateCommand(process.argv.slice(3));
4816
5450
  if (command === 'benchmark')
4817
5451
  return benchmark(process.argv.slice(3));
4818
5452
  if (command === 'release')