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.
Files changed (52) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/html-report-depth.mjs +167 -3
  20. package/bin/lib/html-report.mjs +12 -5
  21. package/bin/lib/install-migrate.mjs +109 -6
  22. package/bin/lib/managed-upgrade.mjs +99 -0
  23. package/bin/lib/presets.mjs +314 -46
  24. package/bin/lib/project-root.mjs +268 -0
  25. package/bin/lib/remediation.mjs +12 -11
  26. package/bin/lib/rules-inventory.mjs +71 -29
  27. package/bin/lib/rules-under-contract.mjs +134 -4
  28. package/bin/lib/start-preview.mjs +48 -14
  29. package/bin/lib/suggestions.mjs +118 -3
  30. package/bin/lib/unavailable-analysis.mjs +2 -0
  31. package/bin/lib/write-path-capabilities.mjs +38 -9
  32. package/dist/eslint/index.cjs +2 -2
  33. package/dist/eslint/index.d.ts +27 -2
  34. package/dist/eslint/index.js +2 -2
  35. package/dist/index.cjs +16 -14
  36. package/dist/index.d.ts +3 -1
  37. package/dist/index.js +16 -14
  38. package/docs/README.md +3 -3
  39. package/docs/ai-gates.md +15 -11
  40. package/docs/brownfield-adoption.md +36 -0
  41. package/docs/configuration.md +36 -0
  42. package/docs/package-surface.md +3 -3
  43. package/docs/product-voice.md +7 -0
  44. package/docs/typescript-support.md +9 -5
  45. package/package.json +3 -1
  46. package/server.json +3 -3
  47. package/templates/architecture-playbook.json +3 -0
  48. package/templates/layers/shared-types.starter.json +29 -0
  49. package/templates/skills/ark-adopt.md +2 -0
  50. package/templates/skills/ark-explain.md +5 -0
  51. package/templates/skills/ark-explore.md +21 -1
  52. package/templates/skills/ark-fix.md +16 -5
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Pure / fail-closed: never invent hard write guarantees; never paint thin
5
5
  * coverage or a dirty freeze as "done." Advisory labels only.
6
+ * Never invents a numeric architecture score.
6
7
  */
7
8
  import {
8
9
  doctorWritePathHonestyMessage,
@@ -164,35 +165,313 @@ export function buildBaselineHonesty(input = {}) {
164
165
  /**
165
166
  * Write-path honesty for the active host (fail-closed).
166
167
  * Soft hosts never claim hard local write; hard hosts without proof stay unverified.
168
+ * Never hard:true without package install evidence (and pin when not self-host).
169
+ *
167
170
  * @param {string|null|undefined} activeHost
168
171
  * @param {boolean} hardWriteActive
172
+ * @param {{
173
+ * packageInstalled?: boolean,
174
+ * packagePinCode?: string | null,
175
+ * packagePinAbsent?: boolean,
176
+ * selfHost?: boolean,
177
+ * motherCli?: boolean,
178
+ * }} [extras]
169
179
  */
170
- export function buildWritePathHonesty(activeHost, hardWriteActive = false) {
180
+ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extras = {}) {
171
181
  const host = typeof activeHost === 'string' ? activeHost.trim().toLowerCase() : '';
172
182
  const softWriteHost = SOFT_WRITE_HOSTS.has(host);
173
183
  const hardCapable = HARD_WRITE_HOSTS.has(host);
174
- const message = doctorWritePathHonestyMessage(host, hardWriteActive);
184
+ const packageInstalled = extras.packageInstalled !== false;
185
+ const selfHost = extras.selfHost === true;
186
+ const motherCli =
187
+ extras.motherCli === true ||
188
+ process.env.ARK_MOTHER_CLI === '1' ||
189
+ process.env.ARK_MOTHER_CLI === 'true';
190
+ const pinCode = typeof extras.packagePinCode === 'string' ? extras.packagePinCode : null;
191
+ const pinAbsent =
192
+ extras.packagePinAbsent === true ||
193
+ pinCode === 'PACKAGE_PIN_ABSENT';
194
+ // Self-host / mother library tree: pin-absent is expected (package IS arkgate).
195
+ const pinAbsentForUser = pinAbsent && !selfHost && !motherCli && pinCode !== 'PACKAGE_PIN_SELF_HOST';
196
+ // Hard write requires package on disk; pin-absent consumers never get hard:true.
197
+ const hardAllowed = packageInstalled && !pinAbsentForUser;
198
+ const effectiveHard =
199
+ Boolean(hardWriteActive) && hardCapable && !softWriteHost && hardAllowed;
200
+ const message = doctorWritePathHonestyMessage(host, effectiveHard);
175
201
 
176
- return {
202
+ /** @type {Record<string, unknown>} */
203
+ const out = {
177
204
  advisory: true,
178
205
  activeHost: host || null,
179
206
  softWriteHost,
180
207
  hardWriteSupported: hardCapable,
181
- hardWriteActive: Boolean(hardWriteActive) && hardCapable && !softWriteHost,
182
- hardWriteUnverified: hardCapable && !hardWriteActive,
208
+ hardWriteActive: effectiveHard,
209
+ hardWriteUnverified: hardCapable && !effectiveHard,
183
210
  hardMergeBoundary: 'required-ci-status (arkgate-check --strict-merge)',
184
- message,
185
- // Explicit product rule for soft hosts.
186
- ...(softWriteHost
187
- ? {
188
- note: 'Local write is advisory / best-effort — not a hard PreToolUse boundary. Required CI status is the hard merge boundary.',
189
- }
190
- : {}),
211
+ packageInstalled,
212
+ packagePinAbsent: pinAbsentForUser,
213
+ ...(pinCode ? { packagePinCode: pinCode } : {}),
214
+ message:
215
+ pinAbsentForUser
216
+ ? `${message} Package pin absent (PACKAGE_PIN_ABSENT) — configured hooks ≠ installed enforcement until arkgate is pinned and in node_modules.`
217
+ : !packageInstalled && hardCapable
218
+ ? `${message} arkgate package not resolved from this project — hard write is not proven.`
219
+ : message,
191
220
  };
221
+ if (softWriteHost) {
222
+ out.note =
223
+ 'Local write is advisory / best-effort — not a hard PreToolUse boundary. Required CI status is the hard merge boundary.';
224
+ }
225
+ if (pinAbsentForUser) {
226
+ out.pinNote =
227
+ 'No arkgate pin in package.json; CI/npx may not resolve this CLI version. Ladder/state hard stays false until pin + install.';
228
+ }
229
+ return out;
192
230
  }
193
231
 
194
232
  /**
195
- * One-shot doctor honesty bundle (coverage + baseline + write path).
233
+ * One coherent anti-false-green product surface (P0-B / FG01).
234
+ * finished is true only when residual honesty sensors are clear AND the graph
235
+ * + mode are green — never when blocking violations, adapt/suggest residual,
236
+ * missing baseline with debt, design residual, dual-truth, weak coverage,
237
+ * pin-absent, or residual pilots remain.
238
+ *
239
+ * activeBlockingViolations must be failsStrict !== false counts only
240
+ * (type-only placement debt must NOT force active-blocking-violations).
241
+ *
242
+ * Adapt/suggest policy (documented): unfinished when mode is adapt/suggest
243
+ * unless whole-tree green AND zero design smells AND zero blocking — contract
244
+ * and tree still disagree while operating outside enforce.
245
+ * Never invents a numeric architecture score.
246
+ *
247
+ * @param {{
248
+ * coverageHonesty?: ReturnType<typeof buildCoverageHonesty>,
249
+ * baselineHonesty?: ReturnType<typeof buildBaselineHonesty>,
250
+ * writePathHonesty?: ReturnType<typeof buildWritePathHonesty>,
251
+ * designWeak?: boolean,
252
+ * designWeakLabel?: string | null,
253
+ * designSmellCount?: number,
254
+ * designSmellsWithOpenEdges?: boolean,
255
+ * packageVersionTruth?: {
256
+ * dualTruth?: boolean,
257
+ * note?: string,
258
+ * code?: string,
259
+ * cliVersion?: string | null,
260
+ * } | null,
261
+ * residualPilots?: boolean,
262
+ * pilotTarget?: string | null,
263
+ * arkRulesMergeHonesty?: Record<string, unknown> | null,
264
+ * primaryNextAction?: string | null,
265
+ * operatingMode?: string | null,
266
+ * activeBlockingViolations?: number | null,
267
+ * }} input
268
+ */
269
+ export function buildProductHonesty(input = {}) {
270
+ const reasons = [];
271
+ const cov = input.coverageHonesty;
272
+ const base = input.baselineHonesty;
273
+ const write = input.writePathHonesty;
274
+ const designWeak = input.designWeak === true;
275
+ const dualTruth = input.packageVersionTruth?.dualTruth === true;
276
+ const pinCode = input.packageVersionTruth?.code || write?.packagePinCode || null;
277
+ const pinAbsent =
278
+ write?.packagePinAbsent === true ||
279
+ pinCode === 'PACKAGE_PIN_ABSENT';
280
+ const residualPilots = input.residualPilots === true;
281
+ const operatingMode =
282
+ typeof input.operatingMode === 'string' ? input.operatingMode.trim().toLowerCase() : null;
283
+ // Prefer explicit blocking count; never treat raw violation totals (incl. type-only) as blocking.
284
+ const activeBlocking = Number.isFinite(Number(input.activeBlockingViolations))
285
+ ? Math.max(0, Number(input.activeBlockingViolations))
286
+ : Number(base?.activeViolations) || 0;
287
+ const smellCount = Number(input.designSmellCount) || 0;
288
+ const designSmellsOpenEdges =
289
+ input.designSmellsWithOpenEdges === true || (smellCount > 0 && activeBlocking > 0);
290
+ const wholeTreeGovernedEarly = cov?.wholeTreeGoverned === true;
291
+
292
+ if (cov?.status === 'empty-scope' || cov?.worseThanNoGate) {
293
+ reasons.push({
294
+ id: 'coverage-weak-or-empty',
295
+ message: cov.message,
296
+ });
297
+ } else if (cov?.greenIsNotEnforcement) {
298
+ reasons.push({
299
+ id: 'coverage-partial',
300
+ message: cov.message,
301
+ });
302
+ }
303
+
304
+ // FG01 / P0B-FINISHED-WITH-OPEN-DEBT — red graph is never "finished".
305
+ if (activeBlocking > 0) {
306
+ reasons.push({
307
+ id: 'active-blocking-violations',
308
+ message: `${activeBlocking} active blocking violation(s) remain — not finished; green edges only after debt is cleared or honestly baselined.`,
309
+ });
310
+ }
311
+
312
+ if (base?.status === 'missing-with-debt') {
313
+ reasons.push({
314
+ id: 'baseline-missing-with-debt',
315
+ message:
316
+ base.message ||
317
+ 'No baseline while violations exist — freeze only real debt after the contract is honest.',
318
+ });
319
+ }
320
+
321
+ if (base?.dirtyBaselineRisk) {
322
+ reasons.push({
323
+ id: 'dirty-freeze',
324
+ message: base.message,
325
+ });
326
+ }
327
+
328
+ if (designWeak) {
329
+ reasons.push({
330
+ id: 'design-weak',
331
+ message:
332
+ input.designWeakLabel ||
333
+ 'ENFORCE · design-weak: edges may be clean, but design residual remains — not elegant, not finished.',
334
+ });
335
+ } else if (designSmellsOpenEdges) {
336
+ // DL-DESIGN-SMELLS-VS-WEAK — smells + open edges ⇒ unfinished (not "elegant true").
337
+ reasons.push({
338
+ id: 'design-smells-open-edges',
339
+ message:
340
+ 'Design smells present alongside open edge debt — not elegant, not finished. Fix edges first; Shape residual after green.',
341
+ });
342
+ }
343
+
344
+ if (dualTruth) {
345
+ reasons.push({
346
+ id: 'package-version-dual-truth',
347
+ message:
348
+ input.packageVersionTruth?.note ||
349
+ 'CLI version and package.json pin disagree — upgrade truth is dual until the pin catches up.',
350
+ });
351
+ } else if (pinAbsent) {
352
+ reasons.push({
353
+ id: 'package-pin-absent',
354
+ message:
355
+ input.packageVersionTruth?.note ||
356
+ write?.pinNote ||
357
+ 'No arkgate pin in package.json (PACKAGE_PIN_ABSENT) — configured gates ≠ installed enforcement until pin + install.',
358
+ });
359
+ }
360
+
361
+ if (residualPilots) {
362
+ reasons.push({
363
+ id: 'residual-pilot',
364
+ message: input.pilotTarget
365
+ ? `Residual pilot remains (${input.pilotTarget}) — one Shape/extraction card at a time; not whole-tree done.`
366
+ : 'Residual pilot pressure remains — one Shape/extraction card at a time; not whole-tree done.',
367
+ });
368
+ }
369
+
370
+ if (write?.softWriteHost) {
371
+ reasons.push({
372
+ id: 'soft-write-host',
373
+ message: write.message || 'Local write is advisory; required CI status is the hard merge boundary.',
374
+ });
375
+ }
376
+
377
+ // Mode adapt/suggest (FG-FINISHED-ADAPT-DEBT): prefer unfinished unless the tree is
378
+ // whole-tree green AND zero design smells AND zero blocking violations.
379
+ // Type-only placement debt alone must not keep adapt unfinished via active-blocking.
380
+ if (operatingMode === 'adapt' || operatingMode === 'suggest') {
381
+ const adaptClear =
382
+ wholeTreeGovernedEarly &&
383
+ activeBlocking === 0 &&
384
+ smellCount === 0 &&
385
+ !designWeak &&
386
+ !designSmellsOpenEdges;
387
+ if (!adaptClear) {
388
+ reasons.push({
389
+ id: operatingMode === 'adapt' ? 'mode-adapt-with-debt' : 'mode-suggest-with-debt',
390
+ message:
391
+ operatingMode === 'adapt'
392
+ ? 'Operating mode is ADAPT — not finished until whole-tree green, zero blocking, and zero design smells (contract and tree still disagree).'
393
+ : 'Operating mode is SUGGEST — not finished until whole-tree green, zero blocking, and zero design smells (contract is not yet the control plane).',
394
+ });
395
+ }
396
+ }
397
+
398
+ if (input.arkRulesMergeHonesty?.active === true && input.arkRulesMergeHonesty?.extraMergeTeeth === false) {
399
+ // Informational only when no enforced arkrule plane — does not alone make unfinished.
400
+ }
401
+
402
+ const unfinished = reasons.length > 0;
403
+ const wholeTreeGoverned = wholeTreeGovernedEarly;
404
+ const coverageIncomplete =
405
+ cov?.status === 'empty-scope' ||
406
+ cov?.worseThanNoGate === true ||
407
+ cov?.greenIsNotEnforcement === true ||
408
+ !wholeTreeGoverned;
409
+
410
+ const primary =
411
+ reasons.find((r) => r.id === 'active-blocking-violations') ||
412
+ reasons.find((r) => r.id === 'mode-adapt-with-debt') ||
413
+ reasons.find((r) => r.id === 'mode-suggest-with-debt') ||
414
+ reasons.find((r) => r.id === 'design-weak') ||
415
+ reasons.find((r) => r.id === 'design-smells-open-edges') ||
416
+ reasons.find((r) => r.id === 'coverage-weak-or-empty') ||
417
+ reasons.find((r) => r.id === 'dirty-freeze') ||
418
+ reasons.find((r) => r.id === 'package-version-dual-truth') ||
419
+ reasons.find((r) => r.id === 'package-pin-absent') ||
420
+ reasons.find((r) => r.id === 'baseline-missing-with-debt') ||
421
+ reasons.find((r) => r.id === 'residual-pilot') ||
422
+ reasons[0];
423
+
424
+ const primaryMessage = unfinished
425
+ ? primary?.message ||
426
+ 'Not finished: residual honesty signals remain (violations, mode, coverage, freeze, design, package pin, or pilots).'
427
+ : wholeTreeGoverned
428
+ ? 'No residual honesty blockers on this slice — still not a numeric architecture score; re-doctor after material change.'
429
+ : 'No residual honesty blockers flagged — green is only as wide as the governed slice.';
430
+
431
+ // P0B-HEADLINE: dual-truth / pin-only unfinished must not claim "not whole-tree"
432
+ // when the governed tree is already 100%.
433
+ let headline;
434
+ if (!unfinished) {
435
+ headline = 'Honesty clear on residual signals';
436
+ } else if (coverageIncomplete) {
437
+ headline = 'Not finished / not whole-tree guarantee';
438
+ } else {
439
+ headline = 'Not finished';
440
+ }
441
+
442
+ // Prefer caller next action; dual-truth / pin-absent get install/pin path when empty.
443
+ let primaryNextAction = input.primaryNextAction || null;
444
+ if (!primaryNextAction && dualTruth) {
445
+ const ver = input.packageVersionTruth?.cliVersion;
446
+ primaryNextAction = ver
447
+ ? `Bump package.json arkgate pin to ${ver} (or re-run install without --no-install)`
448
+ : 'Bump package.json arkgate pin to match this CLI (or re-run install without --no-install)';
449
+ } else if (!primaryNextAction && pinAbsent) {
450
+ primaryNextAction =
451
+ 'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)';
452
+ }
453
+
454
+ return {
455
+ finished: !unfinished && wholeTreeGoverned && !designWeak && activeBlocking === 0,
456
+ elegant: !designWeak && !base?.dirtyBaselineRisk && !designSmellsOpenEdges && activeBlocking === 0,
457
+ wholeTreeGuarantee:
458
+ wholeTreeGoverned &&
459
+ !designWeak &&
460
+ !base?.dirtyBaselineRisk &&
461
+ !cov?.greenIsNotEnforcement &&
462
+ activeBlocking === 0,
463
+ unfinished,
464
+ notAScore: true,
465
+ reasonIds: reasons.map((r) => r.id),
466
+ reasons,
467
+ primaryMessage,
468
+ primaryNextAction,
469
+ headline,
470
+ };
471
+ }
472
+
473
+ /**
474
+ * One-shot doctor honesty bundle (coverage + baseline + write path + product surface).
196
475
  * Keeps doctor-plan.mjs under its module budget.
197
476
  */
198
477
  export function computeDoctorEnforcementHonesty({
@@ -202,24 +481,70 @@ export function computeDoctorEnforcementHonesty({
202
481
  baselineExists,
203
482
  frozenKeys,
204
483
  activeViolations,
484
+ /** failsStrict !== false count only — type-only must not force active-blocking. */
485
+ activeBlockingViolations,
205
486
  suppressed,
206
487
  totalViolations,
207
488
  activeHost,
208
489
  hardWriteActive,
490
+ designWeak,
491
+ designWeakLabel,
492
+ designSmellCount,
493
+ designSmellsWithOpenEdges,
494
+ packageVersionTruth,
495
+ residualPilots,
496
+ pilotTarget,
497
+ arkRulesMergeHonesty,
498
+ primaryNextAction,
499
+ operatingMode,
500
+ packageInstalled,
501
+ selfHost,
502
+ motherCli,
209
503
  } = {}) {
504
+ const coverageHonesty = buildCoverageHonesty({
505
+ percent: governedPercent,
506
+ totalFiles,
507
+ emptyScope,
508
+ });
509
+ const baselineHonesty = buildBaselineHonesty({
510
+ exists: baselineExists,
511
+ frozenKeys,
512
+ activeViolations,
513
+ suppressed,
514
+ totalViolations,
515
+ });
516
+ const writePathHonesty = buildWritePathHonesty(activeHost, hardWriteActive, {
517
+ packageInstalled,
518
+ packagePinCode: packageVersionTruth?.code,
519
+ packagePinAbsent: packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT',
520
+ selfHost,
521
+ motherCli,
522
+ });
523
+ // Prefer explicit blocking count; fall back to activeViolations only when callers
524
+ // already pass blocking-only totals (legacy tests). Type-only must not invent debt.
525
+ const blockingForHonesty = Number.isFinite(Number(activeBlockingViolations))
526
+ ? Math.max(0, Number(activeBlockingViolations))
527
+ : Number(activeViolations) || 0;
528
+ const productHonesty = buildProductHonesty({
529
+ coverageHonesty,
530
+ baselineHonesty,
531
+ writePathHonesty,
532
+ designWeak,
533
+ designWeakLabel,
534
+ designSmellCount,
535
+ designSmellsWithOpenEdges,
536
+ packageVersionTruth,
537
+ residualPilots,
538
+ pilotTarget,
539
+ arkRulesMergeHonesty,
540
+ primaryNextAction,
541
+ operatingMode,
542
+ activeBlockingViolations: blockingForHonesty,
543
+ });
210
544
  return {
211
- coverageHonesty: buildCoverageHonesty({
212
- percent: governedPercent,
213
- totalFiles,
214
- emptyScope,
215
- }),
216
- baselineHonesty: buildBaselineHonesty({
217
- exists: baselineExists,
218
- frozenKeys,
219
- activeViolations,
220
- suppressed,
221
- totalViolations,
222
- }),
223
- writePathHonesty: buildWritePathHonesty(activeHost, hardWriteActive),
545
+ coverageHonesty,
546
+ baselineHonesty,
547
+ writePathHonesty,
548
+ productHonesty,
224
549
  };
225
550
  }
@@ -1,2 +1,2 @@
1
1
  // Generated from enforcement-state.source.mjs — run npm run generate:packaged-tooling.
2
- import b from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function S(a){const e=h.join(a,"package.json");try{if(JSON.parse(b.readFileSync(e,"utf8"))?.name==="arkgate"&&b.statSync(h.join(a,"bin","ark-check.mjs"),{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"package.json + bin/ark-check.mjs (self-host)"}}catch{}try{const r=q(e).resolve("arkgate/package.json"),o=h.dirname(r),t=JSON.parse(b.readFileSync(r,"utf8")),l=h.join(o,"bin","ark-check.mjs");if(t?.name==="arkgate"&&b.statSync(l,{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"arkgate/package.json via project resolver"}}catch{}return{installed:!1,source:"arkgate/package.json unresolved from project"}}function M(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function y({supported:a,configuredPaths:e,installed:r,active:o,runtimeObserved:t,operation:l,operationCoverage:d,bypassable:u,required:c,hard:i,sources:s}){const p=e.length>0,v=!!r.installed;return{supported:a,analyzed:!0,configured:p,installed:v,active:o,runtimeObserved:t,operation:l,operationCoverage:d,bypassable:u,required:c,hard:i,evidence:[...M(e).map(g=>({field:"configured",source:g,value:p})),{field:"installed",source:r.source,value:v},{field:"active",source:s.active,value:o},{field:"runtimeObserved",source:s.runtimeObserved,value:t},{field:"operationCoverage",source:s.operationCoverage,value:d},{field:"bypassable",source:s.bypassable,value:u},{field:"required",source:s.required,value:c},{field:"hard",source:s.hard,value:i}]}}function O(a,e){const r=S(a),o=!!e.support?.capabilities?.["hard-write"],t=!!e.support?.capabilities?.["advisory-write"],l=e.capabilityEvidence["hard-write"],d=e.capabilityEvidence["advisory-write"],u=e.capabilityEvidence["merge-gate"],c=e.enforcementLadder.localWrite,i=typeof c.operationCovered=="boolean",s=i?c.operationCovered:n,p=i&&s===!0,v=!!(o&&p&&c.hard===!0),g=i?p:o&&l.length>0&&r.installed?n:!1,k=t&&d.length>0&&r.installed?n:!1,f=!!(e.ci?.failClosed&&u.length>0),C=f&&r.installed?n:!1;return{schemaVersion:"1.1",activeHost:e.activeHost,localWrite:y({supported:o,configuredPaths:l,installed:r,active:g,runtimeObserved:i,operation:i?c.operation??null:null,operationCoverage:s,bypassable:v?!1:o&&!i?n:!0,required:n,hard:v,sources:{active:i?"observed PreToolUse attempt":"runtime observation unavailable",runtimeObserved:i?"fresh PreToolUse invocation":"runtime observation unavailable",operationCoverage:i?"active-host operation matcher":"operation not observed",bypassable:v?"observed hard write boundary":"host runtime bypass evidence unavailable",required:"local host policy unavailable",hard:v?"fresh covered active-host invocation":"hardness not proven for this invocation"}}),advisoryMcp:y({supported:t,configuredPaths:d,installed:r,active:k,runtimeObserved:!1,operation:null,operationCoverage:n,bypassable:!0,required:n,hard:!1,sources:{active:"MCP runtime observation unavailable",runtimeObserved:"doctor did not observe an MCP tool invocation",operationCoverage:"advisory MCP is caller-invoked",bypassable:"advisory MCP does not intercept every write",required:"local host policy unavailable",hard:"MCP presence is advisory and never proves a hard boundary"}}),ciMerge:y({supported:!0,configuredPaths:f?u:[],installed:r,active:C,runtimeObserved:!1,operation:"merge",operationCoverage:f?n:!1,bypassable:f?n:!0,required:n,hard:!1,sources:{active:"CI run and provider enforcement not observed",runtimeObserved:"provider evidence unavailable",operationCoverage:"required-status operation coverage unavailable",bypassable:"branch-protection evidence unavailable",required:"branch-protection evidence unavailable",hard:"merge hardness requires fresh provider evidence"}})}}function w(a,e,r,o){return{...a,...o,evidence:[...a.evidence.filter(t=>!e.includes(t.field)),...e.map(t=>({field:t,source:r,value:o[t]}))]}}function x(a,e){if(!e?.available)return a;const r=typeof e.arkCheckRequired=="boolean"?e.arkCheckRequired:n,o=!!(a.enforcementState.ciMerge.configured&&a.enforcementState.ciMerge.installed),t=r===!0?o:r===!1?!1:o?n:!1,l=t===!0?e.arkCheckSourceBound===!1?!0:n:r===!1?!0:o?n:!0,d=`GitHub branch protection (${e.repo??"repository"}:${e.branch??"default"})`,u=!0,c=r,i=t===!0&&l===!1&&c===!0,s=w(a.enforcementState.ciMerge,["active","runtimeObserved","operationCoverage","bypassable","required","hard"],d,{active:t,runtimeObserved:u,operationCoverage:c,bypassable:l,required:r,hard:i});return{...a,enforcementState:{...a.enforcementState,ciMerge:s},enforcementLadder:{...a.enforcementLadder,ciMerge:{...a.enforcementLadder.ciMerge,requiredStatus:r}}}}function m(a,e){const r=o=>o===!0?"yes":o===!1?"no":String(o);return`${a} \u2014 supported: ${r(e.supported)} \xB7 analyzed: ${r(e.analyzed)} \xB7 configured: ${r(e.configured)} \xB7 installed: ${r(e.installed)} \xB7 runtime observed: ${r(e.runtimeObserved)} \xB7 operation: ${e.operation??"none"} \xB7 operation covered: ${r(e.operationCoverage)} \xB7 active: ${r(e.active)} \xB7 bypassable: ${r(e.bypassable)} \xB7 required: ${r(e.required)} \xB7 hard: ${r(e.hard)}`}function P(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:m("Local write",a.localWrite)},{level:"warn",text:m("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:m("CI merge",a.ciMerge)}];return a.localWrite.active===n&&a.localWrite.hard===!1&&e.push({level:"bad",text:"RED FLAG: local hook assets exist, but this active-host operation was not observed at runtime; hard blocking is unverified."}),a.activeHost==="unknown"&&e.push({level:"warn",text:"Active host unknown for this invocation \u2014 enforcementState is session projection only. See writePath.inventory for on-disk host hooks; hard write is never claimed without runtime proof."}),e}export{O as buildEnforcementState,P as enforcementDoctorLines,x as withCiProviderEvidence};
2
+ import b from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function S(a){const e=h.join(a,"package.json");try{if(JSON.parse(b.readFileSync(e,"utf8"))?.name==="arkgate"&&b.statSync(h.join(a,"bin","ark-check.mjs"),{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"package.json + bin/ark-check.mjs (self-host)",selfHost:!0}}catch{}try{const r=q(e).resolve("arkgate/package.json"),o=h.dirname(r),t=JSON.parse(b.readFileSync(r,"utf8")),l=h.join(o,"bin","ark-check.mjs");if(t?.name==="arkgate"&&b.statSync(l,{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"arkgate/package.json via project resolver",selfHost:!1}}catch{}return{installed:!1,source:"arkgate/package.json unresolved from project",selfHost:!1}}function M(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function y({supported:a,configuredPaths:e,installed:r,active:o,runtimeObserved:t,operation:l,operationCoverage:d,bypassable:u,required:c,hard:i,sources:s}){const p=e.length>0,v=!!r.installed;return{supported:a,analyzed:!0,configured:p,installed:v,active:o,runtimeObserved:t,operation:l,operationCoverage:d,bypassable:u,required:c,hard:i,evidence:[...M(e).map(g=>({field:"configured",source:g,value:p})),{field:"installed",source:r.source,value:v},{field:"active",source:s.active,value:o},{field:"runtimeObserved",source:s.runtimeObserved,value:t},{field:"operationCoverage",source:s.operationCoverage,value:d},{field:"bypassable",source:s.bypassable,value:u},{field:"required",source:s.required,value:c},{field:"hard",source:s.hard,value:i}]}}function O(a,e){const r=S(a),o=!!e.support?.capabilities?.["hard-write"],t=!!e.support?.capabilities?.["advisory-write"],l=e.capabilityEvidence["hard-write"],d=e.capabilityEvidence["advisory-write"],u=e.capabilityEvidence["merge-gate"],c=e.enforcementLadder.localWrite,i=typeof c.operationCovered=="boolean",s=i?c.operationCovered:n,p=i&&s===!0,v=!!(o&&r.installed&&p&&c.hard===!0),g=i?p&&r.installed:o&&l.length>0&&r.installed?n:!1,k=t&&d.length>0&&r.installed?n:!1,f=!!(e.ci?.failClosed&&u.length>0),C=f&&r.installed?n:!1;return{schemaVersion:"1.1",activeHost:e.activeHost,localWrite:y({supported:o,configuredPaths:l,installed:r,active:g,runtimeObserved:i,operation:i?c.operation??null:null,operationCoverage:s,bypassable:v?!1:o&&!i?n:!0,required:n,hard:v,sources:{active:i?"observed PreToolUse attempt":"runtime observation unavailable",runtimeObserved:i?"fresh PreToolUse invocation":"runtime observation unavailable",operationCoverage:i?"active-host operation matcher":"operation not observed",bypassable:v?"observed hard write boundary":"host runtime bypass evidence unavailable",required:"local host policy unavailable",hard:v?"fresh covered active-host invocation":"hardness not proven for this invocation"}}),advisoryMcp:y({supported:t,configuredPaths:d,installed:r,active:k,runtimeObserved:!1,operation:null,operationCoverage:n,bypassable:!0,required:n,hard:!1,sources:{active:"MCP runtime observation unavailable",runtimeObserved:"doctor did not observe an MCP tool invocation",operationCoverage:"advisory MCP is caller-invoked",bypassable:"advisory MCP does not intercept every write",required:"local host policy unavailable",hard:"MCP presence is advisory and never proves a hard boundary"}}),ciMerge:y({supported:!0,configuredPaths:f?u:[],installed:r,active:C,runtimeObserved:!1,operation:"merge",operationCoverage:f?n:!1,bypassable:f?n:!0,required:n,hard:!1,sources:{active:"CI run and provider enforcement not observed",runtimeObserved:"provider evidence unavailable",operationCoverage:"required-status operation coverage unavailable",bypassable:"branch-protection evidence unavailable",required:"branch-protection evidence unavailable",hard:"merge hardness requires fresh provider evidence"}})}}function w(a,e,r,o){return{...a,...o,evidence:[...a.evidence.filter(t=>!e.includes(t.field)),...e.map(t=>({field:t,source:r,value:o[t]}))]}}function x(a,e){if(!e?.available)return a;const r=typeof e.arkCheckRequired=="boolean"?e.arkCheckRequired:n,o=!!(a.enforcementState.ciMerge.configured&&a.enforcementState.ciMerge.installed),t=r===!0?o:r===!1?!1:o?n:!1,l=t===!0?e.arkCheckSourceBound===!1?!0:n:r===!1?!0:o?n:!0,d=`GitHub branch protection (${e.repo??"repository"}:${e.branch??"default"})`,u=!0,c=r,i=t===!0&&l===!1&&c===!0,s=w(a.enforcementState.ciMerge,["active","runtimeObserved","operationCoverage","bypassable","required","hard"],d,{active:t,runtimeObserved:u,operationCoverage:c,bypassable:l,required:r,hard:i});return{...a,enforcementState:{...a.enforcementState,ciMerge:s},enforcementLadder:{...a.enforcementLadder,ciMerge:{...a.enforcementLadder.ciMerge,requiredStatus:r}}}}function m(a,e){const r=o=>o===!0?"yes":o===!1?"no":String(o);return`${a} \u2014 supported: ${r(e.supported)} \xB7 analyzed: ${r(e.analyzed)} \xB7 configured: ${r(e.configured)} \xB7 installed: ${r(e.installed)} \xB7 runtime observed: ${r(e.runtimeObserved)} \xB7 operation: ${e.operation??"none"} \xB7 operation covered: ${r(e.operationCoverage)} \xB7 active: ${r(e.active)} \xB7 bypassable: ${r(e.bypassable)} \xB7 required: ${r(e.required)} \xB7 hard: ${r(e.hard)}`}function P(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:m("Local write",a.localWrite)},{level:"warn",text:m("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:m("CI merge",a.ciMerge)}];return a.localWrite.active===n&&a.localWrite.hard===!1&&e.push({level:"bad",text:"RED FLAG: local hook assets exist, but this active-host operation was not observed at runtime; hard blocking is unverified."}),a.activeHost==="unknown"&&e.push({level:"warn",text:"Active host unknown for this invocation \u2014 enforcementState is session projection only. See writePath.inventory for on-disk host hooks; hard write is never claimed without runtime proof."}),e}export{O as buildEnforcementState,P as enforcementDoctorLines,S as packageInstallation,x as withCiProviderEvidence};
@@ -221,6 +221,22 @@ export function readDeclaredArkgatePin(root) {
221
221
  }
222
222
  }
223
223
 
224
+ /**
225
+ * True when this tree *is* the arkgate package (mother / self-host dogfood).
226
+ * Consumers never match: they depend on arkgate; they are not named arkgate with bin/.
227
+ * @param {string} root
228
+ */
229
+ export function isArkgateSelfHostRoot(root) {
230
+ try {
231
+ const pkgPath = path.join(root, 'package.json');
232
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
233
+ if (pkg?.name !== 'arkgate') return false;
234
+ return fs.statSync(path.join(root, 'bin', 'ark-check.mjs'), { throwIfNoEntry: false })?.isFile() === true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
224
240
  /**
225
241
  * Dual-truth: CLI/package-shipped version vs consumer package.json pin.
226
242
  * Used by doctor + upgrade so agents never confuse managed-asset CLI with CI pin.
@@ -231,8 +247,9 @@ export function readDeclaredArkgatePin(root) {
231
247
  * dualTruth: boolean,
232
248
  * cliVersion: string|null,
233
249
  * declaredPin: string|null,
234
- * code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'CLI_VERSION_UNKNOWN',
235
- * note: string
250
+ * code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'PACKAGE_PIN_SELF_HOST' | 'CLI_VERSION_UNKNOWN',
251
+ * note: string,
252
+ * selfHost?: boolean,
236
253
  * }}
237
254
  */
238
255
  export function describePackageVersionDualTruth(root, opts = {}) {
@@ -241,6 +258,7 @@ export function describePackageVersionDualTruth(root, opts = {}) {
241
258
  ? opts.cliVersion
242
259
  : arkPackageVersion();
243
260
  const declaredPin = readDeclaredArkgatePin(root);
261
+ const selfHost = isArkgateSelfHostRoot(root);
244
262
  if (!cliVersion) {
245
263
  return {
246
264
  dualTruth: false,
@@ -248,15 +266,28 @@ export function describePackageVersionDualTruth(root, opts = {}) {
248
266
  declaredPin,
249
267
  code: 'CLI_VERSION_UNKNOWN',
250
268
  note: 'Could not read shipped arkgate package version for this CLI.',
269
+ selfHost,
251
270
  };
252
271
  }
253
272
  if (!declaredPin) {
273
+ // Mother / library author tree: package IS arkgate — no consumer pin required.
274
+ if (selfHost || process.env.ARK_MOTHER_CLI === '1' || process.env.ARK_MOTHER_CLI === 'true') {
275
+ return {
276
+ dualTruth: false,
277
+ cliVersion,
278
+ declaredPin: null,
279
+ code: 'PACKAGE_PIN_SELF_HOST',
280
+ note: 'This tree is arkgate itself (self-host); no consumer package.json pin is required.',
281
+ selfHost: true,
282
+ };
283
+ }
254
284
  return {
255
285
  dualTruth: false,
256
286
  cliVersion,
257
287
  declaredPin: null,
258
288
  code: 'PACKAGE_PIN_ABSENT',
259
289
  note: 'No arkgate pin in package.json; CI/npx may not resolve this CLI version.',
290
+ selfHost: false,
260
291
  };
261
292
  }
262
293
  // Normalize ^x.y.z / ~x.y.z / x.y.z for comparison of leading version token.
@@ -294,6 +325,7 @@ export function describePackageVersionDualTruth(root, opts = {}) {
294
325
  declaredPin,
295
326
  code: 'PACKAGE_PIN_BEHIND_CLI',
296
327
  note: `Managed CLI is arkgate@${cliVersion} but package.json pins ${declaredPin}. Bump the pin or re-run install so CI resolves the same version (common after upgrade --no-install).`,
328
+ selfHost,
297
329
  };
298
330
  }
299
331
  return {
@@ -302,6 +334,7 @@ export function describePackageVersionDualTruth(root, opts = {}) {
302
334
  declaredPin,
303
335
  code: 'PACKAGE_PIN_MATCHES',
304
336
  note: `package.json pin ${declaredPin} is aligned with CLI arkgate@${cliVersion}.`,
337
+ selfHost,
305
338
  };
306
339
  }
307
340