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
package/src/plugin.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { decodeFeatureCommandPayload, safePayloadValue } from "./feature-command-payload.js";
|
|
5
|
+
import { normalizePostPrCiConfig } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
const assets = join(root, "assets");
|
|
9
|
+
const OPERATOR_PAYLOAD_MARKER = "UNTRUSTED_OPERATOR_PAYLOAD_START";
|
|
10
|
+
const PARSED_PAYLOAD_START = "PLUGIN_PARSED_OPERATOR_PAYLOAD_START";
|
|
11
|
+
const PARSED_PAYLOAD_END = "PLUGIN_PARSED_OPERATOR_PAYLOAD_END";
|
|
12
|
+
|
|
13
|
+
function readAsset(...parts) {
|
|
14
|
+
return readFileSync(join(assets, ...parts), "utf8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseFrontmatter(markdown) {
|
|
18
|
+
const normalized = String(markdown).replace(/\r\n/g, "\n");
|
|
19
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
20
|
+
if (!match) return { meta: {}, body: normalized };
|
|
21
|
+
return { meta: parseSimpleYaml(match[1]), body: match[2] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseSimpleYaml(src) {
|
|
25
|
+
const meta = {};
|
|
26
|
+
let currentMap = null;
|
|
27
|
+
for (const raw of src.split(/\r?\n/)) {
|
|
28
|
+
if (!raw.trim()) continue;
|
|
29
|
+
const nested = raw.match(/^\s{2}([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
30
|
+
if (nested && currentMap) {
|
|
31
|
+
meta[currentMap][nested[1]] = coerce(nested[2]);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
currentMap = null;
|
|
35
|
+
const mapStart = raw.match(/^([A-Za-z0-9_-]+):\s*$/);
|
|
36
|
+
if (mapStart) {
|
|
37
|
+
currentMap = mapStart[1];
|
|
38
|
+
meta[currentMap] = {};
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const scalar = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
42
|
+
if (scalar) meta[scalar[1]] = coerce(scalar[2]);
|
|
43
|
+
}
|
|
44
|
+
return meta;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coerce(value) {
|
|
48
|
+
const trimmed = String(value ?? "").trim();
|
|
49
|
+
if (trimmed === "true") return true;
|
|
50
|
+
if (trimmed === "false") return false;
|
|
51
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(trimmed);
|
|
54
|
+
} catch {
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return trimmed.replace(/^['"]|['"]$/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function registerCommand(cfg, options = {}) {
|
|
62
|
+
const source = readAsset("command", "feature.md");
|
|
63
|
+
const { meta, body } = parseFrontmatter(source);
|
|
64
|
+
cfg.command ??= {};
|
|
65
|
+
cfg.command.feature = {
|
|
66
|
+
description: meta.description || "Run the durable feature-factory workflow.",
|
|
67
|
+
agent: meta.agent || "build",
|
|
68
|
+
template: commandTemplate(body, options),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function commandTemplate(body, options = {}) {
|
|
73
|
+
const prMode = normalizePrMode(options.prMode ?? options.pr_mode ?? options.pullRequests?.mode);
|
|
74
|
+
const postPrCi = normalizePostPrCiConfig(options.postPrCi);
|
|
75
|
+
const config = [
|
|
76
|
+
"Plugin configuration defaults:",
|
|
77
|
+
`- PR mode: \`${prMode}\`. Use this as the default for successful PR creation when the driver payload has no \`pr_mode\` override.`,
|
|
78
|
+
`- Post-PR CI policy: ${JSON.stringify(postPrCi)}. Use this default-off policy only when the driver payload has no per-field \`post_pr_ci\` override; persist the complete effective policy once and never recalculate it on resume.`,
|
|
79
|
+
].join("\n");
|
|
80
|
+
const markerIndex = operatorPayloadMarkerIndex(body);
|
|
81
|
+
if (markerIndex < 0) return `${body.trim()}\n\n${config}`;
|
|
82
|
+
return `${body.slice(0, markerIndex)}${config}\n\n${body.slice(markerIndex)}`.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parsedPayloadBlock(parsed) {
|
|
86
|
+
if (!parsed.ok) {
|
|
87
|
+
return [
|
|
88
|
+
PARSED_PAYLOAD_START,
|
|
89
|
+
"parse_status: invalid",
|
|
90
|
+
"trust: untrusted-operator-data",
|
|
91
|
+
`reason: ${parsed.reason}`,
|
|
92
|
+
"driver.mode: interactive",
|
|
93
|
+
"routing_authority: none",
|
|
94
|
+
PARSED_PAYLOAD_END,
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
const payload = parsed.payload;
|
|
98
|
+
const lines = [
|
|
99
|
+
PARSED_PAYLOAD_START,
|
|
100
|
+
"parse_status: valid",
|
|
101
|
+
"trust: untrusted-operator-data",
|
|
102
|
+
`operator_request: ${safePayloadValue(payload.operator_request)}`,
|
|
103
|
+
`driver.mode: ${payload.driver.mode}`,
|
|
104
|
+
`driver.ready: ${payload.driver.ready}`,
|
|
105
|
+
`driver.pr_mode: ${safePayloadValue(payload.driver.pr_mode)}`,
|
|
106
|
+
`driver.reviewer: ${safePayloadValue(payload.driver.reviewer)}`,
|
|
107
|
+
`driver.github_account: ${safePayloadValue(payload.driver.github_account)}`,
|
|
108
|
+
`driver.run_id: ${safePayloadValue(payload.driver.run_id)}`,
|
|
109
|
+
`driver.post_pr_ci: ${safePayloadValue(payload.driver.post_pr_ci)}`,
|
|
110
|
+
`resume: ${safePayloadValue(payload.resume)}`,
|
|
111
|
+
`steering: ${safePayloadValue(payload.steering)}`,
|
|
112
|
+
`continuation: ${safePayloadValue(payload.continuation)}`,
|
|
113
|
+
PARSED_PAYLOAD_END,
|
|
114
|
+
];
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function injectParsedPayload(parts, parsed) {
|
|
119
|
+
const block = parsedPayloadBlock(parsed);
|
|
120
|
+
for (const part of parts || []) {
|
|
121
|
+
if (part?.type !== "text" || typeof part.text !== "string") continue;
|
|
122
|
+
const markerIndex = operatorPayloadMarkerIndex(part.text);
|
|
123
|
+
if (markerIndex < 0 || part.text.slice(0, markerIndex).includes(`${PARSED_PAYLOAD_START}\nparse_status:`)) continue;
|
|
124
|
+
part.text = `${part.text.slice(0, markerIndex)}${block}\n\n${part.text.slice(markerIndex)}`;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function operatorPayloadMarkerIndex(text) {
|
|
130
|
+
if (text.startsWith(`${OPERATOR_PAYLOAD_MARKER}\n`)) return 0;
|
|
131
|
+
const index = text.indexOf(`\n${OPERATOR_PAYLOAD_MARKER}\n`);
|
|
132
|
+
return index < 0 ? -1 : index + 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizePrMode(value) {
|
|
136
|
+
const mode = value === undefined || value === null || value === "" ? "ready" : String(value).trim();
|
|
137
|
+
if (mode === "ready" || mode === "draft") return mode;
|
|
138
|
+
throw new Error("opencode-feature-factory option prMode must be 'ready' or 'draft'");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function registerAgents(cfg) {
|
|
142
|
+
cfg.agent ??= {};
|
|
143
|
+
cfg.agent["feature-factory"] = {
|
|
144
|
+
description: "Primary orchestrator for the durable feature-factory workflow. Scoped non-interactive permissions prevent headless factory runs from blocking on approval prompts.",
|
|
145
|
+
mode: "primary",
|
|
146
|
+
permission: nonInteractivePermission("allow", { task: "allow" }),
|
|
147
|
+
prompt: "You are the feature-factory orchestrator. Follow the loaded feature skill exactly: classify intent, persist durable state, use file-based gates, delegate only from this primary agent to specialized subagents, observe evidence yourself, stop at gates in headless/scripted mode instead of waiting for interactive approval, and in explicit autonomous mode use the factory's own reviewed evidence/panel verdicts to record bounded autonomous gate decisions and terminal_result without auto-merging.",
|
|
148
|
+
};
|
|
149
|
+
const agentDir = join(assets, "agent");
|
|
150
|
+
for (const file of readdirSync(agentDir).filter((name) => name.endsWith(".md"))) {
|
|
151
|
+
const name = file.replace(/\.md$/, "");
|
|
152
|
+
const { meta, body } = parseFrontmatter(readFileSync(join(agentDir, file), "utf8"));
|
|
153
|
+
const agent = { ...meta, prompt: body.trim() };
|
|
154
|
+
delete agent.name;
|
|
155
|
+
agent.permission = mergeFactoryPermission(agent.permission);
|
|
156
|
+
cfg.agent[name] = agent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Force-deny Task delegation for every loaded subagent so only the primary
|
|
161
|
+
// feature-factory orchestrator can dispatch, keeping the orchestration tree one
|
|
162
|
+
// level deep. The forced `task: "deny"` is spread last, so agent frontmatter cannot
|
|
163
|
+
// re-enable delegation.
|
|
164
|
+
export function mergeFactoryPermission(existing = {}) {
|
|
165
|
+
const edit = typeof existing === "object" && existing.edit ? existing.edit : "deny";
|
|
166
|
+
return { ...(typeof existing === "object" ? existing : {}), ...nonInteractivePermission(edit, { task: "deny" }) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nonInteractivePermission(edit, { task = "deny" } = {}) {
|
|
170
|
+
return {
|
|
171
|
+
read: "allow",
|
|
172
|
+
glob: "allow",
|
|
173
|
+
grep: "allow",
|
|
174
|
+
list: "allow",
|
|
175
|
+
bash: "allow",
|
|
176
|
+
edit,
|
|
177
|
+
webfetch: "allow",
|
|
178
|
+
task,
|
|
179
|
+
todowrite: "allow",
|
|
180
|
+
external_directory: "deny",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const AGENT_ROLES = {
|
|
185
|
+
"feature-factory": "planning",
|
|
186
|
+
"story-reader": "story",
|
|
187
|
+
"story-writer": "story",
|
|
188
|
+
"codebase-researcher": "research",
|
|
189
|
+
"design-interpreter": "design",
|
|
190
|
+
"spec-writer": "planning",
|
|
191
|
+
"work-decomposer": "planning",
|
|
192
|
+
"backend-builder": "builder",
|
|
193
|
+
"frontend-builder": "builder",
|
|
194
|
+
"test-verifier": "test",
|
|
195
|
+
"work-reviewer": "reviewer",
|
|
196
|
+
"implementation-validator": "reviewer",
|
|
197
|
+
"security-reviewer": "security",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function applyProfileOptions(cfg, options = {}) {
|
|
201
|
+
const profiles = options.profiles || {};
|
|
202
|
+
const topLevelProfile = usableProfile(options.profile);
|
|
203
|
+
for (const [agentName, agent] of Object.entries(cfg.agent || {})) {
|
|
204
|
+
const role = AGENT_ROLES[agentName];
|
|
205
|
+
const selected =
|
|
206
|
+
usableProfile(profiles[agentName]) ||
|
|
207
|
+
roleProfile(profiles, role) ||
|
|
208
|
+
usableProfile(profiles.default) ||
|
|
209
|
+
topLevelProfile;
|
|
210
|
+
if (!selected) continue;
|
|
211
|
+
if (selected.model) agent.model = selected.model;
|
|
212
|
+
if (selected.variant) agent.variant = selected.variant;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function roleProfile(profiles, role) {
|
|
217
|
+
if (!role) return null;
|
|
218
|
+
return usableProfile(profiles[role]) || (role === "security" ? usableProfile(profiles.reviewer) : null);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function usableProfile(profile) {
|
|
222
|
+
if (!profile || typeof profile !== "object") return null;
|
|
223
|
+
return profile.model || profile.variant ? profile : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function registerSkills(cfg) {
|
|
227
|
+
cfg.skills ??= {};
|
|
228
|
+
cfg.skills.paths ??= [];
|
|
229
|
+
const skillPath = join(assets, "skills");
|
|
230
|
+
if (!cfg.skills.paths.includes(skillPath)) cfg.skills.paths.push(skillPath);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default async function featureFactoryPlugin(pluginInput, options = {}) {
|
|
234
|
+
return {
|
|
235
|
+
config(cfg) {
|
|
236
|
+
registerCommand(cfg, options);
|
|
237
|
+
registerAgents(cfg);
|
|
238
|
+
applyProfileOptions(cfg, options);
|
|
239
|
+
registerSkills(cfg);
|
|
240
|
+
},
|
|
241
|
+
"command.execute.before": async (input, output) => {
|
|
242
|
+
if (input.command !== "feature") return;
|
|
243
|
+
injectParsedPayload(output.parts, decodeFeatureCommandPayload(input.arguments, { repo: pluginInput?.directory || pluginInput?.worktree }));
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|