yam-flow 2.3.0 → 2.3.2
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/AGENTS.md +18 -0
- package/CHANGELOG.md +43 -0
- package/COMMANDS.md +6 -4
- package/README.md +21 -13
- package/ROADMAP.md +6 -1
- package/dist/bin/yam.js +330 -82
- package/dist/lib/skill-installation.d.ts +76 -0
- package/dist/lib/skill-installation.js +554 -0
- package/package.json +3 -2
- package/references/hook-lite.md +6 -0
- package/src/bin/yam.ts +319 -82
- package/src/lib/skill-installation.ts +638 -0
- package/yam.manifest.json +3 -1
package/src/bin/yam.ts
CHANGED
|
@@ -33,6 +33,12 @@ import {
|
|
|
33
33
|
verifyUeyeAssetManifest,
|
|
34
34
|
verifyUeyeRevisionHistory
|
|
35
35
|
} from '../lib/ueye-artifacts.js';
|
|
36
|
+
import {
|
|
37
|
+
INSTALL_LOCK_NAME,
|
|
38
|
+
INSTALL_RECEIPT_NAME,
|
|
39
|
+
inspectSkillInstallation,
|
|
40
|
+
installSkillSetTransactional
|
|
41
|
+
} from '../lib/skill-installation.js';
|
|
36
42
|
|
|
37
43
|
type AnyRecord = Record<string, any>;
|
|
38
44
|
|
|
@@ -78,8 +84,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
|
|
|
78
84
|
const PROJECT_PACK = 'yam.project.md';
|
|
79
85
|
const LEGACY_PROJECT_PACK = 'timeto.project.md';
|
|
80
86
|
const PACK_STALE_DAYS = 30;
|
|
81
|
-
const
|
|
82
|
-
const
|
|
87
|
+
const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
|
|
88
|
+
const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
|
|
89
|
+
const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
|
|
83
90
|
const REQUIRED_PACK_SECTIONS = [
|
|
84
91
|
'Product Direction',
|
|
85
92
|
'UI Direction',
|
|
@@ -199,75 +206,73 @@ async function rmrf(target) {
|
|
|
199
206
|
await fsp.rm(target, { recursive: true, force: true });
|
|
200
207
|
}
|
|
201
208
|
|
|
202
|
-
async function copyDir(source, target) {
|
|
203
|
-
await fsp.mkdir(target, { recursive: true });
|
|
204
|
-
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
205
|
-
for (const entry of entries) {
|
|
206
|
-
const from = path.join(source, entry.name);
|
|
207
|
-
const to = path.join(target, entry.name);
|
|
208
|
-
if (entry.isDirectory()) {
|
|
209
|
-
await copyDir(from, to);
|
|
210
|
-
} else if (entry.isFile()) {
|
|
211
|
-
await fsp.copyFile(from, to);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function installSkill(skill) {
|
|
217
|
-
const source = path.join(ROOT, 'skills', skill);
|
|
218
|
-
const target = path.join(DEST, skill);
|
|
219
|
-
const references = path.join(ROOT, 'references');
|
|
220
|
-
|
|
221
|
-
if (!await exists(path.join(source, 'SKILL.md'))) {
|
|
222
|
-
throw new Error(`missing skill source: ${source}`);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
await rmrf(target);
|
|
226
|
-
await fsp.mkdir(target, { recursive: true });
|
|
227
|
-
await fsp.copyFile(path.join(source, 'SKILL.md'), path.join(target, 'SKILL.md'));
|
|
228
|
-
await copyDir(references, path.join(target, 'references'));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
209
|
async function install() {
|
|
232
|
-
await
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
if (CODEX_MIRROR !== DEST && fs.existsSync(CODEX_MIRROR)) {
|
|
243
|
-
for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
|
|
244
|
-
await rmrf(path.join(CODEX_MIRROR, skill));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
210
|
+
const result = await installSkillSetTransactional({
|
|
211
|
+
sourceRoot: ROOT,
|
|
212
|
+
destination: DEST,
|
|
213
|
+
codexMirror: CODEX_MIRROR,
|
|
214
|
+
packageName: String(PACKAGE_JSON.name || 'yam-flow'),
|
|
215
|
+
version: VERSION,
|
|
216
|
+
skills: SKILLS,
|
|
217
|
+
legacySkills: LEGACY_SKILLS,
|
|
218
|
+
retiredSkills: RETIRED_SKILLS
|
|
219
|
+
});
|
|
247
220
|
console.log(`yam installed to ${DEST}`);
|
|
221
|
+
console.log(`install receipt: ${result.receiptPath}`);
|
|
222
|
+
console.log(`integrity: ${result.installedFiles} files, sha256:${result.sourceDigest}`);
|
|
223
|
+
for (const warning of result.cleanupWarnings) console.warn(`warning: ${warning}`);
|
|
248
224
|
console.log('Restart Codex to reload skills.');
|
|
249
225
|
}
|
|
250
226
|
|
|
251
227
|
async function uninstall() {
|
|
228
|
+
const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
|
|
229
|
+
if (await exists(lockPath)) {
|
|
230
|
+
throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
|
|
231
|
+
}
|
|
252
232
|
for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
|
|
253
233
|
await rmrf(path.join(DEST, skill));
|
|
254
234
|
if (CODEX_MIRROR !== DEST) {
|
|
255
235
|
await rmrf(path.join(CODEX_MIRROR, skill));
|
|
256
236
|
}
|
|
257
237
|
}
|
|
238
|
+
await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
|
|
258
239
|
console.log(`yam removed from ${DEST}`);
|
|
259
240
|
if (CODEX_MIRROR !== DEST) console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
|
|
260
241
|
console.log('Restart Codex to unload skills.');
|
|
261
242
|
}
|
|
262
243
|
|
|
263
244
|
async function status({ quiet = false } = {}) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
245
|
+
const report = await inspectSkillInstallation({
|
|
246
|
+
sourceRoot: ROOT,
|
|
247
|
+
destination: DEST,
|
|
248
|
+
packageName: String(PACKAGE_JSON.name || 'yam-flow'),
|
|
249
|
+
version: VERSION,
|
|
250
|
+
skills: SKILLS
|
|
251
|
+
});
|
|
252
|
+
if (!quiet) {
|
|
253
|
+
for (const skill of report.skills) {
|
|
254
|
+
const label = skill.status === 'ok' ? 'ok ' : skill.status === 'missing' ? 'missing' : 'drift ';
|
|
255
|
+
console.log(`${label} ${skill.skill}`);
|
|
256
|
+
}
|
|
257
|
+
if (report.receiptStatus === 'ok' && report.receipt) {
|
|
258
|
+
console.log(`ok install receipt ${report.receipt.package.name}@${report.receipt.package.version}`);
|
|
259
|
+
console.log(` sha256:${report.receipt.integrity.source_digest}`);
|
|
260
|
+
} else {
|
|
261
|
+
console.log(`${report.receiptStatus === 'missing' ? 'missing' : 'drift '} install receipt`);
|
|
262
|
+
}
|
|
263
|
+
if (report.issues.length) {
|
|
264
|
+
console.log('');
|
|
265
|
+
console.log('Install integrity issues:');
|
|
266
|
+
for (const issue of report.issues.slice(0, 12)) console.log(`- ${issue}`);
|
|
267
|
+
if (report.issues.length > 12) console.log(`- ... ${report.issues.length - 12} more issue(s)`);
|
|
268
|
+
if (report.recoveryArtifacts.length) {
|
|
269
|
+
console.log('Confirm no install is running, then inspect and preserve the transaction backup before retrying.');
|
|
270
|
+
} else {
|
|
271
|
+
console.log('Run `yam install` to restore the package-bundled skill set.');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
269
274
|
}
|
|
270
|
-
return
|
|
275
|
+
return report.ok ? 0 : Math.max(1, report.issues.length);
|
|
271
276
|
}
|
|
272
277
|
|
|
273
278
|
async function list() {
|
|
@@ -316,6 +321,7 @@ async function verify({ quiet = false } = {}) {
|
|
|
316
321
|
|
|
317
322
|
if (!manifest) return failOrReport('yam verify', issues, quiet);
|
|
318
323
|
if (manifest.name !== 'yam') issues.push('manifest name must be yam');
|
|
324
|
+
if (manifest.version !== VERSION) issues.push(`manifest version must match package version: ${manifest.version || 'missing'} != ${VERSION}`);
|
|
319
325
|
if (manifest.defaultHooks !== false) issues.push('manifest defaultHooks must be false');
|
|
320
326
|
if (!Array.isArray(manifest.routes) || manifest.routes.length !== SKILLS.length) {
|
|
321
327
|
issues.push(`manifest routes must contain ${SKILLS.length} routes`);
|
|
@@ -751,16 +757,27 @@ Notes:
|
|
|
751
757
|
async function studyNoteCheck(args = []) {
|
|
752
758
|
const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
|
|
753
759
|
const dir = path.resolve(firstPositional(args) || process.cwd());
|
|
754
|
-
const changedFiles = await gitChangedFiles(dir);
|
|
755
760
|
const reportText = await readStudyNoteReportText(flags, dir);
|
|
761
|
+
const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
|
|
762
|
+
const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
|
|
763
|
+
printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
|
|
764
|
+
if (result.blockers.length) process.exitCode = 1;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
|
|
768
|
+
const changedFiles = await gitChangedFiles(dir);
|
|
756
769
|
const needsStudyNote = changedFiles.length > 0;
|
|
757
770
|
const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
|
|
758
771
|
const checks = [
|
|
759
772
|
studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
|
|
760
773
|
studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
|
|
761
774
|
studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
|
|
775
|
+
studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
|
|
762
776
|
studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
|
|
763
|
-
studyNoteGuardCheck('
|
|
777
|
+
studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
|
|
778
|
+
studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
|
|
779
|
+
studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
|
|
780
|
+
studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
|
|
764
781
|
];
|
|
765
782
|
for (const requirement of hygieneRequirements) {
|
|
766
783
|
checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
|
|
@@ -772,14 +789,13 @@ async function studyNoteCheck(args = []) {
|
|
|
772
789
|
project: dir,
|
|
773
790
|
changed_files: changedFiles,
|
|
774
791
|
changed_file_count: changedFiles.length,
|
|
775
|
-
report_source:
|
|
792
|
+
report_source: reportSource,
|
|
776
793
|
checks,
|
|
777
794
|
blockers,
|
|
778
795
|
next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
|
|
779
796
|
truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
|
|
780
797
|
};
|
|
781
|
-
|
|
782
|
-
if (blockers.length) process.exitCode = 1;
|
|
798
|
+
return result;
|
|
783
799
|
}
|
|
784
800
|
|
|
785
801
|
function firstPositional(args = []) {
|
|
@@ -1023,6 +1039,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1023
1039
|
const codexHome = path.join(os.homedir(), '.codex');
|
|
1024
1040
|
const pluginCache = path.join(codexHome, 'plugins', 'cache');
|
|
1025
1041
|
const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
|
|
1042
|
+
const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
|
|
1043
|
+
const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
|
|
1026
1044
|
const tmux = await findExecutable('tmux');
|
|
1027
1045
|
const packageInfo = await projectPackageInfo(dir);
|
|
1028
1046
|
const pack = await findProjectPack(dir);
|
|
@@ -1041,7 +1059,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1041
1059
|
const rows = [
|
|
1042
1060
|
readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
|
|
1043
1061
|
readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
|
|
1044
|
-
readinessRow('yam-lite hook',
|
|
1062
|
+
readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
|
|
1063
|
+
readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
|
|
1045
1064
|
readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
|
|
1046
1065
|
readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
|
|
1047
1066
|
readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
|
|
@@ -1415,7 +1434,8 @@ Usage:
|
|
|
1415
1434
|
yam hook run <lite|study-note>
|
|
1416
1435
|
|
|
1417
1436
|
Notes:
|
|
1418
|
-
hooks are opt-in
|
|
1437
|
+
hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
|
|
1438
|
+
Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
|
|
1419
1439
|
`);
|
|
1420
1440
|
}
|
|
1421
1441
|
|
|
@@ -1445,11 +1465,11 @@ function hookPathFor(parsed) {
|
|
|
1445
1465
|
}
|
|
1446
1466
|
|
|
1447
1467
|
function isYamLiteHook(handler: AnyRecord = {}) {
|
|
1448
|
-
return handler?.type === 'command' && String(handler.command || '')
|
|
1468
|
+
return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
|
|
1449
1469
|
}
|
|
1450
1470
|
|
|
1451
1471
|
function isYamStudyNoteHook(handler: AnyRecord = {}) {
|
|
1452
|
-
return handler?.type === 'command' && String(handler.command || '')
|
|
1472
|
+
return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
|
|
1453
1473
|
}
|
|
1454
1474
|
|
|
1455
1475
|
function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
|
|
@@ -1459,7 +1479,8 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
|
|
|
1459
1479
|
function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
|
|
1460
1480
|
const next = { ...config };
|
|
1461
1481
|
for (const event of Object.keys(next)) {
|
|
1462
|
-
|
|
1482
|
+
if (!Array.isArray(next[event])) continue;
|
|
1483
|
+
const entries = next[event];
|
|
1463
1484
|
const keptEntries = [];
|
|
1464
1485
|
for (const entry of entries) {
|
|
1465
1486
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
|
|
@@ -1474,28 +1495,42 @@ function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
|
|
|
1474
1495
|
|
|
1475
1496
|
function withYamHook(config: AnyRecord = {}, profile = 'lite') {
|
|
1476
1497
|
const next = stripYamHooks(config, profile);
|
|
1477
|
-
const
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1498
|
+
const command = hookCommandForProfile(profile);
|
|
1499
|
+
for (const event of hookEventsForProfile(profile)) {
|
|
1500
|
+
const entry = {
|
|
1501
|
+
hooks: [
|
|
1502
|
+
{
|
|
1503
|
+
type: 'command',
|
|
1504
|
+
command,
|
|
1505
|
+
timeout: 5
|
|
1506
|
+
}
|
|
1507
|
+
]
|
|
1508
|
+
};
|
|
1509
|
+
next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
|
|
1510
|
+
}
|
|
1488
1511
|
return next;
|
|
1489
1512
|
}
|
|
1490
1513
|
|
|
1491
1514
|
async function hookStatus(args = []) {
|
|
1492
1515
|
const parsed = parseHookArgs(args);
|
|
1493
1516
|
const target = hookPathFor(parsed);
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1517
|
+
const loaded = await readHookConfig(target);
|
|
1518
|
+
if (loaded.error) {
|
|
1519
|
+
console.log('yam-lite hook: broken');
|
|
1520
|
+
console.log('yam-study-note hook: broken');
|
|
1521
|
+
console.log(` - hook config unreadable: ${loaded.error}`);
|
|
1522
|
+
console.log(`scope: ${parsed.mode}`);
|
|
1523
|
+
console.log(`file: ${target}`);
|
|
1524
|
+
process.exitCode = 1;
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const lite = await inspectHookProfile(loaded.config, 'lite');
|
|
1528
|
+
const studyNote = await inspectHookProfile(loaded.config, 'study-note');
|
|
1529
|
+
printHookProfileStatus('lite', lite);
|
|
1530
|
+
printHookProfileStatus('study-note', studyNote);
|
|
1497
1531
|
console.log(`scope: ${parsed.mode}`);
|
|
1498
1532
|
console.log(`file: ${target}`);
|
|
1533
|
+
if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
|
|
1499
1534
|
}
|
|
1500
1535
|
|
|
1501
1536
|
function hookConfigHasYamLite(config = {}) {
|
|
@@ -1508,6 +1543,160 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
|
|
|
1508
1543
|
}));
|
|
1509
1544
|
}
|
|
1510
1545
|
|
|
1546
|
+
function hookCommandForProfile(profile = 'lite') {
|
|
1547
|
+
return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function hookEventsForProfile(profile = 'lite') {
|
|
1551
|
+
return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function hookProfileEntries(config: AnyRecord = {}, profile = 'lite') {
|
|
1555
|
+
const matches = [];
|
|
1556
|
+
for (const [event, entries] of Object.entries(config)) {
|
|
1557
|
+
if (!Array.isArray(entries)) continue;
|
|
1558
|
+
for (const entry of entries) {
|
|
1559
|
+
if (!Array.isArray(entry?.hooks)) continue;
|
|
1560
|
+
for (const handler of entry.hooks) {
|
|
1561
|
+
if (isYamHookProfile(handler, profile)) matches.push({ event, handler });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return matches;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function splitHookCommand(command = '') {
|
|
1569
|
+
const words = [];
|
|
1570
|
+
let current = '';
|
|
1571
|
+
let quote = '';
|
|
1572
|
+
let escaped = false;
|
|
1573
|
+
let started = false;
|
|
1574
|
+
for (const char of String(command)) {
|
|
1575
|
+
if (escaped) {
|
|
1576
|
+
current += char;
|
|
1577
|
+
escaped = false;
|
|
1578
|
+
started = true;
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (char === '\\' && quote !== "'") {
|
|
1582
|
+
escaped = true;
|
|
1583
|
+
started = true;
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (quote) {
|
|
1587
|
+
if (char === quote) quote = '';
|
|
1588
|
+
else current += char;
|
|
1589
|
+
started = true;
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (char === '"' || char === "'") {
|
|
1593
|
+
quote = char;
|
|
1594
|
+
started = true;
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (/\s/.test(char)) {
|
|
1598
|
+
if (started) {
|
|
1599
|
+
words.push(current);
|
|
1600
|
+
current = '';
|
|
1601
|
+
started = false;
|
|
1602
|
+
}
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
current += char;
|
|
1606
|
+
started = true;
|
|
1607
|
+
}
|
|
1608
|
+
if (escaped || quote) return [];
|
|
1609
|
+
if (started) words.push(current);
|
|
1610
|
+
return words;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function parseYamHookCommand(command = '', profile = 'lite') {
|
|
1614
|
+
const words = splitHookCommand(command);
|
|
1615
|
+
if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
|
|
1616
|
+
const nodeName = path.basename(words[0]).toLowerCase();
|
|
1617
|
+
if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
|
|
1618
|
+
return { executable: words[0], script: words[1] };
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
|
|
1622
|
+
const yamName = path.basename(words[0]).toLowerCase();
|
|
1623
|
+
if (/^yam(?:\.cmd|\.exe)?$/.test(yamName)) return { executable: words[0], script: '' };
|
|
1624
|
+
}
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
async function hookTargetExists(target = '', executable = false) {
|
|
1629
|
+
if (!target) return false;
|
|
1630
|
+
if (!path.isAbsolute(target)) return executable ? Boolean(await findExecutable(target)) : false;
|
|
1631
|
+
try {
|
|
1632
|
+
await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
|
|
1633
|
+
return true;
|
|
1634
|
+
} catch {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
async function inspectHookHandler(handler: AnyRecord = {}, profile = 'lite') {
|
|
1640
|
+
const parsed = parseYamHookCommand(String(handler.command || ''), profile);
|
|
1641
|
+
if (!parsed) return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
|
|
1642
|
+
if (!await hookTargetExists(parsed.executable, true)) {
|
|
1643
|
+
return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
|
|
1644
|
+
}
|
|
1645
|
+
if (parsed.script && !await hookTargetExists(parsed.script)) {
|
|
1646
|
+
return { ok: false, issue: `hook target is missing: ${parsed.script}` };
|
|
1647
|
+
}
|
|
1648
|
+
return { ok: true, issue: '' };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function inspectHookProfile(config: AnyRecord = {}, profile = 'lite') {
|
|
1652
|
+
const entries = hookProfileEntries(config, profile);
|
|
1653
|
+
if (!entries.length) return { state: 'disabled', issues: [], entries };
|
|
1654
|
+
const issues = [];
|
|
1655
|
+
for (const event of hookEventsForProfile(profile)) {
|
|
1656
|
+
const count = entries.filter((entry) => entry.event === event).length;
|
|
1657
|
+
if (count === 0) issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
|
|
1658
|
+
if (count > 1) issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
|
|
1659
|
+
}
|
|
1660
|
+
for (const entry of entries) {
|
|
1661
|
+
if (!hookEventsForProfile(profile).includes(entry.event)) {
|
|
1662
|
+
issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
|
|
1663
|
+
}
|
|
1664
|
+
const health = await inspectHookHandler(entry.handler, profile);
|
|
1665
|
+
if (!health.ok) issues.push(`${entry.event}: ${health.issue}`);
|
|
1666
|
+
}
|
|
1667
|
+
return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function printHookProfileStatus(profile, health) {
|
|
1671
|
+
console.log(`yam-${profile} hook: ${health.state}`);
|
|
1672
|
+
for (const issue of health.issues) console.log(` - ${issue}`);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function readHookConfig(target) {
|
|
1676
|
+
try {
|
|
1677
|
+
const value = JSON.parse(await fsp.readFile(target, 'utf8'));
|
|
1678
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
1679
|
+
return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
|
|
1680
|
+
}
|
|
1681
|
+
return { config: value, exists: true, error: '' };
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { config: {}, exists: false, error: '' };
|
|
1684
|
+
return { config: {}, exists: true, error: errorMessage(error) };
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function writeHookConfig(target, config) {
|
|
1689
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1690
|
+
const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
|
|
1691
|
+
try {
|
|
1692
|
+
await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
1693
|
+
await fsp.rename(temporary, target);
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
await rmrf(temporary).catch(() => {});
|
|
1696
|
+
throw error;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1511
1700
|
async function hookEnable(args = []) {
|
|
1512
1701
|
const parsed = parseHookArgs(args);
|
|
1513
1702
|
if (!['lite', 'study-note'].includes(parsed.profile)) {
|
|
@@ -1516,23 +1705,45 @@ async function hookEnable(args = []) {
|
|
|
1516
1705
|
return;
|
|
1517
1706
|
}
|
|
1518
1707
|
const target = hookPathFor(parsed);
|
|
1519
|
-
const
|
|
1708
|
+
const loaded = await readHookConfig(target);
|
|
1709
|
+
if (loaded.error) {
|
|
1710
|
+
console.error(`Cannot update unreadable hook config: ${target}`);
|
|
1711
|
+
console.error(loaded.error);
|
|
1712
|
+
process.exitCode = 1;
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const current = loaded.config;
|
|
1716
|
+
const existing = hookProfileEntries(current, parsed.profile);
|
|
1717
|
+
const expectedEvents = hookEventsForProfile(parsed.profile);
|
|
1718
|
+
const expectedCommand = hookCommandForProfile(parsed.profile);
|
|
1719
|
+
const needsMigration = existing.length > 0 && (
|
|
1720
|
+
existing.length !== expectedEvents.length
|
|
1721
|
+
|| existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand)
|
|
1722
|
+
);
|
|
1520
1723
|
const next = withYamHook(current, parsed.profile);
|
|
1521
1724
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1522
|
-
if (
|
|
1725
|
+
if (loaded.exists) {
|
|
1523
1726
|
const backup = `${target}.yam-backup-${timestampId()}`;
|
|
1524
1727
|
await fsp.copyFile(target, backup);
|
|
1525
1728
|
console.log(`backup: ${backup}`);
|
|
1526
1729
|
}
|
|
1527
|
-
await
|
|
1730
|
+
await writeHookConfig(target, next);
|
|
1528
1731
|
console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
|
|
1732
|
+
if (needsMigration) console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
|
|
1529
1733
|
console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
|
|
1530
1734
|
}
|
|
1531
1735
|
|
|
1532
1736
|
async function hookDisable(args = []) {
|
|
1533
1737
|
const parsed = parseHookArgs(args);
|
|
1534
1738
|
const target = hookPathFor(parsed);
|
|
1535
|
-
const
|
|
1739
|
+
const loaded = await readHookConfig(target);
|
|
1740
|
+
if (loaded.error) {
|
|
1741
|
+
console.error(`Cannot update unreadable hook config: ${target}`);
|
|
1742
|
+
console.error(loaded.error);
|
|
1743
|
+
process.exitCode = 1;
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const current = loaded.config;
|
|
1536
1747
|
if (!hookConfigHasProfile(current, parsed.profile)) {
|
|
1537
1748
|
console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
|
|
1538
1749
|
return;
|
|
@@ -1541,7 +1752,7 @@ async function hookDisable(args = []) {
|
|
|
1541
1752
|
if (Object.keys(next).length === 0) {
|
|
1542
1753
|
await rmrf(target);
|
|
1543
1754
|
} else {
|
|
1544
|
-
await
|
|
1755
|
+
await writeHookConfig(target, next);
|
|
1545
1756
|
}
|
|
1546
1757
|
console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
|
|
1547
1758
|
}
|
|
@@ -1555,6 +1766,10 @@ async function hookRun(args = []) {
|
|
|
1555
1766
|
const input = await readStdinJson();
|
|
1556
1767
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
1557
1768
|
const cwd = String(input?.cwd || process.cwd());
|
|
1769
|
+
if (profile === 'study-note' && event === 'Stop') {
|
|
1770
|
+
console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1558
1773
|
const prompt = extractPrompt(input);
|
|
1559
1774
|
const additionalContext = profile === 'study-note'
|
|
1560
1775
|
? await buildStudyNoteHookContext({ cwd })
|
|
@@ -1620,6 +1835,7 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1620
1835
|
];
|
|
1621
1836
|
if (!changedFiles.length) {
|
|
1622
1837
|
lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
|
|
1838
|
+
lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
|
|
1623
1839
|
return lines.join('\n');
|
|
1624
1840
|
}
|
|
1625
1841
|
lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
|
|
@@ -1629,10 +1845,31 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1629
1845
|
lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
|
|
1630
1846
|
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.');
|
|
1631
1847
|
}
|
|
1632
|
-
lines.push('This
|
|
1848
|
+
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.');
|
|
1633
1849
|
return lines.join('\n');
|
|
1634
1850
|
}
|
|
1635
1851
|
|
|
1852
|
+
async function buildStudyNoteStopOutput(input: AnyRecord = {}, cwd = process.cwd()) {
|
|
1853
|
+
const lastAssistantMessage = String(input?.last_assistant_message || input?.lastAssistantMessage || '');
|
|
1854
|
+
const result = await buildStudyNoteGuardResult(path.resolve(cwd), lastAssistantMessage, 'stop_hook_last_assistant_message');
|
|
1855
|
+
if (!result.blockers.length) return { continue: true };
|
|
1856
|
+
const summary = result.blockers.slice(0, 4).join('; ');
|
|
1857
|
+
if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
|
|
1858
|
+
return {
|
|
1859
|
+
continue: true,
|
|
1860
|
+
systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
return {
|
|
1864
|
+
decision: 'block',
|
|
1865
|
+
reason: [
|
|
1866
|
+
'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
|
|
1867
|
+
summary,
|
|
1868
|
+
'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.'
|
|
1869
|
+
].join(' ')
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1636
1873
|
function yamLiteRouteHint(prompt = '') {
|
|
1637
1874
|
const text = String(prompt || '').toLowerCase();
|
|
1638
1875
|
if (!text) return '';
|