@holmes-lab/holmes-kit 0.1.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 (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. package/playbooks/promote-slice/PLAYBOOK.md +134 -0
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.rolesReadmePath = exports.ROLES_README_MARKER = void 0;
37
+ exports.installRolesReadme = installRolesReadme;
38
+ exports.removeRolesReadme = removeRolesReadme;
39
+ // @implements A-SPEC-153
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ /** Identifies the file as ours, so `--remove` never reclaims something a user wrote. */
43
+ exports.ROLES_README_MARKER = '<!-- holmes-kit:roles-guide -->';
44
+ const rolesReadmePath = (root) => path.join(root, '.ax', 'roles', 'README.md');
45
+ exports.rolesReadmePath = rolesReadmePath;
46
+ /**
47
+ * The guidance MUST be markdown, not a `.yaml` template.
48
+ *
49
+ * `loadRolePolicy` reads only `.yaml`/`.yml`, so a directory holding just this file yields no policy
50
+ * and the gate's decision does not move by one bit. Shipping an active `policy.yaml` instead would
51
+ * put every newly initialised project behind a fail-closed role gate on its first run — the same
52
+ * bricking REQ-146 measured when it tried to make a spec field required, and REQ-152 avoided by
53
+ * making roles opt-in. A `.yaml.example` name would also work today, but only because of how the
54
+ * loader currently filters; markdown cannot be mistaken for a policy under any future rule.
55
+ */
56
+ const CONTENT = `${exports.ROLES_README_MARKER}
57
+ # 역할 정책 (선택 기능 — 지금은 꺼져 있습니다)
58
+
59
+ 이 디렉터리에 \`.yaml\` 파일이 **하나라도** 생기면 역할 게이트가 켜집니다. 이 README만 있는 동안에는
60
+ 아무것도 바뀌지 않습니다.
61
+
62
+ ## 켜는 법
63
+
64
+ \`.ax/roles/policy.yaml\`을 만듭니다:
65
+
66
+ \`\`\`yaml
67
+ roles:
68
+ pm: { allow: [WRITE_SPEC] }
69
+ architect: { allow: [WRITE_SPEC] }
70
+ developer: { allow: [WRITE_CODE, WRITE_TEST] }
71
+ tester: { allow: [WRITE_TEST] }
72
+ reviewer: { allow: [] }
73
+ maintainer: { allow: [WRITE_CODE, WRITE_TEST, WRITE_SPEC] }
74
+
75
+ # 역할을 지정하지 않고 실행했을 때 어느 역할로 취급할지.
76
+ # 생략하면 역할 미지정은 거부됩니다(fail-closed).
77
+ default: maintainer
78
+ \`\`\`
79
+
80
+ 역할 이름은 프로젝트가 정합니다 — 위는 예시일 뿐입니다.
81
+ \`allow\`에 쓰는 동작명은 게이트가 실제로 판정하는 이름입니다: \`WRITE_CODE\`, \`WRITE_TEST\`,
82
+ \`WRITE_SPEC\`, \`APPROVE\`.
83
+
84
+ ## 역할을 지정하는 법
85
+
86
+ 에이전트를 기동할 때 **환경변수**로 줍니다:
87
+
88
+ \`\`\`bash
89
+ HOLMES_ROLE=reviewer claude
90
+ \`\`\`
91
+
92
+ 세션은 자기 환경을 바꿀 수 없으므로 **에이전트가 스스로 역할을 주장할 수 없습니다.** 승인
93
+ (\`HOLMES_APPROVAL\`)과 같은 대역외 원칙입니다.
94
+
95
+ ## \`default\`를 왜 코드가 정하지 않는가
96
+
97
+ "역할 미지정이면 만능"을 코드에 넣으면 \`HOLMES_ROLE\`을 비우는 것만으로 모든 제한이 우회됩니다.
98
+ Reviewer에게 코드 작성을 막는 규율이 "설정 안 하면 그만"이 되죠. 그래서 기본값은 **프로젝트가 이 파일에
99
+ 적을 때만** 존재하고, 이 디렉터리는 보호 경로라 에이전트가 고칠 수 없습니다.
100
+
101
+ ## 주의
102
+
103
+ - 이 디렉터리는 **git에 커밋**됩니다 — 거버넌스 설정은 팀이 공유해야 합니다.
104
+ - \`default\`가 정의되지 않은 역할을 가리키면 **거부**됩니다. 오타가 조용히 통과하지 않습니다.
105
+ - 현재 역할은 게이트 권한만 정합니다. 역할별 LLM 모델 배정·에이전트 기동은 아직 없습니다.
106
+ `;
107
+ /** Installs the guidance. A file the user has edited is left alone. */
108
+ function installRolesReadme(root) {
109
+ const file = (0, exports.rolesReadmePath)(root);
110
+ if (fs.existsSync(file))
111
+ return;
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ fs.writeFileSync(file, CONTENT);
114
+ }
115
+ /**
116
+ * Reclaims the guidance on `init --remove`. Returns whether it was ours to remove.
117
+ *
118
+ * Only this file is touched — a project's real `policy.yaml` living beside it must survive, since
119
+ * removing the docs is not a request to disable governance.
120
+ */
121
+ function removeRolesReadme(root) {
122
+ const file = (0, exports.rolesReadmePath)(root);
123
+ let text;
124
+ try {
125
+ text = fs.readFileSync(file, 'utf8');
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ if (!text.includes(exports.ROLES_README_MARKER))
131
+ return false;
132
+ fs.rmSync(file);
133
+ return true;
134
+ }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ // @implements A-SPEC-100.2
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.HOLMES_OWNED_MCP_ENV = void 0;
5
+ exports.isHolmesCommand = isHolmesCommand;
6
+ exports.hookScriptPath = hookScriptPath;
7
+ exports.mergeHooks = mergeHooks;
8
+ exports.removeHooks = removeHooks;
9
+ exports.enableMcpServer = enableMcpServer;
10
+ exports.disableMcpServer = disableMcpServer;
11
+ exports.mergeMcpServers = mergeMcpServers;
12
+ exports.removeMcpServer = removeMcpServer;
13
+ /** A hook entry belongs to holmes-kit iff its command references our hook scripts. */
14
+ function isHolmesCommand(command) {
15
+ return /dist[/\\]holmes[/\\]hooks[/\\](pre-tool-use|stop)\.js/.test(command) || /\bholmes-(kit|mcp|pre-tool-use|stop)\b/.test(command);
16
+ }
17
+ /**
18
+ * The script path out of a hook command line, e.g. `node "/pkg/dist/.../stop.js"` -> `/pkg/dist/.../stop.js`.
19
+ * `init` quotes the path (installs live under paths that may contain spaces), so an unquoting parse is
20
+ * mandatory, not defensive: a naive `\S+` capture keeps the quotes and every existence check then fails.
21
+ * Returns '' when no argument is present — callers treat that as unresolvable.
22
+ */
23
+ function hookScriptPath(command) {
24
+ const m = /(?:^|\s)(?:node(?:\.exe)?\s+)?(?:"([^"]+)"|'([^']+)'|([^\s"']*[/\\][^\s"']*))/.exec(command);
25
+ return m?.[1] ?? m?.[2] ?? m?.[3] ?? '';
26
+ }
27
+ const hasHolmes = (g) => (g.hooks ?? []).some((h) => isHolmesCommand(h.command));
28
+ /**
29
+ * Add or replace holmes-kit's PreToolUse + Stop entries. Every other hook group, every other event,
30
+ * and all unrelated top-level keys (permissions, …) are preserved byte-for-byte in value.
31
+ */
32
+ function mergeHooks(existing, plan) {
33
+ const out = { ...(existing ?? {}) };
34
+ const hooks = { ...(out.hooks ?? {}) };
35
+ const upsert = (event, group) => {
36
+ const groups = (hooks[event] ?? []).map((g) => ({ ...g, hooks: [...(g.hooks ?? [])] }));
37
+ const idx = groups.findIndex(hasHolmes);
38
+ if (idx >= 0)
39
+ groups[idx] = group; // in-place replace: corrects an upgraded path
40
+ else
41
+ groups.push(group);
42
+ hooks[event] = groups;
43
+ };
44
+ upsert('PreToolUse', { matcher: plan.preToolUseMatcher, hooks: [{ type: 'command', command: plan.preToolUseCommand }] });
45
+ upsert('Stop', { hooks: [{ type: 'command', command: plan.stopCommand }] }); // Stop takes NO matcher
46
+ out.hooks = hooks;
47
+ return out;
48
+ }
49
+ /** Remove every holmes-owned hook entry, dropping groups that become empty. Inverse of mergeHooks. */
50
+ function removeHooks(existing) {
51
+ const out = { ...(existing ?? {}) };
52
+ if (!out.hooks)
53
+ return out;
54
+ const hooks = {};
55
+ for (const [event, groups] of Object.entries(out.hooks)) {
56
+ const kept = (groups ?? [])
57
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isHolmesCommand(h.command)) }))
58
+ .filter((g) => g.hooks.length > 0);
59
+ if (kept.length > 0)
60
+ hooks[event] = kept;
61
+ }
62
+ if (Object.keys(hooks).length > 0)
63
+ out.hooks = hooks;
64
+ else
65
+ delete out.hooks;
66
+ return out;
67
+ }
68
+ /** Union — never replace a user's other enabled servers. */
69
+ function enableMcpServer(existing, name) {
70
+ const out = { ...(existing ?? {}) };
71
+ out.enabledMcpjsonServers = [...new Set([...(out.enabledMcpjsonServers ?? []), name])];
72
+ return out;
73
+ }
74
+ function disableMcpServer(existing, name) {
75
+ const out = { ...(existing ?? {}) };
76
+ const rest = (out.enabledMcpjsonServers ?? []).filter((n) => n !== name);
77
+ if (rest.length > 0)
78
+ out.enabledMcpjsonServers = rest;
79
+ else
80
+ delete out.enabledMcpjsonServers;
81
+ return out;
82
+ }
83
+ /**
84
+ * Merge only `mcpServers['holmes-kit']`. `args` MUST be absolute: the MCP server is spawned with the
85
+ * TARGET project's cwd, so a repo-relative path (as this project's own .mcp.json uses) would not resolve.
86
+ */
87
+ /**
88
+ * @implements A-SPEC-179
89
+ * The env keys holmes itself decides in the server entry. Everything else there belongs to whoever
90
+ * put it there.
91
+ */
92
+ exports.HOLMES_OWNED_MCP_ENV = ['HOLMES_SPECS'];
93
+ function mergeMcpServers(existing, name, mcpBinPath, specsDir) {
94
+ const out = { ...(existing ?? {}) };
95
+ // @implements A-SPEC-179
96
+ // Merge, not replace. Measured 2026-08-13 while previewing `--force` on holmes-kit's own
97
+ // workspace: `HOLMES_MCP_AUTORELOAD: "1"` — the REQ-162 supervisor opt-in — would have been
98
+ // dropped without a word. `--force` means "refresh the wiring", not "turn off what I switched on".
99
+ // The owned keys go on TOP so `--specs-dir` still wins; unknown keys survive underneath, because a
100
+ // whitelist of "keys worth keeping" assumes we know everything an operator may set.
101
+ // `mcpServers` values are deliberately `unknown` — this file does not own their shape. Read just
102
+ // the one field, defensively: a non-object entry must not throw during a re-wire.
103
+ const prevEntry = existing?.mcpServers?.[name];
104
+ const prev = (prevEntry && typeof prevEntry === 'object' ? prevEntry.env : undefined) ?? {};
105
+ out.mcpServers = {
106
+ ...(out.mcpServers ?? {}),
107
+ [name]: { command: 'node', args: [mcpBinPath], env: { ...prev, HOLMES_SPECS: specsDir } },
108
+ };
109
+ return out;
110
+ }
111
+ function removeMcpServer(existing, name) {
112
+ const out = { ...(existing ?? {}) };
113
+ if (!out.mcpServers)
114
+ return out;
115
+ const servers = { ...out.mcpServers };
116
+ delete servers[name];
117
+ if (Object.keys(servers).length > 0)
118
+ out.mcpServers = servers;
119
+ else
120
+ delete out.mcpServers;
121
+ return out;
122
+ }
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_CONFIG = void 0;
37
+ exports.loadConfig = loadConfig;
38
+ // @implements A-SPEC-125.5
39
+ // src/holmes/config/config.ts
40
+ const fs = __importStar(require("node:fs"));
41
+ const path = __importStar(require("node:path"));
42
+ exports.DEFAULT_CONFIG = {
43
+ guardrail: { enforcement: 'block', enforceHighRisk: true, overrideRequiresAdr: true },
44
+ highRiskDomains: ['@auth', '@payment'],
45
+ storage: { specStore: 'local-markdown' },
46
+ };
47
+ function deepMerge(base, ov) {
48
+ if (ov == null || typeof ov !== 'object')
49
+ return base;
50
+ const out = { ...base };
51
+ for (const [k, v] of Object.entries(ov)) {
52
+ const b = base[k];
53
+ out[k] = b && typeof b === 'object' && !Array.isArray(b) && v && typeof v === 'object' ? deepMerge(b, v) : v;
54
+ }
55
+ return out;
56
+ }
57
+ function loadConfig(root) {
58
+ const f = path.join(root, 'ax.config.json');
59
+ if (!fs.existsSync(f))
60
+ return structuredClone(exports.DEFAULT_CONFIG);
61
+ // FAIL-SAFE: a malformed/unreadable config must never crash the loader (it gates the guardrail).
62
+ // Fall back to the strict DEFAULT_CONFIG (enforcement:'block') so a broken file cannot silently
63
+ // weaken or disable governance — a bad config degrades to the safest posture, not to an exception.
64
+ try {
65
+ return structuredClone(deepMerge(exports.DEFAULT_CONFIG, JSON.parse(fs.readFileSync(f, 'utf8'))));
66
+ }
67
+ catch {
68
+ return structuredClone(exports.DEFAULT_CONFIG);
69
+ }
70
+ }
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContextBundler = void 0;
4
+ const tiers_1 = require("./tiers");
5
+ const render_1 = require("./render");
6
+ const tokens_1 = require("./tokens");
7
+ const DEFAULT_MAX_DISTANCE = 6;
8
+ /**
9
+ * Largest share of the budget any ONE entry may occupy before it is truncated.
10
+ *
11
+ * Half leaves room for at least one more entry in every case while still letting the seed — usually
12
+ * the thing the caller most wants in full — dominate. The seed remains mandatory, so a bundle can
13
+ * still exceed its budget when the seed alone is bigger than the cap; that overflow is documented
14
+ * below and is the only case where it happens.
15
+ */
16
+ const MAX_ENTRY_BUDGET_SHARE = 0.5;
17
+ /**
18
+ * Appended to any entry the cap shortened, so a cut document never reads as a complete one.
19
+ *
20
+ * HONEST LIMIT: the marker costs ~10 tokens, so a budget smaller than that cannot be honoured — a
21
+ * bundle asked for 5 tokens comes back with 10. The alternative is dropping the marker and returning
22
+ * silently-truncated content, which is worse: a reader acting on half a spec while believing they
23
+ * have all of it is in a worse position than one who knows the budget was unusable. Budgets that
24
+ * small are degenerate anyway; every realistic size is honoured exactly.
25
+ */
26
+ const TRUNCATION_MARKER = '\n… [truncated to fit the context budget]';
27
+ /** Priority order for tiers: hot fills first, then domain, then cold. */
28
+ const TIER_PRIORITY = { hot: 0, domain: 1, cold: 2 };
29
+ /**
30
+ * Assembles a tiered, budget-filled context bundle around a seed node.
31
+ *
32
+ * Pure/deterministic given the injected content source: no Date, randomness,
33
+ * or LLM calls. Same (seedId, tokenBudget) always yields a deep-equal bundle.
34
+ */
35
+ class ContextBundler {
36
+ graph;
37
+ content;
38
+ maxDistance;
39
+ constructor(graph, content, opts) {
40
+ this.graph = graph;
41
+ this.content = content;
42
+ this.maxDistance = opts?.maxDistance ?? DEFAULT_MAX_DISTANCE;
43
+ }
44
+ getContextBundle(seedId, tokenBudget) {
45
+ const dist = (0, tiers_1.bfsDistances)(this.graph, seedId, this.maxDistance);
46
+ const candidates = [];
47
+ for (const [nodeId, distance] of dist) {
48
+ const tier = (0, tiers_1.tierForDistance)(distance);
49
+ const detail = (0, render_1.renderDetail)(nodeId, this.content(nodeId), tier);
50
+ const tokens = (0, tokens_1.estimateTokens)(detail);
51
+ candidates.push({ nodeId, tier, distance, detail, tokens });
52
+ }
53
+ // Deterministic total order: tier priority, then distance, then nodeId.
54
+ candidates.sort((a, b) => {
55
+ const tierDiff = TIER_PRIORITY[a.tier] - TIER_PRIORITY[b.tier];
56
+ if (tierDiff !== 0)
57
+ return tierDiff;
58
+ const distDiff = a.distance - b.distance;
59
+ if (distDiff !== 0)
60
+ return distDiff;
61
+ return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0;
62
+ });
63
+ // No single entry may take more than this share of the budget.
64
+ //
65
+ // The budget stops OVERFLOW, not CROWDING OUT. Measured on this repository: seeding a bundle on
66
+ // a long H-SPEC at budget 2000 produced ONE entry holding 100% of the bundle — a "context
67
+ // bundle" containing nothing but the seed the caller already had, with three other candidates
68
+ // dropped. Capping the share means a long document is truncated rather than allowed to consume
69
+ // the room every other entry needed.
70
+ //
71
+ // Truncation is marked in the text: a silently shortened document reads as a complete one, and a
72
+ // reader acting on a spec's first half while believing they have all of it is worse off than one
73
+ // who knows to go read the rest.
74
+ const perEntryCap = Math.max(1, Math.floor(tokenBudget * MAX_ENTRY_BUDGET_SHARE));
75
+ const capped = candidates.map((c) => {
76
+ if (c.tokens <= perEntryCap)
77
+ return c;
78
+ // estimateTokens is proportional to length, so scaling the text by the token ratio lands close
79
+ // to the cap. The MARKER's own tokens are subtracted first: without that, truncating at a tiny
80
+ // budget still overshot — a probe at budget 1 produced 11 tokens, because the marker alone
81
+ // costs more than the cap. The recomputed count is what is actually recorded.
82
+ const markerTokens = (0, tokens_1.estimateTokens)(TRUNCATION_MARKER);
83
+ const room = Math.max(0, perEntryCap - markerTokens);
84
+ const keep = Math.max(0, Math.floor(c.detail.length * (room / c.tokens)));
85
+ const detail = `${c.detail.slice(0, keep)}${TRUNCATION_MARKER}`;
86
+ return { ...c, detail, tokens: (0, tokens_1.estimateTokens)(detail) };
87
+ });
88
+ const entries = [];
89
+ const dropped = [];
90
+ let usedTokens = 0;
91
+ for (const candidate of capped) {
92
+ // The seed (distance 0) is mandatory: it is always included even if its
93
+ // tokens alone exceed the remaining/total budget, since a bundle
94
+ // without its own seed node is meaningless. This is the only case
95
+ // where usedTokens may exceed tokenBudget.
96
+ const isSeed = candidate.nodeId === seedId;
97
+ if (isSeed || usedTokens + candidate.tokens <= tokenBudget) {
98
+ entries.push(candidate);
99
+ usedTokens += candidate.tokens;
100
+ }
101
+ else {
102
+ dropped.push(candidate.nodeId);
103
+ }
104
+ }
105
+ return {
106
+ seedId,
107
+ budget: tokenBudget,
108
+ usedTokens,
109
+ entries,
110
+ dropped: dropped.sort(),
111
+ };
112
+ }
113
+ }
114
+ exports.ContextBundler = ContextBundler;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderDetail = renderDetail;
4
+ const DOMAIN_PREVIEW_CHARS = 200;
5
+ const COLD_LINE_CHARS = 80;
6
+ /** First non-empty line of content, or '' if none. */
7
+ function firstNonEmptyLine(content) {
8
+ return content.split('\n').find((l) => l.trim().length > 0) ?? '';
9
+ }
10
+ /**
11
+ * Renders a node's content at the detail level appropriate for its context
12
+ * tier. Deterministic: fixed char limits, no ellipsis, no randomness.
13
+ * - hot: full content (nodeId alone if content is null).
14
+ * - domain: nodeId + up to 200-char preview of content.
15
+ * - cold: nodeId + first non-empty line, truncated to 80 chars.
16
+ */
17
+ // @implements A-SPEC-111.1
18
+ function renderDetail(nodeId, content, tier) {
19
+ if (content === null)
20
+ return nodeId;
21
+ switch (tier) {
22
+ case 'hot':
23
+ return content;
24
+ case 'domain':
25
+ return `${nodeId}\n${content.slice(0, DOMAIN_PREVIEW_CHARS)}`;
26
+ case 'cold':
27
+ return `${nodeId}\n${firstNonEmptyLine(content).slice(0, COLD_LINE_CHARS)}`;
28
+ }
29
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HUB_DEGREE = exports.REL_COST = void 0;
4
+ exports.tierForDistance = tierForDistance;
5
+ exports.bfsDistances = bfsDistances;
6
+ /**
7
+ * Distance→tier classifier. 0|1 -> hot, 2|3 -> domain, else cold.
8
+ *
9
+ * NOTE: since edge weighting landed, the input is a traversal COST, not a hop count. A spec-chain
10
+ * hop costs 1 and a `calls` hop costs 2, so a directly-called symbol arrives at 2 and lands in
11
+ * `domain` rather than `hot` — deliberately: what a file implements is the hot context, what calls it
12
+ * is surrounding context. Reading this parameter as "number of hops" would misjudge every call edge.
13
+ */
14
+ function tierForDistance(distance) {
15
+ if (distance <= 1)
16
+ return 'hot';
17
+ if (distance <= 3)
18
+ return 'domain';
19
+ return 'cold';
20
+ }
21
+ /**
22
+ * Deterministic BFS from seedId over graph.neighbors(), recording the shortest
23
+ * hop distance to each reachable node (seed distance is 0). Nodes beyond
24
+ * maxDistance are not enqueued (and thus not included in the result). Ties
25
+ * are broken by visiting neighbors in sorted order (neighbors() already
26
+ * returns sorted, deduped ids), so results are deterministic.
27
+ */
28
+ /**
29
+ * What one hop of each relation costs.
30
+ *
31
+ * `implements` and `depends_on` are the traceability chain the bundle exists to carry, so they cost
32
+ * one. A `calls` hop costs more, because call edges make the graph far denser and a distance
33
+ * calibrated for the spec chain then reaches nearly everything: measured on this repository, adding
34
+ * call edges took a budget-8000 bundle from 164 entries to 252 — 91% of the whole graph — while the
35
+ * distance bound of 6 stayed untouched. A token budget alone does not fix that; it just fills the
36
+ * budget with distant nodes instead of overflowing.
37
+ *
38
+ * This is REQ-127's edge-type weighting: bounding propagation by KIND, not only by depth and hubs.
39
+ */
40
+ exports.REL_COST = { implements: 1, depends_on: 1, calls: 2, imports: 2 };
41
+ const DEFAULT_REL_COST = 2;
42
+ /**
43
+ * Deterministic weighted shortest-path from seedId, recording the cheapest cost to each reachable
44
+ * node (seed cost 0). Nodes beyond maxDistance are never enqueued.
45
+ *
46
+ * Costs are small integers, so this uses a bucket queue rather than a comparison heap — O(V+E+maxD)
47
+ * and no ordering ambiguity. Ties are broken by visiting neighbours in sorted order, which
48
+ * `neighborsWithRel` already guarantees, so results stay byte-identical across runs.
49
+ */
50
+ /**
51
+ * Degree above which a node is a HUB and the walk stops expanding past it.
52
+ *
53
+ * Measured after call edges landed: from every seed tried, distance 6 reached 100% of a 1,159-node
54
+ * graph, so the distance bound contributed nothing and only the token budget was holding the bundle
55
+ * down. The connector is not `calls` — it is SPEC nodes acting as `implements` hubs, since every file
56
+ * anchoring to a spec is two cheap hops from every other file anchoring to it. Weighting call edges
57
+ * alone therefore could not fix it.
58
+ *
59
+ * A hub is still INCLUDED (it is genuinely adjacent); the walk simply does not travel THROUGH it,
60
+ * which is the same rule impact propagation uses and the reason a bound can stay meaningful on a
61
+ * small-world graph.
62
+ */
63
+ exports.HUB_DEGREE = 24;
64
+ function bfsDistances(graph, seedId, maxDistance, hubDegree = exports.HUB_DEGREE) {
65
+ const dist = new Map();
66
+ dist.set(seedId, 0);
67
+ // buckets[d] holds nodes whose current best cost is d; processed in ascending d.
68
+ const buckets = Array.from({ length: maxDistance + 1 }, () => []);
69
+ buckets[0].push(seedId);
70
+ for (let d = 0; d <= maxDistance; d++) {
71
+ for (let i = 0; i < buckets[d].length; i++) {
72
+ const current = buckets[d][i];
73
+ if (dist.get(current) !== d)
74
+ continue; // stale entry, already improved
75
+ if (d >= maxDistance)
76
+ continue;
77
+ const around = graph.neighborsWithRel(current);
78
+ // Damping applies to the FAN-OUT that makes a node a hub, never to the spec chain.
79
+ //
80
+ // Blanket damping was tried and broke the one thing the bundle exists to carry: an A-SPEC
81
+ // anchored by forty files is a hub, so the walk stopped AT it and never reached the H-SPEC and
82
+ // REQ above — traceability severed at the first popular spec. What makes such a node a hub is
83
+ // its `implements` fan-in (many files -> one spec); the chain climbs by `depends_on`, which is
84
+ // one-to-few by construction and never the cause of the blow-up.
85
+ //
86
+ // So a hub still stops LATERAL travel — sibling files anchored to the same spec — while the
87
+ // chain keeps climbing. The climb is OUTGOING `depends_on` only: a shared parent (one REQ with
88
+ // many slices hanging under it) is itself a bidirectional hub, and following it downward walks
89
+ // from any slice into every other one. Allowing both directions here put reach back at 38-45%
90
+ // of the graph; restricting to parents is what keeps the chain intact AND the bundle bounded.
91
+ // The seed is always expanded either way — refusing that would answer "nothing is nearby" for
92
+ // exactly the widely-used symbols people most often change.
93
+ const isHub = d > 0 && around.length > hubDegree;
94
+ const reachable = isHub
95
+ ? graph.parentsOf(current).map((id) => ({ id, rel: 'depends_on' }))
96
+ : around;
97
+ for (const { id: next, rel } of reachable) {
98
+ const cost = d + (exports.REL_COST[rel] ?? DEFAULT_REL_COST);
99
+ if (cost > maxDistance)
100
+ continue;
101
+ const known = dist.get(next);
102
+ if (known === undefined || cost < known) {
103
+ dist.set(next, cost);
104
+ buckets[cost].push(next);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return dist;
110
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.estimateTokens = estimateTokens;
4
+ /** Approximate token count (~4 chars/token). Deterministic heuristic, not a real tokenizer. */
5
+ // @implements A-SPEC-111.1
6
+ function estimateTokens(text) {
7
+ return text.length === 0 ? 0 : Math.ceil(text.length / 4);
8
+ }