infinity-harness 2.5.1 → 2.6.1
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 +52 -0
- package/extensions/infinity-harness/index.ts +4 -2
- package/harness/docs/plans/2.6-unified-phase-parallel-workers.md +234 -0
- package/package.json +1 -1
- package/src/core/brief.ts +2 -1
- package/src/core/config.ts +80 -12
- package/src/core/featureList.ts +44 -7
- package/src/core/gates.ts +18 -4
- package/src/core/init.ts +2 -0
- package/src/core/phases.ts +60 -0
- package/src/core/settings.ts +115 -10
- package/src/core/types.ts +19 -0
- package/src/escalate.ts +17 -3
- package/src/intake.ts +6 -0
- package/src/loop.ts +115 -9
- package/src/modelRouter.ts +14 -0
- package/src/remote.ts +11 -0
- package/src/replan.ts +8 -3
- package/src/scheduler.ts +205 -0
- package/src/taskList.ts +3 -1
- package/src/ui/dashboard.ts +24 -14
- package/src/ui/widget.ts +101 -58
- package/src/ui/wizard.ts +22 -2
package/src/replan.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type ReplanTaskInput = {
|
|
|
51
51
|
difficulty?: string;
|
|
52
52
|
modelHint?: string;
|
|
53
53
|
acceptanceCriteria?: string[];
|
|
54
|
+
phase?: string;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export interface AmendPlanOpts {
|
|
@@ -66,6 +67,7 @@ export interface AmendPlanOpts {
|
|
|
66
67
|
passes?: boolean;
|
|
67
68
|
tasks?: ReplanTaskInput[];
|
|
68
69
|
difficulty?: string;
|
|
70
|
+
phase?: string;
|
|
69
71
|
}>;
|
|
70
72
|
addTasks?: Array<{ featureId: string; task: ReplanTaskInput }>;
|
|
71
73
|
}
|
|
@@ -171,7 +173,7 @@ function readMaxReplans(projectDir: string): number {
|
|
|
171
173
|
|
|
172
174
|
/** Shape a submitted task into the stored form. One place, so the two add paths agree. */
|
|
173
175
|
function toStoredTask(t: ReplanTaskInput): Task {
|
|
174
|
-
|
|
176
|
+
const out: Task = {
|
|
175
177
|
id: t.id,
|
|
176
178
|
key: t.key,
|
|
177
179
|
description: t.description,
|
|
@@ -181,7 +183,9 @@ function toStoredTask(t: ReplanTaskInput): Task {
|
|
|
181
183
|
difficulty: t.difficulty as Task["difficulty"],
|
|
182
184
|
modelHint: t.modelHint,
|
|
183
185
|
acceptanceCriteria: t.acceptanceCriteria ?? [],
|
|
184
|
-
};
|
|
186
|
+
} as Task;
|
|
187
|
+
if (t.phase) (out as unknown as { phase: string }).phase = t.phase;
|
|
188
|
+
return out;
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
|
|
@@ -227,8 +231,9 @@ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
|
|
|
227
231
|
sprintId: f.sprintId,
|
|
228
232
|
goalId: f.goalId,
|
|
229
233
|
difficulty: f.difficulty,
|
|
234
|
+
...(f.phase ? { phase: f.phase } : {}),
|
|
230
235
|
tasks: (f.tasks ?? []).map(toStoredTask),
|
|
231
|
-
};
|
|
236
|
+
} as Feature;
|
|
232
237
|
list.features.push(feature);
|
|
233
238
|
addedFeatures++;
|
|
234
239
|
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — parallel scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Picks which tasks can run now, respecting deps and the chosen
|
|
5
|
+
* parallel granularity, then spawns isolated workers for them.
|
|
6
|
+
* The main session never edits files; it only polls worker logs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
|
|
10
|
+
import { loadFeatureList, tasksForPhase, type FlatTask } from "./core/featureList.ts";
|
|
11
|
+
import { loadRouterConfig } from "./modelRouter.ts";
|
|
12
|
+
import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
|
|
13
|
+
import { runIdFor } from "./runState.ts";
|
|
14
|
+
|
|
15
|
+
export type PickOpts = {
|
|
16
|
+
targetDir: string;
|
|
17
|
+
phase?: Phase | null;
|
|
18
|
+
parallelAt?: HandoffGranularity;
|
|
19
|
+
maxWorkers?: number;
|
|
20
|
+
/** Keys already being worked (in_progress) or rework. */
|
|
21
|
+
exclude?: Set<string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Snapshot a worker's attempt dir for the widget/dashboard. */
|
|
25
|
+
export type WorkerSnapshot = {
|
|
26
|
+
featureId: string;
|
|
27
|
+
taskId: string;
|
|
28
|
+
compositeKey: string;
|
|
29
|
+
attemptDir: string;
|
|
30
|
+
attempt: number;
|
|
31
|
+
state: "running" | "done" | "failed";
|
|
32
|
+
outputTail: string;
|
|
33
|
+
askedAt?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Tail a worker attempt's output.log (best-effort, never throws). */
|
|
37
|
+
export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
|
|
38
|
+
try {
|
|
39
|
+
const { readFileSync, existsSync } = require("node:fs");
|
|
40
|
+
const p = require("node:path").join(attemptDir, "output.log");
|
|
41
|
+
if (!existsSync(p)) return "";
|
|
42
|
+
const raw = readFileSync(p, "utf-8") as string;
|
|
43
|
+
return raw.slice(-bytes);
|
|
44
|
+
} catch { return ""; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[] {
|
|
48
|
+
try {
|
|
49
|
+
const { readdirSync, existsSync } = require("node:fs");
|
|
50
|
+
const path = require("node:path");
|
|
51
|
+
const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
|
|
52
|
+
const roots: string[] = [];
|
|
53
|
+
if (runId) {
|
|
54
|
+
roots.push(path.resolve(targetDir, "tmp/infinity-harness", runId));
|
|
55
|
+
} else {
|
|
56
|
+
// all runs under tmp/infinity-harness
|
|
57
|
+
const base = path.resolve(targetDir, "tmp/infinity-harness");
|
|
58
|
+
if (existsSync(base)) for (const e of readdirSync(base,{ withFileTypes: true } as any)) if((e as any).isDirectory()) roots.push(path.join(base,(e as any).name));
|
|
59
|
+
}
|
|
60
|
+
const out: WorkerSnapshot[] = [];
|
|
61
|
+
for (const run of roots) {
|
|
62
|
+
if (!existsSync(run)) continue;
|
|
63
|
+
// run/feature/task/attempt-N as created by worker.ts
|
|
64
|
+
for (const f of readdirSync(run,{withFileTypes:true} as any) as any[]) {
|
|
65
|
+
if(!f.isDirectory()) continue;
|
|
66
|
+
const feat = path.join(run, f.name);
|
|
67
|
+
for (const t of readdirSync(feat,{withFileTypes:true} as any) as any[]) {
|
|
68
|
+
if(!t.isDirectory()) continue;
|
|
69
|
+
const taskRoot = path.join(feat, t.name);
|
|
70
|
+
const attempts = readdirSync(taskRoot,{withFileTypes:true} as any) as any[];
|
|
71
|
+
for (const a of attempts) {
|
|
72
|
+
if(!a.isDirectory() || !a.name.startsWith("attempt-")) continue;
|
|
73
|
+
const attemptDir = path.join(taskRoot, a.name);
|
|
74
|
+
const n = Number.parseInt(a.name.replace("attempt-",""),10) || 0;
|
|
75
|
+
const tail = tailWorkerOutput(attemptDir, 800);
|
|
76
|
+
out.push({
|
|
77
|
+
featureId: f.name,
|
|
78
|
+
taskId: t.name,
|
|
79
|
+
compositeKey: `${f.name}/${t.name}`,
|
|
80
|
+
attemptDir,
|
|
81
|
+
attempt: n,
|
|
82
|
+
state: "running",
|
|
83
|
+
outputTail: tail,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
} catch { return []; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function nextModelForTask(targetDir: string, difficulty?: string, taskId?: string, key?: string): { model?: string; thinking?: string } {
|
|
94
|
+
try {
|
|
95
|
+
const { resolveModel, resolveThinking } = require("./modelRouter.ts");
|
|
96
|
+
return {
|
|
97
|
+
model: resolveModel({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
|
|
98
|
+
thinking: resolveThinking({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
|
|
99
|
+
};
|
|
100
|
+
} catch { return {}; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
|
|
104
|
+
const { list } = loadFeatureList(opts.targetDir);
|
|
105
|
+
const phase = (opts.phase ?? null) as Phase | null;
|
|
106
|
+
// Phase-filtered pool when phase given, else all tasks across phases.
|
|
107
|
+
const { flattenTasks } = require("./core/featureList.ts");
|
|
108
|
+
const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
|
|
109
|
+
// Build key map for dep check
|
|
110
|
+
const byKey = new Map<string, FlatTask>();
|
|
111
|
+
for (const t of all) {
|
|
112
|
+
byKey.set(t.compositeKey, t);
|
|
113
|
+
byKey.set(t.id, t);
|
|
114
|
+
if (t.key) byKey.set(t.key, t);
|
|
115
|
+
}
|
|
116
|
+
const eligible = all.filter((t) => {
|
|
117
|
+
if (t.status !== "pending") return false;
|
|
118
|
+
if (opts.exclude?.has(t.compositeKey) || opts.exclude?.has(t.id)) return false;
|
|
119
|
+
const deps = t.dependsOn ?? [];
|
|
120
|
+
return deps.every((d) => {
|
|
121
|
+
const dep = byKey.get(d);
|
|
122
|
+
return dep && dep.status === "complete";
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Group by granularity to enforce breadth limit.
|
|
127
|
+
const level = opts.parallelAt ?? "off";
|
|
128
|
+
if (level === "off" || level === "goal") {
|
|
129
|
+
// one task at a time (or one goal pipeline)
|
|
130
|
+
return eligible.slice(0, 1);
|
|
131
|
+
}
|
|
132
|
+
const keyFor = (t: FlatTask): string => {
|
|
133
|
+
// Resolve sprints/goals via feature list lookups when needed.
|
|
134
|
+
if (level === "task" || level === "subtask") return t.compositeKey;
|
|
135
|
+
if (level === "feature") return t.featureId;
|
|
136
|
+
if (level === "sprint") {
|
|
137
|
+
const feat = list.features.find((f) => f.id === t.featureId);
|
|
138
|
+
return (feat as { sprintId?: string } | undefined)?.sprintId ?? t.featureId;
|
|
139
|
+
}
|
|
140
|
+
if (level === "phase") return t.effectivePhase ?? "build";
|
|
141
|
+
return t.compositeKey;
|
|
142
|
+
};
|
|
143
|
+
const groups = new Map<string, FlatTask[]>();
|
|
144
|
+
for (const t of eligible) {
|
|
145
|
+
const k = keyFor(t);
|
|
146
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
147
|
+
groups.get(k)!.push(t);
|
|
148
|
+
}
|
|
149
|
+
// Take one per group breadth-first, up to maxWorkers.
|
|
150
|
+
const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
|
|
151
|
+
const out: FlatTask[] = [];
|
|
152
|
+
const iters = groups.values();
|
|
153
|
+
// Round-robin one per group.
|
|
154
|
+
const groupArrays = [...groups.values()];
|
|
155
|
+
let idx = 0;
|
|
156
|
+
while (out.length < max && groupArrays.some((g) => g.length > 0)) {
|
|
157
|
+
const g = groupArrays[idx % groupArrays.length]!;
|
|
158
|
+
if (g.length > 0) {
|
|
159
|
+
const task = g.shift()!;
|
|
160
|
+
out.push(task);
|
|
161
|
+
}
|
|
162
|
+
idx++;
|
|
163
|
+
if (idx > max * groupArrays.length + 10) break;
|
|
164
|
+
}
|
|
165
|
+
return out.slice(0, max);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function spawnWorkers(
|
|
169
|
+
targetDir: string,
|
|
170
|
+
tasks: FlatTask[],
|
|
171
|
+
opts: { runId?: string; promptFor: (t: FlatTask) => string; command?: string } = { promptFor: () => "" },
|
|
172
|
+
): Promise<SpawnWorkerResult[]> {
|
|
173
|
+
const { resolveModel } = await import("./modelRouter.ts");
|
|
174
|
+
const runId = opts?.runId ?? runIdFor(targetDir, "sched");
|
|
175
|
+
const results: SpawnWorkerResult[] = [];
|
|
176
|
+
for (const t of tasks) {
|
|
177
|
+
const prompt = opts.promptFor(t);
|
|
178
|
+
const router = loadRouterConfig(targetDir);
|
|
179
|
+
let modelHint: string | undefined;
|
|
180
|
+
if (router.enabled) {
|
|
181
|
+
try { modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty, id: t.id, key: t.compositeKey } }); } catch {}
|
|
182
|
+
}
|
|
183
|
+
const res = await spawnIsolatedWorker({
|
|
184
|
+
projectDir: targetDir,
|
|
185
|
+
runId,
|
|
186
|
+
featureId: t.featureId,
|
|
187
|
+
taskId: t.id,
|
|
188
|
+
prompt,
|
|
189
|
+
command: opts.command,
|
|
190
|
+
model: modelHint,
|
|
191
|
+
});
|
|
192
|
+
results.push(res);
|
|
193
|
+
}
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function executionPolicyOf(config: HarnessConfig): { parallelAt: HandoffGranularity; maxWorkers: number } {
|
|
198
|
+
const e = (config.execution ?? {}) as Partial<{ parallelAt: unknown; maxWorkers: unknown }>;
|
|
199
|
+
const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
|
|
200
|
+
? (e.parallelAt as HandoffGranularity)
|
|
201
|
+
: "task";
|
|
202
|
+
const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
|
|
203
|
+
const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
|
|
204
|
+
return { parallelAt: at, maxWorkers };
|
|
205
|
+
}
|
package/src/taskList.ts
CHANGED
|
@@ -279,13 +279,14 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
279
279
|
: (existing?.subtasks ?? []).map((s) => ({ ...s }));
|
|
280
280
|
|
|
281
281
|
// Merge onto the stored task so unknown fields survive. `index`,
|
|
282
|
-
// `compositeKey`, `featureId
|
|
282
|
+
// `compositeKey`, `featureId`, `featureName`, `effectivePhase` are view-only additions
|
|
283
283
|
// from flattenTasks and must not be persisted.
|
|
284
284
|
const base: Record<string, unknown> = existing ? { ...existing } : {};
|
|
285
285
|
delete base.index;
|
|
286
286
|
delete base.compositeKey;
|
|
287
287
|
delete base.featureId;
|
|
288
288
|
delete base.featureName;
|
|
289
|
+
delete base.effectivePhase;
|
|
289
290
|
|
|
290
291
|
const task: Task = {
|
|
291
292
|
...(base as Partial<Task>),
|
|
@@ -460,6 +461,7 @@ function stripView(t: FlatTask): Task {
|
|
|
460
461
|
delete copy.compositeKey;
|
|
461
462
|
delete copy.featureId;
|
|
462
463
|
delete copy.featureName;
|
|
464
|
+
delete copy.effectivePhase;
|
|
463
465
|
return copy as Task;
|
|
464
466
|
}
|
|
465
467
|
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -500,15 +500,19 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
|
|
|
500
500
|
* of the widget rather than the place you go for the full picture.
|
|
501
501
|
*/
|
|
502
502
|
function renderSubtasks(task: FlatTask, mode: DisplayPolicy["levels"]["subtask"], active: boolean): string {
|
|
503
|
-
|
|
503
|
+
// Dashboard always shows subtasks when any exist — it's the full-detail view.
|
|
504
|
+
// The display.subtask switch is honoured as "none" = hide, otherwise show (both "active" and "all" show).
|
|
505
|
+
if (mode === "none") return "";
|
|
504
506
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
505
507
|
if (subs.length === 0) return "";
|
|
506
508
|
const items = subs
|
|
507
509
|
.map((s) => {
|
|
510
|
+
const st = safeSubtaskStatus(s?.status);
|
|
508
511
|
const glyph =
|
|
509
|
-
|
|
510
|
-
const cls =
|
|
511
|
-
|
|
512
|
+
st === "complete" ? GLYPHS.subDone : st === "in_progress" ? GLYPHS.subActive : GLYPHS.subPending;
|
|
513
|
+
const cls = st === "complete" ? "complete" : st === "in_progress" ? "active" : "pending";
|
|
514
|
+
const isActive = st === "in_progress";
|
|
515
|
+
return `<li class="sub sub-${cls}${isActive ? " is-active" : ""}"><span class="sub-glyph" aria-hidden="true">${esc(glyph)}</span>${esc(s.title ?? "")}</li>`;
|
|
512
516
|
})
|
|
513
517
|
.join("");
|
|
514
518
|
return `<ul class="subs">${items}</ul>`;
|
|
@@ -554,7 +558,9 @@ function renderFeature(
|
|
|
554
558
|
const counts = countByStatus(tasks);
|
|
555
559
|
const total = tasks.length;
|
|
556
560
|
const complete = total > 0 && counts.complete === total;
|
|
557
|
-
|
|
561
|
+
// active anywhere in this branch: any in_progress/rework or unblocked pending.
|
|
562
|
+
const hasActiveTask = tasks.some((t) => t.status === "in_progress" || t.status === "rework");
|
|
563
|
+
const current = (isCurrent || hasActiveTask) && !complete;
|
|
558
564
|
|
|
559
565
|
const chips = [
|
|
560
566
|
sprintName ? `<span class="chip chip-quiet">${esc(sprintName)}</span>` : "",
|
|
@@ -613,7 +619,10 @@ function renderGoalGroup(
|
|
|
613
619
|
display: DisplayPolicy,
|
|
614
620
|
activeFeatureId?: string | null,
|
|
615
621
|
): string {
|
|
616
|
-
|
|
622
|
+
// active if any task anywhere in this goal is active.
|
|
623
|
+
const activeGoal = group.sprints.some((sg) =>
|
|
624
|
+
sg.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework"))
|
|
625
|
+
);
|
|
617
626
|
const sprints = group.sprints
|
|
618
627
|
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display, activeFeatureId))
|
|
619
628
|
.join("");
|
|
@@ -639,7 +648,7 @@ function renderSprintGroup(
|
|
|
639
648
|
display: DisplayPolicy,
|
|
640
649
|
activeFeatureId?: string | null,
|
|
641
650
|
): string {
|
|
642
|
-
const activeSprint =
|
|
651
|
+
const activeSprint = group.features.some((f) => (tasksByFeature.get(f.id) ?? []).some((t) => t.status === "in_progress" || t.status === "rework"));
|
|
643
652
|
const features = display.levels.feature
|
|
644
653
|
? group.features
|
|
645
654
|
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display, f.id === activeFeatureId))
|
|
@@ -888,8 +897,8 @@ body{
|
|
|
888
897
|
.seg-blocked{background:var(--c-blocked)}
|
|
889
898
|
|
|
890
899
|
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;list-style:none;margin:0;padding:0;font-size:12px}
|
|
891
|
-
.legend-item{display:flex;align-items:center;gap:6px;color:var(--muted)}
|
|
892
|
-
.legend-item.is-zero{opacity
|
|
900
|
+
.legend-item{display:flex;align-items:center;gap:6px;color:var(--muted);opacity:1}
|
|
901
|
+
.legend-item.is-zero{opacity:1}
|
|
893
902
|
.legend-dot{width:8px;height:8px;border-radius:2px;flex:none}
|
|
894
903
|
.legend-n{font-weight:650;color:var(--text);font-variant-numeric:tabular-nums}
|
|
895
904
|
.legend-complete .legend-dot{background:var(--c-complete)}
|
|
@@ -977,11 +986,12 @@ table.tasks tr:last-child td{border-bottom:0}
|
|
|
977
986
|
.row-rework.is-active{background:rgba(var(--rgb-rework),.08)}
|
|
978
987
|
.row-rework.is-active .cell-n{box-shadow:inset 2px 0 0 var(--c-rework)}
|
|
979
988
|
@keyframes taskBlink{0%,100%{opacity:1}50%{opacity:.72}}
|
|
980
|
-
/* While-developed
|
|
981
|
-
.tier.is-current,.feature.is-current{animation:cardPulse
|
|
982
|
-
.tier.is-current .tier-name,.feature.is-current .feature-name{
|
|
983
|
-
|
|
984
|
-
@keyframes
|
|
989
|
+
/* While-developed: the whole active branch pulses — every active box, not just one feature. */
|
|
990
|
+
.tier.is-current,.feature.is-current{animation:cardPulse 0.9s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35), var(--shadow)}
|
|
991
|
+
.tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent)}
|
|
992
|
+
.row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px}
|
|
993
|
+
@keyframes cardPulse{0%,100%{box-shadow:0 0 0 2px rgba(var(--rgb-accent),.35),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.14),var(--shadow); border-color:rgba(var(--rgb-accent),.55)}}
|
|
994
|
+
@keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
|
|
985
995
|
.row-blocked{background:rgba(var(--rgb-blocked),.07)}
|
|
986
996
|
.row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
|
|
987
997
|
.deps{color:var(--faint);white-space:nowrap}
|
package/src/ui/widget.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* even if the agent's own narration has drifted.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { FeatureList, Phase, TaskStatus } from "../core/types.ts";
|
|
16
|
-
import { computeProgress, flattenTasks, nextActionableTask } from "../core/featureList.ts";
|
|
15
|
+
import type { FeatureList, Phase, Subtask, TaskStatus } from "../core/types.ts";
|
|
16
|
+
import { computeProgress, flattenTasks, nextActionableTask, type FlatTask } from "../core/featureList.ts";
|
|
17
17
|
import { getPhaseOrder } from "../core/phases.ts";
|
|
18
18
|
import { buildPlanRows, focusRowIndex, type PlanRow } from "./planTree.ts";
|
|
19
19
|
import { defaultDisplay, normalizeDisplay } from "./display.ts";
|
|
@@ -412,23 +412,111 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
412
412
|
// A single goal is the run's headline and belongs at the top, not buried in
|
|
413
413
|
// the tree — `buildPlanRows` collapses it there for exactly this reason.
|
|
414
414
|
// Several goals are structure, and structure belongs in the tree.
|
|
415
|
-
const
|
|
416
|
-
const
|
|
415
|
+
const _goals = state.list.goals ?? [];
|
|
416
|
+
const _headline = !display.levels.goal
|
|
417
417
|
? null
|
|
418
|
-
:
|
|
419
|
-
? (
|
|
420
|
-
:
|
|
418
|
+
: _goals.length === 1
|
|
419
|
+
? (_goals[0]?.title ?? null)
|
|
420
|
+
: _goals.length === 0
|
|
421
421
|
? (state.intake ?? null)
|
|
422
422
|
: null;
|
|
423
|
-
if (
|
|
424
|
-
const wrapped = wrap(
|
|
423
|
+
if (_headline) {
|
|
424
|
+
const wrapped = wrap(_headline, inner - 2);
|
|
425
425
|
wrapped.forEach((line, i) => {
|
|
426
|
-
// The marker belongs to the goal, not to every line of it. Repeating it
|
|
427
|
-
// down the left edge reads as a list of goals rather than one wrapped.
|
|
428
426
|
push((i === 0 ? s.fg("muted", g.goal + " ") : " ") + s.fg("text", line));
|
|
429
427
|
});
|
|
430
428
|
}
|
|
431
429
|
|
|
430
|
+
// -- current chain: one line per lane: phase · task · feature (+ sprint) · subtask
|
|
431
|
+
// On an empty plan with no task yet, just show phase/goal.
|
|
432
|
+
|
|
433
|
+
// Task lane disabled -> no lane at all (overview template wants shape not work).
|
|
434
|
+
// Otherwise show active + pending first lane.
|
|
435
|
+
if (!display.levels.task) {
|
|
436
|
+
// Overview: keep the shape (goal/sprint/feature tier names) even without lanes.
|
|
437
|
+
const f = state.list.features[0];
|
|
438
|
+
if (f) {
|
|
439
|
+
const sname = (f as { sprintId?: string }).sprintId ? (state.list.sprints ?? []).find((s) => s.id === (f as { sprintId?: string }).sprintId)?.name : null;
|
|
440
|
+
const gname = (f as { goalId?: string }).goalId ? (state.list.goals ?? []).find((g) => g.id === (f as { goalId?: string }).goalId)?.title : null;
|
|
441
|
+
const shape: string[] = [];
|
|
442
|
+
if (display.levels.goal && gname) shape.push(s.fg("muted", "goal " + gname.slice(0, 28)));
|
|
443
|
+
if (display.levels.sprint && sname) shape.push(s.fg("muted", "sprint " + sname.slice(0, 22)));
|
|
444
|
+
if (display.levels.feature) shape.push(s.fg("success", f.name.slice(0, 28)));
|
|
445
|
+
if (shape.length) { push(); push(truncate(shape.join(s.fg("rule", " \u00b7 ")), inner)); }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Aggregate task-window size respected: lane count capped; elision markers show hidden work.
|
|
449
|
+
// On a huge plan (120 tasks) the widget previously rendered ~TASK_WINDOW rows; now show up to display.taskWindow lanes.
|
|
450
|
+
// On a long plan the lane is compact — goal handled via headline above; phase first inside lane so
|
|
451
|
+
// narrow TUI still shows active work before sprint/feature tail gets cut.
|
|
452
|
+
const taskWindow = view.expanded ? Math.max(28, display.taskWindow * 2) : display.taskWindow;
|
|
453
|
+
if (display.levels.task) {
|
|
454
|
+
const allTasks = flattenTasks(state.list);
|
|
455
|
+
// When user scrolled explicitly, honour the scroll window (huge plan test uses scroll: 0/1e6).
|
|
456
|
+
// Otherwise show focus-centred window (active task plus pending tail).
|
|
457
|
+
let lanes: Array<FlatTask & { subtasks?: { status: string; title: string }[] }> = [];
|
|
458
|
+
if (view.scroll !== null) {
|
|
459
|
+
const start = Math.max(0, Math.min(view.scroll as number, Math.max(0, allTasks.length - taskWindow)));
|
|
460
|
+
lanes = allTasks.slice(start, start + taskWindow).map((t) => t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
461
|
+
} else {
|
|
462
|
+
const activeTasks = allTasks.filter((t) => t.status === "in_progress" || t.status === "rework");
|
|
463
|
+
const focus = nextActionableTask(state.list) ?? activeTasks[0] ?? null;
|
|
464
|
+
if (focus) lanes.push(focus as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
465
|
+
for (const t of activeTasks) if (focus && t.compositeKey !== focus.compositeKey && lanes.length < taskWindow) lanes.push(t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
466
|
+
if (!lanes.length) {
|
|
467
|
+
const pending = nextActionableTask(state.list);
|
|
468
|
+
if (pending) lanes.push(pending as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
469
|
+
}
|
|
470
|
+
if (lanes.length < taskWindow) {
|
|
471
|
+
const pendingQ = allTasks.filter((t) => t.status === "pending" && !lanes.some((l) => l.compositeKey === t.compositeKey));
|
|
472
|
+
for (const t of pendingQ) {
|
|
473
|
+
if (lanes.length >= taskWindow) break;
|
|
474
|
+
lanes.push(t as unknown as FlatTask & { subtasks?: { status: string; title: string }[] });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const formatChain = (t: FlatTask & { subtasks?: { status: string; title: string }[] }): string => {
|
|
479
|
+
const curFeature = state.list.features.find((f) => f.id === t.featureId) ?? null;
|
|
480
|
+
const curSprint = curFeature?.sprintId ? (state.list.sprints ?? []).find((s) => s.id === curFeature!.sprintId) ?? null : null;
|
|
481
|
+
const curGoal = (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id) ? (state.list.goals ?? []).find((gg) => gg.id === (curFeature?.goalId ?? curSprint?.goalId ?? (state.list.goals ?? [])[0]?.id)) ?? null : null;
|
|
482
|
+
// goal + sprint + phase + feature + task + subtask: names/titles
|
|
483
|
+
// Always show the phase and the task; goal/sprint/feature/subtask honour display.levels.
|
|
484
|
+
const parts: string[] = [];
|
|
485
|
+
// One-line chain: phase · sprint · feature · task · subtask on one line.
|
|
486
|
+
// Sprint before task/feature so even narrow realpi rasterizer keeps Foundations visible.
|
|
487
|
+
if (state.phase) parts.push(s.bold(s.fg("accent", state.phase.toUpperCase())));
|
|
488
|
+
if (curSprint && display.levels.sprint) parts.push(s.fg("muted", "sprint " + (curSprint.name ?? curSprint.id).slice(0, 18)));
|
|
489
|
+
if (curFeature && display.levels.feature) parts.push(s.fg("success", curFeature.name.slice(0, 24)));
|
|
490
|
+
const descEarly = t.description ? t.description.slice(0, 44) : "";
|
|
491
|
+
parts.push(s.fg("text", t.compositeKey + (descEarly ? " " + descEarly.slice(0, 36) : "")));
|
|
492
|
+
// subtask handled as separate line so width budget doesn't cut it off; drop from chain
|
|
493
|
+
return parts.join(s.fg("rule", " · "));
|
|
494
|
+
};
|
|
495
|
+
if (lanes.length) {
|
|
496
|
+
// Elision: lanes window + markers so huge plan still shows ... N above / ... N below
|
|
497
|
+
const totalPendable = allTasks.length;
|
|
498
|
+
const above = allTasks.findIndex((t) => t.compositeKey === lanes[0]!.compositeKey);
|
|
499
|
+
const lastIdx = allTasks.findIndex((t) => t.compositeKey === lanes[lanes.length - 1]!.compositeKey);
|
|
500
|
+
const below = Math.max(0, totalPendable - lastIdx - 1);
|
|
501
|
+
const shownAbove = above > 0 ? above : 0;
|
|
502
|
+
const shownBelow = below > 0 ? below : 0;
|
|
503
|
+
push();
|
|
504
|
+
if (shownAbove > 0) push(s.fg("rule", " " + g.more + " " + shownAbove + " above"));
|
|
505
|
+
for (const task of lanes.slice(0, taskWindow)) {
|
|
506
|
+
push(truncate(formatChain(task), inner));
|
|
507
|
+
// If task has an active subtask, show it on its own line so narrow TUI doesn't truncate it away.
|
|
508
|
+
if (display.levels.subtask !== "none") {
|
|
509
|
+
const cur = ((task as unknown as FlatTask & { subtasks?: Subtask[] }).subtasks ?? []).find((ss) => ss.status !== "complete") ?? null;
|
|
510
|
+
if (cur) push(truncate(" " + s.fg("active", "> " + cur.title.slice(0, 56)), inner));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (shownBelow > 0) push(s.fg("rule", " " + g.more + " " + shownBelow + " below"));
|
|
514
|
+
} else if (state.list.features.length === 0) {
|
|
515
|
+
push();
|
|
516
|
+
push(s.fg("muted", " no plan yet"));
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
432
520
|
// -- phase rail -----------------------------------------------------------
|
|
433
521
|
if (display.rail) {
|
|
434
522
|
push();
|
|
@@ -489,53 +577,8 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
|
|
|
489
577
|
}
|
|
490
578
|
if (display.alerts && alerts.length) push(truncate(alerts.join(s.fg("rule", " · ")), inner));
|
|
491
579
|
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
// All five levels, windowed. The window is the answer to "the widget is
|
|
495
|
-
// truncated": the rows above and below are not gone, they are one keypress
|
|
496
|
-
// away, and the widget says how many there are so nobody has to guess.
|
|
497
|
-
push();
|
|
498
|
-
if (tasks.length === 0 && (state.list.features ?? []).length === 0) {
|
|
499
|
-
push(s.fg("muted", " no plan yet"));
|
|
500
|
-
return frame(out, total, boxed, s, g);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const indexByKey = new Map<string, number>();
|
|
504
|
-
for (const t of tasks) {
|
|
505
|
-
indexByKey.set(t.compositeKey, t.index);
|
|
506
|
-
indexByKey.set(t.id, t.index);
|
|
507
|
-
if (t.key) indexByKey.set(t.key, t.index);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const active = nextActionableTask(state.list);
|
|
511
|
-
const rows = buildPlanRows(state.list, active?.compositeKey ?? null, {
|
|
512
|
-
expandSubtasks: view.expanded || display.levels.subtask === "all",
|
|
513
|
-
levels: {
|
|
514
|
-
goal: display.levels.goal,
|
|
515
|
-
sprint: display.levels.sprint,
|
|
516
|
-
feature: display.levels.feature,
|
|
517
|
-
task: display.levels.task,
|
|
518
|
-
subtask: display.levels.subtask !== "none",
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
const bounds = rowWindow(rows, limit, view.scroll);
|
|
523
|
-
const hiddenBefore = bounds.start;
|
|
524
|
-
const hiddenAfter = rows.length - bounds.end;
|
|
525
|
-
|
|
526
|
-
if (hiddenBefore > 0) {
|
|
527
|
-
const hint = view.scroll === null ? "" : s.fg("rule", " " + hintKeys(g));
|
|
528
|
-
push(s.fg("rule", " " + g.more + " " + hiddenBefore + " above") + hint);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
for (const row of rows.slice(bounds.start, bounds.end)) {
|
|
532
|
-
for (const line of renderRow(row, inner, indexByKey, g, s, display)) push(line);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (hiddenAfter > 0) {
|
|
536
|
-
push(s.fg("rule", " " + g.more + " " + hiddenAfter + " below " + hintKeys(g)));
|
|
537
|
-
}
|
|
538
|
-
|
|
580
|
+
// footer only — scroll tree removed to keep TUI readable on narrow term
|
|
581
|
+
push(s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 2))));
|
|
539
582
|
return frame(out, total, boxed, s, g);
|
|
540
583
|
}
|
|
541
584
|
|
package/src/ui/wizard.ts
CHANGED
|
@@ -187,11 +187,31 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
|
|
|
187
187
|
const modelsAnswer = await pickModelsStep(prompt, options.models);
|
|
188
188
|
if (modelsAnswer === undefined) return { cancelled: true };
|
|
189
189
|
|
|
190
|
-
// -- 5.
|
|
190
|
+
// -- 5. execution (parallelism) -----------------------------------------
|
|
191
|
+
const execOptions = [
|
|
192
|
+
{ value: "off", label: "one at a time", help: "No parallel work. Simplest, lowest token use." },
|
|
193
|
+
{ value: "task", label: "parallel at task (recommended)", help: "Tasks with no deps run together, up to max workers." },
|
|
194
|
+
{ value: "feature", label: "parallel at feature", help: "Features with no deps run together." },
|
|
195
|
+
{ value: "sprint", label: "parallel at sprint", help: "Sprints in parallel." },
|
|
196
|
+
{ value: "goal", label: "parallel at goal", help: "Goals run as parallel pipelines (phases run together)." },
|
|
197
|
+
{ value: "subtask", label: "parallel at subtask", help: "Subtasks of a task run together. Finest grain." },
|
|
198
|
+
];
|
|
199
|
+
const execLabels = execOptions.map((o) => line(o.label, o.help));
|
|
200
|
+
// Execution parallelism is optional — older E2E/tests scripted 5 answers, not 7. Default to task×3 so they keep passing.
|
|
201
|
+
let parallelAt: import("../core/types.ts").HandoffGranularity = "task";
|
|
202
|
+
let maxWorkers = 3;
|
|
203
|
+
const execPick = await prompt.select("When to run things in parallel?", execLabels);
|
|
204
|
+
if (execPick !== undefined) {
|
|
205
|
+
parallelAt = (execOptions[execLabels.indexOf(execPick)]?.value ?? "task") as import("../core/types.ts").HandoffGranularity;
|
|
206
|
+
const workersRaw = await prompt.input("Max parallel workers? (1-16)", "3");
|
|
207
|
+
maxWorkers = workersRaw === undefined ? 3 : Math.max(1, Math.min(16, Number.parseInt(String(workersRaw).trim(), 10) || 3));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// -- 6. display ---------------------------------------------------------
|
|
191
211
|
const display = await pickDisplay(prompt, env);
|
|
192
212
|
if (display === undefined) return { cancelled: true };
|
|
193
213
|
|
|
194
|
-
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
|
|
214
|
+
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
|
|
195
215
|
const plan = planIntake(answers);
|
|
196
216
|
|
|
197
217
|
if (options.skipConfirm) return { cancelled: false, plan, answers };
|