@open-agent-toolkit/cli 0.2.63 → 0.2.65

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 (165) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/tool-packs.md +83 -0
  3. package/assets/docs/contributing/skills.md +52 -7
  4. package/assets/docs/provider-sync/manifest-and-drift.md +105 -0
  5. package/assets/public-package-versions.json +4 -4
  6. package/assets/skills/analyze/SKILL.md +2 -1
  7. package/assets/skills/authoring-docs/SKILL.md +2 -1
  8. package/assets/skills/compare/SKILL.md +2 -1
  9. package/assets/skills/create-agnostic-skill/SKILL.md +10 -7
  10. package/assets/skills/create-agnostic-skill/references/skill-template.md +9 -7
  11. package/assets/skills/deep-research/SKILL.md +2 -1
  12. package/assets/skills/explainer-kit/SKILL.md +2 -1
  13. package/assets/skills/oat-agent-instructions-analyze/SKILL.md +2 -1
  14. package/assets/skills/oat-agent-instructions-apply/SKILL.md +2 -1
  15. package/assets/skills/oat-brainstorm/SKILL.md +2 -1
  16. package/assets/skills/oat-cursor-cloud-projects/SKILL.md +2 -1
  17. package/assets/skills/oat-dispatch-subagents/SKILL.md +2 -1
  18. package/assets/skills/oat-docs/SKILL.md +2 -1
  19. package/assets/skills/oat-docs-analyze/SKILL.md +2 -1
  20. package/assets/skills/oat-docs-apply/SKILL.md +2 -1
  21. package/assets/skills/oat-docs-authoring/SKILL.md +2 -1
  22. package/assets/skills/oat-docs-bootstrap/SKILL.md +2 -1
  23. package/assets/skills/oat-doctor/SKILL.md +2 -1
  24. package/assets/skills/oat-explainer-kit/SKILL.md +2 -1
  25. package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +322 -2
  26. package/assets/skills/oat-idea-ideate/SKILL.md +2 -1
  27. package/assets/skills/oat-idea-new/SKILL.md +2 -1
  28. package/assets/skills/oat-idea-scratchpad/SKILL.md +2 -1
  29. package/assets/skills/oat-idea-summarize/SKILL.md +2 -1
  30. package/assets/skills/oat-pjm-add-backlog-item/SKILL.md +2 -1
  31. package/assets/skills/oat-pjm-decision/SKILL.md +2 -1
  32. package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -1
  33. package/assets/skills/oat-pjm-update-repo-reference/SKILL.md +2 -1
  34. package/assets/skills/oat-project-autonomous/SKILL.md +2 -1
  35. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -0
  36. package/assets/skills/oat-project-capture/SKILL.md +2 -1
  37. package/assets/skills/oat-project-clear-active/SKILL.md +2 -1
  38. package/assets/skills/oat-project-complete/SKILL.md +2 -1
  39. package/assets/skills/oat-project-design/SKILL.md +2 -1
  40. package/assets/skills/oat-project-discover/SKILL.md +2 -1
  41. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +2 -1
  42. package/assets/skills/oat-project-document/SKILL.md +2 -1
  43. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -0
  44. package/assets/skills/oat-project-implement/SKILL.md +2 -1
  45. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -0
  46. package/assets/skills/oat-project-import-plan/SKILL.md +2 -1
  47. package/assets/skills/oat-project-lite/SKILL.md +2 -1
  48. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -0
  49. package/assets/skills/oat-project-new/SKILL.md +2 -1
  50. package/assets/skills/oat-project-next/SKILL.md +2 -1
  51. package/assets/skills/oat-project-open/SKILL.md +2 -1
  52. package/assets/skills/oat-project-plan/SKILL.md +2 -1
  53. package/assets/skills/oat-project-plan-writing/SKILL.md +2 -1
  54. package/assets/skills/oat-project-pr-final/SKILL.md +282 -6
  55. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -0
  56. package/assets/skills/oat-project-pr-progress/SKILL.md +2 -1
  57. package/assets/skills/oat-project-progress/SKILL.md +2 -1
  58. package/assets/skills/oat-project-promote-spec-driven/SKILL.md +2 -1
  59. package/assets/skills/oat-project-quick-start/SKILL.md +2 -1
  60. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -0
  61. package/assets/skills/oat-project-reconcile/SKILL.md +2 -1
  62. package/assets/skills/oat-project-retro/SKILL.md +2 -1
  63. package/assets/skills/oat-project-retro-file/SKILL.md +2 -1
  64. package/assets/skills/oat-project-review-provide/SKILL.md +2 -1
  65. package/assets/skills/oat-project-review-provide-remote/SKILL.md +2 -1
  66. package/assets/skills/oat-project-review-receive/SKILL.md +2 -1
  67. package/assets/skills/oat-project-review-receive-remote/SKILL.md +2 -1
  68. package/assets/skills/oat-project-revise/SKILL.md +2 -1
  69. package/assets/skills/oat-project-spec/SKILL.md +2 -1
  70. package/assets/skills/oat-project-split/SKILL.md +2 -1
  71. package/assets/skills/oat-project-summary/SKILL.md +2 -1
  72. package/assets/skills/oat-repo-improve/SKILL.md +1 -1
  73. package/assets/skills/oat-repo-knowledge-index/SKILL.md +2 -1
  74. package/assets/skills/oat-repo-maintainability-review/SKILL.md +2 -1
  75. package/assets/skills/oat-review-provide/SKILL.md +2 -1
  76. package/assets/skills/oat-review-provide-remote/SKILL.md +2 -1
  77. package/assets/skills/oat-review-receive/SKILL.md +2 -1
  78. package/assets/skills/oat-review-receive-remote/SKILL.md +2 -1
  79. package/assets/skills/oat-wave-execute/SKILL.md +2 -1
  80. package/assets/skills/oat-wave-program/SKILL.md +2 -1
  81. package/assets/skills/oat-worktree-bootstrap/SKILL.md +2 -1
  82. package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +2 -1
  83. package/assets/skills/oat-wrap-up/SKILL.md +2 -1
  84. package/assets/skills/recon/SKILL.md +2 -1
  85. package/assets/skills/skeptic/SKILL.md +2 -1
  86. package/assets/skills/subagent-orchestration/SKILL.md +2 -1
  87. package/assets/skills/synthesize/SKILL.md +2 -1
  88. package/dist/agents/canonical/resolve.d.ts.map +1 -1
  89. package/dist/agents/canonical/resolve.js +25 -4
  90. package/dist/commands/doctor/index.d.ts +7 -0
  91. package/dist/commands/doctor/index.d.ts.map +1 -1
  92. package/dist/commands/doctor/index.js +10 -2
  93. package/dist/commands/init/tools/index.d.ts.map +1 -1
  94. package/dist/commands/init/tools/index.js +68 -36
  95. package/dist/commands/shared/frontmatter.d.ts +51 -0
  96. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  97. package/dist/commands/shared/frontmatter.js +118 -6
  98. package/dist/commands/status/index.d.ts.map +1 -1
  99. package/dist/commands/status/index.js +5 -3
  100. package/dist/commands/sync/index.d.ts +14 -0
  101. package/dist/commands/sync/index.d.ts.map +1 -1
  102. package/dist/commands/sync/index.js +21 -0
  103. package/dist/commands/tools/info/index.d.ts.map +1 -1
  104. package/dist/commands/tools/info/index.js +14 -1
  105. package/dist/commands/tools/info/info-tool.d.ts +12 -0
  106. package/dist/commands/tools/info/info-tool.d.ts.map +1 -1
  107. package/dist/commands/tools/info/info-tool.js +54 -6
  108. package/dist/commands/tools/info/skill-views.d.ts +86 -0
  109. package/dist/commands/tools/info/skill-views.d.ts.map +1 -0
  110. package/dist/commands/tools/info/skill-views.js +383 -0
  111. package/dist/commands/tools/install/index.d.ts.map +1 -1
  112. package/dist/commands/tools/install/index.js +3 -25
  113. package/dist/commands/tools/list/list-tools.d.ts +2 -0
  114. package/dist/commands/tools/list/list-tools.d.ts.map +1 -1
  115. package/dist/commands/tools/list/list-tools.js +23 -4
  116. package/dist/commands/tools/remove/index.d.ts.map +1 -1
  117. package/dist/commands/tools/remove/index.js +35 -42
  118. package/dist/commands/tools/remove/remove-tools.d.ts +3 -2
  119. package/dist/commands/tools/remove/remove-tools.d.ts.map +1 -1
  120. package/dist/commands/tools/remove/remove-tools.js +14 -4
  121. package/dist/commands/tools/shared/auto-sync.d.ts +12 -1
  122. package/dist/commands/tools/shared/auto-sync.d.ts.map +1 -1
  123. package/dist/commands/tools/shared/auto-sync.js +20 -4
  124. package/dist/commands/tools/shared/format-pack-inventory.d.ts +12 -1
  125. package/dist/commands/tools/shared/format-pack-inventory.d.ts.map +1 -1
  126. package/dist/commands/tools/shared/format-pack-inventory.js +48 -6
  127. package/dist/commands/tools/shared/in-process-sync.d.ts +11 -0
  128. package/dist/commands/tools/shared/in-process-sync.d.ts.map +1 -0
  129. package/dist/commands/tools/shared/in-process-sync.js +79 -0
  130. package/dist/commands/tools/shared/pack-evidence.d.ts +78 -2
  131. package/dist/commands/tools/shared/pack-evidence.d.ts.map +1 -1
  132. package/dist/commands/tools/shared/pack-evidence.js +96 -2
  133. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts +11 -0
  134. package/dist/commands/tools/shared/pack-lifecycle-outcome.d.ts.map +1 -1
  135. package/dist/commands/tools/shared/pack-lifecycle-outcome.js +24 -3
  136. package/dist/commands/tools/shared/pack-provider-evidence.d.ts +73 -0
  137. package/dist/commands/tools/shared/pack-provider-evidence.d.ts.map +1 -0
  138. package/dist/commands/tools/shared/pack-provider-evidence.js +161 -0
  139. package/dist/commands/tools/shared/provider-context.d.ts +88 -0
  140. package/dist/commands/tools/shared/provider-context.d.ts.map +1 -0
  141. package/dist/commands/tools/shared/provider-context.js +77 -0
  142. package/dist/commands/tools/shared/provider-reachability.d.ts +93 -0
  143. package/dist/commands/tools/shared/provider-reachability.d.ts.map +1 -0
  144. package/dist/commands/tools/shared/provider-reachability.js +358 -0
  145. package/dist/commands/tools/shared/sync-evidence.d.ts +57 -0
  146. package/dist/commands/tools/shared/sync-evidence.d.ts.map +1 -0
  147. package/dist/commands/tools/shared/sync-evidence.js +147 -0
  148. package/dist/commands/tools/update/index.d.ts +0 -4
  149. package/dist/commands/tools/update/index.d.ts.map +1 -1
  150. package/dist/commands/tools/update/index.js +22 -40
  151. package/dist/commands/tools/update/update-tools.d.ts.map +1 -1
  152. package/dist/commands/tools/update/update-tools.js +23 -8
  153. package/dist/config/json.d.ts +36 -0
  154. package/dist/config/json.d.ts.map +1 -1
  155. package/dist/config/json.js +135 -3
  156. package/dist/drift/index.d.ts +2 -0
  157. package/dist/drift/index.d.ts.map +1 -1
  158. package/dist/drift/index.js +1 -0
  159. package/dist/drift/skill-view-diagnostic.d.ts +197 -0
  160. package/dist/drift/skill-view-diagnostic.d.ts.map +1 -0
  161. package/dist/drift/skill-view-diagnostic.js +343 -0
  162. package/dist/validation/skills.d.ts +1 -0
  163. package/dist/validation/skills.d.ts.map +1 -1
  164. package/dist/validation/skills.js +181 -19
  165. package/package.json +2 -2
@@ -0,0 +1,343 @@
1
+ import { join } from 'node:path';
2
+ const CANONICAL_SKILL_DIR = join('.agents', 'skills');
3
+ function skillMappingFor(registration, scope) {
4
+ const mappings = scope === 'project'
5
+ ? registration.adapter.projectMappings
6
+ : registration.adapter.userMappings;
7
+ return mappings.find(({ contentType }) => contentType === 'skill');
8
+ }
9
+ /**
10
+ * Builds the expected provider path for one canonical skill, per registered
11
+ * adapter that maps skills in this scope.
12
+ *
13
+ * Mirrors the engine's own derivation (`providerDir` + entry name in
14
+ * `computeSyncPlan`). A canonical skill is a directory, and the engine applies
15
+ * `providerExtension` only to file entries, so the directory name is carried
16
+ * through unchanged.
17
+ *
18
+ * `allowedCanonicalPaths` mirrors `computeSyncPlan`'s filter of the same name:
19
+ * a targeted sync narrows the plan to specific canonical paths. No durable
20
+ * config excludes a canonical skill today, so the `oat tools info` wiring
21
+ * passes nothing; the parameter exists so that a filtered projection is
22
+ * reported as `excluded` rather than as a projection gap.
23
+ */
24
+ export function resolveExpectedSkillProjections(input) {
25
+ const canonicalPath = join(CANONICAL_SKILL_DIR, input.skillName);
26
+ const excluded = input.allowedCanonicalPaths !== undefined &&
27
+ !input.allowedCanonicalPaths.includes(canonicalPath);
28
+ const projections = [];
29
+ for (const registration of input.registrations) {
30
+ const mapping = skillMappingFor(registration, input.scope);
31
+ if (!mapping)
32
+ continue;
33
+ projections.push({
34
+ provider: registration.adapter.name,
35
+ providerPath: join(mapping.providerDir, input.skillName),
36
+ contentKind: 'skill',
37
+ nativeRead: mapping.nativeRead,
38
+ ...(excluded
39
+ ? {
40
+ excludedReason: `targeted sync filter does not include ${canonicalPath}`,
41
+ }
42
+ : {}),
43
+ });
44
+ }
45
+ return projections;
46
+ }
47
+ function driftReasonText(state) {
48
+ return state.status === 'drifted' ? state.reason : state.status;
49
+ }
50
+ function versionNote(strategy) {
51
+ if (strategy === 'symlink') {
52
+ return ' The view is a symlink to the canonical file, so it has no separate version.';
53
+ }
54
+ if (strategy === 'collection') {
55
+ return ' The view is inherited from a collection alias, so it has no separate version.';
56
+ }
57
+ return '';
58
+ }
59
+ /** Names a version declaration the resolver could not take at face value. */
60
+ function projectedVersionNote(projected, withheld) {
61
+ if (!projected)
62
+ return '';
63
+ if (projected.state === 'conflict' && projected.conflict) {
64
+ const declaration = `The projected SKILL.md declares metadata.version ${projected.conflict.metadata} and top-level version ${projected.conflict.topLevel}; reconcile the two fields.`;
65
+ // The resolver is deterministic — it takes `metadata.version` — so a
66
+ // conflicting copy can genuinely be stale. When the comparison is
67
+ // withheld, say the check was skipped rather than implying the view is
68
+ // fine; when the resolved value agrees with canonical, nothing was
69
+ // withheld and claiming otherwise would be its own falsehood.
70
+ return withheld
71
+ ? ` ${declaration} A self-contradicting declaration is not usable as evidence, so the version comparison was skipped: a real difference from canonical would not be reported here.`
72
+ : ` ${declaration} The shared resolver takes metadata.version, which is the version compared above.`;
73
+ }
74
+ if (projected.state === 'unusable') {
75
+ return ' The projected SKILL.md declares a version that cannot be read, so no version comparison is made.';
76
+ }
77
+ if (projected.state === 'malformed') {
78
+ return ' The projected SKILL.md frontmatter does not parse, so no version comparison is made.';
79
+ }
80
+ return '';
81
+ }
82
+ /**
83
+ * Says which path this row is really about when the manifest disagrees with the
84
+ * adapter.
85
+ *
86
+ * The manifest is keyed by `(canonicalPath, provider)` and never by path, so an
87
+ * entry can point somewhere other than the adapter's expected projection while a
88
+ * healthy file sits at the expected path — reachable after a `providerDir`
89
+ * change or from a manifest written under an older layout. Attaching the drift
90
+ * verdict for the tracked path to a row labelled with the expected path made the
91
+ * row state something false about a file that exists, so the row names the
92
+ * tracked path and this note says what the expected path looks like instead.
93
+ */
94
+ function divergentPathNote(observation) {
95
+ const tracked = observation.manifestEntry?.providerPath;
96
+ if (tracked === undefined ||
97
+ tracked === observation.projection.providerPath) {
98
+ return '';
99
+ }
100
+ // `viewPresent` is only evidence when the observation succeeded: a failed
101
+ // read leaves it at its default, and reporting that default as absence would
102
+ // replace one false claim about the expected path with another.
103
+ const expectedPathNote = observation.unavailableReason !== undefined
104
+ ? 'Whether anything exists at the expected path was not established.'
105
+ : observation.viewPresent
106
+ ? 'Something does exist at the expected path, and this row says nothing about it.'
107
+ : 'Nothing exists at the expected path either.';
108
+ return ` The manifest tracks this view at ${tracked}, which is not the expected projection path ${observation.projection.providerPath}; the state, path, and version above all describe the tracked path. ${expectedPathNote} Syncing this scope re-projects the skill to the expected path.`;
109
+ }
110
+ function classify(observation, isActive) {
111
+ const { projection, manifestEntry, drift, viewPresent } = observation;
112
+ if (!isActive) {
113
+ return {
114
+ viewClass: 'inactive',
115
+ detail: 'Provider is not active in this scope, so nothing is projected.',
116
+ };
117
+ }
118
+ if (observation.unavailableReason !== undefined) {
119
+ // `unverified` is the honest class: a read failure is not evidence of a
120
+ // projection gap, and reporting one as `missing-additive` or `removed`
121
+ // would send the user to a sync that fixes nothing.
122
+ return {
123
+ viewClass: 'unverified',
124
+ detail: `The diagnostic could not read this view, so its state is unverified: ${observation.unavailableReason}`,
125
+ };
126
+ }
127
+ if (projection.excludedReason !== undefined) {
128
+ return {
129
+ viewClass: 'excluded',
130
+ detail: `Excluded from this scope's projection: ${projection.excludedReason}.`,
131
+ };
132
+ }
133
+ if (projection.nativeRead) {
134
+ return {
135
+ viewClass: 'in-sync',
136
+ detail: 'Provider reads the canonical skill in place; there is no separate view to sync or version to compare.',
137
+ };
138
+ }
139
+ if (!manifestEntry) {
140
+ if (viewPresent) {
141
+ return {
142
+ viewClass: 'untracked',
143
+ detail: 'An untracked file occupies the expected path. Stray detection skips provider entries whose name matches a canonical entry, so "oat status" does not report it as a stray; it reports the untracked projection as missing instead. Inspect the path and remove it if it is not wanted, then sync the scope; no repair is suggested here because a sync could overwrite content OAT does not own.',
144
+ };
145
+ }
146
+ return {
147
+ viewClass: 'missing-additive',
148
+ detail: 'Canonical skill has never been projected to this view: no manifest entry and nothing at the expected path.',
149
+ };
150
+ }
151
+ if (!drift) {
152
+ // A tracked entry must never be labelled `untracked`: the same record
153
+ // reports `tracked: true`, and the two together are self-contradictory.
154
+ return {
155
+ viewClass: 'unverified',
156
+ detail: 'A manifest entry tracks this view but no drift observation accompanied it, so its state is unverified.',
157
+ };
158
+ }
159
+ if (drift.state.status === 'missing') {
160
+ return {
161
+ viewClass: 'removed',
162
+ detail: 'The manifest tracks this view but the provider file is gone from disk.',
163
+ };
164
+ }
165
+ if (drift.state.status === 'drifted') {
166
+ return {
167
+ viewClass: 'modified',
168
+ detail: `The tracked view diverged from the canonical skill (${driftReasonText(drift.state)}).`,
169
+ };
170
+ }
171
+ if (drift.state.status === 'in_sync') {
172
+ return {
173
+ viewClass: 'in-sync',
174
+ // A copy is only known to match the content recorded at its last sync:
175
+ // `detectDrift` compares it against the manifest hash, so a canonical
176
+ // edit that was never re-synced leaves both sides agreeing. Say exactly
177
+ // that rather than claiming canonical equality the check cannot support.
178
+ detail: manifestEntry.strategy === 'copy'
179
+ ? 'The tracked copy still matches the content recorded at its last sync; a canonical edit that was never re-synced is not visible in the manifest hash.'
180
+ : 'The tracked view matches the canonical skill.',
181
+ };
182
+ }
183
+ // `detectDrift` never returns `stray` for a keyed entry; treat any such
184
+ // report as unmanaged rather than inventing a projection gap.
185
+ return {
186
+ viewClass: 'untracked',
187
+ detail: 'The provider file is reported as an unmanaged stray.',
188
+ };
189
+ }
190
+ const REPAIRABLE = [
191
+ 'missing-additive',
192
+ 'removed',
193
+ 'modified',
194
+ ];
195
+ /**
196
+ * Maps one canonical skill onto one diagnostic per registered provider for a
197
+ * single concrete scope. Pure: every filesystem, manifest, and drift fact is
198
+ * supplied by the caller.
199
+ */
200
+ export function diagnoseSkillViews(input) {
201
+ if (!input.canonicalPresent) {
202
+ return {
203
+ skill: input.skillName,
204
+ scope: input.scope,
205
+ result: 'unknown-skill',
206
+ views: [],
207
+ };
208
+ }
209
+ const active = new Set(input.providerScopeContext.activeProviders);
210
+ const byProvider = new Map(input.observations.map((observation) => [
211
+ observation.projection.provider,
212
+ observation,
213
+ ]));
214
+ const views = [];
215
+ for (const registration of input.providerScopeContext.registrations) {
216
+ const provider = registration.adapter.name;
217
+ const observation = byProvider.get(provider);
218
+ if (!observation) {
219
+ views.push({
220
+ skill: input.skillName,
221
+ scope: input.scope,
222
+ provider,
223
+ viewClass: 'unsupported',
224
+ driftState: null,
225
+ providerPath: null,
226
+ tracked: false,
227
+ strategy: null,
228
+ nativeRead: false,
229
+ canonicalVersion: input.canonicalVersion,
230
+ viewVersion: null,
231
+ versionComparable: false,
232
+ suggestion: null,
233
+ detail: `Provider has no ${input.scope} skill mapping, so no view is expected.`,
234
+ });
235
+ continue;
236
+ }
237
+ const isActive = active.has(provider);
238
+ const classification = classify(observation, isActive);
239
+ const strategy = observation.manifestEntry?.strategy ?? null;
240
+ const nativeRead = observation.projection.nativeRead;
241
+ // A drift verdict computed for the manifest's path may only appear on a row
242
+ // that names that path. The divergence is reported wherever the manifest
243
+ // entry is what drives the row: an inactive, excluded, or natively read
244
+ // provider has no drift verdict to misattribute.
245
+ const trackedPath = observation.manifestEntry?.providerPath;
246
+ const pathDiverges = trackedPath !== undefined &&
247
+ trackedPath !== observation.projection.providerPath &&
248
+ isActive &&
249
+ !nativeRead &&
250
+ observation.projection.excludedReason === undefined;
251
+ const copyViewReadable = strategy === 'copy' &&
252
+ !nativeRead &&
253
+ (classification.viewClass === 'modified' ||
254
+ classification.viewClass === 'in-sync');
255
+ const projected = observation.projectedVersion;
256
+ const resolvedViewVersion = copyViewReadable
257
+ ? (projected?.version ?? null)
258
+ : null;
259
+ const versionState = projected?.state ?? 'absent';
260
+ // A declaration the resolver could not read, or one that contradicts
261
+ // itself while disagreeing with canonical, is not usable as evidence of
262
+ // divergence. Report no comparable version instead of a stale claim.
263
+ const untrustworthyVersion = versionState === 'unusable' ||
264
+ versionState === 'malformed' ||
265
+ (versionState === 'conflict' &&
266
+ resolvedViewVersion !== input.canonicalVersion);
267
+ const viewVersion = untrustworthyVersion ? null : resolvedViewVersion;
268
+ const versionComparable = copyViewReadable && viewVersion !== null;
269
+ // `detectDrift` compares a copy against the hash recorded at the last
270
+ // sync, so a copy that was never re-synced after a canonical edit matches
271
+ // its own manifest entry and reads as `in_sync`. Two different versions
272
+ // are proof the view is not the canonical content, whatever the manifest
273
+ // agrees with. `driftState` still reports exactly what the detector said.
274
+ const staleCopy = versionComparable &&
275
+ versionState === 'resolved' &&
276
+ classification.viewClass === 'in-sync' &&
277
+ input.canonicalVersion !== null &&
278
+ viewVersion !== null &&
279
+ viewVersion !== input.canonicalVersion;
280
+ // The mirror case: the detector reports a copy as drifted while its
281
+ // version matches canonical. That is the expected state after a
282
+ // copy-strategy sync, because the OAT-managed banner and `.oat-generated`
283
+ // sentinel are not accounted for in the manifest hash
284
+ // (BL-260908-make-copy-strategy-skill), and no sync clears it.
285
+ //
286
+ // Suppressing the repair here is a conservative heuristic, not a proof:
287
+ // equal versions do not establish equal bodies, so an edit to either side
288
+ // that kept the version reaches this branch too, and a sync would fix
289
+ // that one. The detail therefore says which case it cannot distinguish
290
+ // and names the concrete scope command, rather than claiming the content
291
+ // is current.
292
+ const unrepairableCopy = versionComparable &&
293
+ classification.viewClass === 'modified' &&
294
+ input.canonicalVersion !== null &&
295
+ viewVersion === input.canonicalVersion;
296
+ const { viewClass, detail } = staleCopy
297
+ ? {
298
+ viewClass: 'modified',
299
+ detail: `The tracked copy still matches the hash recorded at its last sync, but its version (${viewVersion}) differs from the canonical version (${input.canonicalVersion}), so the view is stale.`,
300
+ }
301
+ : unrepairableCopy
302
+ ? {
303
+ viewClass: 'modified',
304
+ detail: `The detector reports this copy as drifted while its version still matches canonical (${input.canonicalVersion}). That is expected after a copy-strategy sync until BL-260908-make-copy-strategy-skill lands: the OAT-managed banner and ".oat-generated" sentinel are not accounted for in the manifest hash, and no sync clears it. Equal versions do not prove the bodies match, so if either side was edited without a version change, compare them and run "oat sync --scope ${input.scope}" yourself.`,
305
+ }
306
+ : classification;
307
+ views.push({
308
+ skill: input.skillName,
309
+ scope: input.scope,
310
+ provider,
311
+ viewClass,
312
+ driftState: observation.drift?.state ?? null,
313
+ providerPath: pathDiverges
314
+ ? trackedPath
315
+ : observation.projection.providerPath,
316
+ ...(pathDiverges
317
+ ? { expectedProviderPath: observation.projection.providerPath }
318
+ : {}),
319
+ tracked: observation.manifestEntry !== null,
320
+ strategy,
321
+ nativeRead,
322
+ canonicalVersion: input.canonicalVersion,
323
+ viewVersion,
324
+ versionComparable,
325
+ ...(copyViewReadable ? { versionEvidence: versionState } : {}),
326
+ // The narrowest safe repair is the single concrete scope where the gap
327
+ // was observed. `--scope all` would widen a one-scope repair into a
328
+ // two-scope write.
329
+ suggestion: REPAIRABLE.includes(viewClass) && !unrepairableCopy
330
+ ? `oat sync --scope ${input.scope}`
331
+ : null,
332
+ detail: `${versionComparable ? detail : `${detail}${versionNote(strategy)}`}${copyViewReadable
333
+ ? projectedVersionNote(projected, untrustworthyVersion)
334
+ : ''}${pathDiverges ? divergentPathNote(observation) : ''}`,
335
+ });
336
+ }
337
+ return {
338
+ skill: input.skillName,
339
+ scope: input.scope,
340
+ result: 'diagnosed',
341
+ views,
342
+ };
343
+ }
@@ -2,6 +2,7 @@ export interface ValidationFinding {
2
2
  file: string;
3
3
  message: string;
4
4
  severity?: 'error' | 'warning';
5
+ code?: string;
5
6
  }
6
7
  export interface ValidateOatSkillsResult {
7
8
  validatedSkillCount: number;
@@ -1 +1 @@
1
- {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/validation/skills.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAChC;AAED,MAAM,WAAW,uBAAuB;IACtC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,uCAAuC;IACtD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sCAAsC;IACrD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;CAAE,KAChD,OAAO,CAAC,cAAc,CAAC,CAAC;AAE7B,UAAU,6BAA6B;IACrC,WAAW,CAAC,EAAE,YAAY,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AA04BD,wBAAsB,gCAAgC,CACpD,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,uCAAuC,EAChD,YAAY,GAAE,6BAAkC,GAC/C,OAAO,CAAC,sCAAsC,CAAC,CAoBjD;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,wBAA6B,EACtC,YAAY,GAAE,6BAAkC,GAC/C,OAAO,CAAC,uBAAuB,CAAC,CAqIlC"}
1
+ {"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/validation/skills.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,uBAAuB;IACtC,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,uCAAuC;IACtD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sCAAsC;IACrD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,QAAQ,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,CAAC,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;CAAE,KAChD,OAAO,CAAC,cAAc,CAAC,CAAC;AAE7B,UAAU,6BAA6B;IACrC,WAAW,CAAC,EAAE,YAAY,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAwhCD,wBAAsB,gCAAgC,CACpD,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,uCAAuC,EAChD,YAAY,GAAE,6BAAkC,GAC/C,OAAO,CAAC,sCAAsC,CAAC,CAoBjD;AAyED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,wBAA6B,EACtC,YAAY,GAAE,6BAAkC,GAC/C,OAAO,CAAC,uBAAuB,CAAC,CAyIlC"}
@@ -2,7 +2,7 @@ import { execFile as execFileCallback } from 'node:child_process';
2
2
  import { readdir, readFile, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { getFrontmatterBlock } from '../commands/shared/frontmatter.js';
5
+ import { getFrontmatterBlock, parseSkillFrontmatter, resolveSkillVersion, } from '../commands/shared/frontmatter.js';
6
6
  const execFileAsync = promisify(execFileCallback);
7
7
  async function isDirectory(path) {
8
8
  try {
@@ -459,6 +459,66 @@ function hasTrueFrontmatterValue(frontmatter, key) {
459
459
  return (frontmatterHasKey(frontmatter, key) &&
460
460
  getFrontmatterScalar(frontmatter, key) === 'true');
461
461
  }
462
+ function unreadableFrontmatterFinding(file) {
463
+ return {
464
+ file,
465
+ code: 'skill-frontmatter-unreadable',
466
+ severity: 'error',
467
+ message: 'Frontmatter must be a valid YAML mapping with unique keys (version could not be read)',
468
+ };
469
+ }
470
+ function pushUniqueFinding(findings, finding) {
471
+ const duplicate = findings.some((existing) => existing.file === finding.file &&
472
+ existing.code === finding.code &&
473
+ existing.message === finding.message);
474
+ if (!duplicate) {
475
+ findings.push(finding);
476
+ }
477
+ }
478
+ /**
479
+ * A `version` key is present but carries no usable value (empty, or a
480
+ * non-string scalar such as `1.10`, which YAML reads as the number 1.1).
481
+ *
482
+ * This is reported rather than skipped: the value the author wrote is a
483
+ * version OAT cannot read, and letting it resolve to nothing would silently
484
+ * disable both the semver check and bump enforcement for that skill.
485
+ */
486
+ function unusableVersionFinding(file) {
487
+ return {
488
+ file,
489
+ code: 'skill-version-unusable',
490
+ severity: 'error',
491
+ message: 'Frontmatter declares a version that cannot be read; use a quoted semver string (unquoted, version: 1.10 is the number 1.1)',
492
+ };
493
+ }
494
+ function uncomparableBaseFinding(file, baseRef, reason) {
495
+ return {
496
+ file,
497
+ code: reason === 'unreadable'
498
+ ? 'skill-frontmatter-unreadable'
499
+ : 'skill-version-unusable',
500
+ severity: 'error',
501
+ message: reason === 'unreadable'
502
+ ? `Changed canonical skill cannot be version-checked against ${baseRef}: the base frontmatter is not a valid YAML mapping with unique keys`
503
+ : `Changed canonical skill cannot be version-checked against ${baseRef}: the base frontmatter declares a version that cannot be read`,
504
+ };
505
+ }
506
+ function uncomparableBaseConflictFinding(file, baseRef, conflict) {
507
+ return {
508
+ file,
509
+ code: 'skill-version-conflict',
510
+ severity: 'error',
511
+ message: `Changed canonical skill cannot be version-checked against ${baseRef}: the base frontmatter metadata.version (${conflict.metadata}) and top-level version (${conflict.topLevel}) differ`,
512
+ };
513
+ }
514
+ function versionConflictFinding(file, conflict) {
515
+ return {
516
+ file,
517
+ code: 'skill-version-conflict',
518
+ severity: 'error',
519
+ message: `Frontmatter metadata.version (${conflict.metadata}) and top-level version (${conflict.topLevel}) differ; a conflicting skill has no resolvable version`,
520
+ };
521
+ }
462
522
  function isValidSemver(value) {
463
523
  return /^\d+\.\d+\.\d+$/.test(value);
464
524
  }
@@ -597,14 +657,52 @@ async function collectChangedSkillVersionBumpFindings(repoRoot, baseRef, changed
597
657
  if (baseContent === null || baseContent === currentContent) {
598
658
  continue;
599
659
  }
600
- const currentFrontmatter = getFrontmatterBlock(currentContent);
601
- const baseFrontmatter = getFrontmatterBlock(baseContent);
602
- const currentVersion = currentFrontmatter
603
- ? getFrontmatterScalar(currentFrontmatter, 'version')
604
- : null;
605
- const baseVersion = baseFrontmatter
606
- ? getFrontmatterScalar(baseFrontmatter, 'version')
607
- : null;
660
+ const currentBlock = getFrontmatterBlock(currentContent);
661
+ const parsedCurrent = currentBlock === null ? null : parseSkillFrontmatter(currentBlock);
662
+ const baseBlock = getFrontmatterBlock(baseContent);
663
+ const parsedBase = baseBlock === null ? null : parseSkillFrontmatter(baseBlock);
664
+ // Frontmatter that carries no readable version must never fall through to
665
+ // the `continue` below: a value the resolver cannot read would otherwise
666
+ // disable bump enforcement for that skill entirely. Both sides are
667
+ // guarded, because either one being uncomparable defeats the comparison.
668
+ // `validateOatSkills` runs the structural version-source pass before this
669
+ // collector, so the same file can already carry the current-side finding.
670
+ if (parsedCurrent?.malformed) {
671
+ pushUniqueFinding(findings, unreadableFrontmatterFinding(skillPath));
672
+ continue;
673
+ }
674
+ if (parsedCurrent?.unusableVersionDeclaration) {
675
+ pushUniqueFinding(findings, unusableVersionFinding(skillPath));
676
+ continue;
677
+ }
678
+ if (parsedBase?.malformed) {
679
+ findings.push(uncomparableBaseFinding(skillPath, baseRef, 'unreadable'));
680
+ continue;
681
+ }
682
+ if (parsedBase?.unusableVersionDeclaration) {
683
+ findings.push(uncomparableBaseFinding(skillPath, baseRef, 'unusable'));
684
+ continue;
685
+ }
686
+ const resolvedCurrent = parsedCurrent === null ? null : resolveSkillVersion(parsedCurrent);
687
+ const resolvedBase = parsedBase === null ? null : resolveSkillVersion(parsedBase);
688
+ // A conflicting skill has no resolvable version, so it cannot be bump
689
+ // checked. This is the only version-source finding the bump validator
690
+ // emits: `validate-skill-version-bumps.ts` fails the gate on any finding
691
+ // regardless of severity, so the top-level alias deprecation warning stays
692
+ // in structural validation and never reaches this result.
693
+ if (resolvedCurrent?.conflict) {
694
+ findings.push(versionConflictFinding(skillPath, resolvedCurrent.conflict));
695
+ continue;
696
+ }
697
+ // A conflicting base has no single version to compare against either:
698
+ // silently taking its metadata side would accept a downgrade relative to
699
+ // the top-level version the base also declares.
700
+ if (resolvedBase?.conflict) {
701
+ findings.push(uncomparableBaseConflictFinding(skillPath, baseRef, resolvedBase.conflict));
702
+ continue;
703
+ }
704
+ const currentVersion = resolvedCurrent?.version ?? null;
705
+ const baseVersion = resolvedBase?.version ?? null;
608
706
  if (!currentVersion || !baseVersion) {
609
707
  continue;
610
708
  }
@@ -634,6 +732,67 @@ export async function validateChangedSkillVersionBumps(repoRoot, options, depend
634
732
  findings,
635
733
  };
636
734
  }
735
+ /**
736
+ * Report where each bundled skill keeps its version.
737
+ *
738
+ * This pass deliberately iterates every skill directory, not just `oat-*`:
739
+ * the top-level `version` alias is deprecated across the whole bundle, so a
740
+ * pass that inherited the `oat-*` filter used by the other structural checks
741
+ * could never fire for the 18 non-`oat-*` skills. `skill-version-alias` is a
742
+ * warning (`validate-oat-skills.ts` treats warnings as non-blocking) and
743
+ * `skill-version-conflict` is an error. Neither finding is produced by the
744
+ * bump validator, whose wrapper fails on any finding at all.
745
+ */
746
+ async function collectSkillVersionSourceFindings(skillsRoot, skillDirs, findings) {
747
+ for (const dir of skillDirs) {
748
+ const skillPath = join(skillsRoot, dir, 'SKILL.md');
749
+ let content;
750
+ try {
751
+ content = await readFile(skillPath, 'utf8');
752
+ }
753
+ catch {
754
+ // A missing SKILL.md is reported by the structural pass for oat-*
755
+ // skills and is not this pass's concern.
756
+ continue;
757
+ }
758
+ const block = getFrontmatterBlock(content);
759
+ if (block === null) {
760
+ continue;
761
+ }
762
+ const parsed = parseSkillFrontmatter(block);
763
+ if (parsed.malformed) {
764
+ // Reported for every skill, not just `oat-*` ones: unreadable
765
+ // frontmatter is exactly the state that would otherwise make a version
766
+ // silently unreadable everywhere.
767
+ findings.push(unreadableFrontmatterFinding(skillPath));
768
+ continue;
769
+ }
770
+ // Declaration usability is checked here, before the unresolved bail, so it
771
+ // covers every bundled skill. Inside the `oat-*` loop it could never fire
772
+ // for the 18 skills that are not named `oat-*`, which is how an unusable
773
+ // declaration became invisible to validation.
774
+ if (parsed.unusableVersionDeclaration) {
775
+ findings.push(unusableVersionFinding(skillPath));
776
+ continue;
777
+ }
778
+ const resolved = resolveSkillVersion(parsed);
779
+ if (!resolved) {
780
+ continue;
781
+ }
782
+ if (resolved.conflict) {
783
+ findings.push(versionConflictFinding(skillPath, resolved.conflict));
784
+ continue;
785
+ }
786
+ if (resolved.source === 'top-level') {
787
+ findings.push({
788
+ file: skillPath,
789
+ code: 'skill-version-alias',
790
+ severity: 'warning',
791
+ message: `Frontmatter version ${resolved.version} uses the deprecated top-level alias; move it to metadata.version (metadata.version wins when both are present)`,
792
+ });
793
+ }
794
+ }
795
+ }
637
796
  export async function validateOatSkills(repoRoot, options = {}, dependencies = {}) {
638
797
  const skillsRoot = join(repoRoot, '.agents', 'skills');
639
798
  const findings = [];
@@ -641,10 +800,11 @@ export async function validateOatSkills(repoRoot, options = {}, dependencies = {
641
800
  throw new Error(`skills directory not found: ${skillsRoot}`);
642
801
  }
643
802
  const entries = await readdir(skillsRoot, { withFileTypes: true });
644
- const oatSkillDirs = entries
645
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('oat-'))
803
+ const allSkillDirs = entries
804
+ .filter((entry) => entry.isDirectory())
646
805
  .map((entry) => entry.name)
647
806
  .sort();
807
+ const oatSkillDirs = allSkillDirs.filter((name) => name.startsWith('oat-'));
648
808
  for (const dir of oatSkillDirs) {
649
809
  const skillPath = join(skillsRoot, dir, 'SKILL.md');
650
810
  let content;
@@ -707,14 +867,15 @@ export async function validateOatSkills(repoRoot, options = {}, dependencies = {
707
867
  }
708
868
  }
709
869
  }
710
- if (frontmatterHasKey(fm, 'version')) {
711
- const version = getFrontmatterScalar(fm, 'version') ?? '';
712
- if (!isValidSemver(version)) {
713
- findings.push({
714
- file: skillPath,
715
- message: 'Frontmatter version must be valid semver (e.g., 1.0.0)',
716
- });
717
- }
870
+ // Malformed frontmatter and unusable version declarations are reported
871
+ // once, for every skill, by collectSkillVersionSourceFindings; this is the
872
+ // `oat-*` structural rule for a version that does resolve.
873
+ const resolvedVersion = resolveSkillVersion(parseSkillFrontmatter(fm));
874
+ if (resolvedVersion !== null && !isValidSemver(resolvedVersion.version)) {
875
+ findings.push({
876
+ file: skillPath,
877
+ message: 'Frontmatter version must be valid semver (e.g., 1.0.0)',
878
+ });
718
879
  }
719
880
  if (!hasProgressIndicatorsSection(content)) {
720
881
  findings.push({
@@ -732,6 +893,7 @@ export async function validateOatSkills(repoRoot, options = {}, dependencies = {
732
893
  validateQuickStartSemantics(skillPath, content, findings);
733
894
  }
734
895
  }
896
+ await collectSkillVersionSourceFindings(skillsRoot, allSkillDirs, findings);
735
897
  await collectSyncedSafetyFindings(repoRoot, oatSkillDirs, findings);
736
898
  await collectGateabilityFindings(skillsRoot, options.gateSkillNames, findings);
737
899
  if (options.baseRef) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-agent-toolkit/cli",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
4
4
  "private": false,
5
5
  "description": "Open Agent Toolkit CLI",
6
6
  "homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/cli",
@@ -34,7 +34,7 @@
34
34
  "ora": "^9.0.0",
35
35
  "yaml": "2.8.2",
36
36
  "zod": "^3.25.76",
37
- "@open-agent-toolkit/control-plane": "0.2.63"
37
+ "@open-agent-toolkit/control-plane": "0.2.65"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^22.10.0",