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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +877 -0
  3. package/assets/agent/backend-builder.md +73 -0
  4. package/assets/agent/codebase-researcher.md +56 -0
  5. package/assets/agent/design-interpreter.md +37 -0
  6. package/assets/agent/frontend-builder.md +74 -0
  7. package/assets/agent/implementation-validator.md +73 -0
  8. package/assets/agent/security-reviewer.md +60 -0
  9. package/assets/agent/spec-writer.md +94 -0
  10. package/assets/agent/story-reader.md +38 -0
  11. package/assets/agent/story-writer.md +39 -0
  12. package/assets/agent/test-verifier.md +73 -0
  13. package/assets/agent/work-decomposer.md +102 -0
  14. package/assets/agent/work-reviewer.md +77 -0
  15. package/assets/command/feature.md +68 -0
  16. package/assets/skills/feature/SCHEMA.md +990 -0
  17. package/assets/skills/feature/SKILL.md +620 -0
  18. package/dist/tui.js +3641 -0
  19. package/package.json +65 -0
  20. package/src/cleanup-sweep-command.js +75 -0
  21. package/src/cleanup-sweep-eligibility.js +581 -0
  22. package/src/cleanup-sweep-output.js +139 -0
  23. package/src/cleanup-sweep-report.js +548 -0
  24. package/src/cleanup-sweep.js +546 -0
  25. package/src/cli-output.js +251 -0
  26. package/src/cli.js +1152 -0
  27. package/src/config.js +231 -0
  28. package/src/cost-attribution.js +327 -0
  29. package/src/cost-report.js +185 -0
  30. package/src/detached-log-supervisor.js +178 -0
  31. package/src/doctor.js +598 -0
  32. package/src/env-snapshot.js +211 -0
  33. package/src/factory-diagnostics.js +429 -0
  34. package/src/factory-paths.js +40 -0
  35. package/src/factory.js +4769 -0
  36. package/src/feature-command-payload.js +378 -0
  37. package/src/git.js +110 -0
  38. package/src/github.js +252 -0
  39. package/src/hardening/atomic-write.js +954 -0
  40. package/src/hardening/line-output.js +139 -0
  41. package/src/hardening/output-policy.js +365 -0
  42. package/src/hardening/process-verification.js +542 -0
  43. package/src/hardening/sensitive-data.js +341 -0
  44. package/src/hardening/terminal-encoding.js +224 -0
  45. package/src/plugin.js +246 -0
  46. package/src/post-pr-ci.js +754 -0
  47. package/src/process-evidence.js +1139 -0
  48. package/src/refs.js +144 -0
  49. package/src/run-state.js +2411 -0
  50. package/src/steering-conflicts.js +77 -0
  51. package/src/telemetry.js +419 -0
  52. package/src/tui-data.js +499 -0
  53. package/src/tui-rendering.js +148 -0
  54. package/src/utils.js +35 -0
  55. package/src/validate.js +1655 -0
  56. package/src/worktrees.js +56 -0
@@ -0,0 +1,185 @@
1
+ import {
2
+ COST_NUMERIC_FIELDS,
3
+ USAGE_NUMERIC_FIELDS,
4
+ rollupBy,
5
+ rollupEntries,
6
+ } from "./cost-attribution.js";
7
+ import {
8
+ FEATURE_FACTORY_PARENT_SPAN_ID,
9
+ FEATURE_FACTORY_TRACEPARENT,
10
+ validateParentSpanId,
11
+ validateTraceparent,
12
+ } from "./telemetry.js";
13
+ import { validateCostAttributionEntries } from "./validate.js";
14
+
15
+ export const COST_REPORT_SCHEMA_VERSION = 1;
16
+
17
+ const NUMERIC_FIELDS = Object.freeze([...USAGE_NUMERIC_FIELDS, ...COST_NUMERIC_FIELDS]);
18
+ const JSON_LITERAL_CONTROL_PATTERN = /[\u007F-\u009F\u2028\u2029\p{Cf}]/gu;
19
+
20
+ export function buildCostReport(runId, attribution, options = {}) {
21
+ const entries = reportEntries(attribution);
22
+ validateCostAttributionEntries(entries, runId);
23
+ const projectedEntries = entries.map(projectNumericNulls);
24
+ const totals = rollupEntries(projectedEntries);
25
+ const byAgent = rollupBy(projectedEntries, "agent");
26
+ const byStep = rollupBy(projectedEntries, "step");
27
+ const bySlice = rollupBy(projectedEntries, "slice_id");
28
+
29
+ const report = {
30
+ schema_version: COST_REPORT_SCHEMA_VERSION,
31
+ run_id: runId,
32
+ status: totals.status,
33
+ entry_count: totals.entry_count,
34
+ request_count: totals.request_count,
35
+ agent_count: Object.keys(byAgent).length,
36
+ step_count: Object.keys(byStep).length,
37
+ slice_count: Object.keys(bySlice).length,
38
+ unattributed_step_entry_count: projectedEntries.filter((entry) => typeof entry.step !== "string" || entry.step.trim().length === 0).length,
39
+ totals,
40
+ by_agent: byAgent,
41
+ by_step: byStep,
42
+ by_slice: bySlice,
43
+ };
44
+
45
+ if (options.telemetry === true) {
46
+ const telemetry = costReportTelemetryCorrelation(options.env ?? process.env);
47
+ if (telemetry) report.telemetry = telemetry;
48
+ }
49
+ return report;
50
+ }
51
+
52
+ export function formatCostReport(report) {
53
+ const lines = [
54
+ `Cost report for ${report.run_id}`,
55
+ "Totals:",
56
+ ` ${formatRollup(report.totals)}`,
57
+ ];
58
+ appendRollupSection(lines, `By agent (${report.agent_count})`, report.by_agent);
59
+ appendRollupSection(lines, `By step (${report.step_count}; unattributed entries=${report.unattributed_step_entry_count})`, report.by_step);
60
+ appendRollupSection(lines, `By slice (${report.slice_count})`, report.by_slice);
61
+ if (report.telemetry) {
62
+ lines.push(
63
+ "Telemetry correlation:",
64
+ ` trace_id=${report.telemetry.trace_id} | parent_span_id=${report.telemetry.parent_span_id}`,
65
+ );
66
+ }
67
+ return lines.join("\n");
68
+ }
69
+
70
+ export function serializeCostReport(report) {
71
+ return JSON.stringify(report, null, 2).replace(JSON_LITERAL_CONTROL_PATTERN, unicodeEscapeText);
72
+ }
73
+
74
+ export function encodeCostReportDisplayLabel(value) {
75
+ const text = String(value);
76
+ let encoded = "\"";
77
+ for (let index = 0; index < text.length; index += 1) {
78
+ const code = text.charCodeAt(index);
79
+ if (code === 0x22) encoded += "\\\"";
80
+ else if (code === 0x5C) encoded += "\\\\";
81
+ else if (code >= 0x20 && code <= 0x7E) encoded += text[index];
82
+ else encoded += unicodeEscape(code);
83
+ }
84
+ return `${encoded}\"`;
85
+ }
86
+
87
+ export function costReportTelemetryCorrelation(env = process.env) {
88
+ const source = env && typeof env === "object" ? env : {};
89
+ const featureFactoryValue = source[FEATURE_FACTORY_TRACEPARENT];
90
+ const standardValue = source.TRACEPARENT;
91
+ const hasFeatureFactoryTraceparent = featureFactoryValue !== undefined && featureFactoryValue !== null;
92
+ const hasStandardTraceparent = standardValue !== undefined && standardValue !== null;
93
+
94
+ const featureFactoryTraceparent = hasFeatureFactoryTraceparent ? validTraceparent(featureFactoryValue) : null;
95
+ const standardTraceparent = hasStandardTraceparent ? validTraceparent(standardValue) : null;
96
+ if (featureFactoryTraceparent && standardTraceparent && featureFactoryTraceparent.traceparent !== standardTraceparent.traceparent) {
97
+ throw telemetryError("FEATURE_FACTORY_TRACEPARENT and TRACEPARENT must match");
98
+ }
99
+
100
+ const traceparent = featureFactoryTraceparent || standardTraceparent;
101
+ const parentValue = source[FEATURE_FACTORY_PARENT_SPAN_ID];
102
+ const hasParentSpanId = parentValue !== undefined && parentValue !== null;
103
+ const parent = hasParentSpanId ? validParentSpanId(parentValue) : null;
104
+ if (parent && !traceparent) throw telemetryError("FEATURE_FACTORY_PARENT_SPAN_ID requires a traceparent");
105
+ if (parent && parent.spanId !== traceparent.parentSpanId) {
106
+ throw telemetryError("FEATURE_FACTORY_PARENT_SPAN_ID must match traceparent parent span id");
107
+ }
108
+ if (!traceparent) return null;
109
+ return {
110
+ trace_id: traceparent.traceId,
111
+ parent_span_id: traceparent.parentSpanId,
112
+ };
113
+ }
114
+
115
+ function reportEntries(attribution) {
116
+ if (attribution === undefined || attribution === null) return [];
117
+ if (!isRecord(attribution)) throw new Error("run.json.cost_attribution must be an object, null, or absent");
118
+ if (attribution.entries === undefined || attribution.entries === null) return [];
119
+ if (!Array.isArray(attribution.entries)) throw new Error("run.json.cost_attribution.entries must be an array, null, or absent");
120
+ return attribution.entries;
121
+ }
122
+
123
+ function projectNumericNulls(entry) {
124
+ const projected = { ...entry };
125
+ for (const field of NUMERIC_FIELDS) {
126
+ if (Object.prototype.hasOwnProperty.call(projected, field) && projected[field] === null) delete projected[field];
127
+ }
128
+ return projected;
129
+ }
130
+
131
+ function appendRollupSection(lines, title, groups) {
132
+ lines.push(`${title}:`);
133
+ const entries = Object.entries(groups);
134
+ if (entries.length === 0) {
135
+ lines.push(" (none)");
136
+ return;
137
+ }
138
+ for (const [label, rollup] of entries) lines.push(` ${encodeCostReportDisplayLabel(label)}: ${formatRollup(rollup)}`);
139
+ }
140
+
141
+ function formatRollup(rollup) {
142
+ const parts = [
143
+ `status=${rollup.status}`,
144
+ `entries=${rollup.entry_count}`,
145
+ `requests=${rollup.request_count}`,
146
+ ];
147
+ for (const field of NUMERIC_FIELDS) if (rollup[field] !== undefined) parts.push(`${field}=${rollup[field]}`);
148
+ if (rollup.cost_currency !== undefined) parts.push(`cost_currency=${rollup.cost_currency}`);
149
+ parts.push(`mixed_currency=${rollup.mixed_currency === true}`);
150
+ const missing = Array.isArray(rollup.missing) && rollup.missing.length > 0
151
+ ? rollup.missing.map(encodeCostReportDisplayLabel).join(",")
152
+ : "none";
153
+ parts.push(`missing=${missing}`);
154
+ return parts.join(" | ");
155
+ }
156
+
157
+ function validTraceparent(value) {
158
+ const result = validateTraceparent(value);
159
+ if (!result.ok) throw telemetryError(result.error);
160
+ return result;
161
+ }
162
+
163
+ function validParentSpanId(value) {
164
+ const result = validateParentSpanId(value);
165
+ if (!result.ok) throw telemetryError(result.error);
166
+ return result;
167
+ }
168
+
169
+ function telemetryError(message) {
170
+ return new Error(`cost-report telemetry: ${message}`);
171
+ }
172
+
173
+ function unicodeEscape(code) {
174
+ return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
175
+ }
176
+
177
+ function unicodeEscapeText(value) {
178
+ let escaped = "";
179
+ for (let index = 0; index < value.length; index += 1) escaped += unicodeEscape(value.charCodeAt(index));
180
+ return escaped;
181
+ }
182
+
183
+ function isRecord(value) {
184
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
185
+ }
@@ -0,0 +1,178 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { createSanitizedLineWriter } from "./hardening/line-output.js";
4
+ import { renderErrorForTerminal } from "./hardening/output-policy.js";
5
+ import { inspectProcessIdentity, readProcessEvidence, recordDetachedProcessEvidence, writeProcessEvidence } from "./process-evidence.js";
6
+ import { timestamp } from "./utils.js";
7
+
8
+ const ABORT_GRACE_MS = 1000;
9
+ const IDENTITY_SETTLE_TIMEOUT_MS = 5000;
10
+ const IDENTITY_SETTLE_INTERVAL_MS = 25;
11
+ let activeChild = null;
12
+
13
+ export async function superviseDetachedLaunch(init, options = {}) {
14
+ validateInit(init);
15
+ const spawnProcess = typeof options.spawnFn === "function" ? options.spawnFn : spawn;
16
+ const send = typeof options.send === "function" ? options.send : sendIpc;
17
+ const append = typeof options.appendFileFn === "function" ? options.appendFileFn : appendFile;
18
+ let child;
19
+ let writer;
20
+ let failedClosed = false;
21
+
22
+ const failClosed = async (error) => {
23
+ if (failedClosed) return;
24
+ failedClosed = true;
25
+ terminateChild(child);
26
+ await markMatchingEvidence(init, child?.pid, "failed-closed", options);
27
+ throw new Error(renderErrorForTerminal(error));
28
+ };
29
+
30
+ try {
31
+ await append(init.log, Buffer.alloc(0));
32
+ child = spawnProcess("opencode", init.commandArgs, {
33
+ cwd: init.repo,
34
+ detached: true,
35
+ env: init.env,
36
+ stdio: ["ignore", "pipe", "pipe"],
37
+ });
38
+ activeChild = child;
39
+ await waitForSpawn(child);
40
+ send({ type: "spawned", pid: child.pid });
41
+
42
+ writer = createSanitizedLineWriter({
43
+ write: (_stream, buffer) => append(init.log, buffer).catch(async (error) => {
44
+ terminateChild(child);
45
+ await markMatchingEvidence(init, child?.pid, "failed-closed", options);
46
+ throw error;
47
+ }),
48
+ });
49
+ child.stdout.pipe(writer.stdout);
50
+ child.stderr.pipe(writer.stderr);
51
+
52
+ if (init.recordEvidence) {
53
+ const stableIdentity = await waitForStableProcessIdentity(child.pid, options);
54
+ recordDetachedProcessEvidence(init.runDir, {
55
+ runId: init.runId,
56
+ executionId: init.executionId,
57
+ pid: child.pid,
58
+ cwd: init.repo,
59
+ commandName: "opencode",
60
+ logRef: init.logRef,
61
+ now: init.now,
62
+ inspectorFn: () => stableIdentity,
63
+ });
64
+ }
65
+
66
+ send({ type: "ready", pid: child.pid });
67
+ const close = waitForClose(child);
68
+ try {
69
+ await Promise.all([close, writer.finished()]);
70
+ } catch (error) {
71
+ await failClosed(error);
72
+ }
73
+ await markMatchingEvidence(init, child.pid, "exited", options);
74
+ activeChild = null;
75
+ return { pid: child.pid, status: "exited" };
76
+ } catch (error) {
77
+ if (child && !failedClosed) {
78
+ try { await failClosed(error); } catch (safeError) { throw safeError; }
79
+ }
80
+ throw new Error(renderErrorForTerminal(error));
81
+ }
82
+ }
83
+
84
+ async function waitForStableProcessIdentity(pid, options = {}) {
85
+ const inspect = typeof options.inspectorFn === "function" ? options.inspectorFn : inspectProcessIdentity;
86
+ const sleep = typeof options.sleepFn === "function" ? options.sleepFn : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
87
+ const clock = typeof options.clock === "function" ? options.clock : Date.now;
88
+ const deadline = clock() + IDENTITY_SETTLE_TIMEOUT_MS;
89
+ let previous = null;
90
+ while (clock() <= deadline) {
91
+ const observed = inspect(pid);
92
+ const commandName = typeof observed?.command_name === "string" ? observed.command_name.trim() : "";
93
+ if (observed?.ok === true && commandName && commandName.toLowerCase() !== "env") {
94
+ const fingerprint = JSON.stringify([observed.inspector, observed.pid, observed.start_marker, commandName, observed.cwd]);
95
+ if (fingerprint === previous) return observed;
96
+ previous = fingerprint;
97
+ } else {
98
+ previous = null;
99
+ }
100
+ await sleep(IDENTITY_SETTLE_INTERVAL_MS);
101
+ }
102
+ throw new Error("detached child identity did not stabilize before readiness");
103
+ }
104
+
105
+ function validateInit(init) {
106
+ if (!init || typeof init !== "object") throw new Error("invalid detached supervisor init");
107
+ if (typeof init.repo !== "string" || !Array.isArray(init.commandArgs) || typeof init.log !== "string") {
108
+ throw new Error("invalid detached supervisor init");
109
+ }
110
+ if (init.recordEvidence && (!init.runDir || !init.runId || !init.executionId || !init.logRef)) {
111
+ throw new Error("invalid detached supervisor evidence init");
112
+ }
113
+ }
114
+
115
+ function waitForSpawn(child) {
116
+ if (Number.isInteger(child?.pid) && child.pid > 0) return Promise.resolve();
117
+ return new Promise((resolve, reject) => {
118
+ child.once("spawn", resolve);
119
+ child.once("error", reject);
120
+ });
121
+ }
122
+
123
+ function waitForClose(child) {
124
+ return new Promise((resolve, reject) => {
125
+ child.once("error", reject);
126
+ child.once("close", (code, signal) => resolve({ code, signal }));
127
+ });
128
+ }
129
+
130
+ function terminateChild(child) {
131
+ if (!Number.isInteger(child?.pid) || child.pid <= 0) return;
132
+ try { child.kill("SIGTERM"); } catch { return; }
133
+ const timer = setTimeout(() => {
134
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
135
+ }, ABORT_GRACE_MS);
136
+ timer.unref?.();
137
+ }
138
+
139
+ async function markMatchingEvidence(init, pid, state, options = {}) {
140
+ if (!init.recordEvidence || !Number.isInteger(pid)) return false;
141
+ const readEvidence = typeof options.readProcessEvidenceFn === "function" ? options.readProcessEvidenceFn : readProcessEvidence;
142
+ const writeEvidence = typeof options.writeProcessEvidenceFn === "function" ? options.writeProcessEvidenceFn : writeProcessEvidence;
143
+ const current = readEvidence(init.runDir, { runId: init.runId });
144
+ if (!current.ok || current.evidence.execution_id !== init.executionId || current.evidence.pid !== pid || current.evidence.state !== "running") return false;
145
+ writeEvidence(init.runDir, {
146
+ ...current.evidence,
147
+ state,
148
+ updated_at: timestamp(options.now),
149
+ });
150
+ return true;
151
+ }
152
+
153
+ function sendIpc(message) {
154
+ if (typeof process.send !== "function" || !process.connected) throw new Error("detached supervisor IPC is unavailable");
155
+ process.send(message);
156
+ }
157
+
158
+ if (typeof process.send === "function") {
159
+ let started = false;
160
+ process.once("message", async (message) => {
161
+ if (message?.type !== "init" || started) return;
162
+ started = true;
163
+ try {
164
+ await superviseDetachedLaunch(message);
165
+ process.disconnect?.();
166
+ } catch (error) {
167
+ try { sendIpc({ type: "error", error: renderErrorForTerminal(error) }); } catch { /* launcher disconnected */ }
168
+ process.exitCode = 1;
169
+ process.disconnect?.();
170
+ }
171
+ });
172
+ process.on("message", (message) => {
173
+ if (message?.type === "abort") {
174
+ terminateChild(activeChild);
175
+ process.exitCode = 1;
176
+ }
177
+ });
178
+ }