infinity-harness 2.2.1 → 2.3.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 +134 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +770 -51
- package/harness/docs/agents/researcher.md +14 -0
- package/harness/docs/phases/research.md +64 -0
- package/harness/skills/grilling.md +1 -1
- package/harness/skills/research.md +1 -1
- package/package.json +1 -1
- package/src/approval.ts +194 -0
- package/src/core/brief.ts +51 -2
- package/src/core/config.ts +32 -13
- package/src/core/fsx.ts +10 -0
- package/src/core/gates.ts +15 -0
- package/src/core/init.ts +43 -2
- package/src/core/paths.ts +14 -0
- package/src/core/settings.ts +56 -0
- package/src/core/types.ts +65 -2
- package/src/handoff.ts +197 -0
- package/src/intake.ts +263 -0
- package/src/loop.ts +88 -10
- package/src/remote.ts +22 -1
- package/src/runState.ts +121 -0
- package/src/ui/dashboard.ts +140 -16
- package/src/ui/planTree.ts +287 -0
- package/src/ui/theme.ts +15 -0
- package/src/ui/widget.ts +230 -67
- package/src/ui/wizard.ts +195 -0
package/src/loop.ts
CHANGED
|
@@ -39,6 +39,13 @@ import {
|
|
|
39
39
|
type EscalationState,
|
|
40
40
|
} from "./escalate.ts";
|
|
41
41
|
import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
|
|
42
|
+
import {
|
|
43
|
+
needsApproval,
|
|
44
|
+
requestApproval,
|
|
45
|
+
describeApproval,
|
|
46
|
+
renderApprovalRequest,
|
|
47
|
+
rejectionStandsFor,
|
|
48
|
+
} from "./approval.ts";
|
|
42
49
|
|
|
43
50
|
export const LOOP_STATE_FILE = "loop-state.json";
|
|
44
51
|
export const STOP_FILE = "STOP";
|
|
@@ -89,7 +96,13 @@ export type LoopDecision =
|
|
|
89
96
|
| { action: "continue"; message: string; reason: string }
|
|
90
97
|
| { action: "advanced"; toPhase: Phase; message: string; reason: string }
|
|
91
98
|
| { action: "stop"; reason: string; detail: string }
|
|
92
|
-
| { action: "wait"; reason: string; detail: string }
|
|
99
|
+
| { action: "wait"; reason: string; detail: string }
|
|
100
|
+
/**
|
|
101
|
+
* The gate passed, but this phase decides *what gets built* and the human
|
|
102
|
+
* asked to sign it. The run parks here until they answer; it does not
|
|
103
|
+
* advance, and it does not count as being stuck.
|
|
104
|
+
*/
|
|
105
|
+
| { action: "approve"; phase: Phase; message: string; detail: string; reason: string };
|
|
93
106
|
|
|
94
107
|
export function loopStatePath(targetDir: string): string {
|
|
95
108
|
return resolve(harnessDir(targetDir), LOOP_STATE_FILE);
|
|
@@ -232,6 +245,15 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
232
245
|
detail: "The pipeline is paused. Unpause to continue.",
|
|
233
246
|
});
|
|
234
247
|
}
|
|
248
|
+
if (config.awaitingApproval) {
|
|
249
|
+
return finish({
|
|
250
|
+
action: "wait",
|
|
251
|
+
reason: "awaiting-approval",
|
|
252
|
+
detail:
|
|
253
|
+
`${config.awaitingApproval.toUpperCase()} is waiting for your approval. ` +
|
|
254
|
+
"`/infinity:approve` continues; `/infinity:approve <what is wrong>` sends it back.",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
235
257
|
|
|
236
258
|
state.lastPhase = config.currentPhase;
|
|
237
259
|
|
|
@@ -294,10 +316,40 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
294
316
|
|
|
295
317
|
const gate = options.skipGate ? null : await runChecks(targetDir, phase, { record: true });
|
|
296
318
|
|
|
297
|
-
|
|
319
|
+
// A phase the human already sent back, on a project that has not moved
|
|
320
|
+
// since, is not ready to be asked about again. The gate is deterministic:
|
|
321
|
+
// without this it passes on the very next tick and the human is asked the
|
|
322
|
+
// identical question about the identical artefact, forever.
|
|
323
|
+
//
|
|
324
|
+
// Crucially this is treated as a *failure*, not as its own early return. An
|
|
325
|
+
// early return skipped the no-progress detector entirely — so an agent that
|
|
326
|
+
// ignored the rejection re-briefed itself without limit, which is the one
|
|
327
|
+
// thing an unattended loop must never do. Falling through means the same
|
|
328
|
+
// budgets, the same strikes and the same escalation ladder apply.
|
|
329
|
+
const standingRejection =
|
|
330
|
+
gate && gate.overall && needsApproval(config, phase) && config.awaitingApproval !== phase
|
|
331
|
+
? rejectionStandsFor(config, phase, await fingerprint(targetDir))
|
|
332
|
+
: null;
|
|
333
|
+
|
|
334
|
+
if (gate && gate.overall && !standingRejection) {
|
|
298
335
|
state.noProgressStreak = 0;
|
|
299
336
|
state.lastFingerprint = await fingerprint(targetDir);
|
|
300
337
|
|
|
338
|
+
// A passed gate on an approvable phase is not permission to continue — it
|
|
339
|
+
// is permission to *ask*. The gate proves the phase produced what it was
|
|
340
|
+
// supposed to produce; only the human can say it produced the right thing.
|
|
341
|
+
if (needsApproval(config, phase) && config.awaitingApproval !== phase) {
|
|
342
|
+
requestApproval(targetDir, phase);
|
|
343
|
+
const request = describeApproval(phase);
|
|
344
|
+
return finish({
|
|
345
|
+
action: "approve",
|
|
346
|
+
reason: "awaiting-approval",
|
|
347
|
+
phase,
|
|
348
|
+
message: renderApprovalRequest(request),
|
|
349
|
+
detail: `${phase.toUpperCase()} is waiting for your approval.`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
301
353
|
const upcoming = nextPhase(phase, config.phases?.enabled);
|
|
302
354
|
if (upcoming === null) {
|
|
303
355
|
return finish({
|
|
@@ -316,6 +368,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
316
368
|
});
|
|
317
369
|
}
|
|
318
370
|
|
|
371
|
+
// A new phase starts with no baseline.
|
|
372
|
+
//
|
|
373
|
+
// Without this the first failure of the new phase compares the tree
|
|
374
|
+
// against the fingerprint taken when the *previous* phase passed — which
|
|
375
|
+
// is of course identical, because nothing has happened yet — and is
|
|
376
|
+
// counted as a stall. The run then escalated on the first turn of every
|
|
377
|
+
// phase, spending `retry` and `reframe` on an agent that had not yet been
|
|
378
|
+
// given a chance to do anything. A stall means the agent produced nothing
|
|
379
|
+
// when asked; a fresh brief has not asked yet.
|
|
380
|
+
state.lastFingerprint = null;
|
|
381
|
+
state.noProgressStreak = 0;
|
|
382
|
+
state.escalation = { ...state.escalation, tried: [] };
|
|
383
|
+
|
|
319
384
|
const brief = await buildBrief(targetDir);
|
|
320
385
|
return finish({
|
|
321
386
|
action: "advanced",
|
|
@@ -399,9 +464,12 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
399
464
|
if (state.noProgressStreak >= budget.noProgressLimit) {
|
|
400
465
|
return finish({
|
|
401
466
|
action: "stop",
|
|
402
|
-
reason: "no-progress",
|
|
403
|
-
detail:
|
|
404
|
-
`
|
|
467
|
+
reason: standingRejection ? "rejection-unaddressed" : "no-progress",
|
|
468
|
+
detail: standingRejection
|
|
469
|
+
? `You sent ${phase.toUpperCase()} back ${state.noProgressStreak} turns ago and nothing has ` +
|
|
470
|
+
`changed since. The agent is not acting on it: "${standingRejection.note}". ` +
|
|
471
|
+
`Stopping so you can take over.`
|
|
472
|
+
: `The gate has failed ${state.noProgressStreak} times in a row with no change to the working tree ` +
|
|
405
473
|
`or the plan. The agent is looping without making progress` +
|
|
406
474
|
(gate ? `: ${gate.failures.join(", ")}` : "") +
|
|
407
475
|
`.` +
|
|
@@ -435,14 +503,22 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
435
503
|
|
|
436
504
|
// An escalation replaces the standard "fix these" nudge, because repeating
|
|
437
505
|
// that nudge is exactly what the ladder exists to interrupt.
|
|
438
|
-
const head =
|
|
439
|
-
?
|
|
440
|
-
|
|
441
|
-
`
|
|
506
|
+
const head = standingRejection
|
|
507
|
+
? `A human reviewed ${phase.toUpperCase()} and sent it back. Nothing in the project has changed ` +
|
|
508
|
+
`since, so the run is still waiting on this and nothing else:\n\n ${standingRejection.note}\n\n` +
|
|
509
|
+
`Fix that, then validate again.${focus}\n`
|
|
510
|
+
: escalation?.instruction
|
|
511
|
+
? `${escalation.instruction}\n`
|
|
512
|
+
: `The ${phase.toUpperCase()} gate did not pass. Fix exactly these, then stop talking — ` +
|
|
513
|
+
`the harness will re-validate automatically.\n\n${failures}${focus}\n`;
|
|
442
514
|
|
|
443
515
|
return finish({
|
|
444
516
|
action: "continue",
|
|
445
|
-
reason:
|
|
517
|
+
reason: standingRejection
|
|
518
|
+
? "rejected, not yet addressed"
|
|
519
|
+
: escalation?.strategy
|
|
520
|
+
? `escalated: ${describeEscalation(escalation)}`
|
|
521
|
+
: "gate failed",
|
|
446
522
|
message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
|
|
447
523
|
});
|
|
448
524
|
}
|
|
@@ -495,6 +571,8 @@ export function describeDecision(d: LoopDecision): string {
|
|
|
495
571
|
return `advanced to ${d.toPhase}`;
|
|
496
572
|
case "wait":
|
|
497
573
|
return `waiting — ${d.detail}`;
|
|
574
|
+
case "approve":
|
|
575
|
+
return `waiting for your approval of ${d.phase}`;
|
|
498
576
|
case "stop":
|
|
499
577
|
return `stopped — ${d.detail}`;
|
|
500
578
|
}
|
package/src/remote.ts
CHANGED
|
@@ -18,11 +18,12 @@ import { createServer, type Server } from "node:http";
|
|
|
18
18
|
import type { AddressInfo } from "node:net";
|
|
19
19
|
import { resolve } from "node:path";
|
|
20
20
|
|
|
21
|
-
import type { FeatureList, Feature, Goal, GateResult, Phase } from "./core/types.ts";
|
|
21
|
+
import type { FeatureList, Feature, Goal, GateResult, Phase, Sprint } from "./core/types.ts";
|
|
22
22
|
import { loadFeatureList, computeProgress } from "./core/featureList.ts";
|
|
23
23
|
import { loadConfig } from "./core/config.ts";
|
|
24
24
|
import { modelRouterPath, reworkPath } from "./core/paths.ts";
|
|
25
25
|
import { readJsonSafe } from "./core/fsx.ts";
|
|
26
|
+
import { loadRunState } from "./runState.ts";
|
|
26
27
|
import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
|
|
27
28
|
|
|
28
29
|
export { escapeHtml };
|
|
@@ -47,6 +48,12 @@ export interface RemoteState {
|
|
|
47
48
|
timestamp: string;
|
|
48
49
|
router: unknown;
|
|
49
50
|
rework: unknown;
|
|
51
|
+
sprints: Sprint[];
|
|
52
|
+
/** A phase whose gate passed and which is waiting for a human signature. */
|
|
53
|
+
awaitingApproval: string | null;
|
|
54
|
+
/** pi sessions this run has spent — proof the handoff is doing its job. */
|
|
55
|
+
sessions: number | null;
|
|
56
|
+
goalPass: { current: number; max: number } | null;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export interface RemoteServer {
|
|
@@ -104,6 +111,13 @@ export function buildRemoteState(projectDir?: string): RemoteState {
|
|
|
104
111
|
timestamp: new Date().toISOString(),
|
|
105
112
|
router: readJsonSafe<unknown>(modelRouterPath(dir), null),
|
|
106
113
|
rework: readJsonSafe<unknown>(reworkPath(dir), null),
|
|
114
|
+
awaitingApproval: config.awaitingApproval ?? null,
|
|
115
|
+
sessions: loadRunState(dir)?.sessions ?? null,
|
|
116
|
+
goalPass:
|
|
117
|
+
typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
|
|
118
|
+
? { current: config.goalPass, max: config.goalMaxPasses }
|
|
119
|
+
: null,
|
|
120
|
+
sprints: list.sprints ?? [],
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
|
|
@@ -119,6 +133,9 @@ function toDashboardState(s: RemoteState): DashboardState {
|
|
|
119
133
|
retries: s.retries,
|
|
120
134
|
router: s.router,
|
|
121
135
|
rework: s.rework,
|
|
136
|
+
awaitingApproval: s.awaitingApproval,
|
|
137
|
+
sessions: s.sessions,
|
|
138
|
+
goalPass: s.goalPass,
|
|
122
139
|
};
|
|
123
140
|
}
|
|
124
141
|
|
|
@@ -138,6 +155,10 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
|
|
|
138
155
|
gate: state.gate,
|
|
139
156
|
router: state.router,
|
|
140
157
|
rework: state.rework,
|
|
158
|
+
awaitingApproval: state.awaitingApproval,
|
|
159
|
+
sessions: state.sessions,
|
|
160
|
+
goalPass: state.goalPass,
|
|
161
|
+
sprints: state.sprints,
|
|
141
162
|
features: state.features.map((f) => ({
|
|
142
163
|
id: f.id,
|
|
143
164
|
name: f.name,
|
package/src/runState.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — is a continuous run armed, and which run is it?
|
|
3
|
+
*
|
|
4
|
+
* This used to be a `let loopEnabled = false` inside the extension closure,
|
|
5
|
+
* which meant the answer died with the pi session that held it. That was fine
|
|
6
|
+
* while the harness lived in exactly one session forever, and wrong the moment
|
|
7
|
+
* it did not:
|
|
8
|
+
*
|
|
9
|
+
* - a fresh session per handoff (the whole point of `src/handoff.ts`) starts
|
|
10
|
+
* a new extension instance, and the run it was continuing was over
|
|
11
|
+
* - `/reload`, `/new`, `/resume` and a crash all did the same thing
|
|
12
|
+
* - the run id was a `randomUUID()` per session, so `loadLoopState` saw a
|
|
13
|
+
* different run each time and reset the iteration count, the wall-clock
|
|
14
|
+
* budget, the no-progress streak and the escalation ladder — every budget
|
|
15
|
+
* that exists to stop a runaway run
|
|
16
|
+
*
|
|
17
|
+
* A run is a property of the project, not of the terminal window that started
|
|
18
|
+
* it. It lives on disk.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { runStatePath } from "./core/paths.ts";
|
|
22
|
+
import { readJsonSafe, writeJsonAtomic, removeFile } from "./core/fsx.ts";
|
|
23
|
+
|
|
24
|
+
export type RunState = {
|
|
25
|
+
/** Whether the loop should keep driving. Read on every session start. */
|
|
26
|
+
armed: boolean;
|
|
27
|
+
/** Stable across every session this run spans. */
|
|
28
|
+
runId: string;
|
|
29
|
+
startedAt: string;
|
|
30
|
+
/** How many pi sessions this run has used. Shown in the widget. */
|
|
31
|
+
sessions: number;
|
|
32
|
+
/** Why the run last stopped, so a returning human is not left guessing. */
|
|
33
|
+
stoppedAt: string | null;
|
|
34
|
+
stopReason: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function newRunState(runId: string, now = new Date()): RunState {
|
|
38
|
+
return {
|
|
39
|
+
armed: true,
|
|
40
|
+
runId,
|
|
41
|
+
startedAt: now.toISOString(),
|
|
42
|
+
sessions: 1,
|
|
43
|
+
stoppedAt: null,
|
|
44
|
+
stopReason: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadRunState(targetDir: string): RunState | null {
|
|
49
|
+
const raw = readJsonSafe<Partial<RunState> | null>(runStatePath(targetDir), null);
|
|
50
|
+
if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
|
|
51
|
+
return {
|
|
52
|
+
armed: raw.armed === true,
|
|
53
|
+
runId: raw.runId,
|
|
54
|
+
startedAt: typeof raw.startedAt === "string" ? raw.startedAt : new Date(0).toISOString(),
|
|
55
|
+
sessions: typeof raw.sessions === "number" && raw.sessions > 0 ? raw.sessions : 1,
|
|
56
|
+
stoppedAt: typeof raw.stoppedAt === "string" ? raw.stoppedAt : null,
|
|
57
|
+
stopReason: typeof raw.stopReason === "string" ? raw.stopReason : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function saveRunState(targetDir: string, state: RunState): void {
|
|
62
|
+
try {
|
|
63
|
+
writeJsonAtomic(runStatePath(targetDir), state);
|
|
64
|
+
} catch {
|
|
65
|
+
// Losing the file costs the run its cross-session budgets, which is bad,
|
|
66
|
+
// but throwing here would kill the session, which is worse.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Arm a run. Reuses the existing run id when one is already armed. */
|
|
71
|
+
export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
|
|
72
|
+
const existing = loadRunState(targetDir);
|
|
73
|
+
const state =
|
|
74
|
+
existing && existing.armed
|
|
75
|
+
? { ...existing, stoppedAt: null, stopReason: null }
|
|
76
|
+
: newRunState(runId, now);
|
|
77
|
+
saveRunState(targetDir, state);
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
|
|
82
|
+
const existing = loadRunState(targetDir);
|
|
83
|
+
if (!existing) return null;
|
|
84
|
+
const state: RunState = {
|
|
85
|
+
...existing,
|
|
86
|
+
armed: false,
|
|
87
|
+
stoppedAt: now.toISOString(),
|
|
88
|
+
stopReason: reason,
|
|
89
|
+
};
|
|
90
|
+
saveRunState(targetDir, state);
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Count one more pi session against this run. Called from `session_start`. */
|
|
95
|
+
export function countSession(targetDir: string): RunState | null {
|
|
96
|
+
const existing = loadRunState(targetDir);
|
|
97
|
+
if (!existing) return null;
|
|
98
|
+
const state = { ...existing, sessions: existing.sessions + 1 };
|
|
99
|
+
saveRunState(targetDir, state);
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearRunState(targetDir: string): void {
|
|
104
|
+
try {
|
|
105
|
+
removeFile(runStatePath(targetDir));
|
|
106
|
+
} catch {
|
|
107
|
+
/* nothing to clear */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The run id the loop should use.
|
|
113
|
+
*
|
|
114
|
+
* An armed run keeps its id so `loadLoopState` finds the same budgets after a
|
|
115
|
+
* handoff. With nothing armed, the caller's session id is the run id — an
|
|
116
|
+
* ad-hoc `/infinity:validate` should not inherit a finished run's strikes.
|
|
117
|
+
*/
|
|
118
|
+
export function runIdFor(targetDir: string, fallback: string): string {
|
|
119
|
+
const state = loadRunState(targetDir);
|
|
120
|
+
return state && state.armed ? state.runId : fallback;
|
|
121
|
+
}
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { getPhaseOrder } from "../core/phases.ts";
|
|
39
39
|
import { statusGlyph } from "./widget.ts";
|
|
40
40
|
import { UNICODE_GLYPHS } from "./theme.ts";
|
|
41
|
+
import { groupPlan, type PlanGoalGroup, type PlanSprintGroup } from "./planTree.ts";
|
|
41
42
|
|
|
42
43
|
export type DashboardState = {
|
|
43
44
|
list: FeatureList;
|
|
@@ -52,6 +53,12 @@ export type DashboardState = {
|
|
|
52
53
|
router?: unknown;
|
|
53
54
|
/** Rework record. Opaque here — rendered as a badge, never interpreted. */
|
|
54
55
|
rework?: unknown;
|
|
56
|
+
/** A phase whose gate passed and which is waiting for a human signature. */
|
|
57
|
+
awaitingApproval?: string | null;
|
|
58
|
+
/** How many pi sessions this run has spent, when handoff is on. */
|
|
59
|
+
sessions?: number | null;
|
|
60
|
+
/** Which goal pass this is, out of how many. */
|
|
61
|
+
goalPass?: { current: number; max: number } | null;
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
// ── escaping ────────────────────────────────────────────────────────────────
|
|
@@ -361,10 +368,30 @@ function renderAlerts(
|
|
|
361
368
|
paused: boolean,
|
|
362
369
|
retries: DashboardState["retries"],
|
|
363
370
|
gate: GateResult | null,
|
|
371
|
+
extra: {
|
|
372
|
+
awaitingApproval?: string | null;
|
|
373
|
+
sessions?: number | null;
|
|
374
|
+
goalPass?: { current: number; max: number } | null;
|
|
375
|
+
} = {},
|
|
364
376
|
): string {
|
|
365
377
|
const alerts: Alert[] = [];
|
|
366
378
|
|
|
367
379
|
if (paused) alerts.push({ tone: "blocked", text: "run paused" });
|
|
380
|
+
// A run parked for a signature looks, from every other indicator, exactly
|
|
381
|
+
// like a run that has stopped. Saying so is the difference between someone
|
|
382
|
+
// coming back to a finished phase and someone coming back to a dead run.
|
|
383
|
+
if (extra.awaitingApproval) {
|
|
384
|
+
alerts.push({
|
|
385
|
+
tone: "active",
|
|
386
|
+
text: `${String(extra.awaitingApproval).toUpperCase()} is waiting for your approval`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (extra.goalPass && extra.goalPass.max > 1) {
|
|
390
|
+
alerts.push({
|
|
391
|
+
tone: extra.goalPass.current >= extra.goalPass.max ? "blocked" : "active",
|
|
392
|
+
text: `goal pass ${extra.goalPass.current} / ${extra.goalPass.max}`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
368
395
|
if (counts.blocked > 0) {
|
|
369
396
|
alerts.push({ tone: "blocked", text: `${counts.blocked} blocked ${counts.blocked === 1 ? "task" : "tasks"}` });
|
|
370
397
|
}
|
|
@@ -378,6 +405,9 @@ function renderAlerts(
|
|
|
378
405
|
text: `retry budget ${retries.task} / ${retries.max}${spent ? " — exhausted" : ""}`,
|
|
379
406
|
});
|
|
380
407
|
}
|
|
408
|
+
if (typeof extra.sessions === "number" && extra.sessions > 1) {
|
|
409
|
+
alerts.push({ tone: "quiet", text: `${extra.sessions} sessions this run` });
|
|
410
|
+
}
|
|
381
411
|
if (gate && gate.overall === false) {
|
|
382
412
|
const failures = Array.isArray(gate.failures) ? gate.failures : [];
|
|
383
413
|
const head = failures.slice(0, 3).join(", ");
|
|
@@ -453,6 +483,15 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
|
|
|
453
483
|
return `<span class="deps mono" title="depends on">${esc(GLYPHS.arrow)} ${refs.join(", ")}</span>`;
|
|
454
484
|
}
|
|
455
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Every subtask, on every task.
|
|
488
|
+
*
|
|
489
|
+
* The terminal widget shows these only for the task being worked, because it
|
|
490
|
+
* has nine rows and a job to do with them. The dashboard has a whole page and
|
|
491
|
+
* a scrollbar: the reason to open it is precisely to see the detail the widget
|
|
492
|
+
* cannot fit, so hiding four of the five plan levels here made it a worse copy
|
|
493
|
+
* of the widget rather than the place you go for the full picture.
|
|
494
|
+
*/
|
|
456
495
|
function renderSubtasks(task: FlatTask): string {
|
|
457
496
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
458
497
|
if (subs.length === 0) return "";
|
|
@@ -470,8 +509,6 @@ function renderSubtasks(task: FlatTask): string {
|
|
|
470
509
|
function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>): string {
|
|
471
510
|
const status = task.status;
|
|
472
511
|
const cls = STATUS_CLASS[status];
|
|
473
|
-
// Subtasks are shown only for the task actually being worked on. Everywhere
|
|
474
|
-
// else they are detail nobody can act on, and they bury the active row.
|
|
475
512
|
const isActive = status === "in_progress" || status === "rework";
|
|
476
513
|
const difficulty =
|
|
477
514
|
typeof task.difficulty === "string" ? `<span class="chip chip-quiet">${esc(task.difficulty)}</span>` : "";
|
|
@@ -487,7 +524,7 @@ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>):
|
|
|
487
524
|
${difficulty}
|
|
488
525
|
${depLabel(task, indexByKey)}
|
|
489
526
|
</div>
|
|
490
|
-
${
|
|
527
|
+
${renderSubtasks(task)}
|
|
491
528
|
</td>
|
|
492
529
|
<td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
|
|
493
530
|
</tr>`;
|
|
@@ -536,6 +573,59 @@ function renderFeature(
|
|
|
536
573
|
</section>`;
|
|
537
574
|
}
|
|
538
575
|
|
|
576
|
+
/**
|
|
577
|
+
* A goal, its sprints, and their features — one collapsible section per level.
|
|
578
|
+
*
|
|
579
|
+
* `<details open>` is deliberate: everything is visible on load, and a human
|
|
580
|
+
* reading a 60-task plan can fold away the parts they are not looking at
|
|
581
|
+
* without the page needing a line of state management.
|
|
582
|
+
*/
|
|
583
|
+
function renderGoalGroup(
|
|
584
|
+
group: PlanGoalGroup,
|
|
585
|
+
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
586
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
587
|
+
show: { showGoal: boolean; showSprints: boolean },
|
|
588
|
+
): string {
|
|
589
|
+
const sprints = group.sprints
|
|
590
|
+
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
|
|
591
|
+
.join("");
|
|
592
|
+
|
|
593
|
+
if (!show.showGoal || !group.goal) return sprints;
|
|
594
|
+
|
|
595
|
+
return `<details class="tier tier-goal" open>
|
|
596
|
+
<summary class="tier-head">
|
|
597
|
+
<span class="tier-kind">goal</span>
|
|
598
|
+
<span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
|
|
599
|
+
<span class="mono faint">${esc(group.goal.id ?? "")}</span>
|
|
600
|
+
<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
|
|
601
|
+
</summary>
|
|
602
|
+
<div class="tier-body">${sprints}</div>
|
|
603
|
+
</details>`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function renderSprintGroup(
|
|
607
|
+
group: PlanSprintGroup,
|
|
608
|
+
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
609
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
610
|
+
showSprints: boolean,
|
|
611
|
+
): string {
|
|
612
|
+
const features = group.features
|
|
613
|
+
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null))
|
|
614
|
+
.join("");
|
|
615
|
+
|
|
616
|
+
if (!showSprints || !group.sprint) return features;
|
|
617
|
+
|
|
618
|
+
return `<details class="tier tier-sprint" open>
|
|
619
|
+
<summary class="tier-head">
|
|
620
|
+
<span class="tier-kind">sprint</span>
|
|
621
|
+
<span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
|
|
622
|
+
<span class="mono faint">${esc(group.sprint.id ?? "")}</span>
|
|
623
|
+
<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>
|
|
624
|
+
</summary>
|
|
625
|
+
<div class="tier-body">${features}</div>
|
|
626
|
+
</details>`;
|
|
627
|
+
}
|
|
628
|
+
|
|
539
629
|
function renderEmptyPlan(phase: Phase | null): string {
|
|
540
630
|
const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
|
|
541
631
|
return `<section class="card empty">
|
|
@@ -766,6 +856,7 @@ body{
|
|
|
766
856
|
.alert-blocked{color:var(--t-blocked);background:rgba(var(--rgb-blocked),.10);border-color:rgba(var(--rgb-blocked),.30)}
|
|
767
857
|
.alert-rework{color:var(--t-rework);background:rgba(var(--rgb-rework),.10);border-color:rgba(var(--rgb-rework),.28)}
|
|
768
858
|
.alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
|
|
859
|
+
.alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
|
|
769
860
|
|
|
770
861
|
/* -- gate ----------------------------------------------------------------- */
|
|
771
862
|
.gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
|
@@ -857,6 +948,34 @@ table.tasks tr:last-child td{border-bottom:0}
|
|
|
857
948
|
margin:26px 2px 2px;font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;
|
|
858
949
|
color:var(--faint);font-weight:600;
|
|
859
950
|
}
|
|
951
|
+
|
|
952
|
+
/* -- plan tiers: goal > sprint > feature ---------------------------------- */
|
|
953
|
+
/* Depth is carried by a left rule rather than indentation, so a 60-task plan
|
|
954
|
+
does not walk off the right edge of a phone. */
|
|
955
|
+
.tier{margin:18px 0 0}
|
|
956
|
+
.tier-head{
|
|
957
|
+
display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;cursor:pointer;
|
|
958
|
+
padding:7px 2px;list-style:none;border-radius:8px;
|
|
959
|
+
}
|
|
960
|
+
.tier-head::-webkit-details-marker{display:none}
|
|
961
|
+
.tier-head:hover{background:var(--surface-2)}
|
|
962
|
+
.tier-head:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}
|
|
963
|
+
.tier-kind{
|
|
964
|
+
font-size:10px;text-transform:uppercase;letter-spacing:.1em;font-weight:700;
|
|
965
|
+
padding:2px 7px;border-radius:999px;border:1px solid var(--border);color:var(--faint);
|
|
966
|
+
}
|
|
967
|
+
.tier-goal>.tier-head .tier-kind{color:var(--t-brand);border-color:var(--c-brand)}
|
|
968
|
+
.tier-sprint>.tier-head .tier-kind{color:var(--t-accent);border-color:var(--c-accent)}
|
|
969
|
+
.tier-name{font-size:15px;font-weight:650;overflow-wrap:anywhere}
|
|
970
|
+
.tier-goal>.tier-head .tier-name{font-size:17px}
|
|
971
|
+
.tier-count{margin-left:auto;color:var(--muted);font-size:13px;flex:none}
|
|
972
|
+
.tier-body{
|
|
973
|
+
margin-left:5px;padding-left:14px;border-left:2px solid var(--border);
|
|
974
|
+
}
|
|
975
|
+
.tier-goal>.tier-body{border-left-color:var(--c-brand)}
|
|
976
|
+
.tier-sprint>.tier-body{border-left-color:var(--c-accent)}
|
|
977
|
+
.tier[open]>.tier-head .tier-kind{opacity:.85}
|
|
978
|
+
.tier:not([open])>.tier-head{opacity:.72}
|
|
860
979
|
.foot{
|
|
861
980
|
display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between;
|
|
862
981
|
margin-top:22px;padding-top:14px;border-top:1px solid var(--border);
|
|
@@ -1009,19 +1128,20 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1009
1128
|
(b): b is Badge => b !== null,
|
|
1010
1129
|
);
|
|
1011
1130
|
|
|
1131
|
+
// The plan is five levels deep, and it is drawn five levels deep: goals hold
|
|
1132
|
+
// sprints hold features hold tasks hold subtasks. Rendering it flat — which
|
|
1133
|
+
// is what this page used to do, with the goal and sprint reduced to two
|
|
1134
|
+
// chips on a feature card — throws away the only structure that tells you
|
|
1135
|
+
// whether the run is nearly done with something or scattered across
|
|
1136
|
+
// everything.
|
|
1137
|
+
const groups = groupPlan(list);
|
|
1012
1138
|
const body = features.length
|
|
1013
|
-
?
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
indexByKey,
|
|
1020
|
-
f.sprintId ? (sprintNames.get(f.sprintId) ?? null) : null,
|
|
1021
|
-
// A goal chip only earns its place when there is more than one goal
|
|
1022
|
-
// to tell apart.
|
|
1023
|
-
goals.length > 1 && f.goalId ? (goalNames.get(f.goalId) ?? null) : null,
|
|
1024
|
-
),
|
|
1139
|
+
? groups
|
|
1140
|
+
.map((group) =>
|
|
1141
|
+
renderGoalGroup(group, tasksByFeature, indexByKey, {
|
|
1142
|
+
showGoal: goals.length > 0,
|
|
1143
|
+
showSprints: sprints.length > 0,
|
|
1144
|
+
}),
|
|
1025
1145
|
)
|
|
1026
1146
|
.join("")
|
|
1027
1147
|
: renderEmptyPlan(state.phase);
|
|
@@ -1050,7 +1170,11 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1050
1170
|
${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
|
|
1051
1171
|
${renderGoals(goals)}
|
|
1052
1172
|
${renderRail(state.phase, state.enabledPhases, paused)}
|
|
1053
|
-
${renderAlerts(counts, paused, state.retries, gate
|
|
1173
|
+
${renderAlerts(counts, paused, state.retries, gate, {
|
|
1174
|
+
awaitingApproval: state.awaitingApproval ?? null,
|
|
1175
|
+
sessions: state.sessions ?? null,
|
|
1176
|
+
goalPass: state.goalPass ?? null,
|
|
1177
|
+
})}
|
|
1054
1178
|
${renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal)}
|
|
1055
1179
|
${renderGate(gate)}
|
|
1056
1180
|
${body}
|