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