arkgate 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Codex home config ($CODEX_HOME/config.toml) — paths, multi-project wire, adoption assess.
3
+ * Extracted from agent-gates so install/doctor stay orchestration-only (R7 review).
4
+ */
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { execCommandParts } from '../ark-shared.mjs';
10
+
11
+ export const PREFERRED_CODEX_MCP_BIN = 'arkgate-mcp';
12
+
13
+ /** Where Codex loads slash-command prompts ($CODEX_HOME/prompts). */
14
+ export function codexPromptsDir() {
15
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
16
+ return path.join(base, 'prompts');
17
+ }
18
+
19
+ /** Where Codex loads MCP servers ($CODEX_HOME/config.toml) — global, not project-local. */
20
+ export function codexConfigPath() {
21
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
22
+ return path.join(base, 'config.toml');
23
+ }
24
+
25
+ /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
26
+ export function isTempOrUpgradeRoot(p) {
27
+ if (!p || typeof p !== 'string') return false;
28
+ const n = p.replace(/\\/g, '/');
29
+ return (
30
+ /\/var\/folders\//i.test(n) ||
31
+ /\/tmp\//i.test(n) ||
32
+ /\/Temp\//i.test(n) ||
33
+ /ark-upgrade/i.test(n) ||
34
+ /\/T\/(?:ark-|grok-)/i.test(n) ||
35
+ /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Stable secondary table name: basename + short path hash so two projects named
41
+ * `app` do not collide on `mcp_servers.ark_app`.
42
+ */
43
+ export function codexProjectSlug(absRoot) {
44
+ const abs = path.resolve(absRoot);
45
+ const base =
46
+ path
47
+ .basename(abs)
48
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
49
+ .slice(0, 40) || 'project';
50
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
51
+ return `${base}_${hash}`;
52
+ }
53
+
54
+ /** Extract `--root` from one TOML mcp_servers table body. */
55
+ export function extractCodexRootFromBlock(block) {
56
+ if (!block || typeof block !== 'string') return null;
57
+ const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
58
+ return m ? m[1] : null;
59
+ }
60
+
61
+ /**
62
+ * All Ark MCP server tables in a Codex config.toml.
63
+ * @returns {Array<{ table: string, root: string|null, block: string, start: number, end: number }>}
64
+ */
65
+ export function listCodexArkServerTables(tomlText) {
66
+ if (!tomlText || typeof tomlText !== 'string') return [];
67
+ const out = [];
68
+ const headerRe = /\[mcp_servers\.(ark(?:_[a-zA-Z0-9_-]*)?)\]/g;
69
+ const headers = [];
70
+ let hm;
71
+ while ((hm = headerRe.exec(tomlText)) !== null) {
72
+ headers.push({ table: hm[1], index: hm.index });
73
+ }
74
+ for (let i = 0; i < headers.length; i++) {
75
+ const start = headers[i].index;
76
+ let end = i + 1 < headers.length ? headers[i + 1].index : tomlText.length;
77
+ if (i + 1 >= headers.length) {
78
+ const rest = tomlText.slice(start + 1);
79
+ const other = rest.search(/\n\[/);
80
+ if (other >= 0) end = start + 1 + other;
81
+ }
82
+ const block = tomlText.slice(start, end).replace(/\s+$/, '\n');
83
+ out.push({
84
+ table: headers[i].table,
85
+ root: extractCodexRootFromBlock(block),
86
+ block,
87
+ start,
88
+ end,
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Replace an existing `[mcp_servers.<table>]` block or append a new one.
96
+ * `block` should include the header line; trailing whitespace is normalized.
97
+ */
98
+ export function upsertCodexMcpTable(tomlText, tableName, block) {
99
+ const existing = tomlText || '';
100
+ const normalized = `${String(block).replace(/\s+$/, '')}\n`;
101
+ const tables = listCodexArkServerTables(existing);
102
+ const hit = tables.find((t) => t.table === tableName);
103
+ if (hit) {
104
+ return `${existing.slice(0, hit.start)}${normalized}${existing.slice(hit.end).replace(/^\n+/, '\n')}`;
105
+ }
106
+ if (existing.length === 0) return normalized;
107
+ const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
108
+ return `${existing}${sep}${normalized}`;
109
+ }
110
+
111
+ /** Primary table entry or null. */
112
+ export function codexPrimaryTable(tomlText) {
113
+ return listCodexArkServerTables(tomlText).find((t) => t.table === 'ark') ?? null;
114
+ }
115
+
116
+ /** Secondary (non-primary) table whose --root is this project, if any. */
117
+ export function codexScopedTableForRoot(tomlText, absRoot) {
118
+ const abs = path.resolve(absRoot);
119
+ for (const entry of listCodexArkServerTables(tomlText)) {
120
+ if (entry.table === 'ark') continue;
121
+ if (!entry.root) continue;
122
+ try {
123
+ if (path.resolve(entry.root) === abs) return entry.table;
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** Extract --root from primary [mcp_servers.ark]. */
132
+ export function extractCodexArkRootFromToml(tomlText) {
133
+ return codexPrimaryTable(tomlText)?.root ?? null;
134
+ }
135
+
136
+ export function codexArkBlockHasPreferredBin(tomlText) {
137
+ const primary = codexPrimaryTable(tomlText);
138
+ if (!primary) return false;
139
+ const bins = [...primary.block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
140
+ if (bins.length > 1) return false;
141
+ return bins.length === 1 && bins[0] === PREFERRED_CODEX_MCP_BIN;
142
+ }
143
+
144
+ /**
145
+ * True when primary is broken (temp root / dual bin) and should rewrite fail-closed.
146
+ * Permanent different project roots are NOT broken — multi-project uses a secondary table.
147
+ */
148
+ export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
149
+ if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
150
+ const rootArg = extractCodexArkRootFromToml(tomlText);
151
+ if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
152
+ try {
153
+ if (path.resolve(rootArg) !== path.resolve(absRoot)) {
154
+ if (!isTempOrUpgradeRoot(rootArg)) return false;
155
+ return true;
156
+ }
157
+ } catch {
158
+ return true;
159
+ }
160
+ if (!codexArkBlockHasPreferredBin(tomlText)) return true;
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Assess Codex home MCP vs this project. Pure (no I/O).
166
+ * @returns {{
167
+ * root: string|null,
168
+ * tempPath: boolean,
169
+ * wrongRoot: boolean,
170
+ * preferredBin: boolean,
171
+ * needsRewrite: boolean,
172
+ * multiProject: boolean,
173
+ * scopedTable: string|null,
174
+ * gap: null | { id: string, severity: string, message: string, fixArgs: string }
175
+ * }}
176
+ */
177
+ export function assessCodexHomeMcp(tomlText, absRoot) {
178
+ const resolvedRoot = path.resolve(absRoot);
179
+ if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) {
180
+ return {
181
+ root: null,
182
+ tempPath: false,
183
+ wrongRoot: false,
184
+ preferredBin: false,
185
+ needsRewrite: false,
186
+ multiProject: false,
187
+ scopedTable: null,
188
+ gap: null,
189
+ };
190
+ }
191
+ const rootArg = extractCodexArkRootFromToml(tomlText);
192
+ const temp = isTempOrUpgradeRoot(rootArg);
193
+ let wrongRoot = false;
194
+ try {
195
+ wrongRoot = rootArg ? path.resolve(rootArg) !== resolvedRoot : true;
196
+ } catch {
197
+ wrongRoot = true;
198
+ }
199
+ const preferredBin = codexArkBlockHasPreferredBin(tomlText);
200
+ const needsRewrite = codexArkBlockNeedsRewrite(tomlText, resolvedRoot);
201
+ const scopedTable = wrongRoot && !temp ? codexScopedTableForRoot(tomlText, resolvedRoot) : null;
202
+ const multiProject = Boolean(wrongRoot && !temp && !needsRewrite);
203
+
204
+ let gap = null;
205
+ if (needsRewrite) {
206
+ gap = {
207
+ id: 'codex-home-mcp',
208
+ severity: temp || wrongRoot ? 'warn' : 'info',
209
+ message: temp
210
+ ? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
211
+ : wrongRoot
212
+ ? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${resolvedRoot})`
213
+ : `Codex home MCP should use a single ${PREFERRED_CODEX_MCP_BIN} bin with absolute project paths`,
214
+ fixArgs: '--install-agent-gates --codex-home --force',
215
+ };
216
+ } else if (multiProject) {
217
+ gap = {
218
+ id: 'codex-home-multi-project',
219
+ severity: scopedTable ? 'info' : 'warn',
220
+ message: scopedTable
221
+ ? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
222
+ `this project is registered as [mcp_servers.${scopedTable}]. ` +
223
+ `Codex may still prefer the primary binding for ark://manifest — rebind if this repo should own it.`
224
+ : `Codex home primary MCP --root is another permanent project ` +
225
+ `(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
226
+ `Install without --force adds a scoped [mcp_servers.ark_<slug>] table and leaves primary unchanged; ` +
227
+ `--force rebinds primary to this project.`,
228
+ fixArgs: scopedTable
229
+ ? '--install-agent-gates --tools codex --force'
230
+ : '--install-agent-gates --tools codex',
231
+ };
232
+ }
233
+
234
+ return {
235
+ root: rootArg,
236
+ tempPath: temp,
237
+ wrongRoot,
238
+ preferredBin,
239
+ needsRewrite,
240
+ multiProject,
241
+ scopedTable,
242
+ gap,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Merge [mcp_servers.ark] (or scoped secondary) into Codex home config.toml.
248
+ * Without --force, permanent other-project primary is left alone; this project gets
249
+ * [mcp_servers.ark_<slug>]. Temp/stale roots rewrite fail-closed.
250
+ *
251
+ * All mutations go through upsertCodexMcpTable (single table model).
252
+ */
253
+ export function wireCodexMcp(root, force) {
254
+ const file = codexConfigPath();
255
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
256
+ const absRoot = path.resolve(root);
257
+ const absConfig = path.join(absRoot, 'ark.config.json');
258
+ const { command, args } = execCommandParts(root, PREFERRED_CODEX_MCP_BIN, [
259
+ '--root',
260
+ esc(absRoot),
261
+ '--config',
262
+ esc(absConfig),
263
+ ]);
264
+ const argsToml = args.map((value) => `"${value}"`).join(', ');
265
+ const makeBlock = (table) =>
266
+ `[mcp_servers.${table}]
267
+ command = "${command}"
268
+ args = [${argsToml}]`;
269
+
270
+ let existing = '';
271
+ try {
272
+ if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
273
+ } catch (error) {
274
+ return { status: 'failed', file, message: error.message };
275
+ }
276
+
277
+ const primary = codexPrimaryTable(existing);
278
+ const hasPrimary = Boolean(primary);
279
+ const existingRoot = primary?.root ?? null;
280
+ let differentProject = false;
281
+ try {
282
+ differentProject = Boolean(existingRoot && path.resolve(existingRoot) !== absRoot);
283
+ } catch {
284
+ differentProject = Boolean(existingRoot);
285
+ }
286
+ const mustRewrite = hasPrimary && codexArkBlockNeedsRewrite(existing, absRoot);
287
+
288
+ const writeToml = (next) => {
289
+ try {
290
+ fs.mkdirSync(path.dirname(file), { recursive: true });
291
+ fs.writeFileSync(file, next);
292
+ return null;
293
+ } catch (error) {
294
+ return { status: 'failed', file, message: error.message };
295
+ }
296
+ };
297
+
298
+ // Multi-project: leave primary alone; upsert scoped secondary for this root.
299
+ if (hasPrimary && differentProject && !force && !mustRewrite) {
300
+ const existingScoped = codexScopedTableForRoot(existing, absRoot);
301
+ const table = existingScoped || `ark_${codexProjectSlug(absRoot)}`;
302
+ const next = upsertCodexMcpTable(existing, table, makeBlock(table));
303
+ const err = writeToml(next);
304
+ if (err) return err;
305
+ return { status: 'written-multi', file, table, primaryUnchanged: true };
306
+ }
307
+
308
+ if (hasPrimary && !force && !mustRewrite) {
309
+ return { status: 'skipped', file };
310
+ }
311
+
312
+ const next = upsertCodexMcpTable(existing, 'ark', makeBlock('ark'));
313
+ const err = writeToml(next);
314
+ if (err) return err;
315
+ return {
316
+ status: hasPrimary ? 'updated' : 'written',
317
+ file,
318
+ ...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
319
+ };
320
+ }
@@ -162,6 +162,8 @@ export function buildRemediationPlan(root, activeViolations, governedPercent = n
162
162
  ...(v.typeOnly ? { typeOnly: true } : {}),
163
163
  ...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
164
164
  ...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
165
+ ...(v.namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
166
+ ...(v.edgeKind ? { edgeKind: v.edgeKind } : {}),
165
167
  ...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
166
168
  };
167
169
  });
@@ -13,6 +13,25 @@ export const REMEDIATION_CLASSES = [
13
13
  'judgment',
14
14
  'deferred',
15
15
  ];
16
+ /** All remediationKinds that may return class: mechanical-safe (ordered for docs/tests). */
17
+ export const MECHANICAL_SAFE_KINDS = [
18
+ 'pure-type-file-relocate',
19
+ 'type-only-import-move',
20
+ 'import-type-from-pure-type-module',
21
+ 'import-type-of-type-exports',
22
+ ];
23
+ /** fixClass values from enrichViolationWithFixClass (eval corpus / reports). */
24
+ export const KNOWN_FIX_CLASSES = [
25
+ 'file-move',
26
+ 'port-inversion',
27
+ 'inject-port',
28
+ 'registered-intent',
29
+ 'add-source-metadata',
30
+ 'fix-source-layer',
31
+ 'intent-relocation',
32
+ 'break-cycle',
33
+ 'review-contract',
34
+ ];
16
35
  /**
17
36
  * Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
18
37
  * Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
@@ -20,6 +39,15 @@ export const REMEDIATION_CLASSES = [
20
39
  export function classifyRemediation(violation) {
21
40
  const ruleId = violation?.ruleId;
22
41
  if (ruleId === 'LAYER_IMPORT_VIOLATION') {
42
+ // Single invariant: runtime module loads are never mechanical-safe.
43
+ const edgeKind = violation?.edgeKind;
44
+ if (edgeKind === 'require' || edgeKind === 'dynamic-import') {
45
+ return {
46
+ class: 'judgment',
47
+ confidence: 0.75,
48
+ rationale: 'Runtime module load (require/import()) still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
49
+ };
50
+ }
23
51
  if (violation?.typeOnly && violation?.sourcePureTypeModule) {
24
52
  return {
25
53
  class: 'mechanical-safe',
@@ -37,14 +65,6 @@ export function classifyRemediation(violation) {
37
65
  };
38
66
  }
39
67
  if (violation?.targetTypeOnlyExports) {
40
- const kind = violation.edgeKind;
41
- if (kind === 'require' || kind === 'dynamic-import') {
42
- return {
43
- class: 'judgment',
44
- confidence: 0.75,
45
- rationale: 'Runtime module load (require/import()) of a type-only module still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
46
- };
47
- }
48
68
  return {
49
69
  class: 'mechanical-safe',
50
70
  confidence: 0.85,
@@ -52,6 +72,16 @@ export function classifyRemediation(violation) {
52
72
  rationale: 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
53
73
  };
54
74
  }
75
+ // R6: value-syntax named import/export of type-only exports from a mixed module.
76
+ // Only set when scan proves no dual-space value export and no top-level side effects.
77
+ if (violation?.namedBindingsTypeOnly) {
78
+ return {
79
+ class: 'mechanical-safe',
80
+ confidence: 0.86,
81
+ remediationKind: 'import-type-of-type-exports',
82
+ rationale: 'Named bindings are type-only exports of the target module (even if the file also exports values): convert to `import type` / `export type` (erased at runtime). Gate verifies.',
83
+ };
84
+ }
55
85
  return {
56
86
  class: 'judgment',
57
87
  confidence: 0.7,
@@ -92,12 +122,14 @@ export function enrichViolationWithFixClass(violation) {
92
122
  const enriched = { ...violation };
93
123
  switch (violation.ruleId) {
94
124
  case 'LAYER_IMPORT_VIOLATION':
95
- if (violation.typeOnly || violation.targetTypeOnlyExports) {
125
+ if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
96
126
  enriched.fixClass = 'file-move';
97
127
  enriched.effort = 'small';
98
- enriched.enthusiastHint = violation.targetTypeOnlyExports
99
- ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
100
- : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
128
+ enriched.enthusiastHint = violation.namedBindingsTypeOnly
129
+ ? 'Those named imports are type-only exports of the target — use `import type { … }` (or `export type { }`) so the edge is erased at runtime.'
130
+ : violation.targetTypeOnlyExports
131
+ ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
132
+ : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
101
133
  }
102
134
  else {
103
135
  enriched.fixClass = 'port-inversion';
@@ -124,12 +124,13 @@ export function scanCacheKey(root, args) {
124
124
  ? args.manifest
125
125
  : path.join(root, args.manifest)
126
126
  : undefined;
127
- // Bump this schema tag whenever the cached scan shape changes, so a warm cache from an
128
- // older Ark can't feed stale entries to new logic. v2: typeOnly on edges. v3: per-file
129
- // exportsOnlyTypes (target-module type-only export detection for plan classifier).
127
+ // Bump this schema tag whenever the cached scan shape or detection semantics change, so a
128
+ // warm cache from an older Ark can't feed stale entries to new logic. v2: typeOnly on edges.
129
+ // v3: per-file exportsOnlyTypes. v4: typeOnlyExportNames + namedBindings.
130
+ // v5: hasTopLevelSideEffects. v6: non-exported impure inits + non-export class statics.
130
131
  return crypto
131
132
  .createHash('sha1')
132
- .update(`ark-check-cache-v3\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
133
+ .update(`ark-check-cache-v6\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
133
134
  .digest('hex');
134
135
  }
135
136
 
package/dist/index.cjs CHANGED
@@ -80,7 +80,7 @@ __export(index_exports, {
80
80
  module.exports = __toCommonJS(index_exports);
81
81
 
82
82
  // src/version.ts
83
- var version = "2.7.0";
83
+ var version = "2.8.0";
84
84
 
85
85
  // src/kernel/intent/IntentRegistry.ts
86
86
  var IntentRegistry = class {