@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * c4/preflight.mjs — preflight aggregator for `aw c4`.
3
+ *
4
+ * Composes harness, token resolution, REST-API auth verification, gh CLI
5
+ * presence, ECC bridge-status probe, and disk-free reporting into a single
6
+ * PreflightResult shape with per-failure recommendation strings. Pure
7
+ * orchestration over already-tested c4 primitives — no new I/O patterns.
8
+ *
9
+ * The orchestrator (commands/c4.mjs) consumes runPreflight() up front so:
10
+ * (a) `aw c4 --dry-run` can short-circuit with the full report before any
11
+ * harness write, and
12
+ * (b) install-mode runs print recommendations[] alongside the summary
13
+ * line so the user sees actionable guidance for every degraded
14
+ * outcome (no token → set GITHUB_PAT, 401 → refresh PAT, etc.).
15
+ *
16
+ * Every external signal is dependency-injected via opts so this module is
17
+ * exhaustively testable without touching env, network, the real shell, or
18
+ * the real filesystem disk-stat surface.
19
+ *
20
+ * Contract: spec.md::§"c4/preflight.mjs", tasks.md::4.1.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { getGithubToken } from './secrets.mjs';
27
+ import { verifyAuth } from './gitAuth.mjs';
28
+
29
+ /**
30
+ * @typedef {'cursor-cloud' | 'codex-web' | 'claude-web' | 'unknown'} Harness
31
+ * @typedef {'GITHUB_PAT' | 'GITHUB_TOKEN' | null} TokenSource
32
+ * @typedef {'ok' | 'will-install' | 'failed'} BridgeStatus
33
+ *
34
+ * @typedef {object} PreflightResult
35
+ * @property {Harness} harness
36
+ * @property {boolean} hasToken
37
+ * @property {TokenSource} tokenSource
38
+ * @property {boolean} authOk
39
+ * @property {string} [authError] Short failure tag (e.g. 'http-401', 'network').
40
+ * @property {boolean} ghCliPresent
41
+ * @property {BridgeStatus} bridgeStatus
42
+ * @property {number} diskFreeMb
43
+ * @property {string[]} recommendations Actionable guidance strings.
44
+ */
45
+
46
+ /* ─────────────────────────────────────────────────────────────────────────
47
+ * Constants — thresholds and message templates.
48
+ * ───────────────────────────────────────────────────────────────────────── */
49
+
50
+ const DISK_FREE_THRESHOLD_MB = 200;
51
+
52
+ const REC = Object.freeze({
53
+ noToken:
54
+ 'Set GITHUB_PAT in your harness secrets UI before running aw c4 ' +
55
+ '(GITHUB_TOKEN is also accepted as a fallback).',
56
+ authInvalid:
57
+ 'PAT is invalid, expired, or SAML not authorized — refresh the token ' +
58
+ 'and re-authorize the GoHighLevel org if your harness uses SSO.',
59
+ authForbidden:
60
+ 'PAT lacks platform-docs repo access — grant the `repo` scope (or ' +
61
+ 'add the user to GoHighLevel/platform-docs) and retry.',
62
+ authNetwork:
63
+ 'Network connectivity to api.github.com is broken — verify the harness ' +
64
+ 'has outbound HTTPS and DNS reachability before running aw c4.',
65
+ authOther: (status) =>
66
+ `Auth verification returned an unexpected response (${status ?? 'no-status'}); aw c4 will continue but ghl-ai MCP and registry sync may fail.`,
67
+ ghMissing:
68
+ 'gh CLI is not installed — aw c4 will install via apt or fall back to ' +
69
+ 'the insteadOf rewrite alone (degraded but still functional).',
70
+ lowDisk: (mb) =>
71
+ `Insufficient disk: ${mb}MB free (aw c4 needs at least ${DISK_FREE_THRESHOLD_MB}MB ` +
72
+ 'for ECC clone + registry pull).',
73
+ bridgeWillInstall:
74
+ 'ECC registry bridge will be installed during this run (Codex only — ' +
75
+ 'Bug A workaround that symlinks ECC stage skills into ~/.aw/.aw_registry).',
76
+ bridgeFailed:
77
+ 'ECC and/or AW registry not yet provisioned — aw init will create them ' +
78
+ 'on the first c4 run; re-run preflight afterwards.',
79
+ });
80
+
81
+ /* ─────────────────────────────────────────────────────────────────────────
82
+ * Default adapters — overridden in tests.
83
+ * ───────────────────────────────────────────────────────────────────────── */
84
+
85
+ function defaultShell(cmd) {
86
+ const result = spawnSync(cmd, [], {
87
+ shell: true,
88
+ encoding: 'utf8',
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ return {
92
+ ok: result.status === 0,
93
+ stdout: result.stdout ?? '',
94
+ stderr: result.stderr ?? '',
95
+ exitCode: result.status ?? -1,
96
+ };
97
+ }
98
+
99
+ /* ─────────────────────────────────────────────────────────────────────────
100
+ * Helpers — small pure functions per signal.
101
+ * ───────────────────────────────────────────────────────────────────────── */
102
+
103
+ function runShell(shell, cmd) {
104
+ try {
105
+ return shell(cmd) ?? { ok: false, stdout: '', stderr: '', exitCode: -1 };
106
+ } catch (err) {
107
+ return { ok: false, stdout: '', stderr: String(err?.message ?? err), exitCode: -1 };
108
+ }
109
+ }
110
+
111
+ function classifyAuthError(verifyResult) {
112
+ if (verifyResult.ok) return undefined;
113
+ if (typeof verifyResult.status === 'number') {
114
+ if (verifyResult.status === 401) return 'http-401';
115
+ if (verifyResult.status === 403) return 'http-403';
116
+ if (verifyResult.status === 404) return 'http-404';
117
+ return `http-${verifyResult.status}`;
118
+ }
119
+ if (verifyResult.error) return 'network';
120
+ return 'unknown';
121
+ }
122
+
123
+ function probeBridgeStatus(harness, awHome, eccHome) {
124
+ if (harness !== 'codex-web') return 'ok';
125
+
126
+ // Bridge installs ~/.aw/.aw_registry/platform/core/skills/<aw-*>/SKILL.md
127
+ // symlinks pointing at ~/.aw-ecc/skills/<aw-*>/SKILL.md. If aw-plan is
128
+ // already present in the registry path, the bridge is a no-op (ok).
129
+ const bridgedSkillPath = join(
130
+ awHome,
131
+ '.aw_registry/platform/core/skills/aw-plan/SKILL.md',
132
+ );
133
+ if (existsSync(bridgedSkillPath)) return 'ok';
134
+
135
+ // Bridge can install when both ECC home and the registry skeleton exist.
136
+ // (The aw-plan skill source must exist somewhere — the bridge follows
137
+ // applyEccRegistryBridge's contract; preflight only certifies feasibility.)
138
+ if (existsSync(eccHome) && existsSync(awHome)) return 'will-install';
139
+
140
+ return 'failed';
141
+ }
142
+
143
+ function buildRecommendations({
144
+ hasToken,
145
+ authOk,
146
+ authError,
147
+ authStatus,
148
+ ghCliPresent,
149
+ bridgeStatus,
150
+ diskFreeMb,
151
+ }) {
152
+ const out = [];
153
+ if (!hasToken) out.push(REC.noToken);
154
+ if (hasToken && !authOk) {
155
+ if (authError === 'http-401') out.push(REC.authInvalid);
156
+ else if (authError === 'http-403' || authError === 'http-404') out.push(REC.authForbidden);
157
+ else if (authError === 'network') out.push(REC.authNetwork);
158
+ else out.push(REC.authOther(authStatus ?? authError));
159
+ }
160
+ if (!ghCliPresent) out.push(REC.ghMissing);
161
+ if (diskFreeMb < DISK_FREE_THRESHOLD_MB) out.push(REC.lowDisk(diskFreeMb));
162
+ if (bridgeStatus === 'will-install') out.push(REC.bridgeWillInstall);
163
+ else if (bridgeStatus === 'failed') out.push(REC.bridgeFailed);
164
+ return out;
165
+ }
166
+
167
+ /* ─────────────────────────────────────────────────────────────────────────
168
+ * runPreflight — public API.
169
+ * ───────────────────────────────────────────────────────────────────────── */
170
+
171
+ /**
172
+ * Aggregate every preflight signal the orchestrator needs before deciding
173
+ * whether to skip, dry-run, or proceed with a full c4 install.
174
+ *
175
+ * @param {object} opts
176
+ * @param {Harness} opts.harness Required — pre-detected by detect.mjs.
177
+ * @param {string} opts.cwd Required — repo working directory.
178
+ * @param {NodeJS.ProcessEnv} [opts.env]
179
+ * @param {(cmd: string) => { ok: boolean, stdout: string, stderr: string, exitCode: number }} [opts.shell]
180
+ * @param {typeof globalThis.fetch} [opts.fetchImpl]
181
+ * @param {string} [opts.home] Defaults to env.HOME.
182
+ * @param {string} [opts.awHome] Defaults to <home>/.aw.
183
+ * @param {string} [opts.eccHome] Defaults to <home>/.aw-ecc.
184
+ * @param {number} [opts.diskFreeMb] Inject for tests; production defaults to a real statfs probe.
185
+ * @returns {Promise<PreflightResult>}
186
+ */
187
+ export async function runPreflight(opts = {}) {
188
+ if (!opts.harness) throw new Error('runPreflight: harness is required');
189
+ if (!opts.cwd) throw new Error('runPreflight: cwd is required');
190
+
191
+ const {
192
+ harness,
193
+ env = process.env,
194
+ shell = defaultShell,
195
+ fetchImpl = globalThis.fetch,
196
+ diskFreeMb,
197
+ } = opts;
198
+ // home: prefer explicit opts.home, then env.HOME (already DI'd), only
199
+ // last-resort to '~'. Removed the redundant process.env.HOME fallback —
200
+ // when env is the injected `{}`, opts.home should have been passed instead.
201
+ const home = opts.home ?? env.HOME ?? '~';
202
+ const awHome = opts.awHome ?? join(home, '.aw');
203
+ const eccHome = opts.eccHome ?? join(home, '.aw-ecc');
204
+
205
+ // 1. Token resolution.
206
+ const { token, source: tokenSource } = getGithubToken(env);
207
+ const hasToken = token != null;
208
+
209
+ // 2. Auth verification — only when we have a token to verify.
210
+ let authOk = false;
211
+ let authError;
212
+ let authStatus;
213
+ if (hasToken) {
214
+ const verify = await verifyAuth('rest-api', token, { shell, fetchImpl });
215
+ authOk = verify.ok;
216
+ authStatus = verify.status;
217
+ if (!authOk) authError = classifyAuthError(verify);
218
+ }
219
+
220
+ // 3. gh CLI presence.
221
+ const ghCliPresent = runShell(shell, 'command -v gh').ok;
222
+
223
+ // 4. Bridge status (codex-web only; other harnesses always 'ok').
224
+ const bridgeStatus = probeBridgeStatus(harness, awHome, eccHome);
225
+
226
+ // 5. Disk free MB. For tests, callers inject the value directly; in
227
+ // production the orchestrator passes a measured number. Defaulting to
228
+ // Infinity here keeps the preflight from spuriously flagging low disk
229
+ // when the orchestrator forgets to measure.
230
+ const diskFreeMbResolved = typeof diskFreeMb === 'number' ? diskFreeMb : Number.POSITIVE_INFINITY;
231
+
232
+ // 6. Compose recommendations.
233
+ const recommendations = buildRecommendations({
234
+ hasToken,
235
+ authOk,
236
+ authError,
237
+ authStatus,
238
+ ghCliPresent,
239
+ bridgeStatus,
240
+ diskFreeMb: diskFreeMbResolved,
241
+ });
242
+
243
+ return {
244
+ harness,
245
+ hasToken,
246
+ tokenSource,
247
+ authOk,
248
+ ...(authError ? { authError } : {}),
249
+ ghCliPresent,
250
+ bridgeStatus,
251
+ diskFreeMb: diskFreeMbResolved,
252
+ recommendations,
253
+ };
254
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * c4/repoLocalClaudeSettings.mjs — per-repo Claude settings.local.json with
3
+ * Skill permission scoped to the current repo.
4
+ *
5
+ * Why per-repo: home-level settings.json governs the entire user. A repo
6
+ * may want Skill dispatch enabled without inheriting that permission across
7
+ * every other workspace. Pilot script writes this file so the repo-scoped
8
+ * session can dispatch the Skill tool independently.
9
+ *
10
+ * Never overwrites: if the file exists (user-authored or AW-authored from a
11
+ * prior run), we return { alreadyPresent: true, written: false } and leave
12
+ * it untouched. Idempotency is achieved by NOT being a "merge" — it's a
13
+ * "create if absent" semantic.
14
+ *
15
+ * Contract: spec.md::§"c4/repoLocalClaudeSettings.mjs", tasks.md::3.4c.
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ writeFileSync,
21
+ chmodSync,
22
+ mkdirSync,
23
+ } from 'node:fs';
24
+ import { dirname, join } from 'node:path';
25
+
26
+ const FILE_MODE = 0o600;
27
+
28
+ const CANONICAL_BODY = {
29
+ $schema: 'https://json.schemastore.org/claude-code-settings.json',
30
+ permissions: { allow: ['Skill'] },
31
+ };
32
+
33
+ /**
34
+ * Write `<cwd>/.claude/settings.local.json` if absent.
35
+ *
36
+ * @param {string} cwd Repo root.
37
+ * @returns {{ written: boolean, alreadyPresent: boolean }}
38
+ */
39
+ export function ensureRepoLocalClaudeSettings(cwd) {
40
+ if (!cwd || typeof cwd !== 'string') {
41
+ throw new Error('ensureRepoLocalClaudeSettings: cwd is required');
42
+ }
43
+
44
+ const filePath = join(cwd, '.claude/settings.local.json');
45
+ if (existsSync(filePath)) {
46
+ return { written: false, alreadyPresent: true };
47
+ }
48
+
49
+ mkdirSync(dirname(filePath), { recursive: true });
50
+ writeFileSync(filePath, JSON.stringify(CANONICAL_BODY, null, 2) + '\n', { mode: FILE_MODE });
51
+ try { chmodSync(filePath, FILE_MODE); } catch { /* best-effort */ }
52
+
53
+ return { written: true, alreadyPresent: false };
54
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * c4/repoLocalIgnore.mjs — append entries to `<cwd>/.git/info/exclude` so
3
+ * AW C4 cloud bootstrap pollution doesn't show up in `git status`.
4
+ *
5
+ * Contract: spec.md::§"c4/repoLocalIgnore.mjs", tasks.md::3.6.
6
+ *
7
+ * Notes:
8
+ * - Repo-local only. Never touches `.gitignore` (committed file).
9
+ * - No-op if `<cwd>/.git` is absent.
10
+ * - Idempotent: lines are added only when `grep -qxF`-style identity match
11
+ * is missing (whole-line, fixed-string).
12
+ * - MD entries (CLAUDE.md / AGENTS.md) are conditional: we only add them if
13
+ * the file is NOT already tracked in git. This preserves user repos that
14
+ * intentionally commit their own root-level instruction files.
15
+ */
16
+
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { join, dirname } from 'node:path';
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ const ALWAYS_ENTRIES = ['.aw_registry', '.aw/', '.aw-ecc/'];
27
+
28
+ const PER_HARNESS_ENTRIES = {
29
+ 'claude-web': ['.claude/hook-logs/', '.claude/settings.local.json'],
30
+ 'cursor-cloud': [
31
+ '.cursor/hook-logs/',
32
+ '.cursor/settings.local.json',
33
+ '.cursor/mcp.json',
34
+ ],
35
+ 'codex-web': ['.codex/hook-logs/', '.codex/config.local.toml'],
36
+ };
37
+
38
+ const CONDITIONAL_MD_BY_HARNESS = {
39
+ 'claude-web': ['CLAUDE.md'],
40
+ 'cursor-cloud': ['AGENTS.md'],
41
+ 'codex-web': ['AGENTS.md'],
42
+ };
43
+
44
+ /**
45
+ * Default shell adapter — runs `git -C <cwd> ls-files --error-unmatch <path>`
46
+ * via `spawnSync` and reports a normalized result. Production callers can
47
+ * inject their own (e.g. tests) via `opts.shell`.
48
+ *
49
+ * @param {string} cmd
50
+ * @returns {{ ok: boolean, exitCode: number }}
51
+ */
52
+ function defaultShell(cmd) {
53
+ const result = spawnSync('bash', ['-lc', cmd], { encoding: 'utf8' });
54
+ return {
55
+ ok: result.status === 0,
56
+ exitCode: typeof result.status === 'number' ? result.status : 1,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Whether a repo-relative path is currently tracked in git (committed or
62
+ * staged). Mirrors the pilot's `git ls-files --error-unmatch <file>` check.
63
+ *
64
+ * @param {string} cwd
65
+ * @param {string} relativePath
66
+ * @param {(cmd: string) => { ok: boolean, exitCode: number }} shell
67
+ * @returns {boolean}
68
+ */
69
+ function isTrackedInGit(cwd, relativePath, shell) {
70
+ const cmd = `git -C ${shellQuote(cwd)} ls-files --error-unmatch ${shellQuote(relativePath)}`;
71
+ const { ok } = shell(cmd);
72
+ return ok === true;
73
+ }
74
+
75
+ function shellQuote(s) {
76
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
77
+ }
78
+
79
+ function readExclude(filePath) {
80
+ if (!existsSync(filePath)) return '';
81
+ return readFileSync(filePath, 'utf8');
82
+ }
83
+
84
+ /**
85
+ * Whether `entry` already appears as a whole line in the file body.
86
+ * Mirrors `grep -qxF` semantics (fixed string, full-line match).
87
+ */
88
+ function hasExactLine(body, entry) {
89
+ if (body === '') return false;
90
+ const lines = body.split('\n');
91
+ return lines.includes(entry);
92
+ }
93
+
94
+ /**
95
+ * Append the given entries to the file body and return the new body.
96
+ * Ensures a single trailing newline. Skips entries already present.
97
+ */
98
+ function appendMissing(body, entries) {
99
+ let next = body;
100
+ if (next.length > 0 && !next.endsWith('\n')) next += '\n';
101
+ for (const entry of entries) {
102
+ if (hasExactLine(next, entry)) continue;
103
+ next += `${entry}\n`;
104
+ }
105
+ return next;
106
+ }
107
+
108
+ /**
109
+ * Append AW C4 entries to `<cwd>/.git/info/exclude`.
110
+ *
111
+ * @param {object} opts
112
+ * @param {string} opts.cwd Repo root.
113
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'} opts.harness
114
+ * @param {(cmd: string) => { ok: boolean, exitCode: number }} [opts.shell]
115
+ * Optional shell adapter (defaults to spawnSync-backed). Tests inject this
116
+ * to simulate `git ls-files --error-unmatch` outcomes.
117
+ * @returns {{ added: string[] }}
118
+ */
119
+ export function ensureRepoLocalIgnore(opts) {
120
+ if (!opts || typeof opts !== 'object') {
121
+ throw new Error('ensureRepoLocalIgnore: opts object is required');
122
+ }
123
+ const { cwd, harness } = opts;
124
+ const shell = typeof opts.shell === 'function' ? opts.shell : defaultShell;
125
+
126
+ if (!cwd || typeof cwd !== 'string') {
127
+ throw new Error('ensureRepoLocalIgnore: opts.cwd is required');
128
+ }
129
+ if (!PER_HARNESS_ENTRIES[harness]) {
130
+ throw new Error(`ensureRepoLocalIgnore: unsupported harness "${harness}"`);
131
+ }
132
+
133
+ // No-op if the repo isn't a git repo at all.
134
+ if (!existsSync(join(cwd, '.git'))) {
135
+ return { added: [] };
136
+ }
137
+
138
+ const excludePath = join(cwd, '.git/info/exclude');
139
+ const currentBody = readExclude(excludePath);
140
+
141
+ // Compose desired entries.
142
+ const desired = [...ALWAYS_ENTRIES, ...PER_HARNESS_ENTRIES[harness]];
143
+ for (const md of CONDITIONAL_MD_BY_HARNESS[harness]) {
144
+ if (!isTrackedInGit(cwd, md, shell)) desired.push(md);
145
+ }
146
+
147
+ // Compute which entries are actually new (so we can report `added`).
148
+ const added = desired.filter((e) => !hasExactLine(currentBody, e));
149
+ if (added.length === 0) return { added: [] };
150
+
151
+ // Ensure parent dir, then atomically rewrite with new content.
152
+ mkdirSync(dirname(excludePath), { recursive: true });
153
+ const nextBody = appendMissing(currentBody, desired);
154
+ writeFileSync(excludePath, nextBody);
155
+
156
+ return { added };
157
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * c4/repoRootInstructions.mjs — copy ECC's persistent context file
3
+ * (CLAUDE.md / AGENTS.md) into the repo root, with codex-source precedence
4
+ * (L2) and user-authored file preservation.
5
+ *
6
+ * Per harness:
7
+ * - claude-web → ~/.aw-ecc/CLAUDE.md → <cwd>/CLAUDE.md
8
+ * - cursor-cloud → ~/.aw-ecc/AGENTS.md → <cwd>/AGENTS.md
9
+ * - codex-web → ~/.codex/AGENTS.md (preferred) OR ~/.aw-ecc/AGENTS.md (fallback)
10
+ *
11
+ * (L2) Codex source precedence: ~/.codex/AGENTS.md is what `aw init`'s
12
+ * codex provider personalized for this user (rules-bridge + harness
13
+ * tweaks). Always prefer it over the raw ECC fallback when present, even
14
+ * if it lacks the managed-block marker — the user customization wins.
15
+ *
16
+ * Behavior matrix:
17
+ * - target absent + source present → copy verbatim, source: 'aw-ecc-home' or 'codex-home'.
18
+ * - target absent + source absent → no-op, source: 'none'.
19
+ * - target user-authored + source present → preserve user content, replace
20
+ * managed block in place,
21
+ * source: 'preserved-user-file'.
22
+ * - target managed-only (rare) → managed block updated in place.
23
+ *
24
+ * Managed-block markers (this module):
25
+ * <!-- aw-managed:start repo-root-instructions -->
26
+ * ...content...
27
+ * <!-- aw-managed:end repo-root-instructions -->
28
+ *
29
+ * Contract: spec.md::§"c4/repoRootInstructions.mjs", tasks.md::3.5.
30
+ */
31
+
32
+ import {
33
+ existsSync,
34
+ readFileSync,
35
+ writeFileSync,
36
+ } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import { homedir } from 'node:os';
39
+
40
+ const MANAGED_START = '<!-- aw-managed:start repo-root-instructions -->';
41
+ const MANAGED_END = '<!-- aw-managed:end repo-root-instructions -->';
42
+
43
+ const HARNESS_TARGETS = {
44
+ 'claude-web': 'CLAUDE.md',
45
+ 'cursor-cloud': 'AGENTS.md',
46
+ 'codex-web': 'AGENTS.md',
47
+ };
48
+
49
+ /**
50
+ * Replace any existing managed block with `bodyToInject`. Appends a fresh
51
+ * managed block if none exists. Preserves user content outside the block.
52
+ */
53
+ function upsertManagedBlock(existing, bodyToInject) {
54
+ const startIdx = existing.indexOf(MANAGED_START);
55
+ const endIdx =
56
+ startIdx === -1
57
+ ? -1
58
+ : existing.indexOf(MANAGED_END, startIdx + MANAGED_START.length);
59
+
60
+ const block = `${MANAGED_START}\n${bodyToInject.trimEnd()}\n${MANAGED_END}\n`;
61
+
62
+ if (startIdx !== -1 && endIdx !== -1) {
63
+ // Replace existing block in place.
64
+ return (
65
+ existing.slice(0, startIdx) +
66
+ block +
67
+ existing.slice(endIdx + MANAGED_END.length).replace(/^\n+/, '')
68
+ );
69
+ }
70
+
71
+ // Append a managed block (with a separating blank line).
72
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
73
+ return existing + sep + block;
74
+ }
75
+
76
+ /**
77
+ * Pick the source file for a harness, applying codex-source precedence.
78
+ *
79
+ * Codex-web precedence (per execution.md::Phase 0 item 8 L2):
80
+ * 1. <cwd>/.codex/AGENTS.md — repo-author intent (highest priority)
81
+ * 2. ~/.codex/AGENTS.md — aw-init's harness-personalized output
82
+ * 3. ~/.aw-ecc/AGENTS.md — raw ECC fallback
83
+ *
84
+ * Both repo-local and home-level codex variants resolve to type
85
+ * `'codex-home'` because the orchestrator does not need to distinguish
86
+ * which one won — only that we used the codex-personalized form rather
87
+ * than the raw ECC fallback.
88
+ */
89
+ function resolveSource({ harness, cwd, eccHome, home }) {
90
+ if (harness === 'claude-web') {
91
+ const eccPath = join(eccHome, 'CLAUDE.md');
92
+ if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
93
+ return { type: 'none', path: null };
94
+ }
95
+ if (harness === 'cursor-cloud') {
96
+ const eccPath = join(eccHome, 'AGENTS.md');
97
+ if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
98
+ return { type: 'none', path: null };
99
+ }
100
+ if (harness === 'codex-web') {
101
+ const cwdCodexPath = join(cwd, '.codex/AGENTS.md');
102
+ if (existsSync(cwdCodexPath)) return { type: 'codex-home', path: cwdCodexPath };
103
+ const homeCodexPath = join(home, '.codex/AGENTS.md');
104
+ if (existsSync(homeCodexPath)) return { type: 'codex-home', path: homeCodexPath };
105
+ const eccPath = join(eccHome, 'AGENTS.md');
106
+ if (existsSync(eccPath)) return { type: 'aw-ecc-home', path: eccPath };
107
+ return { type: 'none', path: null };
108
+ }
109
+ throw new Error(`copyRepoRootInstructions: unsupported harness "${harness}"`);
110
+ }
111
+
112
+ /**
113
+ * Copy a repo-root persistent-context file into the repo, preserving any
114
+ * user-authored content via managed-block semantics.
115
+ *
116
+ * @param {'claude-web'|'cursor-cloud'|'codex-web'} harness
117
+ * @param {string} cwd Repo root.
118
+ * @param {string} eccHome ~/.aw-ecc.
119
+ * @param {{ home?: string }} [opts] User home (default os.homedir()).
120
+ * @returns {{ copied: string[], sources: Record<string, 'codex-home'|'aw-ecc-home'|'preserved-user-file'|'none'> }}
121
+ */
122
+ export function copyRepoRootInstructions(harness, cwd, eccHome, opts = {}) {
123
+ const fileName = HARNESS_TARGETS[harness];
124
+ if (!fileName) {
125
+ throw new Error(`copyRepoRootInstructions: unsupported harness "${harness}"`);
126
+ }
127
+ if (!cwd || typeof cwd !== 'string') {
128
+ throw new Error('copyRepoRootInstructions: cwd is required');
129
+ }
130
+ if (!eccHome || typeof eccHome !== 'string') {
131
+ throw new Error('copyRepoRootInstructions: eccHome is required');
132
+ }
133
+
134
+ const home = opts.home ?? homedir();
135
+ const targetPath = join(cwd, fileName);
136
+ const sources = {};
137
+ const copied = [];
138
+
139
+ const userFileExists = existsSync(targetPath);
140
+ const source = resolveSource({ harness, cwd, eccHome, home });
141
+
142
+ if (source.type === 'none') {
143
+ sources[fileName] = userFileExists ? 'preserved-user-file' : 'none';
144
+ return { copied, sources };
145
+ }
146
+
147
+ const sourceBody = readFileSync(source.path, 'utf8');
148
+
149
+ if (!userFileExists) {
150
+ // Fresh copy — write source verbatim.
151
+ writeFileSync(targetPath, sourceBody);
152
+ sources[fileName] = source.type;
153
+ copied.push(fileName);
154
+ return { copied, sources };
155
+ }
156
+
157
+ // User file exists — never replace it; only update managed block.
158
+ const userContent = readFileSync(targetPath, 'utf8');
159
+ const next = upsertManagedBlock(userContent, sourceBody);
160
+ if (next !== userContent) {
161
+ writeFileSync(targetPath, next);
162
+ copied.push(fileName);
163
+ }
164
+ sources[fileName] = 'preserved-user-file';
165
+ return { copied, sources };
166
+ }
package/c4/secrets.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * c4/secrets.mjs — GitHub token resolution and masking helpers.
3
+ *
4
+ * The same token is reused for two purposes (user-confirmed):
5
+ * 1. Git authentication (insteadOf rewrite + credential.helper store + gh login)
6
+ * 2. ghl-ai MCP Bearer auth (Authorization header on JSON-RPC requests)
7
+ *
8
+ * Resolution rules:
9
+ * - Prefer GITHUB_PAT (canonical name in pilot bootstraps).
10
+ * - Fall back to GITHUB_TOKEN (some harness UIs only expose this name).
11
+ * - Treat empty/whitespace-only values as absent (defensive — env UIs
12
+ * sometimes echo trailing newlines or leave the field literally blank).
13
+ * - Both empty ⇒ { token: null, source: null }; orchestrator emits a
14
+ * one-line skip and lets the agent continue (exit 0).
15
+ *
16
+ * @typedef {'GITHUB_PAT' | 'GITHUB_TOKEN' | null} TokenSource
17
+ */
18
+
19
+ const ELLIPSIS = '\u2026';
20
+ const MASK_PREFIX_LEN = 8;
21
+
22
+ function readEnvVar(env, name) {
23
+ const raw = env?.[name];
24
+ if (typeof raw !== 'string') return '';
25
+ return raw.trim();
26
+ }
27
+
28
+ /**
29
+ * @param {NodeJS.ProcessEnv} [env]
30
+ * @returns {{ token: string | null, source: TokenSource }}
31
+ */
32
+ export function getGithubToken(env = process.env) {
33
+ const pat = readEnvVar(env, 'GITHUB_PAT');
34
+ if (pat) return { token: pat, source: 'GITHUB_PAT' };
35
+
36
+ const token = readEnvVar(env, 'GITHUB_TOKEN');
37
+ if (token) return { token, source: 'GITHUB_TOKEN' };
38
+
39
+ return { token: null, source: null };
40
+ }
41
+
42
+ /**
43
+ * Mask a token for log/diagnostic display. Reveals the first 8 chars (enough
44
+ * to identify the prefix family — `ghp_`, `ghs_`, `gho_`, etc.) followed by
45
+ * a single Unicode ellipsis. Never returns the full token, even for short
46
+ * inputs (we still append `…` so the value is visibly redacted).
47
+ *
48
+ * @param {unknown} token
49
+ * @returns {string}
50
+ */
51
+ export function maskToken(token) {
52
+ if (typeof token !== 'string' || token.length === 0) return '';
53
+ if (token.length <= MASK_PREFIX_LEN) return token + ELLIPSIS;
54
+ return token.slice(0, MASK_PREFIX_LEN) + ELLIPSIS;
55
+ }