arkgate 4.8.14 → 4.8.15

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 (97) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +13 -5
  3. package/bin/ark-check-runtime.mjs +17 -49
  4. package/bin/ark-mcp-runtime.mjs +111 -2
  5. package/bin/ark-shared.mjs +140 -11
  6. package/bin/ark.mjs +7 -25
  7. package/bin/lib/adr-presence.mjs +97 -0
  8. package/bin/lib/agent-skills-package.mjs +179 -1
  9. package/bin/lib/analysis-completeness.mjs +38 -2
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/architecture-scan.mjs +26 -4
  12. package/bin/lib/ark-run-doctor.mjs +6 -0
  13. package/bin/lib/ark-run-report.mjs +6 -2
  14. package/bin/lib/arkrules-sensors.mjs +81 -0
  15. package/bin/lib/baseline-key.mjs +4 -1
  16. package/bin/lib/check-args.mjs +52 -2
  17. package/bin/lib/config-contract.mjs +64 -1
  18. package/bin/lib/diagnostic-catalog.mjs +3 -1
  19. package/bin/lib/doctor-advisories.mjs +94 -10
  20. package/bin/lib/doctor-human.mjs +18 -5
  21. package/bin/lib/doctor-next-actions.mjs +14 -0
  22. package/bin/lib/doctor-plan.mjs +32 -2
  23. package/bin/lib/enforcement-honesty.mjs +47 -2
  24. package/bin/lib/first-run-help.mjs +8 -1
  25. package/bin/lib/host-support-matrix.mjs +9 -1
  26. package/bin/lib/html-report-depth.mjs +2 -0
  27. package/bin/lib/html-report.mjs +13 -2
  28. package/bin/lib/import-resolve.mjs +74 -13
  29. package/bin/lib/improvement-compass-doctor.mjs +6 -1
  30. package/bin/lib/improvement-compass-map.mjs +3 -1
  31. package/bin/lib/layer-description.mjs +90 -8
  32. package/bin/lib/mcp-hook-payload.mjs +56 -0
  33. package/bin/lib/no-domain-frontend.mjs +91 -0
  34. package/bin/lib/presets.mjs +3 -4
  35. package/bin/lib/product-copy.mjs +15 -0
  36. package/bin/lib/projected-governed-coverage.mjs +114 -0
  37. package/bin/lib/recommend-cli.mjs +54 -0
  38. package/bin/lib/remediation.mjs +4 -0
  39. package/bin/lib/resolved-candidate-facts.mjs +83 -66
  40. package/bin/lib/rules-under-contract.mjs +117 -2
  41. package/bin/lib/snippet-analysis.mjs +14 -8
  42. package/bin/lib/start-preview.mjs +5 -2
  43. package/bin/lib/states-transitions-presence.mjs +212 -0
  44. package/bin/lib/status-command.mjs +2 -0
  45. package/bin/lib/status-transition-catalog.mjs +410 -0
  46. package/bin/lib/team-parliament-io.mjs +10 -0
  47. package/bin/lib/violations.mjs +13 -1
  48. package/bin/lib/write-path-capabilities.mjs +20 -5
  49. package/bin/lib/write-path-detect.mjs +27 -2
  50. package/dist/{configTypes-j7so8B4O.d.ts → configTypes-Dt3DpVbd.d.ts} +19 -0
  51. package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-BEg85XlE.d.ts} +3 -3
  52. package/dist/eslint/index.cjs +4 -4
  53. package/dist/eslint/index.d.ts +1 -1
  54. package/dist/eslint/index.js +4 -4
  55. package/dist/index.cjs +31 -31
  56. package/dist/index.d.ts +70 -9
  57. package/dist/index.js +31 -31
  58. package/dist/nestjs/index.cjs +5 -5
  59. package/dist/nestjs/index.d.ts +3 -3
  60. package/dist/nestjs/index.js +5 -5
  61. package/dist/runtime/index.cjs +15 -15
  62. package/dist/runtime/index.d.ts +6 -6
  63. package/dist/runtime/index.js +15 -15
  64. package/dist/{types-Djbs3KjE.d.ts → types-CN9tVMPz.d.ts} +3 -1
  65. package/dist/{types-tGhZUiGX.d.ts → types-TBiv0WHL.d.ts} +1 -1
  66. package/docs/README.md +1 -1
  67. package/docs/agent-guide.md +6 -4
  68. package/docs/ai-gates.md +10 -3
  69. package/docs/brownfield-adoption.md +4 -1
  70. package/docs/configuration.md +55 -7
  71. package/docs/develop.md +1 -0
  72. package/docs/diagnostics.md +23 -3
  73. package/docs/enthusiast/how-to-agent-gates.md +5 -0
  74. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  75. package/docs/package-surface.md +7 -5
  76. package/docs/use.md +5 -0
  77. package/package.json +1 -1
  78. package/schemas/ark.config.schema.json +23 -2
  79. package/server.json +2 -2
  80. package/templates/agent-skills/README.md +1 -1
  81. package/templates/agent-skills/ark-adopt/SKILL.md +82 -1
  82. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -2
  83. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  84. package/templates/agent-skills/ark-explain/SKILL.md +4 -2
  85. package/templates/agent-skills/ark-explore/SKILL.md +14 -1
  86. package/templates/agent-skills/ark-place/SKILL.md +38 -3
  87. package/templates/agent-skills/ark-runtime/SKILL.md +4 -2
  88. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
  89. package/templates/arkrules/DomainModel.json +14 -1
  90. package/templates/skills/ark-adopt.md +82 -1
  91. package/templates/skills/ark-autopilot.md +6 -2
  92. package/templates/skills/ark-coverage.md +1 -1
  93. package/templates/skills/ark-explain.md +4 -2
  94. package/templates/skills/ark-explore.md +14 -1
  95. package/templates/skills/ark-place.md +38 -3
  96. package/templates/skills/ark-runtime.md +4 -2
  97. package/templates/skills/ark-upgrade.md +1 -1
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Narrow opt-in status/transition catalog (P2 §2 / Ordenar).
3
+ * Tooling I/O. Never a gate fail.
4
+ *
5
+ * Opt-in: a Domain-role file already has a closed status/state vocabulary
6
+ * (≥2 string literals on a *Status / *State / *Lifecycle type or enum, or a
7
+ * status:/state: field with a literal union). Silent when that shape is
8
+ * absent. Residual only when a domain (or dedicated states) doc is in play
9
+ * AND the map is missing or heading-only — names from the code, not invented.
10
+ *
11
+ * Does not walk docs. Does not scan Application/UI. Does not fail the check.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { layerForFile } from '../ark-shared.mjs';
17
+ import { isDomainRoleLayerName } from './arkrules-sensors.mjs';
18
+ import { collectStatesTransitionsResidual } from './states-transitions-presence.mjs';
19
+
20
+ export const STATUS_CATALOG_ASK =
21
+ 'Domain code already names statuses, but the states → transitions map is still thin.';
22
+
23
+ export const STATUS_CATALOG_NEXT =
24
+ 'Add those names to the table on the domain doc (entity · states · allowed from → to). No flag soup.';
25
+
26
+ const SOURCE_EXT = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
27
+ const MAX_FILE_BYTES = 200_000;
28
+ const MAX_ENTITIES = 8;
29
+ const MAX_LITERAL_LEN = 40;
30
+
31
+ function unique(values) {
32
+ const seen = new Set();
33
+ const out = [];
34
+ for (const value of values) {
35
+ if (seen.has(value)) continue;
36
+ seen.add(value);
37
+ out.push(value);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function isStatusTypeName(name) {
43
+ return name.endsWith('Status') || name.endsWith('State') || name.endsWith('Lifecycle');
44
+ }
45
+
46
+ function entityFromTypeName(name, fallback) {
47
+ const stripped = name.endsWith('Lifecycle')
48
+ ? name.slice(0, -'Lifecycle'.length)
49
+ : name.endsWith('Status')
50
+ ? name.slice(0, -'Status'.length)
51
+ : name.endsWith('State')
52
+ ? name.slice(0, -'State'.length)
53
+ : name;
54
+ return stripped.length > 0 ? stripped : fallback;
55
+ }
56
+
57
+ function entityFromFile(rel) {
58
+ const stem = path.basename(rel, path.extname(rel));
59
+ if (!stem) return 'Entity';
60
+ return stem.charAt(0).toUpperCase() + stem.slice(1);
61
+ }
62
+
63
+ /** Quoted string literals only — linear scan, no regex. */
64
+ export function extractQuotedLiterals(text) {
65
+ if (typeof text !== 'string' || text.length === 0) return [];
66
+ const out = [];
67
+ for (let i = 0; i < text.length; i += 1) {
68
+ const ch = text[i];
69
+ if (ch !== "'" && ch !== '"') continue;
70
+ const end = text.indexOf(ch, i + 1);
71
+ if (end < 0) break;
72
+ const lit = text.slice(i + 1, end);
73
+ if (lit.length > 0 && lit.length <= MAX_LITERAL_LEN && !lit.includes('\n')) out.push(lit);
74
+ i = end;
75
+ }
76
+ return unique(out);
77
+ }
78
+
79
+ function readBalanced(text, openIndex, openCh, closeCh) {
80
+ let depth = 0;
81
+ for (let i = openIndex; i < text.length; i += 1) {
82
+ const ch = text[i];
83
+ if (ch === openCh) depth += 1;
84
+ else if (ch === closeCh) {
85
+ depth -= 1;
86
+ if (depth === 0) return text.slice(openIndex + 1, i);
87
+ }
88
+ }
89
+ return '';
90
+ }
91
+
92
+ function indexOfKeyword(text, keyword, from) {
93
+ let idx = from;
94
+ while (idx < text.length) {
95
+ const found = text.indexOf(keyword, idx);
96
+ if (found < 0) return -1;
97
+ const prev = found === 0 ? ' ' : text[found - 1];
98
+ if (!/[A-Za-z0-9_]/.test(prev)) return found;
99
+ idx = found + keyword.length;
100
+ }
101
+ return -1;
102
+ }
103
+
104
+ function nextIdent(text, from) {
105
+ let i = from;
106
+ while (i < text.length && /\s/.test(text[i])) i += 1;
107
+ if (i >= text.length || !/[A-Za-z_]/.test(text[i])) return null;
108
+ let end = i + 1;
109
+ while (end < text.length && /[A-Za-z0-9_]/.test(text[end])) end += 1;
110
+ return { name: text.slice(i, end), end };
111
+ }
112
+
113
+ function collectTypeAlias(text, keywordIndex, fallbackEntity) {
114
+ const afterKw = keywordIndex + 4;
115
+ const ident = nextIdent(text, afterKw);
116
+ if (!ident || !isStatusTypeName(ident.name)) return null;
117
+ let i = ident.end;
118
+ while (i < text.length && /\s/.test(text[i])) i += 1;
119
+ if (text[i] !== '=') return null;
120
+ const rhsEnd = text.indexOf(';', i + 1);
121
+ const rhs = text.slice(i + 1, rhsEnd < 0 ? Math.min(text.length, i + 400) : rhsEnd);
122
+ const states = extractQuotedLiterals(rhs);
123
+ if (states.length < 2) return null;
124
+ return {
125
+ entity: entityFromTypeName(ident.name, fallbackEntity),
126
+ states,
127
+ };
128
+ }
129
+
130
+ function collectEnum(text, keywordIndex, fallbackEntity) {
131
+ const afterKw = keywordIndex + 4;
132
+ const ident = nextIdent(text, afterKw);
133
+ if (!ident || !isStatusTypeName(ident.name)) return null;
134
+ let i = ident.end;
135
+ while (i < text.length && /\s/.test(text[i])) i += 1;
136
+ if (text[i] !== '{') return null;
137
+ const body = readBalanced(text, i, '{', '}');
138
+ const states = extractQuotedLiterals(body);
139
+ if (states.length < 2) return null;
140
+ return {
141
+ entity: entityFromTypeName(ident.name, fallbackEntity),
142
+ states,
143
+ };
144
+ }
145
+
146
+ function collectInlineFieldUnions(text) {
147
+ const found = [];
148
+ const needles = ['status:', 'state:', 'status?:', 'state?:'];
149
+ for (const needle of needles) {
150
+ let from = 0;
151
+ while (from < text.length) {
152
+ const idx = text.indexOf(needle, from);
153
+ if (idx < 0) break;
154
+ const prev = idx === 0 ? ' ' : text[idx - 1];
155
+ if (/[A-Za-z0-9_]/.test(prev)) {
156
+ from = idx + needle.length;
157
+ continue;
158
+ }
159
+ const rhsEnd = Math.min(text.length, idx + needle.length + 240);
160
+ const rhs = text.slice(idx + needle.length, rhsEnd);
161
+ const states = extractQuotedLiterals(rhs);
162
+ if (states.length >= 2) found.push(states);
163
+ from = idx + needle.length;
164
+ }
165
+ }
166
+ return found;
167
+ }
168
+
169
+ function isIdentBoundary(text, index) {
170
+ if (index < 0 || index >= text.length) return true;
171
+ return !/[A-Za-z0-9_-]/.test(text[index]);
172
+ }
173
+
174
+ function collectUnquotedTransitions(text, allowed) {
175
+ const pairs = [];
176
+ const arrows = [' → ', ' -> ', '→', '->'];
177
+ const states = [...allowed].sort((a, b) => b.length - a.length);
178
+ for (const fromState of states) {
179
+ let from = 0;
180
+ while (from < text.length) {
181
+ const idx = text.indexOf(fromState, from);
182
+ if (idx < 0) break;
183
+ if (!isIdentBoundary(text, idx - 1) || !isIdentBoundary(text, idx + fromState.length)) {
184
+ from = idx + fromState.length;
185
+ continue;
186
+ }
187
+ let rest = idx + fromState.length;
188
+ while (rest < text.length && /\s/.test(text[rest])) rest += 1;
189
+ let matched = null;
190
+ for (const arrow of arrows) {
191
+ if (text.startsWith(arrow, rest)) {
192
+ matched = arrow;
193
+ break;
194
+ }
195
+ }
196
+ if (!matched) {
197
+ from = idx + fromState.length;
198
+ continue;
199
+ }
200
+ rest += matched.length;
201
+ while (rest < text.length && /\s/.test(text[rest])) rest += 1;
202
+ const toState = states.find(
203
+ (candidate) =>
204
+ text.startsWith(candidate, rest) && isIdentBoundary(text, rest + candidate.length)
205
+ );
206
+ if (toState && toState !== fromState) pairs.push(`${fromState} → ${toState}`);
207
+ from = idx + fromState.length;
208
+ }
209
+ }
210
+ return unique(pairs);
211
+ }
212
+
213
+ function collectQuotedTransitions(text, allowed) {
214
+ if (allowed.size === 0) return [];
215
+ const pairs = collectUnquotedTransitions(text, allowed);
216
+ const arrows = [' → ', ' -> ', '→', '->'];
217
+ for (const quote of ["'", '"']) {
218
+ let from = 0;
219
+ while (from < text.length) {
220
+ const open = text.indexOf(quote, from);
221
+ if (open < 0) break;
222
+ const mid = text.indexOf(quote, open + 1);
223
+ if (mid < 0) break;
224
+ const left = text.slice(open + 1, mid);
225
+ let rest = mid + 1;
226
+ while (rest < text.length && /\s/.test(text[rest])) rest += 1;
227
+ let matched = null;
228
+ for (const arrow of arrows) {
229
+ if (text.startsWith(arrow, rest)) {
230
+ matched = arrow;
231
+ break;
232
+ }
233
+ }
234
+ if (!matched) {
235
+ from = mid + 1;
236
+ continue;
237
+ }
238
+ rest += matched.length;
239
+ while (rest < text.length && /\s/.test(text[rest])) rest += 1;
240
+ if (text[rest] !== quote) {
241
+ from = mid + 1;
242
+ continue;
243
+ }
244
+ const close = text.indexOf(quote, rest + 1);
245
+ if (close < 0) break;
246
+ const right = text.slice(rest + 1, close);
247
+ if (allowed.has(left) && allowed.has(right) && left !== right) {
248
+ pairs.push(`${left} → ${right}`);
249
+ }
250
+ from = close + 1;
251
+ }
252
+ }
253
+ return unique(pairs);
254
+ }
255
+
256
+ /**
257
+ * Catalog closed status/state vocabularies in one Domain source file.
258
+ *
259
+ * @param {string} rel
260
+ * @param {string} content
261
+ * @returns {{ entity: string, file: string, states: string[], transitions: string[] }[]}
262
+ */
263
+ export function extractStatusCatalogFromSource(rel, content) {
264
+ if (typeof content !== 'string' || content.length === 0) return [];
265
+ const fallback = entityFromFile(rel);
266
+ const rows = [];
267
+ let from = 0;
268
+ while (from < content.length) {
269
+ const typeIdx = indexOfKeyword(content, 'type ', from);
270
+ const enumIdx = indexOfKeyword(content, 'enum ', from);
271
+ const next =
272
+ typeIdx < 0 ? enumIdx : enumIdx < 0 ? typeIdx : Math.min(typeIdx, enumIdx);
273
+ if (next < 0) break;
274
+ if (content.startsWith('type ', next)) {
275
+ const row = collectTypeAlias(content, next, fallback);
276
+ if (row) rows.push(row);
277
+ from = next + 5;
278
+ continue;
279
+ }
280
+ if (content.startsWith('enum ', next)) {
281
+ const row = collectEnum(content, next, fallback);
282
+ if (row) rows.push(row);
283
+ from = next + 5;
284
+ continue;
285
+ }
286
+ from = next + 1;
287
+ }
288
+
289
+ if (rows.length === 0) {
290
+ for (const states of collectInlineFieldUnions(content)) {
291
+ rows.push({ entity: fallback, states });
292
+ }
293
+ }
294
+
295
+ const merged = new Map();
296
+ for (const row of rows) {
297
+ const key = row.entity;
298
+ const prev = merged.get(key);
299
+ if (!prev) {
300
+ merged.set(key, { entity: row.entity, file: rel, states: [...row.states], transitions: [] });
301
+ continue;
302
+ }
303
+ prev.states = unique([...prev.states, ...row.states]);
304
+ }
305
+
306
+ const allowed = new Set();
307
+ for (const row of merged.values()) {
308
+ for (const state of row.states) allowed.add(state);
309
+ }
310
+ const transitions = collectQuotedTransitions(content, allowed);
311
+ for (const row of merged.values()) {
312
+ row.transitions = transitions.filter((pair) => {
313
+ const [fromState, toState] = pair.split(' → ');
314
+ return row.states.includes(fromState) && row.states.includes(toState);
315
+ });
316
+ }
317
+ return [...merged.values()];
318
+ }
319
+
320
+ function isSourceFile(rel) {
321
+ return SOURCE_EXT.has(path.extname(rel).toLowerCase());
322
+ }
323
+
324
+ function readSource(abs) {
325
+ try {
326
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
327
+ const stat = fs.statSync(abs);
328
+ if (stat.size === 0 || stat.size > MAX_FILE_BYTES) return null;
329
+ return fs.readFileSync(abs, 'utf8');
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+
335
+ function domainLayersFromConfig(config) {
336
+ return (config?.layers ?? []).filter((layer) =>
337
+ isDomainRoleLayerName(layer.name, layer.intentPrefixes ?? [])
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Closed catalog from already-classified Domain-role files. No docs walk.
343
+ *
344
+ * @param {{ root?: string, config?: object, files?: readonly string[] }} [input]
345
+ * @returns {{ entity: string, file: string, states: string[], transitions: string[] }[]}
346
+ */
347
+ export function collectStatusTransitionCatalog(input = {}) {
348
+ const root = input.root ?? '';
349
+ const files = Array.isArray(input.files) ? input.files : [];
350
+ if (typeof root !== 'string' || root.length === 0 || files.length === 0) return [];
351
+ const domainLayers = domainLayersFromConfig(input.config);
352
+ if (domainLayers.length === 0) return [];
353
+ const domainNames = new Set(domainLayers.map((layer) => layer.name));
354
+ const layers = input.config?.layers ?? [];
355
+ const catalog = [];
356
+ for (const file of files) {
357
+ if (typeof file !== 'string' || file.length === 0) continue;
358
+ const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
359
+ const rel = path.relative(root, abs).split(path.sep).join('/');
360
+ if (rel.startsWith('..') || !isSourceFile(rel)) continue;
361
+ const layer = layerForFile(root, abs, layers);
362
+ if (!layer || !domainNames.has(layer)) continue;
363
+ const content = readSource(abs);
364
+ if (content == null) continue;
365
+ for (const row of extractStatusCatalogFromSource(rel, content)) {
366
+ catalog.push(row);
367
+ if (catalog.length >= MAX_ENTITIES) return catalog;
368
+ }
369
+ }
370
+ return catalog;
371
+ }
372
+
373
+ function formatEntityList(entities) {
374
+ const parts = entities.map((row) => `${row.entity} (${row.states.join(', ')})`);
375
+ if (parts.length === 1) return parts[0];
376
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
377
+ return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`;
378
+ }
379
+
380
+ function nextActionFor(home, entities) {
381
+ const names = entities.map((row) => row.entity).join(', ');
382
+ const target = home || 'the domain doc';
383
+ return `Add ${names} to the table on ${target} (entity · states · allowed from → to). Names from the code. No flag soup.`;
384
+ }
385
+
386
+ /**
387
+ * Soft residual when Domain already has a status catalog and the domain-doc
388
+ * map is missing or heading-only. Silent when the catalog is empty, the
389
+ * domain doc is absent, or the map is already present.
390
+ *
391
+ * @param {{ root?: string, config?: object, files?: readonly string[], statesTransitions?: { kind?: string, home?: string } | null }} [input]
392
+ * @returns {{ kind: 'thin', home: string, entities: object[], ask: string, nextAction: string } | null}
393
+ */
394
+ export function collectStatusTransitionCatalogResidual(input = {}) {
395
+ const catalog = collectStatusTransitionCatalog(input);
396
+ if (catalog.length === 0) return null;
397
+ const map =
398
+ input.statesTransitions === undefined
399
+ ? collectStatesTransitionsResidual({ root: input.root ?? '' })
400
+ : input.statesTransitions;
401
+ if (!map || (map.kind !== 'missing' && map.kind !== 'incomplete')) return null;
402
+ const listed = catalog.slice(0, MAX_ENTITIES);
403
+ return {
404
+ kind: 'thin',
405
+ home: map.home,
406
+ entities: listed,
407
+ ask: `Domain code already names ${formatEntityList(listed)}, but the states → transitions map is still thin.`,
408
+ nextAction: nextActionFor(map.home, listed),
409
+ };
410
+ }
@@ -235,6 +235,16 @@ export function runTeamPreflight({ root, args, config, policyDelta, teamBase })
235
235
  return { halt: null, teamParliament: null, changedPaths: [] };
236
236
  }
237
237
  const againstRef = args.against || teamBase;
238
+ if (args.local && !againstRef) {
239
+ const message =
240
+ '--local needs a git merge base so it can reuse --changed. Pass --base <ref> (for example --base HEAD or --base origin/main). The merge gate stays --strict-merge.';
241
+ const teamParliament = { deny: false, reasonId: 'local-needs-base', message };
242
+ return {
243
+ halt: { exitCode: 2, message, teamParliament },
244
+ teamParliament,
245
+ changedPaths: [],
246
+ };
247
+ }
238
248
  const listed = againstRef ? listChangedPaths(root, againstRef) : { ok: true, paths: [], error: null };
239
249
  const changedPaths = listed.ok ? listed.paths : [];
240
250
  const changeSet = classifyChangeSet(changedPaths);
@@ -97,10 +97,22 @@ export function printWarning(warning) {
97
97
  );
98
98
  }
99
99
 
100
+ /** Non-blocking type-only placement debt: exit stays 0; do not paint ✖. */
101
+ export function isNonBlockingPlacementDebt(violation) {
102
+ return violation?.failsStrict === false;
103
+ }
104
+
105
+ /** Human mark for one finding — ⚠ when the run still passes, ✖ when it fails. */
106
+ export function violationMark(violation) {
107
+ return isNonBlockingPlacementDebt(violation) ? '⚠' : '✖';
108
+ }
109
+
100
110
  export function printViolation(violation) {
101
111
  const location = `${violation.file}:${violation.line}`;
102
112
  const plane = violationPlaneLabel(violation.ruleId);
103
- console.error(`${color.red('✖')} ${color.bold(`${plane}${violation.ruleId}`)} ${location}`);
113
+ const mark = violationMark(violation);
114
+ const painted = isNonBlockingPlacementDebt(violation) ? color.yellow(mark) : color.red(mark);
115
+ console.error(`${painted} ${color.bold(`${plane}${violation.ruleId}`)} ${location}`);
104
116
  if (violation.fromLayer && violation.toLayer) {
105
117
  const target = violation.target ? ` ${color.dim(`(${violation.target})`)}` : '';
106
118
  console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
@@ -313,14 +313,20 @@ function cursorHookEvidence(root) {
313
313
  hooks = [];
314
314
  }
315
315
  const hardHooks = hooks.filter((entry) => entry.invocation.binArgs.includes('--hook'));
316
+ // Cursor failClosed is the host-native FS analog: if the checker cannot run, deny.
317
+ // A Write|StrReplace Ark hook without failClosed:true is configured but fail-open.
318
+ const failClosedHooks = hardHooks.filter((entry) => entry.hook?.failClosed === true);
316
319
  const required = requiredWriteOperations(relativePath);
317
- const hard = required.every((operation) =>
320
+ const configuredArk = required.every((operation) =>
318
321
  hardHooks.some((entry) => entry.operations.includes(operation))
319
322
  );
323
+ const hard = required.every((operation) =>
324
+ failClosedHooks.some((entry) => entry.operations.includes(operation))
325
+ );
320
326
  const repair =
321
327
  hard &&
322
328
  required.every((operation) =>
323
- hardHooks.some(
329
+ failClosedHooks.some(
324
330
  ({ hook, invocation, operations }) =>
325
331
  operations.includes(operation) &&
326
332
  (invocation.binArgs.includes('--hook-repair') ||
@@ -332,6 +338,7 @@ function cursorHookEvidence(root) {
332
338
  return {
333
339
  hard: hard ? [relativePath] : [],
334
340
  repair: repair ? [relativePath] : [],
341
+ failOpen: configuredArk && !hard,
335
342
  };
336
343
  }
337
344
 
@@ -424,7 +431,7 @@ function codexMcpEvidence(root) {
424
431
  return registered ? [relativeEvidencePath(root, file)] : [];
425
432
  }
426
433
 
427
- function hostRecord(hard, advisory, repair, merge) {
434
+ function hostRecord(hard, advisory, repair, merge, extras = {}) {
428
435
  const evidence = {
429
436
  'hard-write': unique(hard),
430
437
  'advisory-write': unique(advisory),
@@ -434,9 +441,11 @@ function hostRecord(hard, advisory, repair, merge) {
434
441
  return {
435
442
  configured:
436
443
  evidence['hard-write'].length > 0 ||
437
- evidence['advisory-write'].length > 0,
444
+ evidence['advisory-write'].length > 0 ||
445
+ extras.nativeFailClosed === false,
438
446
  capabilities: capabilityMap(evidence),
439
447
  evidence,
448
+ nativeFailClosed: extras.nativeFailClosed ?? null,
440
449
  };
441
450
  }
442
451
 
@@ -480,7 +489,11 @@ export function detectWritePathInventory(root) {
480
489
  // reinjection is not package-guaranteed — keep inventory repair-payload false
481
490
  // (envelope honesty lives on support.repair-envelope-emitted).
482
491
  [],
483
- merge
492
+ merge,
493
+ {
494
+ nativeFailClosed:
495
+ cursorHook.hard.length > 0 ? true : cursorHook.failOpen ? false : null,
496
+ }
484
497
  ),
485
498
  // Current Codex CLI and local ChatGPT Desktop/App Server synchronously run
486
499
  // PreToolUse for apply_patch. Disk evidence remains unverified until a fresh
@@ -542,6 +555,8 @@ export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
542
555
  };
543
556
  model.enforcementState = buildEnforcementState(root, { ...model, ci });
544
557
  model.enforcementLadder.ciMerge.requiredStatus = model.enforcementState.ciMerge.required;
558
+ model.nativeFailClosed = activeRecord?.nativeFailClosed ?? null;
559
+ model.nativeFailClosedPolicy = getHostSupportProfile(activeHost)?.nativeFailClosed ?? 'unsupported';
545
560
  // Ladder/state agreement stamp for pin-absent consumers (configured ≠ installed).
546
561
  if (pkg.installed !== true) {
547
562
  model.packagePinHonesty = {
@@ -54,7 +54,17 @@ export function buildUnknownHostSessionNote(inventory, capabilities = {}) {
54
54
 
55
55
  export function detectWritePathCapabilities(root, explicitHost, attempt) {
56
56
  const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
57
- const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, enforcementState, inventory } = model;
57
+ const {
58
+ activeHost,
59
+ support,
60
+ capabilities,
61
+ capabilityEvidence,
62
+ enforcementLadder,
63
+ enforcementState,
64
+ inventory,
65
+ nativeFailClosed,
66
+ nativeFailClosedPolicy,
67
+ } = model;
58
68
  const hardWrite = capabilities['hard-write'];
59
69
  const advisoryWrite = capabilities['advisory-write'];
60
70
  const repairPayload = capabilities['repair-payload'];
@@ -74,7 +84,20 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
74
84
  const inventoryHasWriteBoundary =
75
85
  Boolean(inventory?.capabilities?.['hard-write']) ||
76
86
  Boolean(inventory?.capabilities?.['advisory-write']);
77
- if (mode === 'none') {
87
+ if (nativeFailClosed === false) {
88
+ const hookPath = support?.hookPath || 'the write hook';
89
+ const label = support?.label || activeHost;
90
+ gap = {
91
+ id: 'write-path-fail-open',
92
+ severity: 'warn',
93
+ host: activeHost,
94
+ message:
95
+ `${label}: the write hook is fail-open. If the checker cannot run, the write still lands. ` +
96
+ `Set failClosed: true on ${hookPath} — same idea as a file permission: no checker, no write. ` +
97
+ 'Required CI is the shared merge line.',
98
+ fix: arkCommand(root, 'ark-check', `--install-agent-gates --tools ${tools} --force`),
99
+ };
100
+ } else if (mode === 'none') {
78
101
  if (activeHost === 'unknown' && inventoryHasWriteBoundary) {
79
102
  gap = null;
80
103
  } else {
@@ -150,6 +173,8 @@ export function detectWritePathCapabilities(root, explicitHost, attempt) {
150
173
  inventory,
151
174
  // Configured inventory (on-disk hosts) vs this-invocation projection (activeHost).
152
175
  ...(sessionNote ? { sessionNote } : {}),
176
+ nativeFailClosed: nativeFailClosed ?? null,
177
+ nativeFailClosedPolicy: nativeFailClosedPolicy ?? 'unsupported',
153
178
  // Compatibility projection for existing doctor/API consumers.
154
179
  mode,
155
180
  prepareWrite: advisoryWrite,
@@ -11,6 +11,8 @@ type ArkConfigCyclePolicy = 'strict' | 'soft' | 'framework-soft' | 'off';
11
11
  type ArkConfigLayerCapabilities = {
12
12
  deny?: string[];
13
13
  };
14
+ /** Who this folder is for. Metadata — not import-rule teeth. Absence is silent. */
15
+ type ArkConfigLayerTrustBoundary = 'public' | 'auth' | 'admin' | 'internal';
14
16
  type ArkConfigLayer = {
15
17
  name: string;
16
18
  patterns: string[];
@@ -21,6 +23,17 @@ type ArkConfigLayer = {
21
23
  * Metadata — excluded from policy hash. Absence is silent.
22
24
  */
23
25
  description?: string;
26
+ /**
27
+ * Who this folder is for: public | auth | admin | internal.
28
+ * Metadata — excluded from policy hash. Absence is silent. Not host/CI TLS.
29
+ */
30
+ trustBoundary?: ArkConfigLayerTrustBoundary;
31
+ /**
32
+ * Who owns this house: GitHub handles or emails (same identity as stewards).
33
+ * Metadata — excluded from policy hash. Absence is silent unless
34
+ * `requireLayerOwners` is true. Not import-rule teeth.
35
+ */
36
+ owners?: string[];
24
37
  forbiddenGlobals?: string[];
25
38
  /** ADR 0009 D2 — opt-in effect-capability walls; absence changes no verdict. */
26
39
  capabilities?: ArkConfigLayerCapabilities;
@@ -153,6 +166,12 @@ type ArkConfig = {
153
166
  * Metadata — excluded from policy hash. Absence means no steward lock (policy-ack still applies).
154
167
  */
155
168
  stewards?: string[];
169
+ /**
170
+ * When true, every non-reserved layer must name `owners`. Absence/false is
171
+ * silent. This flag is policy teeth (stays in policyHash). Owners themselves
172
+ * stay metadata.
173
+ */
174
+ requireLayerOwners?: boolean;
156
175
  };
157
176
  type ArkConfigIssue = {
158
177
  path: string;
@@ -1,5 +1,5 @@
1
- import { e as CreateArchitectureProfileOptions, b as ArchitectureProfile, d as ArkCheckConfig, C as CreateArchitectureProfileFromArkConfigOptions, f as CreateElevenLayerArkConfigOptions, i as Policy, j as IntentCreator, I as IntentName } from './types-Djbs3KjE.js';
2
- import { A as ArkConfig, c as ArkConfigLoadResult } from './configTypes-j7so8B4O.js';
1
+ import { e as CreateArchitectureProfileOptions, b as ArchitectureProfile, d as ArkCheckConfig, C as CreateArchitectureProfileFromArkConfigOptions, f as CreateElevenLayerArkConfigOptions, i as Policy, j as IntentCreator, I as IntentName } from './types-CN9tVMPz.js';
2
+ import { A as ArkConfig, c as ArkConfigLoadResult } from './configTypes-Dt3DpVbd.js';
3
3
 
4
4
  /** Versioned public result contract types + JSON Schema (adapter envelope). */
5
5
  /**
@@ -392,7 +392,7 @@ declare function createAdapterResult(input: {
392
392
  }): CurrentAdapterResult;
393
393
 
394
394
  /** ArkGate library version — single source of truth. */
395
- declare const version = "4.8.14";
395
+ declare const version = "4.8.15";
396
396
 
397
397
  /**
398
398
  * AI Code Gate (basic).