arkgate 4.8.23 → 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.
- package/CHANGELOG.md +68 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +24 -77
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +596 -112
- package/bin/ark-mcp-runtime.mjs +412 -138
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +39 -0
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/agent-projection-command.mjs +5 -1
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +50 -6
- package/bin/lib/ark-order-doctor.mjs +1 -1
- package/bin/lib/ark-order-facts.mjs +410 -15
- package/bin/lib/ark-order-invariants.mjs +51 -9
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +2 -0
- package/bin/lib/ark-run-doctor.mjs +14 -0
- package/bin/lib/ark-run-facts.mjs +434 -11
- package/bin/lib/ark-run-sensors.mjs +5 -3
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrules-sensors.mjs +50 -249
- package/bin/lib/check-args.mjs +23 -1
- package/bin/lib/ci-and-commands.mjs +16 -6
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/codex-home.mjs +4 -2
- package/bin/lib/config-contract-slices.mjs +701 -0
- package/bin/lib/config-contract.mjs +5 -493
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/diagnostic-catalog.mjs +16 -9
- package/bin/lib/doctor-advisories.mjs +25 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +6 -8
- package/bin/lib/effective-contract-load.mjs +121 -59
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/first-run-help.mjs +3 -2
- package/bin/lib/flat-parent-importers.mjs +137 -0
- package/bin/lib/flat-parent-pilot.mjs +40 -100
- package/bin/lib/gate-files.mjs +29 -2
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +1 -1
- package/bin/lib/hook-templates.mjs +18 -16
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage.mjs +59 -11
- package/bin/lib/mcp-adoption.mjs +9 -1
- package/bin/lib/mcp-hook-payload.mjs +401 -61
- package/bin/lib/package-manager.mjs +59 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +81 -0
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +168 -10
- package/bin/lib/prepare-change.mjs +120 -6
- package/bin/lib/prepare-write.mjs +1 -0
- package/bin/lib/presets.mjs +3 -5
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +315 -55
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +179 -5
- package/bin/lib/scan-files.mjs +32 -0
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/stable-hash.mjs +90 -0
- package/bin/lib/start-install-recovery.mjs +71 -1
- package/bin/lib/start-preview.mjs +30 -0
- package/bin/lib/status-command.mjs +33 -4
- package/bin/lib/status-manifest.mjs +32 -4
- package/bin/lib/team-parliament-io.mjs +77 -75
- package/bin/lib/team-parliament.mjs +6 -1
- package/bin/lib/write-path-capabilities.mjs +4 -2
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-MBXCFLO4.js +16 -0
- package/dist/chunk-RNQHRDCQ.cjs +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
- package/dist/configTypes-BJbaeoj4.d.ts +246 -0
- package/dist/eslint/index.cjs +17 -4
- package/dist/eslint/index.d.cts +359 -0
- package/dist/eslint/index.d.ts +130 -156
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +35 -34
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3312 -299
- package/dist/index.js +35 -34
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +84 -15
- package/docs/ai-gates.md +160 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +196 -26
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +96 -19
- package/docs/package-surface.md +31 -8
- package/docs/product-voice.md +1 -1
- package/package.json +59 -18
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +47 -10
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +4 -1
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-runtime.md +6 -1
- package/dist/types-Ik_OakcI.d.ts +0 -183
|
@@ -138,50 +138,151 @@ export function normalizeHookPayload(payload, grokHookEvent = Boolean(process.en
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
/** Realpath of the nearest existing ancestor plus the missing tail (never throws). */
|
|
142
|
+
export function canonicalPathLoose(candidate) {
|
|
143
|
+
const absolute = path.resolve(candidate);
|
|
144
|
+
let existing = absolute;
|
|
145
|
+
const missing = [];
|
|
146
|
+
while (!fs.existsSync(existing)) {
|
|
147
|
+
const parent = path.dirname(existing);
|
|
148
|
+
if (parent === existing) return absolute;
|
|
149
|
+
missing.unshift(path.basename(existing));
|
|
150
|
+
existing = parent;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return path.join(fs.realpathSync(existing), ...missing);
|
|
154
|
+
} catch {
|
|
155
|
+
return absolute;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function relativeInside(root, candidate) {
|
|
160
|
+
const relative = path.relative(root, candidate);
|
|
161
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`)) return null;
|
|
162
|
+
if (path.isAbsolute(relative)) return null;
|
|
163
|
+
return relative;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Project-relative path when `filePath` is inside `root` under the caller's spelling
|
|
168
|
+
* or, failing that, the canonical (realpath) spelling — /tmp vs /private/tmp, a
|
|
169
|
+
* symlinked checkout. Null only when the path is truly outside the root.
|
|
170
|
+
*/
|
|
171
|
+
export function relativeToHookRoot(root, filePath) {
|
|
172
|
+
const absolute = path.resolve(root, filePath);
|
|
173
|
+
const direct = relativeInside(path.resolve(root), absolute);
|
|
174
|
+
if (direct !== null) return direct;
|
|
175
|
+
return relativeInside(canonicalPathLoose(root), canonicalPathLoose(absolute));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CODEX_FILE_DIRECTIVE = /^\*\*\* (Add|Update|Delete) File: (.+)$/;
|
|
179
|
+
const CODEX_MOVE_DIRECTIVE = /^\*\*\* Move to: (.+)$/;
|
|
180
|
+
const CODEX_END_OF_FILE = '*** End of File';
|
|
181
|
+
|
|
182
|
+
/** Codex-style sequence seek: exact, then trailing-whitespace, then trimmed match. */
|
|
183
|
+
function seekSequence(source, pattern, start, eof) {
|
|
184
|
+
if (pattern.length === 0) return start;
|
|
185
|
+
if (pattern.length > source.length) return -1;
|
|
186
|
+
const normalizers = [(line) => line, (line) => line.trimEnd(), (line) => line.trim()];
|
|
187
|
+
const lastStart = source.length - pattern.length;
|
|
188
|
+
for (const normalize of normalizers) {
|
|
189
|
+
const matchesAt = (at) =>
|
|
190
|
+
pattern.every((line, index) => normalize(source[at + index]) === normalize(line));
|
|
191
|
+
if (eof && lastStart >= start && matchesAt(lastStart)) return lastStart;
|
|
192
|
+
for (let at = start; at <= lastStart; at += 1) {
|
|
193
|
+
if (matchesAt(at)) return at;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return -1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Parse one Update File body into chunks. The first chunk may omit its `@@` header;
|
|
201
|
+
* `*** End of File` may close a chunk; a bare empty line is an empty context line.
|
|
202
|
+
* Returns null for any line outside the Codex apply_patch grammar.
|
|
203
|
+
*/
|
|
204
|
+
export function parseCodexUpdateChunks(lines) {
|
|
205
|
+
const body = [...lines];
|
|
206
|
+
while (body.length > 0 && body[body.length - 1] === '') body.pop();
|
|
207
|
+
const chunks = [];
|
|
208
|
+
let chunk = null;
|
|
209
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
210
|
+
const line = body[index];
|
|
147
211
|
if (line.startsWith('@@')) {
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!hunk) return null;
|
|
152
|
-
hunk.entries.push(line);
|
|
212
|
+
if (chunk) chunks.push(chunk);
|
|
213
|
+
chunk = { anchor: line.slice(2).trim(), entries: [], eof: false };
|
|
214
|
+
continue;
|
|
153
215
|
}
|
|
216
|
+
if (line === CODEX_END_OF_FILE) {
|
|
217
|
+
const next = body[index + 1];
|
|
218
|
+
if (!chunk || chunk.entries.length === 0 || (next !== undefined && !next.startsWith('@@'))) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
chunk.eof = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (line === '' || /^[ +\-]/.test(line)) {
|
|
225
|
+
if (!chunk) {
|
|
226
|
+
// Only the FIRST chunk may omit `@@` (implicit, anchorless).
|
|
227
|
+
if (chunks.length > 0) return null;
|
|
228
|
+
chunk = { anchor: '', entries: [], eof: false };
|
|
229
|
+
}
|
|
230
|
+
if (chunk.eof) return null;
|
|
231
|
+
chunk.entries.push(line === '' ? ' ' : line);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
154
235
|
}
|
|
155
|
-
if (
|
|
156
|
-
|
|
236
|
+
if (chunk) chunks.push(chunk);
|
|
237
|
+
if (chunks.length === 0 || chunks.some((entry) => entry.entries.length === 0)) return null;
|
|
238
|
+
return chunks;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function applyCodexUpdatePatch(current, lines) {
|
|
242
|
+
const chunks = parseCodexUpdateChunks(lines);
|
|
243
|
+
if (!chunks) return null;
|
|
244
|
+
const source = current.split('\n');
|
|
245
|
+
// Codex matches against the file's lines without the final newline's empty tail.
|
|
246
|
+
if (source.length > 0 && source[source.length - 1] === '') source.pop();
|
|
247
|
+
let cursor = 0;
|
|
248
|
+
for (const { anchor, entries, eof } of chunks) {
|
|
157
249
|
if (anchor) {
|
|
158
|
-
const anchorAt = source
|
|
250
|
+
const anchorAt = seekSequence(source, [anchor], cursor, false);
|
|
159
251
|
if (anchorAt < 0) return null;
|
|
160
252
|
cursor = anchorAt + 1;
|
|
161
253
|
}
|
|
162
254
|
const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
|
|
163
255
|
const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
|
|
164
|
-
let found
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
256
|
+
let found;
|
|
257
|
+
if (oldLines.length === 0) {
|
|
258
|
+
// Pure insertion lands at the end of the file (Codex semantics).
|
|
259
|
+
found = source.length;
|
|
260
|
+
} else {
|
|
261
|
+
found = seekSequence(source, oldLines, cursor, eof);
|
|
262
|
+
if (found < 0) return null;
|
|
170
263
|
}
|
|
171
|
-
if (found < 0) return null;
|
|
172
264
|
source.splice(found, oldLines.length, ...newLines);
|
|
173
265
|
cursor = found + newLines.length;
|
|
174
266
|
}
|
|
175
|
-
return source.join('\n')
|
|
267
|
+
return `${source.join('\n')}\n`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function codexPatchTarget(root, rawPath) {
|
|
271
|
+
const relative = relativeToHookRoot(root, rawPath.trim());
|
|
272
|
+
if (relative === null) return null;
|
|
273
|
+
return {
|
|
274
|
+
filePath: path.resolve(root, relative),
|
|
275
|
+
path: relative.split(path.sep).join('/'),
|
|
276
|
+
};
|
|
176
277
|
}
|
|
177
278
|
|
|
178
279
|
export function codexPatchWrites(patch, root) {
|
|
179
280
|
if (typeof patch !== 'string') {
|
|
180
281
|
return { writes: [], complete: false };
|
|
181
282
|
}
|
|
182
|
-
const lines = patch.split('\n');
|
|
183
|
-
const begin = lines.
|
|
184
|
-
const end = lines.
|
|
283
|
+
const lines = patch.replace(/\r\n/g, '\n').split('\n');
|
|
284
|
+
const begin = lines.findIndex((line) => line.trim() === '*** Begin Patch');
|
|
285
|
+
const end = lines.findIndex((line, index) => index > begin && line.trim() === '*** End Patch');
|
|
185
286
|
if (begin < 0 || end <= begin) return { writes: [], complete: false };
|
|
186
287
|
const writes = [];
|
|
187
288
|
const seenPaths = new Set();
|
|
@@ -191,31 +292,35 @@ export function codexPatchWrites(patch, root) {
|
|
|
191
292
|
].every((line) => line.trim() === '');
|
|
192
293
|
let sawFileDirective = false;
|
|
193
294
|
for (let index = begin + 1; index < end; index += 1) {
|
|
194
|
-
const match = lines[index].match(
|
|
295
|
+
const match = lines[index].match(CODEX_FILE_DIRECTIVE);
|
|
195
296
|
if (!match) {
|
|
196
297
|
if (lines[index].trim() !== '') complete = false;
|
|
197
298
|
continue;
|
|
198
299
|
}
|
|
199
300
|
sawFileDirective = true;
|
|
200
301
|
const [, action, relativePath] = match;
|
|
302
|
+
let moveTo = null;
|
|
303
|
+
if (action === 'Update') {
|
|
304
|
+
const move = (lines[index + 1] ?? '').match(CODEX_MOVE_DIRECTIVE);
|
|
305
|
+
if (move) {
|
|
306
|
+
moveTo = move[1];
|
|
307
|
+
index += 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
201
310
|
const body = [];
|
|
202
|
-
|
|
311
|
+
// Hunk bodies end only at the next file directive or End Patch — never at
|
|
312
|
+
// `*** End of File`, which belongs to the hunk grammar.
|
|
313
|
+
for (index += 1; index < end && !CODEX_FILE_DIRECTIVE.test(lines[index]); index += 1) {
|
|
203
314
|
body.push(lines[index]);
|
|
204
315
|
}
|
|
205
316
|
index -= 1;
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
seenPaths.has(filePath) ||
|
|
210
|
-
rel.startsWith(`..${path.sep}`) ||
|
|
211
|
-
rel === '..' ||
|
|
212
|
-
path.isAbsolute(rel)
|
|
213
|
-
) {
|
|
317
|
+
const target = codexPatchTarget(root, relativePath);
|
|
318
|
+
if (!target || seenPaths.has(target.filePath)) {
|
|
214
319
|
complete = false;
|
|
215
320
|
continue;
|
|
216
321
|
}
|
|
217
|
-
seenPaths.add(filePath);
|
|
218
|
-
const canonicalRelativePath =
|
|
322
|
+
seenPaths.add(target.filePath);
|
|
323
|
+
const { filePath, path: canonicalRelativePath } = target;
|
|
219
324
|
if (action === 'Delete') {
|
|
220
325
|
if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
|
|
221
326
|
complete = false;
|
|
@@ -224,39 +329,54 @@ export function codexPatchWrites(patch, root) {
|
|
|
224
329
|
writes.push({ path: canonicalRelativePath, filePath, delete: true });
|
|
225
330
|
continue;
|
|
226
331
|
}
|
|
227
|
-
let content;
|
|
228
332
|
if (action === 'Add') {
|
|
333
|
+
const addBody = [...body];
|
|
334
|
+
while (addBody.length > 0 && addBody[addBody.length - 1] === '') addBody.pop();
|
|
229
335
|
if (
|
|
230
|
-
|
|
336
|
+
addBody.length === 0 ||
|
|
231
337
|
fs.existsSync(filePath) ||
|
|
232
|
-
|
|
233
|
-
) {
|
|
234
|
-
complete = false;
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
content = body.filter((line) => line.startsWith('+')).map((line) => line.slice(1)).join('\n');
|
|
238
|
-
if (body.some((line) => line.startsWith('+'))) content += '\n';
|
|
239
|
-
} else {
|
|
240
|
-
if (
|
|
241
|
-
!body.some((line) => line.startsWith('@@')) ||
|
|
242
|
-
body.some((line) => !line.startsWith('@@') && !/^[ +\-]/.test(line))
|
|
338
|
+
addBody.some((line) => !line.startsWith('+'))
|
|
243
339
|
) {
|
|
244
340
|
complete = false;
|
|
245
341
|
continue;
|
|
246
342
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
343
|
+
const content = `${addBody.map((line) => line.slice(1)).join('\n')}\n`;
|
|
344
|
+
writes.push({ path: canonicalRelativePath, filePath, content });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
let current;
|
|
348
|
+
try {
|
|
349
|
+
current = fs.readFileSync(filePath, 'utf8');
|
|
350
|
+
} catch {
|
|
351
|
+
complete = false;
|
|
352
|
+
continue;
|
|
256
353
|
}
|
|
257
|
-
|
|
354
|
+
const content = applyCodexUpdatePatch(current, body);
|
|
355
|
+
if (content === null) {
|
|
356
|
+
complete = false;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (moveTo === null) {
|
|
258
360
|
writes.push({ path: canonicalRelativePath, filePath, content });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// `*** Move to:` — judge the patched content at its destination, delete the source.
|
|
364
|
+
const destination = codexPatchTarget(root, moveTo);
|
|
365
|
+
if (!destination) {
|
|
366
|
+
complete = false;
|
|
367
|
+
continue;
|
|
259
368
|
}
|
|
369
|
+
if (destination.filePath === filePath) {
|
|
370
|
+
writes.push({ path: canonicalRelativePath, filePath, content });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (seenPaths.has(destination.filePath) || fs.existsSync(destination.filePath)) {
|
|
374
|
+
complete = false;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
seenPaths.add(destination.filePath);
|
|
378
|
+
writes.push({ path: destination.path, filePath: destination.filePath, content });
|
|
379
|
+
writes.push({ path: canonicalRelativePath, filePath, delete: true });
|
|
260
380
|
}
|
|
261
381
|
return { writes, complete: complete && sawFileDirective };
|
|
262
382
|
}
|
|
@@ -371,8 +491,12 @@ export function requiredOwnerWriteDeny(config, layerName, relativePath) {
|
|
|
371
491
|
export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
|
|
372
492
|
const target = file || 'this write';
|
|
373
493
|
const why = String(reason || 'a bad import — the write doesn’t land').replace(/\s+/g, ' ').trim();
|
|
494
|
+
// Import-plane denies keep the short "move / place" line unless the rule's own action
|
|
495
|
+
// already says that. Extra planes (ArkRules, ArkRun, ArkOrder, invariants, gate
|
|
496
|
+
// availability) are not import problems: their catalog action is the only right one.
|
|
497
|
+
const extraPlane = /^(ARKRULE_|ARKRUN_|ARKORDER_|INVARIANT_|WRITE_GATE_)/.test(String(ruleId ?? ''));
|
|
374
498
|
const next =
|
|
375
|
-
nextAction && /place|move|import|port/i.test(nextAction)
|
|
499
|
+
nextAction && (extraPlane || /place|move|import|port/i.test(nextAction))
|
|
376
500
|
? nextAction
|
|
377
501
|
: 'Move the import or run /ark-place. Do not weaken ark.config.json.';
|
|
378
502
|
const lines = [`blocked ${target} — ${why}`, `Next: ${next}`];
|
|
@@ -382,3 +506,219 @@ export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLin
|
|
|
382
506
|
}
|
|
383
507
|
return lines.join('\n');
|
|
384
508
|
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Fail-closed envelope for the one-shot PreToolUse hook (`ark-mcp --hook`).
|
|
512
|
+
*
|
|
513
|
+
* Claude Code, Grok Build, and Codex treat any exit code other than 2 as a
|
|
514
|
+
* non-blocking error: the write lands. When the write gate itself cannot run
|
|
515
|
+
* (ark.config.json missing / unparsable / invalid, a referenced ArkRules file
|
|
516
|
+
* missing, dist/ missing or broken), exiting 1 would silently turn the hard
|
|
517
|
+
* write gate off. "No checker, no write": governed source writes are denied
|
|
518
|
+
* with the host's blocking contract instead.
|
|
519
|
+
*
|
|
520
|
+
* Plumbing cases stay fail-open, exactly like the healthy hook: no stdin or a
|
|
521
|
+
* malformed payload, non-file tools, non-source files (so ark.config.json itself
|
|
522
|
+
* can still be repaired), files outside the root, and node_modules.
|
|
523
|
+
*
|
|
524
|
+
* This section (like the whole module) must not import dist/ or the MCP runtime — it runs precisely when
|
|
525
|
+
* those are the broken part.
|
|
526
|
+
*/
|
|
527
|
+
const FAIL_CLOSED_SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
528
|
+
const PATCH_FILE_DIRECTIVE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/;
|
|
529
|
+
const PATCH_MOVE_DIRECTIVE = /^\*\*\* Move to: (.+)$/;
|
|
530
|
+
|
|
531
|
+
/** Same root resolution as the runtime: --root, then the first non-empty --root-env var. */
|
|
532
|
+
export function hookRootFromArgv(argv, env = process.env, cwd = process.cwd()) {
|
|
533
|
+
let root = cwd;
|
|
534
|
+
const rootEnv = [];
|
|
535
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
536
|
+
const value = argv[index];
|
|
537
|
+
if (value === '--root' && argv[index + 1]) root = path.resolve(argv[++index]);
|
|
538
|
+
else if (value === '--root-env' && argv[index + 1]) {
|
|
539
|
+
rootEnv.push(
|
|
540
|
+
...String(argv[++index])
|
|
541
|
+
.split(',')
|
|
542
|
+
.map((name) => name.trim())
|
|
543
|
+
.filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const name of rootEnv) {
|
|
548
|
+
const value = env[name];
|
|
549
|
+
if (typeof value === 'string' && value.trim() !== '') return path.resolve(value.trim());
|
|
550
|
+
}
|
|
551
|
+
return root;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function hookArgvConfig(argv) {
|
|
555
|
+
let config = 'ark.config.json';
|
|
556
|
+
let explicit = false;
|
|
557
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
558
|
+
if (argv[index] === '--config' && argv[index + 1]) {
|
|
559
|
+
config = argv[++index];
|
|
560
|
+
explicit = true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return { config, explicit };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function walkUpForConfig(start, config) {
|
|
567
|
+
let dir = path.resolve(start);
|
|
568
|
+
for (;;) {
|
|
569
|
+
if (fs.existsSync(path.join(dir, config))) return dir;
|
|
570
|
+
const parent = path.dirname(dir);
|
|
571
|
+
if (parent === dir) return null;
|
|
572
|
+
dir = parent;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Hook project root. A set `--root-env` variable or a root that already holds the
|
|
578
|
+
* config wins (the runtime's own resolution). Otherwise — the host ran the hook from a
|
|
579
|
+
* subdirectory, or the env var is unset — walk up from the payload (each written file,
|
|
580
|
+
* then `cwd`, then `workspace_roots`, then the argv root) to the nearest config, the
|
|
581
|
+
* way `ark-check` walks up.
|
|
582
|
+
* `configFound: false` means the write is not inside any Ark project.
|
|
583
|
+
* @returns {{ root: string, configFound: boolean, configExplicit: boolean, source: string }}
|
|
584
|
+
*/
|
|
585
|
+
export function discoverHookRoot({
|
|
586
|
+
argv,
|
|
587
|
+
hookInput,
|
|
588
|
+
env = process.env,
|
|
589
|
+
cwd = process.cwd(),
|
|
590
|
+
grokHookEvent = Boolean(process.env.GROK_HOOK_EVENT),
|
|
591
|
+
}) {
|
|
592
|
+
const base = hookRootFromArgv(argv, env, cwd);
|
|
593
|
+
const { config, explicit } = hookArgvConfig(argv);
|
|
594
|
+
const result = (root, configFound, source) => ({
|
|
595
|
+
root,
|
|
596
|
+
configFound,
|
|
597
|
+
configExplicit: explicit,
|
|
598
|
+
source,
|
|
599
|
+
});
|
|
600
|
+
if (path.isAbsolute(config)) return result(base, fs.existsSync(config), 'argv');
|
|
601
|
+
if (fs.existsSync(path.join(base, config))) return result(base, true, 'argv');
|
|
602
|
+
let payload;
|
|
603
|
+
try {
|
|
604
|
+
payload = JSON.parse(hookInput ?? '');
|
|
605
|
+
} catch {
|
|
606
|
+
payload = undefined;
|
|
607
|
+
}
|
|
608
|
+
const payloadCwd =
|
|
609
|
+
payload && typeof payload.cwd === 'string' && payload.cwd.trim() ? payload.cwd.trim() : null;
|
|
610
|
+
const starts = [];
|
|
611
|
+
if (payload && typeof payload === 'object') {
|
|
612
|
+
const targets = hookPayloadTargets(normalizeHookPayload(payload, grokHookEvent)) ?? [];
|
|
613
|
+
for (const target of targets) {
|
|
614
|
+
if (typeof target !== 'string' || !target) continue;
|
|
615
|
+
starts.push(path.dirname(path.resolve(payloadCwd ?? base, target)));
|
|
616
|
+
}
|
|
617
|
+
if (payloadCwd) starts.push(path.resolve(payloadCwd));
|
|
618
|
+
for (const workspace of Array.isArray(payload.workspace_roots) ? payload.workspace_roots : []) {
|
|
619
|
+
if (typeof workspace === 'string' && workspace.trim()) starts.push(path.resolve(workspace));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
starts.push(base);
|
|
623
|
+
for (const start of starts) {
|
|
624
|
+
const found = walkUpForConfig(start, config);
|
|
625
|
+
if (found) return result(found, true, 'payload');
|
|
626
|
+
}
|
|
627
|
+
return result(base, false, 'none');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The write is outside any Ark project (no config found from the hook root or the
|
|
632
|
+
* payload): ArkGate does not govern it, so the hook allows it.
|
|
633
|
+
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
634
|
+
*/
|
|
635
|
+
export function hookOutsideArkProjectResponse({ hookInput, config = 'ark.config.json', grokHookEvent }) {
|
|
636
|
+
let stdout = '';
|
|
637
|
+
let payload;
|
|
638
|
+
try {
|
|
639
|
+
payload = JSON.parse(hookInput ?? '');
|
|
640
|
+
} catch {
|
|
641
|
+
return { status: 0, stdout, stderr: '' };
|
|
642
|
+
}
|
|
643
|
+
emitHostAllow(
|
|
644
|
+
{ stdout: (value) => { stdout += value; }, stderr: () => {} },
|
|
645
|
+
normalizeHookPayload(payload, grokHookEvent)
|
|
646
|
+
);
|
|
647
|
+
return {
|
|
648
|
+
status: 0,
|
|
649
|
+
stdout,
|
|
650
|
+
stderr: `[ark-mcp] no ${config} found for this write; ArkGate does not govern it.\n`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function patchTargets(patch) {
|
|
655
|
+
if (typeof patch !== 'string') return [];
|
|
656
|
+
const targets = [];
|
|
657
|
+
for (const line of patch.split('\n')) {
|
|
658
|
+
const match = line.match(PATCH_FILE_DIRECTIVE) ?? line.match(PATCH_MOVE_DIRECTIVE);
|
|
659
|
+
if (match) targets.push(match[1].trim());
|
|
660
|
+
}
|
|
661
|
+
return targets;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** File paths the hook payload would write, or null for non-file tools. */
|
|
665
|
+
export function hookPayloadTargets(normalized) {
|
|
666
|
+
const { toolName, toolInput } = normalized;
|
|
667
|
+
if (toolName === 'ApplyPatch') {
|
|
668
|
+
return patchTargets(
|
|
669
|
+
toolInput.command ?? toolInput.patch ?? toolInput.input ?? toolInput.content
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) return null;
|
|
673
|
+
return typeof toolInput.file_path === 'string' ? [toolInput.file_path] : [];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function isGovernedCandidate(root, target) {
|
|
677
|
+
if (!FAIL_CLOSED_SOURCE_FILE.test(target) || target.endsWith('.d.ts')) return false;
|
|
678
|
+
const relative = relativeToHookRoot(root, target);
|
|
679
|
+
if (relative === null) return false;
|
|
680
|
+
return !relative.split(path.sep).includes('node_modules');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Map an internal hook failure onto the host's blocking contract.
|
|
685
|
+
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
686
|
+
*/
|
|
687
|
+
export function hookFailClosedResponse({ hookInput, error, root, grokHookEvent }) {
|
|
688
|
+
let stdout = '';
|
|
689
|
+
let stderr = '';
|
|
690
|
+
const output = {
|
|
691
|
+
stdout: (value) => {
|
|
692
|
+
stdout += value;
|
|
693
|
+
},
|
|
694
|
+
stderr: (value) => {
|
|
695
|
+
stderr += value;
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
let payload;
|
|
699
|
+
try {
|
|
700
|
+
payload = JSON.parse(hookInput ?? '');
|
|
701
|
+
} catch {
|
|
702
|
+
// Same as the healthy hook: no stdin / malformed payload never blocks.
|
|
703
|
+
return { status: 0, stdout, stderr };
|
|
704
|
+
}
|
|
705
|
+
const normalized = normalizeHookPayload(payload, grokHookEvent);
|
|
706
|
+
const targets = hookPayloadTargets(normalized);
|
|
707
|
+
const governed = (targets ?? []).filter((target) => isGovernedCandidate(root, target));
|
|
708
|
+
if (governed.length === 0) {
|
|
709
|
+
emitHostAllow(output, normalized);
|
|
710
|
+
return { status: 0, stdout, stderr };
|
|
711
|
+
}
|
|
712
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
713
|
+
const relative = relativeToHookRoot(root, governed[0]) ?? governed[0];
|
|
714
|
+
const file = String(relative).split(path.sep).join('/');
|
|
715
|
+
const message = formatWriteGateDeny({
|
|
716
|
+
file,
|
|
717
|
+
reason: `ArkGate write gate could not run: ${reason}`,
|
|
718
|
+
ruleId: 'WRITE_GATE_UNAVAILABLE',
|
|
719
|
+
nextAction:
|
|
720
|
+
'Fix ark.config.json (or the ArkRules file it references), or run npm run build / reinstall arkgate from npm, then retry this write. No checker, no write — do not remove the hook to get past it.',
|
|
721
|
+
});
|
|
722
|
+
emitHostDeny(output, { ...normalized, message, file });
|
|
723
|
+
return { status: 2, stdout, stderr };
|
|
724
|
+
}
|
|
@@ -83,9 +83,66 @@ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
|
|
|
83
83
|
* same detection.
|
|
84
84
|
*/
|
|
85
85
|
export function execRunner(root) {
|
|
86
|
+
if (!arkgateIsProjectDependency(root)) return pinnedArkgateRunner();
|
|
86
87
|
return RUNNER_BY_PM[detectPackageManager(root)];
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** The exact arkgate version that ships this CLI (the package.json next to bin/). */
|
|
91
|
+
export function shippedArkgateVersion() {
|
|
92
|
+
try {
|
|
93
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
94
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when the project resolves arkgate locally: a `dependencies` / `devDependencies` entry,
|
|
102
|
+
* an installed node_modules/arkgate, or the arkgate package itself (self-host). Only then
|
|
103
|
+
* does a bare `npx ark-check` / `pnpm exec` / `yarn` runner reach this package's bins.
|
|
104
|
+
*/
|
|
105
|
+
export function arkgateIsProjectDependency(root) {
|
|
106
|
+
// An installed copy (node_modules/arkgate) also resolves the local runner.
|
|
107
|
+
if (fs.existsSync(path.join(root, 'node_modules', 'arkgate', 'package.json'))) return true;
|
|
108
|
+
const pkg = readPackageJson(root);
|
|
109
|
+
if (!pkg || typeof pkg !== 'object') return false;
|
|
110
|
+
if (pkg.name === 'arkgate') return true;
|
|
111
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
112
|
+
(deps) => deps && typeof deps === 'object' && typeof deps.arkgate === 'string'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `arkgate@<exact shipped version>` — the package an unpinned project runs through npx. */
|
|
117
|
+
export function pinnedArkgateSpec(version = shippedArkgateVersion()) {
|
|
118
|
+
return version ? `arkgate@${version}` : 'arkgate';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Runner for a project that does not pin arkgate. Bare `npx ark-check` asks npm for a
|
|
123
|
+
* package named `ark-check` (404) or runs a stale global copy; `-p arkgate@<exact>` names the
|
|
124
|
+
* package that owns the bin, and `-y` keeps CI and host hooks from blocking on a prompt.
|
|
125
|
+
*/
|
|
126
|
+
export function pinnedArkgateRunner(version) {
|
|
127
|
+
return `npx -y -p ${pinnedArkgateSpec(version)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Number of npx arguments before the bin: 0 for `npx <bin>`, more for the pinned form
|
|
132
|
+
* (`npx -y -p arkgate@x <bin>`, `--yes`, `--package arkgate@x`, `--package=arkgate@x`).
|
|
133
|
+
*/
|
|
134
|
+
export function npxArkgatePrefixLength(args) {
|
|
135
|
+
if (!Array.isArray(args)) return 0;
|
|
136
|
+
let index = 0;
|
|
137
|
+
if (args[index] === '-y' || args[index] === '--yes') index += 1;
|
|
138
|
+
const flag = args[index];
|
|
139
|
+
if ((flag === '-p' || flag === '--package') && /^arkgate(?:@\S+)?$/.test(args[index + 1] ?? '')) {
|
|
140
|
+
return index + 2;
|
|
141
|
+
}
|
|
142
|
+
if (typeof flag === 'string' && /^--package=arkgate(?:@\S+)?$/.test(flag)) return index + 1;
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
89
146
|
/** Full runnable command string for an installed Ark binary, package-manager aware. */
|
|
90
147
|
export function arkCommand(root, bin, argsStr = '') {
|
|
91
148
|
return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
|
|
@@ -116,7 +173,8 @@ export function execCommandParts(root, bin, binArgs = []) {
|
|
|
116
173
|
};
|
|
117
174
|
}
|
|
118
175
|
if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
|
|
119
|
-
|
|
176
|
+
// Pinned form (`npx -y -p arkgate@x`) keeps its prefix as real argv entries.
|
|
177
|
+
return { command: 'npx', args: [...runner.split(' ').slice(1), bin, ...binArgs] };
|
|
120
178
|
}
|
|
121
179
|
|
|
122
180
|
/**
|