@momentiq/dark-factory-cli 1.0.0 → 2.0.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 (159) hide show
  1. package/README.md +121 -40
  2. package/dist/adapters/codex-sdk.d.ts +66 -0
  3. package/dist/adapters/codex-sdk.d.ts.map +1 -1
  4. package/dist/adapters/codex-sdk.js +321 -4
  5. package/dist/adapters/codex-sdk.js.map +1 -1
  6. package/dist/adapters/critic-result-schema.d.ts +15 -10
  7. package/dist/adapters/critic-result-schema.d.ts.map +1 -1
  8. package/dist/adapters/critic-result-schema.js +10 -0
  9. package/dist/adapters/critic-result-schema.js.map +1 -1
  10. package/dist/adapters/cursor-cli.d.ts +19 -1
  11. package/dist/adapters/cursor-cli.d.ts.map +1 -1
  12. package/dist/adapters/cursor-cli.js +96 -14
  13. package/dist/adapters/cursor-cli.js.map +1 -1
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.d.ts.map +1 -1
  16. package/dist/adapters/index.js +7 -0
  17. package/dist/adapters/index.js.map +1 -1
  18. package/dist/adapters/static-schema-lint.d.ts +68 -0
  19. package/dist/adapters/static-schema-lint.d.ts.map +1 -0
  20. package/dist/adapters/static-schema-lint.js +813 -0
  21. package/dist/adapters/static-schema-lint.js.map +1 -0
  22. package/dist/branch-protection/spec-default.yaml +2 -2
  23. package/dist/cli.d.ts +17 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +414 -113
  26. package/dist/cli.js.map +1 -1
  27. package/dist/commands/findings.d.ts +6 -0
  28. package/dist/commands/findings.d.ts.map +1 -0
  29. package/dist/commands/findings.js +196 -0
  30. package/dist/commands/findings.js.map +1 -0
  31. package/dist/commands/show.d.ts +11 -0
  32. package/dist/commands/show.d.ts.map +1 -0
  33. package/dist/commands/show.js +78 -0
  34. package/dist/commands/show.js.map +1 -0
  35. package/dist/commands/skills.d.ts +6 -0
  36. package/dist/commands/skills.d.ts.map +1 -0
  37. package/dist/commands/skills.js +248 -0
  38. package/dist/commands/skills.js.map +1 -0
  39. package/dist/commands/status.d.ts +6 -0
  40. package/dist/commands/status.d.ts.map +1 -0
  41. package/dist/commands/status.js +85 -0
  42. package/dist/commands/status.js.map +1 -0
  43. package/dist/compact/index.d.ts +2 -0
  44. package/dist/compact/index.d.ts.map +1 -0
  45. package/dist/compact/index.js +3 -0
  46. package/dist/compact/index.js.map +1 -0
  47. package/dist/compact/lockfile.d.ts +53 -0
  48. package/dist/compact/lockfile.d.ts.map +1 -0
  49. package/dist/compact/lockfile.js +626 -0
  50. package/dist/compact/lockfile.js.map +1 -0
  51. package/dist/cycle-doc-validator/validate_cycle_doc.py +39 -9
  52. package/dist/doctor.d.ts +51 -1
  53. package/dist/doctor.d.ts.map +1 -1
  54. package/dist/doctor.js +497 -1
  55. package/dist/doctor.js.map +1 -1
  56. package/dist/evidence/docker-build.d.ts +6 -0
  57. package/dist/evidence/docker-build.d.ts.map +1 -0
  58. package/dist/evidence/docker-build.js +199 -0
  59. package/dist/evidence/docker-build.js.map +1 -0
  60. package/dist/evidence/index.d.ts +1 -0
  61. package/dist/evidence/index.d.ts.map +1 -1
  62. package/dist/evidence/index.js +6 -0
  63. package/dist/evidence/index.js.map +1 -1
  64. package/dist/handoff/handoff-verb.d.ts.map +1 -1
  65. package/dist/handoff/handoff-verb.js +6 -3
  66. package/dist/handoff/handoff-verb.js.map +1 -1
  67. package/dist/handoff/ports.d.ts +8 -0
  68. package/dist/handoff/ports.d.ts.map +1 -1
  69. package/dist/handoff/real-clients.js +1 -1
  70. package/dist/handoff/real-clients.js.map +1 -1
  71. package/dist/index.d.ts +2 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +8 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/lib/show-status-core.d.ts +63 -0
  76. package/dist/lib/show-status-core.d.ts.map +1 -0
  77. package/dist/lib/show-status-core.js +156 -0
  78. package/dist/lib/show-status-core.js.map +1 -0
  79. package/dist/mcp/prompts.d.ts.map +1 -1
  80. package/dist/mcp/prompts.js +113 -78
  81. package/dist/mcp/prompts.js.map +1 -1
  82. package/dist/mcp/resources.js +1 -1
  83. package/dist/mcp/resources.js.map +1 -1
  84. package/dist/mcp/server.d.ts.map +1 -1
  85. package/dist/mcp/server.js +2 -0
  86. package/dist/mcp/server.js.map +1 -1
  87. package/dist/mcp/tools/doctor.d.ts.map +1 -1
  88. package/dist/mcp/tools/doctor.js +5 -0
  89. package/dist/mcp/tools/doctor.js.map +1 -1
  90. package/dist/mcp/tools/findings.d.ts +0 -22
  91. package/dist/mcp/tools/findings.d.ts.map +1 -1
  92. package/dist/mcp/tools/findings.js +5 -73
  93. package/dist/mcp/tools/findings.js.map +1 -1
  94. package/dist/mcp/tools/handoff.d.ts.map +1 -1
  95. package/dist/mcp/tools/handoff.js +10 -17
  96. package/dist/mcp/tools/handoff.js.map +1 -1
  97. package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
  98. package/dist/mcp/tools/review-bypass.js +22 -1
  99. package/dist/mcp/tools/review-bypass.js.map +1 -1
  100. package/dist/mcp/tools/skills-install.d.ts +6 -0
  101. package/dist/mcp/tools/skills-install.d.ts.map +1 -0
  102. package/dist/mcp/tools/skills-install.js +260 -0
  103. package/dist/mcp/tools/skills-install.js.map +1 -0
  104. package/dist/mcp/tools/stats-gate.d.ts.map +1 -1
  105. package/dist/mcp/tools/stats-gate.js +63 -15
  106. package/dist/mcp/tools/stats-gate.js.map +1 -1
  107. package/dist/policy/baseline.d.ts.map +1 -1
  108. package/dist/policy/baseline.js +11 -3
  109. package/dist/policy/baseline.js.map +1 -1
  110. package/dist/policy/gate.d.ts +1 -1
  111. package/dist/policy/gate.d.ts.map +1 -1
  112. package/dist/policy/gate.js +96 -12
  113. package/dist/policy/gate.js.map +1 -1
  114. package/dist/prompt.d.ts +1 -0
  115. package/dist/prompt.d.ts.map +1 -1
  116. package/dist/prompt.js +123 -4
  117. package/dist/prompt.js.map +1 -1
  118. package/dist/report.d.ts +130 -3
  119. package/dist/report.d.ts.map +1 -1
  120. package/dist/report.js +367 -10
  121. package/dist/report.js.map +1 -1
  122. package/dist/runner.d.ts +6 -0
  123. package/dist/runner.d.ts.map +1 -1
  124. package/dist/runner.js +213 -4
  125. package/dist/runner.js.map +1 -1
  126. package/dist/self-consistency.d.ts +144 -0
  127. package/dist/self-consistency.d.ts.map +1 -0
  128. package/dist/self-consistency.js +368 -0
  129. package/dist/self-consistency.js.map +1 -0
  130. package/dist/skills/config.d.ts +176 -0
  131. package/dist/skills/config.d.ts.map +1 -0
  132. package/dist/skills/config.js +251 -0
  133. package/dist/skills/config.js.map +1 -0
  134. package/dist/skills/index.d.ts +4 -0
  135. package/dist/skills/index.d.ts.map +1 -0
  136. package/dist/skills/index.js +8 -0
  137. package/dist/skills/index.js.map +1 -0
  138. package/dist/skills/install.d.ts +62 -0
  139. package/dist/skills/install.d.ts.map +1 -0
  140. package/dist/skills/install.js +315 -0
  141. package/dist/skills/install.js.map +1 -0
  142. package/dist/skills/template.d.ts +42 -0
  143. package/dist/skills/template.d.ts.map +1 -0
  144. package/dist/skills/template.js +95 -0
  145. package/dist/skills/template.js.map +1 -0
  146. package/dist/trusted-surface/rebind.d.ts.map +1 -1
  147. package/dist/trusted-surface/rebind.js +199 -2
  148. package/dist/trusted-surface/rebind.js.map +1 -1
  149. package/package.json +36 -2
  150. package/skills/README.md +89 -0
  151. package/skills/chief-engineer-blitz/SKILL.md.tmpl +443 -0
  152. package/skills/chief-engineer-blitz/skill.json +53 -0
  153. package/skills/chief-engineer-review/SKILL.md.tmpl +184 -0
  154. package/skills/chief-engineer-review/references/review-examples.md.tmpl +130 -0
  155. package/skills/chief-engineer-review/skill.json +67 -0
  156. package/skills/chief-engineer-review/templates/code-review-prompt.md.tmpl +111 -0
  157. package/skills/chief-engineer-review/templates/escalation-prompt.md.tmpl +56 -0
  158. package/skills/chief-engineer-review/templates/plan-review-prompt.md.tmpl +74 -0
  159. package/skills/skill-schema.json +73 -0
@@ -0,0 +1,813 @@
1
+ // Consumer dark-factory-platform#107 — deterministic schema-lint adapter.
2
+ //
3
+ // Why a sixth adapter (consumer DFP #107 incident): on PR #105 of the
4
+ // consumer hosted control-plane, the local-profile quorum (cursor-cli +
5
+ // codex, quorum 2) returned APPROVED on a `~/.claude/settings.json`
6
+ // example documenting `effortLevel: "max"`. The cloud-profile cursor
7
+ // adapter (via `cursor-sdk`, different prompt envelope) caught it as a
8
+ // high-severity schema finding: the persisted `effortLevel` schema only
9
+ // permits `low|medium|high|xhigh`; `"max"` is silently ignored on
10
+ // session start (the `max` tier exists only as a session-scoped
11
+ // `/effort max` command / `--effort max` flag). Same vendor, different
12
+ // adapter, different verdict.
13
+ //
14
+ // The structural fix: a deterministic critic that does NOT depend on
15
+ // LLM judgement for this class of finding. The adapter scans changed
16
+ // markdown / config files for fenced code blocks annotated with a
17
+ // `// schema: <name>` (or `# schema: <name>` for YAML / `<!-- schema:
18
+ // <name> -->` for HTML / `/* schema: <name> */` for JSONC-block style)
19
+ // marker, looks up the named schema in a built-in registry, and runs
20
+ // `ajv` JSON-Schema validation against the parsed payload. Violations
21
+ // surface as `severity: high` findings; the PR #105 `effortLevel: "max"`
22
+ // example fails in <50ms.
23
+ //
24
+ // Posture:
25
+ // - `requiredEnvVars: []` — no auth, no network, no LLM call.
26
+ // - `runtime: local` (and cloud — belt-and-suspenders; deterministic
27
+ // so the same result either side).
28
+ // - `required: false` in the consumer config; its job is to surface
29
+ // blocking-severity findings, which veto regardless of quorum (the
30
+ // existing single-critic-veto pattern under min-complete-quorum).
31
+ // - Pure function over `packet.changedFiles` — same input → same
32
+ // output, every time. No retries, no flake, no calibration window.
33
+ //
34
+ // What it DOES validate:
35
+ // - Fenced code blocks annotated with a recognized schema marker.
36
+ // - Built-in schemas: `claude-code-settings`, `df-agent-review-config`.
37
+ // The registry is extensible by call sites that supply additional
38
+ // schemas via constructor options (a future cycle can wire
39
+ // `~/.claude/settings.json` schema from the upstream Claude Code
40
+ // project once it stabilizes; for now the registry is local + minimal).
41
+ //
42
+ // What it does NOT do:
43
+ // - It does NOT auto-detect schemas from the file path (a code block
44
+ // in CLAUDE.md showing a `~/.claude/settings.json` example needs the
45
+ // opt-in `// schema: claude-code-settings` marker). The opt-in is
46
+ // intentional: false positives in the local quorum are more harmful
47
+ // than false negatives because they erode trust in the gate. A doc
48
+ // author who forgets the marker gets a missed finding (caught by
49
+ // the cloud cursor anyway); a marker mismatch produces a clear
50
+ // finding the author can correct.
51
+ // - It does NOT validate the entire markdown document as JSON. Only
52
+ // fenced code blocks marked with the schema annotation are subject
53
+ // to lint.
54
+ // - It does NOT call any LLM, network endpoint, or filesystem outside
55
+ // `packet.changedFiles`. The adapter is pure-deterministic.
56
+ //
57
+ // Where it RUNS in the gate:
58
+ // - Local profile: paired with `cursor-cli` + `codex-sdk`. Adds a
59
+ // deterministic backstop without touching quorum (it is veto-only via
60
+ // `blockingSeverities`).
61
+ // - Cloud profile: same wiring; deterministic so identical verdict.
62
+ //
63
+ // Drift control: the built-in schemas live in `STATIC_SCHEMAS` below.
64
+ // When the Claude Code settings schema evolves upstream, this registry
65
+ // needs the corresponding update. The boundary is intentional — we don't
66
+ // chase a moving upstream schema without explicit review.
67
+ // Ajv ships a CJS default + named-types pattern. Under NodeNext ESM the
68
+ // default-import lands as the namespace; the constructor is on `.default`.
69
+ // Probe both shapes so the adapter survives ajv minor-version churn (the
70
+ // shape moved between 8.x patch releases historically).
71
+ import AjvImport from "ajv";
72
+ import addFormatsImport from "ajv-formats";
73
+ import { parse as parseYaml } from "yaml";
74
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ const AjvCtor = AjvImport.default
76
+ ? AjvImport.default
77
+ : AjvImport;
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ const addFormats = typeof addFormatsImport === "function"
80
+ ? addFormatsImport
81
+ : addFormatsImport
82
+ .default;
83
+ export const STATIC_SCHEMA_LINT_ADAPTER_ID = "static-schema-lint";
84
+ // ---------------------------------------------------------------------------
85
+ // Built-in schema registry.
86
+ //
87
+ // Each entry is a JSON Schema (draft-07-compatible) the adapter validates
88
+ // extracted code blocks against. The schemas are intentionally MINIMAL —
89
+ // they encode only the fields whose schema-violation is a real defect-class
90
+ // observed in the consumer incident (consumer DFP #107) or its near
91
+ // neighbors. A schema that tries to validate every field of an upstream
92
+ // settings file becomes brittle the moment the upstream adds a field, so
93
+ // the adapter prefers a smaller surface that catches the named regressions
94
+ // over a larger surface that breaks on every upstream addition.
95
+ /**
96
+ * Schema for `~/.claude/settings.json` examples.
97
+ *
98
+ * The PRIMARY field this schema is designed to gate is `effortLevel`: the
99
+ * persisted-config tier is `low|medium|high|xhigh` only; `"max"` is a
100
+ * session-scoped value (only accepted by `/effort max` and `--effort
101
+ * max`). The consumer DFP PR #105 incident: a CLAUDE.md example
102
+ * documenting `effortLevel: "max"` as a recommended persisted default
103
+ * sailed past the local quorum and was caught by the cloud cursor
104
+ * adapter as a high-severity schema finding.
105
+ *
106
+ * `additionalProperties: true` because the upstream Claude Code settings
107
+ * file accepts many more fields than we want to encode here. The schema
108
+ * is OPT-IN: doc authors mark a code block with `// schema:
109
+ * claude-code-settings` only when they're showing a settings example.
110
+ */
111
+ const CLAUDE_CODE_SETTINGS_SCHEMA = {
112
+ $id: "claude-code-settings",
113
+ type: "object",
114
+ additionalProperties: true,
115
+ properties: {
116
+ model: { type: "string", minLength: 1 },
117
+ effortLevel: {
118
+ type: "string",
119
+ enum: ["low", "medium", "high", "xhigh"],
120
+ },
121
+ theme: { type: "string" },
122
+ },
123
+ };
124
+ /**
125
+ * Schema for `.agent-review/config.json` examples.
126
+ *
127
+ * Mirrors a SUBSET of `parseAgentReviewConfig` (the canonical runtime
128
+ * validator in `@momentiq/dark-factory-schemas`). The mirror is bounded
129
+ * to the high-churn fields where a doc-example regression is most likely
130
+ * to mislead a consumer:
131
+ * - top-level `version` enum (the parser rejects unknown versions)
132
+ * - `aggregation.policy` enum
133
+ * - `aggregation.blockingSeverities` enum-array
134
+ * - `aggregation.quorum` integer constraints (>= 2 when policy is
135
+ * `min-complete-quorum`)
136
+ * - `critics[].runtime` ("local" | "cloud")
137
+ * - `critics[].adapter` (known adapter id)
138
+ *
139
+ * The narrower fields (per-adapter params, profiles, validation routes)
140
+ * are NOT validated here — the runtime parser does that, and chasing every
141
+ * field at the doc-lint layer would make this schema a maintenance
142
+ * burden disproportionate to the catch rate. The doc-lint surface is the
143
+ * fields whose typo is most likely to ship and most expensive to debug.
144
+ */
145
+ const DF_AGENT_REVIEW_CONFIG_SCHEMA = {
146
+ $id: "df-agent-review-config",
147
+ type: "object",
148
+ additionalProperties: true,
149
+ properties: {
150
+ version: { type: "integer", enum: [1, 2] },
151
+ aggregation: {
152
+ type: "object",
153
+ additionalProperties: true,
154
+ properties: {
155
+ policy: {
156
+ type: "string",
157
+ enum: ["block-if-any", "min-complete-quorum"],
158
+ },
159
+ blockingSeverities: {
160
+ type: "array",
161
+ items: {
162
+ type: "string",
163
+ enum: ["blocker", "high", "medium", "low", "note"],
164
+ },
165
+ },
166
+ quorum: { type: "integer", minimum: 2 },
167
+ },
168
+ },
169
+ critics: {
170
+ type: "array",
171
+ items: {
172
+ type: "object",
173
+ additionalProperties: true,
174
+ properties: {
175
+ id: { type: "string", minLength: 1 },
176
+ name: { type: "string", minLength: 1 },
177
+ adapter: {
178
+ type: "string",
179
+ enum: [
180
+ "cursor-sdk",
181
+ "cursor-cli",
182
+ "codex-sdk",
183
+ "gemini-sdk",
184
+ "grok-direct-sdk",
185
+ "static-schema-lint",
186
+ ],
187
+ },
188
+ required: { type: "boolean" },
189
+ runtime: { type: "string", enum: ["local", "cloud"] },
190
+ },
191
+ },
192
+ },
193
+ },
194
+ };
195
+ /**
196
+ * The built-in schema registry. Maps a schema name (the value of the
197
+ * `// schema: <name>` annotation) to the JSON Schema definition the
198
+ * adapter validates against.
199
+ */
200
+ export const STATIC_SCHEMAS = Object.freeze({
201
+ "claude-code-settings": CLAUDE_CODE_SETTINGS_SCHEMA,
202
+ "df-agent-review-config": DF_AGENT_REVIEW_CONFIG_SCHEMA,
203
+ });
204
+ // ---------------------------------------------------------------------------
205
+ // Code-block extraction.
206
+ //
207
+ // The extractor scans a markdown source for fenced code blocks annotated
208
+ // with a recognized schema marker. The marker forms accepted (per the
209
+ // spec's "opt-in via annotation"):
210
+ //
211
+ // ```jsonc
212
+ // // schema: claude-code-settings
213
+ // { "effortLevel": "max" }
214
+ // ```
215
+ //
216
+ // ```yaml
217
+ // # schema: github-actions-workflow
218
+ // on: push
219
+ // ```
220
+ //
221
+ // ```json
222
+ // /* schema: df-agent-review-config */
223
+ // { "version": 2, ... }
224
+ // ```
225
+ //
226
+ // Plus an HTML-comment form for fences that wrap JSON proper (no
227
+ // comment-prefix inside the JSON itself, because JSON forbids comments):
228
+ //
229
+ // <!-- schema: claude-code-settings -->
230
+ // ```json
231
+ // { "model": "opus", "effortLevel": "max" }
232
+ // ```
233
+ //
234
+ // The HTML-comment form is the only way to annotate a strict-JSON block;
235
+ // `// schema:` and `# schema:` produce invalid JSON if placed inside a
236
+ // `json` fence. The extractor handles both: an HTML comment IMMEDIATELY
237
+ // preceding a fence (separated by at most blank lines) attaches its
238
+ // schema to that fence.
239
+ const FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*?)\n```/g;
240
+ const HTML_SCHEMA_RE = /<!--\s*schema:\s*([A-Za-z0-9_\-./]+)\s*-->/g;
241
+ const INLINE_SCHEMA_RES = [
242
+ // // schema: name (JS/TS/JSONC comment style)
243
+ /(?:^|\n)\s*\/\/\s*schema:\s*([A-Za-z0-9_\-./]+)\s*(?:\n|$)/,
244
+ // # schema: name (YAML / shell comment style)
245
+ /(?:^|\n)\s*#\s*schema:\s*([A-Za-z0-9_\-./]+)\s*(?:\n|$)/,
246
+ // /* schema: name */ (block comment style)
247
+ /\/\*\s*schema:\s*([A-Za-z0-9_\-./]+)\s*\*\//,
248
+ ];
249
+ /**
250
+ * Strip JSONC / JSON5-style comments AND trailing commas from a JSON-ish
251
+ * payload so `JSON.parse` succeeds on JSONC examples. Single-pass state
252
+ * machine — comment stripping AND trailing-comma removal both honor
253
+ * string-literal boundaries, so a comma-brace sequence inside a string
254
+ * value (`",}"`) is preserved verbatim. Designed for the limited surface
255
+ * of config-file examples in docs, not as a general JSON5 parser.
256
+ *
257
+ * Exported for unit-testability.
258
+ */
259
+ export function stripJsoncSyntax(text) {
260
+ // Single pass: walk the text character-by-character and emit chars to
261
+ // the output buffer. State tracked: whether we're inside a string
262
+ // literal, and the index of the most recent comma emitted at the
263
+ // top-of-output (used to retroactively drop trailing commas when the
264
+ // next non-whitespace, non-comment, non-string token is `}` or `]`).
265
+ const out = [];
266
+ let i = 0;
267
+ let inString = null;
268
+ let escape = false;
269
+ // pendingCommaIndex: index into `out` of the last emitted ',' that has
270
+ // only seen whitespace / comments since. Reset to -1 once the comma is
271
+ // either kept (any other char emitted) or dropped (closer encountered).
272
+ let pendingCommaIndex = -1;
273
+ while (i < text.length) {
274
+ const c = text[i] ?? "";
275
+ if (inString !== null) {
276
+ out.push(c);
277
+ pendingCommaIndex = -1;
278
+ if (escape) {
279
+ escape = false;
280
+ }
281
+ else if (c === "\\") {
282
+ escape = true;
283
+ }
284
+ else if (c === inString) {
285
+ inString = null;
286
+ }
287
+ i++;
288
+ continue;
289
+ }
290
+ // Not inside a string.
291
+ if (c === '"' || c === "'") {
292
+ inString = c;
293
+ out.push(c);
294
+ pendingCommaIndex = -1;
295
+ i++;
296
+ continue;
297
+ }
298
+ if (c === "/" && text[i + 1] === "/") {
299
+ // Line comment — skip to end-of-line (the newline is consumed
300
+ // outside this branch on the next loop iteration). pendingCommaIndex
301
+ // is preserved across the comment.
302
+ while (i < text.length && text[i] !== "\n")
303
+ i++;
304
+ continue;
305
+ }
306
+ if (c === "/" && text[i + 1] === "*") {
307
+ // Block comment — skip to closing */ (pendingCommaIndex preserved).
308
+ i += 2;
309
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
310
+ i++;
311
+ i += 2;
312
+ continue;
313
+ }
314
+ if (c === ",") {
315
+ out.push(c);
316
+ pendingCommaIndex = out.length - 1;
317
+ i++;
318
+ continue;
319
+ }
320
+ if (c === "}" || c === "]") {
321
+ // Trailing-comma case: if the most-recent non-whitespace token
322
+ // emitted was ',', drop it. Splicing the array preserves the
323
+ // single-pass invariant without an extra string-rewrite step.
324
+ if (pendingCommaIndex !== -1) {
325
+ out.splice(pendingCommaIndex, 1);
326
+ }
327
+ out.push(c);
328
+ pendingCommaIndex = -1;
329
+ i++;
330
+ continue;
331
+ }
332
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
333
+ // Whitespace does NOT close the trailing-comma window.
334
+ out.push(c);
335
+ i++;
336
+ continue;
337
+ }
338
+ // Any other character commits the pending comma (it's a real
339
+ // separator, not trailing).
340
+ out.push(c);
341
+ pendingCommaIndex = -1;
342
+ i++;
343
+ }
344
+ return out.join("");
345
+ }
346
+ /**
347
+ * Extract schema-annotated code blocks from a markdown source. Returns
348
+ * an empty array when the source contains no annotated blocks.
349
+ *
350
+ * The schema annotation may be embedded INSIDE the code block — three
351
+ * forms: JSONC line comment, YAML / shell line comment, or block
352
+ * comment — or as an HTML comment IMMEDIATELY before the fence (the
353
+ * only form compatible with strict JSON blocks, since JSON forbids
354
+ * inline comments).
355
+ */
356
+ export function extractSchemaBlocks(source) {
357
+ const blocks = [];
358
+ // First pass: collect every HTML-comment schema annotation with its
359
+ // position so we can attach it to the next fence that follows.
360
+ const htmlAnnotations = [];
361
+ HTML_SCHEMA_RE.lastIndex = 0;
362
+ let m;
363
+ while ((m = HTML_SCHEMA_RE.exec(source)) !== null) {
364
+ htmlAnnotations.push({ name: m[1] ?? "", index: m.index });
365
+ }
366
+ // Second pass: walk fences. For each, look for an INSIDE annotation
367
+ // first; if none, search backwards for the nearest HTML annotation
368
+ // that precedes the fence with only whitespace / blank lines between.
369
+ FENCE_RE.lastIndex = 0;
370
+ while ((m = FENCE_RE.exec(source)) !== null) {
371
+ const leading = m[1] ?? "";
372
+ const language = (m[2] ?? "").trim().split(/\s+/)[0] ?? "";
373
+ const body = m[3] ?? "";
374
+ const fenceIndex = m.index + leading.length;
375
+ // INSIDE annotation
376
+ let schemaName;
377
+ let strippedBody = body;
378
+ for (const re of INLINE_SCHEMA_RES) {
379
+ const inner = re.exec(body);
380
+ if (inner) {
381
+ schemaName = inner[1];
382
+ // Remove the matched line(s) from the body so JSON.parse won't trip.
383
+ strippedBody = body.replace(re, "\n").replace(/^\s*\n/, "");
384
+ break;
385
+ }
386
+ }
387
+ if (!schemaName) {
388
+ // HTML-comment annotation IMMEDIATELY before the fence — find the
389
+ // nearest one whose index is < fenceIndex and whose intervening
390
+ // text is only whitespace.
391
+ for (let h = htmlAnnotations.length - 1; h >= 0; h--) {
392
+ const ann = htmlAnnotations[h];
393
+ if (!ann || ann.index >= fenceIndex)
394
+ continue;
395
+ const between = source.slice(ann.index, fenceIndex);
396
+ // Allow the closing `-->`, whitespace, and at most one blank line.
397
+ const trimmed = between.replace(/<!--\s*schema:\s*[A-Za-z0-9_\-./]+\s*-->/, "");
398
+ if (/^[\s\n]*$/.test(trimmed)) {
399
+ schemaName = ann.name;
400
+ }
401
+ break;
402
+ }
403
+ }
404
+ if (!schemaName)
405
+ continue;
406
+ // Compute 1-based start line of the fence body for diagnostics.
407
+ const startLine = source.slice(0, fenceIndex).split("\n").length + 1;
408
+ blocks.push({
409
+ schemaName,
410
+ body: strippedBody,
411
+ language,
412
+ startLine,
413
+ });
414
+ }
415
+ return blocks;
416
+ }
417
+ // ---------------------------------------------------------------------------
418
+ // Parser dispatch.
419
+ //
420
+ // `parseBlockBody` selects between JSON/JSONC and YAML parsing based on
421
+ // the fence language tag captured during extraction. The YAML branch
422
+ // runs `yaml.parse` directly (no comment-stripping pass — YAML's syntax
423
+ // already permits `#` line comments, which the parser handles natively).
424
+ // JSON / JSONC / strict-JSON fences go through `stripJsoncSyntax` so
425
+ // `// ...` / `/* ... */` comments and trailing commas don't trip
426
+ // `JSON.parse` on doc examples.
427
+ //
428
+ // Language tags recognized:
429
+ // - "yaml", "yml" → YAML parser
430
+ // - "" (no tag), "json", "jsonc", "json5" → JSON-with-JSONC-strip path
431
+ //
432
+ // An unknown / non-empty language tag falls back to the JSON path. The
433
+ // extractor enforces opt-in via the schema annotation, so this only
434
+ // runs against blocks the author explicitly opted in to.
435
+ const YAML_LANGUAGES = new Set(["yaml", "yml"]);
436
+ function parseBlockBody(block) {
437
+ const lang = block.language.toLowerCase();
438
+ if (YAML_LANGUAGES.has(lang)) {
439
+ return parseYaml(block.body);
440
+ }
441
+ const normalized = stripJsoncSyntax(block.body);
442
+ return JSON.parse(normalized);
443
+ }
444
+ // ---------------------------------------------------------------------------
445
+ // Diff fallback — reconstruct added markdown content from packet.diff.
446
+ //
447
+ // When `context.includeFullChangedFiles: false`, `git.ts:changedFiles`
448
+ // skips the `git show <sha>:<path>` content read and emits each entry
449
+ // without a `content` field. The adapter would otherwise scan zero
450
+ // files and silently APPROVE every markdown-only PR. The fallback
451
+ // extracts the `+` lines from each file's hunks in the unified diff,
452
+ // producing a best-effort reconstruction of the ADDED content the
453
+ // adapter can scan.
454
+ //
455
+ // Caveats (documented in CONSUMER-ADOPTION §4.1):
456
+ // - Only hunks that ADD at least one `+` line contribute — a pure
457
+ // deletion hunk (the consumer fixed the violation) is correctly NOT
458
+ // reported as a new finding.
459
+ // - Within a contributing hunk, BOTH `+` (added) and ` ` (context) lines
460
+ // are preserved so the surrounding fenced-block boundary and the
461
+ // `<!-- schema: ... -->` / `// schema: ...` / `# schema: ...`
462
+ // annotation survive the reconstruction. Without context preservation,
463
+ // a PR that modifies only the payload line inside an existing
464
+ // annotated fenced block produces a body without a fence or schema
465
+ // marker, `extractSchemaBlocks` returns nothing, and the gate
466
+ // silently APPROVES the violation (codex critic #116 — fail-open
467
+ // contract breach for the deterministic backstop).
468
+ // - Deletion (`-`) lines are still dropped — they are NOT current
469
+ // repo state.
470
+ // - Hunks across the same file accumulate; each contributing hunk
471
+ // is appended with a blank line separator so distinct hunks do not
472
+ // fuse into one virtual fenced block.
473
+ // - If the diff itself is truncated (`packet.diffTruncated: true`),
474
+ // fallback coverage is partial. The adapter emits a BLOCKING-
475
+ // severity finding when diffFallbacks were used on any scanned file
476
+ // and the diff was truncated (cursor critic #116) — see review()
477
+ // below.
478
+ export function reconstructAddedContentFromDiff(diff) {
479
+ const out = new Map();
480
+ if (!diff)
481
+ return out;
482
+ const lines = diff.split("\n");
483
+ let currentPath = null;
484
+ // Per-hunk buffers: lines accumulate into the CURRENT hunk's buffer;
485
+ // when the hunk ends (next `@@` header, file boundary, or end of diff)
486
+ // it flushes into the file's content map IFF it contained at least one
487
+ // `+` (added) line. Pure context-only hunks (impossible in normal git
488
+ // diff output but defended against here) and pure deletion-only hunks
489
+ // contribute nothing — matching the cycle's "regressions introduced by
490
+ // this PR" charter.
491
+ let hunkBuffer = [];
492
+ let hunkHasAdd = false;
493
+ const flushHunk = () => {
494
+ if (currentPath !== null && hunkHasAdd && hunkBuffer.length > 0) {
495
+ const prior = out.get(currentPath);
496
+ const joined = hunkBuffer.join("\n");
497
+ // Separate distinct hunks with a blank line so an unclosed fence in
498
+ // one hunk does not fuse into a fenced block from a later hunk.
499
+ out.set(currentPath, prior ? `${prior}\n\n${joined}` : joined);
500
+ }
501
+ hunkBuffer = [];
502
+ hunkHasAdd = false;
503
+ };
504
+ for (const line of lines) {
505
+ // `+++ b/<path>` — header for the destination file in a hunk header.
506
+ // The leading `b/` is the standard `git diff` prefix; strip it if
507
+ // present, fall back to the raw path otherwise.
508
+ if (line.startsWith("+++ ")) {
509
+ flushHunk();
510
+ const rest = line.slice(4).trim();
511
+ if (rest === "/dev/null") {
512
+ currentPath = null;
513
+ }
514
+ else if (rest.startsWith("b/")) {
515
+ currentPath = rest.slice(2);
516
+ }
517
+ else {
518
+ currentPath = rest;
519
+ }
520
+ continue;
521
+ }
522
+ if (line.startsWith("--- ") || line.startsWith("diff --git ")) {
523
+ // File-level headers — DO NOT contribute content, DO NOT switch
524
+ // path (the `+++` line is the authoritative path source).
525
+ continue;
526
+ }
527
+ if (line.startsWith("@@")) {
528
+ // Hunk boundary. Flush whatever the previous hunk gathered.
529
+ flushHunk();
530
+ continue;
531
+ }
532
+ if (currentPath === null)
533
+ continue;
534
+ if (line.startsWith("+")) {
535
+ hunkBuffer.push(line.slice(1));
536
+ hunkHasAdd = true;
537
+ continue;
538
+ }
539
+ if (line.startsWith("-")) {
540
+ // Deletion — NOT current state. Drop.
541
+ continue;
542
+ }
543
+ if (line.startsWith(" ")) {
544
+ // Context line. Preserve it (strip the single leading space) so
545
+ // surrounding fence + schema annotations survive when the PR
546
+ // edits only the payload line inside an existing annotated block.
547
+ hunkBuffer.push(line.slice(1));
548
+ continue;
549
+ }
550
+ if (line === "\") {
551
+ // Standard git diff marker. Skip.
552
+ continue;
553
+ }
554
+ // Anything else (blank line between hunks, etc.) — drop.
555
+ }
556
+ flushHunk();
557
+ return out;
558
+ }
559
+ function buildRegistry(schemas) {
560
+ const ajv = new AjvCtor({ allErrors: true, strict: false });
561
+ addFormats(ajv);
562
+ const compiled = new Map();
563
+ for (const [name, schema] of Object.entries(schemas)) {
564
+ compiled.set(name, ajv.compile(schema));
565
+ }
566
+ return {
567
+ get: (name) => compiled.get(name),
568
+ names: () => [...compiled.keys()],
569
+ };
570
+ }
571
+ // ---------------------------------------------------------------------------
572
+ // Finding construction.
573
+ //
574
+ // Each Ajv `ErrorObject` produces ONE `ReviewFinding`. The mapping
575
+ // preserves the schema path (`instancePath`) and the constraint message so
576
+ // the doc author can locate and correct the violation without re-running
577
+ // Ajv themselves. `severity: high` matches the spec's recommendation:
578
+ // schema-shape regressions in code examples are a defect the gate should
579
+ // block on, not a stylistic note.
580
+ function findingsForAjvErrors(args) {
581
+ const { errors, filePath, schemaName, startLine, blockBody } = args;
582
+ return errors.map((err) => {
583
+ const path = err.instancePath || "(root)";
584
+ const params = JSON.stringify(err.params);
585
+ const evidence = `${schemaName}${path}: ${err.message ?? "schema violation"} (constraint params=${params})`;
586
+ // Best-effort line offset within the block. ajv doesn't track source
587
+ // lines (it operates on parsed JS values), so we surface the fence
588
+ // start line and let the author scan the local context.
589
+ return {
590
+ severity: "high",
591
+ category: "schema",
592
+ file: filePath,
593
+ line: startLine,
594
+ evidence,
595
+ impact: `Code example in ${filePath} violates JSON Schema \`${schemaName}\`. A consumer copy-pasting this example would produce a silently-invalid config; the violation is identical in pattern to consumer dark-factory-platform#107 (the \`effortLevel: "max"\` regression that the local LLM quorum missed).`,
596
+ requiredFix: `Correct the example so it satisfies the schema, or remove the schema annotation if the example is intentionally illustrating an invalid shape. Block body excerpt:\n${blockBody.slice(0, 200)}`,
597
+ };
598
+ });
599
+ }
600
+ const DEFAULT_SCANNED_FILE_PATTERNS = [
601
+ /\.md$/i,
602
+ /\.mdx$/i,
603
+ /CLAUDE\.md$/i,
604
+ /AGENTS\.md$/i,
605
+ /GEMINI\.md$/i,
606
+ ];
607
+ export class StaticSchemaLintAdapter {
608
+ id = STATIC_SCHEMA_LINT_ADAPTER_ID;
609
+ requiredEnvVars = [];
610
+ registry;
611
+ scannedFilePatterns;
612
+ constructor(options = {}) {
613
+ const mergedSchemas = { ...STATIC_SCHEMAS, ...(options.schemas ?? {}) };
614
+ this.registry = buildRegistry(mergedSchemas);
615
+ this.scannedFilePatterns = options.scannedFilePatterns ?? DEFAULT_SCANNED_FILE_PATTERNS;
616
+ }
617
+ async review(packet, critic, options) {
618
+ const findings = [];
619
+ const startMs = Date.now();
620
+ options.emit?.({
621
+ ts: new Date().toISOString(),
622
+ event: "critic_run_started",
623
+ commit: packet.commit.sha,
624
+ criticId: critic.id,
625
+ adapter: this.id,
626
+ model: critic.model.id,
627
+ });
628
+ let scannedFiles = 0;
629
+ let extractedBlocks = 0;
630
+ let parseFailures = 0;
631
+ // Track which scanned files relied on the diff-fallback path so we
632
+ // can emit a blocking finding when `packet.diffTruncated === true`
633
+ // (cursor critic #116 — on large PRs with includeFullChangedFiles:
634
+ // false, the truncation cap in rebind.ts can cut a violating hunk
635
+ // before it reaches the adapter, and the gate would otherwise
636
+ // silently APPROVE).
637
+ const diffFallbackFiles = [];
638
+ // Fallback-source map: when a changed file body is not loaded into
639
+ // `changedFiles[].content` (the consumer set
640
+ // `context.includeFullChangedFiles: false`, so git.ts skips the
641
+ // `git show <sha>:<path>` read), we reconstruct the added markdown
642
+ // content from `packet.diff` so the adapter is not silently starved.
643
+ // This keeps the deterministic backstop on for the DFP #107 fixture
644
+ // without requiring every consumer to flip a context flag.
645
+ const diffFallbacks = reconstructAddedContentFromDiff(packet.diff);
646
+ for (const file of packet.changedFiles) {
647
+ if (!this.scannedFilePatterns.some((re) => re.test(file.path)))
648
+ continue;
649
+ if (file.omittedReason)
650
+ continue;
651
+ const direct = file.content ?? file.compactedContent;
652
+ const fallback = direct === undefined ? diffFallbacks.get(file.path) : undefined;
653
+ const content = direct ?? fallback;
654
+ if (!content)
655
+ continue;
656
+ if (fallback !== undefined) {
657
+ diffFallbackFiles.push(file.path);
658
+ }
659
+ scannedFiles++;
660
+ const blocks = extractSchemaBlocks(content);
661
+ for (const block of blocks) {
662
+ extractedBlocks++;
663
+ const validator = this.registry.get(block.schemaName);
664
+ if (!validator) {
665
+ // Unknown schema — `high` severity by default (blocks merge).
666
+ // A typo in the annotation (e.g. `claude-code-setting` instead
667
+ // of `claude-code-settings`) would otherwise silently disable
668
+ // validation for the exact class of doc examples this adapter
669
+ // is meant to gate. Treat unknown schemas as a configuration
670
+ // error the author must resolve before merge.
671
+ findings.push({
672
+ severity: "high",
673
+ category: "schema",
674
+ file: file.path,
675
+ line: block.startLine,
676
+ evidence: `Unknown schema name "${block.schemaName}" in fenced code-block annotation (registered: ${this.registry.names().join(", ") || "(none)"}).`,
677
+ impact: "The schema annotation is present but the named schema is not in the adapter registry, so this block is NOT being validated. A one-character typo here disables the deterministic backstop for that block; the gate must surface it rather than fail open.",
678
+ requiredFix: `Use one of the registered schema names (${this.registry.names().join(", ") || "(none)"}), OR register a new schema by extending \`STATIC_SCHEMAS\` in \`packages/cli/src/adapters/static-schema-lint.ts\` (or via the \`schemas\` constructor option for repo-local adapters), OR remove the schema annotation if the block is intentionally not subject to schema lint.`,
679
+ });
680
+ continue;
681
+ }
682
+ // Parse the block body. Dispatch on the fence language: YAML
683
+ // fences (annotated with `# schema:`) parse via the `yaml`
684
+ // package; JSON / JSONC / strict-JSON fences parse via the
685
+ // JSONC-stripping JSON.parse path.
686
+ let parsed;
687
+ try {
688
+ parsed = parseBlockBody(block);
689
+ }
690
+ catch (err) {
691
+ parseFailures++;
692
+ findings.push({
693
+ severity: "high",
694
+ category: "schema",
695
+ file: file.path,
696
+ line: block.startLine,
697
+ evidence: `Schema-annotated block (\`${block.schemaName}\`, language=\`${block.language || "(none)"}\`) is not parseable: ${err.message}`,
698
+ impact: "Doc examples that claim a schema MUST be valid in their declared format (JSON/JSONC/YAML); an unparseable block produces a silent regression for any consumer copy-pasting the example.",
699
+ requiredFix: "Fix the syntax error in the code block, or remove the schema annotation if the block is intentionally illustrating malformed input.",
700
+ });
701
+ continue;
702
+ }
703
+ const ok = validator(parsed);
704
+ if (!ok) {
705
+ findings.push(...findingsForAjvErrors({
706
+ errors: validator.errors ?? [],
707
+ filePath: file.path,
708
+ schemaName: block.schemaName,
709
+ startLine: block.startLine,
710
+ blockBody: block.body,
711
+ }));
712
+ }
713
+ }
714
+ }
715
+ // cursor critic #116 — diff-truncation fail-open guard. If ANY scanned
716
+ // file used the diff-fallback path AND packet.diffTruncated is true,
717
+ // emit a blocking finding: the truncation cap in rebind.ts can cut a
718
+ // violating hunk before it reaches this adapter, so the deterministic
719
+ // backstop's silence is NOT meaningful evidence of correctness for
720
+ // this PR. The consumer must either flip `context.includeFullChangedFiles:
721
+ // true` (so this adapter reads file bodies directly, bypassing the
722
+ // truncated diff) or shrink the changeset.
723
+ if (packet.diffTruncated && diffFallbackFiles.length > 0) {
724
+ findings.push({
725
+ severity: "high",
726
+ category: "schema",
727
+ file: diffFallbackFiles[0],
728
+ line: 1,
729
+ evidence: `packet.diffTruncated=true AND ${diffFallbackFiles.length} scanned file(s) relied on diff-fallback content reconstruction (no file.content / file.compactedContent): ${diffFallbackFiles.join(", ")}. The unified diff was capped at the rebind.ts DEFAULT_DIFF_BUDGET (1_500_000 bytes); annotations in the truncated tail are NOT visible to the adapter.`,
730
+ impact: "Under `context.includeFullChangedFiles: false`, the deterministic schema-lint backstop can silently APPROVE schema-invalid doc examples (the same fail-open class as consumer DFP #107) when the violating hunk falls outside the truncated diff budget. The adapter cannot prove the absence of violations in the truncated tail, so silence here is NOT evidence of correctness.",
731
+ requiredFix: "Set `context.includeFullChangedFiles: true` in the consumer `.agent-review/config.json` (the adapter then reads file bodies directly via `git show <sha>:<path>` and bypasses the truncated diff), OR shrink the PR so the unified diff fits inside `DEFAULT_DIFF_BUDGET` (1.5MB). See docs/CONSUMER-ADOPTION.md §4.1 for the full mitigation matrix.",
732
+ });
733
+ }
734
+ const verdict = findings.some((f) => options.blockingSeverities.includes(f.severity))
735
+ ? "CHANGES_REQUESTED"
736
+ : "APPROVED";
737
+ const durationMs = Date.now() - startMs;
738
+ options.emit?.({
739
+ ts: new Date().toISOString(),
740
+ event: "critic_run_finished",
741
+ commit: packet.commit.sha,
742
+ criticId: critic.id,
743
+ adapter: this.id,
744
+ model: critic.model.id,
745
+ durationMs,
746
+ verdict,
747
+ });
748
+ return {
749
+ criticId: critic.id,
750
+ status: "complete",
751
+ verdict,
752
+ requiresHumanJudgment: false,
753
+ reviewer: {
754
+ name: critic.name,
755
+ adapter: critic.adapter,
756
+ model: critic.model,
757
+ runtime: critic.runtime,
758
+ },
759
+ summary: verdict === "APPROVED"
760
+ ? `static-schema-lint: ${scannedFiles} file(s) scanned, ${extractedBlocks} schema-annotated block(s), 0 violations.`
761
+ : `static-schema-lint: ${scannedFiles} file(s) scanned, ${extractedBlocks} schema-annotated block(s), ${findings.length} finding(s)${parseFailures > 0 ? ` (${parseFailures} parse failure(s))` : ""}.`,
762
+ findings,
763
+ validation: { qualityGateResults: [], qualityGatesMissing: [] },
764
+ confidence: "high",
765
+ durationMs,
766
+ };
767
+ }
768
+ async doctor(_critic) {
769
+ // The adapter is dependency-light: ajv is bundled at build time, no
770
+ // env vars, no subprocess, no network. The doctor probes exist so
771
+ // operators can confirm the registry compiled correctly on this
772
+ // host (catches a malformed schema literal at adapter-load time).
773
+ const checks = [];
774
+ const names = this.registry.names();
775
+ checks.push({
776
+ name: "static_schema_lint_registry",
777
+ passed: names.length > 0,
778
+ detail: `${names.length} schema(s) registered: ${names.join(", ") || "(none)"}`,
779
+ ...(names.length > 0
780
+ ? {}
781
+ : { remediation: "Extend STATIC_SCHEMAS in packages/cli/src/adapters/static-schema-lint.ts" }),
782
+ });
783
+ // Smoke-validate a known-good payload against the canonical schema
784
+ // so a packaging error (e.g. ajv tree-shaken away) surfaces in
785
+ // doctor instead of at review time.
786
+ try {
787
+ const validator = this.registry.get("claude-code-settings");
788
+ const smokeOk = validator
789
+ ? Boolean(validator({ model: "opus", effortLevel: "high" }))
790
+ : false;
791
+ checks.push({
792
+ name: "static_schema_lint_smoke",
793
+ passed: smokeOk,
794
+ detail: smokeOk
795
+ ? "ajv compiled the claude-code-settings schema and validated a known-good payload."
796
+ : `ajv smoke test failed (validator present: ${Boolean(validator)})`,
797
+ ...(smokeOk
798
+ ? {}
799
+ : { remediation: "Re-install ajv: `npm ci --workspace=@momentiq/dark-factory-cli`" }),
800
+ });
801
+ }
802
+ catch (err) {
803
+ checks.push({
804
+ name: "static_schema_lint_smoke",
805
+ passed: false,
806
+ detail: `ajv threw during smoke validation: ${err.message}`,
807
+ remediation: "Re-install ajv: `npm ci --workspace=@momentiq/dark-factory-cli`",
808
+ });
809
+ }
810
+ return checks;
811
+ }
812
+ }
813
+ //# sourceMappingURL=static-schema-lint.js.map