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,211 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { git } from "./git.js";
|
|
6
|
+
import plugin from "./plugin.js";
|
|
7
|
+
import { timestamp } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
const SENSITIVE_ENV_KEY_PATTERN = /(?:secret|token|password|passwd|pwd|api[_-]?key|private[_-]?key|credential|authorization|auth[_-]?header|access[_-]?key|bearer|cookie)/iu;
|
|
11
|
+
const SENSITIVE_ENV_VALUE_PATTERN = /(?:secret|token|password|passwd|api[_-]?key|private[_-]?key)/iu;
|
|
12
|
+
const TOKEN_SHAPED_ENV_VALUE_PATTERNS = [
|
|
13
|
+
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/u,
|
|
14
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/u,
|
|
15
|
+
/\bhc_[A-Za-z0-9][A-Za-z0-9_-]{10,}\b/iu,
|
|
16
|
+
/\bsk-proj[-_][A-Za-z0-9_-]{20,}\b/u,
|
|
17
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/u,
|
|
18
|
+
/\bxox[abp][_-][A-Za-z0-9-]{10,}(?:-[A-Za-z0-9-]{10,})*\b/u,
|
|
19
|
+
/\bglpat-[A-Za-z0-9_-]{20,}\b/u,
|
|
20
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/iu,
|
|
21
|
+
/\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/u,
|
|
22
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/u,
|
|
23
|
+
/(?:^|[^A-Za-z0-9-])(?:[A-Fa-f0-9]{32,})(?=$|[^A-Za-z0-9-])/u,
|
|
24
|
+
/(?:https?|ssh|git|ftp):\/\/[^/\s:@]+:[^/\s@]+@/iu,
|
|
25
|
+
];
|
|
26
|
+
const HIGH_ENTROPY_SINGLE_TOKEN_MIN_LENGTH = 32;
|
|
27
|
+
const HIGH_ENTROPY_MIN_SHANNON = 3.5;
|
|
28
|
+
const SAFE_HIGH_ENTROPY_ENV_KEY_PATTERN = /^(?:OTEL_EXPORTER_OTLP(?:_(?:TRACES|METRICS|LOGS))?_(?:ENDPOINT|HEADERS|PROTOCOL|TIMEOUT|COMPRESSION|INSECURE|CERTIFICATE)|FEATURE_FACTORY_OTEL_ENABLED)$/u;
|
|
29
|
+
export const REDACTED_ENV_VALUE = "[redacted]";
|
|
30
|
+
|
|
31
|
+
export async function collectEnv(options = {}) {
|
|
32
|
+
const resolvedConfig = await resolvePluginConfig(options.pluginOptions || {});
|
|
33
|
+
return {
|
|
34
|
+
feature_factory_version: packageVersion(),
|
|
35
|
+
opencode_version: commandOutput("opencode", ["--version"]),
|
|
36
|
+
plugin_spec: options.pluginSpec || "opencode-feature-factory",
|
|
37
|
+
resolved_models: Object.fromEntries(Object.entries(resolvedConfig.agent || {}).map(([name, agent]) => [name, agent.model || null])),
|
|
38
|
+
resolved_variants: Object.fromEntries(Object.entries(resolvedConfig.agent || {}).map(([name, agent]) => [name, agent.variant || null])),
|
|
39
|
+
driver: {
|
|
40
|
+
kind: options.driverKind || "cli",
|
|
41
|
+
name: "feature-factory",
|
|
42
|
+
version: packageVersion(),
|
|
43
|
+
},
|
|
44
|
+
capabilities: detectCapabilities(options.cwd || process.cwd()),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function collectRunDebugSnapshot({ cwd, driverKind, pluginSpec, pluginOptions, event, now } = {}) {
|
|
49
|
+
return {
|
|
50
|
+
collected_at: timestamp(now, "environment snapshot timestamp"),
|
|
51
|
+
event: stringValue(event) ? event.trim() : "run-created",
|
|
52
|
+
diagnostic_only: true,
|
|
53
|
+
env: scrubSecretEnv(await collectEnv({ cwd, driverKind, pluginSpec, pluginOptions })),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolvePluginConfig(pluginOptions = {}) {
|
|
58
|
+
const hooks = await plugin({}, pluginOptions);
|
|
59
|
+
const cfg = {};
|
|
60
|
+
hooks.config(cfg);
|
|
61
|
+
return cfg;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function detectCapabilities(cwd = process.cwd()) {
|
|
65
|
+
return {
|
|
66
|
+
git: commandOk("git", ["--version"]),
|
|
67
|
+
gh: commandOk("gh", ["--version"]),
|
|
68
|
+
gh_auth: commandOk("gh", ["auth", "status"]),
|
|
69
|
+
opencode: commandOk("opencode", ["--version"]),
|
|
70
|
+
opencode_run_command: opencodeRunSupports("--command"),
|
|
71
|
+
opencode_run_dir: opencodeRunSupports("--dir"),
|
|
72
|
+
git_repo: commandOk("git", ["rev-parse", "--show-toplevel"], cwd),
|
|
73
|
+
base_branch: detectBaseBranch(cwd),
|
|
74
|
+
factory_gitignored: gitIgnored(cwd, ".opencode/factory/"),
|
|
75
|
+
worktrees_gitignored: gitIgnored(cwd, ".opencode/worktrees/"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scrubSecretEnv(value) {
|
|
80
|
+
if (Array.isArray(value)) return value.map((item) => scrubSecretEnv(item));
|
|
81
|
+
if (typeof value === "string") return isSensitiveEnvValue(value) ? REDACTED_ENV_VALUE : value;
|
|
82
|
+
if (!value || typeof value !== "object") return value;
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
Object.entries(value)
|
|
85
|
+
.filter(([key]) => !isSensitiveEnvKey(key))
|
|
86
|
+
.map(([key, item]) => [key, scrubSecretEnv(item)]),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isSensitiveEnvKey(key) {
|
|
91
|
+
const string = String(key ?? "");
|
|
92
|
+
return SENSITIVE_ENV_KEY_PATTERN.test(string) || isSecretShapedEnvKey(string);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isSensitiveEnvValue(value) {
|
|
96
|
+
if (typeof value !== "string") return false;
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
if (!trimmed) return false;
|
|
99
|
+
return SENSITIVE_ENV_VALUE_PATTERN.test(trimmed)
|
|
100
|
+
|| TOKEN_SHAPED_ENV_VALUE_PATTERNS.some((pattern) => pattern.test(trimmed))
|
|
101
|
+
|| credentialBearingUrl(trimmed)
|
|
102
|
+
|| highEntropySingleToken(trimmed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isSecretShapedEnvKey(key) {
|
|
106
|
+
const trimmed = String(key ?? "").trim();
|
|
107
|
+
if (!trimmed) return false;
|
|
108
|
+
return TOKEN_SHAPED_ENV_VALUE_PATTERNS.some((pattern) => pattern.test(trimmed))
|
|
109
|
+
|| credentialBearingUrl(trimmed)
|
|
110
|
+
|| highEntropySecretKey(trimmed);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function packageVersion() {
|
|
114
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
115
|
+
return pkg.version || "unknown";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function commandOk(command, args, cwd = process.cwd()) {
|
|
119
|
+
if (command === "git") return git(cwd, args).ok;
|
|
120
|
+
try {
|
|
121
|
+
execFileSync(command, args, { cwd, stdio: "ignore", timeout: 10000, maxBuffer: 1024 * 1024 });
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function commandOutput(command, args, cwd = process.cwd()) {
|
|
129
|
+
if (command === "git") {
|
|
130
|
+
const proc = git(cwd, args);
|
|
131
|
+
return proc.ok ? (proc.stdout || proc.stderr || "").trim() : null;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, maxBuffer: 1024 * 1024 }).trim();
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function opencodeRunSupports(flag) {
|
|
141
|
+
const help = opencodeRunHelpText();
|
|
142
|
+
if (!help) return false;
|
|
143
|
+
// Token-boundary match: a bare substring test would let '--dir' match a
|
|
144
|
+
// future '--directory' (or '--command' match '--commands') and report
|
|
145
|
+
// support that does not exist.
|
|
146
|
+
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
147
|
+
return new RegExp(`(?:^|[\\s,=])${escaped}(?:$|[\\s,=])`, "mu").test(help);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `opencode run --help` prints its usage to stderr and may exit non-zero, so
|
|
151
|
+
// capture both streams instead of relying on stdout-only commandOutput().
|
|
152
|
+
function opencodeRunHelpText() {
|
|
153
|
+
try {
|
|
154
|
+
const proc = spawnSync("opencode", ["run", "--help"], {
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
timeout: 10000,
|
|
157
|
+
maxBuffer: 1024 * 1024,
|
|
158
|
+
});
|
|
159
|
+
return `${proc.stdout || ""}\n${proc.stderr || ""}`.trim() || null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function detectBaseBranch(cwd) {
|
|
166
|
+
const symbolic = commandOutput("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], cwd);
|
|
167
|
+
if (symbolic) return symbolic.replace(/^origin\//u, "");
|
|
168
|
+
for (const candidate of ["main", "master", "development", "develop"]) {
|
|
169
|
+
if (commandOk("git", ["rev-parse", "--verify", `origin/${candidate}`], cwd)) return candidate;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function gitIgnored(cwd, path) {
|
|
175
|
+
if (!existsSync(join(cwd, ".git")) && !commandOk("git", ["rev-parse", "--show-toplevel"], cwd)) return null;
|
|
176
|
+
return commandOk("git", ["check-ignore", path], cwd);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function credentialBearingUrl(value) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = new URL(value);
|
|
182
|
+
return Boolean(parsed.username || parsed.password);
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function highEntropySingleToken(value) {
|
|
189
|
+
if (value.length < HIGH_ENTROPY_SINGLE_TOKEN_MIN_LENGTH) return false;
|
|
190
|
+
if (/\s/u.test(value)) return false;
|
|
191
|
+
if (!/^[A-Za-z0-9._~+/=-]+$/u.test(value)) return false;
|
|
192
|
+
return shannonEntropy(value) >= HIGH_ENTROPY_MIN_SHANNON;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function highEntropySecretKey(value) {
|
|
196
|
+
if (SAFE_HIGH_ENTROPY_ENV_KEY_PATTERN.test(value)) return false;
|
|
197
|
+
return highEntropySingleToken(value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function shannonEntropy(value) {
|
|
201
|
+
const counts = new Map();
|
|
202
|
+
for (const char of value) counts.set(char, (counts.get(char) || 0) + 1);
|
|
203
|
+
return [...counts.values()].reduce((entropy, count) => {
|
|
204
|
+
const probability = count / value.length;
|
|
205
|
+
return entropy - probability * Math.log2(probability);
|
|
206
|
+
}, 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function stringValue(value) {
|
|
210
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
211
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
TERMINAL_RUN_STATUSES,
|
|
5
|
+
pendingProtectedGate,
|
|
6
|
+
runSlicesMatchPlan,
|
|
7
|
+
validateFactoryLock,
|
|
8
|
+
validateHeartbeatState,
|
|
9
|
+
validateProcessSidecar,
|
|
10
|
+
validateRun,
|
|
11
|
+
validateSlicesPlan,
|
|
12
|
+
} from "./validate.js";
|
|
13
|
+
import { PROCESS_EVIDENCE_FILE } from "./process-evidence.js";
|
|
14
|
+
import { hasInFlightHeartbeatWork } from "./run-state.js";
|
|
15
|
+
import { physicalPath, timestamp } from "./utils.js";
|
|
16
|
+
import { projectDiagnosticData } from "./hardening/output-policy.js";
|
|
17
|
+
import {
|
|
18
|
+
probeLegacyBooleanLiveness,
|
|
19
|
+
probeProcessLiveness,
|
|
20
|
+
publicLivenessBoolean,
|
|
21
|
+
} from "./hardening/process-verification.js";
|
|
22
|
+
|
|
23
|
+
export const DIAGNOSTIC_SCHEMA_VERSION = 1;
|
|
24
|
+
export const HEARTBEAT_FILE = "heartbeat.json";
|
|
25
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
|
|
26
|
+
export const MIN_STALE_HEARTBEAT_MS = 120000;
|
|
27
|
+
|
|
28
|
+
const CONDITIONS = Object.freeze([
|
|
29
|
+
Object.freeze({ condition: "invalid-run-state", classification: "invalid", severity: "critical", status: "error", message: "Factory run state is invalid or cannot be parsed.", action: "Treat the run as untrusted until run.json and required sidecars validate." }),
|
|
30
|
+
Object.freeze({ condition: "missing-worktree", classification: "blocked", severity: "error", status: "error", message: "A recorded worktree path is missing or inaccessible.", action: "Restore the worktree or complete cleanup/recovery from durable state." }),
|
|
31
|
+
Object.freeze({ condition: "missing-heartbeat-process", classification: "recoverable", severity: "warning", status: "warning", message: "The heartbeat helper process recorded in heartbeat.json is not alive.", action: "Inspect the run log and validate durable state before resuming; do not infer run failure from PID liveness alone." }),
|
|
32
|
+
Object.freeze({ condition: "stale-heartbeat", classification: "recoverable", severity: "warning", status: "warning", message: "Heartbeat has not advanced within the stale threshold.", action: "Inspect the run log and validate durable state before resuming; do not restart blindly." }),
|
|
33
|
+
Object.freeze({ condition: "protected-gate", classification: "needs-human", severity: "warning", status: "warning", message: "Run is waiting at a protected human gate.", action: "Answer the pending protected gate or stop the run; heartbeat liveness alarms are suppressed while waiting." }),
|
|
34
|
+
Object.freeze({ condition: "terminal-run", classification: "terminal", severity: "info", status: "ok", message: "Run is terminal.", action: "Read terminal_result for the durable outcome." }),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const DIAGNOSTIC_CONDITIONS = Object.freeze(CONDITIONS.map((item) => item.condition));
|
|
38
|
+
export const DIAGNOSTIC_CLASSIFICATIONS = Object.freeze(["healthy", "recoverable", "blocked", "needs-human", "terminal", "invalid"]);
|
|
39
|
+
export const DIAGNOSTIC_STATUSES = Object.freeze(["ok", "warning", "error"]);
|
|
40
|
+
export const DIAGNOSTIC_SEVERITIES = Object.freeze(["info", "warning", "error", "critical"]);
|
|
41
|
+
|
|
42
|
+
const CONDITION_BY_NAME = new Map(CONDITIONS.map((item, index) => [item.condition, { ...item, index }]));
|
|
43
|
+
const TERMINAL_OVERRIDES = Object.freeze({
|
|
44
|
+
completed: Object.freeze({ classification: "terminal", severity: "info", status: "ok" }),
|
|
45
|
+
partial: Object.freeze({ classification: "terminal", severity: "info", status: "ok" }),
|
|
46
|
+
blocked: Object.freeze({ classification: "blocked", severity: "error", status: "error" }),
|
|
47
|
+
"needs-human": Object.freeze({ classification: "needs-human", severity: "warning", status: "warning" }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const TERMINAL_STATUSES = new Set(TERMINAL_RUN_STATUSES);
|
|
51
|
+
const DIAGNOSTIC_IDENTITY_FIELDS = Object.freeze({
|
|
52
|
+
status: new Set(DIAGNOSTIC_STATUSES),
|
|
53
|
+
severity: new Set(DIAGNOSTIC_SEVERITIES),
|
|
54
|
+
classification: new Set(DIAGNOSTIC_CLASSIFICATIONS),
|
|
55
|
+
condition: new Set(DIAGNOSTIC_CONDITIONS),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export function diagnoseRunDir(runDir, options = {}) {
|
|
59
|
+
return diagnoseRunFile(join(runDir, "run.json"), { ...options, runDir });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function diagnoseRunFile(runFile, options = {}) {
|
|
63
|
+
const runDir = options.runDir || dirname(runFile);
|
|
64
|
+
const checkedAt = timestamp(options.now);
|
|
65
|
+
let raw;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(runFile, "utf8");
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return diagnosticEnvelope([
|
|
71
|
+
diagnosticItem("invalid-run-state", {
|
|
72
|
+
checkedAt,
|
|
73
|
+
authoritative: false,
|
|
74
|
+
message: `Factory run state is unavailable: ${error.message}`,
|
|
75
|
+
evidence: { source: "run.json", run_dir: runDir, run_path: runFile, error: error.message },
|
|
76
|
+
}),
|
|
77
|
+
], { checkedAt, authoritative: false });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
return diagnoseRunObject(JSON.parse(raw), { ...options, runDir, runFile, checkedAt });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return diagnosticEnvelope([
|
|
84
|
+
diagnosticItem("invalid-run-state", {
|
|
85
|
+
checkedAt,
|
|
86
|
+
authoritative: false,
|
|
87
|
+
message: `Factory run state is invalid: ${error.message}`,
|
|
88
|
+
evidence: { source: "run.json", run_dir: runDir, run_path: runFile, error: error.message },
|
|
89
|
+
}),
|
|
90
|
+
], { checkedAt, authoritative: false });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function diagnoseRunObject(input, options = {}) {
|
|
95
|
+
const checkedAt = options.checkedAt || timestamp(options.now);
|
|
96
|
+
const runDir = options.runDir || null;
|
|
97
|
+
const runFile = options.runFile || (runDir ? join(runDir, "run.json") : null);
|
|
98
|
+
const items = [];
|
|
99
|
+
let run;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
run = validateRun(input);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return diagnosticEnvelope([
|
|
105
|
+
diagnosticItem("invalid-run-state", {
|
|
106
|
+
checkedAt,
|
|
107
|
+
authoritative: false,
|
|
108
|
+
message: `Factory run state is invalid: ${error.message}`,
|
|
109
|
+
evidence: { source: "run.json", run_dir: runDir, run_path: runFile, error: error.message },
|
|
110
|
+
}),
|
|
111
|
+
], { checkedAt, authoritative: false });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const invalidSidecar = runDir ? inspectSidecars(runDir, checkedAt, run) : null;
|
|
115
|
+
if (invalidSidecar) {
|
|
116
|
+
return diagnosticEnvelope([invalidSidecar], { checkedAt, authoritative: false });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const terminal = TERMINAL_STATUSES.has(run.status);
|
|
120
|
+
const authoritative = Boolean(runDir);
|
|
121
|
+
const protectedGate = pendingProtectedGate(run);
|
|
122
|
+
|
|
123
|
+
if (terminal) {
|
|
124
|
+
items.push(terminalRunItem(run, { checkedAt, runDir, runFile, authoritative }));
|
|
125
|
+
return diagnosticEnvelope(items, { checkedAt, authoritative });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (protectedGate) {
|
|
129
|
+
items.push(diagnosticItem("protected-gate", {
|
|
130
|
+
checkedAt,
|
|
131
|
+
authoritative,
|
|
132
|
+
message: `Run is waiting at protected gate '${protectedGate}'.`,
|
|
133
|
+
evidence: { source: "run.json", run_dir: runDir, run_path: runFile, gate: protectedGate },
|
|
134
|
+
}));
|
|
135
|
+
} else if (hasInFlightHeartbeatWork(run)) {
|
|
136
|
+
const heartbeat = inspectHeartbeat(run, { ...options, checkedAt, runDir });
|
|
137
|
+
if (heartbeat.invalid) {
|
|
138
|
+
items.push(diagnosticItem("invalid-run-state", {
|
|
139
|
+
checkedAt,
|
|
140
|
+
authoritative: false,
|
|
141
|
+
message: `Heartbeat state is invalid: ${heartbeat.error}`,
|
|
142
|
+
evidence: { source: "heartbeat.json", run_dir: runDir, heartbeat_path: heartbeat.path, error: heartbeat.error },
|
|
143
|
+
}));
|
|
144
|
+
} else {
|
|
145
|
+
if (heartbeat.missingProcess) items.push(heartbeat.missingProcess);
|
|
146
|
+
if (heartbeat.stale) items.push(heartbeat.stale);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const missingWorktree = inspectWorktree(run, { ...options, checkedAt, runDir, authoritative });
|
|
151
|
+
if (missingWorktree) items.push(missingWorktree);
|
|
152
|
+
|
|
153
|
+
return diagnosticEnvelope(items, { checkedAt, authoritative });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function diagnosticEnvelope(items = [], options = {}) {
|
|
157
|
+
const checkedAt = options.checkedAt || timestamp(options.now);
|
|
158
|
+
const normalized = items.map((item) => normalizeDiagnosticItem(item, checkedAt));
|
|
159
|
+
const aggregate = aggregateDiagnostics(normalized);
|
|
160
|
+
const envelope = {
|
|
161
|
+
schema_version: DIAGNOSTIC_SCHEMA_VERSION,
|
|
162
|
+
checked_at: checkedAt,
|
|
163
|
+
authoritative: Boolean(options.authoritative) && aggregate.classification !== "invalid",
|
|
164
|
+
status: aggregate.status,
|
|
165
|
+
severity: aggregate.severity,
|
|
166
|
+
classification: aggregate.classification,
|
|
167
|
+
summary: aggregate.summary,
|
|
168
|
+
items: normalized,
|
|
169
|
+
};
|
|
170
|
+
return plainDiagnosticProjection(projectDiagnosticData(envelope, {
|
|
171
|
+
validatedIdentityPaths: validatedDiagnosticIdentityPaths(envelope),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function aggregateDiagnostics(items = []) {
|
|
176
|
+
if (!items.length) {
|
|
177
|
+
return { classification: "healthy", status: "ok", severity: "info", summary: "No diagnostics", primary: null };
|
|
178
|
+
}
|
|
179
|
+
const primary = items
|
|
180
|
+
.map((item, index) => ({ item, index }))
|
|
181
|
+
.sort((left, right) => conditionOrder(left.item.condition) - conditionOrder(right.item.condition) || left.index - right.index)[0].item;
|
|
182
|
+
return {
|
|
183
|
+
classification: primary.classification,
|
|
184
|
+
status: primary.status,
|
|
185
|
+
severity: primary.severity,
|
|
186
|
+
summary: primary.message,
|
|
187
|
+
primary,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function diagnosticItem(condition, options = {}) {
|
|
192
|
+
const definition = condition === "terminal-run"
|
|
193
|
+
? terminalDefinition(options.terminalStatus)
|
|
194
|
+
: CONDITION_BY_NAME.get(condition);
|
|
195
|
+
if (!definition) throw new Error(`unknown diagnostic condition: ${condition}`);
|
|
196
|
+
const checkedAt = options.checkedAt || timestamp(options.now);
|
|
197
|
+
return normalizeDiagnosticItem({
|
|
198
|
+
condition,
|
|
199
|
+
classification: definition.classification,
|
|
200
|
+
severity: definition.severity,
|
|
201
|
+
status: definition.status,
|
|
202
|
+
message: options.message || definition.message,
|
|
203
|
+
action: options.action || definition.action || terminalAction(options.terminalStatus),
|
|
204
|
+
authoritative: Boolean(options.authoritative),
|
|
205
|
+
checked_at: checkedAt,
|
|
206
|
+
evidence: options.evidence || {},
|
|
207
|
+
}, checkedAt);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeDiagnosticItem(item, checkedAt) {
|
|
211
|
+
return {
|
|
212
|
+
condition: item.condition,
|
|
213
|
+
classification: item.classification,
|
|
214
|
+
severity: item.severity,
|
|
215
|
+
status: item.status,
|
|
216
|
+
message: item.message,
|
|
217
|
+
action: item.action,
|
|
218
|
+
authoritative: Boolean(item.authoritative),
|
|
219
|
+
checked_at: item.checked_at || checkedAt,
|
|
220
|
+
evidence: item.evidence && typeof item.evidence === "object" && !Array.isArray(item.evidence) ? item.evidence : {},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function terminalRunItem(run, options) {
|
|
225
|
+
return diagnosticItem("terminal-run", {
|
|
226
|
+
...options,
|
|
227
|
+
terminalStatus: run.status,
|
|
228
|
+
message: terminalMessage(run.status),
|
|
229
|
+
evidence: { source: "run.json", run_dir: options.runDir, run_path: options.runFile, run_status: run.status },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function inspectSidecars(runDir, checkedAt, run) {
|
|
234
|
+
const checks = [
|
|
235
|
+
{ path: join(runDir, HEARTBEAT_FILE), source: HEARTBEAT_FILE, validator: validateHeartbeatState },
|
|
236
|
+
{ path: join(runDir, "factory.lock"), source: "factory.lock", validator: validateFactoryLock },
|
|
237
|
+
{ path: join(runDir, PROCESS_EVIDENCE_FILE), source: PROCESS_EVIDENCE_FILE, validator: (value) => validateProcessSidecar(value, { runDir, runId: run.run_id }), failClosed: true },
|
|
238
|
+
{ path: join(runDir, "plan", "slices.json"), source: "plan/slices.json", validator: (value) => validateSlicesPlan(value, { enforceDependencyDepth: !runSlicesMatchPlan(run, value) }) },
|
|
239
|
+
];
|
|
240
|
+
for (const check of checks) {
|
|
241
|
+
if (!existsSync(check.path)) continue;
|
|
242
|
+
let parsed = null;
|
|
243
|
+
try {
|
|
244
|
+
parsed = JSON.parse(readFileSync(check.path, "utf8"));
|
|
245
|
+
const sidecar = check.validator(parsed);
|
|
246
|
+
if ((check.source === HEARTBEAT_FILE || check.source === "factory.lock") && sidecar.run_id !== run.run_id) {
|
|
247
|
+
return diagnosticItem("invalid-run-state", {
|
|
248
|
+
checkedAt,
|
|
249
|
+
authoritative: false,
|
|
250
|
+
message: `Factory sidecar state contradicts run.json: ${check.source}.run_id does not match run.run_id.`,
|
|
251
|
+
evidence: {
|
|
252
|
+
source: check.source,
|
|
253
|
+
run_dir: runDir,
|
|
254
|
+
path: check.path,
|
|
255
|
+
error: `${check.source}.run_id does not match run.run_id`,
|
|
256
|
+
run_id: run.run_id,
|
|
257
|
+
sidecar_run_id: sidecar.run_id,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const failClosed = Boolean(check.failClosed);
|
|
263
|
+
return diagnosticItem("invalid-run-state", {
|
|
264
|
+
checkedAt,
|
|
265
|
+
authoritative: false,
|
|
266
|
+
message: failClosed
|
|
267
|
+
? `Factory process sidecar state is invalid; cancellation must fail closed: ${error.message}`
|
|
268
|
+
: `Factory sidecar state is invalid: ${error.message}`,
|
|
269
|
+
action: failClosed
|
|
270
|
+
? "Treat process evidence as untrusted; do not signal any process until the sidecar is repaired or manually verified."
|
|
271
|
+
: undefined,
|
|
272
|
+
evidence: {
|
|
273
|
+
source: check.source,
|
|
274
|
+
run_dir: runDir,
|
|
275
|
+
path: check.path,
|
|
276
|
+
error: error.message,
|
|
277
|
+
...(failClosed ? { fail_closed: true, sidecar_run_id: parsed?.run_id || null } : {}),
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function inspectHeartbeat(run, options) {
|
|
286
|
+
const checkedAt = options.checkedAt;
|
|
287
|
+
const runDir = options.runDir;
|
|
288
|
+
const heartbeatPath = runDir ? join(runDir, HEARTBEAT_FILE) : null;
|
|
289
|
+
const nowMs = Date.parse(checkedAt);
|
|
290
|
+
let heartbeat = null;
|
|
291
|
+
let source = "run.json";
|
|
292
|
+
|
|
293
|
+
if (heartbeatPath && existsSync(heartbeatPath)) {
|
|
294
|
+
try {
|
|
295
|
+
heartbeat = validateHeartbeatState(JSON.parse(readFileSync(heartbeatPath, "utf8")));
|
|
296
|
+
if (heartbeat.run_id !== run.run_id) {
|
|
297
|
+
return { invalid: true, error: "heartbeat.run_id does not match run.run_id", path: heartbeatPath };
|
|
298
|
+
}
|
|
299
|
+
source = "heartbeat.json";
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return { invalid: true, error: error.message, path: heartbeatPath };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const lastTickAt = heartbeat?.last_tick_at || run.heartbeat_at || null;
|
|
306
|
+
const intervalMs = positiveInteger(heartbeat?.interval_ms) || DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
307
|
+
const staleMs = Math.max(2 * intervalMs, MIN_STALE_HEARTBEAT_MS);
|
|
308
|
+
const lastTickMs = Date.parse(lastTickAt || "");
|
|
309
|
+
const ageMs = Number.isFinite(nowMs) && Number.isFinite(lastTickMs) ? Math.max(0, nowMs - lastTickMs) : null;
|
|
310
|
+
const evidence = {
|
|
311
|
+
source,
|
|
312
|
+
liveness_only: true,
|
|
313
|
+
run_dir: runDir,
|
|
314
|
+
heartbeat_path: heartbeatPath,
|
|
315
|
+
heartbeat_phase: heartbeat?.phase || null,
|
|
316
|
+
pid: heartbeat?.pid || null,
|
|
317
|
+
process_alive: null,
|
|
318
|
+
last_tick_at: lastTickAt,
|
|
319
|
+
interval_ms: intervalMs,
|
|
320
|
+
stale_after: Number.isFinite(lastTickMs) ? new Date(lastTickMs + staleMs).toISOString() : null,
|
|
321
|
+
age_ms: ageMs,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const result = {};
|
|
325
|
+
if (heartbeat) {
|
|
326
|
+
const liveness = processLiveness(heartbeat.pid, options);
|
|
327
|
+
evidence.process_alive = publicLivenessBoolean(liveness);
|
|
328
|
+
if (liveness === "absent") result.missingProcess = diagnosticItem("missing-heartbeat-process", {
|
|
329
|
+
checkedAt,
|
|
330
|
+
authoritative: false,
|
|
331
|
+
evidence: { ...evidence },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (ageMs !== null && ageMs > staleMs) {
|
|
336
|
+
result.stale = diagnosticItem("stale-heartbeat", {
|
|
337
|
+
checkedAt,
|
|
338
|
+
authoritative: false,
|
|
339
|
+
evidence,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function inspectWorktree(run, options) {
|
|
346
|
+
if (!stringValue(run.worktree)) return null;
|
|
347
|
+
const repoRoot = options.repoRoot || options.cwd || process.cwd();
|
|
348
|
+
const worktree = isAbsolute(run.worktree) ? run.worktree : resolve(repoRoot, run.worktree);
|
|
349
|
+
try {
|
|
350
|
+
const physical = realpath(options, worktree);
|
|
351
|
+
if (!statSync(physical).isDirectory()) throw new Error(`worktree is not a directory: ${worktree}`);
|
|
352
|
+
return null;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return diagnosticItem("missing-worktree", {
|
|
355
|
+
checkedAt: options.checkedAt,
|
|
356
|
+
authoritative: options.authoritative,
|
|
357
|
+
evidence: {
|
|
358
|
+
source: "filesystem",
|
|
359
|
+
run_dir: options.runDir,
|
|
360
|
+
worktree,
|
|
361
|
+
error: error.message,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function processLiveness(pid, options = {}) {
|
|
368
|
+
if (typeof options.processAliveFn === "function") {
|
|
369
|
+
return probeLegacyBooleanLiveness(options.processAliveFn, pid);
|
|
370
|
+
}
|
|
371
|
+
return probeProcessLiveness(pid, options).status;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function validatedDiagnosticIdentityPaths(envelope) {
|
|
375
|
+
const paths = [];
|
|
376
|
+
for (const field of ["status", "severity", "classification"]) {
|
|
377
|
+
if (DIAGNOSTIC_IDENTITY_FIELDS[field].has(envelope[field])) paths.push([field]);
|
|
378
|
+
}
|
|
379
|
+
for (let index = 0; index < envelope.items.length; index += 1) {
|
|
380
|
+
const item = envelope.items[index];
|
|
381
|
+
for (const field of ["condition", "status", "severity", "classification"]) {
|
|
382
|
+
if (DIAGNOSTIC_IDENTITY_FIELDS[field].has(item[field])) paths.push(["items", String(index), field]);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return paths;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function plainDiagnosticProjection(value) {
|
|
389
|
+
if (Array.isArray(value)) return value.map(plainDiagnosticProjection);
|
|
390
|
+
if (value && typeof value === "object") {
|
|
391
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, plainDiagnosticProjection(item)]));
|
|
392
|
+
}
|
|
393
|
+
return value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function realpath(options, path) {
|
|
397
|
+
if (typeof options.realpathFn === "function") return options.realpathFn(path);
|
|
398
|
+
return physicalPath(path, "worktree", { mustExist: true });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function terminalDefinition(status) {
|
|
402
|
+
return { ...CONDITION_BY_NAME.get("terminal-run"), ...(TERMINAL_OVERRIDES[status] || TERMINAL_OVERRIDES.completed) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function terminalMessage(status) {
|
|
406
|
+
if (status === "completed") return "Run completed.";
|
|
407
|
+
if (status === "partial") return "Run ended with a partial result.";
|
|
408
|
+
if (status === "blocked") return "Run ended blocked.";
|
|
409
|
+
if (status === "needs-human") return "Run is terminal and needs human attention.";
|
|
410
|
+
return "Run is terminal.";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function terminalAction(status) {
|
|
414
|
+
if (status === "completed" || status === "partial") return "No liveness action is required.";
|
|
415
|
+
if (status === "blocked") return "Inspect the terminal result and resolve the blocker before attempting follow-up work.";
|
|
416
|
+
return "Inspect the terminal result and provide the required human input.";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function conditionOrder(condition) {
|
|
420
|
+
return CONDITION_BY_NAME.get(condition)?.index ?? Number.MAX_SAFE_INTEGER;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function positiveInteger(value) {
|
|
424
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function stringValue(value) {
|
|
428
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
429
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { repoRoot } from "./git.js";
|
|
4
|
+
|
|
5
|
+
export function directFactoryRoot(cwd = process.cwd()) {
|
|
6
|
+
const local = join(resolve(cwd), ".opencode", "factory");
|
|
7
|
+
if (isDirectory(local)) return local;
|
|
8
|
+
return join(repoRoot(cwd), ".opencode", "factory");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function factoryRootsForLookup(cwd = process.cwd()) {
|
|
12
|
+
const direct = directFactoryRoot(cwd);
|
|
13
|
+
const roots = [direct];
|
|
14
|
+
const ancestor = nearestAncestorFactoryRoot(cwd);
|
|
15
|
+
if (ancestor && ancestor !== direct) roots.push(ancestor);
|
|
16
|
+
return roots;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function factoryRepoFromRunDir(runDir) {
|
|
20
|
+
return dirname(dirname(dirname(resolve(runDir))));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nearestAncestorFactoryRoot(cwd) {
|
|
24
|
+
let dir = resolve(cwd);
|
|
25
|
+
while (true) {
|
|
26
|
+
const candidate = join(dir, ".opencode", "factory");
|
|
27
|
+
if (isDirectory(candidate)) return candidate;
|
|
28
|
+
const parent = dirname(dir);
|
|
29
|
+
if (parent === dir) return null;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isDirectory(path) {
|
|
35
|
+
try {
|
|
36
|
+
return statSync(path).isDirectory();
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|