@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,459 @@
|
|
|
1
|
+
// The acting host session for a `workit` call made from an agent's shell.
|
|
2
|
+
// WORKIT_SESSION_ID, when set, wins (Claude Code exports it from SessionStart; a lead
|
|
3
|
+
// assigns one to a verifier), unless WORKIT_HOST shows it came from another host. Otherwise the id each host itself puts in its
|
|
4
|
+
// shell tool's environment is used, so commits and verdicts made on those
|
|
5
|
+
// hosts carry the same session id their hooks see (M5):
|
|
6
|
+
// - OpenCode V2: OPENCODE_SESSION_ID (the shell tool's session id);
|
|
7
|
+
// - Pi: PI_SESSION_ID (the bash tool exposes the Pi session id);
|
|
8
|
+
// - Codex CLI and Desktop: CODEX_THREAD_ID (the thread id hooks get as session_id).
|
|
9
|
+
// Cursor puts no conversation id in its shell; its hook names the id to use.
|
|
10
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { isProtectedTarget } from "./core/branch";
|
|
14
|
+
import { resolveStateDir } from "./core/logger";
|
|
15
|
+
import { currentBranch } from "./git/rev";
|
|
16
|
+
import { resolveStore } from "./store/paths";
|
|
17
|
+
|
|
18
|
+
export type HostSession = { host: string | null; session: string | null };
|
|
19
|
+
|
|
20
|
+
const HOST_SESSION_VARS: ReadonlyArray<readonly [string, (env: NodeJS.ProcessEnv) => string]> = [
|
|
21
|
+
["OPENCODE_SESSION_ID", () => "opencode"],
|
|
22
|
+
["PI_SESSION_ID", () => "pi"],
|
|
23
|
+
[
|
|
24
|
+
"CODEX_THREAD_ID",
|
|
25
|
+
(env) =>
|
|
26
|
+
env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === "Codex Desktop" ||
|
|
27
|
+
env.CODEX_ELECTRON_RESOURCES_PATH
|
|
28
|
+
? "codex_desktop"
|
|
29
|
+
: "codex_cli",
|
|
30
|
+
],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const family = (host: string | null): string | null =>
|
|
34
|
+
host?.replace(/_(?:cli|desktop)$/, "") ?? null;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* WORKIT_HOST/WORKIT_SESSION_ID, else the host's own shell session variable.
|
|
38
|
+
* A WORKIT_SESSION_ID whose WORKIT_HOST names another host was inherited from
|
|
39
|
+
* an outer session (Codex started from Claude Code's Bash): the inner host's
|
|
40
|
+
* own id wins, so the CLI acts as the session that host's hooks see.
|
|
41
|
+
*/
|
|
42
|
+
export function hostSessionFromEnv(env: NodeJS.ProcessEnv): HostSession {
|
|
43
|
+
const value = (key: string): string | null => env[key]?.trim() || null;
|
|
44
|
+
let inner: HostSession | null = null;
|
|
45
|
+
for (const [key, host] of HOST_SESSION_VARS) {
|
|
46
|
+
const session = value(key);
|
|
47
|
+
if (session) {
|
|
48
|
+
inner = { host: host(env), session };
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const declared = value("WORKIT_HOST");
|
|
53
|
+
// Set, even empty, it is the answer: `WORKIT_SESSION_ID=` opts out of the fallback.
|
|
54
|
+
if (env.WORKIT_SESSION_ID !== undefined) {
|
|
55
|
+
const inherited =
|
|
56
|
+
inner !== null && declared !== null && family(declared) !== family(inner.host);
|
|
57
|
+
if (!inherited) return { host: declared, session: value("WORKIT_SESSION_ID") };
|
|
58
|
+
}
|
|
59
|
+
return inner ? { host: inner.host, session: inner.session } : { host: declared, session: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where this shell's session comes from:
|
|
64
|
+
* - `derived`: the host's own shell variable (OPENCODE_SESSION_ID,
|
|
65
|
+
* PI_SESSION_ID, CODEX_THREAD_ID), which a caller cannot replace by
|
|
66
|
+
* exporting WORKIT_SESSION_ID;
|
|
67
|
+
* - `declared`: a host that puts no id in the shell but names itself
|
|
68
|
+
* (WORKIT_HOST, e.g. Claude Code's SessionStart export) declares it in
|
|
69
|
+
* WORKIT_SESSION_ID;
|
|
70
|
+
* - `none`: no host at all (CI, a plain terminal, Cursor's prefixed
|
|
71
|
+
* commands): whatever session the caller names is only declared.
|
|
72
|
+
*/
|
|
73
|
+
export type RealSession = HostSession & { kind: "derived" | "declared" | "none" };
|
|
74
|
+
|
|
75
|
+
export function realHostSession(env: NodeJS.ProcessEnv): RealSession {
|
|
76
|
+
for (const [key, host] of HOST_SESSION_VARS) {
|
|
77
|
+
const session = env[key]?.trim();
|
|
78
|
+
if (session) return { host: host(env), session, kind: "derived" };
|
|
79
|
+
}
|
|
80
|
+
const declared = hostSessionFromEnv(env);
|
|
81
|
+
const hosted = declared.host !== null && declared.host !== "cli";
|
|
82
|
+
return { ...declared, kind: hosted ? "declared" : "none" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Ids a SubagentStart hook gave a lead's subagents (`<lead>:<agent id>`): one
|
|
86
|
+
// empty file per id under `<state>/issued/<lead hash>/`, so concurrent
|
|
87
|
+
// hooks never lose each other's write, bounded per lead.
|
|
88
|
+
const ISSUED_PER_LEAD = 64;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Written into each issued-id file. A hook bundle that contains this string
|
|
92
|
+
* records issued ids per lead in the user's state dir with a token only the
|
|
93
|
+
* subagent is told (`/3`; `/2` files carry no token, `/1` hooks of 8.10-8.12
|
|
94
|
+
* wrote them per repository); `workit doctor` looks for it in the installed
|
|
95
|
+
* Claude Code and Codex hooks to catch a plugin older than the CLI.
|
|
96
|
+
*/
|
|
97
|
+
export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/3";
|
|
98
|
+
|
|
99
|
+
const hash = (value: string): string =>
|
|
100
|
+
createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
101
|
+
|
|
102
|
+
// Issued ids live in the per-user state dir, keyed by lead session: a lead
|
|
103
|
+
// started in one repository delegates review of others, and the verifier
|
|
104
|
+
// records its verdict from whichever repository it reviews (#294).
|
|
105
|
+
const userIssuedRoot = (): string => path.join(resolveStateDir(), "issued");
|
|
106
|
+
/**
|
|
107
|
+
* Token hashes, one file per issued id under `<state>/issued-tokens/<lead
|
|
108
|
+
* hash>/`: a tree hooks older than tokens never write, so an old cached
|
|
109
|
+
* plugin hook that rewrites a record in `issued/` cannot strip its token.
|
|
110
|
+
* Once a lead has one, every id under it needs a token (sticky binding).
|
|
111
|
+
*/
|
|
112
|
+
const userTokenRoot = (): string => path.join(resolveStateDir(), "issued-tokens");
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hooks of 8.10-8.12 wrote issued ids into `<store>/hooks/issued` of the
|
|
116
|
+
* lead's repository. Move them into the per-user registry file by file
|
|
117
|
+
* (copy, then unlink: an id an old hook writes meanwhile stays for the next
|
|
118
|
+
* read) and drop the emptied directories. Each lead migrates on its own, so
|
|
119
|
+
* one bad entry never strands the rest. Fails open.
|
|
120
|
+
*/
|
|
121
|
+
function migrateRepoIssued(cwd: string | undefined): void {
|
|
122
|
+
if (cwd === undefined) return;
|
|
123
|
+
let old: string;
|
|
124
|
+
try {
|
|
125
|
+
const location = resolveStore(cwd);
|
|
126
|
+
if (location instanceof Error) return;
|
|
127
|
+
old = path.join(location.dir, "hooks", "issued");
|
|
128
|
+
if (!fs.existsSync(old)) return;
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const lead of safeList(old)) {
|
|
133
|
+
try {
|
|
134
|
+
const from = path.join(old, lead);
|
|
135
|
+
if (!fs.statSync(from).isDirectory()) continue;
|
|
136
|
+
const to = path.join(userIssuedRoot(), lead);
|
|
137
|
+
fs.mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
138
|
+
for (const id of fs.readdirSync(from)) {
|
|
139
|
+
// Never over an existing per-user record: a legacy in-repo copy must
|
|
140
|
+
// not replace a token-bound one (EEXIST just drops the legacy copy).
|
|
141
|
+
try {
|
|
142
|
+
fs.copyFileSync(path.join(from, id), path.join(to, id), fs.constants.COPYFILE_EXCL);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
145
|
+
}
|
|
146
|
+
fs.rmSync(path.join(from, id), { force: true });
|
|
147
|
+
}
|
|
148
|
+
removeIfEmpty(from);
|
|
149
|
+
} catch {
|
|
150
|
+
// Fail open: this lead's ids stay for the next read.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
removeIfEmpty(old);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const safeList = (dir: string): string[] => {
|
|
157
|
+
try {
|
|
158
|
+
return fs.readdirSync(dir);
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const removeIfEmpty = (dir: string): void => {
|
|
165
|
+
try {
|
|
166
|
+
fs.rmdirSync(dir);
|
|
167
|
+
} catch {
|
|
168
|
+
// Not empty (a concurrent old hook wrote) or already gone.
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** This lead's hooks never recorded an issued id (an older plugin hook, most likely). */
|
|
173
|
+
const noIssuedRecord = (cwd: string | undefined, lead: string): boolean => {
|
|
174
|
+
if (cwd === undefined) return false;
|
|
175
|
+
try {
|
|
176
|
+
migrateRepoIssued(cwd);
|
|
177
|
+
return !fs.existsSync(path.join(userIssuedRoot(), hash(lead)));
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Per host: how to update the plugin whose SubagentStart hook issues ids. */
|
|
184
|
+
const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
|
|
185
|
+
claude_code: "claude plugin marketplace update workit && claude plugin update workit@workit",
|
|
186
|
+
codex: "codex plugin remove workit@workit && codex plugin add workit@workit",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const tokenHash = (token: string): string => createHash("sha256").update(token).digest("hex");
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Remember that a SubagentStart hook issued `issued` to a subagent of `lead`,
|
|
193
|
+
* and return the token that binds the id to that subagent: the hook hands it
|
|
194
|
+
* only to the subagent, and only its sha256 is stored, so a lead or sibling
|
|
195
|
+
* that reads the id in the ledger cannot act as it (audit mv83zyg6 P1).
|
|
196
|
+
* Each id is issued once: a SubagentStart fired again for an id already
|
|
197
|
+
* issued (a lead replaying the hook) gets no new token (review mv8daa5d P1).
|
|
198
|
+
* Null when nothing was issued: already issued, or the write failed (fails open).
|
|
199
|
+
*/
|
|
200
|
+
export function recordIssuedSession(cwd: string, lead: string, issued: string): string | null {
|
|
201
|
+
// Hex, never base64url: a token that starts with "-" (1 in 64) breaks the
|
|
202
|
+
// `--token <token>` the hook prints, because the CLI parses it as a flag.
|
|
203
|
+
const token = randomBytes(18).toString("hex");
|
|
204
|
+
try {
|
|
205
|
+
migrateRepoIssued(cwd);
|
|
206
|
+
const own = hash(issued);
|
|
207
|
+
const tokens = path.join(userTokenRoot(), hash(lead));
|
|
208
|
+
fs.mkdirSync(tokens, { recursive: true, mode: 0o700 });
|
|
209
|
+
// `wx`: the first SubagentStart for an id wins; any later one fails here.
|
|
210
|
+
fs.writeFileSync(path.join(tokens, own), `${tokenHash(token)}\n`, { mode: 0o600, flag: "wx" });
|
|
211
|
+
const dir = path.join(userIssuedRoot(), hash(lead));
|
|
212
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
213
|
+
fs.writeFileSync(path.join(dir, own), `${ISSUED_SESSIONS_MARKER}\n`, { mode: 0o600 });
|
|
214
|
+
pruneIssued(dir, own, tokens);
|
|
215
|
+
return token;
|
|
216
|
+
} catch {
|
|
217
|
+
// Already issued (EEXIST), or a failed write: no token is handed out.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The branch a judge spawned from `cwd` most likely verifies: the checkout's
|
|
224
|
+
* branch, or null on a detached HEAD or a protected branch (a lead on `main`
|
|
225
|
+
* fanning out names the candidate in its brief, never its own branch).
|
|
226
|
+
*/
|
|
227
|
+
export function judgeBranch(cwd: string): string | null {
|
|
228
|
+
const branch = currentBranch(cwd);
|
|
229
|
+
return branch && !isProtectedTarget(cwd, branch) ? branch : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The exact verdict command a verifier or reviewer runs, filled in (#271
|
|
234
|
+
* S2a). `--token=` keeps a token from ever parsing as a flag. Hosts whose
|
|
235
|
+
* child shell carries its own session (OpenCode, Pi) pass no session.
|
|
236
|
+
* `--kind` is always named: left out, the CLI records `review`.
|
|
237
|
+
*/
|
|
238
|
+
export function verdictCommand(options: {
|
|
239
|
+
branch: string | null;
|
|
240
|
+
judge: "verifier" | "reviewer" | null;
|
|
241
|
+
session?: string | null;
|
|
242
|
+
token?: string | null;
|
|
243
|
+
}): string {
|
|
244
|
+
const kind =
|
|
245
|
+
options.judge === "verifier"
|
|
246
|
+
? "<live|unit>"
|
|
247
|
+
: options.judge === "reviewer"
|
|
248
|
+
? "review"
|
|
249
|
+
: "<live|unit|review>";
|
|
250
|
+
return [
|
|
251
|
+
"workit ledger verdict <verified|failed|blocked>",
|
|
252
|
+
`--kind ${kind}`,
|
|
253
|
+
`--branch ${options.branch ?? "<branch>"}`,
|
|
254
|
+
options.session ? `--session ${options.session}` : null,
|
|
255
|
+
options.token ? `--token=${options.token}` : null,
|
|
256
|
+
'--how "<what you ran and saw>"',
|
|
257
|
+
]
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
.join(" ");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Keep the newest ISSUED_PER_LEAD ids of one lead (and their token hashes); `own` is never pruned. */
|
|
263
|
+
function pruneIssued(dir: string, own: string, tokens: string): void {
|
|
264
|
+
try {
|
|
265
|
+
const entries = fs.readdirSync(dir);
|
|
266
|
+
if (entries.length <= ISSUED_PER_LEAD) return;
|
|
267
|
+
// Oldest first; the id just issued is never the one pruned.
|
|
268
|
+
const byAge = entries
|
|
269
|
+
.filter((name) => name !== own)
|
|
270
|
+
.map((name) => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs }))
|
|
271
|
+
.toSorted((a, b) => a.at - b.at);
|
|
272
|
+
for (const { name } of byAge.slice(0, entries.length - ISSUED_PER_LEAD)) {
|
|
273
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
274
|
+
fs.rmSync(path.join(tokens, name), { force: true });
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Fail open: an unrecorded id is refused later, never a broken hook.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The issued-id file's text, or null when no hook issued `claimed`. */
|
|
282
|
+
const issuedRecord = (cwd: string | undefined, claimed: string): string | null => {
|
|
283
|
+
const cut = claimed.lastIndexOf(":");
|
|
284
|
+
if (cut <= 0) return null;
|
|
285
|
+
migrateRepoIssued(cwd);
|
|
286
|
+
try {
|
|
287
|
+
return fs.readFileSync(
|
|
288
|
+
path.join(userIssuedRoot(), hash(claimed.slice(0, cut)), hash(claimed)),
|
|
289
|
+
"utf8",
|
|
290
|
+
);
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const TOKEN_HASH = /^([0-9a-f]{64})$/mu;
|
|
297
|
+
|
|
298
|
+
/** The stored token hash for `claimed`, and whether its lead has any token at all. */
|
|
299
|
+
const issuedToken = (claimed: string): { stored: string | null; leadBound: boolean } => {
|
|
300
|
+
const cut = claimed.lastIndexOf(":");
|
|
301
|
+
if (cut <= 0) return { stored: null, leadBound: false };
|
|
302
|
+
const dir = path.join(userTokenRoot(), hash(claimed.slice(0, cut)));
|
|
303
|
+
try {
|
|
304
|
+
const text = fs.readFileSync(path.join(dir, hash(claimed)), "utf8");
|
|
305
|
+
return { stored: TOKEN_HASH.exec(text)?.[1] ?? null, leadBound: true };
|
|
306
|
+
} catch {
|
|
307
|
+
return { stored: null, leadBound: safeList(dir).length > 0 };
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* How a SubagentStart hook issued `claimed` (`<lead>:<agent id>`): `token`
|
|
313
|
+
* (it must be presented, `--token`), `legacy` (a hook older than tokens
|
|
314
|
+
* recorded it) or null (never issued). Binding is sticky: once its lead has
|
|
315
|
+
* any token-bound id, a token-less record (an old cached hook rewriting one,
|
|
316
|
+
* or issuing a new one) reads `token` with no token that can match.
|
|
317
|
+
*/
|
|
318
|
+
export type IssuedBinding = "token" | "legacy";
|
|
319
|
+
export function issuedBinding(cwd: string | undefined, claimed: string): IssuedBinding | null {
|
|
320
|
+
const record = issuedRecord(cwd, claimed);
|
|
321
|
+
const { stored, leadBound } = issuedToken(claimed);
|
|
322
|
+
if (stored) return "token";
|
|
323
|
+
if (record === null) return null;
|
|
324
|
+
return leadBound ? "token" : "legacy";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Whether `token` is the one the hook gave the subagent it issued `claimed` to. */
|
|
328
|
+
export function issuedTokenMatches(
|
|
329
|
+
cwd: string | undefined,
|
|
330
|
+
claimed: string,
|
|
331
|
+
token: string | null | undefined,
|
|
332
|
+
): boolean {
|
|
333
|
+
migrateRepoIssued(cwd);
|
|
334
|
+
const { stored } = issuedToken(claimed);
|
|
335
|
+
if (!stored || !token) return false;
|
|
336
|
+
const given = Buffer.from(tokenHash(token.trim()));
|
|
337
|
+
const want = Buffer.from(stored);
|
|
338
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const leadOf = (session: string): string => session.slice(0, session.lastIndexOf(":"));
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Hosts whose child sessions run their own shell with their own host session
|
|
345
|
+
* id: a child proves itself from its own shell, so a lead never mints a child id.
|
|
346
|
+
*/
|
|
347
|
+
const OWN_CHILD_SHELL = new Set(["opencode", "pi"]);
|
|
348
|
+
|
|
349
|
+
const SESSION_UNSAFE = /[^A-Za-z0-9_.:@/+-]/g;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The host-derived identity of a child session: `<lead>:<child>`, the shape
|
|
353
|
+
* Claude Code's SubagentStart hook gives a subagent. The child's own id is
|
|
354
|
+
* kept whole (it is what the host vouches for); the lead part is trimmed to
|
|
355
|
+
* fit the ledger's 128-character session grammar.
|
|
356
|
+
*/
|
|
357
|
+
export function childSessionId(lead: string, child: string): string {
|
|
358
|
+
const leaf = child.replace(SESSION_UNSAFE, "-").slice(-120);
|
|
359
|
+
const prefix = lead.replace(SESSION_UNSAFE, "-").slice(0, 127 - leaf.length);
|
|
360
|
+
return prefix ? `${prefix}:${leaf}` : leaf;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export type SessionClaimRefusal = { error: string; unblock: string };
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether `claimed` may act as this shell's session (`cwd` locates the
|
|
367
|
+
* workspace's record of hook-issued ids):
|
|
368
|
+
* - no host session (CI, a plain terminal): any id; `--session X` and
|
|
369
|
+
* WORKIT_SESSION_ID=X are the same declaration;
|
|
370
|
+
* - the shell's own session; a declared `<lead>:<x>` (any WORKIT_HOST but
|
|
371
|
+
* cli) only when a SubagentStart hook issued it;
|
|
372
|
+
* - on a host that sets the id, `<lead>:<this session>`: a child naming its
|
|
373
|
+
* own host session;
|
|
374
|
+
* - `<this session>:<agent id>` only when this lead's SubagentStart hook
|
|
375
|
+
* issued it (Claude Code, Codex).
|
|
376
|
+
* An issued id the hook bound to a token is accepted only with that token
|
|
377
|
+
* (`options.token`): a lead or sibling that read the id cannot reuse it.
|
|
378
|
+
* `options.flag`: named by `--session`. On a host that is only for an issued
|
|
379
|
+
* id; a shell's own or derived session comes from its environment, so a
|
|
380
|
+
* `--session` the gate would not accept is refused here, not later.
|
|
381
|
+
* Anything else is a forged id: a verdict recorded under it would make author
|
|
382
|
+
* is not verifier a naming convention.
|
|
383
|
+
*/
|
|
384
|
+
const isOwnShellClaim = (real: RealSession, claimed: string): boolean =>
|
|
385
|
+
(claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
|
|
386
|
+
(real.kind === "derived" && claimed.endsWith(`:${real.session}`));
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* `claimed` is the shell's own derived session (OPENCODE_SESSION_ID,
|
|
390
|
+
* PI_SESSION_ID, CODEX_THREAD_ID), alone or as `<lead>:<it>`: a `--session`
|
|
391
|
+
* naming it adds nothing, so the CLI acts as the shell's session instead.
|
|
392
|
+
*/
|
|
393
|
+
export const namesOwnDerivedSession = (env: NodeJS.ProcessEnv, claimed: string): boolean => {
|
|
394
|
+
const real = realHostSession(env);
|
|
395
|
+
return real.kind === "derived" && isOwnShellClaim(real, claimed);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
export function sessionClaimRefusal(
|
|
399
|
+
env: NodeJS.ProcessEnv,
|
|
400
|
+
claimed: string,
|
|
401
|
+
cwd?: string,
|
|
402
|
+
options: { token?: string | null; flag?: boolean } = {},
|
|
403
|
+
): SessionClaimRefusal | null {
|
|
404
|
+
const real = realHostSession(env);
|
|
405
|
+
const host = family(real.host);
|
|
406
|
+
// A host that always sets its own variable, named without it: the variable
|
|
407
|
+
// was removed (`env -u OPENCODE_SESSION_ID`), so nothing vouches for the id.
|
|
408
|
+
if (real.kind !== "derived" && host !== null && HOST_SET_VARIABLE[host])
|
|
409
|
+
return {
|
|
410
|
+
error: `forged_session: WORKIT_HOST names ${real.host}, but its own ${HOST_SET_VARIABLE[host]} is not in this shell, so ${claimed} cannot be checked`,
|
|
411
|
+
unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
|
|
412
|
+
};
|
|
413
|
+
if (real.kind === "none" || !real.session) return null;
|
|
414
|
+
const ownShell = isOwnShellClaim(real, claimed);
|
|
415
|
+
if (ownShell) {
|
|
416
|
+
// A derived session (the host's own shell variable) cannot be replaced by
|
|
417
|
+
// a flag, so naming it is a no-op, not an error: a printed line that
|
|
418
|
+
// carries it still records. Declared sessions keep refusing the flag.
|
|
419
|
+
if (!options.flag || real.kind === "derived") return null;
|
|
420
|
+
return {
|
|
421
|
+
error: `session_flag: --session ${claimed} names this shell's own ${real.host} session; on a host only an issued subagent id is passed as --session`,
|
|
422
|
+
unblock: "drop --session: the shell's own session is already the acting one",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
const issuedClaim = claimed === real.session || claimed.startsWith(`${real.session}:`);
|
|
426
|
+
if (issuedClaim) {
|
|
427
|
+
const binding = issuedBinding(cwd, claimed);
|
|
428
|
+
if (binding === "legacy") return null;
|
|
429
|
+
if (binding === "token")
|
|
430
|
+
return issuedTokenMatches(cwd, claimed, options.token)
|
|
431
|
+
? null
|
|
432
|
+
: {
|
|
433
|
+
error: `unbound_session: ${claimed} was issued to a subagent, and this call did not present the token its SubagentStart hook gave it`,
|
|
434
|
+
unblock: `only that subagent acts as ${claimed}: it passes --token exactly as its SubagentStart context names it (or WORKIT_SESSION_TOKEN); anyone else spawns their own verifier`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const error = `forged_session: ${claimed} is neither this shell's session (${real.session}) nor an id its host issued to a child of it`;
|
|
438
|
+
// A well-formed subagent id, but no hook here ever recorded one: the hook
|
|
439
|
+
// most likely predates this CLI (CLI and plugin update separately).
|
|
440
|
+
if (issuedClaim && noIssuedRecord(cwd, leadOf(claimed)) && host !== null && PLUGIN_UPDATE[host])
|
|
441
|
+
return {
|
|
442
|
+
error: `${error}; no SubagentStart hook has recorded an issued id for this lead`,
|
|
443
|
+
unblock: `the workit plugin hook is probably older than this CLI and does not record the ids it issues: update the plugin (${PLUGIN_UPDATE[host]}), then relaunch the verifier so its SubagentStart runs the new hook (workit doctor checks this as hook_issued_sessions)`,
|
|
444
|
+
};
|
|
445
|
+
const ownChildShell = host !== null && OWN_CHILD_SHELL.has(host);
|
|
446
|
+
return {
|
|
447
|
+
error,
|
|
448
|
+
unblock: ownChildShell
|
|
449
|
+
? `record the verdict from the verifier's own ${host === "pi" ? "Pi worker" : "OpenCode child session"} shell, where WORKIT_SESSION_ID is already set; never set WORKIT_SESSION_ID or --session by hand`
|
|
450
|
+
: "never name a session yourself: spawn a verifier or reviewer subagent and pass exactly the --session and --token its SubagentStart context names, or pass --as verifier",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Hosts that always put their own session variable in the agent's shell. */
|
|
455
|
+
const HOST_SET_VARIABLE: Readonly<Record<string, string>> = {
|
|
456
|
+
opencode: "OPENCODE_SESSION_ID",
|
|
457
|
+
pi: "PI_SESSION_ID",
|
|
458
|
+
codex: "CODEX_THREAD_ID",
|
|
459
|
+
};
|