@worca/app 0.0.1

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 (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,1375 @@
1
+ // src/core/run-context.mjs
2
+ // Generation of the run root's context: memory (§5.4), skills (§5.6), MCP (§5.5).
3
+ //
4
+ // A detached run's cwd is NOT the user's project (§5.3), so none of the context the
5
+ // CLI would normally discover by walking up from a project dir is reachable. This
6
+ // module assembles all of it from the members' REAL directories — a git worktree
7
+ // materializes only committed blobs (E6), so the real dirs are the only source of
8
+ // truth for uncommitted memory, skills, and `.mcp.json` — and writes:
9
+ //
10
+ // <runRoot>/CLAUDE.md neutrality preamble + rosters + inlined memory (§5.4)
11
+ // <runRoot>/mcp.json merged MCP config, passed via --mcp-config (§5.5)
12
+ // <runRoot>/.claude/skills/ workspace-mode skill mount (§5.6)
13
+ // <worktree>/.claude/skills/ single-mode skill mount (E4: the skills walk
14
+ // stops at a git root, so a run-root mount would
15
+ // be invisible from a worktree cwd)
16
+ //
17
+ // It is PURE of CLI behavior: no spawn of `claude`, no DB, no orchestrator state.
18
+ // Everything mode- or environment-dependent (projectsRoot, homeDir, platform) is an
19
+ // explicit input, so the whole module is unit-testable and deterministic. Given the
20
+ // same inputs it produces byte-identical output — which is what makes the resume
21
+ // path's re-assembly idempotent self-healing rather than a second, different run
22
+ // context (§5.2).
23
+ //
24
+ // ENOENT is a DEFINED outcome everywhere (§8.20): a missing file contributes
25
+ // nothing silently (absence is normal); a missing member directory degrades that
26
+ // member to worktree-only context with a named warning; a broken skill entry warns
27
+ // and skips. Assembly never throws on a missing source.
28
+ //
29
+ // ABSENCE AND FAILURE ARE NOT THE SAME THING. Only ENOENT/ENOTDIR are silent. A
30
+ // source that EXISTS but cannot be read (EACCES, EIO, …) is named in `warnings` —
31
+ // §5.5 source 3 spells out a "read error" clause and §8.16 is warn-by-name — and
32
+ // only then degrades like an absent one. Conflating the two would let a single
33
+ // `chmod` remove a member's entire MCP or memory contribution with the run still
34
+ // reporting success. Every reader takes an optional `onError` sink (see fsWarner).
35
+
36
+ import { readFile, writeFile, readdir, stat, mkdir, rm, cp, symlink } from 'node:fs/promises';
37
+ import { execFileSync } from 'node:child_process';
38
+ import { join, resolve, dirname, basename, isAbsolute, sep } from 'node:path';
39
+
40
+ import { contextMaxBytesPerFile, contextMaxBytesTotal, skillMount, defaultRoot } from './settings.mjs';
41
+ import { readRunManifest, updateRunManifest } from './run-manifest.mjs';
42
+ import { isValidSkillName } from './skills.mjs';
43
+ import { mergePermissionRules } from './guardrails.mjs';
44
+
45
+ /**
46
+ * The `--allowedTools` grant shape this build emits for merged MCP servers.
47
+ *
48
+ * NOT a live probe — the value is burned in from the **Phase-0 V1 outcome**
49
+ * (`docs/run-root-verification.md` § "V1 — MCP grant shape · PASS, branch (a)";
50
+ * argv-attested transcript `phase0/out/v1a-rerun.jsonl` + `phase0/out/cmds.txt`):
51
+ * with `--permission-mode acceptEdits` and `--mcp-config`, the server WILDCARD
52
+ * `--allowedTools 'Read,Bash,mcp__zz-mcp-cfga'` made `mcp__zz-mcp-cfga__echo`
53
+ * callable, while the negative control (no grant) was denied. That is §4.1's
54
+ * outcome-table branch **(a) works** ⇒ one `mcp__<server>` grant per merged server,
55
+ * and R1(b)/R2-MCP hold UNQUALIFIED (no degradation warning, no matrix qualifier).
56
+ *
57
+ * Under `'per-tool'` or `'none'` the orchestrator emits an EMPTY grant array and
58
+ * preflight warns; neither branch is in play on this CLI version.
59
+ * @type {'server'|'per-tool'|'none'}
60
+ */
61
+ export const MCP_GRANT_MODE = 'server';
62
+
63
+ /** §5.4: `@import` recursion is capped at 4 levels (also what terminates a cycle). */
64
+ export const MAX_IMPORT_DEPTH = 4;
65
+
66
+ const CLAUDE_MD_FILE = 'CLAUDE.md';
67
+ const MCP_FILE = 'mcp.json';
68
+ const NO_MEMORY_PLACEHOLDER = '*(no CLAUDE.md found in this project)*';
69
+ /** §8.7: a run-log warning above 40 KB, independent of the (raisable) hard cap. */
70
+ const CONTEXT_SOFT_WARN_BYTES = 40960;
71
+
72
+ // ── tiny fs / string helpers ────────────────────────────────────────────────
73
+
74
+ const enc = (s) => Buffer.byteLength(String(s ?? ''), 'utf8');
75
+ /** Thousands separators without ICU (small-icu builds must format identically). */
76
+ const fmtNum = (n) => String(Math.trunc(Number(n) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
77
+
78
+ /**
79
+ * ABSENCE is the only normal failure in this module (§8.20): a missing file
80
+ * contributes nothing, silently. ENOTDIR belongs here too — it is what a path
81
+ * component that is a file (a member dir pointed at a regular file) reports, i.e.
82
+ * "not there" by another name.
83
+ *
84
+ * Everything else — EACCES, EIO, EISDIR, ELOOP, EMFILE — is a REAL read failure, and
85
+ * §5.5 source 3 ("on any shape mismatch, **read error**, or V4 failure") plus §8.16's
86
+ * warn-by-name policy require a signal. Swallowing them would let one `chmod` quietly
87
+ * delete a member's whole MCP or memory contribution with the run still reporting
88
+ * success. The source then degrades EXACTLY as an absent one (contributes nothing,
89
+ * the run proceeds), so §8.20's never-throw posture is untouched.
90
+ */
91
+ const isAbsent = (err) => err?.code === 'ENOENT' || err?.code === 'ENOTDIR';
92
+
93
+ /**
94
+ * A de-duplicating fs-error sink over a `warnings` array: at most one line per
95
+ * (path, code) per assembly, so a source read twice cannot warn twice.
96
+ * @param {string[]} warnings
97
+ * @returns {(p:string, err:object)=>void}
98
+ */
99
+ function fsWarner(warnings) {
100
+ const seen = new Set();
101
+ return (p, err) => {
102
+ const code = err?.code || 'unknown error';
103
+ const key = `${p}|${code}`;
104
+ if (seen.has(key)) return;
105
+ seen.add(key);
106
+ warnings.push(
107
+ `\`${p}\` exists but could not be read (${code}); it contributes nothing to this ` +
108
+ 'run\'s context — fix its permissions and re-run to include it.',
109
+ );
110
+ };
111
+ }
112
+
113
+ // Every reader below takes an OPTIONAL `onError(path, err)` sink. Omitting it keeps
114
+ // the old silent-degrade behavior, which is correct for the two SPECULATIVE probes
115
+ // (pathLikeArg's "is this arg a path?" test and auditAncestors' walk to `/`): there,
116
+ // a stat failure is a question we could not answer about a path that may not be a
117
+ // file at all, not the loss of a declared context source.
118
+ async function isDir(p, onError) {
119
+ try { return (await stat(p)).isDirectory(); }
120
+ catch (err) { if (!isAbsent(err)) onError?.(p, err); return false; }
121
+ }
122
+ async function exists(p, onError) {
123
+ try { await stat(p); return true; }
124
+ catch (err) { if (!isAbsent(err)) onError?.(p, err); return false; }
125
+ }
126
+ async function readTextMaybe(p, onError) {
127
+ try { return await readFile(p, 'utf8'); }
128
+ catch (err) { if (!isAbsent(err)) onError?.(p, err); return null; }
129
+ }
130
+ async function readBytesMaybe(p, onError) {
131
+ try { return await readFile(p); }
132
+ catch (err) { if (!isAbsent(err)) onError?.(p, err); return null; }
133
+ }
134
+ /** readdir with the same contract. Absent => []; a real error warns and yields []. */
135
+ async function readdirMaybe(p, onError) {
136
+ try { return await readdir(p, { withFileTypes: true }); }
137
+ catch (err) { if (!isAbsent(err)) onError?.(p, err); return []; }
138
+ }
139
+
140
+ /** Truncate to `cap` BYTES without ever splitting a UTF-8 sequence. */
141
+ function truncateBytes(text, cap) {
142
+ const buf = Buffer.from(String(text ?? ''), 'utf8');
143
+ if (buf.length <= cap) return String(text ?? '');
144
+ let end = Math.max(0, cap);
145
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--; // back off continuation bytes
146
+ return buf.subarray(0, end).toString('utf8');
147
+ }
148
+
149
+ /** Deterministic slug for rename prefixes (`<memberSlug>-<name>`). */
150
+ function slug(s) {
151
+ const out = String(s ?? '')
152
+ .normalize('NFKD')
153
+ .replace(/[̀-ͯ]/g, '')
154
+ .toLowerCase()
155
+ .replace(/[^a-z0-9]+/g, '-')
156
+ .replace(/-{2,}/g, '-')
157
+ .replace(/^-+|-+$/g, '');
158
+ return out || 'member';
159
+ }
160
+
161
+ /** Key-sorted JSON, so "byte-identical definition" is order-independent. */
162
+ function stableStringify(value) {
163
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
164
+ if (value && typeof value === 'object') {
165
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
166
+ }
167
+ return JSON.stringify(value ?? null);
168
+ }
169
+
170
+ const byProjectKey = (a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0);
171
+
172
+ /** `git -C dir <args>` stdout, or null. Sync + fail-safe (mirrors store.mjs). */
173
+ function gitOut(dir, args) {
174
+ try {
175
+ return execFileSync('git', ['-C', dir, ...args], { stdio: ['ignore', 'pipe', 'ignore'] })
176
+ .toString().trim() || null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /** True when `child` is `parent` or below it (both resolved, no realpath games). */
183
+ function isUnder(child, parent) {
184
+ const c = resolve(child);
185
+ const p = resolve(parent);
186
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
187
+ }
188
+
189
+ // ── §5.4 memory discovery ───────────────────────────────────────────────────
190
+
191
+ /**
192
+ * The ordered memory sources of ONE directory (§5.4): `CLAUDE.md`,
193
+ * `.claude/CLAUDE.md`, `.claude/rules/*.md` (lexicographic), `CLAUDE.local.md`.
194
+ * ENOENT-tolerant per source AND per directory (§8.20) — a missing dir yields [].
195
+ * A REAL error (EACCES on the dir or on a candidate) is reported through `onError`
196
+ * and that source is skipped; discovery never throws.
197
+ * @param {string} dir
198
+ * @param {(p:string, err:object)=>void} [onError]
199
+ * @returns {Promise<Array<{path:string, rel:string}>>}
200
+ */
201
+ export async function discoverMemorySources(dir, onError) {
202
+ if (!dir || !(await isDir(dir, onError))) return [];
203
+ const out = [];
204
+ const add = async (rel) => {
205
+ const abs = join(dir, rel);
206
+ try { if ((await stat(abs)).isFile()) out.push({ path: abs, rel }); }
207
+ catch (err) { if (!isAbsent(err)) onError?.(abs, err); }
208
+ };
209
+ await add(CLAUDE_MD_FILE);
210
+ await add(join('.claude', CLAUDE_MD_FILE));
211
+ const rules = (await readdirMaybe(join(dir, '.claude', 'rules'), onError))
212
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
213
+ .map((e) => e.name)
214
+ .sort();
215
+ for (const name of rules) await add(join('.claude', 'rules', name));
216
+ await add('CLAUDE.local.md');
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * §8.21: the COMMITTED project sub-agents of one checkout — the `.md` files in
222
+ * `<dir>/.claude/agents`, sorted. `dir` is a member's WORKTREE, which materializes
223
+ * committed blobs only (E6), so this is exactly the set that WOULD be discoverable
224
+ * if that checkout were the cwd — and worca-cc never injects `.claude/agents` (only
225
+ * skills, which live at the run root in workspace mode), so there are no false
226
+ * positives. ENOENT-tolerant like every other reader here: a project with no agents
227
+ * yields []. Pure read; never throws. Exported for testing.
228
+ * @param {string} dir
229
+ * @param {(p:string, err:object)=>void} [onError]
230
+ * @returns {Promise<string[]>}
231
+ */
232
+ export async function discoverProjectAgents(dir, onError) {
233
+ if (!dir) return [];
234
+ return (await readdirMaybe(join(dir, '.claude', 'agents'), onError))
235
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
236
+ .map((e) => e.name)
237
+ .sort();
238
+ }
239
+
240
+ /**
241
+ * §8.19: the COMMITTED project settings of one checkout — the top-level keys of
242
+ * `<dir>/.claude/settings.json`. `dir` is a member's WORKTREE (committed blobs only,
243
+ * E6), which is exactly the file the CLI WOULD have loaded back when a workspace
244
+ * node's cwd was the config-source member's checkout; under §5.3 the cwd is the run
245
+ * root, which has no project settings file, so nothing in it applies to any node.
246
+ *
247
+ * Returns `null` when there is no such file — absence is normal and silent (§8.20) —
248
+ * and `{keys:null}` when the file EXISTS but does not parse: we cannot prove an
249
+ * unreadable-shaped file carries nothing, so the caller still warns. Never throws.
250
+ * Exported for testing.
251
+ *
252
+ * `permissions` carries the file's DENY rules (see pickPermissions), which the
253
+ * caller lifts into the run's merged `--settings` payload for members that honor
254
+ * their project settings; null when there are none to lift.
255
+ * @param {string} dir
256
+ * @param {(p:string, err:object)=>void} [onError]
257
+ * @returns {Promise<{file:string, keys:string[]|null, permissions:{deny:string[]}|null}|null>}
258
+ */
259
+ export async function discoverProjectSettings(dir, onError) {
260
+ if (!dir) return null;
261
+ const file = join(dir, '.claude', 'settings.json');
262
+ const body = await readTextMaybe(file, onError);
263
+ if (body === null) return null; // absent, or unreadable + already named
264
+ try {
265
+ const parsed = JSON.parse(body);
266
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { file, keys: null, permissions: null };
267
+ return { file, keys: Object.keys(parsed).sort(), permissions: pickPermissions(parsed.permissions) };
268
+ } catch {
269
+ return { file, keys: null, permissions: null };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The string-filtered DENY rules of a settings `permissions` value, as `{deny}`;
275
+ * null when there are none. allow/ask are NEVER lifted: project-scope `allow`
276
+ * rules are ignored in headless `-p` mode until the workspace-trust dialog, but
277
+ * the CLI `--settings` payload is user-scope and IS applied — lifting them would
278
+ * let a committed repo file widen a read-only role beyond `--allowedTools`
279
+ * (Global Constraint). Deny is pure restriction (deny rules only ever ADD
280
+ * restrictions), so deny alone is safe to lift.
281
+ */
282
+ function pickPermissions(p) {
283
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
284
+ const deny = Array.isArray(p.deny) ? p.deny.filter((r) => typeof r === 'string' && r.trim()) : [];
285
+ return deny.length ? { deny } : null;
286
+ }
287
+
288
+ // ── §5.4 `@import` resolution ───────────────────────────────────────────────
289
+
290
+ // `@path` only counts at the start of a line or after whitespace, so `foo@bar`
291
+ // (an email, a scoped npm range) is never mistaken for an import. A FRESH regex per
292
+ // scan is mandatory: resolution recurses (inline -> walk -> resolveSegment), and a
293
+ // shared /g literal's mutable lastIndex would be reset by the nested call and spin
294
+ // the outer loop forever.
295
+ const importRe = () => /(^|\s)@([^\s`]+)/g;
296
+
297
+ /**
298
+ * Resolve `@path` imports RECURSIVELY and inline their content (§5.4). Relative to
299
+ * the containing file, `~` expanded against `homeDir`, capped at MAX_IMPORT_DEPTH
300
+ * levels (which is also what terminates an import cycle), skipping fenced code
301
+ * blocks and inline code spans. Worca CC resolving imports itself is what sidesteps
302
+ * E7's VERIFIED negative (an import resolving outside the working dir is silently
303
+ * dead headless) with no import rewriting at all.
304
+ *
305
+ * Unresolvable imports are LEFT AS LITERAL TEXT and reported, never dropped.
306
+ * @param {string} text
307
+ * @param {string} containingFile absolute path the relative specs resolve against
308
+ * @param {number} [depth] current nesting level (0 for a top-level file)
309
+ * @param {{homeDir?:string}} [opts]
310
+ * @returns {Promise<{text:string, unresolved:string[]}>}
311
+ */
312
+ export async function resolveImports(text, containingFile, depth = 0, { homeDir = '' } = {}) {
313
+ const unresolved = [];
314
+
315
+ const inline = async (spec, file, d) => {
316
+ if (d >= MAX_IMPORT_DEPTH) {
317
+ unresolved.push(`@${spec} not inlined: the @import depth cap of ${MAX_IMPORT_DEPTH} was reached`);
318
+ return `@${spec}`;
319
+ }
320
+ const target = spec.startsWith('~')
321
+ ? join(homeDir || '', spec.slice(1).replace(/^[/\\]/, ''))
322
+ : isAbsolute(spec) ? spec : resolve(dirname(file), spec);
323
+ // An import that is merely absent and one that exists-but-is-unreadable are
324
+ // both left literal, but they are DIFFERENT operator problems, so the reported
325
+ // reason says which (the sink appends the errno for the second case).
326
+ let reason = '';
327
+ const body = await readTextMaybe(target, (_p, err) => { reason = ` — ${err?.code || err?.message}`; });
328
+ if (body === null) {
329
+ unresolved.push(`@${spec} could not be resolved (looked at ${target})${reason}`);
330
+ return `@${spec}`;
331
+ }
332
+ return walk(body, target, d + 1);
333
+ };
334
+
335
+ const resolveSegment = async (seg, file, d) => {
336
+ if (!seg.includes('@')) return seg;
337
+ let out = '';
338
+ let last = 0;
339
+ const re = importRe();
340
+ let m;
341
+ while ((m = re.exec(seg)) !== null) {
342
+ out += seg.slice(last, m.index) + m[1];
343
+ last = m.index + m[0].length;
344
+ out += await inline(m[2], file, d);
345
+ }
346
+ return out + seg.slice(last);
347
+ };
348
+
349
+ const resolveLine = async (line, file, d) => {
350
+ // Odd indices are inline code spans — never imports.
351
+ const parts = String(line).split(/(`[^`]*`)/);
352
+ for (let i = 0; i < parts.length; i += 2) parts[i] = await resolveSegment(parts[i], file, d);
353
+ return parts.join('');
354
+ };
355
+
356
+ async function walk(body, file, d) {
357
+ const lines = String(body ?? '').split('\n');
358
+ const out = [];
359
+ let fenced = false;
360
+ for (const line of lines) {
361
+ if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; out.push(line); continue; }
362
+ out.push(fenced ? line : await resolveLine(line, file, d));
363
+ }
364
+ return out.join('\n');
365
+ }
366
+
367
+ return { text: await walk(text, containingFile, depth), unresolved };
368
+ }
369
+
370
+ // ── §5.6 skills assembly ────────────────────────────────────────────────────
371
+
372
+ /** Rewrite a SKILL.md frontmatter `name:` so it matches the mounted directory (V2). */
373
+ function rewriteSkillName(text, name) {
374
+ const s = String(text ?? '');
375
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(s);
376
+ if (!m) return null; // no frontmatter: leave the file alone
377
+ const body = m[1];
378
+ const next = /^name:[ \t]*.*$/m.test(body)
379
+ ? body.replace(/^name:[ \t]*.*$/m, `name: ${name}`)
380
+ : `name: ${name}\n${body}`;
381
+ return s.slice(0, m.index) + `---\n${next}\n---` + s.slice(m.index + m[0].length);
382
+ }
383
+
384
+ /** Top-level entry names tracked under `.claude/skills` in a checkout (§5.6 guard). */
385
+ function trackedSkillNames(worktreeDir) {
386
+ if (!worktreeDir) return new Set();
387
+ const out = gitOut(worktreeDir, ['ls-files', '--', join('.claude', 'skills')]);
388
+ if (!out) return new Set();
389
+ const prefix = `${join('.claude', 'skills')}/`;
390
+ return new Set(
391
+ out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith(prefix))
392
+ .map((l) => l.slice(prefix.length).split('/')[0]).filter(Boolean),
393
+ );
394
+ }
395
+
396
+ /** Candidate skill entries of one `<dir>/.claude/skills`, sorted, ENOENT-tolerant. */
397
+ async function skillCandidates(dir, onError) {
398
+ return (await readdirMaybe(join(dir, '.claude', 'skills'), onError))
399
+ .filter((e) => !e.name.startsWith('.'))
400
+ .map((e) => e.name)
401
+ .sort()
402
+ .map((name) => ({ name, source: join(dir, '.claude', 'skills', name) }));
403
+ }
404
+
405
+ /**
406
+ * Mount every skill entry into ONE target `.claude/skills` dir (§5.6).
407
+ *
408
+ * Entry order IS the collision-precedence order: (1) each member's real dir,
409
+ * members sorted by projectKey; (2) `<projectsRoot>/.claude/skills` (skipped in the
410
+ * home special case — E4 puts `~/.claude/skills` on the scan path regardless of
411
+ * cwd); (3) `requiresSkills` resolutions whose source is `bundle` / `plugin:*`.
412
+ * First occupant keeps the bare name; later occupants are renamed by SOURCE CLASS
413
+ * (`<memberSlug>-`, `root-`, `worca-cc-`) with a deterministic numeric tiebreak, and
414
+ * their SKILL.md frontmatter `name:` is rewritten to match (gated on V2, PASSED).
415
+ * Dropping is forbidden — R2 requires the skills of ALL members.
416
+ *
417
+ * Delivery is a COPY by default (isolation: an agent edit lands in the disposable
418
+ * tree, not the user's live checkout); `skillMount:'symlink'` is the opt-in
419
+ * write-through variant (E12). A RENAMED entry always falls back to a copy even
420
+ * under symlink mode, because the frontmatter rewrite would otherwise mutate the
421
+ * user's real source file.
422
+ *
423
+ * @returns {Promise<{names:string[], records:Array<object>, renames:Record<string,string>,
424
+ * roster:Array<object>, warnings:string[]}>}
425
+ */
426
+ export async function assembleSkills({
427
+ target, members = [], projectsRoot, resolutions, homeDir,
428
+ mount = 'copy', trackedNames = new Set(), skipRoot = false,
429
+ }) {
430
+ const warnings = [];
431
+ const onError = fsWarner(warnings); // ENOENT stays silent; a real error is named
432
+ const names = [];
433
+ const records = [];
434
+ const renames = {};
435
+ const roster = [];
436
+
437
+ /** @type {Array<{name:string, source:string, cls:'member'|'root'|'worca-cc', prefix:string, origin:string}>} */
438
+ const candidates = [];
439
+ for (const m of [...members].sort(byProjectKey)) {
440
+ for (const c of await skillCandidates(m.projectDir, onError)) {
441
+ candidates.push({
442
+ ...c, cls: 'member', prefix: `${slug(m.projectName || m.projectKey)}-`,
443
+ origin: `${m.projectName || m.projectKey} (repos/${m.projectKey})`,
444
+ });
445
+ }
446
+ }
447
+ // Entry class 2 is skipped in the home special case: E4 puts `~/.claude/skills` on
448
+ // the scan path regardless of cwd, so copying it would only duplicate names.
449
+ const rootIsHome = skipRoot ||
450
+ (!!projectsRoot && !!homeDir && resolve(projectsRoot) === resolve(homeDir));
451
+ if (!rootIsHome && projectsRoot) {
452
+ for (const c of await skillCandidates(projectsRoot, onError)) {
453
+ candidates.push({ ...c, cls: 'root', prefix: 'root-', origin: `root layer \`${projectsRoot}\`` });
454
+ }
455
+ }
456
+ for (const [name, r] of resolutionEntries(resolutions)) {
457
+ // S2 (the manifest-rehydration half). On a resume these entries come off DISK
458
+ // (`run.json.skillResolutions`) and `name` becomes a path segment in
459
+ // `join(target, effective)` below — a corrupt or hand-edited manifest must not
460
+ // turn the mount into a write-anywhere primitive. §8.20 posture: SKIP the entry
461
+ // and NAME it; a throw here would make a paused run unresumable. The loud half
462
+ // lives at the other choke point (validateSkills, skills.mjs), where the name
463
+ // is still the author's to fix.
464
+ if (!isValidSkillName(name)) {
465
+ warnings.push(
466
+ `skill resolution ${JSON.stringify(name)} was skipped: invalid skill name. A skill name ` +
467
+ 'becomes a directory under `.claude/skills/`, so it may contain only letters, digits, `.`, ' +
468
+ '`_` and `-` — this entry (from `run.json`) was not mounted.',
469
+ );
470
+ continue;
471
+ }
472
+ const src = String(r?.source || '');
473
+ if (src !== 'bundle' && !src.startsWith('plugin:')) continue; // already on the scan path
474
+ if (!r?.path) continue;
475
+ candidates.push({ name, source: r.path, cls: 'worca-cc', prefix: 'worca-cc-', origin: `worca-cc ${src}` });
476
+ }
477
+
478
+ // Tracked names occupy the namespace: a rename may never land on one either.
479
+ const taken = new Set(trackedNames);
480
+
481
+ for (const cand of candidates) {
482
+ if (trackedNames.has(cand.name)) {
483
+ warnings.push(
484
+ `skill \`${cand.name}\` is tracked in the checkout — keeping the project's own committed ` +
485
+ `version and skipping the mount from \`${cand.source}\`.`,
486
+ );
487
+ continue;
488
+ }
489
+ let effective = cand.name;
490
+ if (taken.has(effective)) {
491
+ const base = `${cand.prefix}${cand.name}`;
492
+ effective = base;
493
+ for (let n = 2; taken.has(effective); n++) effective = `${base}-${n}`;
494
+ }
495
+ const renamed = effective !== cand.name;
496
+ const dest = join(target, effective);
497
+ // Symlink mode cannot carry a rename: the frontmatter rewrite would land in the
498
+ // user's real source file. Renamed entries therefore stay copies.
499
+ const asLink = mount === 'symlink' && !renamed;
500
+ try {
501
+ const st = await stat(cand.source); // follows symlinks: a dangling one throws
502
+ if (!st.isDirectory()) continue; // not a skill entry; silently ignored
503
+ await mkdir(target, { recursive: true });
504
+ await rm(dest, { recursive: true, force: true }); // idempotent re-assembly (§5.2)
505
+ if (asLink) await symlink(cand.source, dest);
506
+ else await cp(cand.source, dest, { recursive: true, force: true, dereference: true });
507
+ } catch (err) {
508
+ warnings.push(`skill \`${cand.name}\` could not be mounted from \`${cand.source}\`: ${err?.message || err}`);
509
+ continue;
510
+ }
511
+ if (renamed) {
512
+ const file = join(dest, 'SKILL.md');
513
+ const body = await readTextMaybe(file, onError);
514
+ const next = body === null ? null : rewriteSkillName(body, effective);
515
+ if (next !== null) await writeFile(file, next, 'utf8');
516
+ renames[effective] = cand.name;
517
+ }
518
+ taken.add(effective);
519
+ names.push(effective);
520
+ records.push({
521
+ path: join('.claude', 'skills', effective),
522
+ source: cand.source,
523
+ kind: 'skill',
524
+ ...(asLink ? { mount: 'symlink' } : {}),
525
+ });
526
+ roster.push({ name: effective, origin: cand.origin, renamedFrom: renamed ? cand.name : null });
527
+ }
528
+
529
+ if (mount === 'symlink' && names.length) {
530
+ warnings.push(
531
+ 'skillMount is set to `symlink`: mounted skills are WRITE-THROUGH, so an agent edit to a ' +
532
+ "skill lands in your real directory and appears in no run diff. Set `skillMount: 'copy'` " +
533
+ 'to restore isolation.',
534
+ );
535
+ }
536
+ return { names, records, renames, roster, warnings };
537
+ }
538
+
539
+ /** Accept the resolutions map as a Map (fresh run) or a plain object (from run.json). */
540
+ function resolutionEntries(resolutions) {
541
+ if (resolutions instanceof Map) return [...resolutions.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1));
542
+ if (resolutions && typeof resolutions === 'object') {
543
+ return Object.keys(resolutions).sort().map((k) => [k, resolutions[k]]);
544
+ }
545
+ return [];
546
+ }
547
+
548
+ // ── §5.5 MCP merge ──────────────────────────────────────────────────────────
549
+
550
+ /** Collapse `__` runs so `mcp__<server>__<tool>` can never mis-split (§5.5, §7.1). */
551
+ function normalizeServerName(raw) {
552
+ const n = String(raw ?? '').replace(/__+/g, '_').replace(/^_+|_+$/g, '');
553
+ return n || 'mcp-server';
554
+ }
555
+
556
+ /**
557
+ * Read one `.mcp.json`-shaped file. Absence is SILENT (`{servers:null}` with neither
558
+ * error set — a member with no config is normal). A real read failure surfaces as
559
+ * `readError` and an unparseable body as `parseError`, so the caller can warn by name
560
+ * for both instead of treating "unreadable" as "absent" (§5.5, §8.16).
561
+ * @returns {Promise<{servers:object|null, parseError:string|null, readError:string|null}>}
562
+ */
563
+ async function readMcpFile(file) {
564
+ let readError = null;
565
+ const text = await readTextMaybe(file, (_p, err) => { readError = err?.code || err?.message || 'read failed'; });
566
+ if (text === null) return { servers: null, parseError: null, readError };
567
+ let data;
568
+ try { data = JSON.parse(text); }
569
+ catch (err) { return { servers: null, parseError: err?.message || 'invalid JSON', readError: null }; }
570
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
571
+ return { servers: null, parseError: 'not a JSON object', readError: null };
572
+ }
573
+ const raw = data.mcpServers ?? data;
574
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { servers: null, parseError: null, readError: null };
575
+ const servers = {};
576
+ for (const [k, v] of Object.entries(raw)) if (v && typeof v === 'object' && !Array.isArray(v)) servers[k] = v;
577
+ return { servers, parseError: null, readError: null };
578
+ }
579
+
580
+ /** Is `s` a path (to absolutize against `dir`) rather than a flag/subcommand/URL? */
581
+ async function pathLikeArg(s, dir) {
582
+ if (typeof s !== 'string' || !s || s.startsWith('-')) return false;
583
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) return false; // url
584
+ if (isAbsolute(s)) return false; // already absolute
585
+ if (s.startsWith('./') || s.startsWith('../') || s.includes('/')) return true;
586
+ // SPECULATIVE probe, so no error sink: `server.js` next to the config IS a path,
587
+ // `serve` is a subcommand. A stat failure only means "assume not a path" — warning
588
+ // about a string that may not be a filename at all would be pure noise.
589
+ return exists(join(dir, s));
590
+ }
591
+
592
+ /**
593
+ * Apply §5.5's transforms to ONE server definition, against its source dir.
594
+ * Returns `{ def, warnings }`. `env` / `type` / `url` / `headers` pass through —
595
+ * the CLI expands `${VAR}` / `${VAR:-default}` itself (E5).
596
+ */
597
+ async function transformServer(name, raw, dir, platform) {
598
+ const warnings = [];
599
+ const sub = (v) => (typeof v === 'string' ? v.split('${CLAUDE_PROJECT_DIR}').join(dir) : v);
600
+ const def = {};
601
+ for (const [k, v] of Object.entries(raw)) {
602
+ def[k] = Array.isArray(v)
603
+ ? v.map(sub)
604
+ : v && typeof v === 'object'
605
+ ? Object.fromEntries(Object.entries(v).map(([ek, ev]) => [ek, sub(ev)]))
606
+ : sub(v);
607
+ }
608
+
609
+ // Detect the "needs a project cwd" shape BEFORE absolutization erases it.
610
+ const rawCommand = typeof raw.command === 'string' ? sub(raw.command) : null;
611
+ const relCommand = !!rawCommand && !isAbsolute(rawCommand) && await pathLikeArg(rawCommand, dir);
612
+ const declaredCwd = typeof def.cwd === 'string' && def.cwd ? resolve(dir, def.cwd) : null;
613
+ const needsCwd = relCommand || !!declaredCwd;
614
+
615
+ if (rawCommand && relCommand) def.command = resolve(dir, rawCommand);
616
+ if (Array.isArray(def.args)) {
617
+ const args = [];
618
+ for (const a of def.args) args.push((await pathLikeArg(a, dir)) ? resolve(dir, a) : a);
619
+ def.args = args;
620
+ }
621
+ if (declaredCwd) def.cwd = declaredCwd;
622
+
623
+ if (needsCwd && typeof def.command === 'string') {
624
+ if (platform === 'win32') {
625
+ // /bin/sh does not exist on Windows, so the wrap is unavailable. Include the
626
+ // server un-wrapped and name the remedy (§5.5 platform scope, §8.16).
627
+ warnings.push(
628
+ `server \`${name}\` declares a project cwd; the POSIX cd-wrap is unavailable on Windows, ` +
629
+ 'so it may fail to spawn — make its command path-independent or run worca under WSL.',
630
+ );
631
+ } else {
632
+ // ARGV-SAFE: $1 is the dir, "$@" the original command + args. Every element
633
+ // stays a distinct argv slot, so no path content (spaces, quotes) is ever
634
+ // re-parsed by a shell.
635
+ const original = [def.command, ...(Array.isArray(def.args) ? def.args : [])];
636
+ def.command = '/bin/sh';
637
+ def.args = ['-c', 'cd "$1" && shift && exec "$@"', 'worca-cc-cd-wrap', declaredCwd || dir, ...original];
638
+ }
639
+ }
640
+ return { def, warnings };
641
+ }
642
+
643
+ /**
644
+ * Merge every reachable MCP server into ONE config object (§5.5).
645
+ *
646
+ * Pinned global read order — which also fixes who is "first occupant" on a name
647
+ * collision: for each member in sorted-projectKey order, that member's PROJECT
648
+ * scope then its LOCAL scope; after all members, the ROOT layer once, LAST (so a
649
+ * root server never silently claims a name a member also uses).
650
+ *
651
+ * @returns {Promise<{servers:Record<string,object>, renames:{mcpServers:Record<string,string>},
652
+ * roster:Array<object>, nativeOnly:string[], warnings:string[]}>}
653
+ * `nativeOnly` are names whose generated definition was SKIPPED because the
654
+ * committed `.mcp.json` at cwd carries a byte-identical one; they are still
655
+ * effective in the session, so the caller must still grant them.
656
+ */
657
+ export async function mergeMcpConfigs({
658
+ members = [], projectsRoot, homeDir, isWorkspace = false, platform = process.platform,
659
+ }) {
660
+ const warnings = [];
661
+ const onError = fsWarner(warnings); // ENOENT stays silent; a real error is named
662
+ const servers = {};
663
+ const renames = {};
664
+ const roster = [];
665
+ const nativeOnly = [];
666
+ /** base name -> [{ effective, hash }] so a redundant later copy is de-duped, not renamed. */
667
+ const byBase = new Map();
668
+ const taken = new Set();
669
+
670
+ const sorted = [...members].sort(byProjectKey);
671
+
672
+ // ── the pinned source list ────────────────────────────────────────────────
673
+ /** @type {Array<{cls:'member'|'root', member?:object, dir:string, scope:'project'|'local'}>} */
674
+ const sources = [];
675
+ for (const m of sorted) {
676
+ sources.push({ cls: 'member', member: m, dir: m.projectDir, scope: 'project' });
677
+ sources.push({ cls: 'member', member: m, dir: m.projectDir, scope: 'local' });
678
+ }
679
+ if (projectsRoot) sources.push({ cls: 'root', dir: projectsRoot, scope: 'project' });
680
+
681
+ // ── single-mode cross-scope map: what the WORKTREE's committed .mcp.json says ──
682
+ // In single mode cwd is the worktree, so a committed `.mcp.json` materializes
683
+ // there and is discovered natively (E6 makes that copy stale by design). V3(d)
684
+ // recorded that the `--mcp-config` definition WINS (the native server's process
685
+ // is never even spawned), so the generated entry is always the effective one.
686
+ const committedByMember = new Map();
687
+ if (!isWorkspace) {
688
+ for (const m of sorted) {
689
+ if (!m.worktreeDir) continue;
690
+ const committedFile = join(m.worktreeDir, '.mcp.json');
691
+ const { servers: cs, parseError, readError } = await readMcpFile(committedFile);
692
+ // Without the committed side we cannot run the V3(d) comparison at all, so an
693
+ // unreadable/unparseable one is named rather than silently treated as absent.
694
+ if (readError) onError(committedFile, { code: readError });
695
+ else if (parseError) warnings.push(`\`${committedFile}\` could not be parsed (${parseError}); the cross-scope duplicate check (V3(d)) was skipped for this member.`);
696
+ if (!cs) continue;
697
+ const map = new Map();
698
+ for (const [k, v] of Object.entries(cs)) map.set(normalizeServerName(k), stableStringify(v));
699
+ committedByMember.set(m.projectKey, map);
700
+ }
701
+ }
702
+
703
+ let localStore; // parsed ~/.claude.json, read once
704
+ let localStoreError = null;
705
+
706
+ for (const src of sources) {
707
+ /** @type {Record<string, object>|null} */
708
+ let raw = null;
709
+ if (src.scope === 'project') {
710
+ const file = join(src.dir, '.mcp.json');
711
+ const { servers: s, parseError, readError } = await readMcpFile(file);
712
+ if (readError) { onError(file, { code: readError }); continue; }
713
+ if (parseError) {
714
+ warnings.push(`\`${file}\` could not be parsed (${parseError}); its MCP servers are not delivered.`);
715
+ continue;
716
+ }
717
+ raw = s;
718
+ } else {
719
+ // §5.5 source 3 (V4): local scope is the DEFAULT scope of `claude mcp add`, so
720
+ // it is the most common per-project configuration and is never skipped
721
+ // silently. V4 CORRECTION: `projects[<key>]` is keyed by the GIT ROOT
722
+ // containing the path, not the path itself — the CLI's "[project: …]" line
723
+ // reports cwd but the write lands under `git rev-parse --show-toplevel`. A
724
+ // member pointed at a repo SUBDIRECTORY would otherwise harvest nothing,
725
+ // silently, which is exactly what §5.5 forbids.
726
+ const m = src.member;
727
+ if (localStore === undefined) {
728
+ // ABSENT means the CLI was never run here — normal, silent. EXISTS-BUT-
729
+ // UNREADABLE is §5.5's explicit "read error" clause: it must warn by member,
730
+ // because otherwise one `chmod` silently removes every user's local-scope
731
+ // servers (the DEFAULT scope of `claude mcp add`) with the run still green.
732
+ let readError = null;
733
+ const text = await readTextMaybe(
734
+ join(homeDir || '', '.claude.json'),
735
+ (_p, err) => { readError = `read failed: ${err?.code || err?.message}`; },
736
+ );
737
+ if (text === null) { localStore = null; localStoreError = readError; }
738
+ else {
739
+ try {
740
+ const parsed = JSON.parse(text);
741
+ localStore = parsed && typeof parsed === 'object' ? parsed : null;
742
+ if (!localStore) localStoreError = 'not a JSON object';
743
+ } catch (err) { localStore = null; localStoreError = err?.message || 'invalid JSON'; }
744
+ }
745
+ }
746
+ if (localStore === null) {
747
+ if (localStoreError) warnings.push(localScopeWarning(m, homeDir, localStoreError));
748
+ continue;
749
+ }
750
+ const projects = localStore.projects;
751
+ if (!projects || typeof projects !== 'object' || Array.isArray(projects)) {
752
+ warnings.push(localScopeWarning(m, homeDir, 'no `projects` object'));
753
+ continue;
754
+ }
755
+ const key = gitOut(m.projectDir, ['rev-parse', '--show-toplevel']) || resolve(m.projectDir);
756
+ const entry = projects[key] ?? projects[resolve(m.projectDir)];
757
+ if (!entry || typeof entry !== 'object') continue; // no local config: normal
758
+ if (entry.mcpServers === undefined) continue; // normal
759
+ if (!entry.mcpServers || typeof entry.mcpServers !== 'object' || Array.isArray(entry.mcpServers)) {
760
+ warnings.push(localScopeWarning(m, homeDir, 'unexpected shape'));
761
+ continue;
762
+ }
763
+ raw = {};
764
+ for (const [k, v] of Object.entries(entry.mcpServers)) {
765
+ if (v && typeof v === 'object' && !Array.isArray(v)) raw[k] = v;
766
+ }
767
+ }
768
+ if (!raw || !Object.keys(raw).length) continue;
769
+
770
+ const originLabel = src.cls === 'root'
771
+ ? `root layer \`${join(src.dir, '.mcp.json')}\``
772
+ : `${src.member.projectName || src.member.projectKey} (${src.scope} scope)`;
773
+ const prefix = src.cls === 'root' ? 'root-' : `${slug(src.member.projectName || src.member.projectKey)}-`;
774
+
775
+ for (const rawName of Object.keys(raw).sort()) {
776
+ const base = normalizeServerName(rawName);
777
+ const { def, warnings: tw } = await transformServer(base, raw[rawName], src.dir, platform);
778
+ for (const w of tw) warnings.push(w);
779
+ const hash = stableStringify(def);
780
+
781
+ // Cross-scope duplicate (single mode, member sources only) — V3(d).
782
+ const committed = src.cls === 'member' ? committedByMember.get(src.member.projectKey) : null;
783
+ if (committed && committed.has(base)) {
784
+ if (committed.get(base) === stableStringify(raw[rawName])) {
785
+ if (!nativeOnly.includes(base) && !taken.has(base)) {
786
+ nativeOnly.push(base);
787
+ taken.add(base);
788
+ roster.push({
789
+ name: base, origin: `${originLabel} — identical to the committed \`.mcp.json\` at cwd`,
790
+ renamedFrom: null,
791
+ });
792
+ }
793
+ continue; // nothing to disambiguate
794
+ }
795
+ warnings.push(
796
+ `server \`${base}\` is defined both in the committed \`.mcp.json\` and in your working ` +
797
+ 'copy; V3(d) recorded **config** scope as effective for this CLI version, so the ' +
798
+ 'generated definition is the one that runs.',
799
+ );
800
+ }
801
+
802
+ // Byte-identical definition already delivered under this base name? de-dup.
803
+ const seen = byBase.get(base) || [];
804
+ if (seen.some((e) => e.hash === hash)) continue;
805
+
806
+ let effective = base;
807
+ if (taken.has(effective)) {
808
+ const renameBase = normalizeServerName(`${prefix}${base}`);
809
+ effective = renameBase;
810
+ for (let n = 2; taken.has(effective); n++) effective = `${renameBase}-${n}`;
811
+ }
812
+ if (effective !== rawName) renames[effective] = rawName; // incl. `__`-normalization
813
+ servers[effective] = def;
814
+ taken.add(effective);
815
+ byBase.set(base, [...seen, { effective, hash }]);
816
+ roster.push({ name: effective, origin: originLabel, renamedFrom: effective === base ? null : base });
817
+ }
818
+ }
819
+
820
+ return { servers, renames: { mcpServers: renames }, roster, nativeOnly, warnings };
821
+ }
822
+
823
+ function localScopeWarning(member, homeDir, why) {
824
+ return (
825
+ `member \`${member.projectKey}\`: local-scope MCP servers could not be read from ` +
826
+ `\`${join(homeDir || '~', '.claude.json')}\` (${why}); promote them to ` +
827
+ `\`${join(member.projectDir, '.mcp.json')}\` to guarantee delivery.`
828
+ );
829
+ }
830
+
831
+ // ── §8.6 ancestor audit ─────────────────────────────────────────────────────
832
+
833
+ /**
834
+ * Every `CLAUDE.md` / `.claude/skills` between `<runRoot>`'s PARENT and `/` (§8.6).
835
+ * The CLAUDE.md walk crosses git roots all the way up (E1 VERIFIED), so anything
836
+ * found here loads into every node of the run. Worca CC never creates such a file
837
+ * above a run root; this makes a pre-existing one visible instead of mysterious.
838
+ * @returns {Promise<string[]>} warnings
839
+ */
840
+ export async function auditAncestors(runRoot) {
841
+ const warnings = [];
842
+ if (!runRoot) return warnings;
843
+ let dir = dirname(resolve(runRoot));
844
+ const seen = new Set();
845
+ while (dir && !seen.has(dir)) {
846
+ seen.add(dir);
847
+ const md = join(dir, CLAUDE_MD_FILE);
848
+ if (await exists(md)) {
849
+ warnings.push(`ancestor memory found at \`${md}\` — it loads into every node of this run (§8.6).`);
850
+ }
851
+ const sk = join(dir, '.claude', 'skills');
852
+ if (await isDir(sk)) {
853
+ warnings.push(`ancestor skills found at \`${sk}\` — they are visible to this run (§8.6).`);
854
+ }
855
+ const parent = dirname(dir);
856
+ if (parent === dir) break;
857
+ dir = parent;
858
+ }
859
+ return warnings;
860
+ }
861
+
862
+ // ── §5.4 the generated document ─────────────────────────────────────────────
863
+
864
+ /**
865
+ * Compose `<runRoot>/CLAUDE.md` (§5.4) from already-capped sources plus the run
866
+ * rosters, applying the TOTAL byte budget. When that budget binds, the roster and
867
+ * preamble are trimmed BEFORE any project memory — instructions are never
868
+ * sacrificed to metadata (§5.4 (ii) / §8.7).
869
+ *
870
+ * Deterministic: no timestamps in the body; the pipelineId in the title is the only
871
+ * run-specific token.
872
+ * @returns {{text:string, warnings:string[]}}
873
+ */
874
+ export function generateClaudeMd({
875
+ pipelineId, projectsRoot, members = [], graphInstructions,
876
+ rootSections = [], memberSections = new Map(),
877
+ skills = [], servers = [], projectAgents, maxBytesTotal = Infinity,
878
+ }) {
879
+ const warnings = [];
880
+ const gi = (key) => (graphInstructions instanceof Map ? graphInstructions.get(key) : graphInstructions?.[key]) || '';
881
+ // §8.21 carriers: `projectKey -> [agent file names]`, empty on single-project and
882
+ // legacy runs (nothing is lost there), so their generated docs are unchanged.
883
+ const agentsOf = (key) => {
884
+ const v = projectAgents instanceof Map ? projectAgents.get(key) : projectAgents?.[key];
885
+ return Array.isArray(v) ? v : [];
886
+ };
887
+ const agentCarriers = [...members].sort(byProjectKey).filter((m) => agentsOf(m.projectKey).length);
888
+
889
+ const meta = [];
890
+ meta.push(`# Worca CC run ${pipelineId}`, '');
891
+ meta.push(
892
+ 'You are working in a **worca-cc run root**. All code edits happen inside ' +
893
+ '`repos/<projectKey>`, one git checkout per project below; the run root holds run ' +
894
+ "metadata only. This file is generated per run from those projects' real " +
895
+ 'directories — do not edit it.',
896
+ '',
897
+ );
898
+ meta.push('## Run layout', '');
899
+ meta.push('- `repos/<projectKey>/` — one git worktree per project in this run. **All code edits happen here.**');
900
+ meta.push('- `CLAUDE.md` (this file) — the generated context for the run.');
901
+ meta.push('- `mcp.json` — the merged MCP server config passed to this session.');
902
+ meta.push('- `run.json` — the run manifest (members, mounts, byte budget, warnings).');
903
+ meta.push('');
904
+ meta.push('## Projects in this run', '');
905
+ if (!members.length) meta.push('*(none)*', '');
906
+ for (const m of [...members].sort(byProjectKey)) {
907
+ meta.push(`- \`repos/${m.projectKey}\` — **${m.projectName || m.projectKey}**`);
908
+ meta.push(` - real directory: \`${m.projectDir}\``);
909
+ if (m.branch) meta.push(` - branch: \`${m.branch}\``);
910
+ if (m.checkpointRef) meta.push(` - checkpoint: \`${m.checkpointRef}\``);
911
+ const instruction = gi(m.projectKey).trim();
912
+ if (instruction) meta.push(` - graph: ${instruction.split('\n').join('\n ')}`);
913
+ const agents = agentsOf(m.projectKey);
914
+ if (agents.length) meta.push(` - project sub-agents NOT discoverable this run: ${agents.join(', ')}`);
915
+ }
916
+ meta.push('');
917
+ // §8.21: the same loss, stated once as a labeled note beside the roster, so the model
918
+ // never tries a `subagent_type` that cannot resolve. Absent entirely when no member
919
+ // carries committed agents.
920
+ if (agentCarriers.length) {
921
+ meta.push('## Project sub-agents NOT in force', '');
922
+ meta.push(
923
+ 'Your cwd is this run root, so **no member project\'s `.claude/agents` is discoverable ' +
924
+ 'by name** — a `subagent_type` naming one would fail to resolve:',
925
+ '',
926
+ );
927
+ for (const m of agentCarriers) {
928
+ meta.push(`- **${m.projectName || m.projectKey}** (\`repos/${m.projectKey}\`): ${agentsOf(m.projectKey).join(', ')}`);
929
+ }
930
+ meta.push('');
931
+ meta.push('Your personal `~/.claude/agents` still work (inherited environment); for anything ' +
932
+ 'else use `"general-purpose"` (or `"Explore"` for pure code search).', '');
933
+ }
934
+ meta.push('## Skills mounted for this run', '');
935
+ if (!skills.length) meta.push('*(none)*');
936
+ for (const s of skills) {
937
+ meta.push(`- \`${s.name}\` — from ${s.origin}${s.renamedFrom ? ` (renamed from \`${s.renamedFrom}\`)` : ''}`);
938
+ }
939
+ meta.push('');
940
+ meta.push('## MCP servers merged for this run', '');
941
+ meta.push(
942
+ 'MCP tools are deferred behind tool search in headless mode: search for ' +
943
+ '`mcp__<server>__<tool>` before calling it.',
944
+ '',
945
+ );
946
+ if (!servers.length) meta.push('*(none)*');
947
+ for (const s of servers) {
948
+ meta.push(`- \`${s.name}\` — from ${s.origin}${s.renamedFrom ? ` (renamed from \`${s.renamedFrom}\`)` : ''}`);
949
+ }
950
+ const metaText = `${meta.join('\n')}\n`;
951
+ const minimalMeta =
952
+ `# Worca CC run ${pipelineId}\n\n` +
953
+ 'All code edits happen inside `repos/<projectKey>`; the run root holds run metadata only.\n';
954
+
955
+ // ── memory block ────────────────────────────────────────────────────────
956
+ const mem = [];
957
+ if (rootSections.length) {
958
+ mem.push(`## Root instructions (${projectsRoot})`, '');
959
+ for (const s of rootSections) mem.push(`### ${s.rel}`, '', s.text.replace(/\s+$/, ''), '');
960
+ }
961
+ for (const m of [...members].sort(byProjectKey)) {
962
+ mem.push(`## Project: ${m.projectName || m.projectKey} — repos/${m.projectKey}`, '');
963
+ const sections = memberSections.get(m.projectKey) || [];
964
+ if (!sections.length) mem.push(NO_MEMORY_PLACEHOLDER, '');
965
+ for (const s of sections) mem.push(`### ${s.rel}`, '', s.text.replace(/\s+$/, ''), '');
966
+ }
967
+ let memText = mem.length ? `${mem.join('\n')}\n` : '';
968
+
969
+ // ── total budget ────────────────────────────────────────────────────────
970
+ let head = metaText;
971
+ const cap = Number.isFinite(maxBytesTotal) ? maxBytesTotal : Infinity;
972
+ if (enc(head) + enc(memText) > cap) {
973
+ warnings.push(
974
+ `generated run context is ${fmtNum(enc(head) + enc(memText))} bytes, above the ` +
975
+ `${fmtNum(cap)}-byte contextMaxBytesTotal budget; the run roster/preamble was trimmed ` +
976
+ 'before any project memory — raise contextMaxBytesTotal to keep the roster whole.',
977
+ );
978
+ const metaBudget = Math.max(0, cap - enc(memText));
979
+ head = enc(minimalMeta) <= metaBudget ? minimalMeta : truncateBytes(minimalMeta, metaBudget);
980
+ if (enc(memText) > cap) {
981
+ const marker = `\n*[worca: context truncated at ${fmtNum(cap)} bytes — raise contextMaxBytesTotal]*\n`;
982
+ head = '';
983
+ memText = truncateBytes(memText, Math.max(0, cap - enc(marker))) + marker;
984
+ }
985
+ }
986
+ const text = head && memText ? `${head}\n${memText}` : head || memText;
987
+ return { text, warnings };
988
+ }
989
+
990
+ // ── the public entry point ──────────────────────────────────────────────────
991
+
992
+ /**
993
+ * Assemble the whole run context at `<runRoot>` (§5.2 step 7 order: skills →
994
+ * mcp.json → CLAUDE.md → run.json).
995
+ *
996
+ * @param {object} a
997
+ * @param {string} a.runRoot `<worcaHome>/runs/<pipelineId>`
998
+ * @param {Array<{projectKey:string, projectName:string, projectDir:string, worktreeDir:string}>} a.members
999
+ * `projectDir` is the REAL dir (E6: only it carries uncommitted context).
1000
+ * @param {string} a.projectsRoot §5.1 root context layer
1001
+ * @param {boolean} a.isWorkspace
1002
+ * @param {Map<string,object>|object} a.requiredSkillResolutions
1003
+ * From validateSkills() — MAY BE EMPTY, which is every shipped workflow
1004
+ * today (`grep requiresSkills agents/` → zero hits). On resume this arrives
1005
+ * as the plain object persisted in `run.json.skillResolutions`.
1006
+ * @param {Map<string,string>|object} a.graphInstructions per-member graph instruction
1007
+ * @param {string} a.homeDir
1008
+ * @param {Map<string,boolean>|null} [a.honorByKey] per-member honorProjectSettings keyed by projectKey — null honors everyone
1009
+ * @param {string} [a.platform] injectable for the win32 branch
1010
+ * @returns {Promise<object>} the run-context record (also persisted into run.json)
1011
+ */
1012
+ export async function assembleRunContext({
1013
+ runRoot, members = [], projectsRoot, isWorkspace = false,
1014
+ requiredSkillResolutions, graphInstructions, homeDir, honorByKey = null,
1015
+ platform = process.platform,
1016
+ }) {
1017
+ const warnings = [];
1018
+ // ENOENT/ENOTDIR stay silent (absence is normal, §8.20); every OTHER fs error on a
1019
+ // declared context source is named here, once per (path, code), and the source
1020
+ // degrades exactly as an absent one.
1021
+ const onError = fsWarner(warnings);
1022
+ const pipelineId = basename(resolve(runRoot));
1023
+ // Each cap is read ONCE per assembly: a malformed persisted value warns on every
1024
+ // read, so a per-source read would print the same warning N times (Phase 2 note).
1025
+ const maxBytesPerFile = contextMaxBytesPerFile();
1026
+ const maxBytesTotal = contextMaxBytesTotal();
1027
+ const mount = skillMount();
1028
+
1029
+ const sorted = [...members].sort(byProjectKey);
1030
+ await mkdir(runRoot, { recursive: true });
1031
+
1032
+ // §8.20: a nonexistent projectsRoot (reachable through the unvalidated env tier,
1033
+ // §5.1) contributes nothing and warns ONCE — not once per read.
1034
+ const rootUsable = !!projectsRoot && (await isDir(projectsRoot, onError));
1035
+ if (projectsRoot && !rootUsable) {
1036
+ warnings.push(
1037
+ `projects root \`${projectsRoot}\` does not exist or could not be read — the root ` +
1038
+ 'context layer (memory, skills, MCP) contributes nothing to this run.',
1039
+ );
1040
+ }
1041
+ // The home special case: `<projectsRoot>/.claude/*` IS user scope, which loads
1042
+ // natively from the inherited environment regardless of cwd (E11) and is
1043
+ // therefore never inlined or copied (§5.4, §5.6).
1044
+ const rootIsHome = !!projectsRoot &&
1045
+ (resolve(projectsRoot) === resolve(homeDir || '') || resolve(projectsRoot) === resolve(defaultRoot()));
1046
+
1047
+ // §8.20: a member real dir deleted or moved (typically while the run sat paused)
1048
+ // degrades that member to worktree-only context with a named warning, never a throw.
1049
+ const memberOk = new Map();
1050
+ for (const m of sorted) {
1051
+ const ok = await isDir(m.projectDir, onError);
1052
+ memberOk.set(m.projectKey, ok);
1053
+ if (!ok) {
1054
+ warnings.push(
1055
+ `member \`${m.projectKey}\` real directory \`${m.projectDir}\` is missing or unreadable — ` +
1056
+ 'degrading to worktree-only context for it (its committed blobs still exist in ' +
1057
+ `repos/${m.projectKey}).`,
1058
+ );
1059
+ }
1060
+ }
1061
+ const liveMembers = sorted.filter((m) => memberOk.get(m.projectKey));
1062
+
1063
+ // §5.1: registration is deliberately UNCONSTRAINED — a project or workspace member
1064
+ // may live anywhere, and today's freedom is preserved. That is only honest if the
1065
+ // consequence is NAMED, so a member whose real dir is not under `projectsRoot` is
1066
+ // WARNED about here, per member, and the run proceeds untouched (never a failure).
1067
+ // Derived at assembly like §8.19/§8.21, so it is resume-idempotent and rides
1068
+ // `run.json.warnings` + the run log with no extra manifest state.
1069
+ //
1070
+ // Gated on `rootUsable`: when the root itself is missing the whole layer is already
1071
+ // reported as contributing nothing (above), and NO member can be under a path that
1072
+ // does not exist — one line per member would repeat that single fact N times.
1073
+ // `isUnder` counts equality as inside, so a member sitting exactly AT projectsRoot
1074
+ // (and every member under a home-as-projectsRoot, the default) never warns.
1075
+ if (rootUsable) {
1076
+ for (const m of sorted) {
1077
+ if (!m.projectDir || isUnder(m.projectDir, projectsRoot)) continue;
1078
+ warnings.push(
1079
+ `member \`${m.projectName || m.projectKey}\` at \`${m.projectDir}\` is not under the ` +
1080
+ `projects root \`${projectsRoot}\` — registration is deliberately unconstrained (§5.1), so ` +
1081
+ 'this run proceeds and the root context layer is still injected; point `projectsRoot` at an ' +
1082
+ 'ancestor of your projects if you expected that layer to cover this member natively.',
1083
+ );
1084
+ }
1085
+ }
1086
+
1087
+ // The set THIS run root already recorded, so a re-assembly (resume, §5.2) whose
1088
+ // inputs shrank does not leave an orphaned mount behind: an entry we no longer
1089
+ // record is no longer pathspec-excluded, so in single mode `git add -A` would
1090
+ // commit it into the user's deliverable branch.
1091
+ const previousRecords = (await readRunManifest(runRoot))?.injectedPaths || {};
1092
+
1093
+ // ── 1) skills (§5.6) ─────────────────────────────────────────────────────
1094
+ const injectedPaths = {};
1095
+ let skillMountDir = null;
1096
+ let skillsOut = { names: [], records: [], renames: {}, roster: [], warnings: [] };
1097
+ const primary = sorted[0] || null;
1098
+ if (isWorkspace) {
1099
+ skillMountDir = join(runRoot, '.claude', 'skills');
1100
+ skillsOut = await assembleSkills({
1101
+ target: skillMountDir, members: liveMembers, projectsRoot: rootUsable ? projectsRoot : null,
1102
+ resolutions: requiredSkillResolutions, homeDir, mount, skipRoot: rootIsHome,
1103
+ });
1104
+ if (skillsOut.records.length) injectedPaths.runRoot = skillsOut.records;
1105
+ } else if (primary?.worktreeDir) {
1106
+ // E4/P3b: the skills ancestor walk stops at a git repository root, so a run-root
1107
+ // mount would be invisible to a process whose cwd is the worktree.
1108
+ skillMountDir = join(primary.worktreeDir, '.claude', 'skills');
1109
+ skillsOut = await assembleSkills({
1110
+ target: skillMountDir, members: liveMembers, projectsRoot: rootUsable ? projectsRoot : null,
1111
+ resolutions: requiredSkillResolutions, homeDir, mount, skipRoot: rootIsHome,
1112
+ trackedNames: trackedSkillNames(primary.worktreeDir),
1113
+ });
1114
+ if (skillsOut.records.length) injectedPaths[primary.projectKey] = skillsOut.records;
1115
+ } else if (primary) {
1116
+ warnings.push(
1117
+ `member \`${primary.projectKey}\` has no checkout registered, so no skill mount was created ` +
1118
+ 'for this run; only committed and user-scope skills are visible.',
1119
+ );
1120
+ }
1121
+ for (const w of skillsOut.warnings) warnings.push(w);
1122
+
1123
+ // Prune orphans from the PREVIOUS record set (skill mounts only — `link` and
1124
+ // `claudeMdSection` entries are owned elsewhere and must survive a re-assembly).
1125
+ const scope = isWorkspace ? 'runRoot' : primary?.projectKey;
1126
+ const baseDir = isWorkspace ? runRoot : primary?.worktreeDir;
1127
+ if (scope && baseDir && Array.isArray(previousRecords[scope])) {
1128
+ const keep = new Set(skillsOut.records.map((r) => r.path));
1129
+ for (const old of previousRecords[scope]) {
1130
+ if (old?.kind !== 'skill' || !old.path || keep.has(old.path)) continue;
1131
+ try { await rm(join(baseDir, old.path), { recursive: true, force: true }); } catch { /* best-effort */ }
1132
+ warnings.push(`stale skill mount \`${old.path}\` removed: its source \`${old.source}\` is gone.`);
1133
+ }
1134
+ }
1135
+
1136
+ // ── 2) mcp.json (§5.5) ───────────────────────────────────────────────────
1137
+ const mcp = await mergeMcpConfigs({
1138
+ members: liveMembers, projectsRoot: rootUsable ? projectsRoot : null, homeDir, isWorkspace, platform,
1139
+ });
1140
+ for (const w of mcp.warnings) warnings.push(w);
1141
+ const written = Object.keys(mcp.servers).sort();
1142
+ let mcpConfigPath = null;
1143
+ if (written.length) {
1144
+ mcpConfigPath = join(runRoot, MCP_FILE);
1145
+ const body = `${JSON.stringify({ mcpServers: Object.fromEntries(written.map((k) => [k, mcp.servers[k]])) }, null, 2)}\n`;
1146
+ // JSON-validated BEFORE the spawn: E8's silent-ignore hazard means an invalid
1147
+ // file would be discarded with no error in `-p` mode, so a parse failure here
1148
+ // must be loud rather than shipped.
1149
+ try { JSON.parse(body); } catch (err) {
1150
+ throw new Error(`generated ${MCP_FILE} is not valid JSON: ${err?.message || err}`);
1151
+ }
1152
+ await writeFile(mcpConfigPath, body, 'utf8');
1153
+ } else {
1154
+ await rm(join(runRoot, MCP_FILE), { force: true }); // idempotent re-assembly
1155
+ }
1156
+ const mcpServerNames = [...written, ...mcp.nativeOnly].sort();
1157
+
1158
+ // ── 3) CLAUDE.md (§5.4) ──────────────────────────────────────────────────
1159
+ const bySource = {};
1160
+ const load = async (src, memberKey, worktreeDir) => {
1161
+ const rawBytes = await readBytesMaybe(src.path, onError);
1162
+ if (rawBytes === null) return null; // absent => silent; unreadable => named above
1163
+ // De-duplication (single mode only): the committed copy at cwd loads natively
1164
+ // (E1/P1), so inlining a byte-identical real copy would double-load it.
1165
+ if (!isWorkspace && worktreeDir) {
1166
+ const twin = await readBytesMaybe(join(worktreeDir, src.rel), onError);
1167
+ if (twin && twin.equals(rawBytes)) return null;
1168
+ }
1169
+ const { text: resolved, unresolved } = await resolveImports(
1170
+ rawBytes.toString('utf8'), src.path, 0, { homeDir },
1171
+ );
1172
+ for (const u of unresolved) warnings.push(`\`${src.path}\`: ${u}`);
1173
+ let body = resolved;
1174
+ if (enc(body) > maxBytesPerFile) {
1175
+ const original = enc(body);
1176
+ body = truncateBytes(body, maxBytesPerFile);
1177
+ bySource[src.path] = enc(body);
1178
+ warnings.push(
1179
+ `\`${src.path}\`${memberKey ? ` (member \`${memberKey}\`)` : ''} truncated: ` +
1180
+ `${fmtNum(original)} → ${fmtNum(maxBytesPerFile)} bytes; raise contextMaxBytesPerFile to keep it whole`,
1181
+ );
1182
+ body += `\n\n*[worca: truncated at ${fmtNum(maxBytesPerFile)} bytes — raise contextMaxBytesPerFile]*`;
1183
+ } else {
1184
+ bySource[src.path] = enc(body);
1185
+ }
1186
+ return { rel: src.rel, path: src.path, text: body };
1187
+ };
1188
+
1189
+ const rootSections = [];
1190
+ if (rootUsable) {
1191
+ // Root skip (§5.4): the plain `<projectsRoot>/CLAUDE.md` and `CLAUDE.local.md`
1192
+ // already load natively on the upward walk when the run root is a descendant
1193
+ // (E1 verified exactly these two filenames), so inlining them would
1194
+ // double-load. `.claude/CLAUDE.md` / `.claude/rules/*` were never probed on the
1195
+ // walk and are still inlined — unless projectsRoot IS the home, where they are
1196
+ // user scope and always native (E11).
1197
+ const runRootUnderRoot = isUnder(runRoot, projectsRoot);
1198
+ for (const src of await discoverMemorySources(projectsRoot, onError)) {
1199
+ const plain = src.rel === CLAUDE_MD_FILE || src.rel === 'CLAUDE.local.md';
1200
+ if (plain && runRootUnderRoot) continue;
1201
+ if (!plain && rootIsHome) continue;
1202
+ if (plain && rootIsHome) continue;
1203
+ const section = await load(src, null, null);
1204
+ if (section) rootSections.push(section);
1205
+ }
1206
+ }
1207
+ const memberSections = new Map();
1208
+ for (const m of sorted) {
1209
+ const list = [];
1210
+ if (memberOk.get(m.projectKey)) {
1211
+ for (const src of await discoverMemorySources(m.projectDir, onError)) {
1212
+ const section = await load(src, m.projectKey, m.worktreeDir);
1213
+ if (section) list.push(section);
1214
+ }
1215
+ }
1216
+ memberSections.set(m.projectKey, list);
1217
+ }
1218
+
1219
+ // §8.21 — the fourth named loss, derived HERE so the roster note and the warning
1220
+ // come from ONE source and a resume re-assembly reproduces both idempotently with no
1221
+ // extra manifest state. Read off each member's worktree (committed blobs, E6) and
1222
+ // gated on isWorkspace: single mode's cwd IS a checkout, so its agents are
1223
+ // discoverable and nothing is lost (§5.7). Assembly runs on detached runs only, so
1224
+ // legacy — which never assembles — is untouched.
1225
+ // §8.19 — the project-settings loss, derived at the same place and gated the same
1226
+ // way as §8.21 below. Today a workspace node's cwd is the config-source member's
1227
+ // worktree, so THAT member's committed `.claude/settings.json` (hooks, permission
1228
+ // rules, statusline) applies to every node; under §5.3 the cwd is `<runRoot>`, which
1229
+ // has no project settings file, so NO member's apply.
1230
+ //
1231
+ // The DENY rules are no longer an accepted loss: for every member that honors its
1232
+ // project settings they are LIFTED here into `projectPermissions`, merged into the
1233
+ // single `--settings` payload the runner emits (claude-runner.mjs buildSettingsArgs
1234
+ // emits exactly ONE merged flag, so there is no unmergeable-`--settings` / E8
1235
+ // silent-ignore hazard). allow/ask are NEVER lifted: project-scope `allow` is
1236
+ // ignored in headless `-p` mode until the workspace-trust dialog, but the
1237
+ // `--settings` payload is USER scope and IS applied, so lifting it would let a
1238
+ // committed repo file widen a run past that gate and beyond `--allowedTools`
1239
+ // (Global Constraint). Deny only ever ADDS restrictions, so deny alone is safe.
1240
+ //
1241
+ // hooks/statusline remain accepted losses and are still warned by name. A file that
1242
+ // parses to an EMPTY object carries nothing, so nothing is lost and nothing is said;
1243
+ // one that does not parse still warns — we cannot prove it is empty.
1244
+ let projectPermissions = null;
1245
+ if (isWorkspace) {
1246
+ for (const m of sorted) {
1247
+ const s = await discoverProjectSettings(m.worktreeDir, onError);
1248
+ if (!s || (Array.isArray(s.keys) && s.keys.length === 0)) continue;
1249
+ // Per-member honor gate: each member is gated by ITS OWN effective
1250
+ // honorProjectSettings (Task 7 supplies the map; an absent entry means
1251
+ // unconfigured => honor, matching DEFAULT_GUARDRAILS). Never the run-wide
1252
+ // union — its some() would let one unconfigured member force-lift a member
1253
+ // that explicitly opted out ("a member can never relax another member's
1254
+ // policy").
1255
+ const honored = honorByKey ? honorByKey.get(m.projectKey) !== false : true;
1256
+ const lifted = honored && s.permissions ? s.permissions : null;
1257
+ if (lifted) projectPermissions = mergePermissionRules(projectPermissions, lifted);
1258
+ // Keys still NOT in force this run: everything when not honored / no deny
1259
+ // rules / unparseable; everything except `permissions` when its deny rules
1260
+ // were lifted.
1261
+ const lostKeys = s.keys ? s.keys.filter((k) => !(lifted && k === 'permissions')) : null;
1262
+ if (Array.isArray(lostKeys) && lostKeys.length === 0) continue; // permissions-only file, fully lifted
1263
+ const carries = lostKeys
1264
+ ? `keys: ${lostKeys.join(', ')}`
1265
+ : 'its contents could not be parsed, so what it carries is unknown';
1266
+ warnings.push(
1267
+ `project hooks/permissions from \`${m.projectName || m.projectKey}\` do not apply on ` +
1268
+ 'workspace runs (cwd is the run root); move anything essential into an agent\'s frontmatter ' +
1269
+ '`tools:` or a `requiresSkills` skill. Not in force this run: `.claude/settings.json` ' +
1270
+ `(${carries}).` +
1271
+ // NB: the word "permissions" must not appear after the keys list — the
1272
+ // §8.19 v2 test asserts /keys:.*permissions/ does NOT match, i.e. that the
1273
+ // key left the not-in-force list.
1274
+ (lifted ? ' Its deny rules WERE lifted into this run\'s --settings.' : ''),
1275
+ );
1276
+ }
1277
+ }
1278
+
1279
+ const projectAgents = new Map();
1280
+ if (isWorkspace) {
1281
+ for (const m of sorted) {
1282
+ const names = await discoverProjectAgents(m.worktreeDir, onError);
1283
+ if (!names.length) continue;
1284
+ projectAgents.set(m.projectKey, names);
1285
+ warnings.push(
1286
+ `project sub-agents from \`${m.projectName || m.projectKey}\` are not discoverable on ` +
1287
+ `workspace runs (cwd is the run root); personal \`~/.claude/agents\` still work. ` +
1288
+ `Not available by name this run: ${names.join(', ')}.`,
1289
+ );
1290
+ }
1291
+ }
1292
+
1293
+ const doc = generateClaudeMd({
1294
+ pipelineId, projectsRoot, members: sorted, graphInstructions,
1295
+ rootSections, memberSections,
1296
+ skills: skillsOut.roster, servers: mcp.roster,
1297
+ projectAgents,
1298
+ maxBytesTotal,
1299
+ });
1300
+ for (const w of doc.warnings) warnings.push(w);
1301
+ const claudeMdPath = join(runRoot, CLAUDE_MD_FILE);
1302
+ await writeFile(claudeMdPath, doc.text, 'utf8');
1303
+ const total = enc(doc.text);
1304
+ if (total > CONTEXT_SOFT_WARN_BYTES) {
1305
+ warnings.push(
1306
+ `generated run context is ${fmtNum(total)} bytes; every byte loads at launch in every node ` +
1307
+ '(§8.7) — consider trimming project memory.',
1308
+ );
1309
+ }
1310
+
1311
+ // §8.6: anything above the run root loads into every node too.
1312
+ for (const w of await auditAncestors(runRoot)) warnings.push(w);
1313
+
1314
+ // ── 4) run.json (§5.2) ───────────────────────────────────────────────────
1315
+ const renames = { skills: skillsOut.renames, mcpServers: mcp.renames.mcpServers };
1316
+ // `total` is the FINAL document size — the same number contextMaxBytesTotal caps,
1317
+ // and what renderContextAudit reports. `bySource` is each source's inlined size
1318
+ // AFTER its per-file cap but BEFORE the total-budget trim, so the two can differ
1319
+ // when the budget bound (that case always carries its own warning).
1320
+ const bytes = { total, bySource };
1321
+ // S2: the same filter the mount applies — a corrupt entry is also dropped from the
1322
+ // manifest, so the corruption does not survive into the NEXT resume's rehydration.
1323
+ const skillResolutions = Object.fromEntries(
1324
+ resolutionEntries(requiredSkillResolutions).filter(([name]) => isValidSkillName(name)),
1325
+ );
1326
+ const rc = {
1327
+ claudeMdPath,
1328
+ mcpConfigPath,
1329
+ mcpServerNames,
1330
+ skillMountDir,
1331
+ injectedSkillNames: skillsOut.names,
1332
+ injectedPaths,
1333
+ renames,
1334
+ warnings,
1335
+ projectPermissions,
1336
+ bytes,
1337
+ memberCount: sorted.length,
1338
+ };
1339
+ await updateRunManifest(runRoot, {
1340
+ injectedPaths,
1341
+ skillResolutions,
1342
+ renames,
1343
+ bytes,
1344
+ warnings,
1345
+ projectPermissions,
1346
+ capabilities: { mcpGrants: MCP_GRANT_MODE },
1347
+ mcpConfigPath,
1348
+ mcpServerNames,
1349
+ injectedSkillNames: skillsOut.names,
1350
+ skillMountDir,
1351
+ });
1352
+ return rc;
1353
+ }
1354
+
1355
+ /**
1356
+ * ONE markdown audit line for appendAudit — member / source / mount / server /
1357
+ * warning counts, e.g. "Context: 2 members, 7 memory sources inlined (41,208
1358
+ * bytes), 5 skills mounted (1 renamed), 3 MCP servers merged (1 renamed), 2
1359
+ * warnings." The rename parenthetical is omitted when nothing was renamed.
1360
+ * @param {object} rc the assembleRunContext() result
1361
+ * @returns {string}
1362
+ */
1363
+ export function renderContextAudit(rc) {
1364
+ const count = (v) => (Array.isArray(v) ? v.length : Number(v) || 0);
1365
+ const keys = (o) => Object.keys(o || {}).length;
1366
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
1367
+ const ren = (n) => (n ? ` (${n} renamed)` : '');
1368
+ return (
1369
+ `Context: ${plural(count(rc?.memberCount), 'member')}, ` +
1370
+ `${plural(keys(rc?.bytes?.bySource), 'memory source')} inlined (${fmtNum(rc?.bytes?.total)} bytes), ` +
1371
+ `${plural(count(rc?.injectedSkillNames), 'skill')} mounted${ren(keys(rc?.renames?.skills))}, ` +
1372
+ `${plural(count(rc?.mcpServerNames), 'MCP server')} merged${ren(keys(rc?.renames?.mcpServers))}, ` +
1373
+ `${plural(count(rc?.warnings), 'warning')}.`
1374
+ );
1375
+ }