infinity-harness 2.0.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 (80) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/extensions/infinity-harness/index.ts +870 -0
  5. package/harness/docs/ARCHITECTURE.md +159 -0
  6. package/harness/docs/CONSTRAINTS.md +19 -0
  7. package/harness/docs/DECISIONS.md +107 -0
  8. package/harness/docs/DOMAIN.md +13 -0
  9. package/harness/docs/agents/evaluator.md +14 -0
  10. package/harness/docs/agents/generator.md +13 -0
  11. package/harness/docs/agents/planner.md +13 -0
  12. package/harness/docs/agents/simplifier.md +13 -0
  13. package/harness/docs/api-patterns.md +23 -0
  14. package/harness/docs/phases/build.md +47 -0
  15. package/harness/docs/phases/define.md +58 -0
  16. package/harness/docs/phases/plan.md +50 -0
  17. package/harness/docs/phases/review.md +47 -0
  18. package/harness/docs/phases/ship.md +43 -0
  19. package/harness/docs/phases/simplify.md +45 -0
  20. package/harness/docs/phases/verify.md +46 -0
  21. package/harness/model-router.json +28 -0
  22. package/harness/skills/README.md +60 -0
  23. package/harness/skills/auth-security.md +56 -0
  24. package/harness/skills/building-mcp-servers.md +70 -0
  25. package/harness/skills/building-tools.md +60 -0
  26. package/harness/skills/capability-acquisition.md +72 -0
  27. package/harness/skills/cli-design.md +55 -0
  28. package/harness/skills/code-review.md +57 -0
  29. package/harness/skills/codebase-design.md +70 -0
  30. package/harness/skills/concurrency-async.md +61 -0
  31. package/harness/skills/config-and-secrets.md +52 -0
  32. package/harness/skills/context-hygiene.md +51 -0
  33. package/harness/skills/databases.md +63 -0
  34. package/harness/skills/diagnosing-bugs.md +84 -0
  35. package/harness/skills/domain-modeling.md +65 -0
  36. package/harness/skills/error-handling-logging.md +56 -0
  37. package/harness/skills/frontend-ui.md +56 -0
  38. package/harness/skills/grilling.md +48 -0
  39. package/harness/skills/http-apis.md +60 -0
  40. package/harness/skills/performance.md +53 -0
  41. package/harness/skills/pi-todo-adapted.md +41 -0
  42. package/harness/skills/planning-tasks.md +86 -0
  43. package/harness/skills/prototype.md +39 -0
  44. package/harness/skills/research.md +32 -0
  45. package/harness/skills/resolving-merge-conflicts.md +30 -0
  46. package/harness/skills/scope-discipline.md +49 -0
  47. package/harness/skills/self-review.md +45 -0
  48. package/harness/skills/stuck-protocol.md +51 -0
  49. package/harness/skills/tdd.md +80 -0
  50. package/harness/skills/testing-infra.md +57 -0
  51. package/harness/skills/writing-skills.md +60 -0
  52. package/package.json +61 -0
  53. package/src/core/brief.ts +242 -0
  54. package/src/core/config.ts +265 -0
  55. package/src/core/exec.ts +130 -0
  56. package/src/core/featureList.ts +286 -0
  57. package/src/core/fsx.ts +119 -0
  58. package/src/core/gates.ts +444 -0
  59. package/src/core/lock.ts +192 -0
  60. package/src/core/paths.ts +95 -0
  61. package/src/core/phases.ts +143 -0
  62. package/src/core/settings.ts +445 -0
  63. package/src/core/types.ts +245 -0
  64. package/src/goalLoop.ts +628 -0
  65. package/src/goalSpec.ts +679 -0
  66. package/src/goalState.ts +338 -0
  67. package/src/loop.ts +355 -0
  68. package/src/modelRouter.ts +184 -0
  69. package/src/remote.ts +244 -0
  70. package/src/replan.ts +300 -0
  71. package/src/review.ts +53 -0
  72. package/src/rework.ts +274 -0
  73. package/src/taskList.ts +355 -0
  74. package/src/ui/config.ts +286 -0
  75. package/src/ui/dashboard.ts +1066 -0
  76. package/src/ui/theme.ts +317 -0
  77. package/src/ui/widget.ts +370 -0
  78. package/src/unstuck.ts +214 -0
  79. package/src/worker.ts +351 -0
  80. package/types/proper-lockfile.d.ts +19 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * infinity-harness — the brief.
3
+ *
4
+ * The brief answers one question: "what do I do right now?" It is injected at
5
+ * session start and after every phase change, so it has to be complete enough
6
+ * that an agent needs no other context, and short enough that it does not
7
+ * crowd out the work. Everything in it is derived from state on disk — the
8
+ * brief never invents a plan.
9
+ */
10
+
11
+ import type { Brief, GateResult, HarnessConfig, Phase } from "./types.ts";
12
+ import { PHASE_ROLE } from "./types.ts";
13
+ import { loadConfig, getRetryConfig, isRetryExhausted } from "./config.ts";
14
+ import {
15
+ loadFeatureList,
16
+ computeProgress,
17
+ nextActionableTask,
18
+ findFeature,
19
+ flattenTasks,
20
+ } from "./featureList.ts";
21
+ import { runChecks } from "./gates.ts";
22
+ import { getPhaseOrder, nextPhase, isFinalPhase } from "./phases.ts";
23
+ import * as P from "./paths.ts";
24
+ import { readText } from "./fsx.ts";
25
+
26
+ const PHASE_INTENT: Record<Phase, string> = {
27
+ init: "Set up the project skeleton and confirm the harness can see it.",
28
+ define: "Write down what is being built and how you will know it is done. Acceptance criteria per feature.",
29
+ plan: "Break each feature into ordered, dependency-aware tasks. No code yet.",
30
+ build: "Implement the current task. One task at a time, tests alongside.",
31
+ verify: "Prove the work behaves. Run the suite, look for what the tests do not cover.",
32
+ simplify: "Delete more than you add. Collapse duplication, drop dead paths.",
33
+ review: "Judge this as if someone else wrote it. Score it against the rubric.",
34
+ ship: "Tag, changelog, and leave the tree clean.",
35
+ };
36
+
37
+ export type BuildBriefOptions = {
38
+ /** Run the gate to include a live verdict. Costs a lint/test run. */
39
+ includeGate?: boolean;
40
+ };
41
+
42
+ export async function buildBrief(targetDir: string, options: BuildBriefOptions = {}): Promise<Brief> {
43
+ const { config, ok } = loadConfig(targetDir);
44
+ const { list } = loadFeatureList(targetDir);
45
+ const progress = computeProgress(list);
46
+ const phase = config.currentPhase;
47
+ const role = phase ? PHASE_ROLE[phase] : null;
48
+
49
+ const notes: string[] = [];
50
+ if (!ok) notes.push("harness/config.json is missing or unreadable — run init.");
51
+
52
+ const nextTask = nextActionableTask(list);
53
+ const feature = nextTask ? findFeature(list, nextTask.featureId) : null;
54
+
55
+ let gate: GateResult | null = null;
56
+ if (options.includeGate && phase) {
57
+ gate = await runChecks(targetDir, phase, { record: false });
58
+ }
59
+
60
+ const retry = getRetryConfig(config);
61
+ const exhausted = isRetryExhausted(config);
62
+ if (exhausted.exhausted) {
63
+ notes.push(
64
+ `Retry budget for ${exhausted.which} is exhausted. Stop and escalate to the human rather than retrying again.`,
65
+ );
66
+ }
67
+
68
+ const blockedTasks = flattenTasks(list).filter((t) => t.status === "blocked");
69
+ if (blockedTasks.length > 0) {
70
+ notes.push(`${blockedTasks.length} task(s) are blocked: ${blockedTasks.map((t) => t.compositeKey).join(", ")}`);
71
+ }
72
+
73
+ const complete = isFinalPhase(phase, config.phases?.enabled) && progress.tasksDone === progress.tasksTotal;
74
+
75
+ return {
76
+ phase,
77
+ role,
78
+ paused: Boolean(config.paused),
79
+ complete,
80
+ goal: (list.goals ?? [])[0]?.title ?? null,
81
+ feature: feature ? { id: feature.id, name: feature.name } : null,
82
+ task: nextTask
83
+ ? {
84
+ id: nextTask.id,
85
+ key: nextTask.compositeKey,
86
+ description: nextTask.description,
87
+ status: nextTask.status,
88
+ }
89
+ : null,
90
+ criteria: collectCriteria(feature, nextTask?.criteria),
91
+ validateCommand: "harness validate",
92
+ gate,
93
+ progress: {
94
+ tasksDone: progress.tasksDone,
95
+ tasksTotal: progress.tasksTotal,
96
+ featuresDone: progress.featuresDone,
97
+ featuresTotal: progress.featuresTotal,
98
+ },
99
+ retries: {
100
+ task: config.taskRetryCount ?? 0,
101
+ feature: config.featureRetryCount ?? 0,
102
+ phase: config.phaseRetryCount ?? 0,
103
+ max: retry.tasks.max,
104
+ },
105
+ notes,
106
+ };
107
+ }
108
+
109
+ function collectCriteria(
110
+ feature: { criteria?: string[] } | null,
111
+ taskCriteria: string[] | undefined,
112
+ ): string[] {
113
+ const out: string[] = [];
114
+ if (Array.isArray(taskCriteria)) out.push(...taskCriteria);
115
+ if (out.length === 0 && Array.isArray(feature?.criteria)) out.push(...feature.criteria);
116
+ return out;
117
+ }
118
+
119
+ // ── Rendering ───────────────────────────────────────────────────────────────
120
+
121
+ const RULE = "─".repeat(64);
122
+
123
+ /**
124
+ * Render the brief as the text injected into the agent's context.
125
+ * Deliberately plain: this is read by a model, not a terminal, so it carries
126
+ * no colour and no box drawing that would waste tokens.
127
+ */
128
+ export function renderBrief(brief: Brief, config?: HarnessConfig): string {
129
+ const L: string[] = [];
130
+ const phase = (brief.phase ?? "not started").toUpperCase();
131
+
132
+ if (brief.paused) {
133
+ L.push("HARNESS PAUSED");
134
+ L.push("");
135
+ L.push("The pipeline is paused. Do not continue autonomously — tell the human and stop.");
136
+ return L.join("\n");
137
+ }
138
+
139
+ if (brief.complete) {
140
+ L.push("PIPELINE COMPLETE");
141
+ L.push("");
142
+ L.push(`All ${brief.progress.tasksTotal} task(s) across ${brief.progress.featuresTotal} feature(s) are done`);
143
+ L.push("and the final phase has passed. Report to the human; do not start new work.");
144
+ return L.join("\n");
145
+ }
146
+
147
+ L.push(RULE);
148
+ const head = [`NEXT STEP · ${phase}`];
149
+ if (brief.feature) head.push(brief.feature.id);
150
+ if (brief.task) head.push(brief.task.key);
151
+ L.push(head.join(" · "));
152
+ L.push(RULE);
153
+ L.push("");
154
+
155
+ if (brief.goal) {
156
+ L.push(`GOAL ${brief.goal}`);
157
+ }
158
+ if (brief.role) {
159
+ L.push(`ROLE ${brief.role} — ${PHASE_INTENT[brief.phase!]}`);
160
+ }
161
+ if (brief.feature) {
162
+ L.push(`FEATURE ${brief.feature.id} · ${brief.feature.name}`);
163
+ }
164
+ if (brief.task) {
165
+ L.push(`TASK ${brief.task.key} [${brief.task.status}]`);
166
+ L.push(` ${brief.task.description}`);
167
+ } else {
168
+ L.push("TASK (none actionable — every remaining task is blocked or the plan is empty)");
169
+ }
170
+
171
+ L.push("");
172
+ L.push(
173
+ `PROGRESS ${brief.progress.tasksDone}/${brief.progress.tasksTotal} tasks · ` +
174
+ `${brief.progress.featuresDone}/${brief.progress.featuresTotal} features · ` +
175
+ `retries ${brief.retries.task}/${brief.retries.max}`,
176
+ );
177
+
178
+ if (config) {
179
+ const order = getPhaseOrder(config.phases?.enabled);
180
+ const marked = order
181
+ .map((p) => (p === brief.phase ? `[${p.toUpperCase()}]` : p))
182
+ .join(" → ");
183
+ L.push(`PIPELINE ${marked}`);
184
+ }
185
+
186
+ if (brief.criteria.length) {
187
+ L.push("");
188
+ L.push("ACCEPTANCE CRITERIA");
189
+ for (const c of brief.criteria) L.push(` - ${c}`);
190
+ }
191
+
192
+ if (brief.gate) {
193
+ L.push("");
194
+ L.push(`GATE ${brief.gate.overall ? "PASS" : "FAIL"}`);
195
+ for (const c of brief.gate.checks) {
196
+ const mark = c.advisory ? "·" : c.pass ? "+" : "x";
197
+ L.push(` ${mark} ${c.name}: ${c.detail}`);
198
+ }
199
+ }
200
+
201
+ if (brief.notes.length) {
202
+ L.push("");
203
+ L.push("ATTENTION");
204
+ for (const n of brief.notes) L.push(` ! ${n}`);
205
+ }
206
+
207
+ L.push("");
208
+ L.push("THE LOOP");
209
+ L.push(" 1. Do the work described above.");
210
+ L.push(` 2. Run: ${brief.validateCommand}`);
211
+ L.push(" 3. FAIL → fix the listed checks and validate again.");
212
+ L.push(" 4. PASS → the harness advances the phase and issues the next brief.");
213
+ L.push("");
214
+ L.push("Do not edit harness/config.json by hand and do not mark your own work complete.");
215
+ L.push("The gate is the only referee.");
216
+
217
+ return L.join("\n");
218
+ }
219
+
220
+ /** One-line status suitable for a status bar. */
221
+ export function renderBriefLine(brief: Brief): string {
222
+ if (brief.paused) return "paused";
223
+ if (brief.complete) return "complete";
224
+ const bits = [brief.phase ?? "—"];
225
+ if (brief.task) bits.push(brief.task.key);
226
+ bits.push(`${brief.progress.tasksDone}/${brief.progress.tasksTotal}`);
227
+ return bits.join(" · ");
228
+ }
229
+
230
+ /** Phase doc + craft skill the brief points at, when present in the project. */
231
+ export function referencedDocs(targetDir: string, phase: Phase | null): string[] {
232
+ if (!phase) return [];
233
+ const out: string[] = [];
234
+ const pd = P.phaseDocPath(targetDir, phase);
235
+ if (readText(pd) !== null) out.push(pd);
236
+ const role = PHASE_ROLE[phase];
237
+ const ad = P.agentDocPath(targetDir, role);
238
+ if (readText(ad) !== null) out.push(ad);
239
+ return out;
240
+ }
241
+
242
+ export { nextPhase };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * infinity-harness — harness/config.json load, save, and mutation helpers.
3
+ *
4
+ * The config is the pipeline's control state: which phase, which feature/task,
5
+ * how many retries are burnt, and what the gate history looks like. It is
6
+ * read on nearly every call, so it is deliberately cheap to load and always
7
+ * deep-merged over defaults — an older config missing new keys still works.
8
+ */
9
+
10
+ import type { HarnessConfig, GateHistoryEntry, Phase, Role } from "./types.ts";
11
+ import { DEFAULT_ENABLED_PHASES } from "./types.ts";
12
+ import { configPath } from "./paths.ts";
13
+ import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
14
+
15
+ export const DEFAULT_MAX_RETRIES = 10;
16
+ export const DEFAULT_FEATURE_RETRIES = 2;
17
+ export const DEFAULT_PHASE_RETRIES = 2;
18
+ export const COVERAGE_THRESHOLD_DEFAULT = 80;
19
+
20
+ /** Cap on gateHistory length. Unbounded growth is a real problem on multi-day runs. */
21
+ export const GATE_HISTORY_LIMIT = 500;
22
+
23
+ export function defaultConfig(): HarnessConfig {
24
+ return {
25
+ version: "2.0",
26
+ stack: null,
27
+ mode: "copilot",
28
+ currentPhase: null,
29
+ currentRole: null,
30
+ currentFeature: null,
31
+ currentTask: null,
32
+ paused: false,
33
+ features: { remaining: 0, passing: 0, total: 0 },
34
+ gates: {
35
+ enabled: true,
36
+ checks: ["all"],
37
+ coverage: { enabled: false, threshold: COVERAGE_THRESHOLD_DEFAULT },
38
+ cleanState: { enabled: false, stalePatterns: [], startupCmd: null },
39
+ antiPlaceholder: { enabled: true, patterns: [] },
40
+ },
41
+ commands: { lint: null, test: null, coverage: null, build: null },
42
+ git: {
43
+ autoCommit: false,
44
+ autoTag: false,
45
+ branch: null,
46
+ clean: true,
47
+ hasUpstream: false,
48
+ lastCommitMessage: null,
49
+ },
50
+ phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
51
+ roles: { strict: false },
52
+ loop: {
53
+ maxIterations: 2000,
54
+ maxWallClockMs: 24 * 60 * 60 * 1000,
55
+ noProgressLimit: 3,
56
+ },
57
+ retry: {
58
+ tasks: { enabled: true, maxRetries: null },
59
+ features: { enabled: false, maxRetries: DEFAULT_FEATURE_RETRIES },
60
+ phases: { enabled: false, maxRetries: DEFAULT_PHASE_RETRIES },
61
+ },
62
+ maxRetries: DEFAULT_MAX_RETRIES,
63
+ retryCount: 0,
64
+ taskRetryCount: 0,
65
+ featureRetryCount: 0,
66
+ phaseRetryCount: 0,
67
+ pipelineIteration: 0,
68
+ gateHistory: [],
69
+ };
70
+ }
71
+
72
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
73
+ return typeof v === "object" && v !== null && !Array.isArray(v);
74
+ }
75
+
76
+ /** Deep-merge `partial` over `defaults`. Arrays are replaced, not merged. */
77
+ function deepMerge<T>(defaults: T, partial: unknown): T {
78
+ if (!isPlainObject(partial)) return defaults;
79
+ if (!isPlainObject(defaults)) return partial as T;
80
+ const out: Record<string, unknown> = { ...(defaults as Record<string, unknown>) };
81
+ for (const [k, v] of Object.entries(partial)) {
82
+ const d = (defaults as Record<string, unknown>)[k];
83
+ out[k] = isPlainObject(v) && isPlainObject(d) ? deepMerge(d, v) : v;
84
+ }
85
+ return out as T;
86
+ }
87
+
88
+ export type LoadResult = {
89
+ ok: boolean;
90
+ config: HarnessConfig;
91
+ error: string | null;
92
+ /** True when no config file existed and defaults were synthesised. */
93
+ seeded: boolean;
94
+ };
95
+
96
+ /**
97
+ * Load config, merged over defaults.
98
+ *
99
+ * A missing file yields defaults with `ok:false` (the project is not
100
+ * initialised). A corrupt file yields defaults with an error — never a throw,
101
+ * because every lifecycle hook calls this and a crash there kills the session.
102
+ */
103
+ export function loadConfig(targetDir: string): LoadResult {
104
+ const path = configPath(targetDir);
105
+ if (!fileExists(path)) {
106
+ return { ok: false, config: defaultConfig(), error: "no harness/config.json", seeded: true };
107
+ }
108
+ try {
109
+ const raw = readJson<Partial<HarnessConfig>>(path);
110
+ if (raw === null) {
111
+ return { ok: false, config: defaultConfig(), error: "harness/config.json is empty", seeded: true };
112
+ }
113
+ return { ok: true, config: deepMerge(defaultConfig(), raw), error: null, seeded: false };
114
+ } catch (e) {
115
+ const msg = e instanceof Error ? e.message : String(e);
116
+ return { ok: false, config: defaultConfig(), error: msg, seeded: false };
117
+ }
118
+ }
119
+
120
+ export function saveConfig(targetDir: string, config: HarnessConfig): { ok: boolean; error: string | null } {
121
+ const path = configPath(targetDir);
122
+ try {
123
+ trimGateHistory(config);
124
+ backupOnce(path);
125
+ writeJsonAtomic(path, config);
126
+ return { ok: true, error: null };
127
+ } catch (e) {
128
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
129
+ }
130
+ }
131
+
132
+ export function isHarnessProject(targetDir: string): boolean {
133
+ return fileExists(configPath(targetDir));
134
+ }
135
+
136
+ // ── Gate history ────────────────────────────────────────────────────────────
137
+
138
+ export function trimGateHistory(config: HarnessConfig): void {
139
+ if (!Array.isArray(config.gateHistory)) {
140
+ config.gateHistory = [];
141
+ return;
142
+ }
143
+ if (config.gateHistory.length > GATE_HISTORY_LIMIT) {
144
+ config.gateHistory = config.gateHistory.slice(-GATE_HISTORY_LIMIT);
145
+ }
146
+ }
147
+
148
+ export function recordGate(
149
+ config: HarnessConfig,
150
+ phase: string,
151
+ result: "pass" | "fail",
152
+ scope?: { feature?: string; task?: string },
153
+ ): void {
154
+ if (!Array.isArray(config.gateHistory)) config.gateHistory = [];
155
+ const entry: GateHistoryEntry = {
156
+ phase,
157
+ result,
158
+ timestamp: new Date().toISOString(),
159
+ ...(scope?.feature ? { feature: scope.feature } : {}),
160
+ ...(scope?.task ? { task: scope.task } : {}),
161
+ };
162
+ config.gateHistory.push(entry);
163
+ trimGateHistory(config);
164
+ }
165
+
166
+ // ── Retry budgets ───────────────────────────────────────────────────────────
167
+
168
+ export type EffectiveRetry = {
169
+ tasks: { enabled: boolean; max: number };
170
+ features: { enabled: boolean; max: number };
171
+ phases: { enabled: boolean; max: number };
172
+ };
173
+
174
+ /** Resolve retry budgets, seeding task retries from the legacy `maxRetries`. */
175
+ export function getRetryConfig(config: HarnessConfig): EffectiveRetry {
176
+ const legacy = typeof config.maxRetries === "number" ? config.maxRetries : DEFAULT_MAX_RETRIES;
177
+ const r = config.retry ?? defaultConfig().retry;
178
+ return {
179
+ tasks: { enabled: r.tasks?.enabled ?? true, max: r.tasks?.maxRetries ?? legacy },
180
+ features: { enabled: r.features?.enabled ?? false, max: r.features?.maxRetries ?? DEFAULT_FEATURE_RETRIES },
181
+ phases: { enabled: r.phases?.enabled ?? false, max: r.phases?.maxRetries ?? DEFAULT_PHASE_RETRIES },
182
+ };
183
+ }
184
+
185
+ export function resetTaskRetry(config: HarnessConfig): void {
186
+ config.taskRetryCount = 0;
187
+ }
188
+ export function incrementTaskRetry(config: HarnessConfig): number {
189
+ config.taskRetryCount = (config.taskRetryCount ?? 0) + 1;
190
+ return config.taskRetryCount;
191
+ }
192
+ export function resetFeatureRetry(config: HarnessConfig): void {
193
+ config.featureRetryCount = 0;
194
+ }
195
+ export function incrementFeatureRetry(config: HarnessConfig): number {
196
+ config.featureRetryCount = (config.featureRetryCount ?? 0) + 1;
197
+ return config.featureRetryCount;
198
+ }
199
+ export function resetPhaseRetry(config: HarnessConfig): void {
200
+ config.phaseRetryCount = 0;
201
+ config.retryCount = 0;
202
+ }
203
+ export function incrementPhaseRetry(config: HarnessConfig): number {
204
+ config.phaseRetryCount = (config.phaseRetryCount ?? 0) + 1;
205
+ config.retryCount = (config.retryCount ?? 0) + 1;
206
+ return config.phaseRetryCount;
207
+ }
208
+
209
+ /** True when any *enabled* retry budget is exhausted — the signal to escalate. */
210
+ export function isRetryExhausted(config: HarnessConfig): { exhausted: boolean; which: string | null } {
211
+ const r = getRetryConfig(config);
212
+ if (r.tasks.enabled && (config.taskRetryCount ?? 0) >= r.tasks.max) return { exhausted: true, which: "task" };
213
+ if (r.features.enabled && (config.featureRetryCount ?? 0) >= r.features.max) return { exhausted: true, which: "feature" };
214
+ if (r.phases.enabled && (config.phaseRetryCount ?? 0) >= r.phases.max) return { exhausted: true, which: "phase" };
215
+ return { exhausted: false, which: null };
216
+ }
217
+
218
+ // ── Dotted get/set (used by the `config` command surface) ────────────────────
219
+
220
+ export function getKey(config: HarnessConfig, key: string): unknown {
221
+ let cur: unknown = config;
222
+ for (const part of key.split(".")) {
223
+ if (!isPlainObject(cur)) return undefined;
224
+ cur = cur[part];
225
+ }
226
+ return cur;
227
+ }
228
+
229
+ export function setKey(config: HarnessConfig, key: string, value: unknown): void {
230
+ const parts = key.split(".");
231
+ let cur: Record<string, unknown> = config as unknown as Record<string, unknown>;
232
+ for (let i = 0; i < parts.length - 1; i++) {
233
+ const p = parts[i]!;
234
+ if (!isPlainObject(cur[p])) cur[p] = {};
235
+ cur = cur[p] as Record<string, unknown>;
236
+ }
237
+ cur[parts[parts.length - 1]!] = value;
238
+ }
239
+
240
+ /** Required-field check. `currentPhase` is legitimately null before INIT. */
241
+ export function validateConfig(config: HarnessConfig): string[] {
242
+ const required = ["version", "mode", "gates", "git", "phases", "maxRetries"];
243
+ const missing: string[] = [];
244
+ for (const f of required) {
245
+ const v = (config as Record<string, unknown>)[f];
246
+ if (v === undefined || v === null) missing.push(f);
247
+ }
248
+ if (!("currentPhase" in config)) missing.push("currentPhase");
249
+ return missing;
250
+ }
251
+
252
+ export function currentRoleFor(phase: Phase | null): Role | null {
253
+ if (!phase) return null;
254
+ const map: Record<Phase, Role> = {
255
+ init: "planner",
256
+ define: "planner",
257
+ plan: "planner",
258
+ build: "generator",
259
+ verify: "evaluator",
260
+ simplify: "simplifier",
261
+ review: "evaluator",
262
+ ship: "evaluator",
263
+ };
264
+ return map[phase] ?? null;
265
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * infinity-harness — bounded command execution.
3
+ *
4
+ * Every shell-out in the harness goes through here so that a hung lint or an
5
+ * infinite test loop cannot wedge a multi-day run. `run` never throws: a
6
+ * non-zero exit, a timeout, and a missing binary are all reported as data.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+
11
+ export type RunResult = {
12
+ ok: boolean;
13
+ code: number | null;
14
+ stdout: string;
15
+ stderr: string;
16
+ timedOut: boolean;
17
+ /** Command could not be started at all (ENOENT, EACCES, …). */
18
+ spawnError: string | null;
19
+ durationMs: number;
20
+ };
21
+
22
+ export const DEFAULT_TIMEOUT_MS = 30_000;
23
+ export const LONG_TIMEOUT_MS = 300_000;
24
+
25
+ /** Cap captured output so a runaway process cannot exhaust memory. */
26
+ const MAX_CAPTURE_BYTES = 512 * 1024;
27
+
28
+ export async function run(
29
+ command: string,
30
+ opts: { cwd: string; timeoutMs?: number; env?: NodeJS.ProcessEnv; shell?: boolean } = { cwd: process.cwd() },
31
+ ): Promise<RunResult> {
32
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33
+ const started = Date.now();
34
+
35
+ return new Promise<RunResult>((resolvePromise) => {
36
+ let stdout = "";
37
+ let stderr = "";
38
+ let timedOut = false;
39
+ let settled = false;
40
+
41
+ const child = spawn(command, {
42
+ cwd: opts.cwd,
43
+ shell: opts.shell ?? true,
44
+ env: opts.env ?? process.env,
45
+ windowsHide: true,
46
+ });
47
+
48
+ const timer = setTimeout(() => {
49
+ timedOut = true;
50
+ try {
51
+ child.kill("SIGKILL");
52
+ } catch {
53
+ /* already gone */
54
+ }
55
+ }, timeoutMs);
56
+
57
+ const settle = (code: number | null, spawnError: string | null) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ resolvePromise({
62
+ ok: !timedOut && spawnError === null && code === 0,
63
+ code,
64
+ stdout: stdout.trim(),
65
+ stderr: stderr.trim(),
66
+ timedOut,
67
+ spawnError,
68
+ durationMs: Date.now() - started,
69
+ });
70
+ };
71
+
72
+ child.stdout?.on("data", (d: Buffer) => {
73
+ if (stdout.length < MAX_CAPTURE_BYTES) stdout += d.toString("utf-8");
74
+ });
75
+ child.stderr?.on("data", (d: Buffer) => {
76
+ if (stderr.length < MAX_CAPTURE_BYTES) stderr += d.toString("utf-8");
77
+ });
78
+ child.on("error", (e: Error) => settle(null, e.message));
79
+ child.on("close", (code) => settle(code, null));
80
+ });
81
+ }
82
+
83
+ // ── git helpers ─────────────────────────────────────────────────────────────
84
+
85
+ export async function isGitRepo(cwd: string): Promise<boolean> {
86
+ const r = await run("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 10_000 });
87
+ return r.ok && r.stdout.trim() === "true";
88
+ }
89
+
90
+ export async function gitBranch(cwd: string): Promise<string | null> {
91
+ const r = await run("git rev-parse --abbrev-ref HEAD", { cwd, timeoutMs: 10_000 });
92
+ return r.ok ? r.stdout.trim() : null;
93
+ }
94
+
95
+ export async function gitIsClean(cwd: string): Promise<boolean> {
96
+ const r = await run("git status --porcelain", { cwd, timeoutMs: 15_000 });
97
+ // A failed git call must not be reported as "clean" — that would let a
98
+ // git-clean gate pass on a repo the harness cannot actually inspect.
99
+ if (!r.ok) return false;
100
+ return r.stdout.trim() === "";
101
+ }
102
+
103
+ /** True when the working tree has *any* uncommitted change. Inverse of clean. */
104
+ export async function gitHasChanges(cwd: string): Promise<boolean> {
105
+ const r = await run("git status --porcelain", { cwd, timeoutMs: 15_000 });
106
+ if (!r.ok) return false;
107
+ return r.stdout.trim() !== "";
108
+ }
109
+
110
+ export async function gitHasUpstream(cwd: string): Promise<boolean> {
111
+ const r = await run("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { cwd, timeoutMs: 10_000 });
112
+ return r.ok && r.stdout.trim() !== "";
113
+ }
114
+
115
+ export async function gitLastCommitMessage(cwd: string): Promise<string | null> {
116
+ const r = await run("git log -1 --pretty=%s", { cwd, timeoutMs: 10_000 });
117
+ return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
118
+ }
119
+
120
+ export async function gitHasTag(cwd: string): Promise<boolean> {
121
+ const r = await run("git tag --points-at HEAD", { cwd, timeoutMs: 10_000 });
122
+ return r.ok && r.stdout.trim() !== "";
123
+ }
124
+
125
+ export async function gitBehindUpstream(cwd: string): Promise<number | null> {
126
+ const r = await run("git rev-list --count HEAD..@{u}", { cwd, timeoutMs: 15_000 });
127
+ if (!r.ok) return null;
128
+ const n = Number.parseInt(r.stdout.trim(), 10);
129
+ return Number.isNaN(n) ? null : n;
130
+ }