@open-agent-toolkit/cli 0.3.8 → 0.3.10

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 (121) hide show
  1. package/README.md +3 -3
  2. package/assets/agents/oat-codebase-mapper.md +3 -2
  3. package/assets/agents/oat-reviewer.md +5 -13
  4. package/assets/agents/skeptical-evaluator.md +6 -2
  5. package/assets/bundle-metadata.json +1 -1
  6. package/assets/config/dispatch-matrix-recommendation.json +6 -6
  7. package/assets/docs/cli-utilities/backlog-lifecycle.md +3 -2
  8. package/assets/docs/cli-utilities/bootstrap.md +5 -2
  9. package/assets/docs/cli-utilities/config-and-local-state.md +13 -6
  10. package/assets/docs/cli-utilities/configuration.md +22 -17
  11. package/assets/docs/cli-utilities/tool-packs.md +60 -10
  12. package/assets/docs/cli-utilities/workflow-gates.md +1 -1
  13. package/assets/docs/contributing/code.md +9 -5
  14. package/assets/docs/contributing/updating-model-guidance.md +34 -6
  15. package/assets/docs/contributing/verifying-cursor-pins.md +38 -9
  16. package/assets/docs/provider-sync/commands.md +11 -8
  17. package/assets/docs/provider-sync/instruction-sync.md +241 -39
  18. package/assets/docs/provider-sync/providers.md +4 -4
  19. package/assets/docs/provider-sync/scope-and-surface.md +1 -1
  20. package/assets/docs/reference/cli-reference.md +2 -0
  21. package/assets/docs/reference/oat-directory-structure.md +12 -11
  22. package/assets/docs/reference/troubleshooting.md +19 -4
  23. package/assets/docs/workflows/projects/dispatch-ceiling.md +21 -14
  24. package/assets/docs/workflows/projects/implementation-execution.md +26 -9
  25. package/assets/docs/workflows/projects/lifecycle.md +4 -6
  26. package/assets/public-package-versions.json +4 -4
  27. package/assets/skills/oat-agent-instructions-analyze/SKILL.md +4 -4
  28. package/assets/skills/oat-agent-instructions-analyze/references/analysis-artifact-template.md +5 -5
  29. package/assets/skills/oat-agent-instructions-analyze/references/docs/rules-files.md +14 -11
  30. package/assets/skills/oat-agent-instructions-analyze/references/quality-checklist.md +2 -2
  31. package/assets/skills/oat-agent-instructions-analyze/scripts/resolve-instruction-files.sh +4 -3
  32. package/assets/skills/oat-agent-instructions-apply/SKILL.md +3 -3
  33. package/assets/skills/oat-agent-instructions-apply/references/docs/rules-files.md +14 -11
  34. package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
  35. package/assets/skills/oat-dispatch-subagents/references/provider-cursor.md +5 -2
  36. package/assets/skills/oat-doctor/SKILL.md +17 -13
  37. package/assets/skills/oat-project-implement/SKILL.md +2 -2
  38. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +33 -11
  39. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +1 -1
  40. package/assets/skills/oat-project-implement/references/phase-execution.md +109 -8
  41. package/assets/skills/oat-project-lite/SKILL.md +23 -1
  42. package/assets/skills/oat-project-next/SKILL.md +3 -3
  43. package/assets/skills/oat-project-plan-writing/SKILL.md +3 -3
  44. package/assets/skills/oat-project-progress/SKILL.md +2 -2
  45. package/assets/skills/subagent-orchestration/SKILL.md +1 -1
  46. package/assets/skills/subagent-orchestration/references/evidence-and-refresh.md +20 -8
  47. package/assets/skills/subagent-orchestration/references/provider-claude.md +25 -12
  48. package/assets/skills/subagent-orchestration/references/provider-codex.md +27 -18
  49. package/assets/skills/subagent-orchestration/references/provider-cursor.md +40 -10
  50. package/assets/templates/docs-app-mkdocs/docs/contributing.md +1 -3
  51. package/assets/templates/pjm-agents.md +7 -3
  52. package/assets/templates/state.md +2 -2
  53. package/dist/commands/backlog/archive.d.ts +10 -2
  54. package/dist/commands/backlog/archive.d.ts.map +1 -1
  55. package/dist/commands/backlog/archive.js +22 -6
  56. package/dist/commands/backlog/index.d.ts.map +1 -1
  57. package/dist/commands/backlog/index.js +5 -1
  58. package/dist/commands/backlog/rewrite-references.d.ts +41 -0
  59. package/dist/commands/backlog/rewrite-references.d.ts.map +1 -0
  60. package/dist/commands/backlog/rewrite-references.js +516 -0
  61. package/dist/commands/config/index.d.ts +1 -0
  62. package/dist/commands/config/index.d.ts.map +1 -1
  63. package/dist/commands/config/index.js +69 -34
  64. package/dist/commands/init/index.d.ts +6 -0
  65. package/dist/commands/init/index.d.ts.map +1 -1
  66. package/dist/commands/init/index.js +27 -4
  67. package/dist/commands/init/tools/index.d.ts +18 -1
  68. package/dist/commands/init/tools/index.d.ts.map +1 -1
  69. package/dist/commands/init/tools/index.js +84 -17
  70. package/dist/commands/init/tools/project-guidance.d.ts +19 -4
  71. package/dist/commands/init/tools/project-guidance.d.ts.map +1 -1
  72. package/dist/commands/init/tools/project-guidance.js +58 -15
  73. package/dist/commands/init/tools/workflows/index.d.ts.map +1 -1
  74. package/dist/commands/init/tools/workflows/index.js +5 -1
  75. package/dist/commands/instructions/instructions.types.d.ts +105 -4
  76. package/dist/commands/instructions/instructions.types.d.ts.map +1 -1
  77. package/dist/commands/instructions/instructions.types.js +4 -5
  78. package/dist/commands/instructions/instructions.utils.d.ts +161 -6
  79. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  80. package/dist/commands/instructions/instructions.utils.js +650 -12
  81. package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
  82. package/dist/commands/instructions/sync/sync.js +256 -18
  83. package/dist/commands/instructions/validate/validate.d.ts.map +1 -1
  84. package/dist/commands/instructions/validate/validate.js +36 -8
  85. package/dist/commands/pjm/index.d.ts.map +1 -1
  86. package/dist/commands/pjm/index.js +19 -4
  87. package/dist/commands/pjm/init.d.ts +3 -0
  88. package/dist/commands/pjm/init.d.ts.map +1 -1
  89. package/dist/commands/pjm/init.js +23 -5
  90. package/dist/commands/shared/agents-md.d.ts +26 -5
  91. package/dist/commands/shared/agents-md.d.ts.map +1 -1
  92. package/dist/commands/shared/agents-md.js +182 -3
  93. package/dist/commands/tools/guidance/index.d.ts +15 -0
  94. package/dist/commands/tools/guidance/index.d.ts.map +1 -0
  95. package/dist/commands/tools/guidance/index.js +81 -0
  96. package/dist/commands/tools/index.d.ts.map +1 -1
  97. package/dist/commands/tools/index.js +2 -0
  98. package/dist/config/oat-config.d.ts +49 -3
  99. package/dist/config/oat-config.d.ts.map +1 -1
  100. package/dist/config/oat-config.js +107 -14
  101. package/dist/config/resolve.d.ts.map +1 -1
  102. package/dist/config/resolve.js +12 -2
  103. package/dist/providers/claude/dispatch-envelope.d.ts.map +1 -1
  104. package/dist/providers/claude/dispatch-envelope.js +1 -0
  105. package/dist/providers/claude/targets.d.ts +1 -0
  106. package/dist/providers/claude/targets.d.ts.map +1 -1
  107. package/dist/providers/claude/targets.js +3 -1
  108. package/dist/providers/codex/codec/shared.d.ts +3 -0
  109. package/dist/providers/codex/codec/shared.d.ts.map +1 -1
  110. package/dist/providers/codex/codec/shared.js +4 -0
  111. package/dist/providers/cursor/codec/catalog.d.ts +2 -2
  112. package/dist/providers/cursor/codec/catalog.d.ts.map +1 -1
  113. package/dist/providers/cursor/codec/catalog.js +45 -0
  114. package/dist/providers/cursor/codec/mapping-validation.d.ts +4 -0
  115. package/dist/providers/cursor/codec/mapping-validation.d.ts.map +1 -0
  116. package/dist/providers/cursor/codec/mapping-validation.js +25 -0
  117. package/dist/providers/cursor/codec/materialize.d.ts.map +1 -1
  118. package/dist/providers/cursor/codec/materialize.js +2 -11
  119. package/dist/providers/cursor/codec/sync-extension.d.ts.map +1 -1
  120. package/dist/providers/cursor/codec/sync-extension.js +2 -11
  121. package/package.json +2 -2
@@ -1,8 +1,8 @@
1
1
  import { lstat, readdir, readFile, readlink, realpath, stat, } from 'node:fs/promises';
2
- import { dirname, join, posix, relative, resolve } from 'node:path';
3
- import { readOatConfig, resolveDocumentationContentRoot, } from '../../config/oat-config.js';
2
+ import { basename, dirname, join, posix, relative, resolve } from 'node:path';
3
+ import { DEFAULT_INSTRUCTION_SYNC_STRATEGY, readOatConfig, resolveDocumentationContentRoot, } from '../../config/oat-config.js';
4
4
  export const EXPECTED_CLAUDE_CONTENT = '@AGENTS.md\n';
5
- export const DEFAULT_INSTRUCTION_SYNC_STRATEGY = 'pointer';
5
+ export { DEFAULT_INSTRUCTION_SYNC_STRATEGY };
6
6
  const ROOT_EXCLUDED_DIRECTORIES = new Set(['.git', '.oat', '.worktrees']);
7
7
  const GLOBAL_EXCLUDED_DIRECTORIES = new Set(['node_modules']);
8
8
  // The only excluded-subtree re-entry points: a root-level directory is excluded
@@ -30,11 +30,337 @@ function getErrorCode(error) {
30
30
  function normalizeLineEndings(content) {
31
31
  return content.replaceAll('\r\n', '\n');
32
32
  }
33
- export function resolveInstructionSyncStrategy(strategy) {
34
- return strategy ?? DEFAULT_INSTRUCTION_SYNC_STRATEGY;
33
+ function readFileBytesDefault(path) {
34
+ return readFile(path);
35
+ }
36
+ /**
37
+ * The exact pointer files OAT writes: `@AGENTS.md` and a newline, with the
38
+ * CRLF form a Windows checkout produces. Nothing else is a managed pointer --
39
+ * not trailing whitespace, not a missing newline, not extra lines.
40
+ */
41
+ const MANAGED_POINTER_BYTES = [
42
+ Buffer.from(EXPECTED_CLAUDE_CONTENT, 'utf8'),
43
+ Buffer.from('@AGENTS.md\r\n', 'utf8'),
44
+ ];
45
+ /**
46
+ * Classify one CLAUDE.md against the exact shapes OAT writes, beside its
47
+ * sibling AGENTS.md. This is the single definition of "OAT-managed" that both
48
+ * planning (the scan) and apply-time re-verification use, so the two can never
49
+ * disagree about what may be deleted.
50
+ *
51
+ * Anything that is not provably one of the three shapes is `unmanaged`, which
52
+ * is never deleted: a CLAUDE.md not named exactly `CLAUDE.md`, one inside a
53
+ * `.claude` directory, a symlink whose target is not the sibling AGENTS.md,
54
+ * and any regular file whose bytes are not exactly a pointer or an exact copy.
55
+ */
56
+ export async function inspectManagedShim(claudePath, agentsPath, dependencies) {
57
+ const directoryPath = dirname(claudePath);
58
+ if (basename(claudePath) !== 'CLAUDE.md' ||
59
+ basename(directoryPath) === '.claude' ||
60
+ agentsPath !== join(directoryPath, 'AGENTS.md')) {
61
+ return {
62
+ kind: 'unmanaged',
63
+ detail: 'not a CLAUDE.md beside its AGENTS.md; kept',
64
+ };
65
+ }
66
+ let claudeStats;
67
+ try {
68
+ claudeStats = await dependencies.lstat(claudePath);
69
+ }
70
+ catch (error) {
71
+ const errorCode = getErrorCode(error);
72
+ return errorCode === 'ENOENT'
73
+ ? { kind: 'absent' }
74
+ : {
75
+ kind: 'unreadable',
76
+ detail: `unable to read CLAUDE.md (${errorCode ?? 'unknown error'})`,
77
+ };
78
+ }
79
+ const identity = { dev: claudeStats.dev, ino: claudeStats.ino };
80
+ if (claudeStats.isSymbolicLink()) {
81
+ let linkTarget;
82
+ try {
83
+ linkTarget = await dependencies.readlink(claudePath);
84
+ }
85
+ catch (error) {
86
+ return {
87
+ kind: 'unreadable',
88
+ detail: `unable to read CLAUDE.md symlink target (${getErrorCode(error) ?? 'unknown error'})`,
89
+ };
90
+ }
91
+ // Lexical, not realpath: only a link that names the sibling AGENTS.md is
92
+ // the shape OAT writes. A link that merely resolves to the same file
93
+ // through another path was made by someone else.
94
+ if (resolve(directoryPath, linkTarget) !== agentsPath) {
95
+ return {
96
+ kind: 'unmanaged',
97
+ detail: `CLAUDE.md symlink targets ${JSON.stringify(linkTarget)}, not the sibling AGENTS.md; kept`,
98
+ };
99
+ }
100
+ return {
101
+ kind: 'managed',
102
+ record: { shape: 'symlink', ...identity, linkTarget },
103
+ };
104
+ }
105
+ if (!claudeStats.isFile()) {
106
+ return {
107
+ kind: 'unmanaged',
108
+ detail: 'CLAUDE.md is not a regular file or symlink; kept',
109
+ };
110
+ }
111
+ // A regular CLAUDE.md that the sibling AGENTS.md resolves to -- the
112
+ // Claude-first `ln -s CLAUDE.md AGENTS.md` layout, or a hard link -- holds
113
+ // the only copy of the instructions. Comparing the two would compare the
114
+ // file with itself, so it is never a managed shape of any kind.
115
+ let agentsStats;
116
+ try {
117
+ agentsStats = await dependencies.lstat(agentsPath);
118
+ }
119
+ catch (error) {
120
+ return {
121
+ kind: 'unreadable',
122
+ detail: `unable to read AGENTS.md (${getErrorCode(error) ?? 'unknown error'})`,
123
+ };
124
+ }
125
+ let agentsResolvesToClaude = agentsStats.dev === claudeStats.dev && agentsStats.ino === claudeStats.ino;
126
+ if (agentsStats.isSymbolicLink()) {
127
+ try {
128
+ const [realAgents, realClaude] = await Promise.all([
129
+ dependencies.realpath(agentsPath),
130
+ dependencies.realpath(claudePath),
131
+ ]);
132
+ agentsResolvesToClaude = realAgents === realClaude;
133
+ }
134
+ catch (error) {
135
+ return {
136
+ kind: 'unreadable',
137
+ detail: `unable to resolve AGENTS.md (${getErrorCode(error) ?? 'unknown error'})`,
138
+ };
139
+ }
140
+ }
141
+ if (agentsResolvesToClaude) {
142
+ return {
143
+ kind: 'unmanaged',
144
+ detail: 'AGENTS.md resolves to this CLAUDE.md, so CLAUDE.md holds the instructions ' +
145
+ '(replace the AGENTS.md link with its content before removing CLAUDE.md); kept',
146
+ };
147
+ }
148
+ let claudeBytes;
149
+ try {
150
+ claudeBytes = await dependencies.readFileBytes(claudePath);
151
+ }
152
+ catch (error) {
153
+ return {
154
+ kind: 'unreadable',
155
+ detail: `unable to read CLAUDE.md (${getErrorCode(error) ?? 'unknown error'})`,
156
+ };
157
+ }
158
+ if (MANAGED_POINTER_BYTES.some((pointer) => pointer.equals(claudeBytes))) {
159
+ return { kind: 'managed', record: { shape: 'pointer', ...identity } };
160
+ }
161
+ // The copy shape is only ever judged against a distinct regular AGENTS.md:
162
+ // identical bytes behind a symlink prove nothing about who wrote CLAUDE.md.
163
+ if (!agentsStats.isFile()) {
164
+ return {
165
+ kind: 'unmanaged',
166
+ detail: 'AGENTS.md is not a regular file, so CLAUDE.md is not a managed copy; kept',
167
+ };
168
+ }
169
+ let agentsBytes;
170
+ try {
171
+ agentsBytes = await dependencies.readFileBytes(agentsPath);
172
+ }
173
+ catch (error) {
174
+ return {
175
+ kind: 'unreadable',
176
+ detail: `unable to read AGENTS.md (${getErrorCode(error) ?? 'unknown error'})`,
177
+ };
178
+ }
179
+ if (agentsBytes.equals(claudeBytes)) {
180
+ return { kind: 'managed', record: { shape: 'copy', ...identity } };
181
+ }
182
+ return {
183
+ kind: 'unmanaged',
184
+ detail: 'hand-written or modified CLAUDE.md; kept',
185
+ };
186
+ }
187
+ /**
188
+ * The path of a directory entry itself, with its parent directories resolved
189
+ * but the entry not followed, so a link and the file it names compare by the
190
+ * node they occupy rather than by spelling.
191
+ */
192
+ async function canonicalNodePath(path, dependencies) {
193
+ try {
194
+ return join(await dependencies.realpath(dirname(path)), basename(path));
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ const MAX_LINK_HOPS = 40;
201
+ /**
202
+ * The candidates whose symlink chain passes through `targetPath`: removing
203
+ * `targetPath` would leave each of them dangling. Every hop is followed, so a
204
+ * link to a link to the target counts, and a link to the target that is
205
+ * itself a symlink counts too. Regular files and hard links never dangle, so
206
+ * they are never reported.
207
+ */
208
+ export async function findLinksThrough(targetPath, candidates, dependencies) {
209
+ const targetNode = await canonicalNodePath(targetPath, dependencies);
210
+ if (targetNode === null) {
211
+ return [];
212
+ }
213
+ const linkers = [];
214
+ for (const candidate of new Set(candidates)) {
215
+ if (candidate === targetPath ||
216
+ (await canonicalNodePath(candidate, dependencies)) === targetNode) {
217
+ continue;
218
+ }
219
+ let current = candidate;
220
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop += 1) {
221
+ let stats;
222
+ try {
223
+ stats = await dependencies.lstat(current);
224
+ }
225
+ catch {
226
+ break;
227
+ }
228
+ if (!stats.isSymbolicLink()) {
229
+ break;
230
+ }
231
+ let linkTarget;
232
+ try {
233
+ linkTarget = await dependencies.readlink(current);
234
+ }
235
+ catch {
236
+ break;
237
+ }
238
+ const next = resolve(dirname(current), linkTarget);
239
+ if ((await canonicalNodePath(next, dependencies)) === targetNode) {
240
+ linkers.push(candidate);
241
+ break;
242
+ }
243
+ current = next;
244
+ }
245
+ }
246
+ return linkers.sort((left, right) => left.localeCompare(right));
247
+ }
248
+ /** `a` / `a and b` / `a, b, and c`, repository-relative. */
249
+ function describeLinkers(repoRoot, linkers) {
250
+ const paths = linkers.map((path) => toPosixPath(relative(repoRoot, path)));
251
+ if (paths.length <= 2) {
252
+ return paths.join(' and ');
253
+ }
254
+ return `${paths.slice(0, -1).join(', ')}, and ${paths.at(-1)}`;
255
+ }
256
+ /**
257
+ * Re-verify, immediately before deletion, that a CLAUDE.md planned for removal
258
+ * is still the exact managed shim the scan recorded: same `lstat` identity
259
+ * (device and inode, so a replaced file is caught even with identical bytes),
260
+ * same shape, same symlink target, and still an exact managed shape now.
261
+ *
262
+ * Returns null when removal may proceed, or the reason it must not.
263
+ */
264
+ export async function verifyManagedShimUnchanged(claudePath, agentsPath, planned, dependencies) {
265
+ const current = await inspectManagedShim(claudePath, agentsPath, dependencies);
266
+ if (current.kind === 'absent') {
267
+ return 'CLAUDE.md no longer exists';
268
+ }
269
+ if (current.kind !== 'managed') {
270
+ return current.detail.replace(/; kept$/, '');
271
+ }
272
+ if (current.record.dev !== planned.dev ||
273
+ current.record.ino !== planned.ino) {
274
+ return 'CLAUDE.md was replaced by a different file';
275
+ }
276
+ if (current.record.shape !== planned.shape) {
277
+ return `CLAUDE.md changed from a ${planned.shape} shim to a ${current.record.shape} shim`;
278
+ }
279
+ if (current.record.linkTarget !== planned.linkTarget) {
280
+ return 'CLAUDE.md symlink target changed';
281
+ }
282
+ return null;
283
+ }
284
+ function describeManagedShim(record) {
285
+ switch (record.shape) {
286
+ case 'symlink':
287
+ return 'OAT-managed CLAUDE.md symlink';
288
+ case 'copy':
289
+ return 'OAT-managed CLAUDE.md hard copy';
290
+ default:
291
+ return 'OAT-managed CLAUDE.md pointer file';
292
+ }
293
+ }
294
+ /**
295
+ * Classify one scanned directory under strategy `none`, where no CLAUDE.md is
296
+ * wanted: an absent CLAUDE.md is correct, an exact managed shim is drift that
297
+ * sync removes, and anything else is kept and reported, never deleted.
298
+ */
299
+ async function classifyWithoutShims(directoryPath, directoryEntry, dependencies) {
300
+ const agentsPath = directoryEntry.agentsPath ?? null;
301
+ const claudePath = directoryEntry.claudePath ?? join(directoryPath, 'CLAUDE.md');
302
+ const hasClaude = directoryEntry.claudePath !== undefined ||
303
+ directoryEntry.brokenClaudePath !== undefined;
304
+ const entry = (status, detail) => ({ agentsPath, claudePath, status, detail });
305
+ // `.claude/CLAUDE.md` is Claude Code's alternate project instruction file,
306
+ // not a shim for an AGENTS.md in `.claude`, so it is never adopted or removed.
307
+ if (basename(directoryPath) === '.claude' && hasClaude) {
308
+ return entry('unmanaged', '.claude/CLAUDE.md is not managed by instruction sync; kept');
309
+ }
310
+ if (directoryEntry.brokenClaudePath) {
311
+ const errorCode = directoryEntry.brokenClaudeErrorCode ?? 'unknown error';
312
+ return entry('unmanaged', errorCode === 'ENOENT'
313
+ ? 'broken CLAUDE.md symlink; kept'
314
+ : `unreadable CLAUDE.md symlink target (${errorCode}); kept`);
315
+ }
316
+ if (!agentsPath) {
317
+ try {
318
+ await dependencies.readFile(claudePath, 'utf8');
319
+ }
320
+ catch (error) {
321
+ return entry('content_mismatch', `unable to read CLAUDE.md (${getErrorCode(error) ?? 'unknown'})`);
322
+ }
323
+ return entry('stray', 'CLAUDE.md found without AGENTS.md');
324
+ }
325
+ if (!hasClaude) {
326
+ return entry('ok', 'no CLAUDE.md');
327
+ }
328
+ const inspection = await inspectManagedShim(claudePath, agentsPath, dependencies);
329
+ switch (inspection.kind) {
330
+ case 'absent':
331
+ return entry('ok', 'no CLAUDE.md');
332
+ case 'unreadable':
333
+ return entry('content_mismatch', inspection.detail);
334
+ case 'unmanaged':
335
+ return entry('unmanaged', inspection.detail);
336
+ case 'managed':
337
+ return {
338
+ ...entry('managed_shim', `${describeManagedShim(inspection.record)}; sync removes it under strategy none`),
339
+ managedShim: inspection.record,
340
+ };
341
+ }
342
+ }
343
+ /**
344
+ * The effective strategy for one run: the `--strategy` flag, then the
345
+ * repository's `instructions.claude.shims`, then the built-in
346
+ * default. The flag only ever overrides a single run; it never rewrites config.
347
+ */
348
+ export function resolveInstructionSyncStrategy(flagStrategy, configuredStrategy) {
349
+ return (flagStrategy ?? configuredStrategy ?? DEFAULT_INSTRUCTION_SYNC_STRATEGY);
350
+ }
351
+ /**
352
+ * Read `instructions.claude.shims` from the shared config. A
353
+ * malformed value fails closed in the config normalizer rather than reading as
354
+ * unset, so a typo can never silently select the built-in default.
355
+ */
356
+ export async function readConfiguredInstructionSyncStrategy(repoRoot) {
357
+ const config = await readOatConfig(repoRoot);
358
+ return config.instructions?.claude?.shims;
35
359
  }
36
360
  function getValidInstructionDetail(strategy) {
37
361
  switch (strategy) {
362
+ case 'none':
363
+ return 'no CLAUDE.md';
38
364
  case 'symlink':
39
365
  return 'symlink valid';
40
366
  case 'copy':
@@ -45,6 +371,8 @@ function getValidInstructionDetail(strategy) {
45
371
  }
46
372
  function getInvalidInstructionDetail(strategy) {
47
373
  switch (strategy) {
374
+ case 'none':
375
+ return 'expected no CLAUDE.md';
48
376
  case 'symlink':
49
377
  return 'expected symlink to AGENTS.md';
50
378
  case 'copy':
@@ -193,7 +521,7 @@ async function probeExclusionDirectory(dependencies, repoRoot, relativePath) {
193
521
  /**
194
522
  * The directories `oat instructions sync` and `oat instructions validate` skip:
195
523
  * the derived documentation content root first, then the explicit
196
- * `documentation.instructionPointerExcludes` opt-outs.
524
+ * `instructions.claude.excludes` opt-outs.
197
525
  *
198
526
  * Both commands resolve exclusions through this one function. That is the
199
527
  * property that matters: if they computed exclusions separately, validate
@@ -221,6 +549,7 @@ export async function resolveInstructionPointerExcludes(repoRoot, overrides = {}
221
549
  realpath,
222
550
  readdir,
223
551
  readFile,
552
+ readFileBytes: readFileBytesDefault,
224
553
  readlink,
225
554
  stat,
226
555
  ...overrides,
@@ -242,9 +571,9 @@ export async function resolveInstructionPointerExcludes(repoRoot, overrides = {}
242
571
  ...(contentRoot === null
243
572
  ? []
244
573
  : [{ raw: contentRoot, source: 'documentation.root' }]),
245
- ...(config.documentation?.instructionPointerExcludes ?? []).map((raw) => ({
574
+ ...(config.instructions?.claude?.excludes ?? []).map((raw) => ({
246
575
  raw,
247
- source: 'documentation.instructionPointerExcludes',
576
+ source: 'instructions.claude.excludes',
248
577
  })),
249
578
  ];
250
579
  const configured = [];
@@ -285,6 +614,16 @@ export async function resolveInstructionPointerExcludes(repoRoot, overrides = {}
285
614
  }
286
615
  return { configured, effective, warnings };
287
616
  }
617
+ /**
618
+ * A directory below the repository root that holds its own `.git` (a
619
+ * directory, or the gitdir file of a submodule or linked worktree such as
620
+ * `.claude/worktrees/<name>`) is a separate checkout. Its own
621
+ * `oat instructions` run owns it: the parent never scans, rewrites, removes,
622
+ * or warns about anything inside it.
623
+ */
624
+ function isNestedCheckout(repoRoot, directoryPath, entries) {
625
+ return (directoryPath !== repoRoot && entries.some((entry) => entry.name === '.git'));
626
+ }
288
627
  async function scanInstructionDirectories(repoRoot, dependencies, debug, excludedPaths = new Set()) {
289
628
  const queue = [repoRoot];
290
629
  const directoryEntries = new Map();
@@ -304,6 +643,10 @@ async function scanInstructionDirectories(repoRoot, dependencies, debug, exclude
304
643
  debug?.(`Skipping directory scan for ${toPosixPath(currentDirectory)} (${errorCode ?? 'unknown error'})`);
305
644
  continue;
306
645
  }
646
+ if (isNestedCheckout(repoRoot, currentDirectory, entries)) {
647
+ debug?.(`Skipping nested git checkout ${toPosixPath(relative(repoRoot, currentDirectory))}`);
648
+ continue;
649
+ }
307
650
  for (const entry of entries) {
308
651
  const entryPath = join(currentDirectory, entry.name);
309
652
  const isRootLevel = currentDirectory === repoRoot;
@@ -388,6 +731,7 @@ export async function scanInstructionFiles(repoRoot, options = {}, overrides = {
388
731
  realpath,
389
732
  readdir,
390
733
  readFile,
734
+ readFileBytes: readFileBytesDefault,
391
735
  readlink,
392
736
  stat,
393
737
  ...overrides,
@@ -398,6 +742,10 @@ export async function scanInstructionFiles(repoRoot, options = {}, overrides = {
398
742
  const agentsPath = directoryEntry.agentsPath ?? null;
399
743
  const brokenAgentsPath = directoryEntry.brokenAgentsPath ?? null;
400
744
  const brokenAgentsErrorCode = directoryEntry.brokenAgentsErrorCode ?? 'unknown error';
745
+ if (strategy === 'none' && (agentsPath || !brokenAgentsPath)) {
746
+ entries.push(await classifyWithoutShims(directoryPath, directoryEntry, dependencies));
747
+ continue;
748
+ }
401
749
  const brokenClaudePath = directoryEntry.brokenClaudePath ?? null;
402
750
  const brokenClaudeErrorCode = directoryEntry.brokenClaudeErrorCode ?? 'unknown error';
403
751
  const claudePath = directoryEntry.claudePath ?? join(directoryPath, 'CLAUDE.md');
@@ -586,8 +934,287 @@ export async function scanInstructionFiles(repoRoot, options = {}, overrides = {
586
934
  });
587
935
  }
588
936
  }
937
+ if (strategy === 'none') {
938
+ return normalizeEntries(await keepLinkedClaudeFiles(repoRoot, entries, instructionDirectories, dependencies));
939
+ }
589
940
  return normalizeEntries(entries);
590
941
  }
942
+ /**
943
+ * Under `none`, a CLAUDE.md that any scanned instruction file links to --
944
+ * `pkg/AGENTS.md -> ../CLAUDE.md`, say -- is never removed or adopted, even
945
+ * when it has an exact managed shape: deleting it would leave that link
946
+ * dangling. It is reported as `unmanaged`, naming the links.
947
+ */
948
+ async function keepLinkedClaudeFiles(repoRoot, entries, instructionDirectories, dependencies) {
949
+ const candidates = [...instructionDirectories.values()].flatMap((directoryEntry) => [
950
+ directoryEntry.agentsPath,
951
+ directoryEntry.brokenAgentsPath,
952
+ directoryEntry.claudePath,
953
+ directoryEntry.brokenClaudePath,
954
+ ].filter((path) => path !== undefined));
955
+ const kept = [];
956
+ for (const entry of entries) {
957
+ if (entry.status !== 'managed_shim' && entry.status !== 'stray') {
958
+ kept.push(entry);
959
+ continue;
960
+ }
961
+ const linkers = await findLinksThrough(entry.claudePath, candidates, dependencies);
962
+ if (linkers.length === 0) {
963
+ kept.push(entry);
964
+ continue;
965
+ }
966
+ kept.push({
967
+ agentsPath: entry.agentsPath,
968
+ claudePath: entry.claudePath,
969
+ status: 'unmanaged',
970
+ detail: `${describeLinkers(repoRoot, linkers)} ${linkers.length === 1 ? 'links' : 'link'} to this CLAUDE.md, so removing it would break ${linkers.length === 1 ? 'that link' : 'those links'} (replace ${linkers.length === 1 ? 'the link' : 'each link'} with its content first); kept`,
971
+ });
972
+ }
973
+ return kept;
974
+ }
975
+ // Exact-case names only. A case-insensitive match would flag ordinary
976
+ // documents such as a provider page named `claude.md` (this repository has
977
+ // two) with advice to delete them, on the unverified premise that Claude
978
+ // Code's plugin would treat them as CLAUDE.md; case variants are therefore
979
+ // neither reported nor ever removed.
980
+ const LEFTOVER_CLAUDE_FILE_NAMES = new Set(['CLAUDE.md', 'CLAUDE.local.md']);
981
+ /**
982
+ * Every `CLAUDE.md` (including `.claude/CLAUDE.md`) and `CLAUDE.local.md` in
983
+ * the repository (exact case), sorted, as absolute paths, each with the
984
+ * `AGENTS.md` files that link to it.
985
+ *
986
+ * A separate, read-only walk on purpose. The instruction scan skips the
987
+ * documentation content root and `instructions.claude.excludes`,
988
+ * but those only limit what OAT may change: Claude Code's `agents-md` plugin
989
+ * stands down whichever directory the file is in. Only `.git`, `node_modules`,
990
+ * the root `.worktrees`, and nested git checkouts (separate repositories) are
991
+ * skipped, and symlinked directories are not followed. A symlink named like
992
+ * an instruction file counts, broken or not. Each directory entry is read
993
+ * once, so one file is never reported twice, even on a case-insensitive
994
+ * filesystem.
995
+ */
996
+ export async function findLeftoverClaudeFiles(repoRoot, overrides = {}) {
997
+ const readDirectory = overrides.readdir ?? readdir;
998
+ const linkDependencies = {
999
+ lstat: overrides.lstat ?? lstat,
1000
+ readlink: overrides.readlink ?? readlink,
1001
+ realpath: overrides.realpath ?? realpath,
1002
+ };
1003
+ const inspectionDependencies = {
1004
+ ...linkDependencies,
1005
+ readFileBytes: overrides.readFileBytes ?? readFileBytesDefault,
1006
+ };
1007
+ const queue = [repoRoot];
1008
+ const found = [];
1009
+ const agentsFiles = [];
1010
+ while (queue.length > 0) {
1011
+ const currentDirectory = queue.shift();
1012
+ if (!currentDirectory) {
1013
+ continue;
1014
+ }
1015
+ let entries;
1016
+ try {
1017
+ entries = await readDirectory(currentDirectory, { withFileTypes: true });
1018
+ }
1019
+ catch {
1020
+ continue;
1021
+ }
1022
+ if (isNestedCheckout(repoRoot, currentDirectory, entries)) {
1023
+ continue;
1024
+ }
1025
+ for (const entry of entries) {
1026
+ const entryPath = join(currentDirectory, entry.name);
1027
+ if (entry.isDirectory()) {
1028
+ if (entry.name === '.git' ||
1029
+ GLOBAL_EXCLUDED_DIRECTORIES.has(entry.name) ||
1030
+ (currentDirectory === repoRoot && entry.name === '.worktrees')) {
1031
+ continue;
1032
+ }
1033
+ queue.push(entryPath);
1034
+ continue;
1035
+ }
1036
+ if (entry.name === 'AGENTS.md') {
1037
+ agentsFiles.push(entryPath);
1038
+ }
1039
+ if (LEFTOVER_CLAUDE_FILE_NAMES.has(entry.name)) {
1040
+ found.push(entryPath);
1041
+ }
1042
+ }
1043
+ }
1044
+ const leftovers = [];
1045
+ for (const path of found.sort((left, right) => left.localeCompare(right))) {
1046
+ // `inspectManagedShim` already refuses `CLAUDE.local.md` and anything in
1047
+ // `.claude`, so it alone decides. A file that vanished since the walk
1048
+ // (`absent`) holds nothing and cannot block anything.
1049
+ const inspection = await inspectManagedShim(path, join(dirname(path), 'AGENTS.md'), inspectionDependencies);
1050
+ leftovers.push({
1051
+ path,
1052
+ linkedBy: await findLinksThrough(path, agentsFiles, linkDependencies),
1053
+ exactShim: inspection.kind === 'managed' || inspection.kind === 'absent',
1054
+ });
1055
+ }
1056
+ return leftovers;
1057
+ }
1058
+ /**
1059
+ * Where the docs explain why any CLAUDE.md makes Claude Code ignore AGENTS.md.
1060
+ * Absolute, because the message is read in terminals and JSON consumers that
1061
+ * cannot resolve a repository-relative docs path.
1062
+ */
1063
+ export const CLAUDE_CODE_AGENTS_MD_DOCS_URL = 'https://github.com/voxmedia/open-agent-toolkit/blob/main/apps/oat-docs/docs/provider-sync/instruction-sync.md#claude-code-and-agentsmd';
1064
+ /**
1065
+ * Under strategy `none`, removal is all or nothing: the leftover files with
1066
+ * content of their own (anything but an exact OAT shim) that hold back every
1067
+ * CLAUDE.md removal while they exist.
1068
+ *
1069
+ * A stray the scan will adopt (a lone `CLAUDE.md` with no sibling AGENTS.md)
1070
+ * never blocks: its content moves into a new AGENTS.md, so it is not left
1071
+ * behind. Every other file counts wherever it is, including excluded and
1072
+ * documentation trees, because Claude Code's walk does not honor OAT's
1073
+ * exclusions. An unreadable file counts too: blocking is the safe direction.
1074
+ */
1075
+ export function findShimRemovalBlockers(entries, leftovers) {
1076
+ const adoptedStrays = new Set(entries
1077
+ .filter((entry) => entry.status === 'stray')
1078
+ .map((entry) => entry.claudePath));
1079
+ return leftovers
1080
+ .filter((leftover) => !leftover.exactShim && !adoptedStrays.has(leftover.path))
1081
+ .map(({ path, linkedBy }) => ({ path, linkedBy }));
1082
+ }
1083
+ /**
1084
+ * Under a blocked removal, the leftover warnings minus the files the block
1085
+ * holds back: the block finding already names them, and advice to remove
1086
+ * one by hand would recreate the mix the block exists to prevent.
1087
+ */
1088
+ export function omitHeldBackLeftovers(leftovers, heldBack) {
1089
+ const kept = new Set(heldBack);
1090
+ return leftovers.filter((leftover) => !kept.has(leftover.path));
1091
+ }
1092
+ /** The CLAUDE.md files strategy `none` removes: managed shims and adopted strays. */
1093
+ export function listShimRemovals(entries) {
1094
+ return entries
1095
+ .filter((entry) => entry.status === 'managed_shim' || entry.status === 'stray')
1096
+ .map((entry) => entry.claudePath)
1097
+ .sort((left, right) => left.localeCompare(right));
1098
+ }
1099
+ /** `a` / `a and b` / `a, b, and c`. */
1100
+ function joinList(items) {
1101
+ if (items.length <= 2) {
1102
+ return items.join(' and ');
1103
+ }
1104
+ return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`;
1105
+ }
1106
+ /** The skip reason sync records for each removal the block holds back. */
1107
+ export function describeShimRemovalBlock(repoRoot, blockers) {
1108
+ const paths = blockers.map(({ path }) => toPosixPath(relative(repoRoot, path)));
1109
+ return (`kept: strategy none removes no CLAUDE.md while ${joinList(paths)} ` +
1110
+ `${paths.length === 1 ? 'has' : 'have'} content (see warnings)`);
1111
+ }
1112
+ /**
1113
+ * The single finding for a blocked removal: which shims the configuration
1114
+ * would remove, that none were removed and which files are why, why any
1115
+ * CLAUDE.md matters, and what to do next.
1116
+ */
1117
+ export function buildShimRemovalBlockWarning(repoRoot, blockers, wouldRemove, mode) {
1118
+ const relativeTo = (path) => toPosixPath(relative(repoRoot, path));
1119
+ const paths = blockers.map(({ path }) => relativeTo(path));
1120
+ const removals = wouldRemove.map(relativeTo);
1121
+ const one = paths.length === 1;
1122
+ // A blocker an AGENTS.md links to holds the only copy of those
1123
+ // instructions: removing it would break the links, so its fix is to
1124
+ // replace them with its content first, never plain removal.
1125
+ const unlinked = blockers
1126
+ .filter(({ linkedBy }) => linkedBy.length === 0)
1127
+ .map(({ path }) => relativeTo(path));
1128
+ const fixes = [
1129
+ ...(unlinked.length === 0
1130
+ ? []
1131
+ : [
1132
+ `remove ${joinList(unlinked)} or move ${unlinked.length === 1 ? 'its' : 'their'} content into an AGENTS.md`,
1133
+ ]),
1134
+ ...blockers
1135
+ .filter(({ linkedBy }) => linkedBy.length > 0)
1136
+ .map(({ path, linkedBy }) => {
1137
+ const file = relativeTo(path);
1138
+ return `replace ${describeLinkers(repoRoot, linkedBy)} with the content of ${file}, then remove ${file}`;
1139
+ }),
1140
+ ];
1141
+ return {
1142
+ code: 'claude_md_blocks_shim_removal',
1143
+ paths,
1144
+ wouldRemove: removals,
1145
+ linkedBy: Object.fromEntries(blockers.map(({ path, linkedBy }) => [
1146
+ relativeTo(path),
1147
+ linkedBy.map(relativeTo),
1148
+ ])),
1149
+ message: `Strategy none would remove ${removals.length} CLAUDE.md ` +
1150
+ `${removals.length === 1 ? 'file' : 'files'} (${removals.join(', ')}), ` +
1151
+ `but ${mode === 'apply' ? 'none were removed' : 'none will be removed'} ` +
1152
+ `because ${joinList(paths)} ${one ? 'has' : 'have'} content that is not an OAT shim. ` +
1153
+ 'Any CLAUDE.md makes Claude Code ignore AGENTS.md ' +
1154
+ `(${CLAUDE_CODE_AGENTS_MD_DOCS_URL}), so OAT removes none of them until no ` +
1155
+ 'CLAUDE.md with content remains rather than leave a mix. ' +
1156
+ `To finish, ${fixes.join('; ')} and rerun \`oat instructions sync\`, or set instructions.claude.shims to a shim strategy ` +
1157
+ '(pointer, symlink, or copy) to keep CLAUDE.md files.',
1158
+ };
1159
+ }
1160
+ /**
1161
+ * Restate each managed shim's detail while the block holds: sync will not
1162
+ * remove it, so "sync removes it" would be false.
1163
+ */
1164
+ export function markBlockedShimEntries(entries) {
1165
+ return entries.map((entry) => entry.status === 'managed_shim' && entry.managedShim
1166
+ ? {
1167
+ ...entry,
1168
+ detail: `${describeManagedShim(entry.managedShim)}; not removed while another CLAUDE.md has content`,
1169
+ }
1170
+ : entry);
1171
+ }
1172
+ /**
1173
+ * The directory whose sessions a leftover file affects: its own directory,
1174
+ * except that `.claude/CLAUDE.md` belongs to the directory holding `.claude`.
1175
+ * `.` for the project root.
1176
+ */
1177
+ function leftoverScopeDirectory(relativePath) {
1178
+ const directory = posix.dirname(relativePath);
1179
+ return posix.basename(directory) === '.claude'
1180
+ ? posix.dirname(directory)
1181
+ : directory;
1182
+ }
1183
+ /**
1184
+ * One warning per leftover file, naming exactly two ways out: remove the
1185
+ * file, or opt back into shims and let sync add them everywhere.
1186
+ *
1187
+ * When an `AGENTS.md` links to the file, removing it would break that link
1188
+ * and lose the only copy of the instructions, so the first option becomes
1189
+ * "replace the link with the file's content, then remove the file", and
1190
+ * `linkedBy` names the links.
1191
+ *
1192
+ * Claude Code's plugin stands down when such a file sits in the session's
1193
+ * working directory or any directory above it up to the project root, so a
1194
+ * root file affects every session and a nested one only sessions started in
1195
+ * its directory or below.
1196
+ */
1197
+ export function buildLeftoverClaudeWarnings(repoRoot, leftovers) {
1198
+ return leftovers.map(({ path, linkedBy }) => {
1199
+ const relativePath = toPosixPath(relative(repoRoot, path));
1200
+ const scopeDirectory = leftoverScopeDirectory(relativePath);
1201
+ const scope = scopeDirectory === '.'
1202
+ ? ', whatever directory a session starts in'
1203
+ : ` for Claude Code sessions started in ${scopeDirectory}/ or below`;
1204
+ const firstOption = linkedBy.length === 0
1205
+ ? `Either remove ${relativePath}`
1206
+ : `Either replace ${describeLinkers(repoRoot, linkedBy)} (${linkedBy.length === 1 ? 'a link' : 'links'} to ${relativePath}) with the content of ${relativePath} and then remove ${relativePath}`;
1207
+ return {
1208
+ code: 'claude_md_hides_agents_md',
1209
+ path: relativePath,
1210
+ linkedBy: linkedBy.map((linker) => toPosixPath(relative(repoRoot, linker))),
1211
+ message: `${relativePath} makes Claude Code ignore every AGENTS.md in this project${scope}: ` +
1212
+ "its default agents-md mode stands down while any CLAUDE.md, .claude/CLAUDE.md, or CLAUDE.local.md exists in the session's working directory or any directory above it, up to the project root. " +
1213
+ `${firstOption}, or set instructions.claude.shims in .oat/config.json ` +
1214
+ 'to a shim strategy (pointer, symlink, or copy) and rerun `oat instructions sync` to add shims back.',
1215
+ };
1216
+ });
1217
+ }
591
1218
  export function buildInstructionsSummary(entries, actions) {
592
1219
  const normalizedEntries = normalizeEntries(entries);
593
1220
  const normalizedActions = normalizeActions(actions);
@@ -598,22 +1225,29 @@ export function buildInstructionsSummary(entries, actions) {
598
1225
  .length,
599
1226
  contentMismatch: normalizedEntries.filter((entry) => entry.status === 'content_mismatch').length,
600
1227
  stray: normalizedEntries.filter((entry) => entry.status === 'stray').length,
1228
+ managedShim: normalizedEntries.filter((entry) => entry.status === 'managed_shim').length,
1229
+ unmanaged: normalizedEntries.filter((entry) => entry.status === 'unmanaged')
1230
+ .length,
601
1231
  created: normalizedActions.filter((action) => action.type === 'create')
602
1232
  .length,
603
1233
  updated: normalizedActions.filter((action) => action.type === 'update')
604
1234
  .length,
1235
+ removed: normalizedActions.filter((action) => action.type === 'remove')
1236
+ .length,
605
1237
  skipped: normalizedActions.filter((action) => action.result === 'skipped')
606
1238
  .length,
607
1239
  };
608
1240
  }
609
1241
  function deriveInstructionsStatus(entries, actions) {
610
- if (entries.some((entry) => entry.status !== 'ok') ||
1242
+ // An `unmanaged` CLAUDE.md is reported but is not drift: sync never removes
1243
+ // it, so counting it would leave the repository no clean state to reach.
1244
+ if (entries.some((entry) => entry.status !== 'ok' && entry.status !== 'unmanaged') ||
611
1245
  actions.some((action) => action.result === 'skipped')) {
612
1246
  return 'drift';
613
1247
  }
614
1248
  return 'ok';
615
1249
  }
616
- export function buildInstructionsPayload({ mode, entries, actions, excludedPaths, effectiveExcludedPaths, exclusionWarnings, }) {
1250
+ export function buildInstructionsPayload({ mode, strategy, warnings, entries, actions, excludedPaths, effectiveExcludedPaths, exclusionWarnings, }) {
617
1251
  const normalizedEntries = normalizeEntries(entries);
618
1252
  const normalizedActions = normalizeActions(actions);
619
1253
  const normalizedExcludedPaths = normalizeExcludedPaths(excludedPaths);
@@ -621,8 +1255,10 @@ export function buildInstructionsPayload({ mode, entries, actions, excludedPaths
621
1255
  return {
622
1256
  mode,
623
1257
  status: deriveInstructionsStatus(normalizedEntries, normalizedActions),
1258
+ strategy,
624
1259
  summary: buildInstructionsSummary(normalizedEntries, normalizedActions),
625
- entries: normalizedEntries,
1260
+ // The planning identity is an apply-time safety input, not output.
1261
+ entries: normalizedEntries.map(({ managedShim: _managedShim, ...entry }) => entry),
626
1262
  actions: normalizedActions,
627
1263
  // Both omitted when nothing is configured, so a repository with no
628
1264
  // documentation root keeps its existing payload shape exactly. Once
@@ -641,13 +1277,15 @@ export function buildInstructionsPayload({ mode, entries, actions, excludedPaths
641
1277
  ...(exclusionWarnings !== undefined && exclusionWarnings.length > 0
642
1278
  ? { exclusionWarnings }
643
1279
  : {}),
1280
+ ...(warnings !== undefined && warnings.length > 0 ? { warnings } : {}),
644
1281
  };
645
1282
  }
646
1283
  export function formatInstructionsReport(payload, repoRoot) {
647
1284
  const lines = [
648
1285
  `instructions ${payload.mode}`,
649
1286
  `status: ${payload.status}`,
650
- `summary: scanned=${payload.summary.scanned}, ok=${payload.summary.ok}, missing=${payload.summary.missing}, content_mismatch=${payload.summary.contentMismatch}, stray=${payload.summary.stray}, created=${payload.summary.created}, updated=${payload.summary.updated}, skipped=${payload.summary.skipped}`,
1287
+ `strategy: ${payload.strategy}`,
1288
+ `summary: scanned=${payload.summary.scanned}, ok=${payload.summary.ok}, missing=${payload.summary.missing}, content_mismatch=${payload.summary.contentMismatch}, stray=${payload.summary.stray}, managed_shim=${payload.summary.managedShim}, unmanaged=${payload.summary.unmanaged}, created=${payload.summary.created}, updated=${payload.summary.updated}, removed=${payload.summary.removed}, skipped=${payload.summary.skipped}`,
651
1289
  ];
652
1290
  if (payload.entries.length === 0) {
653
1291
  lines.push('entries: none');