taskchef 7.8.0 → 7.9.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": "7.8.0",
3
+ "version": "7.9.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
@@ -3,11 +3,11 @@
3
3
  TaskChef is a local dispatch desk for Codex. Give one dispatcher a request and
4
4
  it records each independently useful outcome, creates a normal Codex task in
5
5
  the right project, and returns immediately. The executor task is where live
6
- work, approvals, and follow-ups happen; TaskChef keeps every semantic result
7
- while projecting the latest one for compact navigation and reporting.
6
+ work, approvals, and follow-ups happen; TaskChef pairs each turn's request with
7
+ its semantic result while projecting the latest pair for compact navigation.
8
8
 
9
9
  ```text
10
- request -> recorded TaskChef task -> Codex executor -> current state + result history
10
+ request -> recorded TaskChef task -> Codex executor -> request/result turn timeline
11
11
  ```
12
12
 
13
13
  ## Which document should I read?
@@ -49,7 +49,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
49
49
  ```text
50
50
  AGENTS.md managed dispatcher instructions plus user additions
51
51
  taskchef.json schema-2 configured projects and routing metadata
52
- tasks.jsonl one task snapshot per line (schema 6; schema 4/5 migration supported)
52
+ tasks.jsonl one task snapshot per line (schema 7; schema 4/5/6 migration supported)
53
53
  ```
54
54
 
55
55
  List or change routing targets conversationally:
@@ -119,12 +119,12 @@ when a turn starts and one semantic outcome before that same turn ends:
119
119
  - `failed`: the executor or creation attempt ended unsuccessfully.
120
120
 
121
121
  A native approval prompt is live Codex state, not `needs_input`.
122
- TaskChef stores the current reported execution state and appends every concise
123
- semantic result to `results`. A follow-up therefore appears as `working`
124
- immediately without erasing any prior outcome. The final entry is exposed as a
125
- derived `lastResult` compatibility alias; it is not persisted independently and
126
- is planned for removal in the next major version after callers move to
127
- `results.at(-1)`. TaskChef does not store transcripts or non-semantic events.
122
+ When work starts, the executor reports a concise request summary. TaskChef
123
+ appends a `turns` entry that pairs that request with a null result while working,
124
+ then fills the same entry with the semantic outcome. A follow-up therefore shows
125
+ its own request with “In progress,” never the preceding turn's result. Returned
126
+ tasks still derive `results` and `lastResult` as compatibility projections.
127
+ TaskChef does not store transcripts, hidden reasoning, or non-semantic events.
128
128
 
129
129
  Delegated tasks created by earlier TaskChef versions remain compatible: their
130
130
  inline executor protocol still parses, self-links, and may use the deprecated
@@ -174,8 +174,9 @@ taskchef dashboard --port 3211
174
174
  ```
175
175
 
176
176
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
177
- linked Codex tasks. List snapshots and SSE events carry only the latest-result
178
- projection; opening task details fetches the full newest-first result history.
177
+ linked Codex tasks. List snapshots and SSE events carry only the latest
178
+ request/result pair; opening task details fetches the full newest-first activity
179
+ timeline.
179
180
  The header shows the running TaskChef package version reported by the same
180
181
  bounded health identity used for compatible-listener checks.
181
182
  Task and result times are relative through 29 days (with minute detail for the
@@ -203,7 +204,7 @@ no task data, credentials, environment variables, process control, or secrets.
203
204
 
204
205
  ![Immutable event-time dashboard notifications](docs/images/notification-event-snapshots.jpg)
205
206
 
206
- ![Task detail result history](docs/images/result-history-dashboard.jpg)
207
+ ![Task detail activity timeline](docs/images/result-history-dashboard.jpg)
207
208
 
208
209
  ## Common recovery
209
210
 
@@ -218,8 +219,8 @@ taskchef doctor
218
219
 
219
220
  `doctor` is read-only. `workspace init` creates missing files and refreshes
220
221
  managed instructions. `workspace migrate` explicitly upgrades supported schema
221
- 4/5 task lines to schema 6 under the workspace lock. It validates the complete
222
- source and converted log before writing, creates an exclusive `tasks.jsonl.pre-v6-*.bak`
222
+ 4/5/6 task lines to schema 7 under the workspace lock. It validates the complete
223
+ source and converted log before writing, creates an exclusive `tasks.jsonl.pre-v7-*.bak`
223
224
  backup, atomically replaces the log, validates the result, and becomes an
224
225
  idempotent no-op after migration. If replacement fails, the original remains
225
226
  or the reported backup can be restored; unsupported or invalid input is rejected
@@ -230,7 +231,7 @@ executor so its first action can retry `link_task`. Do not guess an identity
230
231
  or edit `tasks.jsonl`. If native task creation failed, the record is retained
231
232
  as `failed` with null thread and turn IDs.
232
233
 
233
- Schemas other than 4, 5, and 6 remain unsupported. Retain such a workspace
234
+ Schemas other than 4, 5, 6, and 7 remain unsupported. Retain such a workspace
234
235
  unchanged and create a current workspace; the migration command deliberately
235
236
  does not guess how to convert unknown formats.
236
237
 
package/docs/spec.md CHANGED
@@ -23,7 +23,7 @@ is dated research, not contract.
23
23
  | **Self-linking** | The executor's one-way registration of its own canonical Codex UUIDv7 from `CODEX_THREAD_ID`. |
24
24
  | **Link-pending** | A working task whose `threadId` is null and `updatedBy` is `dispatcher`. |
25
25
  | **Current execution state** | The latest reported executor turn and its `working`, `needs_input`, `completed`, or `failed` status. |
26
- | **Result history** | The ordered collection of every accepted `completed`, `needs_input`, or `failed` per-turn outcome. |
26
+ | **Turn timeline** | The ordered collection pairing each turn's concise request summary with its eventual semantic result. |
27
27
  | **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
28
28
  | **Current turn ID** | The canonical Codex UUIDv7 returned by an exact native read of the linked executor for the turn being reported. |
29
29
  | **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
@@ -69,8 +69,8 @@ Repository URLs MUST canonicalize to `https://github.com/<owner>/<repository>`
69
69
  and be case-insensitively deduplicated.
70
70
 
71
71
  `tasks.jsonl` MUST contain zero or more newline-terminated schema-4, schema-5,
72
- or schema-6 records, one per line. Schemas 4 and 5 are supported migration/read
73
- formats; every new record and state mutation MUST write schema 6. Other schemas
72
+ schema-6, or schema-7 records, one per line. Schemas 4 through 6 are supported
73
+ migration/read formats; every new record and state mutation MUST write schema 7. Other schemas
74
74
  or unsupported fields MUST be rejected without conversion.
75
75
  Reads and writes MUST reject symlinked managed files. Mutations
76
76
  MUST hold the shared workspace lock and replace state atomically; read-only
@@ -82,7 +82,7 @@ Every record MUST contain exactly these fields:
82
82
 
83
83
  | Field | Contract |
84
84
  | --- | --- |
85
- | `schemaVersion` | Integer `6`; schema-4/5 records remain readable until explicit migration or their next mutation. |
85
+ | `schemaVersion` | Integer `7`; schema-4/5/6 records remain readable until explicit migration or their next mutation. |
86
86
  | `id` | Unique safe TaskChef ID; delegation uses a lowercase full UUID. |
87
87
  | `project` | Immutable configured-project snapshot. |
88
88
  | `title` | Non-empty display title. |
@@ -94,13 +94,12 @@ Every record MUST contain exactly these fields:
94
94
  | `turnId` | Null before turn reporting; otherwise the current reported turn. Linked MCP journeys use a canonical Codex UUIDv7. |
95
95
  | `updatedAt` | ISO 8601 timestamp not earlier than `createdAt` or the prior `updatedAt`; clock rollback cannot backdate a transition. |
96
96
  | `updatedBy` | `dispatcher` or `mcp`. |
97
- | `results` | Ordered oldest-first array of `{status, summary, turnId, updatedAt}` semantic results, with unique turn IDs and nondecreasing timestamps. |
97
+ | `turns` | Ordered oldest-first array of `{turnId, requestSummary, startedAt, result}`. `requestSummary` is null only for migrated/compatibility turns; `result` is null only for the latest working turn or is `{status, summary, updatedAt}`. Canonical self-linking turn IDs are unique. A migrated low-level opaque record may retain one final reused ID to represent its legacy completed-result-plus-working-state ambiguity. |
98
98
 
99
- Returned Task objects MUST additionally expose `lastResult` as null for an empty
100
- history or the final `results` entry. `lastResult` MUST be derived and MUST NOT
101
- be persisted in schema 6. It is a compatibility alias for schema-4/5, CLI,
102
- MCP, reporting-skill, and dashboard-list callers, with removal planned for the
103
- next major version after callers migrate to `results.at(-1)`.
99
+ Returned Task objects MUST additionally expose `latestTurn` as null for an empty
100
+ timeline or the final `turns` entry. They MUST derive `results` from completed
101
+ turns and `lastResult` from the final derived result. These projections MUST NOT
102
+ be persisted in schema 7 and remain compatibility aliases for existing callers.
104
103
 
105
104
  Task IDs and non-null thread identities MUST be unique. The immutable intent
106
105
  fields MUST NOT change after recording.
@@ -238,8 +237,8 @@ new preparation values, though it writes no state.
238
237
 
239
238
  **Structured output:** `{ task: Task }`.
240
239
 
241
- The returned task has schema 6, `working`, null summary/turn/thread/lastResult,
242
- an empty `results` array,
240
+ The returned task has schema 7, `working`, null summary/turn/thread/latestTurn/lastResult,
241
+ empty `turns` and derived `results` arrays,
243
242
  `updatedBy: dispatcher`, and equal creation/update timestamps. Duplicate IDs,
244
243
  unknown projects, malformed markers, and invalid input fail. Repeating a
245
244
  successful call is not idempotent; it fails as a duplicate.
@@ -268,7 +267,7 @@ marker, or ineligible state fails.
268
267
  ### `report_state`
269
268
 
270
269
  **Caller:** executor, or dispatcher only for native creation failure.
271
- **Mutation:** replaces the current state atomically and preserves `results`.
270
+ **Mutation:** replaces the current state atomically and preserves `turns`.
272
271
 
273
272
  **Input:**
274
273
 
@@ -279,6 +278,7 @@ marker, or ineligible state fails.
279
278
  | `turnId` | Current canonical Codex UUIDv7 for a linked MCP journey; null only for creation failure. Maximum 256 characters at the MCP boundary. |
280
279
  | `status` | `working`, `needs_input`, `completed`, or `failed`. |
281
280
  | `summary` | Omitted or null for `working`; required non-empty string of at most 2,000 characters otherwise. |
281
+ | `requestSummary` | Concise current request of at most 1,000 characters for `working`; optional for backward compatibility and omitted for semantic states. |
282
282
 
283
283
  **Structured output:** `{ task: Task }`.
284
284
 
@@ -286,10 +286,10 @@ For a linked self-linking journey, `working` MUST identify a turn newer than
286
286
  the current turn and last semantic result. A semantic state MUST match the
287
287
  current working turn. Repeating an identical state is idempotent; conflicting
288
288
  or older state fails. A null-identity record accepts only a fresh executor
289
- creation `failed` state with both IDs null. Success sets the current state and
290
- appends a semantic result to `results`; starting newer work preserves the entire
291
- collection. An identical retry for any settled turn returns success without an
292
- append. A different result for a settled turn, a stale turn, or a semantic
289
+ creation `failed` state with both IDs null. Starting work appends one turn with
290
+ its request and a null result; the semantic report fills that same turn's result.
291
+ An identical retry for any settled turn returns success without an append. A
292
+ different request or result for the same turn, a stale turn, or a semantic
293
293
  result that does not match the active working turn MUST fail.
294
294
 
295
295
  **Annotations:** `readOnlyHint: false`, `destructiveHint: true`,
@@ -299,10 +299,10 @@ result that does not match the active working turn MUST fail.
299
299
 
300
300
  `report_result` retains the prior semantic-only input shape and statuses as a
301
301
  temporary compatibility alias. It implicitly accepts a fresh supplied turn and
302
- stores its semantic result, including for supported schema-4/5 records and
302
+ stores its semantic result in a request-unknown turn, including for supported schema-4/5/6 records and
303
303
  low-level opaque direct records. It does not accept `working`. New executor
304
- instructions MUST use `report_state`. Successful mutation upgrades schema 4/5
305
- to schema 6; unsupported schemas remain rejected.
304
+ instructions MUST use `report_state`. Successful mutation upgrades schema 4/5/6
305
+ to schema 7; unsupported schemas remain rejected.
306
306
 
307
307
  ## Reporting and dashboard
308
308
 
@@ -324,9 +324,9 @@ startup safely. Direct thread navigation
324
324
  MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
325
325
  configured project. Project paths from task history MUST be matched against
326
326
  current configuration before use.
327
- Snapshot and SSE list payloads MUST omit full `results` history and include the
328
- derived latest-result projection. The bounded per-task detail endpoint MAY
329
- return the full validated task so the dialog can render history newest first.
327
+ Snapshot and SSE list payloads MUST omit full `turns` and derived `results`
328
+ history and include `latestTurn`. The bounded per-task detail endpoint MAY
329
+ return the full validated task so the dialog can render the paired timeline newest first.
330
330
  Dashboard notifications MUST capture an immutable event-time projection of the
331
331
  task title, lifecycle state and event, turn ID when present, event timestamp,
332
332
  and relevant concise summary. Rendering MUST NOT resolve historical notice text
@@ -358,12 +358,12 @@ its displayed summary when present, event time, and missing-task state.
358
358
 
359
359
  ## Task-log migration
360
360
 
361
- `workspace migrate` MUST explicitly convert every supported schema-4/5 record
362
- under the shared lock. A legacy `lastResult`, or a schema-4 current semantic
363
- state, becomes zero or one initial `results` entry. Migration MUST validate the
364
- complete source and complete schema-6 candidate before changing the task log,
361
+ `workspace migrate` MUST explicitly convert every supported schema-4/5/6 record
362
+ under the shared lock. Each legacy semantic result becomes a request-unknown
363
+ completed turn; a newer working state becomes a final unfinished turn. Migration
364
+ MUST validate the complete source and complete schema-7 candidate before changing the task log,
365
365
  create and read back an exclusive recovery backup, atomically replace the log,
366
- and validate the installed result. A fully schema-6 log MUST be an idempotent
366
+ and validate the installed result. A fully schema-7 log MUST be an idempotent
367
367
  no-op without another backup. Invalid/unsupported input MUST remain untouched;
368
368
  failures after backup creation MUST report the backup path and MUST never
369
369
  partially rewrite individual lines.
package/docs/workflows.md CHANGED
@@ -89,7 +89,7 @@ sequenceDiagram
89
89
  D->>D: Choose one configured and native project
90
90
  D->>M: record_task(id, project, title, instruction, null)
91
91
  M->>W: recordTask()
92
- W->>W: Lock, validate, append schema-6 snapshot
92
+ W->>W: Lock, validate, append schema-7 snapshot
93
93
  W-->>M: working link-pending task
94
94
  M-->>D: task
95
95
  D->>C: Create executor with marked instruction
@@ -116,8 +116,8 @@ semantic callbacks.
116
116
  ## State reporting
117
117
 
118
118
  The executor obtains the turn identity from an exact native read of its own
119
- linked task. `report_state` records live turn state while preserving the ordered
120
- semantic result history.
119
+ linked task. `report_state` records live turn state as a paired request/result
120
+ timeline.
121
121
 
122
122
  ```mermaid
123
123
  sequenceDiagram
@@ -128,15 +128,15 @@ sequenceDiagram
128
128
  participant W as workspace.js
129
129
  E->>C: Exact read of linked executor
130
130
  C-->>E: Current turn ID
131
- E->>M: report_state(..., working, null)
131
+ E->>M: report_state(..., working, requestSummary)
132
132
  M->>W: reportTaskState()
133
- W->>W: Store current turn and preserve results
133
+ W->>W: Append turn with request and null result
134
134
  E->>E: Work, finish, or reach semantic decision
135
135
  E->>M: report_state(..., semantic status, summary)
136
136
  M->>W: reportTaskState()
137
137
  W->>W: Lock and validate identity and freshness
138
138
  alt Same current working turn
139
- W->>W: Append semantic result and derive lastResult
139
+ W->>W: Fill that turn's result and derive compatibility results
140
140
  W-->>M: Updated task
141
141
  M-->>E: Recorded result
142
142
  else Same turn and same result
@@ -171,11 +171,11 @@ sequenceDiagram
171
171
  U->>E: Provide decision
172
172
  E->>C: Read exact executor after follow-up
173
173
  C-->>E: turnB
174
- E->>M: report_state(..., turnB, working, null)
174
+ E->>M: report_state(..., turnB, working, requestB)
175
175
  M->>W: reportTaskState()
176
- W->>W: Require turnB greater and preserve results
177
- W-->>M: working snapshot plus history A
178
- M-->>E: working snapshot plus history A
176
+ W->>W: Require turnB greater and append request B
177
+ W-->>M: working snapshot plus paired timeline
178
+ M-->>E: working snapshot plus paired timeline
179
179
  E->>M: report_state(..., turnB, completed, summaryB)
180
180
  M->>W: reportTaskState()
181
181
  W-->>M: completed snapshot plus result B
@@ -263,7 +263,7 @@ sequenceDiagram
263
263
  F-->>D: Filesystem change
264
264
  D->>F: Bounded read from one descriptor
265
265
  D->>D: Validate current schema and sort
266
- D-->>B: Compact SSE snapshot without results
266
+ D-->>B: Compact SSE snapshot with latestTurn only
267
267
  B->>D: GET task detail on demand
268
268
  D-->>B: Full validated history
269
269
  B->>D: Open task action
@@ -279,8 +279,8 @@ The dashboard binds to `127.0.0.1`, has no shared session state, limits task
279
279
  count, file size, result count, and display fields, and checks origin/authority
280
280
  for stateful local actions. Its bounded identity endpoint contains no task data
281
281
  or secrets. The monitor already validates the complete log, but
282
- snapshot/SSE list projections omit `results` so repeated updates do not resend
283
- unnecessary history. A read-only per-task endpoint returns full history only
282
+ snapshot/SSE list projections omit `turns` and derived `results` so repeated
283
+ updates do not resend unnecessary history. A read-only per-task endpoint returns the full timeline only
284
284
  when the dialog opens. Historical project paths are untrusted until matched
285
285
  against current configuration.
286
286
 
@@ -306,14 +306,14 @@ all, and ordinary rerendering do not re-announce retained history. Toast action
306
306
  labels remain concise while `aria-describedby` connects the visible summary,
307
307
  event time, and missing-task explanation for assistive technology.
308
308
 
309
- ## Schema 4/5 migration
309
+ ## Schema 4/5/6 migration
310
310
 
311
311
  `taskchef workspace migrate` acquires the same workspace lock as lifecycle
312
- writers, validates the complete legacy log, converts each schema-4/5 latest
313
- result into zero or one initial schema-6 `results` entry, then validates the
312
+ writers, validates the complete legacy log, converts schema-4/5/6 results into
313
+ request-unknown completed turns and preserves a newer working turn, then validates the
314
314
  complete candidate. Before replacement it writes and reads back an exclusive
315
- `tasks.jsonl.pre-v6-*.bak` file. The task log is replaced atomically and
316
- validated again. A second run sees only schema 6 and returns unchanged without
315
+ `tasks.jsonl.pre-v7-*.bak` file. The task log is replaced atomically and
316
+ validated again. A second run sees only schema 7 and returns unchanged without
317
317
  another backup. Unsupported or malformed input fails before backup/rewrite;
318
318
  after a later filesystem failure, the reported backup is the recovery source.
319
319
 
@@ -330,7 +330,8 @@ summary is cryptographically authenticated; this is a local single-user trust
330
330
  model. Managed files, instructions, project snapshots, MCP inputs, and dashboard
331
331
  requests are validated at every action boundary.
332
332
 
333
- Configuration schema 2 and task schemas 4, 5, and 6 are accepted. Schemas 4/5
334
- are read/migration compatibility until an explicit migration or lifecycle
335
- mutation upgrades each record to schema 6. Schema 6 persists `results` only and
336
- derives `lastResult` for compatibility. Other schemas are rejected without rewrite.
333
+ Configuration schema 2 and task schemas 4, 5, 6, and 7 are accepted. Schemas
334
+ 4/5/6 are read/migration compatibility until an explicit migration or lifecycle
335
+ mutation upgrades each record to schema 7. Schema 7 persists `turns` and derives
336
+ `results`, `lastResult`, and `latestTurn` for compact compatibility. Other schemas
337
+ are rejected without rewrite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.8.0",
3
+ "version": "7.9.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -31,7 +31,8 @@ Complete this lifecycle setup before substantive assignment work:
31
31
  3. Read this exact Codex thread natively and obtain the current turn ID. Do not
32
32
  infer it or reuse an earlier turn ID.
33
33
  4. Call TaskChef `report_state` with the marked task ID, self-linked thread ID,
34
- current turn ID, `status: working`, and an omitted or null summary.
34
+ current turn ID, `status: working`, an omitted or null result summary, and a
35
+ concise `requestSummary` describing this turn's assignment or follow-up.
35
36
 
36
37
  If `CODEX_THREAD_ID`, exact native thread reading, or a required TaskChef tool
37
38
  is unavailable, or if linking or the working-state report fails, report the
@@ -56,7 +57,7 @@ turn, run the start lifecycle with its new current turn ID and report that
56
57
  turn's actual outcome. Say reporting failures visibly instead of claiming a
57
58
  tracked outcome.
58
59
 
59
- Summaries must omit secrets, transcripts, raw command output, hidden reasoning,
60
+ Request and result summaries must omit secrets, transcripts, raw command output, hidden reasoning,
60
61
  and unnecessary personal data. Identical lifecycle retries are safe; never
61
62
  replace a same-turn report with different content or let an older turn
62
63
  overwrite newer state.
@@ -44,15 +44,14 @@ all deterministic task-log operations.
44
44
  detailed read. Native approval is live Codex state, not a `needs_input`
45
45
  callback. An inactive status never proves semantic completion; it only
46
46
  permits a trustworthy cached MCP result to stand.
47
- 4. In schema 6, treat `status`, `turnId`, and `updatedAt` as the latest reported
48
- execution state and treat `results` as the ordered semantic history.
49
- `lastResult` is the derived compatibility alias for `results.at(-1)`; prefer
50
- the collection when full history matters and the alias for compact latest-state
51
- reporting. A `working` state with a non-null `lastResult` means a newer executor
52
- turn started after that result; show the prior result as history, not as the
53
- current outcome. Treat a failed `lastResult` with null thread and turn IDs as
54
- a fresh executor-creation failure. Schema 4/5 snapshots normalize zero or one
55
- semantic result into `results` and `lastResult` without rewriting their log line.
47
+ 4. In schema 7, treat `turns` as the ordered request/result timeline. Each turn
48
+ pairs a bounded `requestSummary` with either one semantic `result` or null
49
+ while work is in progress. `latestTurn` is the compact current pair.
50
+ `results` and `lastResult` remain derived compatibility projections; do not
51
+ pair the latest request with an earlier result. Treat a failed result with
52
+ null thread and turn IDs as a fresh executor-creation failure. Schema 4/5/6
53
+ snapshots normalize their semantic results into legacy turns with a null
54
+ request summary without rewriting their log line.
56
55
  No live read is possible or needed for that creation failure. When identity is certain and
57
56
  metadata says the thread is inactive, trust the latest semantic result by
58
57
  default in a broad overview unless a newer working state makes it historical.
package/src/cli.js CHANGED
@@ -183,10 +183,10 @@ function taskDetails(task) {
183
183
  `Updated by: ${singleLineDetail(task.updatedBy ?? "-")}`,
184
184
  `Task ID: ${singleLineDetail(task.id)}`,
185
185
  `Thread ID: ${singleLineDetail(task.threadId ?? "-")}`,
186
- `Result count: ${task.results.length}`,
187
- "Result history (newest first):",
188
- ...[...task.results].reverse().map((result) => (
189
- `- ${singleLineDetail(result.updatedAt)} | ${singleLineDetail(result.status)} | turn ${singleLineDetail(result.turnId ?? "-")} | ${singleLineDetail(result.summary)}`
186
+ `Turn count: ${task.turns.length}`,
187
+ "Activity timeline (newest first):",
188
+ ...[...task.turns].reverse().map((turn) => (
189
+ `- ${singleLineDetail(turn.startedAt)} | ${singleLineDetail(turn.result?.status ?? "working")} | turn ${singleLineDetail(turn.turnId ?? "-")} | request: ${singleLineDetail(turn.requestSummary ?? "not recorded")} | result: ${singleLineDetail(turn.result?.summary ?? "in progress")}`
190
190
  )),
191
191
  "Instruction:",
192
192
  task.instruction,
@@ -3,6 +3,8 @@ import {
3
3
  dismissNotification,
4
4
  findCurrentTask,
5
5
  KNOWN_TASK_STATUSES,
6
+ latestTurnPresentation,
7
+ mergeProjectedTurns,
6
8
  nextDateFilterRefreshDelay,
7
9
  notificationDismissLabel,
8
10
  notificationOpenLabel,
@@ -222,34 +224,44 @@ function detailRow(term, value) {
222
224
  return [dt, dd];
223
225
  }
224
226
 
225
- function resultHistory(taskId, results) {
226
- if (results.length === 0) {
227
+ function turnTimeline(task) {
228
+ if (task.turns.length === 0) {
227
229
  const empty = document.createElement("p");
228
230
  empty.className = "result-history-empty";
229
- empty.textContent = "No semantic result has been reported yet.";
231
+ empty.textContent = "No executor turn has been reported yet.";
230
232
  return [empty];
231
233
  }
232
- return [...results].reverse().map((result, index) => {
234
+ return [...task.turns].reverse().map((turn, index) => {
233
235
  const item = document.createElement("article");
234
236
  item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
235
237
  const header = document.createElement("div");
236
238
  header.className = "result-history-header";
237
239
  const status = document.createElement("span");
238
- status.className = `status status-${result.status}`;
239
- status.textContent = result.status.replaceAll("_", " ");
240
- const resultKey = result.turnId ?? `${result.status}:${index}`;
241
- const timestamp = timestampControl(result.updatedAt, {
242
- accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
243
- key: `detail:${taskId}:result:${resultKey}`,
240
+ const turnStatus = turn.result?.status ?? "working";
241
+ status.className = `status status-${turnStatus}`;
242
+ status.textContent = turnStatus.replaceAll("_", " ");
243
+ const turnKey = turn.turnId ?? `no-turn:${index}`;
244
+ const timestamp = timestampControl(turn.result?.updatedAt ?? turn.startedAt, {
245
+ accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
246
+ key: `detail:${task.id}:turn:${turnKey}`,
244
247
  });
245
248
  header.append(status, timestamp);
246
- const summary = document.createElement("p");
247
- summary.className = "preserve-lines";
248
- summary.textContent = result.summary;
249
- const turn = document.createElement("p");
250
- turn.className = "result-history-turn";
251
- turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
252
- item.append(header, summary, turn);
249
+ const requestLabel = document.createElement("h4");
250
+ requestLabel.textContent = "Request";
251
+ const request = document.createElement("p");
252
+ request.className = "preserve-lines";
253
+ request.textContent = turn.requestSummary ?? "Request not recorded by this TaskChef version.";
254
+ const resultLabel = document.createElement("h4");
255
+ resultLabel.textContent = "Result";
256
+ const result = document.createElement("p");
257
+ result.className = "preserve-lines";
258
+ result.textContent = turn.result?.summary ?? "In progress";
259
+ const turnMetadata = document.createElement("p");
260
+ turnMetadata.className = "result-history-turn";
261
+ turnMetadata.textContent = turn.turnId
262
+ ? `Turn ${turn.turnId}`
263
+ : "No turn ID (creation failure)";
264
+ item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
253
265
  return item;
254
266
  });
255
267
  }
@@ -260,12 +272,13 @@ function renderDialog(task) {
260
272
  : null;
261
273
  const detailedTask = {
262
274
  ...task,
275
+ turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
263
276
  results: task.results ?? preservedResults ?? [],
264
277
  };
265
278
  state.selectedTask = detailedTask;
266
279
  elements.dialogProject.textContent = task.project.name;
267
280
  elements.dialogTitle.textContent = task.title;
268
- elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
281
+ elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
269
282
  elements.dialogInstruction.textContent = task.instruction;
270
283
  elements.copyThreadId.disabled = !task.threadId;
271
284
  elements.dialogMetadata.replaceChildren(
@@ -313,7 +326,7 @@ async function openDialog(task) {
313
326
  }
314
327
  } catch {
315
328
  if (state.selectedTask?.id === task.id) {
316
- showMessage("Task result history is temporarily unavailable.");
329
+ showMessage("Task activity timeline is temporarily unavailable.");
317
330
  }
318
331
  }
319
332
  }
@@ -337,7 +350,18 @@ function taskCard(task) {
337
350
  project.textContent = task.project.name;
338
351
  const summary = document.createElement("p");
339
352
  summary.className = "task-summary";
340
- summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
353
+ const latest = latestTurnPresentation(task);
354
+ const requestLabel = document.createElement("strong");
355
+ requestLabel.textContent = "Request";
356
+ const request = document.createElement("span");
357
+ request.className = "preserve-lines";
358
+ request.textContent = latest.requestSummary;
359
+ const resultLabel = document.createElement("strong");
360
+ resultLabel.textContent = "Result";
361
+ const result = document.createElement("span");
362
+ result.className = "preserve-lines";
363
+ result.textContent = latest.resultSummary;
364
+ summary.replaceChildren(requestLabel, request, resultLabel, result);
341
365
  const time = timestampControl(
342
366
  task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
343
367
  {
@@ -94,7 +94,7 @@
94
94
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
95
95
  </div>
96
96
  <section>
97
- <h3>Result history</h3>
97
+ <h3>Activity timeline</h3>
98
98
  <div id="dialog-results" class="result-history"></div>
99
99
  </section>
100
100
  <section>
@@ -31,6 +31,37 @@ export function taskStatusLabel(task) {
31
31
  return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
32
32
  }
33
33
 
34
+ export function latestTurnPresentation(task) {
35
+ const turn = task.latestTurn ?? null;
36
+ const result = turn?.result ?? null;
37
+ const requestSummary = turn?.requestSummary
38
+ ?? (turn ? "Request not recorded by this TaskChef version." : task.title);
39
+ return {
40
+ turnId: turn?.turnId ?? task.turnId ?? null,
41
+ startedAt: turn?.startedAt ?? task.updatedAt ?? task.createdAt ?? null,
42
+ requestSummary,
43
+ resultStatus: result?.status ?? (task.status === "working" ? "working" : task.status),
44
+ resultSummary: result?.summary
45
+ ?? (task.status === "working"
46
+ ? "In progress"
47
+ : task.lastResult?.summary ?? task.summary ?? "No result reported."),
48
+ resultUpdatedAt: result?.updatedAt ?? null,
49
+ };
50
+ }
51
+
52
+ export function mergeProjectedTurns(task, preservedTurns = []) {
53
+ if (Array.isArray(task.turns)) return task.turns;
54
+ if (!task.latestTurn) return preservedTurns;
55
+ const turns = [...preservedTurns];
56
+ const lastIndex = turns.length - 1;
57
+ if (lastIndex >= 0 && turns[lastIndex].turnId === task.latestTurn.turnId) {
58
+ turns[lastIndex] = task.latestTurn;
59
+ } else {
60
+ turns.push(task.latestTurn);
61
+ }
62
+ return turns;
63
+ }
64
+
34
65
  export function notificationTitle(notification) {
35
66
  return NOTIFICATION_TITLES.get(notification.event) ?? "Task updated";
36
67
  }
@@ -79,7 +79,8 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
79
79
  .task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
80
80
  .task-project { margin: 3px 0 12px; font-size: 0.86rem; }
81
81
  .task-title, .task-summary { overflow-wrap: anywhere; }
82
- .task-summary { max-width: 78ch; margin-bottom: 14px; }
82
+ .task-summary { display: grid; max-width: 78ch; margin-bottom: 14px; gap: 3px; }
83
+ .task-summary strong { margin-top: 5px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
83
84
  time, .timestamp-missing { font-size: 0.78rem; }
84
85
  .timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
85
86
  .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
@@ -133,6 +134,7 @@ dialog section + section { margin-top: 24px; }
133
134
  .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
134
135
  .result-history-item p { margin-bottom: 7px; }
135
136
  .result-history-item p:last-child { margin-bottom: 0; }
137
+ .result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
136
138
  .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
137
139
  .result-history-empty { margin: 0; }
138
140
  pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
package/src/dashboard.js CHANGED
@@ -139,6 +139,17 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
139
139
  boundedText(task.turnId, 512, `${name} turn ID`);
140
140
  boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
141
141
  boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
142
+ boundedText(task.latestTurn?.requestSummary, 1_000, `${name} latest request summary`);
143
+ boundedText(task.latestTurn?.turnId, 512, `${name} latest turn ID`);
144
+ const turns = task.turns ?? [];
145
+ if (turns.length > 10_000) {
146
+ throw new Error(`${name} has too many turns for the dashboard`);
147
+ }
148
+ for (const [turnIndex, turn] of turns.entries()) {
149
+ boundedText(turn.requestSummary, 1_000, `${name} turn ${turnIndex + 1} request summary`);
150
+ boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
151
+ boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
152
+ }
142
153
  const results = task.results ?? [];
143
154
  if (results.length > 10_000) {
144
155
  throw new Error(`${name} has too many results for the dashboard`);
@@ -160,7 +171,7 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
160
171
  }
161
172
 
162
173
  function taskListProjection(task) {
163
- const { results: _results, ...projection } = task;
174
+ const { turns: _turns, results: _results, ...projection } = task;
164
175
  return projection;
165
176
  }
166
177
 
package/src/delegation.js CHANGED
@@ -5,7 +5,7 @@ export const EXECUTOR_OWNERSHIP_PARAGRAPH = "This task owns the delegated assign
5
5
  /** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
6
6
  export const EXECUTOR_LINK_PARAGRAPH = "Before any other work, read this executor's own durable Codex thread ID from the current task's CODEX_THREAD_ID environment value and call the TaskChef link_task MCP tool with that thread ID and the marked TaskChef task ID. Never use CODEX_SESSION_ID or the parent or delegator thread ID. If linking fails, CODEX_THREAD_ID is unavailable, or the tool is unavailable, report the failure visibly and retry on a later turn; do not guess an identity or continue substantive work while the task is link-pending.";
7
7
  /** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
8
- export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working, and summary omitted or null. link_task remains the first TaskChef action on the initial turn; do not report working before identity is linked. Never reuse a prior turn ID after a follow-up.";
8
+ export const EXECUTOR_WORKING_PARAGRAPH = "After a successful initial link, and at the start of every follow-up turn before substantive work, read this exact Codex thread natively to obtain the current turn ID and call TaskChef report_state with the marked task ID, the self-linked thread ID, that current turn ID, status working, summary omitted or null, and a concise requestSummary for this turn. link_task remains the first TaskChef action on the initial turn; do not report working before identity is linked. Never reuse a prior turn ID after a follow-up.";
9
9
  /** @deprecated Historical v7 inline-prompt snapshot. New delegations use taskchef-executor. */
10
10
  export const EXECUTOR_RESULT_PARAGRAPH = "Before ending, read this exact Codex thread again and call TaskChef report_state for the same current working turn with status completed, needs_input, or failed and a concise summary. Use needs_input only for a semantic decision or information the user must provide; a native approval prompt is live Codex state, not a TaskChef result. Do not include secrets, transcripts, or raw command output.";
11
11
  export const EXECUTOR_SKILL_INVOCATION = "Use $taskchef-executor to execute and report this delegated TaskChef assignment.";
package/src/mcp.js CHANGED
@@ -21,7 +21,7 @@ const projectSchema = z.object({
21
21
  });
22
22
 
23
23
  const taskSchema = z.object({
24
- schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
24
+ schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
25
25
  id: z.string(),
26
26
  project: projectSchema,
27
27
  title: z.string(),
@@ -33,6 +33,26 @@ const taskSchema = z.object({
33
33
  turnId: z.string().nullable(),
34
34
  updatedAt: z.string(),
35
35
  updatedBy: z.enum(["dispatcher", "mcp"]),
36
+ turns: z.array(z.object({
37
+ turnId: z.string().nullable(),
38
+ requestSummary: z.string().nullable(),
39
+ startedAt: z.string(),
40
+ result: z.object({
41
+ status: z.enum(["needs_input", "completed", "failed"]),
42
+ summary: z.string(),
43
+ updatedAt: z.string(),
44
+ }).nullable(),
45
+ })),
46
+ latestTurn: z.object({
47
+ turnId: z.string().nullable(),
48
+ requestSummary: z.string().nullable(),
49
+ startedAt: z.string(),
50
+ result: z.object({
51
+ status: z.enum(["needs_input", "completed", "failed"]),
52
+ summary: z.string(),
53
+ updatedAt: z.string(),
54
+ }).nullable(),
55
+ }).nullable(),
36
56
  results: z.array(z.object({
37
57
  status: z.enum(["needs_input", "completed", "failed"]),
38
58
  summary: z.string(),
@@ -203,13 +223,14 @@ export function createTaskChefMcpServer({
203
223
  {
204
224
  title: "Report TaskChef state",
205
225
  description:
206
- "Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary. Exact retries are idempotent; stale or mismatched turns are rejected.",
226
+ "Report this self-linked executor turn's lifecycle state. Use working before substantive work in a newly linked or follow-up turn, with summary omitted or null and a concise requestSummary. Before ending the same turn, report needs_input, completed, or failed with a concise semantic summary and requestSummary omitted. Exact retries are idempotent; stale or mismatched turns are rejected.",
207
227
  inputSchema: {
208
228
  taskId: z.string().min(1),
209
229
  threadId: z.string().min(1).nullable(),
210
230
  turnId: z.string().min(1).max(256).nullable(),
211
231
  status: z.enum(["working", "needs_input", "completed", "failed"]),
212
232
  summary: z.string().min(1).max(2_000).nullable().optional(),
233
+ requestSummary: z.string().min(1).max(1_000).nullable().optional(),
213
234
  },
214
235
  outputSchema: { task: taskSchema },
215
236
  annotations: {
package/src/workspace.js CHANGED
@@ -38,8 +38,8 @@ const DISPATCH_FILE_NAME = "tasks.jsonl";
38
38
  const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
39
39
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
40
40
  const CURRENT_CONFIG_SCHEMA_VERSION = 2;
41
- const CURRENT_TASK_SCHEMA_VERSION = 6;
42
- const PREVIOUS_TASK_SCHEMA_VERSION = 5;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 7;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 6;
43
43
  const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
44
44
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
45
45
  const PROJECT_FIELDS = new Set([
@@ -65,7 +65,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
65
65
  "updatedBy",
66
66
  ]);
67
67
  const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
68
- const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
68
+ const SCHEMA_6_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
69
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
69
70
  const RECORD_DISPATCH_FIELDS = new Set([
70
71
  "id",
71
72
  "project",
@@ -77,7 +78,10 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
77
78
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
78
79
  const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
79
80
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
81
+ const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
80
82
  const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
83
+ const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
84
+ const TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
81
85
 
82
86
  function requireExactFields(value, fields, name) {
83
87
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -224,7 +228,7 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
224
228
  const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
225
229
  const content = await readFile(dispatchPath, "utf8");
226
230
  const appended = dispatches
227
- .map((dispatch) => `${JSON.stringify(schema6Task(dispatch))}\n`)
231
+ .map((dispatch) => `${JSON.stringify(schema7Task(dispatch))}\n`)
228
232
  .join("");
229
233
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
230
234
  }
@@ -684,6 +688,7 @@ export async function removeProject(workspaceRoot, name) {
684
688
  async function validateDispatchShape(dispatch, name = "task") {
685
689
  const supportedVersions = [
686
690
  FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
691
+ 5,
687
692
  PREVIOUS_TASK_SCHEMA_VERSION,
688
693
  CURRENT_TASK_SCHEMA_VERSION,
689
694
  ];
@@ -695,6 +700,8 @@ async function validateDispatchShape(dispatch, name = "task") {
695
700
  dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
696
701
  ? DISPATCH_FIELDS
697
702
  : dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
703
+ ? SCHEMA_6_DISPATCH_FIELDS
704
+ : dispatch.schemaVersion === 5
698
705
  ? SCHEMA_5_DISPATCH_FIELDS
699
706
  : STATEFUL_DISPATCH_FIELDS,
700
707
  name,
@@ -735,20 +742,77 @@ async function validateDispatchShape(dispatch, name = "task") {
735
742
  }
736
743
  return normalizedResult;
737
744
  };
738
- let results = [];
745
+ const normalizeTurnResult = (result, resultName) => {
746
+ if (result === null) return null;
747
+ requireExactFields(result, TURN_RESULT_FIELDS, resultName);
748
+ const normalizedResult = {
749
+ status: requireEnum(result.status, RESULT_STATUSES, `${resultName}.status`),
750
+ summary: optionalString(result.summary, `${resultName}.summary`, {
751
+ maxLength: MAX_RESULT_SUMMARY_LENGTH,
752
+ }),
753
+ updatedAt: requireTimestamp(result.updatedAt, `${resultName}.updatedAt`),
754
+ };
755
+ if (normalizedResult.summary === null) {
756
+ throw new Error(`${resultName}.summary must be a non-empty string`);
757
+ }
758
+ return normalizedResult;
759
+ };
760
+ const normalizeTurn = (turn, turnName) => {
761
+ requireExactFields(turn, TURN_FIELDS, turnName);
762
+ return {
763
+ turnId: optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 }),
764
+ requestSummary: optionalString(turn.requestSummary, `${turnName}.requestSummary`, {
765
+ maxLength: MAX_REQUEST_SUMMARY_LENGTH,
766
+ }),
767
+ startedAt: requireTimestamp(turn.startedAt, `${turnName}.startedAt`),
768
+ result: normalizeTurnResult(turn.result, `${turnName}.result`),
769
+ };
770
+ };
771
+ let legacyResults = [];
772
+ let turns = [];
739
773
  if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
774
+ if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
775
+ turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
776
+ } else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
740
777
  if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
741
- results = dispatch.results.map((result, index) => (
778
+ legacyResults = dispatch.results.map((result, index) => (
742
779
  normalizeResult(result, `${name}.results[${index}]`)
743
780
  ));
744
- } else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
781
+ } else if (dispatch.schemaVersion === 5) {
745
782
  if (dispatch.lastResult !== null) {
746
- results = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
783
+ legacyResults = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
747
784
  }
748
785
  } else if (RESULT_STATUSES.has(status)) {
749
- results = [{ status, summary, turnId, updatedAt }];
786
+ legacyResults = [{ status, summary, turnId, updatedAt }];
750
787
  }
788
+ if (dispatch.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
789
+ turns = legacyResults.map((result) => ({
790
+ turnId: result.turnId,
791
+ requestSummary: null,
792
+ startedAt: result.updatedAt,
793
+ result: {
794
+ status: result.status,
795
+ summary: result.summary,
796
+ updatedAt: result.updatedAt,
797
+ },
798
+ }));
799
+ if (
800
+ status === "working"
801
+ && turnId !== null
802
+ && (
803
+ turns.at(-1)?.turnId !== turnId
804
+ || turns.at(-1)?.result !== null
805
+ )
806
+ ) {
807
+ turns.push({ turnId, requestSummary: null, startedAt: updatedAt, result: null });
808
+ }
809
+ }
810
+ const results = turns.flatMap((turn) => turn.result === null ? [] : [{
811
+ ...turn.result,
812
+ turnId: turn.turnId,
813
+ }]);
751
814
  const lastResult = results.at(-1) ?? null;
815
+ const latestTurn = turns.at(-1) ?? null;
752
816
  const normalized = {
753
817
  schemaVersion: dispatch.schemaVersion,
754
818
  id,
@@ -764,6 +828,8 @@ async function validateDispatchShape(dispatch, name = "task") {
764
828
  turnId,
765
829
  updatedAt,
766
830
  updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
831
+ turns,
832
+ latestTurn,
767
833
  results,
768
834
  lastResult,
769
835
  };
@@ -773,14 +839,20 @@ async function validateDispatchShape(dispatch, name = "task") {
773
839
  if (normalized.turnId !== null) {
774
840
  normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
775
841
  }
776
- for (const [index, result] of normalized.results.entries()) {
777
- if (result.turnId != null) {
778
- result.turnId = normalizeCodexThreadId(
779
- result.turnId,
780
- `${name}.results[${index}].turnId`,
842
+ for (const [index, turn] of normalized.turns.entries()) {
843
+ if (turn.turnId != null) {
844
+ turn.turnId = normalizeCodexThreadId(
845
+ turn.turnId,
846
+ `${name}.turns[${index}].turnId`,
781
847
  );
782
848
  }
783
849
  }
850
+ normalized.results = normalized.turns.flatMap((turn) => turn.result === null ? [] : [{
851
+ ...turn.result,
852
+ turnId: turn.turnId,
853
+ }]);
854
+ normalized.lastResult = normalized.results.at(-1) ?? null;
855
+ normalized.latestTurn = normalized.turns.at(-1) ?? null;
784
856
  }
785
857
  if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
786
858
  if (normalized.threadId === null) {
@@ -814,7 +886,7 @@ async function validateDispatchShape(dispatch, name = "task") {
814
886
  if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
815
887
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
816
888
  }
817
- if (normalized.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION) {
889
+ if (normalized.schemaVersion >= 5) {
818
890
  if (RESULT_STATUSES.has(normalized.status)) {
819
891
  if (
820
892
  normalized.lastResult === null
@@ -850,32 +922,76 @@ async function validateDispatchShape(dispatch, name = "task") {
850
922
  throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
851
923
  }
852
924
  }
853
- const seenResultTurns = new Set();
925
+ if (normalized.status === "working") {
926
+ if (normalized.turnId === null) {
927
+ if (normalized.turns.length !== 0) {
928
+ throw new Error(`${name}.turns must be empty before the first working turn`);
929
+ }
930
+ } else if (
931
+ normalized.latestTurn === null
932
+ || normalized.latestTurn.turnId !== normalized.turnId
933
+ || normalized.latestTurn.result !== null
934
+ ) {
935
+ throw new Error(`${name}.latestTurn must match the current working turn`);
936
+ }
937
+ }
938
+ if (RESULT_STATUSES.has(normalized.status)) {
939
+ if (
940
+ normalized.latestTurn === null
941
+ || normalized.latestTurn.turnId !== normalized.turnId
942
+ || normalized.latestTurn.result === null
943
+ || normalized.latestTurn.result.status !== normalized.status
944
+ || normalized.latestTurn.result.summary !== normalized.summary
945
+ || normalized.latestTurn.result.updatedAt !== normalized.updatedAt
946
+ ) {
947
+ throw new Error(`${name}.latestTurn must match the current semantic state`);
948
+ }
949
+ }
950
+ const seenTurnIds = new Set();
854
951
  const nullTurnKey = Symbol("null turn");
855
- for (const [index, result] of normalized.results.entries()) {
856
- const turnKey = result.turnId ?? nullTurnKey;
857
- if (seenResultTurns.has(turnKey)) {
858
- throw new Error(`${name}.results contains duplicate turnId: ${result.turnId ?? "null"}`);
952
+ for (const [index, turn] of normalized.turns.entries()) {
953
+ const turnKey = turn.turnId ?? nullTurnKey;
954
+ const isLegacyOpaqueWorkingReuse = (
955
+ !isSelfLinkingRecord
956
+ && normalized.status === "working"
957
+ && index === normalized.turns.length - 1
958
+ && turn.result === null
959
+ && index > 0
960
+ && normalized.turns[index - 1].turnId === turn.turnId
961
+ && normalized.turns[index - 1].result !== null
962
+ );
963
+ if (seenTurnIds.has(turnKey) && !isLegacyOpaqueWorkingReuse) {
964
+ throw new Error(`${name}.turns contains duplicate turnId: ${turn.turnId ?? "null"}`);
859
965
  }
860
- seenResultTurns.add(turnKey);
861
- if (Date.parse(result.updatedAt) < Date.parse(normalized.createdAt)) {
862
- throw new Error(`${name}.results[${index}].updatedAt must not be earlier than createdAt`);
966
+ seenTurnIds.add(turnKey);
967
+ if (Date.parse(turn.startedAt) < Date.parse(normalized.createdAt)) {
968
+ throw new Error(`${name}.turns[${index}].startedAt must not be earlier than createdAt`);
863
969
  }
864
- if (Date.parse(result.updatedAt) > Date.parse(normalized.updatedAt)) {
865
- throw new Error(`${name}.results[${index}].updatedAt must not be later than updatedAt`);
970
+ if (Date.parse(turn.startedAt) > Date.parse(normalized.updatedAt)) {
971
+ throw new Error(`${name}.turns[${index}].startedAt must not be later than updatedAt`);
866
972
  }
867
973
  if (
868
974
  index > 0
869
- && Date.parse(result.updatedAt) < Date.parse(normalized.results[index - 1].updatedAt)
975
+ && Date.parse(turn.startedAt) < Date.parse(normalized.turns[index - 1].startedAt)
870
976
  ) {
871
- throw new Error(`${name}.results must be ordered by updatedAt`);
977
+ throw new Error(`${name}.turns must be ordered by startedAt`);
872
978
  }
873
979
  if (
874
980
  isSelfLinkingRecord
875
981
  && index > 0
876
- && result.turnId <= normalized.results[index - 1].turnId
982
+ && turn.turnId <= normalized.turns[index - 1].turnId
877
983
  ) {
878
- throw new Error(`${name}.results must be ordered by turnId`);
984
+ throw new Error(`${name}.turns must be ordered by turnId`);
985
+ }
986
+ if (turn.result !== null) {
987
+ if (Date.parse(turn.result.updatedAt) < Date.parse(turn.startedAt)) {
988
+ throw new Error(`${name}.turns[${index}].result.updatedAt must not be earlier than startedAt`);
989
+ }
990
+ if (Date.parse(turn.result.updatedAt) > Date.parse(normalized.updatedAt)) {
991
+ throw new Error(`${name}.turns[${index}].result.updatedAt must not be later than updatedAt`);
992
+ }
993
+ } else if (index !== normalized.turns.length - 1) {
994
+ throw new Error(`${name}.turns may contain an unfinished turn only at the end`);
879
995
  }
880
996
  }
881
997
  if (
@@ -960,12 +1076,17 @@ export async function parseTaskLogContent(workspaceRoot, content) {
960
1076
  return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
961
1077
  }
962
1078
 
963
- function schema6Task(dispatch, patch = {}) {
964
- const { lastResult: _lastResult, ...persisted } = dispatch;
1079
+ function schema7Task(dispatch, patch = {}) {
1080
+ const {
1081
+ latestTurn: _latestTurn,
1082
+ results: _results,
1083
+ lastResult: _lastResult,
1084
+ ...persisted
1085
+ } = dispatch;
965
1086
  return {
966
1087
  ...persisted,
967
1088
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
968
- results: dispatch.results ?? [],
1089
+ turns: dispatch.turns ?? [],
969
1090
  ...patch,
970
1091
  };
971
1092
  }
@@ -994,13 +1115,13 @@ export async function migrateTaskLog(workspaceRoot, {
994
1115
  backupPath: null,
995
1116
  };
996
1117
  }
997
- const lines = records.map((record) => JSON.stringify(schema6Task(record.normalized)));
1118
+ const lines = records.map((record) => JSON.stringify(schema7Task(record.normalized)));
998
1119
  const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
999
1120
  await parseDispatchRecordsUnlocked(root, migrated);
1000
1121
  const timestamp = requireTimestamp(now(), "migration timestamp")
1001
1122
  .replaceAll(":", "-")
1002
1123
  .replaceAll(".", "-");
1003
- const backupPath = `${dispatchPath}.pre-v6-${timestamp}-${randomUUID()}.bak`;
1124
+ const backupPath = `${dispatchPath}.pre-v7-${timestamp}-${randomUUID()}.bak`;
1004
1125
  await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1005
1126
  if (await readFile(backupPath, "utf8") !== original) {
1006
1127
  throw new Error(`task log backup validation failed: ${backupPath}`);
@@ -1046,7 +1167,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
1046
1167
  turnId: null,
1047
1168
  updatedAt: createdAt,
1048
1169
  updatedBy: "dispatcher",
1049
- results: [],
1170
+ turns: [],
1050
1171
  });
1051
1172
  const existing = await readDispatchesUnlocked(root);
1052
1173
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -1088,7 +1209,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1088
1209
  throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
1089
1210
  }
1090
1211
  if (dispatch.threadId === durableThreadId) return dispatch;
1091
- const canonical = await validateDispatchShape(schema6Task(dispatch, {
1212
+ const canonical = await validateDispatchShape(schema7Task(dispatch, {
1092
1213
  threadId: durableThreadId,
1093
1214
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1094
1215
  updatedBy: "mcp",
@@ -1117,7 +1238,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1117
1238
  ))) {
1118
1239
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
1119
1240
  }
1120
- const linked = await validateDispatchShape(schema6Task(dispatch, {
1241
+ const linked = await validateDispatchShape(schema7Task(dispatch, {
1121
1242
  threadId: durableThreadId,
1122
1243
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1123
1244
  updatedBy: "mcp",
@@ -1131,11 +1252,13 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1131
1252
  }
1132
1253
 
1133
1254
  function dispatchLineWithState(dispatch, patch) {
1134
- return JSON.stringify(schema6Task(dispatch, patch));
1255
+ return JSON.stringify(schema7Task(dispatch, patch));
1135
1256
  }
1136
1257
 
1137
1258
  function normalizeTaskStateInput(input, { allowWorking }) {
1138
- const fields = new Set(["taskId", "threadId", "turnId", "status", "summary"]);
1259
+ const fields = new Set([
1260
+ "taskId", "threadId", "turnId", "status", "summary", "requestSummary",
1261
+ ]);
1139
1262
  if (!input || typeof input !== "object" || Array.isArray(input)) {
1140
1263
  throw new Error("task state must be an object");
1141
1264
  }
@@ -1157,13 +1280,21 @@ function normalizeTaskStateInput(input, { allowWorking }) {
1157
1280
  const summary = optionalString("summary" in input ? input.summary : null, "summary", {
1158
1281
  maxLength: MAX_RESULT_SUMMARY_LENGTH,
1159
1282
  });
1283
+ const requestSummary = optionalString(
1284
+ "requestSummary" in input ? input.requestSummary : null,
1285
+ "requestSummary",
1286
+ { maxLength: MAX_REQUEST_SUMMARY_LENGTH },
1287
+ );
1160
1288
  if (status === "working" && summary !== null) {
1161
1289
  throw new Error("summary must be null while status is working");
1162
1290
  }
1163
1291
  if (status !== "working" && summary === null) {
1164
1292
  throw new Error(`summary is required for status ${status}`);
1165
1293
  }
1166
- return { id, threadId, turnId, status, summary };
1294
+ if (status !== "working" && requestSummary !== null) {
1295
+ throw new Error(`requestSummary is accepted only for status working`);
1296
+ }
1297
+ return { id, threadId, turnId, status, summary, requestSummary };
1167
1298
  }
1168
1299
 
1169
1300
  function sameLastResult(lastResult, { status, summary, turnId }) {
@@ -1178,7 +1309,7 @@ async function reportTaskStateInternal(
1178
1309
  input,
1179
1310
  { now, compatibilityAlias = false } = {},
1180
1311
  ) {
1181
- const { id, threadId, turnId, status, summary } = normalizeTaskStateInput(input, {
1312
+ const { id, threadId, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
1182
1313
  allowWorking: !compatibilityAlias,
1183
1314
  });
1184
1315
  const root = await realpath(path.resolve(workspaceRoot));
@@ -1242,22 +1373,51 @@ async function reportTaskStateInternal(
1242
1373
  }
1243
1374
  }
1244
1375
  if (status === "working") {
1245
- if (dispatch.status === "working" && stateTurnId === dispatch.turnId) return dispatch;
1376
+ const sameWorkingTurn = dispatch.status === "working" && stateTurnId === dispatch.turnId;
1377
+ if (sameWorkingTurn) {
1378
+ const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
1379
+ if (
1380
+ requestSummary !== null
1381
+ && storedRequest !== null
1382
+ && requestSummary !== storedRequest
1383
+ ) {
1384
+ throw new Error(`working turn already has a different requestSummary: ${id}`);
1385
+ }
1386
+ if (
1387
+ records[index].raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
1388
+ && (requestSummary === null || storedRequest === requestSummary)
1389
+ ) {
1390
+ return dispatch;
1391
+ }
1392
+ }
1246
1393
  if (isSelfLinkingJourney) {
1247
- if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1394
+ if (!sameWorkingTurn && dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1248
1395
  throw new Error(`working turnId must be newer than the current task turnId: ${id}`);
1249
1396
  }
1250
- if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1397
+ if (!sameWorkingTurn && dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1251
1398
  throw new Error(`working turnId must be newer than the last result turnId: ${id}`);
1252
1399
  }
1253
1400
  }
1254
- const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1255
- const updated = await validateDispatchShape(schema6Task(dispatch, {
1401
+ const updatedAt = sameWorkingTurn
1402
+ ? dispatch.updatedAt
1403
+ : transitionTimestamp(now, dispatch.updatedAt);
1404
+ const turns = sameWorkingTurn
1405
+ ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
1406
+ ? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
1407
+ : turn)
1408
+ : [...dispatch.turns, {
1409
+ turnId: stateTurnId,
1410
+ requestSummary,
1411
+ startedAt: updatedAt,
1412
+ result: null,
1413
+ }];
1414
+ const updated = await validateDispatchShape(schema7Task(dispatch, {
1256
1415
  status,
1257
1416
  summary: null,
1258
1417
  turnId: stateTurnId,
1259
1418
  updatedAt,
1260
1419
  updatedBy: "mcp",
1420
+ turns,
1261
1421
  }));
1262
1422
  const lines = records.map((record, recordIndex) => recordIndex === index
1263
1423
  ? dispatchLineWithState(updated, {})
@@ -1265,7 +1425,10 @@ async function reportTaskStateInternal(
1265
1425
  await writeDispatchLinesAtomic(root, lines);
1266
1426
  return updated;
1267
1427
  }
1268
- const priorTurnResult = dispatch.results.find((result) => result.turnId === stateTurnId);
1428
+ const priorTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
1429
+ const priorTurnResult = priorTurn?.result === null || priorTurn === undefined
1430
+ ? null
1431
+ : { ...priorTurn.result, turnId: priorTurn.turnId };
1269
1432
  if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
1270
1433
  return dispatch;
1271
1434
  }
@@ -1287,14 +1450,28 @@ async function reportTaskStateInternal(
1287
1450
  throw new Error(`task result must match the current working turnId: ${id}`);
1288
1451
  }
1289
1452
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1290
- const lastResult = { status, summary, turnId: stateTurnId, updatedAt };
1291
- const candidate = schema6Task(dispatch, {
1453
+ const turnResult = { status, summary, updatedAt };
1454
+ const currentTurnIndex = dispatch.turns.findIndex((turn) => turn.turnId === stateTurnId);
1455
+ let turns;
1456
+ if (currentTurnIndex === -1) {
1457
+ turns = [...dispatch.turns, {
1458
+ turnId: stateTurnId,
1459
+ requestSummary: null,
1460
+ startedAt: updatedAt,
1461
+ result: turnResult,
1462
+ }];
1463
+ } else {
1464
+ turns = dispatch.turns.map((turn, turnIndex) => turnIndex === currentTurnIndex
1465
+ ? { ...turn, result: turnResult }
1466
+ : turn);
1467
+ }
1468
+ const candidate = schema7Task(dispatch, {
1292
1469
  status,
1293
1470
  summary,
1294
1471
  turnId: stateTurnId,
1295
1472
  updatedAt,
1296
1473
  updatedBy: "mcp",
1297
- results: [...dispatch.results, lastResult],
1474
+ turns,
1298
1475
  });
1299
1476
  const updated = await validateDispatchShape(candidate);
1300
1477
  const lines = records.map((record, recordIndex) => recordIndex === index