xtrm-tools 2.4.1 → 2.4.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.
Files changed (125) hide show
  1. package/README.md +15 -6
  2. package/cli/dist/index.cjs +738 -239
  3. package/cli/dist/index.cjs.map +1 -1
  4. package/cli/package.json +1 -1
  5. package/config/hooks.json +10 -0
  6. package/config/pi/extensions/core/adapter.ts +2 -14
  7. package/config/pi/extensions/core/guard-rules.ts +70 -0
  8. package/config/pi/extensions/core/session-state.ts +59 -0
  9. package/config/pi/extensions/main-guard.ts +10 -14
  10. package/config/pi/extensions/plan-mode/README.md +65 -0
  11. package/config/pi/extensions/plan-mode/index.ts +340 -0
  12. package/config/pi/extensions/plan-mode/utils.ts +168 -0
  13. package/config/pi/extensions/service-skills.ts +51 -7
  14. package/config/pi/extensions/session-flow.ts +117 -0
  15. package/hooks/beads-claim-sync.mjs +123 -2
  16. package/hooks/beads-compact-restore.mjs +41 -9
  17. package/hooks/beads-compact-save.mjs +36 -5
  18. package/hooks/beads-gate-messages.mjs +27 -1
  19. package/hooks/beads-stop-gate.mjs +58 -8
  20. package/hooks/guard-rules.mjs +86 -0
  21. package/hooks/hooks.json +28 -18
  22. package/hooks/main-guard.mjs +3 -21
  23. package/hooks/quality-check.cjs +1286 -0
  24. package/hooks/quality-check.py +345 -0
  25. package/hooks/session-state.mjs +138 -0
  26. package/package.json +2 -1
  27. package/project-skills/quality-gates/.claude/settings.json +1 -24
  28. package/skills/creating-service-skills/SKILL.md +433 -0
  29. package/skills/creating-service-skills/references/script_quality_standards.md +425 -0
  30. package/skills/creating-service-skills/references/service_skill_system_guide.md +278 -0
  31. package/skills/creating-service-skills/scripts/bootstrap.py +326 -0
  32. package/skills/creating-service-skills/scripts/deep_dive.py +304 -0
  33. package/skills/creating-service-skills/scripts/scaffolder.py +482 -0
  34. package/skills/scoping-service-skills/SKILL.md +231 -0
  35. package/skills/scoping-service-skills/scripts/scope.py +74 -0
  36. package/skills/sync-docs/SKILL.md +235 -0
  37. package/skills/sync-docs/evals/evals.json +89 -0
  38. package/skills/sync-docs/references/doc-structure.md +104 -0
  39. package/skills/sync-docs/references/schema.md +103 -0
  40. package/skills/sync-docs/scripts/context_gatherer.py +246 -0
  41. package/skills/sync-docs/scripts/doc_structure_analyzer.py +495 -0
  42. package/skills/sync-docs/scripts/validate_doc.py +365 -0
  43. package/skills/sync-docs-workspace/iteration-1/benchmark.json +293 -0
  44. package/skills/sync-docs-workspace/iteration-1/benchmark.md +13 -0
  45. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/eval_metadata.json +27 -0
  46. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/with_skill/outputs/result.md +210 -0
  47. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/with_skill/run-1/grading.json +28 -0
  48. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/with_skill/run-1/timing.json +1 -0
  49. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/without_skill/outputs/result.md +101 -0
  50. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/without_skill/run-1/grading.json +28 -0
  51. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/without_skill/run-1/timing.json +5 -0
  52. package/skills/sync-docs-workspace/iteration-1/eval-doc-audit/without_skill/timing.json +5 -0
  53. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/eval_metadata.json +27 -0
  54. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/with_skill/outputs/result.md +198 -0
  55. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/with_skill/run-1/grading.json +28 -0
  56. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/with_skill/run-1/timing.json +1 -0
  57. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/without_skill/outputs/result.md +94 -0
  58. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/without_skill/run-1/grading.json +28 -0
  59. package/skills/sync-docs-workspace/iteration-1/eval-fix-mode/without_skill/run-1/timing.json +1 -0
  60. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/eval_metadata.json +27 -0
  61. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/with_skill/outputs/result.md +237 -0
  62. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/with_skill/run-1/grading.json +28 -0
  63. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/with_skill/run-1/timing.json +1 -0
  64. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/without_skill/outputs/result.md +134 -0
  65. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/without_skill/run-1/grading.json +28 -0
  66. package/skills/sync-docs-workspace/iteration-1/eval-sprint-closeout/without_skill/run-1/timing.json +1 -0
  67. package/skills/sync-docs-workspace/iteration-2/benchmark.json +297 -0
  68. package/skills/sync-docs-workspace/iteration-2/benchmark.md +13 -0
  69. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/eval_metadata.json +27 -0
  70. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/with_skill/outputs/result.md +137 -0
  71. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/with_skill/run-1/grading.json +92 -0
  72. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/with_skill/run-1/timing.json +1 -0
  73. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/without_skill/outputs/result.md +134 -0
  74. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/without_skill/run-1/grading.json +86 -0
  75. package/skills/sync-docs-workspace/iteration-2/eval-doc-audit/without_skill/run-1/timing.json +1 -0
  76. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/eval_metadata.json +27 -0
  77. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/with_skill/outputs/result.md +193 -0
  78. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/with_skill/run-1/grading.json +72 -0
  79. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/with_skill/run-1/timing.json +1 -0
  80. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/without_skill/outputs/result.md +211 -0
  81. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/without_skill/run-1/grading.json +91 -0
  82. package/skills/sync-docs-workspace/iteration-2/eval-fix-mode/without_skill/run-1/timing.json +5 -0
  83. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/eval_metadata.json +27 -0
  84. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/with_skill/outputs/result.md +182 -0
  85. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/with_skill/run-1/grading.json +95 -0
  86. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/with_skill/run-1/timing.json +1 -0
  87. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/without_skill/outputs/result.md +222 -0
  88. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/without_skill/run-1/grading.json +88 -0
  89. package/skills/sync-docs-workspace/iteration-2/eval-sprint-closeout/without_skill/run-1/timing.json +5 -0
  90. package/skills/sync-docs-workspace/iteration-3/benchmark.json +298 -0
  91. package/skills/sync-docs-workspace/iteration-3/benchmark.md +13 -0
  92. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/eval_metadata.json +27 -0
  93. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/with_skill/outputs/result.md +125 -0
  94. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/with_skill/run-1/grading.json +97 -0
  95. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/with_skill/run-1/timing.json +5 -0
  96. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/without_skill/outputs/result.md +144 -0
  97. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/without_skill/run-1/grading.json +78 -0
  98. package/skills/sync-docs-workspace/iteration-3/eval-doc-audit/without_skill/run-1/timing.json +5 -0
  99. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/eval_metadata.json +27 -0
  100. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/with_skill/outputs/result.md +104 -0
  101. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/with_skill/run-1/grading.json +91 -0
  102. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/with_skill/run-1/timing.json +5 -0
  103. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/without_skill/outputs/result.md +79 -0
  104. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/without_skill/run-1/grading.json +82 -0
  105. package/skills/sync-docs-workspace/iteration-3/eval-fix-mode/without_skill/run-1/timing.json +5 -0
  106. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/eval_metadata.json +27 -0
  107. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/phase1_context.json +302 -0
  108. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/phase2_drift.txt +33 -0
  109. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/phase3_analysis.json +114 -0
  110. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/phase4_fix.txt +118 -0
  111. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/phase5_validate.txt +38 -0
  112. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/outputs/result.md +158 -0
  113. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/run-1/grading.json +95 -0
  114. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/with_skill/run-1/timing.json +5 -0
  115. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/without_skill/outputs/result.md +71 -0
  116. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/without_skill/run-1/grading.json +90 -0
  117. package/skills/sync-docs-workspace/iteration-3/eval-sprint-closeout/without_skill/run-1/timing.json +5 -0
  118. package/skills/updating-service-skills/SKILL.md +136 -0
  119. package/skills/updating-service-skills/scripts/drift_detector.py +222 -0
  120. package/skills/using-quality-gates/SKILL.md +254 -0
  121. package/skills/using-service-skills/SKILL.md +108 -0
  122. package/skills/using-service-skills/scripts/cataloger.py +74 -0
  123. package/skills/using-service-skills/scripts/skill_activator.py +152 -0
  124. package/skills/using-service-skills/scripts/test_skill_activator.py +58 -0
  125. package/skills/using-xtrm/SKILL.md +34 -38
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pure utility functions for plan mode.
3
+ * Extracted for testability.
4
+ */
5
+
6
+ // Destructive commands blocked in plan mode
7
+ const DESTRUCTIVE_PATTERNS = [
8
+ /\brm\b/i,
9
+ /\brmdir\b/i,
10
+ /\bmv\b/i,
11
+ /\bcp\b/i,
12
+ /\bmkdir\b/i,
13
+ /\btouch\b/i,
14
+ /\bchmod\b/i,
15
+ /\bchown\b/i,
16
+ /\bchgrp\b/i,
17
+ /\bln\b/i,
18
+ /\btee\b/i,
19
+ /\btruncate\b/i,
20
+ /\bdd\b/i,
21
+ /\bshred\b/i,
22
+ /(^|[^<])>(?!>)/,
23
+ />>/,
24
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
25
+ /\byarn\s+(add|remove|install|publish)/i,
26
+ /\bpnpm\s+(add|remove|install|publish)/i,
27
+ /\bpip\s+(install|uninstall)/i,
28
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
+ /\bbrew\s+(install|uninstall|upgrade)/i,
30
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
31
+ /\bsudo\b/i,
32
+ /\bsu\b/i,
33
+ /\bkill\b/i,
34
+ /\bpkill\b/i,
35
+ /\bkillall\b/i,
36
+ /\breboot\b/i,
37
+ /\bshutdown\b/i,
38
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
39
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
40
+ /\b(vim?|nano|emacs|code|subl)\b/i,
41
+ ];
42
+
43
+ // Safe read-only commands allowed in plan mode
44
+ const SAFE_PATTERNS = [
45
+ /^\s*cat\b/,
46
+ /^\s*head\b/,
47
+ /^\s*tail\b/,
48
+ /^\s*less\b/,
49
+ /^\s*more\b/,
50
+ /^\s*grep\b/,
51
+ /^\s*find\b/,
52
+ /^\s*ls\b/,
53
+ /^\s*pwd\b/,
54
+ /^\s*echo\b/,
55
+ /^\s*printf\b/,
56
+ /^\s*wc\b/,
57
+ /^\s*sort\b/,
58
+ /^\s*uniq\b/,
59
+ /^\s*diff\b/,
60
+ /^\s*file\b/,
61
+ /^\s*stat\b/,
62
+ /^\s*du\b/,
63
+ /^\s*df\b/,
64
+ /^\s*tree\b/,
65
+ /^\s*which\b/,
66
+ /^\s*whereis\b/,
67
+ /^\s*type\b/,
68
+ /^\s*env\b/,
69
+ /^\s*printenv\b/,
70
+ /^\s*uname\b/,
71
+ /^\s*whoami\b/,
72
+ /^\s*id\b/,
73
+ /^\s*date\b/,
74
+ /^\s*cal\b/,
75
+ /^\s*uptime\b/,
76
+ /^\s*ps\b/,
77
+ /^\s*top\b/,
78
+ /^\s*htop\b/,
79
+ /^\s*free\b/,
80
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
81
+ /^\s*git\s+ls-/i,
82
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
83
+ /^\s*yarn\s+(list|info|why|audit)/i,
84
+ /^\s*node\s+--version/i,
85
+ /^\s*python\s+--version/i,
86
+ /^\s*curl\s/i,
87
+ /^\s*wget\s+-O\s*-/i,
88
+ /^\s*jq\b/,
89
+ /^\s*sed\s+-n/i,
90
+ /^\s*awk\b/,
91
+ /^\s*rg\b/,
92
+ /^\s*fd\b/,
93
+ /^\s*bat\b/,
94
+ /^\s*exa\b/,
95
+ ];
96
+
97
+ export function isSafeCommand(command: string): boolean {
98
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
99
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
100
+ return !isDestructive && isSafe;
101
+ }
102
+
103
+ export interface TodoItem {
104
+ step: number;
105
+ text: string;
106
+ completed: boolean;
107
+ }
108
+
109
+ export function cleanStepText(text: string): string {
110
+ let cleaned = text
111
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
112
+ .replace(/`([^`]+)`/g, "$1") // Remove code
113
+ .replace(
114
+ /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
115
+ "",
116
+ )
117
+ .replace(/\s+/g, " ")
118
+ .trim();
119
+
120
+ if (cleaned.length > 0) {
121
+ cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
122
+ }
123
+ if (cleaned.length > 50) {
124
+ cleaned = `${cleaned.slice(0, 47)}...`;
125
+ }
126
+ return cleaned;
127
+ }
128
+
129
+ export function extractTodoItems(message: string): TodoItem[] {
130
+ const items: TodoItem[] = [];
131
+ const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
132
+ if (!headerMatch) return items;
133
+
134
+ const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
135
+ const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
136
+
137
+ for (const match of planSection.matchAll(numberedPattern)) {
138
+ const text = match[2]
139
+ .trim()
140
+ .replace(/\*{1,2}$/, "")
141
+ .trim();
142
+ if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
143
+ const cleaned = cleanStepText(text);
144
+ if (cleaned.length > 3) {
145
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
146
+ }
147
+ }
148
+ }
149
+ return items;
150
+ }
151
+
152
+ export function extractDoneSteps(message: string): number[] {
153
+ const steps: number[] = [];
154
+ for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
155
+ const step = Number(match[1]);
156
+ if (Number.isFinite(step)) steps.push(step);
157
+ }
158
+ return steps;
159
+ }
160
+
161
+ export function markCompletedSteps(text: string, items: TodoItem[]): number {
162
+ const doneSteps = extractDoneSteps(text);
163
+ for (const step of doneSteps) {
164
+ const item = items.find((t) => t.step === step);
165
+ if (item) item.completed = true;
166
+ }
167
+ return doneSteps.length;
168
+ }
@@ -3,18 +3,56 @@ import { SubprocessRunner } from "./core/lib";
3
3
  import * as path from "node:path";
4
4
  import * as fs from "node:fs";
5
5
 
6
+ const SERVICE_REGISTRY_FILES = [
7
+ "service-registry.json",
8
+ path.join(".claude", "skills", "service-registry.json"),
9
+ ];
10
+
11
+ const GLOBAL_SKILL_ROOTS = [
12
+ path.join(process.env.HOME || "", ".agents", "skills"),
13
+ path.join(process.env.HOME || "", ".claude", "skills"),
14
+ ];
15
+
6
16
  export default function (pi: ExtensionAPI) {
7
17
  const getCwd = (ctx: any) => ctx.cwd || process.cwd();
8
18
 
19
+ const resolveRegistryPath = (cwd: string): string | null => {
20
+ for (const rel of SERVICE_REGISTRY_FILES) {
21
+ const candidate = path.join(cwd, rel);
22
+ if (fs.existsSync(candidate)) return candidate;
23
+ }
24
+ return null;
25
+ };
26
+
27
+ const resolveSkillScript = (cwd: string, skillName: string, scriptName: string): string | null => {
28
+ const localPath = path.join(cwd, ".claude", "skills", skillName, "scripts", scriptName);
29
+ if (fs.existsSync(localPath)) return localPath;
30
+
31
+ for (const root of GLOBAL_SKILL_ROOTS) {
32
+ if (!root) continue;
33
+ const candidate = path.join(root, skillName, "scripts", scriptName);
34
+ if (fs.existsSync(candidate)) return candidate;
35
+ }
36
+
37
+ return null;
38
+ };
39
+
9
40
  // 1. Catalog Injection
10
41
  pi.on("before_agent_start", async (event, ctx) => {
11
42
  const cwd = getCwd(ctx);
12
- const catalogerPath = path.join(cwd, ".claude", "skills", "using-service-skills", "scripts", "cataloger.py");
13
- if (!fs.existsSync(catalogerPath)) return undefined;
43
+ const registryPath = resolveRegistryPath(cwd);
44
+ if (!registryPath) return undefined;
45
+
46
+ const catalogerPath = resolveSkillScript(cwd, "using-service-skills", "cataloger.py");
47
+ if (!catalogerPath) return undefined;
14
48
 
15
49
  const result = await SubprocessRunner.run("python3", [catalogerPath], {
16
50
  cwd,
17
- env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }
51
+ env: {
52
+ ...process.env,
53
+ CLAUDE_PROJECT_DIR: cwd,
54
+ SERVICE_REGISTRY_PATH: registryPath,
55
+ },
18
56
  });
19
57
 
20
58
  if (result.code === 0 && result.stdout.trim()) {
@@ -23,7 +61,6 @@ export default function (pi: ExtensionAPI) {
23
61
  return undefined;
24
62
  });
25
63
 
26
-
27
64
  const toClaudeToolName = (toolName: string): string => {
28
65
  if (toolName === "bash") return "Bash";
29
66
  if (toolName === "read_file") return "Read";
@@ -37,8 +74,11 @@ export default function (pi: ExtensionAPI) {
37
74
  // 2. Drift Detection (skill activation is before_agent_start only — not per-tool)
38
75
  pi.on("tool_result", async (event, ctx) => {
39
76
  const cwd = getCwd(ctx);
40
- const driftDetectorPath = path.join(cwd, ".claude", "skills", "updating-service-skills", "scripts", "drift_detector.py");
41
- if (!fs.existsSync(driftDetectorPath)) return undefined;
77
+ const registryPath = resolveRegistryPath(cwd);
78
+ if (!registryPath) return undefined;
79
+
80
+ const driftDetectorPath = resolveSkillScript(cwd, "updating-service-skills", "drift_detector.py");
81
+ if (!driftDetectorPath) return undefined;
42
82
 
43
83
  const hookInput = JSON.stringify({
44
84
  tool_name: toClaudeToolName(event.toolName),
@@ -49,7 +89,11 @@ export default function (pi: ExtensionAPI) {
49
89
  const result = await SubprocessRunner.run("python3", [driftDetectorPath], {
50
90
  cwd,
51
91
  input: hookInput,
52
- env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
92
+ env: {
93
+ ...process.env,
94
+ CLAUDE_PROJECT_DIR: cwd,
95
+ SERVICE_REGISTRY_PATH: registryPath,
96
+ },
53
97
  timeoutMs: 10000,
54
98
  });
55
99
 
@@ -0,0 +1,117 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { isBashToolResult } from "@mariozechner/pi-coding-agent";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { SubprocessRunner, EventAdapter } from "./core/lib";
6
+ import { readSessionState } from "./core/session-state";
7
+
8
+ function isClaimCommand(command: string): { isClaim: boolean; issueId: string | null } {
9
+ if (!/\bbd\s+update\b/.test(command) || !/--claim\b/.test(command)) {
10
+ return { isClaim: false, issueId: null };
11
+ }
12
+ const match = command.match(/\bbd\s+update\s+(\S+)/);
13
+ return { isClaim: true, issueId: match?.[1] ?? null };
14
+ }
15
+
16
+ function statePathFrom(startCwd: string): string {
17
+ let current = path.resolve(startCwd || process.cwd());
18
+ for (;;) {
19
+ const candidate = path.join(current, ".xtrm-session-state.json");
20
+ if (fs.existsSync(candidate)) return candidate;
21
+ const parent = path.dirname(current);
22
+ if (parent === current) return path.join(startCwd, ".xtrm-session-state.json");
23
+ current = parent;
24
+ }
25
+ }
26
+
27
+ async function ensureWorktreeSessionState(cwd: string, issueId: string): Promise<{ ok: boolean; message?: string }> {
28
+ const repoRootResult = await SubprocessRunner.run("git", ["rev-parse", "--show-toplevel"], { cwd });
29
+ if (repoRootResult.code !== 0 || !repoRootResult.stdout) return { ok: false, message: "not a git repo" };
30
+ const repoRoot = repoRootResult.stdout.trim();
31
+
32
+ const gitDir = await SubprocessRunner.run("git", ["rev-parse", "--git-dir"], { cwd });
33
+ const commonDir = await SubprocessRunner.run("git", ["rev-parse", "--git-common-dir"], { cwd });
34
+ if (gitDir.code === 0 && commonDir.code === 0 && gitDir.stdout.trim() !== commonDir.stdout.trim()) {
35
+ return { ok: false, message: "already in linked worktree" };
36
+ }
37
+
38
+ const overstoryDir = path.join(repoRoot, ".overstory");
39
+ const worktreesBase = fs.existsSync(overstoryDir)
40
+ ? path.join(overstoryDir, "worktrees")
41
+ : path.join(repoRoot, ".worktrees");
42
+ fs.mkdirSync(worktreesBase, { recursive: true });
43
+
44
+ const branch = `feature/${issueId}`;
45
+ const worktreePath = path.join(worktreesBase, issueId);
46
+ if (!fs.existsSync(worktreePath)) {
47
+ const branchExists = (await SubprocessRunner.run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: repoRoot })).code === 0;
48
+ const addArgs = branchExists
49
+ ? ["worktree", "add", worktreePath, branch]
50
+ : ["worktree", "add", worktreePath, "-b", branch];
51
+ const add = await SubprocessRunner.run("git", addArgs, { cwd: repoRoot, timeoutMs: 20000 });
52
+ if (add.code !== 0) {
53
+ return { ok: false, message: add.stderr || add.stdout || "worktree creation failed" };
54
+ }
55
+ }
56
+
57
+ const statePath = statePathFrom(repoRoot);
58
+ const payload = {
59
+ issueId,
60
+ branch,
61
+ worktreePath,
62
+ prNumber: null,
63
+ prUrl: null,
64
+ phase: "claimed",
65
+ conflictFiles: [],
66
+ startedAt: new Date().toISOString(),
67
+ lastChecked: new Date().toISOString(),
68
+ };
69
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
70
+ return { ok: true, message: `Worktree created: ${worktreePath} Branch: ${branch}` };
71
+ }
72
+
73
+ export default function (pi: ExtensionAPI) {
74
+ const getCwd = (ctx: any) => ctx.cwd || process.cwd();
75
+
76
+ pi.on("tool_result", async (event, ctx) => {
77
+ if (!isBashToolResult(event)) return undefined;
78
+ const cwd = getCwd(ctx);
79
+ if (!EventAdapter.isBeadsProject(cwd)) return undefined;
80
+
81
+ const command = event.input.command || "";
82
+ const { isClaim, issueId } = isClaimCommand(command);
83
+ if (!isClaim || !issueId) return undefined;
84
+
85
+ const ensured = await ensureWorktreeSessionState(cwd, issueId);
86
+ if (ensured.ok) {
87
+ const text = `\n\n🧭 Session Flow: ${ensured.message}`;
88
+ return { content: [...event.content, { type: "text", text }] };
89
+ }
90
+ return undefined;
91
+ });
92
+
93
+ pi.on("agent_end", async (_event, ctx) => {
94
+ const cwd = getCwd(ctx);
95
+ if (!EventAdapter.isBeadsProject(cwd)) return undefined;
96
+ const state = readSessionState(cwd);
97
+ if (!state) return undefined;
98
+
99
+ if (state.phase === "waiting-merge" || state.phase === "pending-cleanup") {
100
+ const pr = state.prNumber != null ? `#${state.prNumber}` : "(pending PR)";
101
+ const url = state.prUrl ? ` ${state.prUrl}` : "";
102
+ pi.sendUserMessage(`🚫 PR ${pr}${url} not yet merged. Run: xtrm finish`);
103
+ return undefined;
104
+ }
105
+
106
+ if (state.phase === "conflicting") {
107
+ const files = state.conflictFiles?.length ? state.conflictFiles.join(", ") : "unknown files";
108
+ pi.sendUserMessage(`🚫 Merge conflicts in: ${files}. Resolve, push, then: xtrm finish`);
109
+ return undefined;
110
+ }
111
+
112
+ if (state.phase === "claimed" || state.phase === "phase1-done") {
113
+ pi.sendUserMessage(`⚠ Session has an active worktree at ${state.worktreePath}. Consider running: xtrm finish`);
114
+ }
115
+ return undefined;
116
+ });
117
+ }
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // beads-claim-sync — PostToolUse hook
3
3
  // Auto-sets kv claim on bd update --claim; auto-clears on bd close.
4
+ // Also bootstraps worktree-first session state for xtrm finish workflow.
4
5
 
5
6
  import { spawnSync } from 'node:child_process';
6
- import { readFileSync, existsSync } from 'node:fs';
7
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
7
8
  import { join } from 'node:path';
9
+ import { writeSessionState } from './session-state.mjs';
8
10
 
9
11
  function readInput() {
10
12
  try {
@@ -35,6 +37,112 @@ function commandSucceeded(payload) {
35
37
  return true;
36
38
  }
37
39
 
40
+ function runGit(args, cwd, timeout = 8000) {
41
+ return spawnSync('git', args, {
42
+ cwd,
43
+ stdio: ['pipe', 'pipe', 'pipe'],
44
+ encoding: 'utf8',
45
+ timeout,
46
+ });
47
+ }
48
+
49
+ function getRepoRoot(cwd) {
50
+ const result = runGit(['rev-parse', '--show-toplevel'], cwd);
51
+ if (result.status !== 0) return null;
52
+ return result.stdout.trim();
53
+ }
54
+
55
+ function inLinkedWorktree(cwd) {
56
+ const gitDir = runGit(['rev-parse', '--git-dir'], cwd);
57
+ const gitCommonDir = runGit(['rev-parse', '--git-common-dir'], cwd);
58
+ if (gitDir.status !== 0 || gitCommonDir.status !== 0) return false;
59
+ return gitDir.stdout.trim() !== gitCommonDir.stdout.trim();
60
+ }
61
+
62
+ function ensureWorktreeForClaim(cwd, issueId) {
63
+ const repoRoot = getRepoRoot(cwd);
64
+ if (!repoRoot) return { created: false, reason: 'not-git' };
65
+
66
+ if (inLinkedWorktree(cwd)) {
67
+ return { created: false, reason: 'already-worktree', repoRoot };
68
+ }
69
+
70
+ const overstoryDir = join(repoRoot, '.overstory');
71
+ const worktreesBase = existsSync(overstoryDir)
72
+ ? join(overstoryDir, 'worktrees')
73
+ : join(repoRoot, '.worktrees');
74
+
75
+ mkdirSync(worktreesBase, { recursive: true });
76
+
77
+ const branch = `feature/${issueId}`;
78
+ const worktreePath = join(worktreesBase, issueId);
79
+
80
+ if (existsSync(worktreePath)) {
81
+ // Already created previously — rewrite state file for continuity.
82
+ try {
83
+ const stateFile = writeSessionState({
84
+ issueId,
85
+ branch,
86
+ worktreePath,
87
+ prNumber: null,
88
+ prUrl: null,
89
+ phase: 'claimed',
90
+ conflictFiles: [],
91
+ }, { cwd: repoRoot });
92
+ return { created: false, reason: 'exists', repoRoot, branch, worktreePath, stateFile };
93
+ } catch {
94
+ return { created: false, reason: 'exists', repoRoot, branch, worktreePath };
95
+ }
96
+ }
97
+
98
+ const branchExists = runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot).status === 0;
99
+ const addArgs = branchExists
100
+ ? ['worktree', 'add', worktreePath, branch]
101
+ : ['worktree', 'add', worktreePath, '-b', branch];
102
+
103
+ const addResult = runGit(addArgs, repoRoot, 20000);
104
+ if (addResult.status !== 0) {
105
+ return {
106
+ created: false,
107
+ reason: 'create-failed',
108
+ repoRoot,
109
+ branch,
110
+ worktreePath,
111
+ error: (addResult.stderr || addResult.stdout || '').trim(),
112
+ };
113
+ }
114
+
115
+ try {
116
+ const stateFile = writeSessionState({
117
+ issueId,
118
+ branch,
119
+ worktreePath,
120
+ prNumber: null,
121
+ prUrl: null,
122
+ phase: 'claimed',
123
+ conflictFiles: [],
124
+ }, { cwd: repoRoot });
125
+
126
+ return {
127
+ created: true,
128
+ reason: 'created',
129
+ repoRoot,
130
+ branch,
131
+ worktreePath,
132
+ stateFile,
133
+ };
134
+ } catch (err) {
135
+ return {
136
+ created: true,
137
+ reason: 'created-state-write-failed',
138
+ repoRoot,
139
+ branch,
140
+ worktreePath,
141
+ error: String(err?.message || err),
142
+ };
143
+ }
144
+ }
145
+
38
146
  function main() {
39
147
  const input = readInput();
40
148
  if (!input || input.hook_event_name !== 'PostToolUse') process.exit(0);
@@ -68,8 +176,21 @@ function main() {
68
176
  process.exit(0);
69
177
  }
70
178
 
179
+ const wt = ensureWorktreeForClaim(cwd, issueId);
180
+ const details = [];
181
+ if (wt.created) {
182
+ details.push(`🧭 **Session Flow**: Worktree created: \`${wt.worktreePath}\` Branch: \`${wt.branch}\``);
183
+ } else if (wt.reason === 'exists') {
184
+ details.push(`🧭 **Session Flow**: Worktree already exists: \`${wt.worktreePath}\` Branch: \`${wt.branch}\``);
185
+ } else if (wt.reason === 'already-worktree') {
186
+ details.push('🧭 **Session Flow**: Already in a linked worktree — skipping nested worktree creation.');
187
+ } else if (wt.reason === 'create-failed') {
188
+ const err = wt.error ? `\nWarning: ${wt.error}` : '';
189
+ details.push(`⚠️ **Session Flow**: Worktree creation failed for \`${issueId}\`. Continuing without blocking claim.${err}`);
190
+ }
191
+
71
192
  process.stdout.write(JSON.stringify({
72
- additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.`,
193
+ additionalContext: `\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`.${details.length ? `\n${details.join('\n')}` : ''}`,
73
194
  }));
74
195
  process.stdout.write('\n');
75
196
  process.exit(0);
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Claude Code SessionStart hook — restore in_progress beads issues after context compaction.
3
3
  // Reads .beads/.last_active (written by beads-compact-save.mjs), reinstates statuses,
4
- // deletes the marker, and injects a brief agent context message.
4
+ // restores session state file, deletes the marker, and injects a brief agent context message.
5
5
  // Exit 0 in all paths (informational only).
6
- //
7
- // Installed by: xtrm install
8
6
 
9
7
  import { execSync } from 'node:child_process';
10
8
  import { readFileSync, existsSync, unlinkSync } from 'node:fs';
11
9
  import path from 'node:path';
10
+ import { writeSessionState } from './session-state.mjs';
12
11
 
13
12
  let input;
14
13
  try {
@@ -22,13 +21,26 @@ const lastActivePath = path.join(cwd, '.beads', '.last_active');
22
21
 
23
22
  if (!existsSync(lastActivePath)) process.exit(0);
24
23
 
25
- const ids = readFileSync(lastActivePath, 'utf8').trim().split('\n').filter(Boolean);
24
+ let ids = [];
25
+ let sessionState = null;
26
+
27
+ try {
28
+ const raw = readFileSync(lastActivePath, 'utf8').trim();
29
+ if (raw.startsWith('{')) {
30
+ const parsed = JSON.parse(raw);
31
+ ids = Array.isArray(parsed.ids) ? parsed.ids.filter(Boolean) : [];
32
+ sessionState = parsed.sessionState ?? null;
33
+ } else {
34
+ // Backward compatibility: legacy newline format
35
+ ids = raw.split('\n').filter(Boolean);
36
+ }
37
+ } catch {
38
+ // If file is malformed, just delete and continue fail-open.
39
+ }
26
40
 
27
41
  // Clean up regardless of whether restore succeeds
28
42
  unlinkSync(lastActivePath);
29
43
 
30
- if (ids.length === 0) process.exit(0);
31
-
32
44
  let restored = 0;
33
45
  for (const id of ids) {
34
46
  try {
@@ -44,13 +56,33 @@ for (const id of ids) {
44
56
  }
45
57
  }
46
58
 
47
- if (restored > 0) {
59
+ let restoredSession = false;
60
+ if (sessionState && typeof sessionState === 'object') {
61
+ try {
62
+ writeSessionState(sessionState, { cwd });
63
+ restoredSession = true;
64
+ } catch {
65
+ // fail open
66
+ }
67
+ }
68
+
69
+ if (restored > 0 || restoredSession) {
70
+ const lines = [];
71
+ if (restored > 0) {
72
+ lines.push(`Restored ${restored} in_progress issue${restored === 1 ? '' : 's'} from last session before compaction.`);
73
+ }
74
+
75
+ if (restoredSession && (sessionState.phase === 'waiting-merge' || sessionState.phase === 'pending-cleanup')) {
76
+ const pr = sessionState.prNumber != null ? `#${sessionState.prNumber}` : '(pending PR)';
77
+ const prUrl = sessionState.prUrl ? ` ${sessionState.prUrl}` : '';
78
+ lines.push(`RESUME: Run xtrm finish — PR ${pr}${prUrl} waiting for merge. Worktree: ${sessionState.worktreePath}`);
79
+ }
80
+
48
81
  process.stdout.write(
49
82
  JSON.stringify({
50
83
  hookSpecificOutput: {
51
84
  hookEventName: 'SessionStart',
52
- additionalSystemPrompt:
53
- `Restored ${restored} in_progress issue${restored === 1 ? '' : 's'} from last session before compaction. Check \`bd list\` for details.`,
85
+ additionalSystemPrompt: `${lines.join(' ')} Check \`bd list\` for details.`,
54
86
  },
55
87
  }) + '\n',
56
88
  );
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Claude Code PreCompact hook — save in_progress beads issues before context is compacted.
3
- // Writes issue IDs to .beads/.last_active so beads-compact-restore.mjs can reinstate them.
3
+ // Writes a compact bundle to .beads/.last_active for restore hook.
4
+ // Bundle includes issue IDs and xtrm session state when available.
4
5
  // Exit 0 in all paths (informational only).
5
- //
6
- // Installed by: xtrm install
7
6
 
8
7
  import { execSync } from 'node:child_process';
9
8
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
10
9
  import path from 'node:path';
10
+ import { readSessionState } from './session-state.mjs';
11
11
 
12
12
  let input;
13
13
  try {
@@ -40,7 +40,38 @@ for (const line of output.split('\n')) {
40
40
  if (match) ids.push(match[1]);
41
41
  }
42
42
 
43
- if (ids.length === 0) process.exit(0);
43
+ const sessionState = readSessionState(cwd);
44
+ const bundle = {
45
+ ids,
46
+ sessionState: sessionState ? {
47
+ issueId: sessionState.issueId,
48
+ branch: sessionState.branch,
49
+ worktreePath: sessionState.worktreePath,
50
+ prNumber: sessionState.prNumber,
51
+ prUrl: sessionState.prUrl,
52
+ phase: sessionState.phase,
53
+ conflictFiles: Array.isArray(sessionState.conflictFiles) ? sessionState.conflictFiles : [],
54
+ startedAt: sessionState.startedAt,
55
+ lastChecked: sessionState.lastChecked,
56
+ } : null,
57
+ savedAt: new Date().toISOString(),
58
+ };
59
+
60
+ if (bundle.ids.length === 0 && !bundle.sessionState) process.exit(0);
61
+
62
+ writeFileSync(path.join(beadsDir, '.last_active'), JSON.stringify(bundle, null, 2) + '\n', 'utf8');
63
+
64
+ if (bundle.sessionState?.phase === 'waiting-merge') {
65
+ const pr = bundle.sessionState.prNumber != null ? `#${bundle.sessionState.prNumber}` : '(pending PR)';
66
+ process.stdout.write(
67
+ JSON.stringify({
68
+ hookSpecificOutput: {
69
+ hookEventName: 'PreCompact',
70
+ additionalSystemPrompt:
71
+ `PENDING: xtrm finish waiting for PR ${pr} to merge. Re-run xtrm finish to resume.`,
72
+ },
73
+ }) + '\n',
74
+ );
75
+ }
44
76
 
45
- writeFileSync(path.join(beadsDir, '.last_active'), ids.join('\n') + '\n', 'utf8');
46
77
  process.exit(0);