arkgate 2.11.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +70 -41
  3. package/bin/ark-check.mjs +95 -36
  4. package/bin/ark-mcp.mjs +11 -5
  5. package/bin/ark-shared.mjs +88 -56
  6. package/bin/ark.mjs +97 -29
  7. package/bin/lib/agent-gates.mjs +79 -2093
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +392 -0
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/deploy-path.mjs +205 -0
  13. package/bin/lib/doctor-plan.mjs +43 -16
  14. package/bin/lib/enforcement-profiles.mjs +97 -0
  15. package/bin/lib/gate-files.mjs +223 -0
  16. package/bin/lib/hook-templates.mjs +99 -0
  17. package/bin/lib/host-support-matrix.mjs +77 -0
  18. package/bin/lib/install-migrate.mjs +473 -0
  19. package/bin/lib/mcp-adoption.mjs +455 -0
  20. package/bin/lib/open-html.mjs +75 -0
  21. package/bin/lib/presets.mjs +6 -2
  22. package/bin/lib/safety-diagnostics.mjs +31 -11
  23. package/bin/lib/skill-install.mjs +323 -0
  24. package/bin/lib/ts-resolve.mjs +2 -1
  25. package/bin/lib/typescript-host.mjs +88 -0
  26. package/bin/lib/weakest-link.mjs +417 -0
  27. package/bin/lib/write-path-capabilities.mjs +182 -0
  28. package/bin/lib/write-path-detect.mjs +101 -0
  29. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  30. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  31. package/dist/eslint/index.cjs +375 -13
  32. package/dist/eslint/index.cjs.map +1 -1
  33. package/dist/eslint/index.d.cts +30 -20
  34. package/dist/eslint/index.d.ts +30 -20
  35. package/dist/eslint/index.js +375 -13
  36. package/dist/eslint/index.js.map +1 -1
  37. package/dist/index.cjs +723 -61
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +95 -5
  40. package/dist/index.d.ts +95 -5
  41. package/dist/index.js +716 -61
  42. package/dist/index.js.map +1 -1
  43. package/dist/nestjs/index.cjs +150 -42
  44. package/dist/nestjs/index.cjs.map +1 -1
  45. package/dist/nestjs/index.d.cts +2 -1
  46. package/dist/nestjs/index.d.ts +2 -1
  47. package/dist/nestjs/index.js +150 -42
  48. package/dist/nestjs/index.js.map +1 -1
  49. package/dist/runtime/index.cjs +723 -61
  50. package/dist/runtime/index.cjs.map +1 -1
  51. package/dist/runtime/index.d.cts +3 -2
  52. package/dist/runtime/index.d.ts +3 -2
  53. package/dist/runtime/index.js +716 -61
  54. package/dist/runtime/index.js.map +1 -1
  55. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  56. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  57. package/docs/agent-guide.md +43 -21
  58. package/docs/ai-gates.md +53 -18
  59. package/docs/configuration.md +97 -0
  60. package/docs/enthusiast/README.md +3 -3
  61. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  62. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  63. package/docs/package-surface.md +22 -10
  64. package/docs/production-hardening.md +15 -2
  65. package/docs/threat-model.md +65 -0
  66. package/docs/typescript-support.md +3 -3
  67. package/package.json +15 -2
  68. package/schemas/ark.config.schema.json +750 -0
  69. package/server.json +2 -2
  70. package/templates/hooks/pre-commit-ark +37 -0
  71. package/templates/skills/ark-autopilot.md +77 -45
  72. package/templates/skills/ark-coverage.md +2 -2
  73. package/templates/skills/ark-explain.md +2 -1
  74. package/templates/skills/ark-explore.md +135 -34
  75. package/templates/skills/ark-runtime.md +8 -5
  76. package/templates/skills/ark-upgrade.md +36 -16
  77. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tool detection, skill templates, stamping, and skill freshness gaps.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { codexPromptsDir } from './codex-home.mjs';
7
+ import { __packageRoot, readJson } from './gate-files.mjs';
8
+
9
+ export function normalizeToolsList(tools) {
10
+ if (tools == null) return [];
11
+ if (Array.isArray(tools)) {
12
+ return tools
13
+ .flatMap((t) => String(t).split(','))
14
+ .map((t) => t.trim().toLowerCase())
15
+ .filter(Boolean);
16
+ }
17
+ if (typeof tools === 'string') {
18
+ return tools
19
+ .split(',')
20
+ .map((t) => t.trim().toLowerCase())
21
+ .filter(Boolean);
22
+ }
23
+ return [];
24
+ }
25
+
26
+ function envTruthy(v) {
27
+ if (v == null || v === '') return false;
28
+ const s = String(v).trim().toLowerCase();
29
+ return s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
30
+ }
31
+
32
+ /**
33
+ * Best-effort active agent host for this process (session host).
34
+ * Prefer ARK_ACTIVE_HOST when set. Do NOT treat CODEX_HOME alone as Codex —
35
+ * that dir exists for anyone who installed Codex, even when running Grok/Claude.
36
+ *
37
+ * @param {NodeJS.ProcessEnv} [env]
38
+ * @returns {string|null} tool id (claude|cursor|codex|grok|…) or null if unknown
39
+ */
40
+ export function detectActiveAgentHost(env = process.env) {
41
+ const explicit = String(env.ARK_ACTIVE_HOST || '')
42
+ .trim()
43
+ .toLowerCase();
44
+ if (explicit) return explicit;
45
+
46
+ // Grok / xAI Build
47
+ if (
48
+ envTruthy(env.GROK_BUILD) ||
49
+ envTruthy(env.XAI_GROK) ||
50
+ env.GROK_WORKSPACE_ROOT ||
51
+ env.GROK_SESSION_ID
52
+ ) {
53
+ return 'grok';
54
+ }
55
+ // Claude Code
56
+ if (
57
+ env.CLAUDE_PROJECT_DIR ||
58
+ envTruthy(env.CLAUDE_CODE) ||
59
+ envTruthy(env.CLAUDECODE) ||
60
+ env.CLAUDE_CODE_ENTRYPOINT
61
+ ) {
62
+ return 'claude';
63
+ }
64
+ // Cursor agent
65
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT || envTruthy(env.CURSOR_AGENT_CLI)) {
66
+ return 'cursor';
67
+ }
68
+ // Codex session — never CODEX_HOME alone (see above)
69
+ if (
70
+ envTruthy(env.CODEX_SANDBOX) ||
71
+ env.CODEX_THREAD_ID ||
72
+ envTruthy(env.CODEX_CI) ||
73
+ env.CODEX_SESSION_ID
74
+ ) {
75
+ return 'codex';
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * True when Codex home / MCP / prompts should be treated as an urgent concern
82
+ * for this process. Non-Codex hosts (Grok, Claude, Cursor, …) defer Codex debt.
83
+ *
84
+ * @param {NodeJS.ProcessEnv} [env]
85
+ */
86
+ export function codexConcernIsActive(env = process.env) {
87
+ return detectActiveAgentHost(env) === 'codex';
88
+ }
89
+
90
+ export function resolveTools(args) {
91
+ const explicit = normalizeToolsList(args.tools);
92
+ if (explicit.length > 0) {
93
+ return { tools: new Set(explicit), source: 'explicit' };
94
+ }
95
+ const root = args.root;
96
+ const detected = new Set();
97
+ if (fs.existsSync(path.join(root, '.claude'))) detected.add('claude');
98
+ if (fs.existsSync(path.join(root, '.cursor'))) detected.add('cursor');
99
+ if (fs.existsSync(path.join(root, '.codex'))) detected.add('codex');
100
+ if (fs.existsSync(path.join(root, '.grok'))) detected.add('grok');
101
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.add('windsurf');
102
+ // .clinerules can also be a single FILE (older Cline convention); only a directory
103
+ // can receive .clinerules/ark.md, so a file must not trigger detection.
104
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
105
+ detected.add('cline');
106
+ }
107
+ if (fs.existsSync(path.join(root, '.kiro'))) detected.add('kiro');
108
+ if (fs.existsSync(path.join(root, '.roo'))) detected.add('roo');
109
+ if (fs.existsSync(path.join(root, '.continue'))) detected.add('continue');
110
+ if (fs.existsSync(path.join(root, '.gemini'))) detected.add('gemini');
111
+ // copilot has no reliable directory signal (.github exists in most repos),
112
+ // so it is explicit-only via --tools.
113
+ // Host signals: Grok Build / xAI agents often have no project `.grok/` yet but
114
+ // set an env marker (or run with GROK_*). Include Grok so skills install there.
115
+ if (
116
+ process.env.GROK_BUILD === '1' ||
117
+ process.env.GROK_BUILD === 'true' ||
118
+ process.env.XAI_GROK === '1' ||
119
+ process.env.XAI_GROK === 'true'
120
+ ) {
121
+ detected.add('grok');
122
+ }
123
+ // No signal at all: fall back to a complete starter set including Grok (field
124
+ // log: default claude+cursor+codex silently omitted Grok skills for Grok hosts).
125
+ if (detected.size === 0) {
126
+ return { tools: new Set(['claude', 'cursor', 'codex', 'grok']), source: 'default' };
127
+ }
128
+ return { tools: detected, source: 'detected' };
129
+ }
130
+
131
+ export const KNOWN_TOOLS = [
132
+ 'claude',
133
+ 'cursor',
134
+ 'codex',
135
+ 'grok',
136
+ 'windsurf',
137
+ 'cline',
138
+ 'copilot',
139
+ 'kiro',
140
+ 'roo',
141
+ 'continue',
142
+ 'gemini',
143
+ ];
144
+
145
+ // One canonical markdown per skill (templates/skills/*.md, shipped in the npm
146
+ // package); installed into each tool's slash-command location. The YAML
147
+ // frontmatter (name/description) is understood or harmlessly ignored by every
148
+ // host. Kiro has no command mechanism — its steering rule file is the only gate.
149
+ export const SKILL_TOOL_TARGETS = {
150
+ claude: (name) => `.claude/skills/${name}/SKILL.md`,
151
+ cursor: (name) => `.cursor/commands/${name}.md`,
152
+ codex: (name) => `.codex/prompts/${name}.md`,
153
+ // Grok Build: project skills at .grok/skills/<name>/SKILL.md (slash-invocable).
154
+ grok: (name) => `.grok/skills/${name}/SKILL.md`,
155
+ windsurf: (name) => `.windsurf/workflows/${name}.md`,
156
+ cline: (name) => `.clinerules/workflows/${name}.md`,
157
+ copilot: (name) => `.github/prompts/${name}.prompt.md`,
158
+ };
159
+
160
+ // The version of the arkgate package these bins ship with. Used to
161
+ // stamp installed skills so a normal ark-check can tell "outdated skill from an
162
+ // older Ark" apart from "user-customized skill" — the stamp moves with the
163
+ // package, editing the body doesn't.
164
+ export function arkPackageVersion() {
165
+ try {
166
+ const pkg = readJson(path.join(__packageRoot, 'package.json'));
167
+ return typeof pkg.version === 'string' ? pkg.version : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ // Insert `arkVersion: <v>` into a skill's YAML frontmatter (before its closing
174
+ // `---`). No frontmatter → returned unchanged. Idempotent for a given version.
175
+ export function stampSkill(content, version) {
176
+ if (!version) return content;
177
+ const lines = content.split('\n');
178
+ if (lines[0] !== '---') return content;
179
+ const closeIdx = lines.indexOf('---', 1);
180
+ if (closeIdx === -1) return content;
181
+ const existing = lines.findIndex(
182
+ (line, i) => i > 0 && i < closeIdx && /^arkVersion:/.test(line)
183
+ );
184
+ if (existing !== -1) {
185
+ lines[existing] = `arkVersion: ${version}`;
186
+ } else {
187
+ lines.splice(closeIdx, 0, `arkVersion: ${version}`);
188
+ }
189
+ return lines.join('\n');
190
+ }
191
+
192
+ // Read the `arkVersion:` stamp from an installed skill file. Returns null when
193
+ // the file is absent or has no stamp (installed by a pre-stamp Ark, or hand-authored).
194
+ export function installedSkillVersion(filePath) {
195
+ let content;
196
+ try {
197
+ content = fs.readFileSync(filePath, 'utf8');
198
+ } catch {
199
+ return null;
200
+ }
201
+ const match = content.match(/^arkVersion:\s*(.+)$/m);
202
+ return match ? match[1].trim() : null;
203
+ }
204
+
205
+ // Numeric-tuple compare of dotted versions; true when `a` is strictly older than
206
+ // `b`. Non-numeric/absent segments compare as 0, so "1.7" < "1.7.5".
207
+ export function isVersionOlder(a, b) {
208
+ const parse = (v) => String(v).split('.').map((n) => Number.parseInt(n, 10) || 0);
209
+ const av = parse(a);
210
+ const bv = parse(b);
211
+ const len = Math.max(av.length, bv.length);
212
+ for (let i = 0; i < len; i += 1) {
213
+ const x = av[i] ?? 0;
214
+ const y = bv[i] ?? 0;
215
+ if (x !== y) return x < y;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ export function skillTemplates() {
221
+ const dir = path.join(__packageRoot, 'templates', 'skills');
222
+ // A missing/mispackaged templates dir would otherwise install zero skills with
223
+ // exit 0 — warn so a packaging regression (e.g. "templates" dropped from the
224
+ // package.json files array) is visible instead of a silent no-op.
225
+ let entries;
226
+ try {
227
+ entries = fs.readdirSync(dir, { withFileTypes: true });
228
+ } catch {
229
+ console.error(
230
+ `Warning: skill templates directory not found (${dir}); no /ark-* skills installed.`
231
+ );
232
+ return [];
233
+ }
234
+ return entries
235
+ .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
236
+ .map((entry) => entry.name)
237
+ .sort()
238
+ .map((name) => [path.basename(name, '.md'), fs.readFileSync(path.join(dir, name), 'utf8')]);
239
+ }
240
+
241
+ // Skill names only, silent on a missing templates dir — for the freshness
242
+ // advisory below, which must not print packaging warnings on every check run.
243
+ export function skillTemplateNames() {
244
+ const dir = path.join(__packageRoot, 'templates', 'skills');
245
+ let entries;
246
+ try {
247
+ entries = fs.readdirSync(dir, { withFileTypes: true });
248
+ } catch {
249
+ return [];
250
+ }
251
+ return entries
252
+ .filter((entry) => entry.isFile() && /^[a-z0-9-]+\.md$/.test(entry.name))
253
+ .map((entry) => path.basename(entry.name, '.md'));
254
+ }
255
+
256
+ // A normal ark-check run is the reliable discovery point for new /ark-* skills.
257
+ // Ark ships no install lifecycle script (a postinstall banner would be blocked by
258
+ // modern package managers' script-approval policy anyway, so careful users never
259
+ // saw it — and it broke hardened installs). When a project has adopted Ark agent
260
+ // gates (AGENTS.md present) but a detected tool is missing
261
+ // skills this version ships, surface it here so agents and CI actually notice.
262
+ // Advisory only — never affects the exit code. Copilot has no reliable directory
263
+ // signal, so it is not auto-detected (explicit --tools only), matching resolveTools.
264
+ export function detectCodexHomeGap(root) {
265
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return null;
266
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return null;
267
+ const skillNames = skillTemplateNames();
268
+ if (skillNames.length === 0) return null;
269
+ const dir = codexPromptsDir();
270
+ if (!fs.existsSync(dir)) return null;
271
+ const present = skillNames.filter((name) => fs.existsSync(path.join(dir, `${name}.md`)));
272
+ if (present.length === 0) return null; // Codex home never set up for Ark — don't nag.
273
+ const version = arkPackageVersion();
274
+ const missing = skillNames.length - present.length;
275
+ let stale = 0;
276
+ if (version) {
277
+ for (const name of present) {
278
+ const installed = installedSkillVersion(path.join(dir, `${name}.md`));
279
+ if (installed === null || isVersionOlder(installed, version)) stale += 1;
280
+ }
281
+ }
282
+ return missing > 0 || stale > 0 ? { missing, stale } : null;
283
+ }
284
+
285
+ export function detectSkillGaps(root) {
286
+ if (!fs.existsSync(path.join(root, 'AGENTS.md'))) return [];
287
+ // The Ark source tree keeps the skill templates at templates/skills/ — it's the
288
+ // producer, not a consumer, so it must not nag itself to "install" its own skills.
289
+ if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
290
+ const skillNames = skillTemplateNames();
291
+ if (skillNames.length === 0) return [];
292
+ const detected = [];
293
+ if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
294
+ if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
295
+ if (fs.existsSync(path.join(root, '.codex'))) detected.push('codex');
296
+ if (fs.existsSync(path.join(root, '.grok'))) detected.push('grok');
297
+ if (fs.existsSync(path.join(root, '.windsurf'))) detected.push('windsurf');
298
+ if (fs.statSync(path.join(root, '.clinerules'), { throwIfNoEntry: false })?.isDirectory()) {
299
+ detected.push('cline');
300
+ }
301
+ const version = arkPackageVersion();
302
+ const gaps = [];
303
+ for (const tool of detected) {
304
+ const target = SKILL_TOOL_TARGETS[tool];
305
+ if (!target) continue;
306
+ let missing = 0;
307
+ let stale = 0;
308
+ for (const name of skillNames) {
309
+ const file = path.join(root, target(name));
310
+ if (!fs.existsSync(file)) {
311
+ missing += 1;
312
+ } else if (version) {
313
+ // An installed skill with no stamp predates stamping (older Ark), or one
314
+ // stamped behind the current version is left over from an older install.
315
+ // Either way the shipped skill has moved on — offer a --force refresh.
316
+ const installed = installedSkillVersion(file);
317
+ if (installed === null || isVersionOlder(installed, version)) stale += 1;
318
+ }
319
+ }
320
+ if (missing > 0 || stale > 0) gaps.push({ tool, missing, stale });
321
+ }
322
+ return gaps;
323
+ }
@@ -128,9 +128,10 @@ export function scanCacheKey(root, args) {
128
128
  // warm cache from an older Ark can't feed stale entries to new logic. v2: typeOnly on edges.
129
129
  // v3: per-file exportsOnlyTypes. v4: typeOnlyExportNames + namedBindings.
130
130
  // v5: hasTopLevelSideEffects. v6: non-exported impure inits + non-export class statics.
131
+ // v7: scope-aware forbidden globals + import-equals dependency edges.
131
132
  return crypto
132
133
  .createHash('sha1')
133
- .update(`ark-check-cache-v6\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
134
+ .update(`ark-check-cache-v7\0${read(configPath)}\0${manifestPath ? read(manifestPath) : ''}`)
134
135
  .digest('hex');
135
136
  }
136
137
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * TypeScript host resolution for architecture scan (API-compatible loader).
3
+ */
4
+ import path from 'node:path';
5
+ import { usableTypescript, typescriptUsabilityHint } from '../ark-shared.mjs';
6
+ import { __arkCheckCli } from './gate-files.mjs';
7
+
8
+ export async function loadTypeScript(root) {
9
+ const { createRequire } = await import('node:module');
10
+ const loaders = [];
11
+ try {
12
+ const req = createRequire(path.join(root, 'package.json'));
13
+ loaders.push({
14
+ label: 'project',
15
+ load: () => req('typescript'),
16
+ resolvePath: () => {
17
+ try {
18
+ return req.resolve('typescript');
19
+ } catch {
20
+ return null;
21
+ }
22
+ },
23
+ });
24
+ } catch {
25
+ /* project has no package.json resolvable tree */
26
+ }
27
+ // Nested under arkgate (production dependency) — must work when project has only TS7.
28
+ try {
29
+ const req = createRequire(__arkCheckCli);
30
+ loaders.push({
31
+ label: 'arkgate',
32
+ load: () => req('typescript'),
33
+ resolvePath: () => {
34
+ try {
35
+ return req.resolve('typescript');
36
+ } catch {
37
+ return null;
38
+ }
39
+ },
40
+ });
41
+ } catch {
42
+ /* ark install tree unavailable */
43
+ }
44
+ loaders.push({
45
+ label: 'import',
46
+ load: async () => {
47
+ const m = await import('typescript');
48
+ return m;
49
+ },
50
+ resolvePath: () => null,
51
+ });
52
+
53
+ let projectRejected = null;
54
+ const triedPaths = new Set();
55
+ for (const { label, load, resolvePath } of loaders) {
56
+ try {
57
+ const resolved = typeof resolvePath === 'function' ? resolvePath() : null;
58
+ if (resolved && triedPaths.has(resolved)) {
59
+ // Same physical package already rejected (e.g. project === hoisted arkgate path).
60
+ continue;
61
+ }
62
+ if (resolved) triedPaths.add(resolved);
63
+
64
+ const mod = await load();
65
+ const ts = usableTypescript(mod);
66
+ if (ts) {
67
+ const version =
68
+ typeof ts.version === 'string'
69
+ ? ts.version
70
+ : typeof mod?.version === 'string'
71
+ ? mod.version
72
+ : undefined;
73
+ return {
74
+ ts,
75
+ source: label,
76
+ version,
77
+ ...(projectRejected ? { fallbackReason: projectRejected } : {}),
78
+ };
79
+ }
80
+ if (label === 'project' && mod) {
81
+ projectRejected = `project typescript is not API-compatible (${typescriptUsabilityHint(mod)}); using ArkGate's JS-API TypeScript fallback (TypeScript 7.0 main export is version-only). See docs/typescript-support.md.`;
82
+ }
83
+ } catch {
84
+ /* try next loader */
85
+ }
86
+ }
87
+ return null;
88
+ }