@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// Worktree confinement for implementer subagents (#310). Without host
|
|
2
|
+
// worktree isolation (Claude Code's refused every command it could not
|
|
3
|
+
// verify, `workit git` included), the lead makes the implementer's worktree
|
|
4
|
+
// with `workit fanout worktree create` and Workit's own hooks keep the
|
|
5
|
+
// implementer in it:
|
|
6
|
+
//
|
|
7
|
+
// - binding: the first edit or mutating git command an implementer makes
|
|
8
|
+
// inside a worktree that `workit fanout worktree create` made (its admin
|
|
9
|
+
// dir holds the fanout token) binds that agent to it, in
|
|
10
|
+
// `<state dir>/worktree-bind/<session+agent>.json`, with a ledger row;
|
|
11
|
+
// - after binding, an edit or mutating git command outside that worktree is
|
|
12
|
+
// denied naming it; the host's temp dir stays writable;
|
|
13
|
+
// - before binding (ruling: the safe default), an edit outside every Workit
|
|
14
|
+
// worktree is denied too, so an implementer never edits the lead's checkout;
|
|
15
|
+
// - always, what the lead owns is denied: worktree, ref and config changes
|
|
16
|
+
// (`git worktree add|remove`, `branch -D`, `update-ref`, a delete push…)
|
|
17
|
+
// and `workit fanout plan|worktree`.
|
|
18
|
+
//
|
|
19
|
+
// An agent the host isolated itself (a real linked worktree at Claude Code's
|
|
20
|
+
// `<repo>/.claude/worktrees/agent-<id>`) is left to the host. Reads only the
|
|
21
|
+
// filesystem; fails open on any error.
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { resolveStateDir } from "../core/logger";
|
|
26
|
+
import { appendHookObserved } from "../ledger";
|
|
27
|
+
import type { HookDecision, HookInput } from "./protocol";
|
|
28
|
+
import { inWorkitWorkspace, rawInvocations, skipWorkitGlobals, workitInvocations } from "./raw-git";
|
|
29
|
+
import type { ShellDialect } from "./shell-words";
|
|
30
|
+
import { shellMutationTargets, UNKNOWN_DIR, UNKNOWN_VAR } from "./write-gate";
|
|
31
|
+
|
|
32
|
+
/** Written by `workit fanout worktree create` into git's admin dir for the worktree. */
|
|
33
|
+
export const FANOUT_TOKEN_FILE = "workit-fanout-token";
|
|
34
|
+
|
|
35
|
+
const NONE: HookDecision = { kind: "none" };
|
|
36
|
+
const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
|
|
37
|
+
|
|
38
|
+
/** The real path of `file`'s nearest existing ancestor, plus the rest. */
|
|
39
|
+
const canonical = (file: string): string => {
|
|
40
|
+
let rest = "";
|
|
41
|
+
for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
|
|
42
|
+
try {
|
|
43
|
+
return path.join(fs.realpathSync.native(dir), rest);
|
|
44
|
+
} catch {
|
|
45
|
+
if (path.dirname(dir) === dir) return path.resolve(file);
|
|
46
|
+
rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const within = (child: string, parent: string): boolean => {
|
|
52
|
+
const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
|
|
53
|
+
const relative = path.relative(fold(parent), fold(child));
|
|
54
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The git checkout `file` is in (the nearest directory above it with a
|
|
59
|
+
* `.git`), and whether `workit fanout worktree create` made it: its `.git`
|
|
60
|
+
* file names an admin dir that holds the fanout token. Null outside any
|
|
61
|
+
* repository.
|
|
62
|
+
*/
|
|
63
|
+
const checkoutOf = (file: string): { root: string; workit: boolean } | null => {
|
|
64
|
+
for (let dir = canonical(file); ; dir = path.dirname(dir)) {
|
|
65
|
+
const dotGit = path.join(dir, ".git");
|
|
66
|
+
if (fs.existsSync(dotGit)) {
|
|
67
|
+
if (!fs.statSync(dotGit).isFile()) return { root: dir, workit: false };
|
|
68
|
+
const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
69
|
+
return {
|
|
70
|
+
root: dir,
|
|
71
|
+
workit:
|
|
72
|
+
Boolean(admin) && fs.existsSync(path.join(path.resolve(dir, admin!), FANOUT_TOKEN_FILE)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (path.dirname(dir) === dir) return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The root of the Workit-made worktree `file` is in, else null. */
|
|
80
|
+
const workitWorktreeOf = (file: string): string | null => {
|
|
81
|
+
try {
|
|
82
|
+
const checkout = checkoutOf(file);
|
|
83
|
+
return checkout?.workit ? checkout.root : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type Binding = { worktree: string; session: string; agentId: string; at: string };
|
|
90
|
+
|
|
91
|
+
// Bindings live in Workit's machine state dir, keyed by session and agent, so
|
|
92
|
+
// one is kept wherever the hook's cwd is (even outside every repository).
|
|
93
|
+
const bindingDir = (): string => path.join(resolveStateDir(), "worktree-bind");
|
|
94
|
+
|
|
95
|
+
const bindingFile = (session: string, agentId: string): string =>
|
|
96
|
+
path.join(
|
|
97
|
+
bindingDir(),
|
|
98
|
+
`${createHash("sha256").update(`${session}\0${agentId}`).digest("hex").slice(0, 32)}.json`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const readBinding = (file: string): Binding | null => {
|
|
102
|
+
try {
|
|
103
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Binding>;
|
|
104
|
+
return typeof value.worktree === "string" ? (value as Binding) : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The worktree `input`'s agent is bound to, while that worktree is still a Workit one. */
|
|
111
|
+
const boundWorktree = (input: HookInput): string | null => {
|
|
112
|
+
const agentId = input.session.agentId;
|
|
113
|
+
if (!agentId) return null;
|
|
114
|
+
const binding = readBinding(bindingFile(input.session.id, agentId));
|
|
115
|
+
return binding && workitWorktreeOf(binding.worktree) === binding.worktree
|
|
116
|
+
? binding.worktree
|
|
117
|
+
: null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const bind = (input: HookInput, worktree: string): void => {
|
|
121
|
+
const agentId = input.session.agentId!;
|
|
122
|
+
const file = bindingFile(input.session.id, agentId);
|
|
123
|
+
const binding: Binding = {
|
|
124
|
+
worktree,
|
|
125
|
+
session: input.session.id,
|
|
126
|
+
agentId,
|
|
127
|
+
at: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify(binding));
|
|
131
|
+
try {
|
|
132
|
+
if (inWorkitWorkspace(input.cwd))
|
|
133
|
+
appendHookObserved(input.cwd, {
|
|
134
|
+
type: "worktree.bound",
|
|
135
|
+
actor: { host: input.host, session: input.session.id, agentId },
|
|
136
|
+
agentType: input.session.agentType,
|
|
137
|
+
worktree,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// The binding file is what the hook reads; the row is evidence only.
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Claude Code's own isolation: the agent runs in a linked worktree (its `.git`
|
|
146
|
+
* is a file) whose root is exactly `<repo>/.claude/worktrees/agent-<its id>`.
|
|
147
|
+
* A directory that only looks like one is not.
|
|
148
|
+
*/
|
|
149
|
+
export const hostIsolated = (cwd: string, agentId: string | null): boolean => {
|
|
150
|
+
try {
|
|
151
|
+
if (agentId === null) return false;
|
|
152
|
+
const checkout = checkoutOf(cwd);
|
|
153
|
+
if (!checkout) return false;
|
|
154
|
+
const dotGit = path.join(checkout.root, ".git");
|
|
155
|
+
if (!fs.statSync(dotGit).isFile()) return false;
|
|
156
|
+
const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
157
|
+
if (!admin) return false;
|
|
158
|
+
const adminDir = path.resolve(checkout.root, admin);
|
|
159
|
+
// The admin dir must point back at this checkout: a forged `.git` file
|
|
160
|
+
// naming a sibling's admin dir does not make a directory that sibling's.
|
|
161
|
+
const back = fs.readFileSync(path.join(adminDir, "gitdir"), "utf8").trim();
|
|
162
|
+
if (path.relative(canonical(path.resolve(adminDir, back)), canonical(dotGit)) !== "")
|
|
163
|
+
return false;
|
|
164
|
+
const common = path.join(adminDir, "commondir");
|
|
165
|
+
const gitDir = fs.existsSync(common)
|
|
166
|
+
? path.resolve(adminDir, fs.readFileSync(common, "utf8").trim())
|
|
167
|
+
: adminDir;
|
|
168
|
+
const expected = path.join(
|
|
169
|
+
path.dirname(canonical(gitDir)),
|
|
170
|
+
".claude",
|
|
171
|
+
"worktrees",
|
|
172
|
+
`agent-${agentId}`,
|
|
173
|
+
);
|
|
174
|
+
return path.relative(canonical(expected), checkout.root) === "";
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const shown = (target: string): string =>
|
|
181
|
+
path.basename(target) === ".workit-live-target" ? path.dirname(target) : target;
|
|
182
|
+
|
|
183
|
+
const unboundDeny = (input: HookInput, target: string): HookDecision => {
|
|
184
|
+
const unblock =
|
|
185
|
+
"Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit git --cwd <worktree> commit -m … -- <paths>`, `workit check --cwd <worktree> <name>`). If your brief names no worktree, stop and report that to the lead.";
|
|
186
|
+
return {
|
|
187
|
+
kind: "deny",
|
|
188
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} is not working in a Workit worktree yet, and ${shown(target)} is outside every worktree \`workit fanout worktree create\` made, so it would change the lead's checkout. ${unblock}`,
|
|
189
|
+
unblock,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const outsideDeny = (input: HookInput, worktree: string, target: string): HookDecision => {
|
|
194
|
+
const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit git --cwd ${worktree} commit -m … -- <paths>\`, \`workit check --cwd ${worktree} <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
|
|
195
|
+
return {
|
|
196
|
+
kind: "deny",
|
|
197
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
|
|
198
|
+
unblock,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
|
|
203
|
+
const wt = worktree ?? "<worktree>";
|
|
204
|
+
const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit <verb> --cwd ${wt}\`.`;
|
|
205
|
+
return {
|
|
206
|
+
kind: "deny",
|
|
207
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
|
|
208
|
+
unblock,
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** `command` with each unresolved reference in `refs` spelled as the absolute path it stands for. */
|
|
213
|
+
const literalForm = (command: string, refs: readonly string[]): string => {
|
|
214
|
+
let text = command;
|
|
215
|
+
for (const ref of refs) {
|
|
216
|
+
const placeholder = `<absolute path of ${ref}>`;
|
|
217
|
+
const name = /^\$(\w+)$/.exec(ref)?.[1];
|
|
218
|
+
text = name
|
|
219
|
+
? text.replace(new RegExp(`\\$\\{${name}[^}]*\\}|\\$${name}(?!\\w)`, "g"), placeholder)
|
|
220
|
+
: text.replaceAll(ref, placeholder);
|
|
221
|
+
}
|
|
222
|
+
return text.length > 600 ? `${text.slice(0, 600)}…` : text;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const unknownVarDeny = (
|
|
226
|
+
input: HookInput,
|
|
227
|
+
worktree: string | null,
|
|
228
|
+
targets: readonly string[],
|
|
229
|
+
command: string | undefined,
|
|
230
|
+
): HookDecision => {
|
|
231
|
+
const refs = [
|
|
232
|
+
...new Set(
|
|
233
|
+
targets.flatMap((target) =>
|
|
234
|
+
decodeURIComponent(path.relative(UNKNOWN_VAR, path.resolve(target)).split(path.sep)[0])
|
|
235
|
+
.split(" ")
|
|
236
|
+
.filter(Boolean),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
];
|
|
240
|
+
const wt = worktree ?? "<worktree>";
|
|
241
|
+
const named = refs.join(", ");
|
|
242
|
+
const retry = command === undefined ? "" : ` Retry as: \`${literalForm(command, refs)}\`.`;
|
|
243
|
+
const unblock = `Spell the path out instead of ${named}: Workit follows only a plain \`NAME=<literal>\` set earlier in the same command, \`$(mktemp …)\` and a \`for\` over literal words.${retry} Write under ${wt} (\`workit git --cwd ${wt} …\`, \`workit check --cwd ${wt} …\`) or a temp dir.`;
|
|
244
|
+
return {
|
|
245
|
+
kind: "deny",
|
|
246
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} writes to a path built from ${named}, which Workit cannot resolve (a command substitution, a loop over unknown words, a conditional or a binder such as read or eval sets it), so it cannot tell whether the write stays in its worktree. ${unblock}`,
|
|
247
|
+
unblock,
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Judge an implementer's edit or mutating command by the absolute paths it
|
|
253
|
+
* changes (`targets`). Only targets inside a git checkout, or Workit's
|
|
254
|
+
* binding records, are judged (scratch and temp files elsewhere are host
|
|
255
|
+
* policy). Binds on the first target inside a Workit worktree; denies a
|
|
256
|
+
* target outside the bound worktree (or, unbound, outside every Workit
|
|
257
|
+
* worktree). Deny reasons name a target as the agent spelled it; a path
|
|
258
|
+
* built from a variable Workit cannot resolve is denied naming the variable,
|
|
259
|
+
* with `command` (when given) spelled out as the form to retry.
|
|
260
|
+
*/
|
|
261
|
+
export function confineImplementer(
|
|
262
|
+
input: HookInput,
|
|
263
|
+
targets: readonly string[],
|
|
264
|
+
command?: string,
|
|
265
|
+
): HookDecision {
|
|
266
|
+
try {
|
|
267
|
+
if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
|
|
268
|
+
const unresolved = targets.filter((target) => within(path.resolve(target), UNKNOWN_VAR));
|
|
269
|
+
if (unresolved.length > 0)
|
|
270
|
+
return unknownVarDeny(input, boundWorktree(input), unresolved, command);
|
|
271
|
+
if (targets.some((target) => within(path.resolve(target), UNKNOWN_DIR)))
|
|
272
|
+
return unknownDirDeny(input, boundWorktree(input));
|
|
273
|
+
const bindings = canonical(bindingDir());
|
|
274
|
+
const judged = targets
|
|
275
|
+
.map((named) => ({ named, real: canonical(named) }))
|
|
276
|
+
.filter(({ real }) => within(real, bindings) || checkoutOf(real) !== null);
|
|
277
|
+
if (judged.length === 0) return NONE;
|
|
278
|
+
let worktree = boundWorktree(input);
|
|
279
|
+
if (!worktree) {
|
|
280
|
+
for (const { real } of judged) {
|
|
281
|
+
worktree = within(real, bindings) ? null : workitWorktreeOf(real);
|
|
282
|
+
if (worktree) break;
|
|
283
|
+
}
|
|
284
|
+
if (!worktree) return unboundDeny(input, judged[0].named);
|
|
285
|
+
bind(input, worktree);
|
|
286
|
+
}
|
|
287
|
+
const root = canonical(worktree);
|
|
288
|
+
const outside = judged.find(({ real }) => !within(real, root) || within(real, bindings));
|
|
289
|
+
return outside ? outsideDeny(input, worktree, outside.named) : NONE;
|
|
290
|
+
} catch {
|
|
291
|
+
return NONE;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const MUTATING_REMOTE = new Set([
|
|
296
|
+
"add",
|
|
297
|
+
"remove",
|
|
298
|
+
"rm",
|
|
299
|
+
"rename",
|
|
300
|
+
"set-url",
|
|
301
|
+
"set-head",
|
|
302
|
+
"set-branches",
|
|
303
|
+
"prune",
|
|
304
|
+
]);
|
|
305
|
+
const CONFIG_READ = /^(?:--get(?:-all|-regexp|-urlmatch)?|--list|-l|get|list)$/;
|
|
306
|
+
const CONFIG_WRITE =
|
|
307
|
+
/^(?:--(?:unset(?:-all)?|add|replace-all|rename-section|remove-section|edit)|-e|set|unset|rename-section|remove-section|edit)$/;
|
|
308
|
+
const CONFIG_VALUE = /^(?:--file|-f|--blob|--type|--default|--comment)$/;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Whether git `args` (the subcommand first) change what the lead owns: the
|
|
312
|
+
* repository's worktrees, refs other than by commit, the shared stash, or
|
|
313
|
+
* its configuration and remotes.
|
|
314
|
+
*/
|
|
315
|
+
const leadOwnedGit = (args: readonly string[]): boolean => {
|
|
316
|
+
const [sub = "", ...rest] = args;
|
|
317
|
+
if (rest.some((arg) => arg === "--help" || arg === "-h")) return false;
|
|
318
|
+
const names = rest.filter((arg) => !arg.startsWith("-"));
|
|
319
|
+
const flag = (pattern: RegExp) => rest.some((arg) => pattern.test(arg));
|
|
320
|
+
switch (sub) {
|
|
321
|
+
case "worktree":
|
|
322
|
+
return names[0] !== undefined && names[0] !== "list";
|
|
323
|
+
case "update-ref":
|
|
324
|
+
return true;
|
|
325
|
+
case "symbolic-ref":
|
|
326
|
+
return names.length > 1 || flag(/^(?:-d|--delete)$/);
|
|
327
|
+
case "branch":
|
|
328
|
+
return flag(/^-[a-zA-Z]*[dDfmMcC]|^--(?:delete|force|move|copy)$/);
|
|
329
|
+
case "tag":
|
|
330
|
+
return flag(/^-[a-zA-Z]*[df]|^--(?:delete|force)$/);
|
|
331
|
+
case "push": {
|
|
332
|
+
if (flag(/^(?:-d|--delete|--mirror|--prune)$/)) return true;
|
|
333
|
+
// names[0] is the remote; the rest are refspecs. `git push [-u] [remote]` and
|
|
334
|
+
// `git push --force-with-lease [remote]` (the current branch) stay the implementer's.
|
|
335
|
+
const refspecs = names.slice(1);
|
|
336
|
+
return (
|
|
337
|
+
refspecs.some((name) => name.includes(":") || name.startsWith("+")) ||
|
|
338
|
+
(refspecs.length > 0 && flag(/^-[a-zA-Z]*f|^--force/))
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
case "replace":
|
|
342
|
+
case "filter-branch":
|
|
343
|
+
case "gc":
|
|
344
|
+
case "prune":
|
|
345
|
+
case "repack":
|
|
346
|
+
case "sparse-checkout":
|
|
347
|
+
return true;
|
|
348
|
+
case "notes":
|
|
349
|
+
case "reflog":
|
|
350
|
+
case "submodule":
|
|
351
|
+
return !/^(?:list|show|status|exists)$/.test(names[0] ?? "list");
|
|
352
|
+
case "stash":
|
|
353
|
+
return names[0] === "drop" || names[0] === "clear";
|
|
354
|
+
case "remote":
|
|
355
|
+
return MUTATING_REMOTE.has(names[0] ?? "");
|
|
356
|
+
case "config": {
|
|
357
|
+
if (rest.some((arg) => CONFIG_READ.test(arg))) return false;
|
|
358
|
+
if (rest.some((arg) => CONFIG_WRITE.test(arg))) return true;
|
|
359
|
+
let operands = 0;
|
|
360
|
+
for (let index = 0; index < rest.length; index++)
|
|
361
|
+
if (CONFIG_VALUE.test(rest[index])) index++;
|
|
362
|
+
else if (!rest[index].startsWith("-")) operands++;
|
|
363
|
+
return operands > 1;
|
|
364
|
+
}
|
|
365
|
+
default:
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** A `workit [globals] fanout plan|worktree …` in `args` (also behind `check --`). */
|
|
371
|
+
const leadOwnedWorkit = (args: readonly string[]): boolean =>
|
|
372
|
+
!args.includes("--help") &&
|
|
373
|
+
args.some((_, start) => {
|
|
374
|
+
if (start > 0 && !/(?:^|[\\/])workit(?:\.exe|\.cmd)?$/.test(args[start - 1])) return false;
|
|
375
|
+
const [verb, sub] = skipWorkitGlobals(args.slice(start)).rest;
|
|
376
|
+
return verb === "fanout" && /^(?:plan|worktree)$/.test(sub ?? "");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const leadOwnedDeny = (input: HookInput, command: string): HookDecision => {
|
|
380
|
+
const unblock =
|
|
381
|
+
"Worktrees, branches other than your own, refs, tags, remotes, git config, the shared stash and the fan-out plan belong to the lead: report what you need changed as a follow-up instead.";
|
|
382
|
+
return {
|
|
383
|
+
kind: "deny",
|
|
384
|
+
reason: `workit worktree confinement: implementer ${input.session.agentId} may not run \`${command.slice(0, 160)}\`. ${unblock}`,
|
|
385
|
+
unblock,
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Judge an implementer's shell command: deny what the lead owns, then confine
|
|
391
|
+
* every path it may change (the strict analysis, see `shellMutationTargets`).
|
|
392
|
+
*/
|
|
393
|
+
export function confineImplementerShell(
|
|
394
|
+
input: HookInput,
|
|
395
|
+
command: string,
|
|
396
|
+
dialect: ShellDialect = "posix",
|
|
397
|
+
): HookDecision {
|
|
398
|
+
try {
|
|
399
|
+
if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
|
|
400
|
+
if (
|
|
401
|
+
rawInvocations(command, dialect).some(
|
|
402
|
+
(invocation) => invocation.tool === "git" && leadOwnedGit(invocation.args),
|
|
403
|
+
) ||
|
|
404
|
+
workitInvocations(command, dialect).some(leadOwnedWorkit)
|
|
405
|
+
)
|
|
406
|
+
return leadOwnedDeny(input, command);
|
|
407
|
+
} catch {
|
|
408
|
+
// An unanalysable command falls through to the path confinement.
|
|
409
|
+
}
|
|
410
|
+
return confineImplementer(
|
|
411
|
+
input,
|
|
412
|
+
shellMutationTargets(input.cwd, command, dialect, true),
|
|
413
|
+
command,
|
|
414
|
+
);
|
|
415
|
+
}
|