diff --git a/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts b/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts
index 911d2cf778..0d2fda26ae 100644
--- a/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts
+++ b/apps/desktop/src/main/__tests__/session-environment-prompt.test.ts
@@ -49,8 +49,8 @@ describe('session environment prompt', () => {
it('is injected as a current-turn tail instead of durable system prefix', async () => {
const source = await readMainProcessCombinedSource();
- assert.match(source, /turnTailPrompt:\s*\(\{ cwd \}\) => systemPromptService\.buildTurnTailPrompt\(cwd\)/);
- assert.match(source, /async function buildTurnTailPrompt\(cwd\?: string\)/);
+ assert.match(source, /turnTailPrompt:\s*\(\{ cwd, sessionId \}\) => systemPromptService\.buildTurnTailPrompt\(cwd, sessionId\)/);
+ assert.match(source, /async function buildTurnTailPrompt\(cwd\?: string, sessionId\?: string\)/);
assert.match(source, /projectGit:\s*await resolveProjectGitInfo\(cwd\)/);
assert.doesNotMatch(source, /personalization\.text,\n\s*environment,\n\s*deepResearch/);
});
diff --git a/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts b/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts
new file mode 100644
index 0000000000..3be62de7c9
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/task-ledger-contract.test.ts
@@ -0,0 +1,173 @@
+/**
+ * Contract for the session task-ledger primitive (model-facing slice, PR1).
+ *
+ * Locks the seams that a refactor could silently break:
+ * (a) main.ts wires TaskCreate/TaskUpdate into builtinTools and constructs
+ * the per-session store, and threads sessionId into the turn tail.
+ * (b) the turn-tail injector exists and injects nothing for an empty ledger
+ * (zero cost when the model isn't tracking tasks) but renders when there
+ * are tasks.
+ * (c) subjects are scrubbed (redactSecrets) and cannot escape the
+ * data envelope via embedded wrapper-tag literals.
+ * (d) both tools skip the permission engine (pure local session state).
+ */
+
+import { strict as assert } from 'node:assert';
+import { describe, it } from 'node:test';
+import { mkdtemp } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import type { AppSettings, Task } from '@maka/core';
+import {
+ TASK_CREATE_TOOL_NAME,
+ TASK_UPDATE_TOOL_NAME,
+ buildTaskLedgerTools,
+ type MakaToolContext,
+} from '@maka/runtime';
+import { createMainTaskLedgerWiring } from '../task-ledger-wiring.js';
+import { createSystemPromptMainService } from '../system-prompt-main.js';
+
+function makeService(tasks: Task[]) {
+ return createSystemPromptMainService({
+ settingsStore: { get: async () => ({}) as AppSettings },
+ workspaceRoot: '/tmp/does-not-matter',
+ localMemory: {
+ getState: async () => ({ status: 'ok', agentReadEnabled: false, content: '' }) as never,
+ consumePendingPromptUpdates: () => [],
+ },
+ taskLedger: { list: async () => tasks },
+ });
+}
+
+const sampleTask: Task = {
+ id: 'task-1',
+ subject: '写单元测试',
+ status: 'in_progress',
+ createdAt: 1,
+ updatedAt: 2,
+};
+
+function fakeContext(sessionId: string): MakaToolContext {
+ return {
+ sessionId,
+ turnId: 'turn-1',
+ cwd: '/tmp',
+ toolCallId: 'call-1',
+ abortSignal: new AbortController().signal,
+ emitOutput: () => {},
+ };
+}
+
+describe('task ledger contract', () => {
+ it('wires the store and tools to one shared task ledger the turn tail reads (behavior, not source text)', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-task-ledger-wiring-'));
+ const wiring = createMainTaskLedgerWiring(root);
+ // (a) TaskCreate/TaskUpdate are wired in.
+ assert.ok(wiring.tools.some((t) => t.name === TASK_CREATE_TOOL_NAME), 'TaskCreate must be wired');
+ assert.ok(wiring.tools.some((t) => t.name === TASK_UPDATE_TOOL_NAME), 'TaskUpdate must be wired');
+ // (b) store is real and empty for a fresh workspace.
+ assert.deepEqual(await wiring.store.list('sess-1'), []);
+ // (c) tools and the turn tail share ONE store through the real system prompt
+ // service: a TaskCreate lands in the store the tail reads, proving the
+ // mutate and read faces cannot drift to different ledgers.
+ const service = createSystemPromptMainService({
+ settingsStore: { get: async () => ({}) as AppSettings },
+ workspaceRoot: root,
+ localMemory: {
+ getState: async () => ({ status: 'ok', agentReadEnabled: false, content: '' }) as never,
+ consumePendingPromptUpdates: () => [],
+ },
+ taskLedger: wiring.store,
+ });
+ const create = wiring.tools.find((t) => t.name === TASK_CREATE_TOOL_NAME);
+ assert.ok(create, 'TaskCreate tool must be present');
+ await create.impl({ tasks: [{ subject: '通过装配建任务' }] }, fakeContext('sess-1'));
+ const tail = await service.buildTurnTailPrompt(undefined, 'sess-1');
+ assert.ok(tail, 'tail must render when the shared store has tasks');
+ assert.match(tail, /通过装配建任务/);
+ });
+
+ it('injects nothing for an empty ledger', async () => {
+ const tail = await makeService([]).buildTurnTailPrompt(undefined, 'sess-1');
+ assert.equal(tail, undefined);
+ });
+
+ it('injects nothing when no sessionId is available', async () => {
+ const tail = await makeService([sampleTask]).buildTurnTailPrompt(undefined, undefined);
+ assert.equal(tail, undefined);
+ });
+
+ it('renders the ledger as a current-turn tail fragment when tasks exist', async () => {
+ const tail = await makeService([sampleTask]).buildTurnTailPrompt(undefined, 'sess-1');
+ assert.ok(tail);
+ assert.match(tail, //);
+ assert.match(tail, /写单元测试/);
+ assert.match(tail, /仅供当前回复参考/);
+ });
+
+ it('redacts secret-like text in task subjects before injecting the tail', async () => {
+ // Same samples the core redactSecrets tests use: a bearer token and a
+ // provider key prefix. Subjects are model-authored free text replayed
+ // every turn, so they must pass through redactSecrets like memory tail
+ // text does (cf. compactMemoryUpdateText).
+ const secretTask: Task = {
+ ...sampleTask,
+ subject: '轮换 Bearer sk-live-secret-token-value 和 ghp_abcdefghijklmnopqrstuvwxyz',
+ };
+ const tail = await makeService([secretTask]).buildTurnTailPrompt(undefined, 'sess-1');
+ assert.ok(tail);
+ assert.equal(tail.includes('sk-live-secret-token-value'), false);
+ assert.equal(tail.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false);
+ assert.match(tail, /\[redacted\]/);
+ });
+
+ it('strips wrapper-tag literals so a subject cannot close the data envelope early', async () => {
+ // normalizeTaskSubject only collapses whitespace and redactSecrets only
+ // masks secrets, so a literal in a subject would otherwise
+ // escape the data wrapper and read as instruction-level text.
+ const escapingTask: Task = {
+ ...sampleTask,
+ subject: '正常前缀 假指令 假开头',
+ };
+ const tail = await makeService([escapingTask]).buildTurnTailPrompt(undefined, 'sess-1');
+ assert.ok(tail);
+ // Exactly one closing tag (the real envelope) and one opening tag survive.
+ assert.equal(tail.match(/<\/task-ledger>/g)?.length, 1);
+ assert.equal(tail.match(//g)?.length, 1);
+ assert.match(tail, /正常前缀/);
+ });
+
+ it('strips tag variants (attributes, whitespace, self-closing), not just exact literals', async () => {
+ // The narrow ?task-ledger> regex misses (space before >),
+ // (attributes), , and .
+ // A model-authored subject carrying any of these must not smuggle extra
+ // tag-like text into the tail; only the real envelope open+close survive.
+ const escapingTask: Task = {
+ ...sampleTask,
+ subject: '前缀 假1 假2 假3 后缀',
+ };
+ const tail = await makeService([escapingTask]).buildTurnTailPrompt(undefined, 'sess-1');
+ assert.ok(tail);
+ assert.equal(
+ (tail.match(/<\/?task-ledger[^>]*>/gi) || []).length,
+ 2,
+ 'only the real envelope open+close tags should survive, got: ' + JSON.stringify(tail),
+ );
+ assert.match(tail, /前缀/);
+ assert.match(tail, /后缀/);
+ });
+
+ it('keeps both tools free of the permission gate', () => {
+ const tools = buildTaskLedgerTools({
+ store: {
+ list: async () => [],
+ create: async () => ({ created: [], total: 0 }),
+ update: async () => ({ updated: {} as Task, total: 0 }),
+ },
+ });
+ assert.deepEqual(tools.map((t) => t.name), [TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME]);
+ for (const tool of tools) {
+ assert.equal(tool.permissionRequired, false, `${tool.name} must not require permission`);
+ }
+ });
+});
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 678009e402..4b5f467ee7 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -164,6 +164,7 @@ import { createBotIncomingMainService } from './bot-incoming-main.js';
import { createSubscriptionModelFetch } from './subscription-model-fetch.js';
import { buildContextBudgetPolicy } from './context-budget-policy.js';
import { createSystemPromptMainService } from './system-prompt-main.js';
+import { createMainTaskLedgerWiring } from './task-ledger-wiring.js';
import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js';
import {
applyNetworkPatch,
@@ -297,6 +298,8 @@ const antigravitySubscription = new AntigravitySubscriptionService({
});
const planReminderStore = createPlanReminderStore(workspaceRoot);
+const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot);
+const taskLedgerStore = taskLedgerWiring.store;
async function getWorkspacePrivacyContext(): Promise {
const settings = await settingsStore.get();
@@ -313,6 +316,7 @@ const systemPromptService = createSystemPromptMainService({
settingsStore,
workspaceRoot,
localMemory,
+ taskLedger: taskLedgerStore,
});
const mainWindowController = createMainWindowController({
workspaceRoot,
@@ -399,6 +403,9 @@ const builtinTools = [
settingsStore,
getPrivacyContext: getWorkspacePrivacyContext,
}),
+ // Session task ledger: model manages a flat task list; the current list is
+ // re-injected each turn tail. Pure local state, so no permission gate.
+ ...taskLedgerWiring.tools,
// The `load_tools` connector is built by ToolAvailabilityRuntime; deferred
// group tools just need to be present so they are dispatchable once loaded.
...deferredTools,
@@ -584,7 +591,7 @@ backends.register('ai-sdk', async (ctx) => {
memoryFragment: memoryPromptSnapshot,
childInstruction: ctx.systemPrompt,
}),
- turnTailPrompt: ({ cwd }) => systemPromptService.buildTurnTailPrompt(cwd),
+ turnTailPrompt: ({ cwd, sessionId }) => systemPromptService.buildTurnTailPrompt(cwd, sessionId),
lookupPricing,
recordLlmCall: (event) => recordLlmCall({ repo: telemetryRepo, lookupPricing }, event),
recordToolInvocation: (event) =>
diff --git a/apps/desktop/src/main/system-prompt-main.ts b/apps/desktop/src/main/system-prompt-main.ts
index c56dba8ebc..0733c50f2b 100644
--- a/apps/desktop/src/main/system-prompt-main.ts
+++ b/apps/desktop/src/main/system-prompt-main.ts
@@ -5,8 +5,11 @@ import {
botPlatformFromSessionLabels,
isDeepResearchSession,
redactSecrets,
+ renderSafeTaskLedgerText,
type AppSettings,
type SessionHeader,
+ type Task,
+ type TaskLedgerStore,
} from '@maka/core';
import { buildPersonalizationPromptFragment } from './personalization-prompt.js';
import { resolveProjectGitInfo } from './project-context.js';
@@ -23,6 +26,7 @@ interface SystemPromptMainDeps {
settingsStore: SystemPromptSettingsStore;
workspaceRoot: string;
localMemory: Pick;
+ taskLedger: Pick;
}
export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
@@ -74,7 +78,7 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
].filter((fragment): fragment is string => Boolean(fragment)).join('\n\n');
}
- async function buildTurnTailPrompt(cwd?: string): Promise {
+ async function buildTurnTailPrompt(cwd?: string, sessionId?: string): Promise {
const fragments: string[] = [];
if (cwd) {
fragments.push(
@@ -86,9 +90,22 @@ export function createSystemPromptMainService(deps: SystemPromptMainDeps) {
}
const memoryUpdate = buildLocalMemoryUpdateTailFragment(deps.localMemory.consumePendingPromptUpdates());
if (memoryUpdate) fragments.push(memoryUpdate);
+ const taskLedger = sessionId ? await buildTaskLedgerTailFragment(sessionId) : undefined;
+ if (taskLedger) fragments.push(taskLedger);
return fragments.length > 0 ? fragments.join('\n\n') : undefined;
}
+ // Best-effort: a ledger read failure must never break the turn. An empty
+ // ledger injects nothing (zero cost when the model isn't tracking tasks).
+ async function buildTaskLedgerTailFragment(sessionId: string): Promise {
+ try {
+ const tasks = await deps.taskLedger.list(sessionId);
+ return renderTaskLedgerTailFragment(tasks);
+ } catch {
+ return undefined;
+ }
+ }
+
async function buildLocalMemoryPromptFragment(): Promise {
try {
const state = await deps.localMemory.getState();
@@ -130,6 +147,22 @@ function buildLocalMemoryUpdateTailFragment(updates: ReadonlyArray',
+ // Shared safe renderer: redact secrets, then strip every
+ // / variant (attributes, whitespace
+ // before >, self-closing) so a model-authored subject cannot open or close
+ // the data envelope early. redaction only substitutes '[redacted]', so it
+ // cannot reintroduce a tag; the strip runs last.
+ renderSafeTaskLedgerText(tasks),
+ '',
+ ].join('\n');
+}
+
function compactMemoryUpdateText(value: string): string {
return redactSecrets(value).replace(/\s+/g, ' ').trim().slice(0, 160);
}
diff --git a/apps/desktop/src/main/task-ledger-wiring.ts b/apps/desktop/src/main/task-ledger-wiring.ts
new file mode 100644
index 0000000000..8c1941b4b3
--- /dev/null
+++ b/apps/desktop/src/main/task-ledger-wiring.ts
@@ -0,0 +1,26 @@
+import type { TaskLedgerStore } from '@maka/core';
+import { createTaskLedgerStore } from '@maka/storage';
+import { buildTaskLedgerTools, type MakaTool } from '@maka/runtime';
+
+/**
+ * The task-ledger wiring the main process needs: one per-session store shared
+ * by the mutate face (TaskCreate/TaskUpdate tools) and the read face (the
+ * turn-tail fragment). Grouping the construction here keeps the main-process
+ * entry a thin assembler and lets the contract assert the wiring at behavior
+ * level (tools present, store real, a create lands in the store the tail
+ * reads) instead of via source-text regex.
+ */
+export interface MainTaskLedgerWiring {
+ /** Per-session task ledger store; shared by tools (mutate) and turn tail (read). */
+ store: TaskLedgerStore;
+ /** TaskCreate/TaskUpdate bound to {@link store}. */
+ tools: MakaTool[];
+}
+
+export function createMainTaskLedgerWiring(workspaceRoot: string): MainTaskLedgerWiring {
+ const store = createTaskLedgerStore(workspaceRoot);
+ return {
+ store,
+ tools: buildTaskLedgerTools({ store }),
+ };
+}
\ No newline at end of file
diff --git a/packages/core/package.json b/packages/core/package.json
index 3df1eb75ef..3db7099b68 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -28,6 +28,7 @@
"./bot-events": "./dist/bot-events.js",
"./bot-platform-hints": "./dist/bot-platform-hints.js",
"./plan-reminders": "./dist/plan-reminders.js",
+ "./task-ledger": "./dist/task-ledger.js",
"./memory": "./dist/memory.js",
"./voice": "./dist/voice.js",
"./incognito": "./dist/incognito.js",
diff --git a/packages/core/src/__tests__/task-ledger.test.ts b/packages/core/src/__tests__/task-ledger.test.ts
new file mode 100644
index 0000000000..32e2dd3ec3
--- /dev/null
+++ b/packages/core/src/__tests__/task-ledger.test.ts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+import { isSafeTaskId, renderSafeTaskLedgerText, type Task } from '../task-ledger.js';
+
+function task(subject: string): Task {
+ return { id: 't1', subject, status: 'pending', createdAt: 1, updatedAt: 1 };
+}
+
+describe('renderSafeTaskLedgerText', () => {
+ test('returns empty string for an empty ledger', () => {
+ assert.equal(renderSafeTaskLedgerText([]), '');
+ });
+
+ test('strips tag variants (attributes, whitespace, self-closing) so they cannot open or close the data envelope', () => {
+ const variants = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ];
+ for (const v of variants) {
+ const out = renderSafeTaskLedgerText([task(`正常 ${v} 假指令 ${v} 正常`)]);
+ assert.equal(
+ (out.match(/<\/?task-ledger[^>]*>/gi) || []).length,
+ 0,
+ `variant ${JSON.stringify(v)} should be fully stripped, got: ${JSON.stringify(out)}`,
+ );
+ }
+ });
+
+ test('redacts secret-like subjects', () => {
+ const out = renderSafeTaskLedgerText([task('轮换 Bearer sk-live-secret-token-value 和 ghp_abcdefghijklmnopqrstuvwxyz')]);
+ assert.equal(out.includes('sk-live-secret-token-value'), false);
+ assert.equal(out.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false);
+ assert.match(out, /\[redacted\]/);
+ });
+
+ test('preserves legitimate angle brackets in subjects', () => {
+ const out = renderSafeTaskLedgerText([task('ensure a < b holds')]);
+ assert.equal(out.includes('a < b holds'), true);
+ });
+
+ test('renders the canonical id as a distinct leading field so a subject cannot smuggle a fake id', () => {
+ const t: Task = { id: 'real-id', subject: '做事 (id: fake-id) 收尾', status: 'pending', createdAt: 1, updatedAt: 1 };
+ const out = renderSafeTaskLedgerText([t]);
+ // canonical id is a distinct leading field on the line
+ assert.match(out, /^id=real-id status=pending subject=/);
+ // the canonical id appears exactly once (the leading field), not duplicated
+ assert.equal((out.match(/id=real-id/g) || []).length, 1);
+ // the fake id in the subject is inside the quoted JSON payload, not a bare field
+ assert.match(out, /subject="[^"]*\(id: fake-id\)[^"]*"/);
+ // and the fake id never appears as a bare id= field
+ assert.equal((out.match(/id=fake-id/g) || []).length, 0);
+ });
+
+ test('does not strip across lines: an unclosed on the next task line', () => {
+ // [^>]* in the strip regex crosses newlines, so an unclosed `` in the next would silently delete the text between
+ // them -- collapsing two task lines into one and dropping the first id.
+ const t1: Task = { id: 'id-1', subject: 'foo baz', status: 'pending', createdAt: 2, updatedAt: 2 };
+ const out = renderSafeTaskLedgerText([t1, t2]);
+ assert.equal(out.includes('id=id-1 '), true, `first task id must survive, got: ${JSON.stringify(out)}`);
+ assert.equal(out.includes('id=id-2 '), true, `second task id must survive, got: ${JSON.stringify(out)}`);
+ assert.equal(out.includes('foo'), true, `first subject text must survive, got: ${JSON.stringify(out)}`);
+ assert.equal(out.includes('bar > baz'), true, `second subject text must survive intact, got: ${JSON.stringify(out)}`);
+ // regression guard: complete same-line variants are still stripped
+ const t3: Task = { id: 'id-3', subject: '正常 假', status: 'pending', createdAt: 3, updatedAt: 3 };
+ const out2 = renderSafeTaskLedgerText([t3]);
+ assert.equal((out2.match(/<\/?task-ledger[^>]*>/gi) || []).length, 0, 'same-line variant must still be stripped');
+ });
+});
+
+describe('isSafeTaskId', () => {
+ test('rejects secret-shaped stable tokens that the renderer would redact to [redacted]', () => {
+ const reject = [
+ 'ghp_abcdefghijklmnopqrstuvwxyz',
+ 'sk-abcdefghi',
+ 'a'.repeat(40),
+ 'AIza' + 'X'.repeat(24),
+ ];
+ for (const id of reject) {
+ assert.equal(isSafeTaskId(id), false, `id ${JSON.stringify(id.slice(0, 24))} must be rejected (renderer would redact it)`);
+ }
+ });
+
+ test('accepts UUID-shaped and simple stable tokens that survive redaction', () => {
+ const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2', 'id-1'];
+ for (const id of accept) {
+ assert.equal(isSafeTaskId(id), true, `id ${id} must pass`);
+ }
+ });
+});
\ No newline at end of file
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c88d8487ce..cc22b3245b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -433,6 +433,28 @@ export {
normalizePlanReminderTitle,
normalizeUpdatePlanReminderInput,
} from './plan-reminders.js';
+// task-ledger.ts (main agent session task tracking)
+export type {
+ CreateTaskInput,
+ Task,
+ TaskLedgerNormalizeResult,
+ TaskLedgerStore,
+ TaskStatus,
+ UpdateTaskInput,
+} from './task-ledger.js';
+export {
+ TASK_ID_MAX_CHARS,
+ TASK_LEDGER_MAX_TASKS,
+ TASK_STATUSES,
+ TASK_SUBJECT_MAX_CHARS,
+ isSafeTaskId,
+ isTaskStatus,
+ normalizeCreateTaskInput,
+ normalizeTaskStatus,
+ normalizeTaskSubject,
+ normalizeUpdateTaskInput,
+ renderSafeTaskLedgerText,
+} from './task-ledger.js';
// memory.ts (PR-MEMORY-1) — core contract; no IPC/storage/embedding/UI.
export type {
diff --git a/packages/core/src/task-ledger.ts b/packages/core/src/task-ledger.ts
new file mode 100644
index 0000000000..502ef5fd1a
--- /dev/null
+++ b/packages/core/src/task-ledger.ts
@@ -0,0 +1,185 @@
+// Session-scoped task ledger primitive for the main agent. The model manages a
+// flat task list via TaskCreate/TaskUpdate; each turn tail re-injects the
+// current list. P0 scope is intentionally minimal: no priority, dependency, or
+// assignee fields.
+
+import { redactSecrets } from './redaction.js';
+
+export const TASK_SUBJECT_MAX_CHARS = 200;
+/**
+ * Hard cap on total tasks per session ledger (any status). The full ledger is
+ * re-injected into every turn tail, so an unbounded ledger burns context on
+ * every turn; this is a runaway guard on the total count, not a workflow quota
+ * — completing or cancelling tasks does not free capacity.
+ */
+export const TASK_LEDGER_MAX_TASKS = 200;
+
+/**
+ * Max length of a task id accepted on both the write and read paths. The write
+ * path generates randomUUID (36 chars); the bound leaves headroom for a future
+ * id format while keeping the turn-tail `id=` fielded render bounded.
+ */
+export const TASK_ID_MAX_CHARS = 64;
+
+export const TASK_STATUSES = ['pending', 'in_progress', 'completed', 'cancelled'] as const;
+export type TaskStatus = typeof TASK_STATUSES[number];
+
+export interface Task {
+ id: string;
+ subject: string;
+ status: TaskStatus;
+ createdAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Store contract shared by the storage implementation and the runtime tools.
+ * Mutations return the changed task(s) and the new total, computed inside the
+ * store's serialized write section, so callers render exactly the state their
+ * mutation produced instead of re-reading outside the write queue. The full
+ * ledger never leaves the store through the mutation result.
+ */
+export interface TaskLedgerStore {
+ list(sessionId: string): Promise;
+ create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }>;
+ update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }>;
+}
+
+export interface CreateTaskInput {
+ subject: unknown;
+}
+
+export interface UpdateTaskInput {
+ subject?: unknown;
+ status?: unknown;
+}
+
+export type TaskLedgerNormalizeResult =
+ | { ok: true; value: T }
+ | {
+ ok: false;
+ reason: 'invalid_subject' | 'invalid_status' | 'empty_patch';
+ message: string;
+ };
+
+type TaskLedgerNormalizeErrorReason = Extract, { ok: false }>['reason'];
+
+export function isTaskStatus(value: unknown): value is TaskStatus {
+ return typeof value === 'string' && (TASK_STATUSES as readonly string[]).includes(value);
+}
+
+/**
+ * Stable-token id contract shared by the runtime tool schema (front-door) and
+ * the storage read path. The id is rendered verbatim (see
+ * renderSafeTaskLedgerText), so it must not be deformable by any face that has
+ * ever rendered it: no angle brackets/slashes/quotes/parens/equals (a past
+ * whole-string tag strip would have eaten them; even the fielded renderer
+ * emits the id bare), no whitespace (would break the list-line structure), no
+ * huge length (would bloat every turn tail), and redaction-stable (a renderer
+ * that runs redactSecrets must not turn the id into [redacted] while the store
+ * keeps the real id -- a later TaskUpdate would miss). The whitelist
+ * (alphanumeric plus . _ : -, 1-64 chars) plus redactSecrets(id) === id enforces
+ * these constraints without coupling to the UUID format.
+ */
+export function isSafeTaskId(value: unknown): value is string {
+ // Stable token (alphanumeric plus . _ : -, 1-64 chars) AND redaction-stable:
+ // the id is rendered verbatim, so a secret-shaped id (ghp_..., sk-..., a
+ // 40-char hex, AIza...) must be rejected -- otherwise a renderer that does
+ // run redactSecrets would turn it into [redacted] while the store keeps the
+ // real id, and a later TaskUpdate would miss.
+ return typeof value === 'string'
+ && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(value)
+ && redactSecrets(value) === value;
+}
+
+export function normalizeTaskSubject(input: unknown): TaskLedgerNormalizeResult {
+ if (typeof input !== 'string') {
+ return invalid('invalid_subject', 'Task subject must be a string');
+ }
+ const subject = input.normalize('NFC').replace(/\s+/g, ' ').trim();
+ if (subject.length === 0) {
+ return invalid('invalid_subject', 'Task subject cannot be empty');
+ }
+ if (Array.from(subject).length > TASK_SUBJECT_MAX_CHARS) {
+ return invalid('invalid_subject', `Task subject must be ${TASK_SUBJECT_MAX_CHARS} characters or fewer`);
+ }
+ return { ok: true, value: subject };
+}
+
+export function normalizeTaskStatus(input: unknown): TaskLedgerNormalizeResult {
+ if (!isTaskStatus(input)) {
+ return invalid('invalid_status', `Task status must be one of ${TASK_STATUSES.join(', ')}`);
+ }
+ return { ok: true, value: input };
+}
+
+export function normalizeCreateTaskInput(input: unknown): TaskLedgerNormalizeResult<{ subject: string }> {
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
+ return invalid('invalid_subject', 'Task input must be an object');
+ }
+ const record = input as CreateTaskInput;
+ const subject = normalizeTaskSubject(record.subject);
+ if (!subject.ok) return subject;
+ return { ok: true, value: { subject: subject.value } };
+}
+
+export function normalizeUpdateTaskInput(
+ input: unknown,
+): TaskLedgerNormalizeResult<{ subject?: string; status?: TaskStatus }> {
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
+ return invalid('empty_patch', 'Task update must be an object');
+ }
+ const record = input as UpdateTaskInput;
+ const patch: { subject?: string; status?: TaskStatus } = {};
+ if (record.subject !== undefined) {
+ const subject = normalizeTaskSubject(record.subject);
+ if (!subject.ok) return subject;
+ patch.subject = subject.value;
+ }
+ if (record.status !== undefined) {
+ const status = normalizeTaskStatus(record.status);
+ if (!status.ok) return status;
+ patch.status = status.value;
+ }
+ if (patch.subject === undefined && patch.status === undefined) {
+ return invalid('empty_patch', 'Task update must change at least one of subject or status');
+ }
+ return { ok: true, value: patch };
+}
+
+/**
+ * Safe-render the task ledger for any face that persists into history or is
+ * re-injected into a prompt (tool results, turn-tail fragment). Two invariants:
+ * - the canonical id is rendered verbatim, and the subject is a safe
+ * (redacted, tag-stripped) rendered payload of what the store holds; and
+ * - the model can unambiguously recover each task's id from what it sees, so
+ * a later TaskUpdate hits the right task.
+ *
+ * Rendering is per-task and fielded, not a free-text bullet: each line is
+ * `id= status= subject=`. The
+ * canonical id is a distinct leading field, so a subject cannot smuggle a fake
+ * `id=` field or any other id-like span past it -- any id-like text in the subject stays
+ * inside the quoted JSON payload. The id is emitted verbatim: it is a
+ * redaction-stable stable token validated on write and read, so scrubbing it
+ * could only deform it (and break TaskUpdate); it must not be redacted or
+ * tag-stripped. Each subject is redacted (secrets) and tag-stripped (complete
+ * `` / `` tags on a single line, so a
+ * model-authored subject cannot open or close the data envelope)
+ * independently -- a subject on one task can never eat or deform text on
+ * another task's line. Other angle brackets (e.g. `a < b`) are left intact.
+ * Returns '' for an empty ledger.
+ */
+export function renderSafeTaskLedgerText(tasks: readonly Task[]): string {
+ if (tasks.length === 0) return '';
+ return tasks.map((task) => {
+ const safeSubject = redactSecrets(task.subject).replace(/<\/?task-ledger[^\n>]*>/gi, '');
+ return `id=${task.id} status=${task.status} subject=${JSON.stringify(safeSubject)}`;
+ }).join('\n');
+}
+
+function invalid(
+ reason: T,
+ message: string,
+): Extract, { ok: false }> {
+ return { ok: false, reason, message };
+}
diff --git a/packages/runtime/src/__tests__/task-ledger-tools.test.ts b/packages/runtime/src/__tests__/task-ledger-tools.test.ts
new file mode 100644
index 0000000000..e8a882d545
--- /dev/null
+++ b/packages/runtime/src/__tests__/task-ledger-tools.test.ts
@@ -0,0 +1,214 @@
+import { describe, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { z } from 'zod';
+import { TASK_LEDGER_MAX_TASKS, TASK_SUBJECT_MAX_CHARS, type Task, type TaskLedgerStore } from '@maka/core/task-ledger';
+import {
+ TASK_CREATE_TOOL_NAME,
+ TASK_UPDATE_TOOL_NAME,
+ buildTaskLedgerTools,
+} from '../task-ledger-tools.js';
+import type { MakaTool, MakaToolContext } from '../tool-runtime.js';
+
+const SESSION_ID = 'sess-1';
+
+class FakeTaskLedgerStore implements TaskLedgerStore {
+ private tasks: Task[] = [];
+ public createCalls: Array<{ sessionId: string; drafts: unknown }> = [];
+ public updateCalls: Array<{ sessionId: string; id: string; patch: unknown }> = [];
+
+ async list(): Promise {
+ return this.tasks.map((t) => ({ ...t }));
+ }
+
+ async create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }> {
+ this.createCalls.push({ sessionId, drafts });
+ const now = Date.now();
+ const created = (drafts as Array<{ subject: string }>).map((d, i) => ({
+ id: `id-${this.tasks.length + i}`,
+ subject: d.subject,
+ status: 'pending' as const,
+ createdAt: now,
+ updatedAt: now,
+ }));
+ this.tasks.push(...created);
+ return { created, total: this.tasks.length };
+ }
+
+ async update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }> {
+ this.updateCalls.push({ sessionId, id, patch });
+ const task = this.tasks.find((t) => t.id === id);
+ if (!task) throw new Error(`No such task: ${id}`);
+ Object.assign(task, patch, { updatedAt: Date.now() });
+ return { updated: { ...task }, total: this.tasks.length };
+ }
+}
+
+function fakeContext(sessionId: string): MakaToolContext {
+ return {
+ sessionId,
+ turnId: 'turn-1',
+ cwd: '/tmp',
+ toolCallId: 'call-1',
+ abortSignal: new AbortController().signal,
+ emitOutput: () => {},
+ };
+}
+
+function findTool(tools: MakaTool[], name: string): MakaTool {
+ const tool = tools.find((t) => t.name === name);
+ assert.ok(tool, `expected tool ${name}`);
+ return tool;
+}
+
+describe('task ledger tools', () => {
+ test('builds exactly TaskCreate and TaskUpdate, both local (no permission gate)', () => {
+ const tools = buildTaskLedgerTools({ store: new FakeTaskLedgerStore() });
+ assert.deepEqual(tools.map((t) => t.name), [TASK_CREATE_TOOL_NAME, TASK_UPDATE_TOOL_NAME]);
+ for (const tool of tools) {
+ assert.equal(tool.permissionRequired, false, `${tool.name} must not require permission`);
+ }
+ });
+
+ test('TaskCreate schema rejects a batch larger than the ledger cap and accepts the cap boundary', () => {
+ const create = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_CREATE_TOOL_NAME);
+ const params = create.parameters as z.ZodType;
+ const atCap = { tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS }, () => ({ subject: 'x' })) };
+ assert.equal(params.safeParse(atCap).success, true, `${TASK_LEDGER_MAX_TASKS} tasks (cap) must pass`);
+ const overCap = { tasks: Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, () => ({ subject: 'x' })) };
+ assert.equal(params.safeParse(overCap).success, false, `${TASK_LEDGER_MAX_TASKS + 1} tasks must be rejected at the schema`);
+ });
+
+ test('TaskUpdate schema rejects ids that are not stable tokens and accepts UUID-shaped / simple ids', () => {
+ const update = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_UPDATE_TOOL_NAME);
+ const params = update.parameters as z.ZodType;
+ const reject = ['ab', 'abc\ndef', 'a b', 'X'.repeat(5000), '', 'ghp_abcdefghijklmnopqrstuvwxyz', 'sk-abcdefghi', 'a'.repeat(40)];
+ for (const id of reject) {
+ assert.equal(params.safeParse({ id, status: 'completed' }).success, false, `id ${JSON.stringify(id)} must be rejected`);
+ }
+ const accept = ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2'];
+ for (const id of accept) {
+ assert.equal(params.safeParse({ id, status: 'completed' }).success, true, `id ${id} must pass`);
+ }
+ });
+
+ test('TaskCreate forwards drafts to the store using ctx.sessionId and renders the returned ledger', async () => {
+ const store = new FakeTaskLedgerStore();
+ const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME);
+ const result = await create.impl({ tasks: [{ subject: '写测试' }, { subject: '实现' }] }, fakeContext(SESSION_ID));
+ assert.equal(store.createCalls.length, 1);
+ assert.equal(store.createCalls[0]?.sessionId, SESSION_ID);
+ assert.match(String(result), /写测试/);
+ assert.match(String(result), /实现/);
+ assert.match(String(result), /pending/);
+ });
+
+ test('TaskUpdate forwards only provided fields and renders the returned ledger', async () => {
+ const store = new FakeTaskLedgerStore();
+ const tools = buildTaskLedgerTools({ store });
+ const create = findTool(tools, TASK_CREATE_TOOL_NAME);
+ const update = findTool(tools, TASK_UPDATE_TOOL_NAME);
+ await create.impl({ tasks: [{ subject: '原始' }] }, fakeContext(SESSION_ID));
+
+ const result = await update.impl({ id: 'id-0', status: 'in_progress' }, fakeContext(SESSION_ID));
+ assert.deepEqual(store.updateCalls[0]?.patch, { status: 'in_progress' });
+ assert.match(String(result), /in_progress/);
+ });
+
+ test('TaskCreate result shows only the created tasks (with ids) and total, not the pre-existing ledger', async () => {
+ const store = new FakeTaskLedgerStore();
+ const tools = buildTaskLedgerTools({ store });
+ const create = findTool(tools, TASK_CREATE_TOOL_NAME);
+ // a pre-existing task that must NOT be replayed in the create result
+ await create.impl({ tasks: [{ subject: 'pre-existing' }] }, fakeContext(SESSION_ID));
+ const result = String(await create.impl({ tasks: [{ subject: 'new-task' }] }, fakeContext(SESSION_ID)));
+ assert.match(result, /new-task/, 'result must include the created task');
+ assert.match(result, /ledger total: 2/, 'result must include the ledger total');
+ assert.equal(result.includes('pre-existing'), false, 'result must not replay the pre-existing ledger');
+ // the new task's id is present so the model can update it next
+ const all = await store.list();
+ const newId = all.find((t) => t.subject === 'new-task')?.id;
+ assert.ok(newId, 'new task must have been created');
+ assert.equal(result.includes(newId), true, 'result must include the new task id');
+ });
+
+ test('TaskUpdate result shows only the updated task and total, not the rest of the ledger', async () => {
+ const store = new FakeTaskLedgerStore();
+ const tools = buildTaskLedgerTools({ store });
+ const create = findTool(tools, TASK_CREATE_TOOL_NAME);
+ const update = findTool(tools, TASK_UPDATE_TOOL_NAME);
+ await create.impl({ tasks: [{ subject: 'keep-1' }, { subject: 'keep-2' }, { subject: 'target' }] }, fakeContext(SESSION_ID));
+ const all = await store.list();
+ const target = all.find((t) => t.subject === 'target');
+ assert.ok(target);
+ const result = String(await update.impl({ id: target.id, status: 'completed' }, fakeContext(SESSION_ID)));
+ assert.match(result, /target/, 'result must include the updated task subject');
+ assert.match(result, /ledger total: 3/, 'result must include the ledger total');
+ assert.equal(result.includes('keep-1'), false, 'result must not replay unrelated tasks');
+ assert.equal(result.includes('keep-2'), false, 'result must not replay unrelated tasks');
+ });
+
+ test('tool results scrub secret-like subjects before they persist into history', async () => {
+ // Same samples the core redactSecrets tests use. Tool results replay to
+ // the provider every turn, so redacting only the turn tail is not enough.
+ const store = new FakeTaskLedgerStore();
+ const tools = buildTaskLedgerTools({ store });
+ const create = findTool(tools, TASK_CREATE_TOOL_NAME);
+ const update = findTool(tools, TASK_UPDATE_TOOL_NAME);
+
+ const createResult = String(await create.impl(
+ { tasks: [{ subject: '轮换 Bearer sk-live-secret-token-value' }] },
+ fakeContext(SESSION_ID),
+ ));
+ assert.equal(createResult.includes('sk-live-secret-token-value'), false);
+ assert.match(createResult, /\[redacted\]/);
+
+ const updateResult = String(await update.impl(
+ { id: 'id-0', subject: '换 ghp_abcdefghijklmnopqrstuvwxyz' },
+ fakeContext(SESSION_ID),
+ ));
+ assert.equal(updateResult.includes('ghp_abcdefghijklmnopqrstuvwxyz'), false);
+ });
+
+ test('tool results strip tag variants so a subject cannot smuggle envelope tags into history', async () => {
+ const store = new FakeTaskLedgerStore();
+ const create = findTool(buildTaskLedgerTools({ store }), TASK_CREATE_TOOL_NAME);
+ const variants = [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ];
+ const drafts = variants.map((v) => ({ subject: '正常 ' + v + ' 假指令' }));
+ const result = String(await create.impl({ tasks: drafts }, fakeContext(SESSION_ID)));
+ assert.equal(
+ (result.match(/<\/?task-ledger[^>]*>/gi) || []).length,
+ 0,
+ 'tool result must not contain any task-ledger tag variant, got: ' + JSON.stringify(result),
+ );
+ });
+
+ test('TaskCreate schema enforces non-empty array, non-blank subjects, and the subject length cap', () => {
+ const create = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_CREATE_TOOL_NAME);
+ const schema = create.parameters as z.ZodTypeAny;
+ assert.equal(schema.safeParse({ tasks: [{ subject: 'ok' }] }).success, true);
+ assert.equal(schema.safeParse({ tasks: [] }).success, false);
+ assert.equal(schema.safeParse({ tasks: [{ subject: '' }] }).success, false);
+ assert.equal(schema.safeParse({ tasks: [{ subject: ' ' }] }).success, false);
+ assert.equal(schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS) }] }).success, true);
+ assert.equal(schema.safeParse({ tasks: [{ subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }] }).success, false);
+ assert.equal(schema.safeParse({}).success, false);
+ });
+
+ test('TaskUpdate schema requires id and at least one of status/subject, with the same subject cap', () => {
+ const update = findTool(buildTaskLedgerTools({ store: new FakeTaskLedgerStore() }), TASK_UPDATE_TOOL_NAME);
+ const schema = update.parameters as z.ZodTypeAny;
+ assert.equal(schema.safeParse({ id: 'x', status: 'completed' }).success, true);
+ assert.equal(schema.safeParse({ id: 'x', subject: 'new' }).success, true);
+ assert.equal(schema.safeParse({ id: 'x' }).success, false);
+ assert.equal(schema.safeParse({ status: 'completed' }).success, false);
+ assert.equal(schema.safeParse({ id: 'x', status: 'bogus' }).success, false);
+ assert.equal(schema.safeParse({ id: 'x', subject: 'x'.repeat(TASK_SUBJECT_MAX_CHARS + 1) }).success, false);
+ });
+});
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 4a18b72683..e593568c7f 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -128,6 +128,11 @@ export {
buildSubagentSpawnTool,
buildSubagentToolGroup,
} from './subagent-tools.js';
+export {
+ TASK_CREATE_TOOL_NAME,
+ TASK_UPDATE_TOOL_NAME,
+ buildTaskLedgerTools,
+} from './task-ledger-tools.js';
export {
deriveToolArtifactCandidates,
extractStdoutRedirectPath,
diff --git a/packages/runtime/src/task-ledger-tools.ts b/packages/runtime/src/task-ledger-tools.ts
new file mode 100644
index 0000000000..952543f789
--- /dev/null
+++ b/packages/runtime/src/task-ledger-tools.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+import {
+ TASK_STATUSES,
+ TASK_SUBJECT_MAX_CHARS,
+ TASK_LEDGER_MAX_TASKS,
+ TASK_ID_MAX_CHARS,
+ isSafeTaskId,
+ renderSafeTaskLedgerText,
+ type Task,
+ type TaskLedgerStore,
+} from '@maka/core/task-ledger';
+import type { MakaTool } from './tool-runtime.js';
+
+// PascalCase matches the model-facing builtin tools (Bash/Read/Write); the
+// snake_case convention is reserved for the agent-orchestration family
+// (agent_spawn/agent_list).
+export const TASK_CREATE_TOOL_NAME = 'TaskCreate';
+export const TASK_UPDATE_TOOL_NAME = 'TaskUpdate';
+
+export function buildTaskLedgerTools(deps: { store: TaskLedgerStore }): MakaTool[] {
+ return [buildTaskCreateTool(deps.store), buildTaskUpdateTool(deps.store)];
+}
+
+function buildTaskCreateTool(store: TaskLedgerStore): MakaTool<{ tasks: Array<{ subject: string }> }, string> {
+ return {
+ name: TASK_CREATE_TOOL_NAME,
+ displayName: 'Task Create',
+ description:
+ 'Add one or more tasks to the session task ledger. The full updated ledger is re-shown each turn, '
+ + 'so use this to record work you plan to do; update status with TaskUpdate as you progress.',
+ parameters: z.object({
+ tasks: z.array(z.object({
+ subject: z.string().trim().min(1).max(TASK_SUBJECT_MAX_CHARS)
+ .describe(`Short imperative description of the task (max ${TASK_SUBJECT_MAX_CHARS} characters).`),
+ })).min(1).max(TASK_LEDGER_MAX_TASKS).describe('One or more tasks to add. Each starts in the pending state.'),
+ }),
+ // Pure local session state, no external side effect (cf. agent_list).
+ permissionRequired: false,
+ impl: async (input, ctx) => {
+ const { created, total } = await store.create(ctx.sessionId, input.tasks);
+ // Tool results persist into session history and replay every turn; the
+ // turn tail already re-injects the full ledger each turn, so the tool
+ // result only echoes the created tasks (with their ids, so the model can
+ // update them next) and the new total -- not the whole ledger, which would
+ // duplicate the tail and bloat history under a large ledger.
+ return `Created ${created.length} task(s); ledger total: ${total}.\n${renderSafeTaskLedgerText(created)}`;
+ },
+ };
+}
+
+function buildTaskUpdateTool(
+ store: TaskLedgerStore,
+): MakaTool<{ id: string; status?: typeof TASK_STATUSES[number]; subject?: string }, string> {
+ return {
+ name: TASK_UPDATE_TOOL_NAME,
+ displayName: 'Task Update',
+ description:
+ 'Update a task in the session task ledger by id. Provide status and/or a revised subject. '
+ + 'Mark tasks in_progress when you start them and completed (or cancelled) when done.',
+ parameters: z.object({
+ id: z.string().min(1).max(TASK_ID_MAX_CHARS).refine(isSafeTaskId, 'Task id must be a stable token (alphanumeric plus . _ : -, max 64 chars) from the current ledger.').describe('Task id from the current ledger.'),
+ status: z.enum(TASK_STATUSES).optional().describe('New task status.'),
+ subject: z.string().trim().min(1).max(TASK_SUBJECT_MAX_CHARS).optional()
+ .describe(`Revised task description (max ${TASK_SUBJECT_MAX_CHARS} characters).`),
+ }).superRefine((input, ctx) => {
+ if (input.status === undefined && input.subject === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Provide at least one of status or subject.',
+ });
+ }
+ }),
+ permissionRequired: false,
+ impl: async (input, ctx) => {
+ const { updated, total } = await store.update(ctx.sessionId, input.id, {
+ ...(input.status !== undefined ? { status: input.status } : {}),
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
+ });
+ return `Updated 1 task; ledger total: ${total}.\n${renderSafeTaskLedgerText([updated])}`;
+ },
+ };
+}
+
diff --git a/packages/storage/src/__tests__/task-ledger-store.test.ts b/packages/storage/src/__tests__/task-ledger-store.test.ts
new file mode 100644
index 0000000000..ef6086f089
--- /dev/null
+++ b/packages/storage/src/__tests__/task-ledger-store.test.ts
@@ -0,0 +1,319 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { TASK_LEDGER_MAX_TASKS, TASK_SUBJECT_MAX_CHARS } from '@maka/core/task-ledger';
+import { createTaskLedgerStore } from '../task-ledger-store.js';
+
+const SESSION_ID = 'sess-abc';
+
+async function tempRoot(): Promise {
+ return mkdtemp(join(tmpdir(), 'maka-task-ledger-'));
+}
+
+function tasksFilePath(root: string): string {
+ return join(root, 'sessions', SESSION_ID, 'tasks.json');
+}
+
+describe('TaskLedgerStore', () => {
+ it('creates tasks with normalized subjects and pending status, returning created tasks and the new total', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+
+ const { created, total } = await store.create(SESSION_ID, [{ subject: ' 写测试 ' }, { subject: '实现功能' }]);
+ assert.equal(created.length, 2);
+ assert.equal(created[0]?.subject, '写测试');
+ assert.equal(created[0]?.status, 'pending');
+ assert.equal(typeof created[0]?.id, 'string');
+ assert.equal(created[0]?.createdAt, created[0]?.updatedAt);
+ assert.equal(total, 2);
+
+ const reloaded = await createTaskLedgerStore(root).list(SESSION_ID);
+ assert.equal(reloaded.length, 2);
+ assert.deepEqual(reloaded.map((t) => t.subject), ['写测试', '实现功能']);
+
+ const raw = JSON.parse(await readFile(tasksFilePath(root), 'utf8')) as unknown[];
+ assert.equal(raw.length, 2);
+ });
+
+ it('lists an empty ledger when the file does not exist', async () => {
+ const root = await tempRoot();
+ assert.deepEqual(await createTaskLedgerStore(root).list(SESSION_ID), []);
+ });
+
+ it('updates a task status and subject, returning the updated task and the new total', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ const { created: [task], total: afterCreate } = await store.create(SESSION_ID, [{ subject: '原始' }, { subject: '其他' }]);
+ assert.ok(task);
+ assert.equal(afterCreate, 2);
+
+ const { updated, total } = await store.update(SESSION_ID, task.id, { status: 'in_progress', subject: '改过' });
+ assert.equal(updated.status, 'in_progress');
+ assert.equal(updated.subject, '改过');
+ assert.ok(updated.updatedAt >= task.updatedAt);
+ assert.equal(updated.createdAt, task.createdAt);
+ // total is the post-mutation count from inside the write queue; re-read the
+ // ledger to verify the updated task landed and the file matches it.
+ assert.equal(total, 2);
+ const all = await store.list(SESSION_ID);
+ assert.deepEqual(all.find((t) => t.id === task.id), updated);
+
+ const reloaded = await createTaskLedgerStore(root).list(SESSION_ID);
+ assert.deepEqual(reloaded, all);
+ });
+
+ it('rejects an unknown task id, an empty patch, an invalid status, and empty create drafts', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ const { created: [task] } = await store.create(SESSION_ID, [{ subject: 'x' }]);
+ assert.ok(task);
+
+ await assert.rejects(() => store.update(SESSION_ID, 'no-such-id', { status: 'completed' }), /No such task/);
+ await assert.rejects(() => store.update(SESSION_ID, task.id, {}), /at least one/);
+ await assert.rejects(() => store.update(SESSION_ID, task.id, { status: 'bogus' }), /Task status/);
+ await assert.rejects(() => store.create(SESSION_ID, []), /at least one/);
+ await assert.rejects(() => store.create(SESSION_ID, [{ subject: ' ' }]), /empty/);
+ });
+
+ it('does not rewrite the file when the update target does not exist', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ await store.create(SESSION_ID, [{ subject: 'x' }]);
+ const before = await readFile(tasksFilePath(root), 'utf8');
+
+ await assert.rejects(() => store.update(SESSION_ID, 'no-such-id', { status: 'completed' }), /No such task/);
+
+ const after = await readFile(tasksFilePath(root), 'utf8');
+ assert.equal(after, before);
+ });
+
+ it('degrades a corrupt ledger to an empty list on the render path', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), 'not json at all', 'utf8');
+ assert.deepEqual(await createTaskLedgerStore(root).list(SESSION_ID), []);
+ });
+
+ it('refuses to mutate over a corrupt ledger and leaves the file untouched', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ const store = createTaskLedgerStore(root);
+
+ for (const corrupt of ['not json at all', '{"not":"an array"}']) {
+ await writeFile(tasksFilePath(root), corrupt, 'utf8');
+ await assert.rejects(
+ () => store.create(SESSION_ID, [{ subject: '新任务' }]),
+ /corrupt; refusing to overwrite/,
+ );
+ await assert.rejects(
+ () => store.update(SESSION_ID, 'any-id', { status: 'completed' }),
+ /corrupt; refusing to overwrite/,
+ );
+ // The mutation must not have replaced the damaged file with fn([]).
+ assert.equal(await readFile(tasksFilePath(root), 'utf8'), corrupt);
+ // The render path still degrades to empty so turns are not wedged.
+ assert.deepEqual(await store.list(SESSION_ID), []);
+ }
+ });
+
+ it('drops malformed entries while keeping valid ones', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), JSON.stringify([
+ { id: 'good', subject: '有效', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'bad-status', subject: 'x', status: 'nope', createdAt: 1, updatedAt: 1 },
+ { subject: 'no id', status: 'pending', createdAt: 1, updatedAt: 1 },
+ 'garbage',
+ ]), 'utf8');
+ const tasks = await createTaskLedgerStore(root).list(SESSION_ID);
+ assert.equal(tasks.length, 1);
+ assert.equal(tasks[0]?.id, 'good');
+ });
+
+ it('re-applies subject normalization on read: discards overlong/blank/empty subjects and normalizes whitespace', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), JSON.stringify([
+ { id: 'overlong', subject: 'X'.repeat(TASK_SUBJECT_MAX_CHARS + 1), status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'blank', subject: ' ', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'empty', subject: '', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'whitespace', subject: 'a\t\tb\n\nc d', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'good', subject: '有效', status: 'pending', createdAt: 1, updatedAt: 1 },
+ ]), 'utf8');
+ const tasks = await createTaskLedgerStore(root).list(SESSION_ID);
+ // overlong/blank/empty subjects are discarded per-record; good + whitespace survive.
+ assert.equal(tasks.length, 2, `expected 2 surviving tasks, got ${tasks.length}: ${JSON.stringify(tasks.map((t) => t.id))}`);
+ const ids = tasks.map((t) => t.id);
+ assert.ok(ids.includes('good'));
+ assert.ok(ids.includes('whitespace'));
+ // whitespace subject is normalized (collapse + trim) on read.
+ const ws = tasks.find((t) => t.id === 'whitespace');
+ assert.equal(ws?.subject, 'a b c d', `expected normalized subject, got ${JSON.stringify(ws?.subject)}`);
+ });
+
+ it('treats an over-cap tasks.json as corrupt: list() rejects and mutate stays fail-closed', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ const overcap = Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, (_, i) => ({
+ id: `cap-${i}`, subject: `任务${i}`, status: 'pending', createdAt: i, updatedAt: i,
+ }));
+ await writeFile(tasksFilePath(root), JSON.stringify(overcap), 'utf8');
+ const store = createTaskLedgerStore(root);
+ // render path degrades to an empty list (readForRender try/catches the over-cap file)
+ assert.deepEqual(await store.list(SESSION_ID), []);
+ // mutate path stays fail-closed: a create must not silently truncate-and-overwrite the over-cap file
+ await assert.rejects(() => store.create(SESSION_ID, [{ subject: '新任务' }]), /corrupt|limit|exceed/i);
+ // the file is left untouched (not truncated)
+ const raw = await readFile(tasksFilePath(root), 'utf8');
+ assert.equal(JSON.parse(raw).length, TASK_LEDGER_MAX_TASKS + 1);
+ });
+
+ it('treats a tasks.json with duplicate ids as corrupt: render degrades to empty, mutate stays fail-closed', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), JSON.stringify([
+ { id: 'dup-id', subject: 'first', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'dup-id', subject: 'second', status: 'pending', createdAt: 2, updatedAt: 2 },
+ { id: 'uniq', subject: 'unique', status: 'pending', createdAt: 3, updatedAt: 3 },
+ ]), 'utf8');
+ const store = createTaskLedgerStore(root);
+ // render path degrades to empty: no duplicate id reaches the turn tail
+ // (two same-id tasks would be indistinguishable to the model).
+ assert.deepEqual(await store.list(SESSION_ID), []);
+ // mutate path stays fail-closed: an update must not silently keep both
+ // dups and rewrite a "half-correct" file (first updated, second stale).
+ await assert.rejects(() => store.update(SESSION_ID, 'dup-id', { status: 'completed' }), /corrupt|duplicate|ambiguous/i);
+ const raw = await readFile(tasksFilePath(root), 'utf8');
+ assert.equal(JSON.parse(raw).length, 3, 'file must be left untouched');
+ });
+
+ it('rejects non-finite timestamps (1e999 -> Infinity) so they cannot round-trip to null and vanish', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ // raw JSON with 1e999, which JSON.parse reads as Infinity; JSON.stringify of
+ // Infinity is null, so writing via JSON.stringify could not reproduce this --
+ // only a hand-edited or legacy file carries it.
+ await writeFile(tasksFilePath(root),
+ '[{"id":"good","subject":"ok","status":"pending","createdAt":1,"updatedAt":1},'
+ + '{"id":"bad-ts","subject":"inf","status":"pending","createdAt":1e999,"updatedAt":1e999}]',
+ 'utf8');
+ const store = createTaskLedgerStore(root);
+ // read path drops the non-finite record (render degrades to the valid one)
+ assert.deepEqual((await store.list(SESSION_ID)).map((t) => t.id), ['good']);
+ // mutate path: a create must not round-trip the Infinity to null
+ await store.create(SESSION_ID, [{ subject: 'after' }]);
+ const raw = JSON.parse(await readFile(tasksFilePath(root), 'utf8')) as Array<{ createdAt: unknown; updatedAt: unknown }>;
+ for (const r of raw) {
+ assert.equal(Number.isFinite(r.createdAt), true, `createdAt must stay finite after mutate, got ${JSON.stringify(r)}`);
+ assert.equal(Number.isFinite(r.updatedAt), true, `updatedAt must stay finite after mutate, got ${JSON.stringify(r)}`);
+ }
+ });
+
+ it('rejects records with unsafe ids (newline, overlong, empty, whitespace) on read', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), JSON.stringify([
+ { id: 'abc\nINJECTED', subject: '换行id', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'X'.repeat(5000), subject: '超长id', status: 'pending', createdAt: 2, updatedAt: 2 },
+ { id: '', subject: '空id', status: 'pending', createdAt: 3, updatedAt: 3 },
+ { id: 'has space', subject: '带空格id', status: 'pending', createdAt: 4, updatedAt: 4 },
+ { id: 'good-id', subject: '正常', status: 'pending', createdAt: 5, updatedAt: 5 },
+ ]), 'utf8');
+ const tasks = await createTaskLedgerStore(root).list(SESSION_ID);
+ assert.equal(tasks.length, 1, `expected only the safe-id record to survive, got ${JSON.stringify(tasks.map((t) => t.id))}`);
+ assert.equal(tasks[0]?.id, 'good-id');
+ });
+
+ it('rejects ids that are not redaction-stable tokens (tag-like, angle brackets, quotes, parens, secret-shaped); keeps UUID-shaped and simple ids', async () => {
+ const root = await tempRoot();
+ await mkdir(join(root, 'sessions', SESSION_ID), { recursive: true });
+ await writeFile(tasksFilePath(root), JSON.stringify([
+ { id: 'ab', subject: 'tag-like', status: 'pending', createdAt: 1, updatedAt: 1 },
+ { id: 'a>b', subject: 'gt', status: 'pending', createdAt: 2, updatedAt: 2 },
+ { id: 'a"b', subject: 'quote', status: 'pending', createdAt: 3, updatedAt: 3 },
+ { id: 'a(b)', subject: 'paren', status: 'pending', createdAt: 4, updatedAt: 4 },
+ { id: 'a=b', subject: 'equals', status: 'pending', createdAt: 5, updatedAt: 5 },
+ // secret-shaped stable tokens: pass the charset/length rules but redactSecrets
+ // would render them as (id: [redacted]), so TaskUpdate on [redacted] would miss.
+ { id: 'ghp_abcdefghijklmnopqrstuvwxyz', subject: 'ghp', status: 'pending', createdAt: 6, updatedAt: 6 },
+ { id: 'sk-abcdefghi', subject: 'sk', status: 'pending', createdAt: 7, updatedAt: 7 },
+ { id: 'a'.repeat(40), subject: 'hex40', status: 'pending', createdAt: 8, updatedAt: 8 },
+ { id: 'AIza' + 'X'.repeat(24), subject: 'aiza', status: 'pending', createdAt: 9, updatedAt: 9 },
+ { id: '123e4567-e89b-12d3-a456-426614174000', subject: 'uuid', status: 'pending', createdAt: 10, updatedAt: 10 },
+ { id: 'good-id_1:2', subject: 'simple', status: 'pending', createdAt: 11, updatedAt: 11 },
+ ]), 'utf8');
+ const tasks = await createTaskLedgerStore(root).list(SESSION_ID);
+ const ids = tasks.map((t) => t.id);
+ // Only ids that are stable tokens AND survive redaction (so the rendered id
+ // equals the stored id) survive; a TaskUpdate on the rendered id then hits.
+ assert.deepEqual(ids, ['123e4567-e89b-12d3-a456-426614174000', 'good-id_1:2']);
+ });
+
+ it('rejects an oversized batch before generating tasks or writing (existing ledger unchanged)', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ await store.create(SESSION_ID, [{ subject: 'seed' }]);
+ const before = await readFile(tasksFilePath(root), 'utf8');
+ // Oversized batch with an invalid draft in the middle: without an early
+ // batch-size check, normalizeCreateTaskInput runs during `drafts.map` and
+ // throws the per-draft subject error; with the early check, the batch is
+ // rejected as a batch before any draft is touched or any id is generated.
+ const batch = Array.from({ length: TASK_LEDGER_MAX_TASKS + 5 }, (_, i) =>
+ i === 2 ? { subject: '' } : { subject: `任务${i}` });
+ await assert.rejects(() => store.create(SESSION_ID, batch), /cap|limit|exceed|batch/i);
+ const after = await readFile(tasksFilePath(root), 'utf8');
+ assert.equal(after, before, 'existing ledger must be unchanged');
+ });
+
+ it('rejects an unsafe session id', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ await assert.rejects(() => store.list('../escape'), /Invalid session id/);
+ });
+
+ it('serializes concurrent creates without losing writes', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ await Promise.all([
+ store.create(SESSION_ID, [{ subject: 'a' }]),
+ store.create(SESSION_ID, [{ subject: 'b' }]),
+ store.create(SESSION_ID, [{ subject: 'c' }]),
+ ]);
+ const tasks = await store.list(SESSION_ID);
+ assert.equal(tasks.length, 3);
+ assert.deepEqual(new Set(tasks.map((t) => t.subject)), new Set(['a', 'b', 'c']));
+ });
+
+ it('enforces the total-task cap inside the write queue without touching the file', async () => {
+ const root = await tempRoot();
+ const store = createTaskLedgerStore(root);
+ const fill = Array.from({ length: TASK_LEDGER_MAX_TASKS }, (_, i) => ({ subject: `t${i}` }));
+ await store.create(SESSION_ID, fill);
+
+ // Over-cap create must reject with a clear total-count message...
+ await assert.rejects(
+ () => store.create(SESSION_ID, [{ subject: 'overflow' }]),
+ new RegExp(`limited to ${TASK_LEDGER_MAX_TASKS} tasks total`),
+ );
+
+ // ...and must not have written anything: the ledger is unchanged.
+ const tasks = await store.list(SESSION_ID);
+ assert.equal(tasks.length, TASK_LEDGER_MAX_TASKS);
+ assert.equal(tasks.some((t) => t.subject === 'overflow'), false);
+
+ // Completing tasks does not free capacity: the cap is on total count.
+ const first = tasks[0];
+ assert.ok(first);
+ await store.update(SESSION_ID, first.id, { status: 'completed' });
+ await assert.rejects(() => store.create(SESSION_ID, [{ subject: 'still-over' }]), /hard runaway guard/);
+
+ // A single batch larger than the cap rejects at the front door (per-batch
+ // cap, before generating ids), so the ledger stays empty.
+ const freshStore = createTaskLedgerStore(await tempRoot());
+ const oversizedBatch = Array.from({ length: TASK_LEDGER_MAX_TASKS + 1 }, (_, i) => ({ subject: `b${i}` }));
+ await assert.rejects(() => freshStore.create(SESSION_ID, oversizedBatch), /per-batch cap/);
+ assert.deepEqual(await freshStore.list(SESSION_ID), []);
+ });
+});
diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts
index ce0674b2a6..cb9676101e 100644
--- a/packages/storage/src/index.ts
+++ b/packages/storage/src/index.ts
@@ -18,3 +18,4 @@ export * from './settings-store.js';
export * from './telemetry-repo.js';
export * from './artifact-store.js';
export * from './plan-reminder-store.js';
+export * from './task-ledger-store.js';
diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts
index bef4e81326..65f0d3ef29 100644
--- a/packages/storage/src/session-store.ts
+++ b/packages/storage/src/session-store.ts
@@ -367,13 +367,14 @@ class FileSessionStore implements SessionStore {
}
}
-function assertSafeSessionId(sessionId: string): void {
+/** Shared guard for stores that derive filesystem paths from a session id. */
+export function assertSafeSessionId(sessionId: string): void {
if (!isSafeSessionId(sessionId)) {
throw new Error('Invalid session id');
}
}
-function isSafeSessionId(sessionId: string): boolean {
+export function isSafeSessionId(sessionId: string): boolean {
return SESSION_ID_PATTERN.test(sessionId);
}
diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts
new file mode 100644
index 0000000000..f1446ee695
--- /dev/null
+++ b/packages/storage/src/task-ledger-store.ts
@@ -0,0 +1,232 @@
+import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import {
+ TASK_LEDGER_MAX_TASKS,
+ isSafeTaskId,
+ isTaskStatus,
+ normalizeCreateTaskInput,
+ normalizeTaskSubject,
+ normalizeUpdateTaskInput,
+ type Task,
+ type TaskLedgerStore,
+} from '@maka/core/task-ledger';
+import { chainWrite } from './write-queue.js';
+import { assertSafeSessionId } from './session-store.js';
+
+export type { TaskLedgerStore } from '@maka/core/task-ledger';
+
+export function createTaskLedgerStore(workspaceRoot: string): TaskLedgerStore {
+ return new FileTaskLedgerStore(workspaceRoot);
+}
+
+class FileTaskLedgerStore implements TaskLedgerStore {
+ private readonly sessionsRoot: string;
+ private readonly writeQueues = new Map>();
+
+ constructor(workspaceRoot: string) {
+ this.sessionsRoot = join(workspaceRoot, 'sessions');
+ }
+
+ async list(sessionId: string): Promise {
+ assertSafeSessionId(sessionId);
+ return this.readForRender(sessionId);
+ }
+
+ async create(sessionId: string, drafts: unknown): Promise<{ created: Task[]; total: number }> {
+ assertSafeSessionId(sessionId);
+ if (!Array.isArray(drafts) || drafts.length === 0) {
+ throw new Error('TaskCreate requires at least one task draft');
+ }
+ // Front-door the per-batch cap before generating ids or normalizing drafts:
+ // a single call can never add more than the absolute ledger cap, and rejecting
+ // here avoids generating N uuids for a batch the write-queue total check
+ // would refuse anyway. The total (existing + new) cap is still enforced
+ // inside the serialized mutate callback below.
+ if (drafts.length > TASK_LEDGER_MAX_TASKS) {
+ throw new Error(
+ `TaskCreate batch of ${drafts.length} tasks exceeds the ${TASK_LEDGER_MAX_TASKS}-task per-batch cap; split the work into smaller calls.`,
+ );
+ }
+ const now = Date.now();
+ const created: Task[] = drafts.map((draft) => {
+ const normalized = normalizeCreateTaskInput(draft);
+ if (!normalized.ok) throw new Error(normalized.message);
+ return {
+ id: randomUUID(),
+ subject: normalized.value.subject,
+ status: 'pending',
+ createdAt: now,
+ updatedAt: now,
+ };
+ });
+ // Cap check runs inside the serialized mutate callback (after reading the
+ // current ledger) so concurrent creates cannot race past the limit, and a
+ // rejected create never touches the file.
+ const all = await this.mutate(sessionId, (tasks) => {
+ if (tasks.length + created.length > TASK_LEDGER_MAX_TASKS) {
+ throw new Error(
+ `Task ledger is limited to ${TASK_LEDGER_MAX_TASKS} tasks total per session `
+ + `(currently ${tasks.length}, adding ${created.length}). This is a hard runaway guard on the `
+ + 'total count — completed or cancelled tasks still count, so batch related work into fewer, '
+ + 'coarser tasks instead.',
+ );
+ }
+ return [...tasks, ...created];
+ });
+ return { created, total: all.length };
+ }
+
+ async update(sessionId: string, id: string, patch: unknown): Promise<{ updated: Task; total: number }> {
+ assertSafeSessionId(sessionId);
+ const normalized = normalizeUpdateTaskInput(patch);
+ if (!normalized.ok) throw new Error(normalized.message);
+ const now = Date.now();
+ let updated: Task | undefined;
+ const all = await this.mutate(sessionId, (tasks) => {
+ // Locate the target before producing a new list: an unknown id must
+ // fail inside the callback without rewriting an identical file.
+ const index = tasks.findIndex((task) => task.id === id);
+ const current = index === -1 ? undefined : tasks[index];
+ if (!current) throw new Error(`No such task: ${id}`);
+ updated = {
+ ...current,
+ ...(normalized.value.subject !== undefined ? { subject: normalized.value.subject } : {}),
+ ...(normalized.value.status !== undefined ? { status: normalized.value.status } : {}),
+ updatedAt: now,
+ };
+ const next = [...tasks];
+ next[index] = updated;
+ return next;
+ });
+ if (!updated) throw new Error(`No such task: ${id}`);
+ return { updated, total: all.length };
+ }
+
+ private filePath(sessionId: string): string {
+ return join(this.sessionsRoot, sessionId, 'tasks.json');
+ }
+
+ /**
+ * Render-path read: any failure degrades to an empty list so a damaged
+ * ledger never wedges a turn. Never used as the base of a write.
+ */
+ private async readForRender(sessionId: string): Promise {
+ try {
+ return decodeTasks(await readFile(this.filePath(sessionId), 'utf8'));
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * Mutate-path read: only ENOENT means a legitimately fresh ledger. Any
+ * other read error, undecodable JSON, or a non-array payload throws so the
+ * mutation fails closed instead of rebuilding the ledger from [] and
+ * silently overwriting whatever is on disk.
+ */
+ private async readForMutate(sessionId: string): Promise {
+ let text: string;
+ try {
+ text = await readFile(this.filePath(sessionId), 'utf8');
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
+ throw error;
+ }
+ try {
+ return decodeTasks(text);
+ } catch (error) {
+ throw new Error(
+ `Task ledger file for session ${sessionId} is corrupt; refusing to overwrite it: `
+ + (error instanceof Error ? error.message : String(error)),
+ );
+ }
+ }
+
+ private async mutate(sessionId: string, fn: (tasks: Task[]) => Task[]): Promise {
+ let next: Task[] = [];
+ await chainWrite(this.writeQueues, sessionId, async () => {
+ const current = await this.readForMutate(sessionId);
+ next = fn(current);
+ await this.write(sessionId, next);
+ });
+ return next;
+ }
+
+ private async write(sessionId: string, tasks: Task[]): Promise {
+ const filePath = this.filePath(sessionId);
+ await mkdir(dirname(filePath), { recursive: true });
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
+ await writeFile(tempPath, JSON.stringify(tasks, null, 2) + '\n', 'utf8');
+ await rename(tempPath, filePath);
+ }
+}
+
+function decodeTasks(text: string): Task[] {
+ const parsed = JSON.parse(text) as unknown;
+ if (!Array.isArray(parsed)) {
+ throw new Error('expected a JSON array of tasks');
+ }
+ const tasks: Task[] = [];
+ const seenIds = new Set();
+ for (const value of parsed) {
+ const task = normalizePersistedTask(value);
+ if (!task) continue;
+ // A tasks.json with two records sharing an id would render two
+ // indistinguishable tasks in the turn tail, and TaskUpdate's first-match
+ // lookup would only ever touch the first -- the second is unreachable and
+ // a mutate would silently keep both. Treat a duplicate id as corrupt so
+ // the render path degrades to empty and the mutate path stays fail-closed
+ // instead of rewriting a "half-correct" file.
+ if (seenIds.has(task.id)) {
+ throw new Error(`task ledger has a duplicate id "${task.id}"; refusing to load an ambiguous ledger`);
+ }
+ seenIds.add(task.id);
+ tasks.push(task);
+ }
+ // Enforce the same total-task cap as the write path on read. A hand-edited,
+ // legacy, or externally-written tasks.json could otherwise carry an
+ // unbounded number of valid records, which `list()` would inject into the
+ // turn tail every turn. Treat over-cap as corrupt so the render path
+ // degrades to empty (its caller already try/catches) and the mutate path
+ // stays fail-closed instead of silently truncating-and-overwriting.
+ if (tasks.length > TASK_LEDGER_MAX_TASKS) {
+ throw new Error(
+ `task ledger has ${tasks.length} tasks, exceeding the ${TASK_LEDGER_MAX_TASKS}-task cap; refusing to load an unbounded ledger`,
+ );
+ }
+ return tasks;
+}
+
+function normalizePersistedTask(value: unknown): Task | undefined {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
+ const record = value as Partial;
+ // Timestamps must be finite: a hand-edited `1e999` parses to Infinity, and
+ // JSON.stringify(Infinity) writes null, so the record would silently vanish
+ // on the next write. Reject it up front (per-record drop) instead.
+ if (
+ typeof record.id !== 'string' ||
+ !isSafeTaskId(record.id) ||
+ typeof record.createdAt !== 'number' ||
+ !Number.isFinite(record.createdAt) ||
+ typeof record.updatedAt !== 'number' ||
+ !Number.isFinite(record.updatedAt) ||
+ !isTaskStatus(record.status)
+ ) {
+ return undefined;
+ }
+ // Re-apply the same subject normalization as the write path (NFC, whitespace
+ // collapse, trim, length cap, non-empty) so a manually-edited or legacy
+ // tasks.json cannot inject an overlong/blank subject into the turn tail
+ // every turn. Invalid subjects drop the whole record, matching the existing
+ // "single malformed entry discarded" semantic.
+ const subject = normalizeTaskSubject(record.subject);
+ if (!subject.ok) return undefined;
+ return {
+ id: record.id,
+ subject: subject.value,
+ status: record.status,
+ createdAt: record.createdAt,
+ updatedAt: record.updatedAt,
+ };
+}