arkgate 2.12.0 → 2.13.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.
- package/CHANGELOG.md +83 -0
- package/README.md +55 -31
- package/bin/ark-check.mjs +95 -36
- package/bin/ark-mcp.mjs +11 -5
- package/bin/ark-shared.mjs +88 -56
- package/bin/ark.mjs +45 -10
- package/bin/lib/agent-gates.mjs +12 -0
- package/bin/lib/architecture-scan.mjs +8 -0
- package/bin/lib/ci-and-commands.mjs +9 -3
- package/bin/lib/codex-home.mjs +7 -0
- package/bin/lib/config-contract.mjs +331 -0
- package/bin/lib/doctor-plan.mjs +43 -16
- package/bin/lib/enforcement-profiles.mjs +97 -0
- package/bin/lib/host-support-matrix.mjs +77 -0
- package/bin/lib/install-migrate.mjs +45 -14
- package/bin/lib/mcp-adoption.mjs +35 -3
- package/bin/lib/open-html.mjs +75 -0
- package/bin/lib/presets.mjs +3 -2
- package/bin/lib/safety-diagnostics.mjs +31 -11
- package/bin/lib/skill-install.mjs +64 -0
- package/bin/lib/ts-resolve.mjs +2 -1
- package/bin/lib/weakest-link.mjs +417 -0
- package/bin/lib/write-path-capabilities.mjs +182 -0
- package/bin/lib/write-path-detect.mjs +62 -99
- package/dist/configContract-iBLxx5Tz.d.cts +53 -0
- package/dist/configContract-iBLxx5Tz.d.ts +53 -0
- package/dist/eslint/index.cjs +375 -13
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +30 -20
- package/dist/eslint/index.d.ts +30 -20
- package/dist/eslint/index.js +375 -13
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +723 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -5
- package/dist/index.d.ts +95 -5
- package/dist/index.js +716 -61
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +150 -42
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +2 -1
- package/dist/nestjs/index.d.ts +2 -1
- package/dist/nestjs/index.js +150 -42
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +723 -61
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +3 -2
- package/dist/runtime/index.d.ts +3 -2
- package/dist/runtime/index.js +716 -61
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
- package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
- package/docs/agent-guide.md +32 -20
- package/docs/ai-gates.md +53 -18
- package/docs/configuration.md +97 -0
- package/docs/enthusiast/README.md +3 -3
- package/docs/enthusiast/how-to-agent-gates.md +7 -3
- package/docs/migrate-from-ark-runtime-kernel.md +3 -0
- package/docs/package-surface.md +14 -9
- package/docs/production-hardening.md +15 -2
- package/docs/threat-model.md +65 -0
- package/docs/typescript-support.md +3 -3
- package/package.json +15 -2
- package/schemas/ark.config.schema.json +750 -0
- package/server.json +2 -2
- package/templates/hooks/pre-commit-ark +37 -0
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-runtime.md +8 -5
- package/templates/skills/ark-upgrade.md +36 -16
- package/tests/fixtures/ts-consumer/ark.config.json +2 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/configContract.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/config-contract.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ARK_CONFIG_SCHEMA_VERSION = '1.0';
|
|
12
|
+
export const ARK_CONFIG_SCHEMA_URL = 'https://unpkg.com/arkgate@2/schemas/ark.config.schema.json';
|
|
13
|
+
const DEFAULT_LAYER_NAMES = [
|
|
14
|
+
'DomainModel',
|
|
15
|
+
'ApplicationOrchestration',
|
|
16
|
+
'PersistenceAdapters',
|
|
17
|
+
'IntegrationAdapters',
|
|
18
|
+
'WorkflowSagaEngine',
|
|
19
|
+
'BackgroundJobsScheduling',
|
|
20
|
+
'PresentationAdapters',
|
|
21
|
+
'ReportingReadModels',
|
|
22
|
+
'ExtensibilityMetadata',
|
|
23
|
+
'SecurityAuditObservability',
|
|
24
|
+
'Kernel',
|
|
25
|
+
];
|
|
26
|
+
const DEFAULT_ALLOWED_FLOWS = new Set([
|
|
27
|
+
'PresentationAdapters->ApplicationOrchestration',
|
|
28
|
+
'ApplicationOrchestration->DomainModel',
|
|
29
|
+
'WorkflowSagaEngine->ApplicationOrchestration',
|
|
30
|
+
'WorkflowSagaEngine->DomainModel',
|
|
31
|
+
'BackgroundJobsScheduling->ApplicationOrchestration',
|
|
32
|
+
]);
|
|
33
|
+
function createDefaultRules() {
|
|
34
|
+
const rules = [];
|
|
35
|
+
for (const from of DEFAULT_LAYER_NAMES) {
|
|
36
|
+
for (const to of DEFAULT_LAYER_NAMES) {
|
|
37
|
+
if (from === to || DEFAULT_ALLOWED_FLOWS.has(`${from}->${to}`))
|
|
38
|
+
continue;
|
|
39
|
+
rules.push({ from, to, allowed: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return rules;
|
|
43
|
+
}
|
|
44
|
+
export const DEFAULT_ARK_CONFIG_RULES = createDefaultRules();
|
|
45
|
+
export const ARK_CONFIG_MIGRATIONS = [
|
|
46
|
+
{ from: 'unversioned', to: ARK_CONFIG_SCHEMA_VERSION },
|
|
47
|
+
];
|
|
48
|
+
const stringArraySchema = {
|
|
49
|
+
type: 'array',
|
|
50
|
+
items: { type: 'string', minLength: 1 },
|
|
51
|
+
uniqueItems: true,
|
|
52
|
+
};
|
|
53
|
+
export const ARK_CONFIG_SCHEMA = {
|
|
54
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
55
|
+
$id: ARK_CONFIG_SCHEMA_URL,
|
|
56
|
+
title: 'ArkGate architecture contract',
|
|
57
|
+
description: 'Versioned contract consumed identically by ArkGate CLI, MCP, and ESLint surfaces.',
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
required: ['$schema', 'schemaVersion', 'include', 'layers', 'rules'],
|
|
61
|
+
properties: {
|
|
62
|
+
$schema: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
minLength: 1,
|
|
65
|
+
default: ARK_CONFIG_SCHEMA_URL,
|
|
66
|
+
description: 'Editor-facing URL or local path for this JSON Schema.',
|
|
67
|
+
},
|
|
68
|
+
schemaVersion: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
const: ARK_CONFIG_SCHEMA_VERSION,
|
|
71
|
+
default: ARK_CONFIG_SCHEMA_VERSION,
|
|
72
|
+
},
|
|
73
|
+
name: { type: 'string', minLength: 1 },
|
|
74
|
+
include: { ...stringArraySchema, minItems: 1, default: ['src'] },
|
|
75
|
+
exclude: { ...stringArraySchema, default: [] },
|
|
76
|
+
excludeGenerated: { type: 'boolean', default: true },
|
|
77
|
+
frameworkOverlay: { type: 'string', minLength: 1 },
|
|
78
|
+
layers: {
|
|
79
|
+
type: 'array',
|
|
80
|
+
default: [],
|
|
81
|
+
items: { $ref: '#/$defs/layer' },
|
|
82
|
+
},
|
|
83
|
+
rules: {
|
|
84
|
+
type: 'array',
|
|
85
|
+
default: DEFAULT_ARK_CONFIG_RULES,
|
|
86
|
+
items: { $ref: '#/$defs/rule' },
|
|
87
|
+
},
|
|
88
|
+
cyclePolicy: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
enum: ['strict', 'soft', 'framework-soft', 'off'],
|
|
91
|
+
default: 'strict',
|
|
92
|
+
},
|
|
93
|
+
dynamicImportAllowlist: { ...stringArraySchema, default: [] },
|
|
94
|
+
safety: {
|
|
95
|
+
$ref: '#/$defs/safety',
|
|
96
|
+
default: {
|
|
97
|
+
maxTsSuppressions: 0,
|
|
98
|
+
maxAnyCasts: 0,
|
|
99
|
+
allowInMemory: false,
|
|
100
|
+
allowDisabledPeerIsolation: false,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
$defs: {
|
|
105
|
+
layer: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
required: ['name', 'patterns'],
|
|
109
|
+
properties: {
|
|
110
|
+
name: { type: 'string', minLength: 1 },
|
|
111
|
+
patterns: { ...stringArraySchema, minItems: 1 },
|
|
112
|
+
exclude: stringArraySchema,
|
|
113
|
+
intentPrefixes: stringArraySchema,
|
|
114
|
+
description: { type: 'string', minLength: 1 },
|
|
115
|
+
forbiddenGlobals: stringArraySchema,
|
|
116
|
+
mayImportInfrastructure: { type: 'boolean' },
|
|
117
|
+
optional: { type: 'boolean' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
rule: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
required: ['from', 'to', 'allowed'],
|
|
124
|
+
properties: {
|
|
125
|
+
from: { type: 'string', minLength: 1 },
|
|
126
|
+
to: { type: 'string', minLength: 1 },
|
|
127
|
+
allowed: { type: 'boolean' },
|
|
128
|
+
message: { type: 'string', minLength: 1 },
|
|
129
|
+
peerIsolation: { type: 'boolean' },
|
|
130
|
+
sliceFolders: { ...stringArraySchema, minItems: 1 },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
safety: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
properties: {
|
|
137
|
+
maxTsSuppressions: { type: 'integer', minimum: 0, default: 0 },
|
|
138
|
+
maxAnyCasts: { type: 'integer', minimum: 0, default: 0 },
|
|
139
|
+
allowInMemory: { type: 'boolean', default: false },
|
|
140
|
+
allowDisabledPeerIsolation: { type: 'boolean', default: false },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
export class ArkConfigValidationError extends Error {
|
|
146
|
+
issues;
|
|
147
|
+
source;
|
|
148
|
+
constructor(source, issues) {
|
|
149
|
+
super(`Invalid ArkGate config (${source}):\n${issues
|
|
150
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
151
|
+
.join('\n')}`);
|
|
152
|
+
this.name = 'ArkConfigValidationError';
|
|
153
|
+
this.source = source;
|
|
154
|
+
this.issues = issues;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function isObject(value) {
|
|
158
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
function propertyPath(parent, key) {
|
|
161
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
162
|
+
? `${parent}.${key}`
|
|
163
|
+
: `${parent}[${JSON.stringify(key)}]`;
|
|
164
|
+
}
|
|
165
|
+
function valueType(value) {
|
|
166
|
+
if (value === null)
|
|
167
|
+
return 'null';
|
|
168
|
+
if (Array.isArray(value))
|
|
169
|
+
return 'array';
|
|
170
|
+
return typeof value;
|
|
171
|
+
}
|
|
172
|
+
function resolveSchemaRef(ref, root) {
|
|
173
|
+
const prefix = '#/$defs/';
|
|
174
|
+
if (!ref.startsWith(prefix))
|
|
175
|
+
return undefined;
|
|
176
|
+
return root.$defs[ref.slice(prefix.length)];
|
|
177
|
+
}
|
|
178
|
+
function validateNode(value, schema, path, root, issues) {
|
|
179
|
+
if (schema.$ref) {
|
|
180
|
+
const referenced = resolveSchemaRef(schema.$ref, root);
|
|
181
|
+
if (!referenced) {
|
|
182
|
+
issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
validateNode(value, referenced, path, root, issues);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (schema.const !== undefined && !Object.is(value, schema.const)) {
|
|
189
|
+
issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
|
|
193
|
+
issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (schema.type === 'object') {
|
|
197
|
+
if (!isObject(value)) {
|
|
198
|
+
issues.push({ path, message: `must be an object; received ${valueType(value)}` });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const properties = schema.properties ?? {};
|
|
202
|
+
for (const key of schema.required ?? []) {
|
|
203
|
+
if (value[key] === undefined) {
|
|
204
|
+
issues.push({ path: propertyPath(path, key), message: 'is required' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (schema.additionalProperties === false) {
|
|
208
|
+
for (const key of Object.keys(value)) {
|
|
209
|
+
if (!(key in properties)) {
|
|
210
|
+
issues.push({ path: propertyPath(path, key), message: 'unknown field' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const [key, childSchema] of Object.entries(properties)) {
|
|
215
|
+
if (value[key] !== undefined) {
|
|
216
|
+
validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (schema.type === 'array') {
|
|
222
|
+
if (!Array.isArray(value)) {
|
|
223
|
+
issues.push({ path, message: `must be an array; received ${valueType(value)}` });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
227
|
+
issues.push({ path, message: `must contain at least ${schema.minItems} item(s)` });
|
|
228
|
+
}
|
|
229
|
+
if (schema.uniqueItems) {
|
|
230
|
+
const serialized = value.map((entry) => JSON.stringify(entry));
|
|
231
|
+
if (new Set(serialized).size !== serialized.length) {
|
|
232
|
+
issues.push({ path, message: 'must not contain duplicate items' });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (schema.items) {
|
|
236
|
+
value.forEach((entry, index) => validateNode(entry, schema.items, `${path}[${index}]`, root, issues));
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (schema.type === 'string') {
|
|
241
|
+
if (typeof value !== 'string') {
|
|
242
|
+
issues.push({ path, message: `must be a string; received ${valueType(value)}` });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
246
|
+
issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (schema.type === 'boolean') {
|
|
251
|
+
if (typeof value !== 'boolean') {
|
|
252
|
+
issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (schema.type === 'integer') {
|
|
257
|
+
if (!Number.isInteger(value)) {
|
|
258
|
+
issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
262
|
+
issues.push({ path, message: `must be at least ${schema.minimum}` });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function defaultedConfig(input) {
|
|
267
|
+
return {
|
|
268
|
+
...input,
|
|
269
|
+
$schema: input.$schema === undefined ? ARK_CONFIG_SCHEMA_URL : input.$schema,
|
|
270
|
+
schemaVersion: input.schemaVersion === undefined ? ARK_CONFIG_SCHEMA_VERSION : input.schemaVersion,
|
|
271
|
+
include: input.include === undefined ? ['src'] : input.include,
|
|
272
|
+
layers: input.layers === undefined ? [] : input.layers,
|
|
273
|
+
rules: input.rules === undefined
|
|
274
|
+
? DEFAULT_ARK_CONFIG_RULES.map((rule) => ({ ...rule }))
|
|
275
|
+
: input.rules,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
export function migrateArkConfig(input, source = 'ark.config.json') {
|
|
279
|
+
if (!isObject(input)) {
|
|
280
|
+
throw new ArkConfigValidationError(source, [
|
|
281
|
+
{ path: '$', message: `must be an object; received ${valueType(input)}` },
|
|
282
|
+
]);
|
|
283
|
+
}
|
|
284
|
+
const migratedFrom = input.schemaVersion === undefined ? 'unversioned' : null;
|
|
285
|
+
if (input.schemaVersion !== undefined &&
|
|
286
|
+
input.schemaVersion !== ARK_CONFIG_SCHEMA_VERSION) {
|
|
287
|
+
throw new ArkConfigValidationError(source, [
|
|
288
|
+
{
|
|
289
|
+
path: '$.schemaVersion',
|
|
290
|
+
message: `unsupported version ${JSON.stringify(input.schemaVersion)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
return { candidate: defaultedConfig(input), migratedFrom };
|
|
295
|
+
}
|
|
296
|
+
export function loadArkConfigContract(input, source = 'ark.config.json') {
|
|
297
|
+
const { candidate, migratedFrom } = migrateArkConfig(input, source);
|
|
298
|
+
const issues = [];
|
|
299
|
+
validateNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
|
|
300
|
+
if (issues.length > 0)
|
|
301
|
+
throw new ArkConfigValidationError(source, issues);
|
|
302
|
+
return { config: candidate, migratedFrom };
|
|
303
|
+
}
|
|
304
|
+
export function parseArkConfigJson(json, source = 'ark.config.json') {
|
|
305
|
+
let input;
|
|
306
|
+
try {
|
|
307
|
+
input = JSON.parse(json);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw new ArkConfigValidationError(source, [
|
|
311
|
+
{
|
|
312
|
+
path: '$',
|
|
313
|
+
message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
return loadArkConfigContract(input, source);
|
|
318
|
+
}
|
|
319
|
+
export function withArkConfigMetadata(config) {
|
|
320
|
+
const result = {
|
|
321
|
+
$schema: typeof config.$schema === 'string' && config.$schema.length > 0
|
|
322
|
+
? config.$schema
|
|
323
|
+
: ARK_CONFIG_SCHEMA_URL,
|
|
324
|
+
schemaVersion: ARK_CONFIG_SCHEMA_VERSION,
|
|
325
|
+
};
|
|
326
|
+
for (const [key, value] of Object.entries(config)) {
|
|
327
|
+
if (key !== '$schema' && key !== 'schemaVersion')
|
|
328
|
+
result[key] = value;
|
|
329
|
+
}
|
|
330
|
+
return result;
|
|
331
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -327,8 +327,14 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
327
327
|
gatesMissing,
|
|
328
328
|
skillGaps,
|
|
329
329
|
staleRunnerFiles: staleRunners,
|
|
330
|
-
//
|
|
330
|
+
// Active-host guarantees plus separate repo-wide inventory.
|
|
331
331
|
writePath: {
|
|
332
|
+
activeHost: writePath.activeHost,
|
|
333
|
+
support: writePath.support,
|
|
334
|
+
supportSummary: writePath.supportSummary,
|
|
335
|
+
capabilities: writePath.capabilities,
|
|
336
|
+
capabilityEvidence: writePath.capabilityEvidence,
|
|
337
|
+
inventory: writePath.inventory,
|
|
332
338
|
mode: writePath.mode,
|
|
333
339
|
prepareWrite: writePath.prepareWrite,
|
|
334
340
|
autoPatch: writePath.autoPatch,
|
|
@@ -404,7 +410,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
404
410
|
adapt:
|
|
405
411
|
'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
|
|
406
412
|
enforce:
|
|
407
|
-
'Guard — contract
|
|
413
|
+
'Guard — contract coverage is honest and checked edges are clean. You do not pick this mode; you arrived here. Next: keep the host-appropriate write path and CI check on; only NEW violations should fail.',
|
|
408
414
|
};
|
|
409
415
|
line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
|
|
410
416
|
if (emptyScope) {
|
|
@@ -496,26 +502,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
496
502
|
|
|
497
503
|
console.log('');
|
|
498
504
|
console.log(color.bold('Write path (agent)'));
|
|
505
|
+
const capabilities = writePath.capabilities;
|
|
499
506
|
const writePathLabels = {
|
|
500
507
|
repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
|
|
501
508
|
'reject-only': 'reject-only — hard block with prose; no repair payload',
|
|
502
509
|
'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
|
|
503
|
-
none: '
|
|
510
|
+
none: 'no write gate hook and no Ark MCP',
|
|
504
511
|
};
|
|
505
512
|
const wpMark =
|
|
506
|
-
|
|
513
|
+
capabilities['hard-write']
|
|
507
514
|
? ok
|
|
508
|
-
:
|
|
509
|
-
?
|
|
510
|
-
:
|
|
515
|
+
: capabilities['advisory-write'] || capabilities['merge-gate']
|
|
516
|
+
? warn
|
|
517
|
+
: bad;
|
|
518
|
+
line(' ', `Active host: ${writePath.activeHost}`);
|
|
519
|
+
line(' ', `Supported profile: ${writePath.supportSummary}`);
|
|
511
520
|
line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
|
|
512
521
|
line(
|
|
513
|
-
|
|
514
|
-
`
|
|
522
|
+
capabilities['hard-write'] ? ok : warn,
|
|
523
|
+
`Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
|
|
524
|
+
);
|
|
525
|
+
line(
|
|
526
|
+
warn,
|
|
527
|
+
`Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
|
|
515
528
|
);
|
|
516
529
|
line(
|
|
517
|
-
|
|
518
|
-
`
|
|
530
|
+
capabilities['merge-gate'] ? ok : bad,
|
|
531
|
+
`CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
|
|
532
|
+
);
|
|
533
|
+
line(
|
|
534
|
+
capabilities['repair-payload'] ? ok : warn,
|
|
535
|
+
`Repair payload at hard boundary: ${capabilities['repair-payload'] ? 'yes' : 'no'}`
|
|
519
536
|
);
|
|
520
537
|
if (writePath.gap) {
|
|
521
538
|
line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
|
|
@@ -527,7 +544,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
527
544
|
|
|
528
545
|
console.log('');
|
|
529
546
|
console.log(color.bold('Gates & skills'));
|
|
530
|
-
if (gatesMissing.length === 0) line(ok, '
|
|
547
|
+
if (gatesMissing.length === 0) line(ok, 'Shared gate files present (AGENTS.md, .mcp.json, CI)');
|
|
531
548
|
else {
|
|
532
549
|
line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
|
|
533
550
|
actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
|
|
@@ -570,10 +587,20 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
570
587
|
);
|
|
571
588
|
} else {
|
|
572
589
|
for (const gap of adoption.gaps) {
|
|
573
|
-
|
|
590
|
+
// Deferred Codex-home debt (non-temp) is annotated, not a top action, when the
|
|
591
|
+
// session host is not Codex — fix when that host is used.
|
|
592
|
+
const mark = gap.deferred
|
|
593
|
+
? color.dim('·')
|
|
594
|
+
: gap.severity === 'warn'
|
|
595
|
+
? warn
|
|
596
|
+
: gap.severity === 'info'
|
|
597
|
+
? warn
|
|
598
|
+
: bad;
|
|
574
599
|
line(mark, gap.message);
|
|
575
|
-
if (gap.fix)
|
|
576
|
-
|
|
600
|
+
if (gap.fix) {
|
|
601
|
+
line(' ', color.dim(gap.deferred ? `When using Codex: ${gap.fix}` : `Fix: ${gap.fix}`));
|
|
602
|
+
}
|
|
603
|
+
if (!gap.deferred) actions.push(gap.fix || gap.message);
|
|
577
604
|
}
|
|
578
605
|
if (adoption.layerBalance) {
|
|
579
606
|
line(warn, color.dim(adoption.layerBalance.educational));
|
|
@@ -601,7 +628,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
601
628
|
line(warn, 'Safety diagnostics unavailable');
|
|
602
629
|
} else {
|
|
603
630
|
const rows = [
|
|
604
|
-
['Non-literal dynamic
|
|
631
|
+
['Non-literal dynamic dependencies', safety.nonLiteralDynamicImports],
|
|
605
632
|
['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
|
|
606
633
|
['Explicit any casts', safety.anyCasts],
|
|
607
634
|
['InMemory stores in production source', safety.inMemoryProductionStores],
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static host support plus preflight validation for requested write guarantees.
|
|
3
|
+
* Installed evidence remains authoritative in write-path-capabilities.mjs.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { detectWritePathCapabilities } from './write-path-detect.mjs';
|
|
8
|
+
import {
|
|
9
|
+
HOST_SUPPORT_HOSTS,
|
|
10
|
+
HOST_SUPPORT_MATRIX,
|
|
11
|
+
} from './host-support-matrix.mjs';
|
|
12
|
+
import { KNOWN_TOOLS, normalizeToolsList } from './skill-install.mjs';
|
|
13
|
+
|
|
14
|
+
export const HOST_ENFORCEMENT_SUPPORT = Object.freeze(
|
|
15
|
+
Object.fromEntries(
|
|
16
|
+
HOST_SUPPORT_HOSTS.map((host) => {
|
|
17
|
+
const profile = HOST_SUPPORT_MATRIX[host];
|
|
18
|
+
return [
|
|
19
|
+
host,
|
|
20
|
+
Object.freeze({
|
|
21
|
+
hardWrite: profile.capabilities['hard-write'],
|
|
22
|
+
advisoryWrite: profile.capabilities['advisory-write'],
|
|
23
|
+
hookPath: profile.hookPath,
|
|
24
|
+
}),
|
|
25
|
+
];
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const WRITE_PROFILE_HOSTS = HOST_SUPPORT_HOSTS;
|
|
31
|
+
|
|
32
|
+
export function validateSelectedTools(tools) {
|
|
33
|
+
if (tools == null) return { ok: true, tools: null };
|
|
34
|
+
const selected = normalizeToolsList(tools);
|
|
35
|
+
const unknown = selected.filter((tool) => !KNOWN_TOOLS.includes(tool));
|
|
36
|
+
if (selected.length === 0 || unknown.length > 0) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error:
|
|
40
|
+
`--tools expects a comma-separated subset of: ${KNOWN_TOOLS.join(', ')}` +
|
|
41
|
+
(unknown.length > 0 ? ` (unknown: ${unknown.join(', ')})` : ''),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, tools: selected };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hasHardWriteHook(root, host) {
|
|
48
|
+
return detectWritePathCapabilities(root, host).capabilities['hard-write'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function validateHardWriteRequest({ root, host, tools, force = false }) {
|
|
52
|
+
const toolSelection = validateSelectedTools(tools);
|
|
53
|
+
if (!toolSelection.ok) return toolSelection;
|
|
54
|
+
if (host == null) return { ok: true, host: null, tools: toolSelection.tools };
|
|
55
|
+
|
|
56
|
+
const normalizedHost = String(host).trim().toLowerCase();
|
|
57
|
+
const support = HOST_ENFORCEMENT_SUPPORT[normalizedHost];
|
|
58
|
+
if (!support) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `Unknown write host "${normalizedHost}". Expected: ${WRITE_PROFILE_HOSTS.join(', ')}.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!support.hardWrite) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error:
|
|
68
|
+
`${normalizedHost} supports advisory-write plus the shared CI check, not a hard local write hook. ` +
|
|
69
|
+
'Omit --require-write-hook, keep --strict-merge in CI, and require that status to block merges.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const selectedTools = toolSelection.tools ?? [normalizedHost];
|
|
74
|
+
if (!selectedTools.includes(normalizedHost)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `--require-write-hook ${normalizedHost} requires --tools to include ${normalizedHost}.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hookFile = path.join(root, support.hookPath);
|
|
82
|
+
if (fs.existsSync(hookFile) && !force && !hasHardWriteHook(root, normalizedHost)) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error:
|
|
86
|
+
`${support.hookPath} already exists without an Ark hard-write hook and would be preserved. ` +
|
|
87
|
+
'Use --force to replace that host file, or omit --require-write-hook for merge-only enforcement.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
host: normalizedHost,
|
|
94
|
+
tools: selectedTools,
|
|
95
|
+
support,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical host support promises.
|
|
3
|
+
*
|
|
4
|
+
* These records describe what ArkGate can install for each supported host.
|
|
5
|
+
* Installed evidence remains authoritative for a specific repository and is
|
|
6
|
+
* reported separately by write-path-capabilities.mjs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function hostProfile(label, hookPath, hookSurface, hardWrite, repairPayload) {
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
label,
|
|
12
|
+
hookPath,
|
|
13
|
+
hookSurface,
|
|
14
|
+
capabilities: Object.freeze({
|
|
15
|
+
'hard-write': hardWrite,
|
|
16
|
+
'advisory-write': true,
|
|
17
|
+
'merge-gate': true,
|
|
18
|
+
'repair-payload': repairPayload,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const HOST_SUPPORT_MATRIX = Object.freeze({
|
|
24
|
+
claude: hostProfile(
|
|
25
|
+
'Claude Code',
|
|
26
|
+
'.claude/settings.json',
|
|
27
|
+
'PreToolUse `Write` / `Edit` / `MultiEdit`',
|
|
28
|
+
true,
|
|
29
|
+
true
|
|
30
|
+
),
|
|
31
|
+
grok: hostProfile(
|
|
32
|
+
'Grok Build',
|
|
33
|
+
'.grok/hooks/ark-write-gate.json',
|
|
34
|
+
'PreToolUse `write` / `search_replace` (plus aliases)',
|
|
35
|
+
true,
|
|
36
|
+
true
|
|
37
|
+
),
|
|
38
|
+
cursor: hostProfile('Cursor', null, null, false, false),
|
|
39
|
+
codex: hostProfile('OpenAI Codex', null, null, false, false),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const HOST_SUPPORT_HOSTS = Object.freeze(Object.keys(HOST_SUPPORT_MATRIX));
|
|
43
|
+
|
|
44
|
+
export function getHostSupportProfile(host) {
|
|
45
|
+
const normalized = typeof host === 'string' ? host.trim().toLowerCase() : '';
|
|
46
|
+
return HOST_SUPPORT_MATRIX[normalized] ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatHostSupportSummary(profile) {
|
|
50
|
+
if (!profile) return 'unknown host; no local write guarantee';
|
|
51
|
+
const capabilities = profile.capabilities;
|
|
52
|
+
const write = capabilities['hard-write']
|
|
53
|
+
? 'hard local write boundary'
|
|
54
|
+
: 'no hard local write boundary';
|
|
55
|
+
const repair = capabilities['repair-payload'] ? 'repair payload' : 'no hard-boundary repair';
|
|
56
|
+
return `${write} + advisory MCP + CI check + ${repair}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderHostSupportMatrixMarkdown() {
|
|
60
|
+
const rows = HOST_SUPPORT_HOSTS.map((host) => {
|
|
61
|
+
const profile = HOST_SUPPORT_MATRIX[host];
|
|
62
|
+
const capabilities = profile.capabilities;
|
|
63
|
+
const local = capabilities['hard-write']
|
|
64
|
+
? `Hard block for ${profile.hookSurface}`
|
|
65
|
+
: 'No hard hook; MCP/rules are advisory';
|
|
66
|
+
const repair = capabilities['repair-payload']
|
|
67
|
+
? 'Emitted on hook deny; host must re-inject'
|
|
68
|
+
: 'No hard-boundary payload';
|
|
69
|
+
return `| ${profile.label} | ${local} | Advisory; the agent must call it | Available \`arkgate-check --strict-merge\` check | ${repair} |`;
|
|
70
|
+
}).join('\n');
|
|
71
|
+
|
|
72
|
+
return `| Host | Local write boundary | MCP validation | CI / merge path | Repair payload |
|
|
73
|
+
|------|----------------------|----------------|-----------------|----------------|
|
|
74
|
+
${rows}
|
|
75
|
+
|
|
76
|
+
This table describes the supported profile **after its files are installed and the host loads/trusts them**. A hard local boundary covers only the listed hook operations; alternate tools, direct filesystem writes, and human edits still rely on CI. MCP validation is advisory because the agent must call it. The CI check blocks a merge only when the repository makes that status required. Repair payloads never write code silently: the host must re-inject the candidate and ArkGate revalidates it. Run \`arkgate-check --doctor\` for the evidence actually detected in the current repository.`;
|
|
77
|
+
}
|