arkgate 4.1.1 → 4.2.0
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.
- package/CHANGELOG.md +81 -3
- package/README.md +15 -4
- package/bin/ark-check-runtime.mjs +16 -5
- package/bin/ark-mcp-runtime.mjs +766 -64
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/ci-and-commands.mjs +16 -7
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +36 -36
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +16 -3
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +11 -89
- package/bin/lib/import-resolve.mjs +33 -11
- package/bin/lib/install-activation.mjs +87 -0
- package/bin/lib/install-migrate.mjs +66 -50
- package/bin/lib/managed-upgrade.mjs +10 -41
- package/bin/lib/mcp-adoption.mjs +15 -5
- package/bin/lib/pilot-loop.mjs +25 -8
- package/bin/lib/project-identity.mjs +103 -0
- package/bin/lib/report-snapshot-context.mjs +28 -0
- package/bin/lib/resident-hook.mjs +33 -9
- package/bin/lib/rules-inventory.mjs +100 -8
- package/bin/lib/skill-install.mjs +272 -22
- package/bin/lib/skill-write.mjs +899 -0
- package/bin/lib/start-preview.mjs +84 -1
- package/bin/lib/upgrade-command.mjs +2 -5
- package/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +5 -3
- package/docs/agent-guide.md +110 -14
- package/docs/ai-gates.md +103 -18
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +14 -9
- package/docs/product-voice.md +13 -1
- package/package.json +3 -1
- package/schemas/ark.project-identity.schema.json +116 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +9 -0
- package/templates/skills/ark-architect.md +12 -2
- package/templates/skills/ark-autopilot.md +9 -0
- package/templates/skills/ark-contract.md +11 -1
- package/templates/skills/ark-coverage.md +9 -0
- package/templates/skills/ark-explain.md +13 -1
- package/templates/skills/ark-explore.md +9 -0
- package/templates/skills/ark-fix.md +10 -1
- package/templates/skills/ark-loop.md +11 -2
- package/templates/skills/ark-place.md +17 -6
- package/templates/skills/ark-runtime.md +8 -0
- package/templates/skills/ark-think.md +14 -2
- package/templates/skills/ark-upgrade.md +9 -0
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -183,9 +183,11 @@ export function ensureCheckArchitectureScript(root, opts = {}) {
|
|
|
183
183
|
// source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
|
|
184
184
|
// is a builder because the check command's runner prefix varies with the package manager.
|
|
185
185
|
const AGENT_CONTRACT = {
|
|
186
|
-
|
|
186
|
+
manifestTool: 'ark_manifest',
|
|
187
|
+
compatibilityManifestResource: 'ark://manifest',
|
|
187
188
|
steps: (checkCommand) => [
|
|
188
|
-
`
|
|
189
|
+
`Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\` set to the exact project root's absolute path. Reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only when the matching project id is also supplied. If the tool is missing, the binding is not \`matched\`, or the reported root differs, restart the host and use the local CLI until identity matches.`,
|
|
190
|
+
`Read the authoritative Ark contract with \`ark_manifest\` using the same project expectation. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative.`,
|
|
189
191
|
`Keep source files inside the layer boundaries declared in \`ark.config.json\`.`,
|
|
190
192
|
`Do not bypass Ark publishers, event contracts, or source metadata for runtime mutations.`,
|
|
191
193
|
`After edits, run \`${checkCommand}\`.`,
|
|
@@ -336,7 +338,7 @@ advisory MCP plus CI. The experimental runtime is not required.
|
|
|
336
338
|
|
|
337
339
|
/**
|
|
338
340
|
* Compact onboarding uses one project router instead of copied slash-command
|
|
339
|
-
* skills. The package and ark MCP
|
|
341
|
+
* skills. The package and ark MCP tools remain the canonical capability
|
|
340
342
|
* source; the marker makes the selected host verifiable by the strict gate.
|
|
341
343
|
*/
|
|
342
344
|
export function compactAgentInstructions(root, host = null) {
|
|
@@ -358,8 +360,9 @@ export function compactAgentInstructions(root, host = null) {
|
|
|
358
360
|
**Primary path (do this):**
|
|
359
361
|
|
|
360
362
|
1. Status anytime: \`${doctorCmd}\` — one status light, one next action (control plane).
|
|
361
|
-
2.
|
|
362
|
-
3.
|
|
363
|
+
2. Before trusting MCP evidence: call \`ark_identity\` with \`project.expectedRoot\` set to this project's exact absolute root, then reuse that root plus the returned \`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only with that matching id. Missing tool, non-\`matched\` binding, or wrong root means the process is stale: restart the host and use the local CLI meanwhile.
|
|
364
|
+
3. Day to day: call \`ark_manifest\` with the same project expectation; place new files with \`ark_place\`; validate after edits; run \`${checkCmd}\`. The \`ark://manifest\` resource is compatibility-only and always unverified/non-authoritative. On a gate deny, fix the architecture — do not weaken the contract.
|
|
365
|
+
4. If MCP is unavailable: inspect \`ark.config.json\` and run \`${checkCmd}\`.
|
|
363
366
|
|
|
364
367
|
The selected host is \`${selectedHost}\`. Host registration and CI are installed with this file.
|
|
365
368
|
This compact router is enough for normal feature work.
|
|
@@ -431,8 +434,14 @@ description: Ark architecture contract
|
|
|
431
434
|
alwaysApply: true
|
|
432
435
|
---
|
|
433
436
|
|
|
434
|
-
Before
|
|
435
|
-
|
|
437
|
+
Before trusting Ark MCP evidence, call \`ark_identity\` with \`project.expectedRoot\`
|
|
438
|
+
set to the exact project root's absolute path. Reuse that root plus the returned
|
|
439
|
+
\`projectIdentity.projectId\` on every Ark MCP call. A descendant path is authoritative only
|
|
440
|
+
when that matching id is also supplied. If the tool is missing, the binding is not \`matched\`,
|
|
441
|
+
or the root differs, restart the host and use the local CLI until identity matches. Then call
|
|
442
|
+
\`${AGENT_CONTRACT.manifestTool}\` with the same project expectation. The
|
|
443
|
+
\`${AGENT_CONTRACT.compatibilityManifestResource}\` resource is compatibility-only and always
|
|
444
|
+
unverified/non-authoritative.
|
|
436
445
|
|
|
437
446
|
${AGENT_CONTRACT.cursorValidateStep} After edits, run:
|
|
438
447
|
|
package/bin/lib/codex-home.mjs
CHANGED
|
@@ -165,22 +165,104 @@ export function codexScopedTableForRoot(tomlText, absRoot) {
|
|
|
165
165
|
return null;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
function extractCodexCommandFromBlock(block) {
|
|
169
|
+
const matches = [
|
|
170
|
+
...String(block || '').matchAll(
|
|
171
|
+
/^[ \t]*command[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')[ \t]*(?:#.*)?$/gm
|
|
172
|
+
),
|
|
173
|
+
];
|
|
174
|
+
if (matches.length !== 1) return null;
|
|
175
|
+
try {
|
|
176
|
+
return matches[0][1].startsWith('"')
|
|
177
|
+
? JSON.parse(matches[0][1])
|
|
178
|
+
: matches[0][1].slice(1, -1);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function executableName(value) {
|
|
185
|
+
if (typeof value !== 'string' || value !== value.trim() || value.length === 0) return '';
|
|
186
|
+
return path.posix
|
|
187
|
+
.basename(value.replace(/\\/g, '/'))
|
|
188
|
+
.replace(/\.(?:cmd|exe)$/i, '')
|
|
189
|
+
.toLowerCase();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isArkMcpToken(value) {
|
|
193
|
+
return /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function codexArkMcpInvocation(command, args) {
|
|
197
|
+
if (
|
|
198
|
+
!command ||
|
|
199
|
+
!Array.isArray(args) ||
|
|
200
|
+
[command, ...args].filter(isArkMcpToken).length !== 1
|
|
201
|
+
) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const argv = [command, ...args];
|
|
205
|
+
if (isArkMcpToken(command)) return { binArgs: argv.slice(1) };
|
|
206
|
+
const runner = executableName(command);
|
|
207
|
+
if ((runner === 'npx' || runner === 'yarn') && isArkMcpToken(args[0])) {
|
|
208
|
+
return { binArgs: argv.slice(2) };
|
|
209
|
+
}
|
|
210
|
+
if (runner === 'node') {
|
|
211
|
+
const script = args[0]?.replace(/\\/g, '/');
|
|
212
|
+
return isArkMcpToken(script) && /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script)
|
|
213
|
+
? { binArgs: argv.slice(2) }
|
|
214
|
+
: false;
|
|
215
|
+
}
|
|
216
|
+
if (runner !== 'pnpm') return false;
|
|
217
|
+
const binIndex =
|
|
218
|
+
args[0] === 'exec'
|
|
219
|
+
? 1
|
|
220
|
+
: args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
|
|
221
|
+
? 2
|
|
222
|
+
: -1;
|
|
223
|
+
return binIndex >= 0 && isArkMcpToken(args[binIndex])
|
|
224
|
+
? { binArgs: argv.slice(binIndex + 2) }
|
|
225
|
+
: false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function singleOptionValue(args, option) {
|
|
229
|
+
const indexes = args.flatMap((value, index) => (value === option ? [index] : []));
|
|
230
|
+
return indexes.length === 1 ? args[indexes[0] + 1] ?? null : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function projectPathFlavor(projectRoot) {
|
|
234
|
+
const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(projectRoot);
|
|
235
|
+
return {
|
|
236
|
+
api: windows ? path.win32 : path,
|
|
237
|
+
comparable: (value) => (windows ? value.toLowerCase() : value),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function blockHasWorkingDirectoryOverride(block) {
|
|
242
|
+
return /^[ \t]*(?:cwd|"cwd"|'cwd')[ \t]*=/m.test(String(block || ''));
|
|
243
|
+
}
|
|
244
|
+
|
|
168
245
|
/** True when project TOML owns the primary Ark MCP binding for that project. */
|
|
169
246
|
export function codexProjectMcpIsValid(tomlText, projectRoot) {
|
|
170
|
-
const resolvedRoot = path.resolve(projectRoot);
|
|
171
247
|
if (listCodexArkServerTables(tomlText).filter((entry) => entry.table === 'ark').length !== 1) {
|
|
172
248
|
return false;
|
|
173
249
|
}
|
|
174
250
|
const primary = codexPrimaryTable(tomlText);
|
|
175
251
|
const args = extractCodexArgsFromBlock(primary?.block);
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
252
|
+
const command = extractCodexCommandFromBlock(primary?.block);
|
|
253
|
+
const invocation = args && codexArkMcpInvocation(command, args);
|
|
254
|
+
if (!invocation || blockHasWorkingDirectoryOverride(primary?.block)) return false;
|
|
255
|
+
const rootArg = singleOptionValue(invocation.binArgs, '--root');
|
|
256
|
+
const configArg = singleOptionValue(invocation.binArgs, '--config');
|
|
257
|
+
if (!rootArg || !configArg || invocation.binArgs.length !== 4) return false;
|
|
180
258
|
try {
|
|
259
|
+
const { api, comparable } = projectPathFlavor(projectRoot);
|
|
260
|
+
const resolvedRoot = api.resolve(projectRoot);
|
|
261
|
+
const requestedRoot = api.resolve(resolvedRoot, rootArg);
|
|
262
|
+
const requestedConfig = api.resolve(resolvedRoot, configArg);
|
|
181
263
|
return (
|
|
182
|
-
|
|
183
|
-
|
|
264
|
+
comparable(requestedRoot) === comparable(resolvedRoot) &&
|
|
265
|
+
comparable(requestedConfig) === comparable(api.join(resolvedRoot, 'ark.config.json'))
|
|
184
266
|
);
|
|
185
267
|
} catch {
|
|
186
268
|
return false;
|
|
@@ -281,7 +363,7 @@ export function assessCodexHomeMcp(tomlText, absRoot) {
|
|
|
281
363
|
message: scopedTable
|
|
282
364
|
? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
|
|
283
365
|
`this project is registered as [mcp_servers.${scopedTable}]. ` +
|
|
284
|
-
`Install the project-scoped binding so this repo
|
|
366
|
+
`Install the project-scoped binding so ark_identity and ark_manifest match this repo when active.`
|
|
285
367
|
: `Codex home primary MCP --root is another permanent project ` +
|
|
286
368
|
`(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
|
|
287
369
|
`Install the project-scoped binding for this repo; the global primary can remain unchanged.`,
|
|
@@ -33,7 +33,7 @@ export const DESIGN_SMELL_OUTCOMES = Object.freeze({
|
|
|
33
33
|
'god-module':
|
|
34
34
|
'A few huge files own too many responsibilities — the AI cannot safely edit one concern without breaking others. Split the pilot file by job (one export surface per concern).',
|
|
35
35
|
'domain-logic-in-ui':
|
|
36
|
-
'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move pure
|
|
36
|
+
'Business rules (can*/calculate*/policy) sit in UI components — the AI will duplicate them in pages. Move the pure rule into Domain, expose it through Application, and keep UI imports on that Application boundary.',
|
|
37
37
|
'facade-sql-in-routes':
|
|
38
38
|
'Routes/controllers import the ORM or SQL client — the AI will keep growing “smart controllers.” Keep queries in a repository/adapter; routes only call that port.',
|
|
39
39
|
'mixed-pattern-cluster':
|
|
@@ -76,8 +76,13 @@ const ROUTE_DEFINITION_CALL_RE =
|
|
|
76
76
|
/\bdefineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/;
|
|
77
77
|
const ROUTE_DEFINITION_DECLARATION_RE =
|
|
78
78
|
/\b(?:export\s+)?(?:declare\s+)?(?:async\s+)?function\s+defineRoute\s*(?:<[\s\S]{1,512}?>)?\s*\(/g;
|
|
79
|
-
const
|
|
80
|
-
/\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should)[A-Z]\w
|
|
79
|
+
const DOMAIN_LOGIC_UI_DECL_RE =
|
|
80
|
+
/\b(?:export\s+)?(?:(?:async\s+)?function\s+((?:can|calculate|compute|should)[A-Z]\w*)|const\s+((?:can|calculate|compute|should)[A-Z]\w*)\s*=)/g;
|
|
81
|
+
const UI_PERMISSION_OR_LOCAL_STATE_RE =
|
|
82
|
+
/\b(?:permissions?|roles?|acl|session|currentUser|isOwner|readOnly|useState|useMemo|useContext|localState|uiState|selected(?:Id|Row|Tab)?|is(?:Open|Closed|Expanded|Collapsed|Selected|Loading|Pending|Hovered|Focused|Disabled))\b(?:\s*\.\s*\w+|\s*\[[^\]]+\])*/gi;
|
|
83
|
+
const STRING_LITERAL_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
|
|
84
|
+
const BUSINESS_RULE_CONTEXT_RE =
|
|
85
|
+
/\b(?:amount|total|price|tax|discount|balance|quantity|inventory|order|cart|invoice|credit|policy|threshold|limit|status)\b/i;
|
|
81
86
|
const EXPORT_RE =
|
|
82
87
|
/\bexport\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum|default)\b|\bexport\s*\{/g;
|
|
83
88
|
|
|
@@ -93,6 +98,19 @@ const MAX_SCAN_FILES = 800;
|
|
|
93
98
|
const GOD_LOC = 400;
|
|
94
99
|
const GOD_EXPORTS = 12;
|
|
95
100
|
|
|
101
|
+
/** Files that may be intentionally broad or generated are not extraction pilots. */
|
|
102
|
+
export function isNonProductionPilotPath(filePath) {
|
|
103
|
+
const rel = String(filePath || '').replace(/\\/g, '/');
|
|
104
|
+
return (
|
|
105
|
+
/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|demos?|generated|codegen)(?:\/|$)/i.test(
|
|
106
|
+
rel
|
|
107
|
+
) ||
|
|
108
|
+
/(?:^|\/)(?:(?:fixture|seed|seeder|migration|demo|generated)|[^/]*(?:\.generated|\.gen|[-_.](?:fixture|seed|seeder|migration|demo|generated)))\.(?:ts|tsx|js|jsx|mts|cts)$/i.test(
|
|
109
|
+
rel
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
function normalizeRel(root, filePath) {
|
|
97
115
|
const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
98
116
|
let rel = path.relative(root, abs).split(path.sep).join('/');
|
|
@@ -175,6 +193,38 @@ function hasHollowPersistenceShape(source) {
|
|
|
175
193
|
);
|
|
176
194
|
}
|
|
177
195
|
|
|
196
|
+
function hasGeneratedSourceBanner(source) {
|
|
197
|
+
return /(?:@generated|GENERATED FILE|generated by|do not edit)/i.test(source.slice(0, 400));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function hasDomainLogicInUi(source) {
|
|
201
|
+
for (const match of source.matchAll(DOMAIN_LOGIC_UI_DECL_RE)) {
|
|
202
|
+
const name = match[1] || match[2] || '';
|
|
203
|
+
const start = match.index ?? 0;
|
|
204
|
+
const statementEnd = source.indexOf(';', start);
|
|
205
|
+
const end =
|
|
206
|
+
statementEnd >= start && statementEnd <= start + 800
|
|
207
|
+
? statementEnd + 1
|
|
208
|
+
: Math.min(source.length, start + 800);
|
|
209
|
+
const declaration = source.slice(start, end);
|
|
210
|
+
const uiDecisionName =
|
|
211
|
+
/^(?:can(?:Edit|Delete|View|Manage|Select|Open|Close)|should(?:Show|Render|Display|Open|Close|Disable|Enable))/i.test(
|
|
212
|
+
name
|
|
213
|
+
);
|
|
214
|
+
// canEdit/shouldShow are common local UI or permission decisions. Require
|
|
215
|
+
// business evidence outside the helper name before treating them as domain logic.
|
|
216
|
+
const ruleContext = declaration
|
|
217
|
+
.replace(name, '')
|
|
218
|
+
.replace(STRING_LITERAL_RE, '')
|
|
219
|
+
.replace(UI_PERMISSION_OR_LOCAL_STATE_RE, '');
|
|
220
|
+
if (uiDecisionName && !BUSINESS_RULE_CONTEXT_RE.test(ruleContext)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
178
228
|
/**
|
|
179
229
|
* @typedef {object} DesignSmell
|
|
180
230
|
* @property {string} id
|
|
@@ -277,11 +327,16 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
|
|
|
277
327
|
const loc = countLines(source);
|
|
278
328
|
const exportsCount = countExports(source);
|
|
279
329
|
|
|
280
|
-
if (
|
|
330
|
+
if (
|
|
331
|
+
loc >= GOD_LOC &&
|
|
332
|
+
exportsCount >= GOD_EXPORTS &&
|
|
333
|
+
!isNonProductionPilotPath(rel) &&
|
|
334
|
+
!hasGeneratedSourceBanner(source)
|
|
335
|
+
) {
|
|
281
336
|
godEvidence.push(rel);
|
|
282
337
|
}
|
|
283
338
|
|
|
284
|
-
if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) &&
|
|
339
|
+
if ((UI_PATH_RE.test(rel) || isPresentationLayer(layer)) && hasDomainLogicInUi(source)) {
|
|
285
340
|
domainInUi.push(rel);
|
|
286
341
|
}
|
|
287
342
|
|
|
@@ -353,7 +408,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
|
|
|
353
408
|
severity: 'warn',
|
|
354
409
|
message: `Business-style can*/calculate*/compute* helpers live under UI/presentation paths (${domainInUi.length} file(s)).`,
|
|
355
410
|
evidence: domainInUi.slice(0, 12),
|
|
356
|
-
fix: 'Move pure
|
|
411
|
+
fix: 'Move the pure rule into Domain, expose it through Application, and have UI import the Application boundary (never Presentation → Domain directly).',
|
|
357
412
|
})
|
|
358
413
|
);
|
|
359
414
|
}
|
|
@@ -399,7 +454,7 @@ export function detectDesignSmells(root, config, files = [], coverage = null) {
|
|
|
399
454
|
}
|
|
400
455
|
|
|
401
456
|
/**
|
|
402
|
-
* Whether edge-clean
|
|
457
|
+
* Whether edge-clean analysis should still report design-weak residual.
|
|
403
458
|
*
|
|
404
459
|
* @param {DesignSmell[]} smells
|
|
405
460
|
* @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null }} ctx
|
|
@@ -416,16 +471,23 @@ export function isDesignWeak(smells, ctx = {}) {
|
|
|
416
471
|
|
|
417
472
|
/**
|
|
418
473
|
* Design fitness summary for doctor JSON / human.
|
|
474
|
+
* @param {DesignSmell[]} smells
|
|
475
|
+
* @param {{ activeViolations?: number, governedPercent?: number|null, totalFiles?: number|null, operatingMode?: string }} ctx
|
|
419
476
|
*/
|
|
420
477
|
export function summarizeDesignFitness(smells, ctx = {}) {
|
|
421
478
|
const designWeak = isDesignWeak(smells, ctx);
|
|
479
|
+
const mode =
|
|
480
|
+
typeof ctx.operatingMode === 'string' &&
|
|
481
|
+
/^(?:suggest|adapt|enforce)$/.test(ctx.operatingMode)
|
|
482
|
+
? ctx.operatingMode.toUpperCase()
|
|
483
|
+
: null;
|
|
422
484
|
return {
|
|
423
485
|
status: designWeak ? 'design-weak' : smells.length > 0 ? 'smells-with-open-edges' : 'ok',
|
|
424
486
|
designWeak,
|
|
425
487
|
smellCount: Array.isArray(smells) ? smells.length : 0,
|
|
426
488
|
ids: (smells || []).map((s) => s.id),
|
|
427
489
|
label: designWeak
|
|
428
|
-
?
|
|
490
|
+
? `${mode ? `${mode} · ` : ''}design-weak — edges clean; Shape residual remains (see designSmells / plan B)`
|
|
429
491
|
: smells.length > 0
|
|
430
492
|
? 'Design smells present alongside open edge debt'
|
|
431
493
|
: 'No deterministic design smells detected',
|
|
@@ -471,7 +533,7 @@ function successSignalFor(id) {
|
|
|
471
533
|
case 'god-module':
|
|
472
534
|
return 'Pilot god module split; fan-in and export surface reduced without new edge violations';
|
|
473
535
|
case 'domain-logic-in-ui':
|
|
474
|
-
return 'can*/calculate* pure rules live under Domain; UI imports
|
|
536
|
+
return 'can*/calculate* pure rules live under Domain; Application exposes them; UI imports Application only';
|
|
475
537
|
case 'facade-sql-in-routes':
|
|
476
538
|
return '0 route/controller files import ORM/SQL clients; queries in adapters';
|
|
477
539
|
case 'mixed-pattern-cluster':
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -22,6 +22,7 @@ const matchingLayersForRelativePath =
|
|
|
22
22
|
import {
|
|
23
23
|
collectAdoptionGaps,
|
|
24
24
|
detectSkillGaps,
|
|
25
|
+
skillGapsForActiveHost,
|
|
25
26
|
detectCodexHomeGap,
|
|
26
27
|
codexConcernIsActive,
|
|
27
28
|
detectWritePathCapabilities,
|
|
@@ -331,7 +332,7 @@ export function buildRemediationPlan(
|
|
|
331
332
|
: 'No active violations — the architecture already meets its contract.';
|
|
332
333
|
if (designWeak) {
|
|
333
334
|
statement =
|
|
334
|
-
'No active edge violations — contract edges are clean, but design smells remain (
|
|
335
|
+
'No active edge violations — contract edges are clean, but design smells remain (design-weak). Shape residual is plan B only; not healthy finished.';
|
|
335
336
|
}
|
|
336
337
|
if (completeness !== ANALYSIS_COMPLETENESS.complete) statement = analysisIncompleteStatement(completeness);
|
|
337
338
|
|
|
@@ -361,7 +362,7 @@ export function buildRemediationPlan(
|
|
|
361
362
|
...(designWeak
|
|
362
363
|
? {
|
|
363
364
|
designWeakLabel:
|
|
364
|
-
'
|
|
365
|
+
'Design-weak — use patternBets / dual-plan B; never auto-apply as mechanical-safe',
|
|
365
366
|
...DESIGN_WEAK_HONESTY_FLAGS,
|
|
366
367
|
}
|
|
367
368
|
: {}),
|
|
@@ -422,7 +423,7 @@ export function runPlan(
|
|
|
422
423
|
if (plan.goal.designWeak) {
|
|
423
424
|
console.log(
|
|
424
425
|
color.yellow(
|
|
425
|
-
`
|
|
426
|
+
` Design-weak — ${plan.patternBets?.length ?? 0} pattern bet(s) (never auto-apply)`
|
|
426
427
|
)
|
|
427
428
|
);
|
|
428
429
|
}
|
|
@@ -536,11 +537,32 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
536
537
|
if (!baseline.exists) return true;
|
|
537
538
|
return !baseline.keys.has(occurrenceKeys[index]);
|
|
538
539
|
}).length;
|
|
540
|
+
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
|
|
541
|
+
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
542
|
+
const totalFilesEarly = cov.governed.totalFiles || 0;
|
|
543
|
+
const operatingMode = resolveOperatingMode({
|
|
544
|
+
governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
|
|
545
|
+
// planMet uses blocking only; type-only placement debt alone must not force ADAPT.
|
|
546
|
+
planMet:
|
|
547
|
+
analysisComplete &&
|
|
548
|
+
blockingActive === 0 &&
|
|
549
|
+
!emptyScopeEarly &&
|
|
550
|
+
cov.governed.percent >= 50,
|
|
551
|
+
mature: cov.governed.totalFiles >= 150,
|
|
552
|
+
totalFiles: cov.governed.totalFiles,
|
|
553
|
+
emptyLayers: cov.emptyLayers,
|
|
554
|
+
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
|
|
555
|
+
presentationShare:
|
|
556
|
+
totalFilesEarly > 0 && presentationRowEarly
|
|
557
|
+
? presentationRowEarly.files / totalFilesEarly
|
|
558
|
+
: null,
|
|
559
|
+
});
|
|
539
560
|
const designSmells = detectDesignSmells(root, config, files, cov);
|
|
540
561
|
const observedDesignFitness = summarizeDesignFitness(designSmells, {
|
|
541
562
|
activeViolations: blockingActive,
|
|
542
563
|
governedPercent: cov.governed.percent,
|
|
543
564
|
totalFiles: cov.governed.totalFiles,
|
|
565
|
+
operatingMode,
|
|
544
566
|
});
|
|
545
567
|
const designFitness = analysisComplete ? observedDesignFitness : {
|
|
546
568
|
...observedDesignFitness, status: 'analysis-incomplete', designWeak: false, label: 'Design fitness not verified — analysis is incomplete; observed smells remain advisory.',
|
|
@@ -576,27 +598,6 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
576
598
|
);
|
|
577
599
|
// Single residual expression (nextPilot || extractionCard) — HTML report uses the same.
|
|
578
600
|
const residualPilot = pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
|
|
579
|
-
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
|
|
580
|
-
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
581
|
-
const totalFilesEarly = cov.governed.totalFiles || 0;
|
|
582
|
-
const operatingMode = resolveOperatingMode({
|
|
583
|
-
governedPercent: emptyScopeEarly ? 0 : cov.governed.percent,
|
|
584
|
-
// planMet uses blocking (failsStrict !== false) only — type-only placement debt alone
|
|
585
|
-
// must not force adapt via unmet plan (parity with merge/exit and productHonesty).
|
|
586
|
-
planMet:
|
|
587
|
-
analysisComplete &&
|
|
588
|
-
blockingActive === 0 &&
|
|
589
|
-
!emptyScopeEarly &&
|
|
590
|
-
cov.governed.percent >= 50,
|
|
591
|
-
mature: cov.governed.totalFiles >= 150,
|
|
592
|
-
totalFiles: cov.governed.totalFiles,
|
|
593
|
-
emptyLayers: cov.emptyLayers,
|
|
594
|
-
coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
|
|
595
|
-
presentationShare:
|
|
596
|
-
totalFilesEarly > 0 && presentationRowEarly
|
|
597
|
-
? presentationRowEarly.files / totalFilesEarly
|
|
598
|
-
: null,
|
|
599
|
-
});
|
|
600
601
|
// Evidence-backed hard only (never capabilities-from-hook-files alone).
|
|
601
602
|
const hardWriteActive = writePath.enforcementState?.localWrite?.hard === true;
|
|
602
603
|
const packageInstalled = writePath.enforcementState?.localWrite?.installed === true;
|
|
@@ -796,7 +797,6 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
796
797
|
mode === 'enforce' && !designFitness.designWeak
|
|
797
798
|
? ok
|
|
798
799
|
: warn;
|
|
799
|
-
// Status lights are detected states, not user-picked settings (see docs/product-voice.md).
|
|
800
800
|
// modeTitle alone names the light — bodies must not re-prefix Suggest/Adapt/Enforce.
|
|
801
801
|
const modeHelp = {
|
|
802
802
|
suggest:
|
|
@@ -807,8 +807,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
807
807
|
'honest coverage and clean checked edges. You arrived here; you never turn Enforce on. Next: keep the host write path and CI check; only NEW violations should fail.',
|
|
808
808
|
};
|
|
809
809
|
const modeTitle =
|
|
810
|
-
|
|
811
|
-
?
|
|
810
|
+
designFitness.designWeak
|
|
811
|
+
? `${mode.toUpperCase()} · design-weak`
|
|
812
812
|
: mode.toUpperCase();
|
|
813
813
|
line(
|
|
814
814
|
modeMark,
|
|
@@ -999,7 +999,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
999
999
|
'No active violations — coverage is still thin, so green is not yet honest enforcement'
|
|
1000
1000
|
);
|
|
1001
1001
|
} else if (designFitness.designWeak) {
|
|
1002
|
-
line(warn,
|
|
1002
|
+
line(warn, `None on checked edges — edges match the contract; design residual remains (${modeTitle}). Not healthy finished.`);
|
|
1003
1003
|
} else {
|
|
1004
1004
|
line(ok, 'None — the code matches the contract on checked edges');
|
|
1005
1005
|
}
|
|
@@ -1067,17 +1067,17 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
1067
1067
|
|
|
1068
1068
|
console.log('');
|
|
1069
1069
|
console.log(color.bold('Gates & skills'));
|
|
1070
|
-
if (gatesMissing.length === 0) line(ok, 'Shared gate
|
|
1070
|
+
if (gatesMissing.length === 0) line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
|
|
1071
1071
|
else {
|
|
1072
1072
|
line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
|
|
1073
1073
|
actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
|
|
1074
1074
|
}
|
|
1075
|
-
|
|
1076
|
-
const legacyCodex =
|
|
1077
|
-
const codexLegacySafeDelete =
|
|
1075
|
+
const humanSkillGaps = skillGapsForActiveHost(skillGaps);
|
|
1076
|
+
const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
|
|
1077
|
+
const codexLegacySafeDelete = humanSkillGaps.some(
|
|
1078
1078
|
(g) => g.tool === 'codex' && g.legacyAdvisory && g.catalogComplete
|
|
1079
1079
|
);
|
|
1080
|
-
const remainingGaps =
|
|
1080
|
+
const remainingGaps = humanSkillGaps.filter(
|
|
1081
1081
|
(g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
|
|
1082
1082
|
);
|
|
1083
1083
|
const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
|
|
@@ -1107,7 +1107,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
1107
1107
|
const parts = [
|
|
1108
1108
|
codexHomeGap.legacyPromptsOnly ? 'legacy-prompts-only' : null,
|
|
1109
1109
|
codexHomeGap.missing > 0 ? `${codexHomeGap.missing} missing` : null,
|
|
1110
|
-
codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null,
|
|
1110
|
+
codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null, codexHomeGap.catalogStateReason,
|
|
1111
1111
|
].filter(Boolean);
|
|
1112
1112
|
const deferred = !codexConcernIsActive();
|
|
1113
1113
|
// Deferred home debt is dim/info (not warn) so non-Codex sessions are not "incomplete".
|
|
@@ -1115,7 +1115,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
1115
1115
|
line(color.dim('·'), color.dim(`Codex home skills ${parts.join(', ')} (deferred — not on Codex session)`));
|
|
1116
1116
|
} else {
|
|
1117
1117
|
line(warn, `Codex home skills ${parts.join(', ')}`);
|
|
1118
|
-
actions.push('refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
|
|
1118
|
+
actions.push(codexHomeGap.catalogMetadataInvalid ? 'repair invalid Codex home catalog metadata after verifying the newest installed version' : 'refresh Codex home skills (--install-agent-gates --skills-only --codex-home --force)');
|
|
1119
1119
|
}
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
@@ -1229,7 +1229,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
1229
1229
|
if (postGreenPath) {
|
|
1230
1230
|
console.log(
|
|
1231
1231
|
color.dim(
|
|
1232
|
-
|
|
1232
|
+
` Shape residual is the primary door under ${modeTitle} — do not skill-shop explore vs coverage vs think.`
|
|
1233
1233
|
)
|
|
1234
1234
|
);
|
|
1235
1235
|
} else {
|
|
@@ -10,6 +10,35 @@ import {
|
|
|
10
10
|
loadArkRulesContract,
|
|
11
11
|
} from './arkrules-contract.mjs';
|
|
12
12
|
|
|
13
|
+
function normalizeProjectRelativePath(value) {
|
|
14
|
+
const normalized = value.replace(/\\/g, '/');
|
|
15
|
+
if (
|
|
16
|
+
!normalized ||
|
|
17
|
+
normalized.startsWith('/') ||
|
|
18
|
+
/^[A-Za-z]:/.test(normalized) ||
|
|
19
|
+
normalized.includes('\0')
|
|
20
|
+
) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const segments = [];
|
|
24
|
+
for (const segment of normalized.split('/')) {
|
|
25
|
+
if (!segment || segment === '.') continue;
|
|
26
|
+
if (segment === '..') return undefined;
|
|
27
|
+
segments.push(segment);
|
|
28
|
+
}
|
|
29
|
+
return segments.length > 0 ? segments.join('/') : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWithinRoot(root, candidate) {
|
|
33
|
+
const relative = path.relative(root, candidate);
|
|
34
|
+
return (
|
|
35
|
+
relative === '' ||
|
|
36
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
37
|
+
relative !== '..' &&
|
|
38
|
+
!path.isAbsolute(relative))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
/**
|
|
14
43
|
* @param {string} root
|
|
15
44
|
* @param {Record<string, unknown>} config loaded ark.config.json object
|
|
@@ -29,6 +58,7 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
29
58
|
const warnings = [];
|
|
30
59
|
const parts = [];
|
|
31
60
|
const referenced = new Set();
|
|
61
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
32
62
|
|
|
33
63
|
for (const layer of Object.keys(refs).sort()) {
|
|
34
64
|
const relRaw = refs[layer];
|
|
@@ -37,10 +67,12 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
37
67
|
errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
|
|
38
68
|
continue;
|
|
39
69
|
}
|
|
40
|
-
|
|
70
|
+
const rel = normalizeProjectRelativePath(relRaw);
|
|
71
|
+
if (!rel) {
|
|
41
72
|
errors.push({
|
|
42
73
|
path: pathKey,
|
|
43
|
-
message:
|
|
74
|
+
message:
|
|
75
|
+
'must be a project-relative path without absolute roots or parent-directory traversal',
|
|
44
76
|
});
|
|
45
77
|
continue;
|
|
46
78
|
}
|
|
@@ -52,17 +84,42 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
52
84
|
continue;
|
|
53
85
|
}
|
|
54
86
|
|
|
55
|
-
const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
56
87
|
referenced.add(rel);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
const lexicalTarget = path.resolve(canonicalRoot, ...rel.split('/'));
|
|
89
|
+
if (!isWithinRoot(canonicalRoot, lexicalTarget)) {
|
|
90
|
+
errors.push({
|
|
91
|
+
path: pathKey,
|
|
92
|
+
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!fs.existsSync(lexicalTarget)) {
|
|
60
97
|
errors.push({
|
|
61
98
|
path: pathKey,
|
|
62
99
|
message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
|
|
63
100
|
});
|
|
64
101
|
continue;
|
|
65
102
|
}
|
|
103
|
+
let absolute;
|
|
104
|
+
try {
|
|
105
|
+
absolute = fs.realpathSync(lexicalTarget);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
errors.push({
|
|
108
|
+
path: pathKey,
|
|
109
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} could not be resolved: ${
|
|
110
|
+
error instanceof Error ? error.message : String(error)
|
|
111
|
+
}`,
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!isWithinRoot(canonicalRoot, absolute)) {
|
|
116
|
+
errors.push({
|
|
117
|
+
path: pathKey,
|
|
118
|
+
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
opts.observeInput?.(absolute, 'arkrules');
|
|
66
123
|
let content;
|
|
67
124
|
try {
|
|
68
125
|
content = fs.readFileSync(absolute, 'utf8');
|
|
@@ -90,9 +147,16 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
// Drift: unreferenced files under arkrules/
|
|
93
|
-
const arkrulesDir = path.join(
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
const arkrulesDir = path.join(canonicalRoot, 'arkrules');
|
|
151
|
+
const resolvedArkRulesDir = fs.existsSync(arkrulesDir)
|
|
152
|
+
? fs.realpathSync(arkrulesDir)
|
|
153
|
+
: undefined;
|
|
154
|
+
if (
|
|
155
|
+
resolvedArkRulesDir &&
|
|
156
|
+
isWithinRoot(canonicalRoot, resolvedArkRulesDir) &&
|
|
157
|
+
fs.statSync(resolvedArkRulesDir).isDirectory()
|
|
158
|
+
) {
|
|
159
|
+
for (const name of fs.readdirSync(resolvedArkRulesDir).sort()) {
|
|
96
160
|
if (!name.endsWith('.json')) continue;
|
|
97
161
|
const rel = `arkrules/${name}`;
|
|
98
162
|
if (!referenced.has(rel)) {
|