safeword 0.35.1 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safeword",
3
- "version": "0.35.1",
3
+ "version": "0.35.2",
4
4
  "description": "CLI for setting up and managing safeword development environments",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { execSync } from 'node:child_process';
12
- import { readdirSync, readFileSync } from 'node:fs';
13
- import { basename, dirname, join, relative } from 'node:path';
12
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { basename, dirname, join, relative, resolve } from 'node:path';
14
14
 
15
15
  export interface Entry {
16
16
  timestamp: string;
@@ -107,6 +107,34 @@ export function normalizeRelative(filePath: string, cwd: string): string {
107
107
  return filePath;
108
108
  }
109
109
 
110
+ /**
111
+ * Resolve the project root reliably, regardless of where the session's cwd drifted.
112
+ *
113
+ * Claude Code passes `input.cwd` = the session's current working directory, which
114
+ * is not necessarily the project root. Hooks that wrote `join(cwd, '.safeword-project')`
115
+ * blindly would silently mkdirSync a bogus nested `.safeword-project/` inside whatever
116
+ * subdir the session happened to be in (e.g. `<root>/.safeword-project/tickets/.safeword-project/`).
117
+ *
118
+ * Implementation: walk up from `cwd` looking for a `.git` marker. Pure (no subprocess),
119
+ * preserves the cwd path form (matters on macOS where `git rev-parse --show-toplevel`
120
+ * canonicalizes `/var/folders/...` symlinks to `/private/var/folders/...` and breaks
121
+ * downstream `startsWith` comparisons against absolute paths captured by other code).
122
+ *
123
+ * Returns null when no `.git/` ancestor exists — caller bails silently rather than
124
+ * write to a stray path. Note: we intentionally do NOT match on `.safeword-project/`
125
+ * during the walk because the bogus nested directories created by the old code would
126
+ * mislead the resolver.
127
+ */
128
+ export function resolveProjectRoot(cwd: string): string | null {
129
+ let current = resolve(cwd);
130
+ while (true) {
131
+ if (existsSync(join(current, '.git'))) return current;
132
+ const parent = dirname(current);
133
+ if (parent === current) return null;
134
+ current = parent;
135
+ }
136
+ }
137
+
110
138
  export function detectConflictFiles(cwd: string, transcriptPath: string | undefined): string[] {
111
139
  if (!transcriptPath) return [];
112
140
  const dirtyFiles = new Set(getDirtyFiles(cwd));
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
 
22
- import { detectConflictFiles, type Entry, parseLogLine } from './lib/re-entry';
22
+ import { detectConflictFiles, type Entry, parseLogLine, resolveProjectRoot } from './lib/re-entry';
23
23
 
24
24
  interface HookInput {
25
25
  session_id?: string;
@@ -54,7 +54,11 @@ async function main(): Promise<void> {
54
54
  const { session_id, cwd, source, transcript_path } = input;
55
55
  if (!session_id || !cwd) return;
56
56
 
57
- const logPath = join(cwd, '.safeword-project', 're-entry.md');
57
+ // Same cwd-drift defense as stop-reentry: resolve real project root.
58
+ const projectRoot = resolveProjectRoot(cwd);
59
+ if (!projectRoot) return;
60
+
61
+ const logPath = join(projectRoot, '.safeword-project', 're-entry.md');
58
62
  const logExists = existsSync(logPath);
59
63
  const content = logExists ? readFileSync(logPath, 'utf8').trim() : '';
60
64
 
@@ -72,7 +76,7 @@ async function main(): Promise<void> {
72
76
  briefBody = renderBrief([allEntries[allEntries.length - 1]], { fromAnotherSession: true });
73
77
  }
74
78
 
75
- const conflictFiles = detectConflictFiles(cwd, transcript_path);
79
+ const conflictFiles = detectConflictFiles(projectRoot, transcript_path);
76
80
  const conflictWarning = renderConflictWarning(conflictFiles);
77
81
 
78
82
  // Nothing to inject in either channel → stay silent.
@@ -14,6 +14,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
 
16
16
  import { getActiveTicket } from './lib/active-ticket';
17
+ import { resolveProjectRoot } from './lib/re-entry';
17
18
 
18
19
  interface HookInput {
19
20
  session_id?: string;
@@ -96,18 +97,25 @@ async function main(): Promise<void> {
96
97
  const { session_id, transcript_path, cwd } = input;
97
98
  if (!session_id || !transcript_path || !cwd) return;
98
99
 
100
+ // Claude Code passes input.cwd = the session's current working directory,
101
+ // which can drift into a subdirectory. Resolve the real project root so we
102
+ // never write to a stray nested `.safeword-project/`. Bail silently if we
103
+ // can't find a git repo to anchor to.
104
+ const projectRoot = resolveProjectRoot(cwd);
105
+ if (!projectRoot) return;
106
+
99
107
  const assistantText = readLastAssistantText(transcript_path);
100
108
  if (!assistantText) return;
101
109
 
102
110
  const imperative = extractLastNextImperative(assistantText);
103
111
  if (!imperative) return;
104
112
 
105
- const ticketField = resolveTicketField(cwd);
113
+ const ticketField = resolveTicketField(projectRoot);
106
114
 
107
115
  const timestamp = new Date().toISOString();
108
116
  const line = `${timestamp} ${session_id} ${ticketField} Next: ${imperative}\n`;
109
117
 
110
- const projectDirectory = join(cwd, '.safeword-project');
118
+ const projectDirectory = join(projectRoot, '.safeword-project');
111
119
  if (!existsSync(projectDirectory)) {
112
120
  mkdirSync(projectDirectory, { recursive: true });
113
121
  }