@phnx-labs/agents-cli 1.22.60 → 1.22.62

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 (98) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +6 -0
  3. package/dist/cli/command-registry.d.ts +1 -0
  4. package/dist/cli/command-registry.js +2 -0
  5. package/dist/commands/browser.js +9 -4
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +35 -1
  8. package/dist/commands/feed.js +3 -1
  9. package/dist/commands/harness-hooks.d.ts +55 -0
  10. package/dist/commands/harness-hooks.js +104 -0
  11. package/dist/commands/harness-wizard.d.ts +33 -14
  12. package/dist/commands/harness-wizard.js +53 -23
  13. package/dist/commands/harness.d.ts +14 -0
  14. package/dist/commands/harness.js +86 -5
  15. package/dist/commands/monitors.js +3 -3
  16. package/dist/commands/reminders.d.ts +9 -0
  17. package/dist/commands/reminders.js +49 -0
  18. package/dist/commands/run-account-picker.d.ts +14 -0
  19. package/dist/commands/run-account-picker.js +13 -0
  20. package/dist/commands/send.js +4 -1
  21. package/dist/commands/teams.d.ts +1 -1
  22. package/dist/commands/teams.js +9 -3
  23. package/dist/index.js +9 -0
  24. package/dist/lib/accounting/rotate.d.ts +63 -0
  25. package/dist/lib/accounting/rotate.js +229 -13
  26. package/dist/lib/browser/drivers/local.d.ts +11 -0
  27. package/dist/lib/browser/drivers/local.js +26 -0
  28. package/dist/lib/browser/profiles.js +8 -6
  29. package/dist/lib/browser/service.d.ts +12 -8
  30. package/dist/lib/browser/service.js +38 -10
  31. package/dist/lib/channels/owner-forward.d.ts +14 -8
  32. package/dist/lib/channels/owner-forward.js +9 -5
  33. package/dist/lib/channels/registry.d.ts +2 -0
  34. package/dist/lib/channels/send.js +13 -1
  35. package/dist/lib/claude-statusline.d.ts +14 -1
  36. package/dist/lib/claude-statusline.js +27 -2
  37. package/dist/lib/daemon/runner.js +17 -2
  38. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  39. package/dist/lib/devices/doctor-findings.js +22 -4
  40. package/dist/lib/doctor-diff.d.ts +21 -5
  41. package/dist/lib/doctor-diff.js +242 -76
  42. package/dist/lib/feed/events.d.ts +1 -1
  43. package/dist/lib/feed/events.js +25 -16
  44. package/dist/lib/feed-broadcast.js +5 -12
  45. package/dist/lib/github/gh-overload.d.ts +58 -0
  46. package/dist/lib/github/gh-overload.js +246 -0
  47. package/dist/lib/github/rest.d.ts +64 -0
  48. package/dist/lib/github/rest.js +111 -0
  49. package/dist/lib/harness-connection-test.d.ts +57 -0
  50. package/dist/lib/harness-connection-test.js +80 -0
  51. package/dist/lib/heal.js +8 -3
  52. package/dist/lib/humans.d.ts +9 -0
  53. package/dist/lib/humans.js +29 -8
  54. package/dist/lib/installations/shims.d.ts +22 -0
  55. package/dist/lib/installations/shims.js +104 -0
  56. package/dist/lib/linear-project-counts.js +8 -0
  57. package/dist/lib/linear-rate-limit.d.ts +26 -0
  58. package/dist/lib/linear-rate-limit.js +163 -0
  59. package/dist/lib/mcp.d.ts +9 -0
  60. package/dist/lib/mcp.js +37 -1
  61. package/dist/lib/notify.d.ts +3 -0
  62. package/dist/lib/notify.js +63 -21
  63. package/dist/lib/open-url.js +5 -3
  64. package/dist/lib/permissions.d.ts +28 -0
  65. package/dist/lib/permissions.js +156 -1
  66. package/dist/lib/refresh.js +9 -1
  67. package/dist/lib/reminders.d.ts +29 -0
  68. package/dist/lib/reminders.js +88 -0
  69. package/dist/lib/resource-content-diff.d.ts +33 -0
  70. package/dist/lib/resource-content-diff.js +103 -0
  71. package/dist/lib/rules/compile.d.ts +7 -0
  72. package/dist/lib/rules/compile.js +7 -1
  73. package/dist/lib/session/active.d.ts +41 -4
  74. package/dist/lib/session/active.js +58 -7
  75. package/dist/lib/session/host-link.d.ts +22 -0
  76. package/dist/lib/session/host-link.js +40 -4
  77. package/dist/lib/session/trajectory.d.ts +42 -0
  78. package/dist/lib/session/trajectory.js +46 -27
  79. package/dist/lib/ssh-exec.d.ts +30 -0
  80. package/dist/lib/ssh-exec.js +37 -5
  81. package/dist/lib/startup/command-registry.js +1 -1
  82. package/dist/lib/subagents-registry.d.ts +18 -0
  83. package/dist/lib/subagents-registry.js +79 -0
  84. package/dist/lib/teams/agents.d.ts +12 -0
  85. package/dist/lib/teams/agents.js +51 -0
  86. package/dist/lib/traces/schema2-build.d.ts +85 -0
  87. package/dist/lib/traces/schema2-build.js +637 -0
  88. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  89. package/dist/lib/traces/schema2-danger.js +185 -0
  90. package/dist/lib/traces/schema2.d.ts +149 -0
  91. package/dist/lib/traces/schema2.js +20 -0
  92. package/dist/lib/traces/sync.d.ts +93 -0
  93. package/dist/lib/traces/sync.js +75 -22
  94. package/dist/lib/traces/worker-template.js +5 -0
  95. package/dist/lib/uninstall.js +10 -1
  96. package/dist/lib/workflows.d.ts +11 -0
  97. package/dist/lib/workflows.js +67 -8
  98. package/package.json +1 -1
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The delegate behind the `gh` PATH shim (`agents __gh --real-gh <path> -- <argv>`).
3
+ *
4
+ * Agents are statically trained to type `gh pr checks …`; the shim intercepts that
5
+ * habit and routes it here so the rate-limit-prone read runs over REST instead of
6
+ * GraphQL — no new command for an agent to remember. This is the Tier-1 scope:
7
+ * only `gh pr checks` is handled; every other invocation execs the real gh
8
+ * byte-for-byte (the shim already passes non-`pr checks` verbs straight to real gh,
9
+ * and this file passes through defensively for anything it cannot cleanly serve).
10
+ *
11
+ * Two switch modes (see the PHNX-3501 plan):
12
+ * - `--watch` → EAGER REST: own the poll loop, re-anchored to the live head SHA
13
+ * each tick, so a superseded run's red can never be reported (PHNX-3042).
14
+ * - one-shot → LAZY: run real gh first; only translate to REST when it fails with
15
+ * the exact GraphQL rate-limit signal. An over-budget GraphQL call is rejected
16
+ * at 0 points, so this is nearly free and never reproduces gh's happy path.
17
+ *
18
+ * Fail-open is the rule: anything not cleanly resolvable (no PR number/URL, an
19
+ * unmappable request) execs real gh rather than returning wrong data.
20
+ */
21
+ import { execFile, spawn } from 'child_process';
22
+ import { promisify } from 'util';
23
+ import { isCiGreen } from './pr-verdict.js';
24
+ import { isRateLimitError, pendingCheckSuites, prHead, rollupForSha } from './rest.js';
25
+ const execFileAsync = promisify(execFile);
26
+ /** Split `--real-gh <path> -- <gh argv>`; defaults realGh to bare `gh`. */
27
+ export function parseDelegateArgs(argv) {
28
+ let realGh = 'gh';
29
+ const rest = [];
30
+ for (let i = 0; i < argv.length; i++) {
31
+ if (argv[i] === '--real-gh') {
32
+ realGh = argv[++i] ?? 'gh';
33
+ }
34
+ else if (argv[i] === '--') {
35
+ rest.push(...argv.slice(i + 1));
36
+ break;
37
+ }
38
+ else {
39
+ rest.push(argv[i]);
40
+ }
41
+ }
42
+ return { realGh, ghArgs: rest };
43
+ }
44
+ const PR_URL = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/;
45
+ /** owner/repo from a git remote URL, or null. */
46
+ export function repoFromRemote(remoteUrl) {
47
+ const m = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
48
+ return m ? `${m[1]}/${m[2]}` : null;
49
+ }
50
+ /**
51
+ * Resolve `{repo, number}` from the `gh pr checks` argv + cwd, over git/REST only.
52
+ * Returns null (→ caller passes through to real gh) when it can't be resolved
53
+ * cleanly, e.g. no number and no open PR for the current branch.
54
+ */
55
+ export async function resolveTarget(ghArgs, cwd, realGh) {
56
+ // `pr checks <n|url>` — the arg after "checks", if any.
57
+ const idx = ghArgs.indexOf('checks');
58
+ const arg = idx >= 0 ? ghArgs.slice(idx + 1).find((a) => !a.startsWith('-')) : undefined;
59
+ if (arg) {
60
+ const url = arg.match(PR_URL);
61
+ if (url)
62
+ return { repo: `${url[1]}/${url[2]}`, number: Number(url[3]) };
63
+ if (/^\d+$/.test(arg)) {
64
+ const repo = await repoFromCwd(cwd, ghArgs);
65
+ if (repo)
66
+ return { repo, number: Number(arg) };
67
+ }
68
+ return null;
69
+ }
70
+ // No number: resolve the open PR for the current branch over REST.
71
+ const repo = await repoFromCwd(cwd, ghArgs);
72
+ if (!repo)
73
+ return null;
74
+ try {
75
+ const branch = (await execFileAsync('git', ['-C', cwd, 'symbolic-ref', '--short', 'HEAD']))
76
+ .stdout.trim();
77
+ if (!branch)
78
+ return null;
79
+ const owner = repo.split('/')[0];
80
+ const out = await execFileAsync(realGh, [
81
+ 'api', `repos/${repo}/pulls`, '--method', 'GET',
82
+ '-f', `head=${owner}:${branch}`, '-f', 'state=open',
83
+ '--jq', '.[0].number // empty',
84
+ ], { env: ghChildEnv() });
85
+ const n = out.stdout.trim();
86
+ return n ? { repo, number: Number(n) } : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ /** `--repo owner/name` on the argv wins; else derive from cwd's origin remote. */
93
+ async function repoFromCwd(cwd, ghArgs) {
94
+ const ri = ghArgs.indexOf('--repo');
95
+ if (ri >= 0 && ghArgs[ri + 1])
96
+ return ghArgs[ri + 1];
97
+ try {
98
+ const out = await execFileAsync('git', ['-C', cwd, 'remote', 'get-url', 'origin']);
99
+ return repoFromRemote(out.stdout);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /** Env for any real-gh child: mark the shim sentinel + strip color (FORCE_COLOR breaks JSON). */
106
+ function ghChildEnv() {
107
+ const env = { ...process.env };
108
+ env.AGENTS_GH_SHIM = '1';
109
+ env.GH_NO_COLOR = '1';
110
+ env.GH_PAGER = 'cat';
111
+ env.NO_COLOR = '1';
112
+ delete env.FORCE_COLOR;
113
+ delete env.CLICOLOR_FORCE;
114
+ return env;
115
+ }
116
+ /** A REST GhExec bound to the real gh + sentinel env, so it never re-enters the shim. */
117
+ function restExec(realGh) {
118
+ return async (args) => {
119
+ const { stdout } = await execFileAsync(realGh, args, {
120
+ env: ghChildEnv(),
121
+ maxBuffer: 16 * 1024 * 1024,
122
+ });
123
+ return String(stdout ?? '');
124
+ };
125
+ }
126
+ /** Passthrough: exec real gh inheriting stdio, resolve its exit code. */
127
+ function passthrough(realGh, ghArgs) {
128
+ return new Promise((resolve) => {
129
+ const child = spawn(realGh, ghArgs, { stdio: 'inherit', env: ghChildEnv() });
130
+ child.on('close', (code) => resolve(code ?? 0));
131
+ child.on('error', () => resolve(127));
132
+ });
133
+ }
134
+ /** Render one rollup as `gh pr checks`-ish lines (or JSON when asked). */
135
+ export function renderRollup(input, json) {
136
+ // Sort by name so identical states render identically across polls — otherwise
137
+ // non-deterministic REST/Map order defeats the watch's change-detection dedup.
138
+ const rollup = [...input].sort((a, b) => a.name.localeCompare(b.name));
139
+ if (json) {
140
+ return JSON.stringify(rollup.map((c) => ({
141
+ name: c.name,
142
+ state: (c.conclusion || c.state || c.status || '').toLowerCase() || 'pending',
143
+ link: c.link ?? '',
144
+ })));
145
+ }
146
+ return rollup
147
+ .map((c) => {
148
+ const s = (c.conclusion || c.state || c.status || 'PENDING').toUpperCase();
149
+ const mark = s === 'SUCCESS' ? '✓' : s === 'SKIPPED' || s === 'NEUTRAL' ? '-' : s === 'FAILURE' || s === 'CANCELLED' || s === 'TIMED_OUT' || s === 'ACTION_REQUIRED' ? '✗' : '*';
150
+ return `${mark} ${c.name}\t${s}${c.link ? `\t${c.link}` : ''}`;
151
+ })
152
+ .join('\n');
153
+ }
154
+ const NON_TERMINAL = new Set(['IN_PROGRESS', 'QUEUED', 'PENDING', 'WAITING', 'REQUESTED']);
155
+ /**
156
+ * True once CI has settled for this SHA.
157
+ *
158
+ * Check-suites only disambiguate an EMPTY rollup: no checks + no pending suites is
159
+ * "genuinely none" (settled); no checks + a pending suite is "not registered yet"
160
+ * (wait). Once real checks exist we decide on THEM alone and ignore suites — some
161
+ * App integrations (claude/cursor reviewers) register a suite that stays `queued`
162
+ * forever and never posts a run, exactly what `gh pr checks` also ignores.
163
+ */
164
+ export function isSettled(rollup, pendingSuites) {
165
+ if (rollup.length === 0)
166
+ return pendingSuites === 0;
167
+ return rollup.every((c) => !NON_TERMINAL.has((c.conclusion || c.state || c.status || '').toUpperCase()));
168
+ }
169
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
170
+ /** Eager REST watch: poll to a terminal state, re-anchored to the live head SHA. */
171
+ export async function watchChecks(target, json, realGh) {
172
+ const gh = restExec(realGh);
173
+ const deadline = Date.now() + 30 * 60_000; // 30-min guard against a hung matrix
174
+ let last = '';
175
+ for (;;) {
176
+ const head = await prHead(target.repo, target.number, gh); // re-anchor each tick
177
+ const [rollup, pending] = await Promise.all([
178
+ rollupForSha(target.repo, head.sha, gh),
179
+ pendingCheckSuites(target.repo, head.sha, gh),
180
+ ]);
181
+ const view = renderRollup(rollup, json);
182
+ if (view && view !== last && !json) {
183
+ process.stdout.write(view + '\n');
184
+ last = view;
185
+ }
186
+ if (isSettled(rollup, pending)) {
187
+ if (json)
188
+ process.stdout.write(view + '\n');
189
+ const green = isCiGreen(rollup);
190
+ if (rollup.length === 0) {
191
+ process.stderr.write('no checks reported on the head commit\n');
192
+ return 0;
193
+ }
194
+ return green ? 0 : 1;
195
+ }
196
+ if (Date.now() > deadline) {
197
+ process.stderr.write('gh(REST): watch timed out after 30m\n');
198
+ return 1;
199
+ }
200
+ await sleep(10_000);
201
+ }
202
+ }
203
+ /** Lazy one-shot: real gh first; translate to REST only on the exact rate-limit signal. */
204
+ export async function checksOnce(target, json, realGh, ghArgs) {
205
+ // Try real gh, capturing output so we can detect the rate-limit signal.
206
+ try {
207
+ const { stdout } = await execFileAsync(realGh, ghArgs, { env: ghChildEnv(), maxBuffer: 16 * 1024 * 1024 });
208
+ process.stdout.write(stdout);
209
+ return 0;
210
+ }
211
+ catch (err) {
212
+ const e = err;
213
+ if (!isRateLimitError(String(e.stderr ?? ''))) {
214
+ // A real failure (checks failing, bad flag). Pass gh's own output/exit through.
215
+ if (e.stdout)
216
+ process.stdout.write(e.stdout);
217
+ if (e.stderr)
218
+ process.stderr.write(e.stderr);
219
+ return typeof e.code === 'number' ? e.code : 1;
220
+ }
221
+ }
222
+ // Rate-limited: serve from REST if we can resolve the PR, else re-raise real gh.
223
+ if (!target)
224
+ return passthrough(realGh, ghArgs);
225
+ const gh = restExec(realGh);
226
+ const head = await prHead(target.repo, target.number, gh);
227
+ const rollup = await rollupForSha(target.repo, head.sha, gh);
228
+ process.stdout.write(renderRollup(rollup, json) + '\n');
229
+ return isCiGreen(rollup) ? 0 : 1;
230
+ }
231
+ /** Entry point for the `__gh` early branch in index.ts. */
232
+ export async function runGhOverload(argv, cwd = process.cwd()) {
233
+ const { realGh, ghArgs } = parseDelegateArgs(argv);
234
+ // Only `pr checks` is Tier-1. Anything else → real gh, untouched.
235
+ if (ghArgs[0] !== 'pr' || ghArgs[1] !== 'checks')
236
+ return passthrough(realGh, ghArgs);
237
+ const json = ghArgs.includes('--json');
238
+ const watch = ghArgs.includes('--watch');
239
+ const target = await resolveTarget(ghArgs, cwd, realGh);
240
+ if (watch) {
241
+ if (!target)
242
+ return passthrough(realGh, ghArgs); // can't resolve → let real gh try
243
+ return watchChecks(target, json, realGh);
244
+ }
245
+ return checksOnce(target, json, realGh, ghArgs);
246
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * REST-backed reads for PR CI state — the engine behind the `gh` overload shim.
3
+ *
4
+ * The problem this exists for: the whole fleet shares one GitHub token, and the
5
+ * merge loop's `gh pr checks/view/list` are all **GraphQL**-backed. GraphQL is
6
+ * metered on a 5000-POINT/hr budget separate from REST core (5000 req/hr), so the
7
+ * fleet drains GraphQL while REST core sits idle, and every agent's CI watch dies
8
+ * with `GraphQL: API rate limit already exceeded`. These functions answer the same
9
+ * questions over REST, which is the budget nobody is using.
10
+ *
11
+ * They also fix PHNX-3042 (a superseded run's red reported as the current verdict):
12
+ * `commits/{sha}/check-runs` returns ONLY runs for that exact SHA, so anchoring on
13
+ * the PR's live head SHA can never surface a stale run from a superseded commit —
14
+ * the head-exact property `gh pr checks`'s denormalized GraphQL rollup lacks.
15
+ *
16
+ * `gh api …` here draws REST core, and reuses {@link ghExec}'s hardened env
17
+ * (`ghEnv` strips FORCE_COLOR / pins GH_NO_COLOR — this fleet exports FORCE_COLOR
18
+ * and gh otherwise paints the JSON payload and JSON.parse dies).
19
+ */
20
+ import { type GhExec } from './pr-mergeable.js';
21
+ import type { StatusCheck } from './pr-verdict.js';
22
+ /** A rollup item shaped like `gh pr checks --json`, built from REST. */
23
+ export interface RollupItem extends StatusCheck {
24
+ /** Check name / status context. */
25
+ name: string;
26
+ /** html_url (check-run) or target_url (legacy status); may be empty. */
27
+ link?: string;
28
+ }
29
+ /** PR head identity — the SHA every check query must anchor to. */
30
+ export interface PrHead {
31
+ number: number;
32
+ sha: string;
33
+ }
34
+ /**
35
+ * Resolve a PR's current head SHA over REST (`GET repos/{repo}/pulls/{n}`).
36
+ *
37
+ * This is the anchor for every check query: pinning to the live head SHA is what
38
+ * makes the watch immune to a superseded run's verdict (PHNX-3042). Throws if the
39
+ * PR has no head SHA (deleted / not found) rather than returning a wrong empty.
40
+ */
41
+ export declare function prHead(repo: string, number: number, gh?: GhExec): Promise<PrHead>;
42
+ /**
43
+ * The status-check rollup for ONE commit SHA, over REST — the union of the two
44
+ * GitHub subsystems `gh pr checks`'s GraphQL rollup merges for you:
45
+ *
46
+ * - `GET commits/{sha}/check-runs` — GitHub Actions + check-run apps (paginated).
47
+ * - `GET commits/{sha}/status` — legacy commit statuses (external CI).
48
+ *
49
+ * Deduped by name; a check-run wins over a legacy status of the same context.
50
+ * Fields are ASCII-upper-cased to match {@link StatusCheck}, which
51
+ * {@link isCiGreen} reads as `conclusion || state || status`.
52
+ */
53
+ export declare function rollupForSha(repo: string, sha: string, gh?: GhExec): Promise<RollupItem[]>;
54
+ /**
55
+ * How many check-suites are still queued/in_progress for a SHA.
56
+ *
57
+ * Disambiguates the empty rollup: a suite that is `queued`/`in_progress` with no
58
+ * runs yet means "checks are coming, not registered" (keep polling), NOT "this PR
59
+ * has no checks" (terminal). Without this, a `--watch` on a freshly-pushed SHA
60
+ * would read an empty rollup as green before CI registers.
61
+ */
62
+ export declare function pendingCheckSuites(repo: string, sha: string, gh?: GhExec): Promise<number>;
63
+ /** True when gh stderr is the rate-limit outcome the shim should switch to REST on. */
64
+ export declare function isRateLimitError(stderr: string): boolean;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * REST-backed reads for PR CI state — the engine behind the `gh` overload shim.
3
+ *
4
+ * The problem this exists for: the whole fleet shares one GitHub token, and the
5
+ * merge loop's `gh pr checks/view/list` are all **GraphQL**-backed. GraphQL is
6
+ * metered on a 5000-POINT/hr budget separate from REST core (5000 req/hr), so the
7
+ * fleet drains GraphQL while REST core sits idle, and every agent's CI watch dies
8
+ * with `GraphQL: API rate limit already exceeded`. These functions answer the same
9
+ * questions over REST, which is the budget nobody is using.
10
+ *
11
+ * They also fix PHNX-3042 (a superseded run's red reported as the current verdict):
12
+ * `commits/{sha}/check-runs` returns ONLY runs for that exact SHA, so anchoring on
13
+ * the PR's live head SHA can never surface a stale run from a superseded commit —
14
+ * the head-exact property `gh pr checks`'s denormalized GraphQL rollup lacks.
15
+ *
16
+ * `gh api …` here draws REST core, and reuses {@link ghExec}'s hardened env
17
+ * (`ghEnv` strips FORCE_COLOR / pins GH_NO_COLOR — this fleet exports FORCE_COLOR
18
+ * and gh otherwise paints the JSON payload and JSON.parse dies).
19
+ */
20
+ import { ghExec } from './pr-mergeable.js';
21
+ /** Parse newline-delimited JSON (gh `--jq` streams one object per line/page). */
22
+ function parseNdjson(out) {
23
+ const rows = [];
24
+ for (const line of out.split('\n')) {
25
+ const t = line.trim();
26
+ if (!t)
27
+ continue;
28
+ rows.push(JSON.parse(t));
29
+ }
30
+ return rows;
31
+ }
32
+ /**
33
+ * Resolve a PR's current head SHA over REST (`GET repos/{repo}/pulls/{n}`).
34
+ *
35
+ * This is the anchor for every check query: pinning to the live head SHA is what
36
+ * makes the watch immune to a superseded run's verdict (PHNX-3042). Throws if the
37
+ * PR has no head SHA (deleted / not found) rather than returning a wrong empty.
38
+ */
39
+ export async function prHead(repo, number, gh = ghExec) {
40
+ const sha = (await gh(['api', `repos/${repo}/pulls/${number}`, '--jq', '.head.sha'])).trim();
41
+ if (!sha)
42
+ throw new Error(`no head SHA for ${repo}#${number}`);
43
+ return { number, sha };
44
+ }
45
+ /**
46
+ * The status-check rollup for ONE commit SHA, over REST — the union of the two
47
+ * GitHub subsystems `gh pr checks`'s GraphQL rollup merges for you:
48
+ *
49
+ * - `GET commits/{sha}/check-runs` — GitHub Actions + check-run apps (paginated).
50
+ * - `GET commits/{sha}/status` — legacy commit statuses (external CI).
51
+ *
52
+ * Deduped by name; a check-run wins over a legacy status of the same context.
53
+ * Fields are ASCII-upper-cased to match {@link StatusCheck}, which
54
+ * {@link isCiGreen} reads as `conclusion || state || status`.
55
+ */
56
+ export async function rollupForSha(repo, sha, gh = ghExec) {
57
+ const [runsRaw, statusRaw] = await Promise.all([
58
+ gh([
59
+ 'api', `repos/${repo}/commits/${sha}/check-runs`, '--paginate',
60
+ '--jq',
61
+ '.check_runs[] | {name: .name, status: (.status // "" | ascii_upcase), ' +
62
+ 'conclusion: (.conclusion // "" | ascii_upcase), link: (.html_url // "")}',
63
+ ]),
64
+ gh([
65
+ 'api', `repos/${repo}/commits/${sha}/status`,
66
+ '--jq',
67
+ '.statuses[] | {name: .context, state: (.state // "" | ascii_upcase), ' +
68
+ 'link: (.target_url // "")}',
69
+ ]),
70
+ ]);
71
+ const byName = new Map();
72
+ // Legacy statuses first; check-runs override on a name collision.
73
+ for (const s of parseNdjson(statusRaw)) {
74
+ byName.set(String(s.name), { name: String(s.name), state: str(s.state), link: str(s.link) });
75
+ }
76
+ for (const r of parseNdjson(runsRaw)) {
77
+ byName.set(String(r.name), {
78
+ name: String(r.name),
79
+ status: str(r.status),
80
+ conclusion: str(r.conclusion),
81
+ link: str(r.link),
82
+ });
83
+ }
84
+ return [...byName.values()];
85
+ }
86
+ /**
87
+ * How many check-suites are still queued/in_progress for a SHA.
88
+ *
89
+ * Disambiguates the empty rollup: a suite that is `queued`/`in_progress` with no
90
+ * runs yet means "checks are coming, not registered" (keep polling), NOT "this PR
91
+ * has no checks" (terminal). Without this, a `--watch` on a freshly-pushed SHA
92
+ * would read an empty rollup as green before CI registers.
93
+ */
94
+ export async function pendingCheckSuites(repo, sha, gh = ghExec) {
95
+ const out = await gh([
96
+ 'api', `repos/${repo}/commits/${sha}/check-suites`,
97
+ '--jq',
98
+ '[.check_suites[] | select(.status == "queued" or .status == "in_progress")] | length',
99
+ ]);
100
+ const n = Number.parseInt(out.trim(), 10);
101
+ return Number.isFinite(n) ? n : 0;
102
+ }
103
+ /** The exact GitHub GraphQL primary rate-limit signal (never the bare noun). */
104
+ const RATE_LIMIT_SIGNAL = /GraphQL: API rate limit (?:already )?exceeded|You have exceeded a secondary rate limit/i;
105
+ /** True when gh stderr is the rate-limit outcome the shim should switch to REST on. */
106
+ export function isRateLimitError(stderr) {
107
+ return RATE_LIMIT_SIGNAL.test(stderr);
108
+ }
109
+ function str(v) {
110
+ return v === undefined || v === null || v === '' ? undefined : String(v);
111
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pre-save connection test for a custom harness (PHNX-2221).
3
+ *
4
+ * A harness binds a (host CLI + model + endpoint + auth) — but none of that is
5
+ * exercised until the first real run, so a typo in the base URL, a wrong account,
6
+ * or a model id the endpoint doesn't serve only surfaces later. This module runs
7
+ * a genuine minimal request through the SAME resolution path a real run takes —
8
+ * `agents run <name> "say alive in one word" --headless --timeout 60s`, which
9
+ * flows `resolveProfileForRun` → `resolveProfileEnv` (keychain/account read) →
10
+ * `buildExecEnv` → spawn — and classifies the result so the user learns it works
11
+ * (or exactly why it doesn't) before committing the harness.
12
+ *
13
+ * There is no mock or dry-run: the profile must already be on disk (the caller
14
+ * writes it first) because the test drives the real `agents run` argv. The
15
+ * classifier is a pure function over the child's exit code + combined output, so
16
+ * it is unit-tested against real stderr samples with no spawn.
17
+ */
18
+ /** Why a connection test failed, when it did. `undefined` reason ⇒ it passed. */
19
+ export type ConnectionTestReason = 'auth' | 'endpoint' | 'model' | 'unknown';
20
+ /** Outcome of a harness connection test. */
21
+ export interface ConnectionTestResult {
22
+ ok: boolean;
23
+ /** Machine-readable failure class; absent on success. */
24
+ reason?: ConnectionTestReason;
25
+ /** One-line human summary (the classified cause, or a success note). */
26
+ message?: string;
27
+ }
28
+ /** The prompt the smoke test sends — cheap, deterministic, one token of output. */
29
+ export declare const CONNECTION_TEST_PROMPT = "say alive in one word";
30
+ /**
31
+ * Classify a finished `agents run` smoke test from its exit code and combined
32
+ * stdout+stderr. Pure — no spawn — so the mapping (pass / auth / endpoint /
33
+ * model / unknown) is unit-tested against real provider error strings.
34
+ *
35
+ * Exit 0 is a pass. Otherwise the output is matched against provider-error
36
+ * shapes in priority order: an auth rejection (401 / invalid key) is the most
37
+ * specific, then a model-not-served error, then a transport/DNS failure; a
38
+ * failure that matches none is `unknown` (the run failed but not in a way we can
39
+ * name — surfaced verbatim, never swallowed).
40
+ */
41
+ export declare function classifyConnectionOutput(exitCode: number | null, output: string): ConnectionTestResult;
42
+ /** Options for {@link runHarnessConnectionTest}. */
43
+ export interface ConnectionTestOptions {
44
+ /** Agent-side timeout passed to `agents run --timeout`. Default `60s`. */
45
+ timeout?: string;
46
+ /** Hard wall-clock cap (ms) on the child, above the agent timeout. Default 90s. */
47
+ killAfterMs?: number;
48
+ }
49
+ /**
50
+ * Run the real connection test against an already-saved harness. Spawns the CLI
51
+ * itself (`getCliLaunch`, the one self-invocation primitive) with the same argv a
52
+ * user would type, and classifies the result. Never throws on a failed run — a
53
+ * failure is returned as a classified {@link ConnectionTestResult}; only a spawn
54
+ * that never produced an exit (killed by the wall-clock cap) reads as `endpoint`
55
+ * (the request hung).
56
+ */
57
+ export declare function runHarnessConnectionTest(name: string, opts?: ConnectionTestOptions): Promise<ConnectionTestResult>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pre-save connection test for a custom harness (PHNX-2221).
3
+ *
4
+ * A harness binds a (host CLI + model + endpoint + auth) — but none of that is
5
+ * exercised until the first real run, so a typo in the base URL, a wrong account,
6
+ * or a model id the endpoint doesn't serve only surfaces later. This module runs
7
+ * a genuine minimal request through the SAME resolution path a real run takes —
8
+ * `agents run <name> "say alive in one word" --headless --timeout 60s`, which
9
+ * flows `resolveProfileForRun` → `resolveProfileEnv` (keychain/account read) →
10
+ * `buildExecEnv` → spawn — and classifies the result so the user learns it works
11
+ * (or exactly why it doesn't) before committing the harness.
12
+ *
13
+ * There is no mock or dry-run: the profile must already be on disk (the caller
14
+ * writes it first) because the test drives the real `agents run` argv. The
15
+ * classifier is a pure function over the child's exit code + combined output, so
16
+ * it is unit-tested against real stderr samples with no spawn.
17
+ */
18
+ import { spawnSync } from 'node:child_process';
19
+ import { getCliLaunch } from './cli-entry.js';
20
+ /** The prompt the smoke test sends — cheap, deterministic, one token of output. */
21
+ export const CONNECTION_TEST_PROMPT = 'say alive in one word';
22
+ /**
23
+ * Classify a finished `agents run` smoke test from its exit code and combined
24
+ * stdout+stderr. Pure — no spawn — so the mapping (pass / auth / endpoint /
25
+ * model / unknown) is unit-tested against real provider error strings.
26
+ *
27
+ * Exit 0 is a pass. Otherwise the output is matched against provider-error
28
+ * shapes in priority order: an auth rejection (401 / invalid key) is the most
29
+ * specific, then a model-not-served error, then a transport/DNS failure; a
30
+ * failure that matches none is `unknown` (the run failed but not in a way we can
31
+ * name — surfaced verbatim, never swallowed).
32
+ */
33
+ export function classifyConnectionOutput(exitCode, output) {
34
+ if (exitCode === 0)
35
+ return { ok: true, message: 'Connection test passed.' };
36
+ const text = output || '';
37
+ const firstLine = text.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
38
+ const detail = firstLine ? ` (${firstLine})` : '';
39
+ if (/\b401\b|unauthor|invalid[\s_-]?(x-)?api[\s_-]?key|authentication|invalid x-api-key|missing[\s_-]?api[\s_-]?key/i.test(text)) {
40
+ return { ok: false, reason: 'auth', message: `Authentication rejected — check the harness's account/key.${detail}` };
41
+ }
42
+ if (/model[^\n]{0,48}(not\s+found|not\s+exist|does\s+not\s+exist|unknown|invalid|unsupported|unavailable)|no\s+such\s+model|unknown\s+model|invalid\s+model|unrecognized\s+model/i.test(text)) {
43
+ return { ok: false, reason: 'model', message: `The endpoint did not accept that model id.${detail}` };
44
+ }
45
+ if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|getaddrinfo|connection\s+refused|could\s+not\s+connect|unreachable|fetch\s+failed|socket\s+hang\s+up|network\s+error|dns|certificate|self[\s-]?signed/i.test(text)) {
46
+ return { ok: false, reason: 'endpoint', message: `The endpoint was unreachable — check the base URL.${detail}` };
47
+ }
48
+ return { ok: false, reason: 'unknown', message: `Run failed (exit ${exitCode ?? 'null'}).${detail}` };
49
+ }
50
+ /**
51
+ * Run the real connection test against an already-saved harness. Spawns the CLI
52
+ * itself (`getCliLaunch`, the one self-invocation primitive) with the same argv a
53
+ * user would type, and classifies the result. Never throws on a failed run — a
54
+ * failure is returned as a classified {@link ConnectionTestResult}; only a spawn
55
+ * that never produced an exit (killed by the wall-clock cap) reads as `endpoint`
56
+ * (the request hung).
57
+ */
58
+ export async function runHarnessConnectionTest(name, opts = {}) {
59
+ const { command, args } = getCliLaunch([
60
+ 'run',
61
+ name,
62
+ CONNECTION_TEST_PROMPT,
63
+ '--headless',
64
+ '--timeout',
65
+ opts.timeout ?? '60s',
66
+ ]);
67
+ const result = spawnSync(command, args, {
68
+ encoding: 'utf-8',
69
+ timeout: opts.killAfterMs ?? 90_000,
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ if (result.error && result.error.code === 'ETIMEDOUT') {
73
+ return { ok: false, reason: 'endpoint', message: 'The request hung past the timeout — the endpoint may be unreachable or wrong.' };
74
+ }
75
+ if (result.error) {
76
+ return { ok: false, reason: 'unknown', message: `Could not launch the test: ${result.error.message}` };
77
+ }
78
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
79
+ return classifyConnectionOutput(result.status, output);
80
+ }
package/dist/lib/heal.js CHANGED
@@ -43,6 +43,7 @@ const KIND_TO_SELECTION = {
43
43
  permissions: 'permissions',
44
44
  subagents: 'subagents',
45
45
  plugins: 'plugins',
46
+ workflows: 'workflows',
46
47
  };
47
48
  function totalHealed(r) {
48
49
  return r.versions.reduce((n, v) => n + v.healed.length, 0);
@@ -173,9 +174,13 @@ function healVersion(agent, version, opts) {
173
174
  result.skipped.push({ kind: row.kind, name: row.name, reason: 'drift' });
174
175
  continue;
175
176
  }
176
- if (row.kind === 'promptcuts')
177
- continue; // not version-synced
178
- if (row.kind === 'rules') {
177
+ // Rules (the composed instruction file) and knowledge memory (~/.agents/
178
+ // memory/ facts) are both repaired by a sync of this version: rules via the
179
+ // preset re-composition `selection.memory` drives, knowledge facts via the
180
+ // unconditional `syncMemoryToVersionHome` fan-out every sync runs. Setting
181
+ // the rules selection guarantees a sync executes, so a memory-fact drift is
182
+ // reconciled the same pass (PHNX-3504).
183
+ if (row.kind === 'rules' || row.kind === 'memory') {
179
184
  selection.memory = 'all';
180
185
  attempted.push({ kind: row.kind, name: row.name, was: row.status });
181
186
  continue;
@@ -20,6 +20,15 @@ export declare function getOwnerNotifyFromHumans(): {
20
20
  channel: string;
21
21
  to: string;
22
22
  } | null;
23
+ /**
24
+ * Return every addressable owner destination selected by the normal-severity
25
+ * policy, in policy order. A config without a policy keeps the historical
26
+ * single-channel behavior by selecting the first addressable channel.
27
+ */
28
+ export declare function getOwnerNotifyDestinationsFromHumans(): Array<{
29
+ channel: string;
30
+ to: string;
31
+ }>;
23
32
  /**
24
33
  * Read the owner block from humans.yaml. Returns null if missing.
25
34
  */
@@ -51,19 +51,40 @@ export function writeHumans(config) {
51
51
  * policy is declared, the first addressable channel is the default.
52
52
  */
53
53
  export function getOwnerNotifyFromHumans() {
54
+ return getOwnerNotifyDestinationsFromHumans()[0] ?? null;
55
+ }
56
+ /**
57
+ * Return every addressable owner destination selected by the normal-severity
58
+ * policy, in policy order. A config without a policy keeps the historical
59
+ * single-channel behavior by selecting the first addressable channel.
60
+ */
61
+ export function getOwnerNotifyDestinationsFromHumans() {
54
62
  const owner = readHumans()?.owner;
55
63
  const channels = owner?.channels ?? [];
56
64
  const preferredIds = owner?.policy?.normal ?? [];
57
- const preferred = preferredIds
58
- .map((id) => channels.find((entry) => entry.id === id))
59
- .find((entry) => entry?.to);
60
- const selected = preferred ?? channels.find((entry) => entry.to);
61
- if (selected?.id && selected.to)
62
- return { channel: selected.id, to: selected.to };
65
+ const selected = preferredIds.length > 0
66
+ ? preferredIds.map((id) => channels.find((entry) => entry.id === id))
67
+ : [channels.find((entry) => entry.to)];
68
+ const seen = new Set();
69
+ const destinations = selected
70
+ .filter((entry) => Boolean(entry?.id && entry.to))
71
+ .map((entry) => ({ channel: entry.id, to: entry.to }))
72
+ .filter((entry) => {
73
+ const key = `${entry.channel}\0${entry.to}`;
74
+ if (seen.has(key))
75
+ return false;
76
+ seen.add(key);
77
+ return true;
78
+ });
79
+ if (destinations.length > 0)
80
+ return destinations;
81
+ const firstAddressable = channels.find((entry) => entry.id && entry.to);
82
+ if (firstAddressable?.to)
83
+ return [{ channel: firstAddressable.id, to: firstAddressable.to }];
63
84
  const migrated = owner?.notify;
64
85
  if (migrated?.channel && migrated.to)
65
- return migrated;
66
- return null;
86
+ return [migrated];
87
+ return [];
67
88
  }
68
89
  /**
69
90
  * Read the owner block from humans.yaml. Returns null if missing.