@hecer/yoke 1.0.0 → 1.1.1
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 +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +87 -45
- package/canon/AGENTS.md +2 -0
- package/canon/loop/loop-spec.md +4 -2
- package/canon/manifest.yaml +2 -1
- package/canon/skills/authoring-prd/SKILL.md +4 -3
- 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/dist/agents/host.js +26 -0
- package/dist/agents/providers.js +2 -2
- package/dist/cli.js +170 -5
- package/dist/context/context.js +15 -2
- package/dist/loop/cleanup.js +97 -32
- package/dist/loop/decision.js +517 -0
- package/dist/loop/git.js +23 -0
- package/dist/loop/lock.js +104 -13
- package/dist/loop/loop.js +29 -0
- package/dist/loop/run-command.js +69 -8
- package/dist/loop/runner.js +7 -1
- package/dist/prd/command.js +27 -24
- package/dist/retrofit/command.js +3 -2
- package/dist/retrofit/config.js +5 -1
- package/dist/retrofit/gitignore.js +8 -0
- package/dist/retrofit/report.js +1 -1
- package/dist/setup/command.js +82 -0
- package/docs/MIGRATING-TO-1.1.md +27 -0
- package/docs/PUBLISHING.md +77 -41
- package/gemini-extension.json +6 -0
- package/package.json +4 -2
|
@@ -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
|
+
}
|
package/dist/loop/git.js
CHANGED
|
@@ -29,3 +29,26 @@ export const realGitOps = {
|
|
|
29
29
|
execFileSync('git', ['merge', '--ff-only', sha], { cwd: repoDir, stdio: 'pipe' });
|
|
30
30
|
},
|
|
31
31
|
};
|
|
32
|
+
export function commitPaths(dir, paths, message, identity) {
|
|
33
|
+
if (paths.length === 0)
|
|
34
|
+
throw new Error('no commit paths supplied');
|
|
35
|
+
const identityArgs = identity
|
|
36
|
+
? ['-c', `user.name=${identity.authorName}`, '-c', `user.email=${identity.authorEmail}`]
|
|
37
|
+
: [];
|
|
38
|
+
const authorArgs = identity ? ['--author', `${identity.authorName} <${identity.authorEmail}>`] : [];
|
|
39
|
+
const cleanMessage = sanitizeCommitMessage(message, identity?.allowCoAuthors ?? false);
|
|
40
|
+
execFileSync('git', ['add', '--', ...paths], { cwd: dir, stdio: 'pipe' });
|
|
41
|
+
try {
|
|
42
|
+
const staged = execFileSync('git', ['diff', '--cached', '--name-only', '--', ...paths], { cwd: dir, stdio: 'pipe' }).toString().trim();
|
|
43
|
+
if (!staged)
|
|
44
|
+
throw new Error('nothing to commit for selected paths');
|
|
45
|
+
execFileSync('git', [...identityArgs, '-c', 'commit.gpgsign=false', 'commit', '--only', ...authorArgs, '-m', cleanMessage, '--', ...paths], { cwd: dir, stdio: 'pipe' });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('git', ['reset', '--quiet', '--', ...paths], { cwd: dir, stdio: 'pipe' });
|
|
50
|
+
}
|
|
51
|
+
catch { /* best effort */ }
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/loop/lock.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
export function lockPath(targetDir) {
|
|
4
5
|
return join(targetDir, '.yoke', 'loop.lock');
|
|
5
6
|
}
|
|
7
|
+
export function takeoverLockPath(targetDir) {
|
|
8
|
+
return join(targetDir, '.yoke', 'loop.lock.takeover');
|
|
9
|
+
}
|
|
10
|
+
export function takeoverRecoveryPath(targetDir) {
|
|
11
|
+
return join(targetDir, '.yoke', 'loop.lock.takeover.recovery');
|
|
12
|
+
}
|
|
6
13
|
// Liveness probe via signal 0. EPERM means "exists but not ours" — alive.
|
|
7
14
|
export function isPidAlive(pid) {
|
|
8
15
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -15,8 +22,7 @@ export function isPidAlive(pid) {
|
|
|
15
22
|
return e.code === 'EPERM';
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
|
-
|
|
19
|
-
const file = lockPath(targetDir);
|
|
25
|
+
function readLockFile(file) {
|
|
20
26
|
if (!existsSync(file))
|
|
21
27
|
return null;
|
|
22
28
|
try {
|
|
@@ -27,19 +33,104 @@ export function readLock(targetDir) {
|
|
|
27
33
|
return null;
|
|
28
34
|
}
|
|
29
35
|
}
|
|
36
|
+
export function readLock(targetDir) {
|
|
37
|
+
return readLockFile(lockPath(targetDir));
|
|
38
|
+
}
|
|
39
|
+
// Publish complete metadata in one atomic namespace operation. Unlike open('wx')
|
|
40
|
+
// followed by write(), contenders can never observe an empty/partial lock file.
|
|
41
|
+
function publishLock(file, info) {
|
|
42
|
+
const temp = `${file}.${info.ownerToken ?? randomUUID()}.tmp`;
|
|
43
|
+
writeFileSync(temp, JSON.stringify(info), { flag: 'wx' });
|
|
44
|
+
try {
|
|
45
|
+
linkSync(temp, file);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const code = error.code;
|
|
50
|
+
if (code === 'EEXIST')
|
|
51
|
+
return false;
|
|
52
|
+
if (code === 'EPERM' || code === 'ENOTSUP' || code === 'EXDEV' || code === 'EACCES') {
|
|
53
|
+
throw new Error(`Yoke cannot publish an atomic loop lock on this filesystem (${code}). Move the project to a filesystem with hard-link support.`);
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rmSync(temp, { force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function releaseLockFile(file, ownerToken) {
|
|
62
|
+
if (!ownerToken)
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
if (readLockFile(file)?.ownerToken === ownerToken)
|
|
66
|
+
rmSync(file, { force: true });
|
|
67
|
+
}
|
|
68
|
+
catch { /* best-effort */ }
|
|
69
|
+
}
|
|
70
|
+
export function acquireTakeoverLease(targetDir, pid = process.pid) {
|
|
71
|
+
const file = takeoverLockPath(targetDir);
|
|
72
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
73
|
+
if (existsSync(takeoverRecoveryPath(targetDir))) {
|
|
74
|
+
return { acquired: false, holderPid: readLockFile(takeoverRecoveryPath(targetDir))?.pid };
|
|
75
|
+
}
|
|
76
|
+
const ownerToken = randomUUID();
|
|
77
|
+
const lease = { pid, startedAt: new Date().toISOString(), ownerToken };
|
|
78
|
+
return publishLock(file, lease)
|
|
79
|
+
? { acquired: true, ownerToken }
|
|
80
|
+
: { acquired: false, holderPid: readLockFile(file)?.pid };
|
|
81
|
+
}
|
|
82
|
+
export function releaseTakeoverLease(targetDir, ownerToken) {
|
|
83
|
+
releaseLockFile(takeoverLockPath(targetDir), ownerToken);
|
|
84
|
+
}
|
|
85
|
+
export function acquireTakeoverRecoveryLease(targetDir, pid = process.pid) {
|
|
86
|
+
const file = takeoverRecoveryPath(targetDir);
|
|
87
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
88
|
+
const ownerToken = randomUUID();
|
|
89
|
+
const lease = { pid, startedAt: new Date().toISOString(), ownerToken };
|
|
90
|
+
return publishLock(file, lease)
|
|
91
|
+
? { acquired: true, ownerToken }
|
|
92
|
+
: { acquired: false, holderPid: readLockFile(file)?.pid };
|
|
93
|
+
}
|
|
94
|
+
export function releaseTakeoverRecoveryLease(targetDir, ownerToken) {
|
|
95
|
+
releaseLockFile(takeoverRecoveryPath(targetDir), ownerToken);
|
|
96
|
+
}
|
|
30
97
|
export function acquireLock(targetDir, pid = process.pid) {
|
|
31
98
|
const file = lockPath(targetDir);
|
|
32
|
-
const
|
|
33
|
-
if (holder && isPidAlive(holder.pid))
|
|
34
|
-
return { acquired: false, holderPid: holder.pid };
|
|
99
|
+
const takeoverFile = takeoverLockPath(targetDir);
|
|
35
100
|
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
101
|
+
const ownerToken = randomUUID();
|
|
102
|
+
const candidate = { pid, startedAt: new Date().toISOString(), ownerToken };
|
|
103
|
+
// A stale-lock owner is currently being revalidated. Joining that race would
|
|
104
|
+
// let a late contender publish between removal and replacement.
|
|
105
|
+
const recoveryFile = takeoverRecoveryPath(targetDir);
|
|
106
|
+
if (existsSync(takeoverFile) || existsSync(recoveryFile)) {
|
|
107
|
+
return { acquired: false, holderPid: readLockFile(takeoverFile)?.pid ?? readLockFile(recoveryFile)?.pid };
|
|
108
|
+
}
|
|
109
|
+
if (publishLock(file, candidate))
|
|
110
|
+
return { acquired: true, ownerToken };
|
|
111
|
+
const observed = readLock(targetDir);
|
|
112
|
+
if (observed && isPidAlive(observed.pid))
|
|
113
|
+
return { acquired: false, holderPid: observed.pid };
|
|
114
|
+
// Serialize stale takeover with a second, atomically published lease. A
|
|
115
|
+
// crashed takeover lease is deliberately recovered by `yoke loop cleanup`;
|
|
116
|
+
// guessing here would recreate the same TOCTOU race one level deeper.
|
|
117
|
+
const takeover = acquireTakeoverLease(targetDir, pid);
|
|
118
|
+
if (!takeover.acquired)
|
|
119
|
+
return { ...takeover, stalePid: observed?.pid };
|
|
41
120
|
try {
|
|
42
|
-
|
|
121
|
+
const current = readLock(targetDir);
|
|
122
|
+
if (current && isPidAlive(current.pid))
|
|
123
|
+
return { acquired: false, holderPid: current.pid, stalePid: observed?.pid };
|
|
124
|
+
rmSync(file, { force: true });
|
|
125
|
+
if (publishLock(file, candidate))
|
|
126
|
+
return { acquired: true, stalePid: current?.pid ?? observed?.pid, ownerToken };
|
|
127
|
+
return { acquired: false, holderPid: readLock(targetDir)?.pid, stalePid: current?.pid ?? observed?.pid };
|
|
43
128
|
}
|
|
44
|
-
|
|
129
|
+
finally {
|
|
130
|
+
releaseTakeoverLease(targetDir, takeover.ownerToken);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Best-effort and ownership-checked: teardown never deletes a successor's lock.
|
|
134
|
+
export function releaseLock(targetDir, ownerToken) {
|
|
135
|
+
releaseLockFile(lockPath(targetDir), ownerToken);
|
|
45
136
|
}
|