@nail00749/agent-gvozd 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -987,13 +987,12 @@ ${b}
987
987
  var i = `${styleText("gray", S_BAR)} `;
988
988
 
989
989
  // src/cli.ts
990
- import { realpathSync as realpathSync3 } from "node:fs";
990
+ import { realpathSync as realpathSync5 } from "node:fs";
991
991
  import { fileURLToPath as fileURLToPath2 } from "node:url";
992
992
 
993
993
  // src/config.ts
994
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
995
- import { homedir } from "node:os";
996
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
994
+ import { existsSync as existsSync2, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
995
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
997
996
  import { fileURLToPath } from "node:url";
998
997
 
999
998
  // node_modules/jsonc-parser/lib/esm/impl/scanner.js
@@ -6466,12 +6465,180 @@ function refine(fn, _params = {}) {
6466
6465
  function superRefine(fn, params) {
6467
6466
  return _superRefine(fn, params);
6468
6467
  }
6468
+ // src/config-root.ts
6469
+ import { homedir } from "node:os";
6470
+ import { isAbsolute, join, resolve } from "node:path";
6471
+ var GVOZD_CONFIG_ROOT_ENV = "GVOZD_OPENCODE_CONFIG_ROOT";
6472
+ function absolute(path, label) {
6473
+ if (!isAbsolute(path))
6474
+ throw new Error(`${label} must be an absolute path`);
6475
+ return resolve(path);
6476
+ }
6477
+ function resolveOpenCodeConfigRootContract(env = process.env, platform = process.platform, home = homedir(), explicitRoot) {
6478
+ if (explicitRoot)
6479
+ return { path: absolute(explicitRoot, "OpenCode config root"), source: "explicit" };
6480
+ const override = env[GVOZD_CONFIG_ROOT_ENV];
6481
+ if (override) {
6482
+ return { path: absolute(override, GVOZD_CONFIG_ROOT_ENV), source: GVOZD_CONFIG_ROOT_ENV };
6483
+ }
6484
+ if (env.XDG_CONFIG_HOME) {
6485
+ return { path: join(absolute(env.XDG_CONFIG_HOME, "XDG_CONFIG_HOME"), "opencode"), source: "XDG_CONFIG_HOME" };
6486
+ }
6487
+ if (platform === "win32" && env.APPDATA) {
6488
+ return { path: join(env.APPDATA, "opencode"), source: "APPDATA" };
6489
+ }
6490
+ return { path: join(home, ".config", "opencode"), source: "platform-default" };
6491
+ }
6492
+ function resolveOpenCodeConfigRoot(env = process.env, platform = process.platform, home = homedir(), explicitRoot) {
6493
+ return resolveOpenCodeConfigRootContract(env, platform, home, explicitRoot).path;
6494
+ }
6495
+
6496
+ // src/project-trust.ts
6497
+ import { createHash } from "node:crypto";
6498
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
6499
+ import { dirname, isAbsolute as isAbsolute2, join as join2, relative, resolve as resolve2 } from "node:path";
6500
+ var PROJECT_TRUST_ENV = "GVOZD_TRUST_PROJECT_CONFIG";
6501
+ function findRoot(start) {
6502
+ let current = resolve2(start);
6503
+ while (true) {
6504
+ if (existsSync(join2(current, ".git")))
6505
+ return realpathSync(current);
6506
+ const parent = dirname(current);
6507
+ if (parent === current)
6508
+ return realpathSync(resolve2(start));
6509
+ current = parent;
6510
+ }
6511
+ }
6512
+ function within(base, target, label) {
6513
+ const child = relative(base, target);
6514
+ if (child === "" || !child.startsWith("..") && !isAbsolute2(child))
6515
+ return;
6516
+ throw new Error(`${label} must stay inside ${base}: ${target}`);
6517
+ }
6518
+ function rejectSymlinkComponents(base, target, label) {
6519
+ within(base, target, label);
6520
+ const segments = relative(base, target).split(/[\\/]/).filter(Boolean);
6521
+ let current = base;
6522
+ for (const segment of segments) {
6523
+ current = join2(current, segment);
6524
+ if (!existsSync(current))
6525
+ continue;
6526
+ if (lstatSync(current).isSymbolicLink())
6527
+ throw new Error(`${label} must not contain symlink components: ${current}`);
6528
+ }
6529
+ }
6530
+ function readJsonc(path) {
6531
+ const bytes = readFileSync(path);
6532
+ const errors = [];
6533
+ const value = parse2(bytes.toString("utf8"), errors, { allowTrailingComma: true, disallowComments: false });
6534
+ if (errors.length > 0 || !value || typeof value !== "object" || Array.isArray(value)) {
6535
+ const details = errors.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`).join(", ");
6536
+ throw new Error(`Invalid JSONC in ${path}${details ? `: ${details}` : ""}`);
6537
+ }
6538
+ return { value, bytes };
6539
+ }
6540
+ function promptFromPatch(patch) {
6541
+ if (!patch || typeof patch !== "object" || Array.isArray(patch))
6542
+ return;
6543
+ const prompt = patch.prompt;
6544
+ return typeof prompt === "string" && prompt.length > 0 ? prompt : undefined;
6545
+ }
6546
+ function collectProjectTrustInputs(projectDirectory) {
6547
+ const canonicalRoot = findRoot(projectDirectory);
6548
+ const layer = join2(canonicalRoot, "docs", ".gvozd");
6549
+ const rootPath = join2(layer, "config.jsonc");
6550
+ if (!existsSync(rootPath))
6551
+ return {
6552
+ canonicalRoot,
6553
+ inputs: [{ identity: "docs/.gvozd/config.jsonc:<missing>", bytes: Buffer.alloc(0) }]
6554
+ };
6555
+ rejectSymlinkComponents(canonicalRoot, rootPath, "Project config path");
6556
+ const rootStat = lstatSync(rootPath);
6557
+ if (!rootStat.isFile() || rootStat.isSymbolicLink())
6558
+ throw new Error(`Project config must be a regular non-symlink file: ${rootPath}`);
6559
+ const root = readJsonc(rootPath);
6560
+ const files = new Map([[relative(canonicalRoot, rootPath).replaceAll("\\", "/"), root.bytes]]);
6561
+ const prompts = [];
6562
+ const inlineAgents = root.value.agents;
6563
+ if (inlineAgents && typeof inlineAgents === "object" && !Array.isArray(inlineAgents)) {
6564
+ for (const patch of Object.values(inlineAgents)) {
6565
+ const prompt = promptFromPatch(patch);
6566
+ if (prompt)
6567
+ prompts.push({ source: rootPath, value: prompt });
6568
+ }
6569
+ }
6570
+ const configuredDirectory = root.value.agentsDirectory;
6571
+ if (configuredDirectory !== undefined && (typeof configuredDirectory !== "string" || configuredDirectory.length === 0)) {
6572
+ throw new Error(`Invalid agentsDirectory in ${rootPath}`);
6573
+ }
6574
+ const agentsDirectory = resolve2(layer, configuredDirectory ?? "agents");
6575
+ within(layer, agentsDirectory, "agentsDirectory");
6576
+ if (existsSync(agentsDirectory)) {
6577
+ rejectSymlinkComponents(canonicalRoot, agentsDirectory, "agentsDirectory");
6578
+ const stat = lstatSync(agentsDirectory);
6579
+ if (!stat.isDirectory() || stat.isSymbolicLink())
6580
+ throw new Error(`agentsDirectory must be a regular directory: ${agentsDirectory}`);
6581
+ within(realpathSync(layer), realpathSync(agentsDirectory), "agentsDirectory");
6582
+ for (const entry of readdirSync(agentsDirectory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
6583
+ if (!entry.name.endsWith(".jsonc"))
6584
+ continue;
6585
+ const path = join2(agentsDirectory, entry.name);
6586
+ if (!entry.isFile())
6587
+ throw new Error(`Agent fragment must be a regular non-symlink file: ${path}`);
6588
+ rejectSymlinkComponents(canonicalRoot, path, "Agent fragment path");
6589
+ const stat = lstatSync(path);
6590
+ if (!stat.isFile() || stat.isSymbolicLink())
6591
+ throw new Error(`Agent fragment must be a regular non-symlink file: ${path}`);
6592
+ const fragment = readJsonc(path);
6593
+ files.set(relative(canonicalRoot, path).replaceAll("\\", "/"), fragment.bytes);
6594
+ const prompt = promptFromPatch(fragment.value);
6595
+ if (prompt)
6596
+ prompts.push({ source: path, value: prompt });
6597
+ }
6598
+ }
6599
+ for (const prompt of prompts) {
6600
+ const path = resolve2(dirname(prompt.source), prompt.value);
6601
+ within(layer, path, "Agent prompt");
6602
+ rejectSymlinkComponents(canonicalRoot, path, "Agent prompt");
6603
+ if (!existsSync(path))
6604
+ throw new Error(`Agent prompt is missing: ${path}`);
6605
+ const stat = lstatSync(path);
6606
+ if (!stat.isFile() || stat.isSymbolicLink())
6607
+ throw new Error(`Agent prompt must be a regular non-symlink file: ${path}`);
6608
+ const canonical = realpathSync(path);
6609
+ within(realpathSync(layer), canonical, "Agent prompt");
6610
+ files.set(relative(canonicalRoot, path).replaceAll("\\", "/"), readFileSync(path));
6611
+ }
6612
+ return {
6613
+ canonicalRoot,
6614
+ inputs: [...files].map(([identity, bytes]) => ({ identity, bytes })).sort((left, right) => left.identity.localeCompare(right.identity))
6615
+ };
6616
+ }
6617
+ function computeProjectTrustToken(projectDirectory) {
6618
+ const collected = collectProjectTrustInputs(projectDirectory);
6619
+ const hash = createHash("sha256");
6620
+ hash.update("agent-gvozd-project-trust-v1\x00");
6621
+ hash.update(collected.canonicalRoot);
6622
+ hash.update("\x00");
6623
+ for (const input of collected.inputs) {
6624
+ hash.update(String(Buffer.byteLength(input.identity)));
6625
+ hash.update(":");
6626
+ hash.update(input.identity);
6627
+ hash.update(":");
6628
+ hash.update(String(input.bytes.length));
6629
+ hash.update(":");
6630
+ hash.update(input.bytes);
6631
+ hash.update("\x00");
6632
+ }
6633
+ return `sha256:${hash.digest("hex")}`;
6634
+ }
6635
+
6469
6636
  // src/config.ts
6470
6637
  var permissionSchema = object({
6471
6638
  action: string2().min(1),
6472
6639
  resource: string2().min(1),
6473
6640
  effect: _enum(["allow", "ask", "deny"])
6474
- });
6641
+ }).strict();
6475
6642
  var modelRefSchema = string2().min(1).regex(/^[^/#\s]+\/[^#\s]+(?:#[^#\s]+)?$/, "Expected provider/model or provider/model#variant");
6476
6643
  var agentIdSchema = string2().regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/, "Expected a filesystem-safe agent ID");
6477
6644
  var fileLeaseRoleSchema = _enum(["coordinator", "writer", "readonly"]);
@@ -6485,12 +6652,13 @@ var agentPatchSchema = object({
6485
6652
  permissions: array(permissionSchema).optional(),
6486
6653
  fileLease: fileLeaseRoleSchema.optional(),
6487
6654
  disabled: boolean2().optional()
6488
- });
6655
+ }).strict();
6489
6656
  var rootPatchSchema = object({
6657
+ $schema: string2().min(1).optional(),
6490
6658
  defaultAgent: agentIdSchema.optional(),
6491
6659
  agentsDirectory: string2().min(1).optional(),
6492
6660
  agents: record(agentIdSchema, agentPatchSchema).optional()
6493
- });
6661
+ }).strict();
6494
6662
  var resolvedAgentSchema = agentPatchSchema.extend({
6495
6663
  description: string2().min(1),
6496
6664
  mode: _enum(["primary", "subagent", "all"]),
@@ -6502,9 +6670,18 @@ var resolvedAgentSchema = agentPatchSchema.extend({
6502
6670
  fileLease: fileLeaseRoleSchema,
6503
6671
  disabled: boolean2()
6504
6672
  });
6505
- function readJsonc(path) {
6673
+ var SAFE_UNTRUSTED_AGENT_FIELDS = new Set(["description"]);
6674
+ function assertTrustedProjectPatch(patch, sourcePath, id, trusted, knownAgents) {
6675
+ if (trusted)
6676
+ return;
6677
+ const fields = Object.keys(patch).filter((field) => !SAFE_UNTRUSTED_AGENT_FIELDS.has(field));
6678
+ if (fields.length === 0 && knownAgents.has(id))
6679
+ return;
6680
+ throw new Error(`Untrusted project config ${sourcePath} cannot override ${fields.join(", ") || `unknown agent ${id}`}. ` + `Set ${PROJECT_TRUST_ENV} to the exact token returned by computeProjectTrustToken() after reviewing these files.`);
6681
+ }
6682
+ function readJsonc2(path) {
6506
6683
  const errors = [];
6507
- const value = parse2(readFileSync(path, "utf8"), errors, {
6684
+ const value = parse2(readFileSync2(path, "utf8"), errors, {
6508
6685
  allowTrailingComma: true,
6509
6686
  disallowComments: false
6510
6687
  });
@@ -6515,45 +6692,62 @@ function readJsonc(path) {
6515
6692
  return value;
6516
6693
  }
6517
6694
  function assertWithin(base, target, label) {
6518
- const child = relative(base, target);
6519
- if (child === "" || !child.startsWith("..") && !isAbsolute(child))
6695
+ const child = relative2(base, target);
6696
+ if (child === "" || !child.startsWith("..") && !isAbsolute3(child))
6520
6697
  return;
6521
6698
  throw new Error(`${label} must stay inside ${base}: ${target}`);
6522
6699
  }
6523
6700
  function resolvePrompt(patch, sourcePath, layerDirectory) {
6524
6701
  if (!patch.prompt)
6525
6702
  return patch;
6526
- const prompt = resolve(dirname(sourcePath), patch.prompt);
6703
+ const prompt = resolve3(dirname2(sourcePath), patch.prompt);
6527
6704
  assertWithin(layerDirectory, prompt, "Agent prompt");
6528
- if (!existsSync(prompt))
6705
+ if (!existsSync2(prompt))
6529
6706
  throw new Error(`Agent prompt is missing: ${prompt}`);
6530
- const canonical = realpathSync(prompt);
6531
- assertWithin(realpathSync(layerDirectory), canonical, "Agent prompt");
6532
- return { ...patch, prompt: canonical };
6533
- }
6534
- function loadLayer(directory, rootFileName, required) {
6535
- const rootPath = join(directory, rootFileName);
6536
- if (!existsSync(rootPath)) {
6707
+ const promptStat = lstatSync2(prompt);
6708
+ if (promptStat.isSymbolicLink() || !promptStat.isFile())
6709
+ throw new Error(`Agent prompt must be a regular non-symlink file: ${prompt}`);
6710
+ const canonical = realpathSync2(prompt);
6711
+ assertWithin(realpathSync2(layerDirectory), canonical, "Agent prompt");
6712
+ const promptContent = readFileSync2(canonical, "utf8");
6713
+ return { ...patch, prompt: canonical, promptContent };
6714
+ }
6715
+ function loadLayer(directory, rootFileName, required, projectPolicy) {
6716
+ const rootPath = join3(directory, rootFileName);
6717
+ if (!existsSync2(rootPath)) {
6537
6718
  if (required)
6538
6719
  throw new Error(`Required config is missing: ${rootPath}`);
6539
6720
  return { agents: {}, sources: [] };
6540
6721
  }
6541
- const root = rootPatchSchema.parse(readJsonc(rootPath));
6722
+ const root = rootPatchSchema.parse(readJsonc2(rootPath));
6723
+ if (projectPolicy && !projectPolicy.trusted) {
6724
+ const restricted = ["defaultAgent", "agentsDirectory"].filter((field) => Object.prototype.hasOwnProperty.call(root, field));
6725
+ if (restricted.length > 0)
6726
+ throw new Error(`Untrusted project config ${rootPath} cannot override ${restricted.join(", ")}`);
6727
+ }
6542
6728
  const agents = {};
6543
6729
  for (const [id, patch] of Object.entries(root.agents ?? {})) {
6730
+ if (projectPolicy)
6731
+ assertTrustedProjectPatch(patch, rootPath, id, projectPolicy.trusted, projectPolicy.knownAgents);
6544
6732
  agents[id] = resolvePrompt(patch, rootPath, directory);
6545
6733
  }
6546
- const agentsDirectory = resolve(directory, root.agentsDirectory ?? "agents");
6734
+ const agentsDirectory = resolve3(directory, root.agentsDirectory ?? "agents");
6547
6735
  assertWithin(directory, agentsDirectory, "agentsDirectory");
6548
- if (existsSync(agentsDirectory)) {
6549
- assertWithin(realpathSync(directory), realpathSync(agentsDirectory), "agentsDirectory");
6550
- for (const entry of readdirSync(agentsDirectory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
6736
+ if (existsSync2(agentsDirectory)) {
6737
+ const directoryStat = lstatSync2(agentsDirectory);
6738
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
6739
+ throw new Error(`agentsDirectory must be a regular directory: ${agentsDirectory}`);
6740
+ }
6741
+ assertWithin(realpathSync2(directory), realpathSync2(agentsDirectory), "agentsDirectory");
6742
+ for (const entry of readdirSync2(agentsDirectory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
6551
6743
  if (!entry.isFile() || !entry.name.endsWith(".jsonc"))
6552
6744
  continue;
6553
6745
  const id = entry.name.slice(0, -".jsonc".length);
6554
6746
  agentIdSchema.parse(id);
6555
- const agentPath = join(agentsDirectory, entry.name);
6556
- const patch = agentPatchSchema.parse(readJsonc(agentPath));
6747
+ const agentPath = join3(agentsDirectory, entry.name);
6748
+ const patch = agentPatchSchema.parse(readJsonc2(agentPath));
6749
+ if (projectPolicy)
6750
+ assertTrustedProjectPatch(patch, agentPath, id, projectPolicy.trusted, projectPolicy.knownAgents);
6557
6751
  agents[id] = mergeAgent(agents[id], resolvePrompt(patch, agentPath, directory));
6558
6752
  }
6559
6753
  }
@@ -6567,22 +6761,24 @@ function mergeAgent(base, override) {
6567
6761
  return { ...base ?? {}, ...override };
6568
6762
  }
6569
6763
  function resolveAgentConfig(patch) {
6570
- const parsed = agentPatchSchema.parse(patch);
6571
- return resolvedAgentSchema.parse({
6764
+ const parsed = agentPatchSchema.extend({ promptContent: string2().optional() }).parse(patch);
6765
+ const { promptContent, ...agentPatch } = parsed;
6766
+ const resolved = resolvedAgentSchema.parse({
6572
6767
  skills: [],
6573
6768
  mcp: [],
6574
6769
  permissions: [],
6575
6770
  fileLease: "readonly",
6576
6771
  disabled: false,
6577
- ...parsed
6772
+ ...agentPatch
6578
6773
  });
6774
+ return promptContent === undefined ? resolved : { ...resolved, promptContent };
6579
6775
  }
6580
6776
  function findPackageRoot() {
6581
- let current = dirname(fileURLToPath(import.meta.url));
6777
+ let current = dirname2(fileURLToPath(import.meta.url));
6582
6778
  while (true) {
6583
- if (existsSync(join(current, "defaults", "default.jsonc")))
6779
+ if (existsSync2(join3(current, "defaults", "default.jsonc")))
6584
6780
  return current;
6585
- const parent = dirname(current);
6781
+ const parent = dirname2(current);
6586
6782
  if (parent === current)
6587
6783
  break;
6588
6784
  current = parent;
@@ -6590,34 +6786,35 @@ function findPackageRoot() {
6590
6786
  throw new Error("Unable to locate agent-gvozd package defaults");
6591
6787
  }
6592
6788
  function findProjectRoot(start) {
6593
- let current = resolve(start);
6789
+ let current = resolve3(start);
6594
6790
  while (true) {
6595
- const git = join(current, ".git");
6596
- if (existsSync(git))
6791
+ const git = join3(current, ".git");
6792
+ if (existsSync2(git))
6597
6793
  return current;
6598
- const parent = dirname(current);
6794
+ const parent = dirname2(current);
6599
6795
  if (parent === current)
6600
- return resolve(start);
6796
+ return resolve3(start);
6601
6797
  current = parent;
6602
6798
  }
6603
6799
  }
6604
- function resolveOpenCodeConfigRoot(env = process.env, platform = process.platform, home = homedir()) {
6605
- if (env.XDG_CONFIG_HOME)
6606
- return join(env.XDG_CONFIG_HOME, "opencode");
6607
- if (platform === "win32" && env.APPDATA)
6608
- return join(env.APPDATA, "opencode");
6609
- return join(home, ".config", "opencode");
6610
- }
6611
6800
  function loadConfig(projectDirectory, options = {}) {
6612
6801
  const projectRoot = findProjectRoot(projectDirectory);
6613
6802
  const packageRoot = findPackageRoot();
6614
- const projectConfigDirectory = join(projectRoot, "docs", ".gvozd");
6615
- const globalConfigDirectory = join(options.configRoot ?? resolveOpenCodeConfigRoot(options.env, options.platform, options.home), "gvozd");
6616
- const layers = [
6617
- loadLayer(join(packageRoot, "defaults"), "default.jsonc", true),
6618
- loadLayer(globalConfigDirectory, "config.jsonc", false),
6619
- ...options.includeProject === false ? [] : [loadLayer(projectConfigDirectory, "config.jsonc", false)]
6803
+ const projectConfigDirectory = join3(projectRoot, "docs", ".gvozd");
6804
+ const globalConfigDirectory = join3(resolveOpenCodeConfigRoot(options.env, options.platform, options.home, options.configRoot), "gvozd");
6805
+ const baseLayers = [
6806
+ loadLayer(join3(packageRoot, "defaults"), "default.jsonc", true),
6807
+ loadLayer(globalConfigDirectory, "config.jsonc", false)
6620
6808
  ];
6809
+ const knownAgents = new Set(baseLayers.flatMap((layer) => Object.keys(layer.agents)));
6810
+ const includeProject = options.includeProject !== false;
6811
+ const suppliedToken = includeProject ? options.projectTrustToken ?? (options.env ?? process.env)[PROJECT_TRUST_ENV] : undefined;
6812
+ const trustProjectConfig = includeProject && suppliedToken !== undefined && suppliedToken === computeProjectTrustToken(projectRoot);
6813
+ const projectLayer = includeProject ? loadLayer(projectConfigDirectory, "config.jsonc", false, { trusted: trustProjectConfig, knownAgents }) : undefined;
6814
+ if (trustProjectConfig && suppliedToken !== computeProjectTrustToken(projectRoot)) {
6815
+ throw new Error("Project configuration changed while its trust token was being validated; review it and compute a new token");
6816
+ }
6817
+ const layers = [...baseLayers, ...projectLayer ? [projectLayer] : []];
6621
6818
  let defaultAgent;
6622
6819
  const agents = {};
6623
6820
  for (const layer of layers) {
@@ -6630,7 +6827,12 @@ function loadConfig(projectDirectory, options = {}) {
6630
6827
  throw new Error("defaultAgent is not configured");
6631
6828
  const resolvedAgents = Object.fromEntries(Object.entries(agents).map(([id, patch]) => [
6632
6829
  id,
6633
- resolveAgentConfig(patch)
6830
+ (() => {
6831
+ const agent = resolveAgentConfig(patch);
6832
+ if (agent.promptContent === undefined)
6833
+ throw new Error(`Agent prompt snapshot is missing after configuration load: ${agent.prompt}`);
6834
+ return agent;
6835
+ })()
6634
6836
  ]));
6635
6837
  const defaultConfig = resolvedAgents[defaultAgent];
6636
6838
  if (!defaultConfig || defaultConfig.disabled || defaultConfig.mode === "subagent") {
@@ -6648,15 +6850,79 @@ function loadConfig(projectDirectory, options = {}) {
6648
6850
  }
6649
6851
 
6650
6852
  // src/cli/doctor.ts
6651
- import { lstatSync, readFileSync as readFileSync3 } from "node:fs";
6652
- import { join as join2, resolve as resolve2 } from "node:path";
6853
+ import { existsSync as existsSync3, lstatSync as lstatSync3, readFileSync as readFileSync3, readdirSync as readdirSync3 } from "node:fs";
6854
+ import { join as join4, resolve as resolve4 } from "node:path";
6653
6855
 
6654
- // src/agent-generation.ts
6655
- import { readFileSync as readFileSync2 } from "node:fs";
6856
+ // src/agent-permissions.ts
6857
+ function normalizeMcpName(name) {
6858
+ return name.replaceAll(/[^A-Za-z0-9_-]/g, "_");
6859
+ }
6860
+ function buildAgentPermissions(agent, mcpServers) {
6861
+ const result = [
6862
+ ...agent.permissions,
6863
+ { action: "skill", resource: "*", effect: "deny" },
6864
+ ...agent.skills.map((skill) => ({ action: "skill", resource: skill, effect: "allow" }))
6865
+ ];
6866
+ for (const server of mcpServers) {
6867
+ const prefix = `${normalizeMcpName(server)}_`;
6868
+ result.push({
6869
+ action: `${prefix}*`,
6870
+ resource: "*",
6871
+ effect: agent.mcp.includes(server) ? "allow" : "deny"
6872
+ });
6873
+ result.push(...agent.permissions.filter((rule) => rule.action.startsWith(prefix)));
6874
+ }
6875
+ return result;
6876
+ }
6877
+
6878
+ // src/release-metadata.ts
6879
+ var PACKAGE_NAME = "@nail00749/agent-gvozd";
6880
+ var PACKAGE_VERSION = "0.1.2";
6881
+ var PACKAGE_SPEC = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
6882
+ var SUPPORTED_OPENCODE_VERSION = "0.0.0-beta-19425";
6883
+ var CONFIG_SCHEMA_VERSION = 1;
6656
6884
 
6657
6885
  // src/constants.ts
6658
6886
  var GENERATED_MARKER = "# Generated by agent-gvozd sync. Do not edit this file directly.";
6659
6887
  var GENERATED_PLUGIN_MARKER = "// Generated by agent-gvozd sync. Do not edit this file directly.";
6888
+ function hasGeneratedAgentMarker(content) {
6889
+ return content.startsWith(`---
6890
+ ${GENERATED_MARKER}
6891
+ `);
6892
+ }
6893
+ function hasGeneratedPluginMarker(content) {
6894
+ return content.startsWith(`${GENERATED_PLUGIN_MARKER}
6895
+ `);
6896
+ }
6897
+ function hasGeneratedSchemaMarker(content) {
6898
+ const errors = [];
6899
+ const value = parse2(content, errors, { allowTrailingComma: true, disallowComments: false });
6900
+ return errors.length === 0 && value !== null && typeof value === "object" && !Array.isArray(value) && value.$comment === GENERATED_PLUGIN_MARKER && value["x-agent-gvozd-schema-version"] === CONFIG_SCHEMA_VERSION;
6901
+ }
6902
+ function stable(value) {
6903
+ if (Array.isArray(value))
6904
+ return value.map(stable);
6905
+ if (!value || typeof value !== "object")
6906
+ return value;
6907
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stable(entry)]));
6908
+ }
6909
+ function isEquivalentLegacySchema(content, generated) {
6910
+ try {
6911
+ const previous = JSON.parse(content);
6912
+ const expected = JSON.parse(generated);
6913
+ if (!previous || Array.isArray(previous) || previous.$comment !== undefined)
6914
+ return false;
6915
+ if (previous["x-agent-gvozd-schema-version"] !== undefined && previous["x-agent-gvozd-schema-version"] !== CONFIG_SCHEMA_VERSION)
6916
+ return false;
6917
+ delete previous.$comment;
6918
+ delete previous["x-agent-gvozd-schema-version"];
6919
+ delete expected.$comment;
6920
+ delete expected["x-agent-gvozd-schema-version"];
6921
+ return JSON.stringify(stable(previous)) === JSON.stringify(stable(expected));
6922
+ } catch {
6923
+ return false;
6924
+ }
6925
+ }
6660
6926
 
6661
6927
  // src/agent-generation.ts
6662
6928
  function yamlString(value) {
@@ -6675,12 +6941,11 @@ function renderPermissions(rules) {
6675
6941
  ];
6676
6942
  }
6677
6943
  function renderAgent(agent) {
6678
- const prompt = readFileSync2(agent.prompt, "utf8").trim();
6679
- const permissions = [
6680
- ...agent.permissions,
6681
- { action: "skill", resource: "*", effect: "deny" },
6682
- ...agent.skills.map((skill) => ({ action: "skill", resource: skill, effect: "allow" }))
6683
- ];
6944
+ if (agent.promptContent === undefined) {
6945
+ throw new Error(`Agent is missing its immutable prompt snapshot (${agent.prompt})`);
6946
+ }
6947
+ const prompt = agent.promptContent.trim();
6948
+ const permissions = buildAgentPermissions(agent, []);
6684
6949
  return [
6685
6950
  "---",
6686
6951
  GENERATED_MARKER,
@@ -6695,9 +6960,15 @@ function renderAgent(agent) {
6695
6960
  `);
6696
6961
  }
6697
6962
 
6963
+ // src/runtime-events.ts
6964
+ function redactDiagnostic(error) {
6965
+ const message = error instanceof Error ? error.message : String(error);
6966
+ return message.replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi, "[redacted private key]").replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]*@/gi, "$1[redacted]@").replace(/([?&](?:access_?token|auth(?:orization)?|api_?key|cookie|credential|password|secret|token)=)[^&#\s]*/gi, "$1[redacted]").replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/\b([A-Za-z0-9_]*(?:token|password|authorization|api_?key|secret|credential|cookie)[A-Za-z0-9_]*)\s*[:=]\s*["']?[^\s,;}"']+/gi, "$1=[redacted]").replace(/\s+/g, " ").slice(0, 300);
6967
+ }
6968
+
6698
6969
  // src/cli/opencode.ts
6699
6970
  import { spawn } from "node:child_process";
6700
- import { isAbsolute as isAbsolute2 } from "node:path";
6971
+ import { isAbsolute as isAbsolute4 } from "node:path";
6701
6972
  var MAX_OUTPUT_BYTES = 64 * 1024;
6702
6973
  var DEFAULT_TIMEOUT_MS = 15000;
6703
6974
  function appendBounded(current, chunk) {
@@ -6709,43 +6980,96 @@ function appendBounded(current, chunk) {
6709
6980
  var defaultProcessRunner = {
6710
6981
  run(executable, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
6711
6982
  return new Promise((resolve, reject) => {
6983
+ const grouped = process.platform !== "win32";
6712
6984
  const child = spawn(executable, [...args], {
6985
+ detached: grouped,
6713
6986
  shell: false,
6714
6987
  stdio: ["ignore", "pipe", "pipe"]
6715
6988
  });
6716
6989
  let stdout = "";
6717
6990
  let stderr = "";
6991
+ let settled = false;
6718
6992
  let timedOut = false;
6719
6993
  let forceKill;
6994
+ let hardDeadline;
6995
+ const terminate = (signal) => {
6996
+ try {
6997
+ if (grouped && child.pid)
6998
+ process.kill(-child.pid, signal);
6999
+ else
7000
+ child.kill(signal);
7001
+ } catch {
7002
+ try {
7003
+ child.kill(signal);
7004
+ } catch {}
7005
+ }
7006
+ };
7007
+ const onStdout = (chunk) => {
7008
+ stdout = appendBounded(stdout, chunk);
7009
+ };
7010
+ const onStderr = (chunk) => {
7011
+ stderr = appendBounded(stderr, chunk);
7012
+ };
7013
+ const timeoutError = () => {
7014
+ const error = new Error(`OpenCode command timed out after ${timeoutMs}ms`);
7015
+ error.code = "ETIMEDOUT";
7016
+ return error;
7017
+ };
7018
+ const clearTimers = () => {
7019
+ clearTimeout(timer);
7020
+ if (forceKill)
7021
+ clearTimeout(forceKill);
7022
+ if (hardDeadline)
7023
+ clearTimeout(hardDeadline);
7024
+ };
7025
+ const settleTimeout = (destroy) => {
7026
+ if (settled)
7027
+ return;
7028
+ settled = true;
7029
+ clearTimers();
7030
+ if (destroy) {
7031
+ child.stdout.off("data", onStdout);
7032
+ child.stderr.off("data", onStderr);
7033
+ child.stdout.destroy();
7034
+ child.stderr.destroy();
7035
+ }
7036
+ reject(timeoutError());
7037
+ };
6720
7038
  const timer = setTimeout(() => {
7039
+ if (settled)
7040
+ return;
6721
7041
  timedOut = true;
6722
- child.kill("SIGTERM");
6723
- forceKill = setTimeout(() => child.kill("SIGKILL"), 500);
7042
+ terminate("SIGTERM");
7043
+ forceKill = setTimeout(() => terminate("SIGKILL"), 500);
6724
7044
  forceKill.unref();
7045
+ hardDeadline = setTimeout(() => settleTimeout(true), 1750);
7046
+ hardDeadline.unref();
6725
7047
  }, timeoutMs);
6726
7048
  timer.unref();
6727
- child.stdout.on("data", (chunk) => {
6728
- stdout = appendBounded(stdout, chunk);
6729
- });
6730
- child.stderr.on("data", (chunk) => {
6731
- stderr = appendBounded(stderr, chunk);
6732
- });
7049
+ child.stdout.on("data", onStdout);
7050
+ child.stderr.on("data", onStderr);
6733
7051
  child.once("error", (error) => {
6734
- clearTimeout(timer);
6735
- if (forceKill)
6736
- clearTimeout(forceKill);
7052
+ clearTimers();
7053
+ if (settled)
7054
+ return;
7055
+ settled = true;
7056
+ if (timedOut) {
7057
+ child.stdout.off("data", onStdout);
7058
+ child.stderr.off("data", onStderr);
7059
+ child.stdout.destroy();
7060
+ child.stderr.destroy();
7061
+ }
6737
7062
  reject(error);
6738
7063
  });
6739
7064
  child.once("close", (code) => {
6740
- clearTimeout(timer);
6741
- if (forceKill)
6742
- clearTimeout(forceKill);
7065
+ clearTimers();
7066
+ if (settled)
7067
+ return;
6743
7068
  if (timedOut) {
6744
- const error = new Error(`OpenCode command timed out after ${timeoutMs}ms`);
6745
- error.code = "ETIMEDOUT";
6746
- reject(error);
7069
+ settleTimeout(false);
6747
7070
  return;
6748
7071
  }
7072
+ settled = true;
6749
7073
  resolve({ code: code ?? 1, stdout, stderr });
6750
7074
  });
6751
7075
  });
@@ -6757,8 +7081,8 @@ function bounded(value) {
6757
7081
  async function checked(runner, executable, args, timeoutMs) {
6758
7082
  const result = await runner.run(executable, args, timeoutMs);
6759
7083
  if (result.code !== 0) {
6760
- const detail = bounded(result.stderr).trim();
6761
- throw new Error(`${executable} ${args.join(" ")} exited ${result.code}${detail ? `: ${detail}` : ""}`);
7084
+ const detail = redactDiagnostic(bounded(result.stderr).trim());
7085
+ throw new Error(`OpenCode command exited ${result.code}${detail ? `: ${detail}` : ""}`);
6762
7086
  }
6763
7087
  return bounded(result.stdout);
6764
7088
  }
@@ -6770,7 +7094,7 @@ function parseDebugPaths(output) {
6770
7094
  continue;
6771
7095
  paths[match[1]] = match[2];
6772
7096
  }
6773
- if (!paths.config || !isAbsolute2(paths.config)) {
7097
+ if (!paths.config || !isAbsolute4(paths.config)) {
6774
7098
  throw new Error("OpenCode did not report an absolute config path");
6775
7099
  }
6776
7100
  return paths;
@@ -6875,10 +7199,6 @@ function manualProfile(provider, fast, deep, catalog) {
6875
7199
 
6876
7200
  // src/cli/doctor.ts
6877
7201
  var SETUP_COMMAND = "gvozd setup";
6878
- function redact(value) {
6879
- const message = value instanceof Error ? value.message : String(value);
6880
- return message.replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/\b([A-Za-z0-9_]*(?:token|password|authorization|api_?key|secret|credential|cookie)[A-Za-z0-9_]*)\s*[:=]\s*["']?[^\s,;}"']+/gi, "$1=[redacted]").replace(/\s+/g, " ").slice(0, 300);
6881
- }
6882
7202
  function aggregate(checks) {
6883
7203
  if (checks.some((check) => check.status === "fail"))
6884
7204
  return "fail";
@@ -6888,7 +7208,7 @@ function aggregate(checks) {
6888
7208
  }
6889
7209
  function safeFile(path) {
6890
7210
  try {
6891
- const stat = lstatSync(path);
7211
+ const stat = lstatSync3(path);
6892
7212
  return stat.isFile() && !stat.isSymbolicLink();
6893
7213
  } catch {
6894
7214
  return false;
@@ -6900,8 +7220,10 @@ function escapeRegExp(value) {
6900
7220
  function hasAgentIdentifier(output, id) {
6901
7221
  return new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegExp(id)}(?=$|[^A-Za-z0-9_-])`, "m").test(output);
6902
7222
  }
6903
- function hasPluginIdentifier(output, name) {
6904
- return new RegExp(`(?:^|[\\s"'|│])${escapeRegExp(name)}(?:@[^\\s"'|│,}\\]]+)?(?=$|[\\s"'|│,}\\]])`, "m").test(output);
7223
+ function hasInstalledPluginVersion(output, name, version) {
7224
+ const boundary = `[\\s"'|│,}\\]]`;
7225
+ const pattern = `(?:^|${boundary})${escapeRegExp(name)}(?:@|\\s+)v?${escapeRegExp(version)}(?=$|${boundary})`;
7226
+ return new RegExp(pattern, "m").test(output);
6905
7227
  }
6906
7228
  function checkGlobalFiles(config, configRoot) {
6907
7229
  const missing = [];
@@ -6909,27 +7231,43 @@ function checkGlobalFiles(config, configRoot) {
6909
7231
  for (const [id, agent] of Object.entries(config.agents).sort(([left], [right]) => left.localeCompare(right))) {
6910
7232
  if (agent.disabled)
6911
7233
  continue;
6912
- const path = join2(configRoot, "agents", `${id}.md`);
7234
+ const path = join4(configRoot, "agents", `${id}.md`);
6913
7235
  if (!safeFile(path)) {
6914
7236
  missing.push(id);
6915
7237
  continue;
6916
7238
  }
6917
7239
  const content = readFileSync3(path, "utf8");
6918
- if (!content.includes(GENERATED_MARKER) || content !== renderAgent(agent))
7240
+ if (!hasGeneratedAgentMarker(content) || content !== renderAgent(agent))
6919
7241
  stale.push(id);
6920
7242
  }
6921
- if (missing.length + stale.length === 0) {
6922
- return { id: "global-agents", status: "pass", summary: `${Object.keys(config.agents).length} managed global agents are current` };
7243
+ const enabled = new Set(Object.entries(config.agents).filter(([, agent]) => !agent.disabled).map(([id]) => `${id}.md`));
7244
+ const orphans = [];
7245
+ const directory = join4(configRoot, "agents");
7246
+ if (existsSync3(directory) && lstatSync3(directory).isDirectory() && !lstatSync3(directory).isSymbolicLink()) {
7247
+ for (const entry of readdirSync3(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
7248
+ if (!entry.isFile() || !entry.name.endsWith(".md") || enabled.has(entry.name))
7249
+ continue;
7250
+ const content = readFileSync3(join4(directory, entry.name), "utf8");
7251
+ if (hasGeneratedAgentMarker(content))
7252
+ orphans.push(entry.name.slice(0, -3));
7253
+ }
7254
+ }
7255
+ if (missing.length + stale.length + orphans.length === 0) {
7256
+ return { id: "global-agents", status: "pass", summary: `${enabled.size} managed global agents are current` };
6923
7257
  }
6924
- const details = [missing.length ? `missing: ${missing.join(", ")}` : "", stale.length ? `unmanaged or stale: ${stale.join(", ")}` : ""].filter(Boolean).join("; ");
7258
+ const details = [
7259
+ missing.length ? `missing: ${missing.join(", ")}` : "",
7260
+ stale.length ? `unmanaged or stale: ${stale.join(", ")}` : "",
7261
+ orphans.length ? `orphan managed agents: ${orphans.join(", ")}` : ""
7262
+ ].filter(Boolean).join("; ");
6925
7263
  return { id: "global-agents", status: "fail", summary: details, remediation: SETUP_COMMAND };
6926
7264
  }
6927
7265
  function checkLegacy(config) {
6928
7266
  const duplicates = [];
6929
- const plugin = join2(config.projectRoot, ".opencode", "plugins", "agent-gvozd", "index.ts");
7267
+ const plugin = join4(config.projectRoot, ".opencode", "plugins", "agent-gvozd", "index.ts");
6930
7268
  if (safeFile(plugin))
6931
7269
  duplicates.push("local plugin");
6932
- const agents = Object.keys(config.agents).filter((id) => safeFile(join2(config.projectRoot, ".opencode", "agents", `${id}.md`)));
7270
+ const agents = Object.keys(config.agents).filter((id) => safeFile(join4(config.projectRoot, ".opencode", "agents", `${id}.md`)));
6933
7271
  if (agents.length > 0)
6934
7272
  duplicates.push(`${agents.length} local agents`);
6935
7273
  if (duplicates.length === 0)
@@ -6942,58 +7280,59 @@ function checkLegacy(config) {
6942
7280
  };
6943
7281
  }
6944
7282
  async function runDoctor(input) {
6945
- const packageName = input.packageName ?? "@nail00749/agent-gvozd";
6946
- const packageVersion = input.packageVersion ?? "0.1.0";
6947
- const supportedVersion = input.supportedOpenCodeVersion ?? "0.0.0-beta-19425";
7283
+ const packageName = input.packageName ?? PACKAGE_NAME;
7284
+ const packageVersion = input.packageVersion ?? PACKAGE_VERSION;
7285
+ const supportedVersion = input.supportedOpenCodeVersion ?? SUPPORTED_OPENCODE_VERSION;
6948
7286
  const checks = [];
6949
7287
  try {
6950
7288
  const version = await input.client.version();
6951
- checks.push(parseOpenCodeVersion(version) === supportedVersion ? { id: "opencode-version", status: "pass", summary: `OpenCode ${supportedVersion} is available` } : { id: "opencode-version", status: "fail", summary: `unsupported OpenCode version: ${redact(version)}`, remediation: `Install OpenCode ${supportedVersion}` });
7289
+ checks.push(parseOpenCodeVersion(version) === supportedVersion ? { id: "opencode-version", status: "pass", summary: `OpenCode ${supportedVersion} is available` } : { id: "opencode-version", status: "fail", summary: `unsupported OpenCode version: ${redactDiagnostic(version)}`, remediation: `Install OpenCode ${supportedVersion}` });
6952
7290
  } catch (error) {
6953
- checks.push({ id: "opencode-version", status: "fail", summary: `OpenCode version check failed: ${redact(error)}`, remediation: `Install OpenCode ${supportedVersion}` });
7291
+ checks.push({ id: "opencode-version", status: "fail", summary: `OpenCode version check failed: ${redactDiagnostic(error)}`, remediation: `Install OpenCode ${supportedVersion}` });
6954
7292
  }
6955
7293
  try {
6956
7294
  await input.client.serviceStatus();
6957
7295
  checks.push({ id: "service", status: "pass", summary: "OpenCode service is reachable" });
6958
7296
  } catch (error) {
6959
- checks.push({ id: "service", status: "fail", summary: `OpenCode service is unavailable: ${redact(error)}`, remediation: `${input.client.executable} service restart` });
7297
+ checks.push({ id: "service", status: "fail", summary: `OpenCode service is unavailable: ${redactDiagnostic(error)}`, remediation: `${input.client.executable} service restart` });
6960
7298
  }
6961
7299
  try {
6962
7300
  const output = await input.client.pluginList();
6963
- checks.push(hasPluginIdentifier(output, packageName) ? { id: "plugin", status: "pass", summary: `${packageName} is registered` } : { id: "plugin", status: "fail", summary: `${packageName} is not registered`, remediation: SETUP_COMMAND });
7301
+ checks.push(hasInstalledPluginVersion(output, packageName, packageVersion) ? { id: "plugin", status: "pass", summary: `${packageName} ${packageVersion} is registered` } : { id: "plugin", status: "fail", summary: `${packageName} ${packageVersion} is not registered`, remediation: SETUP_COMMAND });
6964
7302
  } catch (error) {
6965
- checks.push({ id: "plugin", status: "fail", summary: `plugin list failed: ${redact(error)}`, remediation: SETUP_COMMAND });
7303
+ checks.push({ id: "plugin", status: "fail", summary: `plugin list failed: ${redactDiagnostic(error)}`, remediation: SETUP_COMMAND });
6966
7304
  }
6967
7305
  try {
6968
- const output = await input.client.pluginCheck(`${packageName}@^${packageVersion}`);
6969
- const unhealthy = /\b(error|failed|incompatible)\b/i.test(output);
6970
- checks.push(unhealthy ? { id: "plugin-check", status: "fail", summary: "OpenCode reports an unhealthy Gvozd plugin", remediation: SETUP_COMMAND } : { id: "plugin-check", status: "pass", summary: "Gvozd plugin check passed" });
7306
+ await input.client.pluginCheck(PACKAGE_SPEC);
7307
+ checks.push({ id: "plugin-check", status: "pass", summary: "Gvozd plugin check passed" });
6971
7308
  } catch (error) {
6972
- checks.push({ id: "plugin-check", status: "fail", summary: `plugin check failed: ${redact(error)}`, remediation: SETUP_COMMAND });
7309
+ checks.push({ id: "plugin-check", status: "fail", summary: `plugin check failed: ${redactDiagnostic(error)}`, remediation: SETUP_COMMAND });
6973
7310
  }
6974
7311
  try {
6975
7312
  const paths = await input.client.debugPaths();
6976
- checks.push(resolve2(paths.config) === resolve2(input.configRoot) ? { id: "config-root", status: "pass", summary: "runtime and CLI config roots match" } : { id: "config-root", status: "fail", summary: "runtime and CLI config roots differ", remediation: `${input.client.executable} debug paths` });
7313
+ const reported = resolve4(paths.config);
7314
+ const expected = resolve4(input.runtimeConfigRoot ?? input.configRoot);
7315
+ checks.push(reported === resolve4(input.configRoot) && reported === expected ? { id: "config-root", status: "pass", summary: "runtime and CLI config roots match" } : { id: "config-root", status: "fail", summary: "OpenCode debug path and independently resolved runtime config root differ", remediation: `${input.client.executable} debug paths; set GVOZD_OPENCODE_CONFIG_ROOT to the reported config path before starting OpenCode` });
6977
7316
  } catch (error) {
6978
- checks.push({ id: "config-root", status: "fail", summary: `config path check failed: ${redact(error)}` });
7317
+ checks.push({ id: "config-root", status: "fail", summary: `config path check failed: ${redactDiagnostic(error)}` });
6979
7318
  }
6980
7319
  let config;
6981
7320
  let globalConfig;
6982
- const configPath = join2(input.configRoot, "gvozd", "config.jsonc");
6983
- const schemaPath = join2(input.configRoot, "gvozd", "schema.json");
7321
+ const configPath = join4(input.configRoot, "gvozd", "config.jsonc");
7322
+ const schemaPath = join4(input.configRoot, "gvozd", "schema.json");
6984
7323
  try {
6985
7324
  if (!safeFile(configPath) || !safeFile(schemaPath))
6986
7325
  throw new Error("global config.jsonc or schema.json is missing");
6987
7326
  const schemaErrors = [];
6988
7327
  const schema = parse2(readFileSync3(schemaPath, "utf8"), schemaErrors);
6989
- if (schemaErrors.length > 0 || schema?.["x-agent-gvozd-schema-version"] !== 1 || !readFileSync3(schemaPath, "utf8").includes(GENERATED_PLUGIN_MARKER)) {
7328
+ if (schemaErrors.length > 0 || schema?.["x-agent-gvozd-schema-version"] !== CONFIG_SCHEMA_VERSION || schema?.$comment !== GENERATED_PLUGIN_MARKER) {
6990
7329
  throw new Error("global schema is invalid, incompatible, or unmanaged");
6991
7330
  }
6992
7331
  globalConfig = loadConfig(input.cwd, { configRoot: input.configRoot, includeProject: false });
6993
7332
  config = loadConfig(input.cwd, { configRoot: input.configRoot });
6994
7333
  checks.push({ id: "config", status: "pass", summary: "global Gvozd config and schema are valid" });
6995
7334
  } catch (error) {
6996
- checks.push({ id: "config", status: "fail", summary: `global config check failed: ${redact(error)}`, remediation: SETUP_COMMAND });
7335
+ checks.push({ id: "config", status: "fail", summary: `global config check failed: ${redactDiagnostic(error)}`, remediation: SETUP_COMMAND });
6997
7336
  }
6998
7337
  let catalog = parseModels([]);
6999
7338
  try {
@@ -7003,12 +7342,14 @@ async function runDoctor(input) {
7003
7342
  if (!config) {
7004
7343
  checks.push({ id: "models", status: "fail", summary: "configured models cannot be validated without a valid config", remediation: SETUP_COMMAND });
7005
7344
  } else {
7006
- const configured = new Set(Object.values(config.agents).flatMap((agent) => agent.models));
7007
- const missing = [...configured].filter((model) => !catalog.models.includes(model)).sort();
7008
- checks.push(missing.length === 0 ? { id: "models", status: "pass", summary: `${catalog.models.length} available models cover the Gvozd profile` } : { id: "models", status: "fail", summary: `configured models are unavailable: ${missing.join(", ")}`, remediation: "gvozd config" });
7345
+ const available = new Set(catalog.models);
7346
+ const enabled = Object.entries(config.agents).filter(([, agent]) => !agent.disabled);
7347
+ const unavailableAgents = enabled.filter(([, agent]) => !agent.models.some((model) => available.has(model))).map(([id]) => id);
7348
+ const missingFallbacks = [...new Set(enabled.flatMap(([, agent]) => agent.models.filter((model) => !available.has(model))))].sort();
7349
+ checks.push(unavailableAgents.length > 0 ? { id: "models", status: "fail", summary: `no configured model is available for: ${unavailableAgents.join(", ")}`, remediation: "gvozd config" } : missingFallbacks.length > 0 ? { id: "models", status: "warn", summary: `primary coverage is available; unavailable fallback models: ${missingFallbacks.join(", ")}`, remediation: "gvozd config" } : { id: "models", status: "pass", summary: `${catalog.models.length} available models cover the Gvozd profile` });
7009
7350
  }
7010
7351
  } catch (error) {
7011
- checks.push({ id: "models", status: "fail", summary: `model catalog check failed: ${redact(error)}`, remediation: `${input.client.executable} auth` });
7352
+ checks.push({ id: "models", status: "fail", summary: `model catalog check failed: ${redactDiagnostic(error)}`, remediation: `${input.client.executable} auth` });
7012
7353
  }
7013
7354
  if (globalConfig)
7014
7355
  checks.push(checkGlobalFiles(globalConfig, input.configRoot));
@@ -7019,7 +7360,7 @@ async function runDoctor(input) {
7019
7360
  const missing = Object.entries(config?.agents ?? {}).filter(([, agent]) => !agent.disabled).map(([id]) => id).filter((id) => !hasAgentIdentifier(output, id));
7020
7361
  checks.push(missing.length === 0 && config ? { id: "runtime-agents", status: "pass", summary: "all enabled Gvozd agents are visible to OpenCode" } : { id: "runtime-agents", status: "fail", summary: `runtime agents are missing: ${missing.join(", ") || "config unavailable"}`, remediation: `${input.client.executable} service restart` });
7021
7362
  } catch (error) {
7022
- checks.push({ id: "runtime-agents", status: "fail", summary: `runtime agent check failed: ${redact(error)}`, remediation: `${input.client.executable} service restart` });
7363
+ checks.push({ id: "runtime-agents", status: "fail", summary: `runtime agent check failed: ${redactDiagnostic(error)}`, remediation: `${input.client.executable} service restart` });
7023
7364
  }
7024
7365
  checks.push(config ? checkLegacy(config) : { id: "legacy-local", status: "warn", summary: "legacy duplicates could not be checked" });
7025
7366
  return { schemaVersion: 1, status: aggregate(checks), checks };
@@ -7044,20 +7385,84 @@ function doctorOperationalFailure(error) {
7044
7385
  const checks = [{
7045
7386
  id: "opencode-discovery",
7046
7387
  status: "fail",
7047
- summary: `OpenCode discovery failed: ${redact(error)}`,
7048
- remediation: "Install OpenCode 0.0.0-beta-19425 and run gvozd doctor again"
7388
+ summary: `OpenCode discovery failed: ${redactDiagnostic(error)}`,
7389
+ remediation: `Install OpenCode ${SUPPORTED_OPENCODE_VERSION} and run gvozd doctor again`
7049
7390
  }];
7050
7391
  return { schemaVersion: 1, status: "fail", checks };
7051
7392
  }
7052
7393
 
7053
7394
  // src/cli/setup.ts
7054
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "node:fs";
7055
- import { dirname as dirname3, join as join5 } from "node:path";
7395
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
7396
+ import { dirname as dirname6, join as join8 } from "node:path";
7056
7397
 
7057
7398
  // src/cli/config-store.ts
7058
- import { closeSync, existsSync as existsSync3, lstatSync as lstatSync2, mkdirSync, openSync, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync } from "node:fs";
7399
+ import { accessSync, closeSync, constants as fsConstants, existsSync as existsSync4, lstatSync as lstatSync5, mkdirSync, openSync, readFileSync as readFileSync4, renameSync, unlinkSync, writeFileSync } from "node:fs";
7059
7400
  import { randomUUID } from "node:crypto";
7060
- import { dirname as dirname2, join as join3 } from "node:path";
7401
+ import { dirname as dirname3, join as join6 } from "node:path";
7402
+
7403
+ // src/secure-path.ts
7404
+ import { lstatSync as lstatSync4, realpathSync as realpathSync3 } from "node:fs";
7405
+ import { isAbsolute as isAbsolute5, join as join5, parse as parse6, relative as relative3, resolve as resolve5, sep } from "node:path";
7406
+ function secureCanonicalPath(target, label = "Path") {
7407
+ if (!isAbsolute5(target))
7408
+ throw new Error(`${label} must be absolute: ${target}`);
7409
+ const lexical = resolve5(target);
7410
+ const root = parse6(lexical).root;
7411
+ const components = relative3(root, lexical).split(sep).filter(Boolean);
7412
+ let current = root;
7413
+ let nearestExisting = root;
7414
+ const missingSuffix = [];
7415
+ let missing = false;
7416
+ const rootStat = lstatSync4(root);
7417
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
7418
+ throw new Error(`${label} has an unsafe filesystem root: ${root}`);
7419
+ const existingDirectories = [{ path: root, stat: rootStat }];
7420
+ for (const component of components) {
7421
+ current = join5(current, component);
7422
+ if (missing) {
7423
+ missingSuffix.push(component);
7424
+ continue;
7425
+ }
7426
+ let stat;
7427
+ try {
7428
+ stat = lstatSync4(current);
7429
+ } catch (error) {
7430
+ if (error.code !== "ENOENT")
7431
+ throw error;
7432
+ missing = true;
7433
+ missingSuffix.push(component);
7434
+ continue;
7435
+ }
7436
+ if (stat.isSymbolicLink())
7437
+ throw new Error(`${label} contains a symbolic-link component: ${current}`);
7438
+ if (current !== lexical && !stat.isDirectory()) {
7439
+ throw new Error(`${label} contains a non-directory ancestor: ${current}`);
7440
+ }
7441
+ if (stat.isDirectory())
7442
+ existingDirectories.push({ path: current, stat });
7443
+ nearestExisting = current;
7444
+ }
7445
+ if (process.platform !== "win32") {
7446
+ const uid = process.getuid?.();
7447
+ for (let index = 0;index < existingDirectories.length; index++) {
7448
+ const entry = existingDirectories[index];
7449
+ if ((entry.stat.mode & 18) === 0)
7450
+ continue;
7451
+ const sticky = (entry.stat.mode & 512) !== 0;
7452
+ const hasPrivateBoundary = sticky && uid !== undefined && existingDirectories.slice(index + 1).some((candidate) => candidate.stat.uid === uid && (candidate.stat.mode & 18) === 0);
7453
+ if (!hasPrivateBoundary) {
7454
+ throw new Error(`${label} contains a group/world-writable directory without an existing private boundary: ${entry.path}`);
7455
+ }
7456
+ }
7457
+ }
7458
+ const canonicalAncestor = realpathSync3(nearestExisting);
7459
+ if (canonicalAncestor !== nearestExisting) {
7460
+ throw new Error(`${label} contains a non-canonical or changed ancestor: ${nearestExisting}`);
7461
+ }
7462
+ return resolve5(canonicalAncestor, ...missingSuffix);
7463
+ }
7464
+
7465
+ // src/cli/config-store.ts
7061
7466
  var FAST_AGENT_IDS = ["back-fast", "front-fast", "review-fast", "researcher", "git", "docs", "verifier"];
7062
7467
  var DEEP_AGENT_IDS = ["master", "planner", "back-deep", "front-deep", "review-deep", "debugger", "security", "devops"];
7063
7468
  var ALL_AGENT_IDS = [...DEEP_AGENT_IDS, ...FAST_AGENT_IDS, "explorer"];
@@ -7084,14 +7489,30 @@ function applyModelProfile(source, profile) {
7084
7489
  updated = setJsonc(updated, ["agents", "explorer", "models"], profile.agentOverrides.explorer ?? profile.fast);
7085
7490
  return updated;
7086
7491
  }
7087
- function atomicWrite(path, content) {
7088
- mkdirSync(dirname2(path), { recursive: true, mode: 448 });
7089
- const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
7492
+ function matchesSnapshot(path, expected) {
7493
+ if (!expected.exists)
7494
+ return !existsSync4(path);
7495
+ if (!existsSync4(path))
7496
+ return false;
7497
+ const stat = lstatSync5(path);
7498
+ return stat.isFile() && !stat.isSymbolicLink() && stat.dev === expected.dev && stat.ino === expected.ino && readFileSync4(path, "utf8") === expected.bytes;
7499
+ }
7500
+ function atomicWrite(path, content, expected) {
7501
+ const canonicalPath = secureCanonicalPath(path, "Managed global config path");
7502
+ if (canonicalPath !== expected.path)
7503
+ throw new Error(`Global config path changed after preflight: ${path}`);
7504
+ mkdirSync(dirname3(canonicalPath), { recursive: true, mode: 448 });
7505
+ if (secureCanonicalPath(canonicalPath, "Managed global config path") !== canonicalPath)
7506
+ throw new Error(`Global config path changed during write: ${path}`);
7507
+ assertWriteable(dirname3(canonicalPath), "Managed global config directory");
7508
+ const temporary = `${canonicalPath}.tmp-${process.pid}-${randomUUID()}`;
7090
7509
  const descriptor = openSync(temporary, "wx", 384);
7091
7510
  try {
7092
7511
  writeFileSync(descriptor, content);
7093
7512
  closeSync(descriptor);
7094
- renameSync(temporary, path);
7513
+ if (!matchesSnapshot(canonicalPath, expected))
7514
+ throw new Error(`Refusing to replace concurrently changed file: ${canonicalPath}`);
7515
+ renameSync(temporary, canonicalPath);
7095
7516
  } catch (error) {
7096
7517
  try {
7097
7518
  closeSync(descriptor);
@@ -7103,51 +7524,105 @@ function atomicWrite(path, content) {
7103
7524
  }
7104
7525
  }
7105
7526
  function isRegularFile(path) {
7106
- const stat = lstatSync2(path);
7527
+ const stat = lstatSync5(path);
7107
7528
  return stat.isFile() && !stat.isSymbolicLink();
7108
7529
  }
7109
- function preflightGlobalConfig(configRoot) {
7110
- const rootStat = existsSync3(configRoot) ? lstatSync2(configRoot) : undefined;
7530
+ function assertWriteable(path, label) {
7531
+ let candidate = path;
7532
+ while (!existsSync4(candidate)) {
7533
+ const parent = dirname3(candidate);
7534
+ if (parent === candidate)
7535
+ break;
7536
+ candidate = parent;
7537
+ }
7538
+ try {
7539
+ const stat = lstatSync5(candidate);
7540
+ if (stat.isSymbolicLink() || !stat.isDirectory() && candidate !== path)
7541
+ throw new Error("unsafe parent");
7542
+ const uid = process.getuid?.();
7543
+ if (uid !== undefined && (stat.uid !== uid || (stat.mode & 18) !== 0))
7544
+ throw new Error("unsafe ownership or mode");
7545
+ accessSync(candidate, fsConstants.W_OK | (stat.isDirectory() ? fsConstants.X_OK : 0));
7546
+ } catch {
7547
+ throw new Error(`${label} is not writeable: ${path}`);
7548
+ }
7549
+ }
7550
+ function legacySchemaMatches(source, generated) {
7551
+ try {
7552
+ const previous = JSON.parse(source);
7553
+ if (previous.$comment !== undefined || ![undefined, 1].includes(previous["x-agent-gvozd-schema-version"]))
7554
+ return false;
7555
+ if (previous.$id !== "https://example.invalid/agent-gvozd.schema.json")
7556
+ return false;
7557
+ return generated ? isEquivalentLegacySchema(source, generated) : true;
7558
+ } catch {
7559
+ return false;
7560
+ }
7561
+ }
7562
+ function snapshot(path) {
7563
+ if (!existsSync4(path))
7564
+ return Object.freeze({ path, exists: false });
7565
+ const stat = lstatSync5(path);
7566
+ if (!stat.isFile() || stat.isSymbolicLink())
7567
+ throw new Error(`Managed global config snapshot target is unsafe: ${path}`);
7568
+ return Object.freeze({ path, exists: true, bytes: readFileSync4(path, "utf8"), dev: stat.dev, ino: stat.ino });
7569
+ }
7570
+ function preflightGlobalConfig(configRoot, schemaSource) {
7571
+ const canonicalRoot = secureCanonicalPath(configRoot, "OpenCode config root");
7572
+ const rootStat = existsSync4(canonicalRoot) ? lstatSync5(canonicalRoot) : undefined;
7111
7573
  if (rootStat && (rootStat.isSymbolicLink() || !rootStat.isDirectory())) {
7112
- throw new Error(`OpenCode config root is not a safe directory: ${configRoot}`);
7574
+ throw new Error(`OpenCode config root is not a safe directory: ${canonicalRoot}`);
7113
7575
  }
7114
- const directory = join3(configRoot, "gvozd");
7115
- const directoryStat = existsSync3(directory) ? lstatSync2(directory) : undefined;
7576
+ assertWriteable(canonicalRoot, "OpenCode config root");
7577
+ const directory = secureCanonicalPath(join6(canonicalRoot, "gvozd"), "Global Gvozd directory");
7578
+ const directoryStat = existsSync4(directory) ? lstatSync5(directory) : undefined;
7116
7579
  if (directoryStat && (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())) {
7117
7580
  throw new Error(`Global Gvozd path is not a safe directory: ${directory}`);
7118
7581
  }
7119
- const configPath = join3(directory, "config.jsonc");
7120
- const schemaPath = join3(directory, "schema.json");
7121
- if (existsSync3(configPath)) {
7582
+ assertWriteable(directory, "Global Gvozd directory");
7583
+ const configPath = join6(directory, "config.jsonc");
7584
+ const schemaPath = join6(directory, "schema.json");
7585
+ if (existsSync4(configPath)) {
7122
7586
  if (!isRegularFile(configPath))
7123
7587
  throw new Error(`Global Gvozd config is not a safe file: ${configPath}`);
7124
7588
  assertValidJsonc(readFileSync4(configPath, "utf8"), configPath);
7589
+ assertWriteable(configPath, "Global Gvozd config");
7125
7590
  }
7126
- if (existsSync3(schemaPath) && (!isRegularFile(schemaPath) || !readFileSync4(schemaPath, "utf8").includes(GENERATED_PLUGIN_MARKER))) {
7127
- throw new Error(`Refusing to overwrite unmanaged Gvozd schema: ${schemaPath}`);
7591
+ if (existsSync4(schemaPath)) {
7592
+ if (!isRegularFile(schemaPath))
7593
+ throw new Error(`Refusing to overwrite unmanaged Gvozd schema: ${schemaPath}`);
7594
+ const schema = readFileSync4(schemaPath, "utf8");
7595
+ if (!hasGeneratedSchemaMarker(schema) && !legacySchemaMatches(schema, schemaSource)) {
7596
+ throw new Error(`Refusing to overwrite unmanaged Gvozd schema: ${schemaPath}`);
7597
+ }
7598
+ assertWriteable(schemaPath, "Global Gvozd schema");
7128
7599
  }
7600
+ return Object.freeze({ configRoot: canonicalRoot, config: snapshot(configPath), schema: snapshot(schemaPath) });
7129
7601
  }
7130
7602
  function writeGlobalConfig(input) {
7131
- preflightGlobalConfig(input.configRoot);
7132
- const directory = join3(input.configRoot, "gvozd");
7133
- const configPath = join3(directory, "config.jsonc");
7134
- const schemaPath = join3(directory, "schema.json");
7135
- const base = existsSync3(configPath) ? readFileSync4(configPath, "utf8") : `{
7603
+ const state = input.snapshot ?? preflightGlobalConfig(input.configRoot, input.schemaSource);
7604
+ const canonicalRoot = secureCanonicalPath(input.configRoot, "OpenCode config root");
7605
+ if (state.configRoot !== canonicalRoot)
7606
+ throw new Error("Global config snapshot belongs to another config root");
7607
+ const directory = join6(state.configRoot, "gvozd");
7608
+ const configPath = join6(directory, "config.jsonc");
7609
+ const schemaPath = join6(directory, "schema.json");
7610
+ const base = state.config.exists ? state.config.bytes : `{
7136
7611
  "$schema": "./schema.json",
7137
7612
  "agents": {}
7138
7613
  }
7139
7614
  `;
7140
- if (!input.schemaSource.includes(GENERATED_PLUGIN_MARKER)) {
7615
+ if (!hasGeneratedSchemaMarker(input.schemaSource)) {
7141
7616
  throw new Error("Package schema is missing the Gvozd ownership marker");
7142
7617
  }
7143
7618
  const config = applyModelProfile(base, input.profile);
7144
7619
  assertValidJsonc(input.schemaSource, "package Gvozd schema");
7145
7620
  atomicWrite(schemaPath, input.schemaSource.endsWith(`
7146
7621
  `) ? input.schemaSource : `${input.schemaSource}
7147
- `);
7622
+ `, state.schema);
7148
7623
  atomicWrite(configPath, config.endsWith(`
7149
7624
  `) ? config : `${config}
7150
- `);
7625
+ `, state.config);
7151
7626
  return { configPath, schemaPath, config };
7152
7627
  }
7153
7628
 
@@ -7220,20 +7695,43 @@ function profileFromAgents(agents, catalog) {
7220
7695
  }
7221
7696
 
7222
7697
  // src/cli/global-sync.ts
7223
- import { closeSync as closeSync2, lstatSync as lstatSync3, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
7698
+ import { accessSync as accessSync2, closeSync as closeSync2, constants as fsConstants2, existsSync as existsSync5, lstatSync as lstatSync6, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync5, readdirSync as readdirSync4, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
7224
7699
  import { randomUUID as randomUUID2 } from "node:crypto";
7225
- import { join as join4 } from "node:path";
7700
+ import { dirname as dirname4, join as join7 } from "node:path";
7226
7701
  function stat(path) {
7227
7702
  try {
7228
- return lstatSync3(path);
7703
+ return lstatSync6(path);
7229
7704
  } catch (error) {
7230
7705
  if (error.code === "ENOENT")
7231
7706
  return;
7232
7707
  throw error;
7233
7708
  }
7234
7709
  }
7710
+ function assertWriteable2(path, label) {
7711
+ let candidate = path;
7712
+ while (!existsSync5(candidate)) {
7713
+ const parent = dirname4(candidate);
7714
+ if (parent === candidate)
7715
+ break;
7716
+ candidate = parent;
7717
+ }
7718
+ try {
7719
+ const current = lstatSync6(candidate);
7720
+ if (current.isSymbolicLink() || !current.isDirectory() && candidate !== path)
7721
+ throw new Error("unsafe parent");
7722
+ const uid = process.getuid?.();
7723
+ if (uid !== undefined && (current.uid !== uid || (current.mode & 18) !== 0))
7724
+ throw new Error("unsafe ownership or mode");
7725
+ accessSync2(candidate, fsConstants2.W_OK | (current.isDirectory() ? fsConstants2.X_OK : 0));
7726
+ } catch {
7727
+ throw new Error(`${label} is not writeable: ${path}`);
7728
+ }
7729
+ }
7235
7730
  function createFile(path, content) {
7236
- const descriptor = openSync2(path, "wx", 384);
7731
+ const canonicalPath = secureCanonicalPath(path, "Managed global agent path");
7732
+ if (canonicalPath !== path)
7733
+ throw new Error(`Managed global agent path changed: ${path}`);
7734
+ const descriptor = openSync2(canonicalPath, "wx", 384);
7237
7735
  try {
7238
7736
  writeFileSync2(descriptor, content);
7239
7737
  } finally {
@@ -7241,6 +7739,8 @@ function createFile(path, content) {
7241
7739
  }
7242
7740
  }
7243
7741
  function replaceFile(path, content) {
7742
+ if (secureCanonicalPath(path, "Managed global agent path") !== path)
7743
+ throw new Error(`Managed global agent path changed: ${path}`);
7244
7744
  const temporary = `${path}.tmp-${process.pid}-${randomUUID2()}`;
7245
7745
  createFile(temporary, content);
7246
7746
  try {
@@ -7253,21 +7753,41 @@ function replaceFile(path, content) {
7253
7753
  }
7254
7754
  }
7255
7755
  function writeManagedAgents(input) {
7256
- const agentsDirectory = join4(input.configRoot, "agents");
7257
- const result = { created: [], updated: [], unchanged: [], conflicts: [] };
7756
+ const configRoot = secureCanonicalPath(input.configRoot, "OpenCode config root");
7757
+ const agentsDirectory = secureCanonicalPath(join7(configRoot, "agents"), "OpenCode agents path");
7758
+ const result = { created: [], updated: [], unchanged: [], conflicts: [], removed: [] };
7258
7759
  const writes = [];
7259
- const rootStat = stat(input.configRoot);
7760
+ const removals = new Map;
7761
+ const enabled = new Set(Object.entries(input.agents).filter(([, agent]) => !agent.disabled).map(([id]) => `${id}.md`));
7762
+ const rootStat = stat(configRoot);
7260
7763
  if (rootStat && (rootStat.isSymbolicLink() || !rootStat.isDirectory())) {
7261
- throw new Error(`OpenCode config root is not a safe directory: ${input.configRoot}`);
7764
+ throw new Error(`OpenCode config root is not a safe directory: ${configRoot}`);
7262
7765
  }
7766
+ assertWriteable2(configRoot, "OpenCode config root");
7263
7767
  const directoryStat = stat(agentsDirectory);
7264
7768
  if (directoryStat && (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())) {
7265
7769
  throw new Error(`OpenCode agents path is not a safe directory: ${agentsDirectory}`);
7266
7770
  }
7771
+ assertWriteable2(agentsDirectory, "OpenCode agents directory");
7772
+ if (directoryStat) {
7773
+ for (const entry of readdirSync4(agentsDirectory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
7774
+ if (!entry.isFile() || !entry.name.endsWith(".md") || enabled.has(entry.name))
7775
+ continue;
7776
+ const path = join7(agentsDirectory, entry.name);
7777
+ const currentStat = stat(path);
7778
+ if (!currentStat || currentStat.isSymbolicLink() || !currentStat.isFile())
7779
+ continue;
7780
+ if (hasGeneratedAgentMarker(readFileSync5(path, "utf8"))) {
7781
+ assertWriteable2(path, "Managed global agent");
7782
+ result.removed.push(path);
7783
+ removals.set(path, readFileSync5(path, "utf8"));
7784
+ }
7785
+ }
7786
+ }
7267
7787
  for (const [id, agent] of Object.entries(input.agents).sort(([left], [right]) => left.localeCompare(right))) {
7268
7788
  if (agent.disabled)
7269
7789
  continue;
7270
- const path = join4(agentsDirectory, `${id}.md`);
7790
+ const path = join7(agentsDirectory, `${id}.md`);
7271
7791
  const content = renderAgent(agent);
7272
7792
  const currentStat = stat(path);
7273
7793
  if (!currentStat) {
@@ -7280,45 +7800,134 @@ function writeManagedAgents(input) {
7280
7800
  continue;
7281
7801
  }
7282
7802
  const current = readFileSync5(path, "utf8");
7283
- if (!current.includes(GENERATED_MARKER)) {
7803
+ if (!hasGeneratedAgentMarker(current)) {
7284
7804
  result.conflicts.push(path);
7285
7805
  continue;
7286
7806
  }
7807
+ assertWriteable2(path, "Managed global agent");
7287
7808
  if (current === content) {
7288
7809
  result.unchanged.push(path);
7289
7810
  continue;
7290
7811
  }
7291
7812
  result.updated.push(path);
7292
- writes.push({ path, content, replace: true });
7813
+ writes.push({ path, content, replace: true, previous: current });
7293
7814
  }
7294
7815
  if (result.conflicts.length > 0) {
7295
7816
  throw new Error(`Refusing to overwrite unmanaged global agents: ${result.conflicts.join(", ")}`);
7296
7817
  }
7297
7818
  if (input.check)
7298
7819
  return result;
7299
- mkdirSync2(input.configRoot, { recursive: true, mode: 448 });
7820
+ if (secureCanonicalPath(configRoot, "OpenCode config root") !== configRoot)
7821
+ throw new Error("OpenCode config root changed before write");
7822
+ mkdirSync2(configRoot, { recursive: true, mode: 448 });
7823
+ if (secureCanonicalPath(configRoot, "OpenCode config root") !== configRoot)
7824
+ throw new Error("OpenCode config root changed during creation");
7825
+ assertWriteable2(configRoot, "OpenCode config root");
7826
+ if (secureCanonicalPath(agentsDirectory, "OpenCode agents path") !== agentsDirectory)
7827
+ throw new Error("OpenCode agents path changed before creation");
7300
7828
  mkdirSync2(agentsDirectory, { recursive: true, mode: 448 });
7829
+ if (secureCanonicalPath(agentsDirectory, "OpenCode agents path") !== agentsDirectory)
7830
+ throw new Error("OpenCode agents path changed before write");
7831
+ assertWriteable2(configRoot, "OpenCode config root");
7832
+ assertWriteable2(agentsDirectory, "OpenCode agents directory");
7833
+ for (const path of result.removed) {
7834
+ if (secureCanonicalPath(path, "Managed global agent path") !== path)
7835
+ throw new Error(`Managed global agent path changed: ${path}`);
7836
+ const currentStat = stat(path);
7837
+ if (!currentStat || currentStat.isSymbolicLink() || !currentStat.isFile()) {
7838
+ throw new Error(`Refusing to remove a changed or unmanaged global agent: ${path}`);
7839
+ }
7840
+ const current = readFileSync5(path, "utf8");
7841
+ if (!hasGeneratedAgentMarker(current) || current !== removals.get(path)) {
7842
+ throw new Error(`Refusing to remove a concurrently changed global agent: ${path}`);
7843
+ }
7844
+ unlinkSync2(path);
7845
+ }
7301
7846
  for (const write of writes) {
7302
- if (write.replace)
7847
+ if (write.replace) {
7848
+ const currentStat = stat(write.path);
7849
+ const current = currentStat?.isFile() && !currentStat.isSymbolicLink() ? readFileSync5(write.path, "utf8") : undefined;
7850
+ if (!current || !hasGeneratedAgentMarker(current) || current !== write.previous) {
7851
+ throw new Error(`Refusing to replace a concurrently changed global agent: ${write.path}`);
7852
+ }
7303
7853
  replaceFile(write.path, write.content);
7304
- else
7854
+ } else
7305
7855
  createFile(write.path, write.content);
7306
7856
  }
7307
7857
  return result;
7308
7858
  }
7309
7859
 
7860
+ // src/file-lock.ts
7861
+ import { randomUUID as randomUUID3 } from "node:crypto";
7862
+ import { accessSync as accessSync3, closeSync as closeSync3, constants, existsSync as existsSync6, fstatSync, lstatSync as lstatSync7, mkdirSync as mkdirSync3, openSync as openSync3, readFileSync as readFileSync6, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
7863
+ import { dirname as dirname5 } from "node:path";
7864
+ function assertSecure(path) {
7865
+ const stat = lstatSync7(path);
7866
+ if (!stat.isDirectory() || stat.isSymbolicLink())
7867
+ throw new Error(`Lock directory is unsafe: ${path}`);
7868
+ const uid = process.getuid?.();
7869
+ if (uid !== undefined && (stat.uid !== uid || (stat.mode & 18) !== 0)) {
7870
+ throw new Error(`Lock directory must be owner-controlled and not group/world writable: ${path}`);
7871
+ }
7872
+ accessSync3(path, constants.W_OK | constants.X_OK);
7873
+ }
7874
+ function acquire(path, operation) {
7875
+ const canonicalPath = secureCanonicalPath(path, "Lock path");
7876
+ const directory = dirname5(canonicalPath);
7877
+ mkdirSync3(directory, { recursive: true, mode: 448 });
7878
+ if (secureCanonicalPath(canonicalPath, "Lock path") !== canonicalPath)
7879
+ throw new Error(`Lock path changed during creation: ${path}`);
7880
+ assertSecure(directory);
7881
+ const nonce = `${process.pid}:${randomUUID3()}
7882
+ `;
7883
+ let descriptor;
7884
+ try {
7885
+ descriptor = openSync3(canonicalPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 384);
7886
+ } catch (error) {
7887
+ if (error.code === "EEXIST") {
7888
+ throw new Error(`Another Gvozd ${operation} is active or left a lock at ${canonicalPath}; inspect it manually and do not delete it while work may be running`);
7889
+ }
7890
+ throw error;
7891
+ }
7892
+ const created = fstatSync(descriptor);
7893
+ writeFileSync3(descriptor, nonce);
7894
+ return { path: canonicalPath, descriptor, nonce, dev: created.dev, ino: created.ino };
7895
+ }
7896
+ function release(lock) {
7897
+ closeSync3(lock.descriptor);
7898
+ if (existsSync6(lock.path)) {
7899
+ const current = lstatSync7(lock.path);
7900
+ if (current.isFile() && !current.isSymbolicLink() && current.dev === lock.dev && current.ino === lock.ino && readFileSync6(lock.path, "utf8") === lock.nonce)
7901
+ unlinkSync3(lock.path);
7902
+ }
7903
+ }
7904
+ async function withExclusiveFileLock(path, callback) {
7905
+ const lock = acquire(path, "setup");
7906
+ try {
7907
+ return await callback();
7908
+ } finally {
7909
+ release(lock);
7910
+ }
7911
+ }
7912
+ function withExclusiveFileLockSync(path, callback, operation = "operation") {
7913
+ const lock = acquire(path, operation);
7914
+ try {
7915
+ return callback();
7916
+ } finally {
7917
+ release(lock);
7918
+ }
7919
+ }
7920
+
7310
7921
  // src/cli/setup.ts
7311
- var PACKAGE_SPEC = "@nail00749/agent-gvozd@^0.1.0";
7312
- var SUPPORTED_VERSION = "0.0.0-beta-19425";
7313
7922
  function assertVersion(version) {
7314
- if (parseOpenCodeVersion(version) !== SUPPORTED_VERSION) {
7315
- throw new Error(`Unsupported OpenCode version. Gvozd 0.1.0 requires ${SUPPORTED_VERSION}.`);
7923
+ if (parseOpenCodeVersion(version) !== SUPPORTED_OPENCODE_VERSION) {
7924
+ throw new Error(`Unsupported OpenCode version. Gvozd ${PACKAGE_VERSION} requires ${SUPPORTED_OPENCODE_VERSION}.`);
7316
7925
  }
7317
7926
  }
7318
7927
  async function selectProfile(input, client, configRoot) {
7319
7928
  const catalog = parseModels(await client.models());
7320
7929
  const config = loadConfig(input.cwd, { configRoot, includeProject: false });
7321
- const hasGlobalConfig = existsSync4(join5(configRoot, "gvozd", "config.jsonc"));
7930
+ const hasGlobalConfig = existsSync7(join8(configRoot, "gvozd", "config.jsonc"));
7322
7931
  return chooseModelProfile({
7323
7932
  catalog,
7324
7933
  ui: input.ui,
@@ -7335,54 +7944,100 @@ async function confirm2(input, message) {
7335
7944
  const answer = await input.ui.confirm({ message, initialValue: true });
7336
7945
  return typeof answer === "symbol" ? false : answer;
7337
7946
  }
7947
+ function sameSnapshot(left, right) {
7948
+ return left.configRoot === right.configRoot && left.config.exists === right.config.exists && left.config.dev === right.config.dev && left.config.ino === right.config.ino && left.config.bytes === right.config.bytes && left.schema.exists === right.schema.exists && left.schema.dev === right.schema.dev && left.schema.ino === right.schema.ino && left.schema.bytes === right.schema.bytes;
7949
+ }
7338
7950
  async function runSetup(input) {
7339
7951
  const client = await (input.findClient ?? (() => findOpenCode()))();
7340
7952
  const paths = await client.debugPaths();
7341
- const configRoot = paths.config;
7342
- if (!configRoot)
7953
+ if (!paths.config)
7343
7954
  throw new Error("OpenCode did not report its config path");
7344
- assertVersion(await client.version());
7955
+ const configRoot = secureCanonicalPath(paths.config, "OpenCode config root");
7345
7956
  preflightGlobalConfig(configRoot);
7957
+ const runtimeConfigRoot = input.runtimeConfigRoot ?? resolveOpenCodeConfigRoot();
7958
+ assertVersion(await client.version());
7346
7959
  const before = loadConfig(input.cwd, { configRoot, includeProject: false });
7347
- writeManagedAgents({ configRoot, agents: before.agents, check: true });
7960
+ const schemaSource = join8(dirname6(before.sources[0]), "schema.json");
7961
+ const packagedSchema = readFileSync7(schemaSource, "utf8");
7962
+ const previewSnapshot = preflightGlobalConfig(configRoot, packagedSchema);
7963
+ const preview = writeManagedAgents({ configRoot, agents: before.agents, check: true });
7348
7964
  const profile = await selectProfile(input, client, configRoot);
7349
7965
  if (!profile)
7350
7966
  return { status: "cancelled" };
7351
- input.output?.(`Register ${PACKAGE_SPEC}
7352
- Write ${join5(configRoot, "gvozd", "config.jsonc")}
7353
- Write managed agents in ${join5(configRoot, "agents")}`);
7967
+ input.output?.([
7968
+ `Register ${PACKAGE_SPEC}`,
7969
+ `Write ${join8(configRoot, "gvozd", "config.jsonc")}`,
7970
+ `Write managed agents in ${join8(configRoot, "agents")}`,
7971
+ ...preview.removed.length > 0 ? [`Remove ${preview.removed.length} stale or disabled managed agent(s)`] : []
7972
+ ].join(`
7973
+ `));
7354
7974
  if (!await confirm2(input, "Run setup?"))
7355
7975
  return { status: "cancelled" };
7356
- await client.pluginAdd(PACKAGE_SPEC);
7357
- try {
7358
- const schemaSource = join5(dirname3(before.sources[0]), "schema.json");
7359
- writeGlobalConfig({ configRoot, profile, schemaSource: readFileSync6(schemaSource, "utf8") });
7360
- const configured = loadConfig(input.cwd, { configRoot, includeProject: false });
7361
- writeManagedAgents({ configRoot, agents: configured.agents });
7362
- await client.serviceRestart();
7363
- const report = await runDoctor({ client, configRoot, cwd: input.cwd });
7364
- return { status: "complete", report };
7365
- } catch (error) {
7366
- throw new Error(`Gvozd plugin was registered, but setup is incomplete: ${error instanceof Error ? error.message : String(error)}. Rerun: gvozd setup`);
7367
- }
7976
+ return withExclusiveFileLock(join8(configRoot, "gvozd", "setup.lock"), async () => {
7977
+ if (secureCanonicalPath(configRoot, "OpenCode config root") !== configRoot)
7978
+ throw new Error("OpenCode config root changed while setup awaited the lock");
7979
+ const lockedSchema = readFileSync7(schemaSource, "utf8");
7980
+ const snapshot = preflightGlobalConfig(configRoot, lockedSchema);
7981
+ if (!sameSnapshot(previewSnapshot, snapshot))
7982
+ throw new Error("Global Gvozd configuration changed while setup awaited confirmation; review and rerun setup");
7983
+ const lockedBefore = loadConfig(input.cwd, { configRoot, includeProject: false });
7984
+ writeManagedAgents({ configRoot, agents: lockedBefore.agents, check: true });
7985
+ const lockedProfile = input.yes ? await selectProfile(input, client, configRoot) : profile;
7986
+ if (!lockedProfile)
7987
+ throw new Error("Model profile changed while setup awaited the global lock; rerun setup");
7988
+ if (!sameSnapshot(snapshot, preflightGlobalConfig(configRoot, lockedSchema))) {
7989
+ throw new Error("Global Gvozd configuration changed during locked setup revalidation; review and rerun setup");
7990
+ }
7991
+ writeManagedAgents({ configRoot, agents: lockedBefore.agents, check: true });
7992
+ await client.pluginAdd(PACKAGE_SPEC);
7993
+ try {
7994
+ writeGlobalConfig({ configRoot, profile: lockedProfile, schemaSource: lockedSchema, snapshot });
7995
+ const configured = loadConfig(input.cwd, { configRoot, includeProject: false });
7996
+ writeManagedAgents({ configRoot, agents: configured.agents });
7997
+ await client.serviceRestart();
7998
+ const report = await runDoctor({ client, configRoot, runtimeConfigRoot, cwd: input.cwd });
7999
+ return { status: "complete", report };
8000
+ } catch (error) {
8001
+ const detail = redactDiagnostic(error);
8002
+ throw new Error(`${PACKAGE_NAME} remains registered, but setup is incomplete: ${detail}. ` + `Managed files under ${configRoot} may be partially updated; no automatic rollback was attempted because concurrent or user changes cannot be distinguished safely. ` + "Fix the reported cause, then rerun: gvozd setup");
8003
+ }
8004
+ });
7368
8005
  }
7369
8006
  async function runConfigure(input) {
7370
8007
  const client = await (input.findClient ?? (() => findOpenCode()))();
7371
8008
  const paths = await client.debugPaths();
7372
- const configRoot = paths.config;
7373
- if (!configRoot)
8009
+ if (!paths.config)
7374
8010
  throw new Error("OpenCode did not report its config path");
7375
- assertVersion(await client.version());
8011
+ const configRoot = secureCanonicalPath(paths.config, "OpenCode config root");
7376
8012
  preflightGlobalConfig(configRoot);
8013
+ const runtimeConfigRoot = input.runtimeConfigRoot ?? resolveOpenCodeConfigRoot();
8014
+ assertVersion(await client.version());
8015
+ const config = loadConfig(input.cwd, { configRoot, includeProject: false });
8016
+ const schemaSource = join8(dirname6(config.sources[0]), "schema.json");
8017
+ const packagedSchema = readFileSync7(schemaSource, "utf8");
8018
+ const previewSnapshot = preflightGlobalConfig(configRoot, packagedSchema);
7377
8019
  const profile = await selectProfile(input, client, configRoot);
7378
8020
  if (!profile || !await confirm2(input, "Apply model configuration?"))
7379
8021
  return { status: "cancelled" };
7380
- const config = loadConfig(input.cwd, { configRoot, includeProject: false });
7381
- const schemaSource = join5(dirname3(config.sources[0]), "schema.json");
7382
- writeGlobalConfig({ configRoot, profile, schemaSource: readFileSync6(schemaSource, "utf8") });
7383
- await client.serviceRestart();
7384
- const report = await runDoctor({ client, configRoot, cwd: input.cwd });
7385
- return { status: "complete", report };
8022
+ return withExclusiveFileLock(join8(configRoot, "gvozd", "setup.lock"), async () => {
8023
+ if (secureCanonicalPath(configRoot, "OpenCode config root") !== configRoot)
8024
+ throw new Error("OpenCode config root changed while configuration awaited the lock");
8025
+ const lockedSchema = readFileSync7(schemaSource, "utf8");
8026
+ const snapshot = preflightGlobalConfig(configRoot, lockedSchema);
8027
+ if (!sameSnapshot(previewSnapshot, snapshot))
8028
+ throw new Error("Global Gvozd configuration changed while configuration awaited confirmation; review and rerun");
8029
+ loadConfig(input.cwd, { configRoot, includeProject: false });
8030
+ const lockedProfile = input.yes ? await selectProfile(input, client, configRoot) : profile;
8031
+ if (!lockedProfile)
8032
+ throw new Error("Model profile changed while configuration awaited the global lock; rerun configuration");
8033
+ if (!sameSnapshot(snapshot, preflightGlobalConfig(configRoot, lockedSchema))) {
8034
+ throw new Error("Global Gvozd configuration changed during locked configuration revalidation; review and rerun");
8035
+ }
8036
+ writeGlobalConfig({ configRoot, profile: lockedProfile, schemaSource: lockedSchema, snapshot });
8037
+ await client.serviceRestart();
8038
+ const report = await runDoctor({ client, configRoot, runtimeConfigRoot, cwd: input.cwd });
8039
+ return { status: "complete", report };
8040
+ });
7386
8041
  }
7387
8042
  function setupExitCode(result) {
7388
8043
  if (result.status === "cancelled")
@@ -7391,13 +8046,13 @@ function setupExitCode(result) {
7391
8046
  }
7392
8047
 
7393
8048
  // src/sync.ts
7394
- import { closeSync as closeSync3, lstatSync as lstatSync4, mkdirSync as mkdirSync3, openSync as openSync3, readFileSync as readFileSync7, readdirSync as readdirSync2, realpathSync as realpathSync2, renameSync as renameSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
7395
- import { randomUUID as randomUUID3 } from "node:crypto";
7396
- import { basename, dirname as dirname4, join as join6, relative as relative2 } from "node:path";
8049
+ import { closeSync as closeSync4, lstatSync as lstatSync8, mkdirSync as mkdirSync4, openSync as openSync4, readFileSync as readFileSync8, readdirSync as readdirSync5, realpathSync as realpathSync4, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "node:fs";
8050
+ import { randomUUID as randomUUID4 } from "node:crypto";
8051
+ import { basename, dirname as dirname7, join as join9, relative as relative4 } from "node:path";
7397
8052
  function renderPluginEntrypoint(config, destination) {
7398
8053
  let moduleSpecifier = "agent-gvozd/server";
7399
- if (realpathSync2(config.packageRoot) === realpathSync2(config.projectRoot)) {
7400
- moduleSpecifier = relative2(destination, join6(config.packageRoot, "src", "index")).replaceAll("\\", "/");
8054
+ if (realpathSync4(config.packageRoot) === realpathSync4(config.projectRoot)) {
8055
+ moduleSpecifier = relative4(destination, join9(config.packageRoot, "src", "index")).replaceAll("\\", "/");
7401
8056
  if (!moduleSpecifier.startsWith("."))
7402
8057
  moduleSpecifier = `./${moduleSpecifier}`;
7403
8058
  }
@@ -7432,7 +8087,7 @@ function renderDiff(path, before, after) {
7432
8087
  }
7433
8088
  function stat2(path) {
7434
8089
  try {
7435
- return lstatSync4(path);
8090
+ return lstatSync8(path);
7436
8091
  } catch (error) {
7437
8092
  if (error.code === "ENOENT")
7438
8093
  return;
@@ -7440,9 +8095,9 @@ function stat2(path) {
7440
8095
  }
7441
8096
  }
7442
8097
  function safeDirectory(root, segments, create) {
7443
- let current = realpathSync2(root);
8098
+ let current = realpathSync4(root);
7444
8099
  for (const segment of segments) {
7445
- current = join6(current, segment);
8100
+ current = join9(current, segment);
7446
8101
  const currentStat = stat2(current);
7447
8102
  if (currentStat) {
7448
8103
  if (currentStat.isSymbolicLink() || !currentStat.isDirectory()) {
@@ -7451,7 +8106,7 @@ function safeDirectory(root, segments, create) {
7451
8106
  continue;
7452
8107
  }
7453
8108
  if (create)
7454
- mkdirSync3(current);
8109
+ mkdirSync4(current);
7455
8110
  }
7456
8111
  return current;
7457
8112
  }
@@ -7464,145 +8119,191 @@ function assertRegularFile(path) {
7464
8119
  return true;
7465
8120
  }
7466
8121
  function createFile2(path, content) {
7467
- const descriptor = openSync3(path, "wx", 384);
8122
+ const descriptor = openSync4(path, "wx", 384);
7468
8123
  try {
7469
- writeFileSync3(descriptor, content);
8124
+ writeFileSync4(descriptor, content);
7470
8125
  } finally {
7471
- closeSync3(descriptor);
8126
+ closeSync4(descriptor);
7472
8127
  }
7473
8128
  }
7474
8129
  function replaceFile2(path, content) {
7475
- const temporary = `${path}.tmp-${process.pid}-${randomUUID3()}`;
8130
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID4()}`;
7476
8131
  createFile2(temporary, content);
7477
8132
  try {
7478
8133
  renameSync3(temporary, path);
7479
8134
  } catch (error) {
7480
8135
  try {
7481
- unlinkSync3(temporary);
8136
+ unlinkSync4(temporary);
7482
8137
  } catch {}
7483
8138
  throw error;
7484
8139
  }
7485
8140
  }
7486
- function ensureProjectTemplate(config, check) {
8141
+ function planProjectTemplate(config, result, check) {
7487
8142
  const directory = safeDirectory(config.projectRoot, ["docs", ".gvozd"], !check);
7488
- const path = join6(directory, "config.jsonc");
7489
- if (check)
7490
- return;
7491
- if (!assertRegularFile(path)) {
7492
- createFile2(path, [
7493
- "{",
7494
- ' "$schema": "./schema.json",',
7495
- " // Project overrides are merged after built-in and global configuration.",
7496
- ' "agents": {}',
7497
- "}",
7498
- ""
7499
- ].join(`
7500
- `));
8143
+ const writes = [];
8144
+ const configPath = join9(directory, "config.jsonc");
8145
+ if (!assertRegularFile(configPath)) {
8146
+ result.created.push(configPath);
8147
+ writes.push({
8148
+ target: configPath,
8149
+ replace: false,
8150
+ content: [
8151
+ "{",
8152
+ ' "$schema": "./schema.json",',
8153
+ " // Project overrides are merged after built-in and global configuration.",
8154
+ ' "agents": {}',
8155
+ "}",
8156
+ ""
8157
+ ].join(`
8158
+ `)
8159
+ });
7501
8160
  }
7502
- const schemaSource = join6(dirname4(config.sources[0]), "schema.json");
7503
- const schemaTarget = join6(directory, "schema.json");
7504
- const schema = readFileSync7(schemaSource, "utf8");
7505
- if (assertRegularFile(schemaTarget))
7506
- replaceFile2(schemaTarget, schema);
7507
- else
7508
- createFile2(schemaTarget, schema);
8161
+ const schemaSource = join9(dirname7(config.sources[0]), "schema.json");
8162
+ const schemaTarget = join9(directory, "schema.json");
8163
+ const schema = readFileSync8(schemaSource, "utf8");
8164
+ if (!assertRegularFile(schemaTarget)) {
8165
+ result.created.push(schemaTarget);
8166
+ writes.push({ target: schemaTarget, content: schema, replace: false });
8167
+ } else {
8168
+ const current = readFileSync8(schemaTarget, "utf8");
8169
+ if (current !== schema) {
8170
+ if (!hasGeneratedSchemaMarker(current) && !isEquivalentLegacySchema(current, schema)) {
8171
+ throw new Error(`Refusing to overwrite an unmanaged project schema: ${schemaTarget}`);
8172
+ }
8173
+ result.updated.push(schemaTarget);
8174
+ writes.push({ target: schemaTarget, content: schema, replace: true, previous: current });
8175
+ }
8176
+ }
8177
+ return writes;
7509
8178
  }
7510
- function syncAgents(config, options = {}) {
8179
+ function syncAgentsUnlocked(config, options) {
7511
8180
  const check = options.check ?? false;
7512
8181
  const destination = safeDirectory(config.projectRoot, [".opencode", "agents"], false);
7513
8182
  const pluginDestination = safeDirectory(config.projectRoot, [".opencode", "plugins", "agent-gvozd"], false);
7514
8183
  const result = { created: [], updated: [], removed: [], unchanged: [] };
7515
8184
  const writes = [];
7516
8185
  let pluginWrite;
8186
+ const removals = new Map;
8187
+ const templateWrites = planProjectTemplate(config, result, check);
7517
8188
  const enabled = new Set(Object.entries(config.agents).filter(([, agent]) => !agent.disabled).map(([id]) => `${id}.md`));
7518
8189
  if (stat2(destination)) {
7519
- for (const entry of readdirSync2(destination, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
8190
+ for (const entry of readdirSync5(destination, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
7520
8191
  if (!entry.isFile() || !entry.name.endsWith(".md") || enabled.has(entry.name))
7521
8192
  continue;
7522
- const target = join6(destination, entry.name);
7523
- const current = readFileSync7(target, "utf8");
7524
- if (!current.includes(GENERATED_MARKER))
8193
+ const target = join9(destination, entry.name);
8194
+ const current = readFileSync8(target, "utf8");
8195
+ if (!hasGeneratedAgentMarker(current))
7525
8196
  continue;
7526
8197
  result.removed.push(target);
8198
+ removals.set(target, current);
7527
8199
  options.onDiff?.(renderDiff(target, current, ""));
7528
8200
  }
7529
8201
  }
7530
8202
  for (const [id, agent] of Object.entries(config.agents)) {
7531
8203
  if (agent.disabled)
7532
8204
  continue;
7533
- const target = join6(destination, `${id}.md`);
8205
+ const target = join9(destination, `${id}.md`);
7534
8206
  const content = renderAgent(agent);
7535
8207
  if (!assertRegularFile(target)) {
7536
8208
  result.created.push(target);
7537
8209
  writes.push({ target, content, replace: false });
7538
8210
  continue;
7539
8211
  }
7540
- const current = readFileSync7(target, "utf8");
8212
+ const current = readFileSync8(target, "utf8");
7541
8213
  if (current === content) {
7542
8214
  result.unchanged.push(target);
7543
8215
  continue;
7544
8216
  }
7545
- if (!current.includes(GENERATED_MARKER)) {
8217
+ if (!hasGeneratedAgentMarker(current)) {
7546
8218
  throw new Error(`Refusing to overwrite a non-generated agent file: ${target}`);
7547
8219
  }
7548
8220
  result.updated.push(target);
7549
8221
  options.onDiff?.(renderDiff(target, current, content));
7550
- writes.push({ target, content, replace: true });
8222
+ writes.push({ target, content, replace: true, previous: current });
7551
8223
  }
7552
- const pluginTarget = join6(pluginDestination, "index.ts");
8224
+ const pluginTarget = join9(pluginDestination, "index.ts");
7553
8225
  const pluginContent = renderPluginEntrypoint(config, pluginDestination);
7554
8226
  if (!assertRegularFile(pluginTarget)) {
7555
8227
  result.created.push(pluginTarget);
7556
8228
  pluginWrite = { target: pluginTarget, content: pluginContent, replace: false };
7557
8229
  } else {
7558
- const current = readFileSync7(pluginTarget, "utf8");
8230
+ const current = readFileSync8(pluginTarget, "utf8");
7559
8231
  if (current === pluginContent) {
7560
8232
  result.unchanged.push(pluginTarget);
7561
8233
  } else {
7562
- if (!current.includes(GENERATED_PLUGIN_MARKER)) {
8234
+ if (!hasGeneratedPluginMarker(current)) {
7563
8235
  throw new Error(`Refusing to overwrite a non-generated plugin entrypoint: ${pluginTarget}`);
7564
8236
  }
7565
8237
  result.updated.push(pluginTarget);
7566
8238
  options.onDiff?.(renderDiff(pluginTarget, current, pluginContent));
7567
- pluginWrite = { target: pluginTarget, content: pluginContent, replace: true };
8239
+ pluginWrite = { target: pluginTarget, content: pluginContent, replace: true, previous: current };
7568
8240
  }
7569
8241
  }
7570
8242
  if (!check) {
7571
8243
  const writableDestination = safeDirectory(config.projectRoot, [".opencode", "agents"], true);
7572
8244
  const writablePluginDestination = safeDirectory(config.projectRoot, [".opencode", "plugins", "agent-gvozd"], true);
7573
8245
  for (const target of result.removed) {
7574
- if (!assertRegularFile(target) || !readFileSync7(target, "utf8").includes(GENERATED_MARKER)) {
8246
+ if (!assertRegularFile(target)) {
7575
8247
  throw new Error(`Refusing to remove a changed or unsafe agent file: ${target}`);
7576
8248
  }
7577
- unlinkSync3(target);
8249
+ const current = readFileSync8(target, "utf8");
8250
+ if (!hasGeneratedAgentMarker(current) || current !== removals.get(target)) {
8251
+ throw new Error(`Refusing to remove a concurrently changed agent file: ${target}`);
8252
+ }
8253
+ unlinkSync4(target);
7578
8254
  }
7579
8255
  for (const write of writes) {
7580
8256
  if (!write.replace) {
7581
- createFile2(join6(writableDestination, basename(write.target)), write.content);
8257
+ createFile2(join9(writableDestination, basename(write.target)), write.content);
7582
8258
  continue;
7583
8259
  }
7584
- if (!assertRegularFile(write.target) || !readFileSync7(write.target, "utf8").includes(GENERATED_MARKER)) {
8260
+ if (!assertRegularFile(write.target)) {
7585
8261
  throw new Error(`Refusing to replace a changed or unsafe agent file: ${write.target}`);
7586
8262
  }
8263
+ const current = readFileSync8(write.target, "utf8");
8264
+ if (!hasGeneratedAgentMarker(current) || current !== write.previous) {
8265
+ throw new Error(`Refusing to replace a concurrently changed agent file: ${write.target}`);
8266
+ }
7587
8267
  replaceFile2(write.target, write.content);
7588
8268
  }
7589
8269
  if (pluginWrite) {
7590
- const target = join6(writablePluginDestination, basename(pluginWrite.target));
8270
+ const target = join9(writablePluginDestination, basename(pluginWrite.target));
7591
8271
  if (!pluginWrite.replace) {
7592
8272
  createFile2(target, pluginWrite.content);
7593
8273
  } else {
7594
- if (!assertRegularFile(target) || !readFileSync7(target, "utf8").includes(GENERATED_PLUGIN_MARKER)) {
8274
+ if (!assertRegularFile(target)) {
7595
8275
  throw new Error(`Refusing to replace a changed or unsafe plugin entrypoint: ${target}`);
7596
8276
  }
8277
+ const current = readFileSync8(target, "utf8");
8278
+ if (!hasGeneratedPluginMarker(current) || current !== pluginWrite.previous) {
8279
+ throw new Error(`Refusing to replace a concurrently changed plugin entrypoint: ${target}`);
8280
+ }
7597
8281
  replaceFile2(target, pluginWrite.content);
7598
8282
  }
7599
8283
  }
8284
+ const templateDirectory = safeDirectory(config.projectRoot, ["docs", ".gvozd"], true);
8285
+ for (const write of templateWrites) {
8286
+ const target = join9(templateDirectory, basename(write.target));
8287
+ if (!write.replace)
8288
+ createFile2(target, write.content);
8289
+ else {
8290
+ if (!assertRegularFile(target) || readFileSync8(target, "utf8") !== write.previous) {
8291
+ throw new Error(`Refusing to replace a concurrently changed project schema: ${target}`);
8292
+ }
8293
+ replaceFile2(target, write.content);
8294
+ }
8295
+ }
7600
8296
  }
7601
- ensureProjectTemplate(config, check);
7602
8297
  if (!check)
7603
8298
  safeDirectory(config.projectRoot, ["docs", ".gvozd", "tasks"], true);
7604
8299
  return result;
7605
8300
  }
8301
+ function syncAgents(config, options = {}) {
8302
+ if (options.check)
8303
+ return syncAgentsUnlocked(config, options);
8304
+ const root = realpathSync4(config.projectRoot);
8305
+ return withExclusiveFileLockSync(join9(root, ".agent-gvozd-sync.lock"), () => syncAgentsUnlocked(config, options), "sync");
8306
+ }
7606
8307
  function formatSyncResult(result, check) {
7607
8308
  const lines = [check ? "agent-gvozd sync check" : "agent-gvozd sync complete"];
7608
8309
  for (const [label, paths] of [
@@ -7631,8 +8332,23 @@ var promptUI = {
7631
8332
  intro,
7632
8333
  outro
7633
8334
  };
8335
+ var HELP = [
8336
+ "Usage: gvozd <setup|config|doctor|sync|trust-project> [options]",
8337
+ "",
8338
+ "Commands:",
8339
+ " setup [--yes] Install or upgrade the global agent team",
8340
+ " config [--yes] Configure model preferences",
8341
+ " doctor [--json] Diagnose the global installation",
8342
+ " sync [--check] Maintain the legacy project-local installation",
8343
+ " trust-project [directory] Print the current project trust token",
8344
+ "",
8345
+ "Options:",
8346
+ " --help Show this help",
8347
+ " --version Show the Gvozd version"
8348
+ ].join(`
8349
+ `);
7634
8350
  function usage(io) {
7635
- io.stderr("Usage: gvozd <setup|config|doctor|sync> [options]");
8351
+ io.stderr(HELP);
7636
8352
  return 2;
7637
8353
  }
7638
8354
  function parseFlags(args, allowed) {
@@ -7644,6 +8360,14 @@ function parseFlags(args, allowed) {
7644
8360
  async function runCli(args, io = defaultIO, commands = { setup: runSetup, configure: runConfigure }) {
7645
8361
  const [command, ...rest] = args;
7646
8362
  try {
8363
+ if ((command === "--help" || command === "help") && rest.length === 0) {
8364
+ io.stdout(HELP);
8365
+ return 0;
8366
+ }
8367
+ if (command === "--version" && rest.length === 0) {
8368
+ io.stdout(PACKAGE_VERSION);
8369
+ return 0;
8370
+ }
7647
8371
  if (command === "setup" || command === "config") {
7648
8372
  const parsed = parseFlags(rest, ["--yes"]);
7649
8373
  if (!parsed || parsed.positional.length > 0)
@@ -7672,7 +8396,12 @@ async function runCli(args, io = defaultIO, commands = { setup: runSetup, config
7672
8396
  try {
7673
8397
  const client = await (commands.findClient ?? findOpenCode)();
7674
8398
  const paths = await client.debugPaths();
7675
- report = await runDoctor({ client, configRoot: paths.config, cwd: io.cwd() });
8399
+ report = await runDoctor({
8400
+ client,
8401
+ configRoot: paths.config,
8402
+ runtimeConfigRoot: resolveOpenCodeConfigRoot(),
8403
+ cwd: io.cwd()
8404
+ });
7676
8405
  } catch (error) {
7677
8406
  report = doctorOperationalFailure(error);
7678
8407
  }
@@ -7690,14 +8419,21 @@ async function runCli(args, io = defaultIO, commands = { setup: runSetup, config
7690
8419
  io.stdout(formatSyncResult(result, check));
7691
8420
  return check && result.created.length + result.updated.length + result.removed.length > 0 ? 1 : 0;
7692
8421
  }
8422
+ if (command === "trust-project") {
8423
+ const parsed = parseFlags(rest, []);
8424
+ if (!parsed || parsed.positional.length > 1)
8425
+ return usage(io);
8426
+ io.stdout(computeProjectTrustToken(parsed.positional[0] ?? io.cwd()));
8427
+ return 0;
8428
+ }
7693
8429
  return usage(io);
7694
8430
  } catch (error) {
7695
- io.stderr(error instanceof Error ? error.message : String(error));
8431
+ io.stderr(redactDiagnostic(error));
7696
8432
  return 1;
7697
8433
  }
7698
8434
  }
7699
8435
  var invokedPath = process.argv[1];
7700
- if (invokedPath && realpathSync3(invokedPath) === realpathSync3(fileURLToPath2(import.meta.url))) {
8436
+ if (invokedPath && realpathSync5(invokedPath) === realpathSync5(fileURLToPath2(import.meta.url))) {
7701
8437
  process.exitCode = await runCli(process.argv.slice(2));
7702
8438
  }
7703
8439
  export {