@holmes-lab/holmes-kit 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -17
  13. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  14. package/dist/holmes/cli/codex-toml.js +282 -0
  15. package/dist/holmes/cli/doctor.d.ts +56 -0
  16. package/dist/holmes/cli/doctor.js +348 -18
  17. package/dist/holmes/cli/index.js +16 -2
  18. package/dist/holmes/cli/init.js +78 -0
  19. package/dist/holmes/cli/interactive-prompt.js +4 -4
  20. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  21. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  22. package/dist/holmes/cli/semantic-key.js +93 -0
  23. package/dist/holmes/config/config.d.ts +9 -0
  24. package/dist/holmes/config/config.js +8 -1
  25. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  26. package/dist/holmes/cpg/language-capability.js +145 -0
  27. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  28. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  29. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  30. package/dist/holmes/governance/ledger-rechain.js +17 -2
  31. package/dist/holmes/governance/provenance-ledger.js +21 -0
  32. package/dist/holmes/guardrail/blind-spots.js +12 -1
  33. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  34. package/dist/holmes/guardrail/impact-gate.js +263 -0
  35. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  36. package/dist/holmes/guardrail/write-target.js +48 -4
  37. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  38. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  39. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  40. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  41. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  42. package/dist/holmes/hooks/stop.d.ts +65 -0
  43. package/dist/holmes/hooks/stop.js +200 -2
  44. package/dist/holmes/mcp/handlers.d.ts +88 -0
  45. package/dist/holmes/mcp/handlers.js +597 -16
  46. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  47. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  48. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  49. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  50. package/dist/holmes/mcp/tool-schemas.js +71 -0
  51. package/dist/holmes/project/root.js +3 -1
  52. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  53. package/dist/holmes/review/baseline-arm.js +51 -0
  54. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  55. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  56. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  57. package/dist/holmes/review/coherence-verify.js +144 -0
  58. package/dist/holmes/review/commit-text.d.ts +50 -0
  59. package/dist/holmes/review/commit-text.js +76 -0
  60. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  61. package/dist/holmes/review/confidence-calibration.js +39 -0
  62. package/dist/holmes/review/content-baseline.d.ts +38 -0
  63. package/dist/holmes/review/content-baseline.js +103 -0
  64. package/dist/holmes/review/content-verify.d.ts +20 -0
  65. package/dist/holmes/review/content-verify.js +73 -0
  66. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  67. package/dist/holmes/review/dense-retrieval.js +97 -0
  68. package/dist/holmes/review/edge-quality.d.ts +44 -0
  69. package/dist/holmes/review/edge-quality.js +117 -0
  70. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  71. package/dist/holmes/review/evaluation-metrics.js +175 -0
  72. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  73. package/dist/holmes/review/graph-verifier.js +62 -0
  74. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  75. package/dist/holmes/review/hop-ablation.js +89 -0
  76. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  77. package/dist/holmes/review/manual-baseline.js +2846 -0
  78. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  79. package/dist/holmes/review/oracle-gap.js +102 -0
  80. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  81. package/dist/holmes/review/point-in-time-replay.js +161 -0
  82. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  83. package/dist/holmes/review/rank-diagnosis.js +163 -0
  84. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  85. package/dist/holmes/review/replay-calibration.js +83 -0
  86. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  87. package/dist/holmes/review/replay-corpus.js +210 -0
  88. package/dist/holmes/review/run-replay.d.ts +260 -0
  89. package/dist/holmes/review/run-replay.js +729 -0
  90. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  91. package/dist/holmes/review/semantic-arm.js +717 -0
  92. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  93. package/dist/holmes/review/semantic-retrieval.js +156 -0
  94. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  95. package/dist/holmes/review/spec-layer-stats.js +52 -0
  96. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  97. package/dist/holmes/review/temporal-prior.js +53 -0
  98. package/dist/holmes/review/test-runner.d.ts +15 -0
  99. package/dist/holmes/review/test-runner.js +41 -4
  100. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  101. package/dist/holmes/review/test-selection-breadth.js +57 -0
  102. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  103. package/dist/holmes/review/traceability-benchmark.js +135 -0
  104. package/dist/holmes/review/union-verify.d.ts +12 -0
  105. package/dist/holmes/review/union-verify.js +70 -0
  106. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  107. package/dist/holmes/rtm/graph-store.js +122 -0
  108. package/dist/holmes/rtm/incremental.d.ts +25 -1
  109. package/dist/holmes/rtm/incremental.js +18 -1
  110. package/dist/holmes/rtm/localize.d.ts +28 -0
  111. package/dist/holmes/rtm/localize.js +272 -14
  112. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  113. package/dist/holmes/rtm/rtm-builder.js +265 -28
  114. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  115. package/dist/holmes/rtm/rtm-graph.js +194 -31
  116. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  117. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  118. package/dist/holmes/rtm/test-scope.js +24 -1
  119. package/dist/holmes/semantic/credentials.d.ts +15 -0
  120. package/dist/holmes/semantic/credentials.js +134 -0
  121. package/dist/holmes/semantic/embedder.d.ts +44 -0
  122. package/dist/holmes/semantic/embedder.js +185 -0
  123. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  124. package/dist/holmes/semantic/hit-rerank.js +38 -0
  125. package/dist/holmes/semantic/tier.d.ts +37 -0
  126. package/dist/holmes/semantic/tier.js +54 -0
  127. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  128. package/dist/holmes/semantic/vector-cache.js +91 -0
  129. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  130. package/dist/holmes/spec/acceptance-quality.js +169 -0
  131. package/dist/holmes/spec/validator.js +33 -1
  132. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  133. package/dist/holmes/spec/yaml-scalar.js +43 -0
  134. package/package.json +1 -1
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ // @implements A-SPEC-467
3
+ /**
4
+ * Personalized PageRank over the RTM/CPG edge set — the associative-recall primitive.
5
+ *
6
+ * Why this exists: the shipped retrieval primitives sit at two extremes. `issue_localize` stops at
7
+ * one spec hop (its own comment defers anything further), and `rtm_impact` returns an UNGRADED
8
+ * transitive closure — measured on the replay corpus, 84% of a commit's co-changed files are
9
+ * reachable through that closure while the analysis names 11% of them: the bottleneck is selection
10
+ * within reach, not reach. What is missing between the two is graded multi-hop mass —
11
+ * Score(1-hop) > Score(2-hop) > … > 0 — which is exactly what a damped random walk provides.
12
+ *
13
+ * Eleven graph/semantic mechanisms were measured and rejected on this corpus before this one
14
+ * (binary k-hop expansion, caller closure, co-change retrieval, dense embeddings, …). PPR is
15
+ * structurally different from all of them: it produces a RANKING over nodes rather than an
16
+ * admission set, and the geometric damping suppresses the crowd problem that sank the closure.
17
+ * That difference is the reason this module exists; whether it PAYS is decided by the replay arm
18
+ * (rerank and admit modes scored separately, three corpora), never assumed here.
19
+ *
20
+ * Deterministic, pure, no I/O. PPR mass never enters a gate decision — association proposes,
21
+ * the deterministic layers judge.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.buildAdjacency = buildAdjacency;
25
+ exports.personalizedPageRank = personalizedPageRank;
26
+ /** Build the weighted, direction-aware, hub-damped transition structure once per graph. */
27
+ function buildAdjacency(edges, profile) {
28
+ // Degree first (undirected, unweighted): the hub penalty divides by how CONNECTED the target is,
29
+ // not by how heavily this particular rel weighs it.
30
+ const degree = new Map();
31
+ const kept = [];
32
+ for (const e of edges) {
33
+ const w = profile.rels[e.rel];
34
+ if (w === undefined || (w.fwd <= 0 && w.back <= 0))
35
+ continue;
36
+ kept.push({ src: e.src, dst: e.dst, fwd: w.fwd, back: w.back });
37
+ degree.set(e.src, (degree.get(e.src) ?? 0) + 1);
38
+ degree.set(e.dst, (degree.get(e.dst) ?? 0) + 1);
39
+ }
40
+ const damp = (node) => profile.hubExponent > 0 ? Math.pow(degree.get(node) ?? 1, profile.hubExponent) : 1;
41
+ const raw = new Map();
42
+ const nodes = new Set();
43
+ const add = (from, to, w) => {
44
+ if (w <= 0)
45
+ return;
46
+ (raw.get(from) ?? raw.set(from, []).get(from)).push({ to, w: w / damp(to) });
47
+ };
48
+ for (const e of kept) {
49
+ nodes.add(e.src);
50
+ nodes.add(e.dst);
51
+ add(e.src, e.dst, e.fwd);
52
+ add(e.dst, e.src, e.back);
53
+ }
54
+ // Normalize out-mass per node so the walk is a probability distribution.
55
+ for (const [, list] of raw) {
56
+ const total = list.reduce((a, b) => a + b.w, 0);
57
+ for (const o of list)
58
+ o.w /= total;
59
+ }
60
+ return { out: raw, nodes };
61
+ }
62
+ /**
63
+ * Power iteration: v' = d·(Wᵀv + danglingMass·s) + (1−d)·s.
64
+ *
65
+ * Dangling mass refluxes to the SEEDS, not uniformly — a node with no out-edges returns the walk
66
+ * to the question rather than to the whole graph, and this is also what keeps a disconnected
67
+ * component at exactly zero. The vector always sums to 1 (asserted by test): a distribution that
68
+ * silently leaks mass would make cross-config comparisons meaningless.
69
+ */
70
+ function personalizedPageRank(adj, seeds, opts) {
71
+ const { d } = opts;
72
+ const maxIter = opts.maxIter ?? 10;
73
+ const eps = opts.eps ?? 1e-4;
74
+ const seedTotal = [...seeds.values()].reduce((a, b) => a + b, 0);
75
+ if (seedTotal <= 0)
76
+ return new Map();
77
+ const s = new Map();
78
+ for (const [k, v] of seeds)
79
+ if (v > 0)
80
+ s.set(k, v / seedTotal);
81
+ let v = new Map(s);
82
+ for (let i = 0; i < maxIter; i++) {
83
+ // walk = Wᵀv + dangling·s (dangling mass returns to the seeds), then v' = d·walk + (1−d)·s.
84
+ const walk = new Map();
85
+ let dangling = 0;
86
+ for (const [node, mass] of v) {
87
+ const out = adj.out.get(node);
88
+ if (out === undefined || out.length === 0) {
89
+ dangling += mass;
90
+ continue;
91
+ }
92
+ for (const o of out)
93
+ walk.set(o.to, (walk.get(o.to) ?? 0) + mass * o.w);
94
+ }
95
+ const next = new Map();
96
+ for (const [k, sv] of s)
97
+ walk.set(k, (walk.get(k) ?? 0) + dangling * sv);
98
+ for (const [k, m] of walk)
99
+ next.set(k, d * m);
100
+ for (const [k, sv] of s)
101
+ next.set(k, (next.get(k) ?? 0) + (1 - d) * sv);
102
+ let l1 = 0;
103
+ for (const k of new Set([...v.keys(), ...next.keys()]))
104
+ l1 += Math.abs((v.get(k) ?? 0) - (next.get(k) ?? 0));
105
+ v = next;
106
+ if (eps > 0 && l1 < eps)
107
+ break;
108
+ }
109
+ return v;
110
+ }
@@ -13,19 +13,32 @@ export type Agent = (typeof AGENTS)[number];
13
13
  * 그 하네스에서 게이트가 **집행되는가**.
14
14
  *
15
15
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
16
- * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 막을 있다(설치본의
17
- * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
18
- * - `codex` 실측했다(codex-cli 0.149.0, 2026-08-23). 훅 규약 자체는 우리 것과 **동일**하다:
19
- * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
20
- * `hookSpecificOutput` 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 먹이면 정확히
21
- * `deny` 낸다. 그런데도 `false` 이유는 **배선 경로**다: codex 훅은 `config.toml` 의
22
- * `[[hooks]]` 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config`통과 스키마 밖이다).
23
- * `hook_runtime` 트레이스는 실제 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
24
- * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
25
- * codex 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
26
- * 그때까지 codex MCP 서버로만 지배되며, 경로는 실증됐다(spec_approve 서버측 승인 게이트).
16
+ * - `antigravity` — **true**, 실기 프로브로 확인(2026-08-30, `agy -p` 비대화 실행). Codex 에서
17
+ * 무너진 단계를 여기서는 전부 통과한다: `.agents/hooks.json` 이 적재되고(훅이 41회 호출됨),
18
+ * 페이로드가 어댑터의 `TOOL_MAP` 정확히 맞으며(`write_to_file`/`TargetFile`/`CodeContent`),
19
+ * 미승인 쓰기에 `deny` 내자 **파일이 생성되지 않았다.** Stop 훅의 헌법 감사와 루프 가드도
20
+ * 같은 실행에서 발화했다. 값의 근거는 이제 규약 문서가 아니라 실측이다.
21
+ * - `codex` **false**, 실측(codex-cli 0.151.0, 2026-08-30, A-SPEC-442). Codex 훅을 집행할
22
+ * 있다: 벤더 규약대로 배선한 플러그인을 설치하면 PreToolUse 발화하고, exit 2 를 내면
23
+ * `echo BLOCKED > blocked.txt` 실제로 실행되지 않는다(프로브 4회 3·4번째).
24
+ *
25
+ * 그런데 Codex 플러그인을 **설치된 마켓플레이스 스냅샷**에서만 적재한다. 프로젝트 폴더에
26
+ * 매니페스트를 놓아도, 그것을 마켓플레이스로 등록해 두어도, 사용자가 `codex plugin add`
27
+ * 실행하기 전에는 훅이 발화하지 않는다(프로브 1·2번째). 설치는 사용자의 행위이고 doctor 는
28
+ * 오프라인이므로, init 이 끝난 시점에 이 값이 참이라고 말할 근거가 없다.
29
+ *
30
+ * A-SPEC-441 은 이 값을 true 로 올렸다. 그 결과 `AGENTS_MD(true)` 가 집행되지 않는 하네스에
31
+ * "Gates are **enforced**" 라고 적었다 — 이 파일의 머리말이 하지 말라고 적어 둔 바로 그것이다.
27
32
  */
28
33
  export declare const HARNESS_ENFORCES: Record<Agent, boolean>;
34
+ /**
35
+ * @implements A-SPEC-442
36
+ * codex 플러그인이 저장소 안에서 사는 자리. 저장소 루트에 `plugins/` 를 만들지 않으려고
37
+ * 중첩 경로를 쓴다 — 마켓플레이스의 `source.path` 가 `./` 로 시작하는 상대경로이기만 하면
38
+ * 중첩이 허용된다는 것은 실측으로 확인했다.
39
+ */
40
+ export declare const CODEX_PLUGIN_DIR: string;
41
+ export declare const CODEX_MARKETPLACE = "holmes-kit-local";
29
42
  export interface AgentWiringOptions {
30
43
  target: string;
31
44
  packageRoot: string;
@@ -33,9 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
36
+ exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
37
37
  exports.agentFiles = agentFiles;
38
38
  exports.agentLinks = agentLinks;
39
+ // @implements A-SPEC-442
39
40
  // @implements A-SPEC-193
40
41
  const path = __importStar(require("node:path"));
41
42
  const mcp_launcher_1 = require("./mcp-launcher");
@@ -52,23 +53,36 @@ exports.AGENTS = ['claude', 'antigravity', 'codex'];
52
53
  * 그 하네스에서 게이트가 **집행되는가**.
53
54
  *
54
55
  * - `claude` — PreToolUse/Stop 훅. 이 저장소가 처음부터 배선해 온 하네스.
55
- * - `antigravity` — PreToolUse/Stop 훅이 있고 `deny`/`continue` 막을 있다(설치본의
56
- * `agy-customizations` 규약 문서와 실제 대화 기록에서 실측).
57
- * - `codex` 실측했다(codex-cli 0.149.0, 2026-08-23). 훅 규약 자체는 우리 것과 **동일**하다:
58
- * 바이너리 스키마가 `PreToolUse` / `permissionDecision: allow|deny|ask` / `stop_hook_active` /
59
- * `hookSpecificOutput` 그대로 쓰고, codex 입력 형식으로 우리 `pre-tool-use.js` 먹이면 정확히
60
- * `deny` 낸다. 그런데도 `false` 이유는 **배선 경로**다: codex 훅은 `config.toml` 의
61
- * `[[hooks]]` 로드되지 않는다(잘못된 타입을 넣어도 `--strict-config`통과 스키마 밖이다).
62
- * `hook_runtime` 트레이스는 실제 도구 실행에도 진입하지 않았고, 바이너리 문자열이 훅을
63
- * `Plugins`/`RemotePlugin` 시스템(`codex plugin`, `--with-hooks` scaffold)으로 로드한다고 말한다.
64
- * codex 집행은 **plugin 패키징**이 필요하며 MCP·config 배선으로는 닿지 않는다(REQ-248 로 남김).
65
- * 그때까지 codex MCP 서버로만 지배되며, 경로는 실증됐다(spec_approve 서버측 승인 게이트).
56
+ * - `antigravity` — **true**, 실기 프로브로 확인(2026-08-30, `agy -p` 비대화 실행). Codex 에서
57
+ * 무너진 단계를 여기서는 전부 통과한다: `.agents/hooks.json` 이 적재되고(훅이 41회 호출됨),
58
+ * 페이로드가 어댑터의 `TOOL_MAP` 정확히 맞으며(`write_to_file`/`TargetFile`/`CodeContent`),
59
+ * 미승인 쓰기에 `deny` 내자 **파일이 생성되지 않았다.** Stop 훅의 헌법 감사와 루프 가드도
60
+ * 같은 실행에서 발화했다. 값의 근거는 이제 규약 문서가 아니라 실측이다.
61
+ * - `codex` **false**, 실측(codex-cli 0.151.0, 2026-08-30, A-SPEC-442). Codex 훅을 집행할
62
+ * 있다: 벤더 규약대로 배선한 플러그인을 설치하면 PreToolUse 발화하고, exit 2 를 내면
63
+ * `echo BLOCKED > blocked.txt` 실제로 실행되지 않는다(프로브 4회 3·4번째).
64
+ *
65
+ * 그런데 Codex 플러그인을 **설치된 마켓플레이스 스냅샷**에서만 적재한다. 프로젝트 폴더에
66
+ * 매니페스트를 놓아도, 그것을 마켓플레이스로 등록해 두어도, 사용자가 `codex plugin add`
67
+ * 실행하기 전에는 훅이 발화하지 않는다(프로브 1·2번째). 설치는 사용자의 행위이고 doctor 는
68
+ * 오프라인이므로, init 이 끝난 시점에 이 값이 참이라고 말할 근거가 없다.
69
+ *
70
+ * A-SPEC-441 은 이 값을 true 로 올렸다. 그 결과 `AGENTS_MD(true)` 가 집행되지 않는 하네스에
71
+ * "Gates are **enforced**" 라고 적었다 — 이 파일의 머리말이 하지 말라고 적어 둔 바로 그것이다.
66
72
  */
67
73
  exports.HARNESS_ENFORCES = {
68
74
  claude: true,
69
75
  antigravity: true,
70
76
  codex: false,
71
77
  };
78
+ /**
79
+ * @implements A-SPEC-442
80
+ * codex 플러그인이 저장소 안에서 사는 자리. 저장소 루트에 `plugins/` 를 만들지 않으려고
81
+ * 중첩 경로를 쓴다 — 마켓플레이스의 `source.path` 가 `./` 로 시작하는 상대경로이기만 하면
82
+ * 중첩이 허용된다는 것은 실측으로 확인했다.
83
+ */
84
+ exports.CODEX_PLUGIN_DIR = path.join('.holmes', 'codex-plugin', 'holmes-kit');
85
+ exports.CODEX_MARKETPLACE = 'holmes-kit-local';
72
86
  const q = (p) => p;
73
87
  // @implements A-SPEC-251.1 — same launch contract as the Claude wiring: npx-pin for installed
74
88
  // packageRoots, node for source checkouts. Shared via mcpEntryForInstall so all three harnesses
@@ -99,6 +113,17 @@ const mcpConfig = (packageRoot, specsDir, launcher) => {
99
113
  // Antigravity gate was silently off. (The MCP config above is different: it passes `args` as an
100
114
  // array, where quoting would put literal quote characters into the path.)
101
115
  const hookCommand = (packageRoot, script) => `node "${path.join(packageRoot, 'bin', script)}"`;
116
+ /**
117
+ * @implements A-SPEC-442
118
+ * Codex 훅이 부를 것: **Claude 와 같은 정책 커널**.
119
+ *
120
+ * Codex 의 PreToolUse 출력 스키마는 `hookSpecificOutput.permissionDecision`(allow|deny|ask)이며,
121
+ * 이것은 Claude 규약과 같은 모양이다(codex 0.151.0 바이너리의 JSON 스키마에서 확인). 커널은
122
+ * 이미 그 형식을 낸다. 반면 antigravity 어댑터는 `toolCall.name` 을 찾고 `{"decision":"deny"}` 를
123
+ * exit 0 으로 낸다 — 실기에서 Codex 는 그것을 이해하지 못하고 `PreToolUse Failed` 로 처리하며
124
+ * **차단하지 않는다**(실측). 적재되는 훅과 말이 통하는 훅은 다른 문제이고, 둘 다 틀려 있었다.
125
+ */
126
+ const kernelCommand = (packageRoot, script) => `node "${path.join(packageRoot, 'dist', 'holmes', 'hooks', script)}"`;
102
127
  const hooksJson = (packageRoot) => `${JSON.stringify({
103
128
  'holmes-kit': {
104
129
  PreToolUse: [{
@@ -137,11 +162,12 @@ This repository operates under **No Spec, No Code** governance. An approved spec
137
162
 
138
163
  1. Run \`spec_slice_init\` or \`spec_create\` to author REQ -> H-SPEC -> A-SPEC.
139
164
  2. Approve specs via \`spec_slice_approve\` or \`spec_approve\`.
140
- 3. **Write tests first** and verify failure (RED stage).
141
- 4. Approve the mirroring T-SPEC (\`A-SPEC-188\` -> \`T-SPEC-188\`).
142
- 5. Implement source code (GREEN stage).
165
+ 3. **Before editing source**, run \`maintenance_analyze({ root, request, persist: true })\` and keep the digest it returns — that persisted analysis is what the pre-edit evidence gate checks.
166
+ 4. **Write tests first** and verify failure (RED stage).
167
+ 5. Approve the mirroring T-SPEC (\`A-SPEC-188\` -> \`T-SPEC-188\`).
168
+ 6. Implement source code (GREEN stage).
143
169
 
144
- Use \`spec_next\` to check the next required governance step. Use \`phase_check\` to verify action permissions ahead of execution.
170
+ Use \`spec_next\` to check the next required governance step. Use \`phase_check\` to verify action permissions ahead of execution. Use \`impact_gate_check\` to ask what the pre-edit evidence gate would say about an edit — it is advisory and blocks nothing, so it answers the same way in every harness.
145
171
 
146
172
  ## Enforcement in this Harness
147
173
 
@@ -169,8 +195,58 @@ function agentFiles(agent, opts) {
169
195
  { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.antigravity) },
170
196
  ];
171
197
  case 'codex':
198
+ // @implements A-SPEC-442 (was A-SPEC-266, A-SPEC-441) — Codex plugin, at the layout Codex reads:
199
+ // 1. the repository becomes a LOCAL MARKETPLACE (`.agents/plugins/marketplace.json`),
200
+ // because Codex loads plugins only from installed marketplace snapshots;
201
+ // 2. the plugin manifest's `hooks` names `./hooks.json` at the PLUGIN ROOT — the vendor's
202
+ // plugin reference (embedded in the codex binary) defines it that way, and the
203
+ // `./hooks/hooks.json` A-SPEC-441 emitted was measured never to load.
204
+ // The MCP wiring stays in `.codex/config.toml` (merged by init.ts).
205
+ // Wiring is not installation: the user still runs `codex plugin marketplace add` and
206
+ // `codex plugin add`, which is why HARNESS_ENFORCES.codex is false.
172
207
  return [
173
- { path: path.join(target, '.codex', 'mcp_config.json'), content: mcpConfig(packageRoot, specsDir, opts.launcher) },
208
+ {
209
+ path: path.join(target, '.agents', 'plugins', 'marketplace.json'),
210
+ content: `${JSON.stringify({
211
+ name: exports.CODEX_MARKETPLACE,
212
+ interface: { displayName: 'Holmes-Kit (local)' },
213
+ plugins: [{
214
+ name: 'holmes-kit',
215
+ source: { source: 'local', path: `./${exports.CODEX_PLUGIN_DIR.split(path.sep).join('/')}` },
216
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
217
+ category: 'Developer Tools',
218
+ }],
219
+ }, null, 2)}\n`,
220
+ },
221
+ {
222
+ path: path.join(target, exports.CODEX_PLUGIN_DIR, '.codex-plugin', 'plugin.json'),
223
+ content: `${JSON.stringify({
224
+ name: 'holmes-kit',
225
+ version: '0.2.1',
226
+ description: 'Holmes-Kit deterministic governance plugin for Codex',
227
+ hooks: './hooks.json',
228
+ }, null, 2)}\n`,
229
+ },
230
+ {
231
+ path: path.join(target, exports.CODEX_PLUGIN_DIR, 'hooks.json'),
232
+ content: `${JSON.stringify({
233
+ hooks: {
234
+ PreToolUse: [{
235
+ matcher: '.*',
236
+ hooks: [{
237
+ type: 'command',
238
+ command: kernelCommand(packageRoot, 'pre-tool-use.js'),
239
+ timeout: 30,
240
+ }],
241
+ }],
242
+ Stop: [{
243
+ type: 'command',
244
+ command: kernelCommand(packageRoot, 'stop.js'),
245
+ timeout: 30,
246
+ }],
247
+ },
248
+ }, null, 2)}\n`,
249
+ },
174
250
  { path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.codex) },
175
251
  ];
176
252
  default:
@@ -0,0 +1,26 @@
1
+ import { McpServerEntry } from './mcp-launcher';
2
+ /** The one table holmes-kit owns in a Codex config.toml. */
3
+ export declare const CODEX_TABLE = "mcp_servers.holmes-kit";
4
+ /**
5
+ * Serialize the `[mcp_servers.holmes-kit]` table. `env` is an inline table so the whole entry is ONE
6
+ * contiguous region (no `[mcp_servers.holmes-kit.env]` child header) — that keeps the merge boundary
7
+ * unambiguous: our region runs from the header to the next table header. Ends with a trailing newline.
8
+ */
9
+ export declare function codexMcpBlock(entry: McpServerEntry, specsDir: string): string;
10
+ /**
11
+ * Merge `block` (a full `codexMcpBlock` output) into `existing`. When `existing` is null/empty the
12
+ * block stands alone. When our table is already present its region is REPLACED (no duplicate); when
13
+ * absent the block is appended after a blank-line separator. Every other line is preserved verbatim.
14
+ */
15
+ export declare function mergeCodexToml(existing: string | null, block: string): string;
16
+ /** Strip our region from `existing`, preserving everything else. Absent → returned unchanged. */
17
+ export declare function removeCodexToml(existing: string): string;
18
+ /**
19
+ * Read `{command,args}` from our table in a config.toml — for doctor's drift check. Parses only the
20
+ * shape `codexMcpBlock` writes; anything it cannot read returns null (doctor then WARNs rather than
21
+ * translating an unreadable wiring into a pass).
22
+ */
23
+ export declare function readCodexHolmesEntry(raw: string): {
24
+ command: string;
25
+ args: string[];
26
+ } | null;
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CODEX_TABLE = void 0;
4
+ exports.codexMcpBlock = codexMcpBlock;
5
+ exports.mergeCodexToml = mergeCodexToml;
6
+ exports.removeCodexToml = removeCodexToml;
7
+ exports.readCodexHolmesEntry = readCodexHolmesEntry;
8
+ /** The one table holmes-kit owns in a Codex config.toml. */
9
+ exports.CODEX_TABLE = 'mcp_servers.holmes-kit';
10
+ const HEADER = `[${exports.CODEX_TABLE}]`;
11
+ /** TOML basic-string escape — backslash and quote, the named whitespace escapes, then any remaining
12
+ * C0 control character as \uXXXX. Built by code point so no literal control byte lives in the source. */
13
+ function tomlStr(s) {
14
+ let out = '';
15
+ for (const ch of s) {
16
+ const code = ch.codePointAt(0);
17
+ if (ch === '\\')
18
+ out += '\\\\';
19
+ else if (ch === '"')
20
+ out += '\\"';
21
+ else if (ch === '\n')
22
+ out += '\\n';
23
+ else if (ch === '\r')
24
+ out += '\\r';
25
+ else if (ch === '\t')
26
+ out += '\\t';
27
+ else if (code < 0x20 || code === 0x7f)
28
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
29
+ else
30
+ out += ch;
31
+ }
32
+ return `"${out}"`;
33
+ }
34
+ /**
35
+ * Serialize the `[mcp_servers.holmes-kit]` table. `env` is an inline table so the whole entry is ONE
36
+ * contiguous region (no `[mcp_servers.holmes-kit.env]` child header) — that keeps the merge boundary
37
+ * unambiguous: our region runs from the header to the next table header. Ends with a trailing newline.
38
+ */
39
+ function codexMcpBlock(entry, specsDir) {
40
+ const args = entry.args.map(tomlStr).join(', ');
41
+ return [
42
+ HEADER,
43
+ `command = ${tomlStr(entry.command)}`,
44
+ `args = [${args}]`,
45
+ `env = { HOLMES_SPECS = ${tomlStr(specsDir)} }`,
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ /** Advance the string/bracket state across one line's characters (comments end the line). */
50
+ function advance(line, st) {
51
+ let { ml, depth } = st;
52
+ let i = 0;
53
+ while (i < line.length) {
54
+ if (ml === '"""') {
55
+ if (line.startsWith('"""', i)) {
56
+ ml = null;
57
+ i += 3;
58
+ }
59
+ else if (line[i] === '\\') {
60
+ i += 2;
61
+ }
62
+ else
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (ml === "'''") { // literal: no escapes
67
+ if (line.startsWith("'''", i)) {
68
+ ml = null;
69
+ i += 3;
70
+ }
71
+ else
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (line.startsWith('"""', i)) {
76
+ ml = '"""';
77
+ i += 3;
78
+ continue;
79
+ }
80
+ if (line.startsWith("'''", i)) {
81
+ ml = "'''";
82
+ i += 3;
83
+ continue;
84
+ }
85
+ const c = line[i];
86
+ if (c === '#')
87
+ break; // comment runs to end of line
88
+ if (c === '"') {
89
+ i += 1;
90
+ while (i < line.length && line[i] !== '"') {
91
+ if (line[i] === '\\')
92
+ i += 1;
93
+ i += 1;
94
+ }
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (c === "'") {
99
+ i += 1;
100
+ while (i < line.length && line[i] !== "'")
101
+ i += 1;
102
+ i += 1;
103
+ continue;
104
+ }
105
+ if (c === '[' || c === '{') {
106
+ depth += 1;
107
+ i += 1;
108
+ continue;
109
+ }
110
+ if (c === ']' || c === '}') {
111
+ depth = Math.max(0, depth - 1);
112
+ i += 1;
113
+ continue;
114
+ }
115
+ i += 1;
116
+ }
117
+ return { ml, depth };
118
+ }
119
+ /** State at the START of each line (index-aligned to `lines`). */
120
+ function lineStartStates(lines) {
121
+ const states = [];
122
+ let st = { ml: null, depth: 0 };
123
+ for (const line of lines) {
124
+ states.push(st);
125
+ st = advance(line, st);
126
+ }
127
+ return states;
128
+ }
129
+ /**
130
+ * The dotted key path of a table-header line, quotes stripped and each segment trimmed — so
131
+ * `[mcp_servers.holmes-kit]`, `[mcp_servers."holmes-kit"]`, and `[ mcp_servers . holmes-kit ]` all
132
+ * yield ['mcp_servers','holmes-kit'] (TOML treats them as the SAME table; missing an alias would
133
+ * append a duplicate table and invalidate the whole file — round-1 quoted-key, round-2 whitespace).
134
+ * Returns null when the line is not a table header.
135
+ */
136
+ function tableKeyPath(line) {
137
+ const m = line.match(/^\s*\[\[?([^\]]*)\]\]?\s*(#.*)?$/);
138
+ if (!m)
139
+ return null;
140
+ const inner = m[1];
141
+ const segs = [];
142
+ let cur = '';
143
+ let q = null;
144
+ for (let i = 0; i < inner.length; i++) {
145
+ const c = inner[i];
146
+ if (q) {
147
+ if (c === q)
148
+ q = null;
149
+ else
150
+ cur += c;
151
+ continue;
152
+ }
153
+ if (c === '"' || c === "'") {
154
+ q = c;
155
+ continue;
156
+ }
157
+ if (c === '.') {
158
+ segs.push(cur.trim());
159
+ cur = '';
160
+ continue;
161
+ }
162
+ cur += c;
163
+ }
164
+ segs.push(cur.trim());
165
+ if (q !== null)
166
+ return null; // unbalanced quote — not a clean header
167
+ return segs;
168
+ }
169
+ const isOurTable = (kp) => kp.length === 2 && kp[0] === 'mcp_servers' && kp[1] === 'holmes-kit';
170
+ const isOurPrefix = (kp) => kp.length >= 2 && kp[0] === 'mcp_servers' && kp[1] === 'holmes-kit';
171
+ /**
172
+ * Locate our region as a [start, end) line-index pair, or null if absent. `start` is our table's
173
+ * header line; `end` is the first later line that opens a DIFFERENT top-level table (exclusive), or
174
+ * lines.length. Only real headers (outside strings, bracket-depth 0) are considered — AND the end
175
+ * boundary closes on ANY top-level header line, even one `tableKeyPath` cannot normalize (e.g. a
176
+ * quoted key containing `]`). That fail-safe is the point: an unrecognized header is never OURS, so
177
+ * treating it as a boundary preserves the foreign table rather than swallowing it (round-3 finding).
178
+ */
179
+ function ourRegion(lines) {
180
+ const states = lineStartStates(lines);
181
+ const isTopLevelHeaderLine = (i) => states[i].ml === null && states[i].depth === 0 && /^\s*\[/.test(lines[i]);
182
+ const keyAt = (i) => (isTopLevelHeaderLine(i) ? tableKeyPath(lines[i]) : null);
183
+ let start = -1;
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const kp = keyAt(i);
186
+ if (kp && isOurTable(kp)) {
187
+ start = i;
188
+ break;
189
+ }
190
+ }
191
+ if (start === -1)
192
+ return null;
193
+ let end = lines.length;
194
+ for (let i = start + 1; i < lines.length; i++) {
195
+ if (!isTopLevelHeaderLine(i))
196
+ continue;
197
+ const kp = tableKeyPath(lines[i]);
198
+ if (!(kp && isOurPrefix(kp))) {
199
+ end = i;
200
+ break;
201
+ } // any non-our (incl. unparseable) header closes us
202
+ }
203
+ return { start, end };
204
+ }
205
+ /**
206
+ * Merge `block` (a full `codexMcpBlock` output) into `existing`. When `existing` is null/empty the
207
+ * block stands alone. When our table is already present its region is REPLACED (no duplicate); when
208
+ * absent the block is appended after a blank-line separator. Every other line is preserved verbatim.
209
+ */
210
+ function mergeCodexToml(existing, block) {
211
+ if (existing == null || existing.trim() === '')
212
+ return block;
213
+ const lines = existing.split('\n');
214
+ const region = ourRegion(lines);
215
+ const blockLines = block.replace(/\n$/, '').split('\n');
216
+ if (region) {
217
+ const after = lines.slice(region.end);
218
+ const merged = [...lines.slice(0, region.start), ...blockLines, ...after];
219
+ return merged.join('\n').replace(/\n*$/, '\n');
220
+ }
221
+ // Append: exactly one blank line between the user's content and our block.
222
+ const base = existing.replace(/\n*$/, '');
223
+ return `${base}\n\n${block.replace(/\n*$/, '')}\n`;
224
+ }
225
+ /** Strip our region from `existing`, preserving everything else. Absent → returned unchanged. */
226
+ function removeCodexToml(existing) {
227
+ const lines = existing.split('\n');
228
+ const region = ourRegion(lines);
229
+ if (!region)
230
+ return existing;
231
+ const before = lines.slice(0, region.start);
232
+ const after = lines.slice(region.end);
233
+ // Drop a trailing blank line left dangling between `before` and `after` so removal is clean.
234
+ while (before.length > 0 && before[before.length - 1].trim() === '')
235
+ before.pop();
236
+ const merged = [...before, ...after];
237
+ const joined = merged.join('\n');
238
+ if (joined.trim() === '')
239
+ return '';
240
+ return joined.replace(/\n*$/, '\n');
241
+ }
242
+ /**
243
+ * Read `{command,args}` from our table in a config.toml — for doctor's drift check. Parses only the
244
+ * shape `codexMcpBlock` writes; anything it cannot read returns null (doctor then WARNs rather than
245
+ * translating an unreadable wiring into a pass).
246
+ */
247
+ function readCodexHolmesEntry(raw) {
248
+ const lines = raw.split('\n');
249
+ const region = ourRegion(lines);
250
+ if (!region)
251
+ return null;
252
+ let command = null;
253
+ let args = null;
254
+ for (let i = region.start + 1; i < region.end; i++) {
255
+ const cmd = lines[i].match(/^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"\s*(#.*)?$/);
256
+ if (cmd)
257
+ command = unescapeToml(cmd[1]);
258
+ const arr = lines[i].match(/^\s*args\s*=\s*\[(.*)\]\s*(#.*)?$/);
259
+ if (arr)
260
+ args = parseTomlStringArray(arr[1]);
261
+ }
262
+ if (command === null || args === null)
263
+ return null;
264
+ return { command, args };
265
+ }
266
+ function unescapeToml(s) {
267
+ return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, e) => {
268
+ if (e[0] === 'u')
269
+ return String.fromCharCode(parseInt(e.slice(1), 16));
270
+ const map = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' };
271
+ return map[e] ?? e;
272
+ });
273
+ }
274
+ /** Parse a TOML inline array of basic strings: `"a", "b"` → ['a','b']. Non-conforming → []. */
275
+ function parseTomlStringArray(inner) {
276
+ const out = [];
277
+ const re = /"((?:[^"\\]|\\.)*)"/g;
278
+ let mm;
279
+ while ((mm = re.exec(inner)) !== null)
280
+ out.push(unescapeToml(mm[1]));
281
+ return out;
282
+ }