@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.
- package/package.json +1 -1
- package/scripts/analyze-release-scope.ts +99 -8
- package/scripts/bundled-deps.json +133 -0
- package/scripts/publish-changed-packages.ts +28 -16
- package/scripts/rewrite-workspace-deps.ts +11 -5
- package/scripts/sync-release-manifests.ts +5 -1
- package/skills/workit-behavioral-tdd/SKILL.md +15 -9
- package/skills/workit-debug/SKILL.md +2 -1
- package/src/check-config.ts +294 -0
- package/src/checks.ts +480 -0
- package/src/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/host-install.ts +144 -1
- package/src/core/methods.ts +5 -2
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/task-context.ts +6 -2
- package/src/core/task-contract.ts +70 -12
- package/src/core/task-engine.ts +120 -10
- package/src/core/task-evaluation.ts +176 -1
- package/src/core/uninstall.ts +107 -4
- package/src/git/rev.ts +50 -0
- package/src/hooks/context.ts +44 -5
- package/src/hooks/handle.ts +15 -4
|
@@ -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,
|
package/src/core/uninstall.ts
CHANGED
|
@@ -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
|
-
|
|
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 === "
|
|
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;
|
package/src/hooks/context.ts
CHANGED
|
@@ -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 = {
|
|
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 (
|
|
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
|
|
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, {
|
|
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
|
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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",
|