@deftai/directive-core 0.90.0 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/doctor/openclaw-skills.d.ts +42 -8
- package/dist/doctor/openclaw-skills.js +281 -38
- package/dist/eval/health.d.ts +10 -1
- package/dist/eval/health.js +16 -16
- package/dist/init-deposit/hygiene.js +3 -0
- package/dist/init-deposit/init-deposit.d.ts +5 -0
- package/dist/init-deposit/init-deposit.js +37 -2
- package/dist/intake/github-auth-modes.d.ts +1 -1
- package/dist/intake/github-auth-modes.js +25 -3
- package/dist/intake/issue-ingest-cli.js +13 -0
- package/dist/intake/reconcile-issues-cli.js +13 -0
- package/dist/policy/plan-extensions.d.ts +10 -0
- package/dist/policy/plan-extensions.js +16 -0
- package/dist/pr-closing-keywords/detect.d.ts +13 -0
- package/dist/pr-closing-keywords/detect.js +73 -8
- package/dist/pr-closing-keywords/index.d.ts +2 -2
- package/dist/pr-closing-keywords/index.js +1 -1
- package/dist/pr-closing-keywords/main.js +119 -90
- package/dist/pr-closing-keywords/types.d.ts +10 -0
- package/dist/render/constants.d.ts +16 -0
- package/dist/render/constants.js +16 -1
- package/dist/render/export-spec.d.ts +7 -1
- package/dist/render/export-spec.js +51 -5
- package/dist/render/index.d.ts +3 -2
- package/dist/render/index.js +2 -2
- package/dist/render/roadmap-render.js +164 -60
- package/dist/render/scope-outlook.d.ts +5 -0
- package/dist/render/scope-outlook.js +3 -0
- package/dist/render/spec-render.d.ts +28 -2
- package/dist/render/spec-render.js +111 -14
- package/dist/render/text-utils.d.ts +6 -0
- package/dist/render/text-utils.js +23 -0
- package/dist/scm/binary.d.ts +3 -0
- package/dist/scm/binary.js +10 -2
- package/dist/scm/call.d.ts +5 -0
- package/dist/scm/call.js +5 -0
- package/dist/scm/index.d.ts +1 -0
- package/dist/scm/index.js +1 -0
- package/dist/scm/main.d.ts +5 -0
- package/dist/scm/main.js +27 -0
- package/dist/scm/readiness-cli.d.ts +24 -0
- package/dist/scm/readiness-cli.js +85 -0
- package/dist/scm/readiness.d.ts +104 -0
- package/dist/scm/readiness.js +421 -0
- package/dist/scope/batch-promote.d.ts +26 -0
- package/dist/scope/batch-promote.js +167 -0
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/main.d.ts +2 -0
- package/dist/scope/main.js +58 -7
- package/dist/scope/wip-cap-check.d.ts +6 -0
- package/dist/scope/wip-cap-check.js +14 -2
- package/dist/session/session-start.d.ts +7 -0
- package/dist/session/session-start.js +54 -0
- package/dist/triage/welcome/index.d.ts +1 -1
- package/dist/triage/welcome/index.js +1 -1
- package/dist/triage/welcome/onboard.js +7 -1
- package/dist/triage/welcome/prior-state.d.ts +7 -0
- package/dist/triage/welcome/prior-state.js +16 -2
- package/dist/triage/welcome/writers.d.ts +10 -0
- package/dist/triage/welcome/writers.js +72 -2
- package/package.json +3 -3
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCM tooling + auth readiness probe for mismatched / headless envs (#2275).
|
|
3
|
+
*
|
|
4
|
+
* Framework-local gates (session:start, verify:*, xbrief:preflight, doctor,
|
|
5
|
+
* scope:*) run without GitHub credentials. SCM-dependent gates (triage:queue,
|
|
6
|
+
* issue:ingest, pr:*, reconcile:issues, cache:fetch-all, scm:*) need `gh`/`ghx`
|
|
7
|
+
* on PATH and either host credential store auth or an injected token
|
|
8
|
+
* (`GH_TOKEN` / `GITHUB_TOKEN` / `GH_ENTERPRISE_TOKEN`).
|
|
9
|
+
*
|
|
10
|
+
* This module:
|
|
11
|
+
* 1. Detects binary + auth state in the *current* execution env (not the
|
|
12
|
+
* install host).
|
|
13
|
+
* 2. Emits a clear one-line diagnostic for session-start and CLI surfaces.
|
|
14
|
+
* 3. Lists which SCM-dependent gates are skipped when readiness is false.
|
|
15
|
+
* 4. Supplies fail-loud error text so SCM verbs never fail opaquely.
|
|
16
|
+
*
|
|
17
|
+
* Hot-path rule (#2991): the default "shallow" probe is local-only (PATH +
|
|
18
|
+
* env token presence + optional short `gh auth status`). Deep API validation
|
|
19
|
+
* is opt-in via `deep: true` (session:start --with-network).
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { findInjectedToken, GITHUB_AUTH_MODE_HOST_GH, GITHUB_AUTH_MODE_INJECTED_TOKEN, inferGithubAuthMode, validateGithubAuthForWorker, } from "../intake/github-auth-modes.js";
|
|
23
|
+
import { getPlatformCapabilities, probeRuntimeCapabilities, } from "../intake/platform-capabilities.js";
|
|
24
|
+
import { defaultWhich } from "./binary.js";
|
|
25
|
+
import { BINARY_PREFERENCE } from "./constants.js";
|
|
26
|
+
import { ScmStubError } from "./errors.js";
|
|
27
|
+
/** Named SCM-dependent surfaces that need gh/ghx + auth (#2275). */
|
|
28
|
+
export const SCM_DEPENDENT_GATES = [
|
|
29
|
+
"triage:queue",
|
|
30
|
+
"triage:welcome (network hydrate)",
|
|
31
|
+
"issue:ingest",
|
|
32
|
+
"reconcile:issues",
|
|
33
|
+
"pr:*",
|
|
34
|
+
"cache:fetch-all",
|
|
35
|
+
"scm:*",
|
|
36
|
+
"github-auth-modes (deep)",
|
|
37
|
+
"umbrella:current-shape",
|
|
38
|
+
];
|
|
39
|
+
const REMEDIATION_BINARY_ABSENT = "Remediation for missing gh/ghx in this execution env:\n" +
|
|
40
|
+
" - Install GitHub CLI in the *execution* environment (https://cli.github.com/)\n" +
|
|
41
|
+
" - Or install ghx (`task setup:ghx` / directive setup:ghx) then ensure PATH is updated\n" +
|
|
42
|
+
" - Or run SCM-dependent gates from a matched env where gh is already installed\n" +
|
|
43
|
+
" - Framework-local gates (session:start, verify:*, xbrief:preflight, doctor, scope:*) do not need SCM";
|
|
44
|
+
const REMEDIATION_MISSING_TOKEN = "Remediation for missing injected token (cloud/headless auth mode):\n" +
|
|
45
|
+
" - Pass GH_TOKEN, GITHUB_TOKEN, or GH_ENTERPRISE_TOKEN into the execution env\n" +
|
|
46
|
+
" - Keep token values out of prompts and transcripts; inject via host secrets only\n" +
|
|
47
|
+
" - Or run SCM-dependent gates from a matched host-gh env instead";
|
|
48
|
+
const REMEDIATION_UNAUTHENTICATED = "Remediation for unauthenticated gh in this execution env:\n" +
|
|
49
|
+
" - host-gh: run `gh auth login` in this env (host credential store is not shared with sandboxes)\n" +
|
|
50
|
+
" - injected-token: set GH_TOKEN / GITHUB_TOKEN / GH_ENTERPRISE_TOKEN and re-probe\n" +
|
|
51
|
+
" - Or run SCM-dependent gates from the matched/authenticated env\n" +
|
|
52
|
+
" - See content/scm/github.md § Mismatched/headless SCM readiness (#2275)";
|
|
53
|
+
function resolveBinaryPresence(whichFn) {
|
|
54
|
+
for (const candidate of BINARY_PREFERENCE) {
|
|
55
|
+
const path = whichFn(candidate);
|
|
56
|
+
if (path !== null) {
|
|
57
|
+
return { binary: candidate, binaryPath: path };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { binary: null, binaryPath: null };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run gh/ghx argv directly (not via scm.call) so readiness probing never
|
|
64
|
+
* re-enters call() / readiness and cannot mis-route `auth status` (#2275 P1).
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Prefer live `gh` for auth/status/api probes. ghx is a cached GET proxy
|
|
68
|
+
* (#884 / #954) and is not a full passthrough for `auth status` / multi-arg
|
|
69
|
+
* api forms — using it here false-negatives readiness when only ghx is
|
|
70
|
+
* preferred on PATH.
|
|
71
|
+
*/
|
|
72
|
+
function resolveAuthProbeBinary(whichFn) {
|
|
73
|
+
return whichFn("gh") ?? whichFn("ghx") ?? "gh";
|
|
74
|
+
}
|
|
75
|
+
function defaultShallowRunGh(args, environ, whichFn = defaultWhich) {
|
|
76
|
+
const resolved = resolveAuthProbeBinary(whichFn);
|
|
77
|
+
try {
|
|
78
|
+
const result = spawnSync(resolved, [...args], {
|
|
79
|
+
env: environ,
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
timeout: 15_000,
|
|
82
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
args: [resolved, ...args],
|
|
86
|
+
returncode: result.status ?? 1,
|
|
87
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
88
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
return {
|
|
94
|
+
args: [resolved, ...args],
|
|
95
|
+
returncode: 1,
|
|
96
|
+
stdout: "",
|
|
97
|
+
stderr: message,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function mapDeepFailureToAuthState(result) {
|
|
102
|
+
if (result.ok)
|
|
103
|
+
return "authenticated";
|
|
104
|
+
if (result.failureKind === "missing_injected_token")
|
|
105
|
+
return "missing-token";
|
|
106
|
+
if (result.failureKind === "gh_auth_failed")
|
|
107
|
+
return "unauthenticated";
|
|
108
|
+
if (result.failureKind === "api_unreachable" || result.failureKind === "repo_access_denied") {
|
|
109
|
+
return "unauthenticated";
|
|
110
|
+
}
|
|
111
|
+
return "unknown";
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Probe SCM binary + auth readiness in the current execution environment.
|
|
115
|
+
*
|
|
116
|
+
* Never throws. Never echoes token values. Suitable for session:start.
|
|
117
|
+
*/
|
|
118
|
+
export function probeScmReadiness(options = {}) {
|
|
119
|
+
const env = options.env ?? process.env;
|
|
120
|
+
const whichFn = options.whichFn ?? defaultWhich;
|
|
121
|
+
const depth = options.depth ?? "shallow";
|
|
122
|
+
const runtimeReport = options.runtimeReport ?? probeRuntimeCapabilities(env);
|
|
123
|
+
const githubAuthMode = options.githubAuthMode ?? inferGithubAuthMode(runtimeReport);
|
|
124
|
+
const injectedTokenPresent = findInjectedToken(env) !== null;
|
|
125
|
+
const { binary, binaryPath } = resolveBinaryPresence(whichFn);
|
|
126
|
+
if (binary === null) {
|
|
127
|
+
const detail = "gh not found on PATH in this execution env; SCM-dependent gates skipped " +
|
|
128
|
+
`(runtime_mode=${runtimeReport.runtimeMode}, github_auth_mode=${githubAuthMode})`;
|
|
129
|
+
return {
|
|
130
|
+
ready: false,
|
|
131
|
+
binary: null,
|
|
132
|
+
binaryPath: null,
|
|
133
|
+
authState: "binary-absent",
|
|
134
|
+
githubAuthMode,
|
|
135
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
136
|
+
injectedTokenPresent,
|
|
137
|
+
depth,
|
|
138
|
+
detail,
|
|
139
|
+
remediation: REMEDIATION_BINARY_ABSENT,
|
|
140
|
+
skippedGates: [...SCM_DEPENDENT_GATES],
|
|
141
|
+
login: null,
|
|
142
|
+
failureKind: "binary_absent",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Injected-token mode without a token is a hard not-ready even before auth status.
|
|
146
|
+
if (githubAuthMode === GITHUB_AUTH_MODE_INJECTED_TOKEN && !injectedTokenPresent) {
|
|
147
|
+
const detail = "injected-token mode requires GH_TOKEN, GITHUB_TOKEN, or GH_ENTERPRISE_TOKEN; " +
|
|
148
|
+
`binary=${binary} present but SCM-dependent gates skipped ` +
|
|
149
|
+
`(runtime_mode=${runtimeReport.runtimeMode})`;
|
|
150
|
+
return {
|
|
151
|
+
ready: false,
|
|
152
|
+
binary,
|
|
153
|
+
binaryPath,
|
|
154
|
+
authState: "missing-token",
|
|
155
|
+
githubAuthMode,
|
|
156
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
157
|
+
injectedTokenPresent: false,
|
|
158
|
+
depth,
|
|
159
|
+
detail,
|
|
160
|
+
remediation: REMEDIATION_MISSING_TOKEN,
|
|
161
|
+
skippedGates: [...SCM_DEPENDENT_GATES],
|
|
162
|
+
login: null,
|
|
163
|
+
failureKind: "missing_injected_token",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (depth === "deep") {
|
|
167
|
+
// Prefer live `gh` for auth/API validation — ghx is a cached GET proxy and
|
|
168
|
+
// rejects multi-arg api forms used by github-auth-modes (#2275 / #954).
|
|
169
|
+
const deepRunner = options.runGh ??
|
|
170
|
+
((args, environ) => {
|
|
171
|
+
const ghPath = whichFn("gh") ?? binary ?? "gh";
|
|
172
|
+
try {
|
|
173
|
+
const result = spawnSync(ghPath, [...args], {
|
|
174
|
+
env: environ,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
timeout: 30_000,
|
|
177
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
args: [ghPath, ...args],
|
|
181
|
+
returncode: result.status ?? 1,
|
|
182
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
183
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
return { args: [ghPath, ...args], returncode: 1, stdout: "", stderr: message };
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const deep = validateGithubAuthForWorker(githubAuthMode, {
|
|
192
|
+
environ: env,
|
|
193
|
+
runtimeReport,
|
|
194
|
+
repo: options.repo,
|
|
195
|
+
runGh: deepRunner,
|
|
196
|
+
});
|
|
197
|
+
if (deep.ok) {
|
|
198
|
+
const loginPart = deep.login ? ` as ${deep.login}` : "";
|
|
199
|
+
return {
|
|
200
|
+
ready: true,
|
|
201
|
+
binary,
|
|
202
|
+
binaryPath,
|
|
203
|
+
authState: "authenticated",
|
|
204
|
+
githubAuthMode: deep.githubAuthMode,
|
|
205
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
206
|
+
injectedTokenPresent,
|
|
207
|
+
depth,
|
|
208
|
+
detail: `SCM ready: ${binary} present, ${deep.githubAuthMode} authenticated${loginPart} (deep)`,
|
|
209
|
+
remediation: null,
|
|
210
|
+
skippedGates: [],
|
|
211
|
+
login: deep.login,
|
|
212
|
+
failureKind: null,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const authState = mapDeepFailureToAuthState(deep);
|
|
216
|
+
return {
|
|
217
|
+
ready: false,
|
|
218
|
+
binary,
|
|
219
|
+
binaryPath,
|
|
220
|
+
authState,
|
|
221
|
+
githubAuthMode: deep.githubAuthMode,
|
|
222
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
223
|
+
injectedTokenPresent,
|
|
224
|
+
depth,
|
|
225
|
+
detail: `SCM not ready: ${deep.detail}`,
|
|
226
|
+
remediation: deep.remediation ?? REMEDIATION_UNAUTHENTICATED,
|
|
227
|
+
skippedGates: [...SCM_DEPENDENT_GATES],
|
|
228
|
+
login: deep.login,
|
|
229
|
+
failureKind: deep.failureKind,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// Shallow path: optional gh auth status (local credential check, short timeout).
|
|
233
|
+
const checkAuth = options.checkAuthStatus !== false;
|
|
234
|
+
if (!checkAuth) {
|
|
235
|
+
const detail = `SCM binary present (${binary}); auth status not checked (shallow, checkAuthStatus=false); ` +
|
|
236
|
+
`github_auth_mode=${githubAuthMode}`;
|
|
237
|
+
return {
|
|
238
|
+
ready: true,
|
|
239
|
+
binary,
|
|
240
|
+
binaryPath,
|
|
241
|
+
authState: "unknown",
|
|
242
|
+
githubAuthMode,
|
|
243
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
244
|
+
injectedTokenPresent,
|
|
245
|
+
depth,
|
|
246
|
+
detail,
|
|
247
|
+
remediation: null,
|
|
248
|
+
skippedGates: [],
|
|
249
|
+
login: null,
|
|
250
|
+
failureKind: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const runner = options.runGh ?? ((args, environ) => defaultShallowRunGh(args, environ, whichFn));
|
|
254
|
+
const authStatus = runner(["auth", "status"], env);
|
|
255
|
+
if (authStatus.returncode !== 0) {
|
|
256
|
+
const detail = `gh not authenticated in this execution env (binary=${binary}, ` +
|
|
257
|
+
`github_auth_mode=${githubAuthMode}); SCM-dependent gates skipped`;
|
|
258
|
+
return {
|
|
259
|
+
ready: false,
|
|
260
|
+
binary,
|
|
261
|
+
binaryPath,
|
|
262
|
+
authState: "unauthenticated",
|
|
263
|
+
githubAuthMode,
|
|
264
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
265
|
+
injectedTokenPresent,
|
|
266
|
+
depth,
|
|
267
|
+
detail,
|
|
268
|
+
remediation: REMEDIATION_UNAUTHENTICATED,
|
|
269
|
+
skippedGates: [...SCM_DEPENDENT_GATES],
|
|
270
|
+
login: null,
|
|
271
|
+
failureKind: "gh_auth_failed",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
ready: true,
|
|
276
|
+
binary,
|
|
277
|
+
binaryPath,
|
|
278
|
+
authState: "authenticated",
|
|
279
|
+
githubAuthMode,
|
|
280
|
+
runtimeMode: runtimeReport.runtimeMode,
|
|
281
|
+
injectedTokenPresent,
|
|
282
|
+
depth,
|
|
283
|
+
detail: `SCM ready: ${binary} present, ${githubAuthMode} authenticated (shallow)`,
|
|
284
|
+
remediation: null,
|
|
285
|
+
skippedGates: [],
|
|
286
|
+
login: null,
|
|
287
|
+
failureKind: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/** JSON-friendly snake_case dict for session:start --json / CLI. */
|
|
291
|
+
export function scmReadinessToDict(report) {
|
|
292
|
+
return {
|
|
293
|
+
ready: report.ready,
|
|
294
|
+
binary: report.binary,
|
|
295
|
+
binary_path: report.binaryPath,
|
|
296
|
+
auth_state: report.authState,
|
|
297
|
+
github_auth_mode: report.githubAuthMode,
|
|
298
|
+
runtime_mode: report.runtimeMode,
|
|
299
|
+
injected_token_present: report.injectedTokenPresent,
|
|
300
|
+
depth: report.depth,
|
|
301
|
+
detail: report.detail,
|
|
302
|
+
remediation: report.remediation,
|
|
303
|
+
skipped_gates: [...report.skippedGates],
|
|
304
|
+
login: report.login,
|
|
305
|
+
failure_kind: report.failureKind,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Format human lines for session:start (and similar orientation surfaces).
|
|
310
|
+
* First line is always the one-line status; when not ready, adds skipped gates
|
|
311
|
+
* and a short remediation pointer.
|
|
312
|
+
*/
|
|
313
|
+
export function formatScmReadinessLines(report) {
|
|
314
|
+
const lines = [];
|
|
315
|
+
if (report.ready) {
|
|
316
|
+
lines.push(`[deft scm] ${report.detail}`);
|
|
317
|
+
return lines;
|
|
318
|
+
}
|
|
319
|
+
lines.push(`[deft scm] ${report.detail}`);
|
|
320
|
+
if (report.skippedGates.length > 0) {
|
|
321
|
+
lines.push(`[deft scm] skipped gates: ${report.skippedGates.join(", ")}`);
|
|
322
|
+
}
|
|
323
|
+
lines.push("[deft scm] run SCM-dependent gates only after auth is ready, or from a matched env; " +
|
|
324
|
+
"see content/scm/github.md § Mismatched/headless SCM readiness (#2275)");
|
|
325
|
+
return lines;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Fail-loud error for SCM-dependent entry points when readiness is false.
|
|
329
|
+
* Includes named reason + skipped-gate list so agents never see an opaque failure.
|
|
330
|
+
*/
|
|
331
|
+
export function scmNotReadyError(report) {
|
|
332
|
+
const r = report ?? probeScmReadiness({ checkAuthStatus: false });
|
|
333
|
+
if (r.ready && r.binary !== null) {
|
|
334
|
+
// Caller asked for an error but probe is ready — still surface binary guidance.
|
|
335
|
+
return new ScmStubError("SCM readiness unexpected: binary present but caller refused; " +
|
|
336
|
+
"see content/scm/github.md § Mismatched/headless SCM readiness (#2275)");
|
|
337
|
+
}
|
|
338
|
+
const gates = r.skippedGates.length > 0 ? ` skipped_gates=[${r.skippedGates.join(", ")}]` : "";
|
|
339
|
+
return new ScmStubError(`${r.detail}.${gates} Remediation: install gh/ghx and authenticate in this execution env ` +
|
|
340
|
+
`(host-gh: gh auth login; injected-token: GH_TOKEN/GITHUB_TOKEN), or run SCM gates from a matched env. ` +
|
|
341
|
+
`Refs #2275.`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Assert SCM binary presence for call sites that only need PATH resolution.
|
|
345
|
+
* Throws ScmStubError with #2275 diagnostic (not the bare "neither ghx nor gh" text alone).
|
|
346
|
+
*/
|
|
347
|
+
export function assertScmBinaryPresent(whichFn = defaultWhich) {
|
|
348
|
+
const report = probeScmReadiness({
|
|
349
|
+
whichFn,
|
|
350
|
+
checkAuthStatus: false,
|
|
351
|
+
depth: "shallow",
|
|
352
|
+
// Binary-only assert must not flip to missing-token just because runtime is headless.
|
|
353
|
+
githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
|
|
354
|
+
runtimeReport: getPlatformCapabilities(),
|
|
355
|
+
});
|
|
356
|
+
if (report.binary === null) {
|
|
357
|
+
throw scmNotReadyError(report);
|
|
358
|
+
}
|
|
359
|
+
return report.binary;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Fail-loud gate for SCM-dependent verbs (#2275).
|
|
363
|
+
* Throws ScmStubError when binary is absent or auth is not ready so agents
|
|
364
|
+
* never fall through into opaque gh spawn/auth-prompt failures.
|
|
365
|
+
*
|
|
366
|
+
* Process-scoped cache: the first successful probe is reused for the rest of
|
|
367
|
+
* the process so hot call paths do not re-run `gh auth status` every time.
|
|
368
|
+
* Pass `force: true` to re-probe (tests / after credential injection).
|
|
369
|
+
*/
|
|
370
|
+
let cachedReadyReport = null;
|
|
371
|
+
export function requireScmReady(options = {}) {
|
|
372
|
+
if (!options.force && cachedReadyReport !== null && cachedReadyReport.ready) {
|
|
373
|
+
return cachedReadyReport;
|
|
374
|
+
}
|
|
375
|
+
// Hermetic unit tests (vitest) and DEFT_SCM_SKIP_AUTH_PROBE only require
|
|
376
|
+
// binary presence so CI cloud-headless without injected tokens can exercise
|
|
377
|
+
// CLI argv/REST seams. Production agents get full shallow auth probing.
|
|
378
|
+
// Session-start / scm:status always report full auth state regardless.
|
|
379
|
+
const hermeticAuthSkip = options.checkAuthStatus === undefined &&
|
|
380
|
+
(process.env.VITEST === "true" || process.env.DEFT_SCM_SKIP_AUTH_PROBE === "1");
|
|
381
|
+
if (hermeticAuthSkip) {
|
|
382
|
+
// Production path uses assertScmBinaryPresent (not test-only).
|
|
383
|
+
const binary = assertScmBinaryPresent(options.whichFn);
|
|
384
|
+
const report = {
|
|
385
|
+
ready: true,
|
|
386
|
+
binary,
|
|
387
|
+
binaryPath: options.whichFn?.(binary) ?? null,
|
|
388
|
+
authState: "unknown",
|
|
389
|
+
githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
|
|
390
|
+
runtimeMode: (options.runtimeReport ?? getPlatformCapabilities()).runtimeMode,
|
|
391
|
+
injectedTokenPresent: findInjectedToken(options.env ?? process.env) !== null,
|
|
392
|
+
depth: "shallow",
|
|
393
|
+
detail: `SCM binary present (${binary}); auth status not checked (hermetic)`,
|
|
394
|
+
remediation: null,
|
|
395
|
+
skippedGates: [],
|
|
396
|
+
login: null,
|
|
397
|
+
failureKind: null,
|
|
398
|
+
};
|
|
399
|
+
cachedReadyReport = report;
|
|
400
|
+
return report;
|
|
401
|
+
}
|
|
402
|
+
const report = probeScmReadiness({
|
|
403
|
+
...options,
|
|
404
|
+
depth: options.depth ?? "shallow",
|
|
405
|
+
checkAuthStatus: options.checkAuthStatus ?? true,
|
|
406
|
+
});
|
|
407
|
+
if (!report.ready) {
|
|
408
|
+
throw scmNotReadyError(report);
|
|
409
|
+
}
|
|
410
|
+
cachedReadyReport = report;
|
|
411
|
+
return report;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Clear the process-scoped readiness cache.
|
|
415
|
+
* Used by tests and by long-running processes after credential injection.
|
|
416
|
+
*/
|
|
417
|
+
export function clearScmReadyCache() {
|
|
418
|
+
cachedReadyReport = null;
|
|
419
|
+
}
|
|
420
|
+
export { findInjectedToken, GITHUB_AUTH_MODE_HOST_GH, GITHUB_AUTH_MODE_INJECTED_TOKEN };
|
|
421
|
+
//# sourceMappingURL=readiness.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch promote: proposed/ -> pending/ for multi-scope pins (#3011 / epic #3009).
|
|
3
|
+
*
|
|
4
|
+
* Keeps one-active-implement intact: this only stages scopes into pending/.
|
|
5
|
+
* Activate + implement remain one-at-a-time.
|
|
6
|
+
*/
|
|
7
|
+
export interface BatchPromoteOptions {
|
|
8
|
+
/** Explicit files (absolute or project-relative). Empty = all proposed/ artifacts. */
|
|
9
|
+
readonly files?: readonly string[];
|
|
10
|
+
readonly force?: boolean;
|
|
11
|
+
readonly projectRoot?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BatchPromoteResult {
|
|
14
|
+
readonly ok: boolean;
|
|
15
|
+
readonly promoted: number;
|
|
16
|
+
readonly skipped: string[];
|
|
17
|
+
readonly messages: string[];
|
|
18
|
+
readonly exitCode: number;
|
|
19
|
+
readonly wipCapOverride?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Promote one or more proposed scopes to pending/, checking WIP once for the
|
|
23
|
+
* full batch size before any move (#3011).
|
|
24
|
+
*/
|
|
25
|
+
export declare function batchPromote(options?: BatchPromoteOptions): BatchPromoteResult;
|
|
26
|
+
//# sourceMappingURL=batch-promote.d.ts.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch promote: proposed/ -> pending/ for multi-scope pins (#3011 / epic #3009).
|
|
3
|
+
*
|
|
4
|
+
* Keeps one-active-implement intact: this only stages scopes into pending/.
|
|
5
|
+
* Activate + implement remain one-at-a-time.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { hasArtifactSuffix, resolveLifecycleFolder } from "../layout/resolve.js";
|
|
10
|
+
import { stripTrailingPathSeparators } from "../text/redos-safe.js";
|
|
11
|
+
import { resolveProjectRoot } from "./project-context.js";
|
|
12
|
+
import { recordWipCapOverride, runTransition } from "./transition.js";
|
|
13
|
+
import { checkWipCapForAdditional, formatWipCapRefusal } from "./wip-cap-check.js";
|
|
14
|
+
/** True when path (and its realpath, if resolvable) stay inside projectRoot. */
|
|
15
|
+
function isPathInsideProject(projectRoot, absPath) {
|
|
16
|
+
const root = resolve(projectRoot);
|
|
17
|
+
const abs = resolve(absPath);
|
|
18
|
+
const rel = relative(root, abs);
|
|
19
|
+
if (rel === "" || rel.startsWith(`..${sep}`) || rel.startsWith("..") || isAbsolute(rel)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
// Reject in-project symlinks that resolve outside the tree (#3011 Greptile P1).
|
|
23
|
+
try {
|
|
24
|
+
const realRoot = realpathSync(root);
|
|
25
|
+
const realAbs = realpathSync(abs);
|
|
26
|
+
const realRel = relative(realRoot, realAbs);
|
|
27
|
+
if (realRel === "" ||
|
|
28
|
+
realRel.startsWith(`..${sep}`) ||
|
|
29
|
+
realRel.startsWith("..") ||
|
|
30
|
+
isAbsolute(realRel)) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Unreadable realpath — refuse rather than promote through a broken link.
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Promote one or more proposed scopes to pending/, checking WIP once for the
|
|
42
|
+
* full batch size before any move (#3011).
|
|
43
|
+
*/
|
|
44
|
+
export function batchPromote(options = {}) {
|
|
45
|
+
const root = resolveProjectRoot(options.projectRoot);
|
|
46
|
+
if (root === null) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
promoted: 0,
|
|
50
|
+
skipped: [],
|
|
51
|
+
messages: ["Could not resolve project root (pass --project-root)."],
|
|
52
|
+
exitCode: 2,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const candidates = resolvePromoteCandidates(root, options.files ?? []);
|
|
56
|
+
if (candidates.error !== null) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
promoted: 0,
|
|
60
|
+
skipped: [],
|
|
61
|
+
messages: [candidates.error],
|
|
62
|
+
exitCode: 2,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const files = candidates.files;
|
|
66
|
+
if (files.length === 0) {
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
promoted: 0,
|
|
70
|
+
skipped: [],
|
|
71
|
+
messages: ["Batch promote: 0 promoted (no proposed/ scopes matched)."],
|
|
72
|
+
exitCode: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const capCheck = checkWipCapForAdditional(root, files.length, options.force === true);
|
|
76
|
+
if (!capCheck.allowed) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
promoted: 0,
|
|
80
|
+
skipped: [],
|
|
81
|
+
messages: [
|
|
82
|
+
formatWipCapRefusal(capCheck) +
|
|
83
|
+
`\nBatch promote needs room for ${files.length} additional scope(s); ` +
|
|
84
|
+
`current WIP is ${capCheck.count}/${capCheck.cap}.`,
|
|
85
|
+
],
|
|
86
|
+
exitCode: 1,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const skipped = [];
|
|
90
|
+
const messages = [];
|
|
91
|
+
let promoted = 0;
|
|
92
|
+
for (const filePath of files) {
|
|
93
|
+
// Re-validate containment immediately before use (TOCTOU: path may become
|
|
94
|
+
// an out-of-tree symlink after the initial candidate scan) (#3011 P1).
|
|
95
|
+
if (!isPathInsideProject(root, filePath)) {
|
|
96
|
+
skipped.push(`${filePath.split(/[/\\]/).pop() ?? filePath}: path escapes project root at promote time`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const result = runTransition("promote", filePath);
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
promoted += 1;
|
|
102
|
+
messages.push(result.message);
|
|
103
|
+
if (capCheck.forceOverride) {
|
|
104
|
+
recordWipCapOverride(filePath, root, capCheck);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
skipped.push(`${filePath.split(/[/\\]/).pop() ?? filePath}: ${result.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (capCheck.forceOverride && promoted > 0) {
|
|
112
|
+
messages.push(`WIP cap exceeded (count_before=${capCheck.count}, cap=${capCheck.cap}, ` +
|
|
113
|
+
`batch=${files.length}); promote allowed via --force. audit tagged wip_cap_override (#1124/#3011).`);
|
|
114
|
+
}
|
|
115
|
+
messages.unshift(`Batch promote: ${promoted} promoted, ${skipped.length} skipped` +
|
|
116
|
+
(files.length > 0 ? ` (candidates=${files.length})` : "") +
|
|
117
|
+
".");
|
|
118
|
+
return {
|
|
119
|
+
ok: skipped.length === 0,
|
|
120
|
+
promoted,
|
|
121
|
+
skipped,
|
|
122
|
+
messages,
|
|
123
|
+
// Partial batch is failure for automation (#3011 Greptile P1).
|
|
124
|
+
exitCode: skipped.length === 0 ? 0 : 1,
|
|
125
|
+
wipCapOverride: capCheck.forceOverride && promoted > 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function resolvePromoteCandidates(projectRoot, explicit) {
|
|
129
|
+
if (explicit.length > 0) {
|
|
130
|
+
const resolved = [];
|
|
131
|
+
for (const raw of explicit) {
|
|
132
|
+
const stripped = stripTrailingPathSeparators(raw.trim());
|
|
133
|
+
if (stripped.length === 0) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const abs = isAbsolute(stripped) ? resolve(stripped) : resolve(projectRoot, stripped);
|
|
137
|
+
if (!existsSync(abs)) {
|
|
138
|
+
return { files: [], error: `File not found: ${abs}` };
|
|
139
|
+
}
|
|
140
|
+
if (!isPathInsideProject(projectRoot, abs)) {
|
|
141
|
+
return {
|
|
142
|
+
files: [],
|
|
143
|
+
error: `Path escapes project root (refusing out-of-tree scope): ${abs}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const base = abs.split(/[/\\]/).pop() ?? "";
|
|
147
|
+
if (!hasArtifactSuffix(base)) {
|
|
148
|
+
return {
|
|
149
|
+
files: [],
|
|
150
|
+
error: `Not a vBRIEF file (expected .vbrief.json or .xbrief.json): ${base}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
resolved.push(abs);
|
|
154
|
+
}
|
|
155
|
+
return { files: resolved, error: null };
|
|
156
|
+
}
|
|
157
|
+
const proposedDir = resolveLifecycleFolder(projectRoot, "proposed");
|
|
158
|
+
if (!existsSync(proposedDir)) {
|
|
159
|
+
return { files: [], error: null };
|
|
160
|
+
}
|
|
161
|
+
const files = readdirSync(proposedDir)
|
|
162
|
+
.filter((name) => hasArtifactSuffix(name))
|
|
163
|
+
.sort()
|
|
164
|
+
.map((name) => join(proposedDir, name));
|
|
165
|
+
return { files, error: null };
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=batch-promote.js.map
|
package/dist/scope/index.d.ts
CHANGED
package/dist/scope/index.js
CHANGED