@webpieces/tooling-common 0.0.1

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 ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@webpieces/tooling-common",
3
+ "version": "0.0.1",
4
+ "description": "Generic atomic files, errors, harness paths, and repository state paths.",
5
+ "type": "commonjs",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./package.json": "./package.json",
10
+ "./to-error": "./src/to-error.js"
11
+ },
12
+ "files": [
13
+ "src/**/*"
14
+ ],
15
+ "author": "Dean Hiller",
16
+ "license": "Apache-2.0",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/deanhiller/webpieces-ts.git",
20
+ "directory": "packages/tooling/tooling-common"
21
+ },
22
+ "dependencies": {
23
+ "inversify": "7.10.4",
24
+ "reflect-metadata": "0.2.2",
25
+ "tslib": "2.8.1"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "types": "./src/index.d.ts"
31
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Crash-safe / concurrency-safe file writes for the SHARED `.webpieces/` state dir.
3
+ *
4
+ * WHY this exists: once `.webpieces/` is shared by every linked worktree (see SharedStateDir), files
5
+ * that used to have exactly ONE writer per worktree now have N concurrent writers across the repo —
6
+ * seven agents, one `merged-branches.json`. A plain `fs.writeFileSync` TRUNCATES first and then writes,
7
+ * so a reader that opens the file in that window reads a truncated or half-written document and its
8
+ * `JSON.parse` throws. That is not theoretical: it is exactly the failure PR #526 had to paper over
9
+ * from the READER side for webpieces.config.json (retry a transient parse failure). This fixes the
10
+ * WRITER side, which is where it actually belongs — a reader retry cannot help a reader that is handed
11
+ * a syntactically VALID prefix of a JSON document.
12
+ *
13
+ * The fix is write-to-temp-then-`rename()`. POSIX `rename(2)` within a single directory is atomic: a
14
+ * concurrent reader sees either the entire old file or the entire new one, never a mix, and never a
15
+ * zero-length file. The temp file MUST be created in the SAME directory as the destination, otherwise
16
+ * the rename crosses a filesystem boundary, degrades to copy+unlink, and loses the atomicity.
17
+ *
18
+ * NOT everything should come through here. Append-only logs (`branch-mutations.log`,
19
+ * `guard-*.log`) are already concurrency-safe by a different mechanism — `fs.appendFileSync` opens
20
+ * with O_APPEND and issues ONE `write(2)` per record, which POSIX guarantees not to interleave for
21
+ * writes under PIPE_BUF. Routing an append through a rename would be strictly WORSE: it would make
22
+ * concurrent appenders clobber each other's lines wholesale.
23
+ */
24
+ export declare class AtomicFile {
25
+ private sequence;
26
+ /**
27
+ * Write `contents` to `absPath` so that no concurrent reader can ever observe a partial file.
28
+ * Creates the parent directory. Throws only if the write itself genuinely failed (disk full,
29
+ * permissions) — callers that must not fail wrap this.
30
+ */
31
+ writeAtomic(absPath: string, contents: string): void;
32
+ /** `writeAtomic` of a pretty-printed JSON document, trailing newline included. */
33
+ writeJsonAtomic(absPath: string, value: object): void;
34
+ /**
35
+ * Atomic write, SKIPPED when the file already holds exactly `contents`. Returns true when it wrote.
36
+ *
37
+ * This is the instruct-ai regeneration case: every `wp-*` command rewrites the same generated docs,
38
+ * and with a shared `.webpieces/` those rewrites now overlap across worktrees. Identical content is
39
+ * the overwhelmingly common case, so the cheapest correct answer is to not write at all; when the
40
+ * content genuinely changed, the write is atomic so a concurrent reader never sees a half-doc.
41
+ */
42
+ writeIfChanged(absPath: string, contents: string): boolean;
43
+ private alreadyHolds;
44
+ private tempPathFor;
45
+ private discard;
46
+ }
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AtomicFile = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const inversify_1 = require("inversify");
8
+ const to_error_1 = require("./to-error");
9
+ /**
10
+ * Crash-safe / concurrency-safe file writes for the SHARED `.webpieces/` state dir.
11
+ *
12
+ * WHY this exists: once `.webpieces/` is shared by every linked worktree (see SharedStateDir), files
13
+ * that used to have exactly ONE writer per worktree now have N concurrent writers across the repo —
14
+ * seven agents, one `merged-branches.json`. A plain `fs.writeFileSync` TRUNCATES first and then writes,
15
+ * so a reader that opens the file in that window reads a truncated or half-written document and its
16
+ * `JSON.parse` throws. That is not theoretical: it is exactly the failure PR #526 had to paper over
17
+ * from the READER side for webpieces.config.json (retry a transient parse failure). This fixes the
18
+ * WRITER side, which is where it actually belongs — a reader retry cannot help a reader that is handed
19
+ * a syntactically VALID prefix of a JSON document.
20
+ *
21
+ * The fix is write-to-temp-then-`rename()`. POSIX `rename(2)` within a single directory is atomic: a
22
+ * concurrent reader sees either the entire old file or the entire new one, never a mix, and never a
23
+ * zero-length file. The temp file MUST be created in the SAME directory as the destination, otherwise
24
+ * the rename crosses a filesystem boundary, degrades to copy+unlink, and loses the atomicity.
25
+ *
26
+ * NOT everything should come through here. Append-only logs (`branch-mutations.log`,
27
+ * `guard-*.log`) are already concurrency-safe by a different mechanism — `fs.appendFileSync` opens
28
+ * with O_APPEND and issues ONE `write(2)` per record, which POSIX guarantees not to interleave for
29
+ * writes under PIPE_BUF. Routing an append through a rename would be strictly WORSE: it would make
30
+ * concurrent appenders clobber each other's lines wholesale.
31
+ */
32
+ let AtomicFile = class AtomicFile {
33
+ // Distinguishes temp files written by the same process within the same millisecond. Combined with
34
+ // the pid this makes the temp name unique across every concurrent writer of a shared file.
35
+ sequence = 0;
36
+ /**
37
+ * Write `contents` to `absPath` so that no concurrent reader can ever observe a partial file.
38
+ * Creates the parent directory. Throws only if the write itself genuinely failed (disk full,
39
+ * permissions) — callers that must not fail wrap this.
40
+ */
41
+ writeAtomic(absPath, contents) {
42
+ const dir = path.dirname(absPath);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ const tmpPath = this.tempPathFor(absPath);
45
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
46
+ try {
47
+ fs.writeFileSync(tmpPath, contents);
48
+ fs.renameSync(tmpPath, absPath);
49
+ }
50
+ catch (err) {
51
+ const error = (0, to_error_1.toError)(err);
52
+ this.discard(tmpPath);
53
+ throw new Error(`Failed atomic write of ${absPath}: ${error.message}`, { cause: error });
54
+ }
55
+ }
56
+ /** `writeAtomic` of a pretty-printed JSON document, trailing newline included. */
57
+ writeJsonAtomic(absPath, value) {
58
+ this.writeAtomic(absPath, JSON.stringify(value, null, 2) + '\n');
59
+ }
60
+ /**
61
+ * Atomic write, SKIPPED when the file already holds exactly `contents`. Returns true when it wrote.
62
+ *
63
+ * This is the instruct-ai regeneration case: every `wp-*` command rewrites the same generated docs,
64
+ * and with a shared `.webpieces/` those rewrites now overlap across worktrees. Identical content is
65
+ * the overwhelmingly common case, so the cheapest correct answer is to not write at all; when the
66
+ * content genuinely changed, the write is atomic so a concurrent reader never sees a half-doc.
67
+ */
68
+ writeIfChanged(absPath, contents) {
69
+ if (this.alreadyHolds(absPath, contents))
70
+ return false;
71
+ this.writeAtomic(absPath, contents);
72
+ return true;
73
+ }
74
+ // True when the file exists and its bytes already equal `contents`. Any read failure answers false
75
+ // (rewrite it) — "cannot read the current content" must never read as "it is already correct".
76
+ alreadyHolds(absPath, contents) {
77
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
78
+ try {
79
+ if (!fs.existsSync(absPath))
80
+ return false;
81
+ return fs.readFileSync(absPath, 'utf8') === contents;
82
+ }
83
+ catch (err) {
84
+ const error = (0, to_error_1.toError)(err);
85
+ void error;
86
+ return false;
87
+ }
88
+ }
89
+ // A sibling of the destination (same directory ⇒ same filesystem ⇒ the rename stays atomic), named
90
+ // so a crash leaves an obviously-temporary dotfile rather than something mistaken for real state.
91
+ tempPathFor(absPath) {
92
+ this.sequence += 1;
93
+ const stamp = `${String(process.pid)}-${String(Date.now())}-${String(this.sequence)}`;
94
+ return path.join(path.dirname(absPath), `.${path.basename(absPath)}.tmp-${stamp}`);
95
+ }
96
+ // Best-effort removal of an abandoned temp file. A failure here is not worth reporting over the
97
+ // original write failure that caused it.
98
+ discard(tmpPath) {
99
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
100
+ try {
101
+ if (fs.existsSync(tmpPath))
102
+ fs.unlinkSync(tmpPath);
103
+ }
104
+ catch (err) {
105
+ const error = (0, to_error_1.toError)(err);
106
+ void error;
107
+ }
108
+ }
109
+ };
110
+ exports.AtomicFile = AtomicFile;
111
+ exports.AtomicFile = AtomicFile = tslib_1.__decorate([
112
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
113
+ ], AtomicFile);
114
+ //# sourceMappingURL=atomic-file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"atomic-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/tooling-common/src/atomic-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { toError } from './to-error';\n\n/**\n * Crash-safe / concurrency-safe file writes for the SHARED `.webpieces/` state dir.\n *\n * WHY this exists: once `.webpieces/` is shared by every linked worktree (see SharedStateDir), files\n * that used to have exactly ONE writer per worktree now have N concurrent writers across the repo —\n * seven agents, one `merged-branches.json`. A plain `fs.writeFileSync` TRUNCATES first and then writes,\n * so a reader that opens the file in that window reads a truncated or half-written document and its\n * `JSON.parse` throws. That is not theoretical: it is exactly the failure PR #526 had to paper over\n * from the READER side for webpieces.config.json (retry a transient parse failure). This fixes the\n * WRITER side, which is where it actually belongs — a reader retry cannot help a reader that is handed\n * a syntactically VALID prefix of a JSON document.\n *\n * The fix is write-to-temp-then-`rename()`. POSIX `rename(2)` within a single directory is atomic: a\n * concurrent reader sees either the entire old file or the entire new one, never a mix, and never a\n * zero-length file. The temp file MUST be created in the SAME directory as the destination, otherwise\n * the rename crosses a filesystem boundary, degrades to copy+unlink, and loses the atomicity.\n *\n * NOT everything should come through here. Append-only logs (`branch-mutations.log`,\n * `guard-*.log`) are already concurrency-safe by a different mechanism — `fs.appendFileSync` opens\n * with O_APPEND and issues ONE `write(2)` per record, which POSIX guarantees not to interleave for\n * writes under PIPE_BUF. Routing an append through a rename would be strictly WORSE: it would make\n * concurrent appenders clobber each other's lines wholesale.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class AtomicFile {\n // Distinguishes temp files written by the same process within the same millisecond. Combined with\n // the pid this makes the temp name unique across every concurrent writer of a shared file.\n private sequence: number = 0;\n\n /**\n * Write `contents` to `absPath` so that no concurrent reader can ever observe a partial file.\n * Creates the parent directory. Throws only if the write itself genuinely failed (disk full,\n * permissions) — callers that must not fail wrap this.\n */\n writeAtomic(absPath: string, contents: string): void {\n const dir = path.dirname(absPath);\n fs.mkdirSync(dir, { recursive: true });\n const tmpPath = this.tempPathFor(absPath);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(tmpPath, contents);\n fs.renameSync(tmpPath, absPath);\n } catch (err: unknown) {\n const error = toError(err);\n this.discard(tmpPath);\n throw new Error(`Failed atomic write of ${absPath}: ${error.message}`, { cause: error });\n }\n }\n\n /** `writeAtomic` of a pretty-printed JSON document, trailing newline included. */\n writeJsonAtomic(absPath: string, value: object): void {\n this.writeAtomic(absPath, JSON.stringify(value, null, 2) + '\\n');\n }\n\n /**\n * Atomic write, SKIPPED when the file already holds exactly `contents`. Returns true when it wrote.\n *\n * This is the instruct-ai regeneration case: every `wp-*` command rewrites the same generated docs,\n * and with a shared `.webpieces/` those rewrites now overlap across worktrees. Identical content is\n * the overwhelmingly common case, so the cheapest correct answer is to not write at all; when the\n * content genuinely changed, the write is atomic so a concurrent reader never sees a half-doc.\n */\n writeIfChanged(absPath: string, contents: string): boolean {\n if (this.alreadyHolds(absPath, contents)) return false;\n this.writeAtomic(absPath, contents);\n return true;\n }\n\n // True when the file exists and its bytes already equal `contents`. Any read failure answers false\n // (rewrite it) — \"cannot read the current content\" must never read as \"it is already correct\".\n private alreadyHolds(absPath: string, contents: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(absPath)) return false;\n return fs.readFileSync(absPath, 'utf8') === contents;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // A sibling of the destination (same directory ⇒ same filesystem ⇒ the rename stays atomic), named\n // so a crash leaves an obviously-temporary dotfile rather than something mistaken for real state.\n private tempPathFor(absPath: string): string {\n this.sequence += 1;\n const stamp = `${String(process.pid)}-${String(Date.now())}-${String(this.sequence)}`;\n return path.join(path.dirname(absPath), `.${path.basename(absPath)}.tmp-${stamp}`);\n }\n\n // Best-effort removal of an abandoned temp file. A failure here is not worth reporting over the\n // original write failure that caused it.\n private discard(tmpPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n}\n"]}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * THE ONE PLACE THAT KNOWS WHERE CLAUDE CODE KEEPS ITS ON-DISK STATE.
3
+ *
4
+ * ─── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────
5
+ * `~/.claude` is a DEFAULT, not the location. Claude Code relocates the whole tree when
6
+ * `$CLAUDE_CONFIG_DIR` is set, and three modules in this package read that tree: reviewer provenance
7
+ * (`subagent-provenance.ts`), the audit record (`review-provenance.ts`) and the worktree-reap veto
8
+ * (`harness-agent-activity.ts`). Only the third one resolved it correctly; the other two joined
9
+ * `os.homedir()` with a hardcoded `'.claude'`.
10
+ *
11
+ * The consequence was not a degraded answer, it was a HARD BLOCK with no user-side workaround: on a
12
+ * machine with `CLAUDE_CONFIG_DIR=~/.claude-work`, provenance found zero transcripts — not the
13
+ * reviewers', not even the main agent's — so `wp-finish-upsert-pr` refused every PR whose reviewers
14
+ * had genuinely run and genuinely passed, and the refusal blamed the reviewers' cwd. The audit record
15
+ * it wrote said `mainTranscript: ""`, which is the field that settles it: the main transcript is found
16
+ * by session id alone, with no cwd, branch or stamping involved, so an empty one can ONLY mean the
17
+ * lookup ROOT was wrong. Re-spawning cannot fix a wrong root, and the only escape was a symlink into
18
+ * `~/.claude` — i.e. hand-placing evidence inside a verification gate's own evidence tree.
19
+ *
20
+ * So the resolution lives here once, and the three modules call it. A second copy is how the first one
21
+ * got out of step.
22
+ *
23
+ * ─── WHY BOTH ROOTS ARE SEARCHED ─────────────────────────────────────────────────────────────────
24
+ * `$CLAUDE_CONFIG_DIR` is a property of the PROCESS, and the process that runs `wp-finish-upsert-pr`
25
+ * is not the process that wrote the transcripts. A shell launched before the variable was exported —
26
+ * or a hook, or a `pnpm` script whose env was sanitized — reads the transcripts of a session that had
27
+ * it set while itself having it unset, and vice versa. Resolving to exactly ONE root makes the check
28
+ * depend on env agreement between two unrelated processes, which is a fact nobody can verify and
29
+ * nobody can repair from inside the flow.
30
+ *
31
+ * Searching both is cheap (a `readdir` of a directory that usually does not exist) and it only ever
32
+ * ADDS candidate trees. It cannot weaken the integrity property either: every root yields the same
33
+ * harness-written `spawnDepth` / `isSidechain` / branch evidence, so a transcript found under the
34
+ * second root is no more forgeable than one found under the first.
35
+ *
36
+ * Shaped like {@link ClaudeEnv} in `claude-env.ts` — an injectable class plus one process-wide
37
+ * instance — because `no-function-outside-class` is on: module-scope helpers are not a shape this repo
38
+ * writes, whatever their arity.
39
+ */
40
+ /** The env var Claude Code exports when its config tree has been relocated. */
41
+ export declare const CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";
42
+ export declare class ClaudeConfigDir {
43
+ /** The raw `$CLAUDE_CONFIG_DIR` value, or '' when it is not set. For DIAGNOSTICS, never for joining. */
44
+ configuredDir(): string;
45
+ /** Claude Code's config tree: `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */
46
+ root(): string;
47
+ /**
48
+ * Every config tree worth searching: the configured one FIRST, then `~/.claude` when it is a
49
+ * different directory. See the both-roots note above for why this is a list and not one answer.
50
+ */
51
+ roots(): string[];
52
+ /**
53
+ * `<config>/projects` for every root {@link roots} names, in the same order — where per-project
54
+ * session state and transcripts live.
55
+ *
56
+ * There is deliberately NO singular `projectsRoot()` beside this. Searching exactly one root is
57
+ * precisely the defect #963 was, so an exported spelling that returns the one-root answer would make
58
+ * the broken thing the shorter thing to type, in the one file that exists to prevent it.
59
+ */
60
+ projectsRoots(): string[];
61
+ }
62
+ export declare const claudeConfigDir: ClaudeConfigDir;
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeConfigDir = exports.ClaudeConfigDir = exports.CLAUDE_CONFIG_DIR_ENV = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const os = tslib_1.__importStar(require("os"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const inversify_1 = require("inversify");
8
+ /**
9
+ * THE ONE PLACE THAT KNOWS WHERE CLAUDE CODE KEEPS ITS ON-DISK STATE.
10
+ *
11
+ * ─── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────
12
+ * `~/.claude` is a DEFAULT, not the location. Claude Code relocates the whole tree when
13
+ * `$CLAUDE_CONFIG_DIR` is set, and three modules in this package read that tree: reviewer provenance
14
+ * (`subagent-provenance.ts`), the audit record (`review-provenance.ts`) and the worktree-reap veto
15
+ * (`harness-agent-activity.ts`). Only the third one resolved it correctly; the other two joined
16
+ * `os.homedir()` with a hardcoded `'.claude'`.
17
+ *
18
+ * The consequence was not a degraded answer, it was a HARD BLOCK with no user-side workaround: on a
19
+ * machine with `CLAUDE_CONFIG_DIR=~/.claude-work`, provenance found zero transcripts — not the
20
+ * reviewers', not even the main agent's — so `wp-finish-upsert-pr` refused every PR whose reviewers
21
+ * had genuinely run and genuinely passed, and the refusal blamed the reviewers' cwd. The audit record
22
+ * it wrote said `mainTranscript: ""`, which is the field that settles it: the main transcript is found
23
+ * by session id alone, with no cwd, branch or stamping involved, so an empty one can ONLY mean the
24
+ * lookup ROOT was wrong. Re-spawning cannot fix a wrong root, and the only escape was a symlink into
25
+ * `~/.claude` — i.e. hand-placing evidence inside a verification gate's own evidence tree.
26
+ *
27
+ * So the resolution lives here once, and the three modules call it. A second copy is how the first one
28
+ * got out of step.
29
+ *
30
+ * ─── WHY BOTH ROOTS ARE SEARCHED ─────────────────────────────────────────────────────────────────
31
+ * `$CLAUDE_CONFIG_DIR` is a property of the PROCESS, and the process that runs `wp-finish-upsert-pr`
32
+ * is not the process that wrote the transcripts. A shell launched before the variable was exported —
33
+ * or a hook, or a `pnpm` script whose env was sanitized — reads the transcripts of a session that had
34
+ * it set while itself having it unset, and vice versa. Resolving to exactly ONE root makes the check
35
+ * depend on env agreement between two unrelated processes, which is a fact nobody can verify and
36
+ * nobody can repair from inside the flow.
37
+ *
38
+ * Searching both is cheap (a `readdir` of a directory that usually does not exist) and it only ever
39
+ * ADDS candidate trees. It cannot weaken the integrity property either: every root yields the same
40
+ * harness-written `spawnDepth` / `isSidechain` / branch evidence, so a transcript found under the
41
+ * second root is no more forgeable than one found under the first.
42
+ *
43
+ * Shaped like {@link ClaudeEnv} in `claude-env.ts` — an injectable class plus one process-wide
44
+ * instance — because `no-function-outside-class` is on: module-scope helpers are not a shape this repo
45
+ * writes, whatever their arity.
46
+ */
47
+ /** The env var Claude Code exports when its config tree has been relocated. */
48
+ exports.CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR';
49
+ let ClaudeConfigDir = class ClaudeConfigDir {
50
+ /** The raw `$CLAUDE_CONFIG_DIR` value, or '' when it is not set. For DIAGNOSTICS, never for joining. */
51
+ configuredDir() {
52
+ return (process.env[exports.CLAUDE_CONFIG_DIR_ENV] ?? '').trim();
53
+ }
54
+ /** Claude Code's config tree: `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */
55
+ root() {
56
+ const configured = this.configuredDir();
57
+ return configured !== '' ? configured : path.join(os.homedir(), '.claude');
58
+ }
59
+ /**
60
+ * Every config tree worth searching: the configured one FIRST, then `~/.claude` when it is a
61
+ * different directory. See the both-roots note above for why this is a list and not one answer.
62
+ */
63
+ roots() {
64
+ const configured = this.root();
65
+ const fallback = path.join(os.homedir(), '.claude');
66
+ return path.resolve(configured) === path.resolve(fallback) ? [configured] : [configured, fallback];
67
+ }
68
+ /**
69
+ * `<config>/projects` for every root {@link roots} names, in the same order — where per-project
70
+ * session state and transcripts live.
71
+ *
72
+ * There is deliberately NO singular `projectsRoot()` beside this. Searching exactly one root is
73
+ * precisely the defect #963 was, so an exported spelling that returns the one-root answer would make
74
+ * the broken thing the shorter thing to type, in the one file that exists to prevent it.
75
+ */
76
+ projectsRoots() {
77
+ return this.roots().map((root) => path.join(root, 'projects'));
78
+ }
79
+ };
80
+ exports.ClaudeConfigDir = ClaudeConfigDir;
81
+ exports.ClaudeConfigDir = ClaudeConfigDir = tslib_1.__decorate([
82
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
83
+ ], ClaudeConfigDir);
84
+ // Process-wide instance for the many non-DI call sites (hooks, wp-* bins); inversify still injects the
85
+ // singleton wherever a container is in play. Same arrangement as `claudeEnv`.
86
+ exports.claudeConfigDir = new ClaudeConfigDir();
87
+ //# sourceMappingURL=claude-config-dir.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-config-dir.js","sourceRoot":"","sources":["../../../../../packages/tooling/tooling-common/src/claude-config-dir.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAC7B,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,+EAA+E;AAClE,QAAA,qBAAqB,GAAG,mBAAmB,CAAC;AAGlD,IAAM,eAAe,GAArB,MAAM,eAAe;IACxB,wGAAwG;IACxG,aAAa;QACT,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,6BAAqB,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC7D,CAAC;IAED,kFAAkF;IAClF,IAAI;QACA,MAAM,UAAU,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC;QACxC,OAAO,UAAU,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;IAC/E,CAAC;IAED;;;OAGG;IACH,KAAK;QACD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC/B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,CAAC,CAAC;QACpD,OAAO,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;OAOG;IACH,aAAa;QACT,OAAO,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC;IACnF,CAAC;CACJ,CAAA;AAjCY,0CAAe;0BAAf,eAAe;IAD3B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,eAAe,CAiC3B;AAED,uGAAuG;AACvG,8EAA8E;AACjE,QAAA,eAAe,GAAG,IAAI,eAAe,EAAE,CAAC","sourcesContent":["import * as os from 'os';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * THE ONE PLACE THAT KNOWS WHERE CLAUDE CODE KEEPS ITS ON-DISK STATE.\n *\n * ─── WHY THIS FILE EXISTS ────────────────────────────────────────────────────────────────────────\n * `~/.claude` is a DEFAULT, not the location. Claude Code relocates the whole tree when\n * `$CLAUDE_CONFIG_DIR` is set, and three modules in this package read that tree: reviewer provenance\n * (`subagent-provenance.ts`), the audit record (`review-provenance.ts`) and the worktree-reap veto\n * (`harness-agent-activity.ts`). Only the third one resolved it correctly; the other two joined\n * `os.homedir()` with a hardcoded `'.claude'`.\n *\n * The consequence was not a degraded answer, it was a HARD BLOCK with no user-side workaround: on a\n * machine with `CLAUDE_CONFIG_DIR=~/.claude-work`, provenance found zero transcripts — not the\n * reviewers', not even the main agent's — so `wp-finish-upsert-pr` refused every PR whose reviewers\n * had genuinely run and genuinely passed, and the refusal blamed the reviewers' cwd. The audit record\n * it wrote said `mainTranscript: \"\"`, which is the field that settles it: the main transcript is found\n * by session id alone, with no cwd, branch or stamping involved, so an empty one can ONLY mean the\n * lookup ROOT was wrong. Re-spawning cannot fix a wrong root, and the only escape was a symlink into\n * `~/.claude` — i.e. hand-placing evidence inside a verification gate's own evidence tree.\n *\n * So the resolution lives here once, and the three modules call it. A second copy is how the first one\n * got out of step.\n *\n * ─── WHY BOTH ROOTS ARE SEARCHED ─────────────────────────────────────────────────────────────────\n * `$CLAUDE_CONFIG_DIR` is a property of the PROCESS, and the process that runs `wp-finish-upsert-pr`\n * is not the process that wrote the transcripts. A shell launched before the variable was exported —\n * or a hook, or a `pnpm` script whose env was sanitized — reads the transcripts of a session that had\n * it set while itself having it unset, and vice versa. Resolving to exactly ONE root makes the check\n * depend on env agreement between two unrelated processes, which is a fact nobody can verify and\n * nobody can repair from inside the flow.\n *\n * Searching both is cheap (a `readdir` of a directory that usually does not exist) and it only ever\n * ADDS candidate trees. It cannot weaken the integrity property either: every root yields the same\n * harness-written `spawnDepth` / `isSidechain` / branch evidence, so a transcript found under the\n * second root is no more forgeable than one found under the first.\n *\n * Shaped like {@link ClaudeEnv} in `claude-env.ts` — an injectable class plus one process-wide\n * instance — because `no-function-outside-class` is on: module-scope helpers are not a shape this repo\n * writes, whatever their arity.\n */\n\n/** The env var Claude Code exports when its config tree has been relocated. */\nexport const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR';\n\n@injectable(bindingScopeValues.Singleton)\nexport class ClaudeConfigDir {\n /** The raw `$CLAUDE_CONFIG_DIR` value, or '' when it is not set. For DIAGNOSTICS, never for joining. */\n configuredDir(): string {\n return (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim();\n }\n\n /** Claude Code's config tree: `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */\n root(): string {\n const configured = this.configuredDir();\n return configured !== '' ? configured : path.join(os.homedir(), '.claude');\n }\n\n /**\n * Every config tree worth searching: the configured one FIRST, then `~/.claude` when it is a\n * different directory. See the both-roots note above for why this is a list and not one answer.\n */\n roots(): string[] {\n const configured = this.root();\n const fallback = path.join(os.homedir(), '.claude');\n return path.resolve(configured) === path.resolve(fallback) ? [configured] : [configured, fallback];\n }\n\n /**\n * `<config>/projects` for every root {@link roots} names, in the same order — where per-project\n * session state and transcripts live.\n *\n * There is deliberately NO singular `projectsRoot()` beside this. Searching exactly one root is\n * precisely the defect #963 was, so an exported spelling that returns the one-root answer would make\n * the broken thing the shorter thing to type, in the one file that exists to prevent it.\n */\n projectsRoots(): string[] {\n return this.roots().map((root: string): string => path.join(root, 'projects'));\n }\n}\n\n// Process-wide instance for the many non-DI call sites (hooks, wp-* bins); inversify still injects the\n// singleton wherever a container is in play. Same arrangement as `claudeEnv`.\nexport const claudeConfigDir = new ClaudeConfigDir();\n"]}
package/src/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './atomic-file';
2
+ export * from './state-dir';
3
+ export * from './state-dir-migration';
4
+ export * from './inform-ai-error';
5
+ export * from './claude-config-dir';
6
+ export * from './state-path-constants';
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ tslib_1.__exportStar(require("./atomic-file"), exports);
5
+ tslib_1.__exportStar(require("./state-dir"), exports);
6
+ tslib_1.__exportStar(require("./state-dir-migration"), exports);
7
+ tslib_1.__exportStar(require("./inform-ai-error"), exports);
8
+ tslib_1.__exportStar(require("./claude-config-dir"), exports);
9
+ tslib_1.__exportStar(require("./state-path-constants"), exports);
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/tooling/tooling-common/src/index.ts"],"names":[],"mappings":";;;AAAA,wDAA8B;AAC9B,sDAA4B;AAC5B,gEAAsC;AACtC,4DAAkC;AAClC,8DAAoC;AACpC,iEAAuC","sourcesContent":["export * from './atomic-file';\nexport * from './state-dir';\nexport * from './state-dir-migration';\nexport * from './inform-ai-error';\nexport * from './claude-config-dir';\nexport * from './state-path-constants';\n"]}
@@ -0,0 +1,6 @@
1
+ export declare class InformAiError extends Error {
2
+ cause?: Error;
3
+ constructor(message: string, options?: {
4
+ cause?: Error;
5
+ });
6
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InformAiError = void 0;
4
+ class InformAiError extends Error {
5
+ cause;
6
+ constructor(message, options) {
7
+ super(message);
8
+ this.name = 'InformAiError';
9
+ this.cause = options?.cause;
10
+ }
11
+ }
12
+ exports.InformAiError = InformAiError;
13
+ //# sourceMappingURL=inform-ai-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inform-ai-error.js","sourceRoot":"","sources":["../../../../../packages/tooling/tooling-common/src/inform-ai-error.ts"],"names":[],"mappings":";;;AAAA,MAAa,aAAc,SAAQ,KAAK;IAC3B,KAAK,CAAS;IAEvB,YAAY,OAAe,EAAE,OAA2B;QACpD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;QAC5B,IAAI,CAAC,KAAK,GAAG,OAAO,EAAE,KAAK,CAAC;IAChC,CAAC;CACJ;AARD,sCAQC","sourcesContent":["export class InformAiError extends Error {\n override cause?: Error;\n\n constructor(message: string, options?: { cause?: Error }) {\n super(message);\n this.name = 'InformAiError';\n this.cause = options?.cause;\n }\n}\n"]}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * What a migration did. Data-only (per CLAUDE.md: classes for data, explicit construction).
3
+ *
4
+ * `kept` is the important half: an entry that could NOT be moved because the destination already holds
5
+ * something at that path. Nothing in `kept` is ever deleted — it stays where it is, for a human.
6
+ */
7
+ export declare class StateMigrationReport {
8
+ moved: string[];
9
+ kept: string[];
10
+ get movedAnything(): boolean;
11
+ }
12
+ /**
13
+ * Moves a LEGACY per-worktree `.webpieces/` REAL DIRECTORY into the worktree's namespace inside the
14
+ * primary clone (`<primary>/.webpieces/worktrees/<name>/`).
15
+ *
16
+ * NO SYMLINK IS EVER CREATED in its place — an earlier draft of this design planned one and this
17
+ * comment still said so, which is worse than silence: it told the reader to expect an indirection
18
+ * that does not exist. `DotWebpieces` resolves the namespace path EXPLICITLY (see its "Why two
19
+ * explicit methods and not a symlink" section, which records why the link was rejected: lazy creation
20
+ * with three failure modes, a Windows hazard, and `rename(2)` silently REPLACING the link). After a
21
+ * migration the legacy `<worktree>/.webpieces/` is simply gone; nothing takes its place.
22
+ *
23
+ * WHY it must exist: those directories hold REAL in-flight state — above all a half-finished 3-point
24
+ * merge under `merge-info/staged/<branch>/`. The first invocation under the new scheme must not orphan
25
+ * them; a merge an agent is standing in the middle of is not recoverable by re-running anything.
26
+ *
27
+ * WHAT IT MUST NOT TOUCH: the `keepInPlace` leaves — `pr-review/` today. Those are not legacy at all;
28
+ * `DotWebpieces.aiWritable()` resolves them to the worktree root ON PURPOSE, because that is the only
29
+ * directory a worktree-isolated coding agent is permitted to write into. Draining one would delete the
30
+ * live review directory out from under the agent writing it.
31
+ *
32
+ * WHY a plain move suffices: the destination namespace is created FOR this worktree, so it is empty in
33
+ * the ordinary case and the whole tree moves in one `rename`. Where something is already there (a
34
+ * repeat run, or an old PUBLISHED build having written to the legacy path again mid-transition), we
35
+ * descend and move only what is free.
36
+ *
37
+ * SAFETY RULE, absolute: this never deletes or overwrites anything that holds content. If a
38
+ * destination path is occupied, the legacy copy is LEFT WHERE IT IS and reported loudly on stderr.
39
+ * Empty directories are removed as they drain, which is how a fully-migrated legacy `.webpieces/`
40
+ * disappears on its own and lets the symlink be created.
41
+ *
42
+ * It is also the answer to the PUBLISHED-vs-LOCAL transition window. The `wp-*` bins and the hooks run
43
+ * the PUBLISHED package, so for a while some invocations still create a REAL `<worktree>/.webpieces`
44
+ * directory. Because this runs on the first state-dir resolution of EVERY new-code process, anything an
45
+ * old-code invocation deposited is swept into the worktree namespace before any reader looks — and a
46
+ * reader running old code finds it through the symlink either way. The two schemes converge instead of
47
+ * splitting; nothing is lost in either direction.
48
+ */
49
+ export declare class StateDirMigrator {
50
+ /**
51
+ * Drain `legacyDir` into `targetDir`. A no-op when they are the same directory or when the legacy
52
+ * dir does not exist / is not a real directory (a symlink means migration already happened).
53
+ *
54
+ * `keepInPlace` names the TOP-LEVEL leaves of `legacyDir` that are NOT legacy — the ones
55
+ * `DotWebpieces.aiWritable()` deliberately resolves to the worktree's own root because a
56
+ * worktree-isolated coding agent cannot write anywhere else (today: `pr-review/`). Sweeping those
57
+ * into the namespace would move a live directory out from under the agent that is mid-way through
58
+ * writing it, so they are skipped and NOT reported as kept — nothing about them is unresolved.
59
+ */
60
+ migrate(legacyDir: string, targetDir: string, keepInPlace: readonly string[]): StateMigrationReport;
61
+ /**
62
+ * Move everything under `<legacyRoot>/<relative>` to `<targetRoot>/<relative>`.
63
+ *
64
+ * A whole subtree whose destination is free moves in ONE `rename` — which is what keeps an
65
+ * in-flight `merge-info/staged/<branch>/` intact rather than copying it file by file and risking a
66
+ * half-moved merge. Only when the destination is an existing DIRECTORY do we descend and consider
67
+ * its children individually.
68
+ *
69
+ * `keepInPlace` is consulted at the TOP LEVEL only (`relative === ''`), because that is the scope
70
+ * `DotWebpieces.aiWritable()` assigns: a whole leaf of `.webpieces/` either lives in the worktree or
71
+ * it does not. A nested `pr-review/` under some other home is ordinary legacy state.
72
+ */
73
+ private drain;
74
+ private relocate;
75
+ private isRealDirectory;
76
+ private removeIfEmpty;
77
+ private announce;
78
+ private warn;
79
+ }