autonomous-sdlc-harness 0.1.0 → 0.4.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 (121) hide show
  1. package/README.md +4 -3
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/docs.js +220 -0
  4. package/dist/commands/docs.js.map +1 -0
  5. package/dist/commands/doctor.js +32 -11
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.js +240 -64
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/registry.js +2 -0
  10. package/dist/commands/registry.js.map +1 -1
  11. package/dist/config/check.js +36 -9
  12. package/dist/config/check.js.map +1 -1
  13. package/dist/config/model.js +43 -1
  14. package/dist/config/model.js.map +1 -1
  15. package/dist/core/git.js +29 -0
  16. package/dist/core/git.js.map +1 -1
  17. package/dist/core/layerGapRemedy.js +3 -2
  18. package/dist/core/layerGapRemedy.js.map +1 -1
  19. package/dist/core/paths.js +22 -2
  20. package/dist/core/paths.js.map +1 -1
  21. package/dist/core/pluginIdentity.js +33 -0
  22. package/dist/core/pluginIdentity.js.map +1 -0
  23. package/dist/core/prompt.js +6 -2
  24. package/dist/core/prompt.js.map +1 -1
  25. package/dist/core/report.js +9 -0
  26. package/dist/core/report.js.map +1 -1
  27. package/dist/core/writer.js +24 -5
  28. package/dist/core/writer.js.map +1 -1
  29. package/dist/detect/presets.js +35 -26
  30. package/dist/detect/presets.js.map +1 -1
  31. package/dist/detect/signals.js +13 -9
  32. package/dist/detect/signals.js.map +1 -1
  33. package/dist/doctor/checks.js +483 -38
  34. package/dist/doctor/checks.js.map +1 -1
  35. package/dist/generators/claudeContext.js +4 -5
  36. package/dist/generators/claudeContext.js.map +1 -1
  37. package/dist/generators/githubWorkflows.js +66 -0
  38. package/dist/generators/githubWorkflows.js.map +1 -0
  39. package/dist/generators/harnessConfig.js +13 -5
  40. package/dist/generators/harnessConfig.js.map +1 -1
  41. package/dist/generators/notifications.js +105 -19
  42. package/dist/generators/notifications.js.map +1 -1
  43. package/dist/generators/outerLoopScripts.js +30 -3
  44. package/dist/generators/outerLoopScripts.js.map +1 -1
  45. package/dist/generators/permissionProfile.js +152 -21
  46. package/dist/generators/permissionProfile.js.map +1 -1
  47. package/dist/generators/projectSettings.js +5 -15
  48. package/dist/generators/projectSettings.js.map +1 -1
  49. package/dist/generators/repoRoot.js +148 -25
  50. package/dist/generators/repoRoot.js.map +1 -1
  51. package/dist/generators/scripts.js +4 -1
  52. package/dist/generators/scripts.js.map +1 -1
  53. package/dist/generators/stateDir.js +9 -3
  54. package/dist/generators/stateDir.js.map +1 -1
  55. package/dist/machine/paths.js +19 -4
  56. package/dist/machine/paths.js.map +1 -1
  57. package/dist/machine/plugins.js +2 -1
  58. package/dist/machine/plugins.js.map +1 -1
  59. package/dist/remote/githubActions.js +86 -0
  60. package/dist/remote/githubActions.js.map +1 -0
  61. package/dist/retrieval/chunk.js +158 -0
  62. package/dist/retrieval/chunk.js.map +1 -0
  63. package/dist/retrieval/corpus.js +75 -0
  64. package/dist/retrieval/corpus.js.map +1 -0
  65. package/dist/retrieval/models.js +175 -0
  66. package/dist/retrieval/models.js.map +1 -0
  67. package/dist/retrieval/queryLog.js +70 -0
  68. package/dist/retrieval/queryLog.js.map +1 -0
  69. package/dist/retrieval/refresh.js +55 -0
  70. package/dist/retrieval/refresh.js.map +1 -0
  71. package/dist/retrieval/runtime.js +161 -0
  72. package/dist/retrieval/runtime.js.map +1 -0
  73. package/dist/retrieval/search.js +129 -0
  74. package/dist/retrieval/search.js.map +1 -0
  75. package/dist/retrieval/server.js +197 -0
  76. package/dist/retrieval/server.js.map +1 -0
  77. package/dist/retrieval/session.js +41 -0
  78. package/dist/retrieval/session.js.map +1 -0
  79. package/dist/retrieval/setup.js +121 -0
  80. package/dist/retrieval/setup.js.map +1 -0
  81. package/dist/retrieval/store.js +170 -0
  82. package/dist/retrieval/store.js.map +1 -0
  83. package/package.json +22 -3
  84. package/templates/README.md +3 -2
  85. package/templates/claude/CLAUDE.md +4 -4
  86. package/templates/claude/README.md +3 -1
  87. package/templates/claude/context/conventions.md +1 -1
  88. package/templates/claude/context/layer.md +1 -1
  89. package/templates/claude/push-notify.env.example +7 -2
  90. package/templates/claude/settings.autonomous.json +1 -1
  91. package/templates/claude/settings.autonomous.retrieval.json +9 -0
  92. package/templates/github/workflows/harness-resume.yml +124 -0
  93. package/templates/github/workflows/harness-run.yml +446 -0
  94. package/templates/repo/README.md +2 -0
  95. package/templates/repo/gitignore +6 -0
  96. package/templates/repo/gitignore.retrieval +2 -0
  97. package/templates/repo/mcp.retrieval.json +11 -0
  98. package/templates/scripts/README.md +1 -1
  99. package/templates/scripts/autonomous-notify.sh +10 -4
  100. package/templates/scripts/autonomous-watcher.sh +1445 -221
  101. package/templates/scripts/cleanup-merged-worktrees.sh +126 -8
  102. package/templates/scripts/docs-search-server.sh +64 -0
  103. package/templates/scripts/flow-walker.sh +629 -0
  104. package/templates/scripts/flows/task_plan_writing.graph.json +192 -0
  105. package/templates/scripts/lib/flow-walker-gates.sh +165 -0
  106. package/templates/scripts/lib/harness-run-lib.sh +452 -21
  107. package/templates/scripts/remote-run.sh +1785 -0
  108. package/templates/scripts/restart-watcher.sh +24 -3
  109. package/templates/scripts/run-test-suite.sh +182 -0
  110. package/templates/scripts/scratch-run.sh +2 -1
  111. package/templates/state-dir/README-root.md +1 -1
  112. package/templates/state-dir/autonomous_logs/README.md +1 -1
  113. package/templates/state-dir/business_parity_reviews/README.md +1 -1
  114. package/templates/state-dir/clarification_digests/README.md +1 -1
  115. package/templates/state-dir/clarifications/README.md +4 -4
  116. package/templates/state-dir/improvement_observations/README.md +2 -0
  117. package/templates/state-dir/scratch/README.md +1 -1
  118. package/templates/state-dir/test_fix_plan_reviews/README.md +9 -0
  119. package/templates/state-dir/test_fix_plans/README.md +9 -0
  120. package/templates/state-dir/test_fix_point_reviews/README.md +9 -0
  121. package/templates/state-dir/test_run_logs/README.md +11 -0
@@ -21,7 +21,11 @@
21
21
  * that module's {@link GITIGNORE_BLOCK_HEADER}, the line the write engine itself matches on, the
22
22
  * push-settings precedence is `generators/notifications.ts`'s {@link pushEnvCandidates}, the
23
23
  * repository registry's location, reader and staleness grading are `machine/registry.ts`'s {@link registryPath},
24
- * {@link readRegistry} and {@link inspect} — the same three `daemon list` enumerates through — and
24
+ * {@link readRegistry} and {@link inspect} — the same three `daemon list` enumerates through — the
25
+ * remote-execution switch is `config/model.ts`'s {@link remoteExecutionApplies}, the workflow
26
+ * paths and the binary run as `gh` are `remote/githubActions.ts`'s ({@link WORKFLOW_RUN_PATH},
27
+ * {@link WORKFLOW_RESUME_PATH}, {@link GH_CLI_VARIABLE}, {@link ghCli}), whether a ref carries a
28
+ * file is `core/git.ts`'s {@link pathAtRef}, and
25
29
  * the writability probe is the write engine's {@link probeWritable}. A
26
30
  * check that wanted a slightly different answer would be a second definition of the thing being
27
31
  * checked, which is how a green `doctor` starts disagreeing with the run it is supposed to
@@ -54,6 +58,19 @@
54
58
  * did not ask says so, rather than leaving an adopter to read a pass as a promise the packages can
55
59
  * be fetched.
56
60
  *
61
+ * **Remote setup keeps the same line: it is graded from local evidence by default and asks GitHub
62
+ * only under `--check-github`.** {@link REMOTE_EXECUTION_CHECK} reads files, refs and `PATH`, and
63
+ * spawns no `gh` subcommand; {@link REMOTE_GITHUB_CHECK} spawns them only under
64
+ * {@link CheckContext.probeGithub}, and each is a read.
65
+ *
66
+ * **The three docs-retrieval checks keep that line.** `retrieval-dependencies` and
67
+ * `retrieval-model-cache` are file tests answered by `retrieval/runtime.ts`'s
68
+ * {@link retrievalRuntimeState} and `retrieval/models.ts`'s {@link modelFilesPresent}, and load
69
+ * nothing. `retrieval-index` spawns a child of this CLI running `docs index --in-memory`: a child
70
+ * because {@link Check.run} is synchronous and the store is not, and because the child is the
71
+ * installation whose optional peers resolve beside it. It builds in memory and exits, so it starts
72
+ * no server and writes nothing.
73
+ *
57
74
  * ## What this module deliberately does not do
58
75
  *
59
76
  * - **It writes nothing** beyond {@link probeWritable}'s temp file, which that function removes with
@@ -62,18 +79,19 @@
62
79
  * - **It repairs nothing.** Every failure names what to run — `init`, `init --force`, an edit to one
63
80
  * config key — and `doctor` stays a command that is safe to run against a repository at any time.
64
81
  */
65
- import { execFileSync } from 'node:child_process';
82
+ import { execFileSync, spawnSync } from 'node:child_process';
66
83
  import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from 'node:fs';
67
84
  import { delimiter, join, posix, resolve as resolvePath } from 'node:path';
68
85
  import { formatProblem } from '../config/check.js';
69
86
  import { loadConfig } from '../config/io.js';
70
- import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, isPlaceholder, LAYER_CATCH_ALL_PATH, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
71
- import { branchResolves, checkedOutBranch, commitsAhead, configuredRemotes, hasCommits, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
87
+ import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, isPlaceholder, LAYER_CATCH_ALL_PATH, remoteExecutionApplies, retrievalApplies, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
88
+ import { branchResolves, checkedOutBranch, commitsAhead, configuredRemotes, hasCommits, pathAtRef, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
72
89
  import { isJsonObject, readJsonFile } from '../core/json.js';
73
90
  import { layerCoverage } from '../core/layerCoverage.js';
74
91
  import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
75
92
  import { nameList } from '../core/nameList.js';
76
93
  import { readTemplate, workRoot } from '../core/paths.js';
94
+ import { ANALYZE_COMMAND } from '../core/pluginIdentity.js';
77
95
  import { normalizeRepoPathStrict } from '../core/repoPaths.js';
78
96
  import { probeWritable } from '../core/writer.js';
79
97
  import { detectBackend, resolveWatcherPath, watcherMissingMessage } from '../daemon/backend.js';
@@ -82,16 +100,19 @@ import { LAYERLESS_BY_DESIGN_PRESETS, SHARED_CONVENTIONS_PATH, TESTS_LAYER_NAME
82
100
  import { FORCED_SIGNAL_ID } from '../detect/signals.js';
83
101
  import { CLAUDE_MD_PATH, SETUP_PENDING_CLOSE, SETUP_PENDING_OPEN, SKELETON_GUIDANCE_MARKER, TASK_OFFER_PATH, UNFILLED_STUB_MARKER, } from '../generators/claudeContext.js';
84
102
  import { caseLabelMatches, isGlobPattern, PRE_PUSH_HOOK, readProtectedCaseLabel, resolveGithooksDir, resolveProtectedBranches, } from '../generators/githooks.js';
85
- import { PUSH_CMD_KEY, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
86
- import { outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
87
- import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootEntryTarget, PROFILE_PATH, readRule, renderProfile, TEMPLATE_PATH as PROFILE_TEMPLATE_PATH, } from '../generators/permissionProfile.js';
103
+ import { PUSH_CMD_KEY, PUSH_DESTINATION_PLACEHOLDER, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
104
+ import { DOCS_SEARCH_SERVER_SCRIPT_NAME, outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
105
+ import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootEntries, pluginRootEntryTarget, pluginRootHelpers, PROFILE_PATH, readRule, renderProfile, TEMPLATE_PATH as PROFILE_TEMPLATE_PATH, } from '../generators/permissionProfile.js';
88
106
  import { ENABLED_PLUGINS_KEY, MARKETPLACE_ENTRY_SHAPE, MARKETPLACE_FLAG, MARKETPLACES_KEY, MARKETPLACE_NAME, marketplaceEntryDefect, PLUGIN_KEY, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
89
107
  import { clarificationsIgnoreRules, contentsIgnoredDirectories, GITIGNORE_BLOCK_HEADER, GITIGNORE_PATH, MCP_PATH, } from '../generators/repoRoot.js';
90
108
  import { configKeyPath, configuredWrapperFile, scriptInvocation, selectWrapper, wrappedKeyMismatch, wrappedKeyMismatchMessage, WRAPPER_SCRIPTS, wrapperCommandLine, } from '../generators/scripts.js';
91
109
  import { selectedStateDirs } from '../generators/stateDir.js';
92
110
  import { machineConfigDir } from '../machine/paths.js';
93
- import { installedPluginsPath, knownMarketplacesPath, pluginHelperPath, pluginHelperScripts, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
111
+ import { installedPluginsPath, knownMarketplacesPath, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
94
112
  import { inspect, readRegistry, registryPath } from '../machine/registry.js';
113
+ import { API_KEY_SECRET, DEFAULT_GH_CLI, GH_CLI_VARIABLE, ghCli, GIT_TOKEN_SECRET, OAUTH_TOKEN_SECRET, PUSH_URL_SECRET, REMOTE_STOP_VARIABLE, runGh, RUNNER_VARIABLE, WORKFLOW_RESUME_FILE, WORKFLOW_RESUME_PATH, WORKFLOW_RUN_FILE, WORKFLOW_RUN_PATH, } from '../remote/githubActions.js';
114
+ import { modelFilesPresent } from '../retrieval/models.js';
115
+ import { retrievalCliEntry, retrievalModelCacheDir, retrievalRuntimeDir, retrievalRuntimeState, } from '../retrieval/runtime.js';
95
116
  /**
96
117
  * How the CLI is typed, for every remedy that tells an operator what to run next. The `npx` prefix
97
118
  * is not decoration: the rule for which occurrences carry it is stated once in `commands/init.ts`,
@@ -112,6 +133,21 @@ const WORKTREE_SCRIPT = 'create-worktree.sh';
112
133
  * `bash <scriptsDir>/<file>` — so nothing here formats that shape a second time.
113
134
  */
114
135
  const REFRESH_SCRIPT = 'refresh-branch.sh';
136
+ /**
137
+ * The outer-loop script the watcher dispatches a remote run through, named in {@link requiredBinaries}'
138
+ * and {@link REMOTE_EXECUTION_CHECK}'s sentences on {@link WORKTREE_SCRIPT}'s terms: a file in a
139
+ * sentence, never resolved or read here.
140
+ */
141
+ const REMOTE_RUN_SCRIPT = 'remote-run.sh';
142
+ /**
143
+ * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the repository's artifact retention from; `gh`
144
+ * fills `{owner}` and `{repo}` from the checkout's remote. Local, not a `remote/githubActions.ts`
145
+ * export: that module owns the names `cli/src` shares with the shell and YAML mirrors, and no shell or
146
+ * YAML file spells this endpoint.
147
+ */
148
+ const ARTIFACT_RETENTION_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention';
149
+ /** Below this many days of artifact retention {@link REMOTE_GITHUB_CHECK} warns; argued there. */
150
+ const ARTIFACT_RETENTION_WARN_DAYS = 30;
115
151
  /** The permission lists a generated profile carries, in the order the template writes them. */
116
152
  const PERMISSION_LISTS = Object.freeze(['allow', 'deny', 'ask']);
117
153
  /** An unsubstituted token, which a raw template's entry may carry and a rendered profile may not. */
@@ -356,11 +392,11 @@ function serversStartedByProfile(profile) {
356
392
  * has to report on rather than crash against, and the same holds for a config or a profile that does
357
393
  * not parse. Each failure becomes a field the check that owns that subject renders.
358
394
  *
359
- * `probeRegistry` is the caller's answer rather than this function's, and it defaults to `false`: a
360
- * context built without it is the context every default run gets, and no check here reaches a network
361
- * unless the command was asked to.
395
+ * `probeRegistry` and `probeGithub` are the caller's answers rather than this function's, and both
396
+ * default to `false`: a context built without them is the context every default run gets, and no
397
+ * check here reaches a network unless the command was asked to.
362
398
  */
363
- export function buildCheckContext(cwd, probeRegistry = false) {
399
+ export function buildCheckContext(cwd, probeRegistry = false, probeGithub = false) {
364
400
  let repoRoot;
365
401
  let repoProblem;
366
402
  try {
@@ -370,7 +406,7 @@ export function buildCheckContext(cwd, probeRegistry = false) {
370
406
  repoProblem = messageOf(error);
371
407
  }
372
408
  if (repoRoot === undefined)
373
- return { cwd, repoProblem, configProblems: [], probeRegistry };
409
+ return { cwd, repoProblem, configProblems: [], probeRegistry, probeGithub };
374
410
  const loaded = loadConfig(repoRoot);
375
411
  const profilePath = join(repoRoot, PROFILE_PATH);
376
412
  let profile;
@@ -400,6 +436,7 @@ export function buildCheckContext(cwd, probeRegistry = false) {
400
436
  profileProblem,
401
437
  profilePath,
402
438
  probeRegistry,
439
+ probeGithub,
403
440
  };
404
441
  }
405
442
  /** Is this repository inside a work tree at all — the precondition every repo-wiring check has. */
@@ -416,11 +453,11 @@ const GIT_CHECK = {
416
453
  * No `jj` binary is looked for and none is invoked.
417
454
  *
418
455
  * **What the directory establishes is that `jj` manages this working copy, and not that the
419
- * repository is colocated.** Measured on jj 0.44.0 and recorded in `README.md`'s `## Scope and
420
- * limits`, in the bullet opening *Both `jj` shapes adopt*: a **non**-colocated repository's
421
- * `.jj/repo/store/git_target` holds `../../../.git`, so it too keeps a real non-bare `.git` at the
422
- * working-copy root with `.jj/` beside it, and this probe answers the same on both shapes. The one
423
- * clause of the warning below that is colocated-only says so in its own words.
456
+ * repository is colocated.** Measured on jj 0.44.0 and recorded in `docs/cli.md` §7, the
457
+ * `jj-repository` bullet: a **non**-colocated repository's `.jj/repo/store/git_target` holds
458
+ * `../../../.git`, so it too keeps a real non-bare `.git` at the working-copy root with `.jj/`
459
+ * beside it, and this probe answers the same on both shapes. The one clause of the warning below
460
+ * that is colocated-only says so in its own words.
424
461
  */
425
462
  const JJ_DIR = '.jj';
426
463
  /**
@@ -1044,7 +1081,7 @@ function deliveryOptInAdvice(candidates) {
1044
1081
  const repositoryRemedy = candidates.some((candidate) => candidate.origin === 'repository')
1045
1082
  ? ', and the repository-side one is filled in by hand'
1046
1083
  : `; this repository configures no repository-side file, so filling one means setting \`pushEnvPath\` in ${CONFIG_FILENAME} first`;
1047
- return `delivery is opt-in and defaults to nothing pushed, so with neither ${PUSH_URL_KEY} nor ${PUSH_CMD_KEY} set an unattended run's completed, parked and failed events reach a macOS desktop banner where one is available and nothing at all on a Linux host. \`${CLI} init --notifications --push-url <url>\` writes the machine-local file for you${repositoryRemedy}`;
1084
+ return `delivery is opt-in and defaults to nothing pushed, so with neither ${PUSH_URL_KEY} nor ${PUSH_CMD_KEY} set an unattended run's completed, parked and failed events reach a macOS desktop banner where one is available and nothing at all on a Linux host. \`${CLI} init --notifications --push-url ${PUSH_DESTINATION_PLACEHOLDER}\` writes the machine-local file for you${repositoryRemedy}`;
1048
1085
  }
1049
1086
  /**
1050
1087
  * Which settings file an unattended run's notifier will actually read, and whether either delivery
@@ -1586,7 +1623,10 @@ function commandHeads(config, repoRoot) {
1586
1623
  * configuration through and which {@link JQ_CHECK} already treats as a hard floor; and the agent CLI
1587
1624
  * above, whose name arrives as the installed unit's value — `undefined` or empty when the unit does
1588
1625
  * not set it, which is the default case because `${HARNESS_AGENT_CLI:-claude}` reads an empty value
1589
- * as unset too.
1626
+ * as unset too. **A conditional fourth joins them only when {@link remoteExecutionApplies}:** the
1627
+ * binary run as `gh`, which `remote-run.sh` dispatches every remote run through. Its name is the
1628
+ * unit's own {@link GH_CLI_VARIABLE} value when set and non-empty, else {@link DEFAULT_GH_CLI}, on
1629
+ * the agent CLI's terms. With remote execution off the list is exactly the three and the derived.
1590
1630
  *
1591
1631
  * **The unit of comparison is the head of a command line**, over the two places such a line lives, and
1592
1632
  * deriving it is {@link commandHeads}' — this check classifies nothing itself, so it and its sibling
@@ -1612,7 +1652,7 @@ function commandHeads(config, repoRoot) {
1612
1652
  * `ENOENT` in with them printed an unfollowable instruction over the one state `init` clears by
1613
1653
  * itself (Finding 1).
1614
1654
  */
1615
- function requiredBinaries(config, repoRoot, unitAgentCli) {
1655
+ function requiredBinaries(config, repoRoot, unitAgentCli, unitGhCli) {
1616
1656
  const found = new Map();
1617
1657
  const ungraded = [];
1618
1658
  // Named `absent` rather than `missing`: in this module a *missing* binary is one the unit's PATH
@@ -1633,6 +1673,10 @@ function requiredBinaries(config, repoRoot, unitAgentCli) {
1633
1673
  add(agent === '' ? DEFAULT_AGENT_CLI : agent, agent === ''
1634
1674
  ? `the watcher's agent binary (${AGENT_CLI_VARIABLE} unset in the unit, so the watcher's default)`
1635
1675
  : `the watcher's agent binary (${AGENT_CLI_VARIABLE}, as the unit sets it)`);
1676
+ if (remoteExecutionApplies(config)) {
1677
+ const gh = unitGhCli?.trim() ?? '';
1678
+ add(gh === '' ? DEFAULT_GH_CLI : gh, `${REMOTE_RUN_SCRIPT}, which the watcher dispatches every run through while execution.target is github-actions (${GH_CLI_VARIABLE} ${gh === '' ? 'unset in the unit, so the default' : 'as the unit sets it'})`);
1679
+ }
1636
1680
  // `deploy` is this check's to skip, on both arms: its command line lives on `deploy.command`
1637
1681
  // rather than in `commands`, and no daemon-launched run deploys. {@link commandHeads} derives it
1638
1682
  // for its other consumer, which does grade it.
@@ -1770,7 +1814,7 @@ const DAEMON_PATH_CHECK = {
1770
1814
  }
1771
1815
  return warn(`the ${backend.kind} unit for ${unit.label} is at ${unit.targetPath} and could not be read (${messageOf(error)}), so the PATH it gives the daemon is unknown: read it yourself, or ${reinstallAdvice(backend.kind)}`);
1772
1816
  }
1773
- const { binaries: required, ungraded, absent } = requiredBinaries(ctx.config, ctx.repoRoot, unitEnvValue(backend.kind, text, AGENT_CLI_VARIABLE));
1817
+ const { binaries: required, ungraded, absent } = requiredBinaries(ctx.config, ctx.repoRoot, unitEnvValue(backend.kind, text, AGENT_CLI_VARIABLE), unitEnvValue(backend.kind, text, GH_CLI_VARIABLE));
1774
1818
  const envPath = unitEnvValue(backend.kind, text, 'PATH');
1775
1819
  if (envPath === undefined) {
1776
1820
  return warn(`the ${backend.kind} unit for ${unit.label} at ${unit.targetPath} carries no PATH, so the daemon runs on the service manager's own default directories — the state every unit installed before this key shipped is in, and on macOS that is /usr/bin:/bin:/usr/sbin:/sbin, where a Homebrew package manager and an agent CLI under ~/.local/bin both fail to resolve. An unattended run then dies at its first configured command with \`command not found\` while a foreground run is fine, because your shell's PATH is not this. To fix it, ${reinstallAdvice(backend.kind)}`);
@@ -1782,6 +1826,285 @@ const DAEMON_PATH_CHECK = {
1782
1826
  return pass(`the ${backend.kind} unit for ${unit.label} at ${unit.targetPath} carries a PATH reaching every binary an unattended run invokes by name${ungraded.length + absent.length > 0 ? ' that could be derived here' : ''}, each with what requires it: ${nameList(required.map((binary) => `${binary.name} (${binary.sources.join(', ')})`))}. This grades that file rather than this machine — the shell doctor runs in resolves what a service manager's environment does not${describeUngraded(ungraded, absent)}`);
1783
1827
  },
1784
1828
  };
1829
+ /**
1830
+ * The configured execution target, and whether this repository has what a remote run needs locally.
1831
+ *
1832
+ * **Remote setup is graded from local evidence by default and asks GitHub only under
1833
+ * `--check-github`** (the module header's choice 3). This reads the two workflow files, one git ref
1834
+ * and `PATH`, and spawns no `gh` subcommand: resolving the binary {@link ghCli} names is the whole of
1835
+ * its `gh` question. What only GitHub can answer — the secrets, the variables, whether GitHub knows
1836
+ * the workflow — the pass text names and leaves to that flag.
1837
+ *
1838
+ * **Every finding is reported, and the grade is the worst of them.** Two `fail`s: no
1839
+ * `harness-run.yml`, because no remote run can be dispatched; and no `gh`, because the watcher
1840
+ * dispatches through it. Three `warn`s: no `harness-resume.yml`, because a usage-paused hosted run then
1841
+ * waits for `/autonomous-sdlc-harness:branch-resume`; a `harness-run.yml` that
1842
+ * `origin/<defaultBranch>` does not carry, because GitHub dispatches only a workflow its default
1843
+ * branch has — the run starts once it is pushed, so nothing is broken here; and `phases.qa` true,
1844
+ * because a remote run skips the interactive-test phase and the branch still reaches review — the
1845
+ * phase is then owed a local run. It needs no GitHub answer, so it is asked here rather than in
1846
+ * {@link REMOTE_GITHUB_CHECK}.
1847
+ *
1848
+ * `gh` is resolved on **this shell's** `PATH`. Whether the installed daemon's `PATH` reaches it is
1849
+ * {@link DAEMON_PATH_CHECK}'s question, answered there through {@link requiredBinaries}' conditional
1850
+ * member, so it is not asked twice.
1851
+ *
1852
+ * **A missing `origin/<defaultBranch>`, or an unusable `defaultBranch`, leaves the origin row
1853
+ * ungraded** and the detail says so: that ref's absence is {@link REMOTE_CHECK}'s `fail`, and
1854
+ * reporting it again would make one finding two. It is asked only when `harness-run.yml` exists here,
1855
+ * for the same reason. The ref is read as this checkout last fetched it.
1856
+ *
1857
+ * With remote execution off, a workflow file left in place is a `pass` that says it does nothing:
1858
+ * the watcher dispatches nothing while the key is `local`.
1859
+ */
1860
+ const REMOTE_EXECUTION_CHECK = {
1861
+ id: 'remote-execution',
1862
+ title: 'the configured execution target, and whether this repository has what a remote run needs locally',
1863
+ run: (ctx) => {
1864
+ if (ctx.repoRoot === undefined)
1865
+ return unevaluated('the repository root did not resolve (see the git check)');
1866
+ if (ctx.config === undefined)
1867
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
1868
+ const root = ctx.repoRoot;
1869
+ const present = (path) => existsSync(join(root, ...path.split('/')));
1870
+ const runPresent = present(WORKFLOW_RUN_PATH);
1871
+ const resumePresent = present(WORKFLOW_RESUME_PATH);
1872
+ if (!remoteExecutionApplies(ctx.config)) {
1873
+ const left = [WORKFLOW_RUN_PATH, WORKFLOW_RESUME_PATH].filter(present);
1874
+ if (left.length === 0)
1875
+ return pass('local execution; remote execution is off (`execution.target`)');
1876
+ return pass(`local execution; remote execution is off (\`execution.target\`), so ${nameList(left)} ${left.length === 1 ? 'is' : 'are'} present and unused: the watcher dispatches nothing while the key is local. \`${CLI} config set execution.target github-actions\` turns remote execution on`);
1877
+ }
1878
+ const failures = [];
1879
+ const warnings = [];
1880
+ const notes = [];
1881
+ if (!runPresent) {
1882
+ failures.push(`${WORKFLOW_RUN_PATH} is absent, so no remote run can be dispatched: re-run \`${CLI} init\`, which writes it create-if-absent while execution.target is github-actions`);
1883
+ }
1884
+ if (!resumePresent) {
1885
+ warnings.push(`${WORKFLOW_RESUME_PATH} is absent, so a hosted run paused on usage waits for /autonomous-sdlc-harness:branch-resume instead of resuming on its own: re-run \`${CLI} init\` to write it`);
1886
+ }
1887
+ const gh = ghCli();
1888
+ if (!resolvesOnPath(gh)) {
1889
+ failures.push(`${gh} does not resolve on this shell's PATH, and the watcher dispatches every remote run through it (${REMOTE_RUN_SCRIPT}): install the GitHub CLI (https://cli.github.com) and run \`gh auth login\`, or point ${GH_CLI_VARIABLE} at it`);
1890
+ }
1891
+ if (ctx.config.phases?.qa === true) {
1892
+ warnings.push("phases.qa is true, but a remote run skips the interactive-test phase — GitHub Actions jobs have no browser wiring, application dependencies or QA credentials for it (docs/remote-execution.md → §3, The interactive-test phase): run /autonomous-sdlc-harness:branch-qa-test <branch> locally before merging a remote run's branch");
1893
+ }
1894
+ if (runPresent) {
1895
+ const branch = ctx.config.defaultBranch;
1896
+ if (typeof branch !== 'string' || branch.trim() === '') {
1897
+ notes.push(`whether GitHub's default branch carries ${WORKFLOW_RUN_PATH} is not graded, because defaultBranch is not a branch name (see the config check)`);
1898
+ }
1899
+ else if (!remoteTrackingBranchResolves(root, branch)) {
1900
+ notes.push(`whether origin/${branch} carries ${WORKFLOW_RUN_PATH} is not graded, because there is no origin/${branch} (see the remote check)`);
1901
+ }
1902
+ else if (!pathAtRef(root, `origin/${branch}`, WORKFLOW_RUN_PATH)) {
1903
+ warnings.push(`origin/${branch} does not carry ${WORKFLOW_RUN_PATH}, as this checkout last fetched it, and GitHub dispatches only a workflow its default branch carries: commit it and push it with \`git push origin ${branch}\``);
1904
+ }
1905
+ }
1906
+ const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
1907
+ const on = 'remote execution is on (execution.target github-actions)';
1908
+ if (failures.length > 0)
1909
+ return fail(`${on}: ${[...failures, ...warnings].join('; ')}${noted}`);
1910
+ if (warnings.length > 0)
1911
+ return warn(`${on}: ${warnings.join('; ')}${noted}`);
1912
+ return pass(`${on}: ${WORKFLOW_RUN_PATH} and ${WORKFLOW_RESUME_PATH} are present and ${gh} resolves on PATH${noted}. What this cannot see lives on GitHub — a credential secret (${OAUTH_TOKEN_SECRET} or ${API_KEY_SECRET}), the ${PUSH_URL_SECRET} and ${GIT_TOKEN_SECRET} secrets, and the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables; \`${CLI} doctor --check-github\` asks GitHub`);
1913
+ },
1914
+ };
1915
+ /**
1916
+ * What `gh` prints when it cannot reach GitHub at all — `error connecting to api.github.com`, then a
1917
+ * pointer to check the connection. Matched so a network failure grades as *cannot tell* rather than
1918
+ * as the thing asked about being missing.
1919
+ */
1920
+ const GH_UNREACHABLE_PATTERN = /error connecting to|check your internet connection/i;
1921
+ /**
1922
+ * A stopped call — the bound in `runGh`, or any other signal — and one that could not reach GitHub
1923
+ * are `unknown`; any other non-zero exit is `refused`, quoting `gh`'s own first line.
1924
+ */
1925
+ function classifyGh(result) {
1926
+ if (result.status === 0)
1927
+ return { kind: 'answered', stdout: result.stdout };
1928
+ const said = firstLine(result.stderr) || firstLine(result.stdout);
1929
+ if (result.status === null)
1930
+ return { kind: 'unknown', why: 'it was stopped before it answered (timed out)' };
1931
+ if (GH_UNREACHABLE_PATTERN.test(result.stderr))
1932
+ return { kind: 'unknown', why: `it could not reach GitHub (${said})` };
1933
+ return { kind: 'refused', why: said === '' ? `it exited ${result.status}` : `it exited ${result.status}: ${said}` };
1934
+ }
1935
+ /** The `name` field of each element of a `gh … list --json` array, or `undefined` when it is not that shape. */
1936
+ function ghJsonEntries(stdout) {
1937
+ let parsed;
1938
+ try {
1939
+ parsed = JSON.parse(stdout);
1940
+ }
1941
+ catch {
1942
+ return undefined;
1943
+ }
1944
+ if (!Array.isArray(parsed))
1945
+ return undefined;
1946
+ const entries = new Map();
1947
+ for (const item of parsed) {
1948
+ if (!isJsonObject(item) || typeof item.name !== 'string')
1949
+ return undefined;
1950
+ entries.set(item.name, typeof item.value === 'string' ? item.value : '');
1951
+ }
1952
+ return entries;
1953
+ }
1954
+ /** The positive-integer `days` of an artifact-retention answer, or `undefined` for any other shape. */
1955
+ function retentionDaysOf(stdout) {
1956
+ let parsed;
1957
+ try {
1958
+ parsed = JSON.parse(stdout);
1959
+ }
1960
+ catch {
1961
+ return undefined;
1962
+ }
1963
+ if (!isJsonObject(parsed))
1964
+ return undefined;
1965
+ const days = parsed.days;
1966
+ return typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : undefined;
1967
+ }
1968
+ /**
1969
+ * What GitHub says about the remote setup — asked only under {@link CheckContext.probeGithub}.
1970
+ *
1971
+ * **Off by default** (the module header's choice 3): a default run passes saying it did not ask, and
1972
+ * spawns nothing. Every call goes through `remote/githubActions.ts` → `runGh` with a fixed argument
1973
+ * vector and is a read. Secret **values** are never read: `gh secret list` returns names only.
1974
+ *
1975
+ * Grades, worst wins and every finding is reported:
1976
+ * - `fail` — `gh` does not spawn or `gh auth status` refuses (nothing further is asked); GitHub does
1977
+ * not know `harness-run.yml`; neither credential secret is set.
1978
+ * - `warn` — `HARNESS_PUSH_URL` absent; `harness-resume.yml` unknown to GitHub; `HARNESS_REMOTE_STOP`
1979
+ * set; artifact retention below {@link ARTIFACT_RETENTION_WARN_DAYS} days; and any call that timed
1980
+ * out, could not reach GitHub, or answered in a shape not understood — *cannot tell* is not
1981
+ * *missing*, so it never fails.
1982
+ * - both credential secrets present is a note, not a finding: billing follows `ANTHROPIC_API_KEY`.
1983
+ * - the retention read refused (typically HTTP 403: the endpoint needs admin access) is a note too —
1984
+ * the read is best-effort, and a collaborator without admin can still run remotely.
1985
+ *
1986
+ * **Why 30 days.** A parked run waits on a human answer and a usage-paused one on a reset, and the
1987
+ * `harness-state` bundle is the only remote copy of either; once the repository's retention expires
1988
+ * it, the run can no longer be answered and loses its carried counts. Thirty days covers an ordinary
1989
+ * absence — a holiday — while GitHub's own default of 90 passes; below it, an ordinary absence can
1990
+ * cost a parked run its question.
1991
+ */
1992
+ const REMOTE_GITHUB_CHECK = {
1993
+ id: 'remote-github',
1994
+ title: 'what GitHub says about the remote setup',
1995
+ run: (ctx) => {
1996
+ if (ctx.repoRoot === undefined)
1997
+ return unevaluated('the repository root did not resolve (see the git check)');
1998
+ if (ctx.config === undefined)
1999
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
2000
+ if (!remoteExecutionApplies(ctx.config))
2001
+ return pass('remote execution is off (`execution.target`), so nothing was asked of GitHub');
2002
+ if (!ctx.probeGithub)
2003
+ return pass(`not asked — run \`${CLI} doctor --check-github\` to ask GitHub about the secrets, variables and workflows a remote run needs`);
2004
+ const root = ctx.repoRoot;
2005
+ const gh = ghCli();
2006
+ const ask = (args) => {
2007
+ const result = runGh(args, root);
2008
+ return { call: `\`gh ${args.join(' ')}\``, answer: result === undefined ? undefined : classifyGh(result) };
2009
+ };
2010
+ const ghRemedy = `install the GitHub CLI (https://cli.github.com) and run \`gh auth login\`, or point ${GH_CLI_VARIABLE} at it`;
2011
+ const noSpawn = `${gh} could not be run, so GitHub was not asked: ${ghRemedy}`;
2012
+ const cannotTell = (call, why, what) => `cannot tell ${what}: ${call} gave no answer, because ${why}`;
2013
+ const auth = ask(['auth', 'status']);
2014
+ if (auth.answer === undefined)
2015
+ return fail(noSpawn);
2016
+ if (auth.answer.kind === 'unknown')
2017
+ return warn(cannotTell(auth.call, auth.answer.why, 'whether gh is authenticated, so nothing further was asked'));
2018
+ if (auth.answer.kind === 'refused')
2019
+ return fail(`${auth.call} reports no usable login, because ${auth.answer.why}: run \`gh auth login\``);
2020
+ const failures = [];
2021
+ const warnings = [];
2022
+ const notes = [];
2023
+ const run = ask(['workflow', 'view', WORKFLOW_RUN_FILE]);
2024
+ if (run.answer === undefined)
2025
+ return fail(noSpawn);
2026
+ if (run.answer.kind === 'unknown')
2027
+ warnings.push(cannotTell(run.call, run.answer.why, `whether GitHub knows ${WORKFLOW_RUN_FILE}`));
2028
+ if (run.answer.kind === 'refused') {
2029
+ failures.push(`GitHub does not know ${WORKFLOW_RUN_FILE} (${run.call}: ${run.answer.why}), so no remote run can be dispatched: push ${WORKFLOW_RUN_PATH} to the repository's default branch`);
2030
+ }
2031
+ const secrets = ask(['secret', 'list', '--json', 'name']);
2032
+ if (secrets.answer === undefined)
2033
+ return fail(noSpawn);
2034
+ const secretNames = secrets.answer.kind === 'answered' ? ghJsonEntries(secrets.answer.stdout) : undefined;
2035
+ if (secrets.answer.kind !== 'answered') {
2036
+ warnings.push(cannotTell(secrets.call, secrets.answer.why, 'which repository secrets are set'));
2037
+ }
2038
+ else if (secretNames === undefined) {
2039
+ warnings.push(`cannot tell which repository secrets are set: ${secrets.call} answered in a shape this check does not read`);
2040
+ }
2041
+ else {
2042
+ const oauth = secretNames.has(OAUTH_TOKEN_SECRET);
2043
+ const apiKey = secretNames.has(API_KEY_SECRET);
2044
+ if (!oauth && !apiKey) {
2045
+ failures.push(`neither ${OAUTH_TOKEN_SECRET} nor ${API_KEY_SECRET} is a repository secret, so a remote run cannot authenticate its agent: set one with \`gh secret set <name>\` — billing follows ${API_KEY_SECRET} when both are set`);
2046
+ }
2047
+ else if (oauth && apiKey) {
2048
+ notes.push(`both ${OAUTH_TOKEN_SECRET} and ${API_KEY_SECRET} are set, so billing follows ${API_KEY_SECRET}`);
2049
+ }
2050
+ if (!secretNames.has(PUSH_URL_SECRET)) {
2051
+ warnings.push(`${PUSH_URL_SECRET} is not a repository secret, so notifications from a remote run reach no one: set it with \`gh secret set ${PUSH_URL_SECRET}\``);
2052
+ }
2053
+ }
2054
+ const resume = ask(['workflow', 'view', WORKFLOW_RESUME_FILE]);
2055
+ if (resume.answer === undefined)
2056
+ return fail(noSpawn);
2057
+ if (resume.answer.kind === 'unknown')
2058
+ warnings.push(cannotTell(resume.call, resume.answer.why, `whether GitHub knows ${WORKFLOW_RESUME_FILE}`));
2059
+ if (resume.answer.kind === 'refused') {
2060
+ warnings.push(`GitHub does not know ${WORKFLOW_RESUME_FILE} (${resume.call}: ${resume.answer.why}), so usage auto-resume is unavailable: push ${WORKFLOW_RESUME_PATH} to the repository's default branch`);
2061
+ }
2062
+ const variables = ask(['variable', 'list', '--json', 'name,value']);
2063
+ if (variables.answer === undefined)
2064
+ return fail(noSpawn);
2065
+ const variableValues = variables.answer.kind === 'answered' ? ghJsonEntries(variables.answer.stdout) : undefined;
2066
+ let runner;
2067
+ if (variables.answer.kind !== 'answered') {
2068
+ warnings.push(cannotTell(variables.call, variables.answer.why, `the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables`));
2069
+ }
2070
+ else if (variableValues === undefined) {
2071
+ warnings.push(`cannot tell the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables: ${variables.call} answered in a shape this check does not read`);
2072
+ }
2073
+ else {
2074
+ const label = variableValues.get(RUNNER_VARIABLE)?.trim() ?? '';
2075
+ runner = label === '' ? 'GitHub-hosted (`ubuntu-latest`)' : `runner label \`${label}\``;
2076
+ if ((variableValues.get(REMOTE_STOP_VARIABLE) ?? '') !== '') {
2077
+ warnings.push(`${REMOTE_STOP_VARIABLE} is set, so every remote start and continuation is stopped: \`gh variable delete ${REMOTE_STOP_VARIABLE}\` lifts it`);
2078
+ }
2079
+ }
2080
+ const retention = ask(['api', ARTIFACT_RETENTION_ENDPOINT]);
2081
+ if (retention.answer === undefined)
2082
+ return fail(noSpawn);
2083
+ let retentionDays;
2084
+ if (retention.answer.kind === 'unknown') {
2085
+ warnings.push(cannotTell(retention.call, retention.answer.why, 'how long the repository keeps artifacts'));
2086
+ }
2087
+ else if (retention.answer.kind === 'refused') {
2088
+ notes.push(`artifact retention not checked: ${retention.call} needs admin access (${retention.answer.why})`);
2089
+ }
2090
+ else {
2091
+ retentionDays = retentionDaysOf(retention.answer.stdout);
2092
+ if (retentionDays === undefined) {
2093
+ warnings.push(`cannot tell how long the repository keeps artifacts: ${retention.call} answered in a shape this check does not read`);
2094
+ }
2095
+ else if (retentionDays < ARTIFACT_RETENTION_WARN_DAYS) {
2096
+ warnings.push(`the repository keeps artifacts for ${retentionDays} days, so a remote run parked or paused longer than that loses its state bundle: raise it under Settings → Actions → General → Artifact and log retention`);
2097
+ }
2098
+ }
2099
+ const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
2100
+ if (failures.length > 0)
2101
+ return fail(`${[...failures, ...warnings].join('; ')}${noted}`);
2102
+ if (warnings.length > 0)
2103
+ return warn(`${warnings.join('; ')}${noted}`);
2104
+ const kept = retentionDays === undefined ? '' : `, and the repository keeps artifacts for ${retentionDays} days`;
2105
+ 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}${noted}`);
2106
+ },
2107
+ };
1785
2108
  /**
1786
2109
  * The config's shape, re-checked at runtime.
1787
2110
  *
@@ -2202,8 +2525,6 @@ const STATE_DIR_TREE_CHECK = {
2202
2525
  return pass(`all ${expected.length} artifact directories the configured phases call for are present under ${root}`);
2203
2526
  },
2204
2527
  };
2205
- /** The command both remedies below name, spelled as `init` and every stub footer spell it. */
2206
- const ANALYZE_COMMAND = '/harness-analyze';
2207
2528
  /**
2208
2529
  * Setup's judgement half, reported as the four facts that decide it: a configured conventions
2209
2530
  * document with nothing behind it, one that is still an untouched skeleton, a configured
@@ -2459,7 +2780,7 @@ function detectionProvenance(detection) {
2459
2780
  *
2460
2781
  * **It is deliberately blind to the conventions documents.** {@link SETUP_ANALYSIS_CHECK} above
2461
2782
  * grades those, and its skeleton warning was the last indirect trace that a fallback profile had
2462
- * never been examined — a trace `/harness-analyze conventions` clears by filling the documents,
2783
+ * never been examined — a trace `/autonomous-sdlc-harness:harness-analyze conventions` clears by filling the documents,
2463
2784
  * leaving `doctor` green about a profile nobody looked at. This check reads no document, so that
2464
2785
  * command does not move it.
2465
2786
  *
@@ -3035,7 +3356,7 @@ function namesHelperScript(entry) {
3035
3356
  * With nothing left to grade — the phase off at a single root — it reports **not graded** and names
3036
3357
  * which, rather than a pass an adopter would read as coverage.
3037
3358
  *
3038
- * The helper names come from **reading `<root>/scripts/`** ({@link pluginHelperScripts}) at each
3359
+ * The helper names come from **reading `<root>/scripts/`** ({@link pluginRootHelpers}) at each
3039
3360
  * graded root and taking the union, never from a list kept here: they are declared once, in the
3040
3361
  * plugin's own `scripts/README.md`, and a copy in this file would be a second declaration that
3041
3362
  * drifts the first time one is added. Nothing here classifies a helper by its call site either —
@@ -3120,7 +3441,7 @@ const PLUGIN_PERMISSIONS_CHECK = {
3120
3441
  // complete that is one unreadable file away from a stall.
3121
3442
  const phaseKnown = ctx.config !== undefined;
3122
3443
  const qaOn = ctx.config?.phases?.qa === true;
3123
- const helpers = qaOn ? [...new Set(roots.flatMap((root) => pluginHelperScripts(root)))].sort() : [];
3444
+ const helpers = pluginRootHelpers(roots, qaOn);
3124
3445
  const groups = roots.map((root) => ({
3125
3446
  root,
3126
3447
  label: split
@@ -3130,17 +3451,15 @@ const PLUGIN_PERMISSIONS_CHECK = {
3130
3451
  : installRoot === undefined
3131
3452
  ? `the directory this marketplace is sourced from (the only root that resolved: ${installedPluginsPath()} records no install root)`
3132
3453
  : `the one plugin root this machine resolves, recorded in ${installedPluginsPath()}`,
3133
- required: [
3134
- // Outside the phase gate, and only where the runtime root is its own directory: reads at the
3135
- // install root were measured to succeed ungranted, ten at the runtime root to be refused.
3136
- ...(root === installRoot
3137
- ? []
3138
- : [{ rule: readRule(root), symptom: 'improvises in place of a contract file it is refused' }]),
3139
- ...helpers.map((name) => ({
3140
- rule: bashScriptRule(pluginHelperPath(root, name)),
3141
- symptom: 'parks with no error at the first helper script it reaches',
3142
- })),
3143
- ],
3454
+ // The builder `init --plugin-root-entries` writes through too, so the two cannot differ. The
3455
+ // read rule is outside the phase gate and absent at the install root: reads there were measured
3456
+ // to succeed ungranted, ten at the runtime root to be refused.
3457
+ required: pluginRootEntries(root, { isInstallRoot: root === installRoot, helpers }).map(({ kind, rule }) => ({
3458
+ rule,
3459
+ symptom: kind === 'read'
3460
+ ? 'improvises in place of a contract file it is refused'
3461
+ : 'parks with no error at the first helper script it reaches',
3462
+ })),
3144
3463
  }));
3145
3464
  const required = groups.flatMap((group) => group.required);
3146
3465
  if (required.length === 0) {
@@ -3383,6 +3702,121 @@ const BROWSER_WIRING_CHECK = {
3383
3702
  return pass(`${MCP_PATH} declares ${nameList(declared)} and every launch command resolves on PATH; no server was started and no browser was launched${resolution}.${registryNote}${noClient}`);
3384
3703
  },
3385
3704
  };
3705
+ /** The pass every retrieval check gives when {@link retrievalApplies} is false. */
3706
+ 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';
3707
+ /** How many missing model files {@link RETRIEVAL_MODEL_CACHE_CHECK} names before it counts the rest. */
3708
+ const MISSING_MODEL_FILES_NAMED = 5;
3709
+ /**
3710
+ * How long the `retrieval-index` probe may run. Wide because a real corpus embeds every chunk on this
3711
+ * probe, and a bound that cut that off would fail an index that builds.
3712
+ */
3713
+ const RETRIEVAL_INDEX_TIMEOUT_MS = 600_000;
3714
+ /**
3715
+ * Is the runtime `.mcp.json`'s launcher `exec`s installed, at this CLI's version, with its peers?
3716
+ *
3717
+ * Answered by {@link retrievalRuntimeState} alone — the predicate `init`'s setup skips the install on —
3718
+ * so the two cannot disagree (choice 1). Whether **this** installation resolves its own peers is not
3719
+ * consulted: the launcher never runs this installation, so a pass on it would be a pass on a server
3720
+ * that never starts.
3721
+ */
3722
+ const RETRIEVAL_DEPENDENCIES_CHECK = {
3723
+ id: 'retrieval-dependencies',
3724
+ // The first of the three retrieval checks in registration order, and so the one that expands the
3725
+ // term for the whole report: the other two say "RAG" alone.
3726
+ title: 'RAG (docs retrieval) libraries resolve',
3727
+ run: (ctx) => {
3728
+ if (ctx.repoRoot === undefined)
3729
+ return unevaluated('the repository root did not resolve (see the git check)');
3730
+ if (ctx.config === undefined)
3731
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
3732
+ if (!retrievalApplies(ctx.config))
3733
+ return pass(RETRIEVAL_OFF);
3734
+ const runtime = retrievalRuntimeDir();
3735
+ const state = retrievalRuntimeState();
3736
+ if (state.installed) {
3737
+ return pass(`the RAG runtime is installed at ${runtime} (version ${state.version ?? 'unknown'}) with every optional peer, and ${MCP_PATH}'s launcher ${DOCS_SEARCH_SERVER_SCRIPT_NAME} execs that installation's entry`);
3738
+ }
3739
+ return fail(`the RAG runtime at ${runtime} is missing ${nameList(state.missing)}, so ${MCP_PATH}'s launcher ${DOCS_SEARCH_SERVER_SCRIPT_NAME} has nothing to exec until it is installed and the search server never starts — run \`${CLI} init\`, which installs it`);
3740
+ },
3741
+ };
3742
+ /** Are both models' files in the shared model cache — the offline load's precondition? */
3743
+ const RETRIEVAL_MODEL_CACHE_CHECK = {
3744
+ id: 'retrieval-model-cache',
3745
+ title: "RAG's models are cached",
3746
+ run: (ctx) => {
3747
+ if (ctx.repoRoot === undefined)
3748
+ return unevaluated('the repository root did not resolve (see the git check)');
3749
+ if (ctx.config === undefined)
3750
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
3751
+ if (!retrievalApplies(ctx.config))
3752
+ return pass(RETRIEVAL_OFF);
3753
+ const dir = retrievalModelCacheDir();
3754
+ const { present, missing } = modelFilesPresent(dir);
3755
+ if (present)
3756
+ return pass(`every model file RAG loads offline is cached in ${dir}`);
3757
+ const named = missing.slice(0, MISSING_MODEL_FILES_NAMED).join(', ');
3758
+ const rest = missing.length - MISSING_MODEL_FILES_NAMED;
3759
+ return fail(`the model cache at ${dir} is missing ${named}${rest > 0 ? ` and ${rest} more` : ''} — run \`${CLI} init\`, which downloads them at setup time. An unattended run has no web access, so a model missing now is never fetched later`);
3760
+ },
3761
+ };
3762
+ /** The last non-empty line of a child's output, or `undefined` when it printed nothing. */
3763
+ function lastNonEmptyLine(output) {
3764
+ return output
3765
+ .split('\n')
3766
+ .map((line) => line.trim())
3767
+ .filter((line) => line !== '')
3768
+ .at(-1);
3769
+ }
3770
+ /**
3771
+ * Does the docs-retrieval index build?
3772
+ *
3773
+ * A child process of this CLI runs `docs index --in-memory` rather than the server being launched —
3774
+ * choice 3's *"never a launch"*: the child builds the whole index in memory, prints one line and exits,
3775
+ * so no server starts and nothing is written. It is a child at all because {@link Check.run} is
3776
+ * synchronous and the store is not. Its entry is {@link retrievalCliEntry}'s: the probe asks whether an
3777
+ * index builds, not what the launcher runs, which `retrieval-dependencies` grades.
3778
+ *
3779
+ * **`spawnSync` rather than `execFileSync`**, for the reason `cli/src/commands/doctor.ts` →
3780
+ * `runNotifier` states: `execFileSync` returns stdout and surfaces a child's stderr on the error path
3781
+ * only, and the child's corpus-coverage warnings — an unset or mis-spelled `docs.root`, a missing
3782
+ * conventions document — are printed on stderr by a run that **succeeds**. This is the one check an
3783
+ * adopter runs to answer "is retrieval set up correctly?", so a build over a corpus missing the whole
3784
+ * documentation catalog must not read as an unqualified pass.
3785
+ */
3786
+ const RETRIEVAL_INDEX_CHECK = {
3787
+ id: 'retrieval-index',
3788
+ title: 'the RAG index builds',
3789
+ run: (ctx) => {
3790
+ if (ctx.repoRoot === undefined)
3791
+ return unevaluated('the repository root did not resolve (see the git check)');
3792
+ if (ctx.config === undefined)
3793
+ return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
3794
+ if (!retrievalApplies(ctx.config))
3795
+ return pass(RETRIEVAL_OFF);
3796
+ const resolved = retrievalCliEntry();
3797
+ if (resolved === undefined)
3798
+ return fail('cannot build without the RAG libraries (see retrieval-dependencies)');
3799
+ // `spawnSync` does not throw on a non-zero exit, so this `try` now covers a spawn failure alone;
3800
+ // the child's own failure is graded on `status` and `error` below.
3801
+ try {
3802
+ const child = spawnSync(process.execPath, [resolved.entry, 'docs', 'index', '--in-memory', '--cwd', ctx.repoRoot], {
3803
+ encoding: 'utf8',
3804
+ stdio: ['ignore', 'pipe', 'pipe'],
3805
+ timeout: RETRIEVAL_INDEX_TIMEOUT_MS,
3806
+ env: process.env,
3807
+ });
3808
+ const line = lastNonEmptyLine(child.stderr ?? '');
3809
+ if (child.error !== undefined || child.status !== 0) {
3810
+ const how = child.status === null ? `it was stopped by ${child.signal}` : `it exited with status ${child.status}`;
3811
+ return fail(`the RAG index did not build in memory: ${line ?? (child.error === undefined ? how : messageOf(child.error))} — run \`${CLI} init\` to set retrieval up`);
3812
+ }
3813
+ return pass(line === undefined ? child.stdout.trim() : `${child.stdout.trim()} — ${line}`);
3814
+ }
3815
+ catch (error) {
3816
+ return fail(`the RAG index did not build in memory: ${messageOf(error)} — run \`${CLI} init\` to set retrieval up`);
3817
+ }
3818
+ },
3819
+ };
3386
3820
  /**
3387
3821
  * The checks, in the order they are evaluated and reported: the host and the tools first, then the
3388
3822
  * repository's own wiring, then the permission profile and the browser half that depends on it.
@@ -3459,11 +3893,17 @@ const BROWSER_WIRING_CHECK = {
3459
3893
  * for the machine around it — and they are meant to be read together. `daemon-path` comes last of
3460
3894
  * the six because it is the only one that grades an *installed* unit: it has nothing to say until the
3461
3895
  * five above it are answered, and it says so rather than guessing.
3896
+ * `remote-execution` follows it because it asks the same "can this run unattended" question of a run
3897
+ * the watcher dispatches to GitHub rather than spawns here, and `remote-github` follows that because it
3898
+ * asks GitHub the half of the same question local evidence cannot answer.
3462
3899
  *
3463
3900
  * `plugin-permissions` closes the profile block for the same shape of reason: it is the only profile
3464
3901
  * question whose other half is not in the repository at all — the plugin's machine-local install
3465
3902
  * root — so it is answerable only once the profile itself has been read, and a reader whose
3466
3903
  * profile lines all passed reads it as the last thing that can still be missing from that file.
3904
+ *
3905
+ * The three `retrieval-*` checks come last, under `browser-wiring`: the runtime, the models, then
3906
+ * whether an index builds, which needs libraries and models both and so reads after them.
3467
3907
  */
3468
3908
  export const CHECKS = Object.freeze([
3469
3909
  GIT_CHECK,
@@ -3481,6 +3921,8 @@ export const CHECKS = Object.freeze([
3481
3921
  REPO_REGISTRY_CHECK,
3482
3922
  MACHINE_FOOTPRINT_CHECK,
3483
3923
  DAEMON_PATH_CHECK,
3924
+ REMOTE_EXECUTION_CHECK,
3925
+ REMOTE_GITHUB_CHECK,
3484
3926
  CONFIG_CHECK,
3485
3927
  COMMAND_WRAPPERS_CHECK,
3486
3928
  COMMAND_PERMISSIONS_CHECK,
@@ -3499,6 +3941,9 @@ export const CHECKS = Object.freeze([
3499
3941
  PROFILE_DENY_FLOOR_CHECK,
3500
3942
  PLUGIN_PERMISSIONS_CHECK,
3501
3943
  BROWSER_WIRING_CHECK,
3944
+ RETRIEVAL_DEPENDENCIES_CHECK,
3945
+ RETRIEVAL_MODEL_CACHE_CHECK,
3946
+ RETRIEVAL_INDEX_CHECK,
3502
3947
  ]);
3503
3948
  /**
3504
3949
  * Evaluate every check in order and return one result each.