@phnx-labs/agents-cli 1.22.60 → 1.22.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +35 -1
- package/dist/commands/harness-hooks.d.ts +55 -0
- package/dist/commands/harness-hooks.js +104 -0
- package/dist/commands/harness-wizard.d.ts +33 -14
- package/dist/commands/harness-wizard.js +53 -23
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +86 -5
- package/dist/commands/reminders.d.ts +9 -0
- package/dist/commands/reminders.js +49 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +13 -0
- package/dist/commands/teams.d.ts +1 -1
- package/dist/commands/teams.js +9 -3
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +229 -13
- package/dist/lib/browser/drivers/local.d.ts +11 -0
- package/dist/lib/browser/drivers/local.js +26 -0
- package/dist/lib/browser/profiles.js +8 -6
- package/dist/lib/browser/service.d.ts +12 -8
- package/dist/lib/browser/service.js +38 -10
- package/dist/lib/claude-statusline.d.ts +14 -1
- package/dist/lib/claude-statusline.js +27 -2
- package/dist/lib/daemon/runner.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +22 -4
- package/dist/lib/doctor-diff.d.ts +21 -5
- package/dist/lib/doctor-diff.js +242 -76
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +25 -16
- package/dist/lib/github/gh-overload.d.ts +58 -0
- package/dist/lib/github/gh-overload.js +246 -0
- package/dist/lib/github/rest.d.ts +64 -0
- package/dist/lib/github/rest.js +111 -0
- package/dist/lib/harness-connection-test.d.ts +57 -0
- package/dist/lib/harness-connection-test.js +80 -0
- package/dist/lib/heal.js +8 -3
- package/dist/lib/installations/shims.d.ts +22 -0
- package/dist/lib/installations/shims.js +104 -0
- package/dist/lib/linear-project-counts.js +8 -0
- package/dist/lib/linear-rate-limit.d.ts +26 -0
- package/dist/lib/linear-rate-limit.js +163 -0
- package/dist/lib/mcp.d.ts +9 -0
- package/dist/lib/mcp.js +37 -1
- package/dist/lib/open-url.js +5 -3
- package/dist/lib/permissions.d.ts +28 -0
- package/dist/lib/permissions.js +156 -1
- package/dist/lib/refresh.js +9 -1
- package/dist/lib/reminders.d.ts +29 -0
- package/dist/lib/reminders.js +88 -0
- package/dist/lib/resource-content-diff.d.ts +33 -0
- package/dist/lib/resource-content-diff.js +103 -0
- package/dist/lib/rules/compile.d.ts +7 -0
- package/dist/lib/rules/compile.js +7 -1
- package/dist/lib/session/active.d.ts +41 -4
- package/dist/lib/session/active.js +58 -7
- package/dist/lib/session/host-link.d.ts +22 -0
- package/dist/lib/session/host-link.js +40 -4
- package/dist/lib/session/trajectory.d.ts +42 -0
- package/dist/lib/session/trajectory.js +46 -27
- package/dist/lib/ssh-exec.d.ts +30 -0
- package/dist/lib/ssh-exec.js +37 -5
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/subagents-registry.d.ts +18 -0
- package/dist/lib/subagents-registry.js +79 -0
- package/dist/lib/teams/agents.d.ts +12 -0
- package/dist/lib/teams/agents.js +51 -0
- package/dist/lib/traces/schema2-build.d.ts +85 -0
- package/dist/lib/traces/schema2-build.js +637 -0
- package/dist/lib/traces/schema2-danger.d.ts +36 -0
- package/dist/lib/traces/schema2-danger.js +185 -0
- package/dist/lib/traces/schema2.d.ts +149 -0
- package/dist/lib/traces/schema2.js +20 -0
- package/dist/lib/traces/sync.d.ts +93 -0
- package/dist/lib/traces/sync.js +75 -22
- package/dist/lib/traces/worker-template.js +5 -0
- package/dist/lib/uninstall.js +10 -1
- package/dist/lib/workflows.d.ts +11 -0
- package/dist/lib/workflows.js +67 -8
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
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 { type RollupItem } from './rest.js';
|
|
22
|
+
interface Parsed {
|
|
23
|
+
realGh: string;
|
|
24
|
+
ghArgs: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Split `--real-gh <path> -- <gh argv>`; defaults realGh to bare `gh`. */
|
|
27
|
+
export declare function parseDelegateArgs(argv: string[]): Parsed;
|
|
28
|
+
export interface Target {
|
|
29
|
+
repo: string;
|
|
30
|
+
number: number;
|
|
31
|
+
}
|
|
32
|
+
/** owner/repo from a git remote URL, or null. */
|
|
33
|
+
export declare function repoFromRemote(remoteUrl: string): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Resolve `{repo, number}` from the `gh pr checks` argv + cwd, over git/REST only.
|
|
36
|
+
* Returns null (→ caller passes through to real gh) when it can't be resolved
|
|
37
|
+
* cleanly, e.g. no number and no open PR for the current branch.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveTarget(ghArgs: string[], cwd: string, realGh: string): Promise<Target | null>;
|
|
40
|
+
/** Render one rollup as `gh pr checks`-ish lines (or JSON when asked). */
|
|
41
|
+
export declare function renderRollup(input: RollupItem[], json: boolean): string;
|
|
42
|
+
/**
|
|
43
|
+
* True once CI has settled for this SHA.
|
|
44
|
+
*
|
|
45
|
+
* Check-suites only disambiguate an EMPTY rollup: no checks + no pending suites is
|
|
46
|
+
* "genuinely none" (settled); no checks + a pending suite is "not registered yet"
|
|
47
|
+
* (wait). Once real checks exist we decide on THEM alone and ignore suites — some
|
|
48
|
+
* App integrations (claude/cursor reviewers) register a suite that stays `queued`
|
|
49
|
+
* forever and never posts a run, exactly what `gh pr checks` also ignores.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isSettled(rollup: RollupItem[], pendingSuites: number): boolean;
|
|
52
|
+
/** Eager REST watch: poll to a terminal state, re-anchored to the live head SHA. */
|
|
53
|
+
export declare function watchChecks(target: Target, json: boolean, realGh: string): Promise<number>;
|
|
54
|
+
/** Lazy one-shot: real gh first; translate to REST only on the exact rate-limit signal. */
|
|
55
|
+
export declare function checksOnce(target: Target | null, json: boolean, realGh: string, ghArgs: string[]): Promise<number>;
|
|
56
|
+
/** Entry point for the `__gh` early branch in index.ts. */
|
|
57
|
+
export declare function runGhOverload(argv: string[], cwd?: string): Promise<number>;
|
|
58
|
+
export {};
|
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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;
|