@webpieces/repo-workflow-core 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.
Files changed (46) hide show
  1. package/package.json +31 -0
  2. package/src/agent-worktree-lock.d.ts +21 -0
  3. package/src/agent-worktree-lock.js +77 -0
  4. package/src/agent-worktree-lock.js.map +1 -0
  5. package/src/branch-archiver.d.ts +70 -0
  6. package/src/branch-archiver.js +157 -0
  7. package/src/branch-archiver.js.map +1 -0
  8. package/src/branch-identity.d.ts +6 -0
  9. package/src/branch-identity.js +59 -0
  10. package/src/branch-identity.js.map +1 -0
  11. package/src/branch-mutation-log.d.ts +77 -0
  12. package/src/branch-mutation-log.js +221 -0
  13. package/src/branch-mutation-log.js.map +1 -0
  14. package/src/branch-reaper.d.ts +122 -0
  15. package/src/branch-reaper.js +261 -0
  16. package/src/branch-reaper.js.map +1 -0
  17. package/src/harness-agent-activity.d.ts +154 -0
  18. package/src/harness-agent-activity.js +346 -0
  19. package/src/harness-agent-activity.js.map +1 -0
  20. package/src/index.d.ts +14 -0
  21. package/src/index.js +18 -0
  22. package/src/index.js.map +1 -0
  23. package/src/main-sync-file.d.ts +121 -0
  24. package/src/main-sync-file.js +281 -0
  25. package/src/main-sync-file.js.map +1 -0
  26. package/src/main-sync-status.d.ts +197 -0
  27. package/src/main-sync-status.js +555 -0
  28. package/src/main-sync-status.js.map +1 -0
  29. package/src/merged-branch-verdicts.d.ts +189 -0
  30. package/src/merged-branch-verdicts.js +251 -0
  31. package/src/merged-branch-verdicts.js.map +1 -0
  32. package/src/merged-branches.d.ts +140 -0
  33. package/src/merged-branches.js +521 -0
  34. package/src/merged-branches.js.map +1 -0
  35. package/src/review-identity-stamp.d.ts +82 -0
  36. package/src/review-identity-stamp.js +172 -0
  37. package/src/review-identity-stamp.js.map +1 -0
  38. package/src/worktree-lock-verdicts.d.ts +149 -0
  39. package/src/worktree-lock-verdicts.js +225 -0
  40. package/src/worktree-lock-verdicts.js.map +1 -0
  41. package/src/worktree-reaper.d.ts +116 -0
  42. package/src/worktree-reaper.js +318 -0
  43. package/src/worktree-reaper.js.map +1 -0
  44. package/src/worktrees.d.ts +113 -0
  45. package/src/worktrees.js +260 -0
  46. package/src/worktrees.js.map +1 -0
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@webpieces/repo-workflow-core",
3
+ "version": "0.0.1",
4
+ "description": "Shared repository branch, worktree, sync, and identity-stamp mechanics.",
5
+ "type": "commonjs",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "src/**/*"
13
+ ],
14
+ "author": "Dean Hiller",
15
+ "license": "Apache-2.0",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/deanhiller/webpieces-ts.git",
19
+ "directory": "packages/tooling/repo-workflow-core"
20
+ },
21
+ "dependencies": {
22
+ "inversify": "7.10.4",
23
+ "reflect-metadata": "0.2.2",
24
+ "@webpieces/tooling-common": "0.0.1",
25
+ "tslib": "2.8.1"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "types": "./src/index.d.ts"
31
+ }
@@ -0,0 +1,21 @@
1
+ export declare class AgentWorktreeLock {
2
+ /** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */
3
+ agent: string;
4
+ /**
5
+ * The pid the harness recorded. This is the SESSION process, shared by every subagent in that
6
+ * session — printed as a fact about the lock reason, never interrogated. See the file header.
7
+ */
8
+ pid: number;
9
+ /** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */
10
+ startedAt: string;
11
+ constructor(agent: string, pid: number, startedAt: string);
12
+ }
13
+ export declare class AgentWorktreeLockReader {
14
+ /**
15
+ * The parsed agent lock, or null when this reason was written by anything other than the Claude
16
+ * Code harness — including the empty reason `git worktree lock` writes with no `--reason`. Null
17
+ * means "spare it, and say only what the reason says" — somebody locked this and the evidence does
18
+ * not identify who.
19
+ */
20
+ parse(lockReason: string): AgentWorktreeLock | null;
21
+ }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentWorktreeLockReader = exports.AgentWorktreeLock = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const inversify_1 = require("inversify");
6
+ /**
7
+ * Reading the LOCK REASON off a worktree — and NOTHING else.
8
+ *
9
+ * WHY this exists: `git worktree lock` used to mean one thing — a human said "do not touch" — and
10
+ * wp-cleanup spared every locked worktree on that basis. In the agent era that assumption is simply
11
+ * wrong. The Claude Code harness locks EVERY worktree it opens for a subagent, and it writes a
12
+ * machine-readable reason while doing so:
13
+ *
14
+ * locked claude agent agent-a017f6be7c518c68c (pid 64914 start Thu Aug 20 05:12:29 2026)
15
+ *
16
+ * So every `/full-cycle` run left its worktree behind forever, reported as "locked by a human — do not
17
+ * touch" about a human who has never locked a worktree in his life.
18
+ *
19
+ * THE PID IN THAT REASON PROVES NOTHING, AND THIS FILE NO LONGER ASKS THE KERNEL ABOUT IT.
20
+ * Subagents are not separate OS processes — every agent in a session records the SAME pid, the pid of
21
+ * the Claude Code session itself — so `process.kill(pid, 0)` reduced to "is the editor still open?",
22
+ * which is true by construction for the whole life of a session. That check is why nine worktrees
23
+ * piled up against a cap of five with ONE agent running, each reported as "that agent is working in
24
+ * here" while several of their PRs had already merged. It is deleted, not softened: a heuristic that
25
+ * is right by accident and wrong by construction has no safe residual use.
26
+ *
27
+ * The pid is still PARSED and still PRINTED, because it is what the reason says and a reader deserves
28
+ * to see it — but it is presented as the shared session pid it is, never as evidence of an agent.
29
+ * Liveness now comes from HarnessAgentActivityReader, which asks the harness about the AGENT.
30
+ */
31
+ /**
32
+ * The Claude Code harness's lock reason. Anchored at the start so a reason that merely MENTIONS an
33
+ * agent (a human writing "leave this, the claude agent is mid-run") is not mistaken for the harness's
34
+ * own machine-written string; unanchored at the end so a future harness may append to it without this
35
+ * silently reverting to "human lock". `start` is optional for the same reason.
36
+ */
37
+ const AGENT_LOCK_REASON = /^claude agent (\S+) \(pid (\d+)(?:\s+start\s+([^)]*))?\)/;
38
+ // Data-only (per CLAUDE.md, classes for data). One parsed agent lock.
39
+ class AgentWorktreeLock {
40
+ /** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */
41
+ agent;
42
+ /**
43
+ * The pid the harness recorded. This is the SESSION process, shared by every subagent in that
44
+ * session — printed as a fact about the lock reason, never interrogated. See the file header.
45
+ */
46
+ pid;
47
+ /** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */
48
+ startedAt;
49
+ constructor(agent, pid, startedAt) {
50
+ this.agent = agent;
51
+ this.pid = pid;
52
+ this.startedAt = startedAt;
53
+ }
54
+ }
55
+ exports.AgentWorktreeLock = AgentWorktreeLock;
56
+ let AgentWorktreeLockReader = class AgentWorktreeLockReader {
57
+ /**
58
+ * The parsed agent lock, or null when this reason was written by anything other than the Claude
59
+ * Code harness — including the empty reason `git worktree lock` writes with no `--reason`. Null
60
+ * means "spare it, and say only what the reason says" — somebody locked this and the evidence does
61
+ * not identify who.
62
+ */
63
+ parse(lockReason) {
64
+ const match = AGENT_LOCK_REASON.exec(lockReason.trim());
65
+ if (match === null)
66
+ return null;
67
+ const pid = Number.parseInt(match[2], 10);
68
+ if (!Number.isInteger(pid) || pid <= 0)
69
+ return null;
70
+ return new AgentWorktreeLock(match[1], pid, (match[3] ?? '').trim());
71
+ }
72
+ };
73
+ exports.AgentWorktreeLockReader = AgentWorktreeLockReader;
74
+ exports.AgentWorktreeLockReader = AgentWorktreeLockReader = tslib_1.__decorate([
75
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
76
+ ], AgentWorktreeLockReader);
77
+ //# sourceMappingURL=agent-worktree-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-worktree-lock.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/agent-worktree-lock.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;GAKG;AACH,MAAM,iBAAiB,GAAG,0DAA0D,CAAC;AAErF,sEAAsE;AACtE,MAAa,iBAAiB;IAC1B,4EAA4E;IAC5E,KAAK,CAAS;IACd;;;OAGG;IACH,GAAG,CAAS;IACZ,mGAAmG;IACnG,SAAS,CAAS;IAElB,YAAY,KAAa,EAAE,GAAW,EAAE,SAAiB;QACrD,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;CACJ;AAhBD,8CAgBC;AAGM,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAChC;;;;;OAKG;IACH,KAAK,CAAC,UAAkB;QACpB,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC;QACxD,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAChC,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACpD,OAAO,IAAI,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACzE,CAAC;CACJ,CAAA;AAdY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,uBAAuB,CAcnC","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Reading the LOCK REASON off a worktree — and NOTHING else.\n *\n * WHY this exists: `git worktree lock` used to mean one thing — a human said \"do not touch\" — and\n * wp-cleanup spared every locked worktree on that basis. In the agent era that assumption is simply\n * wrong. The Claude Code harness locks EVERY worktree it opens for a subagent, and it writes a\n * machine-readable reason while doing so:\n *\n * locked claude agent agent-a017f6be7c518c68c (pid 64914 start Thu Aug 20 05:12:29 2026)\n *\n * So every `/full-cycle` run left its worktree behind forever, reported as \"locked by a human — do not\n * touch\" about a human who has never locked a worktree in his life.\n *\n * THE PID IN THAT REASON PROVES NOTHING, AND THIS FILE NO LONGER ASKS THE KERNEL ABOUT IT.\n * Subagents are not separate OS processes — every agent in a session records the SAME pid, the pid of\n * the Claude Code session itself — so `process.kill(pid, 0)` reduced to \"is the editor still open?\",\n * which is true by construction for the whole life of a session. That check is why nine worktrees\n * piled up against a cap of five with ONE agent running, each reported as \"that agent is working in\n * here\" while several of their PRs had already merged. It is deleted, not softened: a heuristic that\n * is right by accident and wrong by construction has no safe residual use.\n *\n * The pid is still PARSED and still PRINTED, because it is what the reason says and a reader deserves\n * to see it — but it is presented as the shared session pid it is, never as evidence of an agent.\n * Liveness now comes from HarnessAgentActivityReader, which asks the harness about the AGENT.\n */\n\n/**\n * The Claude Code harness's lock reason. Anchored at the start so a reason that merely MENTIONS an\n * agent (a human writing \"leave this, the claude agent is mid-run\") is not mistaken for the harness's\n * own machine-written string; unanchored at the end so a future harness may append to it without this\n * silently reverting to \"human lock\". `start` is optional for the same reason.\n */\nconst AGENT_LOCK_REASON = /^claude agent (\\S+) \\(pid (\\d+)(?:\\s+start\\s+([^)]*))?\\)/;\n\n// Data-only (per CLAUDE.md, classes for data). One parsed agent lock.\nexport class AgentWorktreeLock {\n /** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */\n agent: string;\n /**\n * The pid the harness recorded. This is the SESSION process, shared by every subagent in that\n * session — printed as a fact about the lock reason, never interrogated. See the file header.\n */\n pid: number;\n /** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */\n startedAt: string;\n\n constructor(agent: string, pid: number, startedAt: string) {\n this.agent = agent;\n this.pid = pid;\n this.startedAt = startedAt;\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class AgentWorktreeLockReader {\n /**\n * The parsed agent lock, or null when this reason was written by anything other than the Claude\n * Code harness — including the empty reason `git worktree lock` writes with no `--reason`. Null\n * means \"spare it, and say only what the reason says\" — somebody locked this and the evidence does\n * not identify who.\n */\n parse(lockReason: string): AgentWorktreeLock | null {\n const match = AGENT_LOCK_REASON.exec(lockReason.trim());\n if (match === null) return null;\n const pid = Number.parseInt(match[2], 10);\n if (!Number.isInteger(pid) || pid <= 0) return null;\n return new AgentWorktreeLock(match[1], pid, (match[3] ?? '').trim());\n }\n}\n"]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Archive a branch tip as a git TAG immediately before the branch is deleted.
3
+ *
4
+ * WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash
5
+ * merge you sometimes need the original pre-merge history to debug — but they are branches, so they
6
+ * accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed
7
+ * onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to
8
+ * create the escape branch and pushed the agent into editing webpieces.config.json to get out.
9
+ *
10
+ * A tag is strictly better at the one job the branch was doing:
11
+ * - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,
12
+ * and cannot be accidentally committed onto.
13
+ * - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,
14
+ * authorship and parentage. A tag preserves the exact objects.
15
+ * - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone
16
+ * that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.
17
+ *
18
+ * Cost: one ref, zero new objects — the commits are already in the object store.
19
+ *
20
+ * Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):
21
+ * git tag archive/2026-07-30/dean/foo dean/foo
22
+ * git branch -D dean/foo
23
+ * git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
24
+ */
25
+ export declare const BRANCH_RETENTION_DELETE = "delete";
26
+ export declare const BRANCH_RETENTION_ARCHIVE_TAG = "archive-tag";
27
+ export declare const BRANCH_RETENTION_KEEP = "keep";
28
+ export declare const BRANCH_RETENTIONS: readonly string[];
29
+ export declare const ARCHIVE_TAG_PREFIX = "archive";
30
+ export declare class ArchiveResult {
31
+ tag: string;
32
+ sha: string;
33
+ ok: boolean;
34
+ error: string;
35
+ constructor(tag: string, sha: string, ok: boolean, error: string);
36
+ }
37
+ export declare class BranchArchiver {
38
+ /**
39
+ * `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
40
+ * chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
41
+ * SAME branch name from colliding across days — the common case, since a branch name gets reused.
42
+ */
43
+ archiveTagName(branch: string, when?: Date): string;
44
+ /**
45
+ * Tag `branch`'s current tip, never overwriting an existing tag.
46
+ *
47
+ * Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force
48
+ * would silently destroy the older archive, which is the exact failure the archive exists to prevent.
49
+ * Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must
50
+ * turn into "then do not delete either", a decision the CALLER makes.
51
+ *
52
+ * IDEMPOTENT for the same tip: if the base name is already taken by a tag pointing at the SAME sha,
53
+ * that tag is returned as-is rather than minting a `-2` beside it. Re-archiving an identical tip is
54
+ * not a collision — it is the same archive — and `wp-land-pr` produces exactly that case, tagging
55
+ * the branch in its own recap and then handing the worktree reap to a child that (correctly,
56
+ * because it trusts nothing the parent told it) archives again a second later. Without this the
57
+ * archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct
58
+ * snapshots and is one more ref to explain.
59
+ */
60
+ archive(repoRoot: string, branch: string, when?: Date): ArchiveResult;
61
+ /** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */
62
+ restoreCommand(branch: string, tag: string): string;
63
+ /** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
64
+ listArchiveTags(repoRoot: string): string[];
65
+ private freeTagName;
66
+ private tagSha;
67
+ protected tagExists(repoRoot: string, tag: string): boolean;
68
+ private dateSegment;
69
+ private capture;
70
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BranchArchiver = exports.ArchiveResult = exports.ARCHIVE_TAG_PREFIX = exports.BRANCH_RETENTIONS = exports.BRANCH_RETENTION_KEEP = exports.BRANCH_RETENTION_ARCHIVE_TAG = exports.BRANCH_RETENTION_DELETE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const inversify_1 = require("inversify");
7
+ /**
8
+ * Archive a branch tip as a git TAG immediately before the branch is deleted.
9
+ *
10
+ * WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash
11
+ * merge you sometimes need the original pre-merge history to debug — but they are branches, so they
12
+ * accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed
13
+ * onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to
14
+ * create the escape branch and pushed the agent into editing webpieces.config.json to get out.
15
+ *
16
+ * A tag is strictly better at the one job the branch was doing:
17
+ * - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,
18
+ * and cannot be accidentally committed onto.
19
+ * - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,
20
+ * authorship and parentage. A tag preserves the exact objects.
21
+ * - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone
22
+ * that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.
23
+ *
24
+ * Cost: one ref, zero new objects — the commits are already in the object store.
25
+ *
26
+ * Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):
27
+ * git tag archive/2026-07-30/dean/foo dean/foo
28
+ * git branch -D dean/foo
29
+ * git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored
30
+ */
31
+ // Retention policy for a branch whose PR has landed (or which wp-cleanup proved dead).
32
+ // DELETE — delete outright, recoverable only via the reflog (the pre-0.4.492 behaviour).
33
+ // ARCHIVE_TAG — tag the tip first, then delete. The default: same disk cost, permanently recoverable.
34
+ // KEEP — do not delete at all (the branch keeps counting toward the branch cap).
35
+ exports.BRANCH_RETENTION_DELETE = 'delete';
36
+ exports.BRANCH_RETENTION_ARCHIVE_TAG = 'archive-tag';
37
+ exports.BRANCH_RETENTION_KEEP = 'keep';
38
+ exports.BRANCH_RETENTIONS = [
39
+ exports.BRANCH_RETENTION_DELETE,
40
+ exports.BRANCH_RETENTION_ARCHIVE_TAG,
41
+ exports.BRANCH_RETENTION_KEEP,
42
+ ];
43
+ // The one namespace every archive tag lives under, so `git tag --list 'archive/*'` is the whole
44
+ // inventory and `git push origin 'refs/tags/archive/*'` is the whole backup.
45
+ exports.ARCHIVE_TAG_PREFIX = 'archive';
46
+ // Data-only (per CLAUDE.md, classes for data): what archiving one branch produced.
47
+ // `tag` is '' when nothing was tagged — either the policy said not to, or git refused; `error` says which.
48
+ class ArchiveResult {
49
+ tag;
50
+ sha;
51
+ ok;
52
+ error;
53
+ constructor(tag, sha, ok, error) {
54
+ this.tag = tag;
55
+ this.sha = sha;
56
+ this.ok = ok;
57
+ this.error = error;
58
+ }
59
+ }
60
+ exports.ArchiveResult = ArchiveResult;
61
+ let BranchArchiver = class BranchArchiver {
62
+ /**
63
+ * `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable
64
+ * chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the
65
+ * SAME branch name from colliding across days — the common case, since a branch name gets reused.
66
+ */
67
+ archiveTagName(branch, when = new Date()) {
68
+ return `${exports.ARCHIVE_TAG_PREFIX}/${this.dateSegment(when)}/${branch}`;
69
+ }
70
+ /**
71
+ * Tag `branch`'s current tip, never overwriting an existing tag.
72
+ *
73
+ * Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force
74
+ * would silently destroy the older archive, which is the exact failure the archive exists to prevent.
75
+ * Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must
76
+ * turn into "then do not delete either", a decision the CALLER makes.
77
+ *
78
+ * IDEMPOTENT for the same tip: if the base name is already taken by a tag pointing at the SAME sha,
79
+ * that tag is returned as-is rather than minting a `-2` beside it. Re-archiving an identical tip is
80
+ * not a collision — it is the same archive — and `wp-land-pr` produces exactly that case, tagging
81
+ * the branch in its own recap and then handing the worktree reap to a child that (correctly,
82
+ * because it trusts nothing the parent told it) archives again a second later. Without this the
83
+ * archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct
84
+ * snapshots and is one more ref to explain.
85
+ */
86
+ archive(repoRoot, branch, when = new Date()) {
87
+ const resolved = this.capture(repoRoot, ['rev-parse', branch]);
88
+ if (!resolved.ok)
89
+ return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);
90
+ const sha = resolved.out;
91
+ const base = this.archiveTagName(branch, when);
92
+ if (this.tagExists(repoRoot, base) && this.tagSha(repoRoot, base) === sha) {
93
+ return new ArchiveResult(base, sha, true, '');
94
+ }
95
+ const tag = this.freeTagName(repoRoot, base);
96
+ const tagged = this.capture(repoRoot, ['tag', tag, sha]);
97
+ if (!tagged.ok)
98
+ return new ArchiveResult('', sha, false, tagged.err);
99
+ return new ArchiveResult(tag, sha, true, '');
100
+ }
101
+ /** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */
102
+ restoreCommand(branch, tag) {
103
+ return `git checkout -b ${branch} ${tag}`;
104
+ }
105
+ /** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */
106
+ listArchiveTags(repoRoot) {
107
+ const result = this.capture(repoRoot, ['tag', '--list', `${exports.ARCHIVE_TAG_PREFIX}/*`]);
108
+ if (!result.ok || result.out === '')
109
+ return [];
110
+ return result.out
111
+ .split('\n')
112
+ .map((line) => line.trim())
113
+ .filter((line) => line !== '')
114
+ .sort()
115
+ .reverse();
116
+ }
117
+ // First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo
118
+ // cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.
119
+ freeTagName(repoRoot, base) {
120
+ const MAX_ATTEMPTS = 50;
121
+ let candidate = base;
122
+ for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {
123
+ if (!this.tagExists(repoRoot, candidate))
124
+ return candidate;
125
+ candidate = `${base}-${String(attempt)}`;
126
+ }
127
+ return candidate;
128
+ }
129
+ // The COMMIT an existing tag points at, '' when it cannot be resolved. `^{commit}` peels an
130
+ // annotated tag, so an archive written by hand with `git tag -a` compares equal to a lightweight one.
131
+ tagSha(repoRoot, tag) {
132
+ const result = this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);
133
+ return result.ok ? result.out : '';
134
+ }
135
+ // Seam: overridden in the spec so name-collision logic is testable with no git and no repo.
136
+ tagExists(repoRoot, tag) {
137
+ return this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]).ok;
138
+ }
139
+ // YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.
140
+ dateSegment(when) {
141
+ const pad = (value) => String(value).padStart(2, '0');
142
+ return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
143
+ }
144
+ capture(repoRoot, args) {
145
+ const result = (0, child_process_1.spawnSync)('git', args, { cwd: repoRoot, encoding: 'utf8' });
146
+ const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';
147
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
148
+ return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };
149
+ }
150
+ return { ok: true, out: result.stdout.trim(), err };
151
+ }
152
+ };
153
+ exports.BranchArchiver = BranchArchiver;
154
+ exports.BranchArchiver = BranchArchiver = tslib_1.__decorate([
155
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
156
+ ], BranchArchiver);
157
+ //# sourceMappingURL=branch-archiver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-archiver.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-archiver.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Archive a branch tip as a git TAG immediately before the branch is deleted.\n *\n * WHY this exists: `<feature>PreMerge<n>` snapshot BRANCHES exist for a real reason — after a squash\n * merge you sometimes need the original pre-merge history to debug — but they are branches, so they\n * accumulate forever, count toward `branch-creation-guard`'s maxLocalBranches, and can be committed\n * onto by accident. Observed in the wild: 6 parked branches against a cap of 5, which then REFUSED to\n * create the escape branch and pushed the agent into editing webpieces.config.json to get out.\n *\n * A tag is strictly better at the one job the branch was doing:\n * - vs KEEPING THE BRANCH — a tag is invisible to `git branch`, does not count toward the branch cap,\n * and cannot be accidentally committed onto.\n * - vs storing a PATCH in merge-info — a patch is lossy: it drops commit boundaries, messages,\n * authorship and parentage. A tag preserves the exact objects.\n * - vs relying on the REFLOG — the reflog expires (90 days by default) and is local to the one clone\n * that did the work. A tag survives `gc` and can be pushed if durability beyond one machine is wanted.\n *\n * Cost: one ref, zero new objects — the commits are already in the object store.\n *\n * Round trip (verified by hand, and asserted in branch-archiver.spec.ts against a real repo):\n * git tag archive/2026-07-30/dean/foo dean/foo\n * git branch -D dean/foo\n * git checkout -b dean/foo archive/2026-07-30/dean/foo # exact objects restored\n */\n\n// Retention policy for a branch whose PR has landed (or which wp-cleanup proved dead).\n// DELETE — delete outright, recoverable only via the reflog (the pre-0.4.492 behaviour).\n// ARCHIVE_TAG — tag the tip first, then delete. The default: same disk cost, permanently recoverable.\n// KEEP — do not delete at all (the branch keeps counting toward the branch cap).\nexport const BRANCH_RETENTION_DELETE = 'delete';\nexport const BRANCH_RETENTION_ARCHIVE_TAG = 'archive-tag';\nexport const BRANCH_RETENTION_KEEP = 'keep';\nexport const BRANCH_RETENTIONS: readonly string[] = [\n BRANCH_RETENTION_DELETE,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n];\n\n// The one namespace every archive tag lives under, so `git tag --list 'archive/*'` is the whole\n// inventory and `git push origin 'refs/tags/archive/*'` is the whole backup.\nexport const ARCHIVE_TAG_PREFIX = 'archive';\n\n// Data-only (per CLAUDE.md, classes for data): what archiving one branch produced.\n// `tag` is '' when nothing was tagged — either the policy said not to, or git refused; `error` says which.\nexport class ArchiveResult {\n tag: string;\n sha: string;\n ok: boolean;\n error: string;\n\n constructor(tag: string, sha: string, ok: boolean, error: string) {\n this.tag = tag;\n this.sha = sha;\n this.ok = ok;\n this.error = error;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a refused tag can be reported verbatim.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchArchiver {\n /**\n * `archive/<YYYY-MM-DD>/<branch>`. The date segment is what makes the namespace browsable\n * chronologically (`git tag --list 'archive/2026-07-*'`) and is also what keeps two archives of the\n * SAME branch name from colliding across days — the common case, since a branch name gets reused.\n */\n archiveTagName(branch: string, when: Date = new Date()): string {\n return `${ARCHIVE_TAG_PREFIX}/${this.dateSegment(when)}/${branch}`;\n }\n\n /**\n * Tag `branch`'s current tip, never overwriting an existing tag.\n *\n * Same branch archived twice on the SAME day gets `…-2`, `…-3`, … rather than `git tag -f`: force\n * would silently destroy the older archive, which is the exact failure the archive exists to prevent.\n * Returns ok=false (with git's stderr) rather than throwing — an archive that cannot be written must\n * turn into \"then do not delete either\", a decision the CALLER makes.\n *\n * IDEMPOTENT for the same tip: if the base name is already taken by a tag pointing at the SAME sha,\n * that tag is returned as-is rather than minting a `-2` beside it. Re-archiving an identical tip is\n * not a collision — it is the same archive — and `wp-land-pr` produces exactly that case, tagging\n * the branch in its own recap and then handing the worktree reap to a child that (correctly,\n * because it trusts nothing the parent told it) archives again a second later. Without this the\n * archive namespace fills with `…` / `…-2` pairs at one sha, which reads like two distinct\n * snapshots and is one more ref to explain.\n */\n archive(repoRoot: string, branch: string, when: Date = new Date()): ArchiveResult {\n const resolved = this.capture(repoRoot, ['rev-parse', branch]);\n if (!resolved.ok) return new ArchiveResult('', '', false, `cannot resolve ${branch}: ${resolved.err}`);\n const sha = resolved.out;\n\n const base = this.archiveTagName(branch, when);\n if (this.tagExists(repoRoot, base) && this.tagSha(repoRoot, base) === sha) {\n return new ArchiveResult(base, sha, true, '');\n }\n\n const tag = this.freeTagName(repoRoot, base);\n const tagged = this.capture(repoRoot, ['tag', tag, sha]);\n if (!tagged.ok) return new ArchiveResult('', sha, false, tagged.err);\n return new ArchiveResult(tag, sha, true, '');\n }\n\n /** The literal command a human runs to bring an archived branch back, byte-identical to what was deleted. */\n restoreCommand(branch: string, tag: string): string {\n return `git checkout -b ${branch} ${tag}`;\n }\n\n /** Every archive tag currently in the repo, newest namespace segment first (plain lexical on the date). */\n listArchiveTags(repoRoot: string): string[] {\n const result = this.capture(repoRoot, ['tag', '--list', `${ARCHIVE_TAG_PREFIX}/*`]);\n if (!result.ok || result.out === '') return [];\n return result.out\n .split('\\n')\n .map((line: string): string => line.trim())\n .filter((line: string): boolean => line !== '')\n .sort()\n .reverse();\n }\n\n // First unused name in the `<base>`, `<base>-2`, `<base>-3` … series. Bounded so a pathological repo\n // cannot spin: past the bound we hand back the last candidate and let `git tag` report the collision.\n private freeTagName(repoRoot: string, base: string): string {\n const MAX_ATTEMPTS = 50;\n let candidate = base;\n for (let attempt = 2; attempt <= MAX_ATTEMPTS; attempt += 1) {\n if (!this.tagExists(repoRoot, candidate)) return candidate;\n candidate = `${base}-${String(attempt)}`;\n }\n return candidate;\n }\n\n // The COMMIT an existing tag points at, '' when it cannot be resolved. `^{commit}` peels an\n // annotated tag, so an archive written by hand with `git tag -a` compares equal to a lightweight one.\n private tagSha(repoRoot: string, tag: string): string {\n const result = this.capture(repoRoot, ['rev-parse', `refs/tags/${tag}^{commit}`]);\n return result.ok ? result.out : '';\n }\n\n // Seam: overridden in the spec so name-collision logic is testable with no git and no repo.\n protected tagExists(repoRoot: string, tag: string): boolean {\n return this.capture(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`]).ok;\n }\n\n // YYYY-MM-DD in LOCAL time: the archive is browsed by the human who created it, on their calendar.\n private dateSegment(when: Date): string {\n const pad = (value: number): string => String(value).padStart(2, '0');\n return `${String(when.getFullYear())}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;\n }\n\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
@@ -0,0 +1,6 @@
1
+ export declare const HOTFIX_BRANCH_SEGMENT = "/hotfix/";
2
+ /** One exact, case-sensitive hotfix convention shared by every Webpieces surface. */
3
+ export declare class BranchIdentity {
4
+ current(): string;
5
+ isHotfix(branchName?: string): boolean;
6
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BranchIdentity = exports.HOTFIX_BRANCH_SEGMENT = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const child_process_1 = require("child_process");
6
+ const inversify_1 = require("inversify");
7
+ const tooling_common_1 = require("@webpieces/tooling-common");
8
+ const to_error_1 = require("@webpieces/tooling-common/to-error");
9
+ // The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,
10
+ // CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main
11
+ // locally made this return "main" on a feature branch, which (a) mislabeled the main-sync cache and
12
+ // (b) silently disabled merged-PR detection (detectMergedPr skips "main").
13
+ //
14
+ // The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in
15
+ // the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where
16
+ // `git rev-parse --abbrev-ref HEAD` returns the literal string "HEAD" and no branch hatch can match.
17
+ // Neither can go stale the way GIT_BRANCH did:
18
+ // GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS
19
+ // the source branch name. Absent on push, so the fallthrough stays safe. (Not
20
+ // GITHUB_REF_NAME: on pull_request that is "<N>/merge", not a branch.)
21
+ // WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here
22
+ // (GitLab, CircleCI, Buildkite). Nobody sets it by accident.
23
+ //
24
+ // This getter answers "what branch am I on?" and NOTHING about whether that answer may be TRUSTED to
25
+ // unlock an escape hatch. That second question is asked by shouldSkipRule alone (see
26
+ // assertBranchIsTrustworthy) because this getter has callers — the main-sync cache label, merged-PR
27
+ // detection, code-rules' re-export of it — for which a fork's own branch name is a perfectly good
28
+ // answer, and making the getter itself throw would redden all of them.
29
+ exports.HOTFIX_BRANCH_SEGMENT = '/hotfix/';
30
+ /** One exact, case-sensitive hotfix convention shared by every Webpieces surface. */
31
+ let BranchIdentity = class BranchIdentity {
32
+ current() {
33
+ const prBranch = process.env['GITHUB_HEAD_REF'];
34
+ if (prBranch)
35
+ return prBranch;
36
+ const override = process.env['WEBPIECES_BRANCH'];
37
+ if (override)
38
+ return override;
39
+ // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI
40
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI
41
+ try {
42
+ return (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
43
+ }
44
+ catch (err) {
45
+ const error = (0, to_error_1.toError)(err);
46
+ throw new tooling_common_1.InformAiError(`Failed to determine current git branch: ${error.message}`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ }
51
+ isHotfix(branchName = this.current()) {
52
+ return branchName.includes(exports.HOTFIX_BRANCH_SEGMENT);
53
+ }
54
+ };
55
+ exports.BranchIdentity = BranchIdentity;
56
+ exports.BranchIdentity = BranchIdentity = tslib_1.__decorate([
57
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
58
+ ], BranchIdentity);
59
+ //# sourceMappingURL=branch-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-identity.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/branch-identity.ts"],"names":[],"mappings":";;;;AAAA,iDAAyC;AACzC,yCAA2D;AAC3D,8DAA0D;AAC1D,iEAA6D;AAE7D,4FAA4F;AAC5F,gGAAgG;AAChG,oGAAoG;AACpG,2EAA2E;AAC3E,EAAE;AACF,qGAAqG;AACrG,qGAAqG;AACrG,qGAAqG;AACrG,+CAA+C;AAC/C,oGAAoG;AACpG,mGAAmG;AACnG,4FAA4F;AAC5F,4FAA4F;AAC5F,kFAAkF;AAClF,EAAE;AACF,qGAAqG;AACrG,qFAAqF;AACrF,oGAAoG;AACpG,kGAAkG;AAClG,uEAAuE;AAC1D,QAAA,qBAAqB,GAAG,UAAU,CAAC;AAChD,qFAAqF;AAE9E,IAAM,cAAc,GAApB,MAAM,cAAc;IACvB,OAAO;QACH,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;QAChD,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAE9B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;QACjD,IAAI,QAAQ;YAAE,OAAO,QAAQ,CAAC;QAE9B,wHAAwH;QACxH,0IAA0I;QAC1I,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACpF,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,8BAAa,CAAC,2CAA2C,KAAK,CAAC,OAAO,EAAE,EAAE;gBAChF,KAAK,EAAE,KAAK;aACf,CAAC,CAAC;QACP,CAAC;IACL,CAAC;IAED,QAAQ,CAAC,aAAqB,IAAI,CAAC,OAAO,EAAE;QACxC,OAAO,UAAU,CAAC,QAAQ,CAAC,6BAAqB,CAAC,CAAC;IACtD,CAAC;CACJ,CAAA;AAvBY,wCAAc;yBAAd,cAAc;IAD1B,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,cAAc,CAuB1B","sourcesContent":["import { execSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { InformAiError } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n// The actual checked-out branch. The grab bag of ambient env vars (BRANCH_NAME, GIT_BRANCH,\n// CI_COMMIT_BRANCH, …) was intentionally REMOVED and must stay removed: a stray GIT_BRANCH=main\n// locally made this return \"main\" on a feature branch, which (a) mislabeled the main-sync cache and\n// (b) silently disabled merged-PR detection (detectMergedPr skips \"main\").\n//\n// The two vars below are NOT that. They are consulted BEFORE git because git cannot answer at all in\n// the case they cover — a `pull_request` checkout leaves HEAD detached on refs/pull/<N>/merge, where\n// `git rev-parse --abbrev-ref HEAD` returns the literal string \"HEAD\" and no branch hatch can match.\n// Neither can go stale the way GIT_BRANCH did:\n// GITHUB_HEAD_REF — set by the GitHub runner ONLY on pull_request/pull_request_target, and it IS\n// the source branch name. Absent on push, so the fallthrough stays safe. (Not\n// GITHUB_REF_NAME: on pull_request that is \"<N>/merge\", not a branch.)\n// WEBPIECES_BRANCH — one documented opt-in override for CI systems not special-cased here\n// (GitLab, CircleCI, Buildkite). Nobody sets it by accident.\n//\n// This getter answers \"what branch am I on?\" and NOTHING about whether that answer may be TRUSTED to\n// unlock an escape hatch. That second question is asked by shouldSkipRule alone (see\n// assertBranchIsTrustworthy) because this getter has callers — the main-sync cache label, merged-PR\n// detection, code-rules' re-export of it — for which a fork's own branch name is a perfectly good\n// answer, and making the getter itself throw would redden all of them.\nexport const HOTFIX_BRANCH_SEGMENT = '/hotfix/';\n/** One exact, case-sensitive hotfix convention shared by every Webpieces surface. */\n@injectable(bindingScopeValues.Singleton)\nexport class BranchIdentity {\n current(): string {\n const prBranch = process.env['GITHUB_HEAD_REF'];\n if (prBranch) return prBranch;\n\n const override = process.env['WEBPIECES_BRANCH'];\n if (override) return override;\n\n // webpieces-disable no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrow as InformAiError so global catch surfaces readable message to AI\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Failed to determine current git branch: ${error.message}`, {\n cause: error,\n });\n }\n }\n\n isHotfix(branchName: string = this.current()): boolean {\n return branchName.includes(HOTFIX_BRANCH_SEGMENT);\n }\n}\n"]}
@@ -0,0 +1,77 @@
1
+ import { DotWebpieces } from '@webpieces/tooling-common';
2
+ export type MutationVerb = 'wp-start-update' | 'wp-finish-update' | 'wp-start-upsert-pr' | 'wp-review-upsert-pr' | 'wp-finish-upsert-pr' | 'wp-cleanup' | 'wp-land-pr' | 'auto-reap';
3
+ export type MutationPhase = 'START' | 'BACKUP' | 'CHECKOUT_MAIN' | 'PULL' | 'SQUASH' | 'RENAME' | 'FINALIZE' | 'CONFLICT' | 'INTERRUPTED' | 'END' | 'REAP' | 'REAP_WORKTREE';
4
+ export declare class BranchMutationEvent {
5
+ verb: MutationVerb;
6
+ phase: MutationPhase;
7
+ fromBranch: string;
8
+ toBranch: string;
9
+ oldMain: string;
10
+ newMain: string;
11
+ conflict: boolean;
12
+ conflictFiles: string[];
13
+ outcome: string;
14
+ artifacts: string[];
15
+ sha: string;
16
+ archiveTag: string;
17
+ worktreePath: string;
18
+ constructor(verb: MutationVerb, phase: MutationPhase);
19
+ }
20
+ /** Appends branch-mutation audit lines. `@injectable(bindingScopeValues.Singleton)` so it's injectable + drawn in the design. */
21
+ export declare class BranchMutationLog {
22
+ private readonly dotDir;
23
+ constructor(dotDir?: DotWebpieces);
24
+ /**
25
+ * LOCAL scope, deliberately — one log per worktree, not one per repo.
26
+ *
27
+ * A SHARED append-only log would genuinely corrupt. `O_APPEND` makes a write indivisible only up to
28
+ * PIPE_BUF, which is 512 bytes on macOS, and a REAP_WORKTREE line carrying
29
+ * `recover=git worktree add -b <branch> <absolute-path> <tag>` exceeds that — concurrent appenders
30
+ * from seven worktrees would interleave into an unrecoverable audit trail, which is the one thing
31
+ * this file exists not to be. Per-worktree, THIS log has one writer and cannot tear.
32
+ *
33
+ * That last claim is narrower than it looks, so do not generalise it (2026-08-06). It holds here
34
+ * because the wp-* bins write this file one command at a time. It does NOT hold for the ai-hook
35
+ * logs in the same directory: Claude Code runs every matching PreToolUse hook IN PARALLEL, and
36
+ * several agents and sessions can share one tree, so a per-worktree name there had several
37
+ * concurrent writers. Those names now carry a `<sessionId>-<agentId|coordinator>-<hook>-` prefix
38
+ * (ai-hook-rules' LogStream), and the rejection DETAIL files sit in a directory of that same name.
39
+ *
40
+ * This file keeps a bare name deliberately, for TWO independent reasons, and neither has gone away
41
+ * (re-checked 2026-08-07). First, LogStream lives in ai-hook-rules, which DEPENDS on rules-config,
42
+ * so the import direction forbids reusing it here. Second — and this is the one that would still
43
+ * bite after moving the class down — the `wp-*` bins that write this file have NO session or agent
44
+ * identity to prefix with: those ids reach a PreToolUse hook on its JSON payload and a plain Bash
45
+ * tool call never sees them. Routing this through LogStream today would therefore produce
46
+ * `unknown-coordinator-hook-branch-mutations.log`: one shared file, exactly as now, under a longer
47
+ * name, plus a rename of a log people already grep. If this log ever gains a second concurrent
48
+ * writer, it needs a real stream identity FIRST — the prefix is only worth having when it
49
+ * discriminates.
50
+ *
51
+ * Nothing is lost by keeping it local: under the `worktrees/<name>/` layout the log lives in the
52
+ * PRIMARY clone, so it survives `git worktree remove`, and the whole history is one glob —
53
+ * `<primary>/.webpieces/worktrees/*/logs/branch-mutations.log`.
54
+ */
55
+ branchMutationLogPath(root: string): string;
56
+ /**
57
+ * Append one tab-separated line per branch-mutation event to
58
+ * `.webpieces/logs/branch-mutations.log`. Swallows all errors — logging must NEVER block or fail
59
+ * the workflow it is observing.
60
+ */
61
+ logBranchMutation(root: string, event: BranchMutationEvent): void;
62
+ private formatDetail;
63
+ /**
64
+ * The worktree flavour of the sha/recover token: path, tip, archive tag and the ONE command that
65
+ * puts the directory AND the branch back together.
66
+ *
67
+ * `git worktree add -b <branch> <path> <ref>` is verified by hand and in worktree-reaper.spec.ts —
68
+ * plain `git worktree add <path> <tag>` would restore the files at a DETACHED HEAD, silently losing
69
+ * the branch name the reap destroyed. Falls back to the sha when nothing was archived (retention
70
+ * 'delete'), and to a bare `git worktree add <path>` when there was no branch at all (detached).
71
+ */
72
+ private worktreeDetail;
73
+ private oneLine;
74
+ private rotateLogFile;
75
+ }
76
+ export declare function branchMutationLogPath(root: string): string;
77
+ export declare function logBranchMutation(root: string, event: BranchMutationEvent): void;