Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The catalog contains 10 canonical roles across lifecycle, audit, and utility res
- **Single source of truth:** `agents/*.md` in this repo. Consumer copies are generated, not edited.
- **Delegates reference the local copy:** `distilled/templates/delegates/*.md` point to `.work/templates/roles/<role>.md`, not back to this repo; legacy installs localize those paths to `.planning/templates/roles/<role>.md`.
- **Idempotent:** `gsdd init` skips the copy if `.work/templates/roles/` already exists.
- **Updates:** `gsdd update --templates` re-copies from latest framework sources with hash-based modification detection.
- **Updates:** plain `gsdd update` reconciles the manifest-owned repo-local templates, helpers, skills, and adapters from latest framework sources with hash-based modification detection.

Verifier note:
- `verifier.md` is phase-scoped.
Expand Down
105 changes: 64 additions & 41 deletions bin/lib/global-install.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os from 'os';
import { spawnSync } from 'child_process';
import { existsSync, lstatSync } from 'fs';
import { join } from 'path';
import { isAbsolute, join, parse, relative, resolve, sep } from 'path';
import { promptMultiSelect } from './init-prompts.mjs';
import {
buildPortableSkillEntries,
Expand Down Expand Up @@ -57,6 +57,25 @@ function getConfigHome(homeDir, env = process.env) {
return env.XDG_CONFIG_HOME || join(homeDir, '.config');
}

function pathIsInside(root, target) {
const rel = relative(resolve(root), resolve(target));
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
}

function globalContainmentRoot(roots, rootDir) {
if (pathIsInside(roots.home, rootDir)) return roots.home;
if (pathIsInside(roots.configHome, rootDir) && pathIsInside(roots.home, roots.configHome)) return roots.home;
// Explicit config/runtime homes outside the normal Workspine home are an
// allowed destination, but a missing leaf must not hide a junction or file
// collision in one of its existing ancestors. Anchor at that path's volume
// root so the read-only containment walk sees every ancestor before mkdir.
return parse(resolve(rootDir)).root;
}

function globalInstallSpec(roots, runtime, rootDir, entries) {
return { runtime, rootDir, containmentRoot: globalContainmentRoot(roots, rootDir), entries };
}

export function resolveGlobalInstallRoots({ homeDir = getHomeDir(), env = process.env } = {}) {
const isolatedHome = env.GSDD_TEST_HOME || null;
const effectiveHome = isolatedHome || homeDir;
Expand Down Expand Up @@ -231,71 +250,43 @@ function buildGlobalEntries(target, ctx, rootDir) {
function buildGlobalInstallSpecs(target, roots, ctx) {
if (target === 'codex') {
return [
{
runtime: 'agent-skills',
rootDir: roots.codexSkills,
entries: buildAgentCompatibleGlobalSkillEntries(ctx),
},
{
runtime: 'codex',
rootDir: roots.codex,
entries: buildCodexGlobalAgentEntries(),
},
globalInstallSpec(roots, 'agent-skills', roots.codexSkills, buildAgentCompatibleGlobalSkillEntries(ctx)),
globalInstallSpec(roots, 'codex', roots.codex, buildCodexGlobalAgentEntries()),
];
}

if (target === 'opencode' && roots.opencode !== roots.opencodeSkills) {
return [
{
runtime: 'agent-skills',
rootDir: roots.opencodeSkills,
entries: buildAgentCompatibleGlobalSkillEntries(ctx),
},
{
runtime: 'opencode',
rootDir: roots.opencode,
entries: [
globalInstallSpec(roots, 'agent-skills', roots.opencodeSkills, buildAgentCompatibleGlobalSkillEntries(ctx)),
globalInstallSpec(roots, 'opencode', roots.opencode, [
...buildOpenCodeGlobalCommandEntries(ctx, roots.opencodeSkills),
...buildOpenCodeGlobalAgentEntries(ctx),
],
},
]),
];
}

if (target === 'copilot' && roots.copilot !== roots.copilotSkills) {
return [
{
runtime: 'agent-skills',
rootDir: roots.copilotSkills,
entries: buildAgentCompatibleGlobalSkillEntries(ctx),
},
{
runtime: 'copilot',
rootDir: roots.copilot,
entries: buildCopilotGlobalAgentEntries(),
},
globalInstallSpec(roots, 'agent-skills', roots.copilotSkills, buildAgentCompatibleGlobalSkillEntries(ctx)),
globalInstallSpec(roots, 'copilot', roots.copilot, buildCopilotGlobalAgentEntries()),
];
}

return [
{
runtime: target,
rootDir: roots[target],
entries: buildGlobalEntries(target, ctx, roots[target]),
},
globalInstallSpec(roots, target, roots[target], buildGlobalEntries(target, ctx, roots[target])),
];
}

function preflightInstallSpec(spec, { strictOwnership = false } = {}) {
const manifestState = inspectGlobalManifest(spec.rootDir);
const manifestState = inspectGlobalManifest(spec.rootDir, spec.containmentRoot);
const previousManifest = manifestState.manifest;
const manifestOwnershipMismatch = manifestState.status === 'valid'
&& (manifestState.manifest.product !== 'Workspine'
|| manifestState.manifest.runtime !== spec.runtime
|| !manifestState.manifest.files
|| typeof manifestState.manifest.files !== 'object'
|| Array.isArray(manifestState.manifest.files));
if (['linked', 'collision', 'unreadable', 'corrupt'].includes(manifestState.status) || manifestOwnershipMismatch) {
if (['linked', 'collision', 'unreadable', 'unsafe', 'corrupt'].includes(manifestState.status) || manifestOwnershipMismatch) {
const status = manifestOwnershipMismatch ? 'skipped_collision' : `skipped_${manifestState.status}`;
return {
...spec,
Expand All @@ -322,12 +313,14 @@ function preflightInstallSpec(spec, { strictOwnership = false } = {}) {
nextFiles,
dryRun: true,
strictOwnership,
containmentRoot: spec.containmentRoot,
}));
const pruneResults = pruneStaleManifestTrackedFiles({
rootDir: spec.rootDir,
previousManifest,
nextFiles,
dryRun: true,
containmentRoot: spec.containmentRoot,
});
const results = [...fileResults, ...pruneResults];

Expand All @@ -349,12 +342,14 @@ function writeInstallSpec(plan, ctx) {
previousManifest: plan.previousManifest,
nextFiles,
dryRun: false,
containmentRoot: plan.containmentRoot,
}));
results.push(...pruneStaleManifestTrackedFiles({
rootDir: plan.rootDir,
previousManifest: plan.previousManifest,
nextFiles,
dryRun: false,
containmentRoot: plan.containmentRoot,
}));

writeGlobalManifest(plan.rootDir, {
Expand All @@ -365,7 +360,7 @@ function writeInstallSpec(plan, ctx) {
runtime: plan.runtime,
generatedAt: new Date().toISOString(),
files: nextFiles,
});
}, plan.containmentRoot);

return results;
}
Expand Down Expand Up @@ -591,6 +586,32 @@ export function getManifestOwnedGlobalTargets({ roots = resolveGlobalInstallRoot
});
}

/**
* Global health must also inspect a partially lost ownership set when native
* Workspine-looking files remain. Shared agent-skills ownership alone is not
* enough to infer OpenCode/Codex/Copilot ownership.
*/
export function getGlobalHealthTargets({ roots = resolveGlobalInstallRoots(), ctx } = {}) {
const pathPresentOrUnreadable = (filePath) => {
try {
lstatSync(filePath);
return true;
} catch (error) {
return error?.code !== 'ENOENT';
}
};
const fullyOwned = new Set(getManifestOwnedGlobalTargets({ roots }));
return GLOBAL_AGENT_IDS.filter((target) => {
if (fullyOwned.has(target)) return true;
if (!ctx) return false;
const nativeSpecs = buildGlobalInstallSpecs(target, roots, ctx)
.filter((spec) => spec.runtime !== 'agent-skills');
return nativeSpecs.some((spec) =>
pathPresentOrUnreadable(join(spec.rootDir, GLOBAL_MANIFEST_FILENAME))
|| spec.entries.some((entry) => pathPresentOrUnreadable(join(spec.rootDir, entry.relativePath))));
});
}

export function collectGlobalInstallSpecs({ target, roots, ctx }) {
return buildGlobalInstallSpecs(target, roots, ctx);
}
Expand Down Expand Up @@ -644,7 +665,9 @@ async function reconcileGlobalTargets({ ctx, targets, dryRun = false, heading =
}

if (hasBlocked) {
console.error('\nGlobal reconciliation finished with skipped files. Review them before retrying.');
console.error(heading === 'update'
? '\nGlobal update blocked. Manual resolution is required before retrying; review the warnings and run `npx -y workspine health --global` after resolving them.'
: '\nGlobal reconciliation finished with skipped files. Review them before retrying.');
process.exitCode = 1;
return { targets, reports, blocked: true };
}
Expand Down
Loading
Loading