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
@@ -0,0 +1,111 @@
1
+ import type { ModelGraph } from "../loader/model-graph.js";
2
+ import type { ModelNode } from "../schema/index.js";
3
+ /**
4
+ * The Phase-2 query command family (Proposal 006 B4 / 001 §4.2 §8):
5
+ * `impact` / `plan` / `scenario` / `evidence` — focused views over the model,
6
+ * distinct from A5's general-purpose `inspect` slice. All are PURE functions of
7
+ * the loaded graph; the CLI and MCP layers add I/O (side-channel file) around
8
+ * them. Each returns `undefined` when the id isn't found (callers turn that
9
+ * into an "unknown id" error), and a small structured result the renderers in
10
+ * this file turn into a stdout summary + a full side-channel body.
11
+ */
12
+ export interface Dependent {
13
+ id: string;
14
+ kind: ModelNode["kind"];
15
+ /** How it depends on the queried node (which reference field links them). */
16
+ relation: string;
17
+ }
18
+ export interface ImpactResult {
19
+ nodeId: string;
20
+ kind: ModelNode["kind"];
21
+ dependents: Dependent[];
22
+ }
23
+ /**
24
+ * Everything that references `nodeId` — the set a change to it may force you to
25
+ * re-check. Reverse edges only (who points AT this node), deliberately NOT the
26
+ * general bidirectional slice `inspect` gives: this answers "if I change X,
27
+ * what's the blast radius", not "what's near X".
28
+ */
29
+ export declare function impactOf(graph: ModelGraph, nodeId: string): ImpactResult | undefined;
30
+ export interface PlanStep {
31
+ loopId: string;
32
+ title: string;
33
+ status: string;
34
+ /** effective-constraint scenario ids that apply to this loop (invariants). */
35
+ effectiveConstraints: string[];
36
+ }
37
+ export interface PlanResult {
38
+ flowId: string;
39
+ title: string;
40
+ steps: PlanStep[];
41
+ guards: {
42
+ loopId: string;
43
+ title: string;
44
+ }[];
45
+ junctions: {
46
+ id: string;
47
+ risk_class: string;
48
+ between: string[];
49
+ title: string;
50
+ }[];
51
+ subFlows: string[];
52
+ }
53
+ /** The ordered "how this flow runs" plan: traverses sequence, guards, crossing junctions. */
54
+ export declare function planOf(graph: ModelGraph, flowId: string): PlanResult | undefined;
55
+ export interface ScenarioResult {
56
+ scenario: import("../schema/index.js").Scenario;
57
+ /** loops/junctions that list this scenario in their `scenarios` array. */
58
+ referencedBy: {
59
+ id: string;
60
+ kind: "loop" | "junction";
61
+ }[];
62
+ /** loops this scenario applies to via applies_to (resolved at query time). */
63
+ appliesToLoops: string[];
64
+ }
65
+ export declare function scenarioDetail(graph: ModelGraph, scenarioId: string): ScenarioResult | undefined;
66
+ export interface EvidenceResult {
67
+ nodeId: string;
68
+ kind: ModelNode["kind"];
69
+ /** Junction evidence entries (kind/anchor/note), if the node is a junction. */
70
+ evidence: import("../schema/index.js").Evidence[];
71
+ /** Anchor strings, if the node is a loop. */
72
+ anchors: string[];
73
+ /** scenarios bound to this node (its own `scenarios` + effective constraints for loops). */
74
+ scenarios: string[];
75
+ }
76
+ export declare function evidenceOf(graph: ModelGraph, nodeId: string): EvidenceResult | undefined;
77
+ export interface MatrixRow {
78
+ scenarioId: string;
79
+ level: string;
80
+ /** Test anchors this GWT is bound to (Scenario.verified_by). Empty = unverified. */
81
+ verifiedBy: string[];
82
+ /** in-flow nodes (junction/loop) that reference this scenario, or "applies_to" for an invariant. */
83
+ referencedBy: string[];
84
+ verified: boolean;
85
+ }
86
+ export interface TestMatrixResult {
87
+ flowId: string;
88
+ title: string;
89
+ rows: MatrixRow[];
90
+ /**
91
+ * `unknownScenario` counts rows whose scenario id resolved to no Scenario
92
+ * node — always 0 on a T0-passing model (referential integrity catches
93
+ * dangling scenario refs), surfaced anyway so a broken model isn't silent.
94
+ */
95
+ summary: {
96
+ total: number;
97
+ verified: number;
98
+ unverified: number;
99
+ unknownScenario: number;
100
+ };
101
+ }
102
+ /**
103
+ * The GWT↔test binding matrix for a flow (Proposal 006 B5 / 001 §12 "GWT 独立于
104
+ * 测试代码长期存在"): every scenario reachable from the flow — via its crossing
105
+ * junctions, its traversed/guarding loops, and each loop's effective
106
+ * constraints — with the tests it's bound to and whether it's verified. Makes
107
+ * the flow's test-coverage surface legible at a glance (which GWT are grounded
108
+ * in a real test, which are deliberately unverified).
109
+ */
110
+ export declare function testMatrix(graph: ModelGraph, flowId: string): TestMatrixResult | undefined;
111
+ //# sourceMappingURL=queries.d.ts.map
@@ -0,0 +1,172 @@
1
+ import { getNode } from "../loader/model-graph.js";
2
+ import { resolveApplicableScenarios, scenarioApplies } from "./effective-constraints.js";
3
+ /**
4
+ * Everything that references `nodeId` — the set a change to it may force you to
5
+ * re-check. Reverse edges only (who points AT this node), deliberately NOT the
6
+ * general bidirectional slice `inspect` gives: this answers "if I change X,
7
+ * what's the blast radius", not "what's near X".
8
+ */
9
+ export function impactOf(graph, nodeId) {
10
+ const node = getNode(graph, nodeId);
11
+ if (!node)
12
+ return undefined;
13
+ const dependents = [];
14
+ const seen = new Set(); // dedup by id: a node is one dependent even if linked two ways
15
+ const add = (id, kind, relation) => {
16
+ if (seen.has(id))
17
+ return;
18
+ seen.add(id);
19
+ dependents.push({ id, kind, relation });
20
+ };
21
+ for (const flow of graph.byKind.flow.values()) {
22
+ if (flow.traverses.includes(nodeId))
23
+ add(flow.id, "flow", "traverses");
24
+ if (flow.guarded_by.includes(nodeId))
25
+ add(flow.id, "flow", "guarded_by");
26
+ if (flow.crosses.includes(nodeId))
27
+ add(flow.id, "flow", "crosses");
28
+ if (flow.references.includes(nodeId))
29
+ add(flow.id, "flow", "references");
30
+ }
31
+ for (const feature of graph.byKind.feature.values()) {
32
+ if (feature.contains.includes(nodeId))
33
+ add(feature.id, "feature", "contains");
34
+ }
35
+ for (const loop of graph.byKind.loop.values()) {
36
+ if (loop.parent === nodeId)
37
+ add(loop.id, "loop", "parent");
38
+ if (loop.scenarios.includes(nodeId))
39
+ add(loop.id, "loop", "scenarios");
40
+ }
41
+ for (const junction of graph.byKind.junction.values()) {
42
+ if (junction.between.includes(nodeId))
43
+ add(junction.id, "junction", "between");
44
+ if (junction.scenarios.includes(nodeId))
45
+ add(junction.id, "junction", "scenarios");
46
+ }
47
+ // A loop is also "impacted" by any invariant scenario that applies to it via
48
+ // applies_to — and a scenario's impact includes the loops it constrains.
49
+ if (node.kind === "scenario") {
50
+ for (const loop of graph.byKind.loop.values()) {
51
+ if (scenarioApplies(node, loop.id, graph))
52
+ add(loop.id, "loop", "applies_to");
53
+ }
54
+ }
55
+ else if (node.kind === "loop") {
56
+ for (const s of resolveApplicableScenarios(graph, nodeId))
57
+ add(s.id, "scenario", "applies_to→");
58
+ }
59
+ // stable order: kind then id
60
+ dependents.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
61
+ return { nodeId, kind: node.kind, dependents };
62
+ }
63
+ /** The ordered "how this flow runs" plan: traverses sequence, guards, crossing junctions. */
64
+ export function planOf(graph, flowId) {
65
+ const flow = graph.byKind.flow.get(flowId);
66
+ if (!flow)
67
+ return undefined;
68
+ const steps = flow.traverses.map((loopId) => {
69
+ const loop = graph.byKind.loop.get(loopId);
70
+ return {
71
+ loopId,
72
+ title: loop?.title ?? loopId,
73
+ status: loop?.status ?? "unknown",
74
+ effectiveConstraints: resolveApplicableScenarios(graph, loopId).map((s) => s.id),
75
+ };
76
+ });
77
+ const guards = flow.guarded_by.map((loopId) => ({
78
+ loopId,
79
+ title: graph.byKind.loop.get(loopId)?.title ?? loopId,
80
+ }));
81
+ const junctions = flow.crosses
82
+ .map((id) => graph.byKind.junction.get(id))
83
+ .filter((j) => j !== undefined)
84
+ .map((j) => ({
85
+ id: j.id,
86
+ risk_class: j.risk_class,
87
+ between: j.between,
88
+ title: j.title ?? "",
89
+ }));
90
+ return { flowId, title: flow.title, steps, guards, junctions, subFlows: flow.references };
91
+ }
92
+ export function scenarioDetail(graph, scenarioId) {
93
+ const scenario = graph.byKind.scenario.get(scenarioId);
94
+ if (!scenario)
95
+ return undefined;
96
+ const referencedBy = [];
97
+ for (const loop of graph.byKind.loop.values())
98
+ if (loop.scenarios.includes(scenarioId))
99
+ referencedBy.push({ id: loop.id, kind: "loop" });
100
+ for (const junction of graph.byKind.junction.values())
101
+ if (junction.scenarios.includes(scenarioId))
102
+ referencedBy.push({ id: junction.id, kind: "junction" });
103
+ const appliesToLoops = [...graph.byKind.loop.values()]
104
+ .filter((l) => scenarioApplies(scenario, l.id, graph))
105
+ .map((l) => l.id);
106
+ return { scenario, referencedBy, appliesToLoops };
107
+ }
108
+ export function evidenceOf(graph, nodeId) {
109
+ const node = getNode(graph, nodeId);
110
+ if (!node)
111
+ return undefined;
112
+ const evidence = node.kind === "junction" ? node.evidence : [];
113
+ const anchors = node.kind === "loop" ? node.anchors : [];
114
+ const own = node.kind === "loop" || node.kind === "junction" ? node.scenarios : [];
115
+ const eff = node.kind === "loop" ? resolveApplicableScenarios(graph, nodeId).map((s) => s.id) : [];
116
+ const scenarios = [...new Set([...own, ...eff])];
117
+ return { nodeId, kind: node.kind, evidence, anchors, scenarios };
118
+ }
119
+ /**
120
+ * The GWT↔test binding matrix for a flow (Proposal 006 B5 / 001 §12 "GWT 独立于
121
+ * 测试代码长期存在"): every scenario reachable from the flow — via its crossing
122
+ * junctions, its traversed/guarding loops, and each loop's effective
123
+ * constraints — with the tests it's bound to and whether it's verified. Makes
124
+ * the flow's test-coverage surface legible at a glance (which GWT are grounded
125
+ * in a real test, which are deliberately unverified).
126
+ */
127
+ export function testMatrix(graph, flowId) {
128
+ const flow = graph.byKind.flow.get(flowId);
129
+ if (!flow)
130
+ return undefined;
131
+ // scenarioId → the in-flow nodes that pull it in (deduped, ordered).
132
+ const refs = new Map();
133
+ const addRef = (scenarioId, from) => {
134
+ const set = refs.get(scenarioId) ?? new Set();
135
+ set.add(from);
136
+ refs.set(scenarioId, set);
137
+ };
138
+ for (const jid of flow.crosses) {
139
+ const junction = graph.byKind.junction.get(jid);
140
+ for (const s of junction?.scenarios ?? [])
141
+ addRef(s, jid);
142
+ }
143
+ for (const lid of [...flow.traverses, ...flow.guarded_by]) {
144
+ const loop = graph.byKind.loop.get(lid);
145
+ for (const s of loop?.scenarios ?? [])
146
+ addRef(s, lid);
147
+ for (const s of resolveApplicableScenarios(graph, lid))
148
+ addRef(s.id, `${lid}:applies_to`);
149
+ }
150
+ const rows = [...refs.keys()]
151
+ .map((scenarioId) => {
152
+ const scenario = graph.byKind.scenario.get(scenarioId);
153
+ const verifiedBy = scenario?.verified_by ?? [];
154
+ return {
155
+ scenarioId,
156
+ level: scenario?.level ?? "unknown",
157
+ verifiedBy,
158
+ referencedBy: [...(refs.get(scenarioId) ?? [])].sort(),
159
+ verified: verifiedBy.length > 0,
160
+ };
161
+ })
162
+ .sort((a, b) => a.scenarioId.localeCompare(b.scenarioId));
163
+ const verified = rows.filter((r) => r.verified).length;
164
+ const unknownScenario = rows.filter((r) => r.level === "unknown").length;
165
+ return {
166
+ flowId,
167
+ title: flow.title,
168
+ rows,
169
+ summary: { total: rows.length, verified, unverified: rows.length - verified, unknownScenario },
170
+ };
171
+ }
172
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Phase-2 query command runner (Proposal 006 B4): loads the model, runs one
3
+ * focused query, writes a staleness-stamped side-channel file, and returns a
4
+ * compact summary + the file path — the 001 §4.2 shape shared by CLI and MCP.
5
+ * `inspect` has its own runner (A5); this covers impact/plan/scenario/evidence.
6
+ */
7
+ export type QueryCommand = "impact" | "plan" | "scenario" | "evidence" | "matrix";
8
+ export interface QueryResult {
9
+ command: QueryCommand;
10
+ id: string;
11
+ outputPath: string;
12
+ summary: string;
13
+ staleWarning?: string;
14
+ }
15
+ export declare function runQuery(targetDir: string, command: QueryCommand, id: string): Promise<QueryResult>;
16
+ //# sourceMappingURL=run-query.d.ts.map
@@ -0,0 +1,181 @@
1
+ import { join } from "node:path";
2
+ import { loadModel } from "../loader/load-model.js";
3
+ import { evidenceOf, impactOf, planOf, scenarioDetail, testMatrix, } from "./queries.js";
4
+ import { writeSideChannel } from "./side-channel.js";
5
+ function renderImpact(r) {
6
+ const byRelation = {};
7
+ for (const d of r.dependents) {
8
+ const list = byRelation[d.relation] ?? [];
9
+ list.push(`${d.id}(${d.kind})`);
10
+ byRelation[d.relation] = list;
11
+ }
12
+ const summary = [
13
+ `impact ${r.nodeId} (${r.kind}) — ${r.dependents.length} dependent(s):`,
14
+ ...Object.entries(byRelation).map(([rel, ids]) => ` via ${rel}: ${ids.join(", ")}`),
15
+ ].join("\n");
16
+ const body = [
17
+ `# impact ${r.nodeId} (${r.kind})`,
18
+ "",
19
+ `${r.dependents.length} node(s) reference it — changing ${r.nodeId} may force re-checking these:`,
20
+ "",
21
+ ...r.dependents.map((d) => `- ${d.id} (${d.kind}) — ${d.relation}`),
22
+ ].join("\n");
23
+ return { summary, body };
24
+ }
25
+ function renderPlan(r) {
26
+ const seq = r.steps.map((s) => s.loopId).join(" → ") || "(no traverses)";
27
+ const summary = [
28
+ `plan ${r.flowId} — ${r.title}`,
29
+ ` sequence: ${seq}`,
30
+ ` guards: ${r.guards.map((g) => g.loopId).join(", ") || "(none)"}`,
31
+ ` junctions: ${r.junctions.map((j) => `${j.id}[${j.risk_class}]`).join(", ") || "(none)"}`,
32
+ r.subFlows.length ? ` sub-flows: ${r.subFlows.join(", ")}` : "",
33
+ ]
34
+ .filter(Boolean)
35
+ .join("\n");
36
+ const body = [
37
+ `# plan ${r.flowId} — ${r.title}`,
38
+ "",
39
+ "## ordered steps",
40
+ "",
41
+ ...r.steps.map((s, i) => {
42
+ const head = `${i + 1}. **${s.loopId}** ${s.title} [${s.status}]`;
43
+ return s.effectiveConstraints.length
44
+ ? `${head}\n - invariants (applies_to): ${s.effectiveConstraints.join(", ")}`
45
+ : head;
46
+ }),
47
+ "",
48
+ "## guards (watchdogs)",
49
+ "",
50
+ ...(r.guards.length ? r.guards.map((g) => `- ${g.loopId} ${g.title}`) : ["(none)"]),
51
+ "",
52
+ "## crossing junctions",
53
+ "",
54
+ ...(r.junctions.length
55
+ ? r.junctions.map((j) => `- ${j.id} [${j.risk_class}] ${j.between.join("→")}${j.title ? ` — ${j.title}` : ""}`)
56
+ : ["(none)"]),
57
+ r.subFlows.length ? `\n## sub-flows\n\n${r.subFlows.map((f) => `- ${f}`).join("\n")}` : "",
58
+ ].join("\n");
59
+ return { summary, body };
60
+ }
61
+ function renderScenario(r) {
62
+ const s = r.scenario;
63
+ const refs = r.referencedBy.map((x) => `${x.id}(${x.kind})`).join(", ") || "(none)";
64
+ const summary = [
65
+ `scenario ${s.id} [${s.level}] — verified_by ${s.verified_by.length} test(s)`,
66
+ ` referenced by: ${refs}`,
67
+ r.appliesToLoops.length ? ` applies to loops: ${r.appliesToLoops.join(", ")}` : "",
68
+ ]
69
+ .filter(Boolean)
70
+ .join("\n");
71
+ const body = [
72
+ `# scenario ${s.id} [${s.level}]`,
73
+ "",
74
+ `- **given** ${s.given}`,
75
+ `- **when** ${s.when}`,
76
+ `- **then** ${s.then}`,
77
+ `- **verified_by**: ${s.verified_by.length ? s.verified_by.join(", ") : "(none — unverified)"}`,
78
+ s.applies_to
79
+ ? `- **applies_to**: nodes=[${s.applies_to.nodes.join(", ")}]${s.applies_to.owner_match ? ` owner_match="${s.applies_to.owner_match}"` : ""}`
80
+ : "",
81
+ "",
82
+ `## referenced by\n\n${r.referencedBy.length ? r.referencedBy.map((x) => `- ${x.id} (${x.kind})`).join("\n") : "(none)"}`,
83
+ r.appliesToLoops.length
84
+ ? `\n## applies to loops (resolved)\n\n${r.appliesToLoops.map((l) => `- ${l}`).join("\n")}`
85
+ : "",
86
+ ]
87
+ .filter(Boolean)
88
+ .join("\n");
89
+ return { summary, body };
90
+ }
91
+ function renderEvidence(r) {
92
+ const summary = [
93
+ `evidence ${r.nodeId} (${r.kind}) — ${r.evidence.length} evidence, ${r.anchors.length} anchor(s), ${r.scenarios.length} scenario(s)`,
94
+ ...r.evidence.map((e) => ` [${e.kind}] ${e.anchor}`),
95
+ ...r.anchors.map((a) => ` anchor: ${a}`),
96
+ ].join("\n");
97
+ const body = [
98
+ `# evidence ${r.nodeId} (${r.kind})`,
99
+ "",
100
+ r.evidence.length ? "## evidence" : "",
101
+ ...r.evidence.map((e) => `- [${e.kind}] \`${e.anchor}\`${e.note ? ` — ${e.note}` : ""}`),
102
+ r.anchors.length ? "\n## anchors" : "",
103
+ ...r.anchors.map((a) => `- \`${a}\``),
104
+ r.scenarios.length
105
+ ? `\n## bound scenarios\n\n${r.scenarios.map((x) => `- ${x}`).join("\n")}`
106
+ : "",
107
+ ]
108
+ .filter(Boolean)
109
+ .join("\n");
110
+ return { summary, body };
111
+ }
112
+ function renderMatrix(r) {
113
+ const unknownNote = r.summary.unknownScenario
114
+ ? ` (⚠ ${r.summary.unknownScenario} dangling scenario ref)`
115
+ : "";
116
+ const summary = [
117
+ `matrix ${r.flowId} — ${r.title}`,
118
+ ` ${r.summary.total} GWT: ${r.summary.verified} verified / ${r.summary.unverified} unverified${unknownNote}`,
119
+ ].join("\n");
120
+ const body = [
121
+ `# test matrix ${r.flowId} — ${r.title}`,
122
+ "",
123
+ `${r.summary.total} scenarios · ${r.summary.verified} verified · ${r.summary.unverified} unverified${unknownNote}`,
124
+ "",
125
+ "| GWT | level | verified | verified_by | referenced by |",
126
+ "|---|---|---|---|---|",
127
+ ...r.rows.map((row) => `| ${row.scenarioId} | ${row.level} | ${row.verified ? "✅" : "—"} | ${
128
+ // "; "-separated so the cell stays readable as plain text (not just rendered)
129
+ row.verifiedBy.length ? row.verifiedBy.map((v) => `\`${v}\``).join("; ") : "(none)"} | ${row.referencedBy.join(", ")} |`),
130
+ "",
131
+ ].join("\n");
132
+ return { summary, body };
133
+ }
134
+ function render(command, graph, id) {
135
+ switch (command) {
136
+ case "impact": {
137
+ const r = impactOf(graph, id);
138
+ return r && renderImpact(r);
139
+ }
140
+ case "plan": {
141
+ const r = planOf(graph, id);
142
+ return r && renderPlan(r);
143
+ }
144
+ case "scenario": {
145
+ const r = scenarioDetail(graph, id);
146
+ return r && renderScenario(r);
147
+ }
148
+ case "evidence": {
149
+ const r = evidenceOf(graph, id);
150
+ return r && renderEvidence(r);
151
+ }
152
+ case "matrix": {
153
+ const r = testMatrix(graph, id);
154
+ return r && renderMatrix(r);
155
+ }
156
+ }
157
+ }
158
+ export async function runQuery(targetDir, command, id) {
159
+ const load = await loadModel(join(targetDir, "model"));
160
+ if (load.parseErrors.length > 0) {
161
+ throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
162
+ }
163
+ const rendered = render(command, load.graph, id);
164
+ if (!rendered) {
165
+ const expected = command === "plan" || command === "matrix"
166
+ ? "flow"
167
+ : command === "scenario"
168
+ ? "scenario"
169
+ : "node";
170
+ throw new Error(`unknown ${expected} id "${id}" for \`${command}\``);
171
+ }
172
+ const { outputPath, staleWarning } = await writeSideChannel(targetDir, `${command}-${id}`, (banner) => `${banner}\n\n${rendered.body}\n`);
173
+ return {
174
+ command,
175
+ id,
176
+ outputPath,
177
+ summary: `${rendered.summary}\n (full detail: ${outputPath})`,
178
+ ...(staleWarning ? { staleWarning } : {}),
179
+ };
180
+ }
181
+ //# sourceMappingURL=run-query.js.map
@@ -0,0 +1,25 @@
1
+ export interface SideChannelResult {
2
+ outputPath: string;
3
+ /** Set when a pre-existing file for this tag was stale vs the current model. */
4
+ staleWarning?: string;
5
+ }
6
+ /**
7
+ * Shared side-channel writer for the query family (extracted from A5's inspect
8
+ * so inspect/impact/plan/scenario/evidence don't each re-implement it). Writes
9
+ * `<targetDir>/.loopgraph/ws/<fileTag>.md`, stamped with a staleness banner,
10
+ * and — before overwriting — reads any prior file and warns if it was stale
11
+ * (Decision 004 技术点 4 consumer). `buildBody` receives the banner to place it
12
+ * wherever the body wants (top, by convention).
13
+ *
14
+ * Concurrency: the side-channel file is a regenerated-on-demand ADVISORY
15
+ * artifact, not source data — the model YAML is never touched here, only this
16
+ * derived output. The read→judge→write is deliberately NOT locked: the worst
17
+ * case under a race is a spurious/missed staleness *warning* plus one of two
18
+ * near-identical regenerations winning; no model data is at risk, so a file
19
+ * lock is not pulled in for a local dev tool. The write itself IS atomic — a
20
+ * uniquely-named temp (`randomUUID`, so two concurrent writers in the SAME
21
+ * process, e.g. two MCP tool calls for the same id, never collide on the temp)
22
+ * then `rename`, so a reader never observes a half-written file.
23
+ */
24
+ export declare function writeSideChannel(targetDir: string, fileTag: string, buildBody: (banner: string) => string): Promise<SideChannelResult>;
25
+ //# sourceMappingURL=side-channel.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { computeStalenessStamp, formatStalenessBanner, isStale, parseStalenessBanner, } from "../staleness.js";
5
+ function shortHash(h) {
6
+ return h.slice(0, 12);
7
+ }
8
+ /**
9
+ * Shared side-channel writer for the query family (extracted from A5's inspect
10
+ * so inspect/impact/plan/scenario/evidence don't each re-implement it). Writes
11
+ * `<targetDir>/.loopgraph/ws/<fileTag>.md`, stamped with a staleness banner,
12
+ * and — before overwriting — reads any prior file and warns if it was stale
13
+ * (Decision 004 技术点 4 consumer). `buildBody` receives the banner to place it
14
+ * wherever the body wants (top, by convention).
15
+ *
16
+ * Concurrency: the side-channel file is a regenerated-on-demand ADVISORY
17
+ * artifact, not source data — the model YAML is never touched here, only this
18
+ * derived output. The read→judge→write is deliberately NOT locked: the worst
19
+ * case under a race is a spurious/missed staleness *warning* plus one of two
20
+ * near-identical regenerations winning; no model data is at risk, so a file
21
+ * lock is not pulled in for a local dev tool. The write itself IS atomic — a
22
+ * uniquely-named temp (`randomUUID`, so two concurrent writers in the SAME
23
+ * process, e.g. two MCP tool calls for the same id, never collide on the temp)
24
+ * then `rename`, so a reader never observes a half-written file.
25
+ */
26
+ export async function writeSideChannel(targetDir, fileTag, buildBody) {
27
+ const modelDir = join(targetDir, "model");
28
+ const currentStamp = await computeStalenessStamp(modelDir, targetDir);
29
+ const wsDir = join(targetDir, ".loopgraph", "ws");
30
+ await mkdir(wsDir, { recursive: true });
31
+ const outputPath = join(wsDir, `${fileTag}.md`);
32
+ let staleWarning;
33
+ const prior = await readFile(outputPath, "utf8").catch(() => undefined);
34
+ if (prior) {
35
+ const priorStamp = parseStalenessBanner(prior);
36
+ if (priorStamp && isStale(priorStamp, currentStamp)) {
37
+ staleWarning = `prior ${outputPath} was stale: model_content_hash ${shortHash(priorStamp.modelContentHash)} != current ${shortHash(currentStamp.modelContentHash)} — regenerated`;
38
+ }
39
+ }
40
+ const body = buildBody(formatStalenessBanner(currentStamp));
41
+ const tmpPath = `${outputPath}.${randomUUID()}.tmp`;
42
+ await writeFile(tmpPath, body, "utf8");
43
+ await rename(tmpPath, outputPath);
44
+ return { outputPath, ...(staleWarning ? { staleWarning } : {}) };
45
+ }
46
+ //# sourceMappingURL=side-channel.js.map
@@ -0,0 +1,57 @@
1
+ import type { ModelGraph } from "../loader/model-graph.js";
2
+ import type { ModelNode } from "../schema/index.js";
3
+ /**
4
+ * Reusable model-slice join (Proposal 006 A5). Given a start node id and a
5
+ * traversal depth, produce the connected task-relevant sub-graph:
6
+ * flow→loops/junctions/sub-flows, loop→junctions/scenarios/parent/effective
7
+ * constraints, junction→loops/scenarios, scenario→referrers/applies_to.
8
+ *
9
+ * This is deliberately factored out of the `inspect` CLI command so the
10
+ * Phase 2 query family (`model impact`/`plan`/`scenario`/`evidence`) reuses
11
+ * one traversal + one summary/truncation module (proposal 001 §4.2), rather
12
+ * than each command re-deriving "what's related to X".
13
+ */
14
+ export interface SliceNode {
15
+ id: string;
16
+ kind: ModelNode["kind"];
17
+ /** Shortest hop distance from the root (root = 0). */
18
+ depth: number;
19
+ node: ModelNode;
20
+ /**
21
+ * For loops only: scenario ids that apply via `applies_to` (effective
22
+ * constraints, Decision 004 技术点 2) — computed at query time, not
23
+ * hand-listed on the node. Undefined for non-loop kinds.
24
+ */
25
+ effectiveConstraints?: string[];
26
+ }
27
+ /** A neighbor one hop past the traversal frontier, surfaced as a pointer (not expanded). */
28
+ export interface FrontierPointer {
29
+ id: string;
30
+ kind: ModelNode["kind"];
31
+ /** An in-slice node that references this pointer (so the user knows where it hangs off). */
32
+ fromId: string;
33
+ }
34
+ export interface ModelSlice {
35
+ rootId: string;
36
+ rootKind: ModelNode["kind"];
37
+ maxDepth: number;
38
+ /** Reached nodes, ordered by (depth, id). Includes the root at depth 0. */
39
+ nodes: SliceNode[];
40
+ /** Neighbors beyond `maxDepth` that were not expanded — expand with `--depth`. */
41
+ frontierPointers: FrontierPointer[];
42
+ }
43
+ /**
44
+ * Depth-limited breadth-first slice from `rootId`. Returns undefined if the
45
+ * root id isn't a node in the graph (the CLI turns that into an "unknown id"
46
+ * error).
47
+ *
48
+ * Standard mark-at-enqueue BFS: a node is added to `enqueued` the moment it's
49
+ * first discovered, so (a) each node is enqueued exactly once and recorded at
50
+ * its shortest distance from the root, and (b) any node reachable within
51
+ * `maxDepth` is marked (via its shallower parent, which the depth-ordered FIFO
52
+ * dequeues first) before a depth==maxDepth sibling could add it to the
53
+ * frontier — so `frontierPointers` and the in-slice node set never overlap by
54
+ * construction, no post-hoc reconciliation needed.
55
+ */
56
+ export declare function sliceModel(graph: ModelGraph, rootId: string, maxDepth: number): ModelSlice | undefined;
57
+ //# sourceMappingURL=slice.d.ts.map