arkgate 4.6.5 → 4.6.6

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 (41) hide show
  1. package/CHANGELOG.md +44 -2106
  2. package/README.md +10 -9
  3. package/bin/ark-check-runtime.mjs +38 -2
  4. package/bin/ark.mjs +13 -3
  5. package/bin/lib/adoption-stance.mjs +104 -0
  6. package/bin/lib/ci-merge-boundary.mjs +4 -2
  7. package/bin/lib/design-delta.mjs +2 -2
  8. package/bin/lib/design-smells.mjs +1 -1
  9. package/bin/lib/diagnostic-catalog.mjs +1 -1
  10. package/bin/lib/doctor-advisories.mjs +2 -2
  11. package/bin/lib/doctor-next-actions.mjs +20 -2
  12. package/bin/lib/doctor-plan.mjs +152 -135
  13. package/bin/lib/enforcement-honesty.mjs +70 -0
  14. package/bin/lib/first-run-help.mjs +8 -7
  15. package/bin/lib/html-report-advisories.mjs +10 -2
  16. package/bin/lib/html-report.mjs +2 -2
  17. package/bin/lib/mcp-adoption.mjs +19 -0
  18. package/bin/lib/policy-delta-io.mjs +1 -1
  19. package/bin/lib/post-green-path.mjs +5 -1
  20. package/bin/lib/product-copy.mjs +6 -3
  21. package/bin/lib/start-preview.mjs +12 -22
  22. package/bin/lib/status-command.mjs +16 -0
  23. package/bin/lib/status-manifest.mjs +8 -2
  24. package/bin/lib/team-parliament-io.mjs +62 -2
  25. package/bin/lib/team-parliament.mjs +25 -5
  26. package/bin/lib/unavailable-analysis.mjs +1 -0
  27. package/dist/index.cjs +2 -2
  28. package/dist/index.d.ts +10 -2
  29. package/dist/index.js +2 -2
  30. package/docs/README.md +6 -10
  31. package/docs/ai-gates.md +12 -5
  32. package/docs/configuration.md +9 -1
  33. package/docs/diagnostics.md +2 -2
  34. package/docs/package-surface.md +5 -4
  35. package/docs/product-voice.md +6 -4
  36. package/docs/threat-model.md +2 -2
  37. package/docs/use.md +5 -4
  38. package/package.json +1 -1
  39. package/schemas/ark.design-delta.schema.json +1 -1
  40. package/server.json +2 -2
  41. package/templates/agent-skills/README.md +1 -1
package/README.md CHANGED
@@ -16,9 +16,11 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 4.6.5** is current (on npm after this release). Adopt/place honesty for existing Next.js
20
- > trees, stable doctor JSON, writePath/CI honesty file, hosts-keep upgrade, CLAUDE.md projection.
21
- > [4.6.5 notes](docs/releases/4.6.5.md) · [4.6.4](docs/releases/4.6.4.md) · [4.6.3](docs/releases/4.6.3.md) · [4.6.2](docs/releases/4.6.2.md) · [4.6.1](docs/releases/4.6.1.md) · [4.6.0](docs/releases/4.6.0.md) · [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
19
+ > **ArkGate 4.6.6** is current (this train). **4.6.5** remains npm `latest` until publish.
20
+ > A tree is **adopted** only with a required GitHub status running `arkgate-check --strict-merge`,
21
+ > or `.ark/adoption-stance.json` `stance: "advisory-only"`. Doctor is compact (`--doctor --all`
22
+ > for Details). [4.6.6 notes](docs/releases/4.6.6.md) · [4.6.5](docs/releases/4.6.5.md) ·
23
+ > [Docs hub](docs/README.md) · [Product voice](docs/product-voice.md)
22
24
 
23
25
  ---
24
26
 
@@ -41,12 +43,11 @@ npm install -D arkgate typescript
41
43
  npx arkgate start # preview files + commands
42
44
  npx arkgate start --apply # compact contract + host router + CI plan
43
45
  npx arkgate-check --doctor # control plane: status light + primary next action
46
+ npx arkgate-check --doctor --all # encyclopedia (Details)
44
47
  ```
45
48
 
46
49
  That is the product. Doctor is the control plane — when stuck, do **primary next action #1**.
47
- Doctor also shows **what to improve next** (architecture lenses such as separation of concerns and
48
- dependency inversion — **not a score**). Leftover lenses mean design work may remain even when
49
- imports are green. Details: [use.md — Improvement compass](docs/use.md#improvement-compass-not-a-score).
50
+ JSON still carries improvement compass and coach (**not a score**). Compact human output does not.
50
51
 
51
52
  ```text
52
53
  start → doctor (+ compass) → /ark-adopt (session 0) → day-to-day /ark-place
@@ -102,7 +103,7 @@ check: [4.3.0 — What ArkGate is / isn't](docs/releases/4.3.0.md#what-arkgate-i
102
103
  |-------|--------|-----------|
103
104
  | **Suggest** | Thin / new tree | Finish `start` → doctor |
104
105
  | **Adapt** | Not fully protected | Doctor action #1 |
105
- | **Enforce** | Honest edges under the contract | Keep write path + CI |
106
+ | **Enforce** | Honest import edges, and no new UI business-rule files vs merge-base | Keep write path + CI |
106
107
  | **Enforce · design-weak** | Edges clean; design residual remains | Shape residual — not “done” |
107
108
 
108
109
  Details: [docs/use.md](docs/use.md).
@@ -216,8 +217,8 @@ for real systems. Details: [docs/production-hardening.md](docs/production-harden
216
217
  | Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
217
218
  | Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
218
219
  | Security | [SECURITY.md](SECURITY.md) |
219
- | Current release (4.6.5) | [docs/releases/4.6.5.md](docs/releases/4.6.5.md) · [CHANGELOG](CHANGELOG.md) |
220
- | Prior published (4.6.4 on npm `latest` until 4.6.5 lands) | [docs/releases/4.6.4.md](docs/releases/4.6.4.md) |
220
+ | Current release (4.6.6) | [docs/releases/4.6.6.md](docs/releases/4.6.6.md) · [CHANGELOG](CHANGELOG.md) |
221
+ | Prior published (4.6.5 on npm `latest` until 4.6.6 lands) | [docs/releases/4.6.5.md](docs/releases/4.6.5.md) |
221
222
  | Prior published (4.6.3) | [docs/releases/4.6.3.md](docs/releases/4.6.3.md) |
222
223
  | Prior (4.6.2) | [docs/releases/4.6.2.md](docs/releases/4.6.2.md) |
223
224
  | Prior (4.6.1) | [docs/releases/4.6.1.md](docs/releases/4.6.1.md) |
@@ -105,10 +105,15 @@ import { runArchitectureScan } from './lib/architecture-scan.mjs';
105
105
  import { ANALYSIS_COMPLETENESS, analysisIncompleteStatement } from './lib/analysis-completeness.mjs';
106
106
  import { reportUnavailableAnalysis } from './lib/unavailable-analysis.mjs';
107
107
  import { validateHardWriteRequest } from './lib/enforcement-profiles.mjs';
108
- import { analyzePolicyTransition } from './lib/policy-delta-io.mjs';
108
+ import {
109
+ analyzePolicyTransition,
110
+ discoverLocalBaseRef,
111
+ normalizePolicyBaseRef,
112
+ } from './lib/policy-delta-io.mjs';
109
113
  import {
110
114
  applyAgainstRatchet,
111
115
  bindTeamBaseRefs,
116
+ contractSessionFrom,
112
117
  filterChangedGovernedFiles,
113
118
  runTeamPreflight,
114
119
  ungovernedDumpMessage,
@@ -122,6 +127,16 @@ import {
122
127
  } from './lib/project-root.mjs';
123
128
  import { demoteArkRuleTeethUnderClassificationFloor } from './lib/rules-under-contract.mjs';
124
129
 
130
+ function resolveDesignDeltaBaseRef(root, explicit, env = process.env) {
131
+ const flag = typeof explicit === 'string' ? explicit.trim() : '';
132
+ if (flag) return flag;
133
+ const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
134
+ if (envRef) return envRef;
135
+ const githubBase = typeof env.GITHUB_BASE_REF === 'string' ? env.GITHUB_BASE_REF.trim() : '';
136
+ if (githubBase) return `origin/${githubBase}`;
137
+ return discoverLocalBaseRef(root) || undefined;
138
+ }
139
+
125
140
  function parseArgs(argv) {
126
141
  const args = {
127
142
  root: process.cwd(),
@@ -1324,6 +1339,11 @@ async function main() {
1324
1339
  console.log('✔ Ark check passed (no governed source or constitution files in the diff).');
1325
1340
  } else {
1326
1341
  console.error(preflight.halt.message);
1342
+ const blocking = policyDelta?.findings?.find(
1343
+ (finding) =>
1344
+ finding.classification === 'weakening' || finding.classification === 'judgment-required'
1345
+ );
1346
+ if (blocking?.nextAction) console.error(`Next: ${blocking.nextAction}`);
1327
1347
  }
1328
1348
  process.exitCode = preflight.halt.exitCode;
1329
1349
  return;
@@ -1406,7 +1426,17 @@ async function main() {
1406
1426
  populatedLayerCount,
1407
1427
  });
1408
1428
 
1409
- const designCheck = createDesignDeltaCheck({ enabled: args.failOnNewSmells, root, config, configPath: args.config, baseRef: args.baseRef, ts });
1429
+ const createdPathsOnly = Boolean(args.strictMerge && !args.failOnNewSmells);
1430
+ const designCheck = createDesignDeltaCheck({
1431
+ enabled: args.failOnNewSmells || args.strictMerge,
1432
+ createdPathsOnly,
1433
+ missingBase: createdPathsOnly ? 'skip' : 'fail-closed',
1434
+ root,
1435
+ config,
1436
+ configPath: args.config,
1437
+ baseRef: resolveDesignDeltaBaseRef(root, args.baseRef),
1438
+ ts,
1439
+ });
1410
1440
  const designDelta = designCheck.result;
1411
1441
 
1412
1442
  if (args.doctor) {
@@ -1417,6 +1447,7 @@ async function main() {
1417
1447
  configWalkedUp: args.configWalkedUp === true,
1418
1448
  safety, designDelta,
1419
1449
  ts, parseHealth, completeness,
1450
+ all: args.all === true,
1420
1451
  });
1421
1452
  if (designDelta) process.exitCode = !designDelta.complete ? 2 : designDelta.valid ? 0 : 1; return;
1422
1453
  }
@@ -1427,6 +1458,11 @@ async function main() {
1427
1458
  }
1428
1459
 
1429
1460
  if (args.updateBaseline) {
1461
+ if (!contractSessionFrom(args)) {
1462
+ console.error('Growing the baseline requires --contract-session. Freeze in a law-only PR.');
1463
+ process.exitCode = 1;
1464
+ return;
1465
+ }
1430
1466
  const summary = summarizeViolations(violations);
1431
1467
  // Bloquear y avisar: a lopsided freeze buries a likely contract bug as "debt". Refuse it
1432
1468
  // (unless --force), diagnose, and point at the contract fix instead of the baseline.
package/bin/ark.mjs CHANGED
@@ -694,11 +694,13 @@ async function start(args) {
694
694
  // Modes are detected (Suggest/Adapt/Enforce), not user-picked settings.
695
695
  // Soft-block false-green using the same doctor adoption gap (no second detector).
696
696
  let falseGreenGap = null;
697
+ let adopted = null;
697
698
  try {
698
699
  const doc = JSON.parse(doctorCapture.stdout || '{}');
699
700
  falseGreenGap = (doc.doctor?.adoption?.gaps ?? []).find(
700
701
  (g) => g?.id === FALSE_GREEN_GAP_ID
701
702
  );
703
+ adopted = doc.doctor?.adoptionStance ?? null;
702
704
  } catch {
703
705
  falseGreenGap = null;
704
706
  }
@@ -714,9 +716,17 @@ async function start(args) {
714
716
  console.log(` • ${falseGreenGap.message}`);
715
717
  console.log(` • Next: ${falseGreenGap.fix}`);
716
718
  } else if (mode === 'enforce' && planOk) {
717
- console.log('Done status: ENFORCE (gates can honestly protect you).');
718
- console.log('What happens now:');
719
- console.log(' Every edit is checked (in CI and, if wired, at write time).');
719
+ if (adopted === 'required-merge' || adopted === 'advisory-only-acked') {
720
+ console.log('Done status: ENFORCE (gates can honestly protect you).');
721
+ console.log('What happens now:');
722
+ console.log(' • Every edit is checked (in CI and, if wired, at write time).');
723
+ } else {
724
+ console.log('Done — status: ENFORCE (contract edges clean; merge boundary not adopted).');
725
+ console.log('What happens now:');
726
+ console.log(
727
+ ' • CI workflow written; make the GitHub status required on arkgate-check --strict-merge, or write .ark/adoption-stance.json with stance: "advisory-only".'
728
+ );
729
+ }
720
730
  } else if (mode === 'suggest') {
721
731
  console.log('Done — status: SUGGEST (starting shape installed; expand as you grow).');
722
732
  console.log('What happens now:');
@@ -0,0 +1,104 @@
1
+ /**
2
+ * D0 adoption stance — required merge status or explicit advisory-only ack.
3
+ * Tooling I/O. Never invents GitHub required from workflow YAML presence.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const ADOPTION_STANCE_REL = '.ark/adoption-stance.json';
9
+ export const ADOPTION_STANCE_VALUE = 'advisory-only';
10
+ export const ADOPTED_REQUIRED_MERGE = 'required-merge';
11
+ export const ADOPTED_ADVISORY_ACKED = 'advisory-only-acked';
12
+ export const ADOPTED_NOT = 'not-adopted';
13
+
14
+ export const NOT_ADOPTED_NEXT_ACTION =
15
+ 'Make arkgate-check --strict-merge a required GitHub status, or write .ark/adoption-stance.json with stance: "advisory-only"';
16
+
17
+ export const MERGE_BOUNDARY_NOT_REQUIRED = 'merge-boundary-not-required';
18
+
19
+ /**
20
+ * @param {string} root
21
+ * @returns {{ schemaVersion?: string, stance?: string, ackedAt?: string, reason?: string } | null}
22
+ */
23
+ export function readAdoptionStance(root) {
24
+ const file = path.join(root, ADOPTION_STANCE_REL);
25
+ if (!fs.existsSync(file)) return null;
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ return parsed && typeof parsed === 'object' ? parsed : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function stanceValue(stance) {
35
+ if (typeof stance === 'string') return stance;
36
+ if (stance && typeof stance === 'object' && typeof stance.stance === 'string') {
37
+ return stance.stance;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Closed enum: required-merge | advisory-only-acked | not-adopted.
44
+ * Workflow presence is never enough.
45
+ *
46
+ * @param {{
47
+ * stance?: { stance?: string } | string | null,
48
+ * github?: { arkCheckRequired?: unknown, requiredStatusConfigured?: unknown },
49
+ * ci?: { state?: string, requiredStatusConfigured?: unknown },
50
+ * }} [input]
51
+ */
52
+ export function classifyAdopted(input = {}) {
53
+ const github = input.github && typeof input.github === 'object' ? input.github : {};
54
+ const ci = input.ci && typeof input.ci === 'object' ? input.ci : {};
55
+ const required =
56
+ github.arkCheckRequired === true ||
57
+ github.requiredStatusConfigured === true ||
58
+ ci.requiredStatusConfigured === true ||
59
+ ci.state === 'required';
60
+ if (required) return ADOPTED_REQUIRED_MERGE;
61
+ if (stanceValue(input.stance) === ADOPTION_STANCE_VALUE) return ADOPTED_ADVISORY_ACKED;
62
+ return ADOPTED_NOT;
63
+ }
64
+
65
+ export function isAdopted(kind) {
66
+ return kind === ADOPTED_REQUIRED_MERGE || kind === ADOPTED_ADVISORY_ACKED;
67
+ }
68
+
69
+ /**
70
+ * Map doctor adoption + writePath into the ci-merge-boundary github input.
71
+ * Never sets requiredStatusConfigured false from a missing GitHub query.
72
+ *
73
+ * @param {object} [adoption]
74
+ * @param {object} [writePath]
75
+ */
76
+ export function githubEvidenceForCiMergeBoundary(adoption, writePath) {
77
+ const github =
78
+ adoption?.enforcement?.github && typeof adoption.enforcement.github === 'object'
79
+ ? adoption.enforcement.github
80
+ : {};
81
+ const ci =
82
+ adoption?.enforcement?.ci && typeof adoption.enforcement.ci === 'object'
83
+ ? adoption.enforcement.ci
84
+ : {};
85
+ const ciMerge =
86
+ writePath?.enforcementState?.ciMerge && typeof writePath.enforcementState.ciMerge === 'object'
87
+ ? writePath.enforcementState.ciMerge
88
+ : {};
89
+ const required =
90
+ github.arkCheckRequired === true ||
91
+ github.requiredStatusConfigured === true ||
92
+ ciMerge.required === true;
93
+ return {
94
+ ...github,
95
+ ...(required ? { arkCheckRequired: true, requiredStatusConfigured: true } : {}),
96
+ workflowPresent:
97
+ github.workflowPresent === true ||
98
+ ci.hasArkCheckWorkflow === true ||
99
+ writePath?.capabilities?.['merge-gate'] === true ||
100
+ writePath?.inventory?.capabilities?.['merge-gate'] === true,
101
+ plan: github.plan,
102
+ canRequire: github.canRequire,
103
+ };
104
+ }
@@ -37,9 +37,11 @@ export function buildCiMergeBoundary(input = {}) {
37
37
  const hookFired = Object.values(perHost).some((h) => h.fired);
38
38
  const github = input.github && typeof input.github === 'object' ? input.github : {};
39
39
  const workflowPresent = Boolean(
40
- writePath.capabilities?.['merge-gate'] || writePath.inventory?.capabilities?.['merge-gate']
40
+ github.workflowPresent ||
41
+ writePath.capabilities?.['merge-gate'] ||
42
+ writePath.inventory?.capabilities?.['merge-gate']
41
43
  );
42
- const required = github.requiredStatusConfigured === true;
44
+ const required = github.requiredStatusConfigured === true || github.arkCheckRequired === true;
43
45
  const canRequire = github.canRequire !== false && github.plan !== 'free';
44
46
  let ciState = 'absent';
45
47
  if (workflowPresent && required) ciState = 'required';
@@ -1,4 +1,4 @@
1
1
  // Generated from design-delta.source.mjs — run npm run generate:packaged-tooling.
2
- import{spawnSync as R}from"node:child_process";import M from"node:crypto";import N from"node:fs";import b from"node:path";import{layerForFile as E}from"../ark-shared.mjs";import{loadGoldenPattern as C}from"./golden-pattern.mjs";import{collectGovernedFiles as O}from"./scan-files.mjs";const K="1.0",A=Object.freeze(["domain-logic-in-ui"]),x=/\.[cm]?[jt]sx?$/i,j=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,q=/^(can|should|calculate|compute)[A-Z_]|policy/i,W=/^(can|should)[A-Z_]|policy/i,U=/^(calculate|compute)[A-Z_]/i,I=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,z=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,B=new Set(["includes","some","every","has"]);function m(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function w(e){return`sha256:${M.createHash("sha256").update(e,"utf8").digest("hex")}`}function k(e,t){const i=[...t].map(n=>[m(n.path),w(n.content)]).sort(([n],[r])=>n.localeCompare(r));return w(JSON.stringify({config:e,files:i}))}function H(e,t){const i=t.toLowerCase();return i.endsWith(".tsx")?e.ScriptKind.TSX:i.endsWith(".jsx")?e.ScriptKind.JSX:i.endsWith(".js")||i.endsWith(".mjs")||i.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function G(e,t,i){const n=E(e,i,t?.layers??[]);return j.test(i)||/presentation|ui|view/i.test(n??"")}function J(e,t){const i=[],n=r=>{if(e.isFunctionDeclaration(r)&&r.name&&r.body)i.push({name:r.name.text,body:r.body,node:r});else if(e.isVariableDeclaration(r)&&e.isIdentifier(r.name)){const s=r.initializer;s&&(e.isArrowFunction(s)||e.isFunctionExpression(s))&&i.push({name:r.name.text,body:s.body,node:r})}else e.isMethodDeclaration(r)&&r.name&&e.isIdentifier(r.name)&&r.body&&i.push({name:r.name.text,body:r.body,node:r});e.forEachChild(r,n)};return n(t),i}function V(e,t){if(!q.test(t.name)||I.test(t.name))return null;let i=0,n=0,r=0,s=0,o=!1,a=!1;const d=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),f=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),c=l=>{if(e.isJsxElement(l)||e.isJsxSelfClosingElement(l)||e.isJsxFragment(l)){a=!0;return}if(e.isBinaryExpression(l)&&(d.has(l.operatorToken.kind)&&(i+=1),u.has(l.operatorToken.kind)&&(n+=1),f.has(l.operatorToken.kind)&&(r+=1)),e.isCallExpression(l)){let S="";e.isIdentifier(l.expression)?S=l.expression.text:e.isPropertyAccessExpression(l.expression)&&(S=l.expression.name.text),B.has(S)&&(s+=1),(z.test(S)||I.test(S))&&(o=!0)}e.forEachChild(l,c)};if(c(t.body),a||o)return null;const p=i+n+s,g=r;let h,y;if(U.test(t.name)&&g>0)h="calculation-rule",y=g;else if(W.test(t.name)&&p>0)h="authorization-policy-rule",y=p;else return null;return{kind:h,magnitude:y,detail:`comparisons:${i};logical:${n};predicates:${s};arithmetic:${r}`}}function Z(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function v({root:e,config:t,records:i,ts:n,goldenPattern:r}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const s=[];for(const o of[...i].sort((a,d)=>m(a.path).localeCompare(m(d.path)))){const a=m(o.path);if(!x.test(a)||a.endsWith(".d.ts")||!G(e,t,a))continue;const d=n.createSourceFile(a,String(o.content),n.ScriptTarget.Latest,!0,H(n,a));for(const u of J(n,d)){const f=V(n,u);if(!f)continue;const c=`domain-logic-in-ui|${a}|${u.name}|${f.kind}`,p=d.getLineAndCharacterOfPosition(u.node.getStart(d)).line+1;s.push({smellId:"domain-logic-in-ui",fingerprint:w(c),identity:c,evidence:{kind:f.kind,path:a,line:p,symbol:u.name,detail:f.detail,magnitude:f.magnitude},repairHint:Z(r,u.name)})}}return s.sort((o,a)=>o.identity.localeCompare(a.identity))}function _({mode:e,baseIdentity:t,candidateIdentity:i,touchedPaths:n,baseFindings:r,candidateFindings:s}){const o=new Set([...n].map(m)),a=new Map(r.map(c=>[c.identity,c])),d=new Set(r),u=[];let f=0;for(const c of s){let p=a.get(c.identity);if(!p){const y=[...d].filter(l=>l.smellId===c.smellId&&l.evidence.symbol===c.evidence.symbol&&l.evidence.kind===c.evidence.kind);y.length===1&&([p]=y)}p&&d.delete(p);const g=p?.evidence?.magnitude??0,h=c.evidence.magnitude;if(!o.has(c.evidence.path)){p&&(f+=1);continue}p?h>g?u.push({...c,classification:"worsened",baseMagnitude:g,candidateMagnitude:h}):f+=1:u.push({...c,classification:"new",baseMagnitude:0,candidateMagnitude:h})}return{schemaVersion:K,mode:e,complete:!0,valid:u.length===0,base:t,candidate:i,supportedSmellIds:[...A],touchedPaths:[...o].sort(),changes:u,baseFindingCount:r.length,candidateFindingCount:s.length,historicalResidualCount:f}}function D(e,t){const i=[];for(const n of O(e,t)){const r=m(b.relative(e,n));!x.test(r)||r.endsWith(".d.ts")||i.push({path:r,content:N.readFileSync(n,"utf8")})}return i}function Q(e,t,i,n){const r=new Map(i.map(s=>[m(s.path),s.content]));for(const s of n){const o=m(s.path);!x.test(o)||o.endsWith(".d.ts")||!E(e,o,t?.layers??[])||(s.delete===!0?r.delete(o):typeof s.content=="string"&&r.set(o,s.content))}return[...r].map(([s,o])=>({path:s,content:o}))}function oe({root:e,config:t,changes:i,ts:n}){const r=D(e,t),s=Q(e,t,r,i??[]),o=C(e),a=v({root:e,config:t,records:r,ts:n,goldenPattern:o}),d=v({root:e,config:t,records:s,ts:n,goldenPattern:o});return _({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:k(t,r)},candidateIdentity:{kind:"candidate-tree",value:k(t,s)},touchedPaths:(i??[]).map(u=>u.path),baseFindings:a,candidateFindings:d})}function T(e,t,i={}){return R("git",t,{cwd:e,encoding:i.encoding??"utf8",maxBuffer:64*1024*1024,input:i.input})}function P(e,t,i){const n=T(e,t);if(n.status!==0)throw new Error(`${i}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function $(e,t,i){const n=T(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${i}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(m).filter(Boolean)}function L(e,t,i){const n=T(e,["show",`${t}:${i}`]);if(n.status!==0)throw new Error(`base file unavailable (${i}): ${(n.stderr||"").trim()}`);return n.stdout}function F(e,t){return{schemaVersion:K,mode:"git-base",complete:!1,valid:!1,base:{kind:"git-tree",value:String(e||"<missing>")},candidate:{kind:"candidate-tree",value:"<unavailable>"},supportedSmellIds:[...A],touchedPaths:[],changes:[],baseFindingCount:0,candidateFindingCount:0,historicalResidualCount:0,error:t}}function X({root:e,config:t,configPath:i="ark.config.json",baseRef:n,ts:r}){if(typeof n!="string"||!n.trim())return F(n,"--fail-on-new-smells requires --base-ref <git-ref>.");try{if(n.startsWith("-"))throw new Error('base ref must not start with "-".');const s=P(e,["rev-parse","--verify",`${n}^{commit}`],"base ref is unresolvable"),o=P(e,["rev-parse","--verify",`${s}^{tree}`],"base tree is unresolvable"),a=m(b.isAbsolute(i)?b.relative(e,i):i);if(!a||a.startsWith("../"))throw new Error("base config path must be inside the project root.");const d=JSON.parse(L(e,s,a)),f=$(e,["ls-tree","-r","--name-only","-z",s],"base tree listing failed").filter(l=>x.test(l)&&!l.endsWith(".d.ts")).filter(l=>E(e,l,d?.layers??[])).map(l=>({path:l,content:L(e,s,l)})),c=D(e,t),p=[...$(e,["diff","--name-only","-z",s,"--"],"candidate diff failed"),...$(e,["ls-files","--others","--exclude-standard","-z"],"untracked-file scan failed")],g=C(e),h=v({root:e,config:d,records:f,ts:r,goldenPattern:g}),y=v({root:e,config:t,records:c,ts:r,goldenPattern:g});return _({mode:"git-base",baseIdentity:{kind:"git-tree",value:o,commit:s},candidateIdentity:{kind:"candidate-tree",value:k(t,c)},touchedPaths:p,baseFindings:h,candidateFindings:y})}catch(s){return F(n,s instanceof Error?s.message:String(s))}}function Y(e){return e?.complete?e.valid?`Design delta passed: 0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s).`:[`Design delta blocked ${e.changes.length} new/worsened supported smell(s):`,...e.changes.map(t=>`- [${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""} (${t.classification})
2
+ import{spawnSync as O}from"node:child_process";import B from"node:crypto";import j from"node:fs";import w from"node:path";import{layerForFile as T}from"../ark-shared.mjs";import{loadGoldenPattern as I}from"./golden-pattern.mjs";import{collectGovernedFiles as q}from"./scan-files.mjs";const P="1.0",_=Object.freeze(["domain-logic-in-ui"]),b=/\.[cm]?[jt]sx?$/i,W=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,U=/^(can|should|calculate|compute)[A-Z_]|policy/i,z=/^(can|should)[A-Z_]|policy/i,H=/^(calculate|compute)[A-Z_]/i,D=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,G=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,J=new Set(["includes","some","every","has"]);function h(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function $(e){return`sha256:${B.createHash("sha256").update(e,"utf8").digest("hex")}`}function C(e,t){const s=[...t].map(n=>[h(n.path),$(n.content)]).sort(([n],[i])=>n.localeCompare(i));return $(JSON.stringify({config:e,files:s}))}function V(e,t){const s=t.toLowerCase();return s.endsWith(".tsx")?e.ScriptKind.TSX:s.endsWith(".jsx")?e.ScriptKind.JSX:s.endsWith(".js")||s.endsWith(".mjs")||s.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function Z(e,t,s){const n=T(e,s,t?.layers??[]);return W.test(s)||/presentation|ui|view/i.test(n??"")}function Q(e,t){const s=[],n=i=>{if(e.isFunctionDeclaration(i)&&i.name&&i.body)s.push({name:i.name.text,body:i.body,node:i});else if(e.isVariableDeclaration(i)&&e.isIdentifier(i.name)){const a=i.initializer;a&&(e.isArrowFunction(a)||e.isFunctionExpression(a))&&s.push({name:i.name.text,body:a.body,node:i})}else e.isMethodDeclaration(i)&&i.name&&e.isIdentifier(i.name)&&i.body&&s.push({name:i.name.text,body:i.body,node:i});e.forEachChild(i,n)};return n(t),s}function X(e,t){if(!U.test(t.name)||D.test(t.name))return null;let s=0,n=0,i=0,a=0,r=!1,o=!1;const l=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),p=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),f=c=>{if(e.isJsxElement(c)||e.isJsxSelfClosingElement(c)||e.isJsxFragment(c)){o=!0;return}if(e.isBinaryExpression(c)&&(l.has(c.operatorToken.kind)&&(s+=1),u.has(c.operatorToken.kind)&&(n+=1),p.has(c.operatorToken.kind)&&(i+=1)),e.isCallExpression(c)){let g="";e.isIdentifier(c.expression)?g=c.expression.text:e.isPropertyAccessExpression(c.expression)&&(g=c.expression.name.text),J.has(g)&&(a+=1),(G.test(g)||D.test(g))&&(r=!0)}e.forEachChild(c,f)};if(f(t.body),o||r)return null;const m=s+n+a,v=i;let S,d;if(H.test(t.name)&&v>0)S="calculation-rule",d=v;else if(z.test(t.name)&&m>0)S="authorization-policy-rule",d=m;else return null;return{kind:S,magnitude:d,detail:`comparisons:${s};logical:${n};predicates:${a};arithmetic:${i}`}}function Y(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function E({root:e,config:t,records:s,ts:n,goldenPattern:i}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const a=[];for(const r of[...s].sort((o,l)=>h(o.path).localeCompare(h(l.path)))){const o=h(r.path);if(!b.test(o)||o.endsWith(".d.ts")||!Z(e,t,o))continue;const l=n.createSourceFile(o,String(r.content),n.ScriptTarget.Latest,!0,V(n,o));for(const u of Q(n,l)){const p=X(n,u);if(!p)continue;const f=`domain-logic-in-ui|${o}|${u.name}|${p.kind}`,m=l.getLineAndCharacterOfPosition(u.node.getStart(l)).line+1;a.push({smellId:"domain-logic-in-ui",fingerprint:$(f),identity:f,evidence:{kind:p.kind,path:o,line:m,symbol:u.name,detail:p.detail,magnitude:p.magnitude},repairHint:Y(i,u.name)})}}return a.sort((r,o)=>r.identity.localeCompare(o.identity))}function L({mode:e,baseIdentity:t,candidateIdentity:s,touchedPaths:n,baseFindings:i,candidateFindings:a,createdPathsOnly:r=!1,baseTreePaths:o=[]}){const l=new Set([...n].map(h)),u=new Set([...o].map(h)),p=new Map(i.map(d=>[d.identity,d])),f=new Set(i),m=[];let v=0;for(const d of a){let c=p.get(d.identity);if(!c){const y=[...f].filter(k=>k.smellId===d.smellId&&k.evidence.symbol===d.evidence.symbol&&k.evidence.kind===d.evidence.kind);y.length===1&&([c]=y)}c&&f.delete(c);const g=c?.evidence?.magnitude??0,x=d.evidence.magnitude;if(!l.has(d.evidence.path)){c&&(v+=1);continue}c?x>g?m.push({...d,classification:"worsened",baseMagnitude:g,candidateMagnitude:x}):v+=1:m.push({...d,classification:"new",baseMagnitude:0,candidateMagnitude:x})}const S=r?m.filter(d=>d.classification==="new"&&!u.has(h(d.evidence.path))):m;return{schemaVersion:P,mode:e,complete:!0,valid:S.length===0,base:t,candidate:s,supportedSmellIds:[..._],touchedPaths:[...l].sort(),changes:S,baseFindingCount:i.length,candidateFindingCount:a.length,historicalResidualCount:v,enforcementScope:r?"created-paths":"touched-new-or-worsened"}}function F(e,t){const s=[];for(const n of q(e,t)){const i=h(w.relative(e,n));!b.test(i)||i.endsWith(".d.ts")||s.push({path:i,content:j.readFileSync(n,"utf8")})}return s}function ee(e,t,s,n){const i=new Map(s.map(a=>[h(a.path),a.content]));for(const a of n){const r=h(a.path);!b.test(r)||r.endsWith(".d.ts")||!T(e,r,t?.layers??[])||(a.delete===!0?i.delete(r):typeof a.content=="string"&&i.set(r,a.content))}return[...i].map(([a,r])=>({path:a,content:r}))}function de({root:e,config:t,changes:s,ts:n}){const i=F(e,t),a=ee(e,t,i,s??[]),r=I(e),o=E({root:e,config:t,records:i,ts:n,goldenPattern:r}),l=E({root:e,config:t,records:a,ts:n,goldenPattern:r});return L({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:C(t,i)},candidateIdentity:{kind:"candidate-tree",value:C(t,a)},touchedPaths:(s??[]).map(u=>u.path),baseFindings:o,candidateFindings:l})}function K(e,t,s={}){return O("git",t,{cwd:e,encoding:s.encoding??"utf8",maxBuffer:64*1024*1024,input:s.input})}function M(e,t,s){const n=K(e,t);if(n.status!==0)throw new Error(`${s}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function A(e,t,s){const n=K(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${s}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(h).filter(Boolean)}function R(e,t,s){const n=K(e,["show",`${t}:${s}`]);if(n.status!==0)throw new Error(`base file unavailable (${s}): ${(n.stderr||"").trim()}`);return n.stdout}function N(e,t){return{schemaVersion:P,mode:"git-base",complete:!1,valid:!1,base:{kind:"git-tree",value:String(e||"<missing>")},candidate:{kind:"candidate-tree",value:"<unavailable>"},supportedSmellIds:[..._],touchedPaths:[],changes:[],baseFindingCount:0,candidateFindingCount:0,historicalResidualCount:0,error:t}}function te({root:e,config:t,configPath:s="ark.config.json",baseRef:n,ts:i,createdPathsOnly:a=!1,missingBase:r="fail-closed"}){const o=r==="skip";if(typeof n!="string"||!n.trim())return N(n,o?"design-delta skipped: no resolvable Git base ref.":"--fail-on-new-smells requires --base-ref <git-ref>.");try{if(n.startsWith("-"))throw new Error('base ref must not start with "-".');const l=M(e,["rev-parse","--verify",`${n}^{commit}`],"base ref is unresolvable"),u=M(e,["rev-parse","--verify",`${l}^{tree}`],"base tree is unresolvable"),p=h(w.isAbsolute(s)?w.relative(e,s):s);if(!p||p.startsWith("../"))throw new Error("base config path must be inside the project root.");const f=JSON.parse(R(e,l,p)),m=A(e,["ls-tree","-r","--name-only","-z",l],"base tree listing failed").filter(y=>b.test(y)&&!y.endsWith(".d.ts")),v=m.filter(y=>T(e,y,f?.layers??[])).map(y=>({path:y,content:R(e,l,y)})),S=F(e,t),d=[...A(e,["diff","--name-only","-z",l,"--"],"candidate diff failed"),...A(e,["ls-files","--others","--exclude-standard","-z"],"untracked-file scan failed")],c=I(e),g=E({root:e,config:f,records:v,ts:i,goldenPattern:c}),x=E({root:e,config:t,records:S,ts:i,goldenPattern:c});return L({mode:"git-base",baseIdentity:{kind:"git-tree",value:u,commit:l},candidateIdentity:{kind:"candidate-tree",value:C(t,S)},touchedPaths:d,baseFindings:g,candidateFindings:x,createdPathsOnly:!!a,baseTreePaths:m})}catch(l){return N(n,l instanceof Error?l.message:String(l))}}function ne(e){return e?.complete?e.valid?`Design delta passed: 0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s).`:[`Design delta blocked ${e.changes.length} new/worsened supported smell(s):`,...e.changes.map(t=>`- [${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""} (${t.classification})
3
3
  Next action: ${t.repairHint}`)].join(`
4
- `):`Design delta unavailable: ${e?.error||"unknown error"}`}function le({enabled:e,...t}){const i=e?X(t):null;return{result:i,combineEdges:({activeViolationCount:n,strictConfig:r,strictWarningCount:s,policyValid:o})=>{const a=n===0&&(!r||s===0)&&o;return{edgeValid:a,observedOk:a&&(i?.valid??!0)}},exitCode:n=>i&&!i.complete?2:n,failureText:()=>i&&!i.valid?Y(i):null}}function ce(e){return e?e.complete?e.valid?[{level:"ok",text:`0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s)`}]:[{level:"bad",text:`${e.changes.length} new/worsened supported smell(s) block this candidate`},...e.changes.slice(0,5).flatMap(t=>[{level:"plain",text:`[${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""}`},{level:"dim",text:`fix: ${t.repairHint}`}])]:[{level:"bad",text:`Unavailable \u2014 ${e.error||"base/candidate evidence incomplete"}`}]:[]}export{K as DESIGN_DELTA_SCHEMA_VERSION,A as DESIGN_DELTA_SUPPORTED_SMELLS,v as analyzeDesignFindings,le as createDesignDeltaCheck,ce as designDeltaDoctorLines,X as evaluateGitDesignDelta,oe as evaluateWriteDesignDelta,Y as formatDesignDeltaBlock};
4
+ `):`Design delta unavailable: ${e?.error||"unknown error"}`}function ue({enabled:e,createdPathsOnly:t,missingBase:s,...n}){const i=s==="skip",a=e?te({...n,createdPathsOnly:!!t,missingBase:i?"skip":"fail-closed"}):null,r=a&&i&&!a.complete?null:a;return{result:r,combineEdges:({activeViolationCount:o,strictConfig:l,strictWarningCount:u,policyValid:p})=>{const f=o===0&&(!l||u===0)&&p;return{edgeValid:f,observedOk:f&&(r?.valid??!0)}},exitCode:o=>r&&!r.complete?2:o,failureText:()=>r&&!r.valid?ne(r):null}}function pe(e){return e?e.complete?e.valid?[{level:"ok",text:`0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s)`}]:[{level:"bad",text:`${e.changes.length} new/worsened supported smell(s) block this candidate`},...e.changes.slice(0,5).flatMap(t=>[{level:"plain",text:`[${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""}`},{level:"dim",text:`fix: ${t.repairHint}`}])]:[{level:"bad",text:`Unavailable \u2014 ${e.error||"base/candidate evidence incomplete"}`}]:[]}export{P as DESIGN_DELTA_SCHEMA_VERSION,_ as DESIGN_DELTA_SUPPORTED_SMELLS,E as analyzeDesignFindings,ue as createDesignDeltaCheck,pe as designDeltaDoctorLines,te as evaluateGitDesignDelta,de as evaluateWriteDesignDelta,ne as formatDesignDeltaBlock};
@@ -483,7 +483,7 @@ export function summarizeDesignFitness(smells, ctx = {}) {
483
483
  smellCount: Array.isArray(smells) ? smells.length : 0,
484
484
  ids: (smells || []).map((s) => s.id),
485
485
  label: designWeak
486
- ? `${operatingModeTitle(ctx.operatingMode, true)} — import rules check out; leftover design work remains (see designSmells / plan B)`
486
+ ? `${operatingModeTitle(ctx.operatingMode, true)} — import rules check out; leftover design work remains`
487
487
  : smells.length > 0
488
488
  ? 'Design smells present alongside open import-rule debt'
489
489
  : 'No deterministic design smells detected',
@@ -67,7 +67,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
67
67
  entry('CANDIDATE_CONTENT_HASH_MISMATCH', 'preflight', 'Candidate content hash mismatch', 'The candidate file content hash does not match the hash expected for the declared change.', 'Rebuild candidate facts from the exact proposed content, then preflight again.'),
68
68
  entry('UNDECLARED_CANDIDATE_CHANGE', 'preflight', 'Undeclared candidate change', 'Candidate facts differ from base for a path that was not listed in the explicit change set.', 'Declare every path that changes in the atomic change set, then preflight again.'),
69
69
  entry('ATOMIC_PREFLIGHT_UNAVAILABLE', 'preflight', 'Atomic preflight unavailable', 'The host/MCP path could not run the atomic preflight engine (missing facts, incomplete setup, or unsupported mode).', 'Use resolved-candidate facts / ark_prepare_change with a complete batch, or fall back to ark-check on disk. Do not treat missing preflight as green.'),
70
- entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces or worsens a blocking design-smell class (e.g. domain-logic-in-ui) under --fail-on-new-smells.', 'Revert the regression or redesign so the smell does not worsen versus base, then re-run with the same base ref.'),
70
+ entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces a created-path domain-logic-in-ui file under --strict-merge, or introduces or worsens a blocking design-smell class under --fail-on-new-smells.', 'Move the new UI business rule out of the created file (or revert a --fail-on-new-smells regression), then re-run with the same base ref.'),
71
71
  // ── analysis completeness / host ─────────────────────────────────────────
72
72
  entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; evidence includes the TypeScript diagnostic (line + message). Incremental mid-edit parse is normal for agents. Contract exclude paths skip the write hook.', 'Finish the source or fix the reported syntax error, then re-run `npx arkgate-check`. The write hook does not deny solely on mid-edit parse. Partial never means pass.'),
73
73
  entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'Single-file validation cannot prove project module resolution. The write hook is already the verdict.', 'Re-run `npx arkgate-check --root . --config ark.config.json`, or treat the hook deny as final. Do not call ark_prepare_change from a hook deny.'),
@@ -73,9 +73,9 @@ export function printDoctorAdvisories(advisories, io) {
73
73
  printParseHealthSection(advisories.parseHealth, io);
74
74
  printGraphBlindSection(advisories.graphBlindSpots, io);
75
75
  const nudge = advisories.stewardNudge;
76
- if ((nudge?.needsStewards || nudge?.drift) && nudge.ask) {
76
+ if ((nudge?.needsStewards || nudge?.drift || nudge?.emptyStewardsPastGrace) && nudge.ask) {
77
77
  console.log('');
78
- console.log(io.color.bold('Stewards (advisory)'));
78
+ console.log(io.color.bold('Stewards'));
79
79
  io.line(io.warn, nudge.ask);
80
80
  if (nudge.nextAction) io.line(' ', io.color.dim(`Next: ${nudge.nextAction}`));
81
81
  }
@@ -6,17 +6,30 @@ import { arkCommand } from '../ark-shared.mjs';
6
6
  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
+ import { ADOPTED_NOT, NOT_ADOPTED_NEXT_ACTION } from './adoption-stance.mjs';
9
10
 
10
11
  export function collectDoctorNextActions(ctx) {
11
12
  const actions = [];
12
13
  const gatesInstalled = Array.isArray(ctx.gatesMissing) && ctx.gatesMissing.length === 0;
13
14
  const planAEmpty = !ctx.activeCount;
15
+ const notAdopted = ctx.adopted !== 'required-merge' && ctx.adopted !== 'advisory-only-acked';
16
+ if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
17
+ actions.push(ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION);
18
+ }
19
+ const nudge = ctx.stewardNudge;
20
+ if (
21
+ nudge &&
22
+ (nudge.needsStewards || nudge.drift || nudge.emptyStewardsPastGrace) &&
23
+ nudge.nextAction
24
+ ) {
25
+ actions.push(nudge.nextAction);
26
+ }
14
27
  const enforceEmptyPlan =
15
- ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled;
28
+ ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled && !notAdopted;
16
29
  if (enforceEmptyPlan) {
17
30
  actions.push(
18
31
  ctx.postGreenPath?.action ||
19
- '/ark-explore shape-focus /ark-autopilot (one B pilot) # ENFORCE + empty plan A → Shape, not reinstall gates'
32
+ '/ark-explore, then one small refactor with /ark-autopilot and your OK'
20
33
  );
21
34
  }
22
35
  if (!ctx.analysisComplete) actions.push('restore complete analysis, then rerun ark-check --doctor');
@@ -88,6 +101,7 @@ export function collectDoctorNextActions(ctx) {
88
101
  );
89
102
  }
90
103
  for (const gap of ctx.adoption.gaps) {
104
+ if (gap.id === 'adoption-stance-missing') continue;
91
105
  if (!gap.deferred) actions.push(gap.fix || gap.message);
92
106
  }
93
107
  if (ctx.safety && ctx.safetyHasEntries) {
@@ -100,5 +114,9 @@ export function collectDoctorNextActions(ctx) {
100
114
  if (ctx.designFitness.designWeak && unique.length === 0 && ctx.postGreenPath) {
101
115
  unique.push(ctx.postGreenPath.action);
102
116
  }
117
+ if (notAdopted) {
118
+ const next = ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION;
119
+ return [next, ...unique.filter((a) => a !== next)];
120
+ }
103
121
  return unique;
104
122
  }