autonomous-sdlc-harness 0.5.0 → 0.6.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.
Files changed (35) hide show
  1. package/dist/commands/init.js +17 -8
  2. package/dist/commands/init.js.map +1 -1
  3. package/dist/config/check.js +27 -5
  4. package/dist/config/check.js.map +1 -1
  5. package/dist/config/model.js +54 -11
  6. package/dist/config/model.js.map +1 -1
  7. package/dist/core/pluginIdentity.js +2 -0
  8. package/dist/core/pluginIdentity.js.map +1 -1
  9. package/dist/core/writer.js +1 -0
  10. package/dist/core/writer.js.map +1 -1
  11. package/dist/doctor/checks.js +317 -40
  12. package/dist/doctor/checks.js.map +1 -1
  13. package/dist/generators/githubWorkflows.js +21 -11
  14. package/dist/generators/githubWorkflows.js.map +1 -1
  15. package/dist/remote/githubActions.js +63 -4
  16. package/dist/remote/githubActions.js.map +1 -1
  17. package/dist/retrieval/pythonBackend.js +114 -0
  18. package/dist/retrieval/pythonBackend.js.map +1 -0
  19. package/dist/retrieval/setup.js +8 -0
  20. package/dist/retrieval/setup.js.map +1 -1
  21. package/package.json +1 -1
  22. package/templates/README.md +1 -1
  23. package/templates/github/workflows/harness-control.yml +184 -0
  24. package/templates/github/workflows/harness-resume.yml +7 -0
  25. package/templates/github/workflows/harness-run.yml +100 -1
  26. package/templates/github/workflows/harness-trigger.yml +5 -4
  27. package/templates/scripts/README.md +1 -1
  28. package/templates/scripts/autonomous-watcher.sh +29 -2
  29. package/templates/scripts/docs-search-server.sh +88 -17
  30. package/templates/scripts/lib/harness-run-lib.sh +205 -26
  31. package/templates/scripts/remote-run.sh +2617 -150
  32. package/templates/scripts/scratch-run.sh +54 -73
  33. package/templates/state-dir/README-root.md +1 -1
  34. package/templates/state-dir/scratch/README.md +4 -2
  35. package/templates/state-dir/user_reviews/README.md +2 -2
@@ -25,7 +25,7 @@
25
25
  * remote-execution switch is `config/model.ts`'s {@link remoteExecutionApplies} and the
26
26
  * issue-trigger switch its {@link forgeTriggerApplies}, the workflow paths and the binary run as
27
27
  * `gh` are `remote/githubActions.ts`'s ({@link WORKFLOW_RUN_PATH}, {@link WORKFLOW_RESUME_PATH},
28
- * {@link WORKFLOW_TRIGGER_PATH}, {@link GH_CLI_VARIABLE}, {@link ghCli}), whether a ref carries a
28
+ * {@link WORKFLOW_TRIGGER_PATH}, {@link WORKFLOW_CONTROL_PATH}, {@link GH_CLI_VARIABLE}, {@link ghCli}), whether a ref carries a
29
29
  * file is `core/git.ts`'s {@link pathAtRef}, and
30
30
  * the writability probe is the write engine's {@link probeWritable}. A
31
31
  * check that wanted a slightly different answer would be a second definition of the thing being
@@ -70,13 +70,17 @@
70
70
  * nothing. `retrieval-index` spawns a child of this CLI running `docs index --in-memory`: a child
71
71
  * because {@link Check.run} is synchronous and the store is not, and because the child is the
72
72
  * installation whose optional peers resolve beside it. It builds in memory and exits, so it starts
73
- * no server and writes nothing.
73
+ * no server and writes nothing. The three `retrieval-python-*` checks share one child running the
74
+ * Python package's own `self-check` and grade its three lines; that child starts no server either.
74
75
  *
75
76
  * ## What this module deliberately does not do
76
77
  *
77
78
  * - **It writes nothing** beyond {@link probeWritable}'s temp file, which that function removes with
78
79
  * an in-process `fs.rm` on a single file — never a shelled-out recursive removal, which a
79
- * user-level `permissions.deny` can silently block (`cli.ts`'s header).
80
+ * user-level `permissions.deny` can silently block (`cli.ts`'s header). One exception:
81
+ * `retrieval-python-index` refreshes the Python backend's index **in its database**, because
82
+ * `self-check`'s `index` question builds it there — the same write the server's first query makes.
83
+ * It writes nothing in the repository or the machine cache.
80
84
  * - **It repairs nothing.** Every failure names what to run — `init`, `init --force`, an edit to one
81
85
  * config key — and `doctor` stays a command that is safe to run against a repository at any time.
82
86
  */
@@ -85,10 +89,11 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, statSyn
85
89
  import { delimiter, join, posix, resolve as resolvePath } from 'node:path';
86
90
  import { formatProblem } from '../config/check.js';
87
91
  import { loadConfig } from '../config/io.js';
88
- import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, FORGE_KINDS, forgeTriggerApplies, isPlaceholder, LAYER_CATCH_ALL_PATH, remoteExecutionApplies, retrievalApplies, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
92
+ import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, FORGE_KINDS, forgeTriggerApplies, isPlaceholder, LAYER_CATCH_ALL_PATH, pythonRetrievalApplies, remoteExecutionApplies, retrievalApplies, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
89
93
  import { branchResolves, checkedOutBranch, commitsAhead, configuredRemotes, hasCommits, mainWorktreeRoot, pathAtRef, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
90
94
  import { isJsonObject, readJsonFile } from '../core/json.js';
91
95
  import { defaultBranchPushCommand, defaultBranchPushReason, WORKFLOW_SCOPE_COMMAND, WORKFLOW_SCOPE_REASON, } from '../core/defaultBranchPush.js';
96
+ import { internal } from '../core/errors.js';
92
97
  import { layerCoverage } from '../core/layerCoverage.js';
93
98
  import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
94
99
  import { nameList } from '../core/nameList.js';
@@ -113,9 +118,11 @@ import { selectedStateDirs } from '../generators/stateDir.js';
113
118
  import { machineConfigDir } from '../machine/paths.js';
114
119
  import { installedPluginsPath, knownMarketplacesPath, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
115
120
  import { inspect, readRegistry, registryPath } from '../machine/registry.js';
116
- import { API_KEY_SECRET, CLI_VERSION_VARIABLE, DEFAULT_GH_CLI, DEFAULT_TRIGGER_LABEL, GH_CLI_VARIABLE, ghCli, GIT_TOKEN_SECRET, OAUTH_TOKEN_SECRET, PUSH_URL_SECRET, REMOTE_STOP_VARIABLE, renderedCliVersions, runGh, RUNNER_VARIABLE, TRIGGER_ALLOWED_BOTS_VARIABLE, TRIGGER_LABEL_VARIABLE, WORKFLOW_RESUME_FILE, WORKFLOW_RESUME_PATH, WORKFLOW_RUN_FILE, WORKFLOW_RUN_PATH, WORKFLOW_TRIGGER_FILE, WORKFLOW_TRIGGER_PATH, } from '../remote/githubActions.js';
121
+ import { API_KEY_SECRET, CLI_VERSION_VARIABLE, DEFAULT_GH_CLI, COMMAND_HANDLE, COMMAND_VERBS, DEFAULT_TRIGGER_LABEL, GH_CLI_VARIABLE, ghCli, GIT_TOKEN_SECRET, LEGACY_TRIGGER_LABEL, OAUTH_TOKEN_SECRET, PR_CREATE_SETTING, PR_CREATE_SETTING_PATH, PUSH_URL_SECRET, REMOTE_STOP_VARIABLE, renderedCliVersions, runGh, RUNNER_VARIABLE, TRIGGER_ALLOWED_BOTS_VARIABLE, TRIGGER_LABEL_VARIABLE, triggerFallbackLabel, WORKFLOW_CONTROL_FILE, WORKFLOW_CONTROL_PATH, WORKFLOW_RESUME_FILE, WORKFLOW_RESUME_PATH, WORKFLOW_RUN_FILE, WORKFLOW_RUN_PATH, WORKFLOW_TRIGGER_FILE, WORKFLOW_TRIGGER_PATH, } from '../remote/githubActions.js';
117
122
  import { modelFilesPresent } from '../retrieval/models.js';
123
+ import { launcherSearchPath, parseSelfCheck, PYTHON_DATABASE_URL_VARIABLE, PYTHON_FETCH_MODELS_SUB_COMMAND, PYTHON_RETRIEVAL_COMMAND, PYTHON_SELF_CHECK_SUB_COMMAND, pythonDatabaseUrl, SELF_CHECK_INDEX_NOT_ATTEMPTED, } from '../retrieval/pythonBackend.js';
118
124
  import { retrievalCliEntry, retrievalModelCacheDir, retrievalRuntimeDir, retrievalRuntimeState, } from '../retrieval/runtime.js';
125
+ import { DOCS_SERVER_NAME } from '../retrieval/server.js';
119
126
  /**
120
127
  * How the CLI is typed, for every remedy that tells an operator what to run next. The `npx` prefix
121
128
  * is not decoration: the rule for which occurrences carry it is stated once in `commands/init.ts`,
@@ -149,6 +156,13 @@ const REMOTE_RUN_SCRIPT = 'remote-run.sh';
149
156
  * YAML file spells this endpoint.
150
157
  */
151
158
  const ARTIFACT_RETENTION_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention';
159
+ /**
160
+ * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the *Allow GitHub Actions to create and approve
161
+ * pull requests* setting from — `can_approve_pull_request_reviews`. Local for
162
+ * {@link ARTIFACT_RETENTION_ENDPOINT}'s reason. A workflow's own token cannot read it; a person's `gh`
163
+ * usually can.
164
+ */
165
+ const PR_SETTING_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/workflow';
152
166
  /** Below this many days of artifact retention {@link REMOTE_GITHUB_CHECK} warns; argued there. */
153
167
  const ARTIFACT_RETENTION_WARN_DAYS = 30;
154
168
  /** The permission lists a generated profile carries, in the order the template writes them. */
@@ -2012,6 +2026,20 @@ function retentionDaysOf(stdout) {
2012
2026
  const days = parsed.days;
2013
2027
  return typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : undefined;
2014
2028
  }
2029
+ /** The boolean `can_approve_pull_request_reviews` of a workflow-permissions answer, or `undefined` for any other shape. */
2030
+ function prApprovalSettingOf(stdout) {
2031
+ let parsed;
2032
+ try {
2033
+ parsed = JSON.parse(stdout);
2034
+ }
2035
+ catch {
2036
+ return undefined;
2037
+ }
2038
+ if (!isJsonObject(parsed))
2039
+ return undefined;
2040
+ const allowed = parsed.can_approve_pull_request_reviews;
2041
+ return typeof allowed === 'boolean' ? allowed : undefined;
2042
+ }
2015
2043
  /**
2016
2044
  * What GitHub says about the remote setup — asked only under {@link CheckContext.probeGithub}.
2017
2045
  *
@@ -2024,15 +2052,29 @@ function retentionDaysOf(stdout) {
2024
2052
  * not know `harness-run.yml`; neither credential secret is set.
2025
2053
  * - `warn` — `HARNESS_PUSH_URL` absent; `harness-resume.yml` unknown to GitHub; `HARNESS_REMOTE_STOP`
2026
2054
  * set; artifact retention below {@link ARTIFACT_RETENTION_WARN_DAYS} days; when
2027
- * {@link forgeTriggerApplies}, `harness-trigger.yml` unknown to GitHub, or no label named by
2028
- * `HARNESS_TRIGGER_LABEL` (default {@link DEFAULT_TRIGGER_LABEL}); and any call that timed
2029
- * out, could not reach GitHub, or answered in a shape not understood — *cannot tell* is not
2030
- * *missing*, so it never fails.
2055
+ * {@link forgeTriggerApplies}, `harness-trigger.yml` or `harness-control.yml` unknown to GitHub, no
2056
+ * label by the effective trigger name, or the pull-request setting off with no `HARNESS_GIT_TOKEN`
2057
+ * secret; and any call that timed out, could not reach GitHub, or answered in a shape not
2058
+ * understood — *cannot tell* is not *missing*, so it never fails.
2059
+ * - the pull-request setting off while the secret list was unreadable is a *cannot tell* warning,
2060
+ * never the missing-`HARNESS_GIT_TOKEN` one.
2061
+ * - the effective trigger label is `HARNESS_TRIGGER_LABEL`, else the fallback the committed
2062
+ * `harness-trigger.yml` carries ({@link triggerFallbackLabel}), else {@link DEFAULT_TRIGGER_LABEL};
2063
+ * a fallback of {@link LEGACY_TRIGGER_LABEL} is a note, since it still starts runs.
2031
2064
  * - both credential secrets present is a note, not a finding: billing follows `ANTHROPIC_API_KEY`.
2032
2065
  * - a non-empty `HARNESS_TRIGGER_ALLOWED_BOTS` is a note naming the bots, which start runs without a
2033
- * permission check. When the trigger does not apply, neither trigger read is made.
2066
+ * permission check. When the trigger does not apply, no trigger, control or pull-request-setting
2067
+ * read is made.
2034
2068
  * - the retention read refused (typically HTTP 403: the endpoint needs admin access) is a note too —
2035
- * the read is best-effort, and a collaborator without admin can still run remotely.
2069
+ * the read is best-effort, and a collaborator without admin can still run remotely. A refused
2070
+ * pull-request-setting read is a note on the same terms.
2071
+ * - the pull-request setting off with `HARNESS_GIT_TOKEN` set is a note: `deliver` opens the pull
2072
+ * request with that token instead.
2073
+ * - all three trigger answers positive — both workflows known and the label present — is confirmed on
2074
+ * every outcome that reaches the trigger reads, `fail` and `warn` included, so an unrelated finding
2075
+ * never hides it; any answer not positive is already among the warnings. An outcome returned before
2076
+ * those reads — `gh` not runnable, no usable login, or no readable answer to the login probe — asks
2077
+ * GitHub nothing about the trigger.
2036
2078
  *
2037
2079
  * **Why 30 days.** A parked run waits on a human answer and a usage-paused one on a reset, and the
2038
2080
  * `harness-state` bundle is the only remote copy of either; once the repository's retention expires
@@ -2157,9 +2199,31 @@ const REMOTE_GITHUB_CHECK = {
2157
2199
  if (trigger.answer.kind === 'refused') {
2158
2200
  warnings.push(`GitHub does not know ${WORKFLOW_TRIGGER_FILE} (${trigger.call}: ${trigger.answer.why}), so labelling an issue starts nothing: push ${WORKFLOW_TRIGGER_PATH} to the repository's default branch`);
2159
2201
  }
2202
+ const control = ask(['workflow', 'view', WORKFLOW_CONTROL_FILE]);
2203
+ if (control.answer === undefined)
2204
+ return fail(noSpawn);
2205
+ if (control.answer.kind === 'unknown')
2206
+ warnings.push(cannotTell(control.call, control.answer.why, `whether GitHub knows ${WORKFLOW_CONTROL_FILE}`));
2207
+ if (control.answer.kind === 'refused') {
2208
+ warnings.push(`GitHub does not know ${WORKFLOW_CONTROL_FILE} (${control.call}: ${control.answer.why}), so comments and reviews start nothing: push ${WORKFLOW_CONTROL_PATH} to the repository's default branch`);
2209
+ }
2160
2210
  // The label's name is a repository variable, so an unread variable listing leaves nothing to compare.
2161
2211
  const configured = variableValues?.get(TRIGGER_LABEL_VARIABLE)?.trim() ?? '';
2162
- const labelName = configured === '' ? DEFAULT_TRIGGER_LABEL : configured;
2212
+ let labelName = configured;
2213
+ if (configured === '') {
2214
+ // Unset, the committed workflow's own fallback starts a run; it is never re-rendered by an upgrade.
2215
+ let committed;
2216
+ try {
2217
+ committed = triggerFallbackLabel(readFileSync(join(root, ...WORKFLOW_TRIGGER_PATH.split('/')), 'utf8'));
2218
+ }
2219
+ catch {
2220
+ committed = undefined;
2221
+ }
2222
+ labelName = committed ?? DEFAULT_TRIGGER_LABEL;
2223
+ if (variableValues !== undefined && labelName === LEGACY_TRIGGER_LABEL) {
2224
+ notes.push(`${WORKFLOW_TRIGGER_PATH} was written by an earlier release and falls back to \`${LEGACY_TRIGGER_LABEL}\`, which keeps starting runs; \`${CLI} init --force\` re-renders it and the scripts to \`${DEFAULT_TRIGGER_LABEL}\`, and setting ${TRIGGER_LABEL_VARIABLE} keeps a name of your choosing under either`);
2225
+ }
2226
+ }
2163
2227
  let labelFound = false;
2164
2228
  if (variableValues === undefined) {
2165
2229
  warnings.push(`cannot tell whether the trigger label exists: its name is the ${TRIGGER_LABEL_VARIABLE} variable, and ${variables.call} gave no readable answer`);
@@ -2186,14 +2250,39 @@ const REMOTE_GITHUB_CHECK = {
2186
2250
  notes.push(`${TRIGGER_ALLOWED_BOTS_VARIABLE} admits ${nameList(bots)}, each of which can start a run without a permission check`);
2187
2251
  }
2188
2252
  }
2189
- if (trigger.answer.kind === 'answered' && labelFound)
2190
- triggerKnown = `; GitHub knows ${WORKFLOW_TRIGGER_FILE} and the label \`${labelName}\` exists`;
2253
+ if (trigger.answer.kind === 'answered' && control.answer.kind === 'answered' && labelFound) {
2254
+ triggerKnown = `; GitHub knows ${WORKFLOW_TRIGGER_FILE} and ${WORKFLOW_CONTROL_FILE}, and the label \`${labelName}\` exists`;
2255
+ }
2256
+ const prSetting = ask(['api', PR_SETTING_ENDPOINT]);
2257
+ if (prSetting.answer === undefined)
2258
+ return fail(noSpawn);
2259
+ if (prSetting.answer.kind === 'unknown') {
2260
+ warnings.push(cannotTell(prSetting.call, prSetting.answer.why, `whether a run's own token may open its pull request (${PR_CREATE_SETTING})`));
2261
+ }
2262
+ else if (prSetting.answer.kind === 'refused') {
2263
+ notes.push(`the pull-request setting was not checked: ${prSetting.call} may need more access than this login has (${prSetting.answer.why})`);
2264
+ }
2265
+ else {
2266
+ const allowed = prApprovalSettingOf(prSetting.answer.stdout);
2267
+ if (allowed === undefined) {
2268
+ warnings.push(`cannot tell whether a run's own token may open its pull request: ${prSetting.call} answered in a shape this check does not read`);
2269
+ }
2270
+ else if (!allowed && secretNames === undefined) {
2271
+ warnings.push(`${PR_CREATE_SETTING} is off, and whether ${GIT_TOKEN_SECRET} is set could not be read, so a completed run may not be able to open its draft pull request: turn the setting on under ${PR_CREATE_SETTING_PATH}, or confirm ${GIT_TOKEN_SECRET} is a repository secret`);
2272
+ }
2273
+ else if (!allowed && secretNames?.has(GIT_TOKEN_SECRET) === true) {
2274
+ notes.push(`${PR_CREATE_SETTING} is off, so a completed run opens its draft pull request with ${GIT_TOKEN_SECRET}`);
2275
+ }
2276
+ else if (!allowed) {
2277
+ warnings.push(`${PR_CREATE_SETTING} is off and ${GIT_TOKEN_SECRET} is not a repository secret, so a completed run cannot open its draft pull request with the job's token: turn it on under ${PR_CREATE_SETTING_PATH}, or set ${GIT_TOKEN_SECRET} with \`gh secret set ${GIT_TOKEN_SECRET}\``);
2278
+ }
2279
+ }
2191
2280
  }
2192
2281
  const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
2193
2282
  if (failures.length > 0)
2194
- return fail(`${[...failures, ...warnings].join('; ')}${noted}`);
2283
+ return fail(`${[...failures, ...warnings].join('; ')}${triggerKnown}${noted}`);
2195
2284
  if (warnings.length > 0)
2196
- return warn(`${warnings.join('; ')}${noted}`);
2285
+ return warn(`${warnings.join('; ')}${triggerKnown}${noted}`);
2197
2286
  const kept = retentionDays === undefined ? '' : `, and the repository keeps artifacts for ${retentionDays} days`;
2198
2287
  return pass(`gh is authenticated, GitHub knows ${WORKFLOW_RUN_FILE} and ${WORKFLOW_RESUME_FILE}, a credential secret and ${PUSH_URL_SECRET} are set, and remote runs use ${runner}${kept}${triggerKnown}${noted}`);
2199
2288
  },
@@ -2206,20 +2295,21 @@ const REMOTE_GITHUB_CHECK = {
2206
2295
  * check speaks only about a value outside {@link FORGE_KINDS}. An absent key is a decision not yet
2207
2296
  * made, and this line is where an operator learns the decision exists.
2208
2297
  *
2209
- * **Graded from local evidence only** (the module header's choice 3): the trigger workflow, one git
2210
- * ref and the configuration. Whether the label exists and whether GitHub knows the workflow are left
2211
- * to `--check-github`.
2298
+ * **Graded from local evidence only** (the module header's choice 3): the two forge workflows —
2299
+ * {@link WORKFLOW_TRIGGER_FILE} and {@link WORKFLOW_CONTROL_FILE}, graded the same way — one git ref
2300
+ * and the configuration. What GitHub says — whether the label exists, which workflows it knows, and
2301
+ * the pull-request setting — is left to {@link REMOTE_GITHUB_CHECK}: the `github` pass names
2302
+ * `--check-github` when it is absent, and under it names that check, which {@link CHECKS} runs first.
2212
2303
  *
2213
2304
  * **Its worst grade is `warn`**: no `forge` state stops a run, because the inbox path works whatever
2214
2305
  * the key says. The three `warn`s are all `github`: remote execution off, because a run started from
2215
- * GitHub always executes through {@link WORKFLOW_RUN_FILE}; the trigger workflow absent; and the
2216
- * trigger workflow not carried by `origin/<defaultBranch>`, because GitHub runs an `issues` workflow
2217
- * only from its default branch — that last with {@link REMOTE_EXECUTION_CHECK}'s push remedy and its
2218
- * two *not graded* notes, on the same reasoning.
2219
- *
2220
- * Draft-pull-request output and comment park-and-ask are not graded, because nothing implements them
2221
- * yet; the `github` pass names them as still to come, so the line never implies the whole coupling
2222
- * exists. A value outside {@link FORGE_KINDS} is the config check's `fail`, and is not graded here.
2306
+ * GitHub always executes through {@link WORKFLOW_RUN_FILE}; a forge workflow absent; and a forge
2307
+ * workflow not carried by `origin/<defaultBranch>`, because GitHub runs an `issues` or
2308
+ * `issue_comment` workflow only from its default branch — that last with
2309
+ * {@link REMOTE_EXECUTION_CHECK}'s push remedy and its two *not graded* notes, on the same reasoning.
2310
+ * Each warn names every file it is about, so two absent files are one line naming both.
2311
+ *
2312
+ * A value outside {@link FORGE_KINDS} is the config check's `fail`, and is not graded here.
2223
2313
  */
2224
2314
  const FORGE_CHECK = {
2225
2315
  id: 'forge',
@@ -2231,7 +2321,9 @@ const FORGE_CHECK = {
2231
2321
  return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
2232
2322
  const root = ctx.repoRoot;
2233
2323
  const forge = ctx.config.forge;
2234
- const triggerPresent = existsSync(join(root, ...WORKFLOW_TRIGGER_PATH.split('/')));
2324
+ const forgeWorkflows = [WORKFLOW_TRIGGER_PATH, WORKFLOW_CONTROL_PATH];
2325
+ const presentWorkflows = forgeWorkflows.filter((path) => existsSync(join(root, ...path.split('/'))));
2326
+ const isAre = (paths) => (paths.length === 1 ? 'is' : 'are');
2235
2327
  if (forge === undefined) {
2236
2328
  return pass(`forge is not set, so the decision is not yet made and nothing starts a run from an issue. \`${CLI} config set forge github\`, with execution.target github-actions, turns the issue trigger on; \`${CLI} config set forge none\` records that this repository has no forge integration`);
2237
2329
  }
@@ -2239,8 +2331,8 @@ const FORGE_CHECK = {
2239
2331
  return pass('not graded, because forge holds a value this CLI does not know (see the config check)');
2240
2332
  }
2241
2333
  if (forge === 'none') {
2242
- const left = triggerPresent
2243
- ? `. ${WORKFLOW_TRIGGER_PATH} is present and unused: the job it starts refuses every event while forge is not github`
2334
+ const left = presentWorkflows.length > 0
2335
+ ? `. ${nameList(presentWorkflows)} ${isAre(presentWorkflows)} present and unused: the job each starts refuses every event while forge is not github`
2244
2336
  : '';
2245
2337
  return pass(`forge is none: this repository has no forge integration, and nothing starts a run from an issue${left}`);
2246
2338
  }
@@ -2251,24 +2343,34 @@ const FORGE_CHECK = {
2251
2343
  return warn(`forge is github, but remote execution is off (\`execution.target\`), so no trigger workflow is written: a run started from GitHub always executes through ${WORKFLOW_RUN_FILE}, because GitHub cannot reach this machine. Run \`${CLI} config set execution.target github-actions\`, then \`${CLI} init\`; to run such runs on your own hardware, register a self-hosted runner and name its label in the ${RUNNER_VARIABLE} repository variable (docs/remote-execution.md → ## 8. Choosing a runner)`);
2252
2344
  }
2253
2345
  const on = 'forge is github and remote execution is on';
2254
- if (!triggerPresent) {
2255
- return warn(`${on}, but ${WORKFLOW_TRIGGER_PATH} is absent, so labelling an issue starts nothing: re-run \`${CLI} init\`, which writes it create-if-absent`);
2346
+ const startsNothing = {
2347
+ [WORKFLOW_TRIGGER_PATH]: 'labelling an issue starts nothing',
2348
+ [WORKFLOW_CONTROL_PATH]: 'comments and reviews start nothing',
2349
+ };
2350
+ const consequence = (paths) => paths.map((path) => startsNothing[path]).join(', and ');
2351
+ const absent = forgeWorkflows.filter((path) => !presentWorkflows.includes(path));
2352
+ if (absent.length > 0) {
2353
+ return warn(`${on}, but ${nameList(absent)} ${isAre(absent)} absent, so ${consequence(absent)}: re-run \`${CLI} init\`, which writes ${absent.length === 1 ? 'it' : 'them'} create-if-absent`);
2256
2354
  }
2257
2355
  const branch = ctx.config.defaultBranch;
2258
2356
  let carried;
2259
2357
  if (typeof branch !== 'string' || branch.trim() === '') {
2260
- carried = `; whether GitHub's default branch carries it is not graded, because defaultBranch is not a branch name (see the config check)`;
2358
+ carried = `; whether GitHub's default branch carries them is not graded, because defaultBranch is not a branch name (see the config check)`;
2261
2359
  }
2262
2360
  else if (!remoteTrackingBranchResolves(root, branch)) {
2263
- carried = `; whether origin/${branch} carries it is not graded, because there is no origin/${branch} (see the remote check)`;
2264
- }
2265
- else if (!pathAtRef(root, `origin/${branch}`, WORKFLOW_TRIGGER_PATH)) {
2266
- return warn(`${on}, but origin/${branch} does not carry ${WORKFLOW_TRIGGER_PATH}, as this checkout last fetched it, and GitHub runs an issues workflow only from its default branch, so labelling an issue starts nothing yet: commit it, then run \`${WORKFLOW_SCOPE_COMMAND}\`, then \`${defaultBranchPushCommand(branch)}\`. ${WORKFLOW_SCOPE_REASON} ${defaultBranchPushReason(branch)}`);
2361
+ carried = `; whether origin/${branch} carries them is not graded, because there is no origin/${branch} (see the remote check)`;
2267
2362
  }
2268
2363
  else {
2269
- carried = ` and origin/${branch} carries it`;
2364
+ const uncarried = forgeWorkflows.filter((path) => !pathAtRef(root, `origin/${branch}`, path));
2365
+ if (uncarried.length > 0) {
2366
+ return warn(`${on}, but origin/${branch} does not carry ${nameList(uncarried)}, as this checkout last fetched it, and GitHub runs an issues or issue_comment workflow only from its default branch, so ${consequence(uncarried)} yet: commit ${uncarried.length === 1 ? 'it' : 'them'}, then run \`${WORKFLOW_SCOPE_COMMAND}\`, then \`${defaultBranchPushCommand(branch)}\`. ${WORKFLOW_SCOPE_REASON} ${defaultBranchPushReason(branch)}`);
2367
+ }
2368
+ carried = ` and origin/${branch} carries them`;
2270
2369
  }
2271
- return pass(`${on}: ${WORKFLOW_TRIGGER_PATH} is present${carried}. Labelling an issue with the ${TRIGGER_LABEL_VARIABLE} label (default \`${DEFAULT_TRIGGER_LABEL}\`) starts a task run; draft-pull-request output and comment park-and-ask are still to come. What this cannot see lives on GitHub — whether that label exists and whether GitHub knows ${WORKFLOW_TRIGGER_FILE}; \`${CLI} doctor --check-github\` asks GitHub`);
2370
+ const asked = ctx.probeGithub
2371
+ ? `the ${REMOTE_GITHUB_CHECK.id} check above reports what GitHub says`
2372
+ : `\`${CLI} doctor --check-github\` asks GitHub`;
2373
+ return pass(`${on}: ${WORKFLOW_TRIGGER_PATH} and ${WORKFLOW_CONTROL_PATH} are present${carried}. Labelling an issue with the ${TRIGGER_LABEL_VARIABLE} label (default \`${DEFAULT_TRIGGER_LABEL}\`) starts a task run; a \`${COMMAND_HANDLE} <verb>\` comment (${nameList([...COMMAND_VERBS])}) steers it; a review requesting changes on the run's pull request starts a user-review round; and a completed run opens a draft pull request. What this cannot see lives on GitHub — the label, the workflows GitHub knows (${WORKFLOW_TRIGGER_FILE}, ${WORKFLOW_CONTROL_FILE}) and the pull-request setting; ${asked}`);
2272
2374
  },
2273
2375
  };
2274
2376
  /**
@@ -3946,6 +4048,10 @@ const BROWSER_WIRING_CHECK = {
3946
4048
  };
3947
4049
  /** The pass every retrieval check gives when {@link retrievalApplies} is false. */
3948
4050
  const RETRIEVAL_OFF = 'docs.retrieval is off (it needs phases.docs and docs.retrieval both true), so no RAG library is expected and none was resolved';
4051
+ /** The pass every `retrieval-python-*` check gives when retrieval is on under another backend. */
4052
+ const PYTHON_NOT_SELECTED = 'docs.retrieval is on with docs.retrievalBackend not python, so the launcher starts the TypeScript runtime and nothing of the Python backend is expected or checked';
4053
+ /** The pass the three TypeScript retrieval checks give when {@link pythonRetrievalApplies} is true. */
4054
+ const TYPESCRIPT_NOT_SELECTED = 'docs.retrievalBackend is python, so the launcher starts the Python backend rather than this runtime and it is not graded; init still installs it, so switching back costs nothing';
3949
4055
  /** How many missing model files {@link RETRIEVAL_MODEL_CACHE_CHECK} names before it counts the rest. */
3950
4056
  const MISSING_MODEL_FILES_NAMED = 5;
3951
4057
  /**
@@ -3973,6 +4079,8 @@ const RETRIEVAL_DEPENDENCIES_CHECK = {
3973
4079
  return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
3974
4080
  if (!retrievalApplies(ctx.config))
3975
4081
  return pass(RETRIEVAL_OFF);
4082
+ if (pythonRetrievalApplies(ctx.config))
4083
+ return pass(TYPESCRIPT_NOT_SELECTED);
3976
4084
  const runtime = retrievalRuntimeDir();
3977
4085
  const state = retrievalRuntimeState();
3978
4086
  if (state.installed) {
@@ -3992,6 +4100,8 @@ const RETRIEVAL_MODEL_CACHE_CHECK = {
3992
4100
  return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
3993
4101
  if (!retrievalApplies(ctx.config))
3994
4102
  return pass(RETRIEVAL_OFF);
4103
+ if (pythonRetrievalApplies(ctx.config))
4104
+ return pass(TYPESCRIPT_NOT_SELECTED);
3995
4105
  const dir = retrievalModelCacheDir();
3996
4106
  const { present, missing } = modelFilesPresent(dir);
3997
4107
  if (present)
@@ -4035,6 +4145,8 @@ const RETRIEVAL_INDEX_CHECK = {
4035
4145
  return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
4036
4146
  if (!retrievalApplies(ctx.config))
4037
4147
  return pass(RETRIEVAL_OFF);
4148
+ if (pythonRetrievalApplies(ctx.config))
4149
+ return pass(TYPESCRIPT_NOT_SELECTED);
4038
4150
  const resolved = retrievalCliEntry();
4039
4151
  if (resolved === undefined)
4040
4152
  return fail('cannot build without the RAG libraries (see retrieval-dependencies)');
@@ -4059,6 +4171,166 @@ const RETRIEVAL_INDEX_CHECK = {
4059
4171
  }
4060
4172
  },
4061
4173
  };
4174
+ const pythonSelfCheckCache = new WeakMap();
4175
+ /** The `self-check` question each `retrieval-python-*` check id grades, for the index's cross-reference. */
4176
+ const PYTHON_CHECK_ID = Object.freeze({
4177
+ packages: 'retrieval-python-dependencies',
4178
+ weights: 'retrieval-python-model-cache',
4179
+ index: 'retrieval-python-index',
4180
+ });
4181
+ /**
4182
+ * Run the Python backend's own `self-check` once per {@link CheckContext}; the three
4183
+ * `retrieval-python-*` checks grade its lines and never re-derive them (choice 1).
4184
+ *
4185
+ * The command is resolved on {@link launcherSearchPath} — the `PATH` the launcher resolves it on —
4186
+ * not on this process's own, so a pass here is a pass on the server the launcher starts.
4187
+ *
4188
+ * **`spawnSync` rather than `execFileSync`:** `self-check` exits 1 whenever a line is `FAIL`, with its
4189
+ * whole answer on stdout, and `execFileSync` would turn that answer into a thrown error.
4190
+ *
4191
+ * **The env is the server's, not the shell's.** `process.env` is overlaid by the string entries of the
4192
+ * `.mcp.json` server's `env` object, which the agent runner passes to the server, and then
4193
+ * {@link PYTHON_DATABASE_URL_VARIABLE} is set to {@link pythonDatabaseUrl}'s answer from that object
4194
+ * alone: the runner never passes a shell export, so honouring one here would grade a database the
4195
+ * server does not use.
4196
+ */
4197
+ function pythonSelfCheck(ctx, repoRoot) {
4198
+ const cached = pythonSelfCheckCache.get(ctx);
4199
+ if (cached !== undefined)
4200
+ return cached;
4201
+ const answer = runPythonSelfCheck(repoRoot);
4202
+ pythonSelfCheckCache.set(ctx, answer);
4203
+ return answer;
4204
+ }
4205
+ function runPythonSelfCheck(repoRoot) {
4206
+ const searched = launcherSearchPath(process.env['PATH'] ?? '', process.env['HOME']);
4207
+ const directory = locateOnPath(PYTHON_RETRIEVAL_COMMAND, searched);
4208
+ if (directory === undefined)
4209
+ return { kind: 'unresolved', searched };
4210
+ const mcp = readJsonFile(join(repoRoot, MCP_PATH));
4211
+ const servers = isJsonObject(mcp) ? mcp[SERVERS_KEY] : undefined;
4212
+ const server = isJsonObject(servers) ? servers[DOCS_SERVER_NAME] : undefined;
4213
+ const serverEnv = isJsonObject(server) ? server['env'] : undefined;
4214
+ const overlay = {};
4215
+ if (isJsonObject(serverEnv)) {
4216
+ for (const [key, value] of Object.entries(serverEnv)) {
4217
+ if (typeof value === 'string')
4218
+ overlay[key] = value;
4219
+ }
4220
+ }
4221
+ const databaseUrl = pythonDatabaseUrl(serverEnv);
4222
+ const child = spawnSync(join(directory, PYTHON_RETRIEVAL_COMMAND), [PYTHON_SELF_CHECK_SUB_COMMAND, '--repo', repoRoot], {
4223
+ encoding: 'utf8',
4224
+ stdio: ['ignore', 'pipe', 'pipe'],
4225
+ timeout: RETRIEVAL_INDEX_TIMEOUT_MS,
4226
+ env: { ...process.env, ...overlay, [PYTHON_DATABASE_URL_VARIABLE]: databaseUrl },
4227
+ });
4228
+ if (child.error !== undefined)
4229
+ return { kind: 'unreadable', text: messageOf(child.error) };
4230
+ const lines = child.status === 0 || child.status === 1 ? parseSelfCheck(child.stdout ?? '') : undefined;
4231
+ if (lines !== undefined)
4232
+ return { kind: 'answered', lines, databaseUrl };
4233
+ const how = child.status === null ? `stopped by ${child.signal}` : `exit status ${child.status}`;
4234
+ const said = firstLine(child.stderr ?? '') || firstLine(child.stdout ?? '');
4235
+ return { kind: 'unreadable', text: said === '' ? how : `${how}: ${said}` };
4236
+ }
4237
+ /** One line of an answered `self-check`; `parseSelfCheck` guarantees all three are present. */
4238
+ function selfCheckLine(answer, question) {
4239
+ const line = answer.lines.get(question);
4240
+ if (line === undefined)
4241
+ throw internal(`self-check answered without its ${question} line, which parseSelfCheck guarantees`);
4242
+ return line;
4243
+ }
4244
+ /**
4245
+ * Host, port and database of a connection string, with the credentials dropped — a check's text is
4246
+ * printed and may be pasted, so the password never reaches it.
4247
+ */
4248
+ function databaseLocation(url) {
4249
+ try {
4250
+ const parsed = new URL(url);
4251
+ const port = parsed.port === '' ? '' : `:${parsed.port}`;
4252
+ return `${parsed.hostname}${port}${parsed.pathname}`;
4253
+ }
4254
+ catch {
4255
+ return 'a connection string this check cannot parse (not printed, as it may carry a password)';
4256
+ }
4257
+ }
4258
+ /** The install remedy every `retrieval-python-dependencies` failure names. */
4259
+ const PYTHON_INSTALL_REMEDY = "install the package with its `models` extra (docs/retrieval.md → `## Turning on the Python backend`)";
4260
+ /** Do the Python backend's console script, interpreter and packages resolve? */
4261
+ const RETRIEVAL_PYTHON_DEPENDENCIES_CHECK = {
4262
+ id: 'retrieval-python-dependencies',
4263
+ title: "RAG's Python backend resolves",
4264
+ run: (ctx) => {
4265
+ if (ctx.repoRoot === undefined)
4266
+ return unevaluated('the repository root did not resolve (see the git check)');
4267
+ if (ctx.config === undefined)
4268
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
4269
+ if (!retrievalApplies(ctx.config))
4270
+ return pass(RETRIEVAL_OFF);
4271
+ if (!pythonRetrievalApplies(ctx.config))
4272
+ return pass(PYTHON_NOT_SELECTED);
4273
+ const answer = pythonSelfCheck(ctx, ctx.repoRoot);
4274
+ if (answer.kind === 'unresolved') {
4275
+ return fail(`${PYTHON_RETRIEVAL_COMMAND} does not resolve on the launcher's PATH (${answer.searched}), so the search server never starts — ${PYTHON_INSTALL_REMEDY}`);
4276
+ }
4277
+ if (answer.kind === 'unreadable') {
4278
+ return fail(`${PYTHON_RETRIEVAL_COMMAND} ${PYTHON_SELF_CHECK_SUB_COMMAND} answered in a shape this CLI cannot grade (${answer.text}) — ${PYTHON_INSTALL_REMEDY}, from a clone at the release tag matching this CLI's version, then run doctor again`);
4279
+ }
4280
+ const line = selfCheckLine(answer, 'packages');
4281
+ return line.ok ? pass(line.detail) : fail(`${line.detail} — ${PYTHON_INSTALL_REMEDY}`);
4282
+ },
4283
+ };
4284
+ /** Are the Python backend's model weights in its cache? */
4285
+ const RETRIEVAL_PYTHON_MODEL_CACHE_CHECK = {
4286
+ id: 'retrieval-python-model-cache',
4287
+ title: "RAG's Python backend weights are cached",
4288
+ run: (ctx) => {
4289
+ if (ctx.repoRoot === undefined)
4290
+ return unevaluated('the repository root did not resolve (see the git check)');
4291
+ if (ctx.config === undefined)
4292
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
4293
+ if (!retrievalApplies(ctx.config))
4294
+ return pass(RETRIEVAL_OFF);
4295
+ if (!pythonRetrievalApplies(ctx.config))
4296
+ return pass(PYTHON_NOT_SELECTED);
4297
+ const answer = pythonSelfCheck(ctx, ctx.repoRoot);
4298
+ if (answer.kind !== 'answered') {
4299
+ return fail(`cannot be checked without the Python backend (see ${PYTHON_CHECK_ID.packages})`);
4300
+ }
4301
+ const line = selfCheckLine(answer, 'weights');
4302
+ if (line.ok)
4303
+ return pass(line.detail);
4304
+ return fail(`${line.detail} — run \`${PYTHON_RETRIEVAL_COMMAND} ${PYTHON_FETCH_MODELS_SUB_COMMAND}\` where an operator is present. An unattended run has no web access, so a weight missing now is never fetched later`);
4305
+ },
4306
+ };
4307
+ /** Does the Python backend build its index in its database? */
4308
+ const RETRIEVAL_PYTHON_INDEX_CHECK = {
4309
+ id: 'retrieval-python-index',
4310
+ title: "the RAG Python backend's index builds",
4311
+ run: (ctx) => {
4312
+ if (ctx.repoRoot === undefined)
4313
+ return unevaluated('the repository root did not resolve (see the git check)');
4314
+ if (ctx.config === undefined)
4315
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
4316
+ if (!retrievalApplies(ctx.config))
4317
+ return pass(RETRIEVAL_OFF);
4318
+ if (!pythonRetrievalApplies(ctx.config))
4319
+ return pass(PYTHON_NOT_SELECTED);
4320
+ const answer = pythonSelfCheck(ctx, ctx.repoRoot);
4321
+ if (answer.kind !== 'answered') {
4322
+ return fail(`cannot be checked without the Python backend (see ${PYTHON_CHECK_ID.packages})`);
4323
+ }
4324
+ const line = selfCheckLine(answer, 'index');
4325
+ if (line.ok)
4326
+ return pass(line.detail);
4327
+ const stoppedBy = SELF_CHECK_INDEX_NOT_ATTEMPTED.exec(line.detail)?.[1];
4328
+ if (stoppedBy !== undefined) {
4329
+ return fail(`cannot build without the Python backend's ${stoppedBy} (see ${PYTHON_CHECK_ID[stoppedBy]})`);
4330
+ }
4331
+ return fail(`${line.detail} — the backend's database is ${databaseLocation(answer.databaseUrl)} (${MCP_PATH}'s ${DOCS_SERVER_NAME} \`env\` ${PYTHON_DATABASE_URL_VARIABLE}, else the compose default): start the bundled one by running \`docker compose up -d --wait postgres\` in docs-retrieval-service/ of a clone of this CLI's repository at its release tag (docs/retrieval.md → \`## Turning on the Python backend\`, step 2), or point ${PYTHON_DATABASE_URL_VARIABLE} in that \`env\` object at yours`);
4332
+ },
4333
+ };
4062
4334
  /**
4063
4335
  * The checks, in the order they are evaluated and reported: the host and the tools first, then the
4064
4336
  * repository's own wiring, then the permission profile and the browser half that depends on it.
@@ -4149,7 +4421,9 @@ const RETRIEVAL_INDEX_CHECK = {
4149
4421
  * profile lines all passed reads it as the last thing that can still be missing from that file.
4150
4422
  *
4151
4423
  * The three `retrieval-*` checks come last, under `browser-wiring`: the runtime, the models, then
4152
- * whether an index builds, which needs libraries and models both and so reads after them.
4424
+ * whether an index builds, which needs libraries and models both and so reads after them. The three
4425
+ * `retrieval-python-*` checks follow them in the same runtime → models → index order; whichever
4426
+ * backend `docs.retrievalBackend` does not select passes its three as not applicable.
4153
4427
  */
4154
4428
  export const CHECKS = Object.freeze([
4155
4429
  GIT_CHECK,
@@ -4192,6 +4466,9 @@ export const CHECKS = Object.freeze([
4192
4466
  RETRIEVAL_DEPENDENCIES_CHECK,
4193
4467
  RETRIEVAL_MODEL_CACHE_CHECK,
4194
4468
  RETRIEVAL_INDEX_CHECK,
4469
+ RETRIEVAL_PYTHON_DEPENDENCIES_CHECK,
4470
+ RETRIEVAL_PYTHON_MODEL_CACHE_CHECK,
4471
+ RETRIEVAL_PYTHON_INDEX_CHECK,
4195
4472
  ]);
4196
4473
  /**
4197
4474
  * Evaluate every check in order and return one result each.