@hecer/yoke 0.9.0 → 1.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/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.codex-plugin/plugin.json +7 -0
- package/CHANGELOG.md +192 -149
- package/README.md +101 -51
- package/TODOS.md +8 -0
- package/agents/docs.toml +6 -0
- package/agents/implementer.toml +6 -0
- package/agents/reviewer.toml +6 -0
- package/agents/security.toml +6 -0
- package/bench/README.md +45 -42
- package/bench/RESULTS.md +46 -36
- package/bench/result-schema.mjs +12 -0
- package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
- package/bench/results/codex-unavailable-1785175418318.json +15 -0
- package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
- package/bench/run-matrix.mjs +26 -0
- package/bench/run.mjs +127 -115
- package/canon/AGENTS.md +2 -0
- package/canon/loop/loop-spec.md +4 -2
- package/canon/loop/prd.schema.md +5 -0
- package/canon/manifest.yaml +2 -1
- package/canon/skills/authoring-prd/SKILL.md +10 -3
- package/canon/skills/ship/SKILL.md +2 -7
- package/canon/skills/workflow/SKILL.md +4 -0
- package/canon/skills/yoke-retrofit/SKILL.md +18 -11
- package/canon/skills/yoke-workflow/SKILL.md +20 -0
- package/canon/tools/codex-rtk-hook.mjs +36 -0
- package/dist/agents/host.js +26 -0
- package/dist/agents/providers.js +23 -0
- package/dist/agents/telemetry.js +30 -0
- package/dist/agents/types.js +1 -0
- package/dist/audit/changes.js +6 -0
- package/dist/audit/command.js +64 -0
- package/dist/audit/dependencies.js +21 -0
- package/dist/audit/secrets.js +16 -0
- package/dist/audit/types.js +1 -0
- package/dist/cli.js +189 -6
- package/dist/context/context.js +15 -2
- package/dist/loop/claims.js +57 -0
- package/dist/loop/cleanup.js +98 -27
- package/dist/loop/decision.js +517 -0
- package/dist/loop/git.js +31 -2
- package/dist/loop/identity.js +27 -0
- package/dist/loop/lock.js +104 -13
- package/dist/loop/loop.js +49 -2
- package/dist/loop/merge-queue.js +20 -0
- package/dist/loop/parallel.js +39 -0
- package/dist/loop/prd.js +48 -2
- package/dist/loop/run-command.js +118 -12
- package/dist/loop/runner.js +48 -30
- package/dist/loop/scheduler.js +8 -0
- package/dist/prd/command.js +30 -21
- package/dist/retrofit/command.js +3 -2
- package/dist/retrofit/config.js +16 -0
- package/dist/retrofit/gitignore.js +8 -0
- package/dist/retrofit/planners/codex.js +64 -19
- package/dist/retrofit/report.js +1 -1
- package/dist/review/command.js +52 -12
- package/dist/review/verdict.js +45 -0
- package/dist/setup/command.js +82 -0
- package/docs/MIGRATING-TO-1.0.md +33 -0
- package/docs/MIGRATING-TO-1.1.md +27 -0
- package/docs/PUBLISHING.md +77 -41
- package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
- package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
- package/gemini-extension.json +6 -0
- package/hooks/hooks.json +19 -0
- package/package.json +84 -67
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
package/dist/loop/cleanup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { lockPath, readLock, isPidAlive } from './lock.js';
|
|
4
|
+
import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverLockPath, readLock, isPidAlive, releaseTakeoverLease, releaseTakeoverRecoveryLease, takeoverRecoveryPath, } from './lock.js';
|
|
5
5
|
import { killProcessTree } from './watchdog.js';
|
|
6
6
|
// Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
|
|
7
7
|
// .yoke/runner.pid files (main dir + each worktree). Never by process-name or
|
|
@@ -41,43 +41,114 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
41
41
|
const isAlive = opts.isAlive ?? isPidAlive;
|
|
42
42
|
const killTree = opts.killTree ?? killProcessTree;
|
|
43
43
|
const wtDir = join(targetDir, '.yoke', 'worktrees');
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
const recoveryFile = takeoverRecoveryPath(targetDir);
|
|
45
|
+
if (opts.discardStaleRecovery && existsSync(recoveryFile)) {
|
|
46
|
+
let recoveryPid;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(recoveryFile, 'utf8'));
|
|
49
|
+
recoveryPid = typeof parsed.pid === 'number' ? parsed.pid : undefined;
|
|
50
|
+
}
|
|
51
|
+
catch { /* corrupt recovery lease can only be operator-discarded */ }
|
|
52
|
+
if (recoveryPid && isAlive(recoveryPid)) {
|
|
53
|
+
console.error(`Refusing to discard a live takeover-recovery lease (pid ${recoveryPid}).`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
rmSync(recoveryFile, { force: true });
|
|
57
|
+
console.warn(`Discarded stale takeover-recovery lease${recoveryPid ? ` from dead pid ${recoveryPid}` : ''}. Do not run this flag concurrently.`);
|
|
50
58
|
}
|
|
51
|
-
|
|
52
|
-
let
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
const takeoverFile = takeoverLockPath(targetDir);
|
|
60
|
+
let cleanupLease;
|
|
61
|
+
try {
|
|
62
|
+
cleanupLease = acquireTakeoverLease(targetDir);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
console.error(`Cannot acquire the Yoke cleanup lease: ${error.message}`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
if (!cleanupLease.acquired) {
|
|
69
|
+
let recoveryLease;
|
|
70
|
+
try {
|
|
71
|
+
recoveryLease = acquireTakeoverRecoveryLease(targetDir);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
console.error(`Cannot acquire the Yoke takeover-recovery lease: ${error.message}`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (!recoveryLease.acquired) {
|
|
78
|
+
if (recoveryLease.holderPid && isAlive(recoveryLease.holderPid)) {
|
|
79
|
+
console.log(`Loop lock takeover recovery is active (pid ${recoveryLease.holderPid}) — no cleanup performed.`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
console.error('A stale takeover-recovery lease is blocking cleanup. Re-run explicitly with: yoke loop cleanup . --discard-stale-recovery');
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// This fixed recovery lease is recognized by normal acquisition and all
|
|
87
|
+
// cleanup processes, so revalidation + removal cannot target a successor.
|
|
88
|
+
let takeoverPid;
|
|
56
89
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
const parsed = JSON.parse(readFileSync(takeoverFile, 'utf8'));
|
|
91
|
+
takeoverPid = typeof parsed.pid === 'number' ? parsed.pid : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch { /* corrupt takeover lease is stale */ }
|
|
94
|
+
if (takeoverPid && isAlive(takeoverPid)) {
|
|
95
|
+
console.log(`Loop lock takeover held by a live process (pid ${takeoverPid}) — no cleanup performed.`);
|
|
59
96
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
else {
|
|
98
|
+
rmSync(takeoverFile, { force: true });
|
|
99
|
+
console.log('Removed stale loop lock takeover lease; rerun cleanup to perform destructive cleanup safely.');
|
|
63
100
|
}
|
|
64
101
|
}
|
|
65
|
-
|
|
66
|
-
|
|
102
|
+
finally {
|
|
103
|
+
releaseTakeoverRecoveryLease(targetDir, recoveryLease.ownerToken);
|
|
67
104
|
}
|
|
68
|
-
|
|
105
|
+
return 0;
|
|
69
106
|
}
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
try {
|
|
108
|
+
// Holding this lease blocks every loop acquisition for the entire cleanup,
|
|
109
|
+
// including runner reaping and worktree removal.
|
|
72
110
|
const holder = readLock(targetDir);
|
|
73
|
-
if (holder &&
|
|
74
|
-
console.log(`Loop lock held by a live process (pid ${holder.pid}) —
|
|
111
|
+
if (holder && isAlive(holder.pid)) {
|
|
112
|
+
console.log(`Loop lock held by a live process (pid ${holder.pid}) — no cleanup performed.`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const killed = reapRecordedRunners(targetDir, wtDir, isAlive, killTree);
|
|
116
|
+
if (killed > 0)
|
|
117
|
+
console.log(`Killed ${killed} orphaned runner process tree(s) recorded in runner.pid files.`);
|
|
118
|
+
let removed = 0;
|
|
119
|
+
let failed = 0;
|
|
120
|
+
if (existsSync(wtDir)) {
|
|
121
|
+
for (const name of readdirSync(wtDir)) {
|
|
122
|
+
const path = join(wtDir, name);
|
|
123
|
+
if (!opts.removeWorktrees) {
|
|
124
|
+
console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
git(['worktree', 'remove', '--force', path], targetDir);
|
|
129
|
+
removed++;
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
console.error(`Failed to remove worktree ${path}: ${e.message}`);
|
|
133
|
+
failed++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (opts.removeWorktrees) {
|
|
137
|
+
try {
|
|
138
|
+
git(['worktree', 'prune'], targetDir);
|
|
139
|
+
}
|
|
140
|
+
catch { /* best-effort */ }
|
|
141
|
+
}
|
|
75
142
|
}
|
|
76
|
-
|
|
143
|
+
const lockFile = lockPath(targetDir);
|
|
144
|
+
if (existsSync(lockFile)) {
|
|
77
145
|
rmSync(lockFile, { force: true });
|
|
78
146
|
console.log('Removed stale loop lock.');
|
|
79
147
|
}
|
|
148
|
+
console.log(removed === 0 && failed === 0 ? 'No destructive cleanup performed.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
|
|
149
|
+
return failed === 0 ? 0 : 1;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
releaseTakeoverLease(targetDir, cleanupLease.ownerToken);
|
|
80
153
|
}
|
|
81
|
-
console.log(removed === 0 && failed === 0 ? 'Nothing to clean.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
|
|
82
|
-
return failed === 0 ? 0 : 1;
|
|
83
154
|
}
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { appendDecision, contextDir } from '../context/context.js';
|
|
8
|
+
import { loadConfig } from '../retrofit/config.js';
|
|
9
|
+
import { resolveCommitIdentity } from './identity.js';
|
|
10
|
+
import { commitPaths, realGitOps } from './git.js';
|
|
11
|
+
import { acquireLock, releaseLock } from './lock.js';
|
|
12
|
+
import { loadPrd, selectNextStory } from './prd.js';
|
|
13
|
+
const singleLine = (max) => z.string().trim().min(1).max(max).refine(value => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value), 'must be a single line without control or bidi-formatting characters');
|
|
14
|
+
const MAX_STATE_BYTES = 256 * 1024;
|
|
15
|
+
const DecisionOptionSchema = z.object({
|
|
16
|
+
id: z.string().trim().min(1).max(32).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u),
|
|
17
|
+
label: singleLine(200),
|
|
18
|
+
tradeoff: singleLine(500).optional(),
|
|
19
|
+
});
|
|
20
|
+
export const DecisionRequestSchema = z.object({
|
|
21
|
+
version: z.literal(1),
|
|
22
|
+
occurrenceId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
23
|
+
storyId: singleLine(128),
|
|
24
|
+
question: singleLine(500),
|
|
25
|
+
reason: singleLine(1000),
|
|
26
|
+
options: z.array(DecisionOptionSchema).min(2).max(4),
|
|
27
|
+
recommended: singleLine(32),
|
|
28
|
+
}).superRefine((request, ctx) => {
|
|
29
|
+
const ids = new Set();
|
|
30
|
+
const labels = new Set();
|
|
31
|
+
request.options.forEach((option, index) => {
|
|
32
|
+
const id = option.id.toLowerCase();
|
|
33
|
+
const label = option.label.toLowerCase();
|
|
34
|
+
if (ids.has(id))
|
|
35
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['options', index, 'id'], message: 'option IDs must be unique' });
|
|
36
|
+
if (labels.has(label))
|
|
37
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['options', index, 'label'], message: 'option labels must be unique' });
|
|
38
|
+
ids.add(id);
|
|
39
|
+
labels.add(label);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
const DecisionJournalSchema = z.object({
|
|
43
|
+
version: z.literal(1),
|
|
44
|
+
phase: z.enum(['claimed', 'committed']),
|
|
45
|
+
requestId: z.string().regex(/^[a-f0-9]{16}$/u),
|
|
46
|
+
request: DecisionRequestSchema,
|
|
47
|
+
answer: z.object({
|
|
48
|
+
selectedId: singleLine(32), rationale: singleLine(1000).optional(),
|
|
49
|
+
answerId: z.string().regex(/^[a-f0-9]{16}$/u),
|
|
50
|
+
}).optional(),
|
|
51
|
+
append: z.object({
|
|
52
|
+
fileExisted: z.boolean(), priorBytes: z.number().int().nonnegative(),
|
|
53
|
+
priorHash: z.string().regex(/^[a-f0-9]{64}$/u), block: z.string().min(1).max(16_384),
|
|
54
|
+
}).optional(),
|
|
55
|
+
});
|
|
56
|
+
export const DecisionResumeSchema = z.object({
|
|
57
|
+
version: z.literal(1),
|
|
58
|
+
storyId: singleLine(128),
|
|
59
|
+
requestId: z.string().regex(/^[a-f0-9]{16}$/u),
|
|
60
|
+
answered: z.boolean().optional(),
|
|
61
|
+
maxIterations: z.number().int().positive(),
|
|
62
|
+
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
63
|
+
isolate: z.boolean().optional(),
|
|
64
|
+
reviewer: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
65
|
+
review: z.boolean().optional(),
|
|
66
|
+
allowSelfReview: z.boolean().optional(),
|
|
67
|
+
timeoutMinutes: z.number().nonnegative().optional(),
|
|
68
|
+
json: z.boolean().optional(),
|
|
69
|
+
onAmbiguity: z.enum(['resolve', 'abort']).optional(),
|
|
70
|
+
decisionPolicy: z.enum(['auto', 'critical']).optional(),
|
|
71
|
+
permissions: z.enum(['safe', 'unsafe', 'read-only']).optional(),
|
|
72
|
+
parallel: z.number().int().positive().optional(),
|
|
73
|
+
projectId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
74
|
+
baseCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
75
|
+
prdHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
|
|
76
|
+
decisionCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
|
|
77
|
+
answerId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
|
|
78
|
+
});
|
|
79
|
+
export function decisionRequestPath(targetDir) {
|
|
80
|
+
return join(targetDir, '.yoke', 'decision-request.yaml');
|
|
81
|
+
}
|
|
82
|
+
export function pendingDecisionPath(targetDir) {
|
|
83
|
+
return join(targetDir, '.yoke', 'pending-decision.yaml');
|
|
84
|
+
}
|
|
85
|
+
export function decisionResumePath(targetDir) {
|
|
86
|
+
const projectId = projectBinding(targetDir).projectId;
|
|
87
|
+
try {
|
|
88
|
+
const gitDir = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
89
|
+
return join(resolve(targetDir, gitDir), 'yoke', `decision-resume-${projectId}.yaml`);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Unit-test/non-git fallback. Real loop projects are git repositories.
|
|
93
|
+
return join(targetDir, '.yoke', `decision-resume-${projectId}.yaml`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function decisionAnsweringPath(targetDir) {
|
|
97
|
+
return join(targetDir, '.yoke', 'decision-answering.yaml');
|
|
98
|
+
}
|
|
99
|
+
export function decisionRequestId(request) {
|
|
100
|
+
return createHash('sha256').update(JSON.stringify(request)).digest('hex').slice(0, 16);
|
|
101
|
+
}
|
|
102
|
+
export function decisionAnswerId(requestId, selectedId, rationale) {
|
|
103
|
+
return createHash('sha256').update(JSON.stringify({ requestId, selectedId, rationale })).digest('hex').slice(0, 16);
|
|
104
|
+
}
|
|
105
|
+
function writeYamlAtomic(path, value) {
|
|
106
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
107
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(temp, stringify(value));
|
|
110
|
+
renameSync(temp, path);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(temp);
|
|
115
|
+
}
|
|
116
|
+
catch { /* renamed or already cleaned */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function writeDecisionJournal(targetDir, journal) {
|
|
120
|
+
writeYamlAtomic(decisionAnsweringPath(targetDir), DecisionJournalSchema.parse(journal));
|
|
121
|
+
}
|
|
122
|
+
function parseYamlFile(path) {
|
|
123
|
+
const size = statSync(path).size;
|
|
124
|
+
if (size > MAX_STATE_BYTES)
|
|
125
|
+
throw new Error(`${path} exceeds the ${MAX_STATE_BYTES}-byte state-file limit`);
|
|
126
|
+
return parse(readFileSync(path, 'utf8'));
|
|
127
|
+
}
|
|
128
|
+
function hashFile(path) {
|
|
129
|
+
const content = existsSync(path) ? readFileSync(path) : Buffer.alloc(0);
|
|
130
|
+
return createHash('sha256').update(content).digest('hex');
|
|
131
|
+
}
|
|
132
|
+
function projectBinding(targetDir) {
|
|
133
|
+
let canonical;
|
|
134
|
+
try {
|
|
135
|
+
canonical = realpathSync.native(targetDir);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
canonical = resolve(targetDir);
|
|
139
|
+
}
|
|
140
|
+
const pathIdentity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
141
|
+
const projectId = createHash('sha256').update(pathIdentity).digest('hex').slice(0, 16);
|
|
142
|
+
let head;
|
|
143
|
+
try {
|
|
144
|
+
head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
145
|
+
}
|
|
146
|
+
catch { /* non-git test/project */ }
|
|
147
|
+
return { projectId, head, prdHash: hashFile(join(targetDir, '.yoke', 'prd.yaml')) };
|
|
148
|
+
}
|
|
149
|
+
function committedDecisionText(targetDir) {
|
|
150
|
+
try {
|
|
151
|
+
const prefix = execFileSync('git', ['rev-parse', '--show-prefix'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
152
|
+
return execFileSync('git', ['show', `HEAD:${prefix}.yoke/context/DECISIONS.md`], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function committedAnswerId(targetDir, requestId) {
|
|
159
|
+
const committed = committedDecisionText(targetDir);
|
|
160
|
+
if (!committed)
|
|
161
|
+
return undefined;
|
|
162
|
+
const match = committed.match(new RegExp(`Request-ID: ${requestId}\\r?\\nAnswer-ID: ([a-f0-9]{16})`, 'u'));
|
|
163
|
+
return match?.[1];
|
|
164
|
+
}
|
|
165
|
+
export function decisionProcessingExists(targetDir) {
|
|
166
|
+
return existsSync(decisionAnsweringPath(targetDir));
|
|
167
|
+
}
|
|
168
|
+
function rollbackClaimedAppend(targetDir, append) {
|
|
169
|
+
if (!append)
|
|
170
|
+
return;
|
|
171
|
+
const file = join(contextDir(targetDir), 'DECISIONS.md');
|
|
172
|
+
if (!existsSync(file)) {
|
|
173
|
+
if (append.fileExisted)
|
|
174
|
+
throw new Error('decision context disappeared while recovering an answer');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const current = readFileSync(file);
|
|
178
|
+
const currentHash = createHash('sha256').update(current).digest('hex');
|
|
179
|
+
if (current.length === append.priorBytes && currentHash === append.priorHash)
|
|
180
|
+
return;
|
|
181
|
+
const block = Buffer.from(append.block);
|
|
182
|
+
if (current.length < block.length || !current.subarray(current.length - block.length).equals(block)) {
|
|
183
|
+
throw new Error('decision context changed after the interrupted answer; refusing an unsafe rollback');
|
|
184
|
+
}
|
|
185
|
+
const prior = current.subarray(0, current.length - block.length);
|
|
186
|
+
if (prior.length !== append.priorBytes || createHash('sha256').update(prior).digest('hex') !== append.priorHash) {
|
|
187
|
+
throw new Error('decision context prefix does not match the interrupted answer journal');
|
|
188
|
+
}
|
|
189
|
+
if (append.fileExisted)
|
|
190
|
+
writeFileSync(file, prior);
|
|
191
|
+
else
|
|
192
|
+
rmSync(file, { force: true });
|
|
193
|
+
try {
|
|
194
|
+
execFileSync('git', ['reset', '--quiet', '--', '.yoke/context/DECISIONS.md'], { cwd: targetDir, stdio: 'ignore' });
|
|
195
|
+
}
|
|
196
|
+
catch { /* non-git or unstaged */ }
|
|
197
|
+
}
|
|
198
|
+
export function recoverDecisionProcessing(targetDir, isAnswerCommitted) {
|
|
199
|
+
const processing = decisionAnsweringPath(targetDir);
|
|
200
|
+
if (!existsSync(processing))
|
|
201
|
+
return { status: 'none' };
|
|
202
|
+
const raw = parseYamlFile(processing);
|
|
203
|
+
const journalResult = DecisionJournalSchema.safeParse(raw);
|
|
204
|
+
const journal = journalResult.success
|
|
205
|
+
? journalResult.data
|
|
206
|
+
: (() => {
|
|
207
|
+
const request = DecisionRequestSchema.parse(raw);
|
|
208
|
+
return { version: 1, phase: 'claimed', requestId: decisionRequestId(request), request };
|
|
209
|
+
})();
|
|
210
|
+
if (journal.answer && (committedAnswerId(targetDir, journal.requestId) === journal.answer.answerId
|
|
211
|
+
|| isAnswerCommitted?.(targetDir, journal.requestId, journal.answer.answerId) === true)) {
|
|
212
|
+
return { status: 'committed', journal };
|
|
213
|
+
}
|
|
214
|
+
rollbackClaimedAppend(targetDir, journal.append);
|
|
215
|
+
const pending = pendingDecisionPath(targetDir);
|
|
216
|
+
if (existsSync(pending)) {
|
|
217
|
+
const existing = readRequest(pending);
|
|
218
|
+
if (decisionRequestId(existing) !== journal.requestId) {
|
|
219
|
+
throw new Error('processing journal and pending decision refer to different requests');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
writeFileSync(pending, stringify(journal.request));
|
|
224
|
+
}
|
|
225
|
+
unlinkSync(processing);
|
|
226
|
+
return { status: 'restored', journal };
|
|
227
|
+
}
|
|
228
|
+
export function writeDecisionResume(targetDir, state) {
|
|
229
|
+
const binding = projectBinding(targetDir);
|
|
230
|
+
const parsed = DecisionResumeSchema.parse({
|
|
231
|
+
...state,
|
|
232
|
+
projectId: state.projectId ?? binding.projectId,
|
|
233
|
+
baseCommit: state.baseCommit ?? binding.head,
|
|
234
|
+
prdHash: state.prdHash ?? binding.prdHash,
|
|
235
|
+
decisionCommit: state.answered ? (state.decisionCommit ?? binding.head) : undefined,
|
|
236
|
+
answerId: state.answered ? (state.answerId ?? committedAnswerId(targetDir, state.requestId)) : undefined,
|
|
237
|
+
});
|
|
238
|
+
const path = decisionResumePath(targetDir);
|
|
239
|
+
writeYamlAtomic(path, parsed);
|
|
240
|
+
}
|
|
241
|
+
export function readDecisionResume(targetDir) {
|
|
242
|
+
const path = decisionResumePath(targetDir);
|
|
243
|
+
return existsSync(path) ? DecisionResumeSchema.parse(parseYamlFile(path)) : null;
|
|
244
|
+
}
|
|
245
|
+
export function clearDecisionResume(targetDir) {
|
|
246
|
+
try {
|
|
247
|
+
unlinkSync(decisionResumePath(targetDir));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code !== 'ENOENT')
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export function decisionResumeMatchesCurrent(targetDir, state) {
|
|
255
|
+
const parsedState = DecisionResumeSchema.parse(state);
|
|
256
|
+
const binding = projectBinding(targetDir);
|
|
257
|
+
let request = readPendingDecision(targetDir);
|
|
258
|
+
let processingCommitted = false;
|
|
259
|
+
if (!request && decisionProcessingExists(targetDir)) {
|
|
260
|
+
const raw = parseYamlFile(decisionAnsweringPath(targetDir));
|
|
261
|
+
const journal = DecisionJournalSchema.safeParse(raw);
|
|
262
|
+
request = journal.success ? journal.data.request : DecisionRequestSchema.parse(raw);
|
|
263
|
+
processingCommitted = journal.success
|
|
264
|
+
&& journal.data.answer !== undefined
|
|
265
|
+
&& committedAnswerId(targetDir, journal.data.requestId) === journal.data.answer.answerId;
|
|
266
|
+
}
|
|
267
|
+
return parsedState.projectId === binding.projectId
|
|
268
|
+
&& (parsedState.baseCommit === binding.head || processingCommitted)
|
|
269
|
+
&& parsedState.prdHash === binding.prdHash
|
|
270
|
+
&& request !== null
|
|
271
|
+
&& parsedState.storyId === request.storyId
|
|
272
|
+
&& parsedState.requestId === decisionRequestId(request);
|
|
273
|
+
}
|
|
274
|
+
export function answeredDecisionResumeIsValid(targetDir, state) {
|
|
275
|
+
const parsedState = DecisionResumeSchema.parse(state);
|
|
276
|
+
const binding = projectBinding(targetDir);
|
|
277
|
+
if (!parsedState.answered || !parsedState.decisionCommit || parsedState.projectId !== binding.projectId || parsedState.prdHash !== binding.prdHash)
|
|
278
|
+
return false;
|
|
279
|
+
try {
|
|
280
|
+
execFileSync('git', ['merge-base', '--is-ancestor', parsedState.decisionCommit, 'HEAD'], { cwd: targetDir, stdio: 'ignore' });
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
return parsedState.answerId !== undefined && committedAnswerId(targetDir, parsedState.requestId) === parsedState.answerId;
|
|
286
|
+
}
|
|
287
|
+
export function finalizeCommittedDecisionResume(targetDir, state) {
|
|
288
|
+
const parsedState = DecisionResumeSchema.parse(state);
|
|
289
|
+
const answerId = committedAnswerId(targetDir, parsedState.requestId);
|
|
290
|
+
if (!answerId)
|
|
291
|
+
return null;
|
|
292
|
+
writeDecisionResume(targetDir, { ...parsedState, answered: true, answerId });
|
|
293
|
+
return readDecisionResume(targetDir);
|
|
294
|
+
}
|
|
295
|
+
function readRequest(path) {
|
|
296
|
+
const request = DecisionRequestSchema.parse(parseYamlFile(path));
|
|
297
|
+
if (!request.options.some(option => option.id === request.recommended)) {
|
|
298
|
+
throw new Error(`recommended option "${request.recommended}" is not present in options`);
|
|
299
|
+
}
|
|
300
|
+
return request;
|
|
301
|
+
}
|
|
302
|
+
export function consumeDecisionRequest(sourceDir, targetDir = sourceDir, expectedStoryId) {
|
|
303
|
+
const source = decisionRequestPath(sourceDir);
|
|
304
|
+
if (!existsSync(source))
|
|
305
|
+
return null;
|
|
306
|
+
const parsedRequest = readRequest(source);
|
|
307
|
+
// The agent controls request content; the harness controls occurrence identity.
|
|
308
|
+
// Identical questions asked twice must never bind to the same historical answer.
|
|
309
|
+
const request = DecisionRequestSchema.parse({
|
|
310
|
+
...parsedRequest,
|
|
311
|
+
occurrenceId: randomUUID().replaceAll('-', '').slice(0, 16),
|
|
312
|
+
});
|
|
313
|
+
if (expectedStoryId && request.storyId !== expectedStoryId) {
|
|
314
|
+
throw new Error(`decision request storyId "${request.storyId}" does not match active story "${expectedStoryId}"`);
|
|
315
|
+
}
|
|
316
|
+
const pending = pendingDecisionPath(targetDir);
|
|
317
|
+
mkdirSync(dirname(pending), { recursive: true });
|
|
318
|
+
writeYamlAtomic(pending, request);
|
|
319
|
+
unlinkSync(source);
|
|
320
|
+
return request;
|
|
321
|
+
}
|
|
322
|
+
export function readPendingDecision(targetDir) {
|
|
323
|
+
const path = pendingDecisionPath(targetDir);
|
|
324
|
+
return existsSync(path) ? readRequest(path) : null;
|
|
325
|
+
}
|
|
326
|
+
export function formatPendingDecision(targetDir) {
|
|
327
|
+
const request = readPendingDecision(targetDir);
|
|
328
|
+
if (!request)
|
|
329
|
+
return 'No pending Yoke decision.';
|
|
330
|
+
return [
|
|
331
|
+
`Story ${request.storyId}: ${request.question}`,
|
|
332
|
+
`Why this is critical: ${request.reason}`,
|
|
333
|
+
...request.options.map(option => ` ${option.id}: ${option.label}${option.tradeoff ? ` — ${option.tradeoff}` : ''}`),
|
|
334
|
+
`recommended: ${request.recommended}`,
|
|
335
|
+
].join('\n');
|
|
336
|
+
}
|
|
337
|
+
function selectedAnswer(request, choice) {
|
|
338
|
+
const normalized = choice.trim().toLowerCase();
|
|
339
|
+
return request.options.find(option => option.id.toLowerCase() === normalized || option.label.toLowerCase() === normalized);
|
|
340
|
+
}
|
|
341
|
+
export function answerPendingDecision(targetDir, opts) {
|
|
342
|
+
let lock;
|
|
343
|
+
try {
|
|
344
|
+
lock = acquireLock(targetDir);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
console.error(`Cannot acquire the Yoke decision lock: ${error.message}`);
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
if (!lock.acquired) {
|
|
351
|
+
console.error(`Cannot answer while another Yoke loop/answer process holds the lock (pid ${lock.holderPid}).`);
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
let recovery;
|
|
356
|
+
try {
|
|
357
|
+
recovery = recoverDecisionProcessing(targetDir, opts.isAnswerCommitted);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
console.error(`Could not recover the prior decision answer: ${error.message}`);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
if (recovery.status === 'committed' && recovery.journal) {
|
|
364
|
+
const selected = selectedAnswer(recovery.journal.request, opts.choice);
|
|
365
|
+
const rationale = opts.rationale?.trim() || undefined;
|
|
366
|
+
if (!selected || !recovery.journal.answer) {
|
|
367
|
+
console.error('The prior answer was committed, but its exact selection cannot be verified. Inspect DECISIONS.md before continuing.');
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
if (selected.id !== recovery.journal.answer.selectedId || rationale !== recovery.journal.answer.rationale) {
|
|
371
|
+
console.error(`Decision ${recovery.journal.requestId} was already committed as ${recovery.journal.answer.selectedId}${recovery.journal.answer.rationale ? ` (${recovery.journal.answer.rationale})` : ''}; refusing a conflicting replay.`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
opts.onCommitted?.(recovery.journal.requestId, recovery.journal.answer.answerId);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
console.error(`The decision is committed, but trusted resume state could not be finalized: ${error.message}`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
unlinkSync(decisionAnsweringPath(targetDir));
|
|
382
|
+
console.log('The identical decision answer was already committed; recovered its cleanup state.');
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
let request;
|
|
386
|
+
try {
|
|
387
|
+
request = readPendingDecision(targetDir);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
console.error(`Invalid pending decision: ${error.message}`);
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
if (!request) {
|
|
394
|
+
console.error('No pending Yoke decision.');
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
if (!request.occurrenceId) {
|
|
398
|
+
request = DecisionRequestSchema.parse({ ...request, occurrenceId: randomUUID().replaceAll('-', '').slice(0, 16) });
|
|
399
|
+
writeYamlAtomic(pendingDecisionPath(targetDir), request);
|
|
400
|
+
}
|
|
401
|
+
const prdFile = join(targetDir, '.yoke', 'prd.yaml');
|
|
402
|
+
let activeStoryId;
|
|
403
|
+
try {
|
|
404
|
+
activeStoryId = existsSync(prdFile) ? selectNextStory(loadPrd(prdFile))?.id : undefined;
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
console.error(`Cannot validate the pending decision against the PRD: ${error.message}`);
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
if (activeStoryId !== request.storyId) {
|
|
411
|
+
console.error(`Pending decision is for story "${request.storyId}", but the current open story is "${activeStoryId ?? 'none'}". The pending decision was kept.`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
const clean = opts.isClean ? opts.isClean(targetDir) : opts.commit ? true : realGitOps.isClean(targetDir);
|
|
415
|
+
if (!clean) {
|
|
416
|
+
console.error('Cannot record the decision while the working tree has uncommitted story changes. Review or clean them first; the pending decision was kept.');
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
const selected = selectedAnswer(request, opts.choice);
|
|
420
|
+
if (!selected) {
|
|
421
|
+
console.error(`Unknown choice "${opts.choice}". Expected one of: ${request.options.map(option => option.id).join(', ')}`);
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
424
|
+
const rationale = opts.rationale?.trim() || undefined;
|
|
425
|
+
if (rationale && !singleLine(1000).safeParse(rationale).success) {
|
|
426
|
+
console.error('Decision rationale must be a single line of at most 1000 characters.');
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
const pending = pendingDecisionPath(targetDir);
|
|
430
|
+
const processing = decisionAnsweringPath(targetDir);
|
|
431
|
+
if (existsSync(processing)) {
|
|
432
|
+
console.error(`A prior decision answer is still being recovered at ${processing}; resolve it before retrying.`);
|
|
433
|
+
return 1;
|
|
434
|
+
}
|
|
435
|
+
const requestId = decisionRequestId(request);
|
|
436
|
+
const answer = { selectedId: selected.id, rationale, answerId: decisionAnswerId(requestId, selected.id, rationale) };
|
|
437
|
+
try {
|
|
438
|
+
renameSync(pending, processing);
|
|
439
|
+
writeDecisionJournal(targetDir, { version: 1, phase: 'claimed', requestId, request, answer });
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
try {
|
|
443
|
+
if (existsSync(processing) && !existsSync(pending))
|
|
444
|
+
renameSync(processing, pending);
|
|
445
|
+
}
|
|
446
|
+
catch { /* recover on next answer */ }
|
|
447
|
+
console.error(`Could not claim pending decision: ${error.message}`);
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
const summary = [
|
|
451
|
+
`Decision: ${JSON.stringify(`${selected.id} — ${selected.label}`)}`,
|
|
452
|
+
rationale ? `Rationale: ${JSON.stringify(rationale)}` : undefined,
|
|
453
|
+
`Request-ID: ${requestId}`,
|
|
454
|
+
`Answer-ID: ${answer.answerId}`,
|
|
455
|
+
].filter(Boolean).join('\n');
|
|
456
|
+
let appendPlan;
|
|
457
|
+
let appended;
|
|
458
|
+
try {
|
|
459
|
+
appended = appendDecision(contextDir(targetDir), {
|
|
460
|
+
storyId: request.storyId,
|
|
461
|
+
title: JSON.stringify(request.question),
|
|
462
|
+
summary,
|
|
463
|
+
}, new Date(), plan => {
|
|
464
|
+
appendPlan = plan;
|
|
465
|
+
writeDecisionJournal(targetDir, { version: 1, phase: 'claimed', requestId, request, answer, append: plan });
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
console.error(`Could not prepare the decision context safely: ${error.message}. The recovery journal was kept.`);
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
if (opts.commit)
|
|
474
|
+
opts.commit(targetDir, `yoke: decide ${request.storyId} ${request.question}`);
|
|
475
|
+
else {
|
|
476
|
+
const config = loadConfig(targetDir);
|
|
477
|
+
const identity = resolveCommitIdentity(targetDir, config?.commit);
|
|
478
|
+
const contextFile = relative(targetDir, join(contextDir(targetDir), 'DECISIONS.md'));
|
|
479
|
+
commitPaths(targetDir, [contextFile], `yoke: decide ${request.storyId} ${request.question}`, identity);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
appended.rollback();
|
|
484
|
+
try {
|
|
485
|
+
writeFileSync(pending, stringify(request));
|
|
486
|
+
unlinkSync(processing);
|
|
487
|
+
}
|
|
488
|
+
catch { /* keep recovery journal if restoration fails */ }
|
|
489
|
+
console.error(`Could not record decision: ${error.message}`);
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
writeDecisionJournal(targetDir, { version: 1, phase: 'committed', requestId, request, answer, append: appendPlan });
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
console.warn(`Decision was committed, but its recovery marker could not be updated: ${error.message}`);
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
opts.onCommitted?.(requestId, answer.answerId);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
console.error(`Decision was committed, but trusted resume state could not be finalized: ${error.message}. The recovery journal was kept.`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
(opts.removeProcessing ?? unlinkSync)(processing);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
console.warn(`Decision was committed, but cleanup of ${processing} failed: ${error.message}. It will not be committed again.`);
|
|
510
|
+
}
|
|
511
|
+
console.log(`Recorded ${request.storyId} decision: ${selected.id} — ${selected.label}`);
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
releaseLock(targetDir, lock.ownerToken);
|
|
516
|
+
}
|
|
517
|
+
}
|