opencode-feature-factory 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { validateRun } from "./validate.js";
|
|
3
|
+
import { normalizePostPrCiDriverOverride } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const PREFIX = "ffpayload-v1:";
|
|
6
|
+
const DRIVER_MODES = new Set(["interactive", "headless", "autonomous"]);
|
|
7
|
+
const DRIVER_KEYS = new Set(["mode", "ready", "pr_mode", "reviewer", "github_account", "run_id", "post_pr_ci"]);
|
|
8
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
|
9
|
+
const SAFE_RUN_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/u;
|
|
10
|
+
const CONTINUATION_KEYS = new Set(["kind", "schema_version", "created_at", "operator_summary", "parent", "review", "target", "parent_artifacts", "parent_evidence", "parent_reviews", "planning_reuse", "post_pr"]);
|
|
11
|
+
const CONTINUATION_PLANNING_REUSE_KEYS = new Set(["eligible", "reason", "spec_review_ref", "spec_review_hash", "spec_artifact_ref", "spec_artifact_hash", "child_spec_review_ref"]);
|
|
12
|
+
const CONTINUATION_CHILD_SPEC_REVIEW_REF = "reviews/spec-writer.json";
|
|
13
|
+
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/iu;
|
|
14
|
+
const CONTINUATION_PARENT_KEYS = new Set(["run_id", "status", "run_ref", "run_hash", "branch", "commit", "worktree"]);
|
|
15
|
+
const CONTINUATION_REVIEW_KEYS = new Set(["kind", "ref", "hash", "subject", "summary", "required_fixes", "source", "verdict"]);
|
|
16
|
+
const CONTINUATION_TARGET_KEYS = new Set(["run_id", "branch", "worktree", "base_ref", "base_commit"]);
|
|
17
|
+
const CONTINUATION_REF_KEYS = new Set(["kind", "ref", "hash"]);
|
|
18
|
+
const STEERING_KEYS = new Set(["schema_version", "kind", "run_id", "pending", "uncheckpointed", "consume", "raw_message_included"]);
|
|
19
|
+
const STEERING_PENDING_KEYS = new Set(["id", "ref", "hash", "message_chars", "created_at"]);
|
|
20
|
+
const STEERING_UNCHECKPOINTED_KEYS = new Set(["id", "ref", "hash", "message_chars", "created_at", "consumed_at"]);
|
|
21
|
+
const STEERING_CONSUME_KEYS = new Set(["command", "args"]);
|
|
22
|
+
const CONTINUATION_REVIEW_KINDS = new Set(["validator", "security_review", "step", "slice", "post_pr"]);
|
|
23
|
+
const CONTINUATION_ARTIFACT_KINDS = new Map([
|
|
24
|
+
["artifacts/story.md", "story"],
|
|
25
|
+
["artifacts/research-map.md", "research_map"],
|
|
26
|
+
["artifacts/design-brief.md", "design_brief"],
|
|
27
|
+
["artifacts/technical-brief.md", "technical_brief"],
|
|
28
|
+
["artifacts/test-report.md", "test_report"],
|
|
29
|
+
["artifacts/validation-report.md", "validation_report"],
|
|
30
|
+
["artifacts/pr-body.md", "pr_body"],
|
|
31
|
+
]);
|
|
32
|
+
const COMMIT_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/iu;
|
|
33
|
+
|
|
34
|
+
export function encodeFeatureCommandPayload(payload) {
|
|
35
|
+
return `${PREFIX}${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decodeFeatureCommandPayload(argumentsText, options = {}) {
|
|
39
|
+
const text = String(argumentsText || "").trim();
|
|
40
|
+
if (!text.startsWith(PREFIX)) return { ok: false, reason: "unencoded" };
|
|
41
|
+
const encoded = text.slice(PREFIX.length);
|
|
42
|
+
if (!BASE64URL_PATTERN.test(encoded)) return { ok: false, reason: "invalid-encoding" };
|
|
43
|
+
|
|
44
|
+
let json;
|
|
45
|
+
try {
|
|
46
|
+
json = Buffer.from(encoded, "base64url").toString("utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: false, reason: "invalid-encoding" };
|
|
49
|
+
}
|
|
50
|
+
if (Buffer.from(json, "utf8").toString("base64url") !== encoded) return { ok: false, reason: "non-canonical-encoding" };
|
|
51
|
+
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
payload = JSON.parse(json);
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, reason: "invalid-json" };
|
|
57
|
+
}
|
|
58
|
+
if (!plainObject(payload) || !nonEmptyString(payload.operator_request)) return { ok: false, reason: "invalid-envelope" };
|
|
59
|
+
|
|
60
|
+
const driver = payload.driver === undefined ? {} : payload.driver;
|
|
61
|
+
if (!plainObject(driver)) return { ok: false, reason: "invalid-driver" };
|
|
62
|
+
if (!hasOnlyKeys(driver, DRIVER_KEYS)) return { ok: false, reason: "invalid-driver" };
|
|
63
|
+
const mode = driver.mode === undefined ? "interactive" : driver.mode;
|
|
64
|
+
if (!DRIVER_MODES.has(mode)) return { ok: false, reason: "invalid-driver-mode" };
|
|
65
|
+
if (driver.pr_mode !== undefined && driver.pr_mode !== null && !["draft", "ready"].includes(driver.pr_mode)) return { ok: false, reason: "invalid-pr-mode" };
|
|
66
|
+
if (driver.ready !== undefined && typeof driver.ready !== "boolean") return { ok: false, reason: "invalid-ready" };
|
|
67
|
+
for (const field of ["reviewer", "github_account", "run_id"]) {
|
|
68
|
+
if (driver[field] !== undefined && driver[field] !== null && !nonEmptyString(driver[field])) return { ok: false, reason: `invalid-driver-${field}` };
|
|
69
|
+
}
|
|
70
|
+
let postPrCi = null;
|
|
71
|
+
try {
|
|
72
|
+
postPrCi = normalizePostPrCiDriverOverride(driver.post_pr_ci);
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: "invalid-driver-post-pr-ci" };
|
|
75
|
+
}
|
|
76
|
+
if (driver.run_id !== undefined && driver.run_id !== null && !safeRunId(driver.run_id)) return { ok: false, reason: "invalid-driver-run_id" };
|
|
77
|
+
|
|
78
|
+
const hasResume = payload.resume !== undefined && payload.resume !== null;
|
|
79
|
+
const hasSteering = payload.steering !== undefined && payload.steering !== null;
|
|
80
|
+
const hasContinuation = payload.continuation !== undefined && payload.continuation !== null;
|
|
81
|
+
if (hasResume !== hasSteering) return { ok: false, reason: "incomplete-resume-route" };
|
|
82
|
+
if (hasResume && hasContinuation) return { ok: false, reason: "ambiguous-route" };
|
|
83
|
+
if (driver.run_id !== undefined && driver.run_id !== null && (hasResume || hasContinuation)) return { ok: false, reason: "invalid-driver-run-id-route" };
|
|
84
|
+
if (hasResume && postPrCi !== null) return { ok: false, reason: "resume-post-pr-policy-override" };
|
|
85
|
+
|
|
86
|
+
let continuation = null;
|
|
87
|
+
if (hasContinuation) {
|
|
88
|
+
const result = normalizeContinuation(payload.continuation, payload.operator_request.trim(), options.repo);
|
|
89
|
+
if (!result.ok) return result;
|
|
90
|
+
continuation = result.value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const normalized = {
|
|
94
|
+
operator_request: payload.operator_request.trim(),
|
|
95
|
+
driver: {
|
|
96
|
+
mode,
|
|
97
|
+
ready: driver.ready === true,
|
|
98
|
+
pr_mode: driver.pr_mode ?? null,
|
|
99
|
+
reviewer: stringOrNull(driver.reviewer),
|
|
100
|
+
github_account: stringOrNull(driver.github_account),
|
|
101
|
+
run_id: stringOrNull(driver.run_id),
|
|
102
|
+
post_pr_ci: postPrCi,
|
|
103
|
+
},
|
|
104
|
+
resume: null,
|
|
105
|
+
steering: null,
|
|
106
|
+
continuation,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (hasResume) {
|
|
110
|
+
if (!plainObject(payload.resume) || !hasOnlyKeys(payload.resume, new Set(["schema_version", "kind", "run_id", "post_pr_policy"])) || payload.resume.schema_version !== 1 || payload.resume.kind !== "existing-run-resume" || !nonEmptyString(payload.resume.run_id)) {
|
|
111
|
+
return { ok: false, reason: "invalid-resume" };
|
|
112
|
+
}
|
|
113
|
+
const runId = payload.resume.run_id.trim();
|
|
114
|
+
if (!safeRunId(runId)) return { ok: false, reason: "invalid-resume-run-id" };
|
|
115
|
+
if (normalized.operator_request !== `resume ${runId}`) return { ok: false, reason: "resume-request-mismatch" };
|
|
116
|
+
let postPrPolicy = null;
|
|
117
|
+
if (payload.resume.post_pr_policy !== undefined && payload.resume.post_pr_policy !== null) {
|
|
118
|
+
try {
|
|
119
|
+
validateRun({ schema_version: 1, run_id: runId, status: "running", max_retries: 3, gates: {}, post_pr: { schema_version: 1, policy: payload.resume.post_pr_policy, phase: payload.resume.post_pr_policy.enabled === true ? "awaiting-pr" : "disabled", attempt: 0, observation: null, remediation: null, evidence_refs: [], continuation_review: null } });
|
|
120
|
+
postPrPolicy = cloneJson(payload.resume.post_pr_policy);
|
|
121
|
+
} catch {
|
|
122
|
+
return { ok: false, reason: "invalid-resume-post-pr-policy" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
normalized.resume = { schema_version: 1, kind: "existing-run-resume", run_id: runId, ...(postPrPolicy === null ? {} : { post_pr_policy: postPrPolicy }) };
|
|
126
|
+
|
|
127
|
+
const steering = normalizeSteering(payload.steering, runId);
|
|
128
|
+
if (!steering.ok) return steering;
|
|
129
|
+
normalized.steering = steering.value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: true, payload: normalized };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function safePayloadValue(value) {
|
|
136
|
+
return JSON.stringify(value)
|
|
137
|
+
.replaceAll("\u0085", "\\u0085")
|
|
138
|
+
.replaceAll("\u2028", "\\u2028")
|
|
139
|
+
.replaceAll("\u2029", "\\u2029");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeSteering(steering, runId) {
|
|
143
|
+
if (!plainObject(steering) || !hasOnlyKeys(steering, STEERING_KEYS) || !Object.hasOwn(steering, "pending") || !Object.hasOwn(steering, "consume") || steering.schema_version !== 1 || steering.kind !== "operator-steering-pointer" || !nonEmptyString(steering.run_id) || steering.raw_message_included !== false) {
|
|
144
|
+
return { ok: false, reason: "invalid-steering" };
|
|
145
|
+
}
|
|
146
|
+
if (runId !== steering.run_id.trim()) return { ok: false, reason: "run-id-mismatch" };
|
|
147
|
+
|
|
148
|
+
const pending = steering.pending ?? null;
|
|
149
|
+
const uncheckpointed = steering.uncheckpointed ?? null;
|
|
150
|
+
const consume = steering.consume ?? null;
|
|
151
|
+
if (pending !== null && uncheckpointed !== null) return { ok: false, reason: "ambiguous-steering-pointer" };
|
|
152
|
+
const pointer = pending || uncheckpointed;
|
|
153
|
+
if (pointer === null || consume === null) {
|
|
154
|
+
if (pointer !== null || consume !== null) return { ok: false, reason: "incomplete-steering-pointer" };
|
|
155
|
+
return { ok: true, value: { schema_version: 1, kind: "operator-steering-pointer", run_id: runId, pending: null, uncheckpointed: null, consume: null, raw_message_included: false } };
|
|
156
|
+
}
|
|
157
|
+
const pointerKeys = pending ? STEERING_PENDING_KEYS : STEERING_UNCHECKPOINTED_KEYS;
|
|
158
|
+
if (!plainObject(pointer) || !hasOnlyKeys(pointer, pointerKeys) || !plainObject(consume) || !hasOnlyKeys(consume, STEERING_CONSUME_KEYS)) {
|
|
159
|
+
return { ok: false, reason: "invalid-steering-pointer" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
validateRun({ schema_version: 1, run_id: runId, status: "running", gates: {}, steering: { schema_version: 1, pending, uncheckpointed, history: [] } });
|
|
164
|
+
} catch {
|
|
165
|
+
return { ok: false, reason: "invalid-steering-pending" };
|
|
166
|
+
}
|
|
167
|
+
if (!(pending ? safePendingRef(pointer.ref) : safeConsumedRef(pointer.ref)) || consume.command !== "feature-factory" || !Array.isArray(consume.args)) {
|
|
168
|
+
return { ok: false, reason: "invalid-steering-consume" };
|
|
169
|
+
}
|
|
170
|
+
const expectedArgs = ["factory", "steer-consume", runId, "--ref", pointer.ref, "--hash", pointer.hash, "--json"];
|
|
171
|
+
if (consume.args.length !== expectedArgs.length || consume.args.some((item, index) => item !== expectedArgs[index])) {
|
|
172
|
+
return { ok: false, reason: "invalid-steering-consume" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
value: {
|
|
178
|
+
schema_version: 1,
|
|
179
|
+
kind: "operator-steering-pointer",
|
|
180
|
+
run_id: runId,
|
|
181
|
+
pending: pending ? { id: pending.id, ref: pending.ref, hash: pending.hash, message_chars: pending.message_chars, created_at: pending.created_at } : null,
|
|
182
|
+
uncheckpointed: uncheckpointed ? { id: uncheckpointed.id, ref: uncheckpointed.ref, hash: uncheckpointed.hash, message_chars: uncheckpointed.message_chars, created_at: uncheckpointed.created_at, consumed_at: uncheckpointed.consumed_at } : null,
|
|
183
|
+
consume: { command: "feature-factory", args: expectedArgs },
|
|
184
|
+
raw_message_included: false,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeContinuation(continuation, operatorRequest, repo) {
|
|
190
|
+
if (!plainObject(continuation) || !hasOnlyKeys(continuation, CONTINUATION_KEYS)) return { ok: false, reason: "invalid-continuation" };
|
|
191
|
+
const { parent, review, target } = continuation;
|
|
192
|
+
if (!plainObject(parent) || !hasOnlyKeys(parent, CONTINUATION_PARENT_KEYS)
|
|
193
|
+
|| !plainObject(review) || !hasOnlyKeys(review, CONTINUATION_REVIEW_KEYS)
|
|
194
|
+
|| !plainObject(target) || !hasOnlyKeys(target, CONTINUATION_TARGET_KEYS)) {
|
|
195
|
+
return { ok: false, reason: "invalid-continuation" };
|
|
196
|
+
}
|
|
197
|
+
for (const items of [continuation.parent_artifacts, continuation.parent_evidence, continuation.parent_reviews]) {
|
|
198
|
+
if (!Array.isArray(items) || items.some((item) => !plainObject(item) || !hasOnlyKeys(item, CONTINUATION_REF_KEYS))) return { ok: false, reason: "invalid-continuation-refs" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const planningReuse = continuation.planning_reuse;
|
|
202
|
+
if (planningReuse !== undefined) {
|
|
203
|
+
if (!plainObject(planningReuse) || !hasOnlyKeys(planningReuse, CONTINUATION_PLANNING_REUSE_KEYS) || typeof planningReuse.eligible !== "boolean") {
|
|
204
|
+
return { ok: false, reason: "invalid-continuation-planning-reuse" };
|
|
205
|
+
}
|
|
206
|
+
if (planningReuse.eligible
|
|
207
|
+
&& (!canonicalJsonRef(planningReuse.spec_review_ref, "reviews/")
|
|
208
|
+
|| planningReuse.child_spec_review_ref !== CONTINUATION_CHILD_SPEC_REVIEW_REF
|
|
209
|
+
|| planningReuse.spec_artifact_ref !== "artifacts/technical-brief.md"
|
|
210
|
+
|| !SHA256_PATTERN.test(planningReuse.spec_review_hash || "")
|
|
211
|
+
|| !SHA256_PATTERN.test(planningReuse.spec_artifact_hash || ""))) {
|
|
212
|
+
return { ok: false, reason: "invalid-continuation-planning-reuse" };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
validateRun({
|
|
218
|
+
schema_version: 1,
|
|
219
|
+
run_id: target.run_id,
|
|
220
|
+
branch: target.branch,
|
|
221
|
+
worktree: target.worktree,
|
|
222
|
+
status: "running",
|
|
223
|
+
gates: {},
|
|
224
|
+
continuation,
|
|
225
|
+
});
|
|
226
|
+
} catch {
|
|
227
|
+
return { ok: false, reason: "invalid-continuation-schema" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!safeRunId(parent.run_id) || !safeRunId(target.run_id) || target.branch !== target.run_id || parent.run_id === target.run_id) return { ok: false, reason: "invalid-continuation-route" };
|
|
231
|
+
const targetWorktree = continuationTargetWorktree(repo, target.run_id);
|
|
232
|
+
if (!targetWorktree) return { ok: false, reason: "invalid-continuation-context" };
|
|
233
|
+
if (!CONTINUATION_REVIEW_KINDS.has(review.kind) || !validContinuationReviewSource(review.kind, review.source)) return { ok: false, reason: "invalid-continuation-review" };
|
|
234
|
+
if (parent.run_ref !== `.opencode/factory/${parent.run_id}/run.json`
|
|
235
|
+
|| !canonicalJsonRef(review.ref, "reviews/")
|
|
236
|
+
|| target.worktree !== targetWorktree
|
|
237
|
+
|| !COMMIT_PATTERN.test(parent.commit)
|
|
238
|
+
|| !COMMIT_PATTERN.test(target.base_commit)
|
|
239
|
+
|| continuation.parent_artifacts.some((item) => CONTINUATION_ARTIFACT_KINDS.get(item.ref) !== item.kind)
|
|
240
|
+
|| continuation.parent_evidence.some((item) => item.kind !== "evidence" || !canonicalJsonRef(item.ref, "evidence/"))
|
|
241
|
+
|| continuation.parent_reviews.some((item) => item.kind !== "review" || !canonicalJsonRef(item.ref, "reviews/"))) {
|
|
242
|
+
return { ok: false, reason: "invalid-continuation-refs" };
|
|
243
|
+
}
|
|
244
|
+
if (review.kind === "post_pr") {
|
|
245
|
+
const postPr = continuation.post_pr;
|
|
246
|
+
const evidence = continuation.parent_evidence.find((item) => item.ref === postPr?.evidence_ref);
|
|
247
|
+
if (!plainObject(postPr)
|
|
248
|
+
|| review.source !== "run.post_pr.continuation_review.ref"
|
|
249
|
+
|| review.ref !== postPr.continuation_review_ref
|
|
250
|
+
|| review.hash !== postPr.continuation_review_hash
|
|
251
|
+
|| !evidence
|
|
252
|
+
|| evidence.hash !== postPr.evidence_hash
|
|
253
|
+
|| postPr.disposition !== "leave-unchanged") return { ok: false, reason: "invalid-continuation-post-pr-binding" };
|
|
254
|
+
} else if (continuation.post_pr !== undefined) return { ok: false, reason: "invalid-continuation-post-pr-binding" };
|
|
255
|
+
const expectedRequest = `Continue blocked feature-factory run '${parent.run_id}' as '${target.run_id}' using review '${review.ref}'.`;
|
|
256
|
+
if (operatorRequest !== expectedRequest || continuation.operator_summary !== `Continue blocked run '${parent.run_id}' from ${review.ref}.`) {
|
|
257
|
+
return { ok: false, reason: "continuation-request-mismatch" };
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
value: {
|
|
262
|
+
kind: "blocked-run-continuation",
|
|
263
|
+
schema_version: 1,
|
|
264
|
+
created_at: continuation.created_at,
|
|
265
|
+
operator_summary: continuation.operator_summary,
|
|
266
|
+
parent: {
|
|
267
|
+
run_id: parent.run_id,
|
|
268
|
+
status: "blocked",
|
|
269
|
+
run_ref: parent.run_ref,
|
|
270
|
+
run_hash: parent.run_hash,
|
|
271
|
+
branch: parent.branch,
|
|
272
|
+
commit: parent.commit,
|
|
273
|
+
worktree: parent.worktree,
|
|
274
|
+
},
|
|
275
|
+
review: {
|
|
276
|
+
kind: review.kind,
|
|
277
|
+
ref: review.ref,
|
|
278
|
+
hash: review.hash,
|
|
279
|
+
subject: review.subject,
|
|
280
|
+
summary: review.summary ?? null,
|
|
281
|
+
required_fixes: Array.isArray(review.required_fixes) ? [...review.required_fixes] : [],
|
|
282
|
+
...(review.source === undefined ? {} : { source: review.source }),
|
|
283
|
+
...(review.verdict === undefined ? {} : { verdict: review.verdict }),
|
|
284
|
+
},
|
|
285
|
+
target: {
|
|
286
|
+
run_id: target.run_id,
|
|
287
|
+
branch: target.branch,
|
|
288
|
+
worktree: targetWorktree,
|
|
289
|
+
base_ref: target.base_ref,
|
|
290
|
+
base_commit: target.base_commit,
|
|
291
|
+
},
|
|
292
|
+
parent_artifacts: continuation.parent_artifacts.map(normalizedRefHash),
|
|
293
|
+
parent_evidence: continuation.parent_evidence.map(normalizedRefHash),
|
|
294
|
+
parent_reviews: continuation.parent_reviews.map(normalizedRefHash),
|
|
295
|
+
...(planningReuse === undefined ? {} : { planning_reuse: normalizedPlanningReuse(planningReuse) }),
|
|
296
|
+
...(continuation.post_pr === undefined ? {} : { post_pr: cloneJson(continuation.post_pr) }),
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizedPlanningReuse(planningReuse) {
|
|
302
|
+
if (!planningReuse.eligible) {
|
|
303
|
+
return { eligible: false, ...(nonEmptyString(planningReuse.reason) ? { reason: planningReuse.reason } : {}) };
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
eligible: true,
|
|
307
|
+
spec_review_ref: planningReuse.spec_review_ref,
|
|
308
|
+
spec_review_hash: planningReuse.spec_review_hash,
|
|
309
|
+
spec_artifact_ref: planningReuse.spec_artifact_ref,
|
|
310
|
+
spec_artifact_hash: planningReuse.spec_artifact_hash,
|
|
311
|
+
child_spec_review_ref: planningReuse.child_spec_review_ref,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function hasOnlyKeys(value, allowed) {
|
|
316
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function safeRunId(value) {
|
|
320
|
+
return nonEmptyString(value) && SAFE_RUN_ID_PATTERN.test(value) && !value.includes("..") && !value.endsWith(".lock");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function safeRelativeRef(value, prefix) {
|
|
324
|
+
return nonEmptyString(value) && value.startsWith(prefix) && !value.includes("\\") && !value.split("/").includes("..");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function canonicalJsonRef(value, prefix) {
|
|
328
|
+
return canonicalFileRef(value, prefix) && value.endsWith(".json");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function canonicalFileRef(value, prefix) {
|
|
332
|
+
if (!safeRelativeRef(value, prefix) || value !== value.trim()) return false;
|
|
333
|
+
const segments = value.split("/");
|
|
334
|
+
return segments.length >= 2 && segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") && basename(value).includes(".") && !basename(value).endsWith(".");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function continuationTargetWorktree(repo, runId) {
|
|
338
|
+
if (!nonEmptyString(repo)) return null;
|
|
339
|
+
return resolve(repo, ".opencode", "worktrees", runId);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizedRefHash(item) {
|
|
343
|
+
return { kind: item.kind, ref: item.ref, hash: item.hash };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function safePendingRef(value) {
|
|
347
|
+
return safeRelativeRef(value, "steering/") && /^steering\/pending-[^/]+\.json$/u.test(value);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function safeConsumedRef(value) {
|
|
351
|
+
return safeRelativeRef(value, "steering/") && /^steering\/consumed-[^/]+\.json$/u.test(value);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function validContinuationReviewSource(kind, source) {
|
|
355
|
+
if (!nonEmptyString(source)) return false;
|
|
356
|
+
if (kind === "validator") return source === "run.validator.review_ref";
|
|
357
|
+
if (kind === "security_review") return source === "run.security_review.review_ref";
|
|
358
|
+
if (kind === "step") return source.startsWith("run.steps.") && source.endsWith(".review_ref") && source.length > "run.steps..review_ref".length;
|
|
359
|
+
if (kind === "slice") return source.startsWith("run.slices.") && source.endsWith(".review_ref") && source.length > "run.slices..review_ref".length;
|
|
360
|
+
if (kind === "post_pr") return source === "run.post_pr.continuation_review.ref";
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function plainObject(value) {
|
|
365
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function nonEmptyString(value) {
|
|
369
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function stringOrNull(value) {
|
|
373
|
+
return nonEmptyString(value) ? value.trim() : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function cloneJson(value) {
|
|
377
|
+
return JSON.parse(JSON.stringify(value));
|
|
378
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawnSync as defaultSpawnSync } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { requireNonEmptyString } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_GIT_TIMEOUT_MS = 10_000;
|
|
6
|
+
export const MAX_GIT_TIMEOUT_MS = 30_000;
|
|
7
|
+
export const DEFAULT_GIT_MAX_BUFFER = 1024 * 1024;
|
|
8
|
+
export const MAX_GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
|
9
|
+
const repoRootCache = new Map();
|
|
10
|
+
|
|
11
|
+
export function git(cwd, args, options = {}) {
|
|
12
|
+
const resolvedCwd = resolve(requireNonEmptyString(cwd, "cwd"));
|
|
13
|
+
const commandArgs = normalizeArgs(args);
|
|
14
|
+
const timeout = clampPositiveInteger(options.timeout, DEFAULT_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS);
|
|
15
|
+
const maxBuffer = clampPositiveInteger(options.maxBuffer, DEFAULT_GIT_MAX_BUFFER, MAX_GIT_MAX_BUFFER);
|
|
16
|
+
const spawn = typeof options.spawnSync === "function" ? options.spawnSync : defaultSpawnSync;
|
|
17
|
+
const env = {
|
|
18
|
+
...process.env,
|
|
19
|
+
...options.env,
|
|
20
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
21
|
+
GIT_PAGER: "cat",
|
|
22
|
+
PAGER: "cat",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const command = {
|
|
26
|
+
file: "git",
|
|
27
|
+
cwd: resolvedCwd,
|
|
28
|
+
args: commandArgs,
|
|
29
|
+
shell: false,
|
|
30
|
+
timeout,
|
|
31
|
+
maxBuffer,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const proc = spawn("git", commandArgs, {
|
|
36
|
+
cwd: resolvedCwd,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
env,
|
|
39
|
+
shell: false,
|
|
40
|
+
timeout,
|
|
41
|
+
maxBuffer,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
}) || {};
|
|
44
|
+
const status = Number.isInteger(proc.status) ? proc.status : null;
|
|
45
|
+
return {
|
|
46
|
+
ok: !proc.error && status === 0,
|
|
47
|
+
status,
|
|
48
|
+
stdout: normalizeOutput(proc.stdout),
|
|
49
|
+
stderr: joinOutput(proc.stderr, proc.error),
|
|
50
|
+
command,
|
|
51
|
+
error: proc.error ? normalizeErrorMessage(proc.error) : undefined,
|
|
52
|
+
signal: typeof proc.signal === "string" ? proc.signal : null,
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
status: null,
|
|
58
|
+
stdout: "",
|
|
59
|
+
stderr: normalizeErrorMessage(error),
|
|
60
|
+
command,
|
|
61
|
+
error: normalizeErrorMessage(error),
|
|
62
|
+
signal: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function repoRoot(cwd = process.cwd(), options = {}) {
|
|
68
|
+
const key = resolve(requireNonEmptyString(cwd, "cwd"));
|
|
69
|
+
if (!options.noCache && repoRootCache.has(key)) return repoRootCache.get(key);
|
|
70
|
+
const proc = git(key, ["rev-parse", "--show-toplevel"], options);
|
|
71
|
+
const rootPath = proc.ok ? proc.stdout.trim() : key;
|
|
72
|
+
if (!options.noCache && proc.ok) repoRootCache.set(key, rootPath);
|
|
73
|
+
return rootPath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeArgs(args) {
|
|
77
|
+
if (!Array.isArray(args)) throw new Error("git args must be an array of strings");
|
|
78
|
+
return args.map((arg, index) => {
|
|
79
|
+
if (typeof arg !== "string" || arg === "") throw new Error(`git args[${index}] must be a non-empty string`);
|
|
80
|
+
if (arg.includes("\0")) throw new Error(`git args[${index}] must not contain NUL bytes`);
|
|
81
|
+
return arg;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clampPositiveInteger(value, fallback, maximum) {
|
|
86
|
+
if (!Number.isInteger(value) || value <= 0) return fallback;
|
|
87
|
+
return Math.min(value, maximum);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeOutput(value) {
|
|
91
|
+
if (typeof value === "string") return value;
|
|
92
|
+
if (value == null) return "";
|
|
93
|
+
if (Buffer.isBuffer(value)) return value.toString("utf8");
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeErrorMessage(error) {
|
|
98
|
+
if (error instanceof Error && error.message) return error.message;
|
|
99
|
+
if (typeof error === "string") return error;
|
|
100
|
+
return String(error ?? "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function joinOutput(output, error) {
|
|
104
|
+
const parts = [];
|
|
105
|
+
const normalizedOutput = normalizeOutput(output);
|
|
106
|
+
const normalizedError = normalizeErrorMessage(error);
|
|
107
|
+
if (normalizedOutput !== "") parts.push(normalizedOutput);
|
|
108
|
+
if (normalizedError !== "" && normalizedError !== normalizedOutput) parts.push(normalizedError);
|
|
109
|
+
return parts.join(parts.length > 1 ? "\n" : "");
|
|
110
|
+
}
|