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.
- package/CHANGELOG.md +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — path resolution.
|
|
3
|
+
*
|
|
4
|
+
* Every harness-managed file lives under `harness/` in the target project.
|
|
5
|
+
* Nothing outside this module hardcodes a harness path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const HARNESS_DIRNAME = "harness";
|
|
11
|
+
|
|
12
|
+
export function harnessDir(targetDir: string): string {
|
|
13
|
+
return resolve(targetDir, HARNESS_DIRNAME);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function configPath(targetDir: string): string {
|
|
17
|
+
return resolve(harnessDir(targetDir), "config.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function featureListPath(targetDir: string): string {
|
|
21
|
+
return resolve(harnessDir(targetDir), "features", "feature-list.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function progressPath(targetDir: string): string {
|
|
25
|
+
return resolve(harnessDir(targetDir), "progress.md");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function rubricPath(targetDir: string): string {
|
|
29
|
+
return resolve(harnessDir(targetDir), "evaluator-rubric.md");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function handoffPath(targetDir: string): string {
|
|
33
|
+
return resolve(harnessDir(targetDir), "session-handoff.md");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function contractPath(targetDir: string): string {
|
|
37
|
+
return resolve(harnessDir(targetDir), "sprint-contract.md");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function lessonsPath(targetDir: string): string {
|
|
41
|
+
return resolve(harnessDir(targetDir), "lessons-decisions.md");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function journalPath(targetDir: string): string {
|
|
45
|
+
return resolve(harnessDir(targetDir), "run-journal.jsonl");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function modelRouterPath(targetDir: string): string {
|
|
49
|
+
return resolve(harnessDir(targetDir), "model-router.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reworkPath(targetDir: string): string {
|
|
53
|
+
return resolve(harnessDir(targetDir), "rework.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function replanPath(targetDir: string): string {
|
|
57
|
+
return resolve(harnessDir(targetDir), "replan.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function docsDir(targetDir: string): string {
|
|
61
|
+
return resolve(harnessDir(targetDir), "docs");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function architecturePath(targetDir: string): string {
|
|
65
|
+
return resolve(docsDir(targetDir), "ARCHITECTURE.md");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function decisionsPath(targetDir: string): string {
|
|
69
|
+
return resolve(docsDir(targetDir), "DECISIONS.md");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function constraintsPath(targetDir: string): string {
|
|
73
|
+
return resolve(docsDir(targetDir), "CONSTRAINTS.md");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function skillsDir(targetDir: string): string {
|
|
77
|
+
return resolve(harnessDir(targetDir), "skills");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function phaseDocPath(targetDir: string, phase: string): string {
|
|
81
|
+
return resolve(docsDir(targetDir), "phases", `${phase}.md`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function agentDocPath(targetDir: string, role: string): string {
|
|
85
|
+
return resolve(docsDir(targetDir), "agents", `${role}.md`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Root for per-run worker isolation. Always inside the project, always ignorable. */
|
|
89
|
+
export function runRoot(targetDir: string): string {
|
|
90
|
+
return resolve(targetDir, "tmp", "infinity-harness");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function agentsPath(targetDir: string): string {
|
|
94
|
+
return resolve(targetDir, "AGENTS.md");
|
|
95
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the phase state machine.
|
|
3
|
+
*
|
|
4
|
+
* Transitions are forward-only and one step at a time. That constraint is the
|
|
5
|
+
* whole point of the harness: an agent cannot decide it is done with BUILD and
|
|
6
|
+
* jump to SHIP. The only backward movement is an explicit rework (src/rework.ts),
|
|
7
|
+
* which records why it happened.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HarnessConfig, Phase } from "./types.ts";
|
|
11
|
+
import { PHASE_ORDER, DEFAULT_ENABLED_PHASES } from "./types.ts";
|
|
12
|
+
import { loadConfig, saveConfig, recordGate, currentRoleFor } from "./config.ts";
|
|
13
|
+
import { gitBranch, gitIsClean, gitHasUpstream, gitLastCommitMessage } from "./exec.ts";
|
|
14
|
+
|
|
15
|
+
export { PHASE_ORDER };
|
|
16
|
+
|
|
17
|
+
export function isPhase(v: unknown): v is Phase {
|
|
18
|
+
return typeof v === "string" && (PHASE_ORDER as readonly string[]).includes(v);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The enabled pipeline, in canonical order. Unknown entries are ignored. */
|
|
22
|
+
export function getPhaseOrder(enabled?: readonly string[] | null): Phase[] {
|
|
23
|
+
if (!Array.isArray(enabled)) return [...DEFAULT_ENABLED_PHASES];
|
|
24
|
+
const set = new Set(enabled);
|
|
25
|
+
const order = PHASE_ORDER.filter((p) => set.has(p));
|
|
26
|
+
return order.length ? [...order] : [...DEFAULT_ENABLED_PHASES];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isValidTransition(
|
|
30
|
+
fromPhase: Phase | null,
|
|
31
|
+
toPhase: Phase,
|
|
32
|
+
enabled?: readonly string[] | null,
|
|
33
|
+
): boolean {
|
|
34
|
+
const order = getPhaseOrder(enabled);
|
|
35
|
+
if (!order.includes(toPhase)) return false;
|
|
36
|
+
if (fromPhase === null) return order[0] === toPhase;
|
|
37
|
+
if (fromPhase === toPhase) return true; // re-running a phase is always legal
|
|
38
|
+
return order.indexOf(toPhase) === order.indexOf(fromPhase) + 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function nextPhase(fromPhase: Phase | null, enabled?: readonly string[] | null): Phase | null {
|
|
42
|
+
const order = getPhaseOrder(enabled);
|
|
43
|
+
if (fromPhase === null) return order[0] ?? null;
|
|
44
|
+
const i = order.indexOf(fromPhase);
|
|
45
|
+
if (i === -1) return order[0] ?? null;
|
|
46
|
+
return order[i + 1] ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isFinalPhase(phase: Phase | null, enabled?: readonly string[] | null): boolean {
|
|
50
|
+
const order = getPhaseOrder(enabled);
|
|
51
|
+
return phase !== null && order[order.length - 1] === phase;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type TransitionResult = {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
error: string | null;
|
|
57
|
+
config: HarnessConfig | null;
|
|
58
|
+
from: Phase | null;
|
|
59
|
+
to: Phase | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Advance to `toPhase`, refreshing git metadata and resetting the right
|
|
64
|
+
* retry counters. Re-entering the same phase counts as a retry; moving to a
|
|
65
|
+
* new phase clears the phase budget but leaves task/feature budgets alone
|
|
66
|
+
* (those are cleared by whoever completes the task/feature).
|
|
67
|
+
*/
|
|
68
|
+
export async function transitionPhase(targetDir: string, toPhase: Phase): Promise<TransitionResult> {
|
|
69
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
70
|
+
if (!ok) {
|
|
71
|
+
return { ok: false, error: error ?? "cannot load config", config: null, from: null, to: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const from = config.currentPhase;
|
|
75
|
+
const enabled = config.phases?.enabled;
|
|
76
|
+
if (!isValidTransition(from, toPhase, enabled)) {
|
|
77
|
+
const order = getPhaseOrder(enabled).join(" → ");
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `invalid transition ${from ?? "start"} → ${toPhase}. Pipeline is: ${order}`,
|
|
81
|
+
config: null,
|
|
82
|
+
from,
|
|
83
|
+
to: toPhase,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isNewPhase = from !== toPhase;
|
|
88
|
+
if (from && isNewPhase) recordGate(config, from, "pass");
|
|
89
|
+
|
|
90
|
+
if (isNewPhase) {
|
|
91
|
+
config.retryCount = 0;
|
|
92
|
+
config.phaseRetryCount = 0;
|
|
93
|
+
config.pipelineIteration = (config.pipelineIteration ?? 0) + 1;
|
|
94
|
+
} else {
|
|
95
|
+
config.retryCount = (config.retryCount ?? 0) + 1;
|
|
96
|
+
config.phaseRetryCount = (config.phaseRetryCount ?? 0) + 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
config.currentPhase = toPhase;
|
|
100
|
+
config.currentRole = currentRoleFor(toPhase);
|
|
101
|
+
config.paused = false;
|
|
102
|
+
|
|
103
|
+
// Git metadata is best-effort context for the brief, never a blocker.
|
|
104
|
+
try {
|
|
105
|
+
config.git = config.git ?? {
|
|
106
|
+
autoCommit: false,
|
|
107
|
+
autoTag: false,
|
|
108
|
+
branch: null,
|
|
109
|
+
clean: true,
|
|
110
|
+
hasUpstream: false,
|
|
111
|
+
lastCommitMessage: null,
|
|
112
|
+
};
|
|
113
|
+
config.git.branch = await gitBranch(targetDir);
|
|
114
|
+
config.git.clean = await gitIsClean(targetDir);
|
|
115
|
+
config.git.hasUpstream = await gitHasUpstream(targetDir);
|
|
116
|
+
config.git.lastCommitMessage = await gitLastCommitMessage(targetDir);
|
|
117
|
+
} catch {
|
|
118
|
+
/* leave prior git metadata in place */
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const saved = saveConfig(targetDir, config);
|
|
122
|
+
if (!saved.ok) {
|
|
123
|
+
return { ok: false, error: saved.error, config: null, from, to: toPhase };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, error: null, config, from, to: toPhase };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Advance one step along the enabled pipeline. */
|
|
129
|
+
export async function advancePhase(targetDir: string): Promise<TransitionResult> {
|
|
130
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
131
|
+
if (!ok) return { ok: false, error: error ?? "cannot load config", config: null, from: null, to: null };
|
|
132
|
+
const to = nextPhase(config.currentPhase, config.phases?.enabled);
|
|
133
|
+
if (to === null) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: "pipeline complete — no phase after " + (config.currentPhase ?? "start"),
|
|
137
|
+
config,
|
|
138
|
+
from: config.currentPhase,
|
|
139
|
+
to: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return transitionPhase(targetDir, to);
|
|
143
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the settings schema.
|
|
3
|
+
*
|
|
4
|
+
* Every option the harness exposes is declared here once, as data: where it
|
|
5
|
+
* lives, what it accepts, what it defaults to, and how to explain it. The
|
|
6
|
+
* config TUI is *generated* from this list rather than hand-written, so a new
|
|
7
|
+
* option cannot be added to the file format and forgotten in the UI — the two
|
|
8
|
+
* cannot drift because there is only one source.
|
|
9
|
+
*
|
|
10
|
+
* Editing the JSON by hand stays entirely valid. This is the same data, with
|
|
11
|
+
* enough type information attached to prompt for it safely.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HarnessConfig, Phase } from "./types.ts";
|
|
15
|
+
import { PHASE_ORDER } from "./types.ts";
|
|
16
|
+
import { loadConfig, saveConfig, getKey, setKey } from "./config.ts";
|
|
17
|
+
import { loadRouterConfig, saveRouterConfig, type RouterConfig } from "../modelRouter.ts";
|
|
18
|
+
import { configPath, modelRouterPath } from "./paths.ts";
|
|
19
|
+
import { withLockSync } from "./lock.ts";
|
|
20
|
+
|
|
21
|
+
/** Which file a setting is persisted in. */
|
|
22
|
+
export type SettingFile = "config" | "router";
|
|
23
|
+
|
|
24
|
+
export type SettingType =
|
|
25
|
+
| { kind: "boolean" }
|
|
26
|
+
| { kind: "number"; min?: number; max?: number; unit?: string }
|
|
27
|
+
| { kind: "text"; placeholder?: string; allowEmpty?: boolean }
|
|
28
|
+
| { kind: "choice"; choices: readonly string[] }
|
|
29
|
+
| { kind: "multi"; choices: readonly string[] }
|
|
30
|
+
/** Resolved at runtime from the models pi has configured. */
|
|
31
|
+
| { kind: "model" };
|
|
32
|
+
|
|
33
|
+
export type Setting = {
|
|
34
|
+
/** Dotted path within the file. */
|
|
35
|
+
path: string;
|
|
36
|
+
file: SettingFile;
|
|
37
|
+
label: string;
|
|
38
|
+
/** One line the user reads while choosing. Say what it does, not its type. */
|
|
39
|
+
help: string;
|
|
40
|
+
type: SettingType;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SettingsGroup = {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
help: string;
|
|
47
|
+
settings: Setting[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── The schema ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const DIFFICULTY_HELP =
|
|
53
|
+
"Tasks the planner marked at this difficulty run on this model. Empty means: use whatever model pi is already on.";
|
|
54
|
+
|
|
55
|
+
export const SETTINGS: SettingsGroup[] = [
|
|
56
|
+
{
|
|
57
|
+
id: "models",
|
|
58
|
+
label: "Models",
|
|
59
|
+
help: "Route work to different models by difficulty. Choices come from the models pi has configured.",
|
|
60
|
+
settings: [
|
|
61
|
+
{
|
|
62
|
+
path: "enabled",
|
|
63
|
+
file: "router",
|
|
64
|
+
label: "Routing enabled",
|
|
65
|
+
help: "Off means every task uses pi's current model, whatever is set below.",
|
|
66
|
+
type: { kind: "boolean" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
path: "byDifficulty.easy",
|
|
70
|
+
file: "router",
|
|
71
|
+
label: "Easy tier",
|
|
72
|
+
help: DIFFICULTY_HELP,
|
|
73
|
+
type: { kind: "model" },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
path: "byDifficulty.moderate",
|
|
77
|
+
file: "router",
|
|
78
|
+
label: "Moderate tier",
|
|
79
|
+
help: DIFFICULTY_HELP,
|
|
80
|
+
type: { kind: "model" },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
path: "byDifficulty.difficult",
|
|
84
|
+
file: "router",
|
|
85
|
+
label: "Difficult tier",
|
|
86
|
+
help: DIFFICULTY_HELP,
|
|
87
|
+
type: { kind: "model" },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
path: "master",
|
|
91
|
+
file: "router",
|
|
92
|
+
label: "Master (consultation only)",
|
|
93
|
+
help: "Never assigned to a task directly — reached only when the ladder is exhausted and the harness asks for one opinion.",
|
|
94
|
+
type: { kind: "model" },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
path: "default",
|
|
98
|
+
file: "router",
|
|
99
|
+
label: "Default",
|
|
100
|
+
help: "Used when nothing more specific matches. Empty means pi's current model.",
|
|
101
|
+
type: { kind: "model" },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: "consultation.enabled",
|
|
105
|
+
file: "router",
|
|
106
|
+
label: "Allow consultation",
|
|
107
|
+
help: "When a task is stuck at the top of the ladder, ask the master model once.",
|
|
108
|
+
type: { kind: "boolean" },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
path: "consultation.maxPerTask",
|
|
112
|
+
file: "router",
|
|
113
|
+
label: "Consultations per task",
|
|
114
|
+
help: "How many times a single task may escalate to the master model.",
|
|
115
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: "pipeline",
|
|
121
|
+
label: "Pipeline",
|
|
122
|
+
help: "Which phases run, and how strictly roles are enforced.",
|
|
123
|
+
settings: [
|
|
124
|
+
{
|
|
125
|
+
path: "phases.enabled",
|
|
126
|
+
file: "config",
|
|
127
|
+
label: "Enabled phases",
|
|
128
|
+
help: "The pipeline, in order. SIMPLIFY is off by default; INIT is only for a fresh project.",
|
|
129
|
+
type: { kind: "multi", choices: PHASE_ORDER },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
path: "mode",
|
|
133
|
+
file: "config",
|
|
134
|
+
label: "Mode",
|
|
135
|
+
help: "copilot expects a human in the loop; autopilot is for unattended runs.",
|
|
136
|
+
type: { kind: "choice", choices: ["copilot", "autopilot"] },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
path: "roles.strict",
|
|
140
|
+
file: "config",
|
|
141
|
+
label: "Strict roles",
|
|
142
|
+
help: "Make role boundaries blocking rather than advisory. Useful with multiple agents.",
|
|
143
|
+
type: { kind: "boolean" },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
path: "paused",
|
|
147
|
+
file: "config",
|
|
148
|
+
label: "Paused",
|
|
149
|
+
help: "A paused pipeline refuses to advance and stops the continuous run.",
|
|
150
|
+
type: { kind: "boolean" },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "commands",
|
|
156
|
+
label: "Project commands",
|
|
157
|
+
help: "What the gate runs to judge the work. An empty command is reported as advisory, never as a failure.",
|
|
158
|
+
settings: [
|
|
159
|
+
{
|
|
160
|
+
path: "commands.lint",
|
|
161
|
+
file: "config",
|
|
162
|
+
label: "Lint",
|
|
163
|
+
help: "e.g. npm run lint · ruff check . · golangci-lint run",
|
|
164
|
+
type: { kind: "text", placeholder: "npm run lint", allowEmpty: true },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
path: "commands.test",
|
|
168
|
+
file: "config",
|
|
169
|
+
label: "Test",
|
|
170
|
+
help: "e.g. npm test · pytest -q · go test ./...",
|
|
171
|
+
type: { kind: "text", placeholder: "npm test", allowEmpty: true },
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: "commands.coverage",
|
|
175
|
+
file: "config",
|
|
176
|
+
label: "Coverage",
|
|
177
|
+
help: "Must print a percentage. The lowest figure it prints is the one used.",
|
|
178
|
+
type: { kind: "text", placeholder: "npm run coverage", allowEmpty: true },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
path: "commands.build",
|
|
182
|
+
file: "config",
|
|
183
|
+
label: "Build",
|
|
184
|
+
help: "Optional. Recorded for context; not currently gated on.",
|
|
185
|
+
type: { kind: "text", placeholder: "npm run build", allowEmpty: true },
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "gates",
|
|
191
|
+
label: "Gates",
|
|
192
|
+
help: "The referee. Turning gates off removes every guarantee the harness makes.",
|
|
193
|
+
settings: [
|
|
194
|
+
{
|
|
195
|
+
path: "gates.enabled",
|
|
196
|
+
file: "config",
|
|
197
|
+
label: "Gates enabled",
|
|
198
|
+
help: "Off means nothing is enforced and phases advance freely. Rarely what you want.",
|
|
199
|
+
type: { kind: "boolean" },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
path: "gates.coverage.enabled",
|
|
203
|
+
file: "config",
|
|
204
|
+
label: "Coverage gate",
|
|
205
|
+
help: "Require the coverage command to clear the threshold below.",
|
|
206
|
+
type: { kind: "boolean" },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
path: "gates.coverage.threshold",
|
|
210
|
+
file: "config",
|
|
211
|
+
label: "Coverage threshold",
|
|
212
|
+
help: "Percent. The gate fails below this figure.",
|
|
213
|
+
type: { kind: "number", min: 0, max: 100, unit: "%" },
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
path: "gates.antiPlaceholder.enabled",
|
|
217
|
+
file: "config",
|
|
218
|
+
label: "Reject placeholders",
|
|
219
|
+
help: "Fail the gate on TODO-implement, FIXME, 'not implemented' and friends in source.",
|
|
220
|
+
type: { kind: "boolean" },
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "loop",
|
|
226
|
+
label: "Continuous run",
|
|
227
|
+
help: "The budgets that decide when an unattended run gives up. These are what make walking away safe.",
|
|
228
|
+
settings: [
|
|
229
|
+
{
|
|
230
|
+
path: "loop.maxIterations",
|
|
231
|
+
file: "config",
|
|
232
|
+
label: "Iteration ceiling",
|
|
233
|
+
help: "Hard stop after this many loop turns, regardless of the clock.",
|
|
234
|
+
type: { kind: "number", min: 1, max: 100000 },
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
path: "loop.maxWallClockMs",
|
|
238
|
+
file: "config",
|
|
239
|
+
label: "Wall-clock budget",
|
|
240
|
+
help: "Milliseconds. 86400000 is 24 hours.",
|
|
241
|
+
type: { kind: "number", min: 60000, unit: "ms" },
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
path: "loop.noProgressLimit",
|
|
245
|
+
file: "config",
|
|
246
|
+
label: "No-progress strikes",
|
|
247
|
+
help: "Stop after this many gate failures in a row with no change to the tree or the plan.",
|
|
248
|
+
type: { kind: "number", min: 1, max: 50 },
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "retries",
|
|
254
|
+
label: "Retry budgets",
|
|
255
|
+
help: "How many attempts a task, feature or phase gets before the run escalates to you.",
|
|
256
|
+
settings: [
|
|
257
|
+
{
|
|
258
|
+
path: "maxRetries",
|
|
259
|
+
file: "config",
|
|
260
|
+
label: "Task retries",
|
|
261
|
+
help: "Attempts per task before the harness stops and asks for help.",
|
|
262
|
+
type: { kind: "number", min: 1, max: 100 },
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
path: "retry.features.enabled",
|
|
266
|
+
file: "config",
|
|
267
|
+
label: "Feature retry budget",
|
|
268
|
+
help: "Also bound retries per feature, not just per task.",
|
|
269
|
+
type: { kind: "boolean" },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
path: "retry.features.maxRetries",
|
|
273
|
+
file: "config",
|
|
274
|
+
label: "Feature retries",
|
|
275
|
+
help: "Attempts per feature when the budget above is enabled.",
|
|
276
|
+
type: { kind: "number", min: 1, max: 100 },
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
path: "retry.phases.enabled",
|
|
280
|
+
file: "config",
|
|
281
|
+
label: "Phase retry budget",
|
|
282
|
+
help: "Also bound how many times one phase may repeat.",
|
|
283
|
+
type: { kind: "boolean" },
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
path: "retry.phases.maxRetries",
|
|
287
|
+
file: "config",
|
|
288
|
+
label: "Phase retries",
|
|
289
|
+
help: "Attempts per phase when the budget above is enabled.",
|
|
290
|
+
type: { kind: "number", min: 1, max: 100 },
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
export function findGroup(id: string): SettingsGroup | undefined {
|
|
297
|
+
return SETTINGS.find((g) => g.id === id);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function allSettings(): Setting[] {
|
|
301
|
+
return SETTINGS.flatMap((g) => g.settings);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Read / write ────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export type SettingsIO = {
|
|
307
|
+
config: HarnessConfig;
|
|
308
|
+
router: RouterConfig;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export function readAll(targetDir: string): SettingsIO {
|
|
312
|
+
return {
|
|
313
|
+
config: loadConfig(targetDir).config,
|
|
314
|
+
router: loadRouterConfig(targetDir),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function readSetting(io: SettingsIO, setting: Setting): unknown {
|
|
319
|
+
const source = setting.file === "config" ? io.config : io.router;
|
|
320
|
+
return getKey(source as unknown as HarnessConfig, setting.path);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Persist one setting.
|
|
325
|
+
*
|
|
326
|
+
* Read-modify-write under the file's lock, so a config edit made from the TUI
|
|
327
|
+
* cannot clobber a concurrent write from the loop (which records gate history
|
|
328
|
+
* on the same file).
|
|
329
|
+
*/
|
|
330
|
+
export function writeSetting(targetDir: string, setting: Setting, value: unknown): void {
|
|
331
|
+
if (setting.file === "config") {
|
|
332
|
+
withLockSync(configPath(targetDir), () => {
|
|
333
|
+
const { config } = loadConfig(targetDir);
|
|
334
|
+
setKey(config, setting.path, value);
|
|
335
|
+
const res = saveConfig(targetDir, config);
|
|
336
|
+
if (!res.ok) throw new Error(res.error ?? "could not save config");
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
withLockSync(modelRouterPath(targetDir), () => {
|
|
341
|
+
const router = loadRouterConfig(targetDir);
|
|
342
|
+
setKey(router as unknown as HarnessConfig, setting.path, value);
|
|
343
|
+
saveRouterConfig(targetDir, router);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Display ─────────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/** How a value is shown in the menu. Empty model slots read as inherited. */
|
|
350
|
+
export function formatValue(setting: Setting, value: unknown): string {
|
|
351
|
+
switch (setting.type.kind) {
|
|
352
|
+
case "boolean":
|
|
353
|
+
return value ? "on" : "off";
|
|
354
|
+
case "model":
|
|
355
|
+
return typeof value === "string" && value.trim() ? value : "(pi's current model)";
|
|
356
|
+
case "text":
|
|
357
|
+
return typeof value === "string" && value.trim() ? value : "(not set)";
|
|
358
|
+
case "multi":
|
|
359
|
+
return Array.isArray(value) && value.length ? value.join(" → ") : "(none)";
|
|
360
|
+
case "number": {
|
|
361
|
+
if (typeof value !== "number") return "(not set)";
|
|
362
|
+
if (setting.type.unit === "ms") return humanizeMs(value);
|
|
363
|
+
return setting.type.unit ? `${value}${setting.type.unit}` : String(value);
|
|
364
|
+
}
|
|
365
|
+
default:
|
|
366
|
+
return value === undefined || value === null ? "(not set)" : String(value);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function humanizeMs(ms: number): string {
|
|
371
|
+
if (ms >= 3_600_000) {
|
|
372
|
+
const h = ms / 3_600_000;
|
|
373
|
+
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
|
|
374
|
+
}
|
|
375
|
+
if (ms >= 60_000) return `${Math.round(ms / 60_000)}m`;
|
|
376
|
+
return `${ms}ms`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Parse a duration the user typed: bare ms, or `12h` / `90m` / `30s`. */
|
|
380
|
+
export function parseDuration(input: string): number | null {
|
|
381
|
+
const s = input.trim().toLowerCase();
|
|
382
|
+
if (!s) return null;
|
|
383
|
+
const m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/);
|
|
384
|
+
if (!m) return null;
|
|
385
|
+
const n = Number.parseFloat(m[1]!);
|
|
386
|
+
if (!Number.isFinite(n)) return null;
|
|
387
|
+
switch (m[2]) {
|
|
388
|
+
case "h":
|
|
389
|
+
return Math.round(n * 3_600_000);
|
|
390
|
+
case "m":
|
|
391
|
+
return Math.round(n * 60_000);
|
|
392
|
+
case "s":
|
|
393
|
+
return Math.round(n * 1000);
|
|
394
|
+
default:
|
|
395
|
+
return Math.round(n);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export type ValidationResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
400
|
+
|
|
401
|
+
/** Coerce and bounds-check a raw answer for `setting`. */
|
|
402
|
+
export function coerce(setting: Setting, raw: string): ValidationResult {
|
|
403
|
+
const t = setting.type;
|
|
404
|
+
switch (t.kind) {
|
|
405
|
+
case "number": {
|
|
406
|
+
const n = t.unit === "ms" ? parseDuration(raw) : Number(raw.trim());
|
|
407
|
+
if (n === null || !Number.isFinite(n)) {
|
|
408
|
+
return { ok: false, error: t.unit === "ms" ? `not a duration: "${raw}" (try 24h, 90m, or a number of ms)` : `not a number: "${raw}"` };
|
|
409
|
+
}
|
|
410
|
+
if (t.min !== undefined && n < t.min) return { ok: false, error: `must be at least ${t.min}` };
|
|
411
|
+
if (t.max !== undefined && n > t.max) return { ok: false, error: `must be at most ${t.max}` };
|
|
412
|
+
return { ok: true, value: n };
|
|
413
|
+
}
|
|
414
|
+
case "text":
|
|
415
|
+
case "model": {
|
|
416
|
+
const v = raw.trim();
|
|
417
|
+
if (!v && t.kind === "text" && t.allowEmpty === false) return { ok: false, error: "cannot be empty" };
|
|
418
|
+
// An empty model slot is meaningful: inherit pi's current model.
|
|
419
|
+
return { ok: true, value: v };
|
|
420
|
+
}
|
|
421
|
+
case "choice": {
|
|
422
|
+
const v = raw.trim();
|
|
423
|
+
if (!t.choices.includes(v)) return { ok: false, error: `must be one of: ${t.choices.join(", ")}` };
|
|
424
|
+
return { ok: true, value: v };
|
|
425
|
+
}
|
|
426
|
+
case "boolean": {
|
|
427
|
+
const v = raw.trim().toLowerCase();
|
|
428
|
+
if (["on", "true", "yes", "y", "1"].includes(v)) return { ok: true, value: true };
|
|
429
|
+
if (["off", "false", "no", "n", "0"].includes(v)) return { ok: true, value: false };
|
|
430
|
+
return { ok: false, error: `expected on or off, got "${raw}"` };
|
|
431
|
+
}
|
|
432
|
+
case "multi": {
|
|
433
|
+
const parts = raw
|
|
434
|
+
.split(/[,\s]+/)
|
|
435
|
+
.map((s) => s.trim())
|
|
436
|
+
.filter(Boolean);
|
|
437
|
+
const bad = parts.filter((p) => !t.choices.includes(p));
|
|
438
|
+
if (bad.length) return { ok: false, error: `not valid: ${bad.join(", ")}` };
|
|
439
|
+
// Keep canonical order regardless of what order they were typed in —
|
|
440
|
+
// the pipeline is an ordered sequence, not a set.
|
|
441
|
+
const ordered = t.choices.filter((c) => parts.includes(c));
|
|
442
|
+
return { ok: true, value: ordered as Phase[] };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|