@brainervirus/workit-core 8.12.0 → 8.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.12.0",
3
+ "version": "8.12.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
package/src/autonomy.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // a process that drives a pseudo-terminal or edits the file directly can
15
15
  // still defeat it; the host's permission prompt is the hard boundary.
16
16
  // - `release` gates `workit git push --release`: pushing the git-flow
17
- // production and develop branches (fast-forward only) and `v*` tags whose
17
+ // production and develop branches (fast-forward only) and release tags (`v*` or git-flow's prefix) whose
18
18
  // commit already landed (a tag never moves). Outside git-flow it pushes
19
19
  // tags only. It needs `push` too.
20
20
  // - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
package/src/git/ops.ts CHANGED
@@ -1138,12 +1138,22 @@ export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1138
1138
  production: string | null;
1139
1139
  };
1140
1140
 
1141
- /** A release tag: `v` and a digit (`v4.1.0`, `v2`). */
1142
- const RELEASE_TAG = /^v\d/u;
1141
+ /**
1142
+ * The release tag prefix: git-flow's own `gitflow.prefix.versiontag` when the
1143
+ * repository sets it (an empty value means bare `4.1.0` tags), else `v`.
1144
+ */
1145
+ const releaseTagPrefix = (cwd: string): string => {
1146
+ const configured = git(cwd, ["config", "--get", "gitflow.prefix.versiontag"]);
1147
+ return configured.ok ? configured.stdout.trim() : "v";
1148
+ };
1149
+
1150
+ /** A release tag: the prefix and a digit (`v4.1.0`, `v2`, or `4.1.0` with an empty prefix). */
1151
+ const isReleaseTag = (name: string, prefix: string): boolean =>
1152
+ name.startsWith(prefix) && /^\d/u.test(name.slice(prefix.length));
1143
1153
 
1144
1154
  /**
1145
1155
  * What `workit git push --release` may push: the git-flow production and
1146
- * develop branches and `v*` tags, nothing else, each by its local ref. Everything else stays on the feature-branch
1156
+ * develop branches and release tags (`v*`, or git-flow's configured prefix), nothing else, each by its local ref. Everything else stays on the feature-branch
1147
1157
  * path (`workit git push`, `workit pr create`).
1148
1158
  */
1149
1159
  export function releasePushPreflight(
@@ -1172,11 +1182,12 @@ export function releasePushPreflight(
1172
1182
  `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1173
1183
  );
1174
1184
  if (tag) {
1175
- if (!RELEASE_TAG.test(tagName))
1185
+ const prefix = releaseTagPrefix(cwd);
1186
+ if (!isReleaseTag(tagName, prefix))
1176
1187
  return failure(
1177
1188
  "blocked",
1178
- `release_push_refused: tag ${tagName} is not a release tag (v<version>)`,
1179
- "git tag -a v<version> -m <message> # then: workit git push --release v<version>",
1189
+ `release_push_refused: tag ${tagName} is not a release tag (${prefix}<version>, from ${prefix === "v" ? "the default" : "gitflow.prefix.versiontag"})`,
1190
+ `git tag -a ${prefix}<version> -m <message> # then: workit git push --release ${prefix}<version>`,
1180
1191
  );
1181
1192
  refs.push({ kind: "tag", name: tagName, ref: `refs/tags/${tagName}`, sha: tag });
1182
1193
  continue;
@@ -10,6 +10,7 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
+ import { resolveStateDir } from "./core/logger";
13
14
  import { resolveStore } from "./store/paths";
14
15
 
15
16
  export type HostSession = { host: string | null; session: string | null };
@@ -80,36 +81,84 @@ export function realHostSession(env: NodeJS.ProcessEnv): RealSession {
80
81
  }
81
82
 
82
83
  // Ids a SubagentStart hook gave a lead's subagents (`<lead>:<agent id>`): one
83
- // empty file per id under `<store>/hooks/issued/<lead hash>/`, so concurrent
84
+ // empty file per id under `<state>/issued/<lead hash>/`, so concurrent
84
85
  // hooks never lose each other's write, bounded per lead.
85
86
  const ISSUED_PER_LEAD = 64;
86
87
 
87
88
  /**
88
89
  * Written into each issued-id file. A hook bundle that contains this string
89
- * records issued ids; `workit doctor` looks for it in the installed Claude
90
+ * records issued ids per lead in the user's state dir (`/2`; `/1` hooks of
91
+ * 8.10-8.12 wrote them per repository, which breaks cross-repo verdicts); `workit doctor` looks for it in the installed Claude
90
92
  * Code and Codex hooks to catch a plugin older than the CLI.
91
93
  */
92
- export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/1";
94
+ export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/2";
93
95
 
94
96
  const hash = (value: string): string =>
95
97
  createHash("sha256").update(value).digest("hex").slice(0, 32);
96
98
 
97
- const issuedRoot = (cwd: string): string | null => {
98
- const location = resolveStore(cwd);
99
- return location instanceof Error ? null : path.join(location.dir, "hooks", "issued");
99
+ // Issued ids live in the per-user state dir, keyed by lead session: a lead
100
+ // started in one repository delegates review of others, and the verifier
101
+ // records its verdict from whichever repository it reviews (#294).
102
+ const userIssuedRoot = (): string => path.join(resolveStateDir(), "issued");
103
+
104
+ /**
105
+ * Hooks of 8.10-8.12 wrote issued ids into `<store>/hooks/issued` of the
106
+ * lead's repository. Move them into the per-user registry file by file
107
+ * (copy, then unlink: an id an old hook writes meanwhile stays for the next
108
+ * read) and drop the emptied directories. Each lead migrates on its own, so
109
+ * one bad entry never strands the rest. Fails open.
110
+ */
111
+ function migrateRepoIssued(cwd: string | undefined): void {
112
+ if (cwd === undefined) return;
113
+ let old: string;
114
+ try {
115
+ const location = resolveStore(cwd);
116
+ if (location instanceof Error) return;
117
+ old = path.join(location.dir, "hooks", "issued");
118
+ if (!fs.existsSync(old)) return;
119
+ } catch {
120
+ return;
121
+ }
122
+ for (const lead of safeList(old)) {
123
+ try {
124
+ const from = path.join(old, lead);
125
+ if (!fs.statSync(from).isDirectory()) continue;
126
+ const to = path.join(userIssuedRoot(), lead);
127
+ fs.mkdirSync(to, { recursive: true, mode: 0o700 });
128
+ for (const id of fs.readdirSync(from)) {
129
+ fs.copyFileSync(path.join(from, id), path.join(to, id));
130
+ fs.rmSync(path.join(from, id), { force: true });
131
+ }
132
+ removeIfEmpty(from);
133
+ } catch {
134
+ // Fail open: this lead's ids stay for the next read.
135
+ }
136
+ }
137
+ removeIfEmpty(old);
138
+ }
139
+
140
+ const safeList = (dir: string): string[] => {
141
+ try {
142
+ return fs.readdirSync(dir);
143
+ } catch {
144
+ return [];
145
+ }
100
146
  };
101
147
 
102
- const issuedDir = (cwd: string, lead: string): string | null => {
103
- const root = issuedRoot(cwd);
104
- return root === null ? null : path.join(root, hash(lead));
148
+ const removeIfEmpty = (dir: string): void => {
149
+ try {
150
+ fs.rmdirSync(dir);
151
+ } catch {
152
+ // Not empty (a concurrent old hook wrote) or already gone.
153
+ }
105
154
  };
106
155
 
107
- /** No hook in this workspace has ever recorded an issued id (an older plugin hook, most likely). */
108
- const noIssuedRecord = (cwd: string | undefined): boolean => {
156
+ /** This lead's hooks never recorded an issued id (an older plugin hook, most likely). */
157
+ const noIssuedRecord = (cwd: string | undefined, lead: string): boolean => {
109
158
  if (cwd === undefined) return false;
110
159
  try {
111
- const root = issuedRoot(cwd);
112
- return root !== null && !fs.existsSync(root);
160
+ migrateRepoIssued(cwd);
161
+ return !fs.existsSync(path.join(userIssuedRoot(), hash(lead)));
113
162
  } catch {
114
163
  return false;
115
164
  }
@@ -124,9 +173,9 @@ const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
124
173
  /** Remember that a SubagentStart hook issued `issued` to a subagent of `lead`. Fails open. */
125
174
  export function recordIssuedSession(cwd: string, lead: string, issued: string): void {
126
175
  try {
127
- const dir = issuedDir(cwd, lead);
128
- if (!dir) return;
129
- fs.mkdirSync(dir, { recursive: true });
176
+ migrateRepoIssued(cwd);
177
+ const dir = path.join(userIssuedRoot(), hash(lead));
178
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
130
179
  const own = hash(issued);
131
180
  fs.writeFileSync(path.join(dir, own), ISSUED_SESSIONS_MARKER);
132
181
  const entries = fs.readdirSync(dir);
@@ -144,10 +193,9 @@ export function recordIssuedSession(cwd: string, lead: string, issued: string):
144
193
  }
145
194
 
146
195
  const wasIssued = (cwd: string | undefined, lead: string, claimed: string): boolean => {
147
- if (cwd === undefined) return false;
196
+ migrateRepoIssued(cwd);
148
197
  try {
149
- const dir = issuedDir(cwd, lead);
150
- return dir !== null && fs.existsSync(path.join(dir, hash(claimed)));
198
+ return fs.existsSync(path.join(userIssuedRoot(), hash(lead), hash(claimed)));
151
199
  } catch {
152
200
  return false;
153
201
  }
@@ -220,9 +268,9 @@ export function sessionClaimRefusal(
220
268
  const error = `forged_session: ${claimed} is neither this shell's session (${real.session}) nor an id its host issued to a child of it`;
221
269
  // A well-formed subagent id, but no hook here ever recorded one: the hook
222
270
  // most likely predates this CLI (CLI and plugin update separately).
223
- if (issuedClaim && noIssuedRecord(cwd) && host !== null && PLUGIN_UPDATE[host])
271
+ if (issuedClaim && noIssuedRecord(cwd, leadOf(claimed)) && host !== null && PLUGIN_UPDATE[host])
224
272
  return {
225
- error: `${error}; no SubagentStart hook in this workspace has recorded an issued id`,
273
+ error: `${error}; no SubagentStart hook has recorded an issued id for this lead`,
226
274
  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)`,
227
275
  };
228
276
  const ownChildShell = host !== null && OWN_CHILD_SHELL.has(host);