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,35 @@
1
+ import { join } from "node:path";
2
+ import { loadModel } from "../../loader/load-model.js";
3
+ import { writeSideChannel } from "../../query/side-channel.js";
4
+ import { sliceModel } from "../../query/slice.js";
5
+ import { renderSliceMarkdown, summarizeSlice } from "../../query/summary.js";
6
+ const DEFAULT_DEPTH = 2;
7
+ /**
8
+ * `loopgraph inspect <node-id>`: slice the model around one node and write a
9
+ * staleness-stamped side-channel file, returning a compact summary for stdout
10
+ * (proposal 001 §4.2 shape: summary + workspace file path). Throws on an
11
+ * unknown id or unparseable model — the CLI turns those into exit code 1.
12
+ */
13
+ export async function runInspect(targetDir, nodeId, options = {}) {
14
+ const depth = options.depth ?? DEFAULT_DEPTH;
15
+ if (!Number.isInteger(depth) || depth < 0) {
16
+ throw new Error(`--depth must be a non-negative integer, got "${depth}"`);
17
+ }
18
+ const load = await loadModel(join(targetDir, "model"));
19
+ if (load.parseErrors.length > 0) {
20
+ throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
21
+ }
22
+ const slice = sliceModel(load.graph, nodeId, depth);
23
+ if (!slice) {
24
+ throw new Error(`unknown node id "${nodeId}" — not a feature/flow/loop/junction/scenario/debt`);
25
+ }
26
+ // node ids match the id-regex charset (L1 / C1 / J-x / GWT-x), all filename-safe.
27
+ const { outputPath, staleWarning } = await writeSideChannel(targetDir, `inspect-${nodeId}`, (banner) => renderSliceMarkdown(slice, banner));
28
+ return {
29
+ rootId: nodeId,
30
+ outputPath,
31
+ summary: summarizeSlice(slice, outputPath),
32
+ ...(staleWarning ? { staleWarning } : {}),
33
+ };
34
+ }
35
+ //# sourceMappingURL=inspect.js.map
@@ -0,0 +1,121 @@
1
+ import type { ImplementationFact, SignalKind } from "../../adapters/agenthub/extract.js";
2
+ import { type Inv1CheckResult } from "../../validate/inv1/check.js";
3
+ import type { T0Result } from "../../validate/types.js";
4
+ export declare const SNAPSHOT_SCHEMA_VERSION: 1;
5
+ /**
6
+ * A fact's stable identity for drift comparison. Deliberately DROPS the source
7
+ * `line` — the extractor documents line as "not a stable anchor", so a cosmetic
8
+ * line shift must NOT register as drift. `detail`/`unanalyzable` ARE compared:
9
+ * a queue that becomes env-suffixed, or a name that goes unanalyzable, is a real
10
+ * change worth surfacing.
11
+ */
12
+ export interface FactDigest {
13
+ signal: SignalKind;
14
+ name: string;
15
+ filePath: string;
16
+ detail?: string;
17
+ unanalyzable?: boolean;
18
+ }
19
+ /**
20
+ * The T2 nightly artifact (Proposal 006 D2). A machine-readable full-scan
21
+ * snapshot: the facts inventory plus T0/INV-1 health, stamped with the adapter
22
+ * version and the scanned commit. Two uses: (1) a PR job reads it as a warm
23
+ * starting point instead of re-scanning from cold; (2) two snapshots diff into a
24
+ * drift report. 001 §6 red line: T2 NEVER gates a PR — this command is
25
+ * standalone and `check` never calls it.
26
+ */
27
+ export interface Snapshot {
28
+ schemaVersion: typeof SNAPSHOT_SCHEMA_VERSION;
29
+ /** The adapter that produced the facts — a bump here explains fact churn that isn't real drift. */
30
+ adapterVersion: string;
31
+ /** HEAD of the scanned repo when known (git rev-parse), else undefined. */
32
+ commit?: string;
33
+ /** ISO timestamp; injected so the artifact is reproducible in tests. */
34
+ generatedAt: string;
35
+ t0: {
36
+ ok: boolean;
37
+ errors: number;
38
+ warnings: number;
39
+ };
40
+ inv1: {
41
+ ran: boolean;
42
+ skippedReason?: string;
43
+ violations: number;
44
+ unanalyzable: number;
45
+ writePoints: number;
46
+ };
47
+ /** Sorted fact inventory (identity fields only — see FactDigest). */
48
+ facts: FactDigest[];
49
+ factCount: number;
50
+ timingMs: number;
51
+ }
52
+ export interface SnapshotDrift {
53
+ /** true when the adapter version differs — fact changes below may be extractor churn, not real drift. */
54
+ adapterBumped: boolean;
55
+ addedFacts: FactDigest[];
56
+ removedFacts: FactDigest[];
57
+ /** same identity (signal+name+filePath), different detail/unanalyzable. */
58
+ changedFacts: {
59
+ before: FactDigest;
60
+ after: FactDigest;
61
+ }[];
62
+ t0Delta: {
63
+ errors: number;
64
+ warnings: number;
65
+ };
66
+ /**
67
+ * INV-1 deltas. `unanalyzable` matters as much as `violations`: a write point
68
+ * the canonical-writer scan can no longer analyze is a real loss of coverage,
69
+ * not a no-op — it must not report as "no drift".
70
+ */
71
+ inv1Delta: {
72
+ violations: number;
73
+ unanalyzable: number;
74
+ };
75
+ /**
76
+ * true when nothing in the TARGET changed (facts, T0, INV-1). An adapter
77
+ * version bump alone does NOT make it dirty (it's a tooling change, not target
78
+ * drift) — but `renderDrift` still surfaces the bump so it's never hidden.
79
+ */
80
+ clean: boolean;
81
+ }
82
+ /**
83
+ * Assemble a Snapshot from already-run scan results. PURE — all non-determinism
84
+ * (commit, timestamp, adapter version) is passed in, so it is fully unit-testable.
85
+ */
86
+ export declare function buildSnapshot(meta: {
87
+ adapterVersion: string;
88
+ commit?: string | undefined;
89
+ generatedAt: string;
90
+ timingMs: number;
91
+ }, t0: T0Result, facts: ImplementationFact[], inv1: Inv1CheckResult | undefined): Snapshot;
92
+ /**
93
+ * Diff two snapshots into a drift report. PURE. Compares the fact inventory by
94
+ * stable identity (added/removed/value-changed) and the T0/INV-1 counts.
95
+ */
96
+ export declare function diffSnapshots(prev: Snapshot, curr: Snapshot): SnapshotDrift;
97
+ export interface SnapshotOptions {
98
+ repoRoot?: string | undefined;
99
+ /** Machine cache dir for facts (B3); null disables. Default: the shared cache. */
100
+ cacheDir?: string | null | undefined;
101
+ /** Injected for reproducibility; defaults to now. */
102
+ generatedAt?: string | undefined;
103
+ /** Injected for tests; defaults to `git rev-parse HEAD` of repoRoot. */
104
+ commit?: string | undefined;
105
+ }
106
+ /**
107
+ * Run a full T2 scan and build the snapshot (Proposal 006 D2): T0 over the
108
+ * model, facts over the repo (which also WARMS the machine cache — the whole
109
+ * point of the nightly run), and a full INV-1 pass. Returns the snapshot; the
110
+ * CLI persists it. Never called from the PR-gate `check` path (001 §6).
111
+ */
112
+ export declare function runSnapshot(targetDir: string, options?: SnapshotOptions): Promise<Snapshot>;
113
+ /** Load a previously-written snapshot (for drift comparison). undefined if absent/unreadable. */
114
+ export declare function loadSnapshot(path: string): Promise<Snapshot | undefined>;
115
+ /** One-line human summary of a snapshot's health. */
116
+ export declare function renderSnapshotSummary(s: Snapshot): string;
117
+ /** Human drift report between two snapshots (Proposal 006 D2). */
118
+ export declare function renderDrift(drift: SnapshotDrift): string[];
119
+ /** Persist a snapshot as pretty JSON, atomically (temp file + rename). */
120
+ export declare function writeSnapshot(path: string, snapshot: Snapshot): Promise<void>;
121
+ //# sourceMappingURL=snapshot.d.ts.map
Binary file
@@ -0,0 +1,34 @@
1
+ import type { StalenessStamp } from "../../staleness.js";
2
+ import type { MermaidValidationResult } from "../../views/validate-mermaid.js";
3
+ export interface ViewOptions {
4
+ /**
5
+ * Actually render the generated mermaid with mmdc and report whether
6
+ * it parses (Decision record 004 技术点 5). Off by default: mmdc needs
7
+ * a real puppeteer-launched browser, which isn't a fair default
8
+ * runtime requirement for every consumer of `loopgraph view` — see
9
+ * src/views/validate-mermaid.ts's docstring.
10
+ */
11
+ validate?: boolean;
12
+ }
13
+ export interface ViewResult {
14
+ flowId: string;
15
+ outputPath: string;
16
+ mermaid: string;
17
+ stamp: StalenessStamp;
18
+ validation?: MermaidValidationResult;
19
+ }
20
+ /**
21
+ * Generates a mermaid view for one Flow (Phase 1 scope: C1 only, per
22
+ * proposal 001 §12) and writes it, staleness-stamped, to the
23
+ * `.loopgraph/ws/` side-channel (proposal 001 §5 — query-side-channel
24
+ * output, gitignored, regenerated on demand, not a cache).
25
+ *
26
+ * Always writes the file, even when `--validate` reports the diagram
27
+ * invalid — same posture as `loopgraph check`, which reports violations
28
+ * rather than refusing to run: hiding the broken output would remove
29
+ * the one artifact useful for debugging what went wrong. The CLI layer
30
+ * (src/cli/run.ts) is what turns an "invalid" validation result into a
31
+ * non-zero exit code.
32
+ */
33
+ export declare function runView(targetDir: string, flowId: string, options?: ViewOptions): Promise<ViewResult>;
34
+ //# sourceMappingURL=view.d.ts.map
@@ -0,0 +1,45 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadModel } from "../../loader/load-model.js";
4
+ import { computeStalenessStamp, formatStalenessBanner } from "../../staleness.js";
5
+ import { renderFlowMermaid } from "../../views/flow-mermaid.js";
6
+ import { validateMermaid } from "../../views/validate-mermaid.js";
7
+ /**
8
+ * Generates a mermaid view for one Flow (Phase 1 scope: C1 only, per
9
+ * proposal 001 §12) and writes it, staleness-stamped, to the
10
+ * `.loopgraph/ws/` side-channel (proposal 001 §5 — query-side-channel
11
+ * output, gitignored, regenerated on demand, not a cache).
12
+ *
13
+ * Always writes the file, even when `--validate` reports the diagram
14
+ * invalid — same posture as `loopgraph check`, which reports violations
15
+ * rather than refusing to run: hiding the broken output would remove
16
+ * the one artifact useful for debugging what went wrong. The CLI layer
17
+ * (src/cli/run.ts) is what turns an "invalid" validation result into a
18
+ * non-zero exit code.
19
+ */
20
+ export async function runView(targetDir, flowId, options = {}) {
21
+ const modelDir = join(targetDir, "model");
22
+ const load = await loadModel(modelDir);
23
+ if (load.parseErrors.length > 0) {
24
+ throw new Error(`model has ${load.parseErrors.length} parse error(s) — run "loopgraph check" first`);
25
+ }
26
+ const mermaid = renderFlowMermaid(load.graph, flowId);
27
+ const stamp = await computeStalenessStamp(modelDir, targetDir);
28
+ const wsDir = join(targetDir, ".loopgraph", "ws");
29
+ await mkdir(wsDir, { recursive: true });
30
+ const outputPath = join(wsDir, `view-${flowId}.md`);
31
+ const body = [
32
+ formatStalenessBanner(stamp),
33
+ "",
34
+ `# ${flowId} view`,
35
+ "",
36
+ "```mermaid",
37
+ mermaid,
38
+ "```",
39
+ "",
40
+ ].join("\n");
41
+ await writeFile(outputPath, body, "utf8");
42
+ const validation = options.validate ? await validateMermaid(mermaid) : undefined;
43
+ return { flowId, outputPath, mermaid, stamp, ...(validation ? { validation } : {}) };
44
+ }
45
+ //# sourceMappingURL=view.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./run.js";
3
+ const io = {
4
+ log: (line) => {
5
+ process.stdout.write(`${line}\n`);
6
+ },
7
+ error: (line) => {
8
+ process.stderr.write(`${line}\n`);
9
+ },
10
+ };
11
+ run(process.argv.slice(2), io)
12
+ .then((exitCode) => {
13
+ process.exitCode = exitCode;
14
+ })
15
+ .catch((err) => {
16
+ io.error(err instanceof Error ? err.message : String(err));
17
+ process.exitCode = 1;
18
+ });
19
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ export interface CliIO {
2
+ log: (line: string) => void;
3
+ error: (line: string) => void;
4
+ }
5
+ /**
6
+ * Core dispatch, factored out of the process entry point so tests can
7
+ * drive it in-process (real filesystem via temp dirs, no subprocess
8
+ * spawning, no mocking process.argv/stdout).
9
+ */
10
+ export declare function run(argv: string[], io: CliIO): Promise<number>;
11
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1,329 @@
1
+ import { runFacts } from "../adapters/agenthub/facts.js";
2
+ import { loadModel } from "../loader/load-model.js";
3
+ import { startMcpServer } from "../mcp/server.js";
4
+ import { runQuery } from "../query/run-query.js";
5
+ import { inv1ViolationsFrom } from "../validate/inv1/check.js";
6
+ import { reconcileFacts } from "../validate/unregistered.js";
7
+ import { parseFlags } from "./args.js";
8
+ import { runCheck } from "./commands/check.js";
9
+ import { runImport } from "./commands/import.js";
10
+ import { runInit } from "./commands/init.js";
11
+ import { runInspect } from "./commands/inspect.js";
12
+ import { diffSnapshots, loadSnapshot, renderDrift, renderSnapshotSummary, runSnapshot, writeSnapshot, } from "./commands/snapshot.js";
13
+ import { runView } from "./commands/view.js";
14
+ const QUERY_COMMANDS = [
15
+ "impact",
16
+ "plan",
17
+ "scenario",
18
+ "evidence",
19
+ "matrix",
20
+ ];
21
+ function formatViolation(v) {
22
+ const marker = v.severity === "error" ? "✗" : "⚠"; // ✗ / ⚠
23
+ const loc = v.file ? ` [${v.file}]` : v.nodeId ? ` [${v.nodeId}]` : "";
24
+ return `${marker} ${v.check}${loc}: ${v.message}`;
25
+ }
26
+ const USAGE = "usage: loopgraph <init|import|check> [dir] [--adapter name] [--repo-root path] [--strict-anchors] [--diff base-ref]\n" +
27
+ " loopgraph view <flow-id> [dir] [--validate]\n" +
28
+ " loopgraph inspect <node-id> [dir] [--depth n]\n" +
29
+ " loopgraph <impact|plan|scenario|evidence|matrix> <id> [dir]\n" +
30
+ " loopgraph mcp [dir] # start the stdio MCP server\n" +
31
+ " loopgraph facts [repo] # extract implementation facts (pg-boss queues, pollers)\n" +
32
+ " loopgraph snapshot [dir] --repo-root path [--out file] [--drift prior.json] # T2 nightly scan artifact";
33
+ /**
34
+ * Reads a `--flag value` string option. Returns `{ error }` when the flag
35
+ * was given with no value (parseFlags then records it as boolean `true`)
36
+ * — that's a malformed invocation (e.g. `--repo-root` as the last arg,
37
+ * or immediately followed by another `--flag`) and must be rejected
38
+ * loudly, not silently treated as "flag not given" (which previously let
39
+ * `check --repo-root` with a missing value quietly skip anchor-existence
40
+ * checking instead of erroring on the typo).
41
+ */
42
+ function readStringFlag(flags, name) {
43
+ const raw = flags[name];
44
+ if (raw === undefined)
45
+ return { value: undefined };
46
+ if (typeof raw === "string")
47
+ return { value: raw };
48
+ return { value: undefined, error: `--${name} requires a value` };
49
+ }
50
+ /**
51
+ * Core dispatch, factored out of the process entry point so tests can
52
+ * drive it in-process (real filesystem via temp dirs, no subprocess
53
+ * spawning, no mocking process.argv/stdout).
54
+ */
55
+ export async function run(argv, io) {
56
+ const [command, ...rest] = argv;
57
+ const { positionals, flags } = parseFlags(rest);
58
+ const targetDir = positionals[0] ?? process.cwd();
59
+ switch (command) {
60
+ case "init": {
61
+ const result = await runInit(targetDir);
62
+ for (const c of result.created)
63
+ io.log(`created ${c}`);
64
+ for (const s of result.skipped)
65
+ io.log(`skipped ${s} (already exists)`);
66
+ return 0;
67
+ }
68
+ case "import": {
69
+ const adapterFlag = readStringFlag(flags, "adapter");
70
+ if (adapterFlag.error) {
71
+ io.error(`${adapterFlag.error}. ${USAGE}`);
72
+ return 1;
73
+ }
74
+ const adapter = adapterFlag.value ?? "agenthub";
75
+ const result = await runImport(targetDir, adapter);
76
+ io.log(`imported "${result.adapter}" seed into ${result.destination}`);
77
+ return 0;
78
+ }
79
+ case "check": {
80
+ const repoRootFlag = readStringFlag(flags, "repo-root");
81
+ if (repoRootFlag.error) {
82
+ io.error(`${repoRootFlag.error}. ${USAGE}`);
83
+ return 1;
84
+ }
85
+ const repoRoot = repoRootFlag.value;
86
+ const strictAnchorExistence = flags["strict-anchors"] === true;
87
+ const diffFlag = readStringFlag(flags, "diff");
88
+ if (diffFlag.error) {
89
+ io.error(`${diffFlag.error}. ${USAGE}`);
90
+ return 1;
91
+ }
92
+ const { t0, inv1, inv1ConfigError, diff, baselineViolations } = await runCheck(targetDir, {
93
+ repoRoot,
94
+ strictAnchorExistence,
95
+ diffBase: diffFlag.value,
96
+ });
97
+ const violations = [...t0.violations];
98
+ if (baselineViolations)
99
+ violations.push(...baselineViolations);
100
+ if (inv1)
101
+ violations.push(...inv1ViolationsFrom(inv1));
102
+ for (const v of violations)
103
+ io.log(formatViolation(v));
104
+ if (diff) {
105
+ if (!diff.changed) {
106
+ io.log(`⚠ --diff ${diff.baseRef}: could not resolve diff (bad ref / not git) — full scan`);
107
+ }
108
+ else {
109
+ io.log(`--diff ${diff.baseRef}: ${diff.changed.length} changed file(s), ${diff.affected.length} affected model node(s)${diff.incremental ? " (INV-1 incremental)" : ""}`);
110
+ for (const a of diff.affected)
111
+ io.log(` affects ${a.nodeId} (${a.kind}) via ${a.anchor}`);
112
+ }
113
+ }
114
+ if (inv1ConfigError)
115
+ io.error(`✗ inv1-config: ${inv1ConfigError}`);
116
+ if (inv1) {
117
+ if (!inv1.ran) {
118
+ io.log(`⚠ INV-1 scan skipped: ${inv1.skippedReason}`);
119
+ }
120
+ else {
121
+ const byVerdict = inv1.writePoints.reduce((acc, w) => {
122
+ acc[w.verdict] = (acc[w.verdict] ?? 0) + 1;
123
+ return acc;
124
+ }, {});
125
+ io.log(`INV-1 scan: ${inv1.filesScanned}/${inv1.candidateFiles} candidate file(s), ` +
126
+ `${byVerdict.allowed ?? 0} allowed / ${byVerdict.violation ?? 0} violation(s) / ${byVerdict.unanalyzable ?? 0} unanalyzable, ` +
127
+ `${inv1.timingMs.toFixed(0)}ms`);
128
+ }
129
+ }
130
+ const errorCount = violations.filter((v) => v.severity === "error").length;
131
+ const warningCount = violations.length - errorCount;
132
+ const ok = errorCount === 0 && !inv1ConfigError;
133
+ io.log(ok
134
+ ? `T0 passed (${warningCount} warning(s))`
135
+ : `T0 failed (${errorCount} error(s), ${warningCount} warning(s))`);
136
+ return ok ? 0 : 1;
137
+ }
138
+ case "view": {
139
+ // Unlike init/import/check, positionals[0] here is the required
140
+ // <flow-id>, not the target dir — the shared `targetDir` computed
141
+ // above (which assumes positionals[0] is a dir) doesn't apply.
142
+ const flowId = positionals[0];
143
+ if (!flowId) {
144
+ io.error(`missing <flow-id>. ${USAGE}`);
145
+ return 1;
146
+ }
147
+ const viewTargetDir = positionals[1] ?? process.cwd();
148
+ const validate = flags.validate === true;
149
+ let result;
150
+ try {
151
+ result = await runView(viewTargetDir, flowId, { validate });
152
+ }
153
+ catch (err) {
154
+ io.error(err instanceof Error ? err.message : String(err));
155
+ return 1;
156
+ }
157
+ io.log(`wrote ${result.outputPath}`);
158
+ if (!result.validation)
159
+ return 0;
160
+ if (result.validation.status === "valid") {
161
+ io.log("mermaid validation: ok");
162
+ return 0;
163
+ }
164
+ if (result.validation.status === "unavailable") {
165
+ io.log(`⚠ mermaid validation skipped: ${result.validation.reason}`);
166
+ return 0;
167
+ }
168
+ io.log(`✗ mermaid validation failed: ${result.validation.error}`);
169
+ return 1;
170
+ }
171
+ case "inspect": {
172
+ // positionals[0] is the required <node-id>, not the target dir.
173
+ const nodeId = positionals[0];
174
+ if (!nodeId) {
175
+ io.error(`missing <node-id>. ${USAGE}`);
176
+ return 1;
177
+ }
178
+ const inspectTargetDir = positionals[1] ?? process.cwd();
179
+ const depthFlag = readStringFlag(flags, "depth");
180
+ if (depthFlag.error) {
181
+ io.error(`${depthFlag.error}. ${USAGE}`);
182
+ return 1;
183
+ }
184
+ let depth;
185
+ if (depthFlag.value !== undefined) {
186
+ depth = Number(depthFlag.value);
187
+ if (!Number.isInteger(depth) || depth < 0) {
188
+ io.error(`--depth must be a non-negative integer, got "${depthFlag.value}". ${USAGE}`);
189
+ return 1;
190
+ }
191
+ }
192
+ let result;
193
+ try {
194
+ result = await runInspect(inspectTargetDir, nodeId, depth === undefined ? {} : { depth });
195
+ }
196
+ catch (err) {
197
+ io.error(err instanceof Error ? err.message : String(err));
198
+ return 1;
199
+ }
200
+ if (result.staleWarning)
201
+ io.log(`⚠ ${result.staleWarning}`);
202
+ io.log(result.summary);
203
+ io.log(`wrote ${result.outputPath}`);
204
+ return 0;
205
+ }
206
+ case "mcp": {
207
+ // Long-lived stdio server: it owns stdout for the JSON-RPC protocol, so
208
+ // nothing else may write there. targetDir is positionals[0] (the dir with
209
+ // model/), defaulting to cwd.
210
+ const mcpTargetDir = positionals[0] ?? process.cwd();
211
+ await startMcpServer(mcpTargetDir);
212
+ return 0; // returns when the transport closes
213
+ }
214
+ case "facts": {
215
+ // Extract implementation facts from a target repo (positionals[0] = repo
216
+ // root, defaulting to cwd). `--no-cache` disables the B3 content cache.
217
+ const repo = positionals[0] ?? process.cwd();
218
+ const result = await runFacts(repo, flags["no-cache"] === true ? { cacheDir: null } : {});
219
+ if (!result.ran) {
220
+ io.log(`⚠ facts extraction skipped: ${result.skippedReason}`);
221
+ return 0;
222
+ }
223
+ const bySignal = {};
224
+ for (const f of result.facts) {
225
+ const list = bySignal[f.signal] ?? [];
226
+ list.push(f);
227
+ bySignal[f.signal] = list;
228
+ }
229
+ for (const [signal, list] of Object.entries(bySignal)) {
230
+ io.log(`${signal} (${list.length}):`);
231
+ for (const f of list) {
232
+ const detail = f.detail ? ` [${f.detail}${f.unanalyzable ? ", unanalyzable" : ""}]` : "";
233
+ io.log(` ${f.name} ${f.filePath}:${f.line}${detail}`);
234
+ }
235
+ }
236
+ io.log(`extracted ${result.facts.length} fact(s) from ${result.filesScanned}/${result.candidateFiles} candidate file(s), ${result.timingMs.toFixed(0)}ms`);
237
+ return 0;
238
+ }
239
+ case "reconcile": {
240
+ // C2 T1 (advisory): which extracted facts no model node registers.
241
+ // `loopgraph reconcile <model-dir> --repo-root <repo>`. Exits 0 always
242
+ // (T1 is report-tier until C3 promotes it); `--strict` flips unregistered
243
+ // facts into a non-zero exit for opt-in local use.
244
+ const repoRootFlag = readStringFlag(flags, "repo-root");
245
+ if (repoRootFlag.error || !repoRootFlag.value) {
246
+ io.error(`reconcile needs --repo-root <repo>. ${USAGE}`);
247
+ return 1;
248
+ }
249
+ const model = await loadModel(`${targetDir}/model`);
250
+ const factsResult = await runFacts(repoRootFlag.value, flags["no-cache"] === true ? { cacheDir: null } : {});
251
+ if (!factsResult.ran) {
252
+ io.log(`⚠ reconcile skipped: ${factsResult.skippedReason}`);
253
+ return 0;
254
+ }
255
+ const { registered, dormantSuppressed, unregistered, coveredFiles, unmatchedConsumedQueues } = reconcileFacts(factsResult.facts, model.graph);
256
+ for (const f of unregistered)
257
+ io.log(`⚠ unregistered ${f.signal}: ${f.name} ${f.filePath}:${f.line}`);
258
+ for (const f of dormantSuppressed)
259
+ io.log(`· dormant-suppressed ${f.signal}: ${f.name} ${f.filePath}:${f.line}`);
260
+ for (const q of unmatchedConsumedQueues)
261
+ io.log(`⚠ dangling consumes_queues: "${q}" matches no extracted queue (typo/stale?)`);
262
+ const total = factsResult.facts.length;
263
+ const pct = total === 0 ? 0 : Math.round((unregistered.length / total) * 100);
264
+ const dormantNote = dormantSuppressed.length > 0 ? `, ${dormantSuppressed.length} dormant-suppressed` : "";
265
+ // registered + dormantSuppressed + unregistered === total (disjoint).
266
+ io.log(`reconcile: ${registered.length}/${total} fact(s) registered${dormantNote}, ${unregistered.length} unregistered (${pct}%), ` +
267
+ `${coveredFiles.length} model-covered file(s)`);
268
+ return flags.strict === true && unregistered.length > 0 ? 1 : 0;
269
+ }
270
+ case "snapshot": {
271
+ // T2 nightly full-scan artifact (Proposal 006 D2). NEVER a PR gate
272
+ // (001 §6): this always exits 0 — drift is reported, not failed on.
273
+ const repoRootFlag = readStringFlag(flags, "repo-root");
274
+ const outFlag = readStringFlag(flags, "out");
275
+ const driftFlag = readStringFlag(flags, "drift");
276
+ for (const f of [repoRootFlag, outFlag, driftFlag]) {
277
+ if (f.error) {
278
+ io.error(`${f.error}. ${USAGE}`);
279
+ return 1;
280
+ }
281
+ }
282
+ const snapshot = await runSnapshot(targetDir, {
283
+ repoRoot: repoRootFlag.value,
284
+ ...(flags["no-cache"] === true ? { cacheDir: null } : {}),
285
+ });
286
+ io.log(renderSnapshotSummary(snapshot));
287
+ if (driftFlag.value) {
288
+ const prior = await loadSnapshot(driftFlag.value);
289
+ if (!prior) {
290
+ io.log(`⚠ --drift: no readable prior snapshot at ${driftFlag.value} — skipping drift`);
291
+ }
292
+ else {
293
+ for (const line of renderDrift(diffSnapshots(prior, snapshot)))
294
+ io.log(line);
295
+ }
296
+ }
297
+ const outPath = outFlag.value ?? `${targetDir}/.loopgraph/snapshot.json`;
298
+ await writeSnapshot(outPath, snapshot);
299
+ io.log(`wrote ${outPath}`);
300
+ return 0;
301
+ }
302
+ default: {
303
+ if (command && QUERY_COMMANDS.includes(command)) {
304
+ // impact / plan / scenario / evidence <id> [dir]
305
+ const id = positionals[0];
306
+ if (!id) {
307
+ io.error(`missing <id> for \`${command}\`. ${USAGE}`);
308
+ return 1;
309
+ }
310
+ const queryTargetDir = positionals[1] ?? process.cwd();
311
+ let result;
312
+ try {
313
+ result = await runQuery(queryTargetDir, command, id);
314
+ }
315
+ catch (err) {
316
+ io.error(err instanceof Error ? err.message : String(err));
317
+ return 1;
318
+ }
319
+ if (result.staleWarning)
320
+ io.log(`⚠ ${result.staleWarning}`);
321
+ io.log(result.summary);
322
+ return 0;
323
+ }
324
+ io.error(`unknown command "${command ?? ""}". ${USAGE}`);
325
+ return 1;
326
+ }
327
+ }
328
+ }
329
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure error-code → message mapping, factored out of findYamlFiles so
3
+ * every branch (ENOENT/ENOTDIR/EACCES/EPERM/unknown) is directly
4
+ * unit-testable without needing to actually reproduce each condition on
5
+ * a real filesystem — reproducing EACCES portably (across macOS/Linux/CI
6
+ * containers, some of which run as root and ignore chmod) is exactly the
7
+ * kind of flaky test this sidesteps. Returns undefined for an
8
+ * unrecognized/missing error code, signaling "rethrow as-is".
9
+ */
10
+ export declare function formatModelDirReadError(dir: string, code: string | undefined): string | undefined;
11
+ /**
12
+ * An unreadable model directory (missing, wrong permissions, or not
13
+ * actually a directory) is a setup error, not "zero nodes". Treating it
14
+ * as an empty model would make `runT0` trivially pass with zero
15
+ * violations — a false "T0 passed" that implies something was checked
16
+ * when nothing was. This surfaces a clear, actionable error instead of
17
+ * whatever raw errno a bare `readdir` would throw.
18
+ *
19
+ * Lives in its own module (not inlined in load-model.ts) because it's
20
+ * reused by src/staleness.ts (computeModelContentHash needs the same
21
+ * "every *.yaml/*.yml file under a model dir" listing to hash) — a
22
+ * second, unrelated caller that has no business depending on
23
+ * `loadModel`'s own orchestration module.
24
+ */
25
+ export declare function findYamlFiles(dir: string): Promise<string[]>;
26
+ //# sourceMappingURL=find-yaml-files.d.ts.map