arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -17,6 +17,7 @@ import {
17
17
  unavailableStatusImprovementCompass,
18
18
  } from './status-manifest.mjs';
19
19
  import { createProjectId } from './project-identity.mjs';
20
+ import { parseArkConfigJson } from './config-contract.mjs';
20
21
  import { resolveEffectiveProjectRoot } from './project-root.mjs';
21
22
  import { detectWritePathCapabilities } from './write-path-detect.mjs';
22
23
  import { buildWritePathHonesty } from './enforcement-honesty.mjs';
@@ -29,6 +30,7 @@ import { projectStatusArkRun } from './ark-run-doctor.mjs';
29
30
  import { projectStatusArkOrder } from './ark-order-doctor.mjs';
30
31
  import { collectVsBaseFacts, discoverTeamBaseRef } from './team-parliament-io.mjs';
31
32
  import { classifyAdopted, readAdoptionStance } from './adoption-stance.mjs';
33
+ import { lastCheckVerdictFacts } from './upstream-report.mjs';
32
34
 
33
35
  function sha256Hex(value) {
34
36
  return createHash('sha256').update(value, 'utf8').digest('hex');
@@ -114,24 +116,7 @@ export function lastCheckFactsFromSnapshot(latest, baseline) {
114
116
  frozenResidual,
115
117
  };
116
118
  }
117
- const at =
118
- typeof latest.generatedAt === 'string'
119
- ? latest.generatedAt
120
- : typeof latest.at === 'string'
121
- ? latest.at
122
- : null;
123
- const active =
124
- typeof latest.activeViolations === 'number'
125
- ? latest.activeViolations
126
- : typeof latest.violations?.active === 'number'
127
- ? latest.violations.active
128
- : null;
129
- let verdict = null;
130
- if (latest.ok === true && (active == null || active === 0)) verdict = 'pass';
131
- else if (latest.ok === false || (typeof active === 'number' && active > 0)) verdict = 'fail';
132
- else if (latest.completeness === 'partial' || latest.completeness === 'unavailable') {
133
- verdict = 'incomplete';
134
- } else if (latest.ok === true) verdict = 'pass';
119
+ const { at, active, verdict } = lastCheckVerdictFacts(latest);
135
120
  return {
136
121
  lastCheckAt: at,
137
122
  lastCheckVerdict: verdict,
@@ -158,7 +143,7 @@ export function statusCompassFromSnapshot(latest, opts = {}) {
158
143
  return unavailableStatusImprovementCompass({
159
144
  reasonCode: 'NO_SESSION_SNAPSHOT',
160
145
  reason:
161
- 'No session report snapshot yet — run ark-check --doctor or --report for residual lenses. Status never invents green.',
146
+ 'No session report snapshot yet — run ark-check --report to write the session snapshot (--doctor shows full lenses but does not write it). Status never invents green.',
162
147
  contractHash,
163
148
  });
164
149
  }
@@ -189,7 +174,7 @@ export function statusCompassFromSnapshot(latest, opts = {}) {
189
174
  reasonCode: complete ? undefined : 'FACTS_PARTIAL',
190
175
  reason: complete
191
176
  ? undefined
192
- : 'Session snapshot residual is partial — re-run doctor/report for full compass.',
177
+ : 'Session snapshot residual is partial — re-run ark-check --report for full compass.',
193
178
  factsSource: 'report-snapshot',
194
179
  contractHash,
195
180
  });
@@ -198,7 +183,7 @@ export function statusCompassFromSnapshot(latest, opts = {}) {
198
183
  return unavailableStatusImprovementCompass({
199
184
  reasonCode: 'NO_SESSION_SNAPSHOT',
200
185
  reason:
201
- 'Session snapshot has no improvement compass facts — run ark-check --doctor or --report. Status never invents green.',
186
+ 'Session snapshot has no improvement compass facts — run ark-check --report to refresh the session snapshot. Status never invents green.',
202
187
  contractHash,
203
188
  });
204
189
  }
@@ -242,6 +227,24 @@ export function thinStatusCompassFromDoctor(doctorCompass, opts = {}) {
242
227
  * contractHash?: string | null,
243
228
  * }} [options]
244
229
  */
230
+ /**
231
+ * Run the shared contract validator (same rules as ark-check / MCP / ESLint).
232
+ * @param {string} text
233
+ * @param {string} source
234
+ * @returns {string[]} `<path>: <message>` lines; empty when valid
235
+ */
236
+ function validateConfigText(text, source) {
237
+ try {
238
+ parseArkConfigJson(text, source);
239
+ return [];
240
+ } catch (error) {
241
+ if (Array.isArray(error?.issues) && error.issues.length > 0) {
242
+ return error.issues.map((issue) => `${issue.path}: ${issue.message}`);
243
+ }
244
+ return [error instanceof Error ? error.message : String(error)];
245
+ }
246
+ }
247
+
245
248
  export function collectStatusFacts(options = {}) {
246
249
  const startRoot = path.resolve(options.root || process.cwd());
247
250
  const configName = options.config || 'ark.config.json';
@@ -256,12 +259,17 @@ export function collectStatusFacts(options = {}) {
256
259
  const configExists = fs.existsSync(configPath);
257
260
 
258
261
  let config = null;
262
+ /** Shared contract validator messages — status must not look authoritative-green on a config every gate refuses. */
263
+ let configErrors = [];
259
264
  if (configExists) {
265
+ let text = '';
260
266
  try {
261
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
267
+ text = fs.readFileSync(configPath, 'utf8');
268
+ config = JSON.parse(text);
262
269
  } catch {
263
270
  config = null;
264
271
  }
272
+ configErrors = validateConfigText(text, path.basename(configPath));
265
273
  }
266
274
 
267
275
  const projectId = configExists
@@ -390,6 +398,7 @@ export function collectStatusFacts(options = {}) {
390
398
  arkgateVersion: options.arkgateVersion || packageVersion(),
391
399
  resolvedRoot: realpathOrResolve(resolvedRoot),
392
400
  resolvedConfigPath: configExists ? realpathOrResolve(configPath) : null,
401
+ configErrors,
393
402
  projectId,
394
403
  expectation,
395
404
  expectedRootRelation,
@@ -564,6 +573,10 @@ export function runStatusCommand(args = {}) {
564
573
  ' · not a score'
565
574
  );
566
575
  }
576
+ if (manifest.contract?.valid === false) {
577
+ write(` contract: invalid (${manifest.contract.errors.length} issue(s)) — every enforcing surface refuses this config`);
578
+ for (const issue of manifest.contract.errors.slice(0, 5)) write(` - ${issue}`);
579
+ }
567
580
  write(` next: [${manifest.nextAction.id}] ${manifest.nextAction.summary}`);
568
581
  }
569
582
 
@@ -10,6 +10,22 @@
10
10
 
11
11
  import { projectStatusArkRun } from './ark-run-doctor.mjs';
12
12
  import { projectStatusArkOrder } from './ark-order-doctor.mjs';
13
+ /** Status slice schema of one extra plane (ArkRun / ArkOrder). */
14
+ function extraPlaneStatusSchema(plane) {
15
+ return {
16
+ type: 'object',
17
+ description: `${plane} extra residual (notAScore). present/mode from config; residual is a finding-id count (null = unknown, not green). extraMergeTeeth is honesty, never a score.`,
18
+ additionalProperties: false,
19
+ required: ['notAScore', 'present', 'mode', 'extraMergeTeeth', 'residual'],
20
+ properties: {
21
+ notAScore: { const: true },
22
+ present: { type: 'boolean' },
23
+ mode: { anyOf: [{ enum: ['advisory', 'enforced'] }, { type: 'null' }] },
24
+ extraMergeTeeth: { type: 'boolean' },
25
+ residual: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] },
26
+ },
27
+ };
28
+ }
13
29
  export const ARK_STATUS_MANIFEST_SCHEMA_VERSION = '1.0';
14
30
  export const ARK_STATUS_MANIFEST_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.status-manifest.schema.json';
15
31
  /**
@@ -175,6 +191,11 @@ export function defaultHonestLabel(writePath, host) {
175
191
  * Deterministic next action from residual facts (no LLM).
176
192
  * Prefer explicit override from productHonesty when provided.
177
193
  */
194
+ function normalizedConfigErrors(errors) {
195
+ if (!Array.isArray(errors))
196
+ return [];
197
+ return errors.filter((entry) => typeof entry === 'string' && entry.length > 0);
198
+ }
178
199
  export function resolveStatusNextAction(facts, binding, activation, lastCheck, rules) {
179
200
  if (facts.nextActionOverride?.id && facts.nextActionOverride.summary) {
180
201
  return {
@@ -202,22 +223,31 @@ export function resolveStatusNextAction(facts, binding, activation, lastCheck, r
202
223
  summary: 'No ark.config.json found — run ark start (preview) then ark start --apply.',
203
224
  };
204
225
  }
226
+ const configErrors = normalizedConfigErrors(facts.configErrors);
227
+ if (configErrors.length > 0) {
228
+ return {
229
+ id: 'fix-config',
230
+ summary: `The Ark config is invalid, so ark-check, MCP and ESLint refuse it — fix: ${configErrors
231
+ .slice(0, 3)
232
+ .join('; ')}${configErrors.length > 3 ? ` (+${configErrors.length - 3} more)` : ''}`,
233
+ };
234
+ }
205
235
  if (lastCheck.verdict === 'fail' || (lastCheck.activeViolations ?? 0) > 0) {
206
236
  return {
207
237
  id: 'fix-active-violations',
208
- summary: `Clear ${lastCheck.activeViolations ?? 'active'} blocking architecture finding(s), then re-run ark-check (or ark-check --doctor).`,
238
+ summary: `Clear ${lastCheck.activeViolations ?? 'active'} blocking architecture finding(s), then re-run ark-check --report to refresh the last-check snapshot (a plain check or --doctor does not update .ark/reports/latest.json).`,
209
239
  };
210
240
  }
211
241
  if (lastCheck.verdict === 'incomplete') {
212
242
  return {
213
243
  id: 'restore-complete-analysis',
214
- summary: 'Last check was incomplete — restore TypeScript/analysis inputs and re-run ark-check.',
244
+ summary: 'Last check was incomplete — restore TypeScript/analysis inputs and re-run ark-check --report to refresh the snapshot.',
215
245
  };
216
246
  }
217
247
  if (lastCheck.verdict == null && lastCheck.at == null) {
218
248
  return {
219
249
  id: 'run-ark-check',
220
- summary: 'No last-check snapshot yet — run ark-check --report (or --doctor) to freeze session evidence.',
250
+ summary: 'No last-check snapshot yet — run ark-check --report to freeze session evidence (--doctor does not write the snapshot).',
221
251
  };
222
252
  }
223
253
  if (activation.writePath === 'unavailable') {
@@ -334,6 +364,10 @@ export function buildStatusManifest(facts) {
334
364
  rules,
335
365
  nextAction: resolveStatusNextAction(facts, binding, activation, lastCheck, rules),
336
366
  };
367
+ const configErrors = normalizedConfigErrors(facts.configErrors);
368
+ if (projectIdentity.resolvedConfigPath && configErrors.length > 0) {
369
+ status.contract = { valid: false, errors: configErrors };
370
+ }
337
371
  const compass = normalizeStatusImprovementCompass(facts.improvementCompass);
338
372
  if (compass)
339
373
  status.improvementCompass = compass;
@@ -433,7 +467,7 @@ export function unavailableStatusImprovementCompass(input = {}) {
433
467
  topResidual: [],
434
468
  reasonCode: input.reasonCode ?? STATUS_COMPASS_REASON_CODES.NO_SESSION_SNAPSHOT,
435
469
  reason: input.reason ??
436
- 'No session compass facts yet — run ark-check --doctor or --report for residual lenses. Status never invents green.',
470
+ 'No session compass facts yet — run ark-check --report to write the session snapshot (--doctor shows full lenses but does not write it). Status never invents green.',
437
471
  factsSource: 'none',
438
472
  contractHash: input.contractHash,
439
473
  });
@@ -591,6 +625,16 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
591
625
  summary: { type: 'string', minLength: 1 },
592
626
  },
593
627
  },
628
+ contract: {
629
+ type: 'object',
630
+ description: 'Present only when the resolved config fails the shared contract validator (the same fail-closed rules ark-check, MCP and ESLint apply).',
631
+ additionalProperties: false,
632
+ required: ['valid', 'errors'],
633
+ properties: {
634
+ valid: { const: false },
635
+ errors: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
636
+ },
637
+ },
594
638
  improvementCompass: {
595
639
  type: 'object',
596
640
  description: 'Thin improvement-compass residual ids with honesty mode (notAScore). full | subset | unavailable. Never a gate input; full lenses on doctor JSON. When full, residual ids ⊆ doctor residual for the same facts. unavailable never invents green residual.',
@@ -625,31 +669,7 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
625
669
  baselineGrew: { type: 'boolean' },
626
670
  },
627
671
  },
628
- arkRun: {
629
- type: 'object',
630
- description: 'ArkRun extra residual (notAScore). present/mode from config; residual is a finding-id count (null = unknown, not green). extraMergeTeeth is honesty, never a score.',
631
- additionalProperties: false,
632
- required: ['notAScore', 'present', 'mode', 'extraMergeTeeth', 'residual'],
633
- properties: {
634
- notAScore: { const: true },
635
- present: { type: 'boolean' },
636
- mode: { anyOf: [{ enum: ['advisory', 'enforced'] }, { type: 'null' }] },
637
- extraMergeTeeth: { type: 'boolean' },
638
- residual: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] },
639
- },
640
- },
641
- arkOrder: {
642
- type: 'object',
643
- description: 'ArkOrder extra residual (notAScore). present/mode from config; residual is a finding-id count (null = unknown, not green). extraMergeTeeth is honesty, never a score.',
644
- additionalProperties: false,
645
- required: ['notAScore', 'present', 'mode', 'extraMergeTeeth', 'residual'],
646
- properties: {
647
- notAScore: { const: true },
648
- present: { type: 'boolean' },
649
- mode: { anyOf: [{ enum: ['advisory', 'enforced'] }, { type: 'null' }] },
650
- extraMergeTeeth: { type: 'boolean' },
651
- residual: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] },
652
- },
653
- },
672
+ arkRun: extraPlaneStatusSchema('ArkRun'),
673
+ arkOrder: extraPlaneStatusSchema('ArkOrder'),
654
674
  },
655
675
  };
@@ -17,12 +17,6 @@ import { layerForFile } from '../ark-shared.mjs';
17
17
  import { isDomainRoleLayerName } from './arkrules-sensors.mjs';
18
18
  import { collectStatesTransitionsResidual } from './states-transitions-presence.mjs';
19
19
 
20
- export const STATUS_CATALOG_ASK =
21
- 'Domain code already names statuses, but the states → transitions map is still thin.';
22
-
23
- export const STATUS_CATALOG_NEXT =
24
- 'Add those names to the table on the domain doc (entity · states · allowed from → to). No flag soup.';
25
-
26
20
  const SOURCE_EXT = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
27
21
  const MAX_FILE_BYTES = 200_000;
28
22
  const MAX_ENTITIES = 8;
@@ -19,7 +19,7 @@ export function dirSegmentsFromGlob(pattern) {
19
19
  * Basename / path tokens that score as Persistence (data clients, auth, CRM).
20
20
  * Used so adopt never maps lib/turso → Presentation (NEW-ADOPT-LIB-AS-PRESENTATION).
21
21
  */
22
- export const PERSISTENCE_PATH_TOKENS = Object.freeze([
22
+ const PERSISTENCE_PATH_TOKENS = Object.freeze([
23
23
  'turso',
24
24
  'prisma',
25
25
  'supabase',
@@ -64,7 +64,7 @@ export function isPersistenceClientPath(relPath) {
64
64
  }
65
65
 
66
66
  /** True when a path looks like pure domain folders. */
67
- export function isDomainPath(relPath) {
67
+ function isDomainPath(relPath) {
68
68
  const posix = String(relPath || '')
69
69
  .split(/[/\\]/)
70
70
  .filter(Boolean)
@@ -81,7 +81,7 @@ let _layerByDir;
81
81
  // Map<dirBasename, string[] layers>. A basename mapping to >1 layer (e.g. `app` — Application
82
82
  // orchestration in the 11-layer defaults, but Presentation in the monorepo/Next preset) is
83
83
  // genuinely ambiguous; every candidate is surfaced rather than silently picked.
84
- export function layerByDir() {
84
+ function layerByDir() {
85
85
  if (_layerByDir) return _layerByDir;
86
86
  const map = new Map();
87
87
  const add = (segment, layer) => {
@@ -2,11 +2,10 @@
2
2
  * Team parliament I/O — git base refs, changed paths, pin/contract/baseline compare.
3
3
  * Pure classification lives in team-parliament.mjs (Domain).
4
4
  */
5
- import { spawnSync } from 'node:child_process';
6
5
  import fs from 'node:fs';
7
6
  import path from 'node:path';
8
7
  import { createHash } from 'node:crypto';
9
- import { applyAdvisorySiblingRatchet } from '../ark-layer-match.mjs';
8
+ import { applyAdvisorySiblingRatchet, globToRegExp } from '../ark-layer-match.mjs';
10
9
  import {
11
10
  baselineKeysFromDocument,
12
11
  classifyBaselineKeyDelta,
@@ -20,39 +19,17 @@ import {
20
19
  resolveStewardHandle,
21
20
  suggestStewards,
22
21
  } from './team-parliament.mjs';
22
+ import {
23
+ TEAM_BASE_CANDIDATES,
24
+ discoverTeamBaseRef,
25
+ gitShowText,
26
+ listChangedPaths,
27
+ runGit,
28
+ safeGitRef,
29
+ } from './git-change-scope.mjs';
23
30
 
24
- export {
25
- baselineKeysFromDocument,
26
- classifyBaselineKeyDelta,
27
- classifyChangeSet,
28
- evaluateTeamGate,
29
- formatVsBaseLine,
30
- isTeamPersona,
31
- mapPolicyClassToKind,
32
- parseCodeownersHandles,
33
- personaCheckBudget,
34
- resolveStewardHandle,
35
- suggestStewards,
36
- };
37
-
38
- /** Kill hung git instead of stalling CI. */
39
- export const SPAWN_TIMEOUT_MS = 8000;
40
-
41
- function runGit(cwd, args) {
42
- return spawnSync('git', ['-C', cwd, ...args], {
43
- encoding: 'utf8',
44
- stdio: ['ignore', 'pipe', 'pipe'],
45
- timeout: SPAWN_TIMEOUT_MS,
46
- });
47
- }
48
-
49
- export function safeGitRef(value) {
50
- return (
51
- typeof value === 'string' &&
52
- /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
53
- !value.includes('..')
54
- );
55
- }
31
+ // prettier-ignore
32
+ export { discoverTeamBaseRef, safeGitRef };
56
33
 
57
34
  export function contractSessionFrom(args, env = process.env) {
58
35
  if (args?.contractSession === true) return true;
@@ -70,49 +47,6 @@ export function resolveTeamAuthor(args, env = process.env) {
70
47
  });
71
48
  }
72
49
 
73
- export function discoverTeamBaseRef(root, preferred) {
74
- if (safeGitRef(preferred)) return preferred;
75
- const candidates = ['origin/dev', 'origin/main', 'origin/master', 'dev', 'main'];
76
- for (const candidate of candidates) {
77
- const exists = runGit(root, ['rev-parse', '--verify', `${candidate}^{commit}`]);
78
- if (exists.status === 0) return candidate;
79
- }
80
- return null;
81
- }
82
-
83
- export function listChangedPaths(root, baseRef) {
84
- if (!safeGitRef(baseRef)) {
85
- return { ok: false, paths: [], error: 'Invalid or missing git base ref.' };
86
- }
87
- const verify = runGit(root, ['rev-parse', '--verify', `${baseRef}^{commit}`]);
88
- if (verify.status !== 0) {
89
- return { ok: false, paths: [], error: `Cannot resolve git ref ${baseRef}.` };
90
- }
91
- const diff = runGit(root, ['diff', '--name-only', `${baseRef}...HEAD`]);
92
- const unstaged = runGit(root, ['diff', '--name-only']);
93
- const staged = runGit(root, ['diff', '--name-only', '--cached']);
94
- const untracked = runGit(root, ['ls-files', '--others', '--exclude-standard']);
95
- if (diff.status !== 0) {
96
- return { ok: false, paths: [], error: diff.stderr.trim() || `git diff ${baseRef} failed.` };
97
- }
98
- const paths = [
99
- ...diff.stdout.split('\n'),
100
- ...unstaged.stdout.split('\n'),
101
- ...staged.stdout.split('\n'),
102
- ...untracked.stdout.split('\n'),
103
- ]
104
- .map((line) => line.trim().replace(/\\/g, '/'))
105
- .filter(Boolean);
106
- return { ok: true, paths: [...new Set(paths)].sort(), error: null };
107
- }
108
-
109
- export function gitShowText(root, baseRef, relPath) {
110
- if (!safeGitRef(baseRef) || !relPath) return null;
111
- const shown = runGit(root, ['show', `${baseRef}:${relPath}`]);
112
- if (shown.status !== 0) return null;
113
- return shown.stdout;
114
- }
115
-
116
50
  export function readJsonMaybe(text) {
117
51
  if (typeof text !== 'string' || !text.trim()) return null;
118
52
  try {
@@ -193,8 +127,11 @@ export function bindTeamBaseRefs(args, root) {
193
127
  if (!next.against && (next.changed || next.contractDiff || next.persona)) {
194
128
  next.against = teamBase;
195
129
  }
196
- if (!next.policyBaseRef && (next.contractDiff || next.changed || next.against)) {
130
+ // A contract session must classify the law change, or loosening skips steward/ack checks.
131
+ // policyBaseFromTeam: a base without the contract yet is adoption (no predecessor), not an error.
132
+ if (!next.policyBaseRef && (next.contractDiff || next.changed || next.against || next.contractSession)) {
197
133
  next.policyBaseRef = teamBase;
134
+ next.policyBaseFromTeam = true;
198
135
  }
199
136
  if (!next.baseRef && next.failOnNewSmells) next.baseRef = teamBase;
200
137
  }
@@ -236,17 +173,28 @@ export function runTeamPreflight({ root, args, config, policyDelta, teamBase })
236
173
  return { halt: null, teamParliament: null, changedPaths: [] };
237
174
  }
238
175
  const againstRef = args.against || teamBase;
239
- if (args.local && !againstRef) {
240
- const message =
241
- '--local needs a git merge base so it can reuse --changed. Pass --base <ref> (for example --base HEAD or --base origin/main). The merge gate stays --strict-merge.';
242
- const teamParliament = { deny: false, reasonId: 'local-needs-base', message };
176
+ if ((args.local || args.changed || args.contractDiff) && !againstRef) {
177
+ const reasonId = args.local
178
+ ? 'local-needs-base'
179
+ : args.changed
180
+ ? 'changed-needs-base'
181
+ : 'contract-diff-needs-base';
182
+ const message = args.local
183
+ ? '--local needs a git merge base so it can reuse --changed. Pass --base <ref> (for example --base HEAD or --base origin/main). The merge gate stays --strict-merge.'
184
+ : args.changed
185
+ ? changedNeedsBaseMessage(args)
186
+ : contractDiffNeedsBaseMessage(args);
187
+ const teamParliament = { deny: false, reasonId, message };
243
188
  return {
244
189
  halt: { exitCode: 2, message, teamParliament },
245
190
  teamParliament,
246
191
  changedPaths: [],
247
192
  };
248
193
  }
249
- const listed = againstRef ? listChangedPaths(root, againstRef) : { ok: true, paths: [], error: null };
194
+ // No base ref means the change set was never computed: never report it as empty.
195
+ const listed = againstRef
196
+ ? listChangedPaths(root, againstRef)
197
+ : { ok: false, paths: [], error: 'No git base ref resolved.' };
250
198
  const changedPaths = listed.ok ? listed.paths : [];
251
199
  const changeSet = classifyChangeSet(changedPaths);
252
200
  const baseBaselineRaw = againstRef
@@ -279,8 +227,12 @@ export function runTeamPreflight({ root, args, config, policyDelta, teamBase })
279
227
  changedPathError: listed.ok ? null : listed.error,
280
228
  };
281
229
  if (!listed.ok && (args.changed || args.against || args.contractDiff)) {
230
+ // The verdict above ran over an uncomputed diff: never label it `ok`.
231
+ teamParliament.deny = false;
232
+ teamParliament.reasonId = 'changed-paths-unavailable';
233
+ teamParliament.message = `${listed.error || 'Cannot list changed paths.'} Pass --base <ref> or fix the git error, or run a full check without diff flags.`;
282
234
  return {
283
- halt: { exitCode: 2, message: listed.error || 'Cannot resolve team base ref.', teamParliament },
235
+ halt: { exitCode: 2, message: teamParliament.message, teamParliament },
284
236
  teamParliament,
285
237
  changedPaths,
286
238
  };
@@ -302,6 +254,17 @@ export function runTeamPreflight({ root, args, config, policyDelta, teamBase })
302
254
  return { halt: null, teamParliament, changedPaths };
303
255
  }
304
256
 
257
+ /** `--changed` / `--persona touch|contributor|agent` without a resolvable merge base. */
258
+ function changedNeedsBaseMessage(args) {
259
+ const flag = args?.persona ? `--persona ${args.persona}` : '--changed';
260
+ return `${flag} needs a git merge base to know which files changed, and none was found (tried ${TEAM_BASE_CANDIDATES.join(', ')}; or this is not a git repository). Pass --base <ref> (for example --base HEAD or --base origin/main), or run without ${flag} for a full-tree check.`;
261
+ }
262
+
263
+ function contractDiffNeedsBaseMessage(args) {
264
+ const flag = args?.persona ? `--persona ${args.persona}` : '--contract-diff';
265
+ return `${flag} compares the contract and .ark-baseline.json against a git base, and none was found (tried ${TEAM_BASE_CANDIDATES.join(', ')}; or this is not a git repository). Pass --base <ref> (for example --base origin/main), or run a plain full-tree check without ${flag}.`;
266
+ }
267
+
305
268
  export function ungovernedDumpMessage(dumped) {
306
269
  return `New ungoverned source in this diff: ${dumped.slice(0, 8).join(', ')}${dumped.length > 8 ? '…' : ''}. Classify via /ark-adopt (contract session) or move into a governed layer.`;
307
270
  }
@@ -312,6 +275,29 @@ export function filterChangedGovernedFiles(allGovernedFiles, root, changedPaths,
312
275
  return allGovernedFiles.filter((abs) => changedSet.has(normalizeRel(path.relative(root, abs))));
313
276
  }
314
277
 
278
+ /**
279
+ * `--changed` scans a subset, so a layer whose files were not touched looks empty to the
280
+ * kernel. Keep CONFIG_LAYER_PATTERN_NO_MATCHES only when the FULL governed tree has no
281
+ * match either (a real typo). `loadFullFiles` is lazy: the full walk runs only when needed.
282
+ */
283
+ export function pruneScopedPatternWarnings(warnings, root, loadFullFiles) {
284
+ const isNoMatch = (w) =>
285
+ (w?.ruleId ?? w?.code) === 'CONFIG_LAYER_PATTERN_NO_MATCHES' && typeof w.pattern === 'string';
286
+ if (!Array.isArray(warnings) || !warnings.some(isNoMatch)) return warnings;
287
+ const rels = loadFullFiles().map((abs) => path.relative(root, abs).split(path.sep).join('/'));
288
+ for (let i = warnings.length - 1; i >= 0; i -= 1) {
289
+ if (!isNoMatch(warnings[i])) continue;
290
+ let expression;
291
+ try {
292
+ expression = globToRegExp(warnings[i].pattern);
293
+ } catch {
294
+ continue;
295
+ }
296
+ if (rels.some((rel) => expression.test(rel))) warnings.splice(i, 1);
297
+ }
298
+ return warnings;
299
+ }
300
+
315
301
  export function applyAgainstRatchet({
316
302
  violations,
317
303
  againstRef,
@@ -319,16 +305,18 @@ export function applyAgainstRatchet({
319
305
  changed,
320
306
  changedPaths,
321
307
  occurrenceKeys,
308
+ rules,
309
+ layers,
322
310
  }) {
323
311
  const baseRaw = readJsonMaybe(gitShowText(root, againstRef, '.ark-baseline.json'));
324
312
  const baseKeys = new Set(baselineKeysFromDocument(baseRaw));
325
- const judged = baseRaw ? applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseKeys) : violations;
313
+ const judged = baseRaw ? applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseKeys, { rules, layers }) : violations;
326
314
  const vsBaseActive = judged.filter((_, index) => !baseKeys.has(occurrenceKeys[index]));
327
315
  const changedSet = new Set(changedPaths ?? []);
316
+ // A CROSS_PARENT_VIA_SHARED path belongs to the change when any file on it changed.
317
+ const touched = (v) => [v.file, ...(Array.isArray(v.via) ? v.via : []), ...(v.reasonId === 'CROSS_PARENT_VIA_SHARED' ? [v.target] : [])];
328
318
  const activeViolations = changed
329
- ? vsBaseActive.filter((violation) =>
330
- changedSet.has(String(violation.file || '').replace(/\\/g, '/'))
331
- )
319
+ ? vsBaseActive.filter((v) => touched(v).some((f) => changedSet.has(String(f || '').replace(/\\/g, '/'))))
332
320
  : vsBaseActive;
333
321
  return {
334
322
  activeViolations,
@@ -8,8 +8,8 @@
8
8
  * Pure CLI helper (bin/lib/team-parliament.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
- export const TEAM_PERSONAS = ['touch', 'contributor', 'agent', 'steward'];
12
- export const CONTRACT_DIFF_KINDS = [
11
+ const TEAM_PERSONAS = ['touch', 'contributor', 'agent', 'steward'];
12
+ const CONTRACT_DIFF_KINDS = [
13
13
  'unchanged',
14
14
  'tighten',
15
15
  'loosen',
@@ -35,11 +35,14 @@ export function isLawRelativePath(relPath) {
35
35
  if ((n.startsWith('arkrules/') || n.includes('/arkrules/')) && n.endsWith('.json')) {
36
36
  return true;
37
37
  }
38
+ // Slice file named by childSlices.arkRulesFile (`arkrules.<Layer>.json`), including a lookalike.
39
+ if (/^arkrules\.[^/]+\.json$/i.test(base))
40
+ return true;
38
41
  return false;
39
42
  }
40
43
  const PRODUCT_SOURCE = /\.(tsx?|jsx?|mjs|cjs)$/i;
41
44
  /** Governable product source (not law, not tests-only heuristic — basename extension). */
42
- export function isProductSourceRelativePath(relPath) {
45
+ function isProductSourceRelativePath(relPath) {
43
46
  const n = posixRel(relPath);
44
47
  if (!n || isLawRelativePath(n))
45
48
  return false;
@@ -100,29 +103,34 @@ export function classifyBaselineKeyDelta(baseKeys, candidateKeys) {
100
103
  kinds.push('baseline-shrink');
101
104
  return { grow, shrink, kinds };
102
105
  }
103
- export function normalizeStewardId(value) {
106
+ function normalizeStewardId(value) {
104
107
  if (typeof value !== 'string')
105
108
  return null;
106
109
  const trimmed = value.trim().replace(/^@/, '').toLowerCase();
107
110
  return trimmed.length > 0 ? trimmed : null;
108
111
  }
109
112
  /** GitHub handle (login), not a display name. Spaces and emails are not handles. */
110
- export function isGitHubHandle(value) {
113
+ function isGitHubHandle(value) {
111
114
  const id = normalizeStewardId(value);
112
115
  if (!id || isAutomationAuthor(id) || id.includes(' ') || id.includes('@'))
113
116
  return false;
114
117
  return /^(?!-)[a-z0-9-]{1,39}(?<!-)$/.test(id);
115
118
  }
116
- export function isGitHubEmail(value) {
119
+ function isGitHubEmail(value) {
117
120
  if (typeof value !== 'string')
118
121
  return false;
119
122
  const email = value.trim().toLowerCase();
120
123
  if (!email.includes('@') || email.includes(' ') || isAutomationAuthor(email))
121
124
  return false;
122
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
125
+ // local@domain.tld without a backtracking regex (CodeQL js/polynomial-redos).
126
+ const parts = email.split('@');
127
+ if (parts.length !== 2 || !parts[0] || /\s/.test(email))
128
+ return false;
129
+ const dot = parts[1].lastIndexOf('.');
130
+ return dot > 0 && dot < parts[1].length - 1;
123
131
  }
124
132
  /** `123+login@users.noreply.github.com` or `login@users.noreply.github.com` → login. */
125
- export function githubHandleFromEmail(email) {
133
+ function githubHandleFromEmail(email) {
126
134
  if (typeof email !== 'string')
127
135
  return null;
128
136
  const match = email.trim().match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i);
@@ -145,7 +153,7 @@ export function canonicalStewardId(value) {
145
153
  return id;
146
154
  return null;
147
155
  }
148
- export function formatStewardMention(id) {
156
+ function formatStewardMention(id) {
149
157
  return id.includes('@') ? id : `@${id}`;
150
158
  }
151
159
  /**
@@ -173,7 +181,7 @@ export function isSteward(author, stewards) {
173
181
  return stewards.some((entry) => canonicalStewardId(entry) === who);
174
182
  }
175
183
  const BOT_STEWARD = /bot\b|\[bot\]|dependabot|renovate|github-actions|imgbot|codecov/i;
176
- export function isAutomationAuthor(value) {
184
+ function isAutomationAuthor(value) {
177
185
  const id = normalizeStewardId(value);
178
186
  return !id || BOT_STEWARD.test(id);
179
187
  }
@@ -313,6 +321,9 @@ export function personaCheckBudget(persona) {
313
321
  denyLoosenUnlessSteward: true,
314
322
  };
315
323
  }
324
+ /**
325
+ * Narrow a string to a team persona.
326
+ */
316
327
  export function isTeamPersona(value) {
317
328
  return TEAM_PERSONAS.includes(value);
318
329
  }
@@ -46,7 +46,7 @@ function versionParts(version) {
46
46
  * @param {string} right
47
47
  * @returns {-1|0|1}
48
48
  */
49
- export function compareUpgradeVersions(left, right) {
49
+ function compareUpgradeVersions(left, right) {
50
50
  const a = versionParts(left);
51
51
  const b = versionParts(right);
52
52
  const length = Math.max(a.length, b.length);
@@ -61,7 +61,7 @@ export function compareUpgradeVersions(left, right) {
61
61
  * @param {UpgradeBehaviorChange} row
62
62
  * @returns {string}
63
63
  */
64
- export function formatBehaviorChange(row) {
64
+ function formatBehaviorChange(row) {
65
65
  return `${row.version}: ${row.note}`;
66
66
  }
67
67