arkgate 4.0.1 → 4.1.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 +90 -0
- package/README.md +6 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +99 -0
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +134 -4
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -3
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +36 -0
- package/docs/configuration.md +36 -0
- package/docs/package-surface.md +3 -3
- package/docs/product-voice.md +7 -0
- package/docs/typescript-support.md +9 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +5 -0
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
|
@@ -12,6 +12,15 @@ import { summarizePilotLoop } from './pilot-loop.mjs';
|
|
|
12
12
|
import { buildPostGreenNextAction } from './post-green-path.mjs';
|
|
13
13
|
import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
|
|
14
14
|
import { collectAdoptionGaps } from './mcp-adoption.mjs';
|
|
15
|
+
import {
|
|
16
|
+
buildCoverageHonesty,
|
|
17
|
+
buildBaselineHonesty,
|
|
18
|
+
buildWritePathHonesty,
|
|
19
|
+
buildProductHonesty,
|
|
20
|
+
} from './enforcement-honesty.mjs';
|
|
21
|
+
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
22
|
+
import { readBaseline, baselineOccurrenceKeys } from './violations.mjs';
|
|
23
|
+
import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
15
24
|
|
|
16
25
|
function esc(value) {
|
|
17
26
|
return String(value)
|
|
@@ -27,12 +36,33 @@ function esc(value) {
|
|
|
27
36
|
* @param {object} config
|
|
28
37
|
* @param {string[]} files
|
|
29
38
|
* @param {object} coverage
|
|
30
|
-
* @param {object[]} activeViolations
|
|
39
|
+
* @param {object[]} activeViolations already baseline-filtered active findings
|
|
40
|
+
* @param {{
|
|
41
|
+
* suppressedCount?: number,
|
|
42
|
+
* totalViolationCount?: number,
|
|
43
|
+
* frozenKeys?: number,
|
|
44
|
+
* activeCount?: number,
|
|
45
|
+
* activeBlockingCount?: number,
|
|
46
|
+
* }} [baselineSplit] same numbers doctor uses (do not recompute from active-only list)
|
|
31
47
|
*/
|
|
32
|
-
export function buildReportDepthPayload(
|
|
48
|
+
export function buildReportDepthPayload(
|
|
49
|
+
root,
|
|
50
|
+
config,
|
|
51
|
+
files,
|
|
52
|
+
coverage,
|
|
53
|
+
activeViolations = [],
|
|
54
|
+
baselineSplit = {}
|
|
55
|
+
) {
|
|
56
|
+
// Blocking = failsStrict !== false only (type-only placement debt is non-blocking).
|
|
57
|
+
// Prefer caller-supplied count for doctor parity; else derive from active list.
|
|
58
|
+
const activeBlockingCount =
|
|
59
|
+
typeof baselineSplit.activeBlockingCount === 'number'
|
|
60
|
+
? baselineSplit.activeBlockingCount
|
|
61
|
+
: activeViolations.filter((v) => v?.failsStrict !== false).length;
|
|
33
62
|
const designSmells = detectDesignSmells(root, config, files, coverage);
|
|
34
63
|
const designFitness = summarizeDesignFitness(designSmells, {
|
|
35
|
-
|
|
64
|
+
// Doctor parity: fitness uses blocking count, not raw active (incl. type-only).
|
|
65
|
+
activeViolations: activeBlockingCount,
|
|
36
66
|
governedPercent: coverage?.governed?.percent,
|
|
37
67
|
totalFiles: coverage?.governed?.totalFiles,
|
|
38
68
|
});
|
|
@@ -45,6 +75,86 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
|
|
|
45
75
|
});
|
|
46
76
|
const goldenPattern = summarizeGoldenPattern(loadGoldenPattern(root));
|
|
47
77
|
const adoption = collectAdoptionGaps(root, config, coverage);
|
|
78
|
+
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
79
|
+
// Prefer caller-supplied baseline split (doctor parity). Recomputing suppressed from an
|
|
80
|
+
// already-filtered activeViolations list always yields 0 and hides dirty-freeze.
|
|
81
|
+
const suppressed =
|
|
82
|
+
typeof baselineSplit.suppressedCount === 'number'
|
|
83
|
+
? baselineSplit.suppressedCount
|
|
84
|
+
: baseline.exists
|
|
85
|
+
? baselineOccurrenceKeys(activeViolations).filter((key) => baseline.keys.has(key)).length
|
|
86
|
+
: 0;
|
|
87
|
+
const frozenKeys =
|
|
88
|
+
typeof baselineSplit.frozenKeys === 'number'
|
|
89
|
+
? baselineSplit.frozenKeys
|
|
90
|
+
: baseline.exists
|
|
91
|
+
? baseline.keys.size
|
|
92
|
+
: 0;
|
|
93
|
+
const activeCount =
|
|
94
|
+
typeof baselineSplit.activeCount === 'number'
|
|
95
|
+
? baselineSplit.activeCount
|
|
96
|
+
: Math.max(0, activeViolations.length - suppressed);
|
|
97
|
+
const totalViolations =
|
|
98
|
+
typeof baselineSplit.totalViolationCount === 'number'
|
|
99
|
+
? baselineSplit.totalViolationCount
|
|
100
|
+
: activeViolations.length + suppressed;
|
|
101
|
+
const coverageHonesty = buildCoverageHonesty({
|
|
102
|
+
percent: coverage?.governed?.percent,
|
|
103
|
+
totalFiles: coverage?.governed?.totalFiles,
|
|
104
|
+
emptyScope: coverage?.emptyScope === true || (coverage?.governed?.totalFiles ?? 0) === 0,
|
|
105
|
+
});
|
|
106
|
+
const baselineHonesty = buildBaselineHonesty({
|
|
107
|
+
exists: baseline.exists || frozenKeys > 0,
|
|
108
|
+
frozenKeys,
|
|
109
|
+
activeViolations: activeCount,
|
|
110
|
+
suppressed,
|
|
111
|
+
totalViolations,
|
|
112
|
+
});
|
|
113
|
+
const writePath = adoption.writePath;
|
|
114
|
+
const packageVersionTruth = describePackageVersionDualTruth(root);
|
|
115
|
+
const hardWriteActive = writePath?.enforcementState?.localWrite?.hard === true;
|
|
116
|
+
const packageInstalled = writePath?.enforcementState?.localWrite?.installed === true;
|
|
117
|
+
const writePathHonesty = buildWritePathHonesty(writePath?.activeHost, hardWriteActive, {
|
|
118
|
+
packageInstalled,
|
|
119
|
+
packagePinCode: packageVersionTruth?.code,
|
|
120
|
+
packagePinAbsent: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT',
|
|
121
|
+
selfHost:
|
|
122
|
+
packageVersionTruth?.selfHost === true ||
|
|
123
|
+
packageVersionTruth?.code === 'PACKAGE_PIN_SELF_HOST',
|
|
124
|
+
});
|
|
125
|
+
const rulesUnderContract = summarizeRulesUnderContract(root, config, undefined, {
|
|
126
|
+
governedPercent: coverage?.governed?.percent ?? null,
|
|
127
|
+
populatedLayerCount: Array.isArray(coverage?.layers)
|
|
128
|
+
? coverage.layers.filter((row) => (row?.files ?? 0) > 0).length
|
|
129
|
+
: null,
|
|
130
|
+
classifiedFiles: coverage?.governed?.classifiedFiles ?? null,
|
|
131
|
+
});
|
|
132
|
+
// Single residual expression (parity with doctor): nextPilot || extractionCard.
|
|
133
|
+
const residualPilot =
|
|
134
|
+
pilotLoop?.nextPilot || pilotLoop?.extractionCard || null;
|
|
135
|
+
const dualTruthNext =
|
|
136
|
+
packageVersionTruth?.dualTruth === true
|
|
137
|
+
? `Bump package.json arkgate pin to ${packageVersionTruth.cliVersion || 'this CLI'} (or re-run install without --no-install)`
|
|
138
|
+
: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT'
|
|
139
|
+
? 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
|
140
|
+
: null;
|
|
141
|
+
const productHonesty = buildProductHonesty({
|
|
142
|
+
coverageHonesty,
|
|
143
|
+
baselineHonesty,
|
|
144
|
+
writePathHonesty,
|
|
145
|
+
designWeak: designFitness.designWeak === true,
|
|
146
|
+
designWeakLabel: designFitness.label,
|
|
147
|
+
designSmellCount: designSmells.length,
|
|
148
|
+
designSmellsWithOpenEdges: designSmells.length > 0 && activeBlockingCount > 0,
|
|
149
|
+
packageVersionTruth,
|
|
150
|
+
residualPilots: Boolean(residualPilot) && designFitness.designWeak === true,
|
|
151
|
+
pilotTarget: residualPilot?.pilotTarget ?? residualPilot?.pilot ?? null,
|
|
152
|
+
arkRulesMergeHonesty: rulesUnderContract?.mergePlanes
|
|
153
|
+
? { active: rulesUnderContract.active === true, ...rulesUnderContract.mergePlanes }
|
|
154
|
+
: null,
|
|
155
|
+
primaryNextAction: postGreenPath?.action ?? dualTruthNext,
|
|
156
|
+
activeBlockingViolations: activeBlockingCount,
|
|
157
|
+
});
|
|
48
158
|
return {
|
|
49
159
|
adoption,
|
|
50
160
|
designDepth: {
|
|
@@ -53,6 +163,9 @@ export function buildReportDepthPayload(root, config, files, coverage, activeVio
|
|
|
53
163
|
pilotLoop,
|
|
54
164
|
postGreenPath,
|
|
55
165
|
goldenPattern,
|
|
166
|
+
// P0-B / P1-M — folded into designDepth so --report stays a single payload.
|
|
167
|
+
productHonesty,
|
|
168
|
+
mergePlanes: rulesUnderContract?.mergePlanes ?? null,
|
|
56
169
|
},
|
|
57
170
|
};
|
|
58
171
|
}
|
|
@@ -167,6 +280,57 @@ function baselineLegendBody(signal) {
|
|
|
167
280
|
* mode?: string,
|
|
168
281
|
* }} depth
|
|
169
282
|
*/
|
|
283
|
+
/**
|
|
284
|
+
* P0-B — prominent anti-false-green honesty card (never a score).
|
|
285
|
+
* @param {object|null|undefined} productHonesty
|
|
286
|
+
* @param {object|null|undefined} [mergePlanes]
|
|
287
|
+
*/
|
|
288
|
+
export function renderProductHonestyCard(productHonesty, mergePlanes = null) {
|
|
289
|
+
if (!productHonesty || typeof productHonesty !== 'object') return '';
|
|
290
|
+
const unfinished = productHonesty.unfinished === true;
|
|
291
|
+
const headline = productHonesty.headline || (unfinished ? 'Not finished' : 'Honesty clear');
|
|
292
|
+
const primary = productHonesty.primaryMessage || '';
|
|
293
|
+
// Avoid repeating the same status label in title and body (past-issue pattern).
|
|
294
|
+
let body = primary;
|
|
295
|
+
if (primary && headline) {
|
|
296
|
+
const h = String(headline).trim();
|
|
297
|
+
const p = String(primary).trim();
|
|
298
|
+
if (p === h) {
|
|
299
|
+
body = unfinished
|
|
300
|
+
? 'Residual honesty signals remain — not a whole-tree guarantee and not a score.'
|
|
301
|
+
: 'No residual honesty blockers on this slice — still not a numeric architecture score.';
|
|
302
|
+
} else if (p.toLowerCase().startsWith(h.toLowerCase())) {
|
|
303
|
+
const stripped = p.slice(h.length).replace(/^[\s—–:-]+/, '').trim();
|
|
304
|
+
body = stripped || p;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const reasons = Array.isArray(productHonesty.reasonIds) ? productHonesty.reasonIds : [];
|
|
308
|
+
const reasonHtml =
|
|
309
|
+
reasons.length > 0
|
|
310
|
+
? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">signals: ${reasons
|
|
311
|
+
.map((id) => `<code>${esc(id)}</code>`)
|
|
312
|
+
.join(' · ')} · <code>notAScore</code></p>`
|
|
313
|
+
: '';
|
|
314
|
+
const mergeHtml =
|
|
315
|
+
mergePlanes?.failMergeWhen
|
|
316
|
+
? `<p class="dim" style="margin:.35rem 0 0;font-size:.86rem">merge planes: ${esc(mergePlanes.failMergeWhen)}</p>`
|
|
317
|
+
: '';
|
|
318
|
+
const dual =
|
|
319
|
+
mergePlanes?.dualPlaneStamp
|
|
320
|
+
? `<p class="dim" style="margin:.25rem 0 0;font-size:.84rem">${esc(mergePlanes.dualPlaneStamp)}</p>`
|
|
321
|
+
: '';
|
|
322
|
+
return `<div class="section card design-strip ${unfinished ? 'is-weak' : 'is-clean'}" id="product-honesty" data-product-honesty="1">
|
|
323
|
+
<div class="design-head">
|
|
324
|
+
<span class="badge design" title="Product honesty — not a score">${esc(headline)}</span>
|
|
325
|
+
<span class="dim" style="font-size:.86rem">${unfinished ? 'residual honesty signals' : 'no residual honesty blockers'}</span>
|
|
326
|
+
</div>
|
|
327
|
+
<p style="margin:.45rem 0 0">${esc(body)}</p>
|
|
328
|
+
${reasonHtml}
|
|
329
|
+
${mergeHtml}
|
|
330
|
+
${dual}
|
|
331
|
+
</div>`;
|
|
332
|
+
}
|
|
333
|
+
|
|
170
334
|
export function renderDesignDepthStrip(depth = {}) {
|
|
171
335
|
const fitness = depth.designFitness;
|
|
172
336
|
const smells = Array.isArray(depth.designSmells) ? depth.designSmells : [];
|
package/bin/lib/html-report.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
renderBaselineSignalLegend,
|
|
17
17
|
renderDesignCleanNote,
|
|
18
18
|
renderDesignDepthStrip,
|
|
19
|
+
renderProductHonestyCard,
|
|
19
20
|
renderWritePathAdoptionBlock,
|
|
20
21
|
} from './html-report-depth.mjs';
|
|
21
22
|
import { FIX_HINTS } from './violations.mjs';
|
|
@@ -222,13 +223,13 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
|
|
|
222
223
|
const row = (coverage?.layers ?? []).find((r) => r.name === layer.name);
|
|
223
224
|
return (row?.files ?? 0) > 0;
|
|
224
225
|
}).length;
|
|
226
|
+
// planMet: blocking (failsStrict !== false) only — type-only alone must not force ADAPT.
|
|
227
|
+
const blockingCount = Array.isArray(violations)
|
|
228
|
+
? violations.filter((v) => v?.failsStrict !== false).length
|
|
229
|
+
: 0;
|
|
225
230
|
const mode = resolveOperatingMode({
|
|
226
231
|
governedPercent: totalFiles === 0 ? 0 : governedPercent,
|
|
227
|
-
planMet:
|
|
228
|
-
ok &&
|
|
229
|
-
(violations?.length ?? 0) === 0 &&
|
|
230
|
-
totalFiles > 0 &&
|
|
231
|
-
(governedPercent == null || governedPercent >= 50),
|
|
232
|
+
planMet: ok && blockingCount === 0 && totalFiles > 0 && (governedPercent == null || governedPercent >= 50),
|
|
232
233
|
mature: totalFiles >= 150,
|
|
233
234
|
totalFiles,
|
|
234
235
|
emptyLayers,
|
|
@@ -531,6 +532,11 @@ export function renderHtmlReport({
|
|
|
531
532
|
ok,
|
|
532
533
|
mode,
|
|
533
534
|
});
|
|
535
|
+
// P0-B product honesty card — prefer payload from buildReportDepthPayload when present.
|
|
536
|
+
const productHonestyHtml = renderProductHonestyCard(
|
|
537
|
+
depth.productHonesty ?? null,
|
|
538
|
+
depth.mergePlanes ?? null
|
|
539
|
+
);
|
|
534
540
|
const writePathHtml = renderWritePathAdoptionBlock(adoptionView.writePath);
|
|
535
541
|
const baselineLegendHtml = renderBaselineSignalLegend();
|
|
536
542
|
|
|
@@ -1111,6 +1117,7 @@ export function renderHtmlReport({
|
|
|
1111
1117
|
</div>
|
|
1112
1118
|
|
|
1113
1119
|
${designStripHtml}
|
|
1120
|
+
${productHonestyHtml}
|
|
1114
1121
|
|
|
1115
1122
|
<div class="section card" id="adoption">
|
|
1116
1123
|
<h2>Adoption</h2>
|
|
@@ -49,7 +49,15 @@ import {
|
|
|
49
49
|
codexTomlSnippet,
|
|
50
50
|
arkCheckCommand,
|
|
51
51
|
checkArchitectureScriptSnippet,
|
|
52
|
+
ensureCheckArchitectureScript,
|
|
52
53
|
} from './ci-and-commands.mjs';
|
|
54
|
+
// Break cycle with managed-upgrade (it imports buildManagedAssetCatalog from here).
|
|
55
|
+
// Static import would form CIRCULAR_DEPENDENCY under mother --strict.
|
|
56
|
+
import { createRequire } from 'node:module';
|
|
57
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
58
|
+
function loadManagedUpgrade() {
|
|
59
|
+
return requireFromHere('./managed-upgrade.mjs');
|
|
60
|
+
}
|
|
53
61
|
import {
|
|
54
62
|
resolveTools,
|
|
55
63
|
SKILL_TOOL_TARGETS,
|
|
@@ -351,6 +359,28 @@ export function runInstallAgentGates(args) {
|
|
|
351
359
|
return;
|
|
352
360
|
}
|
|
353
361
|
const hasCheckScript = hasCheckArchitectureScript(root);
|
|
362
|
+
// S4.2: when a managed-upgrade manifest is already present, a bare
|
|
363
|
+
// --install-agent-gates (no --force / --compact) defaults to skills-only so a
|
|
364
|
+
// refresh cannot clobber customized gates or invalidate upgrade planDigest.
|
|
365
|
+
// Missing AGENTS still gets a full install (gates need repair).
|
|
366
|
+
const {
|
|
367
|
+
MANAGED_MANIFEST_PATH,
|
|
368
|
+
tryReadManagedManifest,
|
|
369
|
+
isManagedAssetCustomizedOnDisk,
|
|
370
|
+
syncManagedManifestFromDisk,
|
|
371
|
+
} = loadManagedUpgrade();
|
|
372
|
+
const managedPresent = Boolean(tryReadManagedManifest(root));
|
|
373
|
+
let defaultedSkillsOnly = false;
|
|
374
|
+
if (
|
|
375
|
+
managedPresent &&
|
|
376
|
+
!args.skillsOnly &&
|
|
377
|
+
!args.compact &&
|
|
378
|
+
!args.force &&
|
|
379
|
+
fs.existsSync(path.join(root, 'AGENTS.md'))
|
|
380
|
+
) {
|
|
381
|
+
args.skillsOnly = true;
|
|
382
|
+
defaultedSkillsOnly = true;
|
|
383
|
+
}
|
|
354
384
|
const { tools, source } = args.compact && args.tools == null
|
|
355
385
|
? { tools: new Set(), source: 'compact-none' }
|
|
356
386
|
: resolveTools(args);
|
|
@@ -373,7 +403,12 @@ export function runInstallAgentGates(args) {
|
|
|
373
403
|
const host = [...tools][0];
|
|
374
404
|
const later = host ? ` --tools ${host}` : '';
|
|
375
405
|
if (args.compact) console.log(`Profile: compact router. Expert skills later: ${arkCommand(root, 'ark-check', `--install-agent-gates --skills-only${later} --force`)}`);
|
|
376
|
-
else if (
|
|
406
|
+
else if (defaultedSkillsOnly) {
|
|
407
|
+
console.log(
|
|
408
|
+
`Profile: skills-only refresh (default — ${MANAGED_MANIFEST_PATH} present). ` +
|
|
409
|
+
'Full gate rewrite: re-run with --force (preserves customized content-identity assets and recomputes managed digest).'
|
|
410
|
+
);
|
|
411
|
+
} else if (args.skillsOnly) console.log('Profile: expert skill pack only (refreshes /ark-*; leaves customized gates).');
|
|
377
412
|
else console.log('Profile: full agent gates. Compact-only onboarding: ark start.');
|
|
378
413
|
}
|
|
379
414
|
// --skills-only refreshes just the canonical /ark-* skills, which are safe to
|
|
@@ -382,6 +417,13 @@ export function runInstallAgentGates(args) {
|
|
|
382
417
|
// `--force` clobbers them — this is the safe way to pick up new skill versions.
|
|
383
418
|
// Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
|
|
384
419
|
if (!args.skillsOnly) {
|
|
420
|
+
// S4.4: always write check:architecture on gate install / start (including compact).
|
|
421
|
+
const checkBootstrap = ensureCheckArchitectureScript(root, { write: true });
|
|
422
|
+
if (checkBootstrap.changed && !args.json) {
|
|
423
|
+
console.log(
|
|
424
|
+
`Added package.json script "check:architecture": "${checkBootstrap.script}" (local/CI parity).`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
385
427
|
// Bootstrap typecheck before CI template so generated workflow includes the step.
|
|
386
428
|
const typecheckBootstrap = ensureTypecheckScript(root, { write: !args.compact });
|
|
387
429
|
if (typecheckBootstrap.changed && !args.compact && !args.json) {
|
|
@@ -397,6 +439,9 @@ export function runInstallAgentGates(args) {
|
|
|
397
439
|
skillsOnly: args.skillsOnly,
|
|
398
440
|
});
|
|
399
441
|
const { skills, skillPaths, version } = catalog;
|
|
442
|
+
const assetKindByPath = new Map(
|
|
443
|
+
catalog.assets.map((asset) => [asset.relativePath, asset.kind ?? 'gate'])
|
|
444
|
+
);
|
|
400
445
|
const templates = catalog.assets.map(({ relativePath, content }) => [relativePath, content]);
|
|
401
446
|
|
|
402
447
|
// A compact router can be moved back from an explicit host removal. Delete the
|
|
@@ -412,6 +457,16 @@ export function runInstallAgentGates(args) {
|
|
|
412
457
|
}
|
|
413
458
|
|
|
414
459
|
const results = templates.map(([relativePath, content]) => {
|
|
460
|
+
// S4.1: preserve content-identity customized/conflicted managed assets even under --force.
|
|
461
|
+
const kind = assetKindByPath.get(relativePath) ?? 'gate';
|
|
462
|
+
if (
|
|
463
|
+
args.force &&
|
|
464
|
+
managedPresent &&
|
|
465
|
+
kind !== 'skill' &&
|
|
466
|
+
isManagedAssetCustomizedOnDisk(root, relativePath, content, kind)
|
|
467
|
+
) {
|
|
468
|
+
return { relativePath, status: 'skipped-customized' };
|
|
469
|
+
}
|
|
415
470
|
if (relativePath === '.codex/config.toml') {
|
|
416
471
|
const fullPath = path.join(root, relativePath);
|
|
417
472
|
let existing = '';
|
|
@@ -474,6 +529,7 @@ export function runInstallAgentGates(args) {
|
|
|
474
529
|
|
|
475
530
|
console.log('Ark agent gate templates:');
|
|
476
531
|
let staleSkipped = 0;
|
|
532
|
+
let preservedCustomized = 0;
|
|
477
533
|
for (const result of results) {
|
|
478
534
|
const marker =
|
|
479
535
|
result.status === 'written'
|
|
@@ -482,13 +538,18 @@ export function runInstallAgentGates(args) {
|
|
|
482
538
|
? 'merged'
|
|
483
539
|
: result.status === 'skipped-non-ark'
|
|
484
540
|
? 'kept'
|
|
485
|
-
: result.status === '
|
|
486
|
-
? '
|
|
487
|
-
: '
|
|
541
|
+
: result.status === 'skipped-customized'
|
|
542
|
+
? 'kept'
|
|
543
|
+
: result.status === 'failed'
|
|
544
|
+
? 'FAILED'
|
|
545
|
+
: 'skipped';
|
|
488
546
|
// A skipped skill reads as "you're fine" — but it may be a version behind.
|
|
489
547
|
// Say which, so the user isn't left guessing (and knows the safe refresh cmd).
|
|
490
548
|
let note = '';
|
|
491
|
-
if (result.status === 'skipped'
|
|
549
|
+
if (result.status === 'skipped-customized') {
|
|
550
|
+
preservedCustomized += 1;
|
|
551
|
+
note = ' (customized — content-identity preserved)';
|
|
552
|
+
} else if (result.status === 'skipped' && skillPaths.has(result.relativePath) && version) {
|
|
492
553
|
const installed = installedSkillVersion(path.join(root, result.relativePath));
|
|
493
554
|
if (installed === null || isVersionOlder(installed, version)) {
|
|
494
555
|
staleSkipped += 1;
|
|
@@ -499,6 +560,11 @@ export function runInstallAgentGates(args) {
|
|
|
499
560
|
}
|
|
500
561
|
console.log(` ${marker.padEnd(7)} ${result.relativePath}${note}`);
|
|
501
562
|
}
|
|
563
|
+
if (preservedCustomized > 0 && !args.json) {
|
|
564
|
+
console.log(
|
|
565
|
+
` ${preservedCustomized} customized managed asset(s) preserved (use ark upgrade --accept-conflicts to overwrite deliberately).`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
502
568
|
if (staleSkipped > 0 && !args.skillsOnly) {
|
|
503
569
|
console.log('');
|
|
504
570
|
console.log(
|
|
@@ -605,11 +671,48 @@ export function runInstallAgentGates(args) {
|
|
|
605
671
|
}
|
|
606
672
|
console.log(`\nHard-write hook verified for ${writeRequest.host}.`);
|
|
607
673
|
}
|
|
674
|
+
// S4.1: after --force, recompute ark.managed.json so a prior upgrade planDigest
|
|
675
|
+
// is not left silently broken (field: force clobber → digest mismatch).
|
|
676
|
+
const wroteManaged =
|
|
677
|
+
results.some((r) => r.status === 'written' || r.status === 'merged') ||
|
|
678
|
+
homeResults.some((r) => r.status === 'written');
|
|
679
|
+
if (args.force && managedPresent) {
|
|
680
|
+
try {
|
|
681
|
+
const sync = syncManagedManifestFromDisk(root, {
|
|
682
|
+
tools: [...tools],
|
|
683
|
+
profile: args.compact ? 'compact' : 'full',
|
|
684
|
+
});
|
|
685
|
+
if (!args.json) {
|
|
686
|
+
console.log('');
|
|
687
|
+
console.log(
|
|
688
|
+
sync.wrote
|
|
689
|
+
? `Recomputed ${MANAGED_MANIFEST_PATH} after --force (planDigest ${sync.planDigest.slice(0, 18)}…). Run a fresh ark upgrade preview before --apply.`
|
|
690
|
+
: `${MANAGED_MANIFEST_PATH} already matches on-disk content after --force.`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!args.json) {
|
|
695
|
+
console.log('');
|
|
696
|
+
console.log(
|
|
697
|
+
`Warning: could not recompute ${MANAGED_MANIFEST_PATH} after --force (${error instanceof Error ? error.message : String(error)}). Run a new ark upgrade preview before --apply.`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} else if (args.force && wroteManaged && !args.skillsOnly) {
|
|
702
|
+
// No prior manifest: still warn that any in-flight upgrade preview is stale.
|
|
703
|
+
if (!args.json) {
|
|
704
|
+
console.log('');
|
|
705
|
+
console.log(
|
|
706
|
+
'Note: --force rewrote gate files. If you had an ark upgrade preview open, run a new preview (planDigest changed).'
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
608
711
|
console.log('');
|
|
609
712
|
console.log('Next steps:');
|
|
610
713
|
console.log(' 1. Review the generated files and commit the ones that match your tools.');
|
|
611
714
|
console.log(` 2. Run: ${arkCheckCommand(root)}`);
|
|
612
|
-
if (!hasCheckScript) {
|
|
715
|
+
if (!hasCheckScript && !hasCheckArchitectureScript(root)) {
|
|
613
716
|
console.log(' 3. Add the package.json alias if you want `run check:architecture`:');
|
|
614
717
|
console.log(` ${checkArchitectureScriptSnippet(root)}`);
|
|
615
718
|
}
|
|
@@ -180,6 +180,105 @@ function readManifest(root) {
|
|
|
180
180
|
return { file, content, value };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Best-effort managed manifest load for install-agent-gates force/preserve paths.
|
|
185
|
+
* Returns null when missing or invalid (never throws).
|
|
186
|
+
* @param {string} root
|
|
187
|
+
* @returns {{ schemaVersion: string, profile: string, hosts: string[], assets: object[] } | null}
|
|
188
|
+
*/
|
|
189
|
+
export function tryReadManagedManifest(root) {
|
|
190
|
+
try {
|
|
191
|
+
const { value } = readManifest(path.resolve(root));
|
|
192
|
+
return value;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Recompute `ark.managed.json` identities from on-disk content after a force
|
|
200
|
+
* gate install (or any out-of-band rewrite of managed assets). Without this,
|
|
201
|
+
* a prior upgrade planDigest is silently invalidated and apply fails with
|
|
202
|
+
* "managed upgrade plan digest mismatch".
|
|
203
|
+
*
|
|
204
|
+
* @param {string} root
|
|
205
|
+
* @param {{ tools?: string[]|string|Set<string>, profile?: 'compact'|'full' }} [options]
|
|
206
|
+
* @returns {{ planDigest: string, profile: string, hosts: string[], assetCount: number, wrote: boolean }}
|
|
207
|
+
*/
|
|
208
|
+
export function syncManagedManifestFromDisk(root, options = {}) {
|
|
209
|
+
const resolvedRoot = path.resolve(root);
|
|
210
|
+
const plan = planManagedUpgrade(resolvedRoot, {
|
|
211
|
+
tools: options.tools,
|
|
212
|
+
profile: options.profile,
|
|
213
|
+
acceptConflicts: options.acceptConflicts === true,
|
|
214
|
+
});
|
|
215
|
+
const file = assertSafeTarget(resolvedRoot, MANAGED_MANIFEST_PATH);
|
|
216
|
+
const previous = readFile(file);
|
|
217
|
+
const next = Buffer.from(plan.manifestContent);
|
|
218
|
+
if (previous && hash(previous) === hash(next)) {
|
|
219
|
+
return {
|
|
220
|
+
planDigest: plan.planDigest,
|
|
221
|
+
profile: plan.profile,
|
|
222
|
+
hosts: plan.hosts,
|
|
223
|
+
assetCount: plan.assets.length,
|
|
224
|
+
wrote: false,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
ensureParentDirectories(resolvedRoot, MANAGED_MANIFEST_PATH, []);
|
|
228
|
+
fs.writeFileSync(file, plan.manifestContent);
|
|
229
|
+
return {
|
|
230
|
+
planDigest: plan.planDigest,
|
|
231
|
+
profile: plan.profile,
|
|
232
|
+
hosts: plan.hosts,
|
|
233
|
+
assetCount: plan.assets.length,
|
|
234
|
+
wrote: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* True when an existing managed asset is customized/conflicted vs the recorded
|
|
240
|
+
* identity — install-agent-gates --force must preserve these (same contract as
|
|
241
|
+
* content-identity upgrade).
|
|
242
|
+
*
|
|
243
|
+
* Force preserve also applies by content-identity when the asset is missing from
|
|
244
|
+
* ark.managed.json (incomplete manifest must not clobber customized AGENTS.md).
|
|
245
|
+
*/
|
|
246
|
+
export function isManagedAssetCustomizedOnDisk(root, relativePath, desiredContent, kind = 'gate') {
|
|
247
|
+
const file = path.join(path.resolve(root), relativePath);
|
|
248
|
+
const currentFile = readFile(file);
|
|
249
|
+
if (currentFile == null) return false;
|
|
250
|
+
let currentScoped = currentFile.toString('utf8');
|
|
251
|
+
|
|
252
|
+
const manifest = tryReadManagedManifest(root);
|
|
253
|
+
const recorded = manifest
|
|
254
|
+
? (manifest.assets ?? []).find((asset) => asset.path === relativePath)
|
|
255
|
+
: null;
|
|
256
|
+
|
|
257
|
+
if (recorded) {
|
|
258
|
+
// Whole-file gates: compare full file bytes. Toml-section uses primary table only.
|
|
259
|
+
if (recorded.scope === 'toml-section') {
|
|
260
|
+
currentScoped = codexPrimaryTable(currentScoped)?.block ?? currentScoped;
|
|
261
|
+
}
|
|
262
|
+
const classified = classifyManagedAsset({
|
|
263
|
+
recorded,
|
|
264
|
+
currentContent: currentScoped,
|
|
265
|
+
targetContent: desiredContent,
|
|
266
|
+
kind,
|
|
267
|
+
});
|
|
268
|
+
return classified.state === 'customized' || classified.state === 'conflicted';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Incomplete / missing managed list: still preserve when on-disk content differs
|
|
272
|
+
// from the desired template (content-identity force preserve, especially AGENTS.md).
|
|
273
|
+
if (kind === 'skill') return false;
|
|
274
|
+
const desiredNorm = normalizedIdentityContent(desiredContent ?? '');
|
|
275
|
+
const currentNorm = normalizedIdentityContent(currentScoped);
|
|
276
|
+
if (!currentNorm.trim()) return false;
|
|
277
|
+
if (currentNorm === desiredNorm) return false;
|
|
278
|
+
// AGENTS.md and other whole-file gates: any divergence from desired template is customized.
|
|
279
|
+
return managedContentIdentity(currentNorm, kind) !== managedContentIdentity(desiredNorm, kind);
|
|
280
|
+
}
|
|
281
|
+
|
|
183
282
|
function resolveSelection(root, options, manifest) {
|
|
184
283
|
const explicit = normalizeToolsList(options.tools);
|
|
185
284
|
const compactHost = compactRouterHost(root);
|