@descryy/mcp 0.1.2 → 0.2.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 (94) hide show
  1. package/dist/bin/descry-mcp.js +24 -1
  2. package/dist/bin/descry-mcp.js.map +1 -1
  3. package/dist/disclosure-ledger.d.ts +38 -0
  4. package/dist/disclosure-ledger.d.ts.map +1 -0
  5. package/dist/disclosure-ledger.js +40 -0
  6. package/dist/disclosure-ledger.js.map +1 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/protocol.d.ts +12 -3
  11. package/dist/protocol.d.ts.map +1 -1
  12. package/dist/protocol.js +12 -3
  13. package/dist/protocol.js.map +1 -1
  14. package/dist/render.d.ts +9 -0
  15. package/dist/render.d.ts.map +1 -1
  16. package/dist/render.js +17 -2
  17. package/dist/render.js.map +1 -1
  18. package/dist/runtime-registry.d.ts +63 -0
  19. package/dist/runtime-registry.d.ts.map +1 -0
  20. package/dist/runtime-registry.js +131 -0
  21. package/dist/runtime-registry.js.map +1 -0
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +5 -0
  24. package/dist/server.js.map +1 -1
  25. package/dist/session.d.ts +74 -2
  26. package/dist/session.d.ts.map +1 -1
  27. package/dist/session.js +177 -4
  28. package/dist/session.js.map +1 -1
  29. package/dist/tools/analyze.d.ts.map +1 -1
  30. package/dist/tools/analyze.js +30 -1
  31. package/dist/tools/analyze.js.map +1 -1
  32. package/dist/tools/cross-pr.d.ts.map +1 -1
  33. package/dist/tools/cross-pr.js +1 -0
  34. package/dist/tools/cross-pr.js.map +1 -1
  35. package/dist/tools/git-diff.d.ts.map +1 -1
  36. package/dist/tools/git-diff.js +24 -3
  37. package/dist/tools/git-diff.js.map +1 -1
  38. package/dist/tools/git-history.d.ts.map +1 -1
  39. package/dist/tools/git-history.js +1 -0
  40. package/dist/tools/git-history.js.map +1 -1
  41. package/dist/tools/history.js +1 -1
  42. package/dist/tools/history.js.map +1 -1
  43. package/dist/tools/impact.js +1 -1
  44. package/dist/tools/impact.js.map +1 -1
  45. package/dist/tools/index.d.ts +3 -1
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +4 -0
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/kit.d.ts +1 -0
  50. package/dist/tools/kit.d.ts.map +1 -1
  51. package/dist/tools/kit.js +1 -0
  52. package/dist/tools/kit.js.map +1 -1
  53. package/dist/tools/link-workspace.d.ts.map +1 -1
  54. package/dist/tools/link-workspace.js +13 -1
  55. package/dist/tools/link-workspace.js.map +1 -1
  56. package/dist/tools/mark-incident.d.ts +69 -0
  57. package/dist/tools/mark-incident.d.ts.map +1 -0
  58. package/dist/tools/mark-incident.js +212 -0
  59. package/dist/tools/mark-incident.js.map +1 -0
  60. package/dist/tools/observe-runtime.d.ts +198 -0
  61. package/dist/tools/observe-runtime.d.ts.map +1 -0
  62. package/dist/tools/observe-runtime.js +951 -0
  63. package/dist/tools/observe-runtime.js.map +1 -0
  64. package/dist/tools/pr-analysis.d.ts +60 -13
  65. package/dist/tools/pr-analysis.d.ts.map +1 -1
  66. package/dist/tools/pr-analysis.js +54 -11
  67. package/dist/tools/pr-analysis.js.map +1 -1
  68. package/dist/tools/propagation.d.ts.map +1 -1
  69. package/dist/tools/propagation.js +1 -0
  70. package/dist/tools/propagation.js.map +1 -1
  71. package/dist/tools/questions.d.ts.map +1 -1
  72. package/dist/tools/questions.js +66 -4
  73. package/dist/tools/questions.js.map +1 -1
  74. package/dist/tools/refusal-fetch.d.ts.map +1 -1
  75. package/dist/tools/refusal-fetch.js +1 -0
  76. package/dist/tools/refusal-fetch.js.map +1 -1
  77. package/dist/tools/runtime-incident.d.ts +92 -0
  78. package/dist/tools/runtime-incident.d.ts.map +1 -0
  79. package/dist/tools/runtime-incident.js +144 -0
  80. package/dist/tools/runtime-incident.js.map +1 -0
  81. package/dist/tools/scope.d.ts.map +1 -1
  82. package/dist/tools/scope.js +1 -0
  83. package/dist/tools/scope.js.map +1 -1
  84. package/dist/tools/similar-incidents.d.ts +13 -0
  85. package/dist/tools/similar-incidents.d.ts.map +1 -1
  86. package/dist/tools/similar-incidents.js +22 -8
  87. package/dist/tools/similar-incidents.js.map +1 -1
  88. package/dist/tools/verification-status.d.ts.map +1 -1
  89. package/dist/tools/verification-status.js +1 -0
  90. package/dist/tools/verification-status.js.map +1 -1
  91. package/dist/tools/verify-claim.d.ts.map +1 -1
  92. package/dist/tools/verify-claim.js +5 -0
  93. package/dist/tools/verify-claim.js.map +1 -1
  94. package/package.json +15 -4
@@ -0,0 +1,951 @@
1
+ /**
2
+ * `observe_runtime` — boot or attach to a real application, watch it, and write
3
+ * what was witnessed into the graph as R4 facts.
4
+ *
5
+ * **The second tool that writes, and the first that writes something no
6
+ * re-index can reproduce.** `analyze` re-derives the graph from source already
7
+ * on disk, so nothing it writes is a new claim about the world. This one runs
8
+ * a real process and records what it saw happen, which is the one accuracy
9
+ * source architecture §11B.3 calls *"the core technical moat"* and the one a
10
+ * purely static tool is structurally unable to reach: *"a purely static
11
+ * code-graph tool is capped at R3 permanently. It has no runtime."*
12
+ *
13
+ * ## What this composes, and what it invents
14
+ *
15
+ * It invents no mechanism. Every stage already existed, gate-verified, in
16
+ * `descry-runtime`, and every one of them was dormant — the whole point of
17
+ * `DEC-NEXT-mcp-runtime-dependency-boundary-for-r4-evidence`, which measured
18
+ * that `applyRuntimeObservations` had **zero production callers anywhere**,
19
+ * not in `descry-desktop` and not in `descry-runtime`'s own pipeline. Four
20
+ * shipped components in a row, and this tool is the wire between them:
21
+ *
22
+ * 1. `runInstrumentedExecution` (`@descryy/runtime-orchestrator`) spawns or
23
+ * attaches the declared services, starts the adapter's collectors, drains
24
+ * them for a stated window, and writes every item through `EvidenceStore`.
25
+ * 2. `correlateExecution` (`@descryy/runtime-evidence-correlation`) resolves
26
+ * each evidence item to the graph node it is *about* — the resolve-then-
27
+ * attribute pass. This answers identity, not edges.
28
+ * 3. `confirmObservedFrontendCaller` (`@descryy/runtime-graph-correlator`)
29
+ * turns a captured call-site stack plus a resolved endpoint into a
30
+ * `RuntimeEdgeObservation`, and
31
+ * 4. calls `applyRuntimeObservations` (`@descryy/core`) with it, which
32
+ * promotes, mints or contradicts the edge and writes the EARNED ledger.
33
+ *
34
+ * ## Why stage 3 exists rather than deriving edges from stage 2 directly
35
+ *
36
+ * `uat-phase-1-bug-fixes.md` Phase 2 describes step 2 as producing
37
+ * `RuntimeEdgeObservation[]`. It does not, and the difference is load-bearing
38
+ * rather than pedantic: `correlateExecution` returns
39
+ * `CorrelationAttribution`s — *(evidenceId, graphNodeId)* pairs saying which
40
+ * single node an observation is about. An edge needs **two** endpoints and a
41
+ * witnessed relationship between them, and manufacturing one by pairing up
42
+ * attributions that happen to co-occur in the same run would mint edges from
43
+ * temporal coincidence. That is precisely the wrong-direction failure rule 2
44
+ * exists to prevent, arriving through the one mechanism built to make the
45
+ * graph *more* trustworthy.
46
+ *
47
+ * So the observation comes from the one shipped producer that can honestly
48
+ * make one: a captured stack naming the caller, against an endpoint the same
49
+ * observation named. Both endpoints come from the observation itself. Every
50
+ * other correlated item is reported in the counts and produces no edge, which
51
+ * is a disclosed gap rather than a silent one.
52
+ *
53
+ * ## What this closure can actually witness today — measured, not assumed
54
+ *
55
+ * The wire is complete and the arrow it carries is narrow, and the second half
56
+ * of that sentence is worth stating plainly because a reader would otherwise
57
+ * infer it from a zero. HTTP evidence carrying a call-site stack is emitted by
58
+ * exactly one collector in `descry-runtime`: the browser network collector,
59
+ * via its `fetch()` initiator capture. Checked across every collector in this
60
+ * server's dependency closure — `InboundProxy` emits `{method, path, status}`
61
+ * and no stack; `ExternalRequestCollector` sets `stackTrace: null` outright,
62
+ * because it parses a log line the application printed and a printed line
63
+ * carries no stack; `LogCollector` (the only thing the TypeScript runtime
64
+ * adapter builds) attaches a stack to log and error lines, which resolve a
65
+ * FUNCTION and name no endpoint.
66
+ *
67
+ * So a backend-only run resolves both kinds of node and writes no edge. The
68
+ * composition is right, the gate is real, and the arrow is currently reachable
69
+ * only from a browser-driven run — which `runInstrumentedExecution`'s own
70
+ * contract explicitly does not do ("does not drive a browser"), and which is
71
+ * therefore a different orchestration rather than a missing argument here.
72
+ * `STANDING_NOTES` says this on every call, whatever the counts come back as,
73
+ * because "no edge was written" and "nothing here could have written one" are
74
+ * different statements and only the second one is a fact about Descry.
75
+ *
76
+ * ## Why no denial is ever emitted
77
+ *
78
+ * `applyRuntimeObservations` accepts `held: false`. Nothing here ever sends
79
+ * one, and `confirmObservedFrontendCaller`'s own header explains why: a run
80
+ * establishes that a call *happened*; it cannot establish that one did not,
81
+ * because a run exercises the paths it happens to take. Demoting a correct
82
+ * edge on the strength of a route this run did not visit would be worse than
83
+ * never running.
84
+ *
85
+ * ## Class and tier
86
+ *
87
+ * `action` — DEC-278's own test is *"can this call's effect change a later
88
+ * finding without the developer having said so"*, and this one spawns
89
+ * processes and writes R4 edges that cap every later reliability class
90
+ * differently. It is gated by the same two-call `confirmToken` handshake
91
+ * `questions` uses, and additionally by the environment profile's declared
92
+ * `safetyLevel` (DEC-270): booting a service is a **write** against the
93
+ * target, so a `readOnly` profile refuses. An all-attach run is genuinely
94
+ * read-only — `ServiceConfiguration.attach`'s own contract is that Descry
95
+ * never executes code in, or applies limits to, a process it did not spawn —
96
+ * so it is allowed under `readOnly`, and that distinction is stated in the
97
+ * disclosures rather than inferred silently.
98
+ *
99
+ * `evidence` — it reports what was witnessed and what was written. It draws no
100
+ * conclusion about the user's code; nothing here reads or writes a finding, a
101
+ * hypothesis or a category (RT-027).
102
+ *
103
+ * ## No new query tools
104
+ *
105
+ * None are needed and none are added. `impact`, `propagation` and the rest
106
+ * already read the `resolution` field, so an edge this tool promotes to R4
107
+ * becomes visible through every one of them on the next call, with no change
108
+ * to any of them.
109
+ */
110
+ import { mkdir } from "node:fs/promises";
111
+ import { dirname, isAbsolute, join } from "node:path";
112
+ import { buildGraph, counts, createConfirmedIncidentSource, persistGraph, } from "@descryy/core";
113
+ import { evaluateAction, validateProfile } from "@descryy/runtime-environment-profile";
114
+ import { correlateExecution } from "@descryy/runtime-evidence-correlation";
115
+ import { EvidenceStore } from "@descryy/runtime-evidence-store";
116
+ import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
117
+ import { runInstrumentedExecution } from "@descryy/runtime-orchestrator";
118
+ import { answer, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
119
+ import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
120
+ import { writeConfirmedIncident } from "../session.js";
121
+ import { runtimeObservedIncident } from "./runtime-incident.js";
122
+ /** Where evidence lands when the call does not say. Beside the graph, not inside it. */
123
+ export const DEFAULT_EVIDENCE_RELATIVE_PATH = join(".descry", "evidence.db");
124
+ /** How long collectors are drained after the services report ready, when unstated. */
125
+ const DEFAULT_OBSERVE_MS = 5_000;
126
+ const DEFAULT_TIMEOUT_MS = 60_000;
127
+ const DEFAULT_READINESS_TIMEOUT_MS = 30_000;
128
+ const READINESS_KINDS = ["http", "tcp-port", "command"];
129
+ const SCHEMA = {
130
+ type: "object",
131
+ properties: {
132
+ profile: {
133
+ type: "object",
134
+ description: "The environment this run targets. Every field is declared by you and never inferred from " +
135
+ "any other (DEC-270): a profile named \"staging\" with safetyLevel \"readOnly\" is read-only, " +
136
+ "and a profile named \"local\" with safetyLevel \"readOnly\" is too.",
137
+ properties: {
138
+ name: { type: "string", description: "Free-form. Matched against no vocabulary anywhere." },
139
+ url: { type: "string", description: "The target's base URL. Must parse." },
140
+ safetyLevel: {
141
+ type: "string",
142
+ enum: ["readOnly", "write", "destructiveWithApproval"],
143
+ description: "Booting a service is a write against the target, so \"readOnly\" refuses a run that " +
144
+ "spawns anything. A run in which every service uses \"attach\" spawns nothing and is " +
145
+ "permitted under \"readOnly\".",
146
+ },
147
+ credentialRef: {
148
+ type: "string",
149
+ description: "An opaque key into a credential store — never the secret itself.",
150
+ },
151
+ mode: { type: "string", enum: ["localBooted", "localAttached", "remote", "production"] },
152
+ },
153
+ required: ["name", "url", "safetyLevel", "credentialRef", "mode"],
154
+ additionalProperties: false,
155
+ },
156
+ services: {
157
+ type: "object",
158
+ description: "One entry per service, keyed by the name evidence will be attributed to. At least one is " +
159
+ "required. Exactly one of \"command\" or \"attach\" per service.",
160
+ additionalProperties: {
161
+ type: "object",
162
+ properties: {
163
+ command: { type: "string", description: "How to start it. Omit when using \"attach\"." },
164
+ cwd: {
165
+ type: "string",
166
+ description: "Working directory. Relative paths resolve against the repository root.",
167
+ },
168
+ port: {
169
+ type: "integer",
170
+ description: "Explicit opt-in only. Omit to get an ephemeral port. An attached service should set " +
171
+ "this when you already know what the running target bound — nothing here can allocate " +
172
+ "one on its behalf.",
173
+ },
174
+ dependsOn: {
175
+ type: "array",
176
+ items: { type: "string" },
177
+ description: "Service names that must be ready first. Declared, never inferred.",
178
+ },
179
+ env: { type: "object", additionalProperties: { type: "string" } },
180
+ attach: {
181
+ type: "object",
182
+ description: "Observe a process that is already running instead of spawning one. Descry never " +
183
+ "executes code in, signals, or applies resource limits to a process it did not spawn.",
184
+ properties: {
185
+ pid: { type: "integer" },
186
+ logFilePath: {
187
+ type: "string",
188
+ description: "A file the target already writes its stdout/stderr to.",
189
+ },
190
+ },
191
+ required: ["pid", "logFilePath"],
192
+ additionalProperties: false,
193
+ },
194
+ readiness: {
195
+ type: "object",
196
+ description: "Required per service — a run refuses rather than treat \"the process started\" as " +
197
+ "\"the service is up\". Only the three mechanisms expressible as JSON are offered here; " +
198
+ "\"log-pattern\" and \"custom-hook\" need a function and are not reachable through this " +
199
+ "tool, which is disclosed on every call rather than left to be discovered.",
200
+ properties: {
201
+ kind: { type: "string", enum: [...READINESS_KINDS] },
202
+ path: {
203
+ type: "string",
204
+ description: "kind \"http\": path appended to http://127.0.0.1:<resolved port>. Defaults to \"/\".",
205
+ },
206
+ expectedStatus: { type: "integer", description: "kind \"http\": defaults to any 2xx/3xx." },
207
+ host: { type: "string", description: "kind \"tcp-port\": defaults to 127.0.0.1." },
208
+ command: { type: "string", description: "kind \"command\": the executable to run." },
209
+ args: { type: "array", items: { type: "string" }, description: "kind \"command\"." },
210
+ timeoutMs: { type: "integer", description: `Defaults to ${DEFAULT_READINESS_TIMEOUT_MS}.` },
211
+ },
212
+ required: ["kind"],
213
+ additionalProperties: false,
214
+ },
215
+ },
216
+ required: ["cwd", "readiness"],
217
+ additionalProperties: false,
218
+ },
219
+ },
220
+ adapter: {
221
+ type: "object",
222
+ description: "The runtime adapter to observe with, named as a module specifier and imported at run time. " +
223
+ "This server depends on none of descry-runtime's per-language runtime adapters by design, and " +
224
+ "names none of them anywhere — including here, which is why this description carries no " +
225
+ "example specifier. Install the one matching the service's runtime alongside this server and " +
226
+ "name its package here; descry-runtime publishes one runtime adapter package per supported " +
227
+ "runtime, and its README lists them.",
228
+ properties: {
229
+ module: { type: "string" },
230
+ export: {
231
+ type: "string",
232
+ description: "Defaults to the single export matching create*RuntimeAdapter. Two matches is an error, " +
233
+ "not a coin toss — name one here.",
234
+ },
235
+ options: { type: "object", description: "Passed to the factory. Adapter-specific and opaque here." },
236
+ },
237
+ required: ["module"],
238
+ additionalProperties: false,
239
+ },
240
+ scopeByService: {
241
+ type: "object",
242
+ description: "Service name → which repository its symbols resolve in. A service with no entry has its " +
243
+ "symbol evidence left alone and its name reported, never resolved against a repository " +
244
+ "nobody named. Defaults to this session's own repo for every declared service.",
245
+ additionalProperties: {
246
+ type: "object",
247
+ properties: {
248
+ repo: { type: "string" },
249
+ repoRoot: { type: "string", description: "Absolute on-disk root, so observed absolute paths translate exactly." },
250
+ cwd: { type: "string" },
251
+ },
252
+ required: ["repo"],
253
+ additionalProperties: false,
254
+ },
255
+ },
256
+ observeForMs: {
257
+ type: "integer",
258
+ description: `How long to drain collector output after the services are up. Defaults to ${DEFAULT_OBSERVE_MS}. ` +
259
+ "There is no \"the application is done\" signal at this layer — a server runs until stopped — " +
260
+ "so you state the observation window rather than this tool guessing at one.",
261
+ },
262
+ timeoutMs: { type: "integer", description: `Whole-execution budget. Defaults to ${DEFAULT_TIMEOUT_MS}.` },
263
+ environmentTier: {
264
+ type: "string",
265
+ enum: ["tier-0-ci-attached", "tier-1-preview", "tier-2-container", "tier-2b-api-only", "tier-3-static-only"],
266
+ description: "Recorded on the execution. Defaults to \"tier-2-container\" and is deliberately not derived " +
267
+ "from \"profile.mode\" — DEC-270's rule is that a declared field is declared, not inferred " +
268
+ "from a neighbouring one.",
269
+ },
270
+ fidelityLevel: {
271
+ type: "integer",
272
+ enum: [1, 2, 3, 4],
273
+ description: "1 rule-aware stub · 2 real code + disposable DB · 3 real code + redacted recordings · " +
274
+ "4 real staging. Defaults to 2. Not derived from anything else, same reason as environmentTier.",
275
+ },
276
+ evidencePath: {
277
+ type: "string",
278
+ description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
279
+ },
280
+ confirmToken: {
281
+ type: "string",
282
+ description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
283
+ "call describes what running would do and returns a token, and only a second call presenting " +
284
+ "that exact token runs anything — with the arguments frozen when the token was minted, never " +
285
+ "whatever the second call supplies.",
286
+ },
287
+ },
288
+ required: ["profile", "services", "adapter"],
289
+ additionalProperties: false,
290
+ };
291
+ // ---------------------------------------------------------------------------
292
+ // Argument reading. Hand-written, same reasoning as `kit.ts`'s own readers.
293
+ // ---------------------------------------------------------------------------
294
+ function asRecord(value, what) {
295
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
296
+ throw new ToolInputError(`"${what}" must be an object`);
297
+ }
298
+ return value;
299
+ }
300
+ function readProfile(args) {
301
+ const raw = asRecord(args["profile"], "profile");
302
+ const field = (key) => {
303
+ const value = raw[key];
304
+ if (typeof value !== "string")
305
+ throw new ToolInputError(`"profile.${key}" must be a string`);
306
+ return value;
307
+ };
308
+ const candidate = {
309
+ name: field("name"),
310
+ url: field("url"),
311
+ safetyLevel: field("safetyLevel"),
312
+ credentialRef: field("credentialRef"),
313
+ mode: field("mode"),
314
+ };
315
+ const errors = validateProfile(candidate);
316
+ if (errors.length > 0) {
317
+ // The profile package's own error codes, verbatim — this tool adds no
318
+ // interpretation to a validation it did not perform.
319
+ throw new ToolInputError(`"profile" is not valid: ${errors.join(", ")}`);
320
+ }
321
+ return candidate;
322
+ }
323
+ function readAdapterSpec(args) {
324
+ const raw = asRecord(args["adapter"], "adapter");
325
+ const module = raw["module"];
326
+ if (typeof module !== "string" || module === "") {
327
+ throw new ToolInputError('"adapter.module" is required and must be a non-empty string');
328
+ }
329
+ const exportName = raw["export"];
330
+ if (exportName !== undefined && typeof exportName !== "string") {
331
+ throw new ToolInputError('"adapter.export" must be a string');
332
+ }
333
+ const options = raw["options"];
334
+ if (options !== undefined && (typeof options !== "object" || options === null)) {
335
+ throw new ToolInputError('"adapter.options" must be an object');
336
+ }
337
+ return {
338
+ module,
339
+ ...(typeof exportName === "string" ? { export: exportName } : {}),
340
+ ...(options === undefined ? {} : { options: options }),
341
+ };
342
+ }
343
+ function readServices(args, repoPath) {
344
+ const raw = asRecord(args["services"], "services");
345
+ const names = Object.keys(raw);
346
+ if (names.length === 0)
347
+ throw new ToolInputError('"services" must declare at least one service');
348
+ return names.map((name) => {
349
+ const entry = asRecord(raw[name], `services.${name}`);
350
+ const command = entry["command"];
351
+ const attachRaw = entry["attach"];
352
+ if ((command === undefined) === (attachRaw === undefined)) {
353
+ throw new ToolInputError(`services.${name} must declare exactly one of "command" or "attach" — ` +
354
+ (command === undefined ? "it declares neither" : "it declares both"));
355
+ }
356
+ if (command !== undefined && typeof command !== "string") {
357
+ throw new ToolInputError(`"services.${name}.command" must be a string`);
358
+ }
359
+ const cwd = entry["cwd"];
360
+ if (typeof cwd !== "string" || cwd === "") {
361
+ throw new ToolInputError(`"services.${name}.cwd" is required and must be a non-empty string`);
362
+ }
363
+ const port = entry["port"];
364
+ if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 0)) {
365
+ throw new ToolInputError(`"services.${name}.port" must be a non-negative integer`);
366
+ }
367
+ const dependsOn = entry["dependsOn"];
368
+ if (dependsOn !== undefined &&
369
+ (!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))) {
370
+ throw new ToolInputError(`"services.${name}.dependsOn" must be an array of strings`);
371
+ }
372
+ const env = entry["env"];
373
+ if (env !== undefined) {
374
+ const record = asRecord(env, `services.${name}.env`);
375
+ for (const [key, value] of Object.entries(record)) {
376
+ if (typeof value !== "string") {
377
+ throw new ToolInputError(`"services.${name}.env.${key}" must be a string`);
378
+ }
379
+ }
380
+ }
381
+ let attach;
382
+ if (attachRaw !== undefined) {
383
+ const a = asRecord(attachRaw, `services.${name}.attach`);
384
+ const pid = a["pid"];
385
+ const logFilePath = a["logFilePath"];
386
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
387
+ throw new ToolInputError(`"services.${name}.attach.pid" must be a positive integer`);
388
+ }
389
+ if (typeof logFilePath !== "string" || logFilePath === "") {
390
+ throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
391
+ }
392
+ attach = { pid, logFilePath };
393
+ }
394
+ const configuration = {
395
+ ...(typeof command === "string" ? { command } : {}),
396
+ cwd: isAbsolute(cwd) ? cwd : join(repoPath, cwd),
397
+ ...(port === undefined ? {} : { port: port }),
398
+ ...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
399
+ ...(env === undefined ? {} : { env: env }),
400
+ ...(attach === undefined ? {} : { attach }),
401
+ };
402
+ return {
403
+ name,
404
+ configuration,
405
+ readiness: readReadiness(entry["readiness"], name, configuration.cwd),
406
+ attached: attach !== undefined,
407
+ };
408
+ });
409
+ }
410
+ /**
411
+ * The JSON→`ReadinessCheck` mapping, and the two mechanisms it cannot express.
412
+ *
413
+ * `log-pattern` needs a `read()` closing over the `ManagedProcess` the
414
+ * controller owns, and `custom-hook` is a function outright. Neither survives a
415
+ * JSON boundary, and inventing a string-shaped stand-in for either would offer
416
+ * a mechanism that silently is not the one named. They are absent from the
417
+ * schema's enum and stated in the disclosures instead — rule 7, honest
418
+ * degradation, applied to a capability rather than to a result.
419
+ */
420
+ function readReadiness(raw, service, cwd) {
421
+ const entry = asRecord(raw, `services.${service}.readiness`);
422
+ const kind = entry["kind"];
423
+ if (typeof kind !== "string" || !READINESS_KINDS.includes(kind)) {
424
+ throw new ToolInputError(`"services.${service}.readiness.kind" must be one of: ${READINESS_KINDS.join(", ")}`);
425
+ }
426
+ const timeoutMs = entry["timeoutMs"];
427
+ if (timeoutMs !== undefined &&
428
+ (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
429
+ throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
430
+ }
431
+ // Every field is validated **here**, not inside `checks`. The controller does
432
+ // not call `checks()` until the service has already spawned, so a bad
433
+ // argument validated lazily would surface as a failed run with a live process
434
+ // to clean up rather than as a rejected call that started nothing — and
435
+ // `ToolInputError`'s whole contract is that it is something the caller can
436
+ // fix before anything happens.
437
+ const path = typeof entry["path"] === "string" ? entry["path"] : "/";
438
+ const expectedStatus = entry["expectedStatus"];
439
+ if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
440
+ throw new ToolInputError(`"services.${service}.readiness.expectedStatus" must be a number`);
441
+ }
442
+ const host = typeof entry["host"] === "string" ? entry["host"] : "127.0.0.1";
443
+ const command = entry["command"];
444
+ const commandArgs = entry["args"];
445
+ if (kind === "command") {
446
+ if (typeof command !== "string" || command === "") {
447
+ throw new ToolInputError(`"services.${service}.readiness.command" is required for kind "command"`);
448
+ }
449
+ if (commandArgs !== undefined &&
450
+ (!Array.isArray(commandArgs) || commandArgs.some((a) => typeof a !== "string"))) {
451
+ throw new ToolInputError(`"services.${service}.readiness.args" must be an array of strings`);
452
+ }
453
+ }
454
+ const checks = (info) => {
455
+ if (kind === "http") {
456
+ return [
457
+ {
458
+ kind: "http",
459
+ url: `http://127.0.0.1:${String(info.port)}${path.startsWith("/") ? path : `/${path}`}`,
460
+ ...(typeof expectedStatus === "number" ? { expectedStatus } : {}),
461
+ },
462
+ ];
463
+ }
464
+ if (kind === "tcp-port") {
465
+ return [{ kind: "tcp-port", host, port: info.port }];
466
+ }
467
+ return [
468
+ {
469
+ kind: "command",
470
+ command: command,
471
+ ...(commandArgs === undefined ? {} : { args: commandArgs }),
472
+ cwd,
473
+ },
474
+ ];
475
+ };
476
+ return {
477
+ checks,
478
+ timeoutMs: typeof timeoutMs === "number" ? timeoutMs : DEFAULT_READINESS_TIMEOUT_MS,
479
+ };
480
+ }
481
+ function readScopes(args, declared, fallback) {
482
+ const scopes = {};
483
+ for (const service of declared)
484
+ scopes[service.name] = fallback;
485
+ const raw = args["scopeByService"];
486
+ if (raw === undefined)
487
+ return scopes;
488
+ for (const [name, value] of Object.entries(asRecord(raw, "scopeByService"))) {
489
+ const entry = asRecord(value, `scopeByService.${name}`);
490
+ const repo = entry["repo"];
491
+ if (typeof repo !== "string" || repo === "") {
492
+ throw new ToolInputError(`"scopeByService.${name}.repo" is required and must be a non-empty string`);
493
+ }
494
+ const repoRoot = entry["repoRoot"];
495
+ const cwd = entry["cwd"];
496
+ if (repoRoot !== undefined && typeof repoRoot !== "string") {
497
+ throw new ToolInputError(`"scopeByService.${name}.repoRoot" must be a string`);
498
+ }
499
+ if (cwd !== undefined && typeof cwd !== "string") {
500
+ throw new ToolInputError(`"scopeByService.${name}.cwd" must be a string`);
501
+ }
502
+ scopes[name] = {
503
+ repo,
504
+ ...(repoRoot === undefined ? {} : { repoRoot }),
505
+ ...(cwd === undefined ? {} : { cwd }),
506
+ };
507
+ }
508
+ return scopes;
509
+ }
510
+ // ---------------------------------------------------------------------------
511
+ // The run
512
+ // ---------------------------------------------------------------------------
513
+ const EMPTY_WRITE = {
514
+ promoted: [],
515
+ created: [],
516
+ confirmed: [],
517
+ refused: [],
518
+ contradictions: [],
519
+ staleR4: [],
520
+ };
521
+ /**
522
+ * The disclosure every call carries, whatever it did.
523
+ *
524
+ * Stated unconditionally rather than only when it bites: a caller who does not
525
+ * know that `log-pattern` readiness is unreachable here will write a
526
+ * `tcp-port` check that passes the instant the socket binds and read the
527
+ * resulting empty evidence as "the service produced nothing".
528
+ */
529
+ const STANDING_NOTES = [
530
+ "Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
531
+ "log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
532
+ "needs one of those is not degraded here, it is unsupported here.",
533
+ "Edges are written only where an observation named both endpoints itself: a captured call-site " +
534
+ "stack resolving to a function, against an endpoint the same observation named. Every other " +
535
+ "correlated evidence item resolves a node and produces no edge, which is a gap in what this run " +
536
+ "could prove rather than evidence that no such edge exists.",
537
+ "That combination — HTTP evidence carrying a call-site stack — is produced today only by a browser " +
538
+ "network collector, which is not in this server's dependency closure. Backend collectors attach a " +
539
+ "stack to log and error lines (resolving a function) and attach none to HTTP traffic (resolving an " +
540
+ "endpoint), so a backend-only run resolves both kinds of node and writes no edge. That is a real " +
541
+ "limit of what can be witnessed here, not a failure of this run, and it is stated on every call " +
542
+ "rather than inferred from a zero.",
543
+ "No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
544
+ "did not, because it exercises only the paths it took. Nothing here demotes an edge.",
545
+ ];
546
+ async function run(args, ctx) {
547
+ const session = ctx.session;
548
+ const profile = readProfile(args);
549
+ const adapterSpec = readAdapterSpec(args);
550
+ const declared = readServices(args, session.repoPath);
551
+ const observeForMs = optionalInteger(args, "observeForMs", 1) ?? DEFAULT_OBSERVE_MS;
552
+ const timeoutMs = optionalInteger(args, "timeoutMs", 1) ?? DEFAULT_TIMEOUT_MS;
553
+ const fidelityRaw = optionalInteger(args, "fidelityLevel", 1) ?? 2;
554
+ if (fidelityRaw > 4)
555
+ throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
556
+ const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
557
+ const evidenceArg = optionalString(args, "evidencePath");
558
+ const evidencePath = evidenceArg === undefined
559
+ ? join(session.repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
560
+ : isAbsolute(evidenceArg)
561
+ ? evidenceArg
562
+ : join(session.repoPath, evidenceArg);
563
+ const notes = [...STANDING_NOTES];
564
+ const base = session.provider().baseStamp();
565
+ const refuse = (headline, data = {}) => answer({
566
+ headline,
567
+ state: "refused",
568
+ nameLevel: true,
569
+ // Nothing ran, so nothing was resolved. A refusal reporting the tier its
570
+ // successful path would have reached is the leaked-default this repo's
571
+ // own UAT already caught once elsewhere.
572
+ resolutionFloor: 0,
573
+ commitSha: base.commitSha,
574
+ graphBuiltAt: base.graphBuiltAt,
575
+ irSchemaVersion: base.irSchemaVersion,
576
+ commitSpread: base.commitSpread,
577
+ notes,
578
+ data: {
579
+ executionId: null,
580
+ executionState: null,
581
+ adapterLanguage: null,
582
+ evidencePath,
583
+ services: [],
584
+ evidenceByType: {},
585
+ correlation: null,
586
+ wrote: EMPTY_WRITE,
587
+ ...data,
588
+ },
589
+ });
590
+ // --- the safety gate ------------------------------------------------------
591
+ // Spawning is a write against the target; attaching is not (Descry never
592
+ // executes code in, signals, or applies limits to a process it did not
593
+ // spawn). So the action's shape depends on what was declared, and the
594
+ // profile's declared level decides — never the profile's name.
595
+ const spawns = declared.some((service) => !service.attached);
596
+ const action = { write: spawns, destructive: false };
597
+ const decision = evaluateAction(profile, action);
598
+ if (decision !== "allow") {
599
+ return refuse(`Profile "${profile.name}" declares safetyLevel "${profile.safetyLevel}", which does not permit ` +
600
+ `${spawns ? "spawning a service" : "this run"}. Nothing was started and nothing was written. ` +
601
+ (spawns
602
+ ? "A run in which every service uses \"attach\" spawns nothing and is permitted under readOnly."
603
+ : ""));
604
+ }
605
+ if (!spawns) {
606
+ notes.push("Every declared service is attached to rather than spawned, so this run started nothing and " +
607
+ "applied no resource, filesystem or network policy to any process — Descry does not constrain " +
608
+ "a process it did not spawn.");
609
+ }
610
+ // --- the graph must exist -------------------------------------------------
611
+ // Correlation resolves evidence against this graph. Against an empty one it
612
+ // resolves nothing, and reporting that as a clean run with no findings would
613
+ // be the exact "empty means broken" collapse the five states exist to stop.
614
+ const driver = session.store().driver;
615
+ const stored = counts(driver);
616
+ if (stored.nodes === 0) {
617
+ return refuse("This repository has no graph yet, so there is nothing for a run's evidence to be resolved " +
618
+ "against. Run analyze first — an observation that cannot name a node cannot become a fact.");
619
+ }
620
+ // --- the adapter ----------------------------------------------------------
621
+ ctx.progress(`Loading runtime adapter ${adapterSpec.module}`);
622
+ let adapter;
623
+ try {
624
+ adapter = await loadRuntimeAdapter(adapterSpec);
625
+ }
626
+ catch (error) {
627
+ if (error instanceof RuntimeAdapterLoadError) {
628
+ notes.push("This server depends on none of descry-runtime's language adapters by design, so the adapter " +
629
+ "must be installed alongside it and named in the call. Nothing was started.");
630
+ return refuse(error.message);
631
+ }
632
+ throw error;
633
+ }
634
+ notes.push(`Observed with the runtime adapter for "${adapter.language}", loaded from ${adapterSpec.module}.`);
635
+ const root = await session.root();
636
+ const scopes = readScopes(args, declared, {
637
+ repo: root.repo,
638
+ repoRoot: root.absolutePath,
639
+ });
640
+ const services = {};
641
+ const readiness = {};
642
+ for (const service of declared) {
643
+ services[service.name] = service.configuration;
644
+ readiness[service.name] = service.readiness;
645
+ }
646
+ const configuration = {
647
+ environmentTier: environmentTier,
648
+ fidelityLevel: fidelityRaw,
649
+ timeoutMs,
650
+ services,
651
+ };
652
+ await mkdir(dirname(evidencePath), { recursive: true });
653
+ const evidenceStore = new EvidenceStore({ path: evidencePath });
654
+ try {
655
+ ctx.progress(`Running ${declared.length} service(s), observing for ${String(observeForMs)}ms`);
656
+ const execution = await runInstrumentedExecution({
657
+ execution: {
658
+ application: root.repo,
659
+ repository: root.repo,
660
+ commit: root.commitSha,
661
+ configuration,
662
+ },
663
+ runOptions: { readiness },
664
+ adapter,
665
+ store: evidenceStore,
666
+ observeForMs,
667
+ });
668
+ const observed = describeServices(execution.execution.processes, declared);
669
+ const evidenceByType = tally(execution.evidence);
670
+ if (execution.validationError !== null) {
671
+ // The controller refused before spawning anything. That is a fact about
672
+ // the declaration, not about the application — no evidence, no
673
+ // correlation, and emphatically not "the service is clean".
674
+ return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
675
+ "evidence was collected, and no graph edge was written.", {
676
+ executionState: execution.execution.state,
677
+ adapterLanguage: adapter.language,
678
+ services: observed,
679
+ evidenceByType,
680
+ });
681
+ }
682
+ ctx.progress(`Correlating ${String(execution.evidence.length)} evidence item(s) against the graph`);
683
+ const pass = correlateExecution({
684
+ store: evidenceStore,
685
+ driver,
686
+ executionId: execution.execution.executionId,
687
+ scopeByService: scopes,
688
+ });
689
+ const correlation = {
690
+ considered: pass.considered,
691
+ skipped: pass.skipped,
692
+ attributed: pass.attributed.length,
693
+ refused: pass.refusals.length,
694
+ unscopedServices: pass.unscopedServices,
695
+ harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
696
+ };
697
+ if (pass.unscopedServices.length > 0) {
698
+ // Two different sentences, because they call for two different actions
699
+ // and the first one used to be printed for both. A named service with no
700
+ // scope is something the caller can fix by supplying one. The `(no
701
+ // service)` sentinel is not: `correlateExecution` looks a scope up by
702
+ // `evidence.service`, and no collector in this dependency closure stamps
703
+ // one — measured by the conformance run, which prints a real V8 stack
704
+ // resolving to a real graph node and watches it go unasked. Telling a
705
+ // caller to name a scope they have no key for would send them after a
706
+ // fix that does not exist, which is the honest-degradation rule failing
707
+ // in the one place it acts.
708
+ const named = pass.unscopedServices.filter((s) => s !== "(no service)");
709
+ const anonymous = pass.unscopedServices.length - named.length;
710
+ if (named.length > 0) {
711
+ notes.push(`Symbol evidence from ${named.join(", ")} was left unresolved: no scope named which ` +
712
+ "repository those symbols belong to, and resolving them against a repository nobody " +
713
+ "named would resolve the wrong one's identically-named file. Supply " +
714
+ '"scopeByService" for those services to have them resolved.');
715
+ }
716
+ if (anonymous > 0) {
717
+ notes.push("Some evidence carried a source location but no service name, so no scope could be " +
718
+ "looked up for it and its symbols were never resolved. This is not a missing argument: " +
719
+ "nothing in this server's runtime closure stamps a service name onto collector " +
720
+ "evidence, so there is no key a caller could supply a scope under. The items are " +
721
+ "counted as skipped rather than dropped, and what they would have resolved to is " +
722
+ "unknown rather than absent.");
723
+ }
724
+ }
725
+ if (pass.harnessErrors.length > 0) {
726
+ notes.push(`${String(pass.harnessErrors.length)} correlation failure(s) were the machinery breaking rather ` +
727
+ "than a resolver honestly declining — each is written into the evidence stream as a " +
728
+ "COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
729
+ }
730
+ // A service that died is a witnessed failure, and this run is the only
731
+ // thing that will ever have seen it. Recorded before the edge write so a
732
+ // failure in one does not silently cost the other.
733
+ // See `runtime-incident.ts` for why an EXCEPTION alone is not an incident.
734
+ const incident = runtimeObservedIncident({
735
+ repo: root.repo,
736
+ repoRoot: root.absolutePath,
737
+ runId: execution.execution.executionId,
738
+ services: observed,
739
+ exceptionLocations: execution.evidence
740
+ .filter((e) => e.eventType === "EXCEPTION")
741
+ .map((e) => ({ file: e.sourceLocation?.file ?? null })),
742
+ exceptionTexts: execution.evidence
743
+ .filter((e) => e.eventType === "EXCEPTION")
744
+ .map((e) => (typeof e.payload === "string" ? e.payload : JSON.stringify(e.payload)))
745
+ .map((text) => text.split("\n")[0] ?? "")
746
+ .filter((line) => line !== ""),
747
+ });
748
+ if (incident !== null) {
749
+ ctx.progress("Recording the observed failure as an incident");
750
+ await writeConfirmedIncident(session.repoPath, incident);
751
+ const projected = await createConfirmedIncidentSource({
752
+ repo: root.repo,
753
+ incidents: [...(session.config.confirmedIncidents ?? []), incident],
754
+ }).emit({ root });
755
+ persistGraph(driver, [projected], buildGraph([projected], {
756
+ nodeExists: (id) => session.provider().node(id) !== undefined,
757
+ }));
758
+ notes.push(`A service died during this run (${incident.summary}), so it was recorded as an incident ` +
759
+ `correlated to ${String(incident.files.length)} file(s) this repository owns, and written ` +
760
+ "durably to .descry/config.json — a run is gone once the process exits. The correlation " +
761
+ "is every file an exception stack named during the run, which is not a claim about the " +
762
+ "cause: nothing here knows which exception killed the process, and choosing the last one " +
763
+ "would be recency standing in for causality.");
764
+ }
765
+ ctx.progress("Writing observed edges into the graph");
766
+ const wrote = writeObservations({
767
+ driver,
768
+ pass,
769
+ evidenceStore,
770
+ repo: root.repo,
771
+ repoRoot: root.absolutePath,
772
+ runId: execution.execution.executionId,
773
+ commitSha: root.commitSha,
774
+ });
775
+ const written = wrote.promoted.length + wrote.created.length;
776
+ if (written === 0) {
777
+ notes.push("No edge was written. Either no observation carried a call-site stack that resolved to a " +
778
+ "function this graph holds, or every one it did carry was already at R4. Both are real " +
779
+ "outcomes of this run, and neither says the graph's existing edges are wrong.");
780
+ }
781
+ const headline = `Observed ${String(execution.evidence.length)} evidence item(s) across ${String(declared.length)} ` +
782
+ `service(s); ${String(pass.attributed.length)} resolved to graph nodes; ` +
783
+ `${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
784
+ return answer({
785
+ headline,
786
+ // Deliberately not `empty` when nothing was witnessed: `empty` is a claim
787
+ // about the population, and "this run took no path that exercised the
788
+ // code" is not "this code does nothing". The counts say what happened.
789
+ state: "ok",
790
+ nameLevel: true,
791
+ // R4 unconditionally, and honestly so — every fact in `wrote` was
792
+ // witnessed at runtime, which is the one resolution level that does not
793
+ // rest on inference (DEC-115). The correlated-but-unwritten items are
794
+ // not claimed here at all; they resolved a node and asserted nothing, so
795
+ // a run that wrote nothing reports R0 rather than borrowing the tier its
796
+ // successful path would have reached.
797
+ resolutionFloor: (written > 0 ? 4 : 0),
798
+ commitSha: base.commitSha,
799
+ graphBuiltAt: base.graphBuiltAt,
800
+ irSchemaVersion: base.irSchemaVersion,
801
+ commitSpread: base.commitSpread,
802
+ notes,
803
+ data: {
804
+ executionId: execution.execution.executionId,
805
+ executionState: execution.execution.state,
806
+ adapterLanguage: adapter.language,
807
+ evidencePath,
808
+ services: observed,
809
+ evidenceByType,
810
+ correlation,
811
+ wrote,
812
+ },
813
+ });
814
+ }
815
+ finally {
816
+ evidenceStore.close();
817
+ }
818
+ }
819
+ /**
820
+ * One row per **declared** service, not one per spawned process — a service
821
+ * that never started must appear with `started: false` rather than vanish from
822
+ * the list, which is the difference between "it ran and did nothing" and "it
823
+ * never ran".
824
+ */
825
+ function describeServices(processes, declared) {
826
+ const byService = new Map();
827
+ for (const handle of processes) {
828
+ if (handle.serviceName !== null)
829
+ byService.set(handle.serviceName, handle);
830
+ }
831
+ return declared.map((service) => {
832
+ const handle = byService.get(service.name);
833
+ return {
834
+ service: service.name,
835
+ started: handle !== undefined,
836
+ attached: service.attached,
837
+ port: handle?.port ?? null,
838
+ exitedAt: handle?.exitedAt ?? null,
839
+ exitCode: handle?.exitCode ?? null,
840
+ signal: handle?.signal ?? null,
841
+ };
842
+ });
843
+ }
844
+ function tally(evidence) {
845
+ const byType = {};
846
+ for (const item of evidence)
847
+ byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
848
+ return byType;
849
+ }
850
+ /**
851
+ * The R4 write, and the one join this tool performs itself.
852
+ *
853
+ * For every evidence item the correlation pass resolved to an endpoint, if that
854
+ * same item also carried a call-site stack, ask `confirmObservedFrontendCaller`
855
+ * whether the stack names a function — and when it does, it writes. Both
856
+ * endpoints of the resulting edge come from the one observation; nothing here
857
+ * pairs two separate items together. See the module header for why that
858
+ * restraint is the whole design rather than a limitation of it.
859
+ */
860
+ function writeObservations(input) {
861
+ const promoted = [];
862
+ const created = [];
863
+ const confirmed = [];
864
+ const refused = [];
865
+ const contradictions = [];
866
+ const staleR4 = [];
867
+ // One evidence item can be attributed twice (an endpoint and a second naming
868
+ // the same line carries). Keyed on both so the same (evidence, endpoint) pair
869
+ // is never confirmed twice within one run.
870
+ const seen = new Set();
871
+ for (const attribution of input.pass.attributed) {
872
+ // `endpoint` only, and `log-text-endpoint` deliberately excluded. That
873
+ // family fires when a backend log LINE mentions a route and carries its
874
+ // own stack frame — which establishes that the function logged about the
875
+ // endpoint, not that it called it. The commonest real shape is a handler
876
+ // logging "GET /invoices -> 500", and that function SERVES the endpoint
877
+ // rather than USING it, so an edge minted from it could point the wrong
878
+ // way. Rule 2: a wrong edge corrupts diff scoping, impact scores and
879
+ // root-cause traversal; a missing one is a disclosed gap. Omitted.
880
+ if (attribution.family !== "endpoint")
881
+ continue;
882
+ const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
883
+ if (seen.has(key))
884
+ continue;
885
+ seen.add(key);
886
+ const evidence = input.evidenceStore.getById(attribution.evidenceId);
887
+ if (evidence === null || evidence.stackTrace === null)
888
+ continue;
889
+ const outcome = confirmObservedFrontendCaller(input.driver, {
890
+ endpointNodeId: attribution.graphNodeId,
891
+ stackTrace: evidence.stackTrace,
892
+ repo: input.repo,
893
+ runId: input.runId,
894
+ commitSha: input.commitSha,
895
+ repoRoot: input.repoRoot,
896
+ });
897
+ promoted.push(...outcome.confirmation.promoted);
898
+ created.push(...outcome.confirmation.created);
899
+ confirmed.push(...outcome.confirmation.confirmed);
900
+ refused.push(...outcome.confirmation.refused.map((r) => r.reason));
901
+ contradictions.push(...outcome.confirmation.contradictions.map((c) => c.detail));
902
+ staleR4.push(...outcome.confirmation.staleR4.map((s) => s.detail));
903
+ }
904
+ return { promoted, created, confirmed, refused, contradictions, staleR4 };
905
+ }
906
+ /**
907
+ * What confirming this call would do, in the caller's own terms.
908
+ *
909
+ * Always returns a sentence — unlike `questions`, whose unconfirmed shape is a
910
+ * genuine pure read of the question queue, there is no argument to this tool
911
+ * that makes it not run anything. Every valid call spawns or attaches, and
912
+ * every one of them can write.
913
+ */
914
+ function describeAction(args) {
915
+ const services = args["services"];
916
+ if (typeof services !== "object" || services === null)
917
+ return undefined;
918
+ const names = Object.keys(services);
919
+ if (names.length === 0)
920
+ return undefined;
921
+ const entries = services;
922
+ const spawned = names.filter((n) => entries[n]?.attach === undefined);
923
+ const attached = names.filter((n) => entries[n]?.attach !== undefined);
924
+ const parts = [];
925
+ if (spawned.length > 0)
926
+ parts.push(`start ${spawned.length} service(s) (${spawned.join(", ")})`);
927
+ if (attached.length > 0)
928
+ parts.push(`attach to ${attached.length} already-running service(s) (${attached.join(", ")})`);
929
+ return (`${parts.join(" and ")}, observe them, and write any edge the run witnesses into this ` +
930
+ "repository's graph at R4 — a durable fact that raises every later finding resting on it to " +
931
+ "reliability class A, and that survives re-indexing. Nothing is ever demoted or deleted.");
932
+ }
933
+ export const observeRuntimeTool = {
934
+ name: "observe_runtime",
935
+ class: "action",
936
+ tier: "evidence",
937
+ version: "1.0.0",
938
+ title: "Run the application and record what was observed",
939
+ description: "Boot or attach to the declared services, watch them with a runtime adapter, resolve what was " +
940
+ "observed against this repository's graph, and record the edges the run actually witnessed at R4 " +
941
+ "— the one resolution level static analysis cannot reach. Promoted and newly minted edges become " +
942
+ "visible through impact, propagation and every other tool immediately, with no second call: they " +
943
+ "already read the resolution field. Running takes a two-call confirmation — the first call " +
944
+ "performs nothing and returns a token describing what it would do; call again with " +
945
+ "\"confirmToken\" to actually run it. The environment profile's declared safetyLevel is checked " +
946
+ "before anything starts: booting is a write, attaching is not.",
947
+ inputSchema: SCHEMA,
948
+ run,
949
+ describeAction,
950
+ };
951
+ //# sourceMappingURL=observe-runtime.js.map