@open-agent-toolkit/cli 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/assets/agents/oat-codebase-mapper.md +3 -2
- package/assets/agents/oat-reviewer.md +5 -13
- package/assets/agents/skeptical-evaluator.md +6 -2
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/cli-utilities/backlog-lifecycle.md +3 -2
- package/assets/docs/cli-utilities/bootstrap.md +5 -2
- package/assets/docs/cli-utilities/config-and-local-state.md +13 -6
- package/assets/docs/cli-utilities/configuration.md +25 -10
- package/assets/docs/cli-utilities/tool-packs.md +60 -10
- package/assets/docs/cli-utilities/workflow-gates.md +37 -0
- package/assets/docs/contributing/code.md +9 -5
- package/assets/docs/provider-sync/commands.md +17 -8
- package/assets/docs/provider-sync/instruction-sync.md +241 -39
- package/assets/docs/provider-sync/providers.md +20 -0
- package/assets/docs/provider-sync/scope-and-surface.md +3 -1
- package/assets/docs/reference/cli-reference.md +4 -1
- package/assets/docs/reference/oat-directory-structure.md +12 -11
- package/assets/docs/reference/troubleshooting.md +19 -4
- package/assets/docs/workflows/projects/evidence-layers.md +5 -1
- package/assets/docs/workflows/projects/implementation-execution.md +22 -8
- package/assets/docs/workflows/projects/lifecycle.md +4 -6
- package/assets/docs/workflows/projects/retro.md +7 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-agent-instructions-analyze/SKILL.md +4 -4
- package/assets/skills/oat-agent-instructions-analyze/references/analysis-artifact-template.md +5 -5
- package/assets/skills/oat-agent-instructions-analyze/references/docs/rules-files.md +14 -11
- package/assets/skills/oat-agent-instructions-analyze/references/quality-checklist.md +2 -2
- package/assets/skills/oat-agent-instructions-analyze/scripts/resolve-instruction-files.sh +4 -3
- package/assets/skills/oat-agent-instructions-analyze/scripts/resolve-providers.sh +17 -6
- package/assets/skills/oat-agent-instructions-apply/SKILL.md +3 -3
- package/assets/skills/oat-agent-instructions-apply/references/docs/rules-files.md +14 -11
- package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
- package/assets/skills/oat-dispatch-subagents/references/managed-claude-example.json +387 -0
- package/assets/skills/oat-dispatch-subagents/references/record-schema.md +75 -0
- package/assets/skills/oat-doctor/SKILL.md +17 -13
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/SKILL.md +2 -2
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +33 -11
- package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +24 -22
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/references/phase-execution.md +109 -8
- package/assets/skills/oat-project-lite/SKILL.md +23 -1
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-next/SKILL.md +3 -3
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-progress/SKILL.md +2 -2
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-retro/SKILL.md +70 -6
- package/assets/skills/oat-project-review-provide/SKILL.md +3 -1
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +3 -1
- package/assets/templates/docs-app-mkdocs/docs/contributing.md +1 -3
- package/assets/templates/pjm-agents.md +7 -3
- package/assets/templates/state.md +2 -2
- package/dist/agents/canonical/resolve.d.ts +6 -4
- package/dist/agents/canonical/resolve.d.ts.map +1 -1
- package/dist/agents/canonical/resolve.js +6 -4
- package/dist/commands/backlog/archive.d.ts +10 -2
- package/dist/commands/backlog/archive.d.ts.map +1 -1
- package/dist/commands/backlog/archive.js +22 -6
- package/dist/commands/backlog/index.d.ts.map +1 -1
- package/dist/commands/backlog/index.js +5 -1
- package/dist/commands/backlog/rewrite-references.d.ts +41 -0
- package/dist/commands/backlog/rewrite-references.d.ts.map +1 -0
- package/dist/commands/backlog/rewrite-references.js +516 -0
- package/dist/commands/config/index.d.ts +1 -0
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +69 -34
- package/dist/commands/gate/index.d.ts.map +1 -1
- package/dist/commands/gate/index.js +51 -2
- package/dist/commands/gate/review-verdict.d.ts +22 -0
- package/dist/commands/gate/review-verdict.d.ts.map +1 -1
- package/dist/commands/gate/review-verdict.js +111 -0
- package/dist/commands/init/index.d.ts +6 -0
- package/dist/commands/init/index.d.ts.map +1 -1
- package/dist/commands/init/index.js +27 -4
- package/dist/commands/init/tools/index.d.ts +18 -1
- package/dist/commands/init/tools/index.d.ts.map +1 -1
- package/dist/commands/init/tools/index.js +84 -17
- package/dist/commands/init/tools/project-guidance.d.ts +19 -4
- package/dist/commands/init/tools/project-guidance.d.ts.map +1 -1
- package/dist/commands/init/tools/project-guidance.js +58 -15
- package/dist/commands/init/tools/workflows/index.d.ts.map +1 -1
- package/dist/commands/init/tools/workflows/index.js +5 -1
- package/dist/commands/instructions/instructions.types.d.ts +105 -4
- package/dist/commands/instructions/instructions.types.d.ts.map +1 -1
- package/dist/commands/instructions/instructions.types.js +4 -5
- package/dist/commands/instructions/instructions.utils.d.ts +161 -6
- package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
- package/dist/commands/instructions/instructions.utils.js +650 -12
- package/dist/commands/instructions/sync/sync.d.ts.map +1 -1
- package/dist/commands/instructions/sync/sync.js +256 -18
- package/dist/commands/instructions/validate/validate.d.ts.map +1 -1
- package/dist/commands/instructions/validate/validate.js +36 -8
- package/dist/commands/pjm/index.d.ts.map +1 -1
- package/dist/commands/pjm/index.js +19 -4
- package/dist/commands/pjm/init.d.ts +3 -0
- package/dist/commands/pjm/init.d.ts.map +1 -1
- package/dist/commands/pjm/init.js +23 -5
- package/dist/commands/project/dispatch/canonical-role.d.ts +33 -0
- package/dist/commands/project/dispatch/canonical-role.d.ts.map +1 -0
- package/dist/commands/project/dispatch/canonical-role.js +114 -0
- package/dist/commands/project/dispatch/index.d.ts.map +1 -1
- package/dist/commands/project/dispatch/index.js +3 -1
- package/dist/commands/project/dispatch/record.d.ts.map +1 -1
- package/dist/commands/project/dispatch/record.js +146 -4
- package/dist/commands/shared/agents-md.d.ts +26 -5
- package/dist/commands/shared/agents-md.d.ts.map +1 -1
- package/dist/commands/shared/agents-md.js +182 -3
- package/dist/commands/sync/apply.d.ts.map +1 -1
- package/dist/commands/sync/apply.js +28 -21
- package/dist/commands/tools/guidance/index.d.ts +15 -0
- package/dist/commands/tools/guidance/index.d.ts.map +1 -0
- package/dist/commands/tools/guidance/index.js +81 -0
- package/dist/commands/tools/index.d.ts.map +1 -1
- package/dist/commands/tools/index.js +2 -0
- package/dist/commands/tools/shared/sync-evidence.d.ts.map +1 -1
- package/dist/commands/tools/shared/sync-evidence.js +32 -5
- package/dist/config/oat-config.d.ts +57 -3
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +223 -15
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +12 -2
- package/dist/engine/compute-plan.d.ts.map +1 -1
- package/dist/engine/compute-plan.js +31 -3
- package/dist/providers/claude/dispatch-envelope.d.ts +53 -0
- package/dist/providers/claude/dispatch-envelope.d.ts.map +1 -1
- package/dist/providers/claude/dispatch-envelope.js +324 -48
- package/dist/providers/claude/rule-transform.js +1 -1
- package/dist/providers/copilot/rule-transform.js +1 -1
- package/dist/providers/cursor/rule-transform.js +1 -1
- package/dist/providers/identity/absolute-paths.d.ts +13 -0
- package/dist/providers/identity/absolute-paths.d.ts.map +1 -1
- package/dist/providers/identity/absolute-paths.js +27 -14
- package/dist/providers/identity/generic-dispatch-record.d.ts +212 -0
- package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -1
- package/dist/providers/identity/generic-dispatch-record.js +78 -19
- package/dist/providers/identity/oat-dispatch-record.d.ts +5 -0
- package/dist/providers/identity/oat-dispatch-record.d.ts.map +1 -1
- package/dist/providers/identity/oat-dispatch-record.js +11 -2
- package/dist/providers/shared/registry.d.ts +6 -0
- package/dist/providers/shared/registry.d.ts.map +1 -1
- package/dist/providers/shared/registry.js +30 -0
- package/dist/rules/canonical/parse.d.ts.map +1 -1
- package/dist/rules/canonical/parse.js +38 -3
- 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
|
|
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
|
-
|
|
34
|
-
return
|
|
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
|
-
* `
|
|
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.
|
|
574
|
+
...(config.instructions?.claude?.excludes ?? []).map((raw) => ({
|
|
246
575
|
raw,
|
|
247
|
-
source: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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');
|