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/dist/bin/yam.js
CHANGED
|
@@ -9,6 +9,7 @@ import process from 'node:process';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { TRUTH_STATUSES, buildLoopReport, buildMediaGenerationProof, buildMissionCompletionGate, buildMissionPatchEnvelope, buildMissionSubagentReceipt, buildRollbackHint, buildRuntimeBackendEvidence, buildStudyNote, buildUeyeDesignCompletionGate, buildUeyeVisualProvenance, buildUeyeRunReport, buildUeyeSurfaceContext, buildYamCompletionProof, detectDbSafetyText as detectTrustDbSafetyText, isTruthStatus } from '../lib/trust-kernel.js';
|
|
11
11
|
import { archiveUeyeRevision, inspectImageFile, upsertUeyeAsset, verifyUeyeAssetManifest, verifyUeyeRevisionHistory } from '../lib/ueye-artifacts.js';
|
|
12
|
+
import { INSTALL_LOCK_NAME, INSTALL_RECEIPT_NAME, inspectSkillInstallation, installSkillSetTransactional } from '../lib/skill-installation.js';
|
|
12
13
|
function errorMessage(error) {
|
|
13
14
|
return error instanceof Error ? error.message : String(error);
|
|
14
15
|
}
|
|
@@ -49,8 +50,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
|
|
|
49
50
|
const PROJECT_PACK = 'yam.project.md';
|
|
50
51
|
const LEGACY_PROJECT_PACK = 'timeto.project.md';
|
|
51
52
|
const PACK_STALE_DAYS = 30;
|
|
52
|
-
const
|
|
53
|
-
const
|
|
53
|
+
const YAM_HOOK_ENTRYPOINT = path.join(ROOT, 'dist', 'bin', 'yam.js');
|
|
54
|
+
const YAM_LITE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run lite`;
|
|
55
|
+
const YAM_STUDY_NOTE_HOOK_COMMAND = `${JSON.stringify(process.execPath)} ${JSON.stringify(YAM_HOOK_ENTRYPOINT)} hook run study-note`;
|
|
54
56
|
const REQUIRED_PACK_SECTIONS = [
|
|
55
57
|
'Product Direction',
|
|
56
58
|
'UI Direction',
|
|
@@ -167,73 +169,77 @@ async function exists(file) {
|
|
|
167
169
|
async function rmrf(target) {
|
|
168
170
|
await fsp.rm(target, { recursive: true, force: true });
|
|
169
171
|
}
|
|
170
|
-
async function copyDir(source, target) {
|
|
171
|
-
await fsp.mkdir(target, { recursive: true });
|
|
172
|
-
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
173
|
-
for (const entry of entries) {
|
|
174
|
-
const from = path.join(source, entry.name);
|
|
175
|
-
const to = path.join(target, entry.name);
|
|
176
|
-
if (entry.isDirectory()) {
|
|
177
|
-
await copyDir(from, to);
|
|
178
|
-
}
|
|
179
|
-
else if (entry.isFile()) {
|
|
180
|
-
await fsp.copyFile(from, to);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
async function installSkill(skill) {
|
|
185
|
-
const source = path.join(ROOT, 'skills', skill);
|
|
186
|
-
const target = path.join(DEST, skill);
|
|
187
|
-
const references = path.join(ROOT, 'references');
|
|
188
|
-
if (!await exists(path.join(source, 'SKILL.md'))) {
|
|
189
|
-
throw new Error(`missing skill source: ${source}`);
|
|
190
|
-
}
|
|
191
|
-
await rmrf(target);
|
|
192
|
-
await fsp.mkdir(target, { recursive: true });
|
|
193
|
-
await fsp.copyFile(path.join(source, 'SKILL.md'), path.join(target, 'SKILL.md'));
|
|
194
|
-
await copyDir(references, path.join(target, 'references'));
|
|
195
|
-
}
|
|
196
172
|
async function install() {
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
if (CODEX_MIRROR !== DEST && fs.existsSync(CODEX_MIRROR)) {
|
|
208
|
-
for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
|
|
209
|
-
await rmrf(path.join(CODEX_MIRROR, skill));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
173
|
+
const result = await installSkillSetTransactional({
|
|
174
|
+
sourceRoot: ROOT,
|
|
175
|
+
destination: DEST,
|
|
176
|
+
codexMirror: CODEX_MIRROR,
|
|
177
|
+
packageName: String(PACKAGE_JSON.name || 'yam-flow'),
|
|
178
|
+
version: VERSION,
|
|
179
|
+
skills: SKILLS,
|
|
180
|
+
legacySkills: LEGACY_SKILLS,
|
|
181
|
+
retiredSkills: RETIRED_SKILLS
|
|
182
|
+
});
|
|
212
183
|
console.log(`yam installed to ${DEST}`);
|
|
184
|
+
console.log(`install receipt: ${result.receiptPath}`);
|
|
185
|
+
console.log(`integrity: ${result.installedFiles} files, sha256:${result.sourceDigest}`);
|
|
186
|
+
for (const warning of result.cleanupWarnings)
|
|
187
|
+
console.warn(`warning: ${warning}`);
|
|
213
188
|
console.log('Restart Codex to reload skills.');
|
|
214
189
|
}
|
|
215
190
|
async function uninstall() {
|
|
191
|
+
const lockPath = path.join(DEST, INSTALL_LOCK_NAME);
|
|
192
|
+
if (await exists(lockPath)) {
|
|
193
|
+
throw new Error(`cannot uninstall while an install lock exists: ${lockPath}`);
|
|
194
|
+
}
|
|
216
195
|
for (const skill of [...SKILLS, ...LEGACY_SKILLS, ...RETIRED_SKILLS]) {
|
|
217
196
|
await rmrf(path.join(DEST, skill));
|
|
218
197
|
if (CODEX_MIRROR !== DEST) {
|
|
219
198
|
await rmrf(path.join(CODEX_MIRROR, skill));
|
|
220
199
|
}
|
|
221
200
|
}
|
|
201
|
+
await rmrf(path.join(DEST, INSTALL_RECEIPT_NAME));
|
|
222
202
|
console.log(`yam removed from ${DEST}`);
|
|
223
203
|
if (CODEX_MIRROR !== DEST)
|
|
224
204
|
console.log(`yam mirror entries removed from ${CODEX_MIRROR}`);
|
|
225
205
|
console.log('Restart Codex to unload skills.');
|
|
226
206
|
}
|
|
227
207
|
async function status({ quiet = false } = {}) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
208
|
+
const report = await inspectSkillInstallation({
|
|
209
|
+
sourceRoot: ROOT,
|
|
210
|
+
destination: DEST,
|
|
211
|
+
packageName: String(PACKAGE_JSON.name || 'yam-flow'),
|
|
212
|
+
version: VERSION,
|
|
213
|
+
skills: SKILLS
|
|
214
|
+
});
|
|
215
|
+
if (!quiet) {
|
|
216
|
+
for (const skill of report.skills) {
|
|
217
|
+
const label = skill.status === 'ok' ? 'ok ' : skill.status === 'missing' ? 'missing' : 'drift ';
|
|
218
|
+
console.log(`${label} ${skill.skill}`);
|
|
219
|
+
}
|
|
220
|
+
if (report.receiptStatus === 'ok' && report.receipt) {
|
|
221
|
+
console.log(`ok install receipt ${report.receipt.package.name}@${report.receipt.package.version}`);
|
|
222
|
+
console.log(` sha256:${report.receipt.integrity.source_digest}`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
console.log(`${report.receiptStatus === 'missing' ? 'missing' : 'drift '} install receipt`);
|
|
226
|
+
}
|
|
227
|
+
if (report.issues.length) {
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log('Install integrity issues:');
|
|
230
|
+
for (const issue of report.issues.slice(0, 12))
|
|
231
|
+
console.log(`- ${issue}`);
|
|
232
|
+
if (report.issues.length > 12)
|
|
233
|
+
console.log(`- ... ${report.issues.length - 12} more issue(s)`);
|
|
234
|
+
if (report.recoveryArtifacts.length) {
|
|
235
|
+
console.log('Confirm no install is running, then inspect and preserve the transaction backup before retrying.');
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
console.log('Run `yam install` to restore the package-bundled skill set.');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
235
241
|
}
|
|
236
|
-
return
|
|
242
|
+
return report.ok ? 0 : Math.max(1, report.issues.length);
|
|
237
243
|
}
|
|
238
244
|
async function list() {
|
|
239
245
|
const manifest = await loadManifest();
|
|
@@ -279,6 +285,8 @@ async function verify({ quiet = false } = {}) {
|
|
|
279
285
|
return failOrReport('yam verify', issues, quiet);
|
|
280
286
|
if (manifest.name !== 'yam')
|
|
281
287
|
issues.push('manifest name must be yam');
|
|
288
|
+
if (manifest.version !== VERSION)
|
|
289
|
+
issues.push(`manifest version must match package version: ${manifest.version || 'missing'} != ${VERSION}`);
|
|
282
290
|
if (manifest.defaultHooks !== false)
|
|
283
291
|
issues.push('manifest defaultHooks must be false');
|
|
284
292
|
if (!Array.isArray(manifest.routes) || manifest.routes.length !== SKILLS.length) {
|
|
@@ -743,16 +751,27 @@ Notes:
|
|
|
743
751
|
async function studyNoteCheck(args = []) {
|
|
744
752
|
const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
|
|
745
753
|
const dir = path.resolve(firstPositional(args) || process.cwd());
|
|
746
|
-
const changedFiles = await gitChangedFiles(dir);
|
|
747
754
|
const reportText = await readStudyNoteReportText(flags, dir);
|
|
755
|
+
const reportSource = flags.report ? path.resolve(dir, String(flags.report)) : flags.text ? 'inline_text' : 'not_provided';
|
|
756
|
+
const result = await buildStudyNoteGuardResult(dir, reportText, reportSource);
|
|
757
|
+
printJsonOrHuman(result, Boolean(flags.json), 'Study Note guard');
|
|
758
|
+
if (result.blockers.length)
|
|
759
|
+
process.exitCode = 1;
|
|
760
|
+
}
|
|
761
|
+
async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'not_provided') {
|
|
762
|
+
const changedFiles = await gitChangedFiles(dir);
|
|
748
763
|
const needsStudyNote = changedFiles.length > 0;
|
|
749
764
|
const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
|
|
750
765
|
const checks = [
|
|
751
766
|
studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
|
|
752
767
|
studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
|
|
753
768
|
studyNoteGuardCheck('role_or_responsibility', !needsStudyNote || /(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what the touched code/artifact does'),
|
|
769
|
+
studyNoteGuardCheck('execution_point', !needsStudyNote || /(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain where or when the touched code/artifact runs or is read'),
|
|
754
770
|
studyNoteGuardCheck('before_after_or_change', !needsStudyNote || /(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should explain what changed from before to after'),
|
|
755
|
-
studyNoteGuardCheck('
|
|
771
|
+
studyNoteGuardCheck('expected_behavior', !needsStudyNote || /(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should describe the expected behavior or result'),
|
|
772
|
+
studyNoteGuardCheck('syntax_or_structure', !needsStudyNote || /(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should include one useful syntax or structure insight'),
|
|
773
|
+
studyNoteGuardCheck('verification', !needsStudyNote || /(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what was checked'),
|
|
774
|
+
studyNoteGuardCheck('limits_or_uncertainty', !needsStudyNote || /(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText) ? 'pass' : 'fail', 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
|
|
756
775
|
];
|
|
757
776
|
for (const requirement of hygieneRequirements) {
|
|
758
777
|
checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
|
|
@@ -764,15 +783,13 @@ async function studyNoteCheck(args = []) {
|
|
|
764
783
|
project: dir,
|
|
765
784
|
changed_files: changedFiles,
|
|
766
785
|
changed_file_count: changedFiles.length,
|
|
767
|
-
report_source:
|
|
786
|
+
report_source: reportSource,
|
|
768
787
|
checks,
|
|
769
788
|
blockers,
|
|
770
789
|
next_action: blockers[0] || (needsStudyNote ? 'Study Note guard passed for the supplied report text' : 'no Study Note required because no changed files were detected'),
|
|
771
790
|
truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
|
|
772
791
|
};
|
|
773
|
-
|
|
774
|
-
if (blockers.length)
|
|
775
|
-
process.exitCode = 1;
|
|
792
|
+
return result;
|
|
776
793
|
}
|
|
777
794
|
function firstPositional(args = []) {
|
|
778
795
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -1010,6 +1027,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1010
1027
|
const codexHome = path.join(os.homedir(), '.codex');
|
|
1011
1028
|
const pluginCache = path.join(codexHome, 'plugins', 'cache');
|
|
1012
1029
|
const globalHook = await readJsonOrDefault(path.join(codexHome, 'hooks.json'), {});
|
|
1030
|
+
const globalLiteHookHealth = await inspectHookProfile(globalHook, 'lite');
|
|
1031
|
+
const globalStudyNoteHookHealth = await inspectHookProfile(globalHook, 'study-note');
|
|
1013
1032
|
const tmux = await findExecutable('tmux');
|
|
1014
1033
|
const packageInfo = await projectPackageInfo(dir);
|
|
1015
1034
|
const pack = await findProjectPack(dir);
|
|
@@ -1028,7 +1047,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1028
1047
|
const rows = [
|
|
1029
1048
|
readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
|
|
1030
1049
|
readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
|
|
1031
|
-
readinessRow('yam-lite hook',
|
|
1050
|
+
readinessRow('yam-lite hook', globalLiteHookHealth.state, globalLiteHookHealth.issues[0] || 'optional UserPromptSubmit guide'),
|
|
1051
|
+
readinessRow('yam-study-note hook', globalStudyNoteHookHealth.state, globalStudyNoteHookHealth.issues[0] || 'optional prompt reminder plus one-pass Stop completion gate'),
|
|
1032
1052
|
readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
|
|
1033
1053
|
readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
|
|
1034
1054
|
readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
|
|
@@ -1406,7 +1426,8 @@ Usage:
|
|
|
1406
1426
|
yam hook run <lite|study-note>
|
|
1407
1427
|
|
|
1408
1428
|
Notes:
|
|
1409
|
-
hooks are opt-in
|
|
1429
|
+
hooks are opt-in. lite is advisory-only; study-note adds a prompt reminder and a one-pass Stop completion gate.
|
|
1430
|
+
Hooks do not generate reports, run tmux, start subagents, or execute verification automatically.
|
|
1410
1431
|
`);
|
|
1411
1432
|
}
|
|
1412
1433
|
function parseHookArgs(args = []) {
|
|
@@ -1437,10 +1458,10 @@ function hookPathFor(parsed) {
|
|
|
1437
1458
|
return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
|
|
1438
1459
|
}
|
|
1439
1460
|
function isYamLiteHook(handler = {}) {
|
|
1440
|
-
return handler?.type === 'command' && String(handler.command || '')
|
|
1461
|
+
return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+lite(?:\s|$)/i.test(String(handler.command || ''));
|
|
1441
1462
|
}
|
|
1442
1463
|
function isYamStudyNoteHook(handler = {}) {
|
|
1443
|
-
return handler?.type === 'command' && String(handler.command || '')
|
|
1464
|
+
return handler?.type === 'command' && /yam(?:\.js)?["']?\s+hook\s+run\s+study-note(?:\s|$)/i.test(String(handler.command || ''));
|
|
1444
1465
|
}
|
|
1445
1466
|
function isYamHookProfile(handler = {}, profile = 'lite') {
|
|
1446
1467
|
return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
|
|
@@ -1448,7 +1469,9 @@ function isYamHookProfile(handler = {}, profile = 'lite') {
|
|
|
1448
1469
|
function stripYamHooks(config = {}, profile = 'lite') {
|
|
1449
1470
|
const next = { ...config };
|
|
1450
1471
|
for (const event of Object.keys(next)) {
|
|
1451
|
-
|
|
1472
|
+
if (!Array.isArray(next[event]))
|
|
1473
|
+
continue;
|
|
1474
|
+
const entries = next[event];
|
|
1452
1475
|
const keptEntries = [];
|
|
1453
1476
|
for (const entry of entries) {
|
|
1454
1477
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
|
|
@@ -1465,27 +1488,42 @@ function stripYamHooks(config = {}, profile = 'lite') {
|
|
|
1465
1488
|
}
|
|
1466
1489
|
function withYamHook(config = {}, profile = 'lite') {
|
|
1467
1490
|
const next = stripYamHooks(config, profile);
|
|
1468
|
-
const
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1491
|
+
const command = hookCommandForProfile(profile);
|
|
1492
|
+
for (const event of hookEventsForProfile(profile)) {
|
|
1493
|
+
const entry = {
|
|
1494
|
+
hooks: [
|
|
1495
|
+
{
|
|
1496
|
+
type: 'command',
|
|
1497
|
+
command,
|
|
1498
|
+
timeout: 5
|
|
1499
|
+
}
|
|
1500
|
+
]
|
|
1501
|
+
};
|
|
1502
|
+
next[event] = [...(Array.isArray(next[event]) ? next[event] : []), entry];
|
|
1503
|
+
}
|
|
1479
1504
|
return next;
|
|
1480
1505
|
}
|
|
1481
1506
|
async function hookStatus(args = []) {
|
|
1482
1507
|
const parsed = parseHookArgs(args);
|
|
1483
1508
|
const target = hookPathFor(parsed);
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1509
|
+
const loaded = await readHookConfig(target);
|
|
1510
|
+
if (loaded.error) {
|
|
1511
|
+
console.log('yam-lite hook: broken');
|
|
1512
|
+
console.log('yam-study-note hook: broken');
|
|
1513
|
+
console.log(` - hook config unreadable: ${loaded.error}`);
|
|
1514
|
+
console.log(`scope: ${parsed.mode}`);
|
|
1515
|
+
console.log(`file: ${target}`);
|
|
1516
|
+
process.exitCode = 1;
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const lite = await inspectHookProfile(loaded.config, 'lite');
|
|
1520
|
+
const studyNote = await inspectHookProfile(loaded.config, 'study-note');
|
|
1521
|
+
printHookProfileStatus('lite', lite);
|
|
1522
|
+
printHookProfileStatus('study-note', studyNote);
|
|
1487
1523
|
console.log(`scope: ${parsed.mode}`);
|
|
1488
1524
|
console.log(`file: ${target}`);
|
|
1525
|
+
if (lite.state === 'broken' || studyNote.state === 'broken')
|
|
1526
|
+
process.exitCode = 1;
|
|
1489
1527
|
}
|
|
1490
1528
|
function hookConfigHasYamLite(config = {}) {
|
|
1491
1529
|
return hookConfigHasProfile(config, 'lite');
|
|
@@ -1495,6 +1533,169 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
|
|
|
1495
1533
|
return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
|
|
1496
1534
|
}));
|
|
1497
1535
|
}
|
|
1536
|
+
function hookCommandForProfile(profile = 'lite') {
|
|
1537
|
+
return profile === 'study-note' ? YAM_STUDY_NOTE_HOOK_COMMAND : YAM_LITE_HOOK_COMMAND;
|
|
1538
|
+
}
|
|
1539
|
+
function hookEventsForProfile(profile = 'lite') {
|
|
1540
|
+
return profile === 'study-note' ? ['UserPromptSubmit', 'Stop'] : ['UserPromptSubmit'];
|
|
1541
|
+
}
|
|
1542
|
+
function hookProfileEntries(config = {}, profile = 'lite') {
|
|
1543
|
+
const matches = [];
|
|
1544
|
+
for (const [event, entries] of Object.entries(config)) {
|
|
1545
|
+
if (!Array.isArray(entries))
|
|
1546
|
+
continue;
|
|
1547
|
+
for (const entry of entries) {
|
|
1548
|
+
if (!Array.isArray(entry?.hooks))
|
|
1549
|
+
continue;
|
|
1550
|
+
for (const handler of entry.hooks) {
|
|
1551
|
+
if (isYamHookProfile(handler, profile))
|
|
1552
|
+
matches.push({ event, handler });
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return matches;
|
|
1557
|
+
}
|
|
1558
|
+
function splitHookCommand(command = '') {
|
|
1559
|
+
const words = [];
|
|
1560
|
+
let current = '';
|
|
1561
|
+
let quote = '';
|
|
1562
|
+
let escaped = false;
|
|
1563
|
+
let started = false;
|
|
1564
|
+
for (const char of String(command)) {
|
|
1565
|
+
if (escaped) {
|
|
1566
|
+
current += char;
|
|
1567
|
+
escaped = false;
|
|
1568
|
+
started = true;
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
if (char === '\\' && quote !== "'") {
|
|
1572
|
+
escaped = true;
|
|
1573
|
+
started = true;
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
if (quote) {
|
|
1577
|
+
if (char === quote)
|
|
1578
|
+
quote = '';
|
|
1579
|
+
else
|
|
1580
|
+
current += char;
|
|
1581
|
+
started = true;
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (char === '"' || char === "'") {
|
|
1585
|
+
quote = char;
|
|
1586
|
+
started = true;
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
if (/\s/.test(char)) {
|
|
1590
|
+
if (started) {
|
|
1591
|
+
words.push(current);
|
|
1592
|
+
current = '';
|
|
1593
|
+
started = false;
|
|
1594
|
+
}
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
current += char;
|
|
1598
|
+
started = true;
|
|
1599
|
+
}
|
|
1600
|
+
if (escaped || quote)
|
|
1601
|
+
return [];
|
|
1602
|
+
if (started)
|
|
1603
|
+
words.push(current);
|
|
1604
|
+
return words;
|
|
1605
|
+
}
|
|
1606
|
+
function parseYamHookCommand(command = '', profile = 'lite') {
|
|
1607
|
+
const words = splitHookCommand(command);
|
|
1608
|
+
if (words.length === 5 && words[2] === 'hook' && words[3] === 'run' && words[4] === profile) {
|
|
1609
|
+
const nodeName = path.basename(words[0]).toLowerCase();
|
|
1610
|
+
if (/^node(?:\.exe)?$/.test(nodeName) && path.basename(words[1]).toLowerCase() === 'yam.js') {
|
|
1611
|
+
return { executable: words[0], script: words[1] };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
if (words.length === 4 && words[1] === 'hook' && words[2] === 'run' && words[3] === profile) {
|
|
1615
|
+
const yamName = path.basename(words[0]).toLowerCase();
|
|
1616
|
+
if (/^yam(?:\.cmd|\.exe)?$/.test(yamName))
|
|
1617
|
+
return { executable: words[0], script: '' };
|
|
1618
|
+
}
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
async function hookTargetExists(target = '', executable = false) {
|
|
1622
|
+
if (!target)
|
|
1623
|
+
return false;
|
|
1624
|
+
if (!path.isAbsolute(target))
|
|
1625
|
+
return executable ? Boolean(await findExecutable(target)) : false;
|
|
1626
|
+
try {
|
|
1627
|
+
await fsp.access(target, executable ? fs.constants.X_OK : fs.constants.R_OK);
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
async function inspectHookHandler(handler = {}, profile = 'lite') {
|
|
1635
|
+
const parsed = parseYamHookCommand(String(handler.command || ''), profile);
|
|
1636
|
+
if (!parsed)
|
|
1637
|
+
return { ok: false, issue: 'unsupported yam hook command shape; rerun hook enable to migrate it' };
|
|
1638
|
+
if (!await hookTargetExists(parsed.executable, true)) {
|
|
1639
|
+
return { ok: false, issue: `hook executable is missing or not executable: ${parsed.executable}` };
|
|
1640
|
+
}
|
|
1641
|
+
if (parsed.script && !await hookTargetExists(parsed.script)) {
|
|
1642
|
+
return { ok: false, issue: `hook target is missing: ${parsed.script}` };
|
|
1643
|
+
}
|
|
1644
|
+
return { ok: true, issue: '' };
|
|
1645
|
+
}
|
|
1646
|
+
async function inspectHookProfile(config = {}, profile = 'lite') {
|
|
1647
|
+
const entries = hookProfileEntries(config, profile);
|
|
1648
|
+
if (!entries.length)
|
|
1649
|
+
return { state: 'disabled', issues: [], entries };
|
|
1650
|
+
const issues = [];
|
|
1651
|
+
for (const event of hookEventsForProfile(profile)) {
|
|
1652
|
+
const count = entries.filter((entry) => entry.event === event).length;
|
|
1653
|
+
if (count === 0)
|
|
1654
|
+
issues.push(`${event} handler is missing; rerun yam hook enable ${profile}`);
|
|
1655
|
+
if (count > 1)
|
|
1656
|
+
issues.push(`${event} has ${count} yam-${profile} handlers; rerun hook enable to remove duplicates`);
|
|
1657
|
+
}
|
|
1658
|
+
for (const entry of entries) {
|
|
1659
|
+
if (!hookEventsForProfile(profile).includes(entry.event)) {
|
|
1660
|
+
issues.push(`unexpected ${entry.event} handler; rerun hook enable to migrate event coverage`);
|
|
1661
|
+
}
|
|
1662
|
+
const health = await inspectHookHandler(entry.handler, profile);
|
|
1663
|
+
if (!health.ok)
|
|
1664
|
+
issues.push(`${entry.event}: ${health.issue}`);
|
|
1665
|
+
}
|
|
1666
|
+
return { state: issues.length ? 'broken' : 'enabled', issues: [...new Set(issues)], entries };
|
|
1667
|
+
}
|
|
1668
|
+
function printHookProfileStatus(profile, health) {
|
|
1669
|
+
console.log(`yam-${profile} hook: ${health.state}`);
|
|
1670
|
+
for (const issue of health.issues)
|
|
1671
|
+
console.log(` - ${issue}`);
|
|
1672
|
+
}
|
|
1673
|
+
async function readHookConfig(target) {
|
|
1674
|
+
try {
|
|
1675
|
+
const value = JSON.parse(await fsp.readFile(target, 'utf8'));
|
|
1676
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
1677
|
+
return { config: {}, exists: true, error: 'top-level value must be a JSON object' };
|
|
1678
|
+
}
|
|
1679
|
+
return { config: value, exists: true, error: '' };
|
|
1680
|
+
}
|
|
1681
|
+
catch (error) {
|
|
1682
|
+
if (error.code === 'ENOENT')
|
|
1683
|
+
return { config: {}, exists: false, error: '' };
|
|
1684
|
+
return { config: {}, exists: true, error: errorMessage(error) };
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
async function writeHookConfig(target, config) {
|
|
1688
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1689
|
+
const temporary = `${target}.yam-write-${process.pid}-${timestampId()}`;
|
|
1690
|
+
try {
|
|
1691
|
+
await fsp.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
1692
|
+
await fsp.rename(temporary, target);
|
|
1693
|
+
}
|
|
1694
|
+
catch (error) {
|
|
1695
|
+
await rmrf(temporary).catch(() => { });
|
|
1696
|
+
throw error;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1498
1699
|
async function hookEnable(args = []) {
|
|
1499
1700
|
const parsed = parseHookArgs(args);
|
|
1500
1701
|
if (!['lite', 'study-note'].includes(parsed.profile)) {
|
|
@@ -1503,22 +1704,43 @@ async function hookEnable(args = []) {
|
|
|
1503
1704
|
return;
|
|
1504
1705
|
}
|
|
1505
1706
|
const target = hookPathFor(parsed);
|
|
1506
|
-
const
|
|
1707
|
+
const loaded = await readHookConfig(target);
|
|
1708
|
+
if (loaded.error) {
|
|
1709
|
+
console.error(`Cannot update unreadable hook config: ${target}`);
|
|
1710
|
+
console.error(loaded.error);
|
|
1711
|
+
process.exitCode = 1;
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const current = loaded.config;
|
|
1715
|
+
const existing = hookProfileEntries(current, parsed.profile);
|
|
1716
|
+
const expectedEvents = hookEventsForProfile(parsed.profile);
|
|
1717
|
+
const expectedCommand = hookCommandForProfile(parsed.profile);
|
|
1718
|
+
const needsMigration = existing.length > 0 && (existing.length !== expectedEvents.length
|
|
1719
|
+
|| existing.some((entry) => !expectedEvents.includes(entry.event) || String(entry.handler?.command || '') !== expectedCommand));
|
|
1507
1720
|
const next = withYamHook(current, parsed.profile);
|
|
1508
1721
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1509
|
-
if (
|
|
1722
|
+
if (loaded.exists) {
|
|
1510
1723
|
const backup = `${target}.yam-backup-${timestampId()}`;
|
|
1511
1724
|
await fsp.copyFile(target, backup);
|
|
1512
1725
|
console.log(`backup: ${backup}`);
|
|
1513
1726
|
}
|
|
1514
|
-
await
|
|
1727
|
+
await writeHookConfig(target, next);
|
|
1515
1728
|
console.log(`yam-${parsed.profile} hook enabled (${parsed.mode}): ${target}`);
|
|
1729
|
+
if (needsMigration)
|
|
1730
|
+
console.log(`migrated: ${existing.length} existing yam-${parsed.profile} handler(s) to the current command and event coverage`);
|
|
1516
1731
|
console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
|
|
1517
1732
|
}
|
|
1518
1733
|
async function hookDisable(args = []) {
|
|
1519
1734
|
const parsed = parseHookArgs(args);
|
|
1520
1735
|
const target = hookPathFor(parsed);
|
|
1521
|
-
const
|
|
1736
|
+
const loaded = await readHookConfig(target);
|
|
1737
|
+
if (loaded.error) {
|
|
1738
|
+
console.error(`Cannot update unreadable hook config: ${target}`);
|
|
1739
|
+
console.error(loaded.error);
|
|
1740
|
+
process.exitCode = 1;
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
const current = loaded.config;
|
|
1522
1744
|
if (!hookConfigHasProfile(current, parsed.profile)) {
|
|
1523
1745
|
console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
|
|
1524
1746
|
return;
|
|
@@ -1528,7 +1750,7 @@ async function hookDisable(args = []) {
|
|
|
1528
1750
|
await rmrf(target);
|
|
1529
1751
|
}
|
|
1530
1752
|
else {
|
|
1531
|
-
await
|
|
1753
|
+
await writeHookConfig(target, next);
|
|
1532
1754
|
}
|
|
1533
1755
|
console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
|
|
1534
1756
|
}
|
|
@@ -1541,6 +1763,10 @@ async function hookRun(args = []) {
|
|
|
1541
1763
|
const input = await readStdinJson();
|
|
1542
1764
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
1543
1765
|
const cwd = String(input?.cwd || process.cwd());
|
|
1766
|
+
if (profile === 'study-note' && event === 'Stop') {
|
|
1767
|
+
console.log(JSON.stringify(await buildStudyNoteStopOutput(input, cwd)));
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1544
1770
|
const prompt = extractPrompt(input);
|
|
1545
1771
|
const additionalContext = profile === 'study-note'
|
|
1546
1772
|
? await buildStudyNoteHookContext({ cwd })
|
|
@@ -1610,6 +1836,7 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1610
1836
|
];
|
|
1611
1837
|
if (!changedFiles.length) {
|
|
1612
1838
|
lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
|
|
1839
|
+
lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
|
|
1613
1840
|
return lines.join('\n');
|
|
1614
1841
|
}
|
|
1615
1842
|
lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
|
|
@@ -1619,9 +1846,30 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1619
1846
|
lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
|
|
1620
1847
|
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.');
|
|
1621
1848
|
}
|
|
1622
|
-
lines.push('This
|
|
1849
|
+
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.');
|
|
1623
1850
|
return lines.join('\n');
|
|
1624
1851
|
}
|
|
1852
|
+
async function buildStudyNoteStopOutput(input = {}, 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)
|
|
1856
|
+
return { continue: true };
|
|
1857
|
+
const summary = result.blockers.slice(0, 4).join('; ');
|
|
1858
|
+
if (Boolean(input?.stop_hook_active || input?.stopHookActive)) {
|
|
1859
|
+
return {
|
|
1860
|
+
continue: true,
|
|
1861
|
+
systemMessage: `yam Study Note completion gate remains blocked after one correction pass: ${summary}`
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
return {
|
|
1865
|
+
decision: 'block',
|
|
1866
|
+
reason: [
|
|
1867
|
+
'yam Study Note completion gate blocked this response because changed project artifacts require a complete Study Note.',
|
|
1868
|
+
summary,
|
|
1869
|
+
'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.'
|
|
1870
|
+
].join(' ')
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1625
1873
|
function yamLiteRouteHint(prompt = '') {
|
|
1626
1874
|
const text = String(prompt || '').toLowerCase();
|
|
1627
1875
|
if (!text)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export declare const INSTALL_RECEIPT_NAME = ".yam-flow-install-receipt.json";
|
|
2
|
+
export declare const INSTALL_LOCK_NAME = ".yam-flow-install.lock";
|
|
3
|
+
export declare const INSTALL_RECEIPT_SCHEMA = "yam.install-receipt.v1";
|
|
4
|
+
export declare const INSTALL_TRANSACTION_PREFIX = ".yam-flow-install-";
|
|
5
|
+
export type InstallFileDigest = {
|
|
6
|
+
path: string;
|
|
7
|
+
bytes: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
};
|
|
10
|
+
export type InstallReceipt = {
|
|
11
|
+
schema: typeof INSTALL_RECEIPT_SCHEMA;
|
|
12
|
+
package: {
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
};
|
|
16
|
+
source: {
|
|
17
|
+
kind: 'package-bundled-skills';
|
|
18
|
+
identity: string;
|
|
19
|
+
};
|
|
20
|
+
destination: string;
|
|
21
|
+
installed_at: string;
|
|
22
|
+
transaction_id: string;
|
|
23
|
+
skills: string[];
|
|
24
|
+
integrity: {
|
|
25
|
+
algorithm: 'sha256';
|
|
26
|
+
file_count: number;
|
|
27
|
+
source_digest: string;
|
|
28
|
+
files: InstallFileDigest[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type InstallFailpointEvent = {
|
|
32
|
+
phase: 'after-stage' | 'after-backup' | 'skill-installed' | 'receipt-installed' | 'mirror-cleaned' | 'before-verify';
|
|
33
|
+
skill?: string;
|
|
34
|
+
};
|
|
35
|
+
export type TransactionalInstallOptions = {
|
|
36
|
+
sourceRoot: string;
|
|
37
|
+
destination: string;
|
|
38
|
+
codexMirror?: string;
|
|
39
|
+
packageName: string;
|
|
40
|
+
version: string;
|
|
41
|
+
skills: string[];
|
|
42
|
+
legacySkills?: string[];
|
|
43
|
+
retiredSkills?: string[];
|
|
44
|
+
now?: () => Date;
|
|
45
|
+
transactionId?: string;
|
|
46
|
+
failpoint?: (event: InstallFailpointEvent) => void | Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
export type InstallationInspectionOptions = Pick<TransactionalInstallOptions, 'sourceRoot' | 'destination' | 'packageName' | 'version' | 'skills'> & {
|
|
49
|
+
ignoreRecoveryArtifacts?: boolean;
|
|
50
|
+
};
|
|
51
|
+
export type SkillInstallStatus = {
|
|
52
|
+
skill: string;
|
|
53
|
+
status: 'ok' | 'missing' | 'drift';
|
|
54
|
+
issues: string[];
|
|
55
|
+
};
|
|
56
|
+
export type InstallationInspection = {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
destination: string;
|
|
59
|
+
receiptPath: string;
|
|
60
|
+
receiptStatus: 'ok' | 'missing' | 'drift';
|
|
61
|
+
receipt?: InstallReceipt;
|
|
62
|
+
sourceDigest?: string;
|
|
63
|
+
recoveryArtifacts: string[];
|
|
64
|
+
skills: SkillInstallStatus[];
|
|
65
|
+
issues: string[];
|
|
66
|
+
};
|
|
67
|
+
export type TransactionalInstallResult = {
|
|
68
|
+
receipt: InstallReceipt;
|
|
69
|
+
receiptPath: string;
|
|
70
|
+
installedFiles: number;
|
|
71
|
+
sourceDigest: string;
|
|
72
|
+
cleanupWarnings: string[];
|
|
73
|
+
};
|
|
74
|
+
export declare function findInstallRecoveryArtifacts(root: string): Promise<string[]>;
|
|
75
|
+
export declare function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection>;
|
|
76
|
+
export declare function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult>;
|