@hecer/yoke 1.18.0 → 1.20.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +86 -910
- package/bench/RESULTS.md +30 -0
- package/bench/analyze-codex-comparison.mjs +22 -0
- package/bench/compare-codex.mjs +71 -0
- package/bench/fixtures/independent-utils/.yoke/prd.yaml +30 -0
- package/bench/fixtures/independent-utils/bench-verify.mjs +4 -0
- package/bench/fixtures/independent-utils/package.json +1 -0
- package/bench/fixtures/independent-utils/src/chunk.mjs +1 -0
- package/bench/fixtures/independent-utils/src/range.mjs +1 -0
- package/bench/fixtures/independent-utils/src/unique.mjs +1 -0
- package/bench/fixtures/independent-utils/tests/STORY-1.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-2.test.mjs +8 -0
- package/bench/fixtures/independent-utils/tests/STORY-3.test.mjs +8 -0
- package/bench/probe-goal-integration.mjs +93 -0
- package/bench/probe-parallel-gate-context.mjs +29 -0
- package/bench/results/codex-comparison-2026-09-29.json +305 -0
- package/bench/results/goal-integration-probes-2026-09-29.json +107 -0
- package/bench/results/native-goal-validation-2026-09-30.json +122 -0
- package/bench/results/parallel-validation-2026-09-30.json +86 -0
- package/canon/manifest.yaml +1 -1
- package/dist/agents/process-record-identity.js +33 -0
- package/dist/agents/process.js +42 -11
- package/dist/agents/providers.js +11 -2
- package/dist/agents/sol-pi-runtime.js +76 -0
- package/dist/check/command.js +218 -0
- package/dist/cli.js +11 -7
- package/dist/code-intelligence/adapters/graphify.js +1 -1
- package/dist/code-intelligence/adapters/mcp.js +25 -4
- package/dist/code-intelligence/adapters/serena.js +1 -1
- package/dist/code-intelligence/coordinator.js +2 -1
- package/dist/code-intelligence/evidence.js +12 -1
- package/dist/code-intelligence/mcp-client.js +2 -2
- package/dist/dashboard/page.js +1 -1
- package/dist/dashboard/panels.js +25 -3
- package/dist/dashboard/server.js +88 -7
- package/dist/goals/codex-native.js +340 -0
- package/dist/goals/command.js +230 -29
- package/dist/loop/cleanup.js +10 -1
- package/dist/loop/dispatcher.js +82 -10
- package/dist/loop/git.js +1 -1
- package/dist/loop/parallel-adapters.js +53 -7
- package/dist/loop/parallel-command.js +8 -1
- package/dist/loop/recovery.js +81 -2
- package/dist/loop/reporter.js +3 -0
- package/dist/loop/resource-pool.js +21 -15
- package/dist/loop/run-command.js +91 -25
- package/dist/loop/run-state.js +116 -0
- package/dist/prd/explore.js +12 -4
- package/dist/retrofit/config.js +29 -0
- package/dist/retrofit/gitignore.js +3 -0
- package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
- package/docs/CONTINUOUS-EXPLORATION.md +6 -2
- package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
- package/docs/GOALS.md +109 -0
- package/docs/HARNESSES.md +1 -1
- package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
- package/docs/SOL-PI.md +41 -0
- package/docs/VERIFIED-PROJECTS.md +4 -4
- package/docs/code-intelligence-stdio-fix-2026-09-27.md +8 -0
- package/docs/parallel-execution.md +15 -0
- package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
- package/gemini-extension.json +1 -1
- package/package.json +5 -1
package/dist/check/command.js
CHANGED
|
@@ -8,11 +8,159 @@ import { defaultConfig, loadConfig, resolveVerifyCommand } from '../retrofit/con
|
|
|
8
8
|
import { commandVerifier } from '../loop/verify.js';
|
|
9
9
|
import { workspaceFingerprint } from '../workspace/fingerprint.js';
|
|
10
10
|
import { statePath } from '../workspace/state.js';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { acquireSharedWorker } from '../loop/resource-pool.js';
|
|
13
|
+
import { compactCommandOutput } from '../output/compact.js';
|
|
14
|
+
import { writeOutputArtifact } from '../output/artifact.js';
|
|
15
|
+
import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
|
|
16
|
+
import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter } from '../agents/process-record.js';
|
|
17
|
+
import { trackProcessRecordIdentity } from '../agents/process-record-identity.js';
|
|
11
18
|
const Criterion = z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), commands: z.array(z.string().min(1).max(8000)).max(30) }).strict();
|
|
12
19
|
const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]) }).strict().superRefine((value, ctx) => {
|
|
13
20
|
if (new Set(value.criteria.map(c => c.id)).size !== value.criteria.length)
|
|
14
21
|
ctx.addIssue({ code: 'custom', message: 'Duplicate acceptance criterion id' });
|
|
15
22
|
});
|
|
23
|
+
export async function checkProjectAsync(directory, options = {}) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const abort = () => controller.abort(options.signal?.reason ?? 'Verification cancelled');
|
|
26
|
+
if (options.signal?.aborted)
|
|
27
|
+
abort();
|
|
28
|
+
else
|
|
29
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
30
|
+
const remaining = options.deadline === undefined ? undefined : options.deadline - Date.now();
|
|
31
|
+
if (remaining !== undefined && remaining <= 0)
|
|
32
|
+
controller.abort('Verification deadline reached');
|
|
33
|
+
const timer = remaining === undefined || remaining <= 0 ? undefined : setTimeout(() => controller.abort('Verification deadline reached'), remaining);
|
|
34
|
+
let lease;
|
|
35
|
+
let report;
|
|
36
|
+
let cleanupUnconfirmed = false;
|
|
37
|
+
try {
|
|
38
|
+
try {
|
|
39
|
+
lease = await acquireSharedWorker({ targetDir: directory, storyId: 'project-check', provider: 'codex', role: 'integration', resource: 'check', signal: controller.signal });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (!controller.signal.aborted)
|
|
43
|
+
throw error;
|
|
44
|
+
// Persist failed evidence even when cancellation happens while queued.
|
|
45
|
+
}
|
|
46
|
+
if (options.deadline !== undefined && Date.now() >= options.deadline)
|
|
47
|
+
controller.abort('Verification deadline reached');
|
|
48
|
+
report = await checkProjectAsyncAdmitted(directory, { ...options, signal: controller.signal, onCleanupUnconfirmed: confirmed => { cleanupUnconfirmed = true; if (confirmed)
|
|
49
|
+
void confirmed.then(() => lease?.release()).catch(() => { }); } });
|
|
50
|
+
return report;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (timer)
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
options.signal?.removeEventListener('abort', abort);
|
|
56
|
+
if (!cleanupUnconfirmed)
|
|
57
|
+
await lease?.release();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function verifyCommandAsync(command, root, signal) {
|
|
61
|
+
if (signal?.aborted)
|
|
62
|
+
return Promise.resolve({ passed: false, summary: 'Verification cancelled or deadline reached' });
|
|
63
|
+
return new Promise(resolveResult => {
|
|
64
|
+
const child = spawn(command, { cwd: root, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
65
|
+
const record = child.pid ? createProviderProcessRecord(realpathSync(root), child.pid, 'verification', `unverified:${new Date().toISOString()}`) : undefined;
|
|
66
|
+
let publishFailed = false;
|
|
67
|
+
if (record) {
|
|
68
|
+
try {
|
|
69
|
+
filesystemProviderProcessRecordAdapter.publish(record);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
publishFailed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const cancelIdentity = trackProcessRecordIdentity(record, child);
|
|
76
|
+
let stdout = '', stderr = '', captured = 0, stopped = '', settled = false, exited = false, treeStopped = process.platform !== 'win32';
|
|
77
|
+
let resolveConfirmed = () => { };
|
|
78
|
+
const cleanupConfirmed = new Promise(resolveValue => { resolveConfirmed = resolveValue; });
|
|
79
|
+
const confirm = () => { if (exited && (!stopped || treeStopped)) {
|
|
80
|
+
cancelIdentity();
|
|
81
|
+
if (record)
|
|
82
|
+
filesystemProviderProcessRecordAdapter.remove(record.path);
|
|
83
|
+
resolveConfirmed();
|
|
84
|
+
} };
|
|
85
|
+
const finish = (passed, reason = '', cleanupUnconfirmed = false) => {
|
|
86
|
+
if (settled)
|
|
87
|
+
return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
if (cleanupTimer)
|
|
91
|
+
clearTimeout(cleanupTimer);
|
|
92
|
+
signal?.removeEventListener('abort', abort);
|
|
93
|
+
cancelIdentity();
|
|
94
|
+
if (record && exited && !cleanupUnconfirmed)
|
|
95
|
+
filesystemProviderProcessRecordAdapter.remove(record.path);
|
|
96
|
+
if (passed) {
|
|
97
|
+
resolveResult({ passed: true, summary: `verify passed: ${command}` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const raw = `=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}`;
|
|
101
|
+
const compacted = compactCommandOutput(raw, { previewBytes: DEFAULT_OUTPUT_POLICY.previewBytes });
|
|
102
|
+
const parts = [`verify failed: ${command}${reason ? ` (${reason})` : ''}`, compacted.preview];
|
|
103
|
+
if (compacted.originalBytes > DEFAULT_OUTPUT_POLICY.artifactThresholdBytes) {
|
|
104
|
+
try {
|
|
105
|
+
parts.push(writeOutputArtifact(root, raw, { phase: 'verify', storyId: process.env.YOKE_STORY }).marker);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
parts.push('[artifact unavailable]');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
resolveResult({ passed: false, summary: parts.filter(Boolean).join('\n'), ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true, cleanupConfirmed } : {}) });
|
|
112
|
+
};
|
|
113
|
+
let cleanupTimer;
|
|
114
|
+
const stop = (reason) => {
|
|
115
|
+
if (stopped || settled)
|
|
116
|
+
return;
|
|
117
|
+
stopped = reason;
|
|
118
|
+
if (child.pid) {
|
|
119
|
+
if (process.platform === 'win32') {
|
|
120
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
121
|
+
killer.on('error', () => { });
|
|
122
|
+
killer.on('close', code => { treeStopped = code === 0; confirm(); if (exited && treeStopped)
|
|
123
|
+
finish(false, stopped); });
|
|
124
|
+
killer.unref();
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
child.kill('SIGKILL');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (process.platform !== 'win32')
|
|
136
|
+
child.kill('SIGKILL');
|
|
137
|
+
cleanupTimer = setTimeout(() => { if (process.platform !== 'win32')
|
|
138
|
+
child.kill('SIGKILL'); finish(false, `${stopped}; process cleanup could not be confirmed`, !exited || !treeStopped); }, 4000);
|
|
139
|
+
};
|
|
140
|
+
const abort = () => stop('cancelled or deadline reached');
|
|
141
|
+
const timer = setTimeout(() => stop('timed out'), 600_000);
|
|
142
|
+
const append = (stream, data) => {
|
|
143
|
+
const room = Math.max(0, 16 * 1024 * 1024 - captured);
|
|
144
|
+
captured += data.length;
|
|
145
|
+
if (stream === 'stdout')
|
|
146
|
+
stdout += data.subarray(0, room).toString('utf8');
|
|
147
|
+
else
|
|
148
|
+
stderr += data.subarray(0, room).toString('utf8');
|
|
149
|
+
if (captured > 16 * 1024 * 1024)
|
|
150
|
+
stop('capture limit exceeded');
|
|
151
|
+
};
|
|
152
|
+
child.stdout.on('data', data => append('stdout', data));
|
|
153
|
+
child.stderr.on('data', data => append('stderr', data));
|
|
154
|
+
child.on('error', () => finish(false, 'command could not start'));
|
|
155
|
+
child.on('close', code => { exited = true; confirm(); if (!stopped || treeStopped)
|
|
156
|
+
finish(!stopped && code === 0, stopped); });
|
|
157
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
158
|
+
if (signal?.aborted)
|
|
159
|
+
abort();
|
|
160
|
+
if (publishFailed)
|
|
161
|
+
stop('process ownership record could not be published');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
16
164
|
export function loadAcceptance(root) {
|
|
17
165
|
const file = statePath(root, 'acceptance.yaml');
|
|
18
166
|
return existsSync(file) ? Acceptance.parse(parse(readFileSync(file, 'utf8'))) : null;
|
|
@@ -112,3 +260,73 @@ export function checkProject(directory, options = {}) {
|
|
|
112
260
|
return report;
|
|
113
261
|
}
|
|
114
262
|
export function checkExitCode(report) { return report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2; }
|
|
263
|
+
async function checkProjectAsyncAdmitted(directory, options) {
|
|
264
|
+
const root = realpathSync(directory);
|
|
265
|
+
const started = Date.now();
|
|
266
|
+
const before = workspaceFingerprint(root);
|
|
267
|
+
const problem = acceptanceProtectionProblem(root);
|
|
268
|
+
const criteria = [];
|
|
269
|
+
let cleanupUnconfirmed = false;
|
|
270
|
+
const rawExecute = options.execute ?? ((command, cwd) => verifyCommandAsync(command, cwd, options.signal));
|
|
271
|
+
const execute = async (command, cwd, signal) => {
|
|
272
|
+
if (cleanupUnconfirmed)
|
|
273
|
+
return { passed: false, summary: 'Previous verification cleanup unconfirmed; no further commands admitted', cleanupUnconfirmed: true };
|
|
274
|
+
const result = await rawExecute(command, cwd, signal);
|
|
275
|
+
if (result.cleanupUnconfirmed) {
|
|
276
|
+
cleanupUnconfirmed = true;
|
|
277
|
+
options.onCleanupUnconfirmed?.(result.cleanupConfirmed);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
};
|
|
281
|
+
if (problem)
|
|
282
|
+
criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
|
|
283
|
+
else {
|
|
284
|
+
const manifest = loadAcceptance(root);
|
|
285
|
+
for (const criterion of manifest?.criteria ?? []) {
|
|
286
|
+
const results = [];
|
|
287
|
+
for (const command of criterion.commands) {
|
|
288
|
+
results.push(await (async () => {
|
|
289
|
+
try {
|
|
290
|
+
if (options.signal?.aborted)
|
|
291
|
+
throw new Error('Verification cancelled or deadline reached');
|
|
292
|
+
return await execute(command, root, options.signal);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return { passed: false, summary: error.message };
|
|
296
|
+
}
|
|
297
|
+
})());
|
|
298
|
+
}
|
|
299
|
+
criteria.push({ ...criterion, status: results.length === 0 ? 'unverified' : results.every(r => r.passed) ? 'passed' : 'failed', summary: results.map(r => r.summary).join('\n') || 'No executable acceptance mapped' });
|
|
300
|
+
}
|
|
301
|
+
const command = resolveVerifyCommand(root, loadConfig(root) ?? defaultConfig('1.6.2'));
|
|
302
|
+
if (command) {
|
|
303
|
+
let result;
|
|
304
|
+
try {
|
|
305
|
+
if (options.signal?.aborted)
|
|
306
|
+
throw new Error('Verification cancelled or deadline reached');
|
|
307
|
+
result = await execute(command, root, options.signal);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
result = { passed: false, summary: error.message };
|
|
311
|
+
}
|
|
312
|
+
criteria.push({ id: 'project-suite', text: 'Configured project verification', commands: [command], status: result.passed ? 'passed' : 'failed', summary: result.summary });
|
|
313
|
+
}
|
|
314
|
+
if (options.requirement)
|
|
315
|
+
criteria.push({ id: 'requested-outcome', text: options.requirement, commands: [], status: 'unverified', summary: 'Map this outcome to executable criteria in .yoke/acceptance.yaml; a green suite alone is not proof of this requirement.' });
|
|
316
|
+
if (criteria.length === 0)
|
|
317
|
+
criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
|
|
318
|
+
}
|
|
319
|
+
const changed = workspaceFingerprint(root) !== before;
|
|
320
|
+
const afterProblem = acceptanceProtectionProblem(root);
|
|
321
|
+
if (changed || afterProblem)
|
|
322
|
+
criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
|
|
323
|
+
if (options.signal?.aborted)
|
|
324
|
+
criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: 'Verification cancelled or deadline reached' });
|
|
325
|
+
const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
|
|
326
|
+
const id = randomUUID();
|
|
327
|
+
const evidencePath = statePath(root, 'checks', `${id}.json`);
|
|
328
|
+
const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
|
|
329
|
+
mkdirSync(dirname(evidencePath), { recursive: true });
|
|
330
|
+
writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
331
|
+
return report;
|
|
332
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { realpathSync } from 'node:fs';
|
|
|
5
5
|
import { checkProject, checkExitCode, protectAcceptance } from './check/command.js';
|
|
6
6
|
import { registerProject, listProjects, unregisterProject } from './dashboard/registry.js';
|
|
7
7
|
import { startDashboard } from './dashboard/server.js';
|
|
8
|
-
import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal } from './goals/command.js';
|
|
8
|
+
import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal, bindProjectGoal } from './goals/command.js';
|
|
9
9
|
import { validateCanon } from './canon/validate.js';
|
|
10
10
|
import { runRetrofit } from './retrofit/command.js';
|
|
11
11
|
import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
|
|
@@ -250,7 +250,7 @@ export function main(argv) {
|
|
|
250
250
|
const objective = value('objective');
|
|
251
251
|
if (!objective)
|
|
252
252
|
throw new Error('Use goal set [dir] --objective="..." with executable .yoke/acceptance.yaml criteria');
|
|
253
|
-
const goal = createProjectGoal(targetDir, objective, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined });
|
|
253
|
+
const goal = createProjectGoal(targetDir, objective, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, maxWallMinutes: value('wall-minutes') ? Number(value('wall-minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, acceptanceIds: value('criteria')?.split(',') });
|
|
254
254
|
console.log(JSON.stringify(goal));
|
|
255
255
|
return 0;
|
|
256
256
|
}
|
|
@@ -258,8 +258,12 @@ export function main(argv) {
|
|
|
258
258
|
console.log(JSON.stringify(readProjectGoal(targetDir)));
|
|
259
259
|
return 0;
|
|
260
260
|
}
|
|
261
|
+
if (sub === 'bind') {
|
|
262
|
+
console.log(JSON.stringify(bindProjectGoal(targetDir, value('criteria')?.split(',') ?? [])));
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
261
265
|
if (sub === 'budget') {
|
|
262
|
-
console.log(JSON.stringify(budgetProjectGoal(targetDir, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, clearTokenBudget: rest.includes('--clear-token-budget') })));
|
|
266
|
+
console.log(JSON.stringify(budgetProjectGoal(targetDir, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, maxWallMinutes: value('wall-minutes') ? Number(value('wall-minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, clearTokenBudget: rest.includes('--clear-token-budget') })));
|
|
263
267
|
return 0;
|
|
264
268
|
}
|
|
265
269
|
if (sub === 'handoff') {
|
|
@@ -272,15 +276,15 @@ export function main(argv) {
|
|
|
272
276
|
return 0;
|
|
273
277
|
}
|
|
274
278
|
if (sub === 'run' || sub === 'resume') {
|
|
275
|
-
const provider = value('runner')
|
|
276
|
-
if (!SUPPORTED_AGENTS.includes(provider))
|
|
279
|
+
const provider = value('runner');
|
|
280
|
+
if (provider && !SUPPORTED_AGENTS.includes(provider))
|
|
277
281
|
throw new Error('Unknown runner');
|
|
278
|
-
return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model') } }).then(goal => {
|
|
282
|
+
return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model'), reasoningEffort: value('effort'), bare: rest.includes('--bare') ? true : undefined }, native: rest.includes('--native-goal') ? true : rest.includes('--no-native-goal') ? false : undefined }).then(goal => {
|
|
279
283
|
console.log(JSON.stringify(goal));
|
|
280
284
|
return goal.status === 'complete' ? 0 : 1;
|
|
281
285
|
}).catch(error => { console.error(`Goal: ${error.message}`); return 2; });
|
|
282
286
|
}
|
|
283
|
-
throw new Error('Use goal set|status|run|resume|pause|handoff');
|
|
287
|
+
throw new Error('Use goal set|bind|status|run|resume|pause|handoff|budget');
|
|
284
288
|
}
|
|
285
289
|
catch (error) {
|
|
286
290
|
console.error(`Goal: ${error.message}`);
|
|
@@ -2,7 +2,7 @@ import { McpBackendAdapter } from './mcp.js';
|
|
|
2
2
|
export const GRAPHIFY_VERSION = '0.9.56';
|
|
3
3
|
export function createGraphifyAdapter(cwd, override) {
|
|
4
4
|
return new McpBackendAdapter({
|
|
5
|
-
name: 'graphify', version: GRAPHIFY_VERSION, cwd, command: override?.command ?? 'python', args: override?.args ?? ['-m', 'graphify.serve'], framing: '
|
|
5
|
+
name: 'graphify', version: GRAPHIFY_VERSION, cwd, command: override?.command ?? 'python', args: override?.args ?? ['-m', 'graphify.serve'], framing: 'line', semantic: false, documents: true,
|
|
6
6
|
aliases: { context: 'query_graph', symbol: 'get_node', neighbors: 'get_neighbors', trace: 'shortest_path', impact: 'get_pr_impact' },
|
|
7
7
|
});
|
|
8
8
|
}
|
|
@@ -2,16 +2,37 @@ import { McpStdioClient } from '../mcp-client.js';
|
|
|
2
2
|
function valueFromResult(value) {
|
|
3
3
|
if (!value || typeof value !== 'object')
|
|
4
4
|
return value;
|
|
5
|
-
if (value.
|
|
6
|
-
|
|
5
|
+
if (value.isError === true)
|
|
6
|
+
throw new Error('MCP backend tool returned an error');
|
|
7
|
+
if (value.structuredContent !== undefined) {
|
|
8
|
+
const structured = value.structuredContent;
|
|
9
|
+
if (structured && typeof structured === 'object' && Object.keys(structured).length === 1 && typeof structured.result === 'string') {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(structured.result);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return structured.result;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return structured;
|
|
18
|
+
}
|
|
7
19
|
const text = Array.isArray(value.content) ? value.content.find((item) => item?.type === 'text')?.text : undefined;
|
|
8
20
|
if (typeof text !== 'string')
|
|
9
21
|
return value;
|
|
10
22
|
try {
|
|
11
|
-
|
|
23
|
+
const parsed = JSON.parse(text);
|
|
24
|
+
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length === 1 && typeof parsed.result === 'string') {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(parsed.result);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return parsed.result;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
12
33
|
}
|
|
13
34
|
catch {
|
|
14
|
-
return
|
|
35
|
+
return text;
|
|
15
36
|
}
|
|
16
37
|
}
|
|
17
38
|
export class McpBackendAdapter {
|
|
@@ -2,7 +2,7 @@ import { McpBackendAdapter } from './mcp.js';
|
|
|
2
2
|
export const SERENA_VERSION = '1.7.1-dev';
|
|
3
3
|
export function createSerenaAdapter(cwd, override) {
|
|
4
4
|
return new McpBackendAdapter({
|
|
5
|
-
name: 'serena-lsp', version: SERENA_VERSION, cwd, command: override?.command ?? 'serena', args: override?.args ?? ['start-mcp-server', '--project-from-cwd', '--context', 'codex'], framing: '
|
|
5
|
+
name: 'serena-lsp', version: SERENA_VERSION, cwd, command: override?.command ?? 'serena', args: override?.args ?? ['start-mcp-server', '--project-from-cwd', '--context', 'codex'], framing: 'line', semantic: true, documents: false,
|
|
6
6
|
aliases: {
|
|
7
7
|
symbol: 'find_symbol', references: 'find_referencing_symbols', implementations: 'find_implementations', overview: 'get_symbols_overview', diagnostics: 'get_diagnostics_for_file',
|
|
8
8
|
rename: 'rename_symbol', replace_symbol_body: 'replace_symbol_body', insert_before_symbol: 'insert_before_symbol', insert_after_symbol: 'insert_after_symbol', safe_delete: 'safe_delete_symbol',
|
|
@@ -185,9 +185,10 @@ export class CodeIntelligenceCoordinator {
|
|
|
185
185
|
const name = input.symbol_id ?? input.query;
|
|
186
186
|
const reference = symbolArgs(name, input.path ?? '');
|
|
187
187
|
const graft = await this.call('graft', 'context', { query: name, limit: 10, ...(input.path ? { in: input.path } : {}) }, input.timeout_ms, state);
|
|
188
|
+
// Graft returns search prose, not structured symbol definitions. Preserve
|
|
189
|
+
// its provenance without inventing a symbol from the response heading.
|
|
188
190
|
if (graft !== null) {
|
|
189
191
|
const normalized = normalizeItems(graft, this.evidenceOptions(snapshot, 'graft'), 'symbol', 20);
|
|
190
|
-
symbols.push(...normalized.items.map(item => ({ symbol_id: `graft:${item.item_id}`, name: item.excerpt.split(/\s|\(|\{/u)[0] ?? name, path: item.path, kind: 'unknown', signature: item.excerpt.slice(0, 500), evidence_ids: item.evidence_ids })));
|
|
191
192
|
provenance.push(...normalized.provenance);
|
|
192
193
|
}
|
|
193
194
|
const symbol = await this.call('serena-lsp', 'symbol', { name_path_pattern: reference.name_path, depth: 0, relative_path: reference.relative_path, include_body: false, max_matches: 20 }, input.timeout_ms, state);
|
|
@@ -12,7 +12,7 @@ function textOf(raw) {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
function locate(text, root) {
|
|
15
|
-
const match = /(?:^|[\s("'`])((?:[A-Za-z0-9_.@-]+[\\/])*[A-Za-z0-9_.@-]+\.[A-Za-z0-9]+):(\d+)/u.exec(text);
|
|
15
|
+
const match = /(?:^|[\s("'`])((?:[A-Za-z0-9_.@-]+[\\/])*[A-Za-z0-9_.@-]+\.[A-Za-z0-9]+):L?(\d+)/u.exec(text);
|
|
16
16
|
if (!match)
|
|
17
17
|
return { path: null, line: null };
|
|
18
18
|
try {
|
|
@@ -44,6 +44,17 @@ export function normalizeItems(raw, options, kind = 'code', limit = 100) {
|
|
|
44
44
|
if (!excerpt)
|
|
45
45
|
continue;
|
|
46
46
|
const p = makeProvenance(options, excerpt, excerpt.toLowerCase().includes('ambiguous') ? 'ambiguous' : 'resolved');
|
|
47
|
+
if (candidate && typeof candidate === 'object' && typeof candidate.relative_path === 'string') {
|
|
48
|
+
try {
|
|
49
|
+
p.source_path = assertSafePath(options.root, candidate.relative_path);
|
|
50
|
+
p.content_hash = options.snapshot.files.find(file => file.path === p.source_path)?.hash ?? null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
p.source_path = null;
|
|
54
|
+
p.content_hash = null;
|
|
55
|
+
p.resolution = 'unresolved';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
47
58
|
const id = evidenceId(p, excerpt);
|
|
48
59
|
provenance.push(p);
|
|
49
60
|
items.push({ item_id: `item-${hash(excerpt).slice(0, 24)}`, kind, path: p.source_path, excerpt, evidence_ids: [id], rank: items.length });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
/** Small, bounded stdio MCP client.
|
|
3
|
-
* Content-Length framing
|
|
2
|
+
/** Small, bounded stdio MCP client. Supports newline JSON-RPC used by the
|
|
3
|
+
* backend SDKs and optional Content-Length framing for legacy peers. */
|
|
4
4
|
export class McpStdioClient {
|
|
5
5
|
command;
|
|
6
6
|
args;
|
package/dist/dashboard/page.js
CHANGED
|
@@ -33,7 +33,7 @@ function validDate(value){if(!/^\\d{4}-\\d{2}-\\d{2}$/u.test(value))return false
|
|
|
33
33
|
function parseNavigationHash(hash){
|
|
34
34
|
const params=new URLSearchParams(String(hash||'').replace(/^#/u,''));const screen=params.get('screen')||'overview',project=params.get('project'),view=params.get('view')||'now',period=Number(params.get('period')||30),group=params.get('group')||'day',from=params.get('from')||'',to=params.get('to')||'',query=params.get('query')||'',status=params.get('status')||'all',sort=params.get('sort')||'attention';
|
|
35
35
|
const datesValid=(!from&&!to)||(validDate(from)&&validDate(to)&&from<=to&&Date.parse(to+'T00:00:00Z')-Date.parse(from+'T00:00:00Z')<=365*86400000);
|
|
36
|
-
if(!['overview','workspace','project'].includes(screen)||!['now','usage','results','history'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!['all','active','attention'].includes(status)||!['attention','activity','tokens','cost','acceptance','name'].includes(sort)||query.length>200||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
|
|
36
|
+
if(!['overview','workspace','project'].includes(screen)||!['now','usage','results','history','settings'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!['all','active','attention'].includes(status)||!['attention','activity','tokens','cost','acceptance','name'].includes(sort)||query.length>200||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
|
|
37
37
|
const state={screen,project:screen==='project'?project:null,view,period,group,from,to};if(params.has('query'))state.query=query;if(params.has('status'))state.status=status;if(params.has('sort'))state.sort=sort;return state;
|
|
38
38
|
}
|
|
39
39
|
function serializeNavigationHash(state){const params=new URLSearchParams({screen:state.screen||'overview',view:state.view||'now',period:String(state.period||30),group:state.group||'day'});if(state.project)params.set('project',state.project);if(state.from&&state.to){params.set('from',state.from);params.set('to',state.to)}if(state.query)params.set('query',state.query);if(state.status&&state.status!=='all')params.set('status',state.status);if(state.sort&&state.sort!=='attention')params.set('sort',state.sort);return '#'+params.toString()}
|
package/dist/dashboard/panels.js
CHANGED
|
@@ -125,25 +125,47 @@ function renderUsageHistory(current,previous,project={}){
|
|
|
125
125
|
}
|
|
126
126
|
function renderResults(p,a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Accepted in period',t.accepted,'Recorded acceptance events'),metric('Repair phases',t.repairs,'Routing escalations: '+t.escalations),metric('Tokens / acceptance',rate(t.tokensPerAccepted),'Reported input + output, including failed work')));const attempts=panel('Measured outcomes');attempts.append(el('p','Ended attempts: '+t.attempts+' · explicitly successful attempts: '+t.successfulAttempts+' · average summed attempt time per acceptance: '+duration(t.timePerAcceptedMs)));attempts.append(el('p','Worker termination alone is not counted as successful acceptance. Rates only describe recorded activity in the selected period.','muted'));attempts.append(table(['Task','Attempts','Accepted','Repairs','Escalations','Tokens / acceptance'],a.tasks.map(v=>[v.storyId,v.attempts,v.accepted,v.repairs,v.escalations,rate(v.tokensPerAccepted)])));content.append(attempts);const checks=panel('Latest saved acceptance evidence');if(p.check){checks.append(el('p',p.check.summary));for(const c of p.check.criteria){const item=row(c.text,c.id,c.status);item.firstChild.append(append(el('details'),el('summary','View evidence'),el('p',c.summary)));checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence; independent of the selected statistics period.','muted'))}else checks.append(el('p','No saved independent check.','empty'));content.append(checks);const goals=panel('Saved goal attempts');for(const attempt of p.goal?.attempts||[])goals.append(row(attempt.provider||'Agent',(attempt.summary||'')+' · '+duration(attempt.durationMs)+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens),attempt.success?'finished':'failed'));content.append(goals)}
|
|
127
127
|
function renderHistoryExplorer(project,history){const state=projectStatus(project),view=panel('History explorer');view.append(el('p','Recorded events and completed runs in the selected UTC window. The list is bounded to the latest 100 events.','muted'));const events=timelineEvents(history.events||[]);if(!events.length)view.append(el('p','No recorded history in this period.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}const runs=panel('Run history');if(!(history.runs||[]).length)runs.append(el('p','No recorded runs in this period.','empty'));else runs.append(table(['Run','Started','Ended','Agent','Provider','Model','Tokens','Accepted','Outcome'],history.runs.map(run=>[dimensionLabel(run.runId),run.startedAt||'Not reported',run.endedAt||'Not reported',dimensionLabel(run.agent),dimensionLabel(run.provider),dimensionLabel(run.model),rankingText(run.total?.tokens?.total),rankingText(run.total?.outcomes?.accepted),rankingText(run.total?.outcomes?.successfulAttempts)])));content.append(renderCoverage(history,{stale:state.stale,unavailable:false}),runs,view)}
|
|
128
|
+
function solPiRatioError(value){const text=String(value);const ratio=Number(text);return text.trim()===''||!Number.isFinite(ratio)||ratio<0?'Enter a finite cache write/read ratio of 0 or more.':null}
|
|
129
|
+
async function saveSolPiSettings(projectId,settings,feedback){
|
|
130
|
+
const error=solPiRatioError(settings.cacheWriteReadRatio);if(error){feedback.className='settings-feedback notice';feedback.textContent=error;return}
|
|
131
|
+
feedback.className='settings-feedback muted';feedback.textContent='Saving settings…';
|
|
132
|
+
try{await api('/api/projects/'+projectId+'/settings',{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify({...settings,cacheWriteReadRatio:Number(settings.cacheWriteReadRatio)})});feedback.className='settings-feedback muted';feedback.textContent='Settings saved.'}
|
|
133
|
+
catch(error){feedback.className='settings-feedback notice';feedback.textContent='Could not save settings: '+error.message}
|
|
134
|
+
}
|
|
135
|
+
function renderSolPiSettings(project,settings){
|
|
136
|
+
const view=panel('SoL-Pi settings');view.append(el('p','SoL-Pi is off by default. Change the master switch and any mechanism switches, then save.','muted'));
|
|
137
|
+
const controls={},toggles=[['enabled','Enable SoL-Pi (off by default)'],['actionFusion','Action fusion'],['observationPack','Observation packing'],['evidencePreservingReducer','Evidence-preserving reducer'],['onlineContextCompact','Online context compaction']];
|
|
138
|
+
for(const [key,label] of toggles){const input=el('input');input.type='checkbox';input.checked=settings[key]===true;input.setAttribute('role','switch');input.setAttribute('aria-label',label);controls[key]=input;view.append(append(el('label',undefined,'setting-toggle'),input,el('span',label)))}
|
|
139
|
+
const ratioLabel=el('label',undefined,'field'),ratio=el('input');ratio.type='number';ratio.min='0';ratio.step='any';ratio.value=String(settings.cacheWriteReadRatio);ratio.setAttribute('aria-label','Cache write/read ratio');ratioLabel.append(el('span','Cache write/read ratio','label'),ratio);view.append(ratioLabel);
|
|
140
|
+
view.append(el('p','The pinned SoL-Pi extension executes inside Pi. Review the pinned SoL-Pi extension before enabling it.','muted'));
|
|
141
|
+
view.append(el('p','SoL-Pi needs a compatible Pi runtime installed and configured; Yoke does not install or authenticate Pi. For headless runs, Pi requires explicit project trust before it loads project-local settings. Open Pi, run /trust, then restart. Yoke does not grant trust.','muted'));
|
|
142
|
+
view.append(el('p','The evidence-preserving reducer may make a model call using diagnostic logs; those logs can contain project data. Review the selected provider and model before enabling it.','muted'));
|
|
143
|
+
const feedback=el('p','Save your changes to apply them.','settings-feedback muted');feedback.setAttribute('role','status');feedback.setAttribute('aria-live','polite');view.append(feedback);
|
|
144
|
+
view.append(button('Save settings',()=>saveSolPiSettings(project.id,{enabled:controls.enabled.checked,actionFusion:controls.actionFusion.checked,observationPack:controls.observationPack.checked,evidencePreservingReducer:controls.evidencePreservingReducer.checked,onlineContextCompact:controls.onlineContextCompact.checked,cacheWriteReadRatio:ratio.value},feedback),true));
|
|
145
|
+
return view
|
|
146
|
+
}
|
|
128
147
|
async function showProject(id){
|
|
129
148
|
const request=beginRequest();selected=id;nav();
|
|
130
149
|
try{
|
|
131
150
|
const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),detailRequest=api('/api/projects/'+id,{},request.signal);
|
|
132
|
-
let project,current=null,previous=null,history=null;
|
|
151
|
+
let project,current=null,previous=null,history=null,settings=null;
|
|
133
152
|
if(projectView==='usage'){
|
|
134
153
|
[project,current,previous]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal),api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.previous),{},request.signal)]);
|
|
135
154
|
}else if(projectView==='results'){
|
|
136
155
|
[project,current]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal)]);
|
|
137
156
|
}else if(projectView==='history'){
|
|
138
157
|
[project,history]=await Promise.all([detailRequest,api('/api/projects/'+id+'/history?'+queryPeriod(bounds.current)+'&limit=100',{},request.signal)]);
|
|
158
|
+
}else if(projectView==='settings'){
|
|
159
|
+
[project,settings]=await Promise.all([detailRequest,api('/api/projects/'+id+'/settings',{},request.signal)]);
|
|
139
160
|
}else project=await detailRequest;
|
|
140
161
|
if(!currentRequest(request.version)||navigationState.screen!=='project'||selected!==id)return;
|
|
141
162
|
lastSuccessfulSync=new Date().toISOString();heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
|
|
142
|
-
const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
|
|
163
|
+
const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History'],['settings','Settings']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
|
|
143
164
|
content.append(tabs);
|
|
144
165
|
if(projectView==='now'){
|
|
145
166
|
const tasks=project.stories||[],state=projectStatus(project);content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(task=>task.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',project.estimate?.available?duration(project.estimate.lowerMs)+' – '+duration(project.estimate.upperMs):'Unknown',project.estimate?.available?project.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',state.primary,state.goal&&state.loop&&state.goal!==state.loop?'Goal '+state.goal+' · loop '+(state.stale?'unconfirmed':state.loop):'Latest reported state')));renderNow(project);
|
|
146
|
-
}else
|
|
167
|
+
}else if(projectView==='settings')content.append(renderSolPiSettings(project,settings));
|
|
168
|
+
else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous,projectStatus(project));else if(projectView==='history')renderHistoryExplorer(project,history);else renderResults(project,current)}
|
|
147
169
|
}catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error)){heading('Project unavailable','Offline or unavailable: '+error.message);content.append(renderCoverage({errors:[],coverage:'Historical analytics are unavailable for this project.'},{unavailable:true}))}}
|
|
148
170
|
}
|
|
149
171
|
if(typeof setInterval==='function')setInterval(()=>{if(selected&&projectView==='now'&&typeof document.hidden!=='undefined'&&!document.hidden&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)&&!document.activeElement?.matches?.(':focus-visible'))showProject(selected)},5000);
|
package/dist/dashboard/server.js
CHANGED
|
@@ -4,12 +4,14 @@ import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from '
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { configPath, defaultConfig, loadConfig, resolveSolPiSettings, saveConfig, SolPiSettingsSchema } from '../retrofit/config.js';
|
|
7
8
|
import { listProjects } from './registry.js';
|
|
8
9
|
import { dashboardPage } from './page.js';
|
|
9
10
|
import { readEvents } from '../observability/events.js';
|
|
10
11
|
import { estimateSchedule } from '../estimation/schedule.js';
|
|
11
12
|
import { pauseProjectGoal, runProjectGoal } from '../goals/command.js';
|
|
12
|
-
import { runLoopCommand } from '../loop/run-command.js';
|
|
13
|
+
import { runLoopCommand, resumeLoopCommand } from '../loop/run-command.js';
|
|
14
|
+
import { readRunState } from '../loop/run-state.js';
|
|
13
15
|
import { isPidAlive, readLock } from '../loop/lock.js';
|
|
14
16
|
import { appendEvent } from '../observability/events.js';
|
|
15
17
|
import { queueChange } from '../change/inbox.js';
|
|
@@ -99,6 +101,28 @@ function writePauseSignal(root) {
|
|
|
99
101
|
throw error;
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
function assertSafeConfigTarget(root) {
|
|
105
|
+
const directory = join(root, '.yoke');
|
|
106
|
+
const file = configPath(root);
|
|
107
|
+
try {
|
|
108
|
+
const stat = lstatSync(directory);
|
|
109
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
110
|
+
throw new Error('Unsafe project settings target');
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== 'ENOENT')
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const stat = lstatSync(file);
|
|
118
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
119
|
+
throw new Error('Unsafe project settings target');
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error.code !== 'ENOENT')
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
102
126
|
function projectIsRunning(root) {
|
|
103
127
|
if (activeResumes.has(root))
|
|
104
128
|
return true;
|
|
@@ -213,13 +237,51 @@ export async function startDashboard(options = {}) {
|
|
|
213
237
|
send(200, workspaceAnalytics(listProjects(), period, sort));
|
|
214
238
|
return;
|
|
215
239
|
}
|
|
216
|
-
const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/resume|\/notes|\/changes|\/analytics|\/history|\/events)?$/u.exec(path);
|
|
240
|
+
const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/resume|\/notes|\/changes|\/settings|\/analytics|\/history|\/events)?$/u.exec(path);
|
|
217
241
|
if (match) {
|
|
218
242
|
const project = listProjects().find(project => project.id === match[1]);
|
|
219
243
|
if (!project) {
|
|
220
244
|
send(404, { error: 'Unknown project' });
|
|
221
245
|
return;
|
|
222
246
|
}
|
|
247
|
+
if (req.method === 'GET' && match[2] === '/settings') {
|
|
248
|
+
if (project.error) {
|
|
249
|
+
send(409, { error: project.error });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
assertSafeConfigTarget(project.root);
|
|
253
|
+
const config = loadConfig(project.root) ?? defaultConfig('0.0.0');
|
|
254
|
+
send(200, resolveSolPiSettings(config));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (req.method === 'POST' && match[2] === '/settings') {
|
|
258
|
+
let settings;
|
|
259
|
+
try {
|
|
260
|
+
settings = SolPiSettingsSchema.parse(await requestBody(req, {}));
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid project settings payload' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (project.error) {
|
|
267
|
+
send(409, { error: project.error });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
assertSafeConfigTarget(project.root);
|
|
272
|
+
const config = loadConfig(project.root) ?? defaultConfig('0.0.0');
|
|
273
|
+
saveConfig(project.root, { ...config, solpi: settings });
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error.message === 'Unsafe project settings target') {
|
|
277
|
+
send(409, { error: error.message });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
send(200, settings);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
223
285
|
if (req.method === 'GET' && match[2] === '/analytics') {
|
|
224
286
|
if (project.error) {
|
|
225
287
|
send(409, { error: project.error });
|
|
@@ -270,9 +332,11 @@ export async function startDashboard(options = {}) {
|
|
|
270
332
|
send(409, { error: project.error });
|
|
271
333
|
return;
|
|
272
334
|
}
|
|
273
|
-
|
|
335
|
+
const saved = readRunState(project.root);
|
|
336
|
+
if (current.goal && (!saved || saved.mode === 'goal'))
|
|
274
337
|
pauseProjectGoal(project.root);
|
|
275
|
-
|
|
338
|
+
if (!saved || saved.mode !== 'goal')
|
|
339
|
+
writePauseSignal(project.root);
|
|
276
340
|
const response = { status: 'pause-requested' };
|
|
277
341
|
send(200, response);
|
|
278
342
|
return;
|
|
@@ -294,13 +358,30 @@ export async function startDashboard(options = {}) {
|
|
|
294
358
|
send(409, { status: 'already-running', error: 'Project is already running' });
|
|
295
359
|
return;
|
|
296
360
|
}
|
|
297
|
-
|
|
361
|
+
let saved;
|
|
362
|
+
try {
|
|
363
|
+
saved = readRunState(project.root);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
send(409, { error: 'Saved execution state is invalid; start a fresh run from the CLI' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const goal = saved ? saved.mode === 'goal' : current.goal !== null && current.goal.status !== 'complete';
|
|
370
|
+
if (saved?.mode === 'goal') {
|
|
371
|
+
const rawGoal = safeFile(project.root, '.yoke/goal.json');
|
|
372
|
+
if (!rawGoal || JSON.parse(rawGoal).id !== saved.goalId) {
|
|
373
|
+
send(409, { error: 'Saved goal identity no longer matches this project' });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
298
377
|
activeResumes.add(project.root);
|
|
299
378
|
void Promise.resolve().then(async () => {
|
|
300
379
|
if (goal)
|
|
301
|
-
await runProjectGoal(project.root);
|
|
380
|
+
await runProjectGoal(project.root, saved?.mode === 'goal' ? { ...saved.options, provider: saved.options.agent, expectedGoalId: saved.goalId } : {});
|
|
381
|
+
else if (saved)
|
|
382
|
+
await resumeLoopCommand(project.root);
|
|
302
383
|
else
|
|
303
|
-
await runLoopCommand(project.root, {});
|
|
384
|
+
await runLoopCommand(project.root, { permissions: 'safe' });
|
|
304
385
|
}).catch(() => undefined).finally(() => { activeResumes.delete(project.root); });
|
|
305
386
|
const response = { status: 'resume-requested' };
|
|
306
387
|
send(202, response);
|