arkgate 4.8.8 → 4.8.10

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 (65) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +9 -6
  3. package/bin/ark-check-runtime.mjs +24 -2
  4. package/bin/ark-layer-match.mjs +25 -10
  5. package/bin/ark.mjs +18 -10
  6. package/bin/lib/agent-homes.mjs +1 -1
  7. package/bin/lib/analysis-engine.mjs +8 -8
  8. package/bin/lib/architecture-scan.mjs +91 -4
  9. package/bin/lib/ark-order-facts.mjs +11 -4
  10. package/bin/lib/ark-order-sensors.mjs +103 -3
  11. package/bin/lib/arkrule-file-hints.mjs +255 -20
  12. package/bin/lib/arkrules-sensors.mjs +364 -68
  13. package/bin/lib/baseline-key.mjs +45 -1
  14. package/bin/lib/config-contract.mjs +9 -3
  15. package/bin/lib/config-extras.mjs +1 -0
  16. package/bin/lib/contract-smells.mjs +12 -6
  17. package/bin/lib/diagnostic-catalog.mjs +1 -0
  18. package/bin/lib/doctor-human.mjs +35 -10
  19. package/bin/lib/doctor-next-actions.mjs +24 -3
  20. package/bin/lib/field-install.mjs +23 -2
  21. package/bin/lib/first-run-help.mjs +69 -5
  22. package/bin/lib/gate-files.mjs +108 -22
  23. package/bin/lib/managed-upgrade.mjs +9 -1
  24. package/bin/lib/resolved-candidate-facts.mjs +82 -1
  25. package/bin/lib/rules-inventory.mjs +7 -3
  26. package/bin/lib/upgrade-command.mjs +17 -4
  27. package/bin/lib/upstream-report.mjs +330 -0
  28. package/bin/lib/violations.mjs +51 -15
  29. package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
  30. package/dist/{diagnosticCatalog-DxKCTBbp.d.ts → diagnosticCatalog-biferT4R.d.ts} +11 -5
  31. package/dist/eslint/index.cjs +5 -8
  32. package/dist/eslint/index.d.ts +6 -4
  33. package/dist/eslint/index.js +5 -8
  34. package/dist/index.cjs +30 -33
  35. package/dist/index.d.ts +18 -6
  36. package/dist/index.js +30 -33
  37. package/dist/nestjs/index.cjs +3 -3
  38. package/dist/nestjs/index.d.ts +3 -3
  39. package/dist/nestjs/index.js +2 -2
  40. package/dist/order/index.cjs +1 -1
  41. package/dist/order/index.d.ts +6 -2
  42. package/dist/order/index.js +1 -1
  43. package/dist/runtime/index.cjs +11 -11
  44. package/dist/runtime/index.d.ts +6 -6
  45. package/dist/runtime/index.js +11 -11
  46. package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
  47. package/dist/{types-DxvmJO-D.d.ts → types-tGhZUiGX.d.ts} +1 -1
  48. package/docs/README.md +4 -3
  49. package/docs/agent-guide.md +27 -2
  50. package/docs/ai-gates.md +8 -0
  51. package/docs/arkorder.md +30 -7
  52. package/docs/brownfield-adoption.md +30 -0
  53. package/docs/configuration.md +61 -14
  54. package/docs/develop.md +4 -2
  55. package/docs/diagnostics.md +10 -0
  56. package/docs/package-surface.md +7 -5
  57. package/docs/use.md +11 -0
  58. package/package.json +1 -1
  59. package/schemas/ark.config.schema.json +12 -2
  60. package/server.json +2 -2
  61. package/templates/agent-skills/README.md +1 -1
  62. package/templates/agent-skills/ark-contract/SKILL.md +1 -1
  63. package/templates/agent-skills/ark-explore/SKILL.md +24 -3
  64. package/templates/skills/ark-contract.md +1 -1
  65. package/templates/skills/ark-explore.md +24 -3
@@ -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
  }
@@ -55,6 +55,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
55
55
  entry('ARKRULE_STRUCTURE', 'arkrules', 'ArkRule structure sensor failed', 'An opt-in ArkRules structure sensor (private state, factory shape, event publish, persistence write outside an aggregate, …) failed on a governed file for a declared arkruleId.', 'Restore the declared structure for the ArkRule (see arkruleSource), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.'),
56
56
  entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
57
57
  entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
58
+ entry('ARKRULE_HINT_BUDGET_EXHAUSTED', 'arkrules', 'Structural-hint budget exhausted', 'orchestration-only, thin-adapter, and writes-via-aggregate only evaluate files the hint loader preloaded. When eligible governed files exceed that budget (coverage.maxFiles, default 400 — there is no arkrules.hintBudget), those sensors never saw the rest of their scope. Enforced + unreviewed is not green. The finding names exact hinted/governed counts and per-sensor reviewed N/M of scope.', 'Raise coverage.maxFiles in ark.config.json (this cap also bounds structural-hint preload; --doctor names the coupling) so hinted/governed counts match, then re-run with --strict-config. An enforced hint sensor that cannot see its scope fails strict.'),
58
59
  entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green. When the message reports an exhausted file budget, raise coverage.maxFiles (or narrow coverage.testGlobs) in ark.config.json.'),
59
60
  entry('INVARIANT_COVERAGE_OUTSIDE_ROOTS', 'arkrules', 'Covering test outside the declared coverage roots', 'The only test naming this invariant sits outside coverage.coverageRoots — the places the project declares its runner executes. ArkGate matches declared text and never executes tests, so it cannot tell whether that file is ever run: coverage there is a test that exists, not a test that runs.', 'Move the test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json. Advisory: it never fails strict, but promotion to enforced refuses on it.', { oftenAdvisory: true }),
60
61
  // ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
@@ -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('');
@@ -336,7 +359,7 @@ export function printDoctorDetailsHuman(view) {
336
359
  `${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
337
360
  );
338
361
  for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
339
- if (summary.concentrated) {
362
+ if (summary.concentrated && typeof summary.dominant === 'string' && summary.dominant.includes(' → ')) {
340
363
  line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
341
364
  }
342
365
  }
@@ -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);
@@ -449,7 +474,7 @@ export function printDoctorDetailsHuman(view) {
449
474
  console.log('');
450
475
  console.log(color.bold('Baseline'));
451
476
  if (!baseline.exists) {
452
- line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
477
+ line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline --force --contract-session --author <steward>' : 'No baseline (nothing to freeze)');
453
478
  } else {
454
479
  const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
455
480
  line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
@@ -457,7 +482,7 @@ export function printDoctorDetailsHuman(view) {
457
482
  line(warn, baselineHonesty.message);
458
483
  }
459
484
  if (analysisComplete && staleBaseline > 0) {
460
- line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline`);
485
+ line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline --force --contract-session --author <steward>`);
461
486
  }
462
487
  }
463
488
 
@@ -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(
@@ -97,7 +116,9 @@ export function collectDoctorNextActions(ctx) {
97
116
  actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
98
117
  }
99
118
  if (ctx.analysisComplete && ctx.staleBaseline > 0) {
100
- actions.push('tighten the baseline (--update-baseline)');
119
+ actions.push(
120
+ 'tighten the baseline (--update-baseline --force --contract-session --author <steward>)'
121
+ );
101
122
  }
102
123
  if (ctx.staleRunners.length > 0) {
103
124
  actions.push(
@@ -83,6 +83,27 @@ function addDevDependencyPreservingFormat(source, version) {
83
83
  return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
84
84
  }
85
85
 
86
+ const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
87
+ const ARK_CHECK_RUNNER_RE =
88
+ /(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
89
+ const GITHUB_RUN_KEY_RE = /^\s*(?:-\s+)?run:\s+/;
90
+ const YAML_CHECK_JOB_ID_RE =
91
+ /^\s*(?:-\s+)?['"]?(?:ark-check|arkgate-check)['"]?\s*:/;
92
+ const YAML_CONCURRENCY_GROUP_RE = /^\s*group:\s+/;
93
+
94
+ /**
95
+ * True when the line invokes ark-check / arkgate-check (npx/pnpm/yarn/npm/node/run).
96
+ * YAML concurrency.group and job-id keys that only contain the name are not invocations.
97
+ */
98
+ export function isArkCheckInvocationLine(command) {
99
+ if (typeof command !== 'string' || !command.trim()) return false;
100
+ if (/^\s*#/.test(command)) return false;
101
+ if (YAML_CHECK_JOB_ID_RE.test(command)) return false;
102
+ if (YAML_CONCURRENCY_GROUP_RE.test(command)) return false;
103
+ if (!ARK_CHECK_BIN_RE.test(command)) return false;
104
+ return ARK_CHECK_RUNNER_RE.test(command) || GITHUB_RUN_KEY_RE.test(command);
105
+ }
106
+
86
107
  /**
87
108
  * Ensure a check command string includes `--baseline <file>`.
88
109
  * Only touches strings that already invoke ark-check / arkgate-check.
@@ -97,7 +118,7 @@ export function ensureBaselineFlagInCheckCommand(
97
118
  if (/^\s*#/.test(command)) {
98
119
  return { command, changed: false };
99
120
  }
100
- if (!/\b(ark-check|arkgate-check)\b/.test(command)) {
121
+ if (!isArkCheckInvocationLine(command)) {
101
122
  return { command, changed: false };
102
123
  }
103
124
  if (/(?:^|\s)--baseline(?:\s|=|$)/.test(command)) {
@@ -180,7 +201,7 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
180
201
  let fileChanged = false;
181
202
  const nextLines = lines.map((line) => {
182
203
  if (/^\s*#/.test(line)) return line;
183
- if (!/\b(ark-check|arkgate-check)\b/.test(line)) return line;
204
+ if (!isArkCheckInvocationLine(line)) return line;
184
205
  if (/(?:^|\s)--baseline(?:\s|=|$)/.test(line)) return line;
185
206
  const { command, changed: c } = ensureBaselineFlagInCheckCommand(line, flagRel);
186
207
  if (c) {
@@ -95,12 +95,74 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
95
95
  `;
96
96
  }
97
97
 
98
+ /** `--sensors` is contract + coverage-evidence only — never a full-check pass. */
99
+ export const SENSORS_PARTIAL_MODE_LINE =
100
+ 'Contract + coverage-evidence only: no TypeScript, no analysis. Not a validity verdict.';
101
+
102
+ export const SENSORS_DID_NOT_RUN = Object.freeze(['TypeScript', 'analysis']);
103
+
104
+ /**
105
+ * Stamp a successful `--sensors --json` payload so agents cannot read exit 0 as
106
+ * a full-check pass. Failure payloads (`sensors.ok === false`) stay untouched.
107
+ */
108
+ export function stampSensorsPartialModePayload(payload) {
109
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
110
+ const sensors = payload.sensors;
111
+ if (!sensors || typeof sensors !== 'object' || Array.isArray(sensors)) return payload;
112
+ if (sensors.ok === false) return payload;
113
+ return {
114
+ ...payload,
115
+ sensors: {
116
+ ...sensors,
117
+ notAVerdict: true,
118
+ didNotRun: [...SENSORS_DID_NOT_RUN],
119
+ partialMode: 'contract-only',
120
+ },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * After `runSensors`, name what this mode skipped. Human success prints the
126
+ * line on stdout; JSON success restamps the captured object. Failures reprint
127
+ * as-is so exit 2 does not look like a map.
128
+ */
129
+ export async function withSensorsPartialModeHonesty(args, run) {
130
+ if (args?.json) {
131
+ const chunks = [];
132
+ const original = console.log;
133
+ console.log = (...parts) => {
134
+ chunks.push(parts.map(String).join(' '));
135
+ };
136
+ try {
137
+ await run();
138
+ } finally {
139
+ console.log = original;
140
+ }
141
+ const text = chunks.join('\n');
142
+ if ((process.exitCode ?? 0) !== 0) {
143
+ if (text) original(text);
144
+ return;
145
+ }
146
+ try {
147
+ original(JSON.stringify(stampSensorsPartialModePayload(JSON.parse(text)), null, 2));
148
+ } catch {
149
+ original(text);
150
+ }
151
+ return;
152
+ }
153
+ await run();
154
+ if ((process.exitCode ?? 0) === 0) {
155
+ console.log(SENSORS_PARTIAL_MODE_LINE);
156
+ }
157
+ }
158
+
98
159
  export function checkUsage() {
99
160
  return [
100
161
  'arkgate-check (alias ark-check) — the architecture check.',
101
162
  '',
102
163
  ' arkgate-check --doctor where you are: one status light, one next action',
103
164
  ' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
165
+ ' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
104
166
  '',
105
167
  'Every flag and command: arkgate-check --help --all',
106
168
  ].join('\n');
@@ -124,7 +186,7 @@ export function checkUsageAll() {
124
186
  ' Exit 0 ran and clean, 1 drift remains, 2 could not run (no usable base ref).',
125
187
  ' ark-check --sensors [--json] every sensor with its tier and whether it can EVER be enforced, plus every declared rule',
126
188
  ' with its local id, the sensor it delegates to, its source file, its mode and why it can or cannot be promoted.',
127
- ' Contract + coverage-evidence only: no TypeScript, no analysis. Exit 0 on a report, 2 if the contract will not load.',
189
+ ` ${SENSORS_PARTIAL_MODE_LINE} Exit 0 on a report, 2 if the contract will not load.`,
128
190
  ' ark-check --promote [<ruleId>] [--json] [--apply]',
129
191
  ' what enforcing would cost: the findings each advisory rule already produces, from ONE run rather than one run per attempt.',
130
192
  ' Plan by default; --promote <ruleId> --apply (or --promote=<ruleId>) writes mode "enforced" into the ArkRules file that declares it.',
@@ -143,12 +205,14 @@ export function checkUsageAll() {
143
205
  ' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|vite-vercel-spa|clean-architecture|onion-architecture] [--force] [--follow-config-root]',
144
206
  ' --follow-config-root On writes (init/install-agent-gates/migrate --write/…), adopt walked-up monorepo config root (default: keep explicit --root)',
145
207
  ' ark-check --install-agent-gates [--tools claude,cursor,codex,grok,antigravity] [--require-write-hook <host>] [--skills-only] [--codex-home] [--claude-home] [--grok-home] [--antigravity-home] [--agent-homes] [--force]',
146
- ' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
208
+ ' ark-check --update-baseline [file] --force --contract-session --author <steward>',
209
+ ' freeze current violations (default .ark-baseline.json). --contract-session is required;',
210
+ ' --force when freeze-refuse fires; --author when stewards[] is set.',
147
211
  ' ark-check --print-config eleven-layer',
148
212
  '',
149
- 'Adopting Ark in an existing codebase? Run --update-baseline once to freeze existing',
150
- 'violations, commit the baseline file, and gate CI with --baseline: only NEW violations',
151
- 'fail the check, so the ratchet only moves toward zero.',
213
+ 'Adopting Ark in an existing codebase? Run --update-baseline --force --contract-session --author <steward>',
214
+ 'once to freeze existing violations, commit the baseline file, and gate CI with --baseline: only NEW',
215
+ 'violations fail the check, so the ratchet only moves toward zero.',
152
216
  '',
153
217
  'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
154
218
  'the same diff as product source. --changed --base <ref> checks touched files only.',
@@ -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) {
@@ -750,6 +750,83 @@ function declaredIntent(value, config) {
750
750
  );
751
751
  }
752
752
 
753
+ /** Call names whose string arguments are declared intent-reference sites. */
754
+ const INTENT_CALL_NAMES = new Set(['publish', 'subscribe', 'defineIntent', 'registerHandler']);
755
+
756
+ function isSyntaxWrapper(ts, node) {
757
+ return Boolean(
758
+ node &&
759
+ (ts.isParenthesizedExpression(node) ||
760
+ ts.isAsExpression(node) ||
761
+ (typeof ts.isTypeAssertionExpression === 'function' &&
762
+ ts.isTypeAssertionExpression(node)) ||
763
+ (typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(node)))
764
+ );
765
+ }
766
+
767
+ function unwrapWrappers(ts, node) {
768
+ let current = node;
769
+ while (current?.parent && isSyntaxWrapper(ts, current.parent)) {
770
+ current = current.parent;
771
+ }
772
+ return current;
773
+ }
774
+
775
+ function callCalleeName(ts, node) {
776
+ if (!node || !ts.isCallExpression(node)) return undefined;
777
+ const expression = node.expression;
778
+ if (ts.isIdentifier(expression)) return expression.text;
779
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
780
+ return undefined;
781
+ }
782
+
783
+ function isPublishMetadataSource(ts, sourceProp) {
784
+ const object = sourceProp.parent;
785
+ if (!object || !ts.isObjectLiteralExpression(object)) return false;
786
+ const objectSite = unwrapWrappers(ts, object);
787
+ const objectParent = objectSite.parent;
788
+ if (!objectParent) return false;
789
+ if (ts.isCallExpression(objectParent) && callCalleeName(ts, objectParent) === 'publish') {
790
+ const args = objectParent.arguments;
791
+ return args[1] === objectSite || args[2] === objectSite;
792
+ }
793
+ if (
794
+ ts.isPropertyAssignment(objectParent) &&
795
+ syntaxPropertyName(ts, objectParent.name) === 'metadata'
796
+ ) {
797
+ const eventObject = objectParent.parent;
798
+ if (!eventObject) return false;
799
+ const eventSite = unwrapWrappers(ts, eventObject);
800
+ const call = eventSite.parent;
801
+ return Boolean(
802
+ call && ts.isCallExpression(call) && callCalleeName(ts, call) === 'publish'
803
+ );
804
+ }
805
+ return false;
806
+ }
807
+
808
+ /** Events, sagas, and publish metadata — not every string that matches a prefix. */
809
+ function isDeclaredIntentSite(ts, node) {
810
+ const siteNode = unwrapWrappers(ts, node);
811
+ const parent = siteNode.parent;
812
+ if (!parent) return false;
813
+ if (ts.isCallExpression(parent)) {
814
+ const name = callCalleeName(ts, parent);
815
+ if (INTENT_CALL_NAMES.has(name) && parent.arguments.some((arg) => arg === siteNode)) {
816
+ return true;
817
+ }
818
+ }
819
+ if (ts.isArrayLiteralExpression(parent)) {
820
+ return isDeclaredIntentSite(ts, parent);
821
+ }
822
+ if (ts.isPropertyAssignment(parent)) {
823
+ const name = syntaxPropertyName(ts, parent.name);
824
+ if (name === 'intent' || name === 'onEvent' || name === 'reactsTo') return true;
825
+ if (name === 'source' && isPublishMetadataSource(ts, parent)) return true;
826
+ }
827
+ return false;
828
+ }
829
+
753
830
  function mayContainForbiddenCapability(ts, sourceFile, forbiddenGlobals) {
754
831
  // Every symbol-aware match originates in an identifier or static string path segment.
755
832
  // Inspect decoded AST text so escaped identifiers still take the full checker path.
@@ -788,7 +865,11 @@ function collectPolicyFacts(ts, sourceFile, relativePath, config) {
788
865
  : {}),
789
866
  });
790
867
  }
791
- if (ts.isStringLiteralLike(node) && declaredIntent(node.text, config)) {
868
+ if (
869
+ ts.isStringLiteralLike(node) &&
870
+ declaredIntent(node.text, config) &&
871
+ isDeclaredIntentSite(ts, node)
872
+ ) {
792
873
  intentReferences.push({
793
874
  file: relativePath,
794
875
  line: lineOf(sourceFile, node.getStart(sourceFile)),