yam-flow 2.3.1 → 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/CHANGELOG.md +21 -0
- package/COMMANDS.md +6 -4
- package/README.md +15 -1
- package/ROADMAP.md +4 -1
- package/dist/bin/yam.js +274 -33
- package/package.json +1 -1
- package/references/hook-lite.md +6 -0
- package/src/bin/yam.ts +264 -32
- package/yam.manifest.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to yam-flow are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.3.2 - 2026-07-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a bounded Codex `Stop` completion gate to the opt-in `study-note` hook profile. Changed work with a missing or incomplete Study Note receives one correction prompt before completion.
|
|
10
|
+
- Added hook health inspection for missing executables, missing script targets, incomplete event coverage, duplicate handlers, unsupported command shapes, and unreadable hook config.
|
|
11
|
+
- Added deterministic packaged-CLI smoke coverage for broken-path detection, nonzero health status, backup-backed migration, unrelated-hook preservation, Stop blocking/passing, and loop prevention.
|
|
12
|
+
|
|
13
|
+
### Improved
|
|
14
|
+
|
|
15
|
+
- `yam hook status` now reports configured-but-broken profiles as `broken` and exits nonzero instead of calling a stale command `enabled`.
|
|
16
|
+
- Re-enabling a hook profile migrates stale absolute paths to the current installed Node and package entrypoint, adds the required events, preserves unrelated hooks, creates a timestamped backup, and writes config atomically.
|
|
17
|
+
- Study Note Guard checks now cover execution point, expected behavior, syntax or structure insight, verification, and limits separately.
|
|
18
|
+
|
|
19
|
+
### Verification
|
|
20
|
+
|
|
21
|
+
- `npm run verify:self`
|
|
22
|
+
- `npm run dist:freshness`
|
|
23
|
+
- `npm run package-boundary:check`
|
|
24
|
+
- `npm run cli-smoke`
|
|
25
|
+
|
|
5
26
|
## v2.3.1 - 2026-07-21
|
|
6
27
|
|
|
7
28
|
### Added
|
package/COMMANDS.md
CHANGED
|
@@ -152,7 +152,7 @@ yam tools doctor /path/to/project
|
|
|
152
152
|
yam tools doctor /path/to/project --json
|
|
153
153
|
yam context pressure /path/to/project --json
|
|
154
154
|
yam cleanup scan /path/to/project --json
|
|
155
|
-
yam study-note check /path/to/project --text "Study Note:
|
|
155
|
+
yam study-note check /path/to/project --text "Study Note: The hook handler's role is final-report validation. It runs at Codex Stop. Before it only reminded; after it requests one correction pass. Expected behavior is a complete note. Structure insight: decision=block creates a continuation prompt. Verification checked the packaged CLI. Limits: a restarted real task remains to be observed." --json
|
|
156
156
|
yam loop report --route quick --intent "fix release readiness" --stage "inspect:passed:read release report" --evidence "typecheck passed" --evidence-level local --evidence-stamp "sha256:release-report" --touched-file src/bin/yam.ts --read-file README.md --verified-file scripts/cli-smoke.mjs --skipped-check "npm publish skipped by design" --stop-condition "stop after readiness evidence is recorded" --resume-hint "rerun release report after npm auth refresh" --readiness-state blocked --covered-requirement "release report is read-only" --uncovered-requirement "npm auth verified" --blocked-kind auth_blocked --failure-cause auth_token_invalid --safe-retry "retry after npm whoami succeeds" --recovery-hint "refresh npm auth, then rerun readiness checks" --fix-first-item "npm auth must be verified before publish" --remaining-task "rerun release report after auth refresh" --recommended-direction "fix npm auth first, then publish manually" --implementation-note "keep loop report read-only" --why-this-next "auth blocks public release claims" --blocked-by "npm whoami E401" --owner-route deep --owner-scope "release readiness only" --scope-owner deep --side-effect "no publish attempted" --avoidance-note "do not retry publish before npm auth is proven" --issue-code "src/bin/yam.ts release report" --issue-role "summarizes release readiness without publishing" --issue-symptom "npm auth failure needs clearer next action" --changed-code "yam loop report" --changed-role "records loop evidence and learning note" --change-summary "added a read-only loop artifact" --why-important "it helps users learn what changed without overclaiming verification" --learning-note "fix blockers before claiming done" --json
|
|
157
157
|
yam proof /path/to/project
|
|
158
158
|
yam proof --route deep --truth verified --command "npm run build: pass"
|
|
@@ -206,7 +206,7 @@ Supported report shapes:
|
|
|
206
206
|
- Verification Ladder: L0 stated, L1 inspected, L2 local check, L3 integrated, L4 release/runtime/visual proof, L5 bounded deep with stop condition and skipped/remaining work.
|
|
207
207
|
- Loop report: intent, guided stage outcomes, touched/read/verified files, skipped checks, stop condition, resume hint, readiness state, evidence level/stamp, blockers, blocker kind, safe retry, next action, fix-first items, remaining tasks, recommended direction, blocked-by notes, owner route/scope, side effects, and study note.
|
|
208
208
|
- Study note: non-specialist explanation of the changed code/artifact, its role, execution point, before/after behavior, expected behavior, one syntax/structure insight, verification, uncertainty, and architecture hygiene around `page.tsx`, `global.css`, and DB `jsonb`.
|
|
209
|
-
- Study Note guard: read-only check for changed files and supplied final-report text; opt
|
|
209
|
+
- Study Note guard: read-only check for changed files and supplied final-report text; the opt-in `study-note` hook adds a prompt reminder and one bounded Codex `Stop` correction pass.
|
|
210
210
|
- Benchmark optimization loop lite: baseline, one change, rerun, keep/revert decision.
|
|
211
211
|
- Structured diagnostic next action: severity, evidence, owner route, next action, truth status.
|
|
212
212
|
- Ueye continuity/comparison: previous report, current report, comparison delta, design quality, next action.
|
|
@@ -215,9 +215,9 @@ Supported report shapes:
|
|
|
215
215
|
- Context pressure: project pack age/size, memory summary, dirty scope, scripts, instruction surfaces, and route hint.
|
|
216
216
|
- Cleanup scan: advisory-only list of confusing surfaces with destructive=false.
|
|
217
217
|
|
|
218
|
-
##
|
|
218
|
+
## Hooks
|
|
219
219
|
|
|
220
|
-
|
|
220
|
+
`lite` is advisory-only. `study-note` runs the read-only Study Note Guard against the final assistant message and can request one correction pass; neither profile runs verification or forces routes.
|
|
221
221
|
|
|
222
222
|
```bash
|
|
223
223
|
yam hook status --global
|
|
@@ -226,3 +226,5 @@ yam hook enable study-note --global
|
|
|
226
226
|
yam hook disable lite --global
|
|
227
227
|
yam hook disable study-note --global
|
|
228
228
|
```
|
|
229
|
+
|
|
230
|
+
`yam hook status` exits nonzero for unreadable configs, stale paths, missing targets, duplicate handlers, or incomplete event coverage. Re-enable the affected profile to back up the file, preserve unrelated hooks, and migrate its command and events.
|
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ See `ROADMAP.md` for remaining implementation stages.
|
|
|
35
35
|
|
|
36
36
|
- No required hooks.
|
|
37
37
|
- Optional `yam-lite` hook is advisory-only and off by default.
|
|
38
|
+
- Optional `study-note` hook adds a prompt reminder and a bounded final-response check; it is off by default.
|
|
38
39
|
- No automatic Team routing.
|
|
39
40
|
- No automatic proof loops.
|
|
40
41
|
- No automatic tmux.
|
|
@@ -87,6 +88,19 @@ yam hook disable lite --global
|
|
|
87
88
|
|
|
88
89
|
It only adds short advisory context. It does not run verification, tmux, browser QA, subagents, or dependency installs.
|
|
89
90
|
|
|
91
|
+
## Study Note Hook
|
|
92
|
+
|
|
93
|
+
Enable this profile when you want Codex to check changed work at final-response time as well as remind the agent at prompt time.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
yam hook enable study-note --global
|
|
97
|
+
yam hook status --global
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The profile installs `UserPromptSubmit` and `Stop` handlers. At `Stop`, yam checks the latest assistant message with the same read-only Study Note Guard used by `yam study-note check`. If changed artifacts lack the required role, execution point, before/after, expected behavior, syntax/structure, verification, limits, or relevant architecture hygiene, Codex receives one correction prompt. A second failed check warns but does not loop forever. The hook never writes the Study Note or runs verification for the agent.
|
|
101
|
+
|
|
102
|
+
`yam hook status` reports stale or missing command targets as `broken` and exits nonzero. Run `yam hook enable <profile> --global` again to create a timestamped backup, preserve unrelated hooks, and migrate that profile to the current installed path and event coverage. Restart Codex or open a new task after changing hooks.
|
|
103
|
+
|
|
90
104
|
## Install
|
|
91
105
|
|
|
92
106
|
Recommended:
|
|
@@ -227,7 +241,7 @@ These shapes keep reports machine-readable without turning normal work into a he
|
|
|
227
241
|
- Release report JSON: `yam release report --json` summarizes typecheck, forbidden-name scan, package boundary, registry status, CLI smoke, dist freshness, diagnostics, readiness receipt, and final truth status.
|
|
228
242
|
- Loop report JSON: `yam loop report --json` records a read-only handoff artifact: guided stages, touched/read/verified files, skipped checks, stop condition, resume hint, readiness state, requirement coverage, evidence level/stamp, blocker kind, failure cause, safe retry, recovery hint, owner route/scope, side effects, next action, fix-first items, remaining tasks, recommended direction, implementation notes, blocked-by notes, avoidance note, tool intent, truth status, and study note.
|
|
229
243
|
- Study note: `yam.study-note.v1` and `references/study-note.md` keep non-specialist explanations for what code/artifact changed, what role it plays, where it runs, what changed from before to after, what behavior is expected, one syntax/structure insight, what was verified, and what remains uncertain. Missing details stay in `limits` instead of being guessed. Study Note v3 also asks whether work was dumped into `page.tsx`, `global.css`, or broad DB `jsonb` when a smaller component, style module/token, typed column, relation, or validation schema would teach and scale better.
|
|
230
|
-
- Study Note guard: `yam study-note check` is a read-only missing-note guard.
|
|
244
|
+
- Study Note guard: `yam study-note check` is a read-only missing-note guard. The opt-in `yam hook enable study-note --global` profile reuses it for a prompt reminder and one bounded Codex `Stop` correction pass; it still does not generate or edit a report.
|
|
231
245
|
- Avoidance note: loop reports may include one short mistake-to-avoid note, but durable memory stays explicit through `yam memory add`; `yam loop report` does not write `.yam/memory`.
|
|
232
246
|
- Publish blocker evidence: release reports classify common npm auth, permission, OTP, immutable-version, cache, registry, and tarball failures into safe next actions.
|
|
233
247
|
- Publish readiness evidence: release reports run read-only registry/auth/version probes, redact account/token details, keep npm publish outside the report, and include a `yam.release-readiness-receipt.v1` basis for the readiness judgment.
|
package/ROADMAP.md
CHANGED
|
@@ -11,9 +11,10 @@ Implemented:
|
|
|
11
11
|
- Transactional skill install with rollback, SHA-256 receipt, install lock, and status drift detection. Done.
|
|
12
12
|
- CLI helpers: detect, budget, template, tune-log, version.
|
|
13
13
|
- Optional advisory `yam-lite` hook with status/enable/disable. Done.
|
|
14
|
+
- Optional Study Note prompt reminder plus bounded Codex `Stop` completion gate, hook health, and stale-path migration. Done.
|
|
14
15
|
- Opt-in project memory: init, add, list, summary, resolve.
|
|
15
16
|
- Honest completion guard: no verified/cleanup/visual claims without evidence.
|
|
16
|
-
- No hooks, automations, or global config.
|
|
17
|
+
- No hooks, automations, or global config mutations unless the user explicitly enables them.
|
|
17
18
|
- Token economy policy.
|
|
18
19
|
|
|
19
20
|
## Next Stages
|
|
@@ -304,6 +305,8 @@ Tasks:
|
|
|
304
305
|
|
|
305
306
|
- Add `yam hook status|enable|disable|run lite`. Done.
|
|
306
307
|
- Keep `yam-lite` advisory-only through `UserPromptSubmit`. Done.
|
|
308
|
+
- Add Study Note `UserPromptSubmit` reminder plus one-pass `Stop` completion gate. Done.
|
|
309
|
+
- Detect broken hook targets and migrate stale paths without replacing unrelated hooks. Done.
|
|
307
310
|
- Add `references/tool-trust-layer.md`. Done.
|
|
308
311
|
- Add read-only `yam tools doctor`. Done.
|
|
309
312
|
- Add `yam proof` summary command. Done.
|
package/dist/bin/yam.js
CHANGED
|
@@ -50,8 +50,9 @@ const VERSION = String(PACKAGE_JSON.version || '0.0.0');
|
|
|
50
50
|
const PROJECT_PACK = 'yam.project.md';
|
|
51
51
|
const LEGACY_PROJECT_PACK = 'timeto.project.md';
|
|
52
52
|
const PACK_STALE_DAYS = 30;
|
|
53
|
-
const
|
|
54
|
-
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`;
|
|
55
56
|
const REQUIRED_PACK_SECTIONS = [
|
|
56
57
|
'Product Direction',
|
|
57
58
|
'UI Direction',
|
|
@@ -750,16 +751,27 @@ Notes:
|
|
|
750
751
|
async function studyNoteCheck(args = []) {
|
|
751
752
|
const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
|
|
752
753
|
const dir = path.resolve(firstPositional(args) || process.cwd());
|
|
753
|
-
const changedFiles = await gitChangedFiles(dir);
|
|
754
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);
|
|
755
763
|
const needsStudyNote = changedFiles.length > 0;
|
|
756
764
|
const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
|
|
757
765
|
const checks = [
|
|
758
766
|
studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
|
|
759
767
|
studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
|
|
760
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'),
|
|
761
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'),
|
|
762
|
-
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')
|
|
763
775
|
];
|
|
764
776
|
for (const requirement of hygieneRequirements) {
|
|
765
777
|
checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
|
|
@@ -771,15 +783,13 @@ async function studyNoteCheck(args = []) {
|
|
|
771
783
|
project: dir,
|
|
772
784
|
changed_files: changedFiles,
|
|
773
785
|
changed_file_count: changedFiles.length,
|
|
774
|
-
report_source:
|
|
786
|
+
report_source: reportSource,
|
|
775
787
|
checks,
|
|
776
788
|
blockers,
|
|
777
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'),
|
|
778
790
|
truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
|
|
779
791
|
};
|
|
780
|
-
|
|
781
|
-
if (blockers.length)
|
|
782
|
-
process.exitCode = 1;
|
|
792
|
+
return result;
|
|
783
793
|
}
|
|
784
794
|
function firstPositional(args = []) {
|
|
785
795
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -1017,6 +1027,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1017
1027
|
const codexHome = path.join(os.homedir(), '.codex');
|
|
1018
1028
|
const pluginCache = path.join(codexHome, 'plugins', 'cache');
|
|
1019
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');
|
|
1020
1032
|
const tmux = await findExecutable('tmux');
|
|
1021
1033
|
const packageInfo = await projectPackageInfo(dir);
|
|
1022
1034
|
const pack = await findProjectPack(dir);
|
|
@@ -1035,7 +1047,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1035
1047
|
const rows = [
|
|
1036
1048
|
readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
|
|
1037
1049
|
readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
|
|
1038
|
-
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'),
|
|
1039
1052
|
readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
|
|
1040
1053
|
readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
|
|
1041
1054
|
readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
|
|
@@ -1413,7 +1426,8 @@ Usage:
|
|
|
1413
1426
|
yam hook run <lite|study-note>
|
|
1414
1427
|
|
|
1415
1428
|
Notes:
|
|
1416
|
-
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.
|
|
1417
1431
|
`);
|
|
1418
1432
|
}
|
|
1419
1433
|
function parseHookArgs(args = []) {
|
|
@@ -1444,10 +1458,10 @@ function hookPathFor(parsed) {
|
|
|
1444
1458
|
return path.join(path.resolve(parsed.projectDir || process.cwd()), '.codex', 'hooks.json');
|
|
1445
1459
|
}
|
|
1446
1460
|
function isYamLiteHook(handler = {}) {
|
|
1447
|
-
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 || ''));
|
|
1448
1462
|
}
|
|
1449
1463
|
function isYamStudyNoteHook(handler = {}) {
|
|
1450
|
-
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 || ''));
|
|
1451
1465
|
}
|
|
1452
1466
|
function isYamHookProfile(handler = {}, profile = 'lite') {
|
|
1453
1467
|
return profile === 'study-note' ? isYamStudyNoteHook(handler) : isYamLiteHook(handler);
|
|
@@ -1455,7 +1469,9 @@ function isYamHookProfile(handler = {}, profile = 'lite') {
|
|
|
1455
1469
|
function stripYamHooks(config = {}, profile = 'lite') {
|
|
1456
1470
|
const next = { ...config };
|
|
1457
1471
|
for (const event of Object.keys(next)) {
|
|
1458
|
-
|
|
1472
|
+
if (!Array.isArray(next[event]))
|
|
1473
|
+
continue;
|
|
1474
|
+
const entries = next[event];
|
|
1459
1475
|
const keptEntries = [];
|
|
1460
1476
|
for (const entry of entries) {
|
|
1461
1477
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
|
|
@@ -1472,27 +1488,42 @@ function stripYamHooks(config = {}, profile = 'lite') {
|
|
|
1472
1488
|
}
|
|
1473
1489
|
function withYamHook(config = {}, profile = 'lite') {
|
|
1474
1490
|
const next = stripYamHooks(config, profile);
|
|
1475
|
-
const
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
+
}
|
|
1486
1504
|
return next;
|
|
1487
1505
|
}
|
|
1488
1506
|
async function hookStatus(args = []) {
|
|
1489
1507
|
const parsed = parseHookArgs(args);
|
|
1490
1508
|
const target = hookPathFor(parsed);
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
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);
|
|
1494
1523
|
console.log(`scope: ${parsed.mode}`);
|
|
1495
1524
|
console.log(`file: ${target}`);
|
|
1525
|
+
if (lite.state === 'broken' || studyNote.state === 'broken')
|
|
1526
|
+
process.exitCode = 1;
|
|
1496
1527
|
}
|
|
1497
1528
|
function hookConfigHasYamLite(config = {}) {
|
|
1498
1529
|
return hookConfigHasProfile(config, 'lite');
|
|
@@ -1502,6 +1533,169 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
|
|
|
1502
1533
|
return Array.isArray(entry?.hooks) && entry.hooks.some((handler) => isYamHookProfile(handler, profile));
|
|
1503
1534
|
}));
|
|
1504
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
|
+
}
|
|
1505
1699
|
async function hookEnable(args = []) {
|
|
1506
1700
|
const parsed = parseHookArgs(args);
|
|
1507
1701
|
if (!['lite', 'study-note'].includes(parsed.profile)) {
|
|
@@ -1510,22 +1704,43 @@ async function hookEnable(args = []) {
|
|
|
1510
1704
|
return;
|
|
1511
1705
|
}
|
|
1512
1706
|
const target = hookPathFor(parsed);
|
|
1513
|
-
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));
|
|
1514
1720
|
const next = withYamHook(current, parsed.profile);
|
|
1515
1721
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1516
|
-
if (
|
|
1722
|
+
if (loaded.exists) {
|
|
1517
1723
|
const backup = `${target}.yam-backup-${timestampId()}`;
|
|
1518
1724
|
await fsp.copyFile(target, backup);
|
|
1519
1725
|
console.log(`backup: ${backup}`);
|
|
1520
1726
|
}
|
|
1521
|
-
await
|
|
1727
|
+
await writeHookConfig(target, next);
|
|
1522
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`);
|
|
1523
1731
|
console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
|
|
1524
1732
|
}
|
|
1525
1733
|
async function hookDisable(args = []) {
|
|
1526
1734
|
const parsed = parseHookArgs(args);
|
|
1527
1735
|
const target = hookPathFor(parsed);
|
|
1528
|
-
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;
|
|
1529
1744
|
if (!hookConfigHasProfile(current, parsed.profile)) {
|
|
1530
1745
|
console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
|
|
1531
1746
|
return;
|
|
@@ -1535,7 +1750,7 @@ async function hookDisable(args = []) {
|
|
|
1535
1750
|
await rmrf(target);
|
|
1536
1751
|
}
|
|
1537
1752
|
else {
|
|
1538
|
-
await
|
|
1753
|
+
await writeHookConfig(target, next);
|
|
1539
1754
|
}
|
|
1540
1755
|
console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
|
|
1541
1756
|
}
|
|
@@ -1548,6 +1763,10 @@ async function hookRun(args = []) {
|
|
|
1548
1763
|
const input = await readStdinJson();
|
|
1549
1764
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
1550
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
|
+
}
|
|
1551
1770
|
const prompt = extractPrompt(input);
|
|
1552
1771
|
const additionalContext = profile === 'study-note'
|
|
1553
1772
|
? await buildStudyNoteHookContext({ cwd })
|
|
@@ -1617,6 +1836,7 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1617
1836
|
];
|
|
1618
1837
|
if (!changedFiles.length) {
|
|
1619
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.');
|
|
1620
1840
|
return lines.join('\n');
|
|
1621
1841
|
}
|
|
1622
1842
|
lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
|
|
@@ -1626,9 +1846,30 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1626
1846
|
lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
|
|
1627
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.');
|
|
1628
1848
|
}
|
|
1629
|
-
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.');
|
|
1630
1850
|
return lines.join('\n');
|
|
1631
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
|
+
}
|
|
1632
1873
|
function yamLiteRouteHint(prompt = '') {
|
|
1633
1874
|
const text = String(prompt || '').toLowerCase();
|
|
1634
1875
|
if (!text)
|
package/package.json
CHANGED
package/references/hook-lite.md
CHANGED
|
@@ -53,3 +53,9 @@ Selective skill systems favor lower-context workflows.
|
|
|
53
53
|
Minimal-core systems avoid hooks unless the rule is short and changes behavior.
|
|
54
54
|
|
|
55
55
|
`yam` keeps this hook advisory-only so beginner momentum is preserved while the agent still receives a direction nudge. Deeper proof belongs in `$deep` and real team execution belongs in `$mission`, not in an always-on prompt hook.
|
|
56
|
+
|
|
57
|
+
## Separate Study Note Profile
|
|
58
|
+
|
|
59
|
+
`study-note` is a separate opt-in profile, not part of the lite contract. It installs a `UserPromptSubmit` reminder and a Codex `Stop` handler that checks the latest assistant message with the read-only Study Note Guard. A missing or incomplete note requests one correction pass; `stop_hook_active` bounds the retry so the hook cannot create an infinite completion loop. The profile never writes the note or runs verification.
|
|
60
|
+
|
|
61
|
+
Hook status validates executable and script targets plus required event coverage. Re-enabling a broken profile creates a timestamped backup, preserves unrelated hook entries, and migrates stale commands to the current package entrypoint.
|
package/src/bin/yam.ts
CHANGED
|
@@ -84,8 +84,9 @@ 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
|
-
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`;
|
|
89
90
|
const REQUIRED_PACK_SECTIONS = [
|
|
90
91
|
'Product Direction',
|
|
91
92
|
'UI Direction',
|
|
@@ -756,16 +757,27 @@ Notes:
|
|
|
756
757
|
async function studyNoteCheck(args = []) {
|
|
757
758
|
const flags = parseSimpleFlags(args, new Set(['report', 'text', 'json']));
|
|
758
759
|
const dir = path.resolve(firstPositional(args) || process.cwd());
|
|
759
|
-
const changedFiles = await gitChangedFiles(dir);
|
|
760
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);
|
|
761
769
|
const needsStudyNote = changedFiles.length > 0;
|
|
762
770
|
const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
|
|
763
771
|
const checks = [
|
|
764
772
|
studyNoteGuardCheck('changed_files', needsStudyNote ? 'pass' : 'skipped', needsStudyNote ? 'changed files detected; Study Note is required before final completion' : 'no changed files detected'),
|
|
765
773
|
studyNoteGuardCheck('study_note_present', !needsStudyNote || hasStudyNoteMarker(reportText) ? 'pass' : 'fail', 'final report should include a Study Note when project artifacts changed'),
|
|
766
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'),
|
|
767
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'),
|
|
768
|
-
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')
|
|
769
781
|
];
|
|
770
782
|
for (const requirement of hygieneRequirements) {
|
|
771
783
|
checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
|
|
@@ -777,14 +789,13 @@ async function studyNoteCheck(args = []) {
|
|
|
777
789
|
project: dir,
|
|
778
790
|
changed_files: changedFiles,
|
|
779
791
|
changed_file_count: changedFiles.length,
|
|
780
|
-
report_source:
|
|
792
|
+
report_source: reportSource,
|
|
781
793
|
checks,
|
|
782
794
|
blockers,
|
|
783
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'),
|
|
784
796
|
truth_status: blockers.length ? 'blocked' : needsStudyNote ? 'verified' : 'skipped'
|
|
785
797
|
};
|
|
786
|
-
|
|
787
|
-
if (blockers.length) process.exitCode = 1;
|
|
798
|
+
return result;
|
|
788
799
|
}
|
|
789
800
|
|
|
790
801
|
function firstPositional(args = []) {
|
|
@@ -1028,6 +1039,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1028
1039
|
const codexHome = path.join(os.homedir(), '.codex');
|
|
1029
1040
|
const pluginCache = path.join(codexHome, 'plugins', 'cache');
|
|
1030
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');
|
|
1031
1044
|
const tmux = await findExecutable('tmux');
|
|
1032
1045
|
const packageInfo = await projectPackageInfo(dir);
|
|
1033
1046
|
const pack = await findProjectPack(dir);
|
|
@@ -1046,7 +1059,8 @@ async function buildToolsDoctorReport(targetDir = process.cwd()) {
|
|
|
1046
1059
|
const rows = [
|
|
1047
1060
|
readinessRow('Codex home', await exists(codexHome) ? 'ready' : 'missing', codexHome),
|
|
1048
1061
|
readinessRow('Yam skills', await status({ quiet: true }) === 0 ? 'ready' : 'missing', DEST),
|
|
1049
|
-
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'),
|
|
1050
1064
|
readinessRow('tmux', tmux ? 'ready' : 'missing', tmux || 'not found on PATH or common Homebrew paths'),
|
|
1051
1065
|
readinessRow('Browser plugin', await pluginCacheHas('openai-bundled/browser') ? 'ready' : 'unknown', 'Codex in-app browser cache'),
|
|
1052
1066
|
readinessRow('Chrome plugin', await pluginCacheHas('openai-bundled/chrome') ? 'ready' : 'unknown', 'Chrome/profile-dependent browser cache'),
|
|
@@ -1420,7 +1434,8 @@ Usage:
|
|
|
1420
1434
|
yam hook run <lite|study-note>
|
|
1421
1435
|
|
|
1422
1436
|
Notes:
|
|
1423
|
-
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.
|
|
1424
1439
|
`);
|
|
1425
1440
|
}
|
|
1426
1441
|
|
|
@@ -1450,11 +1465,11 @@ function hookPathFor(parsed) {
|
|
|
1450
1465
|
}
|
|
1451
1466
|
|
|
1452
1467
|
function isYamLiteHook(handler: AnyRecord = {}) {
|
|
1453
|
-
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 || ''));
|
|
1454
1469
|
}
|
|
1455
1470
|
|
|
1456
1471
|
function isYamStudyNoteHook(handler: AnyRecord = {}) {
|
|
1457
|
-
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 || ''));
|
|
1458
1473
|
}
|
|
1459
1474
|
|
|
1460
1475
|
function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
|
|
@@ -1464,7 +1479,8 @@ function isYamHookProfile(handler: AnyRecord = {}, profile = 'lite') {
|
|
|
1464
1479
|
function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
|
|
1465
1480
|
const next = { ...config };
|
|
1466
1481
|
for (const event of Object.keys(next)) {
|
|
1467
|
-
|
|
1482
|
+
if (!Array.isArray(next[event])) continue;
|
|
1483
|
+
const entries = next[event];
|
|
1468
1484
|
const keptEntries = [];
|
|
1469
1485
|
for (const entry of entries) {
|
|
1470
1486
|
const hooks = Array.isArray(entry?.hooks) ? entry.hooks.filter((handler) => !isYamHookProfile(handler, profile)) : [];
|
|
@@ -1479,28 +1495,42 @@ function stripYamHooks(config: AnyRecord = {}, profile = 'lite') {
|
|
|
1479
1495
|
|
|
1480
1496
|
function withYamHook(config: AnyRecord = {}, profile = 'lite') {
|
|
1481
1497
|
const next = stripYamHooks(config, profile);
|
|
1482
|
-
const
|
|
1483
|
-
const
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
+
}
|
|
1493
1511
|
return next;
|
|
1494
1512
|
}
|
|
1495
1513
|
|
|
1496
1514
|
async function hookStatus(args = []) {
|
|
1497
1515
|
const parsed = parseHookArgs(args);
|
|
1498
1516
|
const target = hookPathFor(parsed);
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
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);
|
|
1502
1531
|
console.log(`scope: ${parsed.mode}`);
|
|
1503
1532
|
console.log(`file: ${target}`);
|
|
1533
|
+
if (lite.state === 'broken' || studyNote.state === 'broken') process.exitCode = 1;
|
|
1504
1534
|
}
|
|
1505
1535
|
|
|
1506
1536
|
function hookConfigHasYamLite(config = {}) {
|
|
@@ -1513,6 +1543,160 @@ function hookConfigHasProfile(config = {}, profile = 'lite') {
|
|
|
1513
1543
|
}));
|
|
1514
1544
|
}
|
|
1515
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
|
+
|
|
1516
1700
|
async function hookEnable(args = []) {
|
|
1517
1701
|
const parsed = parseHookArgs(args);
|
|
1518
1702
|
if (!['lite', 'study-note'].includes(parsed.profile)) {
|
|
@@ -1521,23 +1705,45 @@ async function hookEnable(args = []) {
|
|
|
1521
1705
|
return;
|
|
1522
1706
|
}
|
|
1523
1707
|
const target = hookPathFor(parsed);
|
|
1524
|
-
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
|
+
);
|
|
1525
1723
|
const next = withYamHook(current, parsed.profile);
|
|
1526
1724
|
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
1527
|
-
if (
|
|
1725
|
+
if (loaded.exists) {
|
|
1528
1726
|
const backup = `${target}.yam-backup-${timestampId()}`;
|
|
1529
1727
|
await fsp.copyFile(target, backup);
|
|
1530
1728
|
console.log(`backup: ${backup}`);
|
|
1531
1729
|
}
|
|
1532
|
-
await
|
|
1730
|
+
await writeHookConfig(target, next);
|
|
1533
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`);
|
|
1534
1733
|
console.log('Restart Codex or start a new thread if the app does not pick up hook changes immediately.');
|
|
1535
1734
|
}
|
|
1536
1735
|
|
|
1537
1736
|
async function hookDisable(args = []) {
|
|
1538
1737
|
const parsed = parseHookArgs(args);
|
|
1539
1738
|
const target = hookPathFor(parsed);
|
|
1540
|
-
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;
|
|
1541
1747
|
if (!hookConfigHasProfile(current, parsed.profile)) {
|
|
1542
1748
|
console.log(`yam-${parsed.profile} hook already disabled (${parsed.mode}): ${target}`);
|
|
1543
1749
|
return;
|
|
@@ -1546,7 +1752,7 @@ async function hookDisable(args = []) {
|
|
|
1546
1752
|
if (Object.keys(next).length === 0) {
|
|
1547
1753
|
await rmrf(target);
|
|
1548
1754
|
} else {
|
|
1549
|
-
await
|
|
1755
|
+
await writeHookConfig(target, next);
|
|
1550
1756
|
}
|
|
1551
1757
|
console.log(`yam-${parsed.profile} hook disabled (${parsed.mode}): ${target}`);
|
|
1552
1758
|
}
|
|
@@ -1560,6 +1766,10 @@ async function hookRun(args = []) {
|
|
|
1560
1766
|
const input = await readStdinJson();
|
|
1561
1767
|
const event = input?.hook_event_name || input?.hookEventName || input?.event || 'UserPromptSubmit';
|
|
1562
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
|
+
}
|
|
1563
1773
|
const prompt = extractPrompt(input);
|
|
1564
1774
|
const additionalContext = profile === 'study-note'
|
|
1565
1775
|
? await buildStudyNoteHookContext({ cwd })
|
|
@@ -1625,6 +1835,7 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1625
1835
|
];
|
|
1626
1836
|
if (!changedFiles.length) {
|
|
1627
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.');
|
|
1628
1839
|
return lines.join('\n');
|
|
1629
1840
|
}
|
|
1630
1841
|
lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
|
|
@@ -1634,10 +1845,31 @@ async function buildStudyNoteHookContext({ cwd }) {
|
|
|
1634
1845
|
lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
|
|
1635
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.');
|
|
1636
1847
|
}
|
|
1637
|
-
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.');
|
|
1638
1849
|
return lines.join('\n');
|
|
1639
1850
|
}
|
|
1640
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
|
+
|
|
1641
1873
|
function yamLiteRouteHint(prompt = '') {
|
|
1642
1874
|
const text = String(prompt || '').toLowerCase();
|
|
1643
1875
|
if (!text) return '';
|
package/yam.manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yam",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"principles": [
|
|
5
5
|
"Direction before execution.",
|
|
6
6
|
"Start fast.",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"Context-reuse first.",
|
|
14
14
|
"End with remaining tasks and fix-first items.",
|
|
15
15
|
"When code, config, release metadata, documentation, or project artifacts changed, include a Study Note even if no yam skill was explicitly invoked.",
|
|
16
|
+
"When the opt-in Study Note hook is enabled, check the final response at Codex Stop and request one bounded correction pass instead of relying only on prompt-time advice.",
|
|
16
17
|
"Explain what changed, what role it has, where it runs or is read, what was wrong or missing when relevant, before/after behavior, expected behavior, and one useful syntax or structure insight in short non-specialist language.",
|
|
17
18
|
"When relevant, include architecture hygiene: avoid dumping unrelated logic into page.tsx, one-off component CSS into global.css, or structured product data into broad DB jsonb blobs when better project boundaries exist.",
|
|
18
19
|
"Use handoff notes for fix-first items, remaining tasks, recommended next direction, requirement coverage, blocker kind, failure cause, recovery hint, safe retry, evidence level, and owner scope when useful.",
|