arkgate 3.7.0 → 3.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +76 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-mcp-runtime.mjs +1976 -0
  6. package/bin/ark-mcp.mjs +84 -1495
  7. package/bin/ark-shared.mjs +34 -38
  8. package/bin/ark.mjs +33 -66
  9. package/bin/lib/adapter-contract.mjs +161 -9
  10. package/bin/lib/agent-gates.mjs +1 -0
  11. package/bin/lib/analysis-completeness.mjs +28 -0
  12. package/bin/lib/analysis-engine.mjs +8 -8
  13. package/bin/lib/analysis-policy.mjs +27 -0
  14. package/bin/lib/architecture-scan.mjs +70 -357
  15. package/bin/lib/auto-patch.mjs +76 -8
  16. package/bin/lib/ci-and-commands.mjs +1 -1
  17. package/bin/lib/codex-home.mjs +43 -16
  18. package/bin/lib/design-delta.mjs +4 -0
  19. package/bin/lib/doctor-advisories.mjs +4 -3
  20. package/bin/lib/doctor-plan.mjs +40 -41
  21. package/bin/lib/enforcement-state.mjs +2 -0
  22. package/bin/lib/github-enforcement.mjs +443 -0
  23. package/bin/lib/hook-templates.mjs +12 -148
  24. package/bin/lib/html-report-advisories.mjs +1 -1
  25. package/bin/lib/html-report-depth.mjs +9 -0
  26. package/bin/lib/html-report.mjs +5 -5
  27. package/bin/lib/install-migrate.mjs +83 -79
  28. package/bin/lib/managed-upgrade.mjs +622 -0
  29. package/bin/lib/mcp-adoption.mjs +3 -1
  30. package/bin/lib/parse-health.mjs +6 -5
  31. package/bin/lib/port-proof.mjs +2 -2
  32. package/bin/lib/prepare-change.mjs +68 -38
  33. package/bin/lib/prepare-write.mjs +7 -1
  34. package/bin/lib/resident-doctor-client.mjs +55 -0
  35. package/bin/lib/resident-hook.mjs +247 -0
  36. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  37. package/bin/lib/scan-files.mjs +19 -6
  38. package/bin/lib/snippet-analysis.mjs +119 -0
  39. package/bin/lib/source-policy.mjs +24 -0
  40. package/bin/lib/typescript-host.mjs +15 -18
  41. package/bin/lib/unavailable-analysis.mjs +76 -0
  42. package/bin/lib/upgrade-command.mjs +115 -0
  43. package/bin/lib/weakest-link.mjs +21 -179
  44. package/bin/lib/write-path-capabilities.mjs +167 -16
  45. package/bin/lib/write-path-detect.mjs +3 -2
  46. package/dist/eslint/index.cjs +3 -3
  47. package/dist/eslint/index.d.ts +3 -0
  48. package/dist/eslint/index.js +3 -3
  49. package/dist/index.cjs +7 -7
  50. package/dist/index.d.ts +1073 -141
  51. package/dist/index.js +7 -7
  52. package/docs/agent-guide.md +103 -59
  53. package/docs/ai-gates.md +97 -16
  54. package/docs/demos/01-write-gate-self-correction.md +2 -2
  55. package/docs/enthusiast/README.md +10 -10
  56. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  57. package/docs/enthusiast/reference-commands.md +18 -1
  58. package/docs/enthusiast/tutorial-first-project.md +2 -2
  59. package/docs/package-surface.md +98 -12
  60. package/docs/typescript-support.md +109 -37
  61. package/package.json +32 -4
  62. package/schemas/ark.analysis-result.schema.json +159 -2
  63. package/schemas/ark.design-delta.schema.json +1 -0
  64. package/schemas/ark.enforcement-state.schema.json +84 -0
  65. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  66. package/server.json +2 -2
  67. package/templates/skills/ark-explore.md +5 -5
  68. package/templates/skills/ark-fix.md +1 -1
  69. package/templates/skills/ark-runtime.md +15 -8
  70. package/templates/skills/ark-upgrade.md +122 -182
  71. package/bin/lib/ai-velocity.mjs +0 -293
  72. package/bin/lib/graph-cycles.mjs +0 -6
  73. package/bin/lib/safety-diagnostics.mjs +0 -284
  74. package/bin/lib/ts-resolve.mjs +0 -228
  75. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  76. package/dist/eslint/index.d.cts +0 -146
  77. package/dist/index.d.cts +0 -986
@@ -0,0 +1,622 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { codexPrimaryTable, upsertCodexMcpTable } from './codex-home.mjs';
6
+ import { buildManagedAssetCatalog } from './install-migrate.mjs';
7
+ import {
8
+ KNOWN_TOOLS,
9
+ detectActiveAgentHost,
10
+ normalizeToolsList,
11
+ } from './skill-install.mjs';
12
+
13
+ export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
14
+ const MANIFEST_VERSION = '1.0';
15
+ const AFTER_CONTENT = Symbol('managed-after-content');
16
+ // Exact normalized identities shipped by the last pre-manifest release. They are
17
+ // content proof, not version claims: any user edit changes the hash and is preserved.
18
+ const LEGACY_SKILL_IDENTITIES = new Set([
19
+ 'sha256:3686d7b03ff625b9e8e7a08eef9758785b8dd3ed4bc76ceee831cfcfcc121388',
20
+ 'sha256:46f9661f7a25113687084c8eeb6868d6a358959dd875c2177dc4676bf87a1eb9',
21
+ 'sha256:49fcf32420f5ab7c9a4dbd4bcd60d4977c780bfe370d6303d0040a699ebb29f8',
22
+ 'sha256:19bec67a229c9109e7dddf79e9fd19dd2d2868f1f8afd4a81e849233415a8aa1',
23
+ 'sha256:5d51060fd8d17d6cf66e2c3bca4b928dcc4aed3f4a5d456435f0db9f8f5e57bf',
24
+ 'sha256:bd6a3edc06010519329570d12bed22036e042b9193551a5b25204c3edd32f19a',
25
+ 'sha256:4ae6f271ad466ac76f892536390735442ee12f0fc49bcc9493b309cc7e0068c2',
26
+ 'sha256:9adbd4c2d0ae87340991b830df9d126459f02662ec32fab2c75325fef9a14033',
27
+ 'sha256:1648d292c781cbc1701435e9c06547ffae5fe40ac6eedb58bac69f960b91a7f5',
28
+ 'sha256:29589c6178bef6ef2e2f78fa989976935fa99892a9857aa05df0b849603ec195',
29
+ 'sha256:cdc5a7f6836aad55ae7d6aa965d9d1adf1add5f88551342f4fad8963dc4b0814',
30
+ 'sha256:e209ffc7a354916d9b54fcd05a74aadf9d72a6363db3b44354dd3d1a8d5f350e',
31
+ 'sha256:a5783e492e97fb82cac18bf934071dadeeb3d1ecca2d7bed02ce4328abc5de7d',
32
+ ]);
33
+
34
+ function hash(content) {
35
+ if (content == null) return null;
36
+ return `sha256:${createHash('sha256').update(content).digest('hex')}`;
37
+ }
38
+
39
+ function normalizedIdentityContent(content, kind) {
40
+ let text = String(content).replace(/\r\n/g, '\n');
41
+ if (kind !== 'skill') return text;
42
+ const lines = text.split('\n');
43
+ if (lines[0] !== '---') return text;
44
+ const end = lines.indexOf('---', 1);
45
+ if (end < 0) return text;
46
+ for (let index = 1; index < end; index += 1) {
47
+ if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+
52
+ export function managedContentIdentity(content, kind = 'gate') {
53
+ return hash(Buffer.from(normalizedIdentityContent(content, kind)));
54
+ }
55
+
56
+ function isSafeRelativePath(relativePath) {
57
+ if (typeof relativePath !== 'string' || relativePath.length === 0) return false;
58
+ if (path.isAbsolute(relativePath) || relativePath.includes('\0') || relativePath.includes('\\')) {
59
+ return false;
60
+ }
61
+ const normalized = path.posix.normalize(relativePath);
62
+ return normalized === relativePath && normalized !== '.' && !normalized.startsWith('../');
63
+ }
64
+
65
+ function assertSafeTarget(root, relativePath) {
66
+ if (!isSafeRelativePath(relativePath)) throw new Error(`unsafe managed path: ${relativePath}`);
67
+ const target = path.resolve(root, relativePath);
68
+ const rel = path.relative(path.resolve(root), target);
69
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
70
+ throw new Error(`managed path escapes project root: ${relativePath}`);
71
+ }
72
+ let cursor = path.resolve(root);
73
+ for (const segment of relativePath.split('/').slice(0, -1)) {
74
+ cursor = path.join(cursor, segment);
75
+ const stat = fs.lstatSync(cursor, { throwIfNoEntry: false });
76
+ if (stat?.isSymbolicLink()) throw new Error(`managed path crosses symlink: ${relativePath}`);
77
+ if (stat && !stat.isDirectory()) throw new Error(`managed path parent is not a directory: ${relativePath}`);
78
+ }
79
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
80
+ if (stat?.isSymbolicLink()) throw new Error(`managed path is a symlink: ${relativePath}`);
81
+ if (stat?.isDirectory()) throw new Error(`managed path is a directory: ${relativePath}`);
82
+ if (stat && !stat.isFile()) throw new Error(`managed path is not a regular file: ${relativePath}`);
83
+ if (stat?.isFile() && stat.nlink > 1) throw new Error(`managed path is hard-linked: ${relativePath}`);
84
+ return target;
85
+ }
86
+
87
+ function readFile(file) {
88
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
89
+ if (!stat) return null;
90
+ if (!stat.isFile()) throw new Error(`refusing non-regular managed read: ${file}`);
91
+ if (stat.nlink !== 1) throw new Error(`refusing hard-linked managed read: ${file}`);
92
+ try {
93
+ return fs.readFileSync(file);
94
+ } catch (error) {
95
+ if (error?.code === 'ENOENT') return null;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ function compactRouterHost(root) {
101
+ const content = readFile(path.join(root, 'AGENTS.md'));
102
+ return content?.toString('utf8').match(/<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/)?.[1] ?? null;
103
+ }
104
+
105
+ function hasArkText(root, relativePath) {
106
+ const content = readFile(path.join(root, relativePath));
107
+ return Boolean(content && /\bark(?:gate)?\b/i.test(content.toString('utf8')));
108
+ }
109
+
110
+ const HOST_SIGNALS = {
111
+ claude: ['.claude/settings.json', '.claude/skills/ark-upgrade/SKILL.md'],
112
+ cursor: ['.cursor/mcp.json', '.cursor/rules/ark.mdc', '.cursor/commands/ark-upgrade.md'],
113
+ codex: ['.codex/hooks.json', '.codex/config.toml', '.agents/skills/ark-upgrade/SKILL.md'],
114
+ grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json', '.grok/skills/ark-upgrade/SKILL.md'],
115
+ windsurf: ['.windsurf/rules/ark.md', '.windsurf/workflows/ark-upgrade.md'],
116
+ cline: ['.clinerules/ark.md', '.clinerules/workflows/ark-upgrade.md'],
117
+ copilot: ['.github/copilot-instructions.md', '.github/prompts/ark-upgrade.prompt.md'],
118
+ kiro: ['.kiro/steering/ark.md'],
119
+ roo: ['.roo/rules/ark.md'],
120
+ continue: ['.continue/rules/ark.md'],
121
+ gemini: ['GEMINI.md'],
122
+ };
123
+
124
+ function detectedManagedHosts(root) {
125
+ return KNOWN_TOOLS.filter((host) =>
126
+ (HOST_SIGNALS[host] ?? []).some((relativePath) => hasArkText(root, relativePath))
127
+ );
128
+ }
129
+
130
+ function normalizeHosts(hosts) {
131
+ const normalized = [...new Set(normalizeToolsList(hosts))].sort();
132
+ const invalid = normalized.filter((host) => !KNOWN_TOOLS.includes(host));
133
+ if (invalid.length > 0) throw new Error(`unknown managed host(s): ${invalid.join(', ')}`);
134
+ return normalized;
135
+ }
136
+
137
+ function allKnownPaths(root) {
138
+ return new Set(
139
+ buildManagedAssetCatalog({ root, tools: KNOWN_TOOLS, compact: false }).assets.map(
140
+ (asset) => asset.relativePath
141
+ )
142
+ );
143
+ }
144
+
145
+ function readManifest(root) {
146
+ const file = assertSafeTarget(root, MANAGED_MANIFEST_PATH);
147
+ const content = readFile(file);
148
+ if (!content) return { file, content: null, value: null };
149
+ let value;
150
+ try {
151
+ value = JSON.parse(content.toString('utf8'));
152
+ } catch {
153
+ throw new Error(`${MANAGED_MANIFEST_PATH} is not valid JSON`);
154
+ }
155
+ if (value?.schemaVersion !== MANIFEST_VERSION) {
156
+ throw new Error(`${MANAGED_MANIFEST_PATH} has an unsupported schemaVersion`);
157
+ }
158
+ if (!['compact', 'full'].includes(value.profile) || !Array.isArray(value.hosts) || !Array.isArray(value.assets)) {
159
+ throw new Error(`${MANAGED_MANIFEST_PATH} has an invalid shape`);
160
+ }
161
+ value.hosts = normalizeHosts(value.hosts);
162
+ const known = allKnownPaths(root);
163
+ const seen = new Set();
164
+ for (const asset of value.assets) {
165
+ if (
166
+ !asset ||
167
+ !isSafeRelativePath(asset.path) ||
168
+ !known.has(asset.path) ||
169
+ seen.has(asset.path) ||
170
+ typeof asset.templateId !== 'string' ||
171
+ !['whole-file', 'toml-section'].includes(asset.scope) ||
172
+ !/^sha256:[a-f0-9]{64}$/.test(asset.baseHash) ||
173
+ !/^sha256:[a-f0-9]{64}$/.test(asset.contentIdentity)
174
+ ) {
175
+ throw new Error(`${MANAGED_MANIFEST_PATH} contains an invalid asset identity`);
176
+ }
177
+ seen.add(asset.path);
178
+ }
179
+ value.assets.sort((left, right) => left.path.localeCompare(right.path));
180
+ return { file, content, value };
181
+ }
182
+
183
+ function resolveSelection(root, options, manifest) {
184
+ const explicit = normalizeToolsList(options.tools);
185
+ const compactHost = compactRouterHost(root);
186
+ let hosts;
187
+ if (explicit.length > 0) hosts = normalizeHosts(explicit);
188
+ else if (manifest) hosts = normalizeHosts(manifest.hosts);
189
+ else if (compactHost !== null) hosts = compactHost === 'none' ? [] : normalizeHosts([compactHost]);
190
+ else {
191
+ hosts = detectedManagedHosts(root);
192
+ if (hosts.length === 0) {
193
+ const active = detectActiveAgentHost();
194
+ if (active && KNOWN_TOOLS.includes(active)) hosts = [active];
195
+ }
196
+ }
197
+ const profile = options.profile ?? manifest?.profile ?? (compactHost !== null ? 'compact' : 'full');
198
+ if (!['compact', 'full'].includes(profile)) throw new Error(`unknown managed profile: ${profile}`);
199
+ if (profile === 'compact' && hosts.length > 1) {
200
+ throw new Error('compact managed profile accepts exactly one host');
201
+ }
202
+ return { hosts: [...hosts].sort(), profile };
203
+ }
204
+
205
+ function scopedContent(asset, fileContent) {
206
+ if (fileContent == null) return null;
207
+ if (asset.scope === 'whole-file') return fileContent.toString('utf8');
208
+ return codexPrimaryTable(fileContent.toString('utf8'))?.block ?? null;
209
+ }
210
+
211
+ function hasUnparsedCodexScope(asset, fileContent, scoped) {
212
+ if (asset.scope !== 'toml-section' || fileContent == null || scoped != null) return false;
213
+ const uncommented = fileContent
214
+ .toString('utf8')
215
+ .split('\n')
216
+ .filter((line) => !/^\s*#/.test(line))
217
+ .join('\n');
218
+ return /mcp_servers/.test(uncommented) && /\bark\b/.test(uncommented);
219
+ }
220
+
221
+ function targetContent(asset) {
222
+ if (asset.scope === 'whole-file') return asset.content;
223
+ const table = codexPrimaryTable(asset.content);
224
+ if (!table) throw new Error(`${asset.relativePath} template has no [mcp_servers.ark] section`);
225
+ return table.block;
226
+ }
227
+
228
+ function afterFileContent(asset, currentFile, desiredScoped) {
229
+ if (asset.scope === 'whole-file') return Buffer.from(desiredScoped);
230
+ return Buffer.from(upsertCodexMcpTable(currentFile?.toString('utf8') ?? '', 'ark', desiredScoped));
231
+ }
232
+
233
+ export function classifyManagedAsset({ recorded, currentContent, targetContent: desired, kind }) {
234
+ const targetIdentity = managedContentIdentity(desired, kind);
235
+ if (currentContent == null) {
236
+ return { state: 'missing', managed: true, requiresConsent: Boolean(recorded) };
237
+ }
238
+ const currentIdentity = managedContentIdentity(currentContent, kind);
239
+ if (currentIdentity === targetIdentity) {
240
+ return { state: 'current', managed: true, requiresConsent: false };
241
+ }
242
+ if (!recorded) {
243
+ if (kind === 'skill' && LEGACY_SKILL_IDENTITIES.has(currentIdentity)) {
244
+ return { state: 'stale', managed: true, requiresConsent: false };
245
+ }
246
+ return { state: 'customized', managed: false, requiresConsent: false };
247
+ }
248
+ if (currentIdentity === recorded.contentIdentity) {
249
+ return { state: 'stale', managed: true, requiresConsent: false };
250
+ }
251
+ if (targetIdentity === recorded.contentIdentity) {
252
+ return { state: 'customized', managed: true, requiresConsent: false };
253
+ }
254
+ return { state: 'conflicted', managed: true, requiresConsent: true };
255
+ }
256
+
257
+ function manifestEntry(asset, baseHash, contentIdentity) {
258
+ return {
259
+ path: asset.relativePath,
260
+ templateId: asset.templateId,
261
+ scope: asset.scope,
262
+ baseHash,
263
+ contentIdentity,
264
+ };
265
+ }
266
+
267
+ function serializeManifest(value) {
268
+ return `${JSON.stringify(value, null, 2)}\n`;
269
+ }
270
+
271
+ function summaryFor(assets, manifestChanged) {
272
+ const states = {};
273
+ for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
274
+ const fileChanges = assets.filter((asset) => asset.willApply).length;
275
+ return {
276
+ total: assets.length,
277
+ states,
278
+ fileChanges,
279
+ manifestChanged,
280
+ changed: fileChanges + (manifestChanged ? 1 : 0),
281
+ blocked: assets.filter((asset) => asset.blocked).length,
282
+ };
283
+ }
284
+
285
+ export function managedPlanDigest(plan) {
286
+ const payload = {
287
+ schemaVersion: plan.schemaVersion,
288
+ root: plan.root,
289
+ profile: plan.profile,
290
+ hosts: plan.hosts,
291
+ acceptConflicts: plan.acceptConflicts,
292
+ manifestBeforeHash: plan.manifestBeforeHash,
293
+ manifestAfterHash: hash(Buffer.from(plan.manifestContent)),
294
+ assets: plan.assets.map((asset) => ({
295
+ path: asset.path,
296
+ action: asset.action,
297
+ willApply: asset.willApply,
298
+ blocked: asset.blocked,
299
+ beforeHash: asset.beforeHash,
300
+ afterHash: asset.afterHash,
301
+ containerBeforeHash: asset.containerBeforeHash,
302
+ containerAfterHash: hash(asset[AFTER_CONTENT]),
303
+ })),
304
+ };
305
+ return hash(Buffer.from(JSON.stringify(payload)));
306
+ }
307
+
308
+ export function planManagedUpgrade(root, options = {}) {
309
+ const resolvedRoot = path.resolve(root);
310
+ const manifestRead = readManifest(resolvedRoot);
311
+ const selection = resolveSelection(resolvedRoot, options, manifestRead.value);
312
+ const catalog = buildManagedAssetCatalog({
313
+ root: resolvedRoot,
314
+ tools: selection.hosts,
315
+ compact: selection.profile === 'compact',
316
+ });
317
+ const recordedByPath = new Map(
318
+ (manifestRead.value?.assets ?? []).map((asset) => [asset.path, asset])
319
+ );
320
+ const assets = [];
321
+ const nextEntries = [];
322
+ const selectedPaths = new Set();
323
+
324
+ for (const catalogAsset of catalog.assets) {
325
+ selectedPaths.add(catalogAsset.relativePath);
326
+ const file = assertSafeTarget(resolvedRoot, catalogAsset.relativePath);
327
+ const currentFile = readFile(file);
328
+ const currentScoped = scopedContent(catalogAsset, currentFile);
329
+ const desiredScoped = targetContent(catalogAsset);
330
+ const recorded = recordedByPath.get(catalogAsset.relativePath) ?? null;
331
+ const unparsedScope = hasUnparsedCodexScope(catalogAsset, currentFile, currentScoped);
332
+ const classified = unparsedScope
333
+ ? { state: 'customized', managed: false, requiresConsent: false }
334
+ : classifyManagedAsset({
335
+ recorded,
336
+ currentContent: currentScoped,
337
+ targetContent: desiredScoped,
338
+ kind: catalogAsset.kind,
339
+ });
340
+ const accepted = options.acceptConflicts === true;
341
+ const refreshMetadata =
342
+ classified.state === 'current' &&
343
+ catalogAsset.kind === 'skill' &&
344
+ currentScoped !== desiredScoped;
345
+ const canApply =
346
+ refreshMetadata ||
347
+ classified.state === 'stale' ||
348
+ (classified.state === 'missing' && (!recorded || accepted)) ||
349
+ (classified.state === 'conflicted' && accepted);
350
+ const blocked = classified.requiresConsent && !accepted;
351
+ const desiredFile = afterFileContent(catalogAsset, currentFile, desiredScoped);
352
+ const asset = {
353
+ path: catalogAsset.relativePath,
354
+ templateId: catalogAsset.templateId,
355
+ kind: catalogAsset.kind,
356
+ scope: catalogAsset.scope,
357
+ ...classified,
358
+ ...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
359
+ action: refreshMetadata ? 'refresh-metadata' : canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
360
+ willApply: canApply,
361
+ blocked,
362
+ beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
363
+ afterHash: hash(Buffer.from(desiredScoped)),
364
+ containerBeforeHash: hash(currentFile),
365
+ [AFTER_CONTENT]: desiredFile,
366
+ };
367
+ assets.push(asset);
368
+
369
+ if (canApply || classified.state === 'current') {
370
+ const baseScoped = canApply ? desiredScoped : currentScoped;
371
+ nextEntries.push(
372
+ manifestEntry(
373
+ catalogAsset,
374
+ hash(Buffer.from(baseScoped)),
375
+ managedContentIdentity(baseScoped, catalogAsset.kind)
376
+ )
377
+ );
378
+ } else if (recorded) {
379
+ nextEntries.push(recorded);
380
+ }
381
+ }
382
+
383
+ for (const recorded of manifestRead.value?.assets ?? []) {
384
+ if (selectedPaths.has(recorded.path)) continue;
385
+ nextEntries.push(recorded);
386
+ assets.push({
387
+ path: recorded.path,
388
+ templateId: recorded.templateId,
389
+ scope: recorded.scope,
390
+ kind: 'retired',
391
+ state: 'retired',
392
+ managed: true,
393
+ requiresConsent: false,
394
+ action: 'none',
395
+ willApply: false,
396
+ blocked: false,
397
+ beforeHash: recorded.baseHash,
398
+ afterHash: null,
399
+ containerBeforeHash: null,
400
+ });
401
+ }
402
+
403
+ assets.sort((left, right) => left.path.localeCompare(right.path));
404
+ nextEntries.sort((left, right) => left.path.localeCompare(right.path));
405
+ const nextManifest = {
406
+ schemaVersion: MANIFEST_VERSION,
407
+ profile: selection.profile,
408
+ hosts: selection.hosts,
409
+ assets: nextEntries,
410
+ };
411
+ const manifestContent = serializeManifest(nextManifest);
412
+ const manifestChanged = manifestRead.content?.toString('utf8') !== manifestContent;
413
+ const summary = summaryFor(assets, manifestChanged);
414
+ const plan = {
415
+ schemaVersion: MANIFEST_VERSION,
416
+ root: resolvedRoot,
417
+ readOnly: true,
418
+ applied: false,
419
+ manifestPath: MANAGED_MANIFEST_PATH,
420
+ profile: selection.profile,
421
+ hosts: selection.hosts,
422
+ acceptConflicts: options.acceptConflicts === true,
423
+ assets,
424
+ summary,
425
+ nextManifest,
426
+ manifestBeforeHash: hash(manifestRead.content),
427
+ manifestContent,
428
+ };
429
+ plan.planDigest = managedPlanDigest(plan);
430
+ return plan;
431
+ }
432
+
433
+ function publicPlan(plan, overrides = {}) {
434
+ return {
435
+ schemaVersion: plan.schemaVersion,
436
+ root: plan.root,
437
+ readOnly: overrides.readOnly ?? plan.readOnly,
438
+ applied: overrides.applied ?? plan.applied,
439
+ manifestPath: plan.manifestPath,
440
+ planDigest: plan.planDigest,
441
+ profile: plan.profile,
442
+ hosts: plan.hosts,
443
+ acceptConflicts: plan.acceptConflicts,
444
+ assets: plan.assets.map(
445
+ ({ containerBeforeHash: _container, [AFTER_CONTENT]: _content, ...asset }) => asset
446
+ ),
447
+ summary: plan.summary,
448
+ ...overrides,
449
+ };
450
+ }
451
+
452
+ function ensureParentDirectories(root, relativePath, createdDirectories) {
453
+ let directory = path.resolve(root);
454
+ for (const segment of relativePath.split('/').slice(0, -1)) {
455
+ directory = path.join(directory, segment);
456
+ if (!fs.lstatSync(directory, { throwIfNoEntry: false })) {
457
+ fs.mkdirSync(directory);
458
+ createdDirectories.push(directory);
459
+ }
460
+ // Re-check after every creation so a swapped parent cannot redirect the
461
+ // next mkdir (or the staged bytes) outside the project root.
462
+ assertSafeTarget(root, relativePath);
463
+ }
464
+ }
465
+
466
+ let temporarySequence = 0;
467
+ function stageFile(file, content, mode = 0o644) {
468
+ const temporary = `${file}.arkgate-${process.pid}-${temporarySequence += 1}.tmp`;
469
+ const descriptor = fs.openSync(
470
+ temporary,
471
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
472
+ mode
473
+ );
474
+ try {
475
+ fs.writeFileSync(descriptor, content);
476
+ fs.fsyncSync(descriptor);
477
+ } catch (error) {
478
+ fs.closeSync(descriptor);
479
+ fs.rmSync(temporary, { force: true });
480
+ throw error;
481
+ } finally {
482
+ try {
483
+ fs.closeSync(descriptor);
484
+ } catch {
485
+ // The failure path already closed it before removing the incomplete stage.
486
+ }
487
+ }
488
+ return temporary;
489
+ }
490
+
491
+ function assertAssetUnchanged(root, asset) {
492
+ const file = assertSafeTarget(root, asset.path);
493
+ const currentFile = readFile(file);
494
+ const currentScoped = asset.willApply ? null : scopedContent(asset, currentFile);
495
+ const observedHash = asset.willApply
496
+ ? hash(currentFile)
497
+ : hash(currentScoped == null ? null : Buffer.from(currentScoped));
498
+ const expectedHash = asset.willApply ? asset.containerBeforeHash : asset.beforeHash;
499
+ if (observedHash !== expectedHash) {
500
+ throw new Error(`refusing stale managed upgrade plan: ${asset.path} changed after preview`);
501
+ }
502
+ }
503
+
504
+ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
505
+ const resolvedRoot = path.resolve(root);
506
+ if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
507
+ if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
508
+ if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
509
+ throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
510
+ }
511
+
512
+ const writes = plan.assets.filter((asset) => asset.willApply);
513
+ const adopted = plan.assets.filter((asset) => asset.state === 'current' && !asset.willApply);
514
+ for (const asset of [...writes, ...adopted]) assertAssetUnchanged(resolvedRoot, asset);
515
+ const manifestFile = assertSafeTarget(resolvedRoot, MANAGED_MANIFEST_PATH);
516
+ if (hash(readFile(manifestFile)) !== plan.manifestBeforeHash) {
517
+ throw new Error(`refusing stale managed upgrade plan: ${MANAGED_MANIFEST_PATH} changed after preview`);
518
+ }
519
+
520
+ const createdDirectories = [];
521
+ const operations = writes.map((asset) => ({
522
+ file: path.join(resolvedRoot, asset.path),
523
+ content: asset[AFTER_CONTENT],
524
+ expectedHash: asset.containerBeforeHash,
525
+ label: asset.path,
526
+ }));
527
+ if (plan.summary.manifestChanged) {
528
+ operations.push({
529
+ file: manifestFile,
530
+ content: plan.manifestContent,
531
+ expectedHash: plan.manifestBeforeHash,
532
+ label: MANAGED_MANIFEST_PATH,
533
+ manifest: true,
534
+ });
535
+ }
536
+ const staged = [];
537
+ const committed = [];
538
+ try {
539
+ for (const operation of operations) {
540
+ assertSafeTarget(resolvedRoot, operation.label);
541
+ ensureParentDirectories(resolvedRoot, operation.label, createdDirectories);
542
+ assertSafeTarget(resolvedRoot, operation.label);
543
+ const previous = readFile(operation.file);
544
+ if (hash(previous) !== operation.expectedHash) {
545
+ throw new Error(`refusing stale managed upgrade plan: ${operation.label} changed after preview`);
546
+ }
547
+ const mode = fs.lstatSync(operation.file, { throwIfNoEntry: false })?.mode ?? 0o644;
548
+ operation.previous = previous;
549
+ operation.mode = mode;
550
+ operation.temporary = stageFile(operation.file, operation.content, mode);
551
+ staged.push(operation);
552
+ }
553
+ for (const operation of operations) {
554
+ assertSafeTarget(resolvedRoot, operation.label);
555
+ if (operation.manifest) {
556
+ for (const asset of adopted) assertAssetUnchanged(resolvedRoot, asset);
557
+ }
558
+ if (hash(readFile(operation.file)) !== operation.expectedHash) {
559
+ throw new Error(`refusing stale managed upgrade plan: ${operation.label} changed after preview`);
560
+ }
561
+ fs.renameSync(operation.temporary, operation.file);
562
+ operation.temporary = null;
563
+ committed.push(operation);
564
+ }
565
+ } catch (error) {
566
+ const rollbackConflicts = [];
567
+ for (const operation of committed.reverse()) {
568
+ let unchanged = false;
569
+ try {
570
+ assertSafeTarget(resolvedRoot, operation.label);
571
+ unchanged = hash(readFile(operation.file)) === hash(operation.content);
572
+ } catch {
573
+ unchanged = false;
574
+ }
575
+ if (!unchanged) rollbackConflicts.push(operation.label);
576
+ else if (operation.previous == null) fs.rmSync(operation.file, { force: true });
577
+ else {
578
+ const rollback = stageFile(operation.file, operation.previous, operation.mode);
579
+ fs.renameSync(rollback, operation.file);
580
+ }
581
+ }
582
+ for (const directory of createdDirectories.reverse()) {
583
+ try {
584
+ fs.rmdirSync(directory);
585
+ } catch {
586
+ // A non-empty pre-existing sibling means the directory must remain.
587
+ }
588
+ }
589
+ if (rollbackConflicts.length > 0) {
590
+ throw new Error(
591
+ `${error instanceof Error ? error.message : String(error)}; rollback preserved concurrent changes in: ${rollbackConflicts.join(', ')}`,
592
+ { cause: error }
593
+ );
594
+ }
595
+ throw error;
596
+ } finally {
597
+ for (const operation of staged) {
598
+ if (operation.temporary) fs.rmSync(operation.temporary, { force: true });
599
+ }
600
+ }
601
+ return publicPlan(plan, { readOnly: false, applied: true, blocked: false });
602
+ }
603
+
604
+ export function managedUpgradeJson(plan, overrides = {}) {
605
+ return JSON.stringify(publicPlan(plan, overrides), null, 2);
606
+ }
607
+
608
+ export function renderManagedUpgrade(plan, options = {}) {
609
+ console.log(
610
+ plan.applied
611
+ ? 'Ark managed upgrade applied.'
612
+ : 'Ark managed upgrade preview — no project files were changed.'
613
+ );
614
+ console.log(`Profile: ${plan.profile}; hosts: ${plan.hosts.join(', ') || 'shared only'}.`);
615
+ for (const asset of plan.assets) {
616
+ const consent = asset.requiresConsent ? ' (consent required)' : '';
617
+ console.log(` ${asset.state.padEnd(10)} ${asset.path}${consent}`);
618
+ }
619
+ const label = plan.applied ? 'Applied changes' : 'Planned changes';
620
+ console.log(`${label}: ${plan.summary.changed}; blocked conflicts/deletions: ${plan.summary.blocked}.`);
621
+ if (!plan.applied) console.log(options.next ?? 'Apply the exact preview with: ark upgrade --apply --no-install');
622
+ }
@@ -16,6 +16,7 @@ import { detectWritePathCapabilities } from './write-path-detect.mjs';
16
16
  import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
17
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
18
18
  import { collectWeakestLinkGaps } from './weakest-link.mjs';
19
+ import { withCiProviderEvidence } from './enforcement-state.mjs';
19
20
 
20
21
  export { detectDeployPathQuality };
21
22
 
@@ -92,7 +93,7 @@ export function collectAdoptionGaps(root, config, coverage) {
92
93
  const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
93
94
 
94
95
  // --- Write path: prepare-write / autoPatch / reject-only (W5) ---
95
- const writePath = detectWritePathCapabilities(root);
96
+ let writePath = detectWritePathCapabilities(root);
96
97
  // Only surface write-path gaps when the project has adopted gates (or has partial install).
97
98
  // Producer package tree always has templates — still report capability for dogfood honesty.
98
99
  if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
@@ -450,6 +451,7 @@ export function collectAdoptionGaps(root, config, coverage) {
450
451
  isProducer,
451
452
  includeGithub,
452
453
  });
454
+ writePath = withCiProviderEvidence(writePath, weakest.github);
453
455
  for (const g of weakest.gaps) {
454
456
  gaps.push(g);
455
457
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Y03 — parse honesty over the ASTs already created by architecture-scan.
3
- * Advisory only: counts are transported, never raw TypeScript diagnostics.
2
+ * Y03/Z02 — parse honesty over the ASTs already created by architecture-scan.
3
+ * Counts are transported without raw TypeScript diagnostics; Z02 maps affected
4
+ * governed files to a partial analysis verdict.
4
5
  */
5
6
 
6
7
  export const PARSE_HEALTH_FILE_CAP = 12;
@@ -55,11 +56,11 @@ export function summarizeParseHealth(scanned) {
55
56
  };
56
57
  }
57
58
 
58
- /** Human doctor section; clean parse health stays quiet. */
59
+ /** Human doctor detail; clean parse health stays quiet. */
59
60
  export function printParseHealthSection(health, io) {
60
61
  if (!health || health.affectedFiles === 0) return;
61
62
  console.log('');
62
- console.log(io.color.bold('Parse health (advisory)'));
63
+ console.log(io.color.bold('Parse health (analysis completeness)'));
63
64
  io.line(
64
65
  io.warn,
65
66
  `${health.affectedFiles} governed file(s) carry ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} scanned file(s).`
@@ -70,5 +71,5 @@ export function printParseHealthSection(health, io) {
70
71
  if (health.truncated > 0) {
71
72
  io.line(' ', io.color.dim(`…(+${health.truncated} more affected file(s); list capped)`));
72
73
  }
73
- io.line(' ', io.color.dim('advisory onlythe gate verdict, design fitness, and pattern bets are unchanged'));
74
+ io.line(' ', io.color.dim('analysis incompleteplan cannot be satisfied and strict merge fails closed'));
74
75
  }
@@ -26,14 +26,14 @@ import path from 'node:path';
26
26
  /**
27
27
  * @param {object} ts typescript module
28
28
  * @param {string} source
29
- * @param {{ filePath?: string, importLocalName?: string, importSpecifier?: string }} [opts]
29
+ * @param {{ filePath?: string, importLocalName?: string, importSpecifier?: string, sourceFile?: object }} [opts]
30
30
  * @returns {{ eligible: boolean, reason?: string, bindingName?: string, methods?: string[], functionNames?: string[], specifier?: string }}
31
31
  */
32
32
  export function provePortProofInject(ts, source, opts = {}) {
33
33
  if (!ts || typeof source !== 'string') {
34
34
  return { eligible: false, reason: 'missing-ts-or-source' };
35
35
  }
36
- const sf = ts.createSourceFile(
36
+ const sf = opts.sourceFile ?? ts.createSourceFile(
37
37
  opts.filePath || 'file.ts',
38
38
  source,
39
39
  ts.ScriptTarget.Latest,