@ai-sdlc/orchestrator 0.9.0 → 0.10.0

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Hermetic git env for orchestrator test fixtures (AISDLC-257).
3
+ *
4
+ * Cross-link: mirrors `pipeline-cli/src/__test-helpers/git-env.ts` (AISDLC-253).
5
+ * See that file for the canonical rationale. This copy exists so orchestrator
6
+ * tests don't take a cross-package import dependency on pipeline-cli's test
7
+ * helpers.
8
+ *
9
+ * Why this exists
10
+ * ───────────────
11
+ * Test fixtures that shell out to `git` (e.g. `execSync('git init', { cwd: d })`)
12
+ * are vulnerable to env-var bleed from the parent shell:
13
+ *
14
+ * - `GIT_DIR=...` overrides cwd-based repo discovery — every git command
15
+ * writes into the parent shell's GIT_DIR, NOT the fixture's `cwd`.
16
+ * - `GIT_WORK_TREE=...` ditto for the working tree.
17
+ * - `core.hooksPath` from system/global config can fire husky hooks during
18
+ * fixture commits.
19
+ * - `commit.gpgsign=true` from operator config can break unattended commits.
20
+ *
21
+ * The `cleanGitEnv()` helper in `orchestrator/src/runtime/git-env.ts` strips
22
+ * GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE but still inherits `process.env`
23
+ * in full, meaning `git config user.email` writes still land wherever git
24
+ * resolves config (which may be a polluted GIT_DIR or the operator's global
25
+ * config). AISDLC-257 identified `worktree-pool.integration.test.ts` as the
26
+ * confirmed leak source.
27
+ *
28
+ * `makeGitEnv()` uses an ALLOW-LIST approach rather than a DENY-LIST: it
29
+ * constructs a minimal env from scratch, deliberately omitting GIT_DIR and
30
+ * GIT_WORK_TREE. Identity is provided via `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
31
+ * env vars so fixtures never need `git config user.email`.
32
+ *
33
+ * Pattern (matches pipeline-cli AISDLC-253 + orchestrator AISDLC-241/246):
34
+ *
35
+ * const env = makeGitEnv();
36
+ * execSync('git init -b main', { cwd: repoDir, env, stdio: 'pipe' });
37
+ * // No `git config user.email` needed — identity comes from GIT_AUTHOR_*
38
+ */
39
+ export declare function makeGitEnv(): NodeJS.ProcessEnv;
40
+ //# sourceMappingURL=git-env.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Hermetic git env for orchestrator test fixtures (AISDLC-257).
3
+ *
4
+ * Cross-link: mirrors `pipeline-cli/src/__test-helpers/git-env.ts` (AISDLC-253).
5
+ * See that file for the canonical rationale. This copy exists so orchestrator
6
+ * tests don't take a cross-package import dependency on pipeline-cli's test
7
+ * helpers.
8
+ *
9
+ * Why this exists
10
+ * ───────────────
11
+ * Test fixtures that shell out to `git` (e.g. `execSync('git init', { cwd: d })`)
12
+ * are vulnerable to env-var bleed from the parent shell:
13
+ *
14
+ * - `GIT_DIR=...` overrides cwd-based repo discovery — every git command
15
+ * writes into the parent shell's GIT_DIR, NOT the fixture's `cwd`.
16
+ * - `GIT_WORK_TREE=...` ditto for the working tree.
17
+ * - `core.hooksPath` from system/global config can fire husky hooks during
18
+ * fixture commits.
19
+ * - `commit.gpgsign=true` from operator config can break unattended commits.
20
+ *
21
+ * The `cleanGitEnv()` helper in `orchestrator/src/runtime/git-env.ts` strips
22
+ * GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE but still inherits `process.env`
23
+ * in full, meaning `git config user.email` writes still land wherever git
24
+ * resolves config (which may be a polluted GIT_DIR or the operator's global
25
+ * config). AISDLC-257 identified `worktree-pool.integration.test.ts` as the
26
+ * confirmed leak source.
27
+ *
28
+ * `makeGitEnv()` uses an ALLOW-LIST approach rather than a DENY-LIST: it
29
+ * constructs a minimal env from scratch, deliberately omitting GIT_DIR and
30
+ * GIT_WORK_TREE. Identity is provided via `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
31
+ * env vars so fixtures never need `git config user.email`.
32
+ *
33
+ * Pattern (matches pipeline-cli AISDLC-253 + orchestrator AISDLC-241/246):
34
+ *
35
+ * const env = makeGitEnv();
36
+ * execSync('git init -b main', { cwd: repoDir, env, stdio: 'pipe' });
37
+ * // No `git config user.email` needed — identity comes from GIT_AUTHOR_*
38
+ */
39
+ export function makeGitEnv() {
40
+ return {
41
+ // Minimal OS plumbing.
42
+ PATH: process.env['PATH'] ?? '/usr/bin:/bin',
43
+ HOME: process.env['HOME'] ?? '/tmp',
44
+ ...(process.env['TMPDIR'] ? { TMPDIR: process.env['TMPDIR'] } : {}),
45
+ ...(process.env['TEMP'] ? { TEMP: process.env['TEMP'] } : {}),
46
+ ...(process.env['TMP'] ? { TMP: process.env['TMP'] } : {}),
47
+ // Locale — prevent non-ASCII error strings in git output.
48
+ LANG: process.env['LANG'] ?? 'en_US.UTF-8',
49
+ LC_ALL: 'C',
50
+ // Git identity — supplied via env so `git config user.email/user.name`
51
+ // is never needed inside the fixture (those writes would land in either
52
+ // the fixture's .git/config or, if GIT_DIR is polluted, in the host
53
+ // worktree's config).
54
+ GIT_AUTHOR_NAME: 'Test',
55
+ GIT_AUTHOR_EMAIL: 'test@test.invalid',
56
+ GIT_COMMITTER_NAME: 'Test',
57
+ GIT_COMMITTER_EMAIL: 'test@test.invalid',
58
+ // Disable system + global git config to prevent gpgsign / hookPath /
59
+ // user-config bleed from the operator's machine.
60
+ GIT_CONFIG_NOSYSTEM: '1',
61
+ GIT_CONFIG_GLOBAL: '/dev/null',
62
+ // Disable husky so the calling project's pre-commit hooks don't fire.
63
+ HUSKY: '0',
64
+ // Suppress credential helpers / interactive prompts.
65
+ GIT_TERMINAL_PROMPT: '0',
66
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
67
+ // Note: deliberately NO `GIT_DIR` / `GIT_WORK_TREE` keys — by omitting
68
+ // them from the returned object, child processes inherit nothing for
69
+ // those vars (the env REPLACES the parent's env when passed to execSync,
70
+ // it doesn't merge). That's the load-bearing guarantee against the leak.
71
+ };
72
+ }
73
+ //# sourceMappingURL=git-env.js.map
@@ -64,4 +64,26 @@ export interface AdmissionCompositeOptions {
64
64
  priorityInputOverrides?: Partial<PriorityInput>;
65
65
  }
66
66
  export declare function computeAdmissionComposite(input: AdmissionInput, config?: PriorityConfig, options?: AdmissionCompositeOptions): AdmissionComposite;
67
+ /**
68
+ * Compute admission confidence in [0, 1] as a 50/50 blend of two
69
+ * independent evidence channels:
70
+ *
71
+ * 1. **Mapper coverage** — fraction of `ADMISSION_MAPPER_FIELDS`
72
+ * explicitly populated on the derived `PriorityInput`. Captures
73
+ * "how much issue/backlog signal did `mapIssueToPriorityInput`
74
+ * extract".
75
+ *
76
+ * 2. **Enrichment loaded** — fraction of RFC-0008 enrichment slots
77
+ * present on the input (or supplied via `options`). Captures "how
78
+ * much external context did the enrichment readers contribute".
79
+ *
80
+ * Bug fixed (AISDLC-172 / RFC-0009 §13 OQ-9): previously
81
+ * `computeConfidence(priorityInput)` from `priority.ts` was used, which
82
+ * counts against the full 16-field `SCORABLE_FIELDS` list and ignores
83
+ * enrichment success entirely. The result was a hard ~0.5 ceiling on
84
+ * admit confidence even when DID + DSB + maintainers + soul-tracks all
85
+ * loaded — those four positive observations contributed exactly zero
86
+ * to the formula's denominator and zero to its numerator.
87
+ */
88
+ export declare function computeAdmissionConfidence(input: AdmissionInput, priorityInput: PriorityInput, options?: AdmissionCompositeOptions): number;
67
89
  //# sourceMappingURL=admission-composite.d.ts.map
@@ -27,7 +27,6 @@
27
27
  import { mapIssueToPriorityInput } from './admission-score.js';
28
28
  import { computeAutonomyFactor, computeDefectRiskFactor, computeReadinessFromDesignSystemContext, } from './admission-enrichment.js';
29
29
  import { computeAdmissionHumanCurve } from './admission-hc.js';
30
- import { computeConfidence } from './priority.js';
31
30
  const DEFAULT_SIGNAL = 0.5;
32
31
  export function computeAdmissionComposite(input, config, options) {
33
32
  const timestamp = new Date().toISOString();
@@ -114,7 +113,7 @@ export function computeAdmissionComposite(input, config, options) {
114
113
  // display continuity but do not multiply it into `composite`.
115
114
  calibration: clampCalibration(config?.calibrationCoefficient),
116
115
  },
117
- confidence: computeConfidence(priorityInput),
116
+ confidence: computeAdmissionConfidence(input, priorityInput, options),
118
117
  timestamp,
119
118
  };
120
119
  return {
@@ -132,6 +131,90 @@ export function computeAdmissionComposite(input, config, options) {
132
131
  },
133
132
  };
134
133
  }
134
+ // ── Admission confidence (AISDLC-172) ──────────────────────────────────
135
+ /**
136
+ * `PriorityInput` fields that the admission mapper (`mapIssueToPriorityInput`)
137
+ * actually populates from issue/backlog signals. The full PPA `SCORABLE_FIELDS`
138
+ * list (used by `computeConfidence` in `priority.ts`) includes runtime-only
139
+ * dimensions like `regulatoryUrgency`, `techInflection`, `marketDivergence`,
140
+ * `meetingDecision`, `budgetUtilization`, and `dependencyClearance` — none of
141
+ * which the admission mapper ever sets. Counting against the full 16-field
142
+ * list capped admit confidence at ~9/16 ≈ 0.56 even for fully-shaped issues
143
+ * (RFC-0009 §13 OQ-9 "0.5 ceiling" bug). The mapper-relevant subset is the
144
+ * correct denominator for the mapper-evidence half of the blend.
145
+ */
146
+ const ADMISSION_MAPPER_FIELDS = [
147
+ 'soulAlignment',
148
+ 'demandSignal',
149
+ 'teamConsensus',
150
+ 'builderConviction',
151
+ 'complexity',
152
+ 'bugSeverity',
153
+ 'explicitPriority',
154
+ 'competitiveDrift',
155
+ 'customerRequestCount',
156
+ ];
157
+ /**
158
+ * RFC-0008 enrichment slots whose presence on `AdmissionInput` indicates
159
+ * an enrichment reader successfully loaded its context. This is the
160
+ * "enrichment-success signal" referenced in the OQ-9 hypothesis:
161
+ *
162
+ * - `designSystemContext` ← DSB loader (catalog/token coverage)
163
+ * - `autonomyContext` ← AutonomyPolicy (DID-driven) loader
164
+ * - `codeAreaQuality` ← code-area metrics loader
165
+ * - `designAuthoritySignal` ← maintainers/principals loader
166
+ * - `soulAlignmentOverride` ← soul-tracks SA-1 loader (M5 path)
167
+ *
168
+ * Total slots is `5`. Each loaded slot contributes `1/5` to the
169
+ * enrichment-evidence half of the confidence blend.
170
+ */
171
+ const ADMISSION_ENRICHMENT_SLOT_COUNT = 5;
172
+ function countLoadedEnrichmentSlots(input, options) {
173
+ let loaded = 0;
174
+ if (input.designSystemContext)
175
+ loaded++;
176
+ if (input.autonomyContext)
177
+ loaded++;
178
+ if (input.codeAreaQuality)
179
+ loaded++;
180
+ if (input.designAuthoritySignal)
181
+ loaded++;
182
+ if (options?.soulAlignmentOverride !== undefined)
183
+ loaded++;
184
+ return loaded;
185
+ }
186
+ /**
187
+ * Compute admission confidence in [0, 1] as a 50/50 blend of two
188
+ * independent evidence channels:
189
+ *
190
+ * 1. **Mapper coverage** — fraction of `ADMISSION_MAPPER_FIELDS`
191
+ * explicitly populated on the derived `PriorityInput`. Captures
192
+ * "how much issue/backlog signal did `mapIssueToPriorityInput`
193
+ * extract".
194
+ *
195
+ * 2. **Enrichment loaded** — fraction of RFC-0008 enrichment slots
196
+ * present on the input (or supplied via `options`). Captures "how
197
+ * much external context did the enrichment readers contribute".
198
+ *
199
+ * Bug fixed (AISDLC-172 / RFC-0009 §13 OQ-9): previously
200
+ * `computeConfidence(priorityInput)` from `priority.ts` was used, which
201
+ * counts against the full 16-field `SCORABLE_FIELDS` list and ignores
202
+ * enrichment success entirely. The result was a hard ~0.5 ceiling on
203
+ * admit confidence even when DID + DSB + maintainers + soul-tracks all
204
+ * loaded — those four positive observations contributed exactly zero
205
+ * to the formula's denominator and zero to its numerator.
206
+ */
207
+ export function computeAdmissionConfidence(input, priorityInput, options) {
208
+ let providedMapper = 0;
209
+ for (const field of ADMISSION_MAPPER_FIELDS) {
210
+ if (priorityInput[field] !== undefined)
211
+ providedMapper++;
212
+ }
213
+ const mapperFraction = providedMapper / ADMISSION_MAPPER_FIELDS.length;
214
+ const loadedEnrichment = countLoadedEnrichmentSlots(input, options);
215
+ const enrichmentFraction = loadedEnrichment / ADMISSION_ENRICHMENT_SLOT_COUNT;
216
+ return 0.5 * mapperFraction + 0.5 * enrichmentFraction;
217
+ }
135
218
  function clamp01(value) {
136
219
  return Math.min(1, Math.max(0, value));
137
220
  }
@@ -225,16 +225,26 @@ export function computeDesignAuthorityWeight(signal) {
225
225
  function buildDesignAuthoritySignal(input, ctx) {
226
226
  if (!ctx.designSystemBinding)
227
227
  return undefined;
228
+ // AISDLC-171: surface whether the DSB declares any design authority
229
+ // principals at all. This is a diagnostic flag — it does NOT participate
230
+ // in the HC_design weight calculation (per RFC-0008 §14.2, only
231
+ // principals who participate as author/commenter can emit HC_design).
232
+ // Surfacing it lets pillarBreakdown distinguish "DSB has no design
233
+ // authority structure" from "DSB has design authority but no principal
234
+ // participated in this issue".
235
+ const principalsDeclared = (ctx.designSystemBinding.spec.stewardship.designAuthority.principals?.length ?? 0) > 0;
228
236
  const { isDesignAuthority, signalType } = checkDesignAuthority({
229
237
  authorLogin: input.authorLogin,
230
238
  commenterLogins: input.commenterLogins,
231
239
  labels: input.labels,
232
240
  }, ctx.designSystemBinding);
233
- if (!isDesignAuthority)
234
- return { isDesignAuthority: false };
241
+ if (!isDesignAuthority) {
242
+ return { isDesignAuthority: false, principalsDeclared };
243
+ }
235
244
  return {
236
245
  isDesignAuthority: true,
237
246
  signalType,
247
+ principalsDeclared,
238
248
  ...(ctx.areaComplianceScore !== undefined
239
249
  ? { areaComplianceScore: ctx.areaComplianceScore }
240
250
  : {}),
@@ -34,8 +34,27 @@ export interface AdmissionHumanCurveResult {
34
34
  hcConsensus: number;
35
35
  /** Signed meeting-decision signal in [-1, 1] (0 = neutral when absent). */
36
36
  hcDecision: number;
37
- /** Signed design-authority signal in [-1, 1]. */
37
+ /**
38
+ * Signed design-authority signal in [-1, 1].
39
+ *
40
+ * AISDLC-171: this channel ONLY fires when one of the issue's
41
+ * participants (author or commenters) is a principal listed in
42
+ * `DesignSystemBinding.spec.stewardship.designAuthority.principals`.
43
+ * Per RFC-0008 §14.2, non-principal participants' design opinions
44
+ * route through `hcConsensus` instead — `hcDesign` carries
45
+ * specifically the design-authority leadership signal. To distinguish
46
+ * "no DSB at all" from "DSB exists but no design principal
47
+ * participated", consult `designAuthorityConfigured` on this result
48
+ * (and on `pillarBreakdown.shared.hcComposite`).
49
+ */
38
50
  hcDesign: number;
51
+ /**
52
+ * Diagnostic flag (AISDLC-171) — true when the resolved DSB declared
53
+ * `stewardship.designAuthority.principals` entries. Always undefined
54
+ * when no DSB was supplied (preDesignSystem). Surfaces the
55
+ * "configured but inactive" state without altering hcDesign weight.
56
+ */
57
+ designAuthorityConfigured?: boolean;
39
58
  /** Weighted sum before tanh. */
40
59
  hcRaw: number;
41
60
  /** `tanh(hcRaw)` — the HC value consumed by the admission composite. */
@@ -70,12 +70,23 @@ export function computeAdmissionHumanCurve(input) {
70
70
  const hcConsensus = deriveHcConsensus(input);
71
71
  const hcDecision = deriveHcDecision(input);
72
72
  const hcDesign = deriveHcDesign(input.designAuthoritySignal);
73
+ // AISDLC-171: surface the diagnostic flag from the enriched signal so
74
+ // pillarBreakdown can render the "configured but inactive" state.
75
+ const designAuthorityConfigured = input.designAuthoritySignal?.principalsDeclared;
73
76
  const hcRaw = HC_WEIGHTS.explicit * hcExplicit +
74
77
  HC_WEIGHTS.consensus * hcConsensus +
75
78
  HC_WEIGHTS.decision * hcDecision +
76
79
  HC_WEIGHTS.design * hcDesign;
77
80
  const hcComposite = Math.tanh(hcRaw);
78
- return { hcExplicit, hcConsensus, hcDecision, hcDesign, hcRaw, hcComposite };
81
+ return {
82
+ hcExplicit,
83
+ hcConsensus,
84
+ hcDecision,
85
+ hcDesign,
86
+ hcRaw,
87
+ hcComposite,
88
+ ...(designAuthorityConfigured !== undefined ? { designAuthorityConfigured } : {}),
89
+ };
79
90
  }
80
91
  function clamp(value, min, max) {
81
92
  return Math.min(max, Math.max(min, value));
@@ -102,6 +102,20 @@ export interface DesignAuthoritySignal {
102
102
  signalType?: DesignAuthoritySignalType;
103
103
  /** The issue's code area compliance score in [0, 1] (used to modulate weight). */
104
104
  areaComplianceScore?: number;
105
+ /**
106
+ * Diagnostic flag (AISDLC-171) — true when the resolved DSB declared
107
+ * one or more `stewardship.designAuthority.principals` entries,
108
+ * regardless of whether any of them participated in this issue.
109
+ *
110
+ * This is intentionally NOT consumed by `computeDesignAuthorityWeight`
111
+ * — RFC-0008 §14.2 requires principal participation for HC_design to
112
+ * fire at any weight. The flag exists so `pillarBreakdown.shared.
113
+ * hcComposite.designAuthorityConfigured` can surface "design authority
114
+ * is configured but absent from this issue" — distinct from "no
115
+ * design authority structure exists at all" — without violating the
116
+ * RFC's §14.2 weighting constraint.
117
+ */
118
+ principalsDeclared?: boolean;
105
119
  }
106
120
  export interface AdmissionThresholds {
107
121
  minimumScore: number;
@@ -24,7 +24,13 @@ export function mapIssueToPriorityInput(input) {
24
24
  const backlog = input.backlogContext;
25
25
  // ── Backlog status veto ─────────────────────────────────────
26
26
  // Drafts aren't ready to admit; orchestrator should ignore them.
27
- if (backlog?.status === 'Draft') {
27
+ // RFC-0011 Phase 4 (AISDLC-115.5): the same veto applies to
28
+ // `Needs Clarification` — running PPA on an unready issue burns
29
+ // scoring effort that gets invalidated on clarification (RFC §2.3 +
30
+ // §7.2). The PPA admission step short-circuits with soulAlignment=0
31
+ // so the composite scorer treats the issue as not-admissible without
32
+ // calling out to the design / autonomy / trust enrichment layers.
33
+ if (backlog?.status === 'Draft' || backlog?.status === 'Needs Clarification') {
28
34
  return {
29
35
  itemId: `#${input.issueNumber}`,
30
36
  title: input.title,
@@ -39,6 +39,31 @@ export interface DetectRemoteOptions {
39
39
  * Detect the GitHub-style org/repo from the project's git origin remote.
40
40
  * Returns FALLBACK with detected=false when no remote is configured or
41
41
  * when the URL cannot be parsed.
42
+ *
43
+ * The git invocation is hardened against two failure modes (AISDLC-104):
44
+ *
45
+ * 1. **cwd inheritance race under parallel test workers.** Every git
46
+ * command uses `git -C <cwd>` so the working dir is pinned at the
47
+ * git argv level rather than relying solely on `child_process`
48
+ * honouring the `cwd:` spawn option. Both should agree, but `git -C`
49
+ * is a git-internal contract independent of any subprocess cwd
50
+ * inheritance race that can happen when `process.chdir()` is
51
+ * interleaved with subprocess spawn under thread/fork pools.
52
+ *
53
+ * 2. **Host-repo origin bleed via parent-directory walk-up.** If `cwd`
54
+ * contains an invalid `.git` (e.g. an empty directory left by an
55
+ * init test setup) git normally walks UP looking for a real `.git`
56
+ * and can resolve to an ancestor repository — i.e. when run from
57
+ * inside the ai-sdlc-framework checkout the test would silently see
58
+ * the framework's own origin rather than the fallback. We defend by
59
+ * calling `git rev-parse --show-toplevel` first and confirming the
60
+ * reported toplevel matches `cwd` (after symlink resolution). When
61
+ * it doesn't, we treat the directory as not-a-repo and return the
62
+ * fallback rather than reporting the ancestor's remote. This was
63
+ * preferred over `GIT_CEILING_DIRECTORIES` because the ceiling-list
64
+ * semantics only block walking INTO the listed dirs, not up FROM
65
+ * them — empirically `GIT_CEILING_DIRECTORIES=<cwd>` did not stop
66
+ * git from finding a parent repo.
42
67
  */
43
68
  export declare function detectGitRemote(opts?: DetectRemoteOptions): RemoteInfo;
44
69
  /**
@@ -9,6 +9,7 @@
9
9
  * remote is configured (e.g. a brand-new local-only repo).
10
10
  */
11
11
  import { execSync } from 'node:child_process';
12
+ import { realpathSync } from 'node:fs';
12
13
  const FALLBACK = { org: 'your-org', repo: 'your-repo', detected: false };
13
14
  /**
14
15
  * Parse a single remote URL into org/repo. Supports:
@@ -50,13 +51,65 @@ export function parseRemoteUrl(url) {
50
51
  * Detect the GitHub-style org/repo from the project's git origin remote.
51
52
  * Returns FALLBACK with detected=false when no remote is configured or
52
53
  * when the URL cannot be parsed.
54
+ *
55
+ * The git invocation is hardened against two failure modes (AISDLC-104):
56
+ *
57
+ * 1. **cwd inheritance race under parallel test workers.** Every git
58
+ * command uses `git -C <cwd>` so the working dir is pinned at the
59
+ * git argv level rather than relying solely on `child_process`
60
+ * honouring the `cwd:` spawn option. Both should agree, but `git -C`
61
+ * is a git-internal contract independent of any subprocess cwd
62
+ * inheritance race that can happen when `process.chdir()` is
63
+ * interleaved with subprocess spawn under thread/fork pools.
64
+ *
65
+ * 2. **Host-repo origin bleed via parent-directory walk-up.** If `cwd`
66
+ * contains an invalid `.git` (e.g. an empty directory left by an
67
+ * init test setup) git normally walks UP looking for a real `.git`
68
+ * and can resolve to an ancestor repository — i.e. when run from
69
+ * inside the ai-sdlc-framework checkout the test would silently see
70
+ * the framework's own origin rather than the fallback. We defend by
71
+ * calling `git rev-parse --show-toplevel` first and confirming the
72
+ * reported toplevel matches `cwd` (after symlink resolution). When
73
+ * it doesn't, we treat the directory as not-a-repo and return the
74
+ * fallback rather than reporting the ancestor's remote. This was
75
+ * preferred over `GIT_CEILING_DIRECTORIES` because the ceiling-list
76
+ * semantics only block walking INTO the listed dirs, not up FROM
77
+ * them — empirically `GIT_CEILING_DIRECTORIES=<cwd>` did not stop
78
+ * git from finding a parent repo.
53
79
  */
54
80
  export function detectGitRemote(opts = {}) {
55
81
  const cwd = opts.cwd ?? process.cwd();
56
82
  const exec = opts.execImpl ?? defaultExec;
83
+ // Step 1: confirm cwd is a real git repo whose toplevel IS cwd.
84
+ // If `git rev-parse --show-toplevel` errors OR returns an ancestor,
85
+ // treat as not-a-repo and return FALLBACK. This is the host-repo
86
+ // bleed defense: an empty/invalid `.git/` in cwd causes git to walk
87
+ // UP to a parent repo, and `--show-toplevel` then reports the parent
88
+ // — comparing realpaths catches it.
89
+ let toplevel;
90
+ try {
91
+ toplevel = exec(`git -C ${shellQuote(cwd)} rev-parse --show-toplevel`, {
92
+ cwd,
93
+ encoding: 'utf-8',
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ }).trim();
96
+ }
97
+ catch {
98
+ return FALLBACK;
99
+ }
100
+ if (!sameDir(toplevel, cwd)) {
101
+ // git resolved to an ancestor repository — host-repo bleed. The
102
+ // operator is in a directory that isn't itself a real git root, so
103
+ // we deliberately do NOT report the ancestor's origin; emit
104
+ // FALLBACK so init prints the explicit "no git origin remote
105
+ // detected" message and substitutes `your-org`.
106
+ return FALLBACK;
107
+ }
108
+ // Step 2: ask for the origin URL. If unset (no remote configured)
109
+ // or unparseable, fall back.
57
110
  let url;
58
111
  try {
59
- url = exec('git remote get-url origin', {
112
+ url = exec(`git -C ${shellQuote(cwd)} remote get-url origin`, {
60
113
  cwd,
61
114
  encoding: 'utf-8',
62
115
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -68,6 +121,32 @@ export function detectGitRemote(opts = {}) {
68
121
  const parsed = parseRemoteUrl(url);
69
122
  return parsed ?? FALLBACK;
70
123
  }
124
+ /**
125
+ * Compare two filesystem paths after symlink + canonicalization to
126
+ * decide whether they refer to the same directory. macOS aliases /tmp
127
+ * to /private/tmp, so a string compare of `cwd` against the toplevel
128
+ * git reports would otherwise fail spuriously. Falls back to literal
129
+ * compare when realpath isn't available (deleted dir, permission).
130
+ */
131
+ function sameDir(a, b) {
132
+ const norm = (p) => {
133
+ try {
134
+ return realpathSync(p);
135
+ }
136
+ catch {
137
+ return p;
138
+ }
139
+ };
140
+ return norm(a) === norm(b);
141
+ }
142
+ /**
143
+ * Quote a path for safe single-token interpolation into a shell command.
144
+ * Wraps in single quotes and escapes any embedded single quotes by
145
+ * closing the quote, emitting an escaped quote, then reopening.
146
+ */
147
+ function shellQuote(s) {
148
+ return `'${s.replace(/'/g, `'\\''`)}'`;
149
+ }
71
150
  function defaultExec(cmd, opts) {
72
151
  return execSync(cmd, {
73
152
  cwd: opts.cwd,