greprag 5.49.16 → 5.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/commands/doc-pointer-reminder.d.ts +18 -0
  2. package/dist/commands/doc-pointer-reminder.js +41 -0
  3. package/dist/commands/doc-pointer-reminder.js.map +1 -0
  4. package/dist/commands/enrichment-health-reminder.d.ts +23 -0
  5. package/dist/commands/enrichment-health-reminder.js +39 -0
  6. package/dist/commands/enrichment-health-reminder.js.map +1 -0
  7. package/dist/commands/init.js +20 -0
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/load.d.ts +8 -5
  10. package/dist/commands/load.js +89 -10
  11. package/dist/commands/load.js.map +1 -1
  12. package/dist/commands/memory.js +40 -12
  13. package/dist/commands/memory.js.map +1 -1
  14. package/dist/commands/opencode-interrupt.d.ts +5 -0
  15. package/dist/commands/opencode-interrupt.js +8 -2
  16. package/dist/commands/opencode-interrupt.js.map +1 -1
  17. package/dist/commands/opencode-relay.js +1 -1
  18. package/dist/commands/opencode-relay.js.map +1 -1
  19. package/dist/commands/procedure.d.ts +15 -0
  20. package/dist/commands/procedure.js +167 -0
  21. package/dist/commands/procedure.js.map +1 -0
  22. package/dist/commands/reminder-registry.js +8 -0
  23. package/dist/commands/reminder-registry.js.map +1 -1
  24. package/dist/commands/reminder-types.d.ts +35 -0
  25. package/dist/commands/skill-gain-reminder.d.ts +17 -0
  26. package/dist/commands/skill-gain-reminder.js +32 -0
  27. package/dist/commands/skill-gain-reminder.js.map +1 -0
  28. package/dist/commands/skill-mirror-reminder.d.ts +15 -0
  29. package/dist/commands/skill-mirror-reminder.js +33 -0
  30. package/dist/commands/skill-mirror-reminder.js.map +1 -0
  31. package/dist/commands/skillgain.d.ts +12 -0
  32. package/dist/commands/skillgain.js +109 -0
  33. package/dist/commands/skillgain.js.map +1 -0
  34. package/dist/hook.js +386 -2
  35. package/dist/hook.js.map +1 -1
  36. package/dist/index.js +4 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/opencode-plugin.bundle.js +92 -1
  39. package/dist/procedure-watch.d.ts +153 -0
  40. package/dist/procedure-watch.js +349 -0
  41. package/dist/procedure-watch.js.map +1 -0
  42. package/dist/procedure.d.ts +88 -0
  43. package/dist/procedure.js +269 -0
  44. package/dist/procedure.js.map +1 -0
  45. package/dist/skill-landing.d.ts +88 -0
  46. package/dist/skill-landing.js +220 -0
  47. package/dist/skill-landing.js.map +1 -0
  48. package/dist/skill-mirror-client.d.ts +37 -0
  49. package/dist/skill-mirror-client.js +172 -0
  50. package/dist/skill-mirror-client.js.map +1 -0
  51. package/package.json +1 -1
  52. package/skill/commander/SKILL.md +2 -2
  53. package/skill/greprag/SKILL.md +1 -1
  54. package/skill/greprag/docs/inbox-watch.md +5 -5
  55. package/skill/templates/chip-leader-opencode.md +98 -0
package/dist/hook.js CHANGED
@@ -116,6 +116,14 @@ const coordinate_gate_1 = require("./commands/coordinate-gate");
116
116
  // read, advances the poll cursor. Closes the watcher dead-window gap. All real
117
117
  // logic lives in ./commands/inbox-drain. adr: adr/monitor-resilience.md
118
118
  const inbox_drain_1 = require("./commands/inbox-drain");
119
+ const procedure_1 = require("./procedure");
120
+ // Procedure System LEARN leg (docs/procedure-system.md §V1 LEARN-leg build
121
+ // spec): the span watch — opened at UserPromptSubmit (procedureCheck), observed
122
+ // per turn by the Stop hook (observeProcedureTurn), closed by negative
123
+ // continuity, distilled server-side, transitions applied to the local store.
124
+ const procedure_watch_1 = require("./procedure-watch");
125
+ const skill_landing_1 = require("./skill-landing");
126
+ const skill_mirror_client_1 = require("./skill-mirror-client");
119
127
  const API_URL_DEFAULT = 'https://api.greprag.com';
120
128
  const MAX_FIELD_CHARS = 500_000; // safety cap per text field
121
129
  // ---------- Env + config ---------------------------------------------------
@@ -747,6 +755,268 @@ function capField(text) {
747
755
  const originalKb = (text.length / 1000).toFixed(1);
748
756
  return `${truncated}\n\n[truncated: original ${originalKb}KB]`;
749
757
  }
758
+ // ---------- Doc pointers (docs/doc-pointer-system.md) ----------------------
759
+ //
760
+ // The Document Pointer System's detect half. No new hook: the Stop hook already
761
+ // sees every file the turn touched (filesTouched), so .md writes ride the same
762
+ // event — a deterministic path filter here, the Flash-Lite worthiness judge
763
+ // server-side. Best-effort end to end: a docptr failure never blocks a turn.
764
+ /** Deterministic pre-filter — the cheap gate BEFORE the LLM judge. Rejects the
765
+ * obvious never-index classes so they don't even cost a judge call: non-.md,
766
+ * dot-directories (.tmp-*, .claude/, .github/ — harness/scratch, not project
767
+ * docs), dependency/build trees, and the harness-owned CLAUDE.md/MEMORY.md
768
+ * (always injected anyway — indexing them is circular noise). Judge-worthy
769
+ * ambiguity (drafts, beats, plans) is the JUDGE's call, not this filter's. */
770
+ function isDocPathEligible(relPath) {
771
+ const p = relPath.replace(/\\/g, '/');
772
+ if (!p.toLowerCase().endsWith('.md'))
773
+ return false;
774
+ const segments = p.split('/');
775
+ const base = segments[segments.length - 1];
776
+ if (base === 'CLAUDE.md' || base === 'MEMORY.md')
777
+ return false;
778
+ for (const seg of segments.slice(0, -1)) {
779
+ if (seg.startsWith('.'))
780
+ return false;
781
+ if (seg === 'node_modules' || seg === 'dist' || seg === 'build' || seg === 'vendor')
782
+ return false;
783
+ }
784
+ return true;
785
+ }
786
+ /** Head-snippet cap posted to the judge (server re-caps defensively). */
787
+ const DOC_EVENT_SNIPPET_CHARS = 2_000;
788
+ /** Max doc events per turn (mirrors the server's MAX_EVENTS_PER_POST). */
789
+ const DOC_EVENTS_PER_TURN = 8;
790
+ /** Turn's touched files → judge-ready doc events: project-relative eligible
791
+ * .md paths + head snippets. Files outside cwd (scratchpad, other repos) and
792
+ * unreadable files (deleted later in the turn) are skipped silently. */
793
+ function collectDocEvents(filesTouched, cwd) {
794
+ const events = [];
795
+ const seen = new Set();
796
+ for (const abs of filesTouched) {
797
+ if (events.length >= DOC_EVENTS_PER_TURN)
798
+ break;
799
+ const rel = path.relative(cwd, abs).replace(/\\/g, '/');
800
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
801
+ continue;
802
+ if (!isDocPathEligible(rel) || seen.has(rel))
803
+ continue;
804
+ try {
805
+ const snippet = fs.readFileSync(abs, 'utf-8').slice(0, DOC_EVENT_SNIPPET_CHARS);
806
+ if (!snippet.trim())
807
+ continue;
808
+ seen.add(rel);
809
+ events.push({ path: rel, snippet });
810
+ }
811
+ catch { /* deleted/unreadable — skip */ }
812
+ }
813
+ return events;
814
+ }
815
+ // ---------- Procedure span observer (docs/procedure-system.md) --------------
816
+ //
817
+ // The LEARN leg's per-turn observer (build spec component 3). Gated on "a
818
+ // watch file exists" — zero cost otherwise. Each observed turn: build a
819
+ // compact TurnDigest, append it to the watch, ask the server the negative-
820
+ // continuity question (synchronous — a ~0.5s flash-lite call inside the Stop
821
+ // hook's existing 10s budget), then apply the pure close logic. Fail-open end
822
+ // to end: a dead judge extends the watch (offStreak untouched) and the
823
+ // max-turn watchdog reaps it.
824
+ /** Fire-and-forget the watchdog's fix-queue smell via `greprag fix log`
825
+ * (detached CLI child — the guard-refresh spawn shape). Best-effort. */
826
+ function spawnProcedureSmell(cwd, text) {
827
+ try {
828
+ const cliJs = path.join(__dirname, 'index.js');
829
+ const child = (0, proc_1.safeSpawn)(process.execPath, [cliJs, 'fix', 'log', text, '--scope', 'procedure-watch'], { cwd, detached: true, stdio: 'ignore', windowsHide: true });
830
+ child.unref();
831
+ }
832
+ catch { /* best-effort */ }
833
+ }
834
+ /** One observed turn of the active watch. Never throws (the caller also
835
+ * guards); every network call fail-open. */
836
+ async function observeProcedureTurn(cfg, anchor, turn, cwd, redaction) {
837
+ const watch = (0, procedure_watch_1.readProcedureWatch)(anchor.projectId);
838
+ if (!watch) {
839
+ // File exists but is unreadable/corrupt — drop it so it can't wedge the observer.
840
+ (0, procedure_watch_1.clearProcedureWatch)(anchor.projectId);
841
+ return;
842
+ }
843
+ // Watchdog BEFORE the judge call — the aborting turn costs nothing. Abort =
844
+ // clear the watch, log a fix-queue smell, NEVER distill (build spec comp. 4).
845
+ if ((0, procedure_watch_1.willExceedWatchdog)(watch)) {
846
+ (0, procedure_watch_1.clearProcedureWatch)(anchor.projectId);
847
+ spawnProcedureSmell(cwd, `procedure watchdog: ${watch.phase} watch on "${watch.verb}" hit ${watch.turnCount} turns `
848
+ + `without closing — endpoint never detected (docs/procedure-system.md)`);
849
+ return;
850
+ }
851
+ // Compact digest of THIS turn, scrubbed like the turn envelope (secrets
852
+ // never travel — adr/secret-scrubber.md).
853
+ const rawDigest = (0, procedure_watch_1.buildTurnDigest)({
854
+ userPrompt: turn.userPrompt,
855
+ toolCalls: turn.toolCalls,
856
+ filesTouched: turn.filesTouched,
857
+ status: turn.status,
858
+ }, watch.turnCount + 1);
859
+ const digest = {
860
+ ...rawDigest,
861
+ user: (0, secret_scrubber_1.scrubString)(rawDigest.user, redaction),
862
+ commands: rawDigest.commands.map(c => (0, secret_scrubber_1.scrubString)(c, redaction)),
863
+ };
864
+ // The negative-continuity question — synchronous, fail-open: any miss →
865
+ // live=null → offStreak untouched, the watch just extends.
866
+ let live = null;
867
+ try {
868
+ const res = await fetch(`${cfg.apiUrl}/v1/procedure/${anchor.projectId}/observe`, {
869
+ method: 'POST',
870
+ headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' },
871
+ body: JSON.stringify({
872
+ projectName: anchor.projectName,
873
+ verb: watch.verb,
874
+ phase: watch.phase,
875
+ digest,
876
+ turnCount: watch.turnCount + 1,
877
+ }),
878
+ });
879
+ if (res.ok) {
880
+ const data = await res.json();
881
+ live = typeof data.live === 'boolean' ? data.live : null;
882
+ }
883
+ }
884
+ catch { /* judge unreachable → null */ }
885
+ const { watch: next, disposition } = (0, procedure_watch_1.advanceWatch)(watch, digest, live);
886
+ if (disposition === 'watchdog') {
887
+ (0, procedure_watch_1.clearProcedureWatch)(anchor.projectId);
888
+ spawnProcedureSmell(cwd, `procedure watchdog: ${next.phase} watch on "${next.verb}" hit ${next.turnCount} turns `
889
+ + `without closing — endpoint never detected (docs/procedure-system.md)`);
890
+ return;
891
+ }
892
+ if (disposition === 'close') {
893
+ // Span = [open, lastLiveTurn] — trailing not-live turns never distill.
894
+ const span = (0, procedure_watch_1.liveSpan)(next);
895
+ if (span.length > 0) {
896
+ try {
897
+ const res = await fetch(`${cfg.apiUrl}/v1/procedure/${anchor.projectId}/distill`, {
898
+ method: 'POST',
899
+ headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' },
900
+ body: JSON.stringify({
901
+ projectName: anchor.projectName,
902
+ verb: next.verb,
903
+ phase: next.phase,
904
+ digests: span,
905
+ }),
906
+ });
907
+ if (res.ok) {
908
+ const data = await res.json();
909
+ const outcome = data.outcome === 'clean' || data.outcome === 'floundered'
910
+ ? data.outcome : null;
911
+ const distilled = data.procedure
912
+ && typeof data.procedure.steps === 'string' && data.procedure.steps
913
+ && typeof data.procedure.endpoint === 'string' && data.procedure.endpoint
914
+ ? {
915
+ steps: data.procedure.steps,
916
+ endpoint: data.procedure.endpoint,
917
+ caveats: typeof data.procedure.caveats === 'string' && data.procedure.caveats
918
+ ? data.procedure.caveats : undefined,
919
+ }
920
+ : null;
921
+ const store = (0, procedure_1.readProcedureStore)(anchor.projectId);
922
+ const { store: nextStore, action } = (0, procedure_watch_1.applyDistillTransition)(store, next, outcome, distilled, (0, procedure_watch_1.learnTriggersForVerb)(next.verb, store));
923
+ if (action !== 'discarded')
924
+ (0, procedure_1.writeProcedureStore)(anchor.projectId, nextStore);
925
+ }
926
+ }
927
+ catch { /* judge down → no write; watch discarded (transition table row 4) */ }
928
+ }
929
+ (0, procedure_watch_1.clearProcedureWatch)(anchor.projectId);
930
+ return;
931
+ }
932
+ (0, procedure_watch_1.writeProcedureWatch)(anchor.projectId, next);
933
+ }
934
+ /** SessionStart fetch: the live enrichment-health verdict (fix c2eb8777).
935
+ * Null = healthy OR unreachable — the outage announce only fires on a
936
+ * POSITIVE probe failure, so an API blip never cries wolf. The worker
937
+ * caches the probe ~5 min; this adds one cheap GET per session start. */
938
+ async function fetchEnrichmentDown(apiUrl, apiKey) {
939
+ try {
940
+ const res = await fetch(`${apiUrl}/v1/health/enrichment`, {
941
+ headers: { 'Authorization': `Bearer ${apiKey}` },
942
+ });
943
+ if (!res.ok)
944
+ return null;
945
+ const data = await res.json();
946
+ const e = data.enrichment;
947
+ if (!e || e.ok || !e.errorClass)
948
+ return null;
949
+ return { errorClass: e.errorClass, detail: e.detail, actionHint: e.actionHint };
950
+ }
951
+ catch {
952
+ return null;
953
+ }
954
+ }
955
+ /** SessionStart pass: the Skill Learning Loop's LANDING leg
956
+ * (docs/skill-learning-loop.md — the write-back is CODE, not agent
957
+ * voluntarism). Fetch pending gains → auto-land reference-tier into each
958
+ * skill's docs/learnings.md → POST landed statuses back → return the report
959
+ * for the announce. Undefined on any error → announce silent, gains stay
960
+ * pending for the next session start. */
961
+ async function fetchAndLandSkillGains(apiUrl, apiKey, anchor, cwd) {
962
+ try {
963
+ const res = await fetch(`${apiUrl}/v1/skillgain/${anchor.projectId}/pending`, {
964
+ headers: { 'Authorization': `Bearer ${apiKey}` },
965
+ });
966
+ if (!res.ok)
967
+ return undefined;
968
+ const data = await res.json();
969
+ const pending = (data.gains || []).filter(g => g.nodeId && g.skill && g.text);
970
+ if (pending.length === 0)
971
+ return undefined;
972
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
973
+ const { report, statusUpdates } = (0, skill_landing_1.landPendingGains)(pending, cwd, homeDir, anchor.projectName);
974
+ if (statusUpdates.length > 0) {
975
+ try {
976
+ await fetch(`${apiUrl}/v1/skillgain/${anchor.projectId}/status`, {
977
+ method: 'POST',
978
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
979
+ body: JSON.stringify({ projectName: anchor.projectName, updates: statusUpdates }),
980
+ });
981
+ }
982
+ catch { /* statuses re-sync next start — the stamp keeps lands idempotent */ }
983
+ }
984
+ return report.landed.length > 0 ? report : undefined;
985
+ }
986
+ catch {
987
+ return undefined;
988
+ }
989
+ }
990
+ /** SessionStart fetch: ONE reconcile+list round trip. Ships the project's
991
+ * tracked .md set (`git ls-files`) so vanished docs go stale server-side, and
992
+ * returns the active pointers for the announce. [] on any error → the
993
+ * announce stays silent (never blocks a session start). */
994
+ async function fetchDocPointers(apiUrl, apiKey, anchor, cwd) {
995
+ try {
996
+ let presentPaths;
997
+ try {
998
+ const out = (0, proc_1.safeExecSync)('git ls-files -- "*.md"', {
999
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
1000
+ });
1001
+ presentPaths = out.split('\n').map(s => s.trim()).filter(s => isDocPathEligible(s));
1002
+ }
1003
+ catch { /* not a git repo — list-only reconcile */ }
1004
+ const res = await fetch(`${apiUrl}/v1/docptr/${anchor.projectId}/reconcile`, {
1005
+ method: 'POST',
1006
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
1007
+ body: JSON.stringify({ projectName: anchor.projectName, presentPaths }),
1008
+ });
1009
+ if (!res.ok)
1010
+ return [];
1011
+ const data = await res.json();
1012
+ return (data.pointers || [])
1013
+ .filter(p => p.path && p.hook)
1014
+ .map(p => ({ path: p.path, hook: p.hook }));
1015
+ }
1016
+ catch {
1017
+ return [];
1018
+ }
1019
+ }
750
1020
  // ---------- Store ---------------------------------------------------------
751
1021
  async function store(input, source = 'claude-code') {
752
1022
  const cwd = input.cwd || process.cwd();
@@ -828,6 +1098,41 @@ async function store(input, source = 'claude-code') {
828
1098
  source,
829
1099
  provenance,
830
1100
  });
1101
+ // Doc Pointer System detect half (docs/doc-pointer-system.md): .md writes this
1102
+ // turn → one best-effort event POST; the worthiness judge runs server-side in
1103
+ // waitUntil. After the turn POST (memory capture always wins), never throws.
1104
+ const docEvents = collectDocEvents(turn.filesTouched, cwd);
1105
+ if (docEvents.length > 0) {
1106
+ try {
1107
+ await apiCall(`${cfg.apiUrl}/v1/docptr/${anchor.projectId}/events`, cfg.apiKey, {
1108
+ projectName: anchor.projectName,
1109
+ events: docEvents,
1110
+ });
1111
+ }
1112
+ catch { /* best-effort — a docptr outage never fails the Stop hook */ }
1113
+ }
1114
+ // Procedure System span observer (docs/procedure-system.md V1 LEARN leg):
1115
+ // gated on "a watch file exists" — zero cost when no watch is open. One
1116
+ // synchronous flash-lite continuity call per observed turn; close/watchdog
1117
+ // logic is pure in procedure-watch.ts. Best-effort, never throws.
1118
+ if ((0, procedure_watch_1.hasProcedureWatch)(anchor.projectId)) {
1119
+ try {
1120
+ await observeProcedureTurn(cfg, anchor, turn, cwd, redaction);
1121
+ }
1122
+ catch { /* fail-open — a procedure outage never fails the Stop hook */ }
1123
+ }
1124
+ // Skill mirror (docs/load-system.md Tier 3): a skill-load turn shadows that
1125
+ // skill's current markdown into the tenant's load store — upload ONLY on
1126
+ // change (local hash state), so an unchanged skill costs zero network.
1127
+ // Follower semantics: files stay canonical. Best-effort, never throws.
1128
+ if (provenance === 'skill-injection') {
1129
+ const mirrorSkill = (0, turn_provenance_1.provenanceHint)(provenance, turn.userPrompt);
1130
+ if (mirrorSkill) {
1131
+ await (0, skill_mirror_client_1.maybeMirrorSkill)({
1132
+ skill: mirrorSkill, cwd, apiUrl: cfg.apiUrl, apiKey: cfg.apiKey,
1133
+ });
1134
+ }
1135
+ }
831
1136
  }
832
1137
  // ---------- Main ---------------------------------------------------------
833
1138
  // ---------- Recap (SessionStart) ------------------------------------------
@@ -1143,6 +1448,34 @@ async function recap(input, mode = 'plain', opts = {}) {
1143
1448
  // tap, so the corpus module can tell the agent to search them before answering
1144
1449
  // from training data. Best-effort; empty → the announce stays silent.
1145
1450
  const corpusApiDocs = await fetchCorpusApiDocs(cfg.apiUrl, cfg.apiKey);
1451
+ // Doc-pointer announce signal (docs/doc-pointer-system.md) — one reconcile+list
1452
+ // round trip: vanished docs go stale server-side, active pointers come back for
1453
+ // the "core documents" block. Best-effort; empty → the announce stays silent.
1454
+ const docPointers = await fetchDocPointers(cfg.apiUrl, cfg.apiKey, anchor, cwd);
1455
+ // Enrichment-health signal (fix c2eb8777) — the live Gemini probe verdict.
1456
+ // Null when healthy or unreachable; a positive failure fires the outage announce.
1457
+ const enrichmentDown = await fetchEnrichmentDown(cfg.apiUrl, cfg.apiKey);
1458
+ // Skill Learning Loop landing leg (docs/skill-learning-loop.md): pending
1459
+ // gains → auto-land references into learnings docs, hold rule/scope
1460
+ // proposals for the announce. Best-effort; undefined → announce silent.
1461
+ const skillGains = await fetchAndLandSkillGains(cfg.apiUrl, cfg.apiKey, anchor, cwd);
1462
+ // Tier-3 skill-mirror pointer signal (docs/load-system.md): mirrored-skill
1463
+ // count + freshest names for the one-line announce. Best-effort.
1464
+ const mirroredSkills = await (async () => {
1465
+ try {
1466
+ const res = await fetch(`${cfg.apiUrl}/v1/skillmirror`, {
1467
+ headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
1468
+ });
1469
+ if (!res.ok)
1470
+ return undefined;
1471
+ const data = await res.json();
1472
+ const names = (data.skills || []).map(s => s.skillName || '').filter(Boolean);
1473
+ return names.length > 0 ? { count: names.length, names } : undefined;
1474
+ }
1475
+ catch {
1476
+ return undefined;
1477
+ }
1478
+ })();
1146
1479
  // Assistant doctrine auto-load — fires ONLY for the flagged assistant project
1147
1480
  // (isAssistantProject), so a normal project sees zero change. The hook owns the
1148
1481
  // doctrine-file read; the assistant-doctrine module routes the text. adr: adr/assistant-role.md
@@ -1175,6 +1508,10 @@ async function recap(input, mode = 'plain', opts = {}) {
1175
1508
  setupWarning,
1176
1509
  assistantDoctrine,
1177
1510
  corpusApiDocs,
1511
+ docPointers,
1512
+ enrichmentDown,
1513
+ skillGains,
1514
+ mirroredSkills,
1178
1515
  updateAvailable,
1179
1516
  };
1180
1517
  let announceReg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
@@ -1289,6 +1626,48 @@ function codexSubagentStart(input) {
1289
1626
  if (context)
1290
1627
  writeAdditionalContext(input.hook_event_name || 'SubagentStart', context);
1291
1628
  }
1629
+ /** UserPromptSubmit — Procedure System trigger leg (docs/procedure-system.md
1630
+ * §V1 LEARN-leg build spec, component 2). Two paths off a Tier-1 trigger hit:
1631
+ *
1632
+ * (a) known NON-STALE procedure → inject the recipe + gate BEFORE the agent
1633
+ * moves (the REPLAY inject) + open a bounded REPLAY watch.
1634
+ * (b) matched verb with NO procedure, or status=stale → open an open-ended
1635
+ * LEARN watch, NO injection (there's nothing to inject; the flounder
1636
+ * about to happen is the learning material). The LEARN trigger
1637
+ * vocabulary is the Tier-1 verb list (matchLearnTrigger), independent
1638
+ * of the store — build + test ride here with no seed.
1639
+ *
1640
+ * A LEARN watch NEVER opens for a destructive verb (the seed is the canon
1641
+ * even when stale). One active watch at a time — an in-flight watch is never
1642
+ * replaced. Pure-local (no network on the hot path), additive, fail-open:
1643
+ * a miss or any error never blocks the turn. The Tier-1 intent guard (in
1644
+ * matchProcedure) suppresses keyword mention-not-request false-fires. */
1645
+ async function procedureCheck(input) {
1646
+ try {
1647
+ const prompt = typeof input.prompt === 'string' ? input.prompt : '';
1648
+ if (!prompt.trim())
1649
+ return;
1650
+ const anchor = (0, project_anchor_1.readAnchor)(input.cwd || process.cwd());
1651
+ if (!anchor.projectId)
1652
+ return;
1653
+ const store = (0, procedure_1.readProcedureStore)(anchor.projectId);
1654
+ const m = store.procedures.length ? (0, procedure_1.matchProcedure)(prompt, store) : null;
1655
+ if (m && m.procedure.status !== 'stale') {
1656
+ writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', (0, procedure_1.buildProcedureInjection)(m.procedure));
1657
+ (0, procedure_watch_1.openWatchIfIdle)(anchor.projectId, m.procedure.verb, 'REPLAY');
1658
+ return;
1659
+ }
1660
+ // LEARN path: a stale store hit, or a Tier-1 vocabulary hit with no
1661
+ // procedure for the verb. No injection either way.
1662
+ const lm = m || (0, procedure_watch_1.matchLearnTrigger)(prompt);
1663
+ if (!lm)
1664
+ return;
1665
+ if ((0, procedure_watch_1.isDestructiveVerb)(lm.procedure.verb, store))
1666
+ return; // never LEARN a destructive verb
1667
+ (0, procedure_watch_1.openWatchIfIdle)(anchor.projectId, lm.procedure.verb, 'LEARN');
1668
+ }
1669
+ catch { /* fail-open — a procedure miss never blocks the turn */ }
1670
+ }
1292
1671
  async function notify(input, source = 'claude-code') {
1293
1672
  if (source === 'codex')
1294
1673
  cacheCodexPrompt(input);
@@ -1738,7 +2117,7 @@ async function main() {
1738
2117
  const subcommand = process.argv[2];
1739
2118
  const validSubs = new Set([
1740
2119
  'store', 'recap', 'recompact', 'notify', 'mail', 'friction-reminder', 'session-id', 'pre-spawn-check', 'armcheck',
1741
- 'collision-check', 'drain', 'coordinate-gate',
2120
+ 'collision-check', 'drain', 'coordinate-gate', 'procedure-check',
1742
2121
  'guard', 'guard-refresh', 'guard-flush',
1743
2122
  'context-probe', 'context-check', 'crush-wrap',
1744
2123
  'pre-compact', 'archive-pointer',
@@ -1746,7 +2125,7 @@ async function main() {
1746
2125
  'codex-permission-context', 'codex-subagent-start',
1747
2126
  ]);
1748
2127
  if (!validSubs.has(subcommand)) {
1749
- process.stderr.write(`Usage: greprag-hook <store|recap|recompact|notify|mail|friction-reminder|session-id|pre-spawn-check|armcheck|collision-check|drain|coordinate-gate|guard|guard-refresh|guard-flush|context-probe|context-check|crush-wrap|pre-compact|archive-pointer|codex-store|codex-notify|codex-inbox|codex-recap|codex-permission-context|codex-subagent-start>\n`);
2128
+ process.stderr.write(`Usage: greprag-hook <store|recap|recompact|notify|mail|friction-reminder|procedure-check|session-id|pre-spawn-check|armcheck|collision-check|drain|coordinate-gate|guard|guard-refresh|guard-flush|context-probe|context-check|crush-wrap|pre-compact|archive-pointer|codex-store|codex-notify|codex-inbox|codex-recap|codex-permission-context|codex-subagent-start>\n`);
1750
2129
  process.exit(1);
1751
2130
  }
1752
2131
  let input = {};
@@ -1781,6 +2160,11 @@ async function main() {
1781
2160
  else if (subcommand === 'mail') {
1782
2161
  await mail(input);
1783
2162
  }
2163
+ else if (subcommand === 'procedure-check') {
2164
+ // UserPromptSubmit — Procedure System REPLAY: inject a matched recipe + gate
2165
+ // before the agent moves. Local, additive, fail-open. docs/procedure-system.md
2166
+ await procedureCheck(input);
2167
+ }
1784
2168
  else if (subcommand === 'friction-reminder') {
1785
2169
  // UserPromptSubmit — the active Mechanic's dynamic friction reminder (beside
1786
2170
  // the arm directive). Stress-driven tier: silent/ambient/nudge/nag. Reads the