arkgate 3.8.1 → 3.8.2

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 CHANGED
@@ -5,6 +5,34 @@ in the immutable pre-2.0 archive linked below.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 3.8.2 — 2026-07-22
9
+
10
+ Corrective **patch** over 3.8.1 from PREDIAL WEB field evidence. Aligns doctor skill freshness with
11
+ managed upgrade content identity, hardens upgrade preview honesty, clarifies doctor writePath
12
+ inventory vs this-invocation, ships the Y06 pure-layer opt-in advisory, and treats complete-catalog
13
+ Codex leftover prompts as safe-to-delete. **No required config migration.** Z09 / residual `RB-11`
14
+ remain open.
15
+
16
+ ### Fixed
17
+
18
+ - **Doctor skill “stale” vs managed upgrade (field DX):** skill gap detection now uses the same
19
+ content-identity rules as `ark upgrade` (stamp-normalized). A skill whose body matches the
20
+ package template is not stale when only `arkVersion` lags. Stale copy says content-behind-package.
21
+ - **Upgrade preview phantom apply:** summary reports `managedAssets` / `wouldWrite` /
22
+ `customizedPreserved`; when `wouldWrite` is 0, prints “Nothing to apply” instead of urging
23
+ `--apply` as the primary next step (optional stamp-only apply remains digest-bound).
24
+ - **Doctor writePath honesty:** with `activeHost: unknown`, a `sessionNote` separates on-disk
25
+ inventory from this-invocation hardness; package `installed` is independent of host support.
26
+ Hard still requires runtime proof (Z10).
27
+ - **Codex legacy prompts:** complete `.agents/skills` with leftover `.codex/prompts` is an
28
+ advisory safe-to-delete signal (CLI, doctor, HTML), not an install-agent-gates gap.
29
+
30
+ ### Added
31
+
32
+ - **Y06 pure-layer opt-in nudge:** when a golden pattern names pure modules and no layer sets
33
+ `pure: true`, doctor emits one advisory line (`doctor.pureLayerOptIn`). Never a blocker; never
34
+ auto-writes `pure: true`. Promoted from field evidence (PREDIAL WEB).
35
+
8
36
  ## 3.8.1 — 2026-07-22
9
37
 
10
38
  Corrective **patch** over 3.8.0. Closes silent fail-open on peerIsolation incomplete evidence,
package/README.md CHANGED
@@ -16,9 +16,9 @@ and makes sure a “green” check means something real.
16
16
 
17
17
  </div>
18
18
 
19
- > **ArkGate 3.8.1** is current stable: 3.8.0 complete analysis / resolved verdict / managed upgrades
20
- > / design-delta, plus peerIsolation fail-closed and pure-IR require/type-only accuracy.
21
- > [Release notes](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.8.1.md).
19
+ > **ArkGate 3.8.2** is current stable: field DX on top of 3.8.1 skill stale matches upgrade content
20
+ > identity, honest upgrade preview, doctor session notes, Y06 pure opt-in nudge, Codex legacy advisory.
21
+ > [Release notes](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.8.2.md).
22
22
 
23
23
  > **TypeScript 7 distribution note:** ArkGate prefers a usable project compiler API, then its
24
24
  > exact, physically distinct TypeScript 6 analysis host. Analysis reports required
@@ -422,7 +422,7 @@ for real systems. Details: [production-hardening.md](https://github.com/pedrokni
422
422
  | Security reporting | [SECURITY.md](SECURITY.md) |
423
423
  | Demos | [docs/demos/](https://github.com/pedroknigge/arkgate/tree/main/docs/demos) |
424
424
  | Examples | [examples/](https://github.com/pedroknigge/arkgate/blob/main/examples/README.md) |
425
- | Latest release (3.8.1) | [release notes](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.8.1.md) · [3.0.0 baseline](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.0.0.md) |
425
+ | Latest release (3.8.2) | [release notes](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.8.2.md) · [3.0.0 baseline](https://github.com/pedroknigge/arkgate/blob/main/docs/releases/3.0.0.md) |
426
426
  | Roadmap and decisions | [ROADMAP.md](https://github.com/pedroknigge/arkgate/blob/main/ROADMAP.md) · [ADRs](https://github.com/pedroknigge/arkgate/tree/main/docs/adr) · [Changelog](CHANGELOG.md) |
427
427
 
428
428
  ---
@@ -87,8 +87,11 @@ export {
87
87
  stampSkill,
88
88
  installedSkillVersion,
89
89
  isVersionOlder,
90
+ skillContentIdentity,
91
+ skillContentMatchesTemplate,
90
92
  skillTemplates,
91
93
  skillTemplateNames,
94
+ skillTemplateBodies,
92
95
  detectCodexHomeGap,
93
96
  detectCodexRepoSkillGap,
94
97
  assessCodexSkillParity,
@@ -36,7 +36,11 @@ import {
36
36
  mergePostGreenTopActions,
37
37
  isDoctorHealthyNothingToDo,
38
38
  } from './post-green-path.mjs';
39
- import { loadGoldenPattern, summarizeGoldenPattern } from './golden-pattern.mjs';
39
+ import {
40
+ computePureLayerOptInNudge,
41
+ loadGoldenPattern,
42
+ summarizeGoldenPattern,
43
+ } from './golden-pattern.mjs';
40
44
  import { summarizePilotLoop } from './pilot-loop.mjs';
41
45
  import { computeDoctorAdvisories, printDoctorAdvisories } from './doctor-advisories.mjs';
42
46
  import { ANALYSIS_COMPLETENESS, analysisIncompleteStatement, normalizeAnalysisCompleteness } from './analysis-completeness.mjs';
@@ -418,7 +422,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
418
422
  // Q01 — single post-green door when design-weak (map → B; no skill shopping).
419
423
  const postGreenPath = buildPostGreenNextAction(designFitness);
420
424
  // Q03 — optional golden pattern for NEW code (advisory; never clears design-weak).
421
- const goldenPattern = summarizeGoldenPattern(loadGoldenPattern(root));
425
+ const goldenLoad = loadGoldenPattern(root);
426
+ const goldenPattern = summarizeGoldenPattern(goldenLoad);
427
+ // Y06 — pure-layer opt-in when golden names pure modules but no pure:true layer.
428
+ const pureLayerOptIn = computePureLayerOptInNudge(config, goldenLoad);
422
429
  // Q04 — one next pilot (extraction card) when design-weak.
423
430
  const patternBetsForLoop = buildPatternBetsFromSmells(designSmells);
424
431
  const pilotLoop = summarizePilotLoop({
@@ -463,6 +470,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
463
470
  : {}),
464
471
  // Q03: advisory golden for new-code placement (absent = no claim).
465
472
  goldenPattern,
473
+ // Y06: advisory pure-layer opt-in (null when not applicable).
474
+ pureLayerOptIn,
466
475
  // Q04: one-pilot loop (extraction card → re-doctor).
467
476
  pilotLoop,
468
477
  // Advisories, never a verdict: W01 contract health, U05 ambient state,
@@ -507,6 +516,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
507
516
  hookRepair: writePath.hookRepair,
508
517
  mcpPresent: writePath.mcpPresent,
509
518
  evidence: writePath.evidence,
519
+ ...(writePath.sessionNote ? { sessionNote: writePath.sessionNote } : {}),
510
520
  ...(writePath.gap
511
521
  ? {
512
522
  gap: {
@@ -655,6 +665,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
655
665
  'Fix or remove it — absence is fine; a bad file is not guidance.'
656
666
  );
657
667
  }
668
+ // Y06 — one-line pure-layer opt-in (U05 voice; never blocker).
669
+ if (pureLayerOptIn) {
670
+ line(' ', color.dim(pureLayerOptIn.message));
671
+ }
658
672
 
659
673
  printDoctorAdvisories(doctorAdvisories, { line, warn, color }); // advisory sections
660
674
 
@@ -756,6 +770,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
756
770
  line(' ', `Active host: ${writePath.activeHost}`);
757
771
  line(' ', `Supported profile: ${writePath.supportSummary}`);
758
772
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
773
+ if (writePath.sessionNote) {
774
+ line(warn, writePath.sessionNote);
775
+ }
759
776
  const enforcement = writePath.enforcementState;
760
777
  for (const row of enforcementDoctorLines(enforcement)) line(row.level === 'ok' ? ok : row.level === 'bad' ? bad : warn, row.text);
761
778
  line(
@@ -779,7 +796,12 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
779
796
  }
780
797
  // Report Codex legacy prompts and other-host missing/stale independently (never exclusive).
781
798
  const legacyCodex = skillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
782
- const remainingGaps = skillGaps.filter((g) => !(g.tool === 'codex' && g.legacyPromptsOnly));
799
+ const codexLegacySafeDelete = skillGaps.some(
800
+ (g) => g.tool === 'codex' && g.legacyAdvisory && g.catalogComplete
801
+ );
802
+ const remainingGaps = skillGaps.filter(
803
+ (g) => !(g.tool === 'codex' && (g.legacyPromptsOnly || g.legacyAdvisory))
804
+ );
783
805
  const remMiss = remainingGaps.reduce((s, g) => s + g.missing, 0);
784
806
  const remStale = remainingGaps.reduce((s, g) => s + g.stale, 0);
785
807
  if (remMiss + remStale === 0 && !legacyCodex) line(ok, '/ark-* skills current for detected tools');
@@ -787,8 +809,19 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
787
809
  line(warn, 'Codex: legacy flat .codex/prompts only (not a loadable skill catalog)');
788
810
  actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
789
811
  }
812
+ if (codexLegacySafeDelete) {
813
+ line(
814
+ ' ',
815
+ color.dim(
816
+ 'Codex catalog complete — leftover .codex/prompts/ark-*.md are safe to delete (not loadable; not required).'
817
+ )
818
+ );
819
+ }
790
820
  if (remMiss + remStale > 0) {
791
- line(warn, `${remMiss} missing / ${remStale} outdated /ark-* skill(s) for ${remainingGaps.map((g) => g.tool).join(', ')}`);
821
+ line(
822
+ warn,
823
+ `${remMiss} missing / ${remStale} content-behind-package /ark-* skill(s) for ${remainingGaps.map((g) => g.tool).join(', ')}`
824
+ );
792
825
  actions.push('refresh /ark-* skills (--install-agent-gates --skills-only --force)');
793
826
  }
794
827
  const codexHomeGap = detectCodexHomeGap(root);
@@ -796,7 +829,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
796
829
  const parts = [
797
830
  codexHomeGap.legacyPromptsOnly ? 'legacy-prompts-only' : null,
798
831
  codexHomeGap.missing > 0 ? `${codexHomeGap.missing} missing` : null,
799
- codexHomeGap.stale > 0 ? `${codexHomeGap.stale} outdated` : null,
832
+ codexHomeGap.stale > 0 ? `${codexHomeGap.stale} content-behind-package` : null,
800
833
  ].filter(Boolean);
801
834
  const deferred = !codexConcernIsActive();
802
835
  // Deferred home debt is dim/info (not warn) so non-Codex sessions are not "incomplete".
@@ -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 M(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 S(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function g({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;return{supported:a,analyzed:!0,configured:p,installed:a&&r.installed,active:o,runtimeObserved:t,operation:l,operationCoverage:d,bypassable:u,required:c,hard:i,evidence:[...S(e).map(v=>({field:"configured",source:v,value:p})),{field:"installed",source:r.source,value:a&&r.installed},{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 w(a,e){const r=M(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),m=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:g({supported:o,configuredPaths:l,installed:r,active:m,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:g({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:g({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 j(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=j(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 y(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 L(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:y("Local write",a.localWrite)},{level:"warn",text:y("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:y("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."}),e}export{w as buildEnforcementState,L 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)"}}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};
@@ -116,6 +116,33 @@ export function loadGoldenPattern(root) {
116
116
  return { ok: true, present: true, path: rel, golden };
117
117
  }
118
118
 
119
+ /**
120
+ * Y06 — advisory opt-in nudge when the golden pattern names pure modules but no
121
+ * layer declares `pure: true`. Never a blocker; never auto-writes pure:true.
122
+ *
123
+ * @param {{ layers?: Array<{ pure?: boolean }> }|null|undefined} config
124
+ * @param {GoldenPatternLoadResult|null|undefined} goldenResult
125
+ * @returns {null | { id: string, advisory: true, message: string }}
126
+ */
127
+ export function computePureLayerOptInNudge(config, goldenResult) {
128
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
129
+ if (layers.some((layer) => layer?.pure === true)) return null;
130
+ if (!goldenResult?.present || !goldenResult.golden) return null;
131
+ const g = goldenResult.golden;
132
+ const text = [g.name, g.norm, g.newCodeHome, g.examplePath]
133
+ .filter((part) => typeof part === 'string' && part.trim())
134
+ .join('\n');
135
+ // Require explicit purity language in the golden pattern (not mere "Domain").
136
+ if (!/\bpure\b/i.test(text)) return null;
137
+ return {
138
+ id: 'pure-layer-opt-in',
139
+ advisory: true,
140
+ message:
141
+ 'Golden pattern references pure modules, but no layer sets pure: true. ' +
142
+ 'Opt in on Domain (or equivalent) to enable ambient-state diagnostics — advisory only; never auto-applied.',
143
+ };
144
+ }
145
+
119
146
  /**
120
147
  * One-line guidance for agents / placement notes.
121
148
  * @param {GoldenPatternLoadResult} result
@@ -809,10 +809,33 @@ export function renderHtmlReport({
809
809
  )
810
810
  .join('\n');
811
811
 
812
- const skillsNote =
813
- skillGaps.length === 0
814
- ? '<div class="pill good">Agent skills current for detected tools</div>'
815
- : `<div class="pill warn">${skillGaps.length} skill gap(s) — run ark upgrade / --install-agent-gates</div>`;
812
+ // Actionable catalog debt only — Codex leftover flat prompts with a complete
813
+ // .agents/skills catalog (legacyAdvisory) are cleanup advisories, not install gaps.
814
+ const actionableSkillGaps = skillGaps.filter(
815
+ (gap) =>
816
+ (gap?.missing ?? 0) > 0 ||
817
+ (gap?.stale ?? 0) > 0 ||
818
+ gap?.legacyPromptsOnly === true
819
+ );
820
+ const legacyAdvisoryOnly = skillGaps.some(
821
+ (gap) => gap?.legacyAdvisory === true && gap?.catalogComplete === true
822
+ );
823
+ const skillsParts = [];
824
+ if (actionableSkillGaps.length === 0) {
825
+ skillsParts.push(
826
+ '<div class="pill good">Agent skills current for detected tools</div>'
827
+ );
828
+ } else {
829
+ skillsParts.push(
830
+ `<div class="pill warn">${actionableSkillGaps.length} skill gap(s) — run ark upgrade / --install-agent-gates</div>`
831
+ );
832
+ }
833
+ if (legacyAdvisoryOnly) {
834
+ skillsParts.push(
835
+ '<div class="pill" style="opacity:.85">Codex leftover .codex/prompts are safe to delete (catalog complete; not required)</div>'
836
+ );
837
+ }
838
+ const skillsNote = skillsParts.join('\n');
816
839
 
817
840
  const meta = [
818
841
  version ? `ark-check v${esc(version)}` : '',
@@ -6,8 +6,10 @@ import { codexPrimaryTable, upsertCodexMcpTable } from './codex-home.mjs';
6
6
  import { buildManagedAssetCatalog } from './install-migrate.mjs';
7
7
  import {
8
8
  KNOWN_TOOLS,
9
+ arkPackageVersion,
9
10
  detectActiveAgentHost,
10
11
  normalizeToolsList,
12
+ skillContentIdentity,
11
13
  } from './skill-install.mjs';
12
14
 
13
15
  export const MANAGED_MANIFEST_PATH = 'ark.managed.json';
@@ -36,21 +38,17 @@ function hash(content) {
36
38
  return `sha256:${createHash('sha256').update(content).digest('hex')}`;
37
39
  }
38
40
 
39
- function normalizedIdentityContent(content, kind) {
40
- let text = String(content).replace(/\r\n/g, '\n');
41
- if (kind !== 'skill') return text;
42
- const lines = text.split('\n');
43
- if (lines[0] !== '---') return text;
44
- const end = lines.indexOf('---', 1);
45
- if (end < 0) return text;
46
- for (let index = 1; index < end; index += 1) {
47
- if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
48
- }
49
- return lines.join('\n');
41
+ function normalizedIdentityContent(content) {
42
+ return String(content).replace(/\r\n/g, '\n');
50
43
  }
51
44
 
45
+ /**
46
+ * Content identity for managed assets. Skill kind delegates to skill-install so
47
+ * doctor stale detection and upgrade classify never drift (single hasher).
48
+ */
52
49
  export function managedContentIdentity(content, kind = 'gate') {
53
- return hash(Buffer.from(normalizedIdentityContent(content, kind)));
50
+ if (kind === 'skill') return skillContentIdentity(content);
51
+ return hash(Buffer.from(normalizedIdentityContent(content)));
54
52
  }
55
53
 
56
54
  function isSafeRelativePath(relativePath) {
@@ -271,12 +269,22 @@ function serializeManifest(value) {
271
269
  function summaryFor(assets, manifestChanged) {
272
270
  const states = {};
273
271
  for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
274
- const fileChanges = assets.filter((asset) => asset.willApply).length;
272
+ const applying = assets.filter((asset) => asset.willApply);
273
+ // Content writes (stale/missing/conflicted accepted) — not version-stamp metadata-only.
274
+ const wouldWrite = applying.filter((asset) => asset.action !== 'refresh-metadata').length;
275
+ const metadataRefresh = applying.filter((asset) => asset.action === 'refresh-metadata').length;
276
+ const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
277
+ const fileChanges = applying.length;
275
278
  return {
276
279
  total: assets.length,
280
+ managedAssets: assets.length,
277
281
  states,
282
+ wouldWrite,
283
+ metadataRefresh,
284
+ customizedPreserved,
278
285
  fileChanges,
279
286
  manifestChanged,
287
+ // Full apply count still includes optional stamp refresh + manifest bookkeeping.
280
288
  changed: fileChanges + (manifestChanged ? 1 : 0),
281
289
  blocked: assets.filter((asset) => asset.blocked).length,
282
290
  };
@@ -616,7 +624,41 @@ export function renderManagedUpgrade(plan, options = {}) {
616
624
  const consent = asset.requiresConsent ? ' (consent required)' : '';
617
625
  console.log(` ${asset.state.padEnd(10)} ${asset.path}${consent}`);
618
626
  }
619
- const label = plan.applied ? 'Applied changes' : 'Planned changes';
620
- console.log(`${label}: ${plan.summary.changed}; blocked conflicts/deletions: ${plan.summary.blocked}.`);
621
- if (!plan.applied) console.log(options.next ?? 'Apply the exact preview with: ark upgrade --apply --no-install');
627
+ const summary = plan.summary;
628
+ const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
629
+ const wouldWrite = summary.wouldWrite ?? 0;
630
+ const metadataRefresh = summary.metadataRefresh ?? 0;
631
+ const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
632
+ const blocked = summary.blocked ?? 0;
633
+ console.log(
634
+ `Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
635
+ `customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}` +
636
+ (metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
637
+ '.'
638
+ );
639
+ if (plan.applied) {
640
+ console.log(`Applied changes: ${summary.changed}.`);
641
+ return;
642
+ }
643
+ // Content already matches package templates — do not urge --apply as the primary next step.
644
+ if (wouldWrite === 0 && blocked === 0) {
645
+ const ver = options.packageVersion ?? arkPackageVersion();
646
+ const verLabel = ver ? `arkgate@${ver}` : 'the installed arkgate package';
647
+ console.log(
648
+ `Nothing to apply — managed content matches ${verLabel} (${customizedPreserved} customized preserved).`
649
+ );
650
+ if (metadataRefresh > 0) {
651
+ console.log(
652
+ `Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
653
+ );
654
+ const stampCmd = options.optionalStampApply ?? options.next;
655
+ if (stampCmd) {
656
+ console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
657
+ }
658
+ }
659
+ return;
660
+ }
661
+ console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
662
+ if (options.next) console.log(options.next);
663
+ else console.log('Apply the exact preview with: ark upgrade --apply --no-install');
622
664
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Tool detection, skill templates, stamping, and skill freshness gaps.
3
3
  */
4
+ import { createHash } from 'node:crypto';
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { arkCommand } from '../ark-shared.mjs';
@@ -206,7 +207,12 @@ export function installedSkillVersion(filePath) {
206
207
  } catch {
207
208
  return null;
208
209
  }
209
- const match = content.match(/^arkVersion:\s*(.+)$/m);
210
+ return skillVersionFromContent(content);
211
+ }
212
+
213
+ function skillVersionFromContent(content) {
214
+ if (content == null) return null;
215
+ const match = String(content).match(/^arkVersion:\s*(.+)$/m);
210
216
  return match ? match[1].trim() : null;
211
217
  }
212
218
 
@@ -225,6 +231,63 @@ export function isVersionOlder(a, b) {
225
231
  return false;
226
232
  }
227
233
 
234
+ /**
235
+ * Content identity for managed skills — arkVersion stamp is normalized so a lagging
236
+ * header alone never diverges from the package template (matches managed-upgrade).
237
+ * @param {string|null|undefined} content
238
+ * @returns {string|null}
239
+ */
240
+ export function skillContentIdentity(content) {
241
+ if (content == null) return null;
242
+ let text = String(content).replace(/\r\n/g, '\n');
243
+ const lines = text.split('\n');
244
+ if (lines[0] === '---') {
245
+ const end = lines.indexOf('---', 1);
246
+ if (end >= 0) {
247
+ for (let index = 1; index < end; index += 1) {
248
+ if (/^arkVersion:/.test(lines[index])) lines[index] = 'arkVersion:<managed>';
249
+ }
250
+ text = lines.join('\n');
251
+ }
252
+ }
253
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
254
+ }
255
+
256
+ /**
257
+ * True when installed skill body matches the package template for that skill
258
+ * (version stamp ignored). Used so doctor "stale" aligns with managed upgrade.
259
+ *
260
+ * Templates ship without arkVersion; installs are stamped. Identity normalizes
261
+ * the stamp value, so compare against both the raw template and a stamped copy.
262
+ * @param {string} installedContent
263
+ * @param {string|undefined|null} templateContent
264
+ */
265
+ export function skillContentMatchesTemplate(installedContent, templateContent) {
266
+ if (templateContent == null || installedContent == null) return false;
267
+ const installedId = skillContentIdentity(installedContent);
268
+ if (installedId === skillContentIdentity(templateContent)) return true;
269
+ // Installed skills are stamped; templates are not — stamp with a dummy version
270
+ // so arkVersion:<managed> lines align under skillContentIdentity.
271
+ return installedId === skillContentIdentity(stampSkill(templateContent, '0.0.0'));
272
+ }
273
+
274
+ /** @returns {Record<string, string>} skill name → template body from package */
275
+ export function skillTemplateBodies() {
276
+ return Object.fromEntries(skillTemplates());
277
+ }
278
+
279
+ /**
280
+ * Count a present skill as stale only when content differs from the package
281
+ * template AND the arkVersion stamp is missing or older than the package.
282
+ * Content identity match → never stale (even if header lags).
283
+ */
284
+ function isInstalledSkillStale(installedContent, templateContent, packageVersion) {
285
+ if (!packageVersion) return false;
286
+ if (skillContentMatchesTemplate(installedContent, templateContent)) return false;
287
+ const installed = skillVersionFromContent(installedContent);
288
+ return installed === null || isVersionOlder(installed, packageVersion);
289
+ }
290
+
228
291
  export function skillTemplates() {
229
292
  const dir = path.join(__packageRoot, 'templates', 'skills');
230
293
  // A missing/mispackaged templates dir would otherwise install zero skills with
@@ -263,23 +326,31 @@ export function skillTemplateNames() {
263
326
 
264
327
  /**
265
328
  * Count present / stale / legacy-only skill files for one catalog root.
329
+ * "stale" means content behind the package template (identity mismatch) with a
330
+ * missing/older arkVersion stamp — not merely a lagging version header when the
331
+ * body still matches the template (aligned with managed-upgrade classify).
266
332
  * @param {string[]} skillNames
267
333
  * @param {(name: string) => string} skillFile path builder
268
334
  * @param {string|null} packageVersion
269
- * @param {{ legacyFile?: (name: string) => string }} [opts]
335
+ * @param {{ legacyFile?: (name: string) => string, templateBodies?: Record<string, string> }} [opts]
270
336
  */
271
337
  export function assessSkillCatalogParity(skillNames, skillFile, packageVersion, opts = {}) {
272
338
  const expectedCount = skillNames.length;
339
+ const templates = opts.templateBodies ?? skillTemplateBodies();
273
340
  const present = [];
274
341
  let stale = 0;
275
342
  for (const name of skillNames) {
276
343
  const file = skillFile(name);
277
344
  if (!fs.existsSync(file)) continue;
278
- present.push(name);
279
- if (packageVersion) {
280
- const installed = installedSkillVersion(file);
281
- if (installed === null || isVersionOlder(installed, packageVersion)) stale += 1;
345
+ let content;
346
+ try {
347
+ content = fs.readFileSync(file, 'utf8');
348
+ } catch {
349
+ // Unreadable path is not a usable install — count as missing (matches detectSkillGaps).
350
+ continue;
282
351
  }
352
+ present.push(name);
353
+ if (isInstalledSkillStale(content, templates[name], packageVersion)) stale += 1;
283
354
  }
284
355
  let legacyCount = 0;
285
356
  if (typeof opts.legacyFile === 'function') {
@@ -291,6 +362,7 @@ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion,
291
362
  const missing = expectedCount - presentCount;
292
363
  const legacyPromptsOnly = presentCount === 0 && legacyCount > 0;
293
364
  const hasLegacyPrompts = legacyCount > 0;
365
+ // Legacy prompts beside a complete modern catalog are not catalog debt.
294
366
  const ok = missing === 0 && stale === 0 && !legacyPromptsOnly;
295
367
  return {
296
368
  ok,
@@ -302,6 +374,7 @@ export function assessSkillCatalogParity(skillNames, skillFile, packageVersion,
302
374
  legacyPromptsOnly,
303
375
  hasLegacyPrompts,
304
376
  legacyCount,
377
+ catalogComplete: missing === 0 && stale === 0 && presentCount === expectedCount,
305
378
  };
306
379
  }
307
380
 
@@ -490,6 +563,7 @@ export function detectSkillGaps(root) {
490
563
  if (fs.existsSync(path.join(root, 'templates', 'skills'))) return [];
491
564
  const skillNames = skillTemplateNames();
492
565
  if (skillNames.length === 0) return [];
566
+ const templates = skillTemplateBodies();
493
567
  const detected = [];
494
568
  if (fs.existsSync(path.join(root, '.claude'))) detected.push('claude');
495
569
  if (fs.existsSync(path.join(root, '.cursor'))) detected.push('cursor');
@@ -510,16 +584,21 @@ export function detectSkillGaps(root) {
510
584
  const file = path.join(root, target(name));
511
585
  if (!fs.existsSync(file)) {
512
586
  missing += 1;
513
- } else if (version) {
514
- // An installed skill with no stamp predates stamping (older Ark), or one
515
- // stamped behind the current version is left over from an older install.
516
- // Either way the shipped skill has moved on — offer a --force refresh.
517
- const installed = installedSkillVersion(file);
518
- if (installed === null || isVersionOlder(installed, version)) stale += 1;
587
+ continue;
588
+ }
589
+ let content;
590
+ try {
591
+ content = fs.readFileSync(file, 'utf8');
592
+ } catch {
593
+ missing += 1;
594
+ continue;
519
595
  }
596
+ // Content identity match with package template → not stale (version header may lag).
597
+ if (isInstalledSkillStale(content, templates[name], version)) stale += 1;
520
598
  }
521
599
  let legacyPromptsOnly = false;
522
600
  let hasLegacyPrompts = false;
601
+ let legacyAdvisory = false;
523
602
  if (tool === 'codex') {
524
603
  const legacyCount = skillNames.filter((name) =>
525
604
  fs.existsSync(path.join(root, '.codex', 'prompts', `${name}.md`))
@@ -527,14 +606,20 @@ export function detectSkillGaps(root) {
527
606
  hasLegacyPrompts = legacyCount > 0;
528
607
  // Flat prompts without any SKILL.md catalog entries are not loadable.
529
608
  legacyPromptsOnly = hasLegacyPrompts && missing === skillNames.length;
609
+ // Modern catalog complete + leftover flat prompts → advisory only (safe delete).
610
+ legacyAdvisory =
611
+ hasLegacyPrompts && !legacyPromptsOnly && missing === 0 && stale === 0;
530
612
  }
531
- if (missing > 0 || stale > 0 || legacyPromptsOnly) {
613
+ if (missing > 0 || stale > 0 || legacyPromptsOnly || legacyAdvisory) {
532
614
  gaps.push({
533
615
  tool,
534
616
  missing,
535
617
  stale,
536
618
  ...(legacyPromptsOnly ? { legacyPromptsOnly: true } : {}),
537
619
  ...(hasLegacyPrompts ? { hasLegacyPrompts: true } : {}),
620
+ ...(legacyAdvisory
621
+ ? { legacyAdvisory: true, catalogComplete: true }
622
+ : {}),
538
623
  });
539
624
  }
540
625
  }
@@ -550,8 +635,14 @@ export function printSkillAndCodexGapHints(root, opts) {
550
635
  const { skillGaps, codexHomeGap, codexRepoSkillGap, codexSessionActive, color } = opts;
551
636
  if (skillGaps?.length > 0) {
552
637
  const legacyCodex = skillGaps.some((gap) => gap.tool === 'codex' && gap.legacyPromptsOnly);
638
+ const legacyAdvisory = skillGaps.some(
639
+ (gap) => gap.tool === 'codex' && gap.legacyAdvisory && gap.catalogComplete
640
+ );
553
641
  // Report Codex legacy separately; never suppress missing/stale for other hosts.
554
- const remaining = skillGaps.filter((gap) => !(gap.tool === 'codex' && gap.legacyPromptsOnly));
642
+ const remaining = skillGaps.filter(
643
+ (gap) =>
644
+ !(gap.tool === 'codex' && (gap.legacyPromptsOnly || gap.legacyAdvisory))
645
+ );
555
646
  const missingTotal = remaining.reduce((sum, gap) => sum + gap.missing, 0);
556
647
  const staleTotal = remaining.reduce((sum, gap) => sum + gap.stale, 0);
557
648
  const tools = remaining.map((gap) => gap.tool).join(', ');
@@ -563,6 +654,13 @@ export function printSkillAndCodexGapHints(root, opts) {
563
654
  )
564
655
  );
565
656
  }
657
+ if (legacyAdvisory) {
658
+ console.log(
659
+ color.dim(
660
+ 'Codex .agents/skills catalog is complete; leftover .codex/prompts/ark-*.md are not loadable and safe to delete (not required).'
661
+ )
662
+ );
663
+ }
566
664
  if (missingTotal > 0) {
567
665
  console.log(
568
666
  color.dim(
@@ -574,7 +672,7 @@ export function printSkillAndCodexGapHints(root, opts) {
574
672
  if (staleTotal > 0) {
575
673
  console.log(
576
674
  color.dim(
577
- `${staleTotal} /ark-* skill(s) outdated for ${tools} (this Ark ships newer versions). ` +
675
+ `${staleTotal} /ark-* skill(s) content behind this Ark package for ${tools}. ` +
578
676
  `Refresh: ${arkCommand(root, 'ark-check', '--install-agent-gates --skills-only --force')}`
579
677
  )
580
678
  );
@@ -584,7 +682,7 @@ export function printSkillAndCodexGapHints(root, opts) {
584
682
  const parts = [];
585
683
  if (codexHomeGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
586
684
  if (codexHomeGap.missing > 0) parts.push(`${codexHomeGap.missing} missing`);
587
- if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} outdated`);
685
+ if (codexHomeGap.stale > 0) parts.push(`${codexHomeGap.stale} content-behind-package`);
588
686
  const deferred = !codexSessionActive;
589
687
  const deferredNote = deferred
590
688
  ? ' Deferred unless you use Codex — not a blocker for Grok/Claude/Cursor. '
@@ -600,7 +698,7 @@ export function printSkillAndCodexGapHints(root, opts) {
600
698
  const parts = [];
601
699
  if (codexRepoSkillGap.legacyPromptsOnly) parts.push('legacy-prompts-only');
602
700
  if (codexRepoSkillGap.missing > 0) parts.push(`${codexRepoSkillGap.missing} missing`);
603
- if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} outdated`);
701
+ if (codexRepoSkillGap.stale > 0) parts.push(`${codexRepoSkillGap.stale} content-behind-package`);
604
702
  console.log(
605
703
  color.yellow(
606
704
  `Codex repo skill catalog (.agents/skills) needs refresh (${parts.join(', ')}). ` +