loopgraph 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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/adapters/agenthub/extract.d.ts +63 -0
  4. package/dist/adapters/agenthub/extract.js +144 -0
  5. package/dist/adapters/agenthub/facts.d.ts +27 -0
  6. package/dist/adapters/agenthub/facts.js +89 -0
  7. package/dist/adapters/agenthub/index.d.ts +13 -0
  8. package/dist/adapters/agenthub/index.js +18 -0
  9. package/dist/adapters/registry.d.ts +4 -0
  10. package/dist/adapters/registry.js +17 -0
  11. package/dist/adapters/types.d.ts +40 -0
  12. package/dist/adapters/types.js +2 -0
  13. package/dist/cache/content-cache.d.ts +46 -0
  14. package/dist/cache/content-cache.js +123 -0
  15. package/dist/cli/args.d.ts +7 -0
  16. package/dist/cli/args.js +26 -0
  17. package/dist/cli/assets/agent-kit.d.ts +15 -0
  18. package/dist/cli/assets/agent-kit.js +167 -0
  19. package/dist/cli/commands/check.d.ts +38 -0
  20. package/dist/cli/commands/check.js +70 -0
  21. package/dist/cli/commands/import.d.ts +6 -0
  22. package/dist/cli/commands/import.js +34 -0
  23. package/dist/cli/commands/init.d.ts +13 -0
  24. package/dist/cli/commands/init.js +70 -0
  25. package/dist/cli/commands/inspect.d.ts +23 -0
  26. package/dist/cli/commands/inspect.js +35 -0
  27. package/dist/cli/commands/snapshot.d.ts +121 -0
  28. package/dist/cli/commands/snapshot.js +0 -0
  29. package/dist/cli/commands/view.d.ts +34 -0
  30. package/dist/cli/commands/view.js +45 -0
  31. package/dist/cli/index.d.ts +3 -0
  32. package/dist/cli/index.js +19 -0
  33. package/dist/cli/run.d.ts +11 -0
  34. package/dist/cli/run.js +329 -0
  35. package/dist/loader/find-yaml-files.d.ts +26 -0
  36. package/dist/loader/find-yaml-files.js +51 -0
  37. package/dist/loader/index.d.ts +4 -0
  38. package/dist/loader/index.js +4 -0
  39. package/dist/loader/load-model.d.ts +32 -0
  40. package/dist/loader/load-model.js +54 -0
  41. package/dist/loader/model-graph.d.ts +43 -0
  42. package/dist/loader/model-graph.js +78 -0
  43. package/dist/mcp/server.d.ts +5 -0
  44. package/dist/mcp/server.js +53 -0
  45. package/dist/query/diff.d.ts +47 -0
  46. package/dist/query/diff.js +130 -0
  47. package/dist/query/effective-constraints.d.ts +48 -0
  48. package/dist/query/effective-constraints.js +81 -0
  49. package/dist/query/index.d.ts +7 -0
  50. package/dist/query/index.js +7 -0
  51. package/dist/query/queries.d.ts +111 -0
  52. package/dist/query/queries.js +172 -0
  53. package/dist/query/run-query.d.ts +16 -0
  54. package/dist/query/run-query.js +181 -0
  55. package/dist/query/side-channel.d.ts +25 -0
  56. package/dist/query/side-channel.js +46 -0
  57. package/dist/query/slice.d.ts +57 -0
  58. package/dist/query/slice.js +124 -0
  59. package/dist/query/summary.d.ts +9 -0
  60. package/dist/query/summary.js +180 -0
  61. package/dist/schema/index.d.ts +3 -0
  62. package/dist/schema/index.js +3 -0
  63. package/dist/schema/model.d.ts +661 -0
  64. package/dist/schema/model.js +183 -0
  65. package/dist/schema/status.d.ts +18 -0
  66. package/dist/schema/status.js +28 -0
  67. package/dist/staleness.d.ts +57 -0
  68. package/dist/staleness.js +148 -0
  69. package/dist/validate/anchor.d.ts +4 -0
  70. package/dist/validate/anchor.js +32 -0
  71. package/dist/validate/baseline.d.ts +11 -0
  72. package/dist/validate/baseline.js +18 -0
  73. package/dist/validate/checks.d.ts +59 -0
  74. package/dist/validate/checks.js +303 -0
  75. package/dist/validate/index.d.ts +6 -0
  76. package/dist/validate/index.js +6 -0
  77. package/dist/validate/inv1/check.d.ts +36 -0
  78. package/dist/validate/inv1/check.js +96 -0
  79. package/dist/validate/inv1/config.d.ts +75 -0
  80. package/dist/validate/inv1/config.js +63 -0
  81. package/dist/validate/inv1/scan.d.ts +49 -0
  82. package/dist/validate/inv1/scan.js +172 -0
  83. package/dist/validate/t0.d.ts +27 -0
  84. package/dist/validate/t0.js +31 -0
  85. package/dist/validate/types.d.ts +16 -0
  86. package/dist/validate/types.js +4 -0
  87. package/dist/validate/unregistered.d.ts +70 -0
  88. package/dist/validate/unregistered.js +111 -0
  89. package/dist/views/flow-mermaid.d.ts +30 -0
  90. package/dist/views/flow-mermaid.js +89 -0
  91. package/dist/views/index.d.ts +3 -0
  92. package/dist/views/index.js +3 -0
  93. package/dist/views/validate-mermaid.d.ts +39 -0
  94. package/dist/views/validate-mermaid.js +110 -0
  95. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loopgraph contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # loopgraph
2
+
3
+ 模型驱动工程控制系统(Model-Driven Engineering Control)——把"系统应该怎么运转"写成机器可校验的行为模型,钉在真实代码上,每次 PR 秒级对账,防止实现与认知漂移。
4
+
5
+ ## 核心思路
6
+
7
+ 以结构化行为模型(Feature / Flow / Loop / Junction / Scenario(GWT) / Evidence)为规范事实源,把目标仓库的代码、测试与可观测信号当作投影与证据:
8
+
9
+ - **确定性增量 CI(永不调 LLM)**:T0 检查(schema / 引用完整性 / 图无环 / 锚点存在性)、canonical-writer 不变量扫描(纯 AST)、实现事实对账、`--diff` 增量、内容寻址共享缓存(跨 worktree)。全量亚秒级。
10
+ - **发现靠 LLM,防腐靠引擎**:随附 agent 提示词套件(四遍扫描:假设都是 loop → 三判据证伪 → 组合 → trace 升维),由 coding agent 离线建模;引擎负责锚点核实与漂移拦截。
11
+ - **MCP 查询 + file side-channel**:给 agent 提供任务相关的模型切片(impact / plan / scenario / evidence / matrix),替代全量阅读 spec。
12
+
13
+ ## 快速开始
14
+
15
+ ```bash
16
+ npx loopgraph init # 在目标仓库生成 model/ 骨架 + .loopgraph/agent/ 指令套件
17
+ npx loopgraph check . --repo-root . --strict-anchors # T0 门禁(确定性,秒级)
18
+ npx loopgraph reconcile . --repo-root . # 实现信号 vs 模型登记对账(advisory)
19
+ npx loopgraph snapshot . --repo-root . # nightly 全量快照 + 漂移报告(永不进门禁)
20
+ npx loopgraph impact <loop-id> . # 改这里会牵动什么
21
+ npx loopgraph mcp . # stdio MCP server(给 agent 用)
22
+ ```
23
+
24
+ `init` 产出的 `.loopgraph/agent/` 含三个指令文件,由你仓库里的 coding agent 执行:发现建模(`loop-discovery.md`)、给 PR 模板加 Loop 声明栏、按本仓库 CI 约定编写 advisory / nightly 两个 workflow(均不阻塞 PR)。
25
+
26
+ ## 模型长什么样
27
+
28
+ ```yaml
29
+ # model/loops/*.yaml — 一个后台控制循环(示例为虚构系统)
30
+ - id: L1
31
+ kind: loop
32
+ title: Order 状态机
33
+ boundary: "pending→processing→shipped/cancelled;cancelled 为终态"
34
+ owner: packages/orders
35
+ anchors: ["packages/orders/src/order-service.ts#OrderService"] # 钉在真实代码符号上
36
+ consumes_queues: ["order:process"] # 按名对账生产/消费分离
37
+ scenarios: [GWT-A1-001] # GWT 场景,verified_by 指向真实测试
38
+ ```
39
+
40
+ junction(跨循环风险点)、flow(端到端链路)、debt(死状态机基线,只减不增)同理——全部 YAML,file-per-node,`loopgraph check` 机器校验。
41
+
42
+ ## 设计红线
43
+
44
+ - 门禁检查**零 LLM、零网络、零代码执行**——快、便宜、可复现、可解释。
45
+ - 缓存零正确性依赖(cold == warm 逐字节一致)。
46
+ - 快照/漂移报告(T2)永不进 PR 门禁。
47
+ - 模型新增须过锚点核实;发现层(LLM)只产草案,落库须经维护者核实。
48
+
49
+ ## License
50
+
51
+ 见 package.json。
@@ -0,0 +1,63 @@
1
+ /**
2
+ * AgentHub adapter — implementation-fact extraction (Proposal 006 B1 / 001 §2
3
+ * 三分离's "适配器" pillar). PURE functions of `(filePath, content)` — an
4
+ * in-memory `ts.createSourceFile` parse, no I/O — so the results are cacheable
5
+ * on the same `hash(adapter_version, blob_oid)` terms as A6's INV-1 scan.
6
+ *
7
+ * These surface the "registerable" implementation units (pg-boss queues,
8
+ * setInterval pollers) that the behavioral model is supposed to account for.
9
+ * Listing them is the `extract` stage of the pipeline (001 §3); comparing them
10
+ * against the model to flag UNREGISTERED units is a T1 check (Milestone C, out
11
+ * of B1's scope) — this layer only produces the facts.
12
+ *
13
+ * Scope note (B1 subset): two of the six planned signals are implemented here
14
+ * (pg-boss queue, setInterval poller — the clearest background-loop signals).
15
+ * The Next.js route / drizzle status-column / vitest-E2E / metric-event
16
+ * extractors are deferred; each is an independent pure extractor that slots in
17
+ * beside these (the file domain is non-overlapping, per 006 B1).
18
+ */
19
+ /**
20
+ * Bumped whenever an extractor's LOGIC changes — it's part of the B3 cache key,
21
+ * so a bump invalidates every stale cached result at once (a file whose bytes
22
+ * didn't change still needs re-extraction if the extractor did).
23
+ */
24
+ export declare const ADAPTER_VERSION = "agenthub-facts-1";
25
+ export type SignalKind = "pg_boss_queue" | "setinterval_poller";
26
+ export interface ImplementationFact {
27
+ signal: SignalKind;
28
+ /** The queue name, or the poller's enclosing function/file marker. */
29
+ name: string;
30
+ filePath: string;
31
+ /** 1-based line for a human reading the report — not a stable anchor. */
32
+ line: number;
33
+ /** Extra context: e.g. "env-suffixed" for a QUEUE_SUFFIX template, or the interval. */
34
+ detail?: string;
35
+ /** true when the fact's key part (queue name / interval) couldn't be read statically. */
36
+ unanalyzable?: boolean;
37
+ }
38
+ /**
39
+ * pg-boss queue names. In this target stack the authoritative names live in
40
+ * `const *_QUEUE = <string | template>` declarations (e.g.
41
+ * `` FOO_QUEUE = `foo:process${QUEUE_SUFFIX}` ``), referenced by identifier at
42
+ * the `boss.send(FOO_QUEUE, ...)` call sites — a literal AT the call site is the
43
+ * exception (and is often just a JSDoc-diagram string, which the AST parse
44
+ * ignores). So the primary signal is the const declaration; the `*QUEUE*`
45
+ * naming convention is adapter knowledge, which is exactly what an adapter is
46
+ * allowed to encode. Direct string-literal `.work/.send/.schedule` call sites
47
+ * are also captured. Deduped by queue name.
48
+ */
49
+ export declare function extractQueues(filePath: string, content: string): ImplementationFact[];
50
+ /**
51
+ * setInterval poller registrations: each `setInterval(fn, <interval>)` call
52
+ * site, whether called bare (`setInterval(...)`) or off an object
53
+ * (`globalThis.setInterval(...)`, `window.setInterval(...)`). The interval, if a
54
+ * numeric literal, is reported as detail; a variable/expression interval is
55
+ * marked unanalyzable (the poller exists either way — the cadence just isn't
56
+ * statically known). Residual: an aliased import (`import { setInterval as poll
57
+ * }` called as `poll(...)`) is not caught (needs import tracking) — a documented
58
+ * limitation; this target uses the bare Node global.
59
+ */
60
+ export declare function extractPollers(filePath: string, content: string): ImplementationFact[];
61
+ /** Runs every implemented extractor over one file. */
62
+ export declare function extractFacts(filePath: string, content: string): ImplementationFact[];
63
+ //# sourceMappingURL=extract.d.ts.map
@@ -0,0 +1,144 @@
1
+ import ts from "typescript";
2
+ /**
3
+ * AgentHub adapter — implementation-fact extraction (Proposal 006 B1 / 001 §2
4
+ * 三分离's "适配器" pillar). PURE functions of `(filePath, content)` — an
5
+ * in-memory `ts.createSourceFile` parse, no I/O — so the results are cacheable
6
+ * on the same `hash(adapter_version, blob_oid)` terms as A6's INV-1 scan.
7
+ *
8
+ * These surface the "registerable" implementation units (pg-boss queues,
9
+ * setInterval pollers) that the behavioral model is supposed to account for.
10
+ * Listing them is the `extract` stage of the pipeline (001 §3); comparing them
11
+ * against the model to flag UNREGISTERED units is a T1 check (Milestone C, out
12
+ * of B1's scope) — this layer only produces the facts.
13
+ *
14
+ * Scope note (B1 subset): two of the six planned signals are implemented here
15
+ * (pg-boss queue, setInterval poller — the clearest background-loop signals).
16
+ * The Next.js route / drizzle status-column / vitest-E2E / metric-event
17
+ * extractors are deferred; each is an independent pure extractor that slots in
18
+ * beside these (the file domain is non-overlapping, per 006 B1).
19
+ */
20
+ /**
21
+ * Bumped whenever an extractor's LOGIC changes — it's part of the B3 cache key,
22
+ * so a bump invalidates every stale cached result at once (a file whose bytes
23
+ * didn't change still needs re-extraction if the extractor did).
24
+ */
25
+ export const ADAPTER_VERSION = "agenthub-facts-1";
26
+ function lineOf(src, node) {
27
+ return src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1;
28
+ }
29
+ /** The static queue name of a string/template expression, or null if not statically known. */
30
+ function queueNameOf(expr) {
31
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr))
32
+ return { name: expr.text };
33
+ if (ts.isTemplateExpression(expr)) {
34
+ // Join every STATIC part (head + each span's trailing literal) with "…" for
35
+ // the dynamic `${}` gaps: `foo:process${SUFFIX}` → "foo:process";
36
+ // `${PREFIX}foo:process` → "foo:process"; `${A}${B}` → "" (fully dynamic).
37
+ const staticParts = [expr.head.text, ...expr.templateSpans.map((s) => s.literal.text)].filter(Boolean);
38
+ if (staticParts.length === 0)
39
+ return { name: "<dynamic>", detail: "env-templated" };
40
+ return { name: staticParts.join("…"), detail: "env-templated" };
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * pg-boss queue names. In this target stack the authoritative names live in
46
+ * `const *_QUEUE = <string | template>` declarations (e.g.
47
+ * `` FOO_QUEUE = `foo:process${QUEUE_SUFFIX}` ``), referenced by identifier at
48
+ * the `boss.send(FOO_QUEUE, ...)` call sites — a literal AT the call site is the
49
+ * exception (and is often just a JSDoc-diagram string, which the AST parse
50
+ * ignores). So the primary signal is the const declaration; the `*QUEUE*`
51
+ * naming convention is adapter knowledge, which is exactly what an adapter is
52
+ * allowed to encode. Direct string-literal `.work/.send/.schedule` call sites
53
+ * are also captured. Deduped by queue name.
54
+ */
55
+ export function extractQueues(filePath, content) {
56
+ const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
57
+ const facts = [];
58
+ const seen = new Set();
59
+ const QUEUE_METHODS = new Set(["work", "send", "schedule"]);
60
+ const push = (name, node, detail) => {
61
+ if (seen.has(name))
62
+ return; // one fact per distinct queue name
63
+ seen.add(name);
64
+ facts.push({
65
+ signal: "pg_boss_queue",
66
+ name,
67
+ filePath,
68
+ line: lineOf(src, node),
69
+ ...(detail ? { detail } : {}),
70
+ });
71
+ };
72
+ function visit(node) {
73
+ // `const *QUEUE* = <string | template>` — the authoritative queue-name decl.
74
+ // The target's convention is SCREAMING_SNAKE with QUEUE as a token, mid-name
75
+ // or suffix (e.g. FOO_QUEUE, BAR_QUEUE_WEB, BAZ_QUEUE_BASE), so match a
76
+ // CASE-SENSITIVE uppercase "QUEUE": that catches every real one while
77
+ // excluding the camelCase/lowercase false-matches (`queueLength`,
78
+ // `myQueueHandler`). Direct literal call sites below catch anything not
79
+ // declared via this convention.
80
+ if (ts.isVariableDeclaration(node) &&
81
+ ts.isIdentifier(node.name) &&
82
+ node.name.text.includes("QUEUE") &&
83
+ node.initializer) {
84
+ const q = queueNameOf(node.initializer);
85
+ if (q)
86
+ push(q.name, node, q.detail);
87
+ }
88
+ // Direct string-literal `.work/.send/.schedule("queue", ...)` registrations.
89
+ if (ts.isCallExpression(node) &&
90
+ ts.isPropertyAccessExpression(node.expression) &&
91
+ QUEUE_METHODS.has(node.expression.name.text)) {
92
+ const arg0 = node.arguments[0];
93
+ const q = arg0 ? queueNameOf(arg0) : null;
94
+ if (q)
95
+ push(q.name, node, q.detail);
96
+ }
97
+ ts.forEachChild(node, visit);
98
+ }
99
+ visit(src);
100
+ return facts;
101
+ }
102
+ /**
103
+ * setInterval poller registrations: each `setInterval(fn, <interval>)` call
104
+ * site, whether called bare (`setInterval(...)`) or off an object
105
+ * (`globalThis.setInterval(...)`, `window.setInterval(...)`). The interval, if a
106
+ * numeric literal, is reported as detail; a variable/expression interval is
107
+ * marked unanalyzable (the poller exists either way — the cadence just isn't
108
+ * statically known). Residual: an aliased import (`import { setInterval as poll
109
+ * }` called as `poll(...)`) is not caught (needs import tracking) — a documented
110
+ * limitation; this target uses the bare Node global.
111
+ */
112
+ export function extractPollers(filePath, content) {
113
+ const src = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
114
+ const facts = [];
115
+ const isSetInterval = (expr) => (ts.isIdentifier(expr) && expr.text === "setInterval") ||
116
+ (ts.isPropertyAccessExpression(expr) && expr.name.text === "setInterval") ||
117
+ // globalThis["setInterval"](...) — element access with a string key
118
+ (ts.isElementAccessExpression(expr) &&
119
+ ts.isStringLiteral(expr.argumentExpression) &&
120
+ expr.argumentExpression.text === "setInterval");
121
+ function visit(node) {
122
+ if (ts.isCallExpression(node) && isSetInterval(node.expression)) {
123
+ const intervalArg = node.arguments[1];
124
+ const literalMs = intervalArg && ts.isNumericLiteral(intervalArg) ? intervalArg.text : undefined;
125
+ facts.push({
126
+ signal: "setinterval_poller",
127
+ name: `setInterval@${filePath}:${lineOf(src, node)}`,
128
+ filePath,
129
+ line: lineOf(src, node),
130
+ ...(literalMs
131
+ ? { detail: `${literalMs}ms` }
132
+ : { detail: "dynamic interval", unanalyzable: true }),
133
+ });
134
+ }
135
+ ts.forEachChild(node, visit);
136
+ }
137
+ visit(src);
138
+ return facts;
139
+ }
140
+ /** Runs every implemented extractor over one file. */
141
+ export function extractFacts(filePath, content) {
142
+ return [...extractQueues(filePath, content), ...extractPollers(filePath, content)];
143
+ }
144
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1,27 @@
1
+ import type { Adapter } from "../types.js";
2
+ import type { ImplementationFact } from "./extract.js";
3
+ export interface FactsOptions {
4
+ /**
5
+ * Machine-level content cache dir (B3). Defaults to `~/.cache/loopgraph`;
6
+ * pass `null` to disable (the zero-cache-dependency path CI can take).
7
+ */
8
+ cacheDir?: string | null;
9
+ /** The adapter supplying the candidate pattern + extractors (E1). Default: agenthub. */
10
+ adapter?: Adapter;
11
+ }
12
+ export interface FactsResult {
13
+ /** false when the scan couldn't run (no git checkout) — a loud skip, not a silent empty result. */
14
+ ran: boolean;
15
+ skippedReason?: string;
16
+ facts: ImplementationFact[];
17
+ candidateFiles: number;
18
+ filesScanned: number;
19
+ timingMs: number;
20
+ }
21
+ /**
22
+ * Extracts implementation facts from the target repo via the chosen adapter
23
+ * (E1: the runner is engine-generic; agenthub is the default adapter). Same
24
+ * git-grep-then-pure-AST posture as A6's INV-1 scan.
25
+ */
26
+ export declare function runFacts(repoRoot: string, options?: FactsOptions): Promise<FactsResult>;
27
+ //# sourceMappingURL=facts.d.ts.map
@@ -0,0 +1,89 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { cacheKey, contentHash, defaultCacheDir, pruneCache, withCache, } from "../../cache/content-cache.js";
6
+ import { getAdapter } from "../registry.js";
7
+ const execFileAsync = promisify(execFile);
8
+ /** LRU cap for the machine-level cache (001 §5). */
9
+ const CACHE_MAX_ENTRIES = 50_000;
10
+ /**
11
+ * Candidate pre-filter for the fact extractors (same posture as A6's INV-1
12
+ * scan): `git grep -l` narrows to files that mention a pg-boss method or
13
+ * setInterval, then the pure AST extractors confirm. Over-matching only costs a
14
+ * cheap extra parse; under-matching would be a silent miss, so the pattern is
15
+ * broad. Returns undefined when the target is not a usable git checkout.
16
+ */
17
+ async function gitGrepCandidates(repoRoot, pattern) {
18
+ try {
19
+ const { stdout } = await execFileAsync("git", ["grep", "-lE", pattern, "--", "*.ts", ":!*.test.ts", ":!*.spec.ts"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
20
+ return (stdout
21
+ .split("\n")
22
+ // strip git's optional surrounding quotes on paths with special chars
23
+ .map((l) => l.trim().replace(/^"(.*)"$/, "$1"))
24
+ .filter(Boolean)
25
+ // Belt-and-suspenders over the git pathspec exclude: also drop test/spec
26
+ // files at the Node layer, so the result is right regardless of git
27
+ // version / pathspec behavior.
28
+ .filter((f) => !/\.(test|spec)\.ts$/.test(f)));
29
+ }
30
+ catch (err) {
31
+ if (err.code === 1)
32
+ return []; // no matches
33
+ return undefined; // not a git repo / no git
34
+ }
35
+ }
36
+ /**
37
+ * Extracts implementation facts from the target repo via the chosen adapter
38
+ * (E1: the runner is engine-generic; agenthub is the default adapter). Same
39
+ * git-grep-then-pure-AST posture as A6's INV-1 scan.
40
+ */
41
+ export async function runFacts(repoRoot, options = {}) {
42
+ const start = performance.now();
43
+ // `null` disables; `undefined` (default) uses the machine cache dir.
44
+ const cacheDir = options.cacheDir === null ? undefined : (options.cacheDir ?? defaultCacheDir());
45
+ // getAdapter("agenthub") is registered, so the `!` fallback is unreachable — guard, not assertion.
46
+ const adapter = options.adapter ?? getAdapter("agenthub");
47
+ if (!adapter)
48
+ throw new Error("no adapter available (agenthub not registered)");
49
+ const candidates = await gitGrepCandidates(repoRoot, adapter.candidatePattern);
50
+ if (candidates === undefined) {
51
+ return {
52
+ ran: false,
53
+ skippedReason: "fact extraction needs a git checkout (git grep unavailable at repo-root)",
54
+ facts: [],
55
+ candidateFiles: 0,
56
+ filesScanned: 0,
57
+ timingMs: performance.now() - start,
58
+ };
59
+ }
60
+ const facts = [];
61
+ let filesScanned = 0;
62
+ for (const rel of candidates) {
63
+ const content = await readFile(join(repoRoot, rel), "utf8").catch(() => undefined);
64
+ if (content === undefined)
65
+ continue;
66
+ filesScanned++;
67
+ // Key by (adapter version, repo-relative path, content hash): `rel` is the
68
+ // same in every worktree of the repo, so identical content shares one entry
69
+ // cross-worktree; the path is still part of the key so two files with the
70
+ // same bytes at different paths don't collide.
71
+ const key = cacheKey([adapter.version, rel, contentHash(content)]);
72
+ const fileFacts = await withCache(cacheDir, key, () => adapter.extractFacts(rel, content));
73
+ facts.push(...fileFacts);
74
+ }
75
+ if (cacheDir)
76
+ await pruneCache(cacheDir, CACHE_MAX_ENTRIES);
77
+ // stable order: signal, then name, then path
78
+ facts.sort((a, b) => a.signal.localeCompare(b.signal) ||
79
+ a.name.localeCompare(b.name) ||
80
+ a.filePath.localeCompare(b.filePath));
81
+ return {
82
+ ran: true,
83
+ facts,
84
+ candidateFiles: candidates.length,
85
+ filesScanned,
86
+ timingMs: performance.now() - start,
87
+ };
88
+ }
89
+ //# sourceMappingURL=facts.js.map
@@ -0,0 +1,13 @@
1
+ import type { Adapter } from "../types.js";
2
+ /**
3
+ * The agenthub adapter behind the engine's Adapter interface (E1). All
4
+ * target-stack knowledge lives here; the engine (runner, cache, INV-1
5
+ * primitive) stays generic.
6
+ *
7
+ * Deliberately ships NO defaultInv1Config: the INV-1 guarded-table config
8
+ * (table names, writer allowlists) is target-repo knowledge and lives in the
9
+ * target's own loopgraph.config.json — embedding it here would compile
10
+ * target internals into the published package.
11
+ */
12
+ export declare const agenthubAdapter: Adapter;
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { ADAPTER_VERSION, extractFacts } from "./extract.js";
2
+ /**
3
+ * The agenthub adapter behind the engine's Adapter interface (E1). All
4
+ * target-stack knowledge lives here; the engine (runner, cache, INV-1
5
+ * primitive) stays generic.
6
+ *
7
+ * Deliberately ships NO defaultInv1Config: the INV-1 guarded-table config
8
+ * (table names, writer allowlists) is target-repo knowledge and lives in the
9
+ * target's own loopgraph.config.json — embedding it here would compile
10
+ * target internals into the published package.
11
+ */
12
+ export const agenthubAdapter = {
13
+ name: "agenthub",
14
+ version: ADAPTER_VERSION,
15
+ candidatePattern: "setInterval\\(|\\.(work|send|schedule)\\(",
16
+ extractFacts,
17
+ };
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,4 @@
1
+ import type { Adapter } from "./types.js";
2
+ export declare function getAdapter(name: string): Adapter | undefined;
3
+ export declare function adapterNames(): string[];
4
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { agenthubAdapter } from "./agenthub/index.js";
2
+ /**
3
+ * The adapter registry (E1): the single place the engine resolves an adapter by
4
+ * name. A new target stack registers here and everything else — the fact
5
+ * runner, the cache key, the INV-1 default config — flows through the Adapter
6
+ * interface without further engine changes.
7
+ */
8
+ const ADAPTERS = {
9
+ [agenthubAdapter.name]: agenthubAdapter,
10
+ };
11
+ export function getAdapter(name) {
12
+ return ADAPTERS[name];
13
+ }
14
+ export function adapterNames() {
15
+ return Object.keys(ADAPTERS);
16
+ }
17
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,40 @@
1
+ import type { Inv1Config } from "../validate/inv1/config.js";
2
+ import type { ImplementationFact } from "./agenthub/extract.js";
3
+ /**
4
+ * The engine↔adapter boundary (Proposal 006 E1 / 001 §2 三分离, Phase 5
5
+ * generalization). The ENGINE (loader, T0, INV-1 scan primitive, query, cache,
6
+ * `--diff`, MCP) carries zero target-repo knowledge; an ADAPTER supplies the
7
+ * target-stack-specific facts: how to read this codebase's implementation
8
+ * signals, its version (part of the cache key), and its default INV-1 config.
9
+ *
10
+ * Formalizing this as an interface is the Phase 5 "通用化" — but the 001 red
11
+ * line holds: generalization must not degrade AgentHub analysis quality or CI
12
+ * speed. The regression test (test/adapter.test.ts) enforces that: the agenthub
13
+ * adapter routed through this interface produces BYTE-IDENTICAL facts to the
14
+ * direct extractor. A second adapter would slot in beside agenthub without the
15
+ * engine changing.
16
+ */
17
+ export interface Adapter {
18
+ /** Stable adapter id, used to look it up and (with `version`) to key the cache. */
19
+ name: string;
20
+ /**
21
+ * Bumped when extractor LOGIC changes — part of the B3 cache key so a bump
22
+ * invalidates stale results even when file bytes are unchanged.
23
+ */
24
+ version: string;
25
+ /**
26
+ * `git grep -E` pattern selecting candidate files worth parsing (the cheap
27
+ * pre-filter before the pure extractor confirms). Adapter-specific because it
28
+ * encodes which call shapes this stack's signals appear in.
29
+ */
30
+ candidatePattern: string;
31
+ /** Extract implementation facts from one source file. PURE (no I/O). */
32
+ extractFacts(filePath: string, content: string): ImplementationFact[];
33
+ /**
34
+ * The default INV-1 guarded-column config for this target, if the adapter
35
+ * ships one. A repo-level `loopgraph.config.json` still overrides it; this is
36
+ * the fallback so a target doesn't have to re-declare what the adapter knows.
37
+ */
38
+ defaultInv1Config?: Inv1Config;
39
+ }
40
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Machine-level content-addressed cache (Proposal 006 B3 / 001 §5) for the pure
3
+ * per-file extractor/scanner results (A6 INV-1 scan, B1 fact extraction).
4
+ *
5
+ * Key = sha256 over caller-supplied parts — for the fact extractor,
6
+ * (adapter_version, repo-relative path, content_hash). Every part is
7
+ * worktree-INDEPENDENT: the content hash is by definition, and the
8
+ * repo-relative path is the same in every worktree of the repo. So identical
9
+ * bytes at the same in-repo path share ONE entry across the user's many
10
+ * worktrees (the cross-worktree sharing 001 §5 calls for), while the path stays
11
+ * in the key so two files with the same bytes at DIFFERENT paths don't collide
12
+ * (the cached value embeds its path). Content-addressing also makes writes
13
+ * idempotent: a given key's value is deterministic, so a concurrent write of
14
+ * the same key is harmless (both write identical bytes; temp+rename just makes
15
+ * each atomic).
16
+ *
17
+ * Correctness has ZERO cache dependency (001 §6 red line): the value is exactly
18
+ * `compute()` — a cold run (empty cache) and a warm run are byte-identical, and
19
+ * a missing/corrupt/disabled cache just recomputes. `cacheDir: undefined`
20
+ * disables it entirely (the path CI can take to prove independence).
21
+ *
22
+ * Not modeled (documented deferral): the git-index fast-path for reading a
23
+ * clean tracked file's blob OID without re-hashing. Hashing the content
24
+ * directly is always correct and worktree-identical; the index lookup is only a
25
+ * micro-optimization, so B3 ships the always-correct content-hash path.
26
+ */
27
+ export declare function defaultCacheDir(): string;
28
+ /** sha256 of a file's content — the worktree-independent part of the cache key. */
29
+ export declare function contentHash(content: string): string;
30
+ /** The cache key: a hash over the ordered key parts (adapter version, content hash, config hash). */
31
+ export declare function cacheKey(parts: string[]): string;
32
+ /**
33
+ * Read-or-compute. On a hit, returns the parsed cached value; on a miss (or
34
+ * disabled/corrupt cache), computes, atomically writes (temp + rename), and
35
+ * returns. `compute` must be a PURE function of the same inputs the key was
36
+ * derived from — that's what makes the cache sound. `compute` may be sync or
37
+ * async (`await` handles both).
38
+ */
39
+ export declare function withCache<T>(cacheDir: string | undefined, key: string, compute: () => T | Promise<T>): Promise<T>;
40
+ /**
41
+ * Bounds the cache to `maxEntries` most-recently-modified entries (the LRU cap
42
+ * from 001 §5). Cheap and best-effort — called once per run, not per file — so
43
+ * an occasional over-count between prunes is fine.
44
+ */
45
+ export declare function pruneCache(cacheDir: string, maxEntries: number): Promise<number>;
46
+ //# sourceMappingURL=content-cache.d.ts.map