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
package/src/refs.js ADDED
@@ -0,0 +1,144 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { assertContainedPath, physicalPath, requireNonEmptyString } from "./utils.js";
5
+
6
+ const DURABLE_ROOTS = Object.freeze(["artifacts", "evidence", "reviews", "gates", "steering"]);
7
+
8
+ export function hashFile(file, options = {}) {
9
+ const mode = options.mode || "raw";
10
+ const data = mode === "text" ? readFileSync(file, "utf8") : readFileSync(file);
11
+ return `sha256:${createHash("sha256").update(data).digest("hex")}`;
12
+ }
13
+
14
+ export function hashValue(value) {
15
+ return `sha256:${createHash("sha256").update(canonicalJson(value), "utf8").digest("hex")}`;
16
+ }
17
+
18
+ export function canonicalizeGithubPrUrl(value) {
19
+ if (typeof value !== "string" || value.trim() === "") throw new Error("GitHub PR URL must be a non-empty string");
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(value.trim());
23
+ } catch {
24
+ throw new Error("GitHub PR URL must be a valid URL");
25
+ }
26
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") throw new Error("GitHub PR URL must use https://github.com");
27
+ const segments = parsed.pathname.split("/").filter(Boolean);
28
+ if (segments.length !== 4 || segments[2] !== "pull" || !/^\d+$/u.test(segments[3])) {
29
+ throw new Error("GitHub PR URL must have shape https://github.com/OWNER/REPO/pull/NUMBER");
30
+ }
31
+ return `https://github.com/${segments[0]}/${segments[1]}/pull/${segments[3]}`;
32
+ }
33
+
34
+ export function githubPrUrlParts(value) {
35
+ const canonical = canonicalizeGithubPrUrl(value);
36
+ const segments = new URL(canonical).pathname.split("/").filter(Boolean);
37
+ return {
38
+ url: canonical,
39
+ owner: segments[0],
40
+ repo: segments[1],
41
+ repository: `${segments[0]}/${segments[1]}`,
42
+ number: Number(segments[3]),
43
+ };
44
+ }
45
+
46
+ export function resolveDurableRoots(runDir) {
47
+ const runRealPath = physicalPath(runDir, "runDir", { mustExist: true });
48
+ const roots = { run_dir: runRealPath };
49
+ for (const name of DURABLE_ROOTS) roots[name] = resolveDurableRoot(runRealPath, name, { mustExist: false });
50
+ return roots;
51
+ }
52
+
53
+ export function resolveDurableRef(runDirOrRoots, ref, rootName, options = {}) {
54
+ if (!DURABLE_ROOTS.includes(rootName)) throw new Error(`unknown durable root '${rootName}'`);
55
+ const roots = typeof runDirOrRoots === "string" ? resolveDurableRoots(runDirOrRoots) : runDirOrRoots;
56
+ if (!roots || typeof roots !== "object") throw new Error("durable roots must be an object");
57
+ const root = roots[rootName];
58
+ if (typeof root !== "string" || root.trim() === "") throw new Error(`missing durable root '${rootName}'`);
59
+ const segments = normalizeDurableRefSegments(ref, rootName);
60
+ const candidate = resolve(join(root, ...segments));
61
+ const rootPhysical = resolveDurableRoot(roots.run_dir || root, rootName, { mustExist: false, rootOverride: root });
62
+ assertContainedPath(rootPhysical, candidate, ref);
63
+ if (options.mustExist !== false) {
64
+ if (!existsSync(candidate)) throw new Error(`missing ${rootName} ref: ${ref}`);
65
+ if (lstatSync(candidate).isSymbolicLink()) throw new Error(`${rootName} ref must not be a symlink: ${ref}`);
66
+ return { ref, path: physicalPath(candidate, `${rootName} ref`, { mustExist: true }) };
67
+ }
68
+ if (existsSync(candidate) && lstatSync(candidate).isSymbolicLink()) throw new Error(`${rootName} ref must not be a symlink: ${ref}`);
69
+ return { ref, path: resolveWritableDurablePath(rootPhysical, candidate, ref) };
70
+ }
71
+
72
+ export function resolveEvidenceRef(runDirOrRoots, ref, options = {}) {
73
+ return resolveDurableRef(runDirOrRoots, ref, "evidence", options);
74
+ }
75
+
76
+ export function resolveReviewRef(runDirOrRoots, ref, options = {}) {
77
+ return resolveDurableRef(runDirOrRoots, ref, "reviews", options);
78
+ }
79
+
80
+ export function resolveArtifactRef(runDirOrRoots, ref, options = {}) {
81
+ return resolveDurableRef(runDirOrRoots, ref, "artifacts", options);
82
+ }
83
+
84
+ export function resolveGateRef(runDirOrRoots, ref, options = {}) {
85
+ return resolveDurableRef(runDirOrRoots, ref, "gates", options);
86
+ }
87
+
88
+ export function resolveSteeringRef(runDirOrRoots, ref, options = {}) {
89
+ return resolveDurableRef(runDirOrRoots, ref, "steering", options);
90
+ }
91
+
92
+ function resolveDurableRoot(runDir, rootName, options = {}) {
93
+ const rootPath = options.rootOverride ? resolve(options.rootOverride) : resolve(join(runDir, rootName));
94
+ if (!existsSync(rootPath)) return rootPath;
95
+ if (lstatSync(rootPath).isSymbolicLink()) throw new Error(`${rootName} root must not be a symlink: ${rootPath}`);
96
+ const physical = physicalPath(rootPath, `${rootName} root`, { mustExist: true });
97
+ if (!statSync(physical).isDirectory()) throw new Error(`${rootName} root must be a directory: ${rootPath}`);
98
+ const runPhysical = existsSync(runDir) ? physicalPath(runDir, "runDir", { mustExist: true }) : resolve(runDir);
99
+ assertContainedPath(runPhysical, physical, `${rootName}/`);
100
+ return physical;
101
+ }
102
+
103
+ function normalizeDurableRefSegments(ref, rootName) {
104
+ const value = requireNonEmptyString(ref, `${rootName} ref`);
105
+ if (isAbsolute(value) || value.includes("\\")) throw new Error(`${rootName} ref must be a relative forward-slash path`);
106
+ const segments = value.split("/");
107
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
108
+ throw new Error(`${rootName} ref must not contain empty, '.' or '..' path segments`);
109
+ }
110
+ if (segments[0] === rootName) return segments.slice(1);
111
+ if (DURABLE_ROOTS.includes(segments[0]) && segments[0] !== rootName) throw new Error(`${rootName} ref must stay under ${rootName}/`);
112
+ return segments;
113
+ }
114
+
115
+ function resolveWritableDurablePath(rootPhysical, candidate, label) {
116
+ let existing = candidate;
117
+ const missing = [];
118
+ while (!existsSync(existing)) {
119
+ missing.unshift(basename(existing));
120
+ const parent = dirname(existing);
121
+ if (parent === existing) break;
122
+ existing = parent;
123
+ }
124
+ const existingPhysical = physicalPath(existing, label, { mustExist: true });
125
+ assertWritableAncestor(rootPhysical, existingPhysical, label);
126
+ if (missing.length > 0 && !statSync(existingPhysical).isDirectory()) throw new Error(`${label} parent must be a directory`);
127
+ const writable = resolve(join(existingPhysical, ...missing));
128
+ assertContainedPath(rootPhysical, writable, label);
129
+ return writable;
130
+ }
131
+
132
+ function assertWritableAncestor(rootPhysical, existingPhysical, label) {
133
+ try {
134
+ assertContainedPath(rootPhysical, existingPhysical, label);
135
+ } catch {
136
+ assertContainedPath(existingPhysical, rootPhysical, label);
137
+ }
138
+ }
139
+
140
+ function canonicalJson(value) {
141
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
142
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
143
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
144
+ }