taskchef 7.8.0 → 7.9.1

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.
@@ -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.";
@@ -162,14 +162,18 @@ export function parseTaskChefMarker(instruction) {
162
162
  return hasHistoricalAssignment() ? id : null;
163
163
  }
164
164
  const executorSkillReferences = instruction.match(/\$taskchef-executor\b/gi) ?? [];
165
+ const hasCompactBoundary = index >= 1
166
+ && lines.at(index - 1).trim().length > 0;
167
+ const hasHistoricalBlankBoundary = index >= 2
168
+ && lines.at(index - 1) === ""
169
+ && lines.at(index - 2).trim().length > 0;
170
+ const assignmentEnd = hasCompactBoundary ? index : index - 1;
165
171
  const isTrailingScaffold = index === lines.length - 2
166
- && index >= 2
172
+ && (hasCompactBoundary || hasHistoricalBlankBoundary)
167
173
  && lines[0].trim().length > 0
168
- && lines.at(-3) === ""
169
- && lines.at(-4).trim().length > 0
170
174
  && lines.at(-1) === EXECUTOR_SKILL_INVOCATION
171
- && hasTaskSpecificContent(lines.slice(0, index - 1))
172
- && !lines.slice(0, index - 1).some((line) => HISTORICAL_EXECUTOR_SCAFFOLD_LINES.has(line))
175
+ && hasTaskSpecificContent(lines.slice(0, assignmentEnd))
176
+ && !lines.slice(0, assignmentEnd).some((line) => HISTORICAL_EXECUTOR_SCAFFOLD_LINES.has(line))
173
177
  && executorSkillReferences.length === 1;
174
178
  return isTrailingScaffold ? id : null;
175
179
  }
@@ -198,7 +202,7 @@ export function prepareDelegation(instruction, { taskId = randomUUID() } = {}) {
198
202
  const id = requireUuid(taskId);
199
203
  return {
200
204
  id,
201
- instruction: `${body}\n\n${taskChefMarker(id)}\n${EXECUTOR_SKILL_INVOCATION}`,
205
+ instruction: `${body}\n${taskChefMarker(id)}\n${EXECUTOR_SKILL_INVOCATION}`,
202
206
  };
203
207
  }
204
208
 
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