@hyperdreamer/pi-webui 1.11.0-beta.1 → 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-DLZCHlMU.js → CodeViewer-CAf_j27u.js} +1 -1
- package/dist/client/assets/{UnifiedDiffViewer-XCb8NlrO.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
- package/dist/client/assets/{index-BaDGxg8F.js → index-5EtDb9nj.js} +782 -457
- package/dist/client/index.html +1 -1
- package/dist/config.js +48 -11
- package/dist/config.js.map +1 -1
- package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
- package/dist/plugin-api.d.ts +2 -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 +1 -0
- package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
- package/dist/server/sessiond.js +39 -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/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 +111 -3
- package/dist/shared/apiTypes.js +9 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +12 -0
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +5 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/docs/assets/pi-webui-desktop.png +0 -0
- package/docs/config.md +38 -3
- 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 +12 -9
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime ownership manifest for the optional deterministic SDD skill.
|
|
3
|
+
*
|
|
4
|
+
* The manifest answers two questions a consumer cannot otherwise answer: which
|
|
5
|
+
* package owns this skill, and whether the runtime tree on disk is the tree that
|
|
6
|
+
* was generated. It deliberately does not answer "is this up to date" -- that
|
|
7
|
+
* comparison belongs to an installer, which knows the installed version.
|
|
8
|
+
*
|
|
9
|
+
* The runtime list is explicit rather than globbed. A glob would silently absorb
|
|
10
|
+
* a new file into the shipped surface; an explicit list makes every addition a
|
|
11
|
+
* reviewed edit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Manifest schema version. Bump only for a breaking shape change. */
|
|
19
|
+
export const MANIFEST_SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** Canonical skill name. Must match the `name` in `SKILL.md` frontmatter. */
|
|
22
|
+
export const SKILL_NAME = "deterministic-subagent-driven-development";
|
|
23
|
+
|
|
24
|
+
/** Distribution posture. `opt-in` means never auto-registered by the package. */
|
|
25
|
+
export const DISTRIBUTION = "opt-in";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hash algorithm identifier.
|
|
29
|
+
*
|
|
30
|
+
* `sha256-path-nul-bytes-v1`: for each runtime file in sorted relative-path
|
|
31
|
+
* order, absorb the UTF-8 relative path, a NUL, the raw file bytes, and a NUL.
|
|
32
|
+
* The NUL delimiters make the encoding unambiguous: without them, a rename that
|
|
33
|
+
* shifted bytes between path and content could preserve the digest.
|
|
34
|
+
*/
|
|
35
|
+
export const RUNTIME_HASH_ALGORITHM = "sha256-path-nul-bytes-v1";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Files that constitute the runtime skill, sorted.
|
|
39
|
+
*
|
|
40
|
+
* Excludes `evals/` and `tests/` by construction: those carry fake capability
|
|
41
|
+
* tools and adversarial prompts that must never reach a consumer's skill tree.
|
|
42
|
+
*/
|
|
43
|
+
export const RUNTIME_FILES = Object.freeze([
|
|
44
|
+
"SKILL.md",
|
|
45
|
+
"prompts/final-reviewer.md",
|
|
46
|
+
"prompts/implementer.md",
|
|
47
|
+
"prompts/re-reviewer.md",
|
|
48
|
+
"prompts/task-reviewer.md",
|
|
49
|
+
"references/capability-contract.md",
|
|
50
|
+
"references/plan-contract.md",
|
|
51
|
+
"references/state-machine.md",
|
|
52
|
+
"scripts/lib/manifest.mjs",
|
|
53
|
+
"scripts/lib/plan-policy.mjs",
|
|
54
|
+
"scripts/lib/prompt-renderer.mjs",
|
|
55
|
+
"scripts/lib/state-machine.mjs",
|
|
56
|
+
"scripts/lib/state-store.mjs",
|
|
57
|
+
"scripts/sdd-state",
|
|
58
|
+
"scripts/sdd-state.mjs",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const SEMVER_PATTERN =
|
|
62
|
+
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
63
|
+
|
|
64
|
+
/** Raised for any manifest validation or integrity failure. */
|
|
65
|
+
export class ManifestError extends Error {
|
|
66
|
+
constructor(message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "ManifestError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reject a runtime entry that is absolute, traverses upward, duplicates another,
|
|
74
|
+
* or names excluded development evidence.
|
|
75
|
+
*
|
|
76
|
+
* Validation runs before any hashing so a rejected list never produces a digest.
|
|
77
|
+
*/
|
|
78
|
+
export function assertRuntimeList(runtimeFiles) {
|
|
79
|
+
if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) {
|
|
80
|
+
throw new ManifestError("runtime file list must be a non-empty array");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const entry of runtimeFiles) {
|
|
85
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
86
|
+
throw new ManifestError("runtime entries must be non-empty strings");
|
|
87
|
+
}
|
|
88
|
+
if (isAbsolute(entry) || entry.startsWith("/")) {
|
|
89
|
+
throw new ManifestError(`runtime entry must be relative: ${entry}`);
|
|
90
|
+
}
|
|
91
|
+
if (entry.includes("\\")) {
|
|
92
|
+
throw new ManifestError(`runtime entry must use forward slashes: ${entry}`);
|
|
93
|
+
}
|
|
94
|
+
const segments = entry.split("/");
|
|
95
|
+
if (segments.includes("..") || segments.includes(".") || segments.includes("")) {
|
|
96
|
+
throw new ManifestError(`runtime entry must be a normalized path: ${entry}`);
|
|
97
|
+
}
|
|
98
|
+
if (entry.startsWith("evals/") || entry.startsWith("tests/")) {
|
|
99
|
+
throw new ManifestError(`runtime entry must not ship development evidence: ${entry}`);
|
|
100
|
+
}
|
|
101
|
+
if (seen.has(entry)) {
|
|
102
|
+
throw new ManifestError(`duplicate runtime entry: ${entry}`);
|
|
103
|
+
}
|
|
104
|
+
seen.add(entry);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sorted = [...runtimeFiles].sort();
|
|
108
|
+
if (sorted.some((entry, index) => entry !== runtimeFiles[index])) {
|
|
109
|
+
throw new ManifestError("runtime file list must be sorted");
|
|
110
|
+
}
|
|
111
|
+
return sorted;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a runtime entry against the source root.
|
|
116
|
+
*
|
|
117
|
+
* There is no second escape check here on purpose. `assertRuntimeList` runs first
|
|
118
|
+
* on every path into this module and rejects absolute paths, backslashes, and any
|
|
119
|
+
* `..` segment, so a validated entry cannot escape. A redundant guard here would
|
|
120
|
+
* be unreachable, and an unreachable guard is worse than none: no test can pin it,
|
|
121
|
+
* so it silently rots while implying a protection it never performs.
|
|
122
|
+
*
|
|
123
|
+
* Symlinks are a separate matter and deliberately not handled: the runtime list is
|
|
124
|
+
* a frozen constant in this file, not caller input, so there is no untrusted path
|
|
125
|
+
* by which a runtime entry could become a link out of the tree.
|
|
126
|
+
*/
|
|
127
|
+
function resolveInside(sourceRoot, relativePath) {
|
|
128
|
+
return join(resolve(sourceRoot), relativePath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute the runtime digest over the validated, sorted list.
|
|
133
|
+
*
|
|
134
|
+
* Missing files surface as a `ManifestError` rather than a raw ENOENT, because a
|
|
135
|
+
* manifest naming a file that does not exist is a manifest defect.
|
|
136
|
+
*/
|
|
137
|
+
export function computeRuntimeHash(sourceRoot, runtimeFiles = RUNTIME_FILES) {
|
|
138
|
+
const sorted = assertRuntimeList(runtimeFiles);
|
|
139
|
+
const hash = createHash("sha256");
|
|
140
|
+
for (const relativePath of sorted) {
|
|
141
|
+
const absolutePath = resolveInside(sourceRoot, relativePath);
|
|
142
|
+
let bytes;
|
|
143
|
+
try {
|
|
144
|
+
bytes = readFileSync(absolutePath);
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
throw new ManifestError(`runtime file is missing: ${relativePath}`, { cause });
|
|
147
|
+
}
|
|
148
|
+
hash.update(Buffer.from(relativePath, "utf8"));
|
|
149
|
+
hash.update(Buffer.from([0]));
|
|
150
|
+
hash.update(bytes);
|
|
151
|
+
hash.update(Buffer.from([0]));
|
|
152
|
+
}
|
|
153
|
+
return hash.digest("hex");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Read the owning package's name and version, validating the version is semver. */
|
|
157
|
+
function readSourcePackage(packageJsonPath) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
161
|
+
} catch (cause) {
|
|
162
|
+
throw new ManifestError(`cannot read package manifest: ${packageJsonPath}`, { cause });
|
|
163
|
+
}
|
|
164
|
+
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
165
|
+
throw new ManifestError("package manifest has no name");
|
|
166
|
+
}
|
|
167
|
+
if (typeof parsed.version !== "string" || !SEMVER_PATTERN.test(parsed.version)) {
|
|
168
|
+
throw new ManifestError(`package version is not valid semver: ${String(parsed.version)}`);
|
|
169
|
+
}
|
|
170
|
+
return { name: parsed.name, version: parsed.version };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Build the manifest object without writing it. */
|
|
174
|
+
export function buildManifest({ sourceRoot, packageJsonPath, runtimeFiles = RUNTIME_FILES }) {
|
|
175
|
+
const sorted = assertRuntimeList(runtimeFiles);
|
|
176
|
+
const sourcePackage = readSourcePackage(packageJsonPath);
|
|
177
|
+
// Hash last: a validation failure must never leave a digest to be trusted.
|
|
178
|
+
const runtimeHash = computeRuntimeHash(sourceRoot, sorted);
|
|
179
|
+
return {
|
|
180
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
181
|
+
name: SKILL_NAME,
|
|
182
|
+
distribution: DISTRIBUTION,
|
|
183
|
+
sourcePackage,
|
|
184
|
+
runtimeHashAlgorithm: RUNTIME_HASH_ALGORITHM,
|
|
185
|
+
runtimeHash,
|
|
186
|
+
runtimeFiles: sorted,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write the manifest atomically.
|
|
192
|
+
*
|
|
193
|
+
* The digest is computed fully before the first byte is written, and the file
|
|
194
|
+
* appears via `rename`, so a reader never observes a partial manifest or a
|
|
195
|
+
* placeholder digest. There is no intermediate sentinel hash to leak.
|
|
196
|
+
*/
|
|
197
|
+
export function writeManifest({ sourceRoot, packageJsonPath, outputPath, runtimeFiles }) {
|
|
198
|
+
const manifest = buildManifest({ sourceRoot, packageJsonPath, runtimeFiles });
|
|
199
|
+
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
200
|
+
const temporaryPath = `${outputPath}.tmp-${process.pid.toString(36)}`;
|
|
201
|
+
writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o644 });
|
|
202
|
+
renameSync(temporaryPath, outputPath);
|
|
203
|
+
return manifest;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Parse and structurally validate a manifest file. */
|
|
207
|
+
export function readManifest(manifestPath) {
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
throw new ManifestError(`cannot read manifest: ${manifestPath}`, { cause });
|
|
213
|
+
}
|
|
214
|
+
if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
215
|
+
throw new ManifestError(`unsupported manifest schema version: ${String(parsed.schemaVersion)}`);
|
|
216
|
+
}
|
|
217
|
+
if (parsed.name !== SKILL_NAME) {
|
|
218
|
+
throw new ManifestError(`unexpected skill name: ${String(parsed.name)}`);
|
|
219
|
+
}
|
|
220
|
+
if (parsed.distribution !== DISTRIBUTION) {
|
|
221
|
+
throw new ManifestError(`unexpected distribution: ${String(parsed.distribution)}`);
|
|
222
|
+
}
|
|
223
|
+
if (parsed.runtimeHashAlgorithm !== RUNTIME_HASH_ALGORITHM) {
|
|
224
|
+
throw new ManifestError(
|
|
225
|
+
`unsupported runtime hash algorithm: ${String(parsed.runtimeHashAlgorithm)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (typeof parsed.runtimeHash !== "string" || !/^[0-9a-f]{64}$/u.test(parsed.runtimeHash)) {
|
|
229
|
+
throw new ManifestError("runtime hash must be 64 lowercase hex characters");
|
|
230
|
+
}
|
|
231
|
+
assertRuntimeList(parsed.runtimeFiles);
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Recompute the digest from the tree beside the manifest and compare.
|
|
237
|
+
*
|
|
238
|
+
* Returns the manifest and digest on success; throws on mismatch. The source root
|
|
239
|
+
* is the manifest's own directory, so verification cannot be pointed at a
|
|
240
|
+
* different tree by accident.
|
|
241
|
+
*/
|
|
242
|
+
export function verifyManifest(manifestPath, sourceRoot) {
|
|
243
|
+
const manifest = readManifest(manifestPath);
|
|
244
|
+
const actual = computeRuntimeHash(sourceRoot, manifest.runtimeFiles);
|
|
245
|
+
if (actual !== manifest.runtimeHash) {
|
|
246
|
+
throw new ManifestError(
|
|
247
|
+
`runtime hash mismatch: manifest records ${manifest.runtimeHash} but the tree hashes to ${actual}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return { manifest, runtimeHash: actual };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Absolute paths of the manifest's runtime files, for callers that copy the tree. */
|
|
254
|
+
export function runtimeFilePaths(sourceRoot, runtimeFiles = RUNTIME_FILES) {
|
|
255
|
+
return assertRuntimeList(runtimeFiles).map((relativePath) =>
|
|
256
|
+
join(resolve(sourceRoot), relativePath),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -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
|
+
}
|