arkgate 4.8.24 → 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 (166) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +17 -14
  4. package/bin/ark-layer-match.mjs +115 -13
  5. package/bin/ark-mcp-runtime.mjs +36 -31
  6. package/bin/ark-shared.mjs +4 -23
  7. package/bin/lib/adoption-stance.mjs +2 -2
  8. package/bin/lib/adr-path.mjs +3 -3
  9. package/bin/lib/agent-gates.mjs +0 -61
  10. package/bin/lib/agent-homes.mjs +12 -21
  11. package/bin/lib/agent-projection-command.mjs +4 -4
  12. package/bin/lib/ambient-state.mjs +9 -30
  13. package/bin/lib/analysis-engine.mjs +14 -14
  14. package/bin/lib/architecture-scan.mjs +11 -9
  15. package/bin/lib/ark-order-doctor.mjs +3 -28
  16. package/bin/lib/ark-order-facts.mjs +6 -99
  17. package/bin/lib/ark-order-types.mjs +1 -2
  18. package/bin/lib/ark-run-doctor.mjs +3 -28
  19. package/bin/lib/ark-run-facts.mjs +30 -40
  20. package/bin/lib/ark-run-sensors.mjs +1 -1
  21. package/bin/lib/arkrule-file-hints.mjs +2 -47
  22. package/bin/lib/arkrules-contract.mjs +124 -133
  23. package/bin/lib/arkrules-sensors.mjs +7 -5
  24. package/bin/lib/check-args.mjs +63 -1
  25. package/bin/lib/ci-and-commands.mjs +8 -72
  26. package/bin/lib/ci-merge-boundary.mjs +1 -1
  27. package/bin/lib/class-source-scan.mjs +5 -5
  28. package/bin/lib/clone-detection.mjs +474 -0
  29. package/bin/lib/codex-home.mjs +1 -1
  30. package/bin/lib/config-contract-slices.mjs +44 -6
  31. package/bin/lib/config-contract.mjs +40 -117
  32. package/bin/lib/config-extras.mjs +16 -22
  33. package/bin/lib/contract-smells.mjs +33 -15
  34. package/bin/lib/core-ratchet.mjs +0 -2
  35. package/bin/lib/dashboard-cli.mjs +2 -2
  36. package/bin/lib/deep-module-coach.mjs +2 -4
  37. package/bin/lib/deploy-path.mjs +13 -38
  38. package/bin/lib/design-smells.mjs +2 -2
  39. package/bin/lib/diagnostic-catalog.mjs +8 -1
  40. package/bin/lib/doctor-advisories.mjs +55 -9
  41. package/bin/lib/doctor-green-cite.mjs +1 -1
  42. package/bin/lib/doctor-human.mjs +1 -1
  43. package/bin/lib/doctor-next-actions.mjs +2 -2
  44. package/bin/lib/doctor-plan.mjs +4 -6
  45. package/bin/lib/duplication-io.mjs +572 -0
  46. package/bin/lib/effective-contract-load.mjs +181 -44
  47. package/bin/lib/entry-points-io.mjs +424 -0
  48. package/bin/lib/extra-merge-teeth.mjs +36 -0
  49. package/bin/lib/field-install.mjs +38 -14
  50. package/bin/lib/first-run-help.mjs +7 -3
  51. package/bin/lib/flat-parent-importers.mjs +5 -3
  52. package/bin/lib/gate-files.mjs +30 -89
  53. package/bin/lib/github-enforcement.mjs +165 -28
  54. package/bin/lib/golden-pattern.mjs +1 -1
  55. package/bin/lib/hook-templates.mjs +16 -16
  56. package/bin/lib/html-report-advisories.mjs +4 -0
  57. package/bin/lib/html-report-depth.mjs +2 -2
  58. package/bin/lib/html-report.mjs +2 -2
  59. package/bin/lib/import-graph-projection.mjs +112 -0
  60. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  61. package/bin/lib/invariant-coverage-io.mjs +4 -3
  62. package/bin/lib/invariant-coverage.mjs +26 -0
  63. package/bin/lib/invariant-probe-cli.mjs +347 -0
  64. package/bin/lib/invariant-probe-io.mjs +165 -0
  65. package/bin/lib/invariant-probe-runner.mjs +268 -0
  66. package/bin/lib/invariant-probe-sites.mjs +174 -0
  67. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  68. package/bin/lib/invariant-probe.mjs +461 -0
  69. package/bin/lib/invariant-tests-path.mjs +16 -22
  70. package/bin/lib/literal-path-drift.mjs +4 -2
  71. package/bin/lib/managed-upgrade.mjs +2 -8
  72. package/bin/lib/mcp-adoption.mjs +4 -5
  73. package/bin/lib/mcp-hook-payload.mjs +4 -4
  74. package/bin/lib/orphan-modules-io.mjs +458 -0
  75. package/bin/lib/orphan-modules.mjs +251 -0
  76. package/bin/lib/outside-importers.mjs +197 -0
  77. package/bin/lib/package-manager.mjs +3 -3
  78. package/bin/lib/parse-health.mjs +1 -1
  79. package/bin/lib/persistence-write-hint.mjs +33 -8
  80. package/bin/lib/pin-dependent-gates.mjs +2 -2
  81. package/bin/lib/policy-delta-io.mjs +55 -8
  82. package/bin/lib/post-green-path.mjs +1 -1
  83. package/bin/lib/prepare-change.mjs +3 -9
  84. package/bin/lib/prepare-write.mjs +1 -2
  85. package/bin/lib/presets.mjs +56 -68
  86. package/bin/lib/product-copy.mjs +1 -6
  87. package/bin/lib/project-root.mjs +31 -62
  88. package/bin/lib/resolved-candidate-facts.mjs +90 -62
  89. package/bin/lib/rules-under-contract.mjs +72 -8
  90. package/bin/lib/scan-files.mjs +4 -4
  91. package/bin/lib/schema-validation.mjs +130 -0
  92. package/bin/lib/sensor-promote-cli.mjs +2 -11
  93. package/bin/lib/sensor-promote-io.mjs +33 -22
  94. package/bin/lib/sensor-promotion.mjs +1 -1
  95. package/bin/lib/skill-catalog-apply.mjs +1 -1
  96. package/bin/lib/skill-install.mjs +42 -38
  97. package/bin/lib/snippet-analysis.mjs +1 -1
  98. package/bin/lib/start-install-recovery.mjs +1 -1
  99. package/bin/lib/start-preview.mjs +1 -1
  100. package/bin/lib/states-transitions-presence.mjs +3 -3
  101. package/bin/lib/status-command.mjs +2 -18
  102. package/bin/lib/status-manifest.mjs +18 -26
  103. package/bin/lib/status-transition-catalog.mjs +0 -6
  104. package/bin/lib/suggestions.mjs +3 -3
  105. package/bin/lib/team-parliament-io.mjs +3 -17
  106. package/bin/lib/team-parliament.mjs +15 -9
  107. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  108. package/bin/lib/upgrade-command.mjs +2 -24
  109. package/bin/lib/upgrade-outcome.mjs +1 -1
  110. package/bin/lib/upstream-report.mjs +21 -10
  111. package/bin/lib/violations.mjs +10 -9
  112. package/bin/lib/weakest-link.mjs +13 -0
  113. package/bin/lib/write-path-capabilities.mjs +2 -2
  114. package/dist/chunk-AHACEKSV.js +16 -0
  115. package/dist/chunk-UN5EH4QV.cjs +16 -0
  116. package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
  117. package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
  118. package/dist/eslint/index.cjs +11 -11
  119. package/dist/eslint/index.d.cts +10 -1
  120. package/dist/eslint/index.d.ts +10 -1
  121. package/dist/eslint/index.js +11 -11
  122. package/dist/index.cjs +34 -34
  123. package/dist/index.d.cts +587 -515
  124. package/dist/index.d.ts +587 -515
  125. package/dist/index.js +34 -34
  126. package/dist/nestjs/index.cjs +1 -1
  127. package/dist/nestjs/index.d.cts +1 -1
  128. package/dist/nestjs/index.d.ts +1 -1
  129. package/dist/nestjs/index.js +1 -1
  130. package/dist/order/index.cjs +1 -1
  131. package/dist/order/index.js +1 -1
  132. package/dist/runtime/index.cjs +12 -12
  133. package/dist/runtime/index.d.cts +174 -136
  134. package/dist/runtime/index.d.ts +174 -136
  135. package/dist/runtime/index.js +12 -12
  136. package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
  137. package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
  138. package/docs/README.md +2 -1
  139. package/docs/agent-guide.md +134 -2
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +118 -2
  143. package/docs/package-surface.md +4 -1
  144. package/docs/product-voice.md +7 -1
  145. package/package.json +11 -2
  146. package/schemas/ark.config.schema.json +31 -3
  147. package/schemas/ark.invariant-probe.schema.json +368 -0
  148. package/server.json +2 -2
  149. package/templates/agent-skills/README.md +1 -1
  150. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  151. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  152. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  153. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  154. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  155. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  156. package/templates/skills/ark-adopt.md +1 -0
  157. package/templates/skills/ark-contract.md +17 -0
  158. package/templates/skills/ark-coverage.md +2 -0
  159. package/templates/skills/ark-explore.md +7 -0
  160. package/templates/skills/ark-fix.md +20 -0
  161. package/templates/skills/ark-place.md +8 -0
  162. package/bin/lib/ark-order-error.mjs +0 -18
  163. package/bin/lib/ark-order-invariants.mjs +0 -388
  164. package/bin/lib/stable-hash.mjs +0 -90
  165. package/dist/chunk-MBXCFLO4.js +0 -16
  166. package/dist/chunk-RNQHRDCQ.cjs +0 -16
@@ -7,10 +7,10 @@ import { createRequire } from 'node:module';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { codexProjectMcpIsValid } from './codex-home.mjs';
9
9
  import { enforcingArkRunText, runsArkCheck } from './github-enforcement.mjs';
10
+ import { parseJsonMergeInputs } from './hook-templates.mjs';
10
11
  import { npxArkgatePrefixLength } from './package-manager.mjs';
11
12
 
12
13
  export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
13
- export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
14
14
 
15
15
  export function readJson(file) {
16
16
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -43,13 +43,16 @@ export function packageScriptsHaveTypecheck(scripts) {
43
43
  }
44
44
 
45
45
  /**
46
- * Root package (and shallow nested packages) already have a typecheck script.
47
- * Does not scan CI or framework configs — only package.json scripts.
46
+ * Visit the parsed package.json of each shallow nested package — `root/<dir>/`
47
+ * and `root/<dir>/<child>/`, skipping dot directories and `node_modules` — in
48
+ * directory order. Unreadable or invalid files are skipped. Stops at the first
49
+ * visit that returns true.
50
+ *
48
51
  * @param {string} root
52
+ * @param {(pkg: any, dir: string) => boolean | void} visit
53
+ * @returns {boolean} true when a visit returned true
49
54
  */
50
- export function treeHasTypecheckScript(root) {
51
- const pkg = readPackageJson(root);
52
- if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
55
+ export function someNestedPackageJson(root, visit) {
53
56
  try {
54
57
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
55
58
  if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
@@ -67,8 +70,7 @@ export function treeHasTypecheckScript(root) {
67
70
  const pj = path.join(dir, 'package.json');
68
71
  if (!fs.existsSync(pj)) continue;
69
72
  try {
70
- const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
71
- if (packageScriptsHaveTypecheck(nested.scripts)) return true;
73
+ if (visit(JSON.parse(fs.readFileSync(pj, 'utf8')), dir) === true) return true;
72
74
  } catch {
73
75
  /* ignore */
74
76
  }
@@ -80,6 +82,17 @@ export function treeHasTypecheckScript(root) {
80
82
  return false;
81
83
  }
82
84
 
85
+ /**
86
+ * Root package (and shallow nested packages) already have a typecheck script.
87
+ * Does not scan CI or framework configs — only package.json scripts.
88
+ * @param {string} root
89
+ */
90
+ export function treeHasTypecheckScript(root) {
91
+ const pkg = readPackageJson(root);
92
+ if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
93
+ return someNestedPackageJson(root, (nested) => packageScriptsHaveTypecheck(nested.scripts));
94
+ }
95
+
83
96
  /**
84
97
  * Add a conservative `typecheck` script when the host has a TS/JS project config
85
98
  * but no typecheck-like script yet. Never overwrites an existing script.
@@ -96,7 +109,7 @@ export function treeHasTypecheckScript(root) {
96
109
  * True when `typescript` is declared by the project (deps/devDeps/peerDeps) or resolves
97
110
  * from its root (e.g. hoisted in a workspace), so a generated `tsc --noEmit` can run.
98
111
  */
99
- export function projectHasTypeScript(root, pkg = readPackageJson(root) || {}) {
112
+ function projectHasTypeScript(root, pkg = readPackageJson(root) || {}) {
100
113
  for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
101
114
  const deps = pkg?.[field];
102
115
  if (deps && typeof deps === 'object' && Object.hasOwn(deps, 'typescript')) return true;
@@ -142,7 +155,7 @@ export const REQUIRED_GATE_FILES = [
142
155
  '.mcp.json',
143
156
  ];
144
157
  export const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
145
- export const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
158
+ const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
146
159
  const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
147
160
  const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
148
161
 
@@ -317,16 +330,9 @@ export const CLAUDE_STYLE_HOOK_FILES = ['.claude/settings.json', '.codex/hooks.j
317
330
  * Preserves sibling servers and unknown top-level keys. Returns null if unreadable.
318
331
  */
319
332
  export function mergeArkMcpJson(existingText, generatedText) {
320
- let existing;
321
- let generated;
322
- try {
323
- existing = existingText && existingText.trim() ? JSON.parse(existingText) : {};
324
- generated = JSON.parse(generatedText);
325
- } catch {
326
- return null;
327
- }
328
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return null;
329
- if (!generated || typeof generated !== 'object' || Array.isArray(generated)) return null;
333
+ const parsed = parseJsonMergeInputs(existingText, generatedText);
334
+ if (!parsed) return null;
335
+ const { existing, generated } = parsed;
330
336
  const generatedArk = generated.mcpServers?.ark;
331
337
  if (!generatedArk || typeof generatedArk !== 'object' || Array.isArray(generatedArk)) {
332
338
  return null;
@@ -604,71 +610,6 @@ function unquoteYamlScalar(value) {
604
610
  return match ? match[2].trim() : text;
605
611
  }
606
612
 
607
- function jobCondition(lines, job) {
608
- const condition = jobProperty(lines, job, 'if');
609
- if (!condition) return 'default';
610
- const value = unquoteYamlScalar(condition.value);
611
- if (/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(value)) return 'always';
612
- if (/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(value)) return 'true';
613
- return 'conditional';
614
- }
615
-
616
- function jobNeeds(lines, job) {
617
- const property = jobProperty(lines, job, 'needs');
618
- if (!property) return { ids: [], indexes: [], valid: true };
619
- const indexes = [property.index];
620
- if (property.value) {
621
- const value = unquoteYamlScalar(property.value);
622
- const raw = value.startsWith('[') && value.endsWith(']')
623
- ? value.slice(1, -1).split(',')
624
- : [value];
625
- const ids = raw.map(unquoteYamlScalar).filter((id) => /^[A-Za-z0-9_-]+$/.test(id));
626
- return { ids, indexes, valid: ids.length === raw.length && ids.length > 0 };
627
- }
628
- const ids = [];
629
- for (let index = property.index + 1; index < job.end; index += 1) {
630
- if (!lines[index].trim() || /^\s*#/.test(lines[index])) {
631
- indexes.push(index);
632
- continue;
633
- }
634
- const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
635
- if (indent <= job.propertyIndent) break;
636
- indexes.push(index);
637
- const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
638
- if (!item) return { ids: [], indexes, valid: false };
639
- ids.push(item[2]);
640
- }
641
- return { ids, indexes, valid: ids.length > 0 };
642
- }
643
-
644
- function withVerifiedDependencyJobs(content) {
645
- const { lines, jobs } = workflowJobSections(content);
646
- const byId = new Map(jobs.map((job) => [job.id, job]));
647
- const guaranteed = (job, seen = new Set()) => {
648
- if (!job || seen.has(job.id)) return false;
649
- const condition = jobCondition(lines, job);
650
- if (condition === 'conditional') return false;
651
- if (condition === 'always') return true;
652
- const needs = jobNeeds(lines, job);
653
- if (!needs.valid) return false;
654
- const nextSeen = new Set(seen).add(job.id);
655
- return needs.ids.every((id) => guaranteed(byId.get(id), nextSeen));
656
- };
657
- for (const job of jobs) {
658
- const needs = jobNeeds(lines, job);
659
- if (
660
- needs.valid &&
661
- needs.ids.length > 0 &&
662
- needs.ids.every((id) => guaranteed(byId.get(id)))
663
- ) {
664
- // The shared analyzer treats every `needs` as skippable. Hide it only after
665
- // this dependency chain is proven unconditional; keep uncertain/skipped needs visible.
666
- for (const index of needs.indexes) lines[index] = '';
667
- }
668
- }
669
- return lines.join('\n');
670
- }
671
-
672
613
  function isGuaranteedJobCondition(value) {
673
614
  const text = unquoteYamlScalar(value);
674
615
  return (
@@ -732,13 +673,13 @@ export function inspectArkCiGate(root) {
732
673
  } catch {
733
674
  continue;
734
675
  }
735
- const prepared = withVerifiedDependencyJobs(withFailClosedArkActions(content));
676
+ // The shared analyzer proves `needs:` chains (github-enforcement.mjs), so this
677
+ // file-level view and doctor merge-gate evidence cannot disagree.
678
+ const prepared = withFailClosedArkActions(content);
736
679
  const failClosed = FAIL_CLOSED_ARK_FLAG.test(
737
680
  enforcingArkRunText(prepared, failClosedScript)
738
681
  );
739
- const visible = withVerifiedDependencyJobs(
740
- neutralizeSkippableJobControls(withFailClosedArkActions(content))
741
- );
682
+ const visible = neutralizeSkippableJobControls(prepared);
742
683
  const present =
743
684
  failClosed ||
744
685
  runsArkCheck(visible, declaredScript) ||
@@ -6,6 +6,9 @@ import path from 'node:path';
6
6
  /** Kill hung gh instead of stalling CI. */
7
7
  export const SPAWN_TIMEOUT_MS = 8000;
8
8
 
9
+ /** GitHub Actions app id: a required check bound to it can only be reported by a workflow job. */
10
+ export const GITHUB_ACTIONS_APP_ID = 15368;
11
+
9
12
  function runGh(args, opts = {}) {
10
13
  const { run, ...rest } = opts;
11
14
  const spawn = typeof run === 'function' ? run : spawnSync;
@@ -18,8 +21,8 @@ function runGh(args, opts = {}) {
18
21
 
19
22
  const IF_LINE = /^[ \t]*(?:-\s+)?(?:"if"|'if'|if):\s*(.*?)\s*(?:#.*)?$/i;
20
23
  const CONTINUE_LINE = /^[ \t]*(?:-\s+)?(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*(.*?)\s*(?:#.*)?$/i;
21
- const SAFE_IF = /^(?:['"]?true['"]?|['"]?\$\{\{\s*(?:true|always\(\))\s*\}\}['"]?)$/i;
22
- const SAFE_NEEDS_IF = /^['"]?\$\{\{\s*always\(\)\s*\}\}['"]?$/i;
24
+ const SAFE_IF = /^(?:['"]?true['"]?|['"]?(?:\$\{\{\s*(?:true|always\(\))\s*\}\}|always\(\))['"]?)$/i;
25
+ const SAFE_NEEDS_IF = /^['"]?(?:\$\{\{\s*always\(\)\s*\}\}|always\(\))['"]?$/i;
23
26
  const SAFE_CONTINUE = /^['"]?false['"]?$/i;
24
27
  const ENV_PREFIX = /^(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S+)\s+)*)/;
25
28
  const DIRECT_ARK = /^(?:(?:npx(?:\s+(?:-y|--yes))?(?:\s+(?:-p\s+|--package[\s=])arkgate(?:@\S+)?)?|yarn)\s+(?:arkgate-check|ark-check)(?=\s|$)|pnpm(?:\s+--\S+)*\s+exec\s+(?:arkgate-check|ark-check)(?=\s|$)|node\s+(?:\S+\/)?bin\/(?:arkgate-check|ark-check)\.mjs(?=\s|$)|(?:arkgate-check|ark-check)(?=\s|$))/;
@@ -164,7 +167,7 @@ function propertyValue(line, pattern) {
164
167
  return line.match(pattern)?.[1]?.trim() ?? null;
165
168
  }
166
169
 
167
- function unsafeControl(lines, propertyIndent, needs = false) {
170
+ function controlValues(lines, propertyIndent) {
168
171
  const atIndent = lines.filter((line) => {
169
172
  const indent = line.match(/^\s*/)?.[0].length ?? 0;
170
173
  return indent + (/^\s*-\s+/.test(line) ? 2 : 0) === propertyIndent;
@@ -173,9 +176,13 @@ function unsafeControl(lines, propertyIndent, needs = false) {
173
176
  const continuation = atIndent
174
177
  .map((line) => propertyValue(line, CONTINUE_LINE))
175
178
  .find((value) => value !== null);
179
+ return { condition, continuation };
180
+ }
181
+
182
+ function unsafeControl(lines, propertyIndent) {
183
+ const { condition, continuation } = controlValues(lines, propertyIndent);
176
184
  if (condition !== undefined && !SAFE_IF.test(condition)) return true;
177
- if (continuation !== undefined && !SAFE_CONTINUE.test(continuation)) return true;
178
- return needs && (condition === undefined || !SAFE_NEEDS_IF.test(condition));
185
+ return continuation !== undefined && !SAFE_CONTINUE.test(continuation);
179
186
  }
180
187
 
181
188
  function stepBounds(lines, runIndex, runIndent, inline) {
@@ -217,7 +224,7 @@ function unquoteRun(value) {
217
224
  return trimmed;
218
225
  }
219
226
 
220
- export function workflowRunText(text) {
227
+ function workflowRunText(text) {
221
228
  const lines = String(text || '').split('\n');
222
229
  const commands = [];
223
230
  for (let index = 0; index < lines.length; index++) {
@@ -243,14 +250,90 @@ export function workflowRunText(text) {
243
250
  return commands.join('\n');
244
251
  }
245
252
 
246
- function jobCannotEnforce(job) {
253
+ const NEEDS_LINE = /^\s*(?:"needs"|'needs'|needs):\s*(.*?)\s*(?:#.*)?$/;
254
+ const JOB_ID = /^[A-Za-z0-9_-]+$/;
255
+
256
+ function unquoteScalar(value) {
257
+ return String(value).trim().replace(/^(['"])(.*)\1$/, '$2').trim();
258
+ }
259
+
260
+ /** `needs:` as scalar, flow list, or block list. `valid: false` for expressions/malformed. */
261
+ function jobNeeds(job) {
247
262
  const propertyIndent = jobPropertyIndent(job);
248
263
  const lines = job.body.split('\n');
249
- const needs = lines.some((line) =>
250
- (line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent &&
251
- /^\s*(?:"needs"|'needs'|needs):/.test(line)
264
+ const start = lines.findIndex((line) =>
265
+ (line.match(/^\s*/)?.[0].length ?? 0) === propertyIndent && NEEDS_LINE.test(line)
252
266
  );
253
- return unsafeControl(lines, propertyIndent, needs);
267
+ if (start < 0) return { present: false, ids: [], valid: true };
268
+ const value = unquoteScalar(lines[start].match(NEEDS_LINE)[1]);
269
+ if (value) {
270
+ const raw = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1).split(',') : [value];
271
+ const ids = raw.map(unquoteScalar).filter((id) => JOB_ID.test(id));
272
+ return { present: true, ids, valid: ids.length > 0 && ids.length === raw.length };
273
+ }
274
+ const ids = [];
275
+ for (let index = start + 1; index < lines.length; index++) {
276
+ if (!lines[index].trim() || /^\s*#/.test(lines[index])) continue;
277
+ if ((lines[index].match(/^\s*/)?.[0].length ?? 0) <= propertyIndent) break;
278
+ const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
279
+ if (!item) return { present: true, ids: [], valid: false };
280
+ ids.push(item[2]);
281
+ }
282
+ return { present: true, ids, valid: ids.length > 0 };
283
+ }
284
+
285
+ /** 'always' | 'default' (absent, `true`, `${{ true }}` = implicit success()) | 'conditional'. */
286
+ function jobConditionKind(job) {
287
+ const { condition } = controlValues(job.body.split('\n'), jobPropertyIndent(job));
288
+ if (condition === undefined) return 'default';
289
+ if (SAFE_NEEDS_IF.test(condition)) return 'always';
290
+ return SAFE_IF.test(condition) ? 'default' : 'conditional';
291
+ }
292
+
293
+ function jobIndex(jobs) {
294
+ return new Map(jobs.map((job) => [job.id, job]));
295
+ }
296
+
297
+ /**
298
+ * True when nothing in the `needs:` chain can intentionally skip this job.
299
+ *
300
+ * GitHub skips a job whose needed job failed, and a skipped job satisfies a required
301
+ * status check. An unconditional chain is still fail-closed for the *workflow*: its
302
+ * only skip path is an upstream failure that fails the same run. Whether that upstream
303
+ * failure also blocks the merge is a provider question, answered by required-status
304
+ * correlation over `upstreamJobs`. A conditional `if:` anywhere in the chain (for
305
+ * example a CI-profile output) is an intentional skip reported as success, so the job
306
+ * cannot enforce.
307
+ */
308
+ function needsChainUnconditional(job, byId, seen = new Set()) {
309
+ const needs = jobNeeds(job);
310
+ if (!needs.present) return true;
311
+ if (!needs.valid || seen.has(job.id)) return false;
312
+ const nextSeen = new Set(seen).add(job.id);
313
+ return needs.ids.every((id) => {
314
+ const upstream = byId.get(id);
315
+ if (!upstream || nextSeen.has(id)) return false;
316
+ const kind = jobConditionKind(upstream);
317
+ if (kind === 'conditional') return false;
318
+ return kind === 'always' || needsChainUnconditional(upstream, byId, nextSeen);
319
+ });
320
+ }
321
+
322
+ function jobCannotEnforce(job, byId) {
323
+ if (unsafeControl(job.body.split('\n'), jobPropertyIndent(job))) return true;
324
+ if (jobConditionKind(job) === 'always') return false;
325
+ return !needsChainUnconditional(job, byId);
326
+ }
327
+
328
+ /** Jobs whose failure skips `job`; stops at `if: always()` upstreams, which still run. */
329
+ function upstreamJobs(job, byId, out = new Map()) {
330
+ for (const id of jobNeeds(job).ids) {
331
+ const upstream = byId.get(id);
332
+ if (!upstream || out.has(id)) continue;
333
+ out.set(id, upstream);
334
+ if (jobConditionKind(upstream) !== 'always') upstreamJobs(upstream, byId, out);
335
+ }
336
+ return out;
254
337
  }
255
338
 
256
339
  function commandsRunArk(commands, script) {
@@ -261,9 +344,10 @@ function commandsEnforceArk(commands, script) {
261
344
  return analyzeCommands(commands, script).some((entry) => entry.enforcing && FAIL_CLOSED.test(entry.text));
262
345
  }
263
346
 
264
- export function activeWorkflowRunText(text) {
347
+ function activeWorkflowRunText(text) {
265
348
  const jobs = workflowJobs(text);
266
- return jobs.filter((job) => !jobCannotEnforce(job)).map((job) => workflowRunText(job.body)).join('\n');
349
+ const byId = jobIndex(jobs);
350
+ return jobs.filter((job) => !jobCannotEnforce(job, byId)).map((job) => workflowRunText(job.body)).join('\n');
267
351
  }
268
352
 
269
353
  export function runsArkCheck(text, script = '') {
@@ -322,12 +406,16 @@ function localArkContexts(root) {
322
406
  const known = new Set();
323
407
  const occurrences = new Map();
324
408
  const arkOccurrences = new Map();
409
+ // context -> contexts whose failure would skip it (null = an upstream has no literal context)
410
+ const upstream = new Map();
325
411
  let dynamic = false;
326
412
  for (const workflow of workflowFiles(root)) {
327
- for (const job of workflowJobs(workflow.text)) {
413
+ const jobs = workflowJobs(workflow.text);
414
+ const byId = jobIndex(jobs);
415
+ for (const job of jobs) {
328
416
  const explicit = jobName(job);
329
417
  const matrix = jobHasMatrix(job);
330
- const enforcing = !jobCannotEnforce(job) &&
418
+ const enforcing = !jobCannotEnforce(job, byId) &&
331
419
  commandsEnforceArk(workflowRunText(job.body), script);
332
420
  if (enforcing) {
333
421
  known.add(job.id);
@@ -339,7 +427,15 @@ function localArkContexts(root) {
339
427
  }
340
428
  const context = explicit.name ?? job.id;
341
429
  occurrences.set(context, (occurrences.get(context) ?? 0) + 1);
342
- if (enforcing) arkOccurrences.set(context, (arkOccurrences.get(context) ?? 0) + 1);
430
+ if (enforcing) {
431
+ arkOccurrences.set(context, (arkOccurrences.get(context) ?? 0) + 1);
432
+ const skippers = jobConditionKind(job) === 'always' ? [] : [...upstreamJobs(job, byId).values()];
433
+ const names = skippers.map((needed) => {
434
+ const neededName = jobName(needed);
435
+ return neededName.dynamic || jobHasMatrix(needed) ? null : neededName.name ?? needed.id;
436
+ });
437
+ upstream.set(context, names.includes(null) ? null : names);
438
+ }
343
439
  }
344
440
  }
345
441
  const contexts = new Set();
@@ -347,7 +443,19 @@ function localArkContexts(root) {
347
443
  if (count === 1 && occurrences.get(context) === 1) contexts.add(context);
348
444
  else dynamic = true;
349
445
  }
350
- return { contexts, known, dynamic };
446
+ return { contexts, known, dynamic, upstream };
447
+ }
448
+
449
+ /**
450
+ * Required-status names that must also be required so an upstream failure (which skips
451
+ * the Ark job, and a skipped job satisfies branch protection) still blocks the merge.
452
+ * Returns [] when the chain is closed, otherwise the missing names ('<dynamic>' when an
453
+ * upstream only has a matrix/expression name that cannot be required exactly).
454
+ */
455
+ function unrequiredUpstream(local, context, requiredNames) {
456
+ const names = local.upstream.get(context);
457
+ if (names === null) return ['<dynamic>'];
458
+ return (names ?? []).filter((name) => !requiredNames.has(name));
351
459
  }
352
460
 
353
461
  /** Historical name retained; the set also contains literal, non-dynamic job names. */
@@ -357,17 +465,31 @@ export function jobIdsThatRunArkCheck(root) {
357
465
 
358
466
  export function isArkRequiredStatusCheck(root, requiredNames) {
359
467
  if (!Array.isArray(requiredNames) || requiredNames.length === 0) return false;
360
- const local = localArkContexts(root).contexts;
361
- return requiredNames.some((name) => local.has(String(name)));
468
+ const local = localArkContexts(root);
469
+ const required = new Set(requiredNames.map(String));
470
+ return [...required].some((name) =>
471
+ local.contexts.has(name) && unrequiredUpstream(local, name, required).length === 0
472
+ );
362
473
  }
363
474
 
364
- function classifyRequired(root, legacyContexts, checks, bindingField) {
365
- const local = localArkContexts(root);
475
+ /**
476
+ * `requiredNames` is every required context across classic protection and rulesets: an
477
+ * Ark context only counts as required when each job that can skip it is required too.
478
+ */
479
+ function classifyRequired(local, legacyContexts, checks, bindingField, requiredNames) {
366
480
  const exact = (name) => local.contexts.has(String(name));
481
+ const closed = (name) => unrequiredUpstream(local, String(name), requiredNames).length === 0;
367
482
  const matching = checks.filter((check) => exact(check.context));
368
- if (matching.some((check) => check[bindingField] == null || check[bindingField] === -1)) return true;
483
+ // Unbound (any source) or bound to the GitHub Actions app that runs the local workflow job.
484
+ const satisfiable = matching.filter((check) =>
485
+ check[bindingField] == null || check[bindingField] === -1 ||
486
+ check[bindingField] === GITHUB_ACTIONS_APP_ID
487
+ );
488
+ if (satisfiable.some((check) => closed(check.context))) return true;
369
489
  if (matching.length > 0) return 'unverified';
370
- if (legacyContexts.some(exact)) return true;
490
+ const legacy = legacyContexts.filter(exact);
491
+ if (legacy.some(closed)) return true;
492
+ if (legacy.length > 0) return 'unverified';
371
493
  return local.dynamic && (legacyContexts.length > 0 || checks.length > 0)
372
494
  ? 'unverified'
373
495
  : false;
@@ -421,7 +543,7 @@ export function classifyGithubProviderFailure(errorText, opts = {}) {
421
543
  * @param {{ name?: string, workflowName?: string, displayTitle?: string }} run
422
544
  * @returns {boolean}
423
545
  */
424
- export function isArkishCiRun(run) {
546
+ function isArkishCiRun(run) {
425
547
  const blob = `${run?.name || ''} ${run?.workflowName || ''} ${run?.displayTitle || ''}`.toLowerCase();
426
548
  if (!blob.trim()) return false;
427
549
  // Product tokens with word boundaries (avoid spark, dark, lark false positives).
@@ -558,18 +680,32 @@ export function reportGithubBranchProtection(opts = {}) {
558
680
  })).filter((check) => check.context)
559
681
  : [];
560
682
  const hasWorkflowRule = rulesAvailable && rules.some((rule) => rule?.type === 'workflows');
683
+ const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
684
+ const requiredNames = new Set(all);
685
+ const local = localArkContexts(cwd);
561
686
  const classicRequired = classicAvailable
562
- ? classifyRequired(cwd, contexts, checks, 'app_id')
687
+ ? classifyRequired(local, contexts, checks, 'app_id', requiredNames)
563
688
  : 'unverified';
564
689
  const rulesRequired = rulesAvailable
565
- ? classifyRequired(cwd, [], statusRules, 'integration_id')
690
+ ? classifyRequired(local, [], statusRules, 'integration_id', requiredNames)
566
691
  : 'unverified';
567
692
  const arkCheckRequired = combineRequired(
568
693
  classicRequired, rulesRequired, classicAvailable, rulesAvailable, hasWorkflowRule
569
694
  );
570
- const arkCheckSourceBound = arkCheckRequired === true ? false : 'unverified';
695
+ // true only when every required Ark check is bound to GitHub Actions; unbound or
696
+ // legacy-context-only requirements can be satisfied by any status writer.
697
+ const arkBindings = [
698
+ ...checks.map((check) => ({ context: check.context, id: check.app_id })),
699
+ ...statusRules.map((check) => ({ context: check.context, id: check.integration_id })),
700
+ ].filter((check) => local.contexts.has(check.context));
701
+ const arkCheckSourceBound = arkCheckRequired !== true
702
+ ? 'unverified'
703
+ : arkBindings.length > 0 && arkBindings.every((check) => check.id === GITHUB_ACTIONS_APP_ID);
571
704
  const available = arkCheckRequired === true || (classicAvailable && rulesAvailable);
572
- const all = [...new Set([...contexts, ...checks.map((check) => check.context), ...statusRules.map((check) => check.context)])];
705
+ // Required Ark contexts that an unrequired upstream failure could skip (reported as success).
706
+ const arkCheckUpstreamNotRequired = [...new Set(all
707
+ .filter((name) => local.contexts.has(name))
708
+ .flatMap((name) => unrequiredUpstream(local, name, requiredNames)))];
573
709
  const error = `${classicResult.stderr || ''}${rulesResult.stderr || ''}`.slice(0, 400);
574
710
 
575
711
  let reason = available ? 'ok' : 'provider-enforcement-unverified';
@@ -607,6 +743,7 @@ export function reportGithubBranchProtection(opts = {}) {
607
743
  enforcesAdmins: classicAvailable ? Boolean(classic.enforcesAdmins) : null,
608
744
  arkCheckRequired,
609
745
  arkCheckSourceBound,
746
+ arkCheckUpstreamNotRequired,
610
747
  // hard merge remains false when status is not proven required
611
748
  hard: arkCheckRequired === true ? undefined : false,
612
749
  runtimeObserved: ciRuntime.runtimeObserved === true,
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  /** Relative path from project root (stable contract). */
11
11
  export const GOLDEN_PATTERN_REL = '.ark/golden-pattern.json';
12
12
 
13
- export const GOLDEN_PATTERN_SCHEMA_VERSION = '1';
13
+ const GOLDEN_PATTERN_SCHEMA_VERSION = '1';
14
14
 
15
15
  /**
16
16
  * @typedef {{
@@ -1,24 +1,24 @@
1
1
  // Generated from hook-templates.source.mjs — run npm run generate:packaged-tooling.
2
- import{execCommandParts as d,execRunner as g}from"../ark-shared.mjs";const p="arkgate-mcp";function S(r){const t=g(r);return`${JSON.stringify({version:1,hooks:{preToolUse:[{command:`${t} ${p} --hook --hook-repair --fail-on-new-smells --root . --root-env CURSOR_PROJECT_DIR --config ark.config.json`,matcher:"Write|StrReplace",failClosed:!0,timeout:30}]}},null,2)}
3
- `}function R(r){const t=g(r);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${t} ${p} --session-context --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${t} ${p} --hook --hook-repair --fail-on-new-smells --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
4
- `}function _(r){const t=g(r);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${t} ${p} --session-context --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${t} ${p} --hook --hook-repair --fail-on-new-smells --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
5
- `}function C(r){const{command:t,args:o}=d(r,p,["--root",".","--config","ark.config.json"]),e=n=>n.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),s=o.map(n=>`"${e(n)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
2
+ import{execCommandParts as h,execRunner as d}from"../ark-shared.mjs";const m="arkgate-mcp";function y(t,r){let o,e;try{o=t&&t.trim()?JSON.parse(t):{},e=JSON.parse(r)}catch{return null}return!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e)?null:{existing:o,generated:e}}function x(t){const r=d(t);return`${JSON.stringify({version:1,hooks:{preToolUse:[{command:`${r} ${m} --hook --hook-repair --fail-on-new-smells --root . --root-env CURSOR_PROJECT_DIR --config ark.config.json`,matcher:"Write|StrReplace",failClosed:!0,timeout:30}]}},null,2)}
3
+ `}function _(t){const r=d(t);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",command:`${r} ${m} --session-context --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit",hooks:[{type:"command",command:`${r} ${m} --hook --hook-repair --fail-on-new-smells --root . --root-env CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
4
+ `}function S(t){const r=d(t);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${m} --session-context --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"ApplyPatch|apply_patch|Write|Edit|MultiEdit",hooks:[{type:"command",timeout:30,command:`${r} ${m} --hook --hook-repair --fail-on-new-smells --root . --root-env CODEX_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
5
+ `}function C(t){const{command:r,args:o}=h(t,m,["--root",".","--config","ark.config.json"]),e=n=>n.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),s=o.map(n=>`"${e(n)}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Codex project scope).
6
6
  # CONFIGURED ON DISK \u2014 RUNTIME NOT VERIFIED.
7
7
  # Restart Codex, then call ark_identity with expectedRoot before trusting MCP verdicts.
8
8
  [mcp_servers.ark]
9
- command = "${e(t)}"
9
+ command = "${e(r)}"
10
10
  args = [${s}]
11
- `}function E(r){const{command:t,args:o}=d(r,p,["--root",".","--config","ark.config.json"]),e=o.map(s=>`"${s.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
11
+ `}function E(t){const{command:r,args:o}=h(t,m,["--root",".","--config","ark.config.json"]),e=o.map(s=>`"${s.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"`).join(", ");return`# Generated by ark-check --install-agent-gates (Grok Build project scope).
12
12
  # Restart Grok (or /mcps \u2192 refresh) after changes. Also loads repo-root .mcp.json.
13
13
  [mcp_servers.ark]
14
- command = "${t.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
14
+ command = "${r.replace(/\\/g,"\\\\").replace(/"/g,'\\"')}"
15
15
  args = [${e}]
16
- `}function v(r){const t=g(r);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${t} ${p} --session-context --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${t} ${p} --hook --hook-repair --fail-on-new-smells --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
17
- `}function J(r){const t=g(r);return`${JSON.stringify({"ark-write-gate":{PreToolUse:[{matcher:"write_to_file|replace_file_content|multi_replace_file_content",hooks:[{type:"command",timeout:30,command:`${t} ${p} --hook --hook-repair --fail-on-new-smells --root . --config ark.config.json`}]}]}},null,2)}
18
- `}function N(r){const{command:t,args:o}=d(r,p,["--root",".","--config","ark.config.json"]);return`${JSON.stringify({$schema:"https://opencode.ai/config.json",mcp:{ark:{type:"local",command:[t,...o],enabled:!0}}},null,2)}
19
- `}function x(r,t){let o,e;try{o=r&&r.trim()?JSON.parse(r):{},e=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e))return null;const s=e.hooks,k=(Array.isArray(s?.preToolUse)?s.preToolUse:[]).find(a=>a&&typeof a=="object"&&typeof a.command=="string"&&/arkgate-mcp|ark-mcp/.test(a.command)&&/\s--hook(?:\s|$)/.test(` ${a.command} `));if(!k)return null;const u=o.hooks&&typeof o.hooks=="object"&&!Array.isArray(o.hooks)?{...o.hooks}:{},l=Array.isArray(u.preToolUse)?[...u.preToolUse]:[],f=a=>a&&typeof a=="object"&&typeof a.command=="string"&&/arkgate-mcp|ark-mcp/.test(a.command)&&/\s--hook(?:\s|$)/.test(` ${a.command} `)&&(typeof a.matcher!="string"||/Write/.test(a.matcher)||/StrReplace/.test(a.matcher)),c=l.filter(a=>!f(a));u.preToolUse=[...c,k];const i={...o,version:o.version??e.version??1,hooks:u};return`${JSON.stringify(i,null,2)}
20
- `}const O=/(?:^|[\s/])(?:arkgate-mcp|ark-mcp)(?:\.mjs)?(?:\s|$)/,j=/\s--(?:hook|session-context)(?:\s|$)/;function A(r){return!!r&&typeof r=="object"&&typeof r.command=="string"&&O.test(` ${r.command} `)&&j.test(` ${r.command} `)}function P(r,t){let o,e;try{o=r&&r.trim()?JSON.parse(r):{},e=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e))return null;const s=e.hooks;if(!s||typeof s!="object"||o.hooks!==void 0&&(!o.hooks||typeof o.hooks!="object"||Array.isArray(o.hooks)))return null;const n={...o.hooks??{}};for(const[k,u]of Object.entries(s)){if(!Array.isArray(u))continue;const l=n[k];if(l!==void 0&&!Array.isArray(l))return null;const f=[];for(const c of l??[]){if(!c||typeof c!="object"||!Array.isArray(c.hooks)){f.push(c);continue}const i=c.hooks.filter(a=>!A(a));i.length===c.hooks.length?f.push(c):i.length>0&&f.push({...c,hooks:i})}n[k]=[...f,...u]}return`${JSON.stringify({...o,hooks:n},null,2)}
21
- `}function m(r){const t=o=>typeof o=="string"?o.replace(/arkgate@[^\s"']+/g,"arkgate@*"):Array.isArray(o)?o.map(t):o&&typeof o=="object"?Object.fromEntries(Object.keys(o).sort().map(e=>[e,t(o[e])])):o;return JSON.stringify(t(r))}function h(r,t){try{const o=JSON.parse(r),e=JSON.parse(t);return!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e)?null:{existing:o,generated:e}}catch{return null}}function y(r){return{status:"removed",text:Object.keys(r).length===0?null:`${JSON.stringify(r,null,2)}
22
- `}}function U(r,t){const o=h(r,t);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o;if(e.hooks===void 0)return{status:"absent"};if(!e.hooks||typeof e.hooks!="object"||Array.isArray(e.hooks))return{status:"unreadable"};const n={...e.hooks};let k=0;for(const[l,f]of Object.entries(s.hooks??{})){if(!Array.isArray(f)||n[l]===void 0)continue;if(!Array.isArray(n[l]))return{status:"unreadable"};const c=new Set(f.map(m)),i=[];for(const a of n[l]){if(!(a&&typeof a=="object"&&Array.isArray(a.hooks)&&a.hooks.some(A))){i.push(a);continue}if(!c.has(m(a)))return{status:"customized"};k+=1}i.length>0?n[l]=i:delete n[l]}if(k===0)return{status:"absent"};const u={...e,hooks:n};return Object.keys(n).length===0&&delete u.hooks,y(u)}function T(r,t){const o=h(r,t);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o,n=e.hooks?.preToolUse;if(n===void 0)return{status:"absent"};if(!Array.isArray(n))return{status:"unreadable"};const k=new Set((Array.isArray(s.hooks?.preToolUse)?s.hooks.preToolUse:[]).map(m)),u=[];let l=0;for(const i of n){if(!(i&&typeof i=="object"&&typeof i.command=="string"&&/arkgate-mcp|ark-mcp/.test(i.command)&&/\s--hook(?:\s|$)/.test(` ${i.command} `))){u.push(i);continue}if(!k.has(m(i)))return{status:"customized"};l+=1}if(l===0)return{status:"absent"};const f={...e.hooks};u.length>0?f.preToolUse=u:delete f.preToolUse;const c={...e,hooks:f};return Object.keys(f).length===0&&delete c.hooks,Object.keys(c).length===1&&c.version===(s.version??1)&&delete c.version,y(c)}function D(r,t){const o=h(r,t);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o;if(!Object.prototype.hasOwnProperty.call(e,"ark-write-gate"))return{status:"absent"};if(m(e["ark-write-gate"])!==m(s["ark-write-gate"]))return{status:"customized"};const n={...e};return delete n["ark-write-gate"],y(n)}function H(r,t){let o,e;try{o=r&&r.trim()?JSON.parse(r):{},e=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e))return null;const s=e["ark-write-gate"];if(!s||typeof s!="object")return null;const n={...o,"ark-write-gate":s};return`${JSON.stringify(n,null,2)}
23
- `}function w(r,t){let o,e;try{o=r&&r.trim()?JSON.parse(r):{},e=JSON.parse(t)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o))return null;const s={...o};!s.$schema&&e.$schema&&(s.$schema=e.$schema);const n=o.mcp&&typeof o.mcp=="object"&&!Array.isArray(o.mcp)?{...o.mcp}:{};return n.ark=e.mcp.ark,s.mcp=n,`${JSON.stringify(s,null,2)}
24
- `}export{p as PREFERRED_MCP_BIN,J as antigravityHooks,R as claudeSettings,_ as codexHooks,C as codexProjectConfig,S as cursorHooks,v as grokHooks,E as grokProjectConfig,H as mergeAntigravityArkHook,P as mergeClaudeStyleArkHooks,x as mergeCursorArkHook,w as mergeOpencodeArkMcp,N as opencodeProjectConfig,D as removeAntigravityArkHook,U as removeClaudeStyleArkHooks,T as removeCursorArkHook};
16
+ `}function v(t){const r=d(t);return`${JSON.stringify({hooks:{SessionStart:[{hooks:[{type:"command",timeout:30,command:`${r} ${m} --session-context --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}],PreToolUse:[{matcher:"Write|Edit|MultiEdit|write|search_replace",hooks:[{type:"command",timeout:30,command:`${r} ${m} --hook --hook-repair --fail-on-new-smells --root . --root-env GROK_WORKSPACE_ROOT,CLAUDE_PROJECT_DIR --config ark.config.json`}]}]}},null,2)}
17
+ `}function J(t){const r=d(t);return`${JSON.stringify({"ark-write-gate":{PreToolUse:[{matcher:"write_to_file|replace_file_content|multi_replace_file_content",hooks:[{type:"command",timeout:30,command:`${r} ${m} --hook --hook-repair --fail-on-new-smells --root . --config ark.config.json`}]}]}},null,2)}
18
+ `}function P(t){const{command:r,args:o}=h(t,m,["--root",".","--config","ark.config.json"]);return`${JSON.stringify({$schema:"https://opencode.ai/config.json",mcp:{ark:{type:"local",command:[r,...o],enabled:!0}}},null,2)}
19
+ `}function N(t,r){const o=y(t,r);if(!o)return null;const{existing:e,generated:s}=o,n=s.hooks,p=(Array.isArray(n?.preToolUse)?n.preToolUse:[]).find(c=>c&&typeof c=="object"&&typeof c.command=="string"&&/arkgate-mcp|ark-mcp/.test(c.command)&&/\s--hook(?:\s|$)/.test(` ${c.command} `));if(!p)return null;const i=e.hooks&&typeof e.hooks=="object"&&!Array.isArray(e.hooks)?{...e.hooks}:{},l=Array.isArray(i.preToolUse)?[...i.preToolUse]:[],u=c=>c&&typeof c=="object"&&typeof c.command=="string"&&/arkgate-mcp|ark-mcp/.test(c.command)&&/\s--hook(?:\s|$)/.test(` ${c.command} `)&&(typeof c.matcher!="string"||/Write/.test(c.matcher)||/StrReplace/.test(c.matcher)),a=l.filter(c=>!u(c));i.preToolUse=[...a,p];const f={...e,version:e.version??s.version??1,hooks:i};return`${JSON.stringify(f,null,2)}
20
+ `}const $=/(?:^|[\s/])(?:arkgate-mcp|ark-mcp)(?:\.mjs)?(?:\s|$)/,b=/\s--(?:hook|session-context)(?:\s|$)/;function j(t){return!!t&&typeof t=="object"&&typeof t.command=="string"&&$.test(` ${t.command} `)&&b.test(` ${t.command} `)}function T(t,r){const o=y(t,r);if(!o)return null;const{existing:e,generated:s}=o,n=s.hooks;if(!n||typeof n!="object"||e.hooks!==void 0&&(!e.hooks||typeof e.hooks!="object"||Array.isArray(e.hooks)))return null;const k={...e.hooks??{}};for(const[p,i]of Object.entries(n)){if(!Array.isArray(i))continue;const l=k[p];if(l!==void 0&&!Array.isArray(l))return null;const u=[];for(const a of l??[]){if(!a||typeof a!="object"||!Array.isArray(a.hooks)){u.push(a);continue}const f=a.hooks.filter(c=>!j(c));f.length===a.hooks.length?u.push(a):f.length>0&&u.push({...a,hooks:f})}k[p]=[...u,...i]}return`${JSON.stringify({...e,hooks:k},null,2)}
21
+ `}function g(t){const r=o=>typeof o=="string"?o.replace(/arkgate@[^\s"']+/g,"arkgate@*"):Array.isArray(o)?o.map(r):o&&typeof o=="object"?Object.fromEntries(Object.keys(o).sort().map(e=>[e,r(o[e])])):o;return JSON.stringify(r(t))}function A(t,r){try{const o=JSON.parse(t),e=JSON.parse(r);return!o||typeof o!="object"||Array.isArray(o)||!e||typeof e!="object"||Array.isArray(e)?null:{existing:o,generated:e}}catch{return null}}function O(t){return{status:"removed",text:Object.keys(t).length===0?null:`${JSON.stringify(t,null,2)}
22
+ `}}function U(t,r){const o=A(t,r);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o;if(e.hooks===void 0)return{status:"absent"};if(!e.hooks||typeof e.hooks!="object"||Array.isArray(e.hooks))return{status:"unreadable"};const n={...e.hooks};let k=0;for(const[i,l]of Object.entries(s.hooks??{})){if(!Array.isArray(l)||n[i]===void 0)continue;if(!Array.isArray(n[i]))return{status:"unreadable"};const u=new Set(l.map(g)),a=[];for(const f of n[i]){if(!(f&&typeof f=="object"&&Array.isArray(f.hooks)&&f.hooks.some(j))){a.push(f);continue}if(!u.has(g(f)))return{status:"customized"};k+=1}a.length>0?n[i]=a:delete n[i]}if(k===0)return{status:"absent"};const p={...e,hooks:n};return Object.keys(n).length===0&&delete p.hooks,O(p)}function D(t,r){const o=A(t,r);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o,n=e.hooks?.preToolUse;if(n===void 0)return{status:"absent"};if(!Array.isArray(n))return{status:"unreadable"};const k=new Set((Array.isArray(s.hooks?.preToolUse)?s.hooks.preToolUse:[]).map(g)),p=[];let i=0;for(const a of n){if(!(a&&typeof a=="object"&&typeof a.command=="string"&&/arkgate-mcp|ark-mcp/.test(a.command)&&/\s--hook(?:\s|$)/.test(` ${a.command} `))){p.push(a);continue}if(!k.has(g(a)))return{status:"customized"};i+=1}if(i===0)return{status:"absent"};const l={...e.hooks};p.length>0?l.preToolUse=p:delete l.preToolUse;const u={...e,hooks:l};return Object.keys(l).length===0&&delete u.hooks,Object.keys(u).length===1&&u.version===(s.version??1)&&delete u.version,O(u)}function H(t,r){const o=A(t,r);if(!o)return{status:"unreadable"};const{existing:e,generated:s}=o;if(!Object.prototype.hasOwnProperty.call(e,"ark-write-gate"))return{status:"absent"};if(g(e["ark-write-gate"])!==g(s["ark-write-gate"]))return{status:"customized"};const n={...e};return delete n["ark-write-gate"],O(n)}function w(t,r){const o=y(t,r);if(!o)return null;const{existing:e,generated:s}=o,n=s["ark-write-gate"];if(!n||typeof n!="object")return null;const k={...e,"ark-write-gate":n};return`${JSON.stringify(k,null,2)}
23
+ `}function I(t,r){let o,e;try{o=t&&t.trim()?JSON.parse(t):{},e=JSON.parse(r)}catch{return null}if(!o||typeof o!="object"||Array.isArray(o))return null;const s={...o};!s.$schema&&e.$schema&&(s.$schema=e.$schema);const n=o.mcp&&typeof o.mcp=="object"&&!Array.isArray(o.mcp)?{...o.mcp}:{};return n.ark=e.mcp.ark,s.mcp=n,`${JSON.stringify(s,null,2)}
24
+ `}export{m as PREFERRED_MCP_BIN,J as antigravityHooks,_ as claudeSettings,S as codexHooks,C as codexProjectConfig,x as cursorHooks,v as grokHooks,E as grokProjectConfig,w as mergeAntigravityArkHook,T as mergeClaudeStyleArkHooks,N as mergeCursorArkHook,I as mergeOpencodeArkMcp,P as opencodeProjectConfig,y as parseJsonMergeInputs,H as removeAntigravityArkHook,U as removeClaudeStyleArkHooks,D as removeCursorArkHook};
@@ -14,6 +14,8 @@ import { formatArkRunHtml } from './ark-run-report.mjs';
14
14
  import { formatArkOrderHtml } from './ark-order-report.mjs';
15
15
  import { primaryImprovementCompassNextAction } from './improvement-compass.mjs';
16
16
  import { flatParentPilotHtml } from './flat-parent-pilot.mjs';
17
+ import { orphanModulesHtml } from './orphan-modules-io.mjs';
18
+ import { crossWallDuplicationHtml } from './duplication-io.mjs';
17
19
 
18
20
  // htmlEscape is injected by the caller (html-report.mjs) — importing it back
19
21
  // would be a dependency cycle, and the repo's own gate blocks that. The
@@ -362,6 +364,8 @@ export function renderAdvisorySections(advisories, escape) {
362
364
  physicalCohesionHtml(advisories.physicalCohesion),
363
365
  parseHealthHtml(advisories.parseHealth),
364
366
  graphBlindSpotsHtml(advisories.graphBlindSpots, esc),
367
+ orphanModulesHtml(advisories.orphanModules, esc),
368
+ crossWallDuplicationHtml(advisories.crossWallDuplication, esc),
365
369
  rulesUnderContractHtml(advisories.rulesUnderContract),
366
370
  formatArkRunHtml(advisories.arkRun, esc),
367
371
  formatArkOrderHtml(advisories.arkOrder, esc),
@@ -28,7 +28,7 @@ import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
28
28
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
29
29
  import { computeReshapeDecisionMemory } from './reshape-decisions.mjs';
30
30
  import { POST_GREEN_LEDE, operatingModeTitle } from './product-copy.mjs';
31
- import { anyChildWallAdvisory, anySliceAlias } from '../ark-layer-match.mjs';
31
+ import { anyChildWallAdvisory, sliceAliasOwesMove } from '../ark-layer-match.mjs';
32
32
 
33
33
  function esc(value) {
34
34
  return String(value)
@@ -176,7 +176,7 @@ export function buildReportDepthPayload(
176
176
  primaryNextAction: postGreenPath?.action ?? dualTruthNext,
177
177
  activeBlockingViolations: activeBlockingCount,
178
178
  physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules),
179
- sliceAliasDebt: anySliceAlias(config?.rules),
179
+ sliceAliasDebt: sliceAliasOwesMove(config?.rules),
180
180
  });
181
181
  // Doctor parity: same physical-cohesion + baseline stale facts as runDoctor.
182
182
  const baselineStale =