greprag 5.50.0 → 5.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hook.js CHANGED
@@ -117,7 +117,13 @@ const coordinate_gate_1 = require("./commands/coordinate-gate");
117
117
  // logic lives in ./commands/inbox-drain. adr: adr/monitor-resilience.md
118
118
  const inbox_drain_1 = require("./commands/inbox-drain");
119
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");
120
125
  const skill_landing_1 = require("./skill-landing");
126
+ const skill_mirror_client_1 = require("./skill-mirror-client");
121
127
  const API_URL_DEFAULT = 'https://api.greprag.com';
122
128
  const MAX_FIELD_CHARS = 500_000; // safety cap per text field
123
129
  // ---------- Env + config ---------------------------------------------------
@@ -806,6 +812,125 @@ function collectDocEvents(filesTouched, cwd) {
806
812
  }
807
813
  return events;
808
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
+ }
809
934
  /** SessionStart fetch: the live enrichment-health verdict (fix c2eb8777).
810
935
  * Null = healthy OR unreachable — the outage announce only fires on a
811
936
  * POSITIVE probe failure, so an API blip never cries wolf. The worker
@@ -862,20 +987,88 @@ async function fetchAndLandSkillGains(apiUrl, apiKey, anchor, cwd) {
862
987
  return undefined;
863
988
  }
864
989
  }
990
+ /** Manifest-liveness policy — mirror of core's assessLiveness threshold (the CLI
991
+ * is a zero-dep artifact, so the tiny policy is duplicated, like
992
+ * DOCPTR_ANNOUNCE_LIMIT). A doc must cite ≥3 in-repo paths with ≥60% dead before
993
+ * it BURIES; bias-to-keep. */
994
+ const DOCPTR_BURY_MIN_REFS = 3;
995
+ const DOCPTR_BURY_DEAD_RATIO = 0.6;
996
+ /** Resolve ONE cited ref to alive/dead/external against the live tree. The
997
+ * manifest stores paths verbatim from the doc, so a ref may be repo-root-
998
+ * relative, sibling-relative (`mechanic-repairs.md` inside docs/), or dot-
999
+ * relative (`../packages/...`). Resolution is the precision leg — without it,
1000
+ * live canon that cites siblings by basename false-buries:
1001
+ * • external (absolute / drive / ~ / URL / escapes the repo) → SKIP, never
1002
+ * counted: it's not a claim about THIS tree.
1003
+ * • alive if any candidate (as-given OR resolved against the doc's dir) is
1004
+ * tracked OR exists on disk (existsSync catches gitignored-but-present
1005
+ * files like .env).
1006
+ * • else dead. */
1007
+ function refState(ref, docDir, cwd, present) {
1008
+ const raw = (ref || '').trim();
1009
+ if (!raw)
1010
+ return 'skip';
1011
+ if (/^(~|[a-zA-Z]:|\/)/.test(raw) || /^[a-z]+:\/\//i.test(raw))
1012
+ return 'skip'; // external
1013
+ const candidates = [raw, docDir ? `${docDir}/${raw}` : raw]
1014
+ .map(c => path.posix.normalize(c).replace(/^\.\//, ''));
1015
+ let anyInside = false;
1016
+ for (const c of candidates) {
1017
+ if (c.startsWith('..'))
1018
+ continue; // escapes the repo → not an in-tree claim
1019
+ anyInside = true;
1020
+ if (present.has(c))
1021
+ return 'alive';
1022
+ try {
1023
+ if (fs.existsSync(path.join(cwd, c)))
1024
+ return 'alive';
1025
+ }
1026
+ catch { /* ignore */ }
1027
+ }
1028
+ return anyInside ? 'dead' : 'skip';
1029
+ }
1030
+ function docPtrLiveness(refs, docPath, cwd, present) {
1031
+ const docDir = path.posix.dirname((docPath || '').replace(/\\/g, '/'));
1032
+ const dir = docDir === '.' ? '' : docDir;
1033
+ let alive = 0, dead = 0;
1034
+ for (const r of (refs || [])) {
1035
+ const s = refState(r, dir, cwd, present);
1036
+ if (s === 'alive')
1037
+ alive++;
1038
+ else if (s === 'dead')
1039
+ dead++;
1040
+ }
1041
+ const total = alive + dead; // externals excluded from the denominator
1042
+ if (total === 0 || dead === 0)
1043
+ return { liveness: 'fresh', dead: 0, total };
1044
+ if (total >= DOCPTR_BURY_MIN_REFS && dead / total >= DOCPTR_BURY_DEAD_RATIO) {
1045
+ return { liveness: 'buried', dead, total };
1046
+ }
1047
+ return { liveness: 'decayed', dead, total };
1048
+ }
865
1049
  /** SessionStart fetch: ONE reconcile+list round trip. Ships the project's
866
1050
  * tracked .md set (`git ls-files`) so vanished docs go stale server-side, and
867
- * returns the active pointers for the announce. [] on any error → the
868
- * announce stays silent (never blocks a session start). */
1051
+ * returns the active pointers for the announce. Then the deterministic
1052
+ * staleness leg (docs/doc-pointer-system.md §staleness) scores each pointer's
1053
+ * reference manifest against the FULL tracked-file set: a doc whose cited files
1054
+ * have vanished has decayed against the code it described. Recompute-not-restore:
1055
+ * liveness is derived here each session from the persisted manifest + the live
1056
+ * tree, never a stored verdict. [] on any error → the announce stays silent. */
869
1057
  async function fetchDocPointers(apiUrl, apiKey, anchor, cwd) {
870
1058
  try {
871
- let presentPaths;
1059
+ let presentPaths; // eligible .md — the existence sweep
1060
+ let trackedAll; // ALL tracked files — the manifest oracle
872
1061
  try {
873
- const out = (0, proc_1.safeExecSync)('git ls-files -- "*.md"', {
1062
+ const md = (0, proc_1.safeExecSync)('git ls-files -- "*.md"', {
1063
+ cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
1064
+ });
1065
+ presentPaths = md.split('\n').map(s => s.trim()).filter(s => isDocPathEligible(s));
1066
+ const all = (0, proc_1.safeExecSync)('git ls-files', {
874
1067
  cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
875
1068
  });
876
- presentPaths = out.split('\n').map(s => s.trim()).filter(s => isDocPathEligible(s));
1069
+ trackedAll = new Set(all.split('\n').map(s => s.trim().replace(/\\/g, '/')).filter(Boolean));
877
1070
  }
878
- catch { /* not a git repo — list-only reconcile */ }
1071
+ catch { /* not a git repo — list-only reconcile, no liveness scoring */ }
879
1072
  const res = await fetch(`${apiUrl}/v1/docptr/${anchor.projectId}/reconcile`, {
880
1073
  method: 'POST',
881
1074
  headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
@@ -884,9 +1077,24 @@ async function fetchDocPointers(apiUrl, apiKey, anchor, cwd) {
884
1077
  if (!res.ok)
885
1078
  return [];
886
1079
  const data = await res.json();
887
- return (data.pointers || [])
1080
+ const scored = (data.pointers || [])
888
1081
  .filter(p => p.path && p.hook)
889
- .map(p => ({ path: p.path, hook: p.hook }));
1082
+ .map(p => {
1083
+ const path = p.path;
1084
+ const hook = p.hook;
1085
+ // No git tree (non-repo) → skip scoring; everything reads fresh.
1086
+ if (!trackedAll)
1087
+ return { path, hook };
1088
+ const v = docPtrLiveness(p.refs || [], path, cwd, trackedAll);
1089
+ if (v.liveness === 'fresh')
1090
+ return { path, hook };
1091
+ const note = `${v.dead}/${v.total} cited files gone`;
1092
+ return { path, hook, decayNote: note, buried: v.liveness === 'buried' };
1093
+ });
1094
+ // Fresh + decayed first, buried (probably-stale) sink to the bottom so the
1095
+ // review signal reads as a tail, not scattered through the canon.
1096
+ scored.sort((a, b) => Number(a.buried ?? false) - Number(b.buried ?? false));
1097
+ return scored;
890
1098
  }
891
1099
  catch {
892
1100
  return [];
@@ -986,6 +1194,28 @@ async function store(input, source = 'claude-code') {
986
1194
  }
987
1195
  catch { /* best-effort — a docptr outage never fails the Stop hook */ }
988
1196
  }
1197
+ // Procedure System span observer (docs/procedure-system.md V1 LEARN leg):
1198
+ // gated on "a watch file exists" — zero cost when no watch is open. One
1199
+ // synchronous flash-lite continuity call per observed turn; close/watchdog
1200
+ // logic is pure in procedure-watch.ts. Best-effort, never throws.
1201
+ if ((0, procedure_watch_1.hasProcedureWatch)(anchor.projectId)) {
1202
+ try {
1203
+ await observeProcedureTurn(cfg, anchor, turn, cwd, redaction);
1204
+ }
1205
+ catch { /* fail-open — a procedure outage never fails the Stop hook */ }
1206
+ }
1207
+ // Skill mirror (docs/load-system.md Tier 3): a skill-load turn shadows that
1208
+ // skill's current markdown into the tenant's load store — upload ONLY on
1209
+ // change (local hash state), so an unchanged skill costs zero network.
1210
+ // Follower semantics: files stay canonical. Best-effort, never throws.
1211
+ if (provenance === 'skill-injection') {
1212
+ const mirrorSkill = (0, turn_provenance_1.provenanceHint)(provenance, turn.userPrompt);
1213
+ if (mirrorSkill) {
1214
+ await (0, skill_mirror_client_1.maybeMirrorSkill)({
1215
+ skill: mirrorSkill, cwd, apiUrl: cfg.apiUrl, apiKey: cfg.apiKey,
1216
+ });
1217
+ }
1218
+ }
989
1219
  }
990
1220
  // ---------- Main ---------------------------------------------------------
991
1221
  // ---------- Recap (SessionStart) ------------------------------------------
@@ -1312,6 +1542,23 @@ async function recap(input, mode = 'plain', opts = {}) {
1312
1542
  // gains → auto-land references into learnings docs, hold rule/scope
1313
1543
  // proposals for the announce. Best-effort; undefined → announce silent.
1314
1544
  const skillGains = await fetchAndLandSkillGains(cfg.apiUrl, cfg.apiKey, anchor, cwd);
1545
+ // Tier-3 skill-mirror pointer signal (docs/load-system.md): mirrored-skill
1546
+ // count + freshest names for the one-line announce. Best-effort.
1547
+ const mirroredSkills = await (async () => {
1548
+ try {
1549
+ const res = await fetch(`${cfg.apiUrl}/v1/skillmirror`, {
1550
+ headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
1551
+ });
1552
+ if (!res.ok)
1553
+ return undefined;
1554
+ const data = await res.json();
1555
+ const names = (data.skills || []).map(s => s.skillName || '').filter(Boolean);
1556
+ return names.length > 0 ? { count: names.length, names } : undefined;
1557
+ }
1558
+ catch {
1559
+ return undefined;
1560
+ }
1561
+ })();
1315
1562
  // Assistant doctrine auto-load — fires ONLY for the flagged assistant project
1316
1563
  // (isAssistantProject), so a normal project sees zero change. The hook owns the
1317
1564
  // doctrine-file read; the assistant-doctrine module routes the text. adr: adr/assistant-role.md
@@ -1347,6 +1594,7 @@ async function recap(input, mode = 'plain', opts = {}) {
1347
1594
  docPointers,
1348
1595
  enrichmentDown,
1349
1596
  skillGains,
1597
+ mirroredSkills,
1350
1598
  updateAvailable,
1351
1599
  };
1352
1600
  let announceReg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
@@ -1461,13 +1709,22 @@ function codexSubagentStart(input) {
1461
1709
  if (context)
1462
1710
  writeAdditionalContext(input.hook_event_name || 'SubagentStart', context);
1463
1711
  }
1464
- /** UserPromptSubmit — Procedure System REPLAY leg (docs/procedure-system.md).
1465
- * Match the prompt against the project's procedure store; on a hit inject the
1466
- * recipe + gate as additionalContext BEFORE the agent moves, so an operational
1467
- * verb runs cleanly instead of by trial-and-error. Pure-local (no network on
1468
- * the hot path reads the file beside the matchset cache), additive, and
1469
- * fail-open: a miss or any error never blocks the turn. The Tier-1 intent guard
1470
- * (in matchProcedure) suppresses keyword mention-not-request false-fires. */
1712
+ /** UserPromptSubmit — Procedure System trigger leg (docs/procedure-system.md
1713
+ * §V1 LEARN-leg build spec, component 2). Two paths off a Tier-1 trigger hit:
1714
+ *
1715
+ * (a) known NON-STALE procedure inject the recipe + gate BEFORE the agent
1716
+ * moves (the REPLAY inject) + open a bounded REPLAY watch.
1717
+ * (b) matched verb with NO procedure, or status=stale open an open-ended
1718
+ * LEARN watch, NO injection (there's nothing to inject; the flounder
1719
+ * about to happen is the learning material). The LEARN trigger
1720
+ * vocabulary is the Tier-1 verb list (matchLearnTrigger), independent
1721
+ * of the store — build + test ride here with no seed.
1722
+ *
1723
+ * A LEARN watch NEVER opens for a destructive verb (the seed is the canon
1724
+ * even when stale). One active watch at a time — an in-flight watch is never
1725
+ * replaced. Pure-local (no network on the hot path), additive, fail-open:
1726
+ * a miss or any error never blocks the turn. The Tier-1 intent guard (in
1727
+ * matchProcedure) suppresses keyword mention-not-request false-fires. */
1471
1728
  async function procedureCheck(input) {
1472
1729
  try {
1473
1730
  const prompt = typeof input.prompt === 'string' ? input.prompt : '';
@@ -1477,12 +1734,20 @@ async function procedureCheck(input) {
1477
1734
  if (!anchor.projectId)
1478
1735
  return;
1479
1736
  const store = (0, procedure_1.readProcedureStore)(anchor.projectId);
1480
- if (!store.procedures.length)
1737
+ const m = store.procedures.length ? (0, procedure_1.matchProcedure)(prompt, store) : null;
1738
+ if (m && m.procedure.status !== 'stale') {
1739
+ writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', (0, procedure_1.buildProcedureInjection)(m.procedure));
1740
+ (0, procedure_watch_1.openWatchIfIdle)(anchor.projectId, m.procedure.verb, 'REPLAY');
1481
1741
  return;
1482
- const m = (0, procedure_1.matchProcedure)(prompt, store);
1483
- if (!m)
1742
+ }
1743
+ // LEARN path: a stale store hit, or a Tier-1 vocabulary hit with no
1744
+ // procedure for the verb. No injection either way.
1745
+ const lm = m || (0, procedure_watch_1.matchLearnTrigger)(prompt);
1746
+ if (!lm)
1484
1747
  return;
1485
- writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', (0, procedure_1.buildProcedureInjection)(m.procedure));
1748
+ if ((0, procedure_watch_1.isDestructiveVerb)(lm.procedure.verb, store))
1749
+ return; // never LEARN a destructive verb
1750
+ (0, procedure_watch_1.openWatchIfIdle)(anchor.projectId, lm.procedure.verb, 'LEARN');
1486
1751
  }
1487
1752
  catch { /* fail-open — a procedure miss never blocks the turn */ }
1488
1753
  }