arkgate 4.8.7 → 4.8.9

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 (59) hide show
  1. package/CHANGELOG.md +92 -3
  2. package/README.md +41 -6
  3. package/bin/ark-check-runtime.mjs +22 -0
  4. package/bin/ark-dashboard.mjs +423 -0
  5. package/bin/ark-layer-match.mjs +25 -10
  6. package/bin/ark.mjs +51 -3
  7. package/bin/lib/agent-homes.mjs +1 -1
  8. package/bin/lib/analysis-engine.mjs +8 -8
  9. package/bin/lib/ark-order-sensors.mjs +103 -3
  10. package/bin/lib/config-extras.mjs +1 -0
  11. package/bin/lib/contract-smells.mjs +12 -6
  12. package/bin/lib/doctor-human.mjs +32 -7
  13. package/bin/lib/doctor-next-actions.mjs +21 -2
  14. package/bin/lib/gate-files.mjs +108 -22
  15. package/bin/lib/managed-upgrade.mjs +9 -1
  16. package/bin/lib/upgrade-command.mjs +17 -4
  17. package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
  18. package/dist/{diagnosticCatalog-wDAH08gH.d.ts → diagnosticCatalog-BrkOiwCk.d.ts} +3 -3
  19. package/dist/eslint/index.cjs +5 -5
  20. package/dist/eslint/index.d.ts +6 -4
  21. package/dist/eslint/index.js +5 -5
  22. package/dist/index.cjs +30 -30
  23. package/dist/index.d.ts +13 -4
  24. package/dist/index.js +31 -31
  25. package/dist/nestjs/index.cjs +5 -5
  26. package/dist/nestjs/index.d.ts +3 -3
  27. package/dist/nestjs/index.js +5 -5
  28. package/dist/order/index.cjs +1 -1
  29. package/dist/order/index.d.ts +6 -2
  30. package/dist/order/index.js +1 -1
  31. package/dist/runtime/index.cjs +15 -15
  32. package/dist/runtime/index.d.ts +6 -6
  33. package/dist/runtime/index.js +15 -15
  34. package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
  35. package/dist/{types-CwZ_oz1N.d.ts → types-tGhZUiGX.d.ts} +106 -2
  36. package/docs/README.md +13 -4
  37. package/docs/agent-guide.md +38 -2
  38. package/docs/ai-gates.md +13 -1
  39. package/docs/arkorder.md +41 -8
  40. package/docs/configuration.md +47 -12
  41. package/docs/develop.md +17 -8
  42. package/docs/enthusiast/README.md +13 -2
  43. package/docs/package-surface.md +20 -6
  44. package/docs/product-voice.md +25 -2
  45. package/docs/use.md +11 -3
  46. package/package.json +3 -1
  47. package/schemas/ark.config.schema.json +9 -0
  48. package/server.json +2 -2
  49. package/templates/agent-skills/README.md +1 -1
  50. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  51. package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
  52. package/templates/agent-skills/ark-contract/SKILL.md +1 -1
  53. package/templates/agent-skills/ark-explore/SKILL.md +2 -2
  54. package/templates/agent-skills/ark-place/SKILL.md +1 -0
  55. package/templates/skills/ark-adopt.md +1 -0
  56. package/templates/skills/ark-autopilot.md +1 -1
  57. package/templates/skills/ark-contract.md +1 -1
  58. package/templates/skills/ark-explore.md +2 -2
  59. package/templates/skills/ark-place.md +1 -0
@@ -11,6 +11,102 @@
11
11
  import { extractArkOrderGenericUpdatesFromSource, extractArkOrderIngestWritesXiFromSource, extractArkOrderPlaneCallsFromSource, extractArkOrderReleaseKeyCountsFromSource, extractArkOrderXiFieldWritesFromSource, isArkOrderModuleSpecifier, } from './ark-order-facts.mjs';
12
12
  import { extraMergeTeethAllowed, } from './extra-merge-teeth.mjs';
13
13
  import { deterministicNextAction } from './remediation.mjs';
14
+ /**
15
+ * XIWRITE-001: same engine as `globToRegExp` in src/domain/layerMatch.ts.
16
+ * Inlined so generate:cli-pure emits a self-contained bin/lib/ark-order-sensors.mjs
17
+ * (layerMatch is derived to bin/ark-layer-match.mjs, not a bin/lib sibling).
18
+ */
19
+ const appliesToRegexpCache = new Map();
20
+ function escapeAppliesToLiteral(ch) {
21
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
22
+ }
23
+ function normalizeAppliesToGlob(pattern) {
24
+ let out = '';
25
+ for (let i = 0; i < pattern.length; i += 1) {
26
+ const c = pattern[i];
27
+ if (c === '\\' && i + 1 < pattern.length) {
28
+ const next = pattern[i + 1];
29
+ if ('*?{}[],'.includes(next) || next === '\\') {
30
+ out += '\\' + next;
31
+ i += 1;
32
+ continue;
33
+ }
34
+ out += '/';
35
+ continue;
36
+ }
37
+ out += c;
38
+ }
39
+ return out;
40
+ }
41
+ function appliesToBracesBalanced(glob) {
42
+ let depth = 0;
43
+ for (let i = 0; i < glob.length; i += 1) {
44
+ const c = glob[i];
45
+ if (c === '\\') {
46
+ i += 1;
47
+ continue;
48
+ }
49
+ if (c === '{')
50
+ depth += 1;
51
+ else if (c === '}') {
52
+ depth -= 1;
53
+ if (depth < 0)
54
+ return false;
55
+ }
56
+ }
57
+ return depth === 0;
58
+ }
59
+ function globToRegExp(pattern) {
60
+ const cached = appliesToRegexpCache.get(pattern);
61
+ if (cached)
62
+ return cached;
63
+ const glob = normalizeAppliesToGlob(pattern);
64
+ const useBraces = appliesToBracesBalanced(glob);
65
+ let out = '';
66
+ let braceDepth = 0;
67
+ for (let i = 0; i < glob.length; i += 1) {
68
+ const c = glob[i];
69
+ if (c === '\\' && i + 1 < glob.length) {
70
+ out += escapeAppliesToLiteral(glob[i + 1]);
71
+ i += 1;
72
+ }
73
+ else if (c === '*') {
74
+ if (glob[i + 1] === '*') {
75
+ if (glob[i + 2] === '/') {
76
+ out += '(?:.*/)?';
77
+ i += 2;
78
+ }
79
+ else {
80
+ out += '.*';
81
+ i += 1;
82
+ }
83
+ }
84
+ else {
85
+ out += '[^/]*';
86
+ }
87
+ }
88
+ else if (c === '?') {
89
+ out += '[^/]';
90
+ }
91
+ else if (c === '{' && useBraces) {
92
+ out += '(?:';
93
+ braceDepth += 1;
94
+ }
95
+ else if (c === '}' && useBraces && braceDepth > 0) {
96
+ out += ')';
97
+ braceDepth -= 1;
98
+ }
99
+ else if (c === ',' && useBraces && braceDepth > 0) {
100
+ out += '|';
101
+ }
102
+ else {
103
+ out += escapeAppliesToLiteral(c);
104
+ }
105
+ }
106
+ const re = new RegExp(`^${out}$`);
107
+ appliesToRegexpCache.set(pattern, re);
108
+ return re;
109
+ }
14
110
  export const ARKORDER_TIER1_SENSOR_IDS = [
15
111
  'arkorder-missing-plane',
16
112
  'arkorder-kernel-in-domain',
@@ -31,6 +127,11 @@ export const ARKORDER_RULE_IDS = {
31
127
  'arkorder-information-budget': 'ARKORDER_INFORMATION_BUDGET',
32
128
  'arkorder-xi-ttl': 'ARKORDER_XI_TTL',
33
129
  };
130
+ function matchesArkOrderAppliesTo(file, appliesTo) {
131
+ if (!appliesTo || appliesTo.length === 0)
132
+ return true;
133
+ return appliesTo.some((pattern) => globToRegExp(pattern).test(file));
134
+ }
34
135
  function isDomainRoleLayer(layer, intentPrefixes = []) {
35
136
  const name = layer.trim();
36
137
  if (/^domain(?:model)?$/i.test(name) || /^domain(?=[A-Z_\-\s])/i.test(name))
@@ -103,9 +204,6 @@ export function evaluateArkOrderSensors(input) {
103
204
  findings.push(finding(extra, 'arkorder-generic-update', update.file, update.line, `Generic ${update.method}() on the order plane rewrites ξ; Haken forbids it.`, { target: update.method }, teethAllowed));
104
205
  }
105
206
  const xiKeys = extra.xiKeys ?? [];
106
- if (xiKeys.length > extra.maxXiKeys) {
107
- findings.push(finding(extra, 'arkorder-too-many-params', 'ark.config.json', 1, `arkOrder.xiKeys has ${xiKeys.length} keys; maxXiKeys is ${extra.maxXiKeys} (few slow modes).`, { target: String(xiKeys.length) }, teethAllowed));
108
- }
109
207
  for (const release of input.releaseKeyCounts ?? []) {
110
208
  if (release.keyCount <= extra.maxXiKeys)
111
209
  continue;
@@ -119,6 +217,8 @@ export function evaluateArkOrderSensors(input) {
119
217
  const fromLayer = input.layerForFile(write.file);
120
218
  if (!fromLayer || !managed.has(fromLayer))
121
219
  continue;
220
+ if (!matchesArkOrderAppliesTo(write.file, extra.appliesTo))
221
+ continue;
122
222
  findings.push(finding(extra, 'arkorder-xi-field-write', write.file, write.line, `File writes slow key ${JSON.stringify(write.key)} through a persistence driver; route the field through ingest or a pattern change through proposeRelease.`, { fromLayer, target: write.key }, teethAllowed));
123
223
  }
124
224
  findings.sort((left, right) => left.file.localeCompare(right.file) ||
@@ -42,6 +42,7 @@ export const ARK_ORDER_SCHEMA_DEF = {
42
42
  managedLayers: { ...stringArraySchema, default: [] },
43
43
  maxXiKeys: { type: 'integer', minimum: 1, default: 7 },
44
44
  xiKeys: { ...stringArraySchema, default: [] },
45
+ appliesTo: { ...stringArraySchema, default: [] },
45
46
  },
46
47
  };
47
48
  function isObject(value) {
@@ -41,7 +41,7 @@ export const CONTRACT_SMELL_OUTCOMES = Object.freeze({
41
41
  'contract-lateral-adapter-allow':
42
42
  'One adapter layer may import another adapter family directly — shared mappers/aliases will pile up in the wrong place. Move shared shapes into Domain (or a shared kernel) instead of adapter-to-adapter reach.',
43
43
  'contract-dead-rule':
44
- 'A rule enforces nothing: it points at a layer that matches no files or does not exist, or both sides are the same layer. Fix the layer patterns or delete the rule.',
44
+ 'A rule enforces nothing: it points at a layer that matches no files or does not exist, or is a same-layer allow (classic same-layer deny is also a no-op). A same-layer peerIsolation deny is a live wall, not a dead rule.',
45
45
  });
46
46
 
47
47
  export const CONTRACT_SMELL_ACKS_PATH = '.ark/contract-smell-acks.json';
@@ -277,14 +277,20 @@ export function analyzeContractSmells(
277
277
  }
278
278
  }
279
279
 
280
- // 4) Dead rules: self edges (the gate ignores same-layer rules), unknown layers,
281
- // or — when coverage is known — layers matching zero files (optional layers exempt).
280
+ // 4) Dead rules: self-allow and classic same-layer deny (the gate ignores
281
+ // those), unknown layers, or — when coverage is known — layers matching
282
+ // zero files (optional layers exempt). `peerIsolation: true` + `allowed:
283
+ // false` is a live self-edge wall, not a dead rule.
282
284
  for (const r of rules) {
283
285
  const edge = `${r.from}->${r.to}`;
284
286
  const ackEdge = ackMatchable(r.from, r.to) ? edge : null;
285
287
  if (r.from === r.to) {
286
- add('contract-dead-rule', ackEdge, `rule:${edge} (self edge has no effect)`);
287
- continue;
288
+ const livePeerIsolationWall = r.peerIsolation === true && r.allowed === false;
289
+ if (!livePeerIsolationWall) {
290
+ add('contract-dead-rule', ackEdge, `rule:${edge} (self edge has no effect)`);
291
+ continue;
292
+ }
293
+ // Live slice wall on a self-edge — still check unknown/empty layers.
288
294
  }
289
295
  for (const side of [r.from, r.to]) {
290
296
  if (side.length === 0) continue;
@@ -409,7 +415,7 @@ function fixFor(id) {
409
415
  case 'contract-lateral-adapter-allow':
410
416
  return `Move shared shapes into Domain/shared kernel and drop the lateral allow (/ark-adopt), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
411
417
  case 'contract-dead-rule':
412
- return 'Fix the layer patterns so the layer matches real files, or delete the stale/self rule via /ark-adopt.';
418
+ return 'Fix the layer patterns so the layer matches real files, or delete a stale self-allow / classic self-deny via /ark-adopt. Never delete a live peerIsolation wall (`peerIsolation: true` with `allowed: false`).';
413
419
  default:
414
420
  return 'Review the contract edge via /ark-adopt; never weaken the gate to silence a smell.';
415
421
  }
@@ -13,6 +13,23 @@ import { enforcementDoctorLines } from './enforcement-state.mjs';
13
13
  import { analysisIncompleteStatement } from './analysis-completeness.mjs';
14
14
  import { skillGapsForActiveHost, detectCodexHomeGap, codexConcernIsActive } from './agent-gates.mjs';
15
15
  import { agentHomeConcernIsActive } from './agent-homes.mjs';
16
+ import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
17
+
18
+ function displayedMissingGates(gatesMissing, view) {
19
+ const list = Array.isArray(gatesMissing) ? gatesMissing : [];
20
+ const hideGlob = Boolean(
21
+ view?.ciNotFailClosed?.workflowFile ||
22
+ view?.ciNotFailClosed?.error === 'ci-not-fail-closed' ||
23
+ view?.ciMergeBoundary?.ci?.workflowPresent
24
+ );
25
+ return hideGlob ? list.filter((item) => item !== REQUIRED_GATE_WORKFLOW) : list;
26
+ }
27
+
28
+ function ciNotFailClosedNotice(view) {
29
+ const file = view?.ciNotFailClosed?.workflowFile;
30
+ if (!file) return null;
31
+ return `CI not fail-closed: ${file} — remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only`;
32
+ }
16
33
 
17
34
  function lineWith(ok, warn, bad, color) {
18
35
  return (mark, text) => console.log(` ${mark} ${text}`);
@@ -52,6 +69,8 @@ export function printDoctorCompactHuman(view) {
52
69
  gatesMissing,
53
70
  violations,
54
71
  } = view;
72
+ const listedMissing = displayedMissingGates(gatesMissing, view);
73
+ const skippableCi = ciNotFailClosedNotice(view);
55
74
 
56
75
  console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
57
76
  if (!analysisComplete) line(warn, analysisIncompleteStatement(completeness));
@@ -123,15 +142,17 @@ export function printDoctorCompactHuman(view) {
123
142
  }
124
143
 
125
144
  const hostRed =
126
- gatesMissing.length > 0 ||
145
+ listedMissing.length > 0 ||
127
146
  Boolean(writePath.gap) ||
128
- writePathHonesty?.softWriteHost === true;
147
+ writePathHonesty?.softWriteHost === true ||
148
+ Boolean(skippableCi);
129
149
  if (hostRed) {
130
150
  console.log('');
131
151
  console.log(color.bold('Host / CI'));
132
152
  if (writePath.activeHost) line(' ', `Active host: ${writePath.activeHost}`);
133
- if (gatesMissing.length > 0) line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
134
- else if (writePath.gap || writePathHonesty?.softWriteHost) {
153
+ if (listedMissing.length > 0) line(bad, `Missing gates: ${listedMissing.join(', ')}`);
154
+ if (skippableCi) line(warn, skippableCi);
155
+ else if (listedMissing.length === 0 && (writePath.gap || writePathHonesty?.softWriteHost)) {
135
156
  line(warn, 'Local writes are advisory; required CI is the merge boundary.');
136
157
  }
137
158
  }
@@ -212,6 +233,8 @@ export function printDoctorDetailsHuman(view) {
212
233
  staleRunners,
213
234
  adoption,
214
235
  } = view;
236
+ const listedMissing = displayedMissingGates(gatesMissing, view);
237
+ const skippableCi = ciNotFailClosedNotice(view);
215
238
  const modeTitle = operatingModeTitle(operatingMode, designFitness.designWeak, stewardUnfinished);
216
239
 
217
240
  console.log('');
@@ -385,9 +408,11 @@ export function printDoctorDetailsHuman(view) {
385
408
 
386
409
  console.log('');
387
410
  console.log(color.bold('Gates & skills'));
388
- if (gatesMissing.length === 0) line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
389
- else {
390
- line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
411
+ if (listedMissing.length === 0 && !skippableCi) {
412
+ line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
413
+ } else {
414
+ if (listedMissing.length > 0) line(bad, `Missing gates: ${listedMissing.join(', ')}`);
415
+ if (skippableCi) line(warn, skippableCi);
391
416
  }
392
417
  const humanSkillGaps = skillGapsForActiveHost(skillGaps);
393
418
  const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
@@ -7,10 +7,20 @@ import { skillGapsForActiveHost } from './agent-gates.mjs';
7
7
  import { agentHomeConcernIsActive, agentHomeRefreshCommand } from './agent-homes.mjs';
8
8
  import { mergePostGreenTopActions } from './post-green-path.mjs';
9
9
  import { ADOPTED_NOT, NOT_ADOPTED_NEXT_ACTION } from './adoption-stance.mjs';
10
+ import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
11
+
12
+ function missingGateFiles(ctx) {
13
+ const list = Array.isArray(ctx.gatesMissing) ? ctx.gatesMissing : [];
14
+ if (ctx.ciNotFailClosed) {
15
+ return list.filter((item) => item !== REQUIRED_GATE_WORKFLOW);
16
+ }
17
+ return list;
18
+ }
10
19
 
11
20
  export function collectDoctorNextActions(ctx) {
12
21
  const actions = [];
13
- const gatesInstalled = Array.isArray(ctx.gatesMissing) && ctx.gatesMissing.length === 0;
22
+ const missingFiles = missingGateFiles(ctx);
23
+ const gatesInstalled = missingFiles.length === 0;
14
24
  const planAEmpty = !ctx.activeCount;
15
25
  const notAdopted = ctx.adopted !== 'required-merge' && ctx.adopted !== 'advisory-only-acked';
16
26
  if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
@@ -55,9 +65,18 @@ export function collectDoctorNextActions(ctx) {
55
65
  );
56
66
  }
57
67
  if (ctx.writePath?.gap?.fix && !gatesInstalled) actions.push(ctx.writePath.gap.fix);
58
- if (!gatesInstalled && ctx.gatesMissing.length > 0) {
68
+ if (!gatesInstalled && missingFiles.length > 0) {
59
69
  actions.push(`install gates (${arkCommand(ctx.root, 'ark-check', '--install-agent-gates')})`);
60
70
  }
71
+ if (ctx.ciNotFailClosed) {
72
+ const file = ctx.ciNotFailClosed.workflowFile;
73
+ actions.push(
74
+ ctx.ciNotFailClosed.nextAction ||
75
+ (file
76
+ ? `Remove the skippable if: in ${file}, or write .ark/adoption-stance.json with stance: advisory-only`
77
+ : 'Remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only')
78
+ );
79
+ }
61
80
  const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps);
62
81
  const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
63
82
  const remainingGaps = humanSkillGaps.filter(
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { codexProjectMcpIsValid } from './codex-home.mjs';
8
- import { enforcingArkRunText } from './github-enforcement.mjs';
8
+ import { enforcingArkRunText, runsArkCheck } from './github-enforcement.mjs';
9
9
 
10
10
  export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
11
  export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
@@ -120,7 +120,8 @@ export const REQUIRED_GATE_FILES = [
120
120
  'AGENTS.md',
121
121
  '.mcp.json',
122
122
  ];
123
- const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
123
+ export const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
124
+ export const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
124
125
  const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
125
126
  const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
126
127
 
@@ -584,27 +585,112 @@ function withVerifiedDependencyJobs(content) {
584
585
  return lines.join('\n');
585
586
  }
586
587
 
587
- export function hasArkWorkflow(root) {
588
+ function isGuaranteedJobCondition(value) {
589
+ const text = unquoteYamlScalar(value);
590
+ return (
591
+ /^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(text) ||
592
+ /^(?:true|\$\{\{\s*true\s*\}\})$/i.test(text)
593
+ );
594
+ }
595
+
596
+ function neutralizeSkippableJobControls(content) {
597
+ const { lines, jobs } = workflowJobSections(content);
598
+ for (const job of jobs) {
599
+ const condition = jobProperty(lines, job, 'if');
600
+ if (condition && !isGuaranteedJobCondition(condition.value)) {
601
+ lines[condition.index] = lines[condition.index].replace(
602
+ /^(\s*(?:"if"|'if'|if):\s*).*$/i,
603
+ '$1true'
604
+ );
605
+ }
606
+ const continuation = jobProperty(lines, job, 'continue-on-error');
607
+ if (continuation && !/^['"]?false['"]?$/i.test(unquoteYamlScalar(continuation.value))) {
608
+ lines[continuation.index] = lines[continuation.index].replace(
609
+ /^(\s*(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*).*$/i,
610
+ '$1false'
611
+ );
612
+ }
613
+ }
614
+ return lines.join('\n');
615
+ }
616
+
617
+ function workflowMentionsArkAction(content) {
618
+ return /^\s*(?:-\s+)?uses:\s*['"]?pedroknigge\/arkgate@/im.test(String(content));
619
+ }
620
+
621
+ function listWorkflowYamlFiles(root) {
588
622
  const workflowsDir = path.join(root, '.github', 'workflows');
589
- if (!fs.existsSync(workflowsDir)) return false;
623
+ if (!fs.existsSync(workflowsDir)) return [];
624
+ try {
625
+ return fs.readdirSync(workflowsDir).filter((file) => /\.ya?ml$/i.test(file));
626
+ } catch {
627
+ return [];
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Presence vs fail-closed. A skippable `if:` still means the YAML exists.
633
+ * `failClosed` matches `hasArkWorkflow` (merge line). `present` is any
634
+ * ark-check / arkgate action workflow, including draft-skip jobs.
635
+ */
636
+ export function inspectArkCiGate(root) {
637
+ const failClosedFiles = [];
638
+ const presentFiles = [];
590
639
  const declaredScript = architectureScript(root);
591
- const script = isFailClosedArchitectureScript(declaredScript) ? declaredScript : '';
592
- return fs
593
- .readdirSync(workflowsDir)
594
- .filter((file) => /\.ya?ml$/i.test(file))
595
- .some((file) => {
596
- try {
597
- const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
598
- return FAIL_CLOSED_ARK_FLAG.test(
599
- enforcingArkRunText(
600
- withVerifiedDependencyJobs(withFailClosedArkActions(content)),
601
- script
602
- )
603
- );
604
- } catch {
605
- return false;
606
- }
607
- });
640
+ const failClosedScript = isFailClosedArchitectureScript(declaredScript)
641
+ ? declaredScript
642
+ : '';
643
+ const workflowsDir = path.join(root, '.github', 'workflows');
644
+ for (const file of listWorkflowYamlFiles(root)) {
645
+ let content = '';
646
+ try {
647
+ content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
648
+ } catch {
649
+ continue;
650
+ }
651
+ const prepared = withVerifiedDependencyJobs(withFailClosedArkActions(content));
652
+ const failClosed = FAIL_CLOSED_ARK_FLAG.test(
653
+ enforcingArkRunText(prepared, failClosedScript)
654
+ );
655
+ const visible = withVerifiedDependencyJobs(
656
+ neutralizeSkippableJobControls(withFailClosedArkActions(content))
657
+ );
658
+ const present =
659
+ failClosed ||
660
+ runsArkCheck(visible, declaredScript) ||
661
+ workflowMentionsArkAction(content);
662
+ const relativePath = `.github/workflows/${file}`;
663
+ if (failClosed) failClosedFiles.push(relativePath);
664
+ if (present) presentFiles.push(relativePath);
665
+ }
666
+ return {
667
+ failClosed: failClosedFiles.length > 0,
668
+ present: presentFiles.length > 0,
669
+ failClosedFiles,
670
+ presentFiles,
671
+ workflowFile:
672
+ presentFiles.find((file) => !failClosedFiles.includes(file)) ??
673
+ presentFiles[0] ??
674
+ null,
675
+ };
676
+ }
677
+
678
+ export function hasArkWorkflow(root) {
679
+ return inspectArkCiGate(root).failClosed;
680
+ }
681
+
682
+ export function ciNotFailClosed(root) {
683
+ const ci = inspectArkCiGate(root);
684
+ if (!ci.present || ci.failClosed) return null;
685
+ const workflowFile = ci.workflowFile;
686
+ const named = workflowFile || REQUIRED_GATE_WORKFLOW;
687
+ return {
688
+ error: CI_NOT_FAIL_CLOSED_ERROR,
689
+ workflowFile,
690
+ workflowFiles: ci.presentFiles,
691
+ nextAction: `Remove the skippable if: in ${named}, or write .ark/adoption-stance.json with stance: advisory-only`,
692
+ message: `CI is not fail-closed. Workflow ${named} runs ark-check but a skippable if: is not a merge line.`,
693
+ };
608
694
  }
609
695
 
610
696
  export function missingGates(root) {
@@ -615,7 +701,7 @@ export function missingGates(root) {
615
701
  if (compactHost && !hasCompactHostRegistration(root, compactHost)) {
616
702
  missing.push(`compact host registration (${compactHost})`);
617
703
  }
618
- if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
704
+ if (!inspectArkCiGate(root).present) missing.push(REQUIRED_GATE_WORKFLOW);
619
705
  return missing;
620
706
  }
621
707
 
@@ -494,8 +494,12 @@ export function planManagedUpgrade(root, options = {}) {
494
494
  action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
495
495
  willApply: canApply,
496
496
  blocked,
497
+ // Raw scoped bytes. state/willApply use identity, not these hashes.
497
498
  beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
498
499
  afterHash: hash(Buffer.from(desiredScoped)),
500
+ beforeIdentity:
501
+ currentScoped == null ? null : managedContentIdentity(currentScoped, catalogAsset.kind),
502
+ afterIdentity: managedContentIdentity(desiredScoped, catalogAsset.kind),
499
503
  containerBeforeHash: hash(currentFile),
500
504
  [AFTER_CONTENT]: desiredFile,
501
505
  };
@@ -531,6 +535,8 @@ export function planManagedUpgrade(root, options = {}) {
531
535
  blocked: false,
532
536
  beforeHash: recorded.baseHash,
533
537
  afterHash: null,
538
+ beforeIdentity: recorded.contentIdentity ?? null,
539
+ afterIdentity: null,
534
540
  containerBeforeHash: null,
535
541
  });
536
542
  }
@@ -834,7 +840,9 @@ function assertAssetUnchanged(root, asset) {
834
840
  export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
835
841
  const resolvedRoot = path.resolve(root);
836
842
  if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
837
- if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
843
+ if (plan.summary.blocked > 0) {
844
+ return publicPlan(plan, { blocked: true, reasonCode: 'managed-consent-required' });
845
+ }
838
846
  const wouldWrite = plan.summary.wouldWrite ?? 0;
839
847
  // Content already matches: unbound --apply is a no-op (exit success), not a digest error.
840
848
  if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
@@ -603,10 +603,23 @@ export function runUpgradeCommand(args, dependencies) {
603
603
  return 2;
604
604
  }
605
605
  if (applied.blocked) {
606
- if (args.json) console.log(JSON.stringify(applied, null, 2));
607
- else renderManagedUpgrade(applied, {
608
- next: 'Preview again with --accept-conflicts, then use that preview\'s exact next command.',
609
- });
606
+ const command = buildUpgradeNextCommand(
607
+ { ...args, acceptConflicts: true },
608
+ applied.planDigest
609
+ );
610
+ if (args.json) {
611
+ console.log(
612
+ managedUpgradeJson(applied, {
613
+ blocked: true,
614
+ reasonCode: applied.reasonCode ?? 'managed-consent-required',
615
+ nextCommand: command,
616
+ })
617
+ );
618
+ } else {
619
+ renderManagedUpgrade(applied, {
620
+ next: 'Preview again with --accept-conflicts, then use that preview\'s exact next command.',
621
+ });
622
+ }
610
623
  return 1;
611
624
  }
612
625
  if (applied.nothingToApply && !applied.applied) {
@@ -105,12 +105,24 @@ type ArkConfigArkOrder = {
105
105
  mode: ArkConfigArkOrderMode;
106
106
  planeRoots: string[];
107
107
  managedLayers: string[];
108
+ /**
109
+ * Haken cap on one `release()` / `assertXiKeyCap` (default 7).
110
+ * Not a cap on the `xiKeys` watchlist length.
111
+ */
108
112
  maxXiKeys: number;
109
113
  /**
110
114
  * Slow product keys the team can already name (plan, cost code, protocol).
111
115
  * Optional. Empty → `ARKORDER_XI_FIELD_WRITE` stays silent.
116
+ * Repo-wide watchlist — not compared to `maxXiKeys`.
112
117
  */
113
118
  xiKeys: string[];
119
+ /**
120
+ * Optional globs that narrow ξ field-write observation inside `managedLayers`.
121
+ * Same glob engine as `layers[].patterns`. Absence or empty → every file in
122
+ * those layers. Non-empty → emit only when the layer is managed AND the file
123
+ * matches at least one glob.
124
+ */
125
+ appliesTo?: string[];
114
126
  };
115
127
  type ArkConfig = {
116
128
  $schema: 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-BK47clMl.js';
2
- import { A as ArkConfig, c as ArkConfigLoadResult } from './configTypes-0eHpocR3.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-Djbs3KjE.js';
2
+ import { A as ArkConfig, c as ArkConfigLoadResult } from './configTypes-j7so8B4O.js';
3
3
 
4
4
  /** Versioned public result contract shared by every ArkGate enforcement adapter. */
5
5
  /**
@@ -409,7 +409,7 @@ declare const ARK_ANALYSIS_RESULT_SCHEMA: {
409
409
  };
410
410
 
411
411
  /** ArkGate library version — single source of truth. */
412
- declare const version = "4.8.7";
412
+ declare const version = "4.8.9";
413
413
 
414
414
  /**
415
415
  * AI Code Gate (basic).