@hyperdreamer/pi-webui 1.10.7 → 1.11.0-beta.10
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/README.md +17 -2
- package/dist/cli.js +265 -32
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-BIb2Wx8W.js → CodeViewer-CAf_j27u.js} +1 -1
- package/dist/client/assets/{UnifiedDiffViewer-DpWiGlQo.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
- package/dist/client/assets/{index-DX583DEv.js → index-5EtDb9nj.js} +1197 -476
- package/dist/client/index.html +1 -1
- package/dist/config.js +52 -11
- package/dist/config.js.map +1 -1
- package/dist/pi-webui-plugins/workspace-memory/pi-webui-plugin.js +3 -2
- package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
- package/dist/plugin-api.d.ts +30 -0
- package/dist/server/app.js +6 -1
- package/dist/server/app.js.map +1 -1
- package/dist/server/realtime/sessionEventHub.js +40 -5
- package/dist/server/realtime/sessionEventHub.js.map +1 -1
- package/dist/server/realtime/sessionStatusCoalescer.js +137 -0
- package/dist/server/realtime/sessionStatusCoalescer.js.map +1 -0
- package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
- package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
- package/dist/server/sessiond.js +57 -5
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/modelPolicyCapability.js +88 -0
- package/dist/server/sessions/modelPolicyCapability.js.map +1 -0
- package/dist/server/sessions/modelPolicyTool.js +31 -0
- package/dist/server/sessions/modelPolicyTool.js.map +1 -0
- package/dist/server/sessions/modelTierRegistry.js +14 -0
- package/dist/server/sessions/modelTierRegistry.js.map +1 -1
- package/dist/server/sessions/modelTierSettingsRoutes.js +36 -0
- package/dist/server/sessions/modelTierSettingsRoutes.js.map +1 -0
- package/dist/server/sessions/modelTierSettingsService.js +100 -0
- package/dist/server/sessions/modelTierSettingsService.js.map +1 -0
- package/dist/server/sessions/piSessionService.js +1643 -433
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionDefaultsRoutes.js +39 -0
- package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
- package/dist/server/sessions/sessionDefaultsService.js +36 -6
- package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
- package/dist/server/sessions/sessionMetadataStore.js +107 -13
- package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
- package/dist/server/sessions/sessionModelPolicy.js +118 -0
- package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
- package/dist/server/sessions/sessionReorder.js +117 -0
- package/dist/server/sessions/sessionReorder.js.map +1 -0
- package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
- package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
- package/dist/server/sessions/sessionRoutes.js +208 -3
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/starterModelPolicyPreferenceStore.js +164 -0
- package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -0
- package/dist/server/sessions/utilityModelExtension.js +199 -0
- package/dist/server/sessions/utilityModelExtension.js.map +1 -0
- package/dist/server/sessions/utilityModelResolver.js +77 -0
- package/dist/server/sessions/utilityModelResolver.js.map +1 -0
- package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
- package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
- package/dist/server/sessions/utilityModelSettingsService.js +101 -0
- package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
- package/dist/server/skills/optionalSkillInstall.js +69 -0
- package/dist/server/skills/optionalSkillInstall.js.map +1 -0
- package/dist/server/skills/optionalSkillInstaller.js +148 -0
- package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
- package/dist/shared/apiTypes.d.ts +129 -3
- package/dist/shared/apiTypes.js +10 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +15 -0
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +7 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/docs/assets/pi-webui-desktop.png +0 -0
- package/docs/config.md +61 -3
- package/docs/plugins.md +93 -16
- package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
- package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
- package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
- package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
- package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +350 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1264 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +349 -0
- package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
- package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
- package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
- package/package.json +13 -9
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan parsing and tier derivation for the deterministic SDD controller.
|
|
3
|
+
*
|
|
4
|
+
* The acceptance grammar below is the single source of truth. Documentation may
|
|
5
|
+
* restate it but never redefines it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The frozen tier ladder, in ascending capability order.
|
|
10
|
+
*
|
|
11
|
+
* Lowercase is the identifier, matching `MODEL_TIERS` in PI WEBUI's shared API
|
|
12
|
+
* types, the `modelTiers` config keys, and the `tier` parameter that
|
|
13
|
+
* `spawn_subsession` accepts. TitleCase exists only for display: plan files
|
|
14
|
+
* write `**Implementer tier:** Advanced` because a human writes and reviews
|
|
15
|
+
* them, and the parser normalizes that to `advanced` at the boundary. Keeping
|
|
16
|
+
* one conversion point here means no dispatch site needs to remember to
|
|
17
|
+
* lowercase a tier before handing it to the tool.
|
|
18
|
+
*/
|
|
19
|
+
export const TIERS = Object.freeze([
|
|
20
|
+
"economy",
|
|
21
|
+
"fast",
|
|
22
|
+
"standard",
|
|
23
|
+
"advanced",
|
|
24
|
+
"capable",
|
|
25
|
+
"frontier",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Display labels, mirroring the settings panel's tier label map. */
|
|
29
|
+
const TIER_LABELS = Object.freeze({
|
|
30
|
+
economy: "Economy",
|
|
31
|
+
fast: "Fast",
|
|
32
|
+
standard: "Standard",
|
|
33
|
+
advanced: "Advanced",
|
|
34
|
+
capable: "Capable",
|
|
35
|
+
frontier: "Frontier",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const TASK_HEADING = /^## Task ([1-9][0-9]*): (\S(?:.*\S)?)$/u;
|
|
39
|
+
const TIER_FIELD = /^\*\*Implementer tier:\*\* (Economy|Fast|Standard|Advanced|Capable|Frontier)$/u;
|
|
40
|
+
const GLOBAL_HEADING = /^## Global Constraints$/u;
|
|
41
|
+
const TASK_LIKE_ATX = /^ {0,3}#{1,}[ \t]+Task\b/u;
|
|
42
|
+
const BACKTICK_OPEN = /^ {0,3}(`{3,})([^`]*)$/u;
|
|
43
|
+
const TILDE_OPEN = /^ {0,3}(~{3,})(.*)$/u;
|
|
44
|
+
const ANY_H2 = /^## /u;
|
|
45
|
+
const INDENTED_CODE = /^ {4,}/u;
|
|
46
|
+
|
|
47
|
+
const STANDARD_FLOOR_INDEX = TIERS.indexOf("standard");
|
|
48
|
+
const FRONTIER_INDEX = TIERS.length - 1;
|
|
49
|
+
|
|
50
|
+
/** Rungs added to the implementer tier for each fix round. */
|
|
51
|
+
const FIX_ROUND_ESCALATION = Object.freeze({ 1: 0, 2: 0, 3: 0, 4: 1, 5: 2 });
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The single validating tier lookup every formula resolves through.
|
|
55
|
+
*
|
|
56
|
+
* Accepts only the lowercase identifier. A TitleCase value reaching this point
|
|
57
|
+
* means a caller bypassed the parser's normalization, which is a defect worth
|
|
58
|
+
* failing on rather than silently coercing.
|
|
59
|
+
*/
|
|
60
|
+
function tierIndex(tier) {
|
|
61
|
+
const index = TIERS.indexOf(tier);
|
|
62
|
+
if (index < 0) throw new Error(`unknown tier: ${String(tier)}`);
|
|
63
|
+
return index;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The display label for a tier, for prose and human-facing output. */
|
|
67
|
+
export function tierLabel(tier) {
|
|
68
|
+
return TIER_LABELS[TIERS[tierIndex(tier)]];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cap(index) {
|
|
72
|
+
return TIERS[Math.min(index, FRONTIER_INDEX)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reviewers sit one rung above the implementer, never below Standard and never
|
|
77
|
+
* above Frontier.
|
|
78
|
+
*/
|
|
79
|
+
export function reviewerTier(implementer) {
|
|
80
|
+
return cap(Math.max(STANDARD_FLOOR_INDEX, tierIndex(implementer) + 1));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Re-reviewers use the same derivation as reviewers. */
|
|
84
|
+
export function reReviewerTier(implementer) {
|
|
85
|
+
return reviewerTier(implementer);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fixers match the implementer through round 3, then escalate. */
|
|
89
|
+
export function fixerTier(implementer, round) {
|
|
90
|
+
const index = tierIndex(implementer);
|
|
91
|
+
if (!Number.isInteger(round) || round < 1 || round > 5) {
|
|
92
|
+
throw new Error(`fix round must be an integer from 1 through 5, received ${String(round)}`);
|
|
93
|
+
}
|
|
94
|
+
return cap(index + FIX_ROUND_ESCALATION[round]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The final reviewer always runs at the top of the ladder. */
|
|
98
|
+
export function finalReviewerTier() {
|
|
99
|
+
return TIERS[FRONTIER_INDEX];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The human-readable tier echo.
|
|
104
|
+
*
|
|
105
|
+
* This is display text, not a control channel. `spawn_subsession` selects a
|
|
106
|
+
* model from its typed `tier` parameter; a `Model tier: <tier>` line in a
|
|
107
|
+
* rendered prompt has no effect on which model runs. It exists so a human
|
|
108
|
+
* reading a transcript can see the intended tier, and so renderer/formula
|
|
109
|
+
* divergence is detectable.
|
|
110
|
+
*/
|
|
111
|
+
export function tierEcho(tier) {
|
|
112
|
+
return `Model tier: ${TIERS[tierIndex(tier)]}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @deprecated Use `tierEcho`; retained for older controller instructions. */
|
|
116
|
+
export function tierDirective(tier) {
|
|
117
|
+
return tierEcho(tier);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ROLES = Object.freeze({
|
|
121
|
+
implementer: { needsRound: false, resolve: (tier) => TIERS[tierIndex(tier)] },
|
|
122
|
+
"task-reviewer": { needsRound: false, resolve: reviewerTier },
|
|
123
|
+
"re-reviewer": { needsRound: false, resolve: reReviewerTier },
|
|
124
|
+
final: { needsRound: false, resolve: () => finalReviewerTier() },
|
|
125
|
+
fixer: { needsRound: true, resolve: fixerTier },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve one role's tier and its display echo.
|
|
130
|
+
*
|
|
131
|
+
* The round argument is required for the fixer role and rejected for every
|
|
132
|
+
* other role, so a caller cannot silently pass a round that has no effect.
|
|
133
|
+
* `directive` is a compatibility alias for controllers written before the
|
|
134
|
+
* display line stopped looking like a slash command.
|
|
135
|
+
*/
|
|
136
|
+
export function roleTier({ implementer, role, round }) {
|
|
137
|
+
const definition = ROLES[role];
|
|
138
|
+
if (definition === undefined) throw new Error(`unknown role: ${String(role)}`);
|
|
139
|
+
|
|
140
|
+
if (definition.needsRound && round === undefined) {
|
|
141
|
+
throw new Error(`role ${role} requires a fix round`);
|
|
142
|
+
}
|
|
143
|
+
if (!definition.needsRound && round !== undefined) {
|
|
144
|
+
throw new Error(`role ${role} does not accept a fix round`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tier = definition.needsRound
|
|
148
|
+
? definition.resolve(implementer, round)
|
|
149
|
+
: definition.resolve(implementer);
|
|
150
|
+
const echo = tierEcho(tier);
|
|
151
|
+
return { tier, echo, directive: echo };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class PlanError extends Error {
|
|
155
|
+
constructor(message, planPath, lineNumber) {
|
|
156
|
+
super(`${planPath}:${String(lineNumber)}: ${message}`);
|
|
157
|
+
this.name = "PlanError";
|
|
158
|
+
this.planPath = planPath;
|
|
159
|
+
this.lineNumber = lineNumber;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Detect a fence opener. Returns `{ marker, length }`, `"invalid"` for a
|
|
165
|
+
* marker-like line that violates its opener grammar, or `null` for prose.
|
|
166
|
+
*/
|
|
167
|
+
function fenceOpener(line) {
|
|
168
|
+
// Four-or-more-space indented code is ordinary content, never a fence.
|
|
169
|
+
if (INDENTED_CODE.test(line)) return null;
|
|
170
|
+
|
|
171
|
+
const backtick = BACKTICK_OPEN.exec(line);
|
|
172
|
+
if (backtick !== null) return { marker: "`", length: backtick[1].length };
|
|
173
|
+
|
|
174
|
+
const tilde = TILDE_OPEN.exec(line);
|
|
175
|
+
if (tilde !== null) return { marker: "~", length: tilde[1].length };
|
|
176
|
+
|
|
177
|
+
// A line that starts with three or more markers but matched no opener grammar
|
|
178
|
+
// is rejected rather than silently treated as prose.
|
|
179
|
+
if (/^ {0,3}`{3,}/u.test(line) || /^ {0,3}~{3,}/u.test(line)) return "invalid";
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Does `line` close a fence opened with `opener`? */
|
|
184
|
+
function closesFence(line, opener) {
|
|
185
|
+
if (INDENTED_CODE.test(line)) return false;
|
|
186
|
+
const pattern = opener.marker === "`"
|
|
187
|
+
? /^ {0,3}(`{3,})[ \t]*$/u
|
|
188
|
+
: /^ {0,3}(~{3,})[ \t]*$/u;
|
|
189
|
+
const match = pattern.exec(line);
|
|
190
|
+
return match !== null && match[1].length >= opener.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parsePlanText(planText, planPath = "<plan>") {
|
|
194
|
+
const lines = String(planText).replaceAll("\r\n", "\n").split("\n");
|
|
195
|
+
|
|
196
|
+
let globalConstraints = null;
|
|
197
|
+
let sawGlobalConstraints = false;
|
|
198
|
+
const tasks = [];
|
|
199
|
+
/** The section currently collecting body lines, or null. */
|
|
200
|
+
let current = null;
|
|
201
|
+
let openFence = null;
|
|
202
|
+
let openFenceLine = 0;
|
|
203
|
+
|
|
204
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
205
|
+
const line = lines[index];
|
|
206
|
+
const lineNumber = index + 1;
|
|
207
|
+
|
|
208
|
+
if (openFence !== null) {
|
|
209
|
+
// Inside a fence, test the closer before any opener logic.
|
|
210
|
+
if (closesFence(line, openFence)) {
|
|
211
|
+
openFence = null;
|
|
212
|
+
}
|
|
213
|
+
if (current !== null) current.body.push(line);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const opener = fenceOpener(line);
|
|
218
|
+
if (opener === "invalid") {
|
|
219
|
+
throw new PlanError("malformed fence opener", planPath, lineNumber);
|
|
220
|
+
}
|
|
221
|
+
if (opener !== null) {
|
|
222
|
+
openFence = opener;
|
|
223
|
+
openFenceLine = lineNumber;
|
|
224
|
+
if (current !== null) current.body.push(line);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const taskMatch = TASK_HEADING.exec(line);
|
|
229
|
+
if (taskMatch !== null) {
|
|
230
|
+
current = {
|
|
231
|
+
kind: "task",
|
|
232
|
+
number: Number(taskMatch[1]),
|
|
233
|
+
title: taskMatch[2],
|
|
234
|
+
implementerTier: null,
|
|
235
|
+
tierLine: 0,
|
|
236
|
+
body: [],
|
|
237
|
+
headingLine: lineNumber,
|
|
238
|
+
};
|
|
239
|
+
tasks.push(current);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (GLOBAL_HEADING.test(line)) {
|
|
244
|
+
if (sawGlobalConstraints) {
|
|
245
|
+
throw new PlanError("duplicate Global Constraints section", planPath, lineNumber);
|
|
246
|
+
}
|
|
247
|
+
if (tasks.length > 0) {
|
|
248
|
+
throw new PlanError("Global Constraints must precede the first task", planPath, lineNumber);
|
|
249
|
+
}
|
|
250
|
+
sawGlobalConstraints = true;
|
|
251
|
+
current = { kind: "global", body: [] };
|
|
252
|
+
globalConstraints = current;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Any task-like ATX heading outside a fence that is not canonical is an
|
|
257
|
+
// error. The diagnostic names the depth found, the depth required, and the
|
|
258
|
+
// tier line, because the most common source of a non-canonical plan is the
|
|
259
|
+
// `writing-plans` skill, which emits `### Task N:` with no tier field. A bare
|
|
260
|
+
// "not canonical" would leave the operator guessing at two separate repairs.
|
|
261
|
+
if (TASK_LIKE_ATX.test(line) && !INDENTED_CODE.test(line)) {
|
|
262
|
+
const depth = /^ {0,3}(#+)/u.exec(line)?.[1] ?? "#";
|
|
263
|
+
const title = line.replace(/^ {0,3}#+[ \t]+/u, "");
|
|
264
|
+
throw new PlanError(
|
|
265
|
+
[
|
|
266
|
+
`task-like heading is not canonical: ${line}`,
|
|
267
|
+
`found heading depth "${depth}" but the deterministic controller requires "##"`,
|
|
268
|
+
`rewrite it as: ## ${title.startsWith("Task") ? title : `Task N: ${title}`}`,
|
|
269
|
+
'and give every task a tier line on its own: **Implementer tier:** Advanced',
|
|
270
|
+
"a tier-annotated plan is a precondition of tiered dispatch; the controller never guesses a tier",
|
|
271
|
+
].join("\n "),
|
|
272
|
+
planPath,
|
|
273
|
+
lineNumber,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// A non-canonical H2 terminates the open section without being captured.
|
|
278
|
+
if (ANY_H2.test(line)) {
|
|
279
|
+
current = null;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (current === null) continue;
|
|
284
|
+
|
|
285
|
+
if (current.kind === "task") {
|
|
286
|
+
const tierMatch = TIER_FIELD.exec(line);
|
|
287
|
+
if (tierMatch !== null) {
|
|
288
|
+
if (current.implementerTier !== null) {
|
|
289
|
+
throw new PlanError(
|
|
290
|
+
`duplicate Implementer tier for Task ${String(current.number)}`,
|
|
291
|
+
planPath,
|
|
292
|
+
lineNumber,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
// Plan files carry TitleCase for readability; the identifier is
|
|
296
|
+
// lowercase everywhere past this boundary.
|
|
297
|
+
current.implementerTier = tierMatch[1].toLowerCase();
|
|
298
|
+
current.tierLine = lineNumber;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// A tier-like line that failed the exact grammar is a hard error.
|
|
302
|
+
if (/^\s*\*\*Implementer tier:\*\*/u.test(line)) {
|
|
303
|
+
throw new PlanError(`malformed Implementer tier field: ${line}`, planPath, lineNumber);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
current.body.push(line);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (openFence !== null) {
|
|
311
|
+
throw new PlanError("unterminated fence", planPath, openFenceLine);
|
|
312
|
+
}
|
|
313
|
+
if (tasks.length === 0) {
|
|
314
|
+
throw new PlanError("plan declares no tasks", planPath, lines.length);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (let position = 0; position < tasks.length; position += 1) {
|
|
318
|
+
const task = tasks[position];
|
|
319
|
+
const expected = position + 1;
|
|
320
|
+
if (task.number !== expected) {
|
|
321
|
+
throw new PlanError(
|
|
322
|
+
`expected Task ${String(expected)} but found Task ${String(task.number)}`,
|
|
323
|
+
planPath,
|
|
324
|
+
task.headingLine,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (task.implementerTier === null) {
|
|
328
|
+
throw new PlanError(
|
|
329
|
+
`Task ${String(task.number)} has no Implementer tier
|
|
330
|
+
add a line reading exactly: **Implementer tier:** <Economy|Fast|Standard|Advanced|Capable|Frontier>
|
|
331
|
+
a tier-annotated plan is a precondition of tiered dispatch; the controller never guesses a tier`,
|
|
332
|
+
planPath,
|
|
333
|
+
task.headingLine,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
planPath,
|
|
340
|
+
globalConstraints: globalConstraints === null
|
|
341
|
+
? null
|
|
342
|
+
: globalConstraints.body.join("\n").trim(),
|
|
343
|
+
tasks: tasks.map((task) => ({
|
|
344
|
+
number: task.number,
|
|
345
|
+
title: task.title,
|
|
346
|
+
implementerTier: task.implementerTier,
|
|
347
|
+
body: task.body.join("\n").trim(),
|
|
348
|
+
})),
|
|
349
|
+
};
|
|
350
|
+
}
|
package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic prompt rendering.
|
|
3
|
+
*
|
|
4
|
+
* A rendered prompt is dispatch input whose exact bytes are stored in state and
|
|
5
|
+
* reissued verbatim on recovery. Rendering is therefore a pure function of
|
|
6
|
+
* (tier, role, context): no clock, no working directory, no environment, no
|
|
7
|
+
* template evaluation.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no general-purpose templating here. A template engine
|
|
10
|
+
* would let a context value introduce a tier label, a heading, or another
|
|
11
|
+
* placeholder, and the prompt is the one artifact a child treats as instructions.
|
|
12
|
+
* Role contracts are static files copied verbatim; context is emitted as a
|
|
13
|
+
* validated, escaped key/value list.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { TIERS } from "./plan-policy.mjs";
|
|
20
|
+
|
|
21
|
+
/** Maximum bytes for a rendered prompt, matching the dispatch-intent bound. */
|
|
22
|
+
const MAX_PROMPT_BYTES = 384 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Maximum UTF-8 bytes for any single path. */
|
|
25
|
+
const MAX_PATH_BYTES = 4096;
|
|
26
|
+
|
|
27
|
+
/** Maximum finding records the contract allows in one ledger. */
|
|
28
|
+
const MAX_FINDING_IDS = 256;
|
|
29
|
+
|
|
30
|
+
const TIER_SET = new Set(TIERS);
|
|
31
|
+
|
|
32
|
+
/** A rejected render. */
|
|
33
|
+
export class RenderError extends Error {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "RenderError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fail = (message) => {
|
|
41
|
+
throw new RenderError(message);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Role definitions.
|
|
46
|
+
*
|
|
47
|
+
* `paths` and `scalars` are the complete accepted key set for each role. Anything
|
|
48
|
+
* else is rejected rather than ignored, so a typo in a context file fails loudly
|
|
49
|
+
* instead of silently omitting information a child needed.
|
|
50
|
+
*/
|
|
51
|
+
const ROLES = Object.freeze({
|
|
52
|
+
implementer: {
|
|
53
|
+
template: "implementer.md",
|
|
54
|
+
paths: ["briefPath", "reportPath"],
|
|
55
|
+
scalars: ["task"],
|
|
56
|
+
optional: ["contextPath", "findingPackagePath", "baseSha", "headSha", "round"],
|
|
57
|
+
},
|
|
58
|
+
"task-reviewer": {
|
|
59
|
+
template: "task-reviewer.md",
|
|
60
|
+
paths: ["briefPath", "reportPath"],
|
|
61
|
+
scalars: ["task"],
|
|
62
|
+
optional: ["baseSha", "headSha", "reviewPackagePath"],
|
|
63
|
+
},
|
|
64
|
+
"re-reviewer": {
|
|
65
|
+
template: "re-reviewer.md",
|
|
66
|
+
paths: ["briefPath", "reportPath"],
|
|
67
|
+
scalars: ["task"],
|
|
68
|
+
optional: ["baseSha", "headSha", "reviewPackagePath", "findingIds", "round"],
|
|
69
|
+
},
|
|
70
|
+
"final-reviewer": {
|
|
71
|
+
template: "final-reviewer.md",
|
|
72
|
+
paths: ["briefPath", "reportPath"],
|
|
73
|
+
scalars: ["task"],
|
|
74
|
+
optional: ["baseSha", "headSha", "reviewPackagePath", "findingIds", "ledgerPath"],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Keys every role pins, naming the roots that confine every other path. */
|
|
79
|
+
const ROOT_KEYS = Object.freeze(["worktree", "runRoot"]);
|
|
80
|
+
|
|
81
|
+
/** Emission order. Fixed, because byte-exact output cannot depend on key order. */
|
|
82
|
+
const FIELD_ORDER = Object.freeze([
|
|
83
|
+
"worktree",
|
|
84
|
+
"runRoot",
|
|
85
|
+
"task",
|
|
86
|
+
"round",
|
|
87
|
+
"baseSha",
|
|
88
|
+
"headSha",
|
|
89
|
+
"briefPath",
|
|
90
|
+
"contextPath",
|
|
91
|
+
"reportPath",
|
|
92
|
+
"reviewPackagePath",
|
|
93
|
+
"findingPackagePath",
|
|
94
|
+
"ledgerPath",
|
|
95
|
+
"findingIds",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
99
|
+
const FINDING_ID = /^[A-Za-z0-9._:-]{1,64}$/u;
|
|
100
|
+
|
|
101
|
+
/** Reject control characters anywhere in a rendered value. */
|
|
102
|
+
const requireClean = (value, field) => {
|
|
103
|
+
// eslint-disable-next-line no-control-regex -- rejecting these is the point.
|
|
104
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) {
|
|
105
|
+
fail(`${field} must not contain a control character`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate a path: absolute, normalized, bounded, and confined to a pinned root.
|
|
112
|
+
*
|
|
113
|
+
* When the path already exists its real path is checked too, so a symlink cannot
|
|
114
|
+
* point a "confined" path at content outside the run. For an output path the real
|
|
115
|
+
* parent is checked, since the file itself will not exist yet.
|
|
116
|
+
*/
|
|
117
|
+
const validatePath = (value, field, roots, { mustExist = false } = {}) => {
|
|
118
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
119
|
+
fail(`${field} must be a non-empty string`);
|
|
120
|
+
}
|
|
121
|
+
requireClean(value, field);
|
|
122
|
+
if (Buffer.byteLength(value, "utf8") > MAX_PATH_BYTES) {
|
|
123
|
+
fail(`${field} exceeds ${String(MAX_PATH_BYTES)} bytes`);
|
|
124
|
+
}
|
|
125
|
+
if (!isAbsolute(value)) fail(`${field} must be an absolute path: ${value}`);
|
|
126
|
+
if (value.split("/").includes("..")) {
|
|
127
|
+
fail(`${field} must be normalized and contain no ".." segment`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const within = (candidate) =>
|
|
131
|
+
roots.some((root) => candidate === root || candidate.startsWith(`${root}/`));
|
|
132
|
+
|
|
133
|
+
if (!within(resolve(value))) {
|
|
134
|
+
fail(`${field} must live beneath a pinned root: ${value}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let real;
|
|
138
|
+
try {
|
|
139
|
+
real = realpathSync(value);
|
|
140
|
+
} catch {
|
|
141
|
+
real = null;
|
|
142
|
+
}
|
|
143
|
+
if (real === null && mustExist) fail(`${field} does not exist: ${value}`);
|
|
144
|
+
if (real !== null && !within(real)) {
|
|
145
|
+
fail(`${field} resolves outside the pinned roots via a symlink: ${value} -> ${real}`);
|
|
146
|
+
}
|
|
147
|
+
if (real === null) {
|
|
148
|
+
// The file may legitimately not exist yet; its parent still must not escape.
|
|
149
|
+
let parentReal;
|
|
150
|
+
try {
|
|
151
|
+
parentReal = realpathSync(dirname(value));
|
|
152
|
+
} catch {
|
|
153
|
+
fail(`${field} names a directory that does not exist: ${dirname(value)}`);
|
|
154
|
+
}
|
|
155
|
+
if (!within(parentReal)) {
|
|
156
|
+
fail(`${field} has a real parent outside the pinned roots: ${parentReal}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Render one context value as a single line. */
|
|
163
|
+
const renderValue = (key, value) => {
|
|
164
|
+
if (Array.isArray(value)) return `- ${key}: ${value.join(", ")}`;
|
|
165
|
+
if (typeof value === "number") return `- ${key}: ${String(value)}`;
|
|
166
|
+
return `- ${key}: ${value}`;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate a context object against its role.
|
|
171
|
+
*
|
|
172
|
+
* Returns the accepted subset in a fixed emission order.
|
|
173
|
+
*/
|
|
174
|
+
const validateContext = (role, context) => {
|
|
175
|
+
const definition = ROLES[role];
|
|
176
|
+
const accepted = new Set([
|
|
177
|
+
...ROOT_KEYS,
|
|
178
|
+
...definition.paths,
|
|
179
|
+
...definition.scalars,
|
|
180
|
+
...definition.optional,
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const unexpected = Object.keys(context).filter((key) => !accepted.has(key));
|
|
184
|
+
if (unexpected.length > 0) {
|
|
185
|
+
fail(`unexpected context key(s) for role ${role}: ${unexpected.join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const key of ROOT_KEYS) {
|
|
189
|
+
const value = context[key];
|
|
190
|
+
if (typeof value !== "string" || !isAbsolute(value)) {
|
|
191
|
+
fail(`${key} must be a pinned absolute path`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const roots = ROOT_KEYS.map((key) => resolve(context[key]));
|
|
195
|
+
|
|
196
|
+
for (const key of definition.paths) {
|
|
197
|
+
if (context[key] === undefined) fail(`role ${role} requires ${key}`);
|
|
198
|
+
validatePath(context[key], key, roots);
|
|
199
|
+
}
|
|
200
|
+
for (const key of definition.scalars) {
|
|
201
|
+
const value = context[key];
|
|
202
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
203
|
+
fail(`${key} must be a positive integer for role ${role}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const key of definition.optional) {
|
|
208
|
+
const value = context[key];
|
|
209
|
+
if (value === undefined) continue;
|
|
210
|
+
if (key.endsWith("Path")) {
|
|
211
|
+
validatePath(value, key, roots);
|
|
212
|
+
} else if (key === "baseSha" || key === "headSha") {
|
|
213
|
+
if (typeof value !== "string" || !SHA.test(value)) {
|
|
214
|
+
fail(`${key} must be a 40-character lowercase hex object name`);
|
|
215
|
+
}
|
|
216
|
+
} else if (key === "round") {
|
|
217
|
+
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
218
|
+
fail("round must be an integer from 1 to 5");
|
|
219
|
+
}
|
|
220
|
+
} else if (key === "findingIds") {
|
|
221
|
+
if (!Array.isArray(value)) fail("findingIds must be an array");
|
|
222
|
+
if (value.length > MAX_FINDING_IDS) {
|
|
223
|
+
fail(
|
|
224
|
+
`findingIds holds ${String(value.length)} entries, exceeding the ${String(MAX_FINDING_IDS)}-finding bound`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
for (const [index, id] of value.entries()) {
|
|
228
|
+
if (typeof id !== "string" || !FINDING_ID.test(id)) {
|
|
229
|
+
fail(`findingIds[${String(index)}] must match ^[A-Za-z0-9._:-]{1,64}$`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
fail(`no validation rule for optional key ${key}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return FIELD_ORDER.filter((key) => context[key] !== undefined).map((key) => [key, context[key]]);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render a prompt.
|
|
242
|
+
*
|
|
243
|
+
* Byte layout, fixed by contract because dispatch stores and reissues these bytes:
|
|
244
|
+
*
|
|
245
|
+
* Model tier: <tier>\n
|
|
246
|
+
* \n
|
|
247
|
+
* <role contract, trailing whitespace trimmed>\n
|
|
248
|
+
* \n
|
|
249
|
+
* ## Dispatch Context\n\n<one "- key: value" line each>\n
|
|
250
|
+
* \n
|
|
251
|
+
* ## Return Channel\n\n<two lines>\n
|
|
252
|
+
*/
|
|
253
|
+
export function renderPrompt({ tier, role, context, skillRoot }) {
|
|
254
|
+
if (!TIER_SET.has(tier)) {
|
|
255
|
+
fail(`unknown tier: ${String(tier)} (expected one of ${TIERS.join(", ")})`);
|
|
256
|
+
}
|
|
257
|
+
const definition = ROLES[role];
|
|
258
|
+
if (definition === undefined) {
|
|
259
|
+
fail(`unknown role: ${String(role)} (expected one of ${Object.keys(ROLES).join(", ")})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const fields = validateContext(role, context);
|
|
263
|
+
const template = readFileSync(join(skillRoot, "prompts", definition.template), "utf8");
|
|
264
|
+
|
|
265
|
+
const rendered = [
|
|
266
|
+
`Model tier: ${tier}`,
|
|
267
|
+
"",
|
|
268
|
+
template.trimEnd(),
|
|
269
|
+
"",
|
|
270
|
+
"## Dispatch Context",
|
|
271
|
+
"",
|
|
272
|
+
...fields.map(([key, value]) => renderValue(key, value)),
|
|
273
|
+
"",
|
|
274
|
+
"## Return Channel",
|
|
275
|
+
"",
|
|
276
|
+
`Write exactly one report at ${context.reportPath}.`,
|
|
277
|
+
"Return exactly one status token defined by your role contract above.",
|
|
278
|
+
"",
|
|
279
|
+
].join("\n");
|
|
280
|
+
|
|
281
|
+
if (Buffer.byteLength(rendered, "utf8") > MAX_PROMPT_BYTES) {
|
|
282
|
+
fail(
|
|
283
|
+
`rendered prompt is ${String(Buffer.byteLength(rendered, "utf8"))} bytes, exceeding ${String(MAX_PROMPT_BYTES)} (384 KiB)`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (rendered.includes("{{")) {
|
|
287
|
+
fail("rendered prompt contains an unresolved template marker");
|
|
288
|
+
}
|
|
289
|
+
return rendered;
|
|
290
|
+
}
|