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