@brainervirus/workit-core 8.9.0 → 8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.9.0",
3
+ "version": "8.9.1",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -73,11 +73,20 @@ type CompactDecision = {
73
73
  digest: string;
74
74
  choice: string;
75
75
  references: CompactReference[];
76
+ /** Where an inherited decision was recorded ("branch develop"); absent for the task's own. */
77
+ origin?: string;
76
78
  };
77
79
  type CompactMethod = Pick<SelectedMethod, "id" | "assurance"> & { reason: string };
80
+ /** Decisions of a task the current one inherits from (its base branch's task). */
81
+ export type InheritedDecisions = {
82
+ origin: string;
83
+ taskId: string;
84
+ decisions: TaskView["task"]["decisions"];
85
+ };
78
86
  export type CompactTaskContext = {
79
87
  objective: string;
80
- status: TaskView["task"]["status"];
88
+ /** Null when the checkout has no task yet and only inherited decisions apply. */
89
+ status: TaskView["task"]["status"] | null;
81
90
  decisions: CompactDecision[];
82
91
  methods: CompactMethod[];
83
92
  gaps: string[];
@@ -128,54 +137,83 @@ const compactReference = (ref: Ref): CompactReference => {
128
137
  return { kind: ref.kind };
129
138
  };
130
139
 
131
- export function compactTaskContext(view: TaskView): string {
132
- const decisions: CompactDecision[] = view.task.decisions
133
- .slice()
134
- .toSorted((left, right) => {
135
- const leftAt = compactTimestamp(left.recordedAt);
136
- const rightAt = compactTimestamp(right.recordedAt);
137
- if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
138
- return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
139
- })
140
- .slice(0, COMPACT_MAX_ITEMS)
141
- .map((entry) => ({
142
- id: entry.id,
143
- purpose: entry.data.purpose,
144
- status: entry.data.response,
145
- digest: entry.data.digest,
146
- choice:
147
- compactDecisionChoice(entry) ||
148
- `task.inspect taskId=${view.task.id} view=full; decision=${entry.id}`,
149
- references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
150
- }));
151
- const methods: CompactMethod[] = view.task.policy
140
+ type TaskDecision = TaskView["task"]["decisions"][number];
141
+ const newestDecisionFirst = (left: TaskDecision, right: TaskDecision): number => {
142
+ const leftAt = compactTimestamp(left.recordedAt);
143
+ const rightAt = compactTimestamp(right.recordedAt);
144
+ if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
145
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
146
+ };
147
+ /**
148
+ * The task's own decisions (newest first), then the ones it inherits (origin
149
+ * set; the engine passes only unrevoked ones), at most COMPACT_MAX_ITEMS.
150
+ */
151
+ const compactDecisions = (
152
+ sources: Array<{ origin: string | null; taskId: string; decisions: TaskDecision[] } | null>,
153
+ ): CompactDecision[] =>
154
+ sources
155
+ .flatMap((source) =>
156
+ source
157
+ ? source.decisions.toSorted(newestDecisionFirst).map((entry) => ({
158
+ id: entry.id,
159
+ purpose: entry.data.purpose,
160
+ status: entry.data.response,
161
+ digest: entry.data.digest,
162
+ choice:
163
+ compactDecisionChoice(entry) ||
164
+ `task.inspect taskId=${source.taskId} view=full; decision=${entry.id}`,
165
+ references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
166
+ // Undefined (dropped from the JSON) for the task's own decisions.
167
+ origin: source.origin ?? undefined,
168
+ }))
169
+ : [],
170
+ )
171
+ .slice(0, COMPACT_MAX_ITEMS);
172
+
173
+ /**
174
+ * The compact context of `view`'s task plus the decisions it inherits; with
175
+ * a null `view` (the branch has no task of its own yet) only the inherited
176
+ * decisions. Stale evidence is one counted gap with the command that lists it.
177
+ */
178
+ export function compactTaskContext(
179
+ view: TaskView | null,
180
+ inherited: InheritedDecisions | null = null,
181
+ ): string {
182
+ const decisions = compactDecisions([
183
+ view && { origin: null, taskId: view.task.id, decisions: view.task.decisions },
184
+ inherited,
185
+ ]);
186
+ const methods: CompactMethod[] = view?.task.policy
152
187
  ? selectMethods(view.task.policy, view.capabilities).map((method) => ({
153
188
  id: method.id,
154
189
  assurance: method.assurance,
155
190
  reason: compactText(method.reason, COMPACT_TEXT_BYTES) ?? "",
156
191
  }))
157
192
  : [];
193
+ const stale = view?.evidence.filter((entry) => entry.status === "stale").length;
158
194
  const gaps = Array.from(
159
195
  new Set([
160
- ...view.evidence
161
- .filter((entry) => entry.status === "stale")
162
- .map((entry) => `stale evidence: ${entry.evidenceId}`),
163
- ...view.requirements
196
+ ...(stale
197
+ ? [`stale evidence: ${stale}; workit task inspect --task ${view.task.id} --view full`]
198
+ : []),
199
+ ...(view?.requirements ?? [])
164
200
  .filter((entry) => entry.status !== "satisfied")
165
201
  .map((entry) => entry.reason),
166
- ...view.task.progress.blockers.map((entry) => entry.reason),
202
+ ...(view?.task.progress.blockers ?? []).map((entry) => entry.reason),
167
203
  ]),
168
204
  )
169
205
  .toSorted()
170
206
  .slice(0, COMPACT_MAX_ITEMS)
171
207
  .map((gap) => compactText(gap, COMPACT_TEXT_BYTES)!);
172
208
  const context: CompactTaskContext = {
173
- objective: compactText(view.task.intent.data.objective, COMPACT_TEXT_BYTES) ?? "",
174
- status: view.task.status,
209
+ objective: view
210
+ ? (compactText(view.task.intent.data.objective, COMPACT_TEXT_BYTES) ?? "")
211
+ : "no task on this branch yet",
212
+ status: view?.task.status ?? null,
175
213
  decisions,
176
214
  methods,
177
215
  gaps,
178
- nextAction: compactText(view.task.progress.nextAction, COMPACT_TEXT_BYTES),
216
+ nextAction: compactText(view?.task.progress.nextAction ?? null, COMPACT_TEXT_BYTES),
179
217
  };
180
218
  let encoded = JSON.stringify(context);
181
219
  while (
@@ -68,6 +68,7 @@ import { defaultLockTimeout } from "./store-lock";
68
68
  import {
69
69
  compactTaskContext,
70
70
  exportDigest,
71
+ type InheritedDecisions,
71
72
  reconcileResume as reconcileResumeContext,
72
73
  type ResumeReconciliation,
73
74
  } from "./task-context";
@@ -389,6 +390,18 @@ const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
389
390
  "worker.cancel",
390
391
  "state.export",
391
392
  ]);
393
+ /** Worker actions that name their worker: they resolve to that worker's task. */
394
+ const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
395
+ /**
396
+ * The exact CLI command that runs worker `request` (cancel or report, with a
397
+ * valid workerId) on `taskId`, so a retry never depends on the branch.
398
+ */
399
+ const workerCommand = (taskId: string, request: unknown): string => {
400
+ const { action, workerId, reason, report } = request as Record<string, unknown>;
401
+ const rest = JSON.stringify({ reason, report });
402
+ const payload = rest === "{}" ? "" : ` --payload '${rest.replace(/'/gu, "'\\''")}'`;
403
+ return `workit worker ${String(action)} ${String(workerId)} --task ${taskId}${payload}`;
404
+ };
392
405
  /** Recording actions that create the implicit task on first use. */
393
406
  const CREATING_ACTIONS: ReadonlySet<string> = new Set([
394
407
  "task.revise",
@@ -711,6 +724,24 @@ export class WorkitCore {
711
724
  if (!TASK_SCOPED_ACTIONS.has(action)) return success(null, null, request);
712
725
  const root = this.contextRootError();
713
726
  if (!root.ok) return root;
727
+ // A worker belongs to the task it was assigned on, whatever branch is
728
+ // checked out now (#264): worker.cancel/report follow the named worker,
729
+ // a helper's own calls follow its worker id.
730
+ const addressed = WORKER_ADDRESSED_ACTIONS.has(action);
731
+ // A missing or malformed id is invalid_input (from taskForWorker).
732
+ const workerId: unknown = addressed
733
+ ? (value as { workerId?: unknown }).workerId
734
+ : (this.context.workerId ?? null);
735
+ if (workerId !== null) {
736
+ const owner = this.store.taskForWorker(workerId);
737
+ if (!owner.ok) return owner;
738
+ if (owner.data) return success(null, null, { ...request, taskId: owner.data });
739
+ if (addressed)
740
+ return failure(
741
+ "not_found",
742
+ `worker ${workerId as string} is on no open task; find its task with \`workit task status --all\`, then run \`${workerCommand("<task-id>", request)}\``,
743
+ );
744
+ }
714
745
  const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
715
746
  const found = this.store.implicitTask({
716
747
  provenance: provenance(this.context),
@@ -1605,7 +1636,16 @@ export class WorkitCore {
1605
1636
  const resolved = this.implicitTask("worker", request);
1606
1637
  if (!resolved.ok) return resolved;
1607
1638
  const value = resolved.data;
1608
- return this.retryOmittedRevisions(value, () => this.workerOnce(value));
1639
+ const result = this.retryOmittedRevisions(value, () => this.workerOnce(value));
1640
+ // A cancel/report whose task was resolved from the worker id names the
1641
+ // exact command for that task (every host and the CLI see the same text).
1642
+ const { action, taskId } = (value ?? {}) as { action?: unknown; taskId?: unknown };
1643
+ return result.ok ||
1644
+ (request as { taskId?: unknown } | null)?.taskId !== undefined ||
1645
+ typeof taskId !== "string" ||
1646
+ !WORKER_ADDRESSED_ACTIONS.has(`worker.${String(action)}`)
1647
+ ? result
1648
+ : { ...result, error: `${result.error}; run \`${workerCommand(taskId, value)}\`` };
1609
1649
  }
1610
1650
 
1611
1651
  private workerOnce(request: unknown): Result<Entry<Worker>> {
@@ -1892,9 +1932,31 @@ export class WorkitCore {
1892
1932
  * judged against the last recorded candidate, and live staleness is
1893
1933
  * detected by evidence, close, resume, and full inspection, which capture.
1894
1934
  */
1895
- compactContext(taskId: string, freshness?: Freshness): Result<string> {
1935
+ compactContext(
1936
+ taskId: string | null,
1937
+ freshness?: Freshness,
1938
+ /** The base branch's task (TaskStore.originTaskEntry), whose decisions are inherited. */
1939
+ origin: { origin: string; taskId: string } | null = null,
1940
+ ): Result<string> {
1896
1941
  const root = this.contextRootError();
1897
1942
  if (!root.ok) return root;
1943
+ const base = origin && this.store.readStoreTask(origin.taskId);
1944
+ const inherited: InheritedDecisions | null =
1945
+ base?.ok && base.data
1946
+ ? {
1947
+ origin: origin!.origin,
1948
+ taskId: base.data.id,
1949
+ // A revoked decision no longer applies to anything cut from the base.
1950
+ decisions: base.data.decisions.filter((entry) => entry.data.revoked === null),
1951
+ }
1952
+ : null;
1953
+ // No task of its own yet: only what the branch inherits.
1954
+ if (taskId === null)
1955
+ return success(
1956
+ null,
1957
+ null,
1958
+ inherited?.decisions.length ? compactTaskContext(null, inherited) : "",
1959
+ );
1898
1960
  const task = this.store.readTask(taskId);
1899
1961
  if (!task.ok) return task;
1900
1962
  const helper = this.helperTaskGuard(task.data, true);
@@ -1902,7 +1964,7 @@ export class WorkitCore {
1902
1964
  // Per-turn path: the cheap worktree signal, never the object-writing tree hash.
1903
1965
  const view = this.view(task.data, false, freshness ?? signalFreshness(this.store.root));
1904
1966
  if (!view.ok) return view;
1905
- return success(view.revision, view.workspaceRevision, compactTaskContext(view.data));
1967
+ return success(view.revision, view.workspaceRevision, compactTaskContext(view.data, inherited));
1906
1968
  }
1907
1969
 
1908
1970
  private view(
@@ -314,6 +314,70 @@ const isNewerVersion = (candidate: string, current: string): boolean => {
314
314
  };
315
315
  const validId = (value: string): boolean =>
316
316
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
317
+
318
+ const readText = (file: string): string => {
319
+ try {
320
+ return fs.readFileSync(file, "utf8");
321
+ } catch {
322
+ return "";
323
+ }
324
+ };
325
+
326
+ /** `<old> <new> <ident> <unix seconds> <tz>\t<message>`: new sha, seconds, message. */
327
+ const REFLOG_LINE = /^[0-9a-f]+ ([0-9a-f]+) .* (\d+) [+-]\d{4}\t(.*)$/u;
328
+
329
+ /**
330
+ * The branch `branch` was cut from, read from git's reflogs (no git process).
331
+ * Only the branch log's first line counts, and only when it is the creation
332
+ * entry "branch: Created from <start>": an expired, rewritten or missing log
333
+ * says nothing. A branch created from HEAD takes
334
+ * the worktree HEAD log's checkout made by that creation (same new sha, same
335
+ * or next second) onto the branch or one of its earlier names (`aliases`, it
336
+ * may have been renamed since). A remote start (`origin/develop`) is
337
+ * returned as written.
338
+ */
339
+ const branchStart = (
340
+ location: StoreLocation,
341
+ branch: string,
342
+ aliases: readonly string[],
343
+ ): string | null => {
344
+ const first = readText(
345
+ path.join(path.dirname(location.dir), "logs", "refs", "heads", branch),
346
+ ).split("\n", 1)[0];
347
+ // A "Created from" message only ever starts a log (git deletes the log
348
+ // with the branch), so the first line alone decides.
349
+ const created = REFLOG_LINE.exec(first);
350
+ const start = created && /^branch: Created from (\S+)$/u.exec(created[3])?.[1];
351
+ if (!created || !start) return null;
352
+ if (start !== "HEAD") return start;
353
+ if (!location.git) return null;
354
+ for (const line of readText(path.join(location.git.gitDir, "logs", "HEAD")).split("\n")) {
355
+ const entry = REFLOG_LINE.exec(line);
356
+ const moved = entry && /^checkout: moving from (\S+) to (\S+)$/u.exec(entry[3]);
357
+ const seconds = entry ? Number(entry[2]) - Number(created[2]) : -1;
358
+ if (
359
+ moved &&
360
+ entry[1] === created[1] &&
361
+ seconds >= 0 &&
362
+ seconds <= 1 &&
363
+ aliases.includes(moved[2])
364
+ )
365
+ return moved[1];
366
+ }
367
+ return null;
368
+ };
369
+
370
+ /** Whether `ref` (`refs/remotes/origin/develop`) exists, loose or packed. */
371
+ const refExists = (common: string, ref: string): boolean =>
372
+ fs.existsSync(path.join(common, ref)) ||
373
+ readText(path.join(common, "packed-refs")).includes(` ${ref}\n`);
374
+
375
+ /** Resolved bases per checkout and branch, reused while the reflogs they came
376
+ * from are unchanged (one entry per branch a process looked at). */
377
+ const origins = new Map<
378
+ string,
379
+ { signature: string; name: string | null; renames: ReturnType<typeof branchRenames> }
380
+ >();
317
381
  export const sameDirectoryIdentity = (left: string, right: string): boolean => {
318
382
  if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
319
383
  const normalizedLeft = path.resolve(left);
@@ -658,6 +722,94 @@ export class TaskStore {
658
722
  return success(null, null, { key: key.data, bound: bound.data });
659
723
  }
660
724
 
725
+ /**
726
+ * The open task of this checkout that worker `workerId` is assigned to,
727
+ * whichever branch it is bound to: worker commands follow the worker, not
728
+ * the branch checked out now (#264). Null when no open task has it.
729
+ */
730
+ taskForWorker(workerId: unknown): Result<Id | null> {
731
+ if (typeof workerId !== "string" || !validId(workerId))
732
+ return failure(
733
+ "invalid_input",
734
+ "needs a worker id: `workit worker cancel|report <workerId>`",
735
+ );
736
+ const listed = this.listTaskIndex();
737
+ if (!listed.ok) return listed;
738
+ for (const entry of listed.data) {
739
+ if (entry.status === "closed") continue;
740
+ const loaded = this.load(entry.id);
741
+ if (!loaded.ok) return loaded;
742
+ if (loaded.data?.record.workers.some((worker) => worker.id === workerId))
743
+ return success(null, null, entry.id);
744
+ }
745
+ return success(null, null, null);
746
+ }
747
+
748
+ /**
749
+ * The open task whose decisions the current branch inherits: the task of
750
+ * the branch it was cut from (`origin/develop` counts as `develop`), in any
751
+ * checkout of this repository, labelled with its origin ("branch develop").
752
+ * Null off a branch, or when git's reflogs do not name a base.
753
+ */
754
+ originTaskEntry(): Result<{ origin: string; entry: TaskIndexEntry } | null> {
755
+ const key = this.currentKey();
756
+ const location = this.location();
757
+ const branch = key.ok ? key.data.branch : null;
758
+ if (!branch || !location.ok) return success(null, null, null);
759
+ const common = path.dirname(location.data.dir);
760
+ const heads = path.join(common, "logs", "refs", "heads");
761
+ const headLog = location.data.git ? path.join(location.data.git.gitDir, "logs", "HEAD") : "";
762
+ // Per-turn path: reading a long HEAD log and every branch log costs tens
763
+ // of ms, so the resolution is reused until one of these files changes.
764
+ const signatures = [
765
+ path.join(heads, branch),
766
+ headLog,
767
+ heads,
768
+ path.join(common, "packed-refs"),
769
+ ].map((file) => fileSignature(file) ?? "-");
770
+ const slot = `${location.data.dir}\0${headLog}\0${branch}`;
771
+ let resolved = origins.get(slot);
772
+ if (resolved?.signature !== signatures.join("|")) {
773
+ const renames = branchRenames(location.data);
774
+ // The branch's earlier names, newest first: a rename keeps its log.
775
+ const aliases = [branch];
776
+ for (const rename of renames.toReversed())
777
+ if (rename.to === aliases.at(-1)) aliases.push(rename.from);
778
+ const start = branchStart(location.data, branch, aliases);
779
+ const slash = start?.indexOf("/") ?? -1;
780
+ // `origin/develop` is the remote's `develop`: strip the remote only for
781
+ // a remote-tracking ref or a configured remote, never for a local
782
+ // branch or tag that has a slash in its name.
783
+ const remote =
784
+ slash > 0 &&
785
+ !refExists(common, `refs/heads/${start}`) &&
786
+ (refExists(common, `refs/remotes/${start}`) ||
787
+ readText(path.join(common, "config")).includes(`[remote "${start!.slice(0, slash)}"]`));
788
+ const name = start && (remote ? start.slice(slash + 1) : start);
789
+ // Logs only grow (their size is in the signature), so no racy check.
790
+ resolved = {
791
+ signature: signatures.join("|"),
792
+ name: name && !aliases.includes(name) ? name : null,
793
+ renames,
794
+ };
795
+ origins.set(slot, resolved);
796
+ }
797
+ const entry = resolved.name ? this.openTasksForKey(resolved.name, resolved.renames) : null;
798
+ if (entry && !entry.ok) return entry;
799
+ return success(
800
+ null,
801
+ null,
802
+ entry?.data[0] ? { origin: `branch ${resolved.name}`, entry: entry.data[0] } : null,
803
+ );
804
+ }
805
+
806
+ /** The record of an indexed task (`taskId` from the index) in any checkout of
807
+ * this store: read-only, no binding check. */
808
+ readStoreTask(taskId: Id): Result<TaskRecord | null> {
809
+ const loaded = this.load(taskId);
810
+ return loaded.ok ? success(null, null, loaded.data?.record ?? null) : loaded;
811
+ }
812
+
661
813
  // -------------------------------------------------------------------------
662
814
  // writes
663
815
 
@@ -1442,11 +1594,14 @@ export class TaskStore {
1442
1594
  * task follows its branch through `git branch -m`: it counts for the new
1443
1595
  * name, and a branch created later under the old name starts fresh.
1444
1596
  */
1445
- private openTasksForKey(key: string): Result<TaskIndexEntry[]> {
1597
+ private openTasksForKey(
1598
+ key: string,
1599
+ known?: ReturnType<typeof branchRenames>,
1600
+ ): Result<TaskIndexEntry[]> {
1446
1601
  const all = this.listStoreIndex();
1447
1602
  if (!all.ok) return all;
1448
- const location = this.location();
1449
- const renames = location.ok ? branchRenames(location.data) : [];
1603
+ const location = known ? null : this.location();
1604
+ const renames = known ?? (location?.ok ? branchRenames(location.data) : []);
1450
1605
  return success(
1451
1606
  null,
1452
1607
  null,
@@ -74,6 +74,13 @@ export const currentTaskEntry = (
74
74
  * CLI (`workit_cli`, which creates implicit tasks) are tooling, not
75
75
  * conversations.
76
76
  */
77
+ /** Whether a host conversation other than `session` is bound to the task. */
78
+ const otherConversation = (entry: TaskIndexEntry, session: SessionHandle): boolean =>
79
+ entry.sessions.some(
80
+ (item) =>
81
+ item.host !== "workit_cli" && !(item.host === session.host && item.handle === session.handle),
82
+ );
83
+
77
84
  const implicitTaskEntry = (
78
85
  store: TaskStore,
79
86
  entries: TaskIndexEntry[],
@@ -81,13 +88,7 @@ const implicitTaskEntry = (
81
88
  ): TaskIndexEntry | null => {
82
89
  const open = entries.filter(
83
90
  (entry) =>
84
- entry.key !== null &&
85
- entry.status !== "closed" &&
86
- !entry.sessions.some(
87
- (item) =>
88
- item.host !== "workit_cli" &&
89
- !(item.host === session.host && item.handle === session.handle),
90
- ),
91
+ entry.key !== null && entry.status !== "closed" && !otherConversation(entry, session),
91
92
  );
92
93
  if (open.length === 0) return null;
93
94
  const key = store.currentKey();
@@ -174,23 +175,37 @@ export function sessionCompactContext(
174
175
  const entry =
175
176
  currentTaskEntry(listed.data, session, selection) ??
176
177
  implicitTaskEntry(store, listed.data, session);
177
- return entry ? entryCompactContext(store, entry, session, context) : null;
178
+ return entryCompactContext(store, entry, session, context);
178
179
  }
179
180
 
180
181
  function entryCompactContext(
181
182
  store: TaskStore,
182
- entry: TaskIndexEntry,
183
+ entry: TaskIndexEntry | null,
183
184
  session: SessionHandle,
184
185
  context: OperationContext,
185
186
  ): string | null {
187
+ // The task of the branch this one was cut from: its decisions stay in view
188
+ // (labelled with their origin) instead of vanishing on a switch. Another
189
+ // conversation's task is never injected.
190
+ const found = store.originTaskEntry();
191
+ const origin =
192
+ found.ok &&
193
+ found.data &&
194
+ found.data.entry.id !== entry?.id &&
195
+ !otherConversation(found.data.entry, session)
196
+ ? found.data
197
+ : null;
198
+ if (!entry && !origin) return null;
186
199
  const workspace = store.readWorkspace();
187
- if (!workspace.ok || !workspace.data) return null;
200
+ if (!workspace.ok || (entry && !workspace.data)) return null;
188
201
  const slot = `${store.root}\0${session.host}\0${session.handle}`;
189
202
  const key = canonicalJson({
190
- task: entry.id,
191
- revision: entry.revision,
192
- file: entry.file,
193
- workspace: workspace.data.revision,
203
+ task: entry?.id ?? null,
204
+ revision: entry?.revision ?? null,
205
+ file: entry?.file ?? null,
206
+ // The origin task's log signature changes with every write to it.
207
+ origin: origin && origin.entry.file,
208
+ workspace: workspace.data?.revision ?? null,
194
209
  caller: context.caller,
195
210
  workerId: context.workerId ?? null,
196
211
  capabilities: context.capabilities,
@@ -205,18 +220,22 @@ function entryCompactContext(
205
220
  (hit.signal === null || hit.signal === recentSignal(store.root))
206
221
  )
207
222
  return hit.value;
208
- const task = store.readTask(entry.id);
209
- if (!task.ok) return null;
223
+ const task = entry ? store.readTask(entry.id) : null;
224
+ if (task && !task.ok) return null;
210
225
  const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
211
- const observed = task.data.evidence.some(isObservedCheck);
212
- const files = task.data.decisions.flatMap((decision) =>
226
+ const observed = task?.data.evidence.some(isObservedCheck) ?? false;
227
+ const files = (task?.data.decisions ?? []).flatMap((decision) =>
213
228
  decision.data.binding.contentRefs.flatMap((ref) =>
214
229
  ref.kind === "file" ? [path.resolve(store.root, ref.path)] : [],
215
230
  ),
216
231
  );
217
232
  const observedFiles = filesKey(files);
218
- const compact = new WorkitCore(store, context).compactContext(entry.id, freshness);
219
- if (!compact.ok) return null;
233
+ const compact = new WorkitCore(store, context).compactContext(
234
+ entry?.id ?? null,
235
+ freshness,
236
+ origin && { origin: origin.origin, taskId: origin.entry.id },
237
+ );
238
+ if (!compact.ok || !compact.data) return null;
220
239
  cache.delete(slot);
221
240
  cache.set(slot, {
222
241
  key,
@@ -264,9 +283,12 @@ export const sessionContextText = (
264
283
  const entry =
265
284
  currentTaskEntry(listed.data, session, descriptor.context.task) ??
266
285
  implicitTaskEntry(store, listed.data, session);
267
- const text = entry
268
- ? entryCompactContext(store, entry, session, hookOperationContext(input, descriptor))
269
- : null;
286
+ const text = entryCompactContext(
287
+ store,
288
+ entry,
289
+ session,
290
+ hookOperationContext(input, descriptor),
291
+ );
270
292
  if (text) compact = `\n<workit-task-context>${text}</workit-task-context>`;
271
293
  if (options.offer) offer = unfinishedTaskOffer(listed.data, session, entry?.id ?? null);
272
294
  } catch {