infinity-harness 2.3.1 → 2.4.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 +64 -0
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +195 -39
- package/package.json +1 -1
- package/src/approval.ts +15 -5
- package/src/core/config.ts +44 -1
- package/src/core/init.ts +22 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +141 -12
- package/src/core/types.ts +49 -1
- package/src/intake.ts +124 -115
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +6 -0
- package/src/ui/dashboard.ts +90 -27
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +257 -90
- package/src/workflow.ts +309 -0
package/src/intake.ts
CHANGED
|
@@ -7,100 +7,98 @@
|
|
|
7
7
|
* "you decide everything, including what I want", which is not a mode anybody
|
|
8
8
|
* asked for.
|
|
9
9
|
*
|
|
10
|
-
* The fix
|
|
10
|
+
* The fix was to separate two questions that were tangled together:
|
|
11
11
|
*
|
|
12
|
-
* 1. What are we building? — always asked
|
|
13
|
-
* 2. Who signs off on what? — the
|
|
12
|
+
* 1. What are we building? — always asked
|
|
13
|
+
* 2. Who signs off on what? — the workflow
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* walk away" mode; keeping DEFINE is the common middle.
|
|
22
|
-
*
|
|
23
|
-
* RESEARCH is a separate, optional phase that runs before DEFINE: the human
|
|
24
|
-
* gives an idea, the model goes and finds out what it actually has to be.
|
|
15
|
+
* And the second of those turned out to be tangled too. "copilot" and
|
|
16
|
+
* "autopilot" are one switch, and one switch cannot say "let it define and
|
|
17
|
+
* plan on its own but show me the review". So the answer is a **workflow**: a
|
|
18
|
+
* mode per phase, with the two familiar words as two named points in that
|
|
19
|
+
* space rather than the only two points in it. `src/workflow.ts` owns what a
|
|
20
|
+
* workflow is and where saved ones live.
|
|
25
21
|
*
|
|
26
22
|
* Nothing here talks to pi. It takes answers and returns a plan of record, so
|
|
27
23
|
* the flow can be unit-tested without a terminal and driven from the adapter,
|
|
28
24
|
* from a test, or from a config file.
|
|
29
25
|
*/
|
|
30
26
|
|
|
31
|
-
import type { ApprovalPolicy, Phase, SessionPolicy } from "./core/types.ts";
|
|
27
|
+
import type { ApprovalPolicy, DisplayPolicy, Phase, SessionPolicy } from "./core/types.ts";
|
|
32
28
|
import { DEFAULT_ENABLED_PHASES, PHASE_ORDER } from "./core/types.ts";
|
|
29
|
+
import {
|
|
30
|
+
BUILTIN_WORKFLOWS,
|
|
31
|
+
describeModes,
|
|
32
|
+
normalizeModes,
|
|
33
|
+
normalizePhases,
|
|
34
|
+
signedPhases as signedIn,
|
|
35
|
+
type PhaseMode,
|
|
36
|
+
type PhaseModes,
|
|
37
|
+
type Workflow,
|
|
38
|
+
} from "./workflow.ts";
|
|
39
|
+
import { defaultDisplay, normalizeDisplay } from "./ui/display.ts";
|
|
33
40
|
|
|
34
41
|
export type Mode = "copilot" | "autopilot";
|
|
35
42
|
|
|
36
43
|
/** The wizard's questions, in the order they are asked. */
|
|
37
|
-
export const INTAKE_STEPS = ["
|
|
44
|
+
export const INTAKE_STEPS = ["workflow", "brief", "handoff", "display"] as const;
|
|
38
45
|
export type IntakeStep = (typeof INTAKE_STEPS)[number];
|
|
39
46
|
|
|
40
47
|
export type IntakeAnswers = {
|
|
41
|
-
|
|
48
|
+
/** The chosen workflow: a built-in, one they saved, or one they just built. */
|
|
49
|
+
workflow: Workflow;
|
|
42
50
|
/** What the human wants built, in their words. Empty is allowed but warned about. */
|
|
43
51
|
brief: string;
|
|
44
|
-
/** Run the optional RESEARCH phase before DEFINE. */
|
|
45
|
-
research: boolean;
|
|
46
|
-
/**
|
|
47
|
-
* Which phases the human wants to sign, for autopilot only.
|
|
48
|
-
* Ignored in copilot, where all three are always signed.
|
|
49
|
-
*/
|
|
50
|
-
approvals?: Partial<ApprovalPolicy>;
|
|
51
52
|
/** Session handoff policy. Defaults to a fresh session per phase. */
|
|
52
53
|
handoff?: SessionPolicy["handoff"];
|
|
53
|
-
/**
|
|
54
|
-
|
|
54
|
+
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
55
|
+
display?: DisplayPolicy;
|
|
55
56
|
};
|
|
56
57
|
|
|
57
58
|
export type IntakePlan = {
|
|
59
|
+
/** Derived: "copilot" when the run stops for the human anywhere, else "autopilot". */
|
|
58
60
|
mode: Mode;
|
|
61
|
+
workflow: { id: string; name: string };
|
|
59
62
|
brief: string | null;
|
|
60
63
|
phases: Phase[];
|
|
64
|
+
phaseModes: PhaseModes;
|
|
65
|
+
/** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
|
|
61
66
|
approvals: ApprovalPolicy;
|
|
62
67
|
session: SessionPolicy;
|
|
68
|
+
display: DisplayPolicy;
|
|
63
69
|
/** What the human should be told about what they just chose. */
|
|
64
70
|
summary: string;
|
|
65
71
|
/** Things that will bite later if left as they are. */
|
|
66
72
|
warnings: string[];
|
|
67
73
|
};
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* phase that is enabled is theirs. Making this configurable would make
|
|
72
|
-
* "copilot" mean nothing.
|
|
73
|
-
*/
|
|
74
|
-
export function copilotApprovals(phases: Phase[]): ApprovalPolicy {
|
|
75
|
-
return {
|
|
76
|
-
research: phases.includes("research"),
|
|
77
|
-
define: true,
|
|
78
|
-
plan: true,
|
|
79
|
-
};
|
|
75
|
+
export function builtInByName(name: string): Workflow | null {
|
|
76
|
+
return BUILTIN_WORKFLOWS.find((w) => w.id === name || w.name === name) ?? null;
|
|
80
77
|
}
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
79
|
+
/** Build an ad-hoc workflow from a phase list and a mode per phase. */
|
|
80
|
+
export function customWorkflow(
|
|
81
|
+
phases: Phase[],
|
|
82
|
+
modes: PhaseModes,
|
|
83
|
+
name = "custom",
|
|
84
|
+
): Workflow {
|
|
85
|
+
const ordered = normalizePhases(phases);
|
|
86
|
+
const normalized = normalizeModes(modes, ordered);
|
|
87
|
+
return {
|
|
88
|
+
id: "custom",
|
|
89
|
+
name,
|
|
90
|
+
description: describeModes(ordered, normalized),
|
|
91
|
+
builtIn: false,
|
|
92
|
+
phases: ordered,
|
|
93
|
+
modes: normalized,
|
|
94
|
+
};
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/** Turn the wizard's answers into everything `initHarness` needs. */
|
|
92
98
|
export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
93
|
-
const
|
|
94
|
-
const phases = normalizePhases(
|
|
95
|
-
|
|
96
|
-
const approvals: ApprovalPolicy =
|
|
97
|
-
answers.mode === "copilot"
|
|
98
|
-
? copilotApprovals(phases)
|
|
99
|
-
: {
|
|
100
|
-
research: phases.includes("research") && answers.approvals?.research === true,
|
|
101
|
-
define: answers.approvals?.define === true,
|
|
102
|
-
plan: answers.approvals?.plan === true,
|
|
103
|
-
};
|
|
99
|
+
const workflow = answers.workflow;
|
|
100
|
+
const phases = normalizePhases(workflow.phases);
|
|
101
|
+
const phaseModes = normalizeModes(workflow.modes, phases);
|
|
104
102
|
|
|
105
103
|
const handoff = answers.handoff ?? "phase";
|
|
106
104
|
const session: SessionPolicy = {
|
|
@@ -109,7 +107,10 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
109
107
|
carryNotes: true,
|
|
110
108
|
};
|
|
111
109
|
|
|
110
|
+
const display = normalizeDisplay(answers.display ?? defaultDisplay());
|
|
112
111
|
const brief = answers.brief?.trim() ? answers.brief.trim() : null;
|
|
112
|
+
const signed = PHASE_ORDER.filter((p) => phaseModes[p] === "copilot");
|
|
113
|
+
const mode: Mode = signed.length > 0 ? "copilot" : "autopilot";
|
|
113
114
|
|
|
114
115
|
const warnings: string[] = [];
|
|
115
116
|
if (!brief) {
|
|
@@ -118,13 +119,19 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
118
119
|
"It will not guess a project.",
|
|
119
120
|
);
|
|
120
121
|
}
|
|
121
|
-
if (
|
|
122
|
+
if (signed.length === 0) {
|
|
122
123
|
warnings.push(
|
|
123
124
|
"Nothing is being approved by you. The model decides what to build and how, " +
|
|
124
125
|
"and you see it when it is done. This is the right setting for a run you " +
|
|
125
126
|
"want to walk away from — and the wrong one if the goal is vague.",
|
|
126
127
|
);
|
|
127
128
|
}
|
|
129
|
+
if (signed.length === phases.length) {
|
|
130
|
+
warnings.push(
|
|
131
|
+
"Every phase stops for you. Nothing moves while you are away, which is the " +
|
|
132
|
+
"point — just do not expect to start this and go to bed.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
128
135
|
if (handoff === "off") {
|
|
129
136
|
warnings.push(
|
|
130
137
|
"Session handoff is off, so the whole run shares one context window. " +
|
|
@@ -132,20 +139,36 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
132
139
|
);
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
return {
|
|
142
|
+
return {
|
|
143
|
+
mode,
|
|
144
|
+
workflow: { id: workflow.id, name: workflow.name },
|
|
145
|
+
brief,
|
|
146
|
+
phases,
|
|
147
|
+
phaseModes,
|
|
148
|
+
approvals: {
|
|
149
|
+
research: phaseModes.research === "copilot",
|
|
150
|
+
define: phaseModes.define === "copilot",
|
|
151
|
+
plan: phaseModes.plan === "copilot",
|
|
152
|
+
},
|
|
153
|
+
session,
|
|
154
|
+
display,
|
|
155
|
+
summary: summarize(workflow, phases, phaseModes, session, display, brief),
|
|
156
|
+
warnings,
|
|
157
|
+
};
|
|
136
158
|
}
|
|
137
159
|
|
|
138
160
|
function summarize(
|
|
139
|
-
|
|
161
|
+
workflow: Workflow,
|
|
140
162
|
phases: Phase[],
|
|
141
|
-
|
|
163
|
+
modes: PhaseModes,
|
|
142
164
|
session: SessionPolicy,
|
|
165
|
+
display: DisplayPolicy,
|
|
143
166
|
brief: string | null,
|
|
144
167
|
): string {
|
|
145
|
-
const signed =
|
|
168
|
+
const signed = phases.filter((p) => modes[p] === "copilot");
|
|
146
169
|
const L: string[] = [];
|
|
147
|
-
L.push(`
|
|
148
|
-
L.push(`Pipeline ${phases.join(" → ")}`);
|
|
170
|
+
L.push(`Workflow ${workflow.name}`);
|
|
171
|
+
L.push(`Pipeline ${phases.map((p) => (modes[p] === "copilot" ? `[${p}]` : p)).join(" → ")}`);
|
|
149
172
|
L.push(
|
|
150
173
|
`You sign ${signed.length ? signed.map((s) => s.toUpperCase()).join(", ") : "nothing — the model decides and runs"}`,
|
|
151
174
|
);
|
|
@@ -158,6 +181,7 @@ function summarize(
|
|
|
158
181
|
: "fresh session per phase"
|
|
159
182
|
}`,
|
|
160
183
|
);
|
|
184
|
+
L.push(`Display ${display.preset}`);
|
|
161
185
|
L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
|
|
162
186
|
return L.join("\n");
|
|
163
187
|
}
|
|
@@ -177,21 +201,9 @@ export type Question = {
|
|
|
177
201
|
placeholder?: string;
|
|
178
202
|
};
|
|
179
203
|
|
|
180
|
-
export const
|
|
181
|
-
id: "
|
|
182
|
-
title: "How
|
|
183
|
-
options: [
|
|
184
|
-
{
|
|
185
|
-
value: "copilot",
|
|
186
|
-
label: "copilot — I approve the definition and the plan",
|
|
187
|
-
help: "The run stops and shows you its work before it starts building. You can send any phase back with a note.",
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
value: "autopilot",
|
|
191
|
-
label: "autopilot — I choose what to approve, if anything",
|
|
192
|
-
help: "You pick which of research, definition and plan you sign. Approve none of them and the run is yours to walk away from.",
|
|
193
|
-
},
|
|
194
|
-
],
|
|
204
|
+
export const WORKFLOW_QUESTION: Question = {
|
|
205
|
+
id: "workflow",
|
|
206
|
+
title: "How should this run — which phases, and which of them stop for you?",
|
|
195
207
|
};
|
|
196
208
|
|
|
197
209
|
export const BRIEF_QUESTION: Question = {
|
|
@@ -200,23 +212,6 @@ export const BRIEF_QUESTION: Question = {
|
|
|
200
212
|
placeholder: "e.g. a CLI that reconciles Stripe payouts against our ledger",
|
|
201
213
|
};
|
|
202
214
|
|
|
203
|
-
export const RESEARCH_QUESTION: Question = {
|
|
204
|
-
id: "research",
|
|
205
|
-
title: "Research the idea first?",
|
|
206
|
-
options: [
|
|
207
|
-
{
|
|
208
|
-
value: "no",
|
|
209
|
-
label: "no — go straight to defining it",
|
|
210
|
-
help: "Right when you already know what has to be built.",
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
value: "yes",
|
|
214
|
-
label: "yes — find out what it has to be first",
|
|
215
|
-
help: "Adds a RESEARCH phase before DEFINE: prior art, constraints, options with costs, a recommendation, and the questions only you can answer.",
|
|
216
|
-
},
|
|
217
|
-
],
|
|
218
|
-
};
|
|
219
|
-
|
|
220
215
|
export const HANDOFF_QUESTION: Question = {
|
|
221
216
|
id: "handoff",
|
|
222
217
|
title: "When should the run start a fresh session?",
|
|
@@ -239,25 +234,39 @@ export const HANDOFF_QUESTION: Question = {
|
|
|
239
234
|
],
|
|
240
235
|
};
|
|
241
236
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
237
|
+
export const DISPLAY_QUESTION: Question = {
|
|
238
|
+
id: "display",
|
|
239
|
+
title: "How much of the plan do you want on screen?",
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** The two modes, as a question about one phase. */
|
|
243
|
+
export const PHASE_MODE_OPTIONS: { value: PhaseMode; label: string; help: string }[] = [
|
|
244
|
+
{
|
|
245
|
+
value: "autopilot",
|
|
246
|
+
label: "autopilot — it passes the gate and moves on",
|
|
247
|
+
help: "The deterministic gate is the only referee for this phase.",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
value: "copilot",
|
|
251
|
+
label: "copilot — it stops and waits for you",
|
|
252
|
+
help: "When the gate passes, the run parks and asks you to sign it off before advancing.",
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
/** What each phase is for, one line, shown while picking modes. */
|
|
257
|
+
export const PHASE_PURPOSE: Record<Phase, string> = {
|
|
258
|
+
init: "set the project up",
|
|
259
|
+
research: "find out what it actually has to be",
|
|
260
|
+
define: "write down what is being built, and the criteria",
|
|
261
|
+
plan: "break it into ordered, dependency-aware tasks",
|
|
262
|
+
build: "implement it, one task at a time",
|
|
263
|
+
verify: "prove it behaves; hunt what the tests miss",
|
|
264
|
+
simplify: "delete more than you add",
|
|
265
|
+
review: "judge it as if someone else wrote it",
|
|
266
|
+
ship: "tag, changelog, leave the tree clean",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Phases someone can put in a pipeline. INIT is not one of them. */
|
|
270
|
+
export const SELECTABLE_PHASES: Phase[] = PHASE_ORDER.filter((p) => p !== "init");
|
|
271
|
+
|
|
272
|
+
export { DEFAULT_ENABLED_PHASES, signedIn as signedPhases };
|
package/src/remote.ts
CHANGED
|
@@ -18,12 +18,13 @@ 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, Sprint } from "./core/types.ts";
|
|
21
|
+
import type { DisplayPolicy, 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
26
|
import { loadRunState } from "./runState.ts";
|
|
27
|
+
import { normalizeDisplay } from "./ui/display.ts";
|
|
27
28
|
import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
|
|
28
29
|
|
|
29
30
|
export { escapeHtml };
|
|
@@ -54,6 +55,8 @@ export interface RemoteState {
|
|
|
54
55
|
/** pi sessions this run has spent — proof the handoff is doing its job. */
|
|
55
56
|
sessions: number | null;
|
|
56
57
|
goalPass: { current: number; max: number } | null;
|
|
58
|
+
/** What the reader has asked the dashboard to draw. */
|
|
59
|
+
display: DisplayPolicy;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
export interface RemoteServer {
|
|
@@ -118,6 +121,7 @@ export function buildRemoteState(projectDir?: string): RemoteState {
|
|
|
118
121
|
? { current: config.goalPass, max: config.goalMaxPasses }
|
|
119
122
|
: null,
|
|
120
123
|
sprints: list.sprints ?? [],
|
|
124
|
+
display: normalizeDisplay(config.display),
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -136,6 +140,7 @@ function toDashboardState(s: RemoteState): DashboardState {
|
|
|
136
140
|
awaitingApproval: s.awaitingApproval,
|
|
137
141
|
sessions: s.sessions,
|
|
138
142
|
goalPass: s.goalPass,
|
|
143
|
+
display: s.display,
|
|
139
144
|
};
|
|
140
145
|
}
|
|
141
146
|
|
|
@@ -159,6 +164,7 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
|
|
|
159
164
|
sessions: state.sessions,
|
|
160
165
|
goalPass: state.goalPass,
|
|
161
166
|
sprints: state.sprints,
|
|
167
|
+
display: state.display,
|
|
162
168
|
features: state.features.map((f) => ({
|
|
163
169
|
id: f.id,
|
|
164
170
|
name: f.name,
|
package/src/ui/config.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Setting, SettingsGroup } from "../core/settings.ts";
|
|
18
|
+
import { summarizeWorkflow } from "../workflow.ts";
|
|
19
|
+
import { normalizeDisplay, summarizeDisplay } from "./display.ts";
|
|
18
20
|
import {
|
|
19
21
|
SETTINGS,
|
|
20
22
|
coerce,
|
|
@@ -92,6 +94,10 @@ function summarize(group: SettingsGroup, io: ReturnType<typeof readAll>): string
|
|
|
92
94
|
}
|
|
93
95
|
case "pipeline":
|
|
94
96
|
return (io.config.phases?.enabled ?? []).join(" → ") || "(none)";
|
|
97
|
+
case "workflow":
|
|
98
|
+
return summarizeWorkflow(io.config);
|
|
99
|
+
case "display":
|
|
100
|
+
return summarizeDisplay(normalizeDisplay(io.config.display));
|
|
95
101
|
case "commands": {
|
|
96
102
|
const set = Object.entries(io.config.commands ?? {}).filter(([, v]) => Boolean(v));
|
|
97
103
|
return set.length ? set.map(([k]) => k).join(", ") : "none set";
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { getPhaseOrder } from "../core/phases.ts";
|
|
|
39
39
|
import { statusGlyph } from "./widget.ts";
|
|
40
40
|
import { UNICODE_GLYPHS } from "./theme.ts";
|
|
41
41
|
import { groupPlan, type PlanGoalGroup, type PlanSprintGroup } from "./planTree.ts";
|
|
42
|
+
import { defaultDisplay, normalizeDisplay } from "./display.ts";
|
|
43
|
+
import type { DisplayPolicy } from "../core/types.ts";
|
|
42
44
|
|
|
43
45
|
export type DashboardState = {
|
|
44
46
|
list: FeatureList;
|
|
@@ -59,6 +61,11 @@ export type DashboardState = {
|
|
|
59
61
|
sessions?: number | null;
|
|
60
62
|
/** Which goal pass this is, out of how many. */
|
|
61
63
|
goalPass?: { current: number; max: number } | null;
|
|
64
|
+
/**
|
|
65
|
+
* What this reader has asked to see — the same policy the terminal widget
|
|
66
|
+
* reads, so a level turned off in one is off in the other.
|
|
67
|
+
*/
|
|
68
|
+
display?: DisplayPolicy | null;
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
// ── escaping ────────────────────────────────────────────────────────────────
|
|
@@ -492,7 +499,8 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
|
|
|
492
499
|
* cannot fit, so hiding four of the five plan levels here made it a worse copy
|
|
493
500
|
* of the widget rather than the place you go for the full picture.
|
|
494
501
|
*/
|
|
495
|
-
function renderSubtasks(task: FlatTask): string {
|
|
502
|
+
function renderSubtasks(task: FlatTask, mode: DisplayPolicy["levels"]["subtask"], active: boolean): string {
|
|
503
|
+
if (mode === "none" || (mode === "active" && !active)) return "";
|
|
496
504
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
497
505
|
if (subs.length === 0) return "";
|
|
498
506
|
const items = subs
|
|
@@ -506,7 +514,11 @@ function renderSubtasks(task: FlatTask): string {
|
|
|
506
514
|
return `<ul class="subs">${items}</ul>`;
|
|
507
515
|
}
|
|
508
516
|
|
|
509
|
-
function renderTaskRow(
|
|
517
|
+
function renderTaskRow(
|
|
518
|
+
task: FlatTask,
|
|
519
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
520
|
+
display: DisplayPolicy,
|
|
521
|
+
): string {
|
|
510
522
|
const status = task.status;
|
|
511
523
|
const cls = STATUS_CLASS[status];
|
|
512
524
|
const isActive = status === "in_progress" || status === "rework";
|
|
@@ -522,9 +534,9 @@ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>):
|
|
|
522
534
|
<div class="task-line">
|
|
523
535
|
<span class="task-desc">${esc(task.description || task.compositeKey)}</span>
|
|
524
536
|
${difficulty}
|
|
525
|
-
${depLabel(task, indexByKey)}
|
|
537
|
+
${display.dependencies ? depLabel(task, indexByKey) : ""}
|
|
526
538
|
</div>
|
|
527
|
-
${renderSubtasks(task)}
|
|
539
|
+
${renderSubtasks(task, display.levels.subtask, isActive)}
|
|
528
540
|
</td>
|
|
529
541
|
<td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
|
|
530
542
|
</tr>`;
|
|
@@ -536,6 +548,7 @@ function renderFeature(
|
|
|
536
548
|
indexByKey: ReadonlyMap<string, number>,
|
|
537
549
|
sprintName: string | null,
|
|
538
550
|
goalName: string | null,
|
|
551
|
+
display: DisplayPolicy,
|
|
539
552
|
): string {
|
|
540
553
|
const counts = countByStatus(tasks);
|
|
541
554
|
const total = tasks.length;
|
|
@@ -547,14 +560,23 @@ function renderFeature(
|
|
|
547
560
|
feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
|
|
548
561
|
].join("");
|
|
549
562
|
|
|
550
|
-
const body =
|
|
551
|
-
?
|
|
563
|
+
const body = !display.levels.task
|
|
564
|
+
? ""
|
|
565
|
+
: total
|
|
566
|
+
? `<div class="table-wrap">
|
|
552
567
|
<table class="tasks">
|
|
553
568
|
<thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
|
|
554
|
-
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey)).join("")}</tbody>
|
|
569
|
+
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
|
|
555
570
|
</table>
|
|
556
571
|
</div>`
|
|
557
|
-
|
|
572
|
+
: `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
|
|
573
|
+
|
|
574
|
+
const criteria =
|
|
575
|
+
display.criteria && Array.isArray(feature.criteria) && feature.criteria.length
|
|
576
|
+
? `<ul class="criteria">${feature.criteria
|
|
577
|
+
.map((c) => `<li>${esc(String(c))}</li>`)
|
|
578
|
+
.join("")}</ul>`
|
|
579
|
+
: "";
|
|
558
580
|
|
|
559
581
|
return `<section class="card feature${complete ? " is-complete" : ""}">
|
|
560
582
|
<div class="feature-head">
|
|
@@ -564,11 +586,12 @@ function renderFeature(
|
|
|
564
586
|
${chips}
|
|
565
587
|
</div>
|
|
566
588
|
<div class="feature-progress">
|
|
567
|
-
|
|
589
|
+
${display.counts ? `<span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>` : ""}
|
|
568
590
|
${meter(counts, total)}
|
|
569
591
|
</div>
|
|
570
592
|
</div>
|
|
571
593
|
${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
|
|
594
|
+
${criteria}
|
|
572
595
|
${body}
|
|
573
596
|
</section>`;
|
|
574
597
|
}
|
|
@@ -585,9 +608,10 @@ function renderGoalGroup(
|
|
|
585
608
|
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
586
609
|
indexByKey: ReadonlyMap<string, number>,
|
|
587
610
|
show: { showGoal: boolean; showSprints: boolean },
|
|
611
|
+
display: DisplayPolicy,
|
|
588
612
|
): string {
|
|
589
613
|
const sprints = group.sprints
|
|
590
|
-
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
|
|
614
|
+
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display))
|
|
591
615
|
.join("");
|
|
592
616
|
|
|
593
617
|
if (!show.showGoal || !group.goal) return sprints;
|
|
@@ -597,7 +621,7 @@ function renderGoalGroup(
|
|
|
597
621
|
<span class="tier-kind">goal</span>
|
|
598
622
|
<span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
|
|
599
623
|
<span class="mono faint">${esc(group.goal.id ?? "")}</span>
|
|
600
|
-
|
|
624
|
+
${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
|
|
601
625
|
</summary>
|
|
602
626
|
<div class="tier-body">${sprints}</div>
|
|
603
627
|
</details>`;
|
|
@@ -608,10 +632,16 @@ function renderSprintGroup(
|
|
|
608
632
|
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
609
633
|
indexByKey: ReadonlyMap<string, number>,
|
|
610
634
|
showSprints: boolean,
|
|
635
|
+
display: DisplayPolicy,
|
|
611
636
|
): string {
|
|
612
|
-
const features =
|
|
613
|
-
|
|
614
|
-
|
|
637
|
+
const features = display.levels.feature
|
|
638
|
+
? group.features
|
|
639
|
+
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display))
|
|
640
|
+
.join("")
|
|
641
|
+
: // Hiding the feature card must not hide its tasks: they move up into the
|
|
642
|
+
// sprint, which is what "hide features" has to mean on a page whose whole
|
|
643
|
+
// job is to show the plan.
|
|
644
|
+
renderLooseTasks(group.features.flatMap((f) => tasksByFeature.get(f.id) ?? []), indexByKey, display);
|
|
615
645
|
|
|
616
646
|
if (!showSprints || !group.sprint) return features;
|
|
617
647
|
|
|
@@ -620,12 +650,29 @@ function renderSprintGroup(
|
|
|
620
650
|
<span class="tier-kind">sprint</span>
|
|
621
651
|
<span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
|
|
622
652
|
<span class="mono faint">${esc(group.sprint.id ?? "")}</span>
|
|
623
|
-
|
|
653
|
+
${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
|
|
624
654
|
</summary>
|
|
625
655
|
<div class="tier-body">${features}</div>
|
|
626
656
|
</details>`;
|
|
627
657
|
}
|
|
628
658
|
|
|
659
|
+
/** Tasks with no feature card above them, for the templates that hide features. */
|
|
660
|
+
function renderLooseTasks(
|
|
661
|
+
tasks: readonly FlatTask[],
|
|
662
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
663
|
+
display: DisplayPolicy,
|
|
664
|
+
): string {
|
|
665
|
+
if (!display.levels.task || tasks.length === 0) return "";
|
|
666
|
+
return `<section class="card feature">
|
|
667
|
+
<div class="table-wrap">
|
|
668
|
+
<table class="tasks">
|
|
669
|
+
<thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
|
|
670
|
+
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
|
|
671
|
+
</table>
|
|
672
|
+
</div>
|
|
673
|
+
</section>`;
|
|
674
|
+
}
|
|
675
|
+
|
|
629
676
|
function renderEmptyPlan(phase: Phase | null): string {
|
|
630
677
|
const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
|
|
631
678
|
return `<section class="card empty">
|
|
@@ -858,6 +905,11 @@ body{
|
|
|
858
905
|
.alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
|
|
859
906
|
.alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
|
|
860
907
|
|
|
908
|
+
/* -- acceptance criteria -------------------------------------------------- */
|
|
909
|
+
.criteria{margin:10px 0 0;padding:0 0 0 18px;color:var(--muted);font-size:13px}
|
|
910
|
+
.criteria li{margin:3px 0;overflow-wrap:anywhere}
|
|
911
|
+
.criteria li::marker{color:var(--t-accent)}
|
|
912
|
+
|
|
861
913
|
/* -- gate ----------------------------------------------------------------- */
|
|
862
914
|
.gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
|
863
915
|
.gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
|
|
@@ -1134,14 +1186,21 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1134
1186
|
// chips on a feature card — throws away the only structure that tells you
|
|
1135
1187
|
// whether the run is nearly done with something or scattered across
|
|
1136
1188
|
// everything.
|
|
1189
|
+
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
1137
1190
|
const groups = groupPlan(list);
|
|
1138
1191
|
const body = features.length
|
|
1139
1192
|
? groups
|
|
1140
1193
|
.map((group) =>
|
|
1141
|
-
renderGoalGroup(
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1194
|
+
renderGoalGroup(
|
|
1195
|
+
group,
|
|
1196
|
+
tasksByFeature,
|
|
1197
|
+
indexByKey,
|
|
1198
|
+
{
|
|
1199
|
+
showGoal: display.levels.goal && goals.length > 0,
|
|
1200
|
+
showSprints: display.levels.sprint && sprints.length > 0,
|
|
1201
|
+
},
|
|
1202
|
+
display,
|
|
1203
|
+
),
|
|
1145
1204
|
)
|
|
1146
1205
|
.join("")
|
|
1147
1206
|
: renderEmptyPlan(state.phase);
|
|
@@ -1168,14 +1227,18 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1168
1227
|
<div id="app">
|
|
1169
1228
|
<div class="page">
|
|
1170
1229
|
${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
|
|
1171
|
-
${renderGoals(goals)}
|
|
1172
|
-
${renderRail(state.phase, state.enabledPhases, paused)}
|
|
1173
|
-
${
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1230
|
+
${display.levels.goal ? renderGoals(goals) : ""}
|
|
1231
|
+
${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
|
|
1232
|
+
${
|
|
1233
|
+
display.alerts
|
|
1234
|
+
? renderAlerts(counts, paused, state.retries, gate, {
|
|
1235
|
+
awaitingApproval: state.awaitingApproval ?? null,
|
|
1236
|
+
sessions: state.sessions ?? null,
|
|
1237
|
+
goalPass: state.goalPass ?? null,
|
|
1238
|
+
})
|
|
1239
|
+
: ""
|
|
1240
|
+
}
|
|
1241
|
+
${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
|
|
1179
1242
|
${renderGate(gate)}
|
|
1180
1243
|
${body}
|
|
1181
1244
|
<footer class="foot">
|