@open-agent-toolkit/cli 0.2.66 → 0.2.67

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 (107) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +14 -1
  3. package/assets/docs/cli-utilities/project-log.md +63 -4
  4. package/assets/docs/contributing/code.md +8 -4
  5. package/assets/docs/contributing/skills.md +29 -17
  6. package/assets/docs/docs-tooling/commands.md +3 -1
  7. package/assets/docs/provider-sync/manifest-and-drift.md +2 -18
  8. package/assets/docs/workflows/projects/lifecycle.md +17 -0
  9. package/assets/docs/workflows/projects/picking-up-projects.md +18 -0
  10. package/assets/docs/workflows/skills/repo-improve.md +7 -3
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/create-agnostic-skill/SKILL.md +19 -37
  13. package/assets/skills/create-agnostic-skill/references/docs/skills-guide.md +58 -15
  14. package/assets/skills/create-agnostic-skill/references/skill-template.md +17 -15
  15. package/assets/skills/oat-agent-instructions-apply/SKILL.md +1 -1
  16. package/assets/skills/oat-agent-instructions-apply/references/instruction-file-templates/glob-scoped-rule.md +1 -4
  17. package/assets/skills/oat-doctor/SKILL.md +1 -4
  18. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  19. package/assets/skills/oat-project-complete/SKILL.md +158 -30
  20. package/assets/skills/oat-project-complete/scripts/finalize-synced-archive.mjs +26 -7
  21. package/assets/skills/oat-project-complete/scripts/validate-durable-archive-receipt.mjs +183 -0
  22. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  23. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  24. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
  25. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  26. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  27. package/assets/skills/oat-project-retro/SKILL.md +1 -1
  28. package/assets/skills/oat-project-retro/references/apply-procedure.md +11 -0
  29. package/assets/skills/oat-project-review-provide/SKILL.md +4 -4
  30. package/assets/skills/oat-project-summary/SKILL.md +24 -2
  31. package/assets/skills/oat-repo-improve/SKILL.md +9 -2
  32. package/assets/skills/oat-repo-improve/references/plan-template.md +4 -6
  33. package/assets/skills/oat-repo-knowledge-index/SKILL.md +11 -9
  34. package/assets/skills/oat-wave-execute/SKILL.md +20 -6
  35. package/assets/skills/oat-wave-program/SKILL.md +9 -4
  36. package/dist/commands/config/index.d.ts +2 -1
  37. package/dist/commands/config/index.d.ts.map +1 -1
  38. package/dist/commands/config/index.js +133 -38
  39. package/dist/commands/docs/index-generate/index.d.ts.map +1 -1
  40. package/dist/commands/docs/index-generate/index.js +68 -12
  41. package/dist/commands/docs/init/index.d.ts +11 -0
  42. package/dist/commands/docs/init/index.d.ts.map +1 -1
  43. package/dist/commands/docs/init/index.js +19 -1
  44. package/dist/commands/gate/index.d.ts.map +1 -1
  45. package/dist/commands/gate/index.js +10 -2
  46. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  47. package/dist/commands/instructions/instructions.utils.js +42 -9
  48. package/dist/commands/internal/validate-skill-version-bumps.d.ts.map +1 -1
  49. package/dist/commands/internal/validate-skill-version-bumps.js +5 -5
  50. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  51. package/dist/commands/project/dispatch-ceiling/index.js +32 -18
  52. package/dist/commands/project/log/append.d.ts +48 -0
  53. package/dist/commands/project/log/append.d.ts.map +1 -1
  54. package/dist/commands/project/log/append.js +207 -30
  55. package/dist/commands/project/log/check.d.ts +87 -1
  56. package/dist/commands/project/log/check.d.ts.map +1 -1
  57. package/dist/commands/project/log/check.js +139 -5
  58. package/dist/commands/project/log/grammar.d.ts +165 -0
  59. package/dist/commands/project/log/grammar.d.ts.map +1 -1
  60. package/dist/commands/project/log/grammar.js +219 -1
  61. package/dist/commands/project/log/rollup.d.ts +16 -1
  62. package/dist/commands/project/log/rollup.d.ts.map +1 -1
  63. package/dist/commands/project/log/rollup.js +22 -2
  64. package/dist/commands/project/log/synthesize.d.ts +16 -0
  65. package/dist/commands/project/log/synthesize.d.ts.map +1 -1
  66. package/dist/commands/project/log/synthesize.js +85 -9
  67. package/dist/commands/status/index.d.ts.map +1 -1
  68. package/dist/commands/status/index.js +24 -2
  69. package/dist/commands/sync/apply.d.ts.map +1 -1
  70. package/dist/commands/sync/apply.js +6 -4
  71. package/dist/config/dispatch-matrix.d.ts.map +1 -1
  72. package/dist/config/dispatch-matrix.js +13 -5
  73. package/dist/config/oat-config.d.ts +15 -0
  74. package/dist/config/oat-config.d.ts.map +1 -1
  75. package/dist/config/oat-config.js +61 -7
  76. package/dist/config/own-keys.d.ts +22 -0
  77. package/dist/config/own-keys.d.ts.map +1 -0
  78. package/dist/config/own-keys.js +32 -0
  79. package/dist/config/resolve.d.ts +11 -0
  80. package/dist/config/resolve.d.ts.map +1 -1
  81. package/dist/config/resolve.js +35 -11
  82. package/dist/config/sync-config.d.ts.map +1 -1
  83. package/dist/config/sync-config.js +16 -0
  84. package/dist/drift/detector.d.ts.map +1 -1
  85. package/dist/drift/detector.js +52 -1
  86. package/dist/drift/skill-view-diagnostic.d.ts.map +1 -1
  87. package/dist/drift/skill-view-diagnostic.js +2 -23
  88. package/dist/engine/compute-plan.d.ts.map +1 -1
  89. package/dist/engine/compute-plan.js +22 -72
  90. package/dist/engine/managed-copy-hash.d.ts +69 -0
  91. package/dist/engine/managed-copy-hash.d.ts.map +1 -0
  92. package/dist/engine/managed-copy-hash.js +175 -0
  93. package/dist/fs/assets.d.ts +11 -0
  94. package/dist/fs/assets.d.ts.map +1 -1
  95. package/dist/fs/assets.js +1 -1
  96. package/dist/manifest/hash.d.ts +110 -0
  97. package/dist/manifest/hash.d.ts.map +1 -1
  98. package/dist/manifest/hash.js +111 -13
  99. package/dist/providers/ceiling/registry.d.ts.map +1 -1
  100. package/dist/providers/ceiling/registry.js +6 -1
  101. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  102. package/dist/providers/identity/dispatch-report.js +4 -1
  103. package/dist/release/public-package-contract.d.ts.map +1 -1
  104. package/dist/release/public-package-contract.js +21 -0
  105. package/dist/validation/skills.d.ts.map +1 -1
  106. package/dist/validation/skills.js +305 -40
  107. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { isWorkflowDispatchCandidateLadder } from './dispatch-matrix.js';
2
2
  import { BUILTIN_EXEC_TARGETS, isWorkflowPostImplementStructuredSequence, readOatConfig, readOatLocalConfig, readUserConfig, } from './oat-config.js';
3
+ import { getOwnKey, setOwnKey } from './own-keys.js';
3
4
  const DEFAULT_DEPENDENCIES = {
4
5
  readOatConfig,
5
6
  readOatLocalConfig,
@@ -38,6 +39,11 @@ const DEFAULT_SHARED_CONFIG = {
38
39
  tooling: null,
39
40
  config: null,
40
41
  index: null,
42
+ // `oat config dump` enumerates the resolved map, so a catalogued key that
43
+ // is absent here has no row at all while it is unset. `null` keeps meaning
44
+ // "not set at this layer", so a configured array still wins.
45
+ excludes: null,
46
+ instructionPointerExcludes: null,
41
47
  requireForProjectCompletion: false,
42
48
  },
43
49
  tools: {
@@ -229,27 +235,33 @@ export function resolveExecTargetViews(effective) {
229
235
  }
230
236
  for (const [id, override] of Object.entries(layer)) {
231
237
  if (override === null) {
232
- const target = targets[id] ?? views[id]?.target;
238
+ // Exec-target ids come from user config, which can now carry a key
239
+ // named `__proto__` as an own key, so every lookup here is own-key
240
+ // guarded. `delete` needs no guard: it removes an own key and is a
241
+ // no-op otherwise.
242
+ const target = getOwnKey(targets, id) ?? getOwnKey(views, id)?.target;
233
243
  delete targets[id];
234
244
  if (target) {
235
- views[id] = {
245
+ setOwnKey(views, id, {
236
246
  target: cloneExecTarget(target),
237
247
  origin,
238
248
  explicitlyConfigured: true,
239
249
  enabled: false,
240
- };
250
+ });
241
251
  }
242
252
  continue;
243
253
  }
254
+ // The one-entry layer uses a computed key in an object literal, which
255
+ // is define semantics and already safe.
244
256
  mergeExecTargetLayer(targets, { [id]: override });
245
- const target = targets[id];
257
+ const target = getOwnKey(targets, id);
246
258
  if (target) {
247
- views[id] = {
259
+ setOwnKey(views, id, {
248
260
  target: cloneExecTarget(target),
249
261
  origin,
250
262
  explicitlyConfigured: true,
251
263
  enabled: true,
252
- };
264
+ });
253
265
  }
254
266
  }
255
267
  }
@@ -261,12 +273,14 @@ function mergeExecTargetLayer(targets, layer) {
261
273
  }
262
274
  for (const [id, override] of Object.entries(layer)) {
263
275
  if (override === null) {
276
+ // `delete` removes an own key and is a no-op otherwise, so it is already
277
+ // correct for a user-supplied id such as `__proto__`.
264
278
  delete targets[id];
265
279
  continue;
266
280
  }
267
- const existing = targets[id];
281
+ const existing = getOwnKey(targets, id);
268
282
  if (existing) {
269
- targets[id] = cloneExecTarget({
283
+ setOwnKey(targets, id, cloneExecTarget({
270
284
  runtime: override.runtime ?? existing.runtime,
271
285
  baseCommand: override.baseCommand ?? existing.baseCommand,
272
286
  invocation: mergeExecTargetInvocation(existing.invocation, override.invocation),
@@ -275,12 +289,12 @@ function mergeExecTargetLayer(targets, layer) {
275
289
  availabilityCommand: override.availabilityCommand ?? existing.availabilityCommand,
276
290
  priority: override.priority ?? existing.priority,
277
291
  timeoutMs: override.timeoutMs ?? existing.timeoutMs,
278
- });
292
+ }));
279
293
  continue;
280
294
  }
281
295
  const completeTarget = toCompleteExecTarget(override);
282
296
  if (completeTarget) {
283
- targets[id] = completeTarget;
297
+ setOwnKey(targets, id, completeTarget);
284
298
  }
285
299
  }
286
300
  }
@@ -388,7 +402,17 @@ function isAtomicConfigLeaf(key, value) {
388
402
  (key === 'workflow.postImplementSequence' &&
389
403
  isWorkflowPostImplementStructuredSequence(value)));
390
404
  }
391
- function resolveEnvOverride(key, env) {
405
+ /**
406
+ * Report the environment override for `key`, if one is live.
407
+ *
408
+ * Exported because `oat config unset` needs the answer to "is this key
409
+ * environment-overridden?" without performing a whole-config read, which a
410
+ * malformed stored value aborts -- and a malformed stored value is exactly what
411
+ * `unset` exists to remove. `ENV_OVERRIDE_MAP` plus the env-first branch of
412
+ * `resolveEffectiveConfig` is the single source of that answer, so callers that
413
+ * only need the boolean must not resolve the whole config to derive it.
414
+ */
415
+ export function resolveEnvOverride(key, env) {
392
416
  const envKey = ENV_OVERRIDE_MAP[key];
393
417
  if (!envKey) {
394
418
  return undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"sync-config.d.ts","sourceRoot":"","sources":["../../src/config/sync-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,QAAA,MAAM,oBAAoB;;;;;;;;;EAGxB,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK3B,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEtE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG;IAC1D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,UAKjC,CAAC;AAkBF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAW7E;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACnC,MAAM,EAAE,CAMV;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,QAAQ,EAAE,aAAa,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,GACxD,MAAM,EAAE,CAEV;AAqCD,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,QAAQ,GAAE,UAAgC,GACzC,OAAO,CAAC,UAAU,CAAC,CA6BrB;AAED,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,UAAU,CAAC,CAWrB;AAED,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,UAAU,CAAC,CAcrB;AAED,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAcrB"}
1
+ {"version":3,"file":"sync-config.d.ts","sourceRoot":"","sources":["../../src/config/sync-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,QAAA,MAAM,oBAAoB;;;;;;;;;EAGxB,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAK3B,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAEtE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,GAAG;IAC1D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,UAKjC,CAAC;AAkCF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAW7E;AAED,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACnC,MAAM,EAAE,CAMV;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,QAAQ,EAAE,aAAa,CAAC,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,GACxD,MAAM,EAAE,CAEV;AAqCD,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,QAAQ,GAAE,UAAgC,GACzC,OAAO,CAAC,UAAU,CAAC,CA6BrB;AAED,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,UAAU,GACjB,OAAO,CAAC,UAAU,CAAC,CAWrB;AAED,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,UAAU,CAAC,CAcrB;AAED,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAcrB"}
@@ -21,6 +21,22 @@ export const DEFAULT_SYNC_CONFIG = {
21
21
  knownStrays: [],
22
22
  providers: {},
23
23
  };
24
+ /**
25
+ * Provider names reaching this family are guarded twice, so no own-key access
26
+ * is needed here even though `config/json.ts` preserves a key literally named
27
+ * `__proto__` as an own data property.
28
+ *
29
+ * 1. `SyncConfigSchema.providers` is a `z.record`, which drops that key during
30
+ * parsing, so it never reaches `merged[name]` below.
31
+ * 2. `commands/providers/set/index.ts` rejects any provider name outside
32
+ * `knownProviders`, and the init and sync commands iterate adapter names
33
+ * and detection results rather than raw user strings.
34
+ *
35
+ * Guard 1 is pinned by the `drops a \`__proto__\` provider during zod record
36
+ * parsing` case in `sync-config.test.ts`. If a future zod upgrade stops
37
+ * stripping the key that test fails, and this classification must be re-opened
38
+ * rather than assumed.
39
+ */
24
40
  function mergeProviderConfigs(base, override) {
25
41
  const merged = { ...base };
26
42
  for (const [name, config] of Object.entries(override)) {
@@ -1 +1 @@
1
- {"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/drift/detector.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,MAAM,WAAW,aAAa;IAC5B,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC;CACxE;AAcD,wBAAsB,WAAW,CAC/B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,WAAW,CAAC,CAoGtB"}
1
+ {"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/drift/detector.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD,MAAM,WAAW,aAAa;IAC5B,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,KAAK,MAAM,CAAC;CACxE;AAcD,wBAAsB,WAAW,CAC/B,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,MAAM,EACjB,aAAa,CAAC,EAAE,aAAa,GAC5B,OAAO,CAAC,WAAW,CAAC,CAgKtB"}
@@ -1,7 +1,8 @@
1
1
  import { lstat, readFile, readlink, stat } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { proveCollectionIdentity } from '../engine/collection-sync.js';
4
- import { computeContentHash, computeStringHash } from '../manifest/hash.js';
4
+ import { computeManagedDirectoryCopyHash } from '../engine/managed-copy-hash.js';
5
+ import { computeContentHash, computeDirectoryDigests, computeStringHash, } from '../manifest/hash.js';
5
6
  function createReport(entry, state) {
6
7
  return {
7
8
  canonical: entry.canonicalPath,
@@ -80,6 +81,56 @@ export async function detectDrift(entry, scopeRoot, copyTransform) {
80
81
  if (entry.contentHash === currentHash) {
81
82
  return createReport(entry, { status: 'in_sync' });
82
83
  }
84
+ // A directory copy is decorated by the writer: `applyCopyMarker` adds the
85
+ // `.oat-generated` sentinel and prepends the banner to SKILL.md/AGENT.md,
86
+ // while the manifest records the undecorated canonical hash. Re-ask the
87
+ // question with those two artifacts excluded, so a faithful copy reads
88
+ // `in_sync` instead of permanently `modified`.
89
+ //
90
+ // Deliberately *not* gated on `copyTransform`: the banner and sentinel are
91
+ // engine-owned rather than adapter-owned, and `commands/tools/info/index.ts`
92
+ // passes no transform. Gating here would leave `oat tools info` disagreeing
93
+ // with `oat status`. A `null` result — no sentinel, a sentinel naming a
94
+ // different canonical path, a marker file that is absent or does not carry
95
+ // the banner, a non-regular entry — falls through to the raw verdict below,
96
+ // as does any other digest, so a tampered or unverifiable copy is still
97
+ // `drifted`.
98
+ if (!entry.isFile && entry.contentHash !== null) {
99
+ const managedHash = await computeManagedDirectoryCopyHash(providerPath, canonicalPath, entry.contentType);
100
+ if (managedHash !== null) {
101
+ if (managedHash === entry.contentHash) {
102
+ return createReport(entry, { status: 'in_sync' });
103
+ }
104
+ // Pre-framing manifest bridge (wave-7 final review, Critical 4).
105
+ // Length framing changed every directory digest, and a no-op `oat sync`
106
+ // does not restamp an entry it already owns (`ensureSkipEntryManaged` in
107
+ // `engine/execute-plan.ts` returns the manifest untouched for `skip`), so
108
+ // a manifest written before the change keeps its legacy value
109
+ // indefinitely. Without this branch every pre-existing copy-strategy
110
+ // install would report drift that no command repairs.
111
+ //
112
+ // This cannot reopen the collision. It fires only when the recorded hash
113
+ // is the legacy digest of the canonical tree, and acceptance still rests
114
+ // entirely on the framed digests: framing is injective, so
115
+ // `managedHash === framed` means the copy's logical content is exactly
116
+ // the canonical file set, which the legacy digests therefore also agreed
117
+ // on. Every input accepted here was accepted before the change too, so
118
+ // the branch cannot widen the acceptance set. That argument needs both
119
+ // digests to describe one canonical state, which is why
120
+ // `computeDirectoryDigests` folds them from a single capture rather than
121
+ // from two traversals — pairing `framed` from one state with `legacy`
122
+ // from another would accept a view the pre-framing detector rejected.
123
+ //
124
+ // Any failure to read the canonical tree is treated as "cannot verify",
125
+ // which falls through to `drifted` below.
126
+ const canonicalDigests = await computeDirectoryDigests(canonicalPath).catch(() => null);
127
+ if (canonicalDigests !== null &&
128
+ entry.contentHash === canonicalDigests.legacy &&
129
+ managedHash === canonicalDigests.framed) {
130
+ return createReport(entry, { status: 'in_sync' });
131
+ }
132
+ }
133
+ }
83
134
  // When the manifest hash is stale (e.g. frontmatter-only edits to the
84
135
  // canonical source that don't change the rendered output), re-derive the
85
136
  // expected provider content from the current canonical + transform and
@@ -1 +1 @@
1
- {"version":3,"file":"skill-view-diagnostic.d.ts","sourceRoot":"","sources":["../../src/drift/skill-view-diagnostic.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"skill-view-diagnostic.d.ts","sourceRoot":"","sources":["../../src/drift/skill-view-diagnostic.ts"],"names":[],"mappings":"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"}
@@ -277,33 +277,12 @@ export function diagnoseSkillViews(input) {
277
277
  input.canonicalVersion !== null &&
278
278
  viewVersion !== null &&
279
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
280
  const { viewClass, detail } = staleCopy
297
281
  ? {
298
282
  viewClass: 'modified',
299
283
  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
284
  }
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;
285
+ : classification;
307
286
  views.push({
308
287
  skill: input.skillName,
309
288
  scope: input.scope,
@@ -326,7 +305,7 @@ export function diagnoseSkillViews(input) {
326
305
  // The narrowest safe repair is the single concrete scope where the gap
327
306
  // was observed. `--scope all` would widen a one-scope repair into a
328
307
  // two-scope write.
329
- suggestion: REPAIRABLE.includes(viewClass) && !unrepairableCopy
308
+ suggestion: REPAIRABLE.includes(viewClass)
330
309
  ? `oat sync --scope ${input.scope}`
331
310
  : null,
332
311
  detail: `${versionComparable ? detail : `${detail}${versionNote(strategy)}`}${copyViewReadable
@@ -1 +1 @@
1
- {"version":3,"file":"compute-plan.d.ts","sourceRoot":"","sources":["../../src/engine/compute-plan.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAGtD,OAAO,KAAK,EACV,QAAQ,EACR,aAAa,EAGd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAEV,eAAe,EAChB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,OAAO,KAAK,EAEV,WAAW,EACX,oBAAoB,EACpB,QAAQ,EAET,MAAM,gBAAgB,CAAC;AAGxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,UAAU,mBAAmB;IAC3B,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,sCAAsC,CAAC,EAAE,QAAQ,CAC/C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAClC,CAAC;IACF,+BAA+B,CAAC,EAAE,SAAS;QACzC,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,WAAW,CAAC;KAC1B,EAAE,CAAC;IACJ,gCAAgC,CAAC,EAAE,OAAO,CAAC;CAC5C;AAqTD,wBAAsB,iCAAiC,CACrD,aAAa,EAAE,aAAa,EAC5B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,CAAC,CAqG/B;AAiID,wBAAsB,eAAe,CAAC,EACpC,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,MAAM,EACN,SAAS,EAAE,iBAAiB,EAC5B,qBAAqB,EACrB,sCAAsC,EACtC,+BAAoC,EACpC,gCAAwC,GACzC,EAAE,mBAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4bzC"}
1
+ {"version":3,"file":"compute-plan.d.ts","sourceRoot":"","sources":["../../src/engine/compute-plan.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAGtD,OAAO,KAAK,EACV,QAAQ,EACR,aAAa,EAGd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAEV,eAAe,EAChB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAGjD,OAAO,KAAK,EAEV,WAAW,EACX,oBAAoB,EACpB,QAAQ,EAET,MAAM,gBAAgB,CAAC;AAGxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD,UAAU,mBAAmB;IAC3B,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,QAAQ,CAAC;IACnB,KAAK,EAAE,WAAW,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qBAAqB,CAAC,EAAE,MAAM,EAAE,CAAC;IACjC,sCAAsC,CAAC,EAAE,QAAQ,CAC/C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAClC,CAAC;IACF,+BAA+B,CAAC,EAAE,SAAS;QACzC,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,WAAW,CAAC;KAC1B,EAAE,CAAC;IACJ,gCAAgC,CAAC,EAAE,OAAO,CAAC;CAC5C;AAiOD,wBAAsB,iCAAiC,CACrD,aAAa,EAAE,aAAa,EAC5B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,CAAC,CAqG/B;AAyJD,wBAAsB,eAAe,CAAC,EACpC,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,MAAM,EACN,SAAS,EAAE,iBAAiB,EAC5B,qBAAqB,EACrB,sCAAsC,EACtC,+BAAoC,EACpC,gCAAwC,GACzC,EAAE,mBAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA4bzC"}
@@ -1,11 +1,10 @@
1
- import { createHash } from 'node:crypto';
2
- import { access, lstat, readdir, readFile, readlink, stat, } from 'node:fs/promises';
1
+ import { access, lstat, readFile, readlink, stat } from 'node:fs/promises';
3
2
  import { basename, dirname, join, normalize, relative, resolve, } from 'node:path';
4
3
  import { computeContentHash, computeStringHash } from '../manifest/hash.js';
5
4
  import { findEntry } from '../manifest/manager.js';
6
5
  import { getSyncMappings } from '../providers/shared/adapter.utils.js';
7
6
  import { proveCollectionIdentity } from './collection-sync.js';
8
- import { OAT_DIRECTORY_SENTINEL, OAT_MARKER_PREFIX } from './markers.js';
7
+ import { computeManagedDirectoryCopyHash } from './managed-copy-hash.js';
9
8
  import { assertSafeProviderMutationPath } from './provider-path-safety.js';
10
9
  function buildUpLevels(depth) {
11
10
  return Array.from({ length: depth }, () => '..');
@@ -150,75 +149,6 @@ function manifestEntryInsideMapping(entry, mapping) {
150
149
  return (canonicalPath === canonicalDir ||
151
150
  canonicalPath.startsWith(`${canonicalDir}/`));
152
151
  }
153
- async function computeManagedDirectoryCopyHash(providerPath, canonicalPath, contentType) {
154
- const expectedMarker = `${OAT_MARKER_PREFIX} Source: ${canonicalPath} -->`;
155
- const sentinelPath = join(providerPath, OAT_DIRECTORY_SENTINEL);
156
- try {
157
- const sentinel = await readFile(sentinelPath, 'utf8');
158
- if (sentinel !== `${expectedMarker}\n`) {
159
- return null;
160
- }
161
- }
162
- catch {
163
- return null;
164
- }
165
- const files = [];
166
- async function collectFiles(current) {
167
- const entries = await readdir(current, { withFileTypes: true });
168
- for (const entry of entries) {
169
- const fullPath = join(current, entry.name);
170
- if (fullPath === sentinelPath) {
171
- continue;
172
- }
173
- if (entry.isDirectory()) {
174
- if (!(await collectFiles(fullPath))) {
175
- return false;
176
- }
177
- continue;
178
- }
179
- if (!entry.isFile()) {
180
- return false;
181
- }
182
- files.push(fullPath);
183
- }
184
- return true;
185
- }
186
- try {
187
- if (!(await collectFiles(providerPath))) {
188
- return null;
189
- }
190
- files.sort((left, right) => relative(providerPath, left).localeCompare(relative(providerPath, right)));
191
- const markerFileName = contentType === 'skill'
192
- ? 'SKILL.md'
193
- : contentType === 'agent'
194
- ? 'AGENT.md'
195
- : null;
196
- const markerPath = markerFileName
197
- ? join(providerPath, markerFileName)
198
- : null;
199
- const hash = createHash('sha256');
200
- for (const file of files) {
201
- const relativePath = relative(providerPath, file);
202
- let content = await readFile(file);
203
- if (file === markerPath) {
204
- const markerPrefix = Buffer.from(`${expectedMarker}\n`);
205
- if (content.length < markerPrefix.length ||
206
- !content.subarray(0, markerPrefix.length).equals(markerPrefix)) {
207
- return null;
208
- }
209
- content = content.subarray(markerPrefix.length);
210
- }
211
- hash.update(relativePath);
212
- hash.update('\0');
213
- hash.update(content);
214
- hash.update('\0');
215
- }
216
- return hash.digest('hex');
217
- }
218
- catch {
219
- return null;
220
- }
221
- }
222
152
  export async function classifyObsoleteMappingRetirement(manifestEntry, scopeRoot) {
223
153
  const providerPath = resolve(scopeRoot, manifestEntry.providerPath);
224
154
  const canonicalPath = resolve(scopeRoot, manifestEntry.canonicalPath);
@@ -335,6 +265,26 @@ async function classifyOperation(canonicalEntry, providerPath, strategy, rendere
335
265
  reason: 'already in sync',
336
266
  };
337
267
  }
268
+ // A directory copy is decorated by the writer: `applyCopyMarker` adds the
269
+ // `.oat-generated` sentinel and prepends the banner to SKILL.md/AGENT.md, so
270
+ // the raw hashes above can never agree and every re-plan produced another
271
+ // `update_copy`. Re-ask the question with those two artifacts excluded. This
272
+ // runs only after the raw comparison already failed, so a genuinely in-sync
273
+ // tree is never hashed twice. `null` means "not a verifiable managed copy"
274
+ // (no sentinel, a sentinel naming a different canonical path, a missing
275
+ // banner, a non-regular entry) and keeps the `update_copy` below, as does any
276
+ // other digest — a tampered body still re-syncs.
277
+ if (strategy === 'copy' && !canonicalEntry.isFile) {
278
+ const managedHash = await computeManagedDirectoryCopyHash(providerPath,
279
+ // Exactly the string `applyCopyMarker` wrote into the sentinel.
280
+ canonicalEntry.canonicalPath, canonicalEntry.type);
281
+ if (managedHash !== null && managedHash === canonicalHash) {
282
+ return {
283
+ operation: 'skip',
284
+ reason: 'already in sync',
285
+ };
286
+ }
287
+ }
338
288
  return {
339
289
  operation: 'update_copy',
340
290
  reason: 'copied content differs from canonical content',
@@ -0,0 +1,69 @@
1
+ import type { ManifestEntry } from '../manifest/manifest.types.js';
2
+ /**
3
+ * The banner-and-sentinel-aware hash of an OAT-managed directory copy.
4
+ *
5
+ * When `oat sync` materializes a directory copy it deliberately decorates the
6
+ * provider tree with two artifacts the canonical tree does not have: an
7
+ * `.oat-generated` sentinel and a marker line prepended to `SKILL.md` /
8
+ * `AGENT.md`. Hashing that tree raw therefore never equals the canonical hash
9
+ * the manifest records, which is why every raw reader saw a fresh copy as
10
+ * drifted. This module owns the comparison that accounts for both.
11
+ *
12
+ * Standing claims owned here (backstop: `managed-copy-hash.test.ts`):
13
+ *
14
+ * 1. For a faithful managed directory copy the digest returned is **equal** to
15
+ * `computeDirectoryHash` of the canonical directory — the same value
16
+ * `execute-plan.ts` writes into the manifest `contentHash`. The encoding is
17
+ * not merely the same shape as `manifest/hash.ts`: this module calls that
18
+ * module's `createFramedDirectoryDigest`, `compareDirectoryRelativePaths`
19
+ * and `updateFramedDirectoryDigest`, so the domain tag, the ordering and
20
+ * the per-field length framing cannot drift apart. That framing is what
21
+ * makes the digest a self-delimiting encoding of the sorted file list
22
+ * (wave-7 final review, Critical 4 — the header of `manifest/hash.ts`
23
+ * records the collision that motivated it, the cross-encoding forgery the
24
+ * domain tag closes, and the one-time manifest migration it forces).
25
+ * 2. `null` is returned rather than a digest whenever the sentinel does not
26
+ * name exactly this canonical path — and likewise when the sentinel is
27
+ * absent or has trailing content, when the marker file is missing or does
28
+ * not start with exactly the expected banner, or when the provider tree
29
+ * holds any non-regular entry.
30
+ * 3. "Non-regular entry" includes two shapes the pre-hardening helper waved
31
+ * through, for two different reasons: a `.oat-generated` sentinel that is a
32
+ * symlink (or anything other than a regular file) — it was skipped by
33
+ * pathname before its type was ever examined and then read through a
34
+ * symlink-following `readFile`; and a provider root that is a symlink
35
+ * rather than a real directory — the root was never type-checked before
36
+ * `readdir`, which traverses a symlinked directory. Both return `null`.
37
+ * Without this the detector and planner would newly accept a
38
+ * symlink-substituted provider view as `in_sync`.
39
+ * 4. A `skill` or `agent` copy must actually contain its marker file. The
40
+ * banner check used to be keyed on `file === markerPath` while walking the
41
+ * tree, so deleting `SKILL.md` outright did not fail the check — it simply
42
+ * skipped it. Combined with the unframed digest that let a view drop
43
+ * `SKILL.md`, fuse its bytes into a surviving file, and read `in_sync`. The
44
+ * requirement is unconditional, so a skill directory that genuinely has no
45
+ * `SKILL.md` (a layout `applyCopyMarker` tolerates as best-effort) is now
46
+ * permanently `drifted` / `update_copy` rather than `in_sync`: the safe
47
+ * direction, and the direction the review asked for.
48
+ *
49
+ * These claims describe a quiescent tree. Every check here is path-based, so
50
+ * none of them survives an adversary swapping an entry between the check and
51
+ * the read — the same pre-existing limitation every other filesystem reader in
52
+ * this package shares. The guarantee that does hold unconditionally is the
53
+ * superset property below: each check is additive, so no interleaving lets
54
+ * this helper accept an input the pre-hardening helper rejected.
55
+ *
56
+ * This module's rejection set is therefore a strict superset of the original
57
+ * private helper's, on every consumer. `classifyObsoleteMappingRetirement`
58
+ * inherits the stricter contract deliberately: for the symlinked-sentinel
59
+ * shape it now classifies `detach` rather than `remove`, the safe direction
60
+ * for a destructive path (a symlinked root was already `detach` there, because
61
+ * `expectedTypeMatches` is computed from `lstat` before the helper runs).
62
+ *
63
+ * `null` means "this is not a verifiable managed copy", never "it matches".
64
+ * Callers must fall back to their raw comparison on `null`, so an unverifiable
65
+ * copy degrades to `drifted` / `update_copy` rather than to a false `in_sync`.
66
+ * A tampered body still produces a digest, but a different one.
67
+ */
68
+ export declare function computeManagedDirectoryCopyHash(providerPath: string, canonicalPath: string, contentType: ManifestEntry['contentType']): Promise<string | null>;
69
+ //# sourceMappingURL=managed-copy-hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"managed-copy-hash.d.ts","sourceRoot":"","sources":["../../src/engine/managed-copy-hash.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAI9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,wBAAsB,+BAA+B,CACnD,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,aAAa,CAAC,aAAa,CAAC,GACxC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyHxB"}
@@ -0,0 +1,175 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { compareDirectoryRelativePaths, createFramedDirectoryDigest, updateFramedDirectoryDigest, } from '../manifest/hash.js';
4
+ import { OAT_DIRECTORY_SENTINEL, OAT_MARKER_PREFIX } from './markers.js';
5
+ /**
6
+ * The banner-and-sentinel-aware hash of an OAT-managed directory copy.
7
+ *
8
+ * When `oat sync` materializes a directory copy it deliberately decorates the
9
+ * provider tree with two artifacts the canonical tree does not have: an
10
+ * `.oat-generated` sentinel and a marker line prepended to `SKILL.md` /
11
+ * `AGENT.md`. Hashing that tree raw therefore never equals the canonical hash
12
+ * the manifest records, which is why every raw reader saw a fresh copy as
13
+ * drifted. This module owns the comparison that accounts for both.
14
+ *
15
+ * Standing claims owned here (backstop: `managed-copy-hash.test.ts`):
16
+ *
17
+ * 1. For a faithful managed directory copy the digest returned is **equal** to
18
+ * `computeDirectoryHash` of the canonical directory — the same value
19
+ * `execute-plan.ts` writes into the manifest `contentHash`. The encoding is
20
+ * not merely the same shape as `manifest/hash.ts`: this module calls that
21
+ * module's `createFramedDirectoryDigest`, `compareDirectoryRelativePaths`
22
+ * and `updateFramedDirectoryDigest`, so the domain tag, the ordering and
23
+ * the per-field length framing cannot drift apart. That framing is what
24
+ * makes the digest a self-delimiting encoding of the sorted file list
25
+ * (wave-7 final review, Critical 4 — the header of `manifest/hash.ts`
26
+ * records the collision that motivated it, the cross-encoding forgery the
27
+ * domain tag closes, and the one-time manifest migration it forces).
28
+ * 2. `null` is returned rather than a digest whenever the sentinel does not
29
+ * name exactly this canonical path — and likewise when the sentinel is
30
+ * absent or has trailing content, when the marker file is missing or does
31
+ * not start with exactly the expected banner, or when the provider tree
32
+ * holds any non-regular entry.
33
+ * 3. "Non-regular entry" includes two shapes the pre-hardening helper waved
34
+ * through, for two different reasons: a `.oat-generated` sentinel that is a
35
+ * symlink (or anything other than a regular file) — it was skipped by
36
+ * pathname before its type was ever examined and then read through a
37
+ * symlink-following `readFile`; and a provider root that is a symlink
38
+ * rather than a real directory — the root was never type-checked before
39
+ * `readdir`, which traverses a symlinked directory. Both return `null`.
40
+ * Without this the detector and planner would newly accept a
41
+ * symlink-substituted provider view as `in_sync`.
42
+ * 4. A `skill` or `agent` copy must actually contain its marker file. The
43
+ * banner check used to be keyed on `file === markerPath` while walking the
44
+ * tree, so deleting `SKILL.md` outright did not fail the check — it simply
45
+ * skipped it. Combined with the unframed digest that let a view drop
46
+ * `SKILL.md`, fuse its bytes into a surviving file, and read `in_sync`. The
47
+ * requirement is unconditional, so a skill directory that genuinely has no
48
+ * `SKILL.md` (a layout `applyCopyMarker` tolerates as best-effort) is now
49
+ * permanently `drifted` / `update_copy` rather than `in_sync`: the safe
50
+ * direction, and the direction the review asked for.
51
+ *
52
+ * These claims describe a quiescent tree. Every check here is path-based, so
53
+ * none of them survives an adversary swapping an entry between the check and
54
+ * the read — the same pre-existing limitation every other filesystem reader in
55
+ * this package shares. The guarantee that does hold unconditionally is the
56
+ * superset property below: each check is additive, so no interleaving lets
57
+ * this helper accept an input the pre-hardening helper rejected.
58
+ *
59
+ * This module's rejection set is therefore a strict superset of the original
60
+ * private helper's, on every consumer. `classifyObsoleteMappingRetirement`
61
+ * inherits the stricter contract deliberately: for the symlinked-sentinel
62
+ * shape it now classifies `detach` rather than `remove`, the safe direction
63
+ * for a destructive path (a symlinked root was already `detach` there, because
64
+ * `expectedTypeMatches` is computed from `lstat` before the helper runs).
65
+ *
66
+ * `null` means "this is not a verifiable managed copy", never "it matches".
67
+ * Callers must fall back to their raw comparison on `null`, so an unverifiable
68
+ * copy degrades to `drifted` / `update_copy` rather than to a false `in_sync`.
69
+ * A tampered body still produces a digest, but a different one.
70
+ */
71
+ export async function computeManagedDirectoryCopyHash(providerPath, canonicalPath, contentType) {
72
+ const expectedMarker = `${OAT_MARKER_PREFIX} Source: ${canonicalPath} -->`;
73
+ const sentinelPath = join(providerPath, OAT_DIRECTORY_SENTINEL);
74
+ try {
75
+ // The provider root must be a real directory. `lstat` does not resolve the
76
+ // final component, so a symlink pointing at an otherwise faithful tree is
77
+ // rejected here rather than silently traversed by `readdir`.
78
+ const providerStat = await lstat(providerPath);
79
+ if (!providerStat.isDirectory()) {
80
+ return null;
81
+ }
82
+ // Type-check the sentinel before reading it. `readFile` follows symlinks,
83
+ // so without this a symlink to a file holding the exact marker would
84
+ // authenticate a tree the writer never produced.
85
+ //
86
+ // This is a check against the state on disk, not a handle-bound one: like
87
+ // every other filesystem reader in this package (`manifest/hash.ts`, the
88
+ // detector's own `lstat` + `computeContentHash`, and this file's caller in
89
+ // `classifyObsoleteMappingRetirement`), it resolves each path more than
90
+ // once and so cannot exclude a concurrent swap between the check and the
91
+ // read. It is not a defence against an adversary racing writes inside the
92
+ // provider directory; closing that would take handle-bound traversal
93
+ // (`openat`/`O_NOFOLLOW`) across all of those readers. What it does
94
+ // guarantee is that this helper's checks are a strict superset of the ones
95
+ // it replaced, so no input accepted here was rejected before.
96
+ const sentinelStat = await lstat(sentinelPath);
97
+ if (!sentinelStat.isFile()) {
98
+ return null;
99
+ }
100
+ const sentinel = await readFile(sentinelPath, 'utf8');
101
+ if (sentinel !== `${expectedMarker}\n`) {
102
+ return null;
103
+ }
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ const files = [];
109
+ async function collectFiles(current) {
110
+ const entries = await readdir(current, { withFileTypes: true });
111
+ for (const entry of entries) {
112
+ const fullPath = join(current, entry.name);
113
+ if (fullPath === sentinelPath) {
114
+ // Validate the type before skipping by pathname: a non-regular
115
+ // sentinel must disqualify the tree, not be waved through.
116
+ if (!entry.isFile()) {
117
+ return false;
118
+ }
119
+ continue;
120
+ }
121
+ if (entry.isDirectory()) {
122
+ if (!(await collectFiles(fullPath))) {
123
+ return false;
124
+ }
125
+ continue;
126
+ }
127
+ if (!entry.isFile()) {
128
+ return false;
129
+ }
130
+ files.push(fullPath);
131
+ }
132
+ return true;
133
+ }
134
+ try {
135
+ if (!(await collectFiles(providerPath))) {
136
+ return null;
137
+ }
138
+ files.sort((left, right) => compareDirectoryRelativePaths(relative(providerPath, left), relative(providerPath, right)));
139
+ const markerFileName = contentType === 'skill'
140
+ ? 'SKILL.md'
141
+ : contentType === 'agent'
142
+ ? 'AGENT.md'
143
+ : null;
144
+ const markerPath = markerFileName
145
+ ? join(providerPath, markerFileName)
146
+ : null;
147
+ // The marker file must be present, not merely validated when it happens to
148
+ // exist. Keying the banner check on `file === markerPath` inside the loop
149
+ // below meant a view with no `SKILL.md` at all skipped the check entirely,
150
+ // which is half of the forgery this module now rejects.
151
+ if (markerPath !== null && !files.includes(markerPath)) {
152
+ return null;
153
+ }
154
+ const hash = createFramedDirectoryDigest();
155
+ for (const file of files) {
156
+ const relativePath = relative(providerPath, file);
157
+ let content = await readFile(file);
158
+ if (file === markerPath) {
159
+ const markerPrefix = Buffer.from(`${expectedMarker}\n`);
160
+ if (content.length < markerPrefix.length ||
161
+ !content.subarray(0, markerPrefix.length).equals(markerPrefix)) {
162
+ return null;
163
+ }
164
+ content = content.subarray(markerPrefix.length);
165
+ }
166
+ // Shared with `manifest/hash.ts` rather than mirrored: the canonical
167
+ // digest this must reproduce is produced by the very same encoder.
168
+ updateFramedDirectoryDigest(hash, relativePath, content);
169
+ }
170
+ return hash.digest('hex');
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }