@brainervirus/workit-core 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,8 @@ import {
14
14
  type Capability,
15
15
  type Caller,
16
16
  type Decision,
17
+ type Entry,
18
+ type Evidence,
17
19
  type EvidenceEvaluation,
18
20
  type Outcome,
19
21
  type Requirement,
@@ -24,6 +26,16 @@ import {
24
26
  type WorkspaceRecord,
25
27
  } from "./task-contract";
26
28
  import { verifyDecisionContentAtRoot } from "./authority";
29
+ import {
30
+ CHECKS_FILE,
31
+ checkCommand,
32
+ gateCheckNames,
33
+ loadCheckConfig,
34
+ matchesNamedCheck,
35
+ type CheckConfig,
36
+ } from "../check-config";
37
+ import { worktreeSignal, worktreeTree } from "../git/rev";
38
+ import { runtimeVersion } from "./task-store";
27
39
 
28
40
  export type CandidateEnvironment =
29
41
  | readonly (string | { name: string; value?: string | null })[]
@@ -318,9 +330,49 @@ const evidenceScopes = (task: TaskRecord, ids: string[]): Scope[] =>
318
330
  .filter((requirement) => ids.includes(requirement.id))
319
331
  .map((requirement) => requirement.scope);
320
332
 
333
+ /**
334
+ * How observed checks are judged fresh (§2.2). `tree`: the exact worktree tree
335
+ * key (worktreeTree hashes changed files into the object store, so it is only
336
+ * for paths that decide: close, inspect, evidence). `signal`: the cheap
337
+ * stat-cached worktreeSignal for per-turn context, which writes nothing.
338
+ * `current` is computed at most once and is null outside git.
339
+ */
340
+ export type Freshness = { mode: "tree" | "signal"; current: () => string | null };
341
+
342
+ const memo = (compute: () => string | null): (() => string | null) => {
343
+ let value: string | null | undefined;
344
+ return () => {
345
+ if (value === undefined) value = compute();
346
+ return value;
347
+ };
348
+ };
349
+ export const treeFreshness = (root: string): Freshness => ({
350
+ mode: "tree",
351
+ current: memo(() => worktreeTree(root)?.key ?? null),
352
+ });
353
+ export const signalFreshness = (root: string): Freshness => ({
354
+ mode: "signal",
355
+ current: memo(() => worktreeSignal(root)),
356
+ });
357
+
358
+ /** Evidence the workit CLI itself observed (`workit check`), as opposed to an agent's report. */
359
+ export const isObservedCheck = (entry: Entry<Evidence>): boolean =>
360
+ entry.data.kind === "check" &&
361
+ entry.data.observation?.observer === "workit_cli" &&
362
+ entry.provenance?.kind === "host_observed" &&
363
+ entry.provenance.host === "workit_cli";
364
+
365
+ /**
366
+ * Evidence status against the current state. Observed checks keyed to a tree
367
+ * are fresh while the worktree is unchanged (§2.2), judged by `freshness`
368
+ * (exact tree key, or the cheap signal on per-turn paths); a check that
369
+ * changed the worktree itself is stale (fail safe), and a closed task keeps
370
+ * the recorded status. Without `freshness` they read stale.
371
+ */
321
372
  export function evaluateEvidence(
322
373
  task: TaskRecord,
323
374
  candidate: Candidate | null,
375
+ freshness?: Freshness,
324
376
  ): EvidenceEvaluation[] {
325
377
  const current = candidate ?? task.candidates.at(-1) ?? null;
326
378
  return task.evidence.map((entry) => {
@@ -331,6 +383,28 @@ export function evaluateEvidence(
331
383
  status: "failed" as const,
332
384
  reason: "failed evidence remains historical",
333
385
  };
386
+ const observation = isObservedCheck(entry) ? evidence.observation : undefined;
387
+ if (observation && observation.tree !== null) {
388
+ if (task.status === "closed")
389
+ return { evidenceId: entry.id, status: evidence.result, reason: "recorded before close" };
390
+ if (observation.modifiedWorktree)
391
+ return {
392
+ evidenceId: entry.id,
393
+ status: "stale" as const,
394
+ reason: "the check changed the worktree while it ran; ignore or clean its outputs",
395
+ };
396
+ const recorded = freshness?.mode === "signal" ? observation.signal : observation.tree;
397
+ if (recorded !== null && freshness && recorded === freshness.current())
398
+ return { evidenceId: entry.id, status: evidence.result, reason: "worktree is unchanged" };
399
+ return {
400
+ evidenceId: entry.id,
401
+ status: "stale" as const,
402
+ reason:
403
+ freshness?.mode === "signal"
404
+ ? "the worktree may have changed since the check ran (quick signal; inspect re-checks exactly)"
405
+ : "the worktree changed since the check ran",
406
+ };
407
+ }
334
408
  if (evidence.kind === "check" || evidence.kind === "review") {
335
409
  if (!evidence.beforeCandidateId || !evidence.candidateId)
336
410
  return {
@@ -499,6 +573,82 @@ const expectedKinds = (dimension: Requirement["dimension"], ruleId?: string): st
499
573
  return "worker report";
500
574
  };
501
575
 
576
+ /** Close-time testing/verification gates accept only CLI-observed checks (D5, D14). */
577
+ const observedGate = (requirement: Requirement): boolean =>
578
+ (requirement.dimension === "testing" || requirement.dimension === "verification") &&
579
+ requirement.before === "close";
580
+
581
+ type GateResult = { satisfied: string[]; considered: string[]; reason: string };
582
+
583
+ /**
584
+ * A close gate over CLI-observed checks. Only a fresh passing run of one of
585
+ * the gate's named checks whose argv is still its configured command
586
+ * satisfies it; ad-hoc runs never do, and with no named check for the gate
587
+ * the reason says how to configure one. Agent-reported checks are notes and
588
+ * never count. A broken check config fails closed.
589
+ */
590
+ function evaluateObservedGate(
591
+ task: TaskRecord,
592
+ requirement: Requirement,
593
+ statuses: Map<string, EvidenceEvaluation["status"]>,
594
+ config: CheckConfig,
595
+ ): GateResult {
596
+ const names = gateCheckNames(config, requirement.dimension);
597
+ const observed = task.evidence.filter(isObservedCheck);
598
+ const fresh = observed.filter(
599
+ (entry) => statuses.get(entry.id) === "passed" && entry.data.observation?.exitCode === 0,
600
+ );
601
+ const named = (entry: Entry<Evidence>): boolean => {
602
+ const observation = entry.data.observation!;
603
+ return (
604
+ observation.configured &&
605
+ !observation.shell &&
606
+ names.includes(observation.name ?? "") &&
607
+ matchesNamedCheck(config, observation.name, observation.argv, observation.cwd)
608
+ );
609
+ };
610
+ const considered = observed.map((entry) => entry.id);
611
+ const run = checkCommand(names[0] ?? "<name>", runtimeVersion());
612
+ if (config.error)
613
+ return {
614
+ satisfied: [],
615
+ considered,
616
+ reason: `check config is invalid (${config.error}); fix it, then run ${run}`,
617
+ };
618
+ if (!names.length)
619
+ return {
620
+ satisfied: [],
621
+ considered,
622
+ reason:
623
+ requirement.dimension === "testing" && config.checks.length
624
+ ? `no \`test\` check is configured; add one (or gates.testing) to ${CHECKS_FILE}, then run ${checkCommand("test", runtimeVersion())}`
625
+ : `no checks are configured or detected; add ${CHECKS_FILE} (committed, so it shows in the diff), e.g. {"checks":{"test":"<your test command>"}}, then run ${checkCommand("test", runtimeVersion())}`,
626
+ };
627
+ const satisfied = fresh.filter(named);
628
+ if (satisfied.length)
629
+ return {
630
+ satisfied: satisfied.map((entry) => entry.id),
631
+ considered,
632
+ reason: `a fresh passing configured check (${names.join("|")}) was observed by workit check`,
633
+ };
634
+ const reported = task.evidence.some(
635
+ (entry) =>
636
+ entry.data.kind === "check" &&
637
+ !isObservedCheck(entry) &&
638
+ entry.data.requirementIds.includes(requirement.id),
639
+ );
640
+ const reason = fresh.length
641
+ ? `ad-hoc checks do not satisfy a configured gate; run ${run}`
642
+ : observed.some((entry) => statuses.get(entry.id) === "stale")
643
+ ? `observed checks are stale (the worktree changed); run ${run}`
644
+ : observed.some((entry) => statuses.get(entry.id) === "failed")
645
+ ? `the observed check failed; fix it and run ${run}`
646
+ : reported
647
+ ? `agent-reported checks are notes, not evidence; run ${run}`
648
+ : `no CLI-observed check (needs kind:check observed by workit check); run ${run}`;
649
+ return { satisfied: [], considered, reason };
650
+ }
651
+
502
652
  export function evaluateRequirements(
503
653
  task: TaskRecord,
504
654
  workspace: WorkspaceRecord,
@@ -506,8 +656,12 @@ export function evaluateRequirements(
506
656
  candidate: Candidate | null,
507
657
  checkoutRoot: string,
508
658
  _caller?: Caller,
659
+ freshness: Freshness = treeFreshness(checkoutRoot),
509
660
  ) {
510
- const evidence = evaluateEvidence(task, candidate);
661
+ const evidence = evaluateEvidence(task, candidate, freshness);
662
+ const statuses = new Map(evidence.map((item) => [item.evidenceId, item.status]));
663
+ let checkConfig: CheckConfig | undefined;
664
+ const configFor = (): CheckConfig => (checkConfig ??= loadCheckConfig(checkoutRoot));
511
665
  if (task.policy && task.policy.policyVersion !== POLICY_VERSION)
512
666
  return task.policy.requirements.map((requirement) => ({
513
667
  requirementId: requirement.id,
@@ -517,10 +671,23 @@ export function evaluateRequirements(
517
671
  reason: "stored policy version is unsupported; reassessment is required",
518
672
  }));
519
673
  return (task.policy?.requirements ?? []).map((requirement) => {
674
+ const gate = observedGate(requirement)
675
+ ? evaluateObservedGate(task, requirement, statuses, configFor())
676
+ : null;
677
+ if (gate?.satisfied.length)
678
+ return {
679
+ requirementId: requirement.id,
680
+ status: "satisfied" as const,
681
+ evidenceIds: gate.satisfied,
682
+ decisionIds: [],
683
+ reason: gate.reason,
684
+ };
520
685
  const related = task.evidence
521
686
  .map((entry, index) => ({ entry, evaluation: evidence[index] }))
522
687
  .filter(({ entry }) => entry.data.requirementIds.includes(requirement.id));
523
688
  const passed = related.filter(({ entry, evaluation }) => {
689
+ // A gated requirement was decided above: reported checks are only notes.
690
+ if (gate) return false;
524
691
  if (
525
692
  !evidenceMatchesRequirement(
526
693
  evaluation.status,
@@ -659,6 +826,14 @@ export function evaluateRequirements(
659
826
  capability.assurance === "unavailable" &&
660
827
  (capability.name === requirement.dimension || capability.surface === requirement.dimension),
661
828
  );
829
+ if (gate)
830
+ return {
831
+ requirementId: requirement.id,
832
+ status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
833
+ evidenceIds: [...new Set([...gate.considered, ...related.map(({ entry }) => entry.id)])],
834
+ decisionIds: [],
835
+ reason: unavailable ? "required capability is unavailable" : gate.reason,
836
+ };
662
837
  if (!unavailable && related.length > 0 && !passed.length)
663
838
  return {
664
839
  requirementId: requirement.id,
@@ -10,12 +10,28 @@ import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "n
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { isWorkitPlugin } from "./registration";
13
+ import {
14
+ claudeWorkitInstalls,
15
+ findHostExecutable,
16
+ runHostCommand,
17
+ type HostCommandRunner,
18
+ } from "./host-install";
13
19
 
14
- export type UninstallHost = "opencode" | "cursor" | "codex" | "pi";
20
+ export type UninstallHost = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
15
21
 
16
22
  export type UninstallAction =
17
23
  | { kind: "edit-json-remove"; path: string; detail: string }
18
- | { kind: "remove-dir"; path: string; detail: string };
24
+ | { kind: "remove-dir"; path: string; detail: string }
25
+ /** A host-native uninstall (Claude Code owns its plugin cache and registry). */
26
+ | {
27
+ kind: "host-command";
28
+ path: string;
29
+ detail: string;
30
+ command: "claude";
31
+ args: string[];
32
+ /** The project a project/local-scope uninstall must run from; null for user scope. */
33
+ cwd: string | null;
34
+ };
19
35
 
20
36
  export type UninstallHostPlan = {
21
37
  host: UninstallHost;
@@ -49,6 +65,10 @@ export type UninstallPaths = {
49
65
  cursorSettings?: string;
50
66
  cursorMcp?: string;
51
67
  cursorPluginDir?: string;
68
+ /** Injectable host-command runner for host-native uninstall actions. */
69
+ runHostCommand?: HostCommandRunner;
70
+ /** Project directory for Claude Code project/local-scope installs (default: process cwd). */
71
+ cwd?: string;
52
72
  };
53
73
 
54
74
  type ResolvedUninstall = {
@@ -321,7 +341,32 @@ export function planUninstall(paths: UninstallPaths = {}): UninstallPlan {
321
341
  : [],
322
342
  };
323
343
 
324
- return { hosts: [opencode, cursor, codex, pi] };
344
+ // Claude Code: one native `claude plugin uninstall <id> --scope <scope>`
345
+ // per recorded Workit install that applies here (user scope, plus this
346
+ // project's project/local scope, run from that project). A --plugin-dir
347
+ // pin is never recorded.
348
+ const home = paths.home ?? paths.env?.HOME ?? os.homedir();
349
+ const claudeInstalls = claudeWorkitInstalls(
350
+ home,
351
+ paths.env ?? process.env,
352
+ paths.cwd ?? process.cwd(),
353
+ );
354
+ const claude: UninstallHostPlan = {
355
+ host: "claude-code",
356
+ installed: claudeInstalls.length > 0,
357
+ actions: claudeInstalls.map((install) => ({
358
+ kind: "host-command" as const,
359
+ path: install.installPath,
360
+ detail: `claude plugin uninstall ${install.id} --scope ${install.scope}${
361
+ install.projectPath ? ` (in ${install.projectPath})` : ""
362
+ }`,
363
+ command: "claude" as const,
364
+ args: ["plugin", "uninstall", install.id, "--scope", install.scope],
365
+ cwd: install.projectPath,
366
+ })),
367
+ };
368
+
369
+ return { hosts: [opencode, cursor, codex, pi, claude] };
325
370
  }
326
371
 
327
372
  // CA-14 traversal guard: rm -rf is permitted ONLY on the exact resolved
@@ -390,6 +435,60 @@ const applyEditJsonRemove = (
390
435
  return { status: "removed" };
391
436
  };
392
437
 
438
+ const CLAUDE_PLUGIN_ID = /^workit@[A-Za-z0-9._-]+$/;
439
+
440
+ /** Runs a reviewed `claude plugin uninstall workit@<marketplace>`; any other
441
+ * argv is refused, so a tampered plan cannot run an arbitrary command. */
442
+ const applyClaudeUninstall = (
443
+ action: Extract<UninstallAction, { kind: "host-command" }>,
444
+ paths: UninstallPaths,
445
+ ): { status: UninstallResultStatus; detail?: string } => {
446
+ const [verb, sub, id, flag, scope, ...rest] = action.args;
447
+ const projectScoped = scope === "project" || scope === "local";
448
+ if (
449
+ action.command !== "claude" ||
450
+ verb !== "plugin" ||
451
+ sub !== "uninstall" ||
452
+ !CLAUDE_PLUGIN_ID.test(id ?? "") ||
453
+ flag !== "--scope" ||
454
+ !(scope === "user" || projectScoped) ||
455
+ rest.length > 0 ||
456
+ // user scope runs anywhere; project/local only from their project.
457
+ (projectScoped ? typeof action.cwd !== "string" : action.cwd !== null)
458
+ )
459
+ return {
460
+ status: "failed",
461
+ detail: `refusing unreviewed host command: ${action.args.join(" ")}`,
462
+ };
463
+ const home = paths.home ?? paths.env?.HOME ?? os.homedir();
464
+ const env = paths.env ?? process.env;
465
+ const recorded = claudeWorkitInstalls(home, env, action.cwd).some(
466
+ (install) =>
467
+ install.id === id &&
468
+ install.scope === scope &&
469
+ (install.projectPath === null
470
+ ? action.cwd === null
471
+ : path.resolve(install.projectPath) === path.resolve(action.cwd ?? "")),
472
+ );
473
+ if (!recorded)
474
+ return { status: "skipped", detail: `${id} (${scope} scope) is no longer installed` };
475
+ const executable = findHostExecutable("claude", { home, env });
476
+ if (!executable && !paths.runHostCommand)
477
+ return { status: "failed", detail: "claude executable was not found on PATH" };
478
+ const step = {
479
+ command: executable ?? "claude",
480
+ args: action.args,
481
+ ...(action.cwd === null ? {} : { cwd: action.cwd }),
482
+ purpose: `Uninstall the Workit Claude Code plugin ${id} (${scope} scope)`,
483
+ };
484
+ const result = paths.runHostCommand
485
+ ? paths.runHostCommand(step)
486
+ : runHostCommand(step, { home, env });
487
+ return result.exitCode === 0
488
+ ? { status: "removed", detail: action.detail }
489
+ : { status: "failed", detail: result.stderr || `exit ${result.exitCode}` };
490
+ };
491
+
393
492
  /** Applies ONLY the reviewed plan actions with the given path options. Each
394
493
  * planned action yields exactly one result entry; malformed host JSON fails
395
494
  * its own action untouched while the remaining actions proceed (CA-13). */
@@ -400,7 +499,11 @@ export function applyUninstall(plan: UninstallPlan, paths: UninstallPaths = {}):
400
499
  for (const action of hostPlan.actions) {
401
500
  let status: UninstallResultStatus;
402
501
  let detail: string | undefined;
403
- if (action.kind === "remove-dir") {
502
+ if (action.kind === "host-command") {
503
+ const outcome = applyClaudeUninstall(action, paths);
504
+ status = outcome.status;
505
+ detail = outcome.detail;
506
+ } else if (action.kind === "remove-dir") {
404
507
  // Resolve before comparing so ".."/symlink tricks can never widen the rm.
405
508
  const resolved = path.resolve(action.path);
406
509
  const allowed =
package/src/git/rev.ts CHANGED
@@ -296,6 +296,56 @@ export function worktreeTree(
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * A cheap worktree freshness signal for per-turn paths: sha256 over HEAD and,
301
+ * for every entry `git status` reports (untracked files listed one by one),
302
+ * its path, lstat mode, size and mtime. A directory entry (a submodule or an
303
+ * untracked nested repository) also contributes its own HEAD, so moving a
304
+ * submodule or committing in a nested repo changes the signal. Git answers
305
+ * from its stat cache and, with GIT_OPTIONAL_LOCKS=0, never refreshes the
306
+ * index, and nothing is written to the object store (unlike worktreeTree).
307
+ * Equal signals mean "probably unchanged": an edit to an already-dirty file
308
+ * that keeps its mode, size and mtime is not seen, so gates that decide
309
+ * (close, inspect) use worktreeTree. Null outside a repository.
310
+ */
311
+ export function worktreeSignal(cwd: string, options: { timeoutMs?: number } = {}): string | null {
312
+ const top = line(git(cwd, ["rev-parse", "--show-toplevel"]));
313
+ if (!top) return null;
314
+ const status = git(
315
+ top,
316
+ [
317
+ "status",
318
+ "--porcelain=v1",
319
+ "-z",
320
+ "--untracked-files=all",
321
+ "--no-renames",
322
+ "--ignore-submodules=dirty",
323
+ ],
324
+ {
325
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
326
+ timeoutMs: options.timeoutMs ?? GIT_TIMEOUTS.worktree,
327
+ },
328
+ );
329
+ if (!status.ok) return null;
330
+ const hash = createHash("sha256");
331
+ hash.update(`${headSha(top) ?? "unborn"}\0`);
332
+ for (const entry of status.stdout.split("\0")) {
333
+ if (entry.length < 4) continue;
334
+ const file = entry.slice(3);
335
+ const absolute = path.join(top, file);
336
+ let stat = "-";
337
+ try {
338
+ const info = fs.lstatSync(absolute, { bigint: true });
339
+ stat = `${info.mode.toString(8)}:${info.size}:${info.mtimeNs}`;
340
+ if (info.isDirectory()) stat += `:${headSha(absolute) ?? "-"}`;
341
+ } catch {
342
+ // Deleted: the status code alone records it.
343
+ }
344
+ hash.update(`${entry.slice(0, 2)}\0${file}\0${stat}\0`);
345
+ }
346
+ return `sig:${hash.digest("hex")}`;
347
+ }
348
+
299
349
  /** The best common ancestor of two revisions, or null. */
300
350
  export function mergeBase(cwd: string, a: string, b: string): string | null {
301
351
  if (!safeArg(a) || !safeArg(b)) return null;
@@ -3,6 +3,8 @@
3
3
  import path from "node:path";
4
4
  import { invariantBootstrap } from "../core/methods";
5
5
  import { canonicalJson } from "../core/task-contract";
6
+ import { isObservedCheck, type Freshness } from "../core/task-evaluation";
7
+ import { worktreeSignal } from "../git/rev";
6
8
  import { WorkitCore, type OperationContext } from "../core/task-engine";
7
9
  import { fileSignature, racySignature, TaskStore, type TaskIndexEntry } from "../core/task-store";
8
10
  import { capabilitiesFor, type HostDescriptor } from "./descriptor";
@@ -80,8 +82,30 @@ const filesKey = (files: string[]): string | null => {
80
82
  : signatures.join("\0");
81
83
  };
82
84
 
83
- type CachedContext = { key: string; files: string[]; filesKey: string | null; value: string };
85
+ type CachedContext = {
86
+ key: string;
87
+ files: string[];
88
+ filesKey: string | null;
89
+ /** The worktree signal the value was judged against; null when no observed check depends on it. */
90
+ signal: string | null;
91
+ value: string;
92
+ };
84
93
  const CACHE_LIMIT = 64;
94
+
95
+ /** How long a computed worktree signal is reused, so cache hits do not run `git status` each turn. */
96
+ export const SIGNAL_TTL_MS = 1_500;
97
+ const signals = new Map<string, { at: number; value: string | null }>();
98
+
99
+ /** worktreeSignal for `root`, reused for SIGNAL_TTL_MS. */
100
+ const recentSignal = (root: string): string | null => {
101
+ const now = Date.now();
102
+ const cached = signals.get(root);
103
+ if (cached && now - cached.at < SIGNAL_TTL_MS) return cached.value;
104
+ const value = worktreeSignal(root);
105
+ signals.set(root, { at: now, value });
106
+ if (signals.size > CACHE_LIMIT) signals.delete(signals.keys().next().value!);
107
+ return value;
108
+ };
85
109
  const cache = new Map<string, CachedContext>();
86
110
 
87
111
  /**
@@ -123,20 +147,35 @@ function entryCompactContext(
123
147
  capabilities: context.capabilities,
124
148
  });
125
149
  const hit = cache.get(slot);
126
- if (hit && hit.key === key && hit.filesKey !== null && hit.filesKey === filesKey(hit.files))
150
+ if (
151
+ hit &&
152
+ hit.key === key &&
153
+ hit.filesKey !== null &&
154
+ hit.filesKey === filesKey(hit.files) &&
155
+ // Observed-check freshness changes with the worktree, not the revision.
156
+ (hit.signal === null || hit.signal === recentSignal(store.root))
157
+ )
127
158
  return hit.value;
128
159
  const task = store.readTask(entry.id);
129
160
  if (!task.ok) return null;
161
+ const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
162
+ const observed = task.data.evidence.some(isObservedCheck);
130
163
  const files = task.data.decisions.flatMap((decision) =>
131
164
  decision.data.binding.contentRefs.flatMap((ref) =>
132
165
  ref.kind === "file" ? [path.resolve(store.root, ref.path)] : [],
133
166
  ),
134
167
  );
135
- const observed = filesKey(files);
136
- const compact = new WorkitCore(store, context).compactContext(entry.id);
168
+ const observedFiles = filesKey(files);
169
+ const compact = new WorkitCore(store, context).compactContext(entry.id, freshness);
137
170
  if (!compact.ok) return null;
138
171
  cache.delete(slot);
139
- cache.set(slot, { key, files, filesKey: observed, value: compact.data });
172
+ cache.set(slot, {
173
+ key,
174
+ files,
175
+ filesKey: observedFiles,
176
+ signal: observed ? freshness.current() : null,
177
+ value: compact.data,
178
+ });
140
179
  if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
141
180
  return compact.data;
142
181
  }
@@ -12,6 +12,20 @@ const NONE: HookDecision = { kind: "none" };
12
12
  /** Sessions already offered unfinished tasks in this process. */
13
13
  const offered = new Set<string>();
14
14
 
15
+ /** The Workit plugin's worktree-isolated implementer is the one Claude Code
16
+ * subagent that writes. Claude namespaces plugin agents, so only the exact
17
+ * `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */
18
+ const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
19
+
20
+ const subagentStartText = (
21
+ host: HookInput["host"],
22
+ descriptor: HostDescriptor,
23
+ event: Extract<HookInput["event"], { kind: "subagent.start" }>,
24
+ ): string =>
25
+ host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER
26
+ ? `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch (\`git switch -c <type>/<slug>\`, e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never push, open a PR, or merge unless the brief asks for it.`
27
+ : `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
28
+
15
29
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
16
30
  const { descriptor } = deps;
17
31
  const event = input.event;
@@ -36,10 +50,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
36
50
  case "shell.pre":
37
51
  return usable(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE;
38
52
  case "subagent.start":
39
- return {
40
- kind: "context",
41
- text: `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`,
42
- };
53
+ return { kind: "context", text: subagentStartText(input.host, descriptor, event) };
43
54
  case "compact.pre":
44
55
  return {
45
56
  kind: "notice",