arkgate 4.8.22 → 4.8.24

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 (136) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +27 -78
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +1117 -38
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  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/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -2
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +45 -6
  35. package/bin/lib/doctor-advisories.mjs +84 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +7 -9
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-honesty.mjs +22 -0
  40. package/bin/lib/enforcement-profiles.mjs +40 -4
  41. package/bin/lib/first-run-help.mjs +3 -2
  42. package/bin/lib/flat-parent-importers.mjs +137 -0
  43. package/bin/lib/flat-parent-pilot.mjs +192 -0
  44. package/bin/lib/gate-files.mjs +29 -2
  45. package/bin/lib/git-change-scope.mjs +109 -0
  46. package/bin/lib/github-enforcement.mjs +1 -1
  47. package/bin/lib/hook-templates.mjs +18 -16
  48. package/bin/lib/host-support-matrix.mjs +3 -1
  49. package/bin/lib/html-report-advisories.mjs +2 -0
  50. package/bin/lib/html-report-depth.mjs +3 -1
  51. package/bin/lib/import-resolve.mjs +13 -1
  52. package/bin/lib/install-migrate.mjs +50 -73
  53. package/bin/lib/invariant-coverage.mjs +59 -11
  54. package/bin/lib/mcp-adoption.mjs +9 -1
  55. package/bin/lib/mcp-hook-payload.mjs +401 -61
  56. package/bin/lib/package-manager.mjs +59 -1
  57. package/bin/lib/passthrough-commands.mjs +83 -0
  58. package/bin/lib/persistence-write-hint.mjs +81 -0
  59. package/bin/lib/physical-cohesion.mjs +16 -18
  60. package/bin/lib/pilot-loop.mjs +21 -8
  61. package/bin/lib/pin-dependent-gates.mjs +117 -0
  62. package/bin/lib/policy-delta-io.mjs +168 -10
  63. package/bin/lib/prepare-change.mjs +120 -6
  64. package/bin/lib/prepare-write.mjs +1 -0
  65. package/bin/lib/presets.mjs +3 -5
  66. package/bin/lib/remediation.mjs +23 -4
  67. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  68. package/bin/lib/rules-inventory.mjs +22 -3
  69. package/bin/lib/rules-under-contract.mjs +179 -5
  70. package/bin/lib/scan-files.mjs +32 -0
  71. package/bin/lib/snippet-analysis.mjs +140 -6
  72. package/bin/lib/source-policy.mjs +34 -0
  73. package/bin/lib/stable-hash.mjs +90 -0
  74. package/bin/lib/start-install-recovery.mjs +71 -1
  75. package/bin/lib/start-preview.mjs +30 -0
  76. package/bin/lib/status-command.mjs +33 -4
  77. package/bin/lib/status-manifest.mjs +32 -4
  78. package/bin/lib/team-parliament-io.mjs +79 -75
  79. package/bin/lib/team-parliament.mjs +6 -1
  80. package/bin/lib/write-path-capabilities.mjs +4 -2
  81. package/dist/chunk-A5EIY3HI.cjs +5 -0
  82. package/dist/chunk-MBXCFLO4.js +16 -0
  83. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  84. package/dist/chunk-SVSQVGFJ.js +5 -0
  85. package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
  86. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  87. package/dist/eslint/index.cjs +17 -4
  88. package/dist/eslint/index.d.cts +359 -0
  89. package/dist/eslint/index.d.ts +178 -158
  90. package/dist/eslint/index.js +10 -4
  91. package/dist/index.cjs +38 -37
  92. package/dist/index.d.cts +5475 -0
  93. package/dist/index.d.ts +3408 -296
  94. package/dist/index.js +38 -37
  95. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  96. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  97. package/dist/nestjs/index.cjs +1 -18
  98. package/dist/nestjs/index.d.cts +22 -0
  99. package/dist/nestjs/index.d.ts +1 -3
  100. package/dist/nestjs/index.js +1 -18
  101. package/dist/order/index.cjs +1 -1
  102. package/dist/order/index.d.cts +155 -0
  103. package/dist/order/index.d.ts +15 -3
  104. package/dist/order/index.js +1 -1
  105. package/dist/runtime/index.cjs +13 -30
  106. package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
  107. package/dist/runtime/index.d.ts +2678 -108
  108. package/dist/runtime/index.js +13 -30
  109. package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
  110. package/dist/types-DRinG-vT.d.ts +1792 -0
  111. package/docs/README.md +3 -1
  112. package/docs/agent-guide.md +92 -15
  113. package/docs/ai-gates.md +160 -26
  114. package/docs/arkorder.md +32 -15
  115. package/docs/brownfield-adoption.md +5 -1
  116. package/docs/configuration.md +273 -12
  117. package/docs/develop.md +2 -1
  118. package/docs/diagnostics.md +122 -13
  119. package/docs/package-surface.md +31 -8
  120. package/docs/product-voice.md +1 -1
  121. package/package.json +59 -18
  122. package/schemas/ark.analysis-result.schema.json +15 -0
  123. package/schemas/ark.config.schema.json +163 -4
  124. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  125. package/schemas/ark.status-manifest.schema.json +22 -0
  126. package/server.json +3 -9
  127. package/templates/agent-skills/README.md +1 -1
  128. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  129. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  130. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  131. package/templates/arkrules/PersistenceAdapters.json +1 -1
  132. package/templates/arkrules/PresentationAdapters.json +1 -1
  133. package/templates/skills/ark-adopt.md +4 -1
  134. package/templates/skills/ark-order.md +4 -2
  135. package/templates/skills/ark-runtime.md +6 -1
  136. package/dist/types-fQ9-KrBj.d.ts +0 -183
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Subcommands of `arkgate` / `ark` that hand their own flags to a sibling bin untouched:
3
+ * `dashboard` (bin/ark-dashboard.mjs) and `mcp` (bin/ark-mcp.mjs).
4
+ *
5
+ * `mcp` exists because `npx arkgate@<version> <args>` always runs the bin named after the
6
+ * package (`arkgate`), never `arkgate-mcp`. The MCP Registry descriptor (`server.json`) is
7
+ * launched exactly that way, so `arkgate mcp --root .` must start the stdio MCP server.
8
+ * `arkgate-mcp` / `ark-mcp` are also accepted as the first argument, so a descriptor or host
9
+ * config that spells the bin name still reaches the server. That does not repair registry
10
+ * entries already published: each pins the arkgate version it was published with, and
11
+ * versions through 4.8.23 have no passthrough. Only a republished descriptor fixes those.
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const arkDashboard = path.join(here, '..', 'ark-dashboard.mjs');
19
+ const arkMcp = path.join(here, '..', 'ark-mcp.mjs');
20
+
21
+ /** First positional arguments that start the MCP server (stdio JSON-RPC). */
22
+ export const MCP_COMMANDS = Object.freeze(['mcp', 'arkgate-mcp', 'ark-mcp']);
23
+
24
+ /** First positional arguments whose remaining argv belongs to another executable. */
25
+ export function isPassthroughCommand(command) {
26
+ return command === 'dashboard' || command === 'report' || MCP_COMMANDS.includes(command);
27
+ }
28
+
29
+ export function isMcpCommand(command) {
30
+ return MCP_COMMANDS.includes(command);
31
+ }
32
+
33
+ export { DASHBOARD_HELP as dashboardHelp } from './dashboard-cli.mjs';
34
+
35
+ export function withDashboardHelp(text, detailed) {
36
+ const extra = detailed
37
+ ? ' arkgate dashboard [--url <inspector-url>] [--interval <ms>] [--once]\n arkgate mcp [--root <project>] [--config <path>]\n arkgate report [--root <project>] [--json] [--title <text>] [--finding <ref>] [--submit] [--i-confirm-submit]\n'
38
+ : '';
39
+ const extraDesc = detailed
40
+ ? ' dashboard ANSI observability TUI (spawns ark-dashboard).\n mcp Stdio MCP server (same as arkgate-mcp; used by npx and the MCP Registry).\n report Draft an upstream GitHub issue for pedroknigge/arkgate. Create needs --submit plus confirm. --yes does not submit.\n'
41
+ : '';
42
+ if (detailed) {
43
+ return text
44
+ .replace(' arkgate agents-md [--root', `${extra} arkgate agents-md [--root`)
45
+ .replace(' agents-md Version-matched', `${extraDesc} agents-md Version-matched`);
46
+ }
47
+ return text.replace(
48
+ ' arkgate-check --doctor status — one next step\n',
49
+ ' arkgate-check --doctor status — one next step\n arkgate dashboard observability TUI (inspector)\n arkgate report draft an upstream GitHub issue\n'
50
+ );
51
+ }
52
+
53
+ export function runDashboard(passthroughArgs) {
54
+ const result = spawnSync(process.execPath, [arkDashboard, ...passthroughArgs], {
55
+ stdio: 'inherit',
56
+ encoding: 'utf8',
57
+ });
58
+ return result.status ?? 1;
59
+ }
60
+
61
+ /**
62
+ * Run bin/ark-mcp.mjs with the caller's stdio. Nothing is written to stdout here: stdout is
63
+ * the JSON-RPC channel. Termination signals are forwarded so the host can stop the server.
64
+ */
65
+ export function runMcp(passthroughArgs) {
66
+ return new Promise((resolve) => {
67
+ const child = spawn(process.execPath, [arkMcp, ...passthroughArgs], { stdio: 'inherit' });
68
+ const forward = (signal) => {
69
+ if (child.exitCode === null) child.kill(signal);
70
+ };
71
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
72
+ for (const signal of signals) process.on(signal, forward);
73
+ const done = (code) => {
74
+ for (const signal of signals) process.off(signal, forward);
75
+ resolve(code);
76
+ };
77
+ child.on('error', (error) => {
78
+ process.stderr.write(`arkgate mcp: ${error.message}\n`);
79
+ done(1);
80
+ });
81
+ child.on('exit', (code, signal) => done(code ?? (signal ? 1 : 0)));
82
+ });
83
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/persistenceWriteHint.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/persistence-write-hint.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /** IO / ORM import evidence. postgres and drizzle-orm include package subpaths. */
12
+ export const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
13
+ /** Path-alias / local db module (`@/lib/db`) without resolving tsconfig. */
14
+ export const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)/;
15
+ /** Named imports from a local persistence module (same module names as IO_ALIAS_IMPORT_RE). */
16
+ const LOCAL_CLIENT_IMPORT_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]/g;
17
+ /** Conventional client identifiers (PrismaClient included via the /i flag). */
18
+ export const PERSISTENCE_CLIENT_NAMES = [
19
+ 'db',
20
+ 'tx',
21
+ 'client',
22
+ 'prisma',
23
+ 'prismaClient',
24
+ 'drizzle',
25
+ ];
26
+ const WRITE_VERB_SOURCE = '(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))';
27
+ const SQL_WRITE_SOURCE = '\\bINSERT\\s+INTO\\b|\\bUPDATE\\s+[A-Za-z_][\\w.]*\\s+SET\\b|\\bDELETE\\s+FROM\\b';
28
+ /** Call arguments with one level of nested parentheses: `({ log: fn() })`. */
29
+ const CALL_ARGS_SOURCE = '\\((?:[^()]|\\([^()]*\\))*\\)';
30
+ /** Receivers built inline: `new PrismaClient(...)` / `drizzle(...)`. */
31
+ const INLINE_CLIENT_SOURCE = `\\bnew\\s+PrismaClient\\s*${CALL_ARGS_SOURCE}|\\bdrizzle\\s*${CALL_ARGS_SOURCE}`;
32
+ /**
33
+ * `const orm = new PrismaClient()` / `private orm = new PrismaClient()` /
34
+ * `this.orm = new PrismaClient()` / `const sql = postgres(url)` / `db = drizzle(pool)`.
35
+ */
36
+ const DRIVER_BINDING_RE = /(?<![\w$])([A-Za-z_$][\w$]*)\s*[?!]?\s*(?::[^=;\n]+)?=(?![=>])\s*(?:await\s+)?(?:new\s+(?:PrismaClient|Pool|Client|MongoClient|Sequelize|Kysely|Knex)\b|(?:drizzle|knex|Knex|postgres)\s*\()/g;
37
+ /** `orm: PrismaClient` / `db: Kysely<DB>` / `tx: Prisma.TransactionClient` — params and fields. */
38
+ const DRIVER_ANNOTATION_RE = /(?<![\w$])([A-Za-z_$][\w$]*)\s*[?!]?\s*:\s*(?:Readonly\s*<\s*)?(?:Prisma\s*\.\s*TransactionClient|PrismaClient|Pool|PoolClient|MongoClient|Sequelize|Kysely|Knex|DataSource|EntityManager|NodePgDatabase|PostgresJsDatabase|BetterSQLite3Database|MySql2Database|LibSQLDatabase)\b/g;
39
+ function escapeRegExp(value) {
40
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+ export function sourceImportsPersistenceDriverText(content) {
43
+ return IO_IMPORT_HINT_RE.test(content) || IO_ALIAS_IMPORT_RE.test(content);
44
+ }
45
+ function collectFirstGroup(re, content, names) {
46
+ const global = new RegExp(re.source, 'g');
47
+ let match;
48
+ while ((match = global.exec(content)) !== null) {
49
+ if (match[1])
50
+ names.add(match[1]);
51
+ }
52
+ }
53
+ /**
54
+ * Identifiers that hold a persistence client in this file (sorted, unique): bound to a
55
+ * driver constructor, annotated with a driver client type, or imported by name from a
56
+ * local persistence module.
57
+ */
58
+ export function persistenceClientBindings(content) {
59
+ const names = new Set();
60
+ collectFirstGroup(DRIVER_BINDING_RE, content, names);
61
+ collectFirstGroup(DRIVER_ANNOTATION_RE, content, names);
62
+ const imports = new RegExp(LOCAL_CLIENT_IMPORT_RE.source, 'g');
63
+ let match;
64
+ while ((match = imports.exec(content)) !== null) {
65
+ for (const element of (match[1] ?? '').split(',')) {
66
+ const parts = element.trim().replace(/^type\s+/, '').split(/\s+as\s+/);
67
+ const local = (parts[1] ?? parts[0] ?? '').trim();
68
+ if (/^[A-Za-z_$][\w$]*$/.test(local))
69
+ names.add(local);
70
+ }
71
+ }
72
+ return [...names].sort();
73
+ }
74
+ /** Receiver-bound write evidence (ORM verb on a persistence client, or raw SQL write). */
75
+ export function sourceHasPersistenceWrite(content) {
76
+ const receivers = [...PERSISTENCE_CLIENT_NAMES, ...persistenceClientBindings(content)]
77
+ .map(escapeRegExp)
78
+ .join('|');
79
+ const re = new RegExp(`(?:\\b(?:${receivers})(?![\\w$])|${INLINE_CLIENT_SOURCE})(?:\\s*\\.\\s*[A-Za-z_]\\w*)*\\s*\\.\\s*${WRITE_VERB_SOURCE}\\s*\\(|${SQL_WRITE_SOURCE}`, 'i');
80
+ return re.test(content);
81
+ }
@@ -15,7 +15,7 @@
15
15
  * as W01 layer roles: a miss costs a warning line, never a verdict.
16
16
  */
17
17
  import path from 'node:path';
18
- import { layerForRelativePath, sliceIdForPath } from '../ark-layer-match.mjs';
18
+ import { layerForRelativePath, resolveGovernedSlice } from '../ark-layer-match.mjs';
19
19
 
20
20
  /** ADR 0010 D3 — corpus-calibrated, fixed. */
21
21
  const CLUSTER_MIN = 40;
@@ -135,23 +135,21 @@ function anchorIsConsolidationSubtree(anchor, concept) {
135
135
  return String(anchor).split('/').some((part) => part === concept);
136
136
  }
137
137
 
138
- function declaredSliceFolders(rules) {
139
- const out = [];
140
- const seen = new Set();
138
+ /**
139
+ * Layer must match. Universe id and child id must match on every rule, using
140
+ * the same resolver as the walls (`sliceIdentity` included). A missing child
141
+ * id is universe common. Two children of one universe do not keep the slice.
142
+ */
143
+ export function destinationKeepsLayerAndSlice(fromRel, toRel, layers, rules) {
144
+ if (layerForRelativePath(fromRel, layers) !== layerForRelativePath(toRel, layers)) return false;
141
145
  for (const rule of Array.isArray(rules) ? rules : []) {
142
- const folders = Array.isArray(rule?.sliceFolders) ? rule.sliceFolders : [];
143
- for (const folder of folders) {
144
- if (typeof folder !== 'string' || folder.length === 0 || seen.has(folder.toLowerCase())) continue;
145
- seen.add(folder.toLowerCase());
146
- out.push(folder);
147
- }
146
+ if (!rule || typeof rule !== 'object') continue;
147
+ const from = resolveGovernedSlice(fromRel, rule);
148
+ const to = resolveGovernedSlice(toRel, rule);
149
+ if ((from.universeId ?? '') !== (to.universeId ?? '')) return false;
150
+ if ((from.childId ?? '') !== (to.childId ?? '')) return false;
148
151
  }
149
- return out;
150
- }
151
-
152
- function destinationKeepsLayerAndSlice(fromRel, toRel, layers, sliceFolders) {
153
- return layerForRelativePath(fromRel, layers) === layerForRelativePath(toRel, layers)
154
- && sliceIdForPath(fromRel, sliceFolders) === sliceIdForPath(toRel, sliceFolders);
152
+ return true;
155
153
  }
156
154
 
157
155
  function withheldPilot(concept, note) {
@@ -168,7 +166,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
168
166
  const top = cohesion?.findings?.[0];
169
167
  if (!top) return null;
170
168
  const layers = contract?.layers;
171
- const sliceFolders = declaredSliceFolders(contract?.rules);
169
+ const rules = Array.isArray(contract?.rules) ? contract.rules : [];
172
170
  // Full anchor map: the finding's anchors are display-filtered (>= MIRROR_MIN).
173
171
  const byAnchor = new Map();
174
172
  const relOf = (abs) => path.relative(root, abs).split(path.sep).join('/');
@@ -193,7 +191,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
193
191
  const ranked = classifyPhysical(rel);
194
192
  if (!ranked || ranked.concept !== top.concept || ranked.anchor !== anchor.path) return false;
195
193
  const to = `${targetDir}/${rel.split('/').at(-1)}`;
196
- return destinationKeepsLayerAndSlice(rel, to, layers, sliceFolders);
194
+ return destinationKeepsLayerAndSlice(rel, to, layers, rules);
197
195
  }).sort().map((rel) => ({ from: rel, to: `${targetDir}/${rel.split('/').at(-1)}` }));
198
196
  if (legal.length > 0) legalAnchors.push({ ...anchor, targetDir, legal });
199
197
  }
@@ -9,6 +9,7 @@ import {
9
9
  buildPatternBetsFromSmells,
10
10
  isNonProductionPilotPath,
11
11
  } from './design-smells.mjs';
12
+ import { flatParentCandidates } from './flat-parent-pilot.mjs';
12
13
 
13
14
  /** Stable product id for JSON / tests. */
14
15
  export const PILOT_LOOP_ID = 'one-pilot-redoctor';
@@ -181,7 +182,7 @@ export function formatExtractionCard(card) {
181
182
  * One proposed pilot. The loop does not know designWeak or which sensor
182
183
  * produced the card — only this list.
183
184
  *
184
- * @typedef {'pattern-bet' | 'reshape'} PilotSource
185
+ * @typedef {'pattern-bet' | 'reshape' | 'flat-parent'} PilotSource
185
186
  * @typedef {{
186
187
  * source: PilotSource,
187
188
  * target: string,
@@ -243,10 +244,15 @@ function extractionCardFromCandidate(candidate) {
243
244
  .filter((entry) => typeof entry === 'string' && entry.length > 0)
244
245
  .slice(0, 8);
245
246
  const reshape = candidate.source === 'reshape';
247
+ const flatParent = candidate.source === 'flat-parent';
246
248
  return {
247
249
  id: PILOT_LOOP_ID,
248
- patternBetId: reshape ? `reshape:${candidate.target}` : `pattern-b:${candidate.target}`,
249
- smellId: reshape ? 'physical-cohesion' : 'unknown',
250
+ patternBetId: flatParent
251
+ ? `flat-parent:${candidate.target}`
252
+ : reshape
253
+ ? `reshape:${candidate.target}`
254
+ : `pattern-b:${candidate.target}`,
255
+ smellId: flatParent ? 'flat-parent' : reshape ? 'physical-cohesion' : 'unknown',
250
256
  pilot: candidate.target,
251
257
  pilotTarget: candidate.target,
252
258
  evidence,
@@ -262,9 +268,11 @@ function extractionCardFromCandidate(candidate) {
262
268
  loopStep: 'one-pilot',
263
269
  reDoctor: 'ark-check --doctor --json',
264
270
  rePlan: 'ark-check --plan --json',
265
- next: reshape
266
- ? '/ark-loop (one reshape pilot) | re-doctor after pilot'
267
- : '/ark-autopilot (one cluster / one B pilot) | re-doctor after pilot',
271
+ next: flatParent
272
+ ? '/ark-loop (one flat-parent suggestion) | re-doctor after the move'
273
+ : reshape
274
+ ? '/ark-loop (one reshape pilot) | re-doctor after pilot'
275
+ : '/ark-autopilot (one cluster / one B pilot) | re-doctor after pilot',
268
276
  };
269
277
  }
270
278
 
@@ -272,8 +280,10 @@ function extractionCardFromCandidate(candidate) {
272
280
  * Normalize design-weak pattern bets and a proposed reshape pilot into one list.
273
281
  * Pattern bets stay in selector rank (best first) and are omitted unless
274
282
  * design fitness is design-weak, so that path keeps today's card. A reshape
275
- * card is appended when physical cohesion proposes one. Doctor builds this
276
- * list after advisories, so the loop never depends on call order.
283
+ * card is appended when physical cohesion proposes one. A flat-parent move
284
+ * is appended after that: one file with one importer waits behind a card the
285
+ * loop already promised. Doctor builds this list after advisories, so the
286
+ * loop never depends on call order.
277
287
  *
278
288
  * @param {{
279
289
  * designWeak?: boolean,
@@ -317,6 +327,9 @@ export function collectPilotCandidates(designFitness = {}, advisories = {}) {
317
327
  ...(Array.isArray(card.doNot) ? { doNot: card.doNot } : {}),
318
328
  });
319
329
  }
330
+ for (const candidate of flatParentCandidates(advisories?.flatParentPilot)) {
331
+ candidates.push(candidate);
332
+ }
320
333
  return candidates;
321
334
  }
322
335
 
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Generated gate files whose Ark command only resolves through a local arkgate pin.
3
+ *
4
+ * `npx ark-check`, `pnpm exec arkgate-mcp` and `yarn ark-check` reach arkgate's bins only
5
+ * when package.json declares arkgate. Without that pin the same line asks npm for a package
6
+ * named `ark-check` (404) or runs a stale global copy. The pinned form
7
+ * (`npx -y -p arkgate@<exact>`) does not depend on the pin and is not reported.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { npxArkgatePrefixLength } from './package-manager.mjs';
12
+
13
+ const ARK_BIN = /^(?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)$/;
14
+ const LOCAL_RUNNER_TEXT =
15
+ /(?:^|[\s"'`;&|(])(?:npx|pnpm(?:\s+--config\.verify-deps-before-run=false)?\s+exec|yarn)\s+(?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)(?=[\s"'`;&|)]|$)/m;
16
+ const TOML_SERVER = /command\s*=\s*"([^"]*)"\s*\r?\nargs\s*=\s*\[([^\]]*)\]/g;
17
+
18
+ /** Host hooks, package scripts, and MCP registrations Ark generates. */
19
+ export const PIN_DEPENDENT_CANDIDATES = [
20
+ '.claude/settings.json',
21
+ '.codex/hooks.json',
22
+ '.cursor/hooks.json',
23
+ '.grok/hooks/ark-write-gate.json',
24
+ '.agents/hooks.json',
25
+ '.mcp.json',
26
+ '.cursor/mcp.json',
27
+ '.agents/mcp_config.json',
28
+ 'opencode.json',
29
+ '.codex/config.toml',
30
+ '.grok/config.toml',
31
+ 'package.json',
32
+ ];
33
+
34
+ function readText(file) {
35
+ try {
36
+ return fs.readFileSync(file, 'utf8');
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** True when this argv runs an Ark bin through a runner that needs the local pin. */
43
+ export function argvDependsOnArkgatePin(command, args) {
44
+ if (typeof command !== 'string' || !Array.isArray(args)) return false;
45
+ const runner = path.basename(command.trim().replace(/\\/g, '/')).replace(/\.(?:cmd|exe)$/i, '');
46
+ if (runner === 'npx') return npxArkgatePrefixLength(args) === 0 && ARK_BIN.test(args[0] ?? '');
47
+ if (runner === 'yarn') return ARK_BIN.test(args[0] ?? '');
48
+ if (runner === 'pnpm') {
49
+ const exec = args.indexOf('exec');
50
+ return exec >= 0 && ARK_BIN.test(args[exec + 1] ?? '');
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function jsonServersDependOnPin(text) {
56
+ let json;
57
+ try {
58
+ json = JSON.parse(text);
59
+ } catch {
60
+ return false;
61
+ }
62
+ const servers = [json?.mcpServers?.ark, json?.servers?.ark].filter(
63
+ (server) => server && typeof server === 'object'
64
+ );
65
+ if (servers.some((server) => argvDependsOnArkgatePin(server.command, server.args ?? []))) {
66
+ return true;
67
+ }
68
+ const argv = json?.mcp?.ark?.command;
69
+ return Array.isArray(argv) && argvDependsOnArkgatePin(argv[0], argv.slice(1));
70
+ }
71
+
72
+ function tomlServersDependOnPin(text) {
73
+ for (const match of text.matchAll(TOML_SERVER)) {
74
+ const args = [...match[2].matchAll(/"([^"]*)"/g)].map((entry) => entry[1]);
75
+ if (argvDependsOnArkgatePin(match[1], args)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function fileDependsOnPin(relativePath, text) {
81
+ if (LOCAL_RUNNER_TEXT.test(text)) return true;
82
+ if (relativePath.endsWith('.toml')) return tomlServersDependOnPin(text);
83
+ if (relativePath.endsWith('.json')) return jsonServersDependOnPin(text);
84
+ return false;
85
+ }
86
+
87
+ function workflowFiles(root) {
88
+ const dir = path.join(root, '.github', 'workflows');
89
+ try {
90
+ return fs
91
+ .readdirSync(dir)
92
+ .filter((name) => /\.ya?ml$/i.test(name))
93
+ .sort()
94
+ .map((name) => `.github/workflows/${name}`);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ /** Project-relative generated files whose Ark command needs arkgate in package.json. */
101
+ export function pinDependentGateFiles(root) {
102
+ const found = [];
103
+ for (const rel of [...PIN_DEPENDENT_CANDIDATES, ...workflowFiles(root)]) {
104
+ const text = readText(path.join(root, rel));
105
+ if (text && fileDependsOnPin(rel, text)) found.push(rel);
106
+ }
107
+ return found;
108
+ }
109
+
110
+ /**
111
+ * Attach `dependentGateFiles` to a PACKAGE_PIN_ABSENT truth so doctor can lead with the pin
112
+ * when a generated file would 404 without it. Other codes pass through unchanged.
113
+ */
114
+ export function withPinDependentGateFiles(root, truth) {
115
+ if (truth?.code !== 'PACKAGE_PIN_ABSENT') return truth;
116
+ return { ...truth, dependentGateFiles: pinDependentGateFiles(root) };
117
+ }
@@ -1,8 +1,14 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { analyzePolicyDelta } from './analysis-engine.mjs';
5
- import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
4
+ import { analyzePolicyDelta, stableSerialize } from './analysis-engine.mjs';
5
+ import { loadArkConfigContract } from './config-contract.mjs';
6
+ import {
7
+ directoryArkRulesReader,
8
+ loadEffectiveArkRules,
9
+ loadEffectiveArkRulesFromDisk,
10
+ objectArkRulesReader,
11
+ } from './effective-contract-load.mjs';
6
12
  import {
7
13
  coverageOptionsFromConfig,
8
14
  invariantIdsFromCatalog,
@@ -85,16 +91,52 @@ function configPathInRepository(root, configPath, top) {
85
91
  return relative;
86
92
  }
87
93
 
94
+ /**
95
+ * ArkRules files at the base ref, relative to the project root inside the repository.
96
+ * A path absent at the ref (added by the candidate) reads as `missing`.
97
+ */
98
+ function baseRefArkRulesReader(root, top, ref) {
99
+ const prefix = path
100
+ .relative(fs.realpathSync(top), fs.realpathSync(root))
101
+ .split(path.sep)
102
+ .join('/');
103
+ const spec = (rel) => `${ref}:${prefix ? `${prefix}/` : ''}${rel}`;
104
+ return {
105
+ readArkRulesFile: (rel) => {
106
+ const shown = runGit(top, ['show', spec(rel)]);
107
+ if (shown.status === 0) return { ok: true, content: shown.stdout };
108
+ const exists = runGit(top, ['cat-file', '-e', spec(rel)]);
109
+ return {
110
+ ok: false,
111
+ missing: exists.status !== 0 && !shown.error,
112
+ message: `Cannot read policy base ArkRules ${spec(rel)}: ${
113
+ shown.stderr?.trim() || shown.error?.message || 'git show failed'
114
+ }`,
115
+ };
116
+ },
117
+ };
118
+ }
119
+
88
120
  export function resolvePolicyBaseConfig({
89
121
  root,
90
122
  configPath,
91
123
  basePath,
92
124
  baseRef,
125
+ adoptMissingBase = false,
93
126
  env = process.env,
94
127
  }) {
95
128
  if (basePath) {
96
129
  const absolute = path.isAbsolute(basePath) ? basePath : path.resolve(root, basePath);
97
- return { config: readJsonFile(absolute, 'Policy base'), source: absolute, ref: null };
130
+ const config = readJsonFile(absolute, 'Policy base');
131
+ // A base config FILE carries its ArkRules next to it (same relative paths as the
132
+ // project), never from the candidate working tree: that would compare the
133
+ // candidate catalog with itself and hide every demotion/deletion.
134
+ return {
135
+ config,
136
+ source: absolute,
137
+ ref: null,
138
+ readArkRulesFile: directoryArkRulesReader(fs.realpathSync(path.dirname(absolute))),
139
+ };
98
140
  }
99
141
 
100
142
  const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
@@ -119,7 +161,8 @@ export function resolvePolicyBaseConfig({
119
161
  // A newly adopted contract has no predecessor to weaken. CI-provided and
120
162
  // auto-discovered bases may therefore omit the config; an explicit CLI ref
121
163
  // remains fail-closed because the caller asked to compare that exact input.
122
- if (refExists.status === 0 && !baseRef) return null;
164
+ // A team base bound by --base / discovery (adoptMissingBase) is not such a request.
165
+ if (refExists.status === 0 && (!baseRef || adoptMissingBase)) return null;
123
166
  if (requestedRef) {
124
167
  throw new Error(
125
168
  `Cannot read policy base ${ref}:${relativeConfig}: ${result.stderr.trim() || 'git show failed'}`
@@ -132,6 +175,7 @@ export function resolvePolicyBaseConfig({
132
175
  config: JSON.parse(result.stdout),
133
176
  source: `git:${ref}:${relativeConfig}`,
134
177
  ref,
178
+ ...baseRefArkRulesReader(root, top, ref),
135
179
  };
136
180
  } catch (error) {
137
181
  throw new Error(
@@ -148,6 +192,119 @@ export function readPolicyAcknowledgement(root, acknowledgementPath) {
148
192
  return readJsonFile(absolute, 'Policy acknowledgement');
149
193
  }
150
194
 
195
+ /**
196
+ * Effective ArkRules of the policy base. A path the base config references but the
197
+ * base ref does not contain yet counts as an empty layer (git refs only). Any other
198
+ * failure is fail-closed: silently dropping the base catalog would make every
199
+ * ArkRule demotion or deletion classify as neutral.
200
+ */
201
+ export function loadBaseArkRules(base) {
202
+ const loaded = loadEffectiveArkRules(base.config, base.readArkRulesFile, {
203
+ missingAsEmpty: base.ref != null,
204
+ });
205
+ if (loaded.errors.length > 0) {
206
+ const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
207
+ const remedy =
208
+ base.ref == null
209
+ ? `\nA --policy-base file reads its ArkRules next to it: copy the base catalog to the same relative paths under ${path.dirname(base.source)}, or compare against a git ref with --policy-base-ref.`
210
+ : '';
211
+ throw new Error(
212
+ `Policy base ArkRules could not be loaded (${base.source}); ArkRules transitions cannot be classified:\n${message}${remedy}`
213
+ );
214
+ }
215
+ return loaded.arkRules;
216
+ }
217
+
218
+ function hasArkRulesMap(config) {
219
+ const refs = config?.arkRules;
220
+ return Boolean(refs && typeof refs === 'object' && Object.keys(refs).length > 0);
221
+ }
222
+
223
+ function loadedOrThrow(loaded, label) {
224
+ if (loaded.errors.length === 0) return loaded.arkRules;
225
+ const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
226
+ throw new Error(`${label} ArkRules could not be loaded:\n${message}`);
227
+ }
228
+
229
+ function normalizedArkRulesMap(config) {
230
+ let normalized = config;
231
+ try {
232
+ normalized = loadArkConfigContract(config, 'candidateConfig').config;
233
+ } catch {
234
+ // An invalid candidate is reported by analyzePolicyDelta; compare it as given.
235
+ }
236
+ return stableSerialize(hasArkRulesMap(normalized) ? normalized.arkRules : null);
237
+ }
238
+
239
+ /**
240
+ * True when a supplied `candidateConfig` names the same ArkRules catalog files as the
241
+ * loaded project config, so the candidate catalog is the one on disk. Both sides are
242
+ * normalised first: the project config in memory carries loader defaults (`$schema`,
243
+ * upgraded `schemaVersion`), so passing `ark.config.json` verbatim must still match.
244
+ */
245
+ export function candidateArkRulesMatchProject(candidateConfig, projectConfig) {
246
+ if (candidateConfig === undefined) return true;
247
+ if (!candidateConfig || typeof candidateConfig !== 'object' || Array.isArray(candidateConfig)) {
248
+ return false;
249
+ }
250
+ return normalizedArkRulesMap(candidateConfig) === normalizedArkRulesMap(projectConfig);
251
+ }
252
+
253
+ /**
254
+ * MCP `ark_policy_delta`: Effective ArkRules for both sides of a config transition.
255
+ * Base ArkRules must be SUPPLIED as data (`baseArkRuleFiles`, keyed by the project-
256
+ * relative paths in `baseConfig.arkRules`) whenever the base config maps any: the
257
+ * server only sees the candidate working tree, and reading the base catalog from it
258
+ * would classify every ArkRule demotion or deletion as neutral. The candidate comes
259
+ * from `candidateArkRuleFiles`, or from disk when the candidate is the project config.
260
+ */
261
+ export function resolvePolicyDeltaArkRules({
262
+ root,
263
+ baseConfig,
264
+ candidateConfig,
265
+ candidateIsProjectConfig,
266
+ baseArkRuleFiles,
267
+ candidateArkRuleFiles,
268
+ }) {
269
+ let baseArkRules;
270
+ if (hasArkRulesMap(baseConfig)) {
271
+ if (!baseArkRuleFiles || typeof baseArkRuleFiles !== 'object') {
272
+ throw new Error(
273
+ 'baseConfig maps arkRules, so the ArkRules transition cannot be classified without the base catalog: ' +
274
+ 'pass baseArkRuleFiles as { "<path from baseConfig.arkRules>": <ArkRules file JSON> }.'
275
+ );
276
+ }
277
+ baseArkRules = loadedOrThrow(
278
+ loadEffectiveArkRules(baseConfig, objectArkRulesReader(baseArkRuleFiles)),
279
+ 'Base'
280
+ );
281
+ } else {
282
+ baseArkRules = loadEffectiveArkRules(baseConfig, () => ({ ok: false, message: '' })).arkRules;
283
+ }
284
+
285
+ let candidateArkRules;
286
+ if (candidateArkRuleFiles && typeof candidateArkRuleFiles === 'object') {
287
+ candidateArkRules = loadedOrThrow(
288
+ loadEffectiveArkRules(candidateConfig, objectArkRulesReader(candidateArkRuleFiles)),
289
+ 'Candidate'
290
+ );
291
+ } else if (candidateIsProjectConfig) {
292
+ candidateArkRules = loadedOrThrow(
293
+ loadEffectiveArkRulesFromDisk(root, candidateConfig),
294
+ 'Candidate'
295
+ );
296
+ } else if (hasArkRulesMap(candidateConfig)) {
297
+ throw new Error(
298
+ 'candidateConfig maps different arkRules files than the project contract: pass candidateArkRuleFiles ' +
299
+ 'as { "<path from candidateConfig.arkRules>": <ArkRules file JSON> }.'
300
+ );
301
+ } else {
302
+ candidateArkRules = loadEffectiveArkRules(candidateConfig, () => ({ ok: false, message: '' }))
303
+ .arkRules;
304
+ }
305
+ return { baseArkRules, candidateArkRules };
306
+ }
307
+
151
308
  export function analyzePolicyTransition({
152
309
  root,
153
310
  configPath,
@@ -155,18 +312,21 @@ export function analyzePolicyTransition({
155
312
  strictMerge,
156
313
  basePath,
157
314
  baseRef,
315
+ adoptMissingBase = false,
158
316
  acknowledgementPath,
159
317
  }) {
160
318
  if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
161
- const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef });
162
- if (!base && (basePath || baseRef || acknowledgementPath)) {
319
+ const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef, adoptMissingBase });
320
+ if (!base && (basePath || (baseRef && !adoptMissingBase) || acknowledgementPath)) {
163
321
  throw new Error('Policy delta was requested but no policy base could be resolved.');
164
322
  }
165
323
  if (!base) return undefined;
166
324
 
167
325
  // AR02/AR11: load Effective ArkRules so mode edits inside arkrules/*.json classify,
168
326
  // and attach candidate coverage so covered advisory→enforced can auto-allow.
169
- const baseLoad = loadEffectiveArkRulesFromDisk(root, base.config);
327
+ // Base ArkRules come from the SAME source as the base config (git ref or the
328
+ // base file's directory), never from the candidate working tree.
329
+ const baseArkRules = loadBaseArkRules(base);
170
330
  const candidateLoad = loadEffectiveArkRulesFromDisk(root, candidateConfig);
171
331
  if (candidateLoad.errors.length > 0) {
172
332
  const message = candidateLoad.errors
@@ -174,8 +334,6 @@ export function analyzePolicyTransition({
174
334
  .join('\n');
175
335
  throw new Error(`Invalid candidate Effective Contract:\n${message}`);
176
336
  }
177
- // Base load errors: best-effort empty (base git tree may lack arkrules files on disk).
178
- const baseArkRules = baseLoad.errors.length > 0 ? undefined : baseLoad.arkRules;
179
337
  const candidateArkRules = candidateLoad.arkRules;
180
338
 
181
339
  let candidateInvariantCoverage;
@@ -205,7 +363,7 @@ export function analyzePolicyTransition({
205
363
  acknowledgement,
206
364
  baseSource: base.source,
207
365
  candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
208
- ...(baseArkRules ? { baseArkRules } : {}),
366
+ baseArkRules,
209
367
  candidateArkRules,
210
368
  ...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
211
369
  }),