vgxness 0.1.0
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/LICENSE +9 -0
- package/README.md +110 -0
- package/dist/agents/agent-activation-service.js +144 -0
- package/dist/agents/agent-registry-service.js +46 -0
- package/dist/agents/agent-resolver.js +249 -0
- package/dist/agents/agent-seed-service.js +146 -0
- package/dist/agents/manager-profile-overlay-service.js +34 -0
- package/dist/agents/profile-model-routing.js +26 -0
- package/dist/agents/renderers/claude-renderer.js +98 -0
- package/dist/agents/renderers/index.js +16 -0
- package/dist/agents/renderers/json-renderer.js +87 -0
- package/dist/agents/renderers/opencode-renderer.js +100 -0
- package/dist/agents/renderers/provider-adapter.js +6 -0
- package/dist/agents/repositories/agents.js +185 -0
- package/dist/agents/repositories/manager-profile-overlays.js +81 -0
- package/dist/agents/schema.js +1 -0
- package/dist/cli/dashboard-operational-read-models.js +153 -0
- package/dist/cli/dashboard-renderer.js +109 -0
- package/dist/cli/dashboard-screen-renderers.js +332 -0
- package/dist/cli/dashboard-tui-read-model.js +71 -0
- package/dist/cli/dashboard-tui-state.js +218 -0
- package/dist/cli/dispatcher.js +2880 -0
- package/dist/cli/index.js +27 -0
- package/dist/cli/interactive-dashboard.js +29 -0
- package/dist/cli/mcp-start-path.js +21 -0
- package/dist/cli/setup-status-renderer.js +29 -0
- package/dist/cli/setup-wizard-read-model.js +56 -0
- package/dist/cli/setup-wizard-renderer.js +148 -0
- package/dist/cli/setup-wizard-state.js +82 -0
- package/dist/cli/tui-render-helpers.js +192 -0
- package/dist/export/redaction.js +71 -0
- package/dist/harness/tools/agents.js +245 -0
- package/dist/harness/tools/memory.js +29 -0
- package/dist/mcp/client-install-opencode-contract.js +227 -0
- package/dist/mcp/client-install-opencode.js +194 -0
- package/dist/mcp/client-setup-preview.js +38 -0
- package/dist/mcp/control-plane.js +175 -0
- package/dist/mcp/doctor.js +193 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/opencode-default-agent-config.js +156 -0
- package/dist/mcp/opencode-visibility.js +102 -0
- package/dist/mcp/schema.js +234 -0
- package/dist/mcp/stdio-server.js +56 -0
- package/dist/mcp/validation.js +761 -0
- package/dist/memory/import/dry-run-planner.js +58 -0
- package/dist/memory/import/index.js +3 -0
- package/dist/memory/import/observation-writer.js +220 -0
- package/dist/memory/import/package.js +178 -0
- package/dist/memory/memory-service.js +126 -0
- package/dist/memory/repositories/artifacts.js +41 -0
- package/dist/memory/repositories/observations.js +133 -0
- package/dist/memory/repositories/sessions.js +105 -0
- package/dist/memory/repositories/traces.js +58 -0
- package/dist/memory/schema.js +1 -0
- package/dist/memory/search.js +11 -0
- package/dist/memory/sqlite/database.js +97 -0
- package/dist/memory/sqlite/migrations/001_initial.sql +128 -0
- package/dist/memory/sqlite/migrations/002_observation_revisions.sql +14 -0
- package/dist/memory/sqlite/migrations/003_agent_registry.sql +26 -0
- package/dist/memory/sqlite/migrations/004_run_runtime.sql +62 -0
- package/dist/memory/sqlite/migrations/005_run_approvals.sql +20 -0
- package/dist/memory/sqlite/migrations/006_run_operation_attempts.sql +32 -0
- package/dist/memory/sqlite/migrations/007_abandoned_operation_attempts.sql +46 -0
- package/dist/memory/sqlite/migrations/008_run_execution_plan_events.sql +105 -0
- package/dist/memory/sqlite/migrations/009_multiple_operation_attempts.sql +73 -0
- package/dist/memory/sqlite/migrations/010_skill_registry.sql +66 -0
- package/dist/memory/sqlite/migrations/011_skill_usage_resolution_outcomes.sql +21 -0
- package/dist/memory/sqlite/migrations/012_skill_improvement_proposals.sql +37 -0
- package/dist/memory/sqlite/migrations/013_skill_evaluation_scenarios.sql +43 -0
- package/dist/memory/sqlite/migrations/014_manager_profile_overlays.sql +14 -0
- package/dist/memory/storage-paths.js +72 -0
- package/dist/orchestrator/natural-language-planner.js +191 -0
- package/dist/orchestrator/schema.js +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/policy-evaluator.js +109 -0
- package/dist/permissions/schema.js +1 -0
- package/dist/providers/opencode/injection-preview.js +134 -0
- package/dist/providers/opencode/manager-payload.js +129 -0
- package/dist/runs/execution-planning.js +117 -0
- package/dist/runs/operation-execution.js +1 -0
- package/dist/runs/operation-retry.js +124 -0
- package/dist/runs/repositories/runs.js +611 -0
- package/dist/runs/run-insights.js +145 -0
- package/dist/runs/run-service.js +713 -0
- package/dist/runs/run-snapshot-export-service.js +31 -0
- package/dist/runs/sandbox-process-execution.js +218 -0
- package/dist/runs/sandbox-worktree-planning.js +59 -0
- package/dist/runs/schema.js +1 -0
- package/dist/sdd/artifact-portability-service.js +118 -0
- package/dist/sdd/schema.js +17 -0
- package/dist/sdd/sdd-workflow-service.js +217 -0
- package/dist/setup/backup-rollback-service.js +76 -0
- package/dist/setup/index.js +3 -0
- package/dist/setup/providers/antigravity-setup-adapter.js +18 -0
- package/dist/setup/providers/claude-setup-adapter.js +30 -0
- package/dist/setup/providers/custom-setup-adapter.js +18 -0
- package/dist/setup/providers/index.js +6 -0
- package/dist/setup/providers/opencode-setup-adapter.js +104 -0
- package/dist/setup/providers/provider-setup-adapter.js +15 -0
- package/dist/setup/providers/provider-setup-registry.js +11 -0
- package/dist/setup/schema.js +1 -0
- package/dist/setup/setup-defaults.js +11 -0
- package/dist/setup/setup-lifecycle-service.js +175 -0
- package/dist/setup/setup-plan.js +105 -0
- package/dist/skills/repositories/skill-evaluation-scenarios.js +289 -0
- package/dist/skills/repositories/skill-improvement-proposals.js +288 -0
- package/dist/skills/repositories/skills.js +430 -0
- package/dist/skills/schema.js +1 -0
- package/dist/skills/skill-payload.js +94 -0
- package/dist/skills/skill-registry-service.js +92 -0
- package/dist/skills/skill-resolver.js +191 -0
- package/dist/workflows/command-allowlist-adapter.js +70 -0
- package/dist/workflows/schema.js +4 -0
- package/dist/workflows/workflow-executor.js +345 -0
- package/dist/workflows/workflow-registry.js +66 -0
- package/docs/architecture.md +698 -0
- package/docs/cli.md +741 -0
- package/docs/funcionamiento-del-sistema.md +868 -0
- package/docs/harness-gap-analysis.md +229 -0
- package/docs/prd.md +372 -0
- package/package.json +57 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { redactJsonValue } from '../export/redaction.js';
|
|
2
|
+
import { runSnapshotPackageKind } from './schema.js';
|
|
3
|
+
export class RunSnapshotExportService {
|
|
4
|
+
runs;
|
|
5
|
+
constructor(runs) {
|
|
6
|
+
this.runs = runs;
|
|
7
|
+
}
|
|
8
|
+
exportSnapshot(input) {
|
|
9
|
+
const details = this.runs.getRun(input.runId);
|
|
10
|
+
if (!details.ok)
|
|
11
|
+
return details;
|
|
12
|
+
if (details.value.project !== input.project) {
|
|
13
|
+
return validationFailure(`Run project mismatch: expected ${input.project}, got ${details.value.project}`);
|
|
14
|
+
}
|
|
15
|
+
const redactedRun = redactJsonValue(details.value);
|
|
16
|
+
return {
|
|
17
|
+
ok: true,
|
|
18
|
+
value: {
|
|
19
|
+
kind: runSnapshotPackageKind,
|
|
20
|
+
version: 1,
|
|
21
|
+
exportedAt: new Date().toISOString(),
|
|
22
|
+
project: input.project,
|
|
23
|
+
runId: input.runId,
|
|
24
|
+
run: redactedRun,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function validationFailure(message) {
|
|
30
|
+
return { ok: false, error: { code: 'validation_failed', message } };
|
|
31
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
const defaultTimeoutMs = 10_000;
|
|
6
|
+
const defaultOutputLimitBytes = 64 * 1024;
|
|
7
|
+
const allowedEnvKey = /^[A-Z_][A-Z0-9_]*$/u;
|
|
8
|
+
const shellMetaCharacters = /[\s;&|<>$`\\]/u;
|
|
9
|
+
export function buildSandboxExecutionPlan(request) {
|
|
10
|
+
const workspaceRoot = realpathOrUndefined(request.workspaceRoot);
|
|
11
|
+
if (workspaceRoot === undefined)
|
|
12
|
+
return rejected('workspace-root-not-found', `Workspace root does not exist: ${request.workspaceRoot}`);
|
|
13
|
+
const cwd = realpathOrUndefined(request.cwd ?? request.workspaceRoot);
|
|
14
|
+
if (cwd === undefined)
|
|
15
|
+
return rejected('cwd-not-found', `Process cwd does not exist: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot);
|
|
16
|
+
if (!isInside(cwd, workspaceRoot))
|
|
17
|
+
return rejected('cwd-escapes-workspace', `Process cwd escapes workspace boundary: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot, cwd);
|
|
18
|
+
if (isProviderConfigPath(cwd, workspaceRoot))
|
|
19
|
+
return rejected('provider-config-cwd-rejected', `Process cwd targets provider/OpenCode config: ${request.cwd ?? request.workspaceRoot}`, workspaceRoot, cwd);
|
|
20
|
+
if (request.command.length === 0)
|
|
21
|
+
return rejected('empty-command', 'Process command is required.', workspaceRoot, cwd);
|
|
22
|
+
if (isAbsolute(request.command) || request.command.includes(sep) || shellMetaCharacters.test(request.command)) {
|
|
23
|
+
return rejected('shell-or-path-command-rejected', 'Command must be a single allowlisted executable name; shell strings and paths are rejected.', workspaceRoot, cwd);
|
|
24
|
+
}
|
|
25
|
+
if (!request.allowedCommands.includes(request.command)) {
|
|
26
|
+
return rejected('command-not-allowlisted', `Command is not allowlisted: ${request.command}`, workspaceRoot, cwd);
|
|
27
|
+
}
|
|
28
|
+
const argv = request.args ?? [];
|
|
29
|
+
if (!argv.every((arg) => typeof arg === 'string'))
|
|
30
|
+
return rejected('invalid-argv', 'Arguments must be an argv array of strings.', workspaceRoot, cwd);
|
|
31
|
+
const targetPaths = request.targetPaths ?? [];
|
|
32
|
+
if (!targetPaths.every((targetPath) => typeof targetPath === 'string'))
|
|
33
|
+
return rejected('invalid-target-paths', 'Target paths must be an array of strings.', workspaceRoot, cwd);
|
|
34
|
+
const providerConfigTargetPath = targetPaths.find((targetPath) => isProviderConfigPath(targetPath, workspaceRoot));
|
|
35
|
+
if (providerConfigTargetPath !== undefined)
|
|
36
|
+
return rejected('provider-config-target-path-rejected', `Process plan target path targets provider/OpenCode config: ${providerConfigTargetPath}`, workspaceRoot, cwd);
|
|
37
|
+
const env = request.env ?? {};
|
|
38
|
+
const envKeys = Object.keys(env).sort();
|
|
39
|
+
const invalidEnvKey = envKeys.find((key) => !allowedEnvKey.test(key));
|
|
40
|
+
if (invalidEnvKey !== undefined)
|
|
41
|
+
return rejected('invalid-env-key', `Environment key is not in the limited allow shape: ${invalidEnvKey}`, workspaceRoot, cwd);
|
|
42
|
+
const timeoutMs = request.timeoutMs ?? defaultTimeoutMs;
|
|
43
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000)
|
|
44
|
+
return rejected('invalid-timeout', 'Timeout must be an integer between 1 and 60000 ms.', workspaceRoot, cwd);
|
|
45
|
+
const outputLimitBytes = request.outputLimitBytes ?? defaultOutputLimitBytes;
|
|
46
|
+
if (!Number.isInteger(outputLimitBytes) || outputLimitBytes < 1 || outputLimitBytes > 1024 * 1024)
|
|
47
|
+
return rejected('invalid-output-limit', 'Output limit must be an integer between 1 byte and 1 MiB.', workspaceRoot, cwd);
|
|
48
|
+
const evidence = acceptedEvidence(workspaceRoot, cwd, [
|
|
49
|
+
'workspace-root-realpath-resolved',
|
|
50
|
+
'cwd-realpath-inside-workspace',
|
|
51
|
+
'command-is-executable-name-not-shell-string',
|
|
52
|
+
'command-allowlisted',
|
|
53
|
+
'argv-array-only',
|
|
54
|
+
'limited-env-keys-validated',
|
|
55
|
+
'timeout-configured',
|
|
56
|
+
'output-cap-configured',
|
|
57
|
+
'provider-config-paths-not-targeted-by-process-plan',
|
|
58
|
+
]);
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
value: {
|
|
62
|
+
strategy: 'bounded-process',
|
|
63
|
+
executable: true,
|
|
64
|
+
request: { workspaceRoot, cwd, command: request.command, argv, envKeys, timeoutMs, outputLimitBytes },
|
|
65
|
+
evidence,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export async function executeSandboxedProcess(request) {
|
|
70
|
+
const plan = buildSandboxExecutionPlan(request);
|
|
71
|
+
if (!plan.ok)
|
|
72
|
+
return plan;
|
|
73
|
+
const { command, argv, cwd, timeoutMs, outputLimitBytes } = plan.value.request;
|
|
74
|
+
const env = limitedEnv(request.env ?? {});
|
|
75
|
+
return await new Promise((resolvePromise) => {
|
|
76
|
+
const child = spawn(command, argv, { cwd, env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
77
|
+
let stdout = '';
|
|
78
|
+
let stderr = '';
|
|
79
|
+
let outputBytes = 0;
|
|
80
|
+
let outputTruncated = false;
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
timedOut = true;
|
|
84
|
+
child.kill('SIGTERM');
|
|
85
|
+
}, timeoutMs);
|
|
86
|
+
const append = (chunk, target) => {
|
|
87
|
+
if (outputBytes >= outputLimitBytes) {
|
|
88
|
+
outputTruncated = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const remaining = outputLimitBytes - outputBytes;
|
|
92
|
+
const slice = chunk.subarray(0, Math.max(0, remaining));
|
|
93
|
+
outputBytes += slice.byteLength;
|
|
94
|
+
if (slice.byteLength < chunk.byteLength)
|
|
95
|
+
outputTruncated = true;
|
|
96
|
+
if (target === 'stdout')
|
|
97
|
+
stdout += slice.toString('utf8');
|
|
98
|
+
else
|
|
99
|
+
stderr += slice.toString('utf8');
|
|
100
|
+
};
|
|
101
|
+
child.stdout?.on('data', (chunk) => append(chunk, 'stdout'));
|
|
102
|
+
child.stderr?.on('data', (chunk) => append(chunk, 'stderr'));
|
|
103
|
+
child.on('error', (error) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolvePromise({ ok: false, error: { code: 'process-launch-failed', message: error.message, evidence: plan.value.evidence } });
|
|
106
|
+
});
|
|
107
|
+
child.on('close', (exitCode, signal) => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolvePromise({ ok: true, value: { exitCode, signal, stdout, stderr, timedOut, outputTruncated, evidence: plan.value.evidence } });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export function executeSandboxedProcessSync(request) {
|
|
114
|
+
const plan = buildSandboxExecutionPlan(request);
|
|
115
|
+
if (!plan.ok)
|
|
116
|
+
return plan;
|
|
117
|
+
const { command, argv, cwd, timeoutMs, outputLimitBytes } = plan.value.request;
|
|
118
|
+
const executed = spawnSync(command, argv, {
|
|
119
|
+
cwd,
|
|
120
|
+
env: limitedEnv(request.env ?? {}),
|
|
121
|
+
shell: false,
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
timeout: timeoutMs,
|
|
124
|
+
encoding: 'buffer',
|
|
125
|
+
maxBuffer: outputLimitBytes,
|
|
126
|
+
});
|
|
127
|
+
if (executed.error !== undefined && executed.error.message.includes('maxBuffer')) {
|
|
128
|
+
// Keep the bounded-process contract explicit: output beyond maxBuffer is truncated.
|
|
129
|
+
}
|
|
130
|
+
else if (executed.error !== undefined && !isTimeoutError(executed.error)) {
|
|
131
|
+
return { ok: false, error: { code: 'process-launch-failed', message: executed.error.message, evidence: plan.value.evidence } };
|
|
132
|
+
}
|
|
133
|
+
const stdout = bufferToLimitedString(executed.stdout, outputLimitBytes);
|
|
134
|
+
const remaining = Math.max(0, outputLimitBytes - Buffer.byteLength(stdout));
|
|
135
|
+
const stderr = bufferToLimitedString(executed.stderr, remaining);
|
|
136
|
+
const outputTruncated = Buffer.byteLength(stdout) + Buffer.byteLength(stderr) >= outputLimitBytes || (executed.error?.message.includes('maxBuffer') ?? false);
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
value: {
|
|
140
|
+
exitCode: executed.status,
|
|
141
|
+
signal: executed.signal,
|
|
142
|
+
stdout,
|
|
143
|
+
stderr,
|
|
144
|
+
timedOut: isTimeoutError(executed.error),
|
|
145
|
+
outputTruncated,
|
|
146
|
+
evidence: plan.value.evidence,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function isTimeoutError(error) {
|
|
151
|
+
return error?.code === 'ETIMEDOUT';
|
|
152
|
+
}
|
|
153
|
+
function bufferToLimitedString(buffer, limit) {
|
|
154
|
+
if (buffer === null || buffer === undefined || limit <= 0)
|
|
155
|
+
return '';
|
|
156
|
+
const source = typeof buffer === 'string' ? Buffer.from(buffer) : buffer;
|
|
157
|
+
return source.subarray(0, limit).toString('utf8');
|
|
158
|
+
}
|
|
159
|
+
function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
160
|
+
return {
|
|
161
|
+
strategy: 'bounded-process',
|
|
162
|
+
enforceable: true,
|
|
163
|
+
workspaceRoot,
|
|
164
|
+
cwd,
|
|
165
|
+
capabilities: {
|
|
166
|
+
sandboxEnforceable: true,
|
|
167
|
+
processExecutionEnforceable: true,
|
|
168
|
+
workspaceBoundaryEnforced: true,
|
|
169
|
+
providerConfigWritesBlocked: true,
|
|
170
|
+
shellDisabled: true,
|
|
171
|
+
argvArrayOnly: true,
|
|
172
|
+
limitedEnvironment: true,
|
|
173
|
+
timeoutEnforced: true,
|
|
174
|
+
outputCapEnforced: true,
|
|
175
|
+
filesystemIsolation: false,
|
|
176
|
+
networkIsolation: false,
|
|
177
|
+
},
|
|
178
|
+
limitations: [
|
|
179
|
+
'bounded-process enforces local process launch constraints only; it is not an OS-level filesystem sandbox',
|
|
180
|
+
'bounded-process does not prove network isolation',
|
|
181
|
+
],
|
|
182
|
+
validation: { accepted: true, checks },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function rejected(code, message, workspaceRoot, cwd) {
|
|
186
|
+
const evidence = workspaceRoot === undefined || cwd === undefined
|
|
187
|
+
? undefined
|
|
188
|
+
: { ...acceptedEvidence(workspaceRoot, cwd, []), enforceable: false, validation: { accepted: false, checks: [code] } };
|
|
189
|
+
return { ok: false, error: { code, message, ...(evidence === undefined ? {} : { evidence }) } };
|
|
190
|
+
}
|
|
191
|
+
function realpathOrUndefined(path) {
|
|
192
|
+
try {
|
|
193
|
+
return realpathSync.native(resolve(path));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function isInside(path, workspaceRoot) {
|
|
200
|
+
return path === workspaceRoot || path.startsWith(`${workspaceRoot}${sep}`);
|
|
201
|
+
}
|
|
202
|
+
function isProviderConfigPath(path, workspaceRoot) {
|
|
203
|
+
const absolutePath = resolve(workspaceRoot, path);
|
|
204
|
+
const projectOpenCodeDirectory = resolve(workspaceRoot, '.opencode');
|
|
205
|
+
if (absolutePath === projectOpenCodeDirectory || isInside(absolutePath, projectOpenCodeDirectory))
|
|
206
|
+
return true;
|
|
207
|
+
if (absolutePath === resolve(workspaceRoot, 'opencode.json') || absolutePath === resolve(workspaceRoot, 'opencode.jsonc'))
|
|
208
|
+
return true;
|
|
209
|
+
const userOpenCodeDirectory = resolve(homedir(), '.config', 'opencode');
|
|
210
|
+
return absolutePath === userOpenCodeDirectory || isInside(absolutePath, userOpenCodeDirectory);
|
|
211
|
+
}
|
|
212
|
+
function limitedEnv(input) {
|
|
213
|
+
const env = { PATH: process.env.PATH ?? '' };
|
|
214
|
+
for (const [key, value] of Object.entries(input))
|
|
215
|
+
if (value !== undefined)
|
|
216
|
+
env[key] = value;
|
|
217
|
+
return env;
|
|
218
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { resolveWorkspaceContainedPath } from '../permissions/policy-evaluator.js';
|
|
3
|
+
const uncheckedGitBoundary = {
|
|
4
|
+
status: 'uncertain',
|
|
5
|
+
reason: 'git boundary validation was not reached',
|
|
6
|
+
};
|
|
7
|
+
export function planWorktreeSandbox(input) {
|
|
8
|
+
const contained = resolveWorkspaceContainedPath(input.workspaceRoot, input.targetPath);
|
|
9
|
+
if (!contained.ok)
|
|
10
|
+
return rejected(input.workspaceRoot, uncheckedGitBoundary, 'sandbox_boundary', contained.reason);
|
|
11
|
+
const configPath = providerConfigPath(contained.targetPath, contained.workspaceRoot);
|
|
12
|
+
if (configPath !== undefined) {
|
|
13
|
+
return rejected(contained.workspaceRoot, uncheckedGitBoundary, 'provider_config_write', `Provider config target is prohibited: ${configPath}`);
|
|
14
|
+
}
|
|
15
|
+
const gitBoundary = input.gitBoundaryInspector({ workspaceRoot: contained.workspaceRoot, targetPath: contained.targetPath });
|
|
16
|
+
if (gitBoundary.status !== 'compatible')
|
|
17
|
+
return rejected(contained.workspaceRoot, gitBoundary, 'git_boundary', gitBoundary.reason);
|
|
18
|
+
return {
|
|
19
|
+
strategy: 'worktree',
|
|
20
|
+
decision: 'accepted',
|
|
21
|
+
workspaceRoot: contained.workspaceRoot,
|
|
22
|
+
targetPath: contained.targetPath,
|
|
23
|
+
gitBoundary,
|
|
24
|
+
providerConfigMutation: false,
|
|
25
|
+
createsWorktree: false,
|
|
26
|
+
executesProvider: false,
|
|
27
|
+
audit: {
|
|
28
|
+
workspaceRoot: contained.workspaceRoot,
|
|
29
|
+
strategy: 'worktree',
|
|
30
|
+
decision: 'accepted',
|
|
31
|
+
validationSummary: gitBoundary.reason,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function rejected(workspaceRoot, gitBoundary, reason, validationSummary) {
|
|
36
|
+
return {
|
|
37
|
+
strategy: 'worktree',
|
|
38
|
+
decision: 'rejected',
|
|
39
|
+
workspaceRoot,
|
|
40
|
+
gitBoundary,
|
|
41
|
+
providerConfigMutation: false,
|
|
42
|
+
createsWorktree: false,
|
|
43
|
+
executesProvider: false,
|
|
44
|
+
reason,
|
|
45
|
+
audit: {
|
|
46
|
+
workspaceRoot,
|
|
47
|
+
strategy: 'worktree',
|
|
48
|
+
decision: 'rejected',
|
|
49
|
+
validationSummary,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function providerConfigPath(targetPath, workspaceRoot) {
|
|
54
|
+
const relative = targetPath.slice(workspaceRoot.length).split(sep).filter(Boolean);
|
|
55
|
+
const firstSegment = relative[0];
|
|
56
|
+
if (firstSegment === '.opencode' || firstSegment === '.claude')
|
|
57
|
+
return firstSegment;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const runSnapshotPackageKind = 'vgxness.run-snapshot-package';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { redactJsonValue } from '../export/redaction.js';
|
|
2
|
+
import { isSddPhase, sddTopicKey } from './schema.js';
|
|
3
|
+
export const artifactPackageKind = 'vgxness.sdd-artifact-package';
|
|
4
|
+
const defaultContext = { actor: 'artifact-portability-service' };
|
|
5
|
+
const validChangePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
6
|
+
export class ArtifactPortabilityService {
|
|
7
|
+
memory;
|
|
8
|
+
workflow;
|
|
9
|
+
context;
|
|
10
|
+
constructor(memory, workflow, context = defaultContext) {
|
|
11
|
+
this.memory = memory;
|
|
12
|
+
this.workflow = workflow;
|
|
13
|
+
this.context = context;
|
|
14
|
+
}
|
|
15
|
+
exportPackage(input) {
|
|
16
|
+
const validIdentity = validateIdentity(input.project, input.change);
|
|
17
|
+
if (!validIdentity.ok)
|
|
18
|
+
return validIdentity;
|
|
19
|
+
const listed = this.memory.listArtifactsByTopicPrefix(input.project, `sdd/${input.change}/`, this.context);
|
|
20
|
+
if (!listed.ok)
|
|
21
|
+
return listed;
|
|
22
|
+
const artifacts = listed.value.filter(isCanonicalArtifact).map(toPortableArtifact);
|
|
23
|
+
if (artifacts.length === 0)
|
|
24
|
+
return failure(`No SDD artifacts found for ${input.project}/${input.change}`);
|
|
25
|
+
const artifactPackage = { kind: artifactPackageKind, version: 1, project: input.project, change: input.change, exportedAt: new Date().toISOString(), artifacts };
|
|
26
|
+
return { ok: true, value: redactJsonValue(artifactPackage) };
|
|
27
|
+
}
|
|
28
|
+
validatePackage(input) {
|
|
29
|
+
if (!isRecord(input))
|
|
30
|
+
return failure('Package must be a JSON object');
|
|
31
|
+
if (input.kind !== artifactPackageKind)
|
|
32
|
+
return failure('Package kind is invalid');
|
|
33
|
+
if (input.version !== 1)
|
|
34
|
+
return failure('Package version is invalid');
|
|
35
|
+
if (typeof input.project !== 'string' || input.project.trim().length === 0)
|
|
36
|
+
return failure('Package project is required');
|
|
37
|
+
if (typeof input.change !== 'string' || !validChangePattern.test(input.change))
|
|
38
|
+
return failure('Package change is invalid');
|
|
39
|
+
if (typeof input.exportedAt !== 'string' || input.exportedAt.trim().length === 0)
|
|
40
|
+
return failure('Package exportedAt is required');
|
|
41
|
+
if (!Array.isArray(input.artifacts) || input.artifacts.length === 0)
|
|
42
|
+
return failure('Package artifacts must not be empty');
|
|
43
|
+
const artifacts = [];
|
|
44
|
+
for (const item of input.artifacts) {
|
|
45
|
+
if (!isRecord(item))
|
|
46
|
+
return failure('Artifact entry must be an object');
|
|
47
|
+
if (typeof item.phase !== 'string' || !isSddPhase(item.phase))
|
|
48
|
+
return failure('Artifact phase is invalid');
|
|
49
|
+
if (item.topicKey !== sddTopicKey(input.change, item.phase))
|
|
50
|
+
return failure(`Artifact topicKey must be ${sddTopicKey(input.change, item.phase)}`);
|
|
51
|
+
if (typeof item.content !== 'string' || item.content.trim().length === 0)
|
|
52
|
+
return failure('Artifact content must not be empty');
|
|
53
|
+
artifacts.push({ phase: item.phase, topicKey: item.topicKey, content: item.content, ...(typeof item.updatedAt === 'string' ? { updatedAt: item.updatedAt } : {}) });
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, value: { kind: artifactPackageKind, version: 1, project: input.project, change: input.change, exportedAt: input.exportedAt, artifacts } };
|
|
56
|
+
}
|
|
57
|
+
planImport(input) {
|
|
58
|
+
const validated = this.validateImport(input);
|
|
59
|
+
if (!validated.ok)
|
|
60
|
+
return validated;
|
|
61
|
+
const plan = { toCreate: [], toOverwrite: [], skipped: [], errors: [] };
|
|
62
|
+
for (const artifact of validated.value.artifacts) {
|
|
63
|
+
const existing = this.memory.getArtifact(input.project, artifact.topicKey, this.context);
|
|
64
|
+
if (existing.ok && input.overwrite === true)
|
|
65
|
+
plan.toOverwrite.push(artifact.topicKey);
|
|
66
|
+
else if (existing.ok) {
|
|
67
|
+
plan.skipped.push(artifact.topicKey);
|
|
68
|
+
plan.errors.push(`Artifact already exists: ${artifact.topicKey}`);
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
plan.toCreate.push(artifact.topicKey);
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: plan };
|
|
74
|
+
}
|
|
75
|
+
importPackage(input) {
|
|
76
|
+
const plan = this.planImport(input);
|
|
77
|
+
if (!plan.ok)
|
|
78
|
+
return plan;
|
|
79
|
+
if (plan.value.errors.length > 0)
|
|
80
|
+
return failure(plan.value.errors.join('; '));
|
|
81
|
+
const validated = this.validateImport(input);
|
|
82
|
+
if (!validated.ok)
|
|
83
|
+
return validated;
|
|
84
|
+
for (const artifact of validated.value.artifacts) {
|
|
85
|
+
const saved = this.workflow.saveArtifact({ project: input.project, change: input.change, phase: artifact.phase, content: artifact.content });
|
|
86
|
+
if (!saved.ok)
|
|
87
|
+
return saved;
|
|
88
|
+
}
|
|
89
|
+
return plan;
|
|
90
|
+
}
|
|
91
|
+
validateImport(input) {
|
|
92
|
+
const identity = validateIdentity(input.project, input.change);
|
|
93
|
+
if (!identity.ok)
|
|
94
|
+
return identity;
|
|
95
|
+
const parsed = this.validatePackage(input.package);
|
|
96
|
+
if (!parsed.ok)
|
|
97
|
+
return parsed;
|
|
98
|
+
if (parsed.value.project !== input.project || parsed.value.change !== input.change)
|
|
99
|
+
return failure('Package identity does not match import target');
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function isCanonicalArtifact(artifact) {
|
|
104
|
+
const phase = artifact.topicKey.split('/').at(-1);
|
|
105
|
+
return phase !== undefined && isSddPhase(phase) && artifact.topicKey === sddTopicKey(artifact.topicKey.split('/')[1] ?? '', phase);
|
|
106
|
+
}
|
|
107
|
+
function toPortableArtifact(artifact) {
|
|
108
|
+
return { phase: artifact.phase, topicKey: artifact.topicKey, content: artifact.content, updatedAt: artifact.updatedAt };
|
|
109
|
+
}
|
|
110
|
+
function validateIdentity(project, change) {
|
|
111
|
+
if (project.trim().length === 0)
|
|
112
|
+
return failure('Project must not be empty');
|
|
113
|
+
if (!validChangePattern.test(change))
|
|
114
|
+
return failure(`Invalid SDD change id: ${change}`);
|
|
115
|
+
return { ok: true, value: { project, change } };
|
|
116
|
+
}
|
|
117
|
+
function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
118
|
+
function failure(message) { return { ok: false, error: { code: 'validation_failed', message } }; }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const sddPhases = ['explore', 'proposal', 'spec', 'design', 'tasks', 'apply-progress', 'verify', 'archive'];
|
|
2
|
+
export const sddPrerequisites = {
|
|
3
|
+
explore: [],
|
|
4
|
+
proposal: ['explore'],
|
|
5
|
+
spec: ['proposal'],
|
|
6
|
+
design: ['proposal', 'spec'],
|
|
7
|
+
tasks: ['proposal', 'spec', 'design'],
|
|
8
|
+
'apply-progress': ['tasks'],
|
|
9
|
+
verify: ['apply-progress'],
|
|
10
|
+
archive: ['verify'],
|
|
11
|
+
};
|
|
12
|
+
export function isSddPhase(value) {
|
|
13
|
+
return sddPhases.includes(value);
|
|
14
|
+
}
|
|
15
|
+
export function sddTopicKey(change, phase) {
|
|
16
|
+
return `sdd/${change}/${phase}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { isSddPhase, sddPhases, sddPrerequisites, sddTopicKey } from './schema.js';
|
|
2
|
+
const defaultContext = { actor: 'sdd-workflow-service' };
|
|
3
|
+
const validChangePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
4
|
+
export class SddWorkflowService {
|
|
5
|
+
memory;
|
|
6
|
+
context;
|
|
7
|
+
constructor(memory, context = defaultContext) {
|
|
8
|
+
this.memory = memory;
|
|
9
|
+
this.context = context;
|
|
10
|
+
}
|
|
11
|
+
getWorkflow(change) {
|
|
12
|
+
return sddPhases.map((phase) => ({
|
|
13
|
+
phase,
|
|
14
|
+
topicKey: sddTopicKey(change, phase),
|
|
15
|
+
prerequisites: [...sddPrerequisites[phase]],
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
getReady(input) {
|
|
19
|
+
const validated = this.validatePhaseInput(input);
|
|
20
|
+
if (!validated.ok)
|
|
21
|
+
return validated;
|
|
22
|
+
const phases = this.getPhaseStatuses(validated.value.project, validated.value.change);
|
|
23
|
+
const readiness = getReadinessFromStatuses(validated.value.change, validated.value.phase, phases);
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
value: {
|
|
27
|
+
change: validated.value.change,
|
|
28
|
+
phase: validated.value.phase,
|
|
29
|
+
...readiness,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
getStatus(input) {
|
|
34
|
+
const validated = validateProjectAndChange(input.project, input.change);
|
|
35
|
+
if (!validated.ok)
|
|
36
|
+
return validated;
|
|
37
|
+
const phases = this.getPhaseStatuses(validated.value.project, validated.value.change);
|
|
38
|
+
return statusFromPhases(validated.value.change, phases);
|
|
39
|
+
}
|
|
40
|
+
getDashboardStatus(input) {
|
|
41
|
+
const validated = validateProjectAndChange(input.project, input.change);
|
|
42
|
+
if (!validated.ok)
|
|
43
|
+
return validated;
|
|
44
|
+
const phases = this.getPhaseStatusesNoTrace(validated.value.project, validated.value.change);
|
|
45
|
+
if (!phases.ok)
|
|
46
|
+
return phases;
|
|
47
|
+
return statusFromPhases(validated.value.change, phases.value);
|
|
48
|
+
}
|
|
49
|
+
getPhaseStatusesNoTrace(project, change) {
|
|
50
|
+
const statuses = [];
|
|
51
|
+
for (const phase of sddPhases) {
|
|
52
|
+
const topicKey = sddTopicKey(change, phase);
|
|
53
|
+
const present = this.memory.hasArtifactNoTrace(project, topicKey);
|
|
54
|
+
if (!present.ok)
|
|
55
|
+
return present;
|
|
56
|
+
statuses.push({ phase, topicKey, present: present.value });
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, value: statuses };
|
|
59
|
+
}
|
|
60
|
+
getNext(input) {
|
|
61
|
+
const validated = validateProjectAndChange(input.project, input.change);
|
|
62
|
+
if (!validated.ok)
|
|
63
|
+
return validated;
|
|
64
|
+
const phases = this.getPhaseStatuses(validated.value.project, validated.value.change);
|
|
65
|
+
const blockedPresentPhase = phases.find((status) => {
|
|
66
|
+
if (!status.present)
|
|
67
|
+
return false;
|
|
68
|
+
return getReadinessFromStatuses(validated.value.change, status.phase, phases).missingArtifactTopicKeys.length > 0;
|
|
69
|
+
});
|
|
70
|
+
if (blockedPresentPhase !== undefined) {
|
|
71
|
+
const readiness = getReadinessFromStatuses(validated.value.change, blockedPresentPhase.phase, phases);
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
value: {
|
|
75
|
+
change: validated.value.change,
|
|
76
|
+
status: 'blocked',
|
|
77
|
+
nextPhase: blockedPresentPhase.phase,
|
|
78
|
+
missingArtifactTopicKeys: readiness.missingArtifactTopicKeys,
|
|
79
|
+
reason: `${blockedPresentPhase.phase} is blocked by missing prerequisite artifacts.`,
|
|
80
|
+
recommendedAction: `Create or restore ${readiness.missingArtifactTopicKeys.join(', ')} before continuing.`,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const nextMissingPhase = phases.find((status) => !status.present)?.phase;
|
|
85
|
+
if (nextMissingPhase === undefined) {
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
value: {
|
|
89
|
+
change: validated.value.change,
|
|
90
|
+
status: 'complete',
|
|
91
|
+
missingArtifactTopicKeys: [],
|
|
92
|
+
reason: 'All canonical SDD phases are complete.',
|
|
93
|
+
recommendedAction: 'No next SDD phase remains for this change.',
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const readiness = getReadinessFromStatuses(validated.value.change, nextMissingPhase, phases);
|
|
98
|
+
if (!readiness.ready) {
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
value: {
|
|
102
|
+
change: validated.value.change,
|
|
103
|
+
status: 'blocked',
|
|
104
|
+
nextPhase: nextMissingPhase,
|
|
105
|
+
missingArtifactTopicKeys: readiness.missingArtifactTopicKeys,
|
|
106
|
+
reason: `${nextMissingPhase} is blocked by missing prerequisite artifacts.`,
|
|
107
|
+
recommendedAction: `Create or restore ${readiness.missingArtifactTopicKeys.join(', ')} before running ${nextMissingPhase}.`,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
value: {
|
|
114
|
+
change: validated.value.change,
|
|
115
|
+
status: 'runnable',
|
|
116
|
+
nextPhase: nextMissingPhase,
|
|
117
|
+
missingArtifactTopicKeys: [],
|
|
118
|
+
reason: `${nextMissingPhase} is ready to run.`,
|
|
119
|
+
recommendedAction: `Run the ${nextMissingPhase} SDD phase for ${validated.value.change}.`,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
saveArtifact(input) {
|
|
124
|
+
const validated = this.validatePhaseInput(input);
|
|
125
|
+
if (!validated.ok)
|
|
126
|
+
return validated;
|
|
127
|
+
if (input.content.trim().length === 0)
|
|
128
|
+
return validationFailure('SDD artifact content must not be empty');
|
|
129
|
+
return this.memory.saveArtifact({
|
|
130
|
+
project: validated.value.project,
|
|
131
|
+
topicKey: sddTopicKey(validated.value.change, validated.value.phase),
|
|
132
|
+
phase: validated.value.phase,
|
|
133
|
+
content: input.content,
|
|
134
|
+
}, this.context);
|
|
135
|
+
}
|
|
136
|
+
getArtifact(input) {
|
|
137
|
+
const validated = this.validatePhaseInput(input);
|
|
138
|
+
if (!validated.ok)
|
|
139
|
+
return validated;
|
|
140
|
+
return this.memory.getArtifact(validated.value.project, sddTopicKey(validated.value.change, validated.value.phase), this.context);
|
|
141
|
+
}
|
|
142
|
+
listArtifacts(input) {
|
|
143
|
+
const validated = validateProjectAndChange(input.project, input.change);
|
|
144
|
+
if (!validated.ok)
|
|
145
|
+
return validated;
|
|
146
|
+
const listed = this.memory.listArtifactsByTopicPrefix(validated.value.project, `sdd/${validated.value.change}/`, this.context);
|
|
147
|
+
if (!listed.ok)
|
|
148
|
+
return listed;
|
|
149
|
+
const artifactsByTopicKey = new Map(listed.value.map((artifact) => [artifact.topicKey, artifact]));
|
|
150
|
+
const artifacts = sddPhases
|
|
151
|
+
.map((phase) => artifactsByTopicKey.get(sddTopicKey(validated.value.change, phase)))
|
|
152
|
+
.filter((artifact) => artifact !== undefined);
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
value: {
|
|
156
|
+
project: validated.value.project,
|
|
157
|
+
change: validated.value.change,
|
|
158
|
+
artifacts,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
validatePhaseInput(input) {
|
|
163
|
+
const validated = validateProjectAndChange(input.project, input.change);
|
|
164
|
+
if (!validated.ok)
|
|
165
|
+
return validated;
|
|
166
|
+
if (!isSddPhase(input.phase))
|
|
167
|
+
return validationFailure(`Unknown SDD phase: ${input.phase}`);
|
|
168
|
+
return { ok: true, value: { ...validated.value, phase: input.phase } };
|
|
169
|
+
}
|
|
170
|
+
getPhaseStatuses(project, change) {
|
|
171
|
+
return sddPhases.map((phase) => {
|
|
172
|
+
const topicKey = sddTopicKey(change, phase);
|
|
173
|
+
return {
|
|
174
|
+
phase,
|
|
175
|
+
topicKey,
|
|
176
|
+
present: this.memory.getArtifact(project, topicKey, this.context).ok,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function statusFromPhases(change, phases) {
|
|
182
|
+
const nextReadyPhase = sddPhases.find((phase) => {
|
|
183
|
+
if (phases.find((status) => status.phase === phase)?.present)
|
|
184
|
+
return false;
|
|
185
|
+
return getReadinessFromStatuses(change, phase, phases).ready;
|
|
186
|
+
});
|
|
187
|
+
const status = { change, phases };
|
|
188
|
+
if (nextReadyPhase !== undefined)
|
|
189
|
+
status.nextReadyPhase = nextReadyPhase;
|
|
190
|
+
return { ok: true, value: status };
|
|
191
|
+
}
|
|
192
|
+
function getReadinessFromStatuses(change, phase, phases) {
|
|
193
|
+
const satisfiedPrerequisites = [];
|
|
194
|
+
const missingArtifactTopicKeys = [];
|
|
195
|
+
for (const prerequisite of sddPrerequisites[phase]) {
|
|
196
|
+
const status = phases.find((candidate) => candidate.phase === prerequisite);
|
|
197
|
+
if (status?.present)
|
|
198
|
+
satisfiedPrerequisites.push(prerequisite);
|
|
199
|
+
else
|
|
200
|
+
missingArtifactTopicKeys.push(sddTopicKey(change, prerequisite));
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
ready: missingArtifactTopicKeys.length === 0,
|
|
204
|
+
satisfiedPrerequisites,
|
|
205
|
+
missingArtifactTopicKeys,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function validateProjectAndChange(project, change) {
|
|
209
|
+
if (project.trim().length === 0)
|
|
210
|
+
return validationFailure('Project must not be empty');
|
|
211
|
+
if (!validChangePattern.test(change))
|
|
212
|
+
return validationFailure(`Invalid SDD change id: ${change}`);
|
|
213
|
+
return { ok: true, value: { project, change } };
|
|
214
|
+
}
|
|
215
|
+
function validationFailure(message) {
|
|
216
|
+
return { ok: false, error: { code: 'validation_failed', message } };
|
|
217
|
+
}
|