taskchef 5.1.1 → 5.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.1.1",
3
+ "version": "5.3.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -241,6 +241,17 @@ loads and validates configured projects, and returns a generated task UUID,
241
241
  preparation timestamp, and exact correlation marker. `task record` accepts one
242
242
  JSON value only from closed, non-interactive standard input.
243
243
 
244
+ For repeatable live delegation measurements, run
245
+ `npm run benchmark:e2e -- write`. It reads one non-interactive JSON value,
246
+ validates a stable schema, derives durations and summary totals, and writes a
247
+ timestamped result under the supplied output directory (default:
248
+ `reports/e2e-benchmarks`). Use `validate <file>` to verify a saved result and
249
+ `clean [directory]` to remove only prior TaskChef end-to-end result JSON files.
250
+ Start from `assets/e2e-benchmark-example.json`; the writer accepts strict
251
+ schema fields only and supports stopped workflows after preparation, creation,
252
+ or recording failures. The writer stamps `taskchefVersion` from its own package;
253
+ saved files retain that version for historical validation.
254
+
244
255
  ### One-time upgrade from an older workspace
245
256
 
246
257
  TaskChef 5 does not include a general migration command. For a one-time upgrade,
@@ -345,17 +356,21 @@ taskchef task show t1
345
356
  taskchef task list
346
357
  taskchef task list --project payments
347
358
  taskchef task list --ascending
359
+ taskchef task list --full-id
348
360
  taskchef task summary
349
361
  ```
350
362
 
351
- Human-readable task listings put the scannable fields first and the durable ID
352
- last. Values are kept in full and aligned in columns. Tasks are newest-first by
353
- default; pass `--ascending` to list them from oldest to newest. The same order
354
- applies to the `tasks` array in `--json` output.
363
+ Human-readable task listings include both the task ID and Codex thread ID.
364
+ UUID-shaped IDs use their first eight-character section by default; pass
365
+ `--full-id` to show both IDs in full. Null thread IDs appear as `-`, consistent
366
+ with other empty table cells. Tasks are newest-first by default; pass
367
+ `--ascending` to list them from oldest to newest. ID formatting does not alter
368
+ the complete values in `--json` output, and the selected order applies to its
369
+ `tasks` array.
355
370
 
356
371
  ```text
357
- TITLE PROJECT CREATED ID
358
- Add retry logs payments 2026-08-12T10:00:00.000Z c0f010ff-84f2-4838-a69d-0ff1f5d721d7
372
+ TITLE PROJECT CREATED ID THREAD ID
373
+ Add retry logs payments 2026-08-12T10:00:00.000Z c0f010ff 019f9d46
359
374
  ```
360
375
 
361
376
  The complete data contract is in [SPEC.md](SPEC.md). Deferred ideas are in
package/SPEC.md CHANGED
@@ -228,6 +228,51 @@ helpers with injected thread-tool callbacks, while the skill owns the actual
228
228
  desktop-tool calls and the CLI remains responsible only for validated data
229
229
  operations.
230
230
 
231
+ ### End-to-end benchmark results
232
+
233
+ An explicitly requested live benchmark writes one ignored, timestamped JSON
234
+ artifact through `scripts/e2e-benchmark.js`. Schema version 1 contains:
235
+
236
+ - benchmark name, TaskChef version, run ID, and wall-clock start/end
237
+ - project, title, and exact unmarked workload prompt
238
+ - TaskChef task ID, nullable durable/client IDs, recording state, resolution,
239
+ and attempt count
240
+ - ISO start/end, derived duration, and outcome for preparation/project listing,
241
+ task creation, recording, and optional provisional resolution
242
+ - boolean record, marker, executor-output, and candidate-filter-effectiveness
243
+ validation results
244
+ - derived total wall time, measured stage time, orchestration overhead,
245
+ resolution state, and attempt count
246
+
247
+ Stage outcomes are `success|failed` for preparation,
248
+ `durable|provisional|failed` for creation, `recorded|failed` for recording, and
249
+ `native|discovered|unresolved|failed` for provisional resolution. `failed`
250
+ distinguishes a terminal resolver error from a completed no-match checkpoint,
251
+ which must proceed to the second fallback attempt. Task resolution is
252
+ `immediate|native|discovered|unresolved`. Preparation failure has only the
253
+ preparation stage and a null task ID; creation failure stops after creation;
254
+ recording failure stops before provisional resolution. The committed example
255
+ shows the durable success shape, while these transition rules define the
256
+ shorter failure and longer provisional shapes.
257
+ The writer derives `taskchefVersion` from the running package instead of
258
+ trusting template input. Raw executor output and transcripts are never stored;
259
+ `outputVerified` records only whether the requested output was verified.
260
+
261
+ The writer rejects missing required stages, duplicate or unknown stages,
262
+ invalid timestamps, contradictory workflow outcomes, overlapping stages,
263
+ unknown fields, and overwriting an existing run. Later stages are omitted when
264
+ preparation or creation fails, and provisional resolution is omitted when task
265
+ recording fails. Fallback snapshot observations include the complete recent
266
+ task count plus the filtered candidate and exact-match counts, from which
267
+ candidate-filter effectiveness is checked. Each snapshot records
268
+ `resolveWriteOutcome` as `not-attempted`, `succeeded`, or `failed`, so a unique
269
+ match followed by an atomic write failure remains an honest unresolved result.
270
+ A compact valid starting document is committed as
271
+ `assets/e2e-benchmark-example.json`. Filenames use
272
+ `<startedAt-with-colons-replaced>-taskchef-delegate-e2e.json`. Its cleanup
273
+ operation removes only files matching the full generated filename grammar in
274
+ the selected results directory.
275
+
231
276
  `task record` rejects an interactive TTY before reading because its protocol is
232
277
  exactly one JSON value followed by EOF. Workspace-lock contention is retried for
233
278
  up to seven seconds, while permanent permission failures such as `EPERM` or
@@ -245,7 +290,10 @@ The CLI reads persisted history without contacting Codex:
245
290
  - `task show <id>` returns one entry.
246
291
  - `task list` returns entries newest-first by creation time, optionally filtered
247
292
  by historical project name or exact path. `--ascending` returns oldest-first.
248
- The selected order applies to both human rows and the JSON `tasks` array.
293
+ Human rows include task and thread ID columns, abbreviating UUID-shaped IDs
294
+ to their first eight-character section unless `--full-id` is passed. Null
295
+ thread IDs display as `-`. ID formatting does not alter JSON values, and the
296
+ selected order applies to both human rows and the JSON `tasks` array.
249
297
  - `task summary` returns the total and per-project counts.
250
298
  - `task resolve <id> --thread-id <thread-id>` atomically fills one nullable
251
299
  thread ID after Codex verifies the exact structured marker match.
@@ -0,0 +1,46 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "benchmark": "taskchef-delegate-e2e",
4
+ "runId": "replace-with-run-id",
5
+ "startedAt": "2026-08-13T06:30:00.000Z",
6
+ "completedAt": "2026-08-13T06:30:05.000Z",
7
+ "workload": {
8
+ "project": "example-project",
9
+ "title": "Example workload",
10
+ "prompt": "Return the requested example output."
11
+ },
12
+ "task": {
13
+ "taskId": "00000000-0000-4000-8000-000000000000",
14
+ "threadId": "00000000-0000-7000-8000-000000000000",
15
+ "clientThreadId": null,
16
+ "recorded": true,
17
+ "resolution": "immediate",
18
+ "resolutionAttempts": 0
19
+ },
20
+ "stages": [
21
+ {
22
+ "name": "prepare-and-list-projects",
23
+ "startedAt": "2026-08-13T06:30:00.000Z",
24
+ "completedAt": "2026-08-13T06:30:00.200Z",
25
+ "outcome": "success"
26
+ },
27
+ {
28
+ "name": "create-thread",
29
+ "startedAt": "2026-08-13T06:30:01.000Z",
30
+ "completedAt": "2026-08-13T06:30:01.100Z",
31
+ "outcome": "durable"
32
+ },
33
+ {
34
+ "name": "record-task",
35
+ "startedAt": "2026-08-13T06:30:02.000Z",
36
+ "completedAt": "2026-08-13T06:30:04.000Z",
37
+ "outcome": "recorded"
38
+ }
39
+ ],
40
+ "validation": {
41
+ "recordVerified": true,
42
+ "markerVerified": true,
43
+ "outputVerified": false,
44
+ "candidateFilterEffective": false
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.1.1",
3
+ "version": "5.3.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -25,6 +25,7 @@
25
25
  "index.js",
26
26
  "SPEC.md",
27
27
  "scripts/benchmark-dispatch-prepare.js",
28
+ "scripts/e2e-benchmark.js",
28
29
  "src",
29
30
  "skills/taskchef-bootstrap",
30
31
  "skills/taskchef-delegate",
@@ -39,6 +40,7 @@
39
40
  },
40
41
  "scripts": {
41
42
  "benchmark:dispatch": "node scripts/benchmark-dispatch-prepare.js",
43
+ "benchmark:e2e": "node scripts/e2e-benchmark.js",
42
44
  "test": "node --test tests/taskchef.test.js"
43
45
  },
44
46
  "dependencies": {
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const BENCHMARK_NAME = "taskchef-delegate-e2e";
8
+ const STAGE_NAMES = [
9
+ "prepare-and-list-projects",
10
+ "create-thread",
11
+ "record-task",
12
+ "resolve-provisional",
13
+ ];
14
+ const STAGE_OUTCOMES = Object.freeze({
15
+ "prepare-and-list-projects": new Set(["success", "failed"]),
16
+ "create-thread": new Set(["durable", "provisional", "failed"]),
17
+ "record-task": new Set(["recorded", "failed"]),
18
+ "resolve-provisional": new Set(["native", "discovered", "unresolved", "failed"]),
19
+ });
20
+ const RESOLUTIONS = new Set(["immediate", "native", "discovered", "unresolved"]);
21
+ const RESULT_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-taskchef-delegate-e2e\.json$/;
22
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
23
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
24
+
25
+ function requireExactKeys(value, allowed, name) {
26
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
27
+ if (unknown.length > 0) throw new Error(`${name} has unknown field ${unknown[0]}`);
28
+ }
29
+
30
+ function requireObject(value, name) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
32
+ throw new Error(`${name} must be an object`);
33
+ }
34
+ return value;
35
+ }
36
+
37
+ function requireString(value, name) {
38
+ if (typeof value !== "string" || value.trim().length === 0) {
39
+ throw new Error(`${name} must be a non-empty string`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function timestamp(value, name) {
45
+ requireString(value, name);
46
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) {
47
+ throw new Error(`${name} must use a four-digit UTC year`);
48
+ }
49
+ const milliseconds = Date.parse(value);
50
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
51
+ throw new Error(`${name} must be a canonical UTC ISO timestamp`);
52
+ }
53
+ return milliseconds;
54
+ }
55
+
56
+ function nullableString(value, name) {
57
+ if (value === null) return null;
58
+ return requireString(value, name);
59
+ }
60
+
61
+ function managedResultFilename(name) {
62
+ if (!RESULT_FILENAME_PATTERN.test(name)) return false;
63
+ const timestampPart = name.slice(0, -`-${BENCHMARK_NAME}.json`.length);
64
+ const iso = `${timestampPart.slice(0, 13)}:${timestampPart.slice(14, 16)}:${timestampPart.slice(17)}`;
65
+ try {
66
+ timestamp(iso, "result filename timestamp");
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function requireNonnegativeNumber(value, name) {
74
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
75
+ throw new Error(`${name} must be a nonnegative number`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function normalizeObservations(
81
+ input,
82
+ startedMs,
83
+ completedMs,
84
+ preparationStage,
85
+ creationStage,
86
+ recordingStage,
87
+ resolutionStage,
88
+ resolutionAttempts,
89
+ ) {
90
+ if (input === undefined) return undefined;
91
+ const observations = requireObject(input, "observations");
92
+ requireExactKeys(observations, [
93
+ "preparation",
94
+ "resolutionSnapshots",
95
+ "validationDurationMs",
96
+ "candidateMetadata",
97
+ ], "observations");
98
+ const result = {};
99
+ if (observations.preparation !== undefined) {
100
+ const preparation = requireObject(observations.preparation, "observations.preparation");
101
+ requireExactKeys(preparation, ["dispatchPrepareMs", "nativeProjectListMs"], "observations.preparation");
102
+ result.preparation = {
103
+ dispatchPrepareMs: requireNonnegativeNumber(preparation.dispatchPrepareMs, "observations.preparation.dispatchPrepareMs"),
104
+ nativeProjectListMs: requireNonnegativeNumber(preparation.nativeProjectListMs, "observations.preparation.nativeProjectListMs"),
105
+ };
106
+ const preparationDurationMs = timestamp(preparationStage.completedAt, "prepare-and-list-projects.completedAt") -
107
+ timestamp(preparationStage.startedAt, "prepare-and-list-projects.startedAt");
108
+ if (Object.values(result.preparation).some((durationMs) => durationMs > preparationDurationMs)) {
109
+ throw new Error("observations.preparation durations must fit within the preparation stage");
110
+ }
111
+ }
112
+ if (observations.resolutionSnapshots !== undefined) {
113
+ if (!Array.isArray(observations.resolutionSnapshots)) {
114
+ throw new Error("observations.resolutionSnapshots must be an array");
115
+ }
116
+ let previousSnapshotStartedMs = null;
117
+ let previousSnapshotCompletedMs = null;
118
+ result.resolutionSnapshots = observations.resolutionSnapshots.map((snapshot, index) => {
119
+ requireObject(snapshot, `observations.resolutionSnapshots[${index}]`);
120
+ requireExactKeys(snapshot, [
121
+ "attempt", "startedAt", "completedAt", "recentTaskCount", "candidateCount", "exactMatchCount",
122
+ "resolveWriteMs", "resolveWriteOutcome",
123
+ ], `observations.resolutionSnapshots[${index}]`);
124
+ if (!Number.isInteger(snapshot.attempt) || snapshot.attempt !== index + 1 || snapshot.attempt > resolutionAttempts) {
125
+ throw new Error(`observations.resolutionSnapshots[${index}].attempt must be sequential and within resolutionAttempts`);
126
+ }
127
+ const snapshotStartedMs = timestamp(snapshot.startedAt, `observations.resolutionSnapshots[${index}].startedAt`);
128
+ const snapshotCompletedMs = timestamp(snapshot.completedAt, `observations.resolutionSnapshots[${index}].completedAt`);
129
+ const resolutionStartedMs = resolutionStage ? timestamp(resolutionStage.startedAt, "resolve-provisional.startedAt") : null;
130
+ const resolutionCompletedMs = resolutionStage ? timestamp(resolutionStage.completedAt, "resolve-provisional.completedAt") : null;
131
+ if (
132
+ resolutionStartedMs === null || snapshotStartedMs < resolutionStartedMs ||
133
+ snapshotCompletedMs < snapshotStartedMs || snapshotCompletedMs > resolutionCompletedMs ||
134
+ snapshotStartedMs < startedMs || snapshotCompletedMs > completedMs
135
+ ) {
136
+ throw new Error(`observations.resolutionSnapshots[${index}] must fall within the resolution stage`);
137
+ }
138
+ const firstCheckpointMs = Math.max(
139
+ timestamp(creationStage.completedAt, "create-thread.completedAt") + 10_000,
140
+ timestamp(recordingStage.completedAt, "record-task.completedAt"),
141
+ );
142
+ if (index === 0 && snapshotStartedMs < firstCheckpointMs) {
143
+ throw new Error("the first resolution snapshot must respect the 10-second catch-up checkpoint");
144
+ }
145
+ if (previousSnapshotCompletedMs !== null && snapshotStartedMs < previousSnapshotCompletedMs) {
146
+ throw new Error("resolutionSnapshots must be ordered and non-overlapping");
147
+ }
148
+ if (previousSnapshotStartedMs !== null && snapshotStartedMs - previousSnapshotStartedMs < 20_000) {
149
+ throw new Error("resolutionSnapshots must start at least 20 seconds apart");
150
+ }
151
+ for (const name of ["recentTaskCount", "candidateCount", "exactMatchCount", "resolveWriteMs"]) {
152
+ requireNonnegativeNumber(snapshot[name], `observations.resolutionSnapshots[${index}].${name}`);
153
+ }
154
+ if (!["not-attempted", "succeeded", "failed"].includes(snapshot.resolveWriteOutcome)) {
155
+ throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteOutcome is invalid`);
156
+ }
157
+ if ((snapshot.exactMatchCount === 1) !== (snapshot.resolveWriteOutcome !== "not-attempted")) {
158
+ throw new Error(`observations.resolutionSnapshots[${index}] resolve write must agree with exact matches`);
159
+ }
160
+ if (snapshot.resolveWriteOutcome === "not-attempted" && snapshot.resolveWriteMs !== 0) {
161
+ throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteMs must be zero when no write was attempted`);
162
+ }
163
+ if (!Number.isInteger(snapshot.recentTaskCount) || !Number.isInteger(snapshot.candidateCount) || !Number.isInteger(snapshot.exactMatchCount)) {
164
+ throw new Error(`observations.resolutionSnapshots[${index}] counts must be integers`);
165
+ }
166
+ if (snapshot.recentTaskCount > 50) {
167
+ throw new Error(`observations.resolutionSnapshots[${index}].recentTaskCount must not exceed 50`);
168
+ }
169
+ if (snapshot.candidateCount > snapshot.recentTaskCount || snapshot.exactMatchCount > snapshot.candidateCount) {
170
+ throw new Error(`observations.resolutionSnapshots[${index}] counts are inconsistent`);
171
+ }
172
+ if (snapshot.resolveWriteMs > snapshotCompletedMs - snapshotStartedMs) {
173
+ throw new Error(`observations.resolutionSnapshots[${index}].resolveWriteMs exceeds snapshot duration`);
174
+ }
175
+ previousSnapshotStartedMs = snapshotStartedMs;
176
+ previousSnapshotCompletedMs = snapshotCompletedMs;
177
+ return { ...snapshot };
178
+ });
179
+ }
180
+ if (observations.validationDurationMs !== undefined) {
181
+ result.validationDurationMs = requireNonnegativeNumber(observations.validationDurationMs, "observations.validationDurationMs");
182
+ const finalStage = resolutionStage ?? recordingStage ?? creationStage ?? preparationStage;
183
+ const postWorkflowMs = completedMs - timestamp(finalStage.completedAt, `${finalStage.name}.completedAt`);
184
+ if (result.validationDurationMs > postWorkflowMs) {
185
+ throw new Error("observations.validationDurationMs must fit after the workflow stages");
186
+ }
187
+ }
188
+ if (observations.candidateMetadata !== undefined) {
189
+ const metadata = requireObject(observations.candidateMetadata, "observations.candidateMetadata");
190
+ const names = ["targetProjectIdPresent", "targetCreatedAtPresent", "targetEnvironmentPresent", "targetCwdPresent"];
191
+ requireExactKeys(metadata, names, "observations.candidateMetadata");
192
+ for (const name of names) {
193
+ if (typeof metadata[name] !== "boolean") throw new Error(`observations.candidateMetadata.${name} must be boolean`);
194
+ }
195
+ result.candidateMetadata = { ...metadata };
196
+ }
197
+ return result;
198
+ }
199
+
200
+ export function normalizeBenchmarkResult(input, { requireDerived = false } = {}) {
201
+ const value = structuredClone(requireObject(input, "benchmark result"));
202
+ requireExactKeys(value, [
203
+ "schemaVersion", "benchmark", "taskchefVersion", "runId", "startedAt", "completedAt",
204
+ "workload", "task", "stages", "validation", "observations", "summary",
205
+ ], "benchmark result");
206
+ if (value.schemaVersion !== 1) throw new Error("schemaVersion must be 1");
207
+ if (value.benchmark !== BENCHMARK_NAME) {
208
+ throw new Error(`benchmark must be ${BENCHMARK_NAME}`);
209
+ }
210
+ requireString(value.taskchefVersion, "taskchefVersion");
211
+ if (!SEMVER_PATTERN.test(value.taskchefVersion)) throw new Error("taskchefVersion must be a semantic version");
212
+ requireString(value.runId, "runId");
213
+ const startedMs = timestamp(value.startedAt, "startedAt");
214
+ const completedMs = timestamp(value.completedAt, "completedAt");
215
+ if (completedMs < startedMs) throw new Error("completedAt must not precede startedAt");
216
+
217
+ const workload = requireObject(value.workload, "workload");
218
+ requireExactKeys(workload, ["project", "title", "prompt"], "workload");
219
+ requireString(workload.project, "workload.project");
220
+ requireString(workload.title, "workload.title");
221
+ requireString(workload.prompt, "workload.prompt");
222
+ if (/<!--\s*taskchef_id=/i.test(workload.prompt)) {
223
+ throw new Error("workload.prompt must not contain a TaskChef marker");
224
+ }
225
+
226
+ const task = requireObject(value.task, "task");
227
+ requireExactKeys(task, [
228
+ "taskId", "threadId", "clientThreadId", "recorded", "resolution", "resolutionAttempts",
229
+ ], "task");
230
+ task.taskId = nullableString(task.taskId, "task.taskId");
231
+ if (task.taskId !== null && !UUID_PATTERN.test(task.taskId)) {
232
+ throw new Error("task.taskId must be a lowercase UUID");
233
+ }
234
+ task.threadId = nullableString(task.threadId, "task.threadId");
235
+ task.clientThreadId = nullableString(task.clientThreadId, "task.clientThreadId");
236
+ if (task.threadId !== null && task.threadId.trim().toLowerCase().startsWith("local:")) {
237
+ throw new Error("task.threadId must not use the provisional local: namespace");
238
+ }
239
+ if (task.threadId !== null && task.threadId === task.clientThreadId) {
240
+ throw new Error("task.threadId must differ from task.clientThreadId");
241
+ }
242
+ if (typeof task.recorded !== "boolean") throw new Error("task.recorded must be boolean");
243
+ requireString(task.resolution, "task.resolution");
244
+ if (!RESOLUTIONS.has(task.resolution)) throw new Error("task.resolution is invalid");
245
+ if (!Number.isInteger(task.resolutionAttempts) || task.resolutionAttempts < 0) {
246
+ throw new Error("task.resolutionAttempts must be a nonnegative integer");
247
+ }
248
+ if (task.resolutionAttempts > 2) throw new Error("task.resolutionAttempts must not exceed 2");
249
+
250
+ if (!Array.isArray(value.stages)) throw new Error("stages must be an array");
251
+ const stagesByName = new Map();
252
+ let previousCompletedMs = startedMs;
253
+ for (const [index, stage] of value.stages.entries()) {
254
+ requireObject(stage, `stages[${index}]`);
255
+ requireExactKeys(stage, ["name", "startedAt", "completedAt", "outcome", "durationMs"], `stages[${index}]`);
256
+ requireString(stage.name, `stages[${index}].name`);
257
+ if (!STAGE_NAMES.includes(stage.name)) throw new Error(`unknown stage ${stage.name}`);
258
+ if (stagesByName.has(stage.name)) throw new Error(`duplicate stage ${stage.name}`);
259
+ const stageStartedMs = timestamp(stage.startedAt, `${stage.name}.startedAt`);
260
+ const stageCompletedMs = timestamp(stage.completedAt, `${stage.name}.completedAt`);
261
+ if (stageCompletedMs < stageStartedMs) {
262
+ throw new Error(`${stage.name}.completedAt must not precede startedAt`);
263
+ }
264
+ if (stageStartedMs < startedMs || stageCompletedMs > completedMs) {
265
+ throw new Error(`${stage.name} must fall within the benchmark run`);
266
+ }
267
+ if (stageStartedMs < previousCompletedMs) throw new Error("stages must be ordered and non-overlapping");
268
+ requireString(stage.outcome, `${stage.name}.outcome`);
269
+ if (!STAGE_OUTCOMES[stage.name].has(stage.outcome)) {
270
+ throw new Error(`${stage.name}.outcome is invalid`);
271
+ }
272
+ const durationMs = stageCompletedMs - stageStartedMs;
273
+ if (requireDerived && stage.durationMs === undefined) {
274
+ throw new Error(`${stage.name}.durationMs is required in a saved result`);
275
+ }
276
+ if (stage.durationMs !== undefined && stage.durationMs !== durationMs) {
277
+ throw new Error(`${stage.name}.durationMs does not match its timestamps`);
278
+ }
279
+ stage.durationMs = durationMs;
280
+ stagesByName.set(stage.name, stage);
281
+ previousCompletedMs = stageCompletedMs;
282
+ }
283
+ if (!stagesByName.has(STAGE_NAMES[0])) throw new Error(`missing required stage ${STAGE_NAMES[0]}`);
284
+ const resolutionStagePresent = stagesByName.has("resolve-provisional");
285
+ if (task.resolutionAttempts > 0 && !resolutionStagePresent) {
286
+ throw new Error("resolutionAttempts must agree with the resolve-provisional stage");
287
+ }
288
+ if (task.resolutionAttempts === 0 && resolutionStagePresent) {
289
+ throw new Error("zero resolutionAttempts must omit the resolve-provisional stage");
290
+ }
291
+
292
+ if (!value.stages.every((stage, index) => stage.name === STAGE_NAMES[index])) {
293
+ throw new Error("stages must use the documented workflow order");
294
+ }
295
+
296
+ const preparationOutcome = stagesByName.get("prepare-and-list-projects").outcome;
297
+ const creationOutcome = stagesByName.get("create-thread")?.outcome;
298
+ const recordingOutcome = stagesByName.get("record-task")?.outcome;
299
+ const resolutionOutcome = stagesByName.get("resolve-provisional")?.outcome;
300
+ if (preparationOutcome === "failed") {
301
+ if (
302
+ value.stages.length !== 1 || task.taskId !== null || task.threadId !== null || task.clientThreadId !== null ||
303
+ task.recorded || task.resolution !== "unresolved" || task.resolutionAttempts !== 0
304
+ ) throw new Error("failed preparation must stop the workflow unresolved");
305
+ } else if (task.taskId === null || creationOutcome === undefined) {
306
+ throw new Error("successful preparation requires a task ID and creation stage");
307
+ }
308
+ if (creationOutcome !== undefined && creationOutcome !== "failed" && recordingOutcome === undefined) {
309
+ throw new Error("successful creation requires a record-task stage");
310
+ }
311
+ if (recordingOutcome !== undefined && (recordingOutcome === "recorded") !== task.recorded) {
312
+ throw new Error("record-task outcome must agree with task.recorded");
313
+ }
314
+ if (creationOutcome === "durable") {
315
+ if (task.resolution !== "immediate" || task.threadId === null || resolutionStagePresent) {
316
+ throw new Error("durable creation must be an immediate durable resolution");
317
+ }
318
+ } else if (creationOutcome === "provisional") {
319
+ if (task.clientThreadId === null) throw new Error("provisional creation requires a clientThreadId");
320
+ if (task.recorded) {
321
+ if (task.resolutionAttempts === 0) {
322
+ if (resolutionStagePresent || task.resolution !== "unresolved" || task.threadId !== null) {
323
+ throw new Error("zero-attempt provisional creation must remain unresolved");
324
+ }
325
+ } else if (
326
+ !resolutionStagePresent ||
327
+ (resolutionOutcome !== task.resolution && !(task.resolution === "unresolved" && resolutionOutcome === "failed"))
328
+ ) {
329
+ throw new Error("recorded provisional creation must agree with its resolution stage");
330
+ }
331
+ if ((task.resolution === "unresolved") !== (task.threadId === null)) {
332
+ throw new Error("provisional resolution must agree with task.threadId");
333
+ }
334
+ } else if (
335
+ resolutionStagePresent || task.resolution !== "unresolved" || task.threadId !== null || task.resolutionAttempts !== 0
336
+ ) {
337
+ throw new Error("unrecorded provisional creation must remain unresolved");
338
+ }
339
+ } else if (
340
+ creationOutcome === "failed" && (
341
+ value.stages.length !== 2 || task.resolution !== "unresolved" || task.threadId !== null ||
342
+ task.clientThreadId !== null || task.recorded || resolutionStagePresent
343
+ )
344
+ ) {
345
+ throw new Error("failed creation must remain unrecorded and unresolved");
346
+ }
347
+
348
+ const validation = requireObject(value.validation, "validation");
349
+ const validationNames = [
350
+ "recordVerified",
351
+ "markerVerified",
352
+ "outputVerified",
353
+ "candidateFilterEffective",
354
+ ];
355
+ requireExactKeys(validation, validationNames, "validation");
356
+ for (const name of validationNames) {
357
+ if (typeof validation[name] !== "boolean") throw new Error(`validation.${name} must be boolean`);
358
+ }
359
+ if (validation.recordVerified && !task.recorded) throw new Error("recordVerified requires a recorded task");
360
+ if (validation.markerVerified && task.threadId === null) throw new Error("markerVerified requires a durable threadId");
361
+ if (validation.outputVerified && task.threadId === null) throw new Error("outputVerified requires a durable threadId");
362
+
363
+ value.observations = normalizeObservations(
364
+ value.observations,
365
+ startedMs,
366
+ completedMs,
367
+ stagesByName.get("prepare-and-list-projects"),
368
+ stagesByName.get("create-thread"),
369
+ stagesByName.get("record-task"),
370
+ stagesByName.get("resolve-provisional"),
371
+ task.resolutionAttempts,
372
+ );
373
+ if (value.observations === undefined) delete value.observations;
374
+ const snapshots = value.observations?.resolutionSnapshots ?? [];
375
+ if (
376
+ snapshots.length > 0 && snapshots.length !== task.resolutionAttempts &&
377
+ !(resolutionOutcome === "failed" && snapshots.length === task.resolutionAttempts - 1)
378
+ ) {
379
+ throw new Error("resolutionSnapshots must account for every fallback attempt");
380
+ }
381
+ if (
382
+ task.resolutionAttempts === 2 && snapshots.length !== 2 &&
383
+ !(resolutionOutcome === "failed" && snapshots.length === 1)
384
+ ) {
385
+ throw new Error("two resolution attempts require two fallback snapshots");
386
+ }
387
+ if (task.resolution === "native" && (task.resolutionAttempts !== 1 || snapshots.length !== 0)) {
388
+ throw new Error("native resolution requires exactly one non-snapshot attempt");
389
+ }
390
+ if (task.resolution === "discovered") {
391
+ if (
392
+ snapshots.length === 0 || snapshots.at(-1).exactMatchCount !== 1 ||
393
+ snapshots.at(-1).resolveWriteOutcome !== "succeeded"
394
+ ) {
395
+ throw new Error("discovered resolution requires one successfully persisted final snapshot match");
396
+ }
397
+ }
398
+ if (snapshots.some(
399
+ (snapshot) => snapshot.attempt < task.resolutionAttempts && snapshot.exactMatchCount > 0,
400
+ )) {
401
+ throw new Error("any nonzero snapshot match count must terminate fallback resolution");
402
+ }
403
+ if (
404
+ task.resolution === "unresolved" && snapshots.some(
405
+ (snapshot) => snapshot.resolveWriteOutcome === "succeeded",
406
+ )
407
+ ) {
408
+ throw new Error("unresolved resolution cannot have a successful resolve write");
409
+ }
410
+ if (
411
+ snapshots.some((snapshot) => snapshot.resolveWriteOutcome === "failed") &&
412
+ resolutionOutcome !== "failed"
413
+ ) {
414
+ throw new Error("a failed resolve write requires a failed resolution stage");
415
+ }
416
+ if (
417
+ resolutionStagePresent && snapshots.length === 0 &&
418
+ !["native", "unresolved"].includes(task.resolution)
419
+ ) {
420
+ throw new Error("non-snapshot resolution must be native or unresolved");
421
+ }
422
+ if (snapshots.length === 1 && snapshots[0].exactMatchCount === 0 && task.resolutionAttempts !== 2) {
423
+ throw new Error("a completed first snapshot with zero matches requires the second fallback attempt");
424
+ }
425
+ const filterEffective = snapshots.length > 0 && snapshots.every(
426
+ (snapshot) => snapshot.candidateCount < snapshot.recentTaskCount,
427
+ );
428
+ if (validation.candidateFilterEffective !== filterEffective) {
429
+ throw new Error("candidateFilterEffective must match resolution snapshot counts");
430
+ }
431
+
432
+ const measuredStageMs = value.stages.reduce((total, stage) => total + stage.durationMs, 0);
433
+ const totalWallMs = completedMs - startedMs;
434
+ const summary = {
435
+ totalWallMs,
436
+ measuredStageMs,
437
+ orchestrationOverheadMs: totalWallMs - measuredStageMs,
438
+ resolved: task.threadId !== null,
439
+ resolutionAttempts: task.resolutionAttempts,
440
+ };
441
+ if (requireDerived && value.summary === undefined) {
442
+ throw new Error("summary is required in a saved result");
443
+ }
444
+ if (value.summary !== undefined) {
445
+ const suppliedSummary = requireObject(value.summary, "summary");
446
+ requireExactKeys(suppliedSummary, Object.keys(summary), "summary");
447
+ if (Object.entries(summary).some(([name, expected]) => suppliedSummary[name] !== expected)) {
448
+ throw new Error("summary does not match the derived benchmark summary");
449
+ }
450
+ }
451
+ value.summary = summary;
452
+ return value;
453
+ }
454
+
455
+ function timestampFilename(iso) {
456
+ return iso.replaceAll(":", "-");
457
+ }
458
+
459
+ export async function writeBenchmarkResult(input, outputDirectory) {
460
+ const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
461
+ const result = normalizeBenchmarkResult({ ...input, taskchefVersion: packageJson.version });
462
+ await mkdir(outputDirectory, { recursive: true });
463
+ const filename = `${timestampFilename(result.startedAt)}-${BENCHMARK_NAME}.json`;
464
+ const outputPath = path.join(outputDirectory, filename);
465
+ await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, { flag: "wx" });
466
+ return { outputPath, result };
467
+ }
468
+
469
+ export async function cleanBenchmarkResults(outputDirectory) {
470
+ const entries = await readdir(outputDirectory, { withFileTypes: true }).catch((error) => {
471
+ if (error.code === "ENOENT") return [];
472
+ throw error;
473
+ });
474
+ const removed = [];
475
+ for (const entry of entries) {
476
+ if (!entry.isFile() || !managedResultFilename(entry.name)) continue;
477
+ await unlink(path.join(outputDirectory, entry.name));
478
+ removed.push(entry.name);
479
+ }
480
+ return removed.sort();
481
+ }
482
+
483
+ async function readJsonStdin() {
484
+ if (process.stdin.isTTY) throw new Error("write requires non-interactive JSON on stdin");
485
+ let input = "";
486
+ process.stdin.setEncoding("utf8");
487
+ for await (const chunk of process.stdin) input += chunk;
488
+ if (input.trim().length === 0) throw new Error("expected benchmark JSON on stdin");
489
+ return JSON.parse(input);
490
+ }
491
+
492
+ async function main(args) {
493
+ const command = args[0];
494
+ const target = path.resolve(args[1] ?? "reports/e2e-benchmarks");
495
+ if (command === "write") {
496
+ const { outputPath, result } = await writeBenchmarkResult(await readJsonStdin(), target);
497
+ process.stdout.write(`${JSON.stringify({ outputPath, summary: result.summary })}\n`);
498
+ return;
499
+ }
500
+ if (command === "validate") {
501
+ const result = normalizeBenchmarkResult(JSON.parse(await readFile(target, "utf8")), { requireDerived: true });
502
+ process.stdout.write(`${JSON.stringify({ inputPath: target, summary: result.summary })}\n`);
503
+ return;
504
+ }
505
+ if (command === "clean") {
506
+ process.stdout.write(`${JSON.stringify({ outputDirectory: target, removed: await cleanBenchmarkResults(target) })}\n`);
507
+ return;
508
+ }
509
+ throw new Error("usage: e2e-benchmark.js <write|validate|clean> [path]");
510
+ }
511
+
512
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
513
+ main(process.argv.slice(2)).catch((error) => {
514
+ process.stderr.write(`${error.message}\n`);
515
+ process.exitCode = 1;
516
+ });
517
+ }
@@ -139,3 +139,28 @@ delegated input contains an unresolved task's exact marker, run
139
139
  `<plugin-root>/bin/taskchef.js task resolve <task-id> --thread-id <thread-id> --json`.
140
140
  Never edit `tasks.jsonl` directly. The CLI permits only an idempotent one-way
141
141
  transition from `threadId: null` to one unique durable thread ID.
142
+
143
+ ## End-to-end evaluation
144
+
145
+ When explicitly asked to benchmark delegation, measure one real task without
146
+ waiting for executor completion. Start from
147
+ `<plugin-root>/assets/e2e-benchmark-example.json`. Capture one ISO start/end
148
+ interval for the parallel preparation/project-list operation, followed by
149
+ sequential non-overlapping creation, recording, and optional provisional
150
+ resolution stages. Follow the complete schema and workflow transitions in
151
+ `<plugin-root>/SPEC.md`; the example is the durable-success starting shape.
152
+ Omit later stages when an earlier operation stops the workflow.
153
+ Fallback snapshot observations additionally require `recentTaskCount`,
154
+ `candidateCount`, `exactMatchCount`, and `resolveWriteMs`. For each snapshot,
155
+ also record `resolveWriteOutcome` as `not-attempted`, `succeeded`, or `failed`;
156
+ a unique match whose atomic write fails remains unresolved. After the bounded
157
+ workflow ends, verify the canonical task record once and, when resolved, read
158
+ the task once to verify the requested output. A failed verification remains a
159
+ false validation flag even when the corresponding operation succeeded. Feed
160
+ one exact JSON value to
161
+ `node <plugin-root>/scripts/e2e-benchmark.js write <output-directory>` on closed,
162
+ non-interactive stdin. Keep timestamped results in the TaskChef source
163
+ repository's ignored `reports/e2e-benchmarks/` directory. Never include hidden
164
+ reasoning or transcripts. Use the script's `clean` command before establishing
165
+ a replacement baseline. Mark candidate filtering effective only when the
166
+ fallback snapshot narrows reads below the complete recent-task window.
package/src/cli.js CHANGED
@@ -154,6 +154,12 @@ function sortTasksByCreatedAt(tasks, ascending) {
154
154
  .map(({ task }) => task);
155
155
  }
156
156
 
157
+ function displayId(value, fullId) {
158
+ if (fullId || value === null || value === undefined) return value;
159
+ const uuidSection = String(value).match(/^[0-9a-fA-F]{8}(?=-)/);
160
+ return uuidSection ? uuidSection[0] : value;
161
+ }
162
+
157
163
  async function initialize(args) {
158
164
  validateCommandArgs(args, 2, {
159
165
  values: ["--workspace", "--codex-cli"],
@@ -315,20 +321,22 @@ async function taskShow(args) {
315
321
  async function taskList(args) {
316
322
  validateCommandArgs(args, 2, {
317
323
  values: ["--workspace", "--project"],
318
- switches: ["--ascending", "--json"],
324
+ switches: ["--ascending", "--full-id", "--json"],
319
325
  });
320
326
  const filtered = await filterTasks(workspaceRoot(args), {
321
327
  project: option(args, "--project", null),
322
328
  });
323
329
  const dispatches = sortTasksByCreatedAt(filtered, args.includes("--ascending"));
324
330
  const result = { taskCount: dispatches.length, tasks: dispatches };
331
+ const fullId = args.includes("--full-id");
325
332
  print(result, args, (value) => table(
326
- ["TITLE", "PROJECT", "CREATED", "ID"],
333
+ ["TITLE", "PROJECT", "CREATED", "ID", "THREAD ID"],
327
334
  value.tasks.map((dispatch) => [
328
335
  dispatch.title,
329
336
  dispatch.project?.name,
330
337
  dispatch.createdAt,
331
- dispatch.id,
338
+ displayId(dispatch.id, fullId),
339
+ displayId(dispatch.threadId, fullId),
332
340
  ]),
333
341
  ));
334
342
  return 0;
@@ -360,7 +368,7 @@ Usage:
360
368
  taskchef task record [--json] [--workspace <path>]
361
369
  taskchef task resolve <task-id> --thread-id <thread-id> [--json] [--workspace <path>]
362
370
  taskchef task show <task-id> [--json] [--workspace <path>]
363
- taskchef task list [--project <name-or-path>] [--ascending] [--json] [--workspace <path>]
371
+ taskchef task list [--project <name-or-path>] [--ascending] [--full-id] [--json] [--workspace <path>]
364
372
  taskchef task summary [--json] [--workspace <path>]
365
373
 
366
374
  Task record reads one JSON value from closed, non-interactive standard input.