arkgate 4.6.5 → 4.6.7

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 (51) hide show
  1. package/CHANGELOG.md +72 -2106
  2. package/README.md +11 -9
  3. package/bin/ark-check-runtime.mjs +36 -332
  4. package/bin/ark-mcp-runtime.mjs +7 -323
  5. package/bin/ark-shared.mjs +24 -158
  6. package/bin/ark.mjs +13 -3
  7. package/bin/lib/adoption-stance.mjs +104 -0
  8. package/bin/lib/check-args.mjs +173 -0
  9. package/bin/lib/check-config-detect.mjs +101 -0
  10. package/bin/lib/check-watch.mjs +80 -0
  11. package/bin/lib/ci-merge-boundary.mjs +4 -2
  12. package/bin/lib/deep-module-coach.mjs +3 -0
  13. package/bin/lib/design-delta.mjs +2 -2
  14. package/bin/lib/design-smells.mjs +1 -1
  15. package/bin/lib/diagnostic-catalog.mjs +1 -1
  16. package/bin/lib/doctor-advisories.mjs +2 -2
  17. package/bin/lib/doctor-human.mjs +509 -0
  18. package/bin/lib/doctor-next-actions.mjs +20 -2
  19. package/bin/lib/doctor-plan.mjs +86 -456
  20. package/bin/lib/enforcement-honesty.mjs +70 -0
  21. package/bin/lib/first-run-help.mjs +8 -7
  22. package/bin/lib/github-enforcement.mjs +22 -9
  23. package/bin/lib/html-report-advisories.mjs +10 -2
  24. package/bin/lib/html-report.mjs +26 -9
  25. package/bin/lib/mcp-adoption.mjs +19 -0
  26. package/bin/lib/mcp-hook-payload.mjs +328 -0
  27. package/bin/lib/package-manager.mjs +174 -0
  28. package/bin/lib/policy-delta-io.mjs +5 -1
  29. package/bin/lib/post-green-path.mjs +5 -1
  30. package/bin/lib/product-copy.mjs +6 -3
  31. package/bin/lib/start-preview.mjs +12 -22
  32. package/bin/lib/status-command.mjs +16 -0
  33. package/bin/lib/status-manifest.mjs +8 -2
  34. package/bin/lib/team-parliament-io.mjs +66 -2
  35. package/bin/lib/team-parliament.mjs +25 -5
  36. package/bin/lib/unavailable-analysis.mjs +1 -0
  37. package/dist/index.cjs +2 -2
  38. package/dist/index.d.ts +10 -2
  39. package/dist/index.js +2 -2
  40. package/docs/README.md +6 -10
  41. package/docs/ai-gates.md +12 -5
  42. package/docs/configuration.md +9 -1
  43. package/docs/diagnostics.md +2 -2
  44. package/docs/package-surface.md +6 -4
  45. package/docs/product-voice.md +6 -4
  46. package/docs/threat-model.md +2 -2
  47. package/docs/use.md +5 -4
  48. package/package.json +1 -1
  49. package/schemas/ark.design-delta.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Host PreToolUse payload mapping (Claude/Grok/Cursor/Antigravity/Codex).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Map Google Antigravity write tools (PascalCase args) onto Claude Write/Edit/MultiEdit.
9
+ * @returns {{ toolName: string, toolInput: object }|null}
10
+ */
11
+ export function mapAntigravityToolCall(toolCall) {
12
+ if (!toolCall || typeof toolCall !== 'object') return null;
13
+ const name = toolCall.name ?? '';
14
+ const args = toolCall.args && typeof toolCall.args === 'object' ? toolCall.args : {};
15
+ const filePath = args.TargetFile ?? args.targetFile ?? args.file_path ?? args.path;
16
+ if (name === 'write_to_file') {
17
+ return {
18
+ toolName: 'Write',
19
+ toolInput: {
20
+ file_path: filePath,
21
+ content: args.CodeContent ?? args.codeContent ?? args.content ?? '',
22
+ },
23
+ operation: 'write_to_file',
24
+ };
25
+ }
26
+ if (name === 'replace_file_content') {
27
+ return {
28
+ toolName: 'Edit',
29
+ toolInput: {
30
+ file_path: filePath,
31
+ old_string: args.TargetContent ?? args.targetContent ?? args.old_string ?? '',
32
+ new_string: args.ReplacementContent ?? args.replacementContent ?? args.new_string ?? '',
33
+ replace_all: Boolean(args.AllowMultiple ?? args.allowMultiple),
34
+ },
35
+ operation: 'replace_file_content',
36
+ };
37
+ }
38
+ if (name === 'multi_replace_file_content') {
39
+ const chunks = Array.isArray(args.ReplacementChunks)
40
+ ? args.ReplacementChunks
41
+ : Array.isArray(args.replacementChunks)
42
+ ? args.replacementChunks
43
+ : [];
44
+ return {
45
+ toolName: 'MultiEdit',
46
+ toolInput: {
47
+ file_path: filePath,
48
+ edits: chunks.map((chunk) => ({
49
+ old_string: chunk?.TargetContent ?? chunk?.targetContent ?? chunk?.old_string ?? '',
50
+ new_string:
51
+ chunk?.ReplacementContent ?? chunk?.replacementContent ?? chunk?.new_string ?? '',
52
+ replace_all: Boolean(chunk?.AllowMultiple ?? chunk?.allowMultiple),
53
+ })),
54
+ },
55
+ operation: 'multi_replace_file_content',
56
+ };
57
+ }
58
+ return {
59
+ toolName: name,
60
+ toolInput: { ...args, file_path: filePath },
61
+ operation: name,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Normalize agent PreToolUse payloads.
67
+ * Claude Code: { tool_name, tool_input: { file_path, content | old_string/new_string } }
68
+ * Grok Build: { toolName, toolInput: { file_path, content | old_string/new_string } }
69
+ * (aliases Write/Edit/MultiEdit → write/search_replace; matcher keeps both)
70
+ * Antigravity: { toolCall: { name, args: { TargetFile, CodeContent, … } } }
71
+ * Cursor: { tool_name, tool_input, hook_event_name?, workspace_roots? }
72
+ * Write uses `contents`; StrReplace maps to Edit (path/old_string/new_string).
73
+ * Codex: { tool_name: "apply_patch", tool_input: { command: "*** Begin Patch..." } }
74
+ */
75
+ export function normalizeHookPayload(payload, grokHookEvent = Boolean(process.env.GROK_HOOK_EVENT)) {
76
+ const antigravityStyle =
77
+ payload != null && typeof payload === 'object' && 'toolCall' in payload;
78
+ if (antigravityStyle) {
79
+ const mapped = mapAntigravityToolCall(payload.toolCall);
80
+ const filePath =
81
+ mapped?.toolInput?.file_path ??
82
+ mapped?.toolInput?.filePath ??
83
+ mapped?.toolInput?.path ??
84
+ mapped?.toolInput?.target_file;
85
+ return {
86
+ toolName: mapped?.toolName ?? '',
87
+ toolInput: { ...(mapped?.toolInput ?? {}), file_path: filePath },
88
+ grokStyle: true, // decision JSON on stdout (deny)
89
+ antigravityStyle: true,
90
+ cursorStyle: false,
91
+ operation: mapped?.operation ?? mapped?.toolName ?? null,
92
+ };
93
+ }
94
+
95
+ const rawName = payload?.tool_name ?? payload?.toolName ?? '';
96
+ const toolInputRaw = payload?.tool_input ?? payload?.toolInput ?? {};
97
+ const toolInput =
98
+ toolInputRaw && typeof toolInputRaw === 'object' ? { ...toolInputRaw } : {};
99
+ // Cursor Write uses `contents`; Claude/Grok use `content`.
100
+ if (toolInput.content == null && typeof toolInput.contents === 'string') {
101
+ toolInput.content = toolInput.contents;
102
+ }
103
+ const nameMap = {
104
+ Write: 'Write',
105
+ write: 'Write',
106
+ Edit: 'Edit',
107
+ search_replace: 'Edit',
108
+ StrReplace: 'Edit',
109
+ MultiEdit: 'MultiEdit',
110
+ ApplyPatch: 'ApplyPatch',
111
+ apply_patch: 'ApplyPatch',
112
+ write_to_file: 'Write',
113
+ replace_file_content: 'Edit',
114
+ multi_replace_file_content: 'MultiEdit',
115
+ };
116
+ const toolName = nameMap[rawName] ?? rawName;
117
+ const filePath =
118
+ toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? toolInput.target_file;
119
+ const cursorStyle =
120
+ Boolean(process.env.CURSOR_PROJECT_DIR) ||
121
+ Boolean(process.env.CURSOR_VERSION) ||
122
+ (payload != null &&
123
+ typeof payload === 'object' &&
124
+ (payload.hook_event_name === 'preToolUse' ||
125
+ Array.isArray(payload.workspace_roots) ||
126
+ rawName === 'StrReplace' ||
127
+ (rawName === 'Write' && typeof toolInputRaw?.contents === 'string')));
128
+ return {
129
+ toolName,
130
+ toolInput: { ...toolInput, file_path: filePath },
131
+ // Grok-style camelCase (or GROK_HOOK_EVENT) → also emit deny JSON on stdout.
132
+ grokStyle:
133
+ grokHookEvent ||
134
+ (payload != null && typeof payload === 'object' && 'toolName' in payload),
135
+ antigravityStyle: false,
136
+ cursorStyle,
137
+ operation: rawName === 'StrReplace' ? 'StrReplace' : null,
138
+ };
139
+ }
140
+
141
+ export function applyCodexUpdatePatch(current, lines) {
142
+ let source = current.split('\n');
143
+ let cursor = 0;
144
+ const hunks = [];
145
+ let hunk = null;
146
+ for (const line of lines) {
147
+ if (line.startsWith('@@')) {
148
+ if (hunk) hunks.push(hunk);
149
+ hunk = { anchor: line.slice(2).trim(), entries: [] };
150
+ } else if (/^[ +\-]/.test(line)) {
151
+ if (!hunk) return null;
152
+ hunk.entries.push(line);
153
+ }
154
+ }
155
+ if (hunk) hunks.push(hunk);
156
+ for (const { anchor, entries } of hunks) {
157
+ if (anchor) {
158
+ const anchorAt = source.findIndex((line, index) => index >= cursor && line === anchor);
159
+ if (anchorAt < 0) return null;
160
+ cursor = anchorAt + 1;
161
+ }
162
+ const oldLines = entries.filter((line) => !line.startsWith('+')).map((line) => line.slice(1));
163
+ const newLines = entries.filter((line) => !line.startsWith('-')).map((line) => line.slice(1));
164
+ let found = -1;
165
+ for (let at = cursor; at <= source.length - oldLines.length; at += 1) {
166
+ if (oldLines.every((line, index) => source[at + index] === line)) {
167
+ found = at;
168
+ break;
169
+ }
170
+ }
171
+ if (found < 0) return null;
172
+ source.splice(found, oldLines.length, ...newLines);
173
+ cursor = found + newLines.length;
174
+ }
175
+ return source.join('\n');
176
+ }
177
+
178
+ export function codexPatchWrites(patch, root) {
179
+ if (typeof patch !== 'string') {
180
+ return { writes: [], complete: false };
181
+ }
182
+ const lines = patch.split('\n');
183
+ const begin = lines.indexOf('*** Begin Patch');
184
+ const end = lines.indexOf('*** End Patch', begin + 1);
185
+ if (begin < 0 || end <= begin) return { writes: [], complete: false };
186
+ const writes = [];
187
+ const seenPaths = new Set();
188
+ let complete = [
189
+ ...lines.slice(0, begin),
190
+ ...lines.slice(end + 1),
191
+ ].every((line) => line.trim() === '');
192
+ let sawFileDirective = false;
193
+ for (let index = begin + 1; index < end; index += 1) {
194
+ const match = lines[index].match(/^\*\*\* (Add|Update|Delete) File: (.+)$/);
195
+ if (!match) {
196
+ if (lines[index].trim() !== '') complete = false;
197
+ continue;
198
+ }
199
+ sawFileDirective = true;
200
+ const [, action, relativePath] = match;
201
+ const body = [];
202
+ for (index += 1; index < end && !lines[index].startsWith('*** '); index += 1) {
203
+ body.push(lines[index]);
204
+ }
205
+ index -= 1;
206
+ const filePath = path.resolve(root, relativePath);
207
+ const rel = path.relative(root, filePath);
208
+ if (
209
+ seenPaths.has(filePath) ||
210
+ rel.startsWith(`..${path.sep}`) ||
211
+ rel === '..' ||
212
+ path.isAbsolute(rel)
213
+ ) {
214
+ complete = false;
215
+ continue;
216
+ }
217
+ seenPaths.add(filePath);
218
+ const canonicalRelativePath = rel.split(path.sep).join('/');
219
+ if (action === 'Delete') {
220
+ if (body.some((line) => line.trim() !== '') || !fs.existsSync(filePath)) {
221
+ complete = false;
222
+ continue;
223
+ }
224
+ writes.push({ path: canonicalRelativePath, filePath, delete: true });
225
+ continue;
226
+ }
227
+ let content;
228
+ if (action === 'Add') {
229
+ if (
230
+ body.length === 0 ||
231
+ fs.existsSync(filePath) ||
232
+ body.some((line) => !line.startsWith('+'))
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))
243
+ ) {
244
+ complete = false;
245
+ continue;
246
+ }
247
+ let current;
248
+ try {
249
+ current = fs.readFileSync(filePath, 'utf8');
250
+ } catch {
251
+ complete = false;
252
+ continue;
253
+ }
254
+ content = applyCodexUpdatePatch(current, body);
255
+ if (content === null) complete = false;
256
+ }
257
+ if (typeof content === 'string') {
258
+ writes.push({ path: canonicalRelativePath, filePath, content });
259
+ }
260
+ }
261
+ return { writes, complete: complete && sawFileDirective };
262
+ }
263
+
264
+ /**
265
+ * Compute the file content a Write/Edit/MultiEdit is about to produce. Edits are applied
266
+ * to the CURRENT on-disk file so the gate judges the real post-edit state, not the edit
267
+ * snippet out of context. Replacement uses a function argument so `$&`-style sequences in
268
+ * generated code are inserted literally, never interpreted as replacement patterns.
269
+ */
270
+ export function proposedSource(toolName, toolInput) {
271
+ if (toolName === 'Write') return toolInput.content ?? toolInput.contents;
272
+
273
+ let text = '';
274
+ try {
275
+ text = fs.readFileSync(toolInput.file_path, 'utf8');
276
+ } catch {
277
+ // New file created via Edit: fall through with an empty base.
278
+ }
279
+ const edits = toolName === 'MultiEdit' ? toolInput.edits ?? [] : [toolInput];
280
+ for (const edit of edits) {
281
+ const from = edit.old_string ?? '';
282
+ const to = edit.new_string ?? '';
283
+ if (from === '') {
284
+ text = to;
285
+ } else if (edit.replace_all) {
286
+ text = text.split(from).join(to);
287
+ } else {
288
+ text = text.replace(from, () => to);
289
+ }
290
+ }
291
+ return text;
292
+ }
293
+
294
+ /** Antigravity PreToolUse requires stdout `decision` on every response (allow included). */
295
+ export function emitAntigravityAllow(output, antigravityStyle) {
296
+ if (!antigravityStyle) return;
297
+ output.stdout(`${JSON.stringify({ decision: 'allow' })}\n`);
298
+ }
299
+
300
+ /** Cursor preToolUse accepts explicit allow; exit 0 alone also works. */
301
+ export function emitCursorAllow(output, cursorStyle) {
302
+ if (!cursorStyle) return;
303
+ output.stdout(`${JSON.stringify({ permission: 'allow' })}\n`);
304
+ }
305
+
306
+ export function emitHostAllow(output, { antigravityStyle, cursorStyle }) {
307
+ emitAntigravityAllow(output, antigravityStyle);
308
+ emitCursorAllow(output, cursorStyle);
309
+ }
310
+
311
+ /**
312
+ * Socket-style write-gate deny: two lines first. Pass/fail, no score.
313
+ * Rule id stays on a following line, not the first sentence.
314
+ */
315
+ export function formatWriteGateDeny({ file, reason, ruleId, nextAction, extraLines = [] }) {
316
+ const target = file || 'this write';
317
+ const why = String(reason || 'this change breaks the architecture layers').replace(/\s+/g, ' ').trim();
318
+ const next =
319
+ nextAction && /place|move|import|port/i.test(nextAction)
320
+ ? nextAction
321
+ : 'Move the import or run /ark-place. Do not weaken ark.config.json.';
322
+ const lines = [`blocked ${target} — ${why}`, `Next: ${next}`];
323
+ if (ruleId) lines.push(`[${ruleId}]`);
324
+ for (const extra of extraLines) {
325
+ if (extra) lines.push(extra);
326
+ }
327
+ return lines.join('\n');
328
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Package-manager detection and emitted install/run command shapes.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /** The three package managers Ark emits commands for. */
8
+ const LOCKFILES = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', npm: 'package-lock.json' };
9
+
10
+ function readPackageJson(root) {
11
+ const file = path.join(root, 'package.json');
12
+ if (!fs.existsSync(file)) return null;
13
+ try {
14
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The Corepack `packageManager` field (and the newer `devEngines.packageManager`) is the
22
+ * project's OWN authoritative statement of its package manager. When present it wins over any
23
+ * lockfile guess. Returns 'pnpm' | 'yarn' | 'npm' | undefined.
24
+ */
25
+ function declaredPackageManager(root) {
26
+ let pkg;
27
+ try {
28
+ pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ const raw =
33
+ (typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : undefined) ??
34
+ (typeof pkg.devEngines?.packageManager?.name === 'string'
35
+ ? pkg.devEngines.packageManager.name
36
+ : undefined);
37
+ const name = raw?.trim().toLowerCase();
38
+ return name === 'pnpm' || name === 'yarn' || name === 'npm' ? name : undefined;
39
+ }
40
+
41
+ /** Lockfiles present in the project root, in { pnpm, yarn, npm } key order. */
42
+ export function presentLockfiles(root) {
43
+ return Object.entries(LOCKFILES)
44
+ .filter(([, file]) => fs.existsSync(path.join(root, file)))
45
+ .map(([pm]) => pm);
46
+ }
47
+
48
+ /**
49
+ * Detect the project's package manager: 'pnpm' | 'yarn' | 'npm'.
50
+ *
51
+ * Priority: (1) the `packageManager` / `devEngines` field (the project's own declaration);
52
+ * (2) a single lockfile; (3) on CONFLICT (more than one lockfile and no declaration) prefer
53
+ * npm whenever a package-lock.json is present. Rationale: `npx` runs fine inside a pnpm/yarn
54
+ * repo, but `pnpm exec` / `yarn` in an npm repo BREAKS (frozen-lockfile / no-TTY / a spurious
55
+ * pnpm-lock). So a stray pnpm-lock.yaml left in an npm project must NOT hijack it into pnpm —
56
+ * package-lock.json wins the tie, and the field is the escape hatch for a genuine pnpm repo
57
+ * that still carries a package-lock.json. Falls back to npm when nothing is detectable.
58
+ */
59
+ export function detectPackageManager(root) {
60
+ const declared = declaredPackageManager(root);
61
+ if (declared) return declared;
62
+ const locks = presentLockfiles(root);
63
+ if (locks.length <= 1) return locks[0] ?? 'npm';
64
+ if (locks.includes('npm')) return 'npm';
65
+ return locks[0]; // pnpm over yarn when only those two collide
66
+ }
67
+
68
+ // pnpm 10+ `pnpm exec` runs a deps-status pre-check that fails with ERR_PNPM_IGNORED_BUILDS
69
+ // when the repo has un-approved native build scripts (sharp, esbuild, tailwind oxide, …) —
70
+ // the common state of real pnpm apps. Skip that gate so Ark's emitted commands still run.
71
+ const PNPM_EXEC = 'pnpm --config.verify-deps-before-run=false exec';
72
+ const RUNNER_BY_PM = { pnpm: PNPM_EXEC, yarn: 'yarn', npm: 'npx' };
73
+
74
+ /**
75
+ * The command prefix that runs an INSTALLED package binary, matched to the project's
76
+ * package manager. `npx` is used for npm and as the safe fallback.
77
+ *
78
+ * This is the single source of truth that makes every command Ark EMITS — the AGENTS.md
79
+ * contract, .mcp.json, the Claude/Codex hooks, the check:architecture script, the
80
+ * SessionStart summary and every console hint — respect a pnpm-only or yarn repo instead
81
+ * of hardcoding `npx`. (A "pnpm only, never npx" repo treats an emitted `npx` as a policy
82
+ * violation.) `packageManager()` in ark-check.mjs builds the CI-workflow variant on the
83
+ * same detection.
84
+ */
85
+ export function execRunner(root) {
86
+ return RUNNER_BY_PM[detectPackageManager(root)];
87
+ }
88
+
89
+ /** Full runnable command string for an installed Ark binary, package-manager aware. */
90
+ export function arkCommand(root, bin, argsStr = '') {
91
+ return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
92
+ }
93
+
94
+ /**
95
+ * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
96
+ * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]
97
+ * so the runner is a real argv[0], not a space-joined string a shell would mis-split.
98
+ */
99
+ export function execCommandParts(root, bin, binArgs = []) {
100
+ const runner = execRunner(root);
101
+ if (runner === PNPM_EXEC || runner.startsWith('pnpm ')) {
102
+ return {
103
+ command: 'pnpm',
104
+ args: ['--config.verify-deps-before-run=false', 'exec', bin, ...binArgs],
105
+ };
106
+ }
107
+ if (runner === 'yarn') return { command: 'yarn', args: [bin, ...binArgs] };
108
+ return { command: 'npx', args: [bin, ...binArgs] };
109
+ }
110
+
111
+ /**
112
+ * True when this directory is a pnpm workspace root (needs `pnpm add -w` for root deps).
113
+ * Nested packages under the workspace are not roots.
114
+ */
115
+ export function isPnpmWorkspaceRoot(root) {
116
+ return fs.existsSync(path.join(root, 'pnpm-workspace.yaml'));
117
+ }
118
+
119
+ /**
120
+ * True when package.json declares npm/yarn workspaces (yarn classic needs `-W` at root).
121
+ */
122
+ export function isNpmYarnWorkspaceRoot(root) {
123
+ const pkg = readPackageJson(root);
124
+ if (!pkg) return false;
125
+ const ws = pkg.workspaces;
126
+ return Array.isArray(ws) || (ws && typeof ws === 'object' && Array.isArray(ws.packages));
127
+ }
128
+
129
+ /**
130
+ * Normalize a version/range/spec into an installable package argument for arkgate.
131
+ * Accepts `latest`, `^3.8.2`, `arkgate@latest`, or a full package name.
132
+ */
133
+ export function normalizeArkgateInstallSpec(versionSpec) {
134
+ const raw = typeof versionSpec === 'string' && versionSpec.trim() ? versionSpec.trim() : 'latest';
135
+ if (raw.startsWith('arkgate@') || raw === 'arkgate') return raw === 'arkgate' ? 'arkgate@latest' : raw;
136
+ if (raw.includes('/') || raw.startsWith('file:') || raw.startsWith('link:')) return raw;
137
+ return `arkgate@${raw}`;
138
+ }
139
+
140
+ /**
141
+ * Package-manager argv to add a dev dependency (e.g. arkgate@latest).
142
+ * pnpm workspace roots get `-w`; yarn classic workspaces get `-W`.
143
+ *
144
+ * @param {string} root
145
+ * @param {string} [versionSpec] package name or name@version (default arkgate@latest)
146
+ * @returns {[string, string[]]}
147
+ */
148
+ export function packageInstallArgv(root, versionSpec = 'latest') {
149
+ const pkgSpec = normalizeArkgateInstallSpec(versionSpec);
150
+ const pm = detectPackageManager(root);
151
+ if (pm === 'pnpm') {
152
+ const args = ['add', '-D', pkgSpec];
153
+ if (isPnpmWorkspaceRoot(root)) args.push('-w');
154
+ return ['pnpm', args];
155
+ }
156
+ if (pm === 'yarn') {
157
+ const args = ['add', '-D', pkgSpec];
158
+ if (isNpmYarnWorkspaceRoot(root)) args.push('-W');
159
+ return ['yarn', args];
160
+ }
161
+ return ['npm', ['install', '-D', pkgSpec]];
162
+ }
163
+
164
+ /** Package-manager aware "install a dev dependency" hint (e.g. for a missing typescript). */
165
+ export function installDevHint(root, pkg) {
166
+ const pm = detectPackageManager(root);
167
+ if (pm === 'pnpm') {
168
+ return isPnpmWorkspaceRoot(root) ? `pnpm add -D ${pkg} -w` : `pnpm add -D ${pkg}`;
169
+ }
170
+ if (pm === 'yarn') {
171
+ return isNpmYarnWorkspaceRoot(root) ? `yarn add -D ${pkg} -W` : `yarn add -D ${pkg}`;
172
+ }
173
+ return `npm install -D ${pkg}`;
174
+ }
@@ -17,10 +17,14 @@ function readJsonFile(filePath, label) {
17
17
  }
18
18
  }
19
19
 
20
+ /** Kill hung git instead of stalling CI. */
21
+ export const SPAWN_TIMEOUT_MS = 8000;
22
+
20
23
  function runGit(cwd, args) {
21
24
  return spawnSync('git', ['-C', cwd, ...args], {
22
25
  encoding: 'utf8',
23
26
  stdio: ['ignore', 'pipe', 'pipe'],
27
+ timeout: SPAWN_TIMEOUT_MS,
24
28
  });
25
29
  }
26
30
 
@@ -42,7 +46,7 @@ function repositoryRoot(root) {
42
46
  return result.status === 0 ? result.stdout.trim() : null;
43
47
  }
44
48
 
45
- function discoverLocalBaseRef(root) {
49
+ export function discoverLocalBaseRef(root) {
46
50
  const top = repositoryRoot(root);
47
51
  if (!top) return null;
48
52
  const remoteHead = runGit(top, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
@@ -92,10 +92,14 @@ export function mergePostGreenTopActions(actions, postGreen) {
92
92
 
93
93
  /**
94
94
  * Whether doctor may print “Healthy — nothing to do”.
95
+ * Empty actions + !designWeak is not Healthy unless the merge boundary is
96
+ * required-merge (advisory-only ack is adopted but not this Healthy string).
95
97
  * @param {{ designWeak?: boolean } | null | undefined} designFitness
96
98
  * @param {string[]} topActions
99
+ * @param {string | null | undefined} adopted
97
100
  */
98
- export function isDoctorHealthyNothingToDo(designFitness, topActions = []) {
101
+ export function isDoctorHealthyNothingToDo(designFitness, topActions = [], adopted = null) {
99
102
  if (designFitness?.designWeak) return false;
103
+ if (adopted !== 'required-merge') return false;
100
104
  return !topActions.some(Boolean);
101
105
  }
@@ -13,17 +13,20 @@ export const LEFTOVER_DESIGN_LABEL = 'leftover design work';
13
13
  * Operating-mode title for humans (and doctor JSON `designFitness.label` prefix).
14
14
  * @param {string|null|undefined} mode suggest|adapt|enforce
15
15
  * @param {boolean} leftoverDesign
16
+ * @param {boolean} [stewardsUnset]
16
17
  */
17
- export function operatingModeTitle(mode, leftoverDesign) {
18
+ export function operatingModeTitle(mode, leftoverDesign, stewardsUnset) {
18
19
  const light = String(mode || 'enforce').toUpperCase();
19
- return leftoverDesign ? `${light} · ${LEFTOVER_DESIGN_LABEL}` : light;
20
+ if (leftoverDesign) return `${light} · ${LEFTOVER_DESIGN_LABEL}`;
21
+ if (stewardsUnset) return `${light} · stewards unset`;
22
+ return light;
20
23
  }
21
24
 
22
25
  /** Short HTML/doctor badge text. */
23
26
  export const LEFTOVER_DESIGN_BADGE = LEFTOVER_DESIGN_LABEL;
24
27
 
25
28
  export const POST_GREEN_HUMAN =
26
- 'Imports check out, but the design is still messy. Map leftover work with /ark-explore shape-focus, then apply one small refactor via /ark-autopilot. A clean import check is not done; pattern bets are never auto-applied.';
29
+ 'Imports check out, but the design is still messy. Next: /ark-explore, then one small refactor with /ark-autopilot and your OK.';
27
30
 
28
31
  export const POST_GREEN_LEDE =
29
32
  'Import rules are clean, but leftover design work remains. That does not fail the check — it only means “done” is still wrong until you tidy shape.';
@@ -151,7 +151,7 @@ export function renderStartPreview(preview, options = {}) {
151
151
  console.log('Apply this plan with: arkgate start --apply');
152
152
  }
153
153
  if (preview.analysis) {
154
- console.log(`Your project looks like: ${preview.analysis.label} (${preview.analysis.archetype}, confidence ${preview.analysis.confidence}).`);
154
+ console.log(`Your project looks like: ${preview.analysis.label}.`);
155
155
  }
156
156
  console.log(applying ? 'Files create/edit/delete:' : 'Files to create/edit/delete:');
157
157
  if (preview.changes.length === 0) console.log(' (none)');
@@ -159,34 +159,24 @@ export function renderStartPreview(preview, options = {}) {
159
159
  console.log(` ${change.action.padEnd(6)} ${change.path}`);
160
160
  }
161
161
  if (!applying) {
162
- console.log('Commands in the approved setup plan:');
163
- for (const command of preview.commands) console.log(` ${command}`);
162
+ console.log('Setup: install package + host gates (see --json).');
163
+ console.log('Preview does not write. Apply installs CI.');
164
164
  }
165
- console.log('Host guarantees:');
166
- for (const guarantee of preview.hostGuarantees) console.log(` ${guarantee}`);
167
165
  if (preview.runtimeActivation) {
168
- console.log('Codex MCP CONFIGURED RUNTIME NOT VERIFIED.');
169
- console.log(` Runtime activation: ${JSON.stringify(preview.runtimeActivation)}`);
170
- console.log(` Restart Codex, then call ark_identity with expectedRoot "${preview.root}".`);
171
- console.log(' Do not trust MCP verdicts before the project identity matches.');
166
+ console.log('Host: Codex is configured but not verified yet. Restart the host, then confirm this project.');
172
167
  }
173
168
  if (preview.unresolvedDecisions.length > 0) {
174
169
  console.log('Unresolved decisions:');
175
170
  for (const decision of preview.unresolvedDecisions) console.log(` ${decision}`);
176
171
  }
177
- console.log('Details (optional):');
178
- console.log(`Projected governed coverage: ${preview.projectedCoverage.percent ?? 'unknown'}% (${preview.projectedCoverage.classifiedFiles}/${preview.projectedCoverage.totalFiles} files)`);
179
- const budget = preview.setupBudget;
180
- const arkrulesNote =
181
- budget.arkrulesFiles > 0 ? ` (+${budget.arkrulesFiles} arkrules)` : '';
182
- const gateCount = budget.gateFiles ?? budget.files;
183
- console.log(
184
- `Compact setup budget: ${gateCount}/${budget.maxFiles} gate files${arkrulesNote}, ${budget.bytes}/${budget.maxBytes} bytes${budget.ok ? '' : ' (exceeded)'}.`
185
- );
186
- for (const change of preview.changes) {
187
- console.log(` ${change.action.padEnd(6)} ${change.path} ${change.afterHash ?? '(deleted)'}`);
188
- }
189
- if (!applying) {
172
+ if (applying) {
173
+ const percent = preview.projectedCoverage?.percent;
174
+ if (percent != null) console.log(`Projected governed coverage: ${percent}%`);
175
+ const verified = (preview.hostGuarantees || []).find((line) =>
176
+ String(line).startsWith('Hard-write hook verified')
177
+ );
178
+ if (verified) console.log(verified);
179
+ } else {
190
180
  console.log('Review complete file contents with --json.');
191
181
  }
192
182
  }
@@ -26,6 +26,7 @@ import { readBaseline } from './violations.mjs';
26
26
  import { reportsDir, readJsonSafe } from './html-report.mjs';
27
27
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
28
28
  import { collectVsBaseFacts, discoverTeamBaseRef } from './team-parliament-io.mjs';
29
+ import { classifyAdopted, readAdoptionStance } from './adoption-stance.mjs';
29
30
 
30
31
  function sha256Hex(value) {
31
32
  return createHash('sha256').update(value, 'utf8').digest('hex');
@@ -387,6 +388,21 @@ export function collectStatusFacts(options = {}) {
387
388
  latest?.leftoverDesignWork === true ||
388
389
  latest?.designFitness?.designWeak === true ||
389
390
  latest?.doctor?.designFitness?.designWeak === true,
391
+ adopted:
392
+ options.adopted ??
393
+ classifyAdopted({
394
+ stance: readAdoptionStance(resolvedRoot),
395
+ github: {
396
+ requiredStatusConfigured: writePath?.enforcementState?.ciMerge?.required === true,
397
+ arkCheckRequired: writePath?.enforcementState?.ciMerge?.required === true,
398
+ },
399
+ ci: {
400
+ state:
401
+ writePath?.enforcementState?.ciMerge?.required === true
402
+ ? 'required'
403
+ : undefined,
404
+ },
405
+ }),
390
406
  improvementCompass,
391
407
  vsBase: (() => {
392
408
  const vsRef = typeof options.vs === 'string' ? options.vs.trim() : '';
@@ -242,9 +242,15 @@ export function resolveStatusNextAction(facts, binding, activation, lastCheck, r
242
242
  summary: 'ArkRules residual remains frozen — review inventory debt without claiming a score.',
243
243
  };
244
244
  }
245
+ if (facts.adopted === 'required-merge' || facts.adopted === 'advisory-only-acked') {
246
+ return {
247
+ id: 'stay-enforced',
248
+ summary: 'Contract looks enforceable for this session — keep writing through the gate and re-check after structural edits.',
249
+ };
250
+ }
245
251
  return {
246
- id: 'stay-enforced',
247
- summary: 'Contract looks enforceable for this session keep writing through the gate and re-check after structural edits.',
252
+ id: 'require-ci-merge-status',
253
+ summary: 'Make arkgate-check --strict-merge a required GitHub status, or write .ark/adoption-stance.json with stance: "advisory-only".',
248
254
  };
249
255
  }
250
256
  export function buildStatusManifest(facts) {