From 1d7dab93c27ea4641d7d0a2205219a74feda2f8b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sat, 30 May 2026 23:04:37 -0700 Subject: [PATCH 1/3] agents|fix: Correct helper paths when run from a subdirectory Helpers that read or write `.agents/` files now produce correct results when run from any subdirectory of the repository, not only from the directory where the session started. Running one from a subdirectory previously wrote the session manifest to the wrong location and skipped project preferences, leaving stray files behind and forcing a fresh derivation on every call. The session-context deriver and the title-rendering and ticket-ID scripts now locate `.agents/` from the git repository root. An explicit `--cwd` continues to override that default for callers that already hold the root. --- .../scripts/__tests__/describe_change_test.sh | 18 ++++++ .../scripts/__tests__/get_ticket_id_test.sh | 18 ++++++ .../agents/content/scripts/describe-change.sh | 11 +++- .../agents/content/scripts/get-ticket-id.sh | 58 ++++++++++--------- .../agents/src/derive-session-context/cli.ts | 8 ++- .../__tests__/resolve-project-root.test.ts | 44 ++++++++++++++ .../agents/src/shared/resolve-project-root.ts | 50 ++++++++++++++++ 7 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 packages/agents/src/shared/__tests__/resolve-project-root.test.ts create mode 100644 packages/agents/src/shared/resolve-project-root.ts diff --git a/packages/agents/content/scripts/__tests__/describe_change_test.sh b/packages/agents/content/scripts/__tests__/describe_change_test.sh index 3af60e9e..a98197e1 100644 --- a/packages/agents/content/scripts/__tests__/describe_change_test.sh +++ b/packages/agents/content/scripts/__tests__/describe_change_test.sh @@ -653,4 +653,22 @@ run_script() { When call run_script The output should equal '{"commit_title":"line1\nline2","ticket_title":"","pr_title":"","merge_title":""}' End + +It "reads project preferences from the repo root when invoked from a subdirectory" +prepare() { + cat >".agents/preferences.yaml" <<'YAML' +commit: + title_format: '{title}' +YAML + git -C "$tmpdir/workdir" init --quiet + mkdir -p "$tmpdir/workdir/packages/nested" + cd "$tmpdir/workdir/packages/nested" +} +run_script() { + prepare + bash "$script" --title "Add foo" +} +When call run_script +The output should equal '{"commit_title":"Add foo","ticket_title":"","pr_title":"","merge_title":""}' +End End diff --git a/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh index b17d9a77..052be40f 100644 --- a/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh +++ b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh @@ -450,4 +450,22 @@ run_script() { When call run_script The output should equal "525" End + +It "reads preferences from the repo root when invoked from a subdirectory" +prepare() { + git -C "$tmpdir/workdir" init --quiet + cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML' +project: + ticket_ref_prefix: 'MAC-' +YAML + mkdir -p "$tmpdir/workdir/packages/nested" + cd "$tmpdir/workdir/packages/nested" +} +run_script() { + prepare + bash "$script" "147" +} +When call run_script +The output should equal "MAC-147" +End End diff --git a/packages/agents/content/scripts/describe-change.sh b/packages/agents/content/scripts/describe-change.sh index 71d67aa9..e89cd984 100755 --- a/packages/agents/content/scripts/describe-change.sh +++ b/packages/agents/content/scripts/describe-change.sh @@ -65,6 +65,15 @@ while [[ $# -gt 0 ]]; do esac done +# Resolve the project preferences file, anchored at the git repo root so the lookup does not depend on the +# caller's working directory. Falls back to the current directory when not inside a git repository, preserving +# the prior relative-path behavior. +project_preferences_file() { + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + printf '%s/.agents/preferences.yaml' "$root" +} + # Parse a specific `title_format` value from a YAML file. # Reads line-by-line, tracks the current top-level section, and matches # `title_format:` within the target section (commit, ticket, pr, merge). @@ -123,7 +132,7 @@ resolve_title_format() { local result # Project preferences - result="$(parse_title_format ".agents/preferences.yaml" "$section")" + result="$(parse_title_format "$(project_preferences_file)" "$section")" if [[ "$result" == FOUND:* ]]; then echo "${result#FOUND:}" return diff --git a/packages/agents/content/scripts/get-ticket-id.sh b/packages/agents/content/scripts/get-ticket-id.sh index 3a591c59..22cb41e5 100755 --- a/packages/agents/content/scripts/get-ticket-id.sh +++ b/packages/agents/content/scripts/get-ticket-id.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash # Extract a Jira-style ticket ID from a branch name. # -# Tries the Jira-style pattern first (case-insensitive `[A-Za-z]{2,}-[0-9]+`, -# uppercased on output). Falls back to a bare-numeric match anchored at the -# start of the branch name, formatted using `project.ticket_ref_prefix` from -# `.agents/preferences.yaml`. +# Tries the Jira-style pattern first (case-insensitive `[A-Za-z]{2,}-[0-9]+`, uppercased on output). +# Falls back to a bare-numeric match anchored at the start of the branch name, formatted using +# `project.ticket_ref_prefix` from `.agents/preferences.yaml`. # # Usage: # get-ticket-id.sh [BRANCH_NAME] @@ -12,20 +11,17 @@ # Arguments: # BRANCH_NAME Branch to extract from. Defaults to the current git branch. # -# Output: The resolved ticket ID on stdout, or an empty string when no ID -# can be derived. Exit status is always 0. +# Output: The resolved ticket ID on stdout, or an empty string when no ID can be derived. Exit status is always 0. set -euo pipefail readonly PROG="$(basename "$0")" -# Match a Jira-style ticket ID anywhere in the branch name. Returns the first -# match (uppercased) or empty. Pattern: Two or more letters, hyphen, one or -# more digits, matched case-insensitively. Deliberately unanchored so -# author-prefixed branches (e.g., `wt/COMPPLAN-795`, `wthorsen/MAC-130`) -# match correctly. The greedy `[0-9]+` boundary stops at the first non-digit, -# so `.N` sub-ticket suffixes and `-description` suffixes are naturally -# truncated. See `_data/ticket-id-extraction.md` for the canonical contract. +# Matches a Jira-style ticket ID anywhere in the branch name. Returns the first match (uppercased) or empty. +# Pattern: Two or more letters, hyphen, one or more digits, matched case-insensitively. +# Deliberately unanchored so author-prefixed branches (e.g., `wt/COMPPLAN-795`, `wthorsen/MAC-130`) match correctly. +# The greedy `[0-9]+` boundary stops at the first non-digit, so `.N` sub-ticket suffixes and `-description` suffixes +# are naturally truncated. See `_data/ticket-id-extraction.md` for the canonical contract. extract_jira_id() { local branch_name="$1" echo "$branch_name" | grep -oiE '[A-Z]{2,}-[0-9]+' | head -1 | tr '[:lower:]' '[:upper:]' || true @@ -38,20 +34,28 @@ extract_bare_number() { echo "$branch_name" | grep -oE '^[0-9]+' | head -1 || true } -# Read `project.ticket_ref_prefix` from a preferences YAML file. Defaults to -# `.agents/preferences.yaml`. Returns empty when the key is absent or the -# file does not exist. Handles both quoted and unquoted values, and strips -# trailing inline comments (`# ...`) from unquoted values. A `#` inside -# single or double quotes is preserved as part of the value. +# Resolve the project preferences file, anchored at the git repo root so that the lookup does not depend on the +# caller's working directory. Falls back to the current directory when not inside a git repository, preserving the +# prior relative-path behavior. +project_preferences_file() { + local root + root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + printf '%s/.agents/preferences.yaml' "$root" +} + +# Reads `project.ticket_ref_prefix` from a preferences YAML file. Defaults to the preferences file at the repo root. +# Returns empty when the key is absent or the file does not exist. +# Handles both quoted and unquoted values, and strips trailing inline comments (`# ...`) from unquoted values. A `#` +# inside single or double quotes is preserved as part of the value. read_ticket_ref_prefix() { - local file="${1:-.agents/preferences.yaml}" + local file="${1:-$(project_preferences_file)}" if [[ ! -f "$file" ]]; then return fi local line - # Anchor the match at the start of the line (allowing leading whitespace) so - # commented-out preference lines (`# ticket_ref_prefix: ...`) are skipped. + # Anchor the match at the start of the line (allowing leading whitespace) so that commented-out preference lines + # (`# ticket_ref_prefix: ...`) are skipped. line=$(grep -E '^[[:space:]]*ticket_ref_prefix:' "$file" 2>/dev/null | head -1) || true if [[ -z "$line" ]]; then return @@ -67,8 +71,7 @@ read_ticket_ref_prefix() { return fi - # Quoted value: Capture the contents between the matching quotes. This - # preserves `#` characters that appear inside the value. + # Quoted value: Capture the contents between the matching quotes. This preserves `#` characters inside the value. if [[ "$line" =~ ^\'([^\']*)\' ]]; then echo "${BASH_REMATCH[1]}" return @@ -85,8 +88,8 @@ read_ticket_ref_prefix() { } # Combine a bare number with the configured prefix to produce a ticket ID. -# - `#` prefix: Return the bare number alone (the `#` is a GitHub display -# convention and must not appear in returned values or file paths). +# - `#` prefix: Return the bare number alone (`#` is a GitHub display convention and must not appear in returned values +# or file paths). # - Other non-empty prefix: Return `{prefix}{number}` (e.g., `MAC-147`). # - Empty prefix: Return the bare number unchanged. format_bare_ticket_id() { @@ -102,9 +105,8 @@ format_bare_ticket_id() { fi } -# Resolve a ticket ID for the given branch name. Tries the Jira-style match -# first; falls back to the bare-numeric prefix when no Jira-style ID is -# found. Returns empty when neither matches. +# Resolve a ticket ID for the given branch name. Tries the Jira-style match first; +# falls back to the bare-numeric prefix when no Jira-style ID is found. Returns empty when neither matches. extract_ticket_id() { local branch_name="$1" local ticket_id diff --git a/packages/agents/src/derive-session-context/cli.ts b/packages/agents/src/derive-session-context/cli.ts index 56baadf9..4090c7d8 100644 --- a/packages/agents/src/derive-session-context/cli.ts +++ b/packages/agents/src/derive-session-context/cli.ts @@ -13,7 +13,10 @@ * * Flags: * --branch Override branch lookup (used by tests and the smoke harness). - * --cwd Override working directory (used by tests). + * --cwd Caller-supplied base directory for repo-relative paths (`.agents/`). When omitted, the base + * resolves to the git repo root (worktree-aware), falling back to the current working directory. + * Callers that already hold the root (e.g. `resolve-frontmatter.sh`) pass it explicitly; tests + * use it for isolation. See `resolveProjectRoot` for the full precedence. * --home Override home directory for `~/.agents/preferences.yaml` lookup. * Defaults to `os.homedir()`. */ @@ -27,6 +30,7 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { isEnoent, isRecord } from '../lib/type-guards.ts'; +import { resolveProjectRoot } from '../shared/resolve-project-root.ts'; import { composeManifest } from './compose-manifest.ts'; import { readPreferences } from './read-preferences.ts'; import type { BranchManifest } from './types.ts'; @@ -57,7 +61,7 @@ interface ParsedArgs { async function main(): Promise { try { const parsed = parseArgs(process.argv.slice(2)); - const cwd = parsed.cwd ?? process.cwd(); + const cwd = resolveProjectRoot({ cwd: parsed.cwd }); const branch = parsed.branch ?? (await resolveCurrentBranch(cwd)); const manifest = await deriveSessionContext({ diff --git a/packages/agents/src/shared/__tests__/resolve-project-root.test.ts b/packages/agents/src/shared/__tests__/resolve-project-root.test.ts new file mode 100644 index 00000000..b0792ff9 --- /dev/null +++ b/packages/agents/src/shared/__tests__/resolve-project-root.test.ts @@ -0,0 +1,44 @@ +import { execFileSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveProjectRoot } from '../resolve-project-root.ts'; + +describe(resolveProjectRoot, () => { + let scratch: string; + + beforeEach(async () => { + scratch = await mkdtemp(path.join(tmpdir(), 'resolve-project-root-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(scratch, { recursive: true, force: true }); + }); + + it('returns an explicit cwd override verbatim without invoking git', () => { + // `scratch` is not a git repository, so a returned value equal to it proves the override short-circuited + // ahead of the git-root lookup rather than falling through to it. + expect(resolveProjectRoot({ cwd: '/explicit/override', startDir: scratch })).toBe('/explicit/override'); + }); + + it('resolves the git repo root when invoked from a subdirectory', async () => { + execFileSync('git', ['-C', scratch, 'init', '--quiet']); + const subdir = path.join(scratch, 'packages', 'nested'); + await mkdir(subdir, { recursive: true }); + + // `git rev-parse --show-toplevel` returns the realpath, which on macOS resolves `/var` to `/private/var`. + expect(resolveProjectRoot({ startDir: subdir })).toBe(realpathSync(scratch)); + }); + + it('falls back to the start directory and warns when not inside a git repository', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + expect(resolveProjectRoot({ startDir: scratch })).toBe(scratch); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/not inside a git repository/)); + }); +}); diff --git a/packages/agents/src/shared/resolve-project-root.ts b/packages/agents/src/shared/resolve-project-root.ts new file mode 100644 index 00000000..a95d9ff2 --- /dev/null +++ b/packages/agents/src/shared/resolve-project-root.ts @@ -0,0 +1,50 @@ +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; + +/** Options controlling how the project root is resolved. */ +interface ResolveProjectRootOptions { + /** Caller-supplied base (e.g. from a `--cwd` flag). When set and non-empty, it is returned verbatim. */ + readonly cwd?: string | null; + /** Directory the git-root discovery runs from. Defaults to the ambient working directory. */ + readonly startDir?: string; +} + +/** + * Resolves the directory that repo-relative helper paths (`.agents/`, etc.) should anchor against, so a helper is + * correct from any subdirectory of the repo regardless of where the agent happened to invoke it. + * + * Precedence, highest first: + * 1. An explicit `cwd` override, used verbatim with no git invocation. + * 2. The git repo root (`git rev-parse --show-toplevel`, which is worktree-aware). + * 3. The ambient working directory, as a last resort, accompanied by a one-line stderr diagnostic. + * + * The diagnostic is written to stderr only: callers such as `derive-session-context` emit machine-readable output on + * stdout, so a stray stdout write would corrupt it. The branches are ordered so a future captured-invocation-directory + * tier can be inserted ahead of the git-root check without disturbing the others. + */ +export function resolveProjectRoot(options: ResolveProjectRootOptions = {}): string { + const { cwd } = options; + if (cwd !== null && cwd !== undefined && cwd !== '') { + return cwd; + } + const startDir = options.startDir ?? process.cwd(); + const gitRoot = tryGitToplevel(startDir); + if (gitRoot !== null) { + return gitRoot; + } + process.stderr.write( + `resolve-project-root: not inside a git repository; anchoring at ${startDir}. ` + + `Repo-relative paths may be wrong if the working directory changed.\n`, + ); + return startDir; +} + +/** Returns the git working-tree root for `startDir`, or `null` when `startDir` is not inside a git repository. */ +function tryGitToplevel(startDir: string): string | null { + try { + const stdout = execFileSync('git', ['-C', startDir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }); + return stdout.trim() || null; + } catch { + return null; + } +} From cb26051e780751c2d0c6975a294e367eaf92798b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 31 May 2026 09:13:08 -0700 Subject: [PATCH 2/3] agents|fix: Surface a diagnostic when bash helpers fall back outside a repo The bash preference resolvers in describe-change.sh and get-ticket-id.sh anchor `.agents/preferences.yaml` at the git repo root and fall back to the working directory when no repository is found. That fallback was silent, leaving a misanchored run undiagnosable. Both now emit a one-line stderr diagnostic on the fallback path, matching the TypeScript resolver. describe-change.sh resolves the preferences path once in main and threads it through resolve_title_format, so the git lookup and any diagnostic happen a single time per invocation rather than once per title section. The shell test workdirs are now real git repositories, so the resolvers anchor there and the not-a-repo diagnostic stays silent during normal cases; two new cases run from a deliberately uninitialized directory to cover the fallback path and its stderr output. --- .../scripts/__tests__/describe_change_test.sh | 26 +++++++++++++++---- .../scripts/__tests__/get_ticket_id_test.sh | 23 +++++++++++++--- .../agents/content/scripts/describe-change.sh | 26 +++++++++++++------ .../agents/content/scripts/get-ticket-id.sh | 9 ++++--- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/packages/agents/content/scripts/__tests__/describe_change_test.sh b/packages/agents/content/scripts/__tests__/describe_change_test.sh index a98197e1..57805639 100644 --- a/packages/agents/content/scripts/__tests__/describe_change_test.sh +++ b/packages/agents/content/scripts/__tests__/describe_change_test.sh @@ -243,7 +243,7 @@ BeforeEach "setup_tmpdir" AfterEach "cleanup_tmpdir" It "returns empty when no preferences files exist" -When call resolve_title_format "commit" +When call resolve_title_format "commit" ".agents/preferences.yaml" The output should equal "" End @@ -256,7 +256,7 @@ YAML } test_resolve() { write_global - resolve_title_format "commit" + resolve_title_format "commit" ".agents/preferences.yaml" } When call test_resolve The output should equal "{type}({scope}): {title}" @@ -275,7 +275,7 @@ YAML } test_resolve() { write_both - resolve_title_format "commit" + resolve_title_format "commit" ".agents/preferences.yaml" } When call test_resolve The output should equal "project-template" @@ -294,7 +294,7 @@ YAML } test_resolve() { write_both - resolve_title_format "commit" + resolve_title_format "commit" ".agents/preferences.yaml" } When call test_resolve The output should equal "" @@ -519,6 +519,9 @@ setup_tmpdir() { export HOME="$tmpdir/home" mkdir -p "$HOME/.agents" mkdir -p "$tmpdir/workdir/.agents" + # Make workdir a real repo so the resolver anchors there and the not-a-repo diagnostic stays + # silent; cases that need the not-a-repo path run from a separate uninitialized directory. + git -C "$tmpdir/workdir" init --quiet cd "$tmpdir/workdir" } @@ -660,7 +663,6 @@ prepare() { commit: title_format: '{title}' YAML - git -C "$tmpdir/workdir" init --quiet mkdir -p "$tmpdir/workdir/packages/nested" cd "$tmpdir/workdir/packages/nested" } @@ -671,4 +673,18 @@ run_script() { When call run_script The output should equal '{"commit_title":"Add foo","ticket_title":"","pr_title":"","merge_title":""}' End + +It "warns to stderr when invoked outside a git repository" +# Run from a sibling directory that is deliberately not a repo so the resolver falls back to the +# working directory and must announce the misanchor on stderr rather than failing silently. +run_outside_repo() { + mkdir -p "$tmpdir/outside/.agents" + cd "$tmpdir/outside" + bash "$script" --title "Add foo" +} +When call run_outside_repo +The output should equal '{"commit_title":"","ticket_title":"","pr_title":"","merge_title":""}' +The stderr should include "not inside a git repository" +The status should be success +End End diff --git a/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh index 052be40f..2c4f10e5 100644 --- a/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh +++ b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh @@ -290,9 +290,10 @@ When call test_read The output should equal "" End -It "defaults to .agents/preferences.yaml in the current working directory" +It "defaults to the preferences file at the git repo root" prepare() { mkdir -p "$tmpdir/workdir/.agents" + git -C "$tmpdir/workdir" init --quiet cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML' project: ticket_ref_prefix: 'MAC-' @@ -332,6 +333,9 @@ setup_tmpdir() { tmpdir=$(mktemp -d) original_pwd="$PWD" mkdir -p "$tmpdir/workdir/.agents" + # Make workdir a real repo so the resolver anchors there and the not-a-repo diagnostic stays + # silent; cases that need the not-a-repo path run from a separate uninitialized directory. + git -C "$tmpdir/workdir" init --quiet cd "$tmpdir/workdir" } @@ -344,7 +348,7 @@ BeforeEach "setup_tmpdir" AfterEach "cleanup_tmpdir" # Each end-to-end case runs the script as a child process so the -# preferences-file lookup honors the test's working directory. +# preferences-file lookup honors the resolved repo root. It "returns COMPPLAN-795 for an author-prefixed branch" When run bash "$script" "wt/COMPPLAN-795" @@ -453,7 +457,6 @@ End It "reads preferences from the repo root when invoked from a subdirectory" prepare() { - git -C "$tmpdir/workdir" init --quiet cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML' project: ticket_ref_prefix: 'MAC-' @@ -468,4 +471,18 @@ run_script() { When call run_script The output should equal "MAC-147" End + +It "warns to stderr when a bare number is resolved outside a git repository" +# Run from a sibling directory that is deliberately not a repo so the prefix lookup falls back to +# the working directory and must announce the misanchor on stderr rather than failing silently. +run_outside_repo() { + mkdir -p "$tmpdir/outside" + cd "$tmpdir/outside" + bash "$script" "147" +} +When call run_outside_repo +The output should equal "147" +The stderr should include "not inside a git repository" +The status should be success +End End diff --git a/packages/agents/content/scripts/describe-change.sh b/packages/agents/content/scripts/describe-change.sh index e89cd984..92092d0d 100755 --- a/packages/agents/content/scripts/describe-change.sh +++ b/packages/agents/content/scripts/describe-change.sh @@ -66,11 +66,14 @@ while [[ $# -gt 0 ]]; do done # Resolve the project preferences file, anchored at the git repo root so the lookup does not depend on the -# caller's working directory. Falls back to the current directory when not inside a git repository, preserving -# the prior relative-path behavior. +# caller's working directory. Falls back to the current directory when not inside a git repository, emitting a +# stderr diagnostic so a misanchored run is debuggable rather than silent. project_preferences_file() { local root - root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + if ! root="$(git rev-parse --show-toplevel 2>/dev/null)"; then + root="$PWD" + printf '%s: not inside a git repository; anchoring .agents/ lookup at %s\n' "$PROG" "$root" >&2 + fi printf '%s/.agents/preferences.yaml' "$root" } @@ -127,12 +130,15 @@ parse_title_format() { # Resolve a `title_format` value by checking project, then global, then defaulting to empty. # parse_title_format returns "FOUND:{value}" when the key is present, or empty when absent. # This lets an explicit empty value at the project level override a global non-empty value. +# The project preferences path is resolved once by the caller and passed in, so the git-root +# lookup (and its not-a-repo diagnostic) runs once per invocation rather than once per section. resolve_title_format() { local section="$1" + local project_prefs_file="$2" local result # Project preferences - result="$(parse_title_format "$(project_preferences_file)" "$section")" + result="$(parse_title_format "$project_prefs_file" "$section")" if [[ "$result" == FOUND:* ]]; then echo "${result#FOUND:}" return @@ -265,11 +271,15 @@ json_escape() { } main() { + # Resolve the project preferences path once so the git-root lookup runs a single time. + local project_prefs_file + project_prefs_file="$(project_preferences_file)" + local commit_template ticket_template pr_template merge_template - commit_template="$(resolve_title_format "commit")" - ticket_template="$(resolve_title_format "ticket")" - pr_template="$(resolve_title_format "pr")" - merge_template="$(resolve_title_format "merge")" + commit_template="$(resolve_title_format "commit" "$project_prefs_file")" + ticket_template="$(resolve_title_format "ticket" "$project_prefs_file")" + pr_template="$(resolve_title_format "pr" "$project_prefs_file")" + merge_template="$(resolve_title_format "merge" "$project_prefs_file")" local commit_title ticket_title pr_title merge_title commit_title="$(json_escape "$(render_title "$commit_template")")" diff --git a/packages/agents/content/scripts/get-ticket-id.sh b/packages/agents/content/scripts/get-ticket-id.sh index 22cb41e5..ab7e82b9 100755 --- a/packages/agents/content/scripts/get-ticket-id.sh +++ b/packages/agents/content/scripts/get-ticket-id.sh @@ -35,11 +35,14 @@ extract_bare_number() { } # Resolve the project preferences file, anchored at the git repo root so that the lookup does not depend on the -# caller's working directory. Falls back to the current directory when not inside a git repository, preserving the -# prior relative-path behavior. +# caller's working directory. Falls back to the current directory when not inside a git repository, emitting a +# stderr diagnostic so a misanchored run is debuggable rather than silent. project_preferences_file() { local root - root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="$PWD" + if ! root="$(git rev-parse --show-toplevel 2>/dev/null)"; then + root="$PWD" + printf '%s: not inside a git repository; anchoring .agents/ lookup at %s\n' "$PROG" "$root" >&2 + fi printf '%s/.agents/preferences.yaml' "$root" } From c161af27bd676c05588265321c36e02cfcf8ba91 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 31 May 2026 09:13:14 -0700 Subject: [PATCH 3/3] agents|test: Cover an empty --cwd override falling through to the git root resolveProjectRoot treats an empty cwd override (the `--cwd=` flag form) as unset and resolves the git repo root instead of anchoring at the empty string. Pin that contract so a future refactor of the override guard cannot silently regress it. --- .../src/shared/__tests__/resolve-project-root.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/agents/src/shared/__tests__/resolve-project-root.test.ts b/packages/agents/src/shared/__tests__/resolve-project-root.test.ts index b0792ff9..cd1dc32f 100644 --- a/packages/agents/src/shared/__tests__/resolve-project-root.test.ts +++ b/packages/agents/src/shared/__tests__/resolve-project-root.test.ts @@ -26,6 +26,14 @@ describe(resolveProjectRoot, () => { expect(resolveProjectRoot({ cwd: '/explicit/override', startDir: scratch })).toBe('/explicit/override'); }); + it('treats an empty cwd override as unset and falls through to the git root', () => { + execFileSync('git', ['-C', scratch, 'init', '--quiet']); + + // The `--cwd=` flag form yields an empty string; it must not anchor at `''` but fall through to + // the git-root lookup, matching the no-override behavior. + expect(resolveProjectRoot({ cwd: '', startDir: scratch })).toBe(realpathSync(scratch)); + }); + it('resolves the git repo root when invoked from a subdirectory', async () => { execFileSync('git', ['-C', scratch, 'init', '--quiet']); const subdir = path.join(scratch, 'packages', 'nested');