@yagni-app/code-staging 1.2.4-staging.1778.1 → 1.2.4-staging.1809.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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A cloud run's skill sources (YAG-784): the mission file `sources.json` the
3
+ * backend writes beside `ticket.md`, and the compat options it turns into.
4
+ *
5
+ * The backend decides everything here: whether the working repo is trusted
6
+ * for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
7
+ * and which org marketplace and personal skills snapshots it fetched and
8
+ * extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
9
+ * carries its own small validator rather than importing @yagni/shared (this
10
+ * package never takes a build-time dependency on a private workspace package).
11
+ * The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
12
+ *
13
+ * Everything is fail-soft: a missing, oversized, or garbled file means "no
14
+ * cloud sources" (the caller falls back to local behavior), and one bad
15
+ * source is dropped without touching the others.
16
+ */
17
+ import type { CompatExtraRoot, CompatMarketplace } from "./claudeCompat.js";
18
+ /** Where the backend extracts the org and personal snapshots in the sandbox. */
19
+ export declare const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
20
+ export declare const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
21
+ /** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
22
+ export declare const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
23
+ export interface AgentRunSources {
24
+ repo: {
25
+ fullName: string;
26
+ sha: string;
27
+ trusted: boolean;
28
+ };
29
+ org: {
30
+ fullName: string;
31
+ ref: string | null;
32
+ sha: string;
33
+ dir: string;
34
+ enabledPlugins: string[];
35
+ } | null;
36
+ personal: {
37
+ fullName: string;
38
+ ref: string | null;
39
+ sha: string;
40
+ dir: string;
41
+ userId: string;
42
+ } | null;
43
+ }
44
+ /** Validate a parsed sources.json (version 1). Null when it isn't one. */
45
+ export declare function parseAgentRunSources(raw: unknown): AgentRunSources | null;
46
+ /**
47
+ * Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
48
+ * Never throws. A file that is there but unusable is not the same as no file:
49
+ * the backend already recorded its sources as loaded, so `warn` says so (one
50
+ * stderr line in a headless run) rather than degrade to local mode silently.
51
+ */
52
+ export declare function readAgentRunSources(missionDir: string, warn?: (line: string) => void): AgentRunSources | null;
53
+ /** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
54
+ export declare function missionDirFromEnv(env: NodeJS.ProcessEnv): string;
55
+ /**
56
+ * `dir` as a realpath when it is absolute, an existing directory, and inside
57
+ * `sourcesRoot` after symlink resolution; else the reason it was refused.
58
+ */
59
+ export declare function checkSourceDir(dir: string, sourcesRoot: string): {
60
+ dir: string;
61
+ } | {
62
+ refused: string;
63
+ };
64
+ /** What a valid sources file asks `claudeCompatArgs` to load. */
65
+ export interface CloudCompatOptions {
66
+ trustOverride: boolean;
67
+ extraRoots: CompatExtraRoot[];
68
+ marketplaces: CompatMarketplace[];
69
+ /** `label: reason` for each source dir that was refused. */
70
+ refused: string[];
71
+ }
72
+ /**
73
+ * Turn a sources file into compat options. Snapshot dirs must sit under
74
+ * `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
75
+ * and reported, never loaded.
76
+ */
77
+ export declare function cloudCompatOptions(sources: AgentRunSources, sourcesRoot?: string): CloudCompatOptions;
78
+ //# sourceMappingURL=agentSources.d.ts.map
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A cloud run's skill sources (YAG-784): the mission file `sources.json` the
3
+ * backend writes beside `ticket.md`, and the compat options it turns into.
4
+ *
5
+ * The backend decides everything here: whether the working repo is trusted
6
+ * for agent runs (an admin's per-repo flag, standing in for pi's trust.json),
7
+ * and which org marketplace and personal skills snapshots it fetched and
8
+ * extracted under AGENT_SOURCES_ROOT. The CLI only reads the file, so it
9
+ * carries its own small validator rather than importing @yagni/shared (this
10
+ * package never takes a build-time dependency on a private workspace package).
11
+ * The contract is `AgentRunSourcesFile` in packages/shared/src/agentSources.ts.
12
+ *
13
+ * Everything is fail-soft: a missing, oversized, or garbled file means "no
14
+ * cloud sources" (the caller falls back to local behavior), and one bad
15
+ * source is dropped without touching the others.
16
+ */
17
+ import { closeSync, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
18
+ import { isAbsolute, join, sep } from "node:path";
19
+ /** Where the backend extracts the org and personal snapshots in the sandbox. */
20
+ export const AGENT_SOURCES_ROOT = "/opt/yagni/sources";
21
+ export const AGENT_RUN_SOURCES_FILE_NAME = "sources.json";
22
+ /** pi-harness.sh's default when YAGNI_MISSION_DIR is unset. */
23
+ export const DEFAULT_MISSION_DIR = "/opt/yagni/mission";
24
+ /** A sources file is a few hundred bytes; anything past this is not one. */
25
+ const MAX_SOURCES_FILE_BYTES = 64 * 1024;
26
+ /** Mirrors MAX_ENABLED_PLUGINS in the shared contract. */
27
+ const MAX_ENABLED_PLUGINS = 50;
28
+ /**
29
+ * A label, never a path. The owner allows `_` because a working repo can
30
+ * belong to an Enterprise Managed User (`jane_acme`); rejecting it would drop
31
+ * the whole file and silently ignore the repo's trust flag.
32
+ */
33
+ const FULL_NAME = /^[A-Za-z0-9_-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
34
+ const SHA = /^[0-9a-f]{40}$/;
35
+ /** Same shape as claudePlugins' SAFE_SEGMENT: plugin names are path segments. */
36
+ const PLUGIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
37
+ function isRecord(value) {
38
+ return !!value && typeof value === "object" && !Array.isArray(value);
39
+ }
40
+ function refOf(value) {
41
+ if (value === undefined || value === null)
42
+ return null;
43
+ return typeof value === "string" && value.length > 0 && value.length <= 255 ? value : undefined;
44
+ }
45
+ function snapshotBase(value) {
46
+ if (!isRecord(value))
47
+ return null;
48
+ const { fullName, sha, dir } = value;
49
+ const ref = refOf(value.ref);
50
+ if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
51
+ return null;
52
+ if (typeof sha !== "string" || !SHA.test(sha))
53
+ return null;
54
+ if (typeof dir !== "string" || dir.length === 0)
55
+ return null;
56
+ if (ref === undefined)
57
+ return null;
58
+ return { fullName, ref, sha, dir };
59
+ }
60
+ /** Validate a parsed sources.json (version 1). Null when it isn't one. */
61
+ export function parseAgentRunSources(raw) {
62
+ if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.repo))
63
+ return null;
64
+ const { fullName, sha, trusted } = raw.repo;
65
+ if (typeof fullName !== "string" || !FULL_NAME.test(fullName))
66
+ return null;
67
+ if (typeof sha !== "string" || !SHA.test(sha))
68
+ return null;
69
+ if (typeof trusted !== "boolean")
70
+ return null;
71
+ let org = null;
72
+ const orgBase = snapshotBase(raw.org);
73
+ if (orgBase && isRecord(raw.org) && Array.isArray(raw.org.enabledPlugins)) {
74
+ const enabledPlugins = [
75
+ ...new Set(raw.org.enabledPlugins.filter((p) => typeof p === "string" && PLUGIN_NAME.test(p))),
76
+ ].slice(0, MAX_ENABLED_PLUGINS);
77
+ org = { ...orgBase, enabledPlugins };
78
+ }
79
+ let personal = null;
80
+ const personalBase = snapshotBase(raw.personal);
81
+ if (personalBase && isRecord(raw.personal) && typeof raw.personal.userId === "string") {
82
+ personal = { ...personalBase, userId: raw.personal.userId };
83
+ }
84
+ return { repo: { fullName, sha, trusted }, org, personal };
85
+ }
86
+ /**
87
+ * Read `<missionDir>/sources.json`. Null when absent, too large, or invalid.
88
+ * Never throws. A file that is there but unusable is not the same as no file:
89
+ * the backend already recorded its sources as loaded, so `warn` says so (one
90
+ * stderr line in a headless run) rather than degrade to local mode silently.
91
+ */
92
+ export function readAgentRunSources(missionDir, warn = () => { }) {
93
+ const path = join(missionDir, AGENT_RUN_SOURCES_FILE_NAME);
94
+ let fd = null;
95
+ try {
96
+ try {
97
+ fd = openSync(path, "r");
98
+ }
99
+ catch (err) {
100
+ if (err?.code === "ENOENT")
101
+ return null;
102
+ throw err;
103
+ }
104
+ const size = fstatSync(fd).size;
105
+ if (size === 0 || size > MAX_SOURCES_FILE_BYTES) {
106
+ warn(`Skills: ${path} ignored (${size === 0 ? "empty" : `larger than ${MAX_SOURCES_FILE_BYTES} bytes`}); no skills sources loaded`);
107
+ return null;
108
+ }
109
+ const buffer = Buffer.alloc(size);
110
+ readSync(fd, buffer, 0, size, 0);
111
+ const parsed = parseAgentRunSources(JSON.parse(buffer.toString("utf8")));
112
+ if (!parsed)
113
+ warn(`Skills: ${path} ignored (not a valid version 1 sources file); no skills sources loaded`);
114
+ return parsed;
115
+ }
116
+ catch (err) {
117
+ const why = err instanceof SyntaxError ? "not valid JSON" : err instanceof Error ? err.message : String(err);
118
+ warn(`Skills: ${path} ignored (${why}); no skills sources loaded`);
119
+ return null;
120
+ }
121
+ finally {
122
+ if (fd !== null) {
123
+ try {
124
+ closeSync(fd);
125
+ }
126
+ catch {
127
+ // Already closed.
128
+ }
129
+ }
130
+ }
131
+ }
132
+ /** The mission dir the harness wrote into (`YAGNI_MISSION_DIR`, else its default). */
133
+ export function missionDirFromEnv(env) {
134
+ const dir = env.YAGNI_MISSION_DIR?.trim();
135
+ return dir ? dir : DEFAULT_MISSION_DIR;
136
+ }
137
+ /**
138
+ * `dir` as a realpath when it is absolute, an existing directory, and inside
139
+ * `sourcesRoot` after symlink resolution; else the reason it was refused.
140
+ */
141
+ export function checkSourceDir(dir, sourcesRoot) {
142
+ if (!isAbsolute(dir))
143
+ return { refused: "not an absolute path" };
144
+ let real;
145
+ let realRoot;
146
+ try {
147
+ real = realpathSync(dir);
148
+ realRoot = realpathSync(sourcesRoot);
149
+ }
150
+ catch {
151
+ return { refused: "not present in the sandbox" };
152
+ }
153
+ if (!real.startsWith(realRoot + sep))
154
+ return { refused: `outside ${sourcesRoot}` };
155
+ try {
156
+ if (!statSync(real).isDirectory())
157
+ return { refused: "not a directory" };
158
+ }
159
+ catch {
160
+ return { refused: "not present in the sandbox" };
161
+ }
162
+ return { dir: real };
163
+ }
164
+ /**
165
+ * Turn a sources file into compat options. Snapshot dirs must sit under
166
+ * `sourcesRoot` (AGENT_SOURCES_ROOT in the sandbox); any other dir is refused
167
+ * and reported, never loaded.
168
+ */
169
+ export function cloudCompatOptions(sources, sourcesRoot = AGENT_SOURCES_ROOT) {
170
+ const out = {
171
+ trustOverride: sources.repo.trusted,
172
+ extraRoots: [],
173
+ marketplaces: [],
174
+ refused: [],
175
+ };
176
+ if (sources.org) {
177
+ const label = `${sources.org.fullName} @ ${sources.org.sha.slice(0, 7)}`;
178
+ const checked = checkSourceDir(sources.org.dir, sourcesRoot);
179
+ if ("dir" in checked)
180
+ out.marketplaces.push({ root: checked.dir, enabled: sources.org.enabledPlugins, label });
181
+ else
182
+ out.refused.push(`${label}: ${checked.refused}`);
183
+ }
184
+ if (sources.personal) {
185
+ const label = `${sources.personal.fullName} @ ${sources.personal.sha.slice(0, 7)}`;
186
+ const checked = checkSourceDir(sources.personal.dir, sourcesRoot);
187
+ if ("dir" in checked)
188
+ out.extraRoots.push({ root: checked.dir, label });
189
+ else
190
+ out.refused.push(`${label}: ${checked.refused}`);
191
+ }
192
+ return out;
193
+ }
194
+ //# sourceMappingURL=agentSources.js.map
@@ -107,6 +107,60 @@ export declare function readTrustDecision(agentDir: string, cwd: string): boolea
107
107
  * asked again next launch.
108
108
  */
109
109
  export declare function writeTrustDecision(agentDir: string, cwd: string, decision: boolean): void;
110
+ /**
111
+ * A personal skills root (a person's own skills repository, extracted into the
112
+ * sandbox). Any of the layouts in EXTRA_ROOT_LAYOUT that exist load,
113
+ * regardless of the working repo's trust: the backend only hands over a root
114
+ * it authorized.
115
+ */
116
+ export interface CompatExtraRoot {
117
+ root: string;
118
+ /** Human label for logs, e.g. `jane/skills`. */
119
+ label: string;
120
+ }
121
+ /**
122
+ * A plugin marketplace checkout (`<root>/.claude-plugin/marketplace.json`) of
123
+ * which only the `enabled` plugins load. Only relative-path plugin sources are
124
+ * supported; object sources (github, url, npm) are reported, never fetched.
125
+ */
126
+ export interface CompatMarketplace {
127
+ root: string;
128
+ enabled: string[];
129
+ label: string;
130
+ }
131
+ /** Personal root layouts, in load order. */
132
+ export declare const EXTRA_ROOT_LAYOUT: {
133
+ readonly skills: readonly ["skills", ".claude/skills", ".agents/skills"];
134
+ readonly commands: readonly ["commands", ".claude/commands"];
135
+ readonly rules: readonly ["rules", ".claude/rules"];
136
+ };
137
+ /** Bound on the symlink scan of one asset path, so a huge tree can't stall a launch. */
138
+ export declare const SYMLINK_SCAN_LIMIT = 20000;
139
+ /**
140
+ * `<root>/<rel>` as a realpath when it exists, is a directory, stays inside
141
+ * `root` after symlink resolution, and holds no symlink pointing outside it.
142
+ * Every source the interactive launcher never read goes through this, so a
143
+ * hostile link in a repo or snapshot can't hand pi a directory outside it.
144
+ * `scanLimit` exists for tests: a tree with more entries than it is refused.
145
+ */
146
+ export declare function safeSourceDir(root: string, rel: string, scanLimit?: number): string | null;
147
+ /** One source's contribution, summarized for the launcher's log line. */
148
+ export interface CompatSourceSummary {
149
+ label: string;
150
+ kind: "user" | "project" | "personal" | "org-marketplace";
151
+ skills: string[];
152
+ commands: string[];
153
+ rulesDirs: string[];
154
+ /** `name@marketplace` keys of the plugins it contributed. */
155
+ plugins: string[];
156
+ /** Things present but not loaded (unsupported plugin parts, missing plugins). */
157
+ unsupported: string[];
158
+ }
159
+ /** The enabled plugins of one marketplace checkout, plus what could not load. Never throws. */
160
+ export declare function marketplacePlugins(mp: CompatMarketplace): {
161
+ plugins: PluginAssets[];
162
+ unsupported: string[];
163
+ };
110
164
  export interface ClaudeCompatDeps {
111
165
  cwd: string;
112
166
  /** The hermetic pi agent dir for the active profile (holds trust.json). */
@@ -116,6 +170,26 @@ export interface ClaudeCompatDeps {
116
170
  interactive?: boolean;
117
171
  /** The one-time trust question; injectable for tests. */
118
172
  confirm?: (question: string) => Promise<boolean>;
173
+ /**
174
+ * A trust decision made elsewhere (a cloud run's per-repo admin flag). It
175
+ * replaces the trust.json lookup and the prompt, and is never written back.
176
+ */
177
+ trustOverride?: boolean;
178
+ /** False skips `~/.claude`, home plugins and the YAGNI ledger. Default true. */
179
+ userScope?: boolean;
180
+ /** False never emits YAGNI_CLAUDE_PLUGIN_MCP (headless runs). Default true. */
181
+ pluginMcp?: boolean;
182
+ /**
183
+ * Once the folder is trusted, also wire `<cwd>/.agents/skills`, `.pi/skills`
184
+ * (`--skill`) and `.pi/prompts` (`--prompt-template`) explicitly, so a
185
+ * headless run never needs pi's `--approve` (which would also run the repo's
186
+ * `.pi/extensions` code). Default false.
187
+ */
188
+ projectNativeSkills?: boolean;
189
+ /** Personal skills roots; load regardless of the folder's trust. */
190
+ extraRoots?: CompatExtraRoot[];
191
+ /** Plugin marketplaces with an explicit enabled list; load regardless of trust. */
192
+ marketplaces?: CompatMarketplace[];
119
193
  }
120
194
  export declare function compatDisabled(env: NodeJS.ProcessEnv): boolean;
121
195
  export interface ClaudeCompatLaunch {
@@ -123,12 +197,21 @@ export interface ClaudeCompatLaunch {
123
197
  argv: string[];
124
198
  /** Extra child env (plugin agent dirs, rules dirs) for the extension. */
125
199
  env: Record<string, string>;
200
+ /**
201
+ * What each source contributed, for logging. Absent when nothing loaded and
202
+ * no extra source was asked for.
203
+ */
204
+ sources?: CompatSourceSummary[];
126
205
  }
127
206
  /**
128
207
  * The extra pi argv + child env for this launch: user-scope dirs and plugins
129
208
  * unconditionally, project-sourced dirs/plugins/rules once trusted, asking
130
209
  * (and persisting) the trust decision when needed. Everything is fail-soft —
131
210
  * compat can only add flags/env, never break a launch.
211
+ *
212
+ * The optional deps (`trustOverride`, `userScope`, `pluginMcp`,
213
+ * `projectNativeSkills`, `extraRoots`, `marketplaces`) serve headless cloud
214
+ * runs; left unset, the argv and env are exactly the interactive behavior.
132
215
  */
133
216
  export declare function claudeCompatArgs(deps: ClaudeCompatDeps): Promise<ClaudeCompatLaunch>;
134
217
  /** One plugin as the in-session `/plugin` panel sees it. */
@@ -28,10 +28,10 @@
28
28
  *
29
29
  * Set YAGNI_DISABLE_CLAUDE_COMPAT=1 to turn the whole bridge off.
30
30
  */
31
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
31
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
32
32
  import { homedir } from "node:os";
33
- import { delimiter, dirname, join, resolve } from "node:path";
34
- import { discoverClaudePlugins } from "./claudePlugins.js";
33
+ import { delimiter, dirname, join, resolve, sep } from "node:path";
34
+ import { containedExistingPath, discoverClaudePlugins, pluginAssets, readMarketplace, resolveLocalPluginRoot, } from "./claudePlugins.js";
35
35
  import { pluginsHome, readLedger } from "./pluginStore.js";
36
36
  export const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
37
37
  /** Plugin `agents/` dirs for the extension's subagent discovery (delimiter-joined). */
@@ -241,6 +241,137 @@ export function writeTrustDecision(agentDir, cwd, decision) {
241
241
  }
242
242
  }
243
243
  }
244
+ /** Personal root layouts, in load order. */
245
+ export const EXTRA_ROOT_LAYOUT = {
246
+ skills: ["skills", ".claude/skills", ".agents/skills"],
247
+ commands: ["commands", ".claude/commands"],
248
+ rules: ["rules", ".claude/rules"],
249
+ };
250
+ /** Project dirs pi loads only behind its own trust; wired explicitly instead. */
251
+ const PROJECT_NATIVE_SKILLS = [".agents/skills", ".pi/skills"];
252
+ const PROJECT_NATIVE_PROMPTS = [".pi/prompts"];
253
+ /** Bound on the symlink scan of one asset path, so a huge tree can't stall a launch. */
254
+ export const SYMLINK_SCAN_LIMIT = 20_000;
255
+ function isContainedIn(realRoot, real) {
256
+ return real === realRoot || real.startsWith(realRoot + sep);
257
+ }
258
+ /**
259
+ * True when `path` (inside `realRoot`) holds no symlink that resolves outside
260
+ * `realRoot`. A tree too large to scan within the bound counts as unsafe.
261
+ */
262
+ function symlinksStayInside(path, realRoot, scanLimit = SYMLINK_SCAN_LIMIT) {
263
+ let budget = scanLimit;
264
+ const stack = [path];
265
+ while (stack.length > 0) {
266
+ const current = stack.pop();
267
+ let entries;
268
+ try {
269
+ if (!lstatSync(current).isDirectory())
270
+ continue;
271
+ entries = readdirSync(current, { withFileTypes: true });
272
+ }
273
+ catch {
274
+ continue;
275
+ }
276
+ for (const entry of entries) {
277
+ if (--budget < 0)
278
+ return false;
279
+ const child = join(current, entry.name);
280
+ if (entry.isSymbolicLink()) {
281
+ let real;
282
+ try {
283
+ real = realpathSync(child);
284
+ }
285
+ catch {
286
+ continue; // Dangling: nothing can be read through it.
287
+ }
288
+ if (!isContainedIn(realRoot, real))
289
+ return false;
290
+ }
291
+ else if (entry.isDirectory()) {
292
+ stack.push(child);
293
+ }
294
+ }
295
+ }
296
+ return true;
297
+ }
298
+ /**
299
+ * `<root>/<rel>` as a realpath when it exists, is a directory, stays inside
300
+ * `root` after symlink resolution, and holds no symlink pointing outside it.
301
+ * Every source the interactive launcher never read goes through this, so a
302
+ * hostile link in a repo or snapshot can't hand pi a directory outside it.
303
+ * `scanLimit` exists for tests: a tree with more entries than it is refused.
304
+ */
305
+ export function safeSourceDir(root, rel, scanLimit = SYMLINK_SCAN_LIMIT) {
306
+ const contained = containedExistingPath(root, rel);
307
+ if (!contained || !isDirectory(contained))
308
+ return null;
309
+ let realRoot;
310
+ try {
311
+ realRoot = realpathSync(root);
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ return symlinksStayInside(contained, realRoot, scanLimit) ? contained : null;
317
+ }
318
+ function emptySummary(label, kind) {
319
+ return { label, kind, skills: [], commands: [], rulesDirs: [], plugins: [], unsupported: [] };
320
+ }
321
+ function summaryHasContent(s) {
322
+ return s.skills.length + s.commands.length + s.rulesDirs.length + s.plugins.length + s.unsupported.length > 0;
323
+ }
324
+ function addPluginsToSummary(summary, plugins) {
325
+ for (const plugin of plugins) {
326
+ summary.plugins.push(plugin.key);
327
+ summary.skills.push(...plugin.skillPaths);
328
+ summary.commands.push(...plugin.commandPaths);
329
+ summary.unsupported.push(...plugin.unsupported.map((u) => `${plugin.key}: ${u}`));
330
+ }
331
+ }
332
+ /** The enabled plugins of one marketplace checkout, plus what could not load. Never throws. */
333
+ export function marketplacePlugins(mp) {
334
+ const marketplace = readMarketplace(mp.root);
335
+ let realRoot = null;
336
+ try {
337
+ realRoot = realpathSync(mp.root);
338
+ }
339
+ catch {
340
+ realRoot = null;
341
+ }
342
+ if (!marketplace || !realRoot) {
343
+ return { plugins: [], unsupported: [".claude-plugin/marketplace.json missing or unreadable"] };
344
+ }
345
+ const plugins = [];
346
+ const unsupported = [];
347
+ for (const name of new Set(mp.enabled)) {
348
+ const entry = marketplace.plugins.find((p) => p.name === name);
349
+ if (!entry) {
350
+ unsupported.push(`${name}: not in the marketplace`);
351
+ continue;
352
+ }
353
+ const root = resolveLocalPluginRoot(marketplace, entry);
354
+ if (!root) {
355
+ unsupported.push(`${name}: plugin source not supported`);
356
+ continue;
357
+ }
358
+ if (!symlinksStayInside(root, realRoot)) {
359
+ unsupported.push(`${name}: links outside the marketplace`);
360
+ continue;
361
+ }
362
+ const assets = pluginAssets(root, name, {
363
+ key: `${name}@${marketplace.name}`,
364
+ origin: "org-marketplace",
365
+ version: entry.version ?? null,
366
+ });
367
+ if (!assets) {
368
+ unsupported.push(`${name}: nothing to load`);
369
+ continue;
370
+ }
371
+ plugins.push(assets);
372
+ }
373
+ return { plugins, unsupported };
374
+ }
244
375
  export function compatDisabled(env) {
245
376
  const value = env[CLAUDE_COMPAT_DISABLE_ENV];
246
377
  return value !== undefined && value !== "" && value !== "0";
@@ -250,27 +381,34 @@ export function compatDisabled(env) {
250
381
  * unconditionally, project-sourced dirs/plugins/rules once trusted, asking
251
382
  * (and persisting) the trust decision when needed. Everything is fail-soft —
252
383
  * compat can only add flags/env, never break a launch.
384
+ *
385
+ * The optional deps (`trustOverride`, `userScope`, `pluginMcp`,
386
+ * `projectNativeSkills`, `extraRoots`, `marketplaces`) serve headless cloud
387
+ * runs; left unset, the argv and env are exactly the interactive behavior.
253
388
  */
254
389
  export async function claudeCompatArgs(deps) {
255
390
  const env = deps.env ?? process.env;
256
391
  if (compatDisabled(env))
257
392
  return { argv: [], env: {} };
258
393
  const home = deps.homeDir ?? homedir();
259
- const projectTrust = readTrustDecision(deps.agentDir, deps.cwd);
394
+ const userScope = deps.userScope !== false;
395
+ const projectTrust = deps.trustOverride ?? readTrustDecision(deps.agentDir, deps.cwd);
260
396
  let plugins;
261
397
  try {
262
398
  plugins = discoverClaudePlugins({
263
399
  cwd: deps.cwd,
264
400
  homeDir: home,
265
- yagniLedger: readLedger(pluginsHome(env, home)),
401
+ ...(userScope ? { yagniLedger: readLedger(pluginsHome(env, home)) } : { includeUserScope: false }),
266
402
  });
267
403
  }
268
404
  catch {
269
405
  plugins = { user: [], project: [] };
270
406
  }
407
+ const projectDirs = probeClaudeDirs(deps.cwd);
408
+ const userDirs = userScope ? probeClaudeDirs(home) : { skills: null, commands: null, rules: null };
271
409
  const decision = decideClaudeCompat({
272
- project: probeClaudeDirs(deps.cwd),
273
- user: probeClaudeDirs(home),
410
+ project: projectDirs,
411
+ user: userDirs,
274
412
  projectPlugins: plugins.project,
275
413
  userPlugins: plugins.user,
276
414
  projectTrust,
@@ -286,27 +424,106 @@ export async function claudeCompatArgs(deps) {
286
424
  trusted = null;
287
425
  }
288
426
  }
289
- const argv = [...decision.userArgv];
290
- if (trusted === true)
427
+ // Backend-authorized sources (the org marketplace's enabled plugins and the
428
+ // personal roots) load whatever the folder's trust says.
429
+ const summaries = [];
430
+ const orgPlugins = [];
431
+ // Only keys that will actually load shadow an org copy: an untrusted repo's
432
+ // plugins never load, so they must not hide the org marketplace's copy.
433
+ const seenKeys = new Set([...plugins.user, ...(trusted === true ? plugins.project : [])].map((p) => p.key));
434
+ for (const mp of deps.marketplaces ?? []) {
435
+ const summary = emptySummary(mp.label, "org-marketplace");
436
+ const found = marketplacePlugins(mp);
437
+ const fresh = found.plugins.filter((p) => !seenKeys.has(p.key));
438
+ for (const p of fresh)
439
+ seenKeys.add(p.key);
440
+ orgPlugins.push(...fresh);
441
+ addPluginsToSummary(summary, fresh);
442
+ summary.unsupported.push(...found.unsupported);
443
+ summaries.push(summary);
444
+ }
445
+ const extraArgv = [];
446
+ const extraRulesDirs = [];
447
+ for (const extra of deps.extraRoots ?? []) {
448
+ const summary = emptySummary(extra.label, "personal");
449
+ const found = (rels) => rels.map((rel) => safeSourceDir(extra.root, rel)).filter((dir) => dir !== null);
450
+ summary.skills.push(...found(EXTRA_ROOT_LAYOUT.skills));
451
+ summary.commands.push(...found(EXTRA_ROOT_LAYOUT.commands));
452
+ summary.rulesDirs.push(...found(EXTRA_ROOT_LAYOUT.rules));
453
+ for (const dir of summary.skills)
454
+ extraArgv.push("--skill", dir);
455
+ for (const dir of summary.commands)
456
+ extraArgv.push("--prompt-template", dir);
457
+ extraRulesDirs.push(...summary.rulesDirs);
458
+ summaries.push(summary);
459
+ }
460
+ const nativeSkills = [];
461
+ const nativePrompts = [];
462
+ if (trusted === true && deps.projectNativeSkills === true) {
463
+ for (const rel of PROJECT_NATIVE_SKILLS) {
464
+ const dir = safeSourceDir(deps.cwd, rel);
465
+ if (dir)
466
+ nativeSkills.push(dir);
467
+ }
468
+ for (const rel of PROJECT_NATIVE_PROMPTS) {
469
+ const dir = safeSourceDir(deps.cwd, rel);
470
+ if (dir)
471
+ nativePrompts.push(dir);
472
+ }
473
+ }
474
+ const argv = [...decision.userArgv, ...pluginsToArgv(orgPlugins), ...extraArgv];
475
+ if (trusted === true) {
291
476
  argv.push(...decision.projectArgv);
477
+ for (const dir of nativeSkills)
478
+ argv.push("--skill", dir);
479
+ for (const dir of nativePrompts)
480
+ argv.push("--prompt-template", dir);
481
+ }
292
482
  const agentDirs = [
293
483
  ...decision.userAgentDirs,
484
+ ...orgPlugins.flatMap((p) => p.agentDirs),
294
485
  ...(trusted === true ? decision.projectAgentDirs : []),
295
486
  ];
296
487
  // User rules first, project rules last: the extension appends in order, so
297
488
  // project rules end up closest to the task — matching Claude Code priority.
298
489
  const rulesDirs = [
299
490
  ...decision.userRulesDirs,
491
+ ...extraRulesDirs,
300
492
  ...(trusted === true ? decision.projectRulesDirs : []),
301
493
  ];
302
- const mcpEntries = [
303
- ...decision.userMcp,
304
- ...(trusted === true ? decision.projectMcp : []),
305
- ];
494
+ // Org plugin servers come from repo config, so they ride the approval gate.
495
+ const mcpEntries = deps.pluginMcp === false
496
+ ? []
497
+ : [
498
+ ...decision.userMcp,
499
+ ...pluginMcpEntries(orgPlugins, true),
500
+ ...(trusted === true ? decision.projectMcp : []),
501
+ ];
306
502
  const loadedKeys = [
307
503
  ...plugins.user.map((p) => p.key),
504
+ ...orgPlugins.map((p) => p.key),
308
505
  ...(trusted === true ? plugins.project.map((p) => p.key) : []),
309
506
  ];
507
+ const user = emptySummary("~/.claude", "user");
508
+ if (userDirs.skills)
509
+ user.skills.push(userDirs.skills);
510
+ if (userDirs.commands)
511
+ user.commands.push(userDirs.commands);
512
+ user.rulesDirs.push(...decision.userRulesDirs);
513
+ addPluginsToSummary(user, plugins.user);
514
+ summaries.unshift(user);
515
+ if (trusted === true) {
516
+ const project = emptySummary("repository", "project");
517
+ if (projectDirs.skills)
518
+ project.skills.push(projectDirs.skills);
519
+ if (projectDirs.commands)
520
+ project.commands.push(projectDirs.commands);
521
+ project.rulesDirs.push(...decision.projectRulesDirs);
522
+ project.skills.push(...nativeSkills);
523
+ project.commands.push(...nativePrompts);
524
+ addPluginsToSummary(project, plugins.project);
525
+ summaries.push(project);
526
+ }
310
527
  const extraEnv = {};
311
528
  if (agentDirs.length > 0)
312
529
  extraEnv[CLAUDE_AGENT_DIRS_ENV] = agentDirs.join(delimiter);
@@ -316,7 +533,10 @@ export async function claudeCompatArgs(deps) {
316
533
  extraEnv[CLAUDE_PLUGIN_MCP_ENV] = JSON.stringify(mcpEntries);
317
534
  if (loadedKeys.length > 0)
318
535
  extraEnv[CLAUDE_PLUGIN_KEYS_ENV] = loadedKeys.join(delimiter);
319
- return { argv, env: extraEnv };
536
+ // Explicitly requested sources are always reported (an empty one is news);
537
+ // user and project scope only when they contributed something.
538
+ const sources = summaries.filter((s) => s.kind === "org-marketplace" || s.kind === "personal" || summaryHasContent(s));
539
+ return { argv, env: extraEnv, ...(sources.length > 0 ? { sources } : {}) };
320
540
  }
321
541
  function toInventory(plugin, gated) {
322
542
  return {
@@ -38,7 +38,12 @@
38
38
  * cannot reach outside its repo/plugin.
39
39
  */
40
40
  import type { PluginLedger } from "./pluginStore.js";
41
- export type PluginOrigin = "yagni" | "claude-code" | "repo-marketplace";
41
+ /**
42
+ * `org-marketplace`: a workspace's plugin marketplace snapshot handed to a
43
+ * cloud run (YAG-784); loaded by `claudeCompatArgs`'s `marketplaces` option,
44
+ * never by `discoverClaudePlugins`.
45
+ */
46
+ export type PluginOrigin = "yagni" | "claude-code" | "repo-marketplace" | "org-marketplace";
42
47
  export interface PluginMcp {
43
48
  /** File the servers came from (plugin `.mcp.json` or `plugin.json`). */
44
49
  sourcePath: string;
@@ -98,6 +103,8 @@ export interface EnableState {
98
103
  * project `.claude/settings.local.json`.
99
104
  */
100
105
  export declare function readEnabledPlugins(cwd: string, homeDir: string): Map<string, EnableState>;
106
+ /** Project-scope `enabledPlugins` only (no user `~/.claude/settings.json`). */
107
+ export declare function readProjectEnabledPlugins(cwd: string): Map<string, EnableState>;
101
108
  /** `~/.claude/plugins/known_marketplaces.json` → marketplace name → checkout dir. */
102
109
  export declare function readKnownMarketplaces(homeDir: string): Map<string, string>;
103
110
  /**
@@ -142,6 +149,13 @@ export interface DiscoverPluginsDeps {
142
149
  homeDir: string;
143
150
  /** YAGNI's own ledger (`readLedger` from pluginStore.ts); wins over the mirrored Claude Code state. */
144
151
  yagniLedger?: PluginLedger;
152
+ /**
153
+ * False skips everything under `homeDir` (the YAGNI ledger, Claude Code's
154
+ * install ledger, known marketplaces, user `enabledPlugins`), leaving only
155
+ * the repo's own marketplace and project `enabledPlugins` opt-outs. Headless
156
+ * cloud runs use it so nothing a sandbox step wrote under HOME can load.
157
+ */
158
+ includeUserScope?: boolean;
145
159
  }
146
160
  /**
147
161
  * All locally-present Claude Code plugin content relevant to `cwd`, split by
@@ -145,6 +145,13 @@ export function readEnabledPlugins(cwd, homeDir) {
145
145
  collectEnabled(join(cwd, ".claude", "settings.local.json"), "project", out);
146
146
  return out;
147
147
  }
148
+ /** Project-scope `enabledPlugins` only (no user `~/.claude/settings.json`). */
149
+ export function readProjectEnabledPlugins(cwd) {
150
+ const out = new Map();
151
+ collectEnabled(join(cwd, ".claude", "settings.json"), "project", out);
152
+ collectEnabled(join(cwd, ".claude", "settings.local.json"), "project", out);
153
+ return out;
154
+ }
148
155
  /** `~/.claude/plugins/known_marketplaces.json` → marketplace name → checkout dir. */
149
156
  export function readKnownMarketplaces(homeDir) {
150
157
  const out = new Map();
@@ -365,9 +372,10 @@ function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
365
372
  */
366
373
  export function discoverClaudePlugins(deps) {
367
374
  try {
368
- const installed = readInstalledPlugins(deps.homeDir);
369
- const enabled = readEnabledPlugins(deps.cwd, deps.homeDir);
370
- const marketplaces = readKnownMarketplaces(deps.homeDir);
375
+ const userScope = deps.includeUserScope !== false;
376
+ const installed = userScope ? readInstalledPlugins(deps.homeDir) : new Map();
377
+ const enabled = userScope ? readEnabledPlugins(deps.cwd, deps.homeDir) : readProjectEnabledPlugins(deps.cwd);
378
+ const marketplaces = userScope ? readKnownMarketplaces(deps.homeDir) : new Map();
371
379
  const realCwd = realOrNull(deps.cwd) ?? resolve(deps.cwd);
372
380
  const user = [];
373
381
  const project = [];
@@ -375,7 +383,7 @@ export function discoverClaudePlugins(deps) {
375
383
  // YAGNI-installed plugins are the user's own explicit action (`yagni
376
384
  // plugin install`), so both scopes load without ceremony; a project-scope
377
385
  // install applies only inside its repo.
378
- for (const [key, entries] of Object.entries(deps.yagniLedger?.plugins ?? {})) {
386
+ for (const [key, entries] of Object.entries((userScope ? deps.yagniLedger?.plugins : undefined) ?? {})) {
379
387
  const applicable = entries.filter((e) => {
380
388
  if (!e.enabled || !isDirectory(e.installPath))
381
389
  return false;
@@ -143,6 +143,11 @@ export interface HeadlessGoDeps {
143
143
  env?: NodeJS.ProcessEnv;
144
144
  /** Environment for spawned stage children. Defaults to `env`. */
145
145
  childEnv?: NodeJS.ProcessEnv;
146
+ /**
147
+ * Extra pi flags for the build and fix stage children: the launcher's
148
+ * `--skill` / `--prompt-template` paths (YAG-784). See RunPipelineDeps.extraArgs.
149
+ */
150
+ extraArgs?: string[];
146
151
  /** Wall clock (epoch ms) the host's time left is anchored to. Defaults to `Date.now`; injected in tests. */
147
152
  now?: () => number;
148
153
  runPipeline?: typeof defaultRunPipeline;
@@ -322,6 +322,7 @@ export async function runHeadlessGo(argv, deps = {}) {
322
322
  const pipelineDeps = {
323
323
  cwd,
324
324
  childEnv,
325
+ ...(deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
325
326
  // `go.fanout` rides the same env surface as the tier cap, so a benchmark lane
326
327
  // pins the implement diamond without a second flag (spec decision 8).
327
328
  fanout: resolveFanoutMode(env),
@@ -73,5 +73,6 @@ export declare function buildStageInvocation(stage: PipelineStage, ctx: {
73
73
  export declare function groundedChildArgv(stageArgv: string[], opts: {
74
74
  piCli: string;
75
75
  extensionPath: string;
76
+ extraArgs?: string[];
76
77
  }): string[];
77
78
  //# sourceMappingURL=invocation.d.ts.map
@@ -89,12 +89,19 @@ export function buildStageInvocation(stage, ctx) {
89
89
  */
90
90
  export function groundedChildArgv(stageArgv, opts) {
91
91
  const userChoseProvider = stageArgv.includes("--provider");
92
+ // Extra flags (the launcher's `--skill` / `--prompt-template` paths for a
93
+ // build or fix stage) go before the positional `Task: …` prompt, which pi
94
+ // reads as the message only while it stays last.
95
+ const extra = opts.extraArgs ?? [];
96
+ const passthrough = extra.length > 0 && stageArgv.length > 0
97
+ ? [...stageArgv.slice(0, -1), ...extra, stageArgv[stageArgv.length - 1]]
98
+ : stageArgv;
92
99
  return [
93
100
  opts.piCli,
94
101
  "-e",
95
102
  opts.extensionPath,
96
103
  ...(userChoseProvider ? [] : ["--provider", "yagni"]),
97
- ...stageArgv,
104
+ ...passthrough,
98
105
  ];
99
106
  }
100
107
  //# sourceMappingURL=invocation.js.map
@@ -98,6 +98,14 @@ export interface RunPipelineDeps {
98
98
  grounded?: boolean;
99
99
  /** Environment inherited by spawned child stages. Defaults to the parent process env. */
100
100
  childEnv?: NodeJS.ProcessEnv;
101
+ /**
102
+ * Extra pi flags for the stages that write code: the single-writer
103
+ * implement, each builder, the synthesizer, every fixer, and the review
104
+ * loop's fix stage. Headless /go passes the launcher's `--skill` /
105
+ * `--prompt-template` paths here (YAG-784). Map, plan, the partitioner and
106
+ * the review lenses never get them.
107
+ */
108
+ extraArgs?: string[];
101
109
  /**
102
110
  * The no-op guard's git seam (injected for unit tests). Captures the repo
103
111
  * HEAD + porcelain status so the orchestrator can tell whether the build half
@@ -275,12 +275,14 @@ export async function runPipeline(ticket, deps) {
275
275
  // `go:implement:<workstream>`), so per-workstream spend attributes for free the
276
276
  // way review lenses do. Every other stage leaves it undefined and keeps the
277
277
  // runner's own `go:<stage>` default.
278
- const stageDepsFor = (tag, callerLabel) => {
278
+ // `writes` marks a build or fix child, the only ones that get `extraArgs`.
279
+ const stageDepsFor = (tag, callerLabel, writes = false) => {
279
280
  const onEvent = deps.onEvent;
280
281
  return {
281
282
  cwd: deps.cwd,
282
283
  signal: deps.signal,
283
284
  ...(deps.childEnv ? { env: deps.childEnv } : {}),
285
+ ...(writes && deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
284
286
  ...(runId ? { attribution: { runId } } : {}),
285
287
  ...(callerLabel ? { callerLabel } : {}),
286
288
  onEvent: (ev) => {
@@ -462,7 +464,7 @@ export async function runPipeline(ticket, deps) {
462
464
  // after the builders have fixed what it pointed at.
463
465
  let summaryDefects = [];
464
466
  if (fanned) {
465
- const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL));
467
+ const synth = await runStageFn({ ...synthesizerStage(), tools: stage.tools }, { ticket: promptTicket, previous: composeSynthesizerInput(handoff, report), grounded }, stageDepsFor({ stageId: stage.id }, SYNTHESIZER_CALLER_LABEL, true));
466
468
  stages.push(synth);
467
469
  if (!isFailed(synth) && synth.finalOutput.trim()) {
468
470
  body = synth.finalOutput;
@@ -495,7 +497,7 @@ export async function runPipeline(ticket, deps) {
495
497
  const fixes = await mapWithConcurrencyLimit(assigned, ceiling, async (a) => {
496
498
  const siblings = workstreams.filter((s) => s.name !== a.workstream.name);
497
499
  progress({ kind: "stage_start", stageId: stage.id, workstream: a.workstream.name });
498
- const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name)));
500
+ const fixed = await runStageFn({ ...fixerStage(a.workstream, renderFindings(a.findings), siblings), tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id, workstream: a.workstream.name }, workstreamCallerLabel(a.workstream.name), true));
499
501
  progress({
500
502
  kind: "stage_done",
501
503
  stageId: stage.id,
@@ -520,7 +522,7 @@ export async function runPipeline(ticket, deps) {
520
522
  if (unassigned.length > 0) {
521
523
  const findingsText = renderFindings(unassigned);
522
524
  const fixStage = fanned ? synthesizerFixStage(findingsText) : fixerStage(undefined, findingsText);
523
- const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined));
525
+ const fixed = await runStageFn({ ...fixStage, tools: stage.tools }, { ticket: promptTicket, grounded }, stageDepsFor({ stageId: stage.id }, fanned ? SYNTHESIZER_CALLER_LABEL : undefined, true));
524
526
  stages.push(fixed);
525
527
  if (fanned) {
526
528
  if (!isFailed(fixed) && fixed.finalOutput.trim()) {
@@ -587,7 +589,7 @@ export async function runPipeline(ticket, deps) {
587
589
  const partitionTag = { stageId: stage.id };
588
590
  const runSingleWriter = async (decision) => {
589
591
  recordDecision(decision, beforeBuild);
590
- const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }));
592
+ const written = await runStageFn(stage, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id }, undefined, true));
591
593
  stages.push(written);
592
594
  // A failed or credit-starved writer never reaches the checker: the build
593
595
  // loop's own guard turns it into `partial` or the honest stage error, and
@@ -670,7 +672,7 @@ export async function runPipeline(ticket, deps) {
670
672
  // starts and finishes on its own timing, tagged so interleaved events stay
671
673
  // attributed, and callers that ignore the tag still see one implement pair.
672
674
  progress({ kind: "stage_start", stageId: stage.id, workstream: w.name });
673
- const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name)));
675
+ const built = await runStageFn({ ...builderStage(w, siblings), tools: stage.tools }, { ticket: promptTicket, previous, grounded }, stageDepsFor({ stageId: stage.id, workstream: w.name }, workstreamCallerLabel(w.name), true));
674
676
  // A child that died says so on its own done beat (the `degraded` flag the
675
677
  // review lenses already use), so no surface paints a dead builder as built.
676
678
  progress({
@@ -1107,7 +1109,7 @@ export async function runPipeline(ticket, deps) {
1107
1109
  const fixStartedAt = clock();
1108
1110
  progress({ kind: "stage_start", stageId: "fix", round });
1109
1111
  stageEvent("fix", "start", { round });
1110
- const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
1112
+ const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: designContext ? `${serializeBlocking(findings)}\n\n${designContext}` : serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }, undefined, true));
1111
1113
  observedMs.fix = clock() - fixStartedAt;
1112
1114
  stages.push(fixResult);
1113
1115
  log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
@@ -35,6 +35,12 @@ export interface RunStageDeps {
35
35
  };
36
36
  /** Optional environment for spawned child stages. Defaults to Node's inherited env. */
37
37
  env?: NodeJS.ProcessEnv;
38
+ /**
39
+ * Extra pi flags for this child, placed before the positional task prompt.
40
+ * Headless /go passes the launcher's `--skill` / `--prompt-template` paths
41
+ * here for build and fix stages only (YAG-784).
42
+ */
43
+ extraArgs?: string[];
38
44
  /**
39
45
  * YAG-471 attribution: the tracked run's server-assigned id (a UUID), sent to
40
46
  * the child as `YAGNI_RUN_ID` so the model proxy's `x-yagni-run-id` header
@@ -122,7 +122,11 @@ export async function runStage(stage, ctx, deps) {
122
122
  ...(tierCap ? { tierCap } : {}),
123
123
  });
124
124
  const { piCli, extensionPath } = resolveChild();
125
- const argv = groundedChildArgv(passthrough, { piCli, extensionPath });
125
+ const argv = groundedChildArgv(passthrough, {
126
+ piCli,
127
+ extensionPath,
128
+ ...(deps.extraArgs?.length ? { extraArgs: deps.extraArgs } : {}),
129
+ });
126
130
  // Fold events into a fixed-size accumulator as they stream — never retain
127
131
  // the full array (that OOMs on a verbose stage; YAG-317 follow-up).
128
132
  const acc = newEventAccumulator();
@@ -14,7 +14,18 @@
14
14
  * `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
15
15
  * runs locally and what the fleet executes are the same binary and the same
16
16
  * pipeline.
17
+ *
18
+ * Skills (YAG-784): the launcher also wires the same Claude Code compat seams
19
+ * an interactive launch gets, minus plugin MCP. In a cloud run the backend's
20
+ * `sources.json` mission file decides: the repo's committed skills load only
21
+ * when an admin marked it trusted for agent runs, and the org marketplace and
22
+ * personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
23
+ * (every local run) the folder's own pi trust decides, as it does
24
+ * interactively. The skill flags reach only the build and fix stages; rules
25
+ * and agent dirs ride the env to every stage.
17
26
  */
27
+ import { type AgentRunSources } from "./agentSources.js";
28
+ import { type ClaudeCompatDeps, type ClaudeCompatLaunch, type CompatSourceSummary } from "./claudeCompat.js";
18
29
  import { type OtelLaunchConfig } from "./otel.js";
19
30
  /** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
20
31
  export declare const GO_EXIT: {
@@ -28,6 +39,7 @@ interface HeadlessGoModule {
28
39
  cwd?: string;
29
40
  env?: NodeJS.ProcessEnv;
30
41
  childEnv?: NodeJS.ProcessEnv;
42
+ extraArgs?: string[];
31
43
  }) => Promise<{
32
44
  exitCode: number;
33
45
  }>;
@@ -48,6 +60,12 @@ export interface GoCommandDeps {
48
60
  writeErr?: (line: string) => void;
49
61
  env?: NodeJS.ProcessEnv;
50
62
  cwd?: string;
63
+ /** Compat seam (tests inject one so nothing reads the real home). */
64
+ claudeCompat?: (deps: ClaudeCompatDeps) => Promise<ClaudeCompatLaunch>;
65
+ /** The `sources.json` reader; defaults to `$YAGNI_MISSION_DIR/sources.json`. */
66
+ readSources?: (env: NodeJS.ProcessEnv) => AgentRunSources | null;
67
+ /** Root every cloud snapshot dir must sit under; tests point it at a temp dir. */
68
+ sourcesRoot?: string;
51
69
  }
52
70
  /**
53
71
  * Build the environment every pipeline stage child inherits: the proxy
@@ -68,6 +86,12 @@ export declare function buildHeadlessChildEnv(opts: {
68
86
  /** Resolved OTel export config; stage children export when present. */
69
87
  otel?: OtelLaunchConfig;
70
88
  }): NodeJS.ProcessEnv;
89
+ /** The one stderr line naming what each source contributed; null when there is nothing to say. */
90
+ export declare function skillsSummaryLine(opts: {
91
+ cloud: AgentRunSources | null;
92
+ sources: CompatSourceSummary[];
93
+ refused: string[];
94
+ }): string | null;
71
95
  /**
72
96
  * Run `yagni go`. Returns the process exit code: 0 only on a verified
73
97
  * candidate, 1 on any other pipeline outcome, 2 on a usage or credential
@@ -14,11 +14,23 @@
14
14
  * `--plan-file`, optional `--memo-file`, `--run-id`, `--json`), so what Jack
15
15
  * runs locally and what the fleet executes are the same binary and the same
16
16
  * pipeline.
17
+ *
18
+ * Skills (YAG-784): the launcher also wires the same Claude Code compat seams
19
+ * an interactive launch gets, minus plugin MCP. In a cloud run the backend's
20
+ * `sources.json` mission file decides: the repo's committed skills load only
21
+ * when an admin marked it trusted for agent runs, and the org marketplace and
22
+ * personal skills snapshots load from AGENT_SOURCES_ROOT. With no such file
23
+ * (every local run) the folder's own pi trust decides, as it does
24
+ * interactively. The skill flags reach only the build and fix stages; rules
25
+ * and agent dirs ride the env to every stage.
17
26
  */
18
27
  import { randomUUID } from "node:crypto";
19
28
  import { mkdirSync } from "node:fs";
29
+ import { resolve } from "node:path";
20
30
  import { pathToFileURL } from "node:url";
31
+ import { AGENT_SOURCES_ROOT, cloudCompatOptions, missionDirFromEnv, readAgentRunSources, } from "./agentSources.js";
21
32
  import { agentDirEnvVar } from "./branding.js";
33
+ import { CLAUDE_AGENT_DIRS_ENV, CLAUDE_PLUGIN_KEYS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_RULES_DIRS_ENV, claudeCompatArgs, } from "./claudeCompat.js";
22
34
  import { agentDir, credentialsDir } from "./credentials.js";
23
35
  import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
24
36
  import { credentialsFromProfile, profilePath, readActiveProfile } from "./profiles.js";
@@ -60,6 +72,83 @@ export function buildHeadlessChildEnv(opts) {
60
72
  ...(opts.otel ? otelChildEnv(opts.otel, opts.baseEnv) : {}),
61
73
  };
62
74
  }
75
+ /** The compat keys a headless launch computes itself rather than inherits. */
76
+ const COMPAT_ENV_KEYS = [CLAUDE_AGENT_DIRS_ENV, CLAUDE_RULES_DIRS_ENV, CLAUDE_PLUGIN_MCP_ENV, CLAUDE_PLUGIN_KEYS_ENV];
77
+ /** The run's working dir as the pipeline will resolve it (`--cwd` wins). */
78
+ function pipelineCwd(args, fallback) {
79
+ for (let i = 0; i < args.length; i++) {
80
+ const token = args[i];
81
+ if (token === "--cwd" && args[i + 1])
82
+ return resolve(fallback, args[i + 1]);
83
+ if (token.startsWith("--cwd=") && token.length > 6)
84
+ return resolve(fallback, token.slice(6));
85
+ }
86
+ return fallback;
87
+ }
88
+ function countsOf(s) {
89
+ const parts = [];
90
+ const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
91
+ if (s.plugins.length > 0)
92
+ parts.push(n(s.plugins.length, "plugin"));
93
+ if (s.skills.length > 0)
94
+ parts.push(n(s.skills.length, "skills dir"));
95
+ if (s.commands.length > 0)
96
+ parts.push(n(s.commands.length, "commands dir"));
97
+ if (s.rulesDirs.length > 0)
98
+ parts.push(n(s.rulesDirs.length, "rules dir"));
99
+ if (s.unsupported.length > 0)
100
+ parts.push(`${s.unsupported.length} not loaded (${s.unsupported.join("; ")})`);
101
+ return parts.length > 0 ? parts.join(", ") : "nothing to load";
102
+ }
103
+ /** The one stderr line naming what each source contributed; null when there is nothing to say. */
104
+ export function skillsSummaryLine(opts) {
105
+ const parts = opts.sources.map((s) => {
106
+ const label = s.kind === "project" ? `${opts.cloud?.repo.fullName ?? "repository"} (trusted)` : s.label;
107
+ return `${label}: ${countsOf(s)}`;
108
+ });
109
+ if (opts.cloud && !opts.cloud.repo.trusted)
110
+ parts.push(`${opts.cloud.repo.fullName}: repository skills off (not trusted for agent runs)`);
111
+ for (const refused of opts.refused)
112
+ parts.push(`${refused} (skipped)`);
113
+ return parts.length > 0 ? `Skills: ${parts.join(" | ")}` : null;
114
+ }
115
+ /**
116
+ * The compat argv + env for a headless run. Cloud mode (a valid sources.json)
117
+ * trusts what the backend decided and never reads HOME; local mode keeps the
118
+ * folder's pi trust decision and the user's own `~/.claude`. Plugin MCP never
119
+ * loads headlessly. Never throws: a failure here only means no skills, and
120
+ * it says so on stderr so it is not mistaken for "nothing configured".
121
+ */
122
+ async function headlessCompat(opts) {
123
+ const { cwd, agentDir, childEnv, deps, writeErr } = opts;
124
+ try {
125
+ const cloud = (deps.readSources ?? ((env) => readAgentRunSources(missionDirFromEnv(env), writeErr)))(childEnv);
126
+ const compat = deps.claudeCompat ?? claudeCompatArgs;
127
+ const base = { cwd, agentDir, env: childEnv, interactive: false, pluginMcp: false };
128
+ let refused = [];
129
+ let launch;
130
+ if (cloud) {
131
+ const plan = cloudCompatOptions(cloud, deps.sourcesRoot ?? AGENT_SOURCES_ROOT);
132
+ refused = plan.refused;
133
+ launch = await compat({
134
+ ...base,
135
+ trustOverride: plan.trustOverride,
136
+ userScope: false,
137
+ projectNativeSkills: true,
138
+ extraRoots: plan.extraRoots,
139
+ marketplaces: plan.marketplaces,
140
+ });
141
+ }
142
+ else {
143
+ launch = await compat(base);
144
+ }
145
+ return { launch, summary: skillsSummaryLine({ cloud, sources: launch.sources ?? [], refused }) };
146
+ }
147
+ catch (err) {
148
+ const why = err instanceof Error && err.message ? err.message : String(err);
149
+ return { launch: { argv: [], env: {} }, summary: `Skills: not loaded (the skills setup failed: ${why})` };
150
+ }
151
+ }
63
152
  /**
64
153
  * Run `yagni go`. Returns the process exit code: 0 only on a verified
65
154
  * candidate, 1 on any other pipeline outcome, 2 on a usage or credential
@@ -135,10 +224,27 @@ export async function goCommand(args, deps = {}, cliVersion) {
135
224
  writeErr(err instanceof Error ? err.message : String(err));
136
225
  return GO_EXIT.usage;
137
226
  }
227
+ const cwd = deps.cwd ?? process.cwd();
228
+ const compat = await headlessCompat({
229
+ cwd: pipelineCwd(args, cwd),
230
+ agentDir: childEnv[agentDirEnvVar()],
231
+ childEnv,
232
+ deps,
233
+ writeErr,
234
+ });
235
+ // Recompute the compat keys rather than inherit a parent session's: plugin
236
+ // MCP especially must never reach a headless stage child.
237
+ for (const key of COMPAT_ENV_KEYS)
238
+ delete childEnv[key];
239
+ Object.assign(childEnv, compat.launch.env);
240
+ delete childEnv[CLAUDE_PLUGIN_MCP_ENV];
241
+ if (compat.summary)
242
+ writeErr(compat.summary);
138
243
  const outcome = await headless.runHeadlessGo(args, {
139
- cwd: deps.cwd ?? process.cwd(),
244
+ cwd,
140
245
  env: baseEnv,
141
246
  childEnv,
247
+ ...(compat.launch.argv.length > 0 ? { extraArgs: compat.launch.argv } : {}),
142
248
  });
143
249
  return outcome.exitCode;
144
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.4-staging.1778.1",
3
+ "version": "1.2.4-staging.1809.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "4aedf8e9e5093b88e04de0470bbf2c527583b644"
61
+ "yagniSourceSha": "33de16e5a97a30639624793c8e9f9d5120b7aea8"
62
62
  }