autonomous-sdlc-harness 0.2.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +0 -0
- package/dist/commands/docs.js +2 -1
- package/dist/commands/docs.js.map +1 -1
- package/dist/commands/doctor.js +56 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +107 -20
- package/dist/commands/init.js.map +1 -1
- package/dist/config/check.js +11 -4
- package/dist/config/check.js.map +1 -1
- package/dist/config/model.js +25 -0
- package/dist/config/model.js.map +1 -1
- package/dist/core/defaultBranchPush.js +26 -0
- package/dist/core/defaultBranchPush.js.map +1 -0
- package/dist/core/git.js +61 -0
- package/dist/core/git.js.map +1 -1
- package/dist/core/paths.js +22 -2
- package/dist/core/paths.js.map +1 -1
- package/dist/core/prompt.js +6 -2
- package/dist/core/prompt.js.map +1 -1
- package/dist/core/writer.js +2 -0
- package/dist/core/writer.js.map +1 -1
- package/dist/doctor/checks.js +482 -73
- package/dist/doctor/checks.js.map +1 -1
- package/dist/generators/githubWorkflows.js +66 -0
- package/dist/generators/githubWorkflows.js.map +1 -0
- package/dist/generators/notifications.js +105 -19
- package/dist/generators/notifications.js.map +1 -1
- package/dist/generators/outerLoopScripts.js +17 -3
- package/dist/generators/outerLoopScripts.js.map +1 -1
- package/dist/generators/permissionProfile.js +141 -21
- package/dist/generators/permissionProfile.js.map +1 -1
- package/dist/generators/projectSettings.js +2 -1
- package/dist/generators/projectSettings.js.map +1 -1
- package/dist/generators/repoRoot.js +23 -4
- package/dist/generators/repoRoot.js.map +1 -1
- package/dist/generators/stateDir.js +9 -3
- package/dist/generators/stateDir.js.map +1 -1
- package/dist/machine/paths.js +4 -1
- package/dist/machine/paths.js.map +1 -1
- package/dist/remote/githubActions.js +86 -0
- package/dist/remote/githubActions.js.map +1 -0
- package/dist/retrieval/queryLog.js +6 -4
- package/dist/retrieval/queryLog.js.map +1 -1
- package/dist/retrieval/runtime.js +46 -33
- package/dist/retrieval/runtime.js.map +1 -1
- package/dist/retrieval/search.js +21 -10
- package/dist/retrieval/search.js.map +1 -1
- package/dist/retrieval/server.js +1 -1
- package/dist/retrieval/setup.js +2 -1
- package/dist/retrieval/setup.js.map +1 -1
- package/package.json +2 -2
- package/templates/README.md +3 -2
- package/templates/claude/context/conventions.md +1 -1
- package/templates/claude/context/layer.md +1 -1
- package/templates/claude/push-notify.env.example +7 -2
- package/templates/claude/settings.autonomous.json +1 -1
- package/templates/github/workflows/harness-resume.yml +124 -0
- package/templates/github/workflows/harness-run.yml +446 -0
- package/templates/repo/gitignore +11 -1
- package/templates/scripts/README.md +1 -1
- package/templates/scripts/autonomous-watcher.sh +1296 -169
- package/templates/scripts/flow-walker.sh +629 -0
- package/templates/scripts/flows/task_plan_writing.graph.json +192 -0
- package/templates/scripts/lib/flow-walker-gates.sh +165 -0
- package/templates/scripts/lib/harness-run-lib.sh +595 -18
- package/templates/scripts/remote-run.sh +1784 -0
- package/templates/scripts/restart-watcher.sh +21 -1
- package/templates/scripts/run-test-suite.sh +182 -0
- package/templates/scripts/scratch-run.sh +2 -1
- package/templates/state-dir/README-root.md +1 -1
- package/templates/state-dir/autonomous_logs/README.md +2 -2
- package/templates/state-dir/improvement_observations/README.md +2 -0
- package/templates/state-dir/scratch/README.md +1 -1
- package/templates/state-dir/test_fix_plan_reviews/README.md +9 -0
- package/templates/state-dir/test_fix_plans/README.md +9 -0
- package/templates/state-dir/test_fix_point_reviews/README.md +9 -0
- package/templates/state-dir/test_run_logs/README.md +11 -0
package/dist/doctor/checks.js
CHANGED
|
@@ -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 —
|
|
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,11 @@
|
|
|
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
|
+
*
|
|
57
66
|
* **The three docs-retrieval checks keep that line.** `retrieval-dependencies` and
|
|
58
67
|
* `retrieval-model-cache` are file tests answered by `retrieval/runtime.ts`'s
|
|
59
68
|
* {@link retrievalRuntimeState} and `retrieval/models.ts`'s {@link modelFilesPresent}, and load
|
|
@@ -75,9 +84,10 @@ import { accessSync, constants as fsConstants, existsSync, readFileSync, statSyn
|
|
|
75
84
|
import { delimiter, join, posix, resolve as resolvePath } from 'node:path';
|
|
76
85
|
import { formatProblem } from '../config/check.js';
|
|
77
86
|
import { loadConfig } from '../config/io.js';
|
|
78
|
-
import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, isPlaceholder, LAYER_CATCH_ALL_PATH, retrievalApplies, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
|
|
79
|
-
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, mainWorktreeRoot, pathAtRef, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
|
|
80
89
|
import { isJsonObject, readJsonFile } from '../core/json.js';
|
|
90
|
+
import { defaultBranchPushCommand, defaultBranchPushReason, WORKFLOW_SCOPE_COMMAND, WORKFLOW_SCOPE_REASON, } from '../core/defaultBranchPush.js';
|
|
81
91
|
import { layerCoverage } from '../core/layerCoverage.js';
|
|
82
92
|
import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
|
|
83
93
|
import { nameList } from '../core/nameList.js';
|
|
@@ -91,16 +101,17 @@ import { LAYERLESS_BY_DESIGN_PRESETS, SHARED_CONVENTIONS_PATH, TESTS_LAYER_NAME
|
|
|
91
101
|
import { FORCED_SIGNAL_ID } from '../detect/signals.js';
|
|
92
102
|
import { CLAUDE_MD_PATH, SETUP_PENDING_CLOSE, SETUP_PENDING_OPEN, SKELETON_GUIDANCE_MARKER, TASK_OFFER_PATH, UNFILLED_STUB_MARKER, } from '../generators/claudeContext.js';
|
|
93
103
|
import { caseLabelMatches, isGlobPattern, PRE_PUSH_HOOK, readProtectedCaseLabel, resolveGithooksDir, resolveProtectedBranches, } from '../generators/githooks.js';
|
|
94
|
-
import { PUSH_CMD_KEY, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
|
|
104
|
+
import { PUSH_CMD_KEY, PUSH_DESTINATION_PLACEHOLDER, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
|
|
95
105
|
import { DOCS_SEARCH_SERVER_SCRIPT_NAME, outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
|
|
96
|
-
import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootEntryTarget,
|
|
106
|
+
import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootDirectories, pluginRootEntries, pluginRootEntryTarget, pluginRootHelpers, PLUGIN_ROOT_ENTRIES_FLAG, PROFILE_PATH, renderProfile, TEMPLATE_PATH as PROFILE_TEMPLATE_PATH, } from '../generators/permissionProfile.js';
|
|
97
107
|
import { ENABLED_PLUGINS_KEY, MARKETPLACE_ENTRY_SHAPE, MARKETPLACE_FLAG, MARKETPLACES_KEY, MARKETPLACE_NAME, marketplaceEntryDefect, PLUGIN_KEY, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
|
|
98
108
|
import { clarificationsIgnoreRules, contentsIgnoredDirectories, GITIGNORE_BLOCK_HEADER, GITIGNORE_PATH, MCP_PATH, } from '../generators/repoRoot.js';
|
|
99
109
|
import { configKeyPath, configuredWrapperFile, scriptInvocation, selectWrapper, wrappedKeyMismatch, wrappedKeyMismatchMessage, WRAPPER_SCRIPTS, wrapperCommandLine, } from '../generators/scripts.js';
|
|
100
110
|
import { selectedStateDirs } from '../generators/stateDir.js';
|
|
101
111
|
import { machineConfigDir } from '../machine/paths.js';
|
|
102
|
-
import { installedPluginsPath, knownMarketplacesPath,
|
|
112
|
+
import { installedPluginsPath, knownMarketplacesPath, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
|
|
103
113
|
import { inspect, readRegistry, registryPath } from '../machine/registry.js';
|
|
114
|
+
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';
|
|
104
115
|
import { modelFilesPresent } from '../retrieval/models.js';
|
|
105
116
|
import { retrievalCliEntry, retrievalModelCacheDir, retrievalRuntimeDir, retrievalRuntimeState, } from '../retrieval/runtime.js';
|
|
106
117
|
/**
|
|
@@ -123,6 +134,21 @@ const WORKTREE_SCRIPT = 'create-worktree.sh';
|
|
|
123
134
|
* `bash <scriptsDir>/<file>` — so nothing here formats that shape a second time.
|
|
124
135
|
*/
|
|
125
136
|
const REFRESH_SCRIPT = 'refresh-branch.sh';
|
|
137
|
+
/**
|
|
138
|
+
* The outer-loop script the watcher dispatches a remote run through, named in {@link requiredBinaries}'
|
|
139
|
+
* and {@link REMOTE_EXECUTION_CHECK}'s sentences on {@link WORKTREE_SCRIPT}'s terms: a file in a
|
|
140
|
+
* sentence, never resolved or read here.
|
|
141
|
+
*/
|
|
142
|
+
const REMOTE_RUN_SCRIPT = 'remote-run.sh';
|
|
143
|
+
/**
|
|
144
|
+
* The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the repository's artifact retention from; `gh`
|
|
145
|
+
* fills `{owner}` and `{repo}` from the checkout's remote. Local, not a `remote/githubActions.ts`
|
|
146
|
+
* export: that module owns the names `cli/src` shares with the shell and YAML mirrors, and no shell or
|
|
147
|
+
* YAML file spells this endpoint.
|
|
148
|
+
*/
|
|
149
|
+
const ARTIFACT_RETENTION_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention';
|
|
150
|
+
/** Below this many days of artifact retention {@link REMOTE_GITHUB_CHECK} warns; argued there. */
|
|
151
|
+
const ARTIFACT_RETENTION_WARN_DAYS = 30;
|
|
126
152
|
/** The permission lists a generated profile carries, in the order the template writes them. */
|
|
127
153
|
const PERMISSION_LISTS = Object.freeze(['allow', 'deny', 'ask']);
|
|
128
154
|
/** An unsubstituted token, which a raw template's entry may carry and a rendered profile may not. */
|
|
@@ -367,11 +393,17 @@ function serversStartedByProfile(profile) {
|
|
|
367
393
|
* has to report on rather than crash against, and the same holds for a config or a profile that does
|
|
368
394
|
* not parse. Each failure becomes a field the check that owns that subject renders.
|
|
369
395
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
396
|
+
* The profile is read from the main checkout ({@link mainWorktreeRoot}, falling back to `repoRoot`
|
|
397
|
+
* when the probe does not answer), since that is the one a run loads; `repoRoot` stays the checkout
|
|
398
|
+
* `doctor` runs in, so a linked worktree's profile checks grade the main checkout's file against the
|
|
399
|
+
* worktree's root.
|
|
400
|
+
*
|
|
401
|
+
* `probeRegistry` and `probeGithub` are the caller's answers rather than this function's, and both
|
|
402
|
+
* default to `false`: a context built without them is the context every default run gets, and no
|
|
403
|
+
* check here reaches a network unless the command was asked to. `remoteJob` is the caller's too, and
|
|
404
|
+
* defaults to `false` for the same reason: only the remote job's preflight asks for its grading.
|
|
373
405
|
*/
|
|
374
|
-
export function buildCheckContext(cwd, probeRegistry = false) {
|
|
406
|
+
export function buildCheckContext(cwd, probeRegistry = false, probeGithub = false, remoteJob = false) {
|
|
375
407
|
let repoRoot;
|
|
376
408
|
let repoProblem;
|
|
377
409
|
try {
|
|
@@ -381,15 +413,19 @@ export function buildCheckContext(cwd, probeRegistry = false) {
|
|
|
381
413
|
repoProblem = messageOf(error);
|
|
382
414
|
}
|
|
383
415
|
if (repoRoot === undefined)
|
|
384
|
-
return { cwd, repoProblem, configProblems: [], probeRegistry };
|
|
416
|
+
return { cwd, repoProblem, configProblems: [], probeRegistry, probeGithub, remoteJob };
|
|
385
417
|
const loaded = loadConfig(repoRoot);
|
|
386
|
-
const
|
|
418
|
+
const profileRoot = mainWorktreeRoot(repoRoot) ?? repoRoot;
|
|
419
|
+
const profilePath = join(profileRoot, PROFILE_PATH);
|
|
387
420
|
let profile;
|
|
388
421
|
let profileProblem;
|
|
389
422
|
try {
|
|
390
423
|
const parsed = readJsonFile(profilePath);
|
|
391
424
|
if (parsed === undefined) {
|
|
392
|
-
profileProblem =
|
|
425
|
+
profileProblem =
|
|
426
|
+
profileRoot === repoRoot
|
|
427
|
+
? `no ${PROFILE_PATH} at ${profilePath}: this repository has no unattended-run permission profile — run \`${CLI} init\` to generate one`
|
|
428
|
+
: `no ${PROFILE_PATH} at ${profilePath}: a linked worktree carries no profile of its own, and runs load the main checkout's, at ${profileRoot} — run \`${CLI} init\` there, not in this worktree, to generate one`;
|
|
393
429
|
}
|
|
394
430
|
else if (!isJsonObject(parsed)) {
|
|
395
431
|
profileProblem = `${profilePath} is not a JSON object, so it is not a settings file the agent runner can load`;
|
|
@@ -411,6 +447,8 @@ export function buildCheckContext(cwd, probeRegistry = false) {
|
|
|
411
447
|
profileProblem,
|
|
412
448
|
profilePath,
|
|
413
449
|
probeRegistry,
|
|
450
|
+
probeGithub,
|
|
451
|
+
remoteJob,
|
|
414
452
|
};
|
|
415
453
|
}
|
|
416
454
|
/** Is this repository inside a work tree at all — the precondition every repo-wiring check has. */
|
|
@@ -1055,7 +1093,7 @@ function deliveryOptInAdvice(candidates) {
|
|
|
1055
1093
|
const repositoryRemedy = candidates.some((candidate) => candidate.origin === 'repository')
|
|
1056
1094
|
? ', and the repository-side one is filled in by hand'
|
|
1057
1095
|
: `; this repository configures no repository-side file, so filling one means setting \`pushEnvPath\` in ${CONFIG_FILENAME} first`;
|
|
1058
|
-
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
|
|
1096
|
+
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}`;
|
|
1059
1097
|
}
|
|
1060
1098
|
/**
|
|
1061
1099
|
* Which settings file an unattended run's notifier will actually read, and whether either delivery
|
|
@@ -1597,7 +1635,10 @@ function commandHeads(config, repoRoot) {
|
|
|
1597
1635
|
* configuration through and which {@link JQ_CHECK} already treats as a hard floor; and the agent CLI
|
|
1598
1636
|
* above, whose name arrives as the installed unit's value — `undefined` or empty when the unit does
|
|
1599
1637
|
* not set it, which is the default case because `${HARNESS_AGENT_CLI:-claude}` reads an empty value
|
|
1600
|
-
* as unset too.
|
|
1638
|
+
* as unset too. **A conditional fourth joins them only when {@link remoteExecutionApplies}:** the
|
|
1639
|
+
* binary run as `gh`, which `remote-run.sh` dispatches every remote run through. Its name is the
|
|
1640
|
+
* unit's own {@link GH_CLI_VARIABLE} value when set and non-empty, else {@link DEFAULT_GH_CLI}, on
|
|
1641
|
+
* the agent CLI's terms. With remote execution off the list is exactly the three and the derived.
|
|
1601
1642
|
*
|
|
1602
1643
|
* **The unit of comparison is the head of a command line**, over the two places such a line lives, and
|
|
1603
1644
|
* deriving it is {@link commandHeads}' — this check classifies nothing itself, so it and its sibling
|
|
@@ -1623,7 +1664,7 @@ function commandHeads(config, repoRoot) {
|
|
|
1623
1664
|
* `ENOENT` in with them printed an unfollowable instruction over the one state `init` clears by
|
|
1624
1665
|
* itself (Finding 1).
|
|
1625
1666
|
*/
|
|
1626
|
-
function requiredBinaries(config, repoRoot, unitAgentCli) {
|
|
1667
|
+
function requiredBinaries(config, repoRoot, unitAgentCli, unitGhCli) {
|
|
1627
1668
|
const found = new Map();
|
|
1628
1669
|
const ungraded = [];
|
|
1629
1670
|
// Named `absent` rather than `missing`: in this module a *missing* binary is one the unit's PATH
|
|
@@ -1644,6 +1685,10 @@ function requiredBinaries(config, repoRoot, unitAgentCli) {
|
|
|
1644
1685
|
add(agent === '' ? DEFAULT_AGENT_CLI : agent, agent === ''
|
|
1645
1686
|
? `the watcher's agent binary (${AGENT_CLI_VARIABLE} unset in the unit, so the watcher's default)`
|
|
1646
1687
|
: `the watcher's agent binary (${AGENT_CLI_VARIABLE}, as the unit sets it)`);
|
|
1688
|
+
if (remoteExecutionApplies(config)) {
|
|
1689
|
+
const gh = unitGhCli?.trim() ?? '';
|
|
1690
|
+
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'})`);
|
|
1691
|
+
}
|
|
1647
1692
|
// `deploy` is this check's to skip, on both arms: its command line lives on `deploy.command`
|
|
1648
1693
|
// rather than in `commands`, and no daemon-launched run deploys. {@link commandHeads} derives it
|
|
1649
1694
|
// for its other consumer, which does grade it.
|
|
@@ -1781,7 +1826,7 @@ const DAEMON_PATH_CHECK = {
|
|
|
1781
1826
|
}
|
|
1782
1827
|
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)}`);
|
|
1783
1828
|
}
|
|
1784
|
-
const { binaries: required, ungraded, absent } = requiredBinaries(ctx.config, ctx.repoRoot, unitEnvValue(backend.kind, text, AGENT_CLI_VARIABLE));
|
|
1829
|
+
const { binaries: required, ungraded, absent } = requiredBinaries(ctx.config, ctx.repoRoot, unitEnvValue(backend.kind, text, AGENT_CLI_VARIABLE), unitEnvValue(backend.kind, text, GH_CLI_VARIABLE));
|
|
1785
1830
|
const envPath = unitEnvValue(backend.kind, text, 'PATH');
|
|
1786
1831
|
if (envPath === undefined) {
|
|
1787
1832
|
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)}`);
|
|
@@ -1793,6 +1838,287 @@ const DAEMON_PATH_CHECK = {
|
|
|
1793
1838
|
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)}`);
|
|
1794
1839
|
},
|
|
1795
1840
|
};
|
|
1841
|
+
/**
|
|
1842
|
+
* The configured execution target, and whether this repository has what a remote run needs locally.
|
|
1843
|
+
*
|
|
1844
|
+
* **Remote setup is graded from local evidence by default and asks GitHub only under
|
|
1845
|
+
* `--check-github`** (the module header's choice 3). This reads the two workflow files, one git ref
|
|
1846
|
+
* and `PATH`, and spawns no `gh` subcommand: resolving the binary {@link ghCli} names is the whole of
|
|
1847
|
+
* its `gh` question. What only GitHub can answer — the secrets, the variables, whether GitHub knows
|
|
1848
|
+
* the workflow — the pass text names and leaves to that flag.
|
|
1849
|
+
*
|
|
1850
|
+
* **Every finding is reported, and the grade is the worst of them.** Two `fail`s: no
|
|
1851
|
+
* `harness-run.yml`, because no remote run can be dispatched; and no `gh`, because the watcher
|
|
1852
|
+
* dispatches through it. Three `warn`s: no `harness-resume.yml`, because a usage-paused hosted run then
|
|
1853
|
+
* waits for `/autonomous-sdlc-harness:branch-resume`; a `harness-run.yml` that
|
|
1854
|
+
* `origin/<defaultBranch>` does not carry, because GitHub dispatches only a workflow its default
|
|
1855
|
+
* branch has — the run starts once it is pushed, so nothing is broken here, and its remedy's push
|
|
1856
|
+
* skips the hook because the `pre-push` hook `init` wired refuses every push to the default branch
|
|
1857
|
+
* (`core/defaultBranchPush.ts` owns that push, its `workflow`-scope step and both reasons); and `phases.qa` true,
|
|
1858
|
+
* because a remote run skips the interactive-test phase and the branch still reaches review — the
|
|
1859
|
+
* phase is then owed a local run. It needs no GitHub answer, so it is asked here rather than in
|
|
1860
|
+
* {@link REMOTE_GITHUB_CHECK}.
|
|
1861
|
+
*
|
|
1862
|
+
* `gh` is resolved on **this shell's** `PATH`. Whether the installed daemon's `PATH` reaches it is
|
|
1863
|
+
* {@link DAEMON_PATH_CHECK}'s question, answered there through {@link requiredBinaries}' conditional
|
|
1864
|
+
* member, so it is not asked twice.
|
|
1865
|
+
*
|
|
1866
|
+
* **A missing `origin/<defaultBranch>`, or an unusable `defaultBranch`, leaves the origin row
|
|
1867
|
+
* ungraded** and the detail says so: that ref's absence is {@link REMOTE_CHECK}'s `fail`, and
|
|
1868
|
+
* reporting it again would make one finding two. It is asked only when `harness-run.yml` exists here,
|
|
1869
|
+
* for the same reason. The ref is read as this checkout last fetched it.
|
|
1870
|
+
*
|
|
1871
|
+
* With remote execution off, a workflow file left in place is a `pass` that says it does nothing:
|
|
1872
|
+
* the watcher dispatches nothing while the key is `local`.
|
|
1873
|
+
*/
|
|
1874
|
+
const REMOTE_EXECUTION_CHECK = {
|
|
1875
|
+
id: 'remote-execution',
|
|
1876
|
+
title: 'the configured execution target, and whether this repository has what a remote run needs locally',
|
|
1877
|
+
run: (ctx) => {
|
|
1878
|
+
if (ctx.repoRoot === undefined)
|
|
1879
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1880
|
+
if (ctx.config === undefined)
|
|
1881
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
1882
|
+
const root = ctx.repoRoot;
|
|
1883
|
+
const present = (path) => existsSync(join(root, ...path.split('/')));
|
|
1884
|
+
const runPresent = present(WORKFLOW_RUN_PATH);
|
|
1885
|
+
const resumePresent = present(WORKFLOW_RESUME_PATH);
|
|
1886
|
+
if (!remoteExecutionApplies(ctx.config)) {
|
|
1887
|
+
const left = [WORKFLOW_RUN_PATH, WORKFLOW_RESUME_PATH].filter(present);
|
|
1888
|
+
if (left.length === 0)
|
|
1889
|
+
return pass('local execution; remote execution is off (`execution.target`)');
|
|
1890
|
+
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`);
|
|
1891
|
+
}
|
|
1892
|
+
const failures = [];
|
|
1893
|
+
const warnings = [];
|
|
1894
|
+
const notes = [];
|
|
1895
|
+
if (!runPresent) {
|
|
1896
|
+
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`);
|
|
1897
|
+
}
|
|
1898
|
+
if (!resumePresent) {
|
|
1899
|
+
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`);
|
|
1900
|
+
}
|
|
1901
|
+
const gh = ghCli();
|
|
1902
|
+
if (!resolvesOnPath(gh)) {
|
|
1903
|
+
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`);
|
|
1904
|
+
}
|
|
1905
|
+
if (ctx.config.phases?.qa === true) {
|
|
1906
|
+
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");
|
|
1907
|
+
}
|
|
1908
|
+
if (runPresent) {
|
|
1909
|
+
const branch = ctx.config.defaultBranch;
|
|
1910
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
1911
|
+
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)`);
|
|
1912
|
+
}
|
|
1913
|
+
else if (!remoteTrackingBranchResolves(root, branch)) {
|
|
1914
|
+
notes.push(`whether origin/${branch} carries ${WORKFLOW_RUN_PATH} is not graded, because there is no origin/${branch} (see the remote check)`);
|
|
1915
|
+
}
|
|
1916
|
+
else if (!pathAtRef(root, `origin/${branch}`, WORKFLOW_RUN_PATH)) {
|
|
1917
|
+
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, then run \`${WORKFLOW_SCOPE_COMMAND}\`, then \`${defaultBranchPushCommand(branch)}\`. ${WORKFLOW_SCOPE_REASON} ${defaultBranchPushReason(branch)}`);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
|
|
1921
|
+
const on = 'remote execution is on (execution.target github-actions)';
|
|
1922
|
+
if (failures.length > 0)
|
|
1923
|
+
return fail(`${on}: ${[...failures, ...warnings].join('; ')}${noted}`);
|
|
1924
|
+
if (warnings.length > 0)
|
|
1925
|
+
return warn(`${on}: ${warnings.join('; ')}${noted}`);
|
|
1926
|
+
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`);
|
|
1927
|
+
},
|
|
1928
|
+
};
|
|
1929
|
+
/**
|
|
1930
|
+
* What `gh` prints when it cannot reach GitHub at all — `error connecting to api.github.com`, then a
|
|
1931
|
+
* pointer to check the connection. Matched so a network failure grades as *cannot tell* rather than
|
|
1932
|
+
* as the thing asked about being missing.
|
|
1933
|
+
*/
|
|
1934
|
+
const GH_UNREACHABLE_PATTERN = /error connecting to|check your internet connection/i;
|
|
1935
|
+
/**
|
|
1936
|
+
* A stopped call — the bound in `runGh`, or any other signal — and one that could not reach GitHub
|
|
1937
|
+
* are `unknown`; any other non-zero exit is `refused`, quoting `gh`'s own first line.
|
|
1938
|
+
*/
|
|
1939
|
+
function classifyGh(result) {
|
|
1940
|
+
if (result.status === 0)
|
|
1941
|
+
return { kind: 'answered', stdout: result.stdout };
|
|
1942
|
+
const said = firstLine(result.stderr) || firstLine(result.stdout);
|
|
1943
|
+
if (result.status === null)
|
|
1944
|
+
return { kind: 'unknown', why: 'it was stopped before it answered (timed out)' };
|
|
1945
|
+
if (GH_UNREACHABLE_PATTERN.test(result.stderr))
|
|
1946
|
+
return { kind: 'unknown', why: `it could not reach GitHub (${said})` };
|
|
1947
|
+
return { kind: 'refused', why: said === '' ? `it exited ${result.status}` : `it exited ${result.status}: ${said}` };
|
|
1948
|
+
}
|
|
1949
|
+
/** The `name` field of each element of a `gh … list --json` array, or `undefined` when it is not that shape. */
|
|
1950
|
+
function ghJsonEntries(stdout) {
|
|
1951
|
+
let parsed;
|
|
1952
|
+
try {
|
|
1953
|
+
parsed = JSON.parse(stdout);
|
|
1954
|
+
}
|
|
1955
|
+
catch {
|
|
1956
|
+
return undefined;
|
|
1957
|
+
}
|
|
1958
|
+
if (!Array.isArray(parsed))
|
|
1959
|
+
return undefined;
|
|
1960
|
+
const entries = new Map();
|
|
1961
|
+
for (const item of parsed) {
|
|
1962
|
+
if (!isJsonObject(item) || typeof item.name !== 'string')
|
|
1963
|
+
return undefined;
|
|
1964
|
+
entries.set(item.name, typeof item.value === 'string' ? item.value : '');
|
|
1965
|
+
}
|
|
1966
|
+
return entries;
|
|
1967
|
+
}
|
|
1968
|
+
/** The positive-integer `days` of an artifact-retention answer, or `undefined` for any other shape. */
|
|
1969
|
+
function retentionDaysOf(stdout) {
|
|
1970
|
+
let parsed;
|
|
1971
|
+
try {
|
|
1972
|
+
parsed = JSON.parse(stdout);
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return undefined;
|
|
1976
|
+
}
|
|
1977
|
+
if (!isJsonObject(parsed))
|
|
1978
|
+
return undefined;
|
|
1979
|
+
const days = parsed.days;
|
|
1980
|
+
return typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : undefined;
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* What GitHub says about the remote setup — asked only under {@link CheckContext.probeGithub}.
|
|
1984
|
+
*
|
|
1985
|
+
* **Off by default** (the module header's choice 3): a default run passes saying it did not ask, and
|
|
1986
|
+
* spawns nothing. Every call goes through `remote/githubActions.ts` → `runGh` with a fixed argument
|
|
1987
|
+
* vector and is a read. Secret **values** are never read: `gh secret list` returns names only.
|
|
1988
|
+
*
|
|
1989
|
+
* Grades, worst wins and every finding is reported:
|
|
1990
|
+
* - `fail` — `gh` does not spawn or `gh auth status` refuses (nothing further is asked); GitHub does
|
|
1991
|
+
* not know `harness-run.yml`; neither credential secret is set.
|
|
1992
|
+
* - `warn` — `HARNESS_PUSH_URL` absent; `harness-resume.yml` unknown to GitHub; `HARNESS_REMOTE_STOP`
|
|
1993
|
+
* set; artifact retention below {@link ARTIFACT_RETENTION_WARN_DAYS} days; and any call that timed
|
|
1994
|
+
* out, could not reach GitHub, or answered in a shape not understood — *cannot tell* is not
|
|
1995
|
+
* *missing*, so it never fails.
|
|
1996
|
+
* - both credential secrets present is a note, not a finding: billing follows `ANTHROPIC_API_KEY`.
|
|
1997
|
+
* - the retention read refused (typically HTTP 403: the endpoint needs admin access) is a note too —
|
|
1998
|
+
* the read is best-effort, and a collaborator without admin can still run remotely.
|
|
1999
|
+
*
|
|
2000
|
+
* **Why 30 days.** A parked run waits on a human answer and a usage-paused one on a reset, and the
|
|
2001
|
+
* `harness-state` bundle is the only remote copy of either; once the repository's retention expires
|
|
2002
|
+
* it, the run can no longer be answered and loses its carried counts. Thirty days covers an ordinary
|
|
2003
|
+
* absence — a holiday — while GitHub's own default of 90 passes; below it, an ordinary absence can
|
|
2004
|
+
* cost a parked run its question.
|
|
2005
|
+
*/
|
|
2006
|
+
const REMOTE_GITHUB_CHECK = {
|
|
2007
|
+
id: 'remote-github',
|
|
2008
|
+
title: 'what GitHub says about the remote setup',
|
|
2009
|
+
run: (ctx) => {
|
|
2010
|
+
if (ctx.repoRoot === undefined)
|
|
2011
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2012
|
+
if (ctx.config === undefined)
|
|
2013
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
2014
|
+
if (!remoteExecutionApplies(ctx.config))
|
|
2015
|
+
return pass('remote execution is off (`execution.target`), so nothing was asked of GitHub');
|
|
2016
|
+
if (!ctx.probeGithub)
|
|
2017
|
+
return pass(`not asked — run \`${CLI} doctor --check-github\` to ask GitHub about the secrets, variables and workflows a remote run needs`);
|
|
2018
|
+
const root = ctx.repoRoot;
|
|
2019
|
+
const gh = ghCli();
|
|
2020
|
+
const ask = (args) => {
|
|
2021
|
+
const result = runGh(args, root);
|
|
2022
|
+
return { call: `\`gh ${args.join(' ')}\``, answer: result === undefined ? undefined : classifyGh(result) };
|
|
2023
|
+
};
|
|
2024
|
+
const ghRemedy = `install the GitHub CLI (https://cli.github.com) and run \`gh auth login\`, or point ${GH_CLI_VARIABLE} at it`;
|
|
2025
|
+
const noSpawn = `${gh} could not be run, so GitHub was not asked: ${ghRemedy}`;
|
|
2026
|
+
const cannotTell = (call, why, what) => `cannot tell ${what}: ${call} gave no answer, because ${why}`;
|
|
2027
|
+
const auth = ask(['auth', 'status']);
|
|
2028
|
+
if (auth.answer === undefined)
|
|
2029
|
+
return fail(noSpawn);
|
|
2030
|
+
if (auth.answer.kind === 'unknown')
|
|
2031
|
+
return warn(cannotTell(auth.call, auth.answer.why, 'whether gh is authenticated, so nothing further was asked'));
|
|
2032
|
+
if (auth.answer.kind === 'refused')
|
|
2033
|
+
return fail(`${auth.call} reports no usable login, because ${auth.answer.why}: run \`gh auth login\``);
|
|
2034
|
+
const failures = [];
|
|
2035
|
+
const warnings = [];
|
|
2036
|
+
const notes = [];
|
|
2037
|
+
const run = ask(['workflow', 'view', WORKFLOW_RUN_FILE]);
|
|
2038
|
+
if (run.answer === undefined)
|
|
2039
|
+
return fail(noSpawn);
|
|
2040
|
+
if (run.answer.kind === 'unknown')
|
|
2041
|
+
warnings.push(cannotTell(run.call, run.answer.why, `whether GitHub knows ${WORKFLOW_RUN_FILE}`));
|
|
2042
|
+
if (run.answer.kind === 'refused') {
|
|
2043
|
+
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`);
|
|
2044
|
+
}
|
|
2045
|
+
const secrets = ask(['secret', 'list', '--json', 'name']);
|
|
2046
|
+
if (secrets.answer === undefined)
|
|
2047
|
+
return fail(noSpawn);
|
|
2048
|
+
const secretNames = secrets.answer.kind === 'answered' ? ghJsonEntries(secrets.answer.stdout) : undefined;
|
|
2049
|
+
if (secrets.answer.kind !== 'answered') {
|
|
2050
|
+
warnings.push(cannotTell(secrets.call, secrets.answer.why, 'which repository secrets are set'));
|
|
2051
|
+
}
|
|
2052
|
+
else if (secretNames === undefined) {
|
|
2053
|
+
warnings.push(`cannot tell which repository secrets are set: ${secrets.call} answered in a shape this check does not read`);
|
|
2054
|
+
}
|
|
2055
|
+
else {
|
|
2056
|
+
const oauth = secretNames.has(OAUTH_TOKEN_SECRET);
|
|
2057
|
+
const apiKey = secretNames.has(API_KEY_SECRET);
|
|
2058
|
+
if (!oauth && !apiKey) {
|
|
2059
|
+
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`);
|
|
2060
|
+
}
|
|
2061
|
+
else if (oauth && apiKey) {
|
|
2062
|
+
notes.push(`both ${OAUTH_TOKEN_SECRET} and ${API_KEY_SECRET} are set, so billing follows ${API_KEY_SECRET}`);
|
|
2063
|
+
}
|
|
2064
|
+
if (!secretNames.has(PUSH_URL_SECRET)) {
|
|
2065
|
+
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}\``);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const resume = ask(['workflow', 'view', WORKFLOW_RESUME_FILE]);
|
|
2069
|
+
if (resume.answer === undefined)
|
|
2070
|
+
return fail(noSpawn);
|
|
2071
|
+
if (resume.answer.kind === 'unknown')
|
|
2072
|
+
warnings.push(cannotTell(resume.call, resume.answer.why, `whether GitHub knows ${WORKFLOW_RESUME_FILE}`));
|
|
2073
|
+
if (resume.answer.kind === 'refused') {
|
|
2074
|
+
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`);
|
|
2075
|
+
}
|
|
2076
|
+
const variables = ask(['variable', 'list', '--json', 'name,value']);
|
|
2077
|
+
if (variables.answer === undefined)
|
|
2078
|
+
return fail(noSpawn);
|
|
2079
|
+
const variableValues = variables.answer.kind === 'answered' ? ghJsonEntries(variables.answer.stdout) : undefined;
|
|
2080
|
+
let runner;
|
|
2081
|
+
if (variables.answer.kind !== 'answered') {
|
|
2082
|
+
warnings.push(cannotTell(variables.call, variables.answer.why, `the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables`));
|
|
2083
|
+
}
|
|
2084
|
+
else if (variableValues === undefined) {
|
|
2085
|
+
warnings.push(`cannot tell the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables: ${variables.call} answered in a shape this check does not read`);
|
|
2086
|
+
}
|
|
2087
|
+
else {
|
|
2088
|
+
const label = variableValues.get(RUNNER_VARIABLE)?.trim() ?? '';
|
|
2089
|
+
runner = label === '' ? 'GitHub-hosted (`ubuntu-latest`)' : `runner label \`${label}\``;
|
|
2090
|
+
if ((variableValues.get(REMOTE_STOP_VARIABLE) ?? '') !== '') {
|
|
2091
|
+
warnings.push(`${REMOTE_STOP_VARIABLE} is set, so every remote start and continuation is stopped: \`gh variable delete ${REMOTE_STOP_VARIABLE}\` lifts it`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
const retention = ask(['api', ARTIFACT_RETENTION_ENDPOINT]);
|
|
2095
|
+
if (retention.answer === undefined)
|
|
2096
|
+
return fail(noSpawn);
|
|
2097
|
+
let retentionDays;
|
|
2098
|
+
if (retention.answer.kind === 'unknown') {
|
|
2099
|
+
warnings.push(cannotTell(retention.call, retention.answer.why, 'how long the repository keeps artifacts'));
|
|
2100
|
+
}
|
|
2101
|
+
else if (retention.answer.kind === 'refused') {
|
|
2102
|
+
notes.push(`artifact retention not checked: ${retention.call} needs admin access (${retention.answer.why})`);
|
|
2103
|
+
}
|
|
2104
|
+
else {
|
|
2105
|
+
retentionDays = retentionDaysOf(retention.answer.stdout);
|
|
2106
|
+
if (retentionDays === undefined) {
|
|
2107
|
+
warnings.push(`cannot tell how long the repository keeps artifacts: ${retention.call} answered in a shape this check does not read`);
|
|
2108
|
+
}
|
|
2109
|
+
else if (retentionDays < ARTIFACT_RETENTION_WARN_DAYS) {
|
|
2110
|
+
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`);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
|
|
2114
|
+
if (failures.length > 0)
|
|
2115
|
+
return fail(`${[...failures, ...warnings].join('; ')}${noted}`);
|
|
2116
|
+
if (warnings.length > 0)
|
|
2117
|
+
return warn(`${warnings.join('; ')}${noted}`);
|
|
2118
|
+
const kept = retentionDays === undefined ? '' : `, and the repository keeps artifacts for ${retentionDays} days`;
|
|
2119
|
+
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}`);
|
|
2120
|
+
},
|
|
2121
|
+
};
|
|
1796
2122
|
/**
|
|
1797
2123
|
* The config's shape, re-checked at runtime.
|
|
1798
2124
|
*
|
|
@@ -2891,7 +3217,10 @@ const PLUGIN_WIRING_CHECK = {
|
|
|
2891
3217
|
return pass(`${SETTINGS_PATH} enables ${PLUGIN_KEY} and declares the ${MARKETPLACE_NAME} marketplace in the shape the agent runner reads, so a clone of this repository resolves the plugin from the committed file`);
|
|
2892
3218
|
},
|
|
2893
3219
|
};
|
|
2894
|
-
/**
|
|
3220
|
+
/**
|
|
3221
|
+
* Is there a permission profile, and does it parse? Everything below reads it. In a linked worktree it
|
|
3222
|
+
* grades the main checkout's profile, the one a run loads ({@link buildCheckContext}).
|
|
3223
|
+
*/
|
|
2895
3224
|
const PROFILE_CHECK = {
|
|
2896
3225
|
id: 'permission-profile',
|
|
2897
3226
|
title: `${PROFILE_PATH} exists and parses`,
|
|
@@ -2903,6 +3232,27 @@ const PROFILE_CHECK = {
|
|
|
2903
3232
|
return pass(`${ctx.profilePath} parses as the settings document an unattended run loads`);
|
|
2904
3233
|
},
|
|
2905
3234
|
};
|
|
3235
|
+
/**
|
|
3236
|
+
* The route that stops a committed profile from reaching a remote job: untrack it on the default
|
|
3237
|
+
* branch, so the job's create-if-absent `init` finds none and generates its own.
|
|
3238
|
+
*
|
|
3239
|
+
* The commit must land on the **default branch**, because every run's branch is cut from
|
|
3240
|
+
* `origin/<defaultBranch>` (`create-worktree.sh` → `worktree add … "origin/$default_branch"`): an
|
|
3241
|
+
* untrack pushed only to a run branch covers that one run, and the next branch carries the profile
|
|
3242
|
+
* again. The push and its `--no-verify` reason are `core/defaultBranchPush.ts`'s, spelled nowhere here.
|
|
3243
|
+
*
|
|
3244
|
+
* Called by {@link PROFILE_PATHS_CHECK} under `--remote-job`, and by {@link PROFILE_TRACKED_CHECK}.
|
|
3245
|
+
*/
|
|
3246
|
+
function profileUntrackRemedy(ctx) {
|
|
3247
|
+
const configured = ctx.config?.defaultBranch;
|
|
3248
|
+
const branch = typeof configured === 'string' && configured !== '' ? configured : '<defaultBranch>';
|
|
3249
|
+
const commands = [
|
|
3250
|
+
`git rm --cached ${PROFILE_PATH}`,
|
|
3251
|
+
'git commit -m "Stop tracking the machine-local permission profile"',
|
|
3252
|
+
defaultBranchPushCommand(branch),
|
|
3253
|
+
];
|
|
3254
|
+
return `${commands.map((command) => `\`${command}\``).join(', then ')}. The commit must land on ${branch}, because every run's branch is cut from origin/${branch}, so an untrack pushed only to a run branch covers that one run and the next branch carries the profile again. ${defaultBranchPushReason(branch)}`;
|
|
3255
|
+
}
|
|
2906
3256
|
/**
|
|
2907
3257
|
* Do the profile's absolute paths still name this checkout?
|
|
2908
3258
|
*
|
|
@@ -2911,6 +3261,11 @@ const PROFILE_CHECK = {
|
|
|
2911
3261
|
* at a location that no longer exists. A `warn`, because the fix is a re-run rather than an edit and
|
|
2912
3262
|
* because a hand-tuned profile is a file the adopter may deliberately have pointed elsewhere.
|
|
2913
3263
|
*
|
|
3264
|
+
* **A `fail` under `--remote-job`** ({@link CheckContext.remoteJob}): there the profile was committed
|
|
3265
|
+
* from another machine and the job's create-if-absent `init` kept it, so the job cannot regenerate it
|
|
3266
|
+
* — the step that runs `init` refuses a rewrite of a tracked file, which is why `init --force` is not
|
|
3267
|
+
* printed there. The remedy is {@link profileUntrackRemedy}, so the job generates its own.
|
|
3268
|
+
*
|
|
2914
3269
|
* The question asked is "does any rule cover this root", not "is every path correct": a profile
|
|
2915
3270
|
* generated here mentions the root in its edit, write and read rules, so its complete absence is the
|
|
2916
3271
|
* signal.
|
|
@@ -2918,10 +3273,11 @@ const PROFILE_CHECK = {
|
|
|
2918
3273
|
* "Cover" is deliberately not "contain" ({@link namesRoot}). A generated profile names two locations —
|
|
2919
3274
|
* the checkout it was generated at, and the sibling-worktree pattern that is emitted unconditionally
|
|
2920
3275
|
* beside it — and a sibling worktree is covered by the second while appearing in neither as a
|
|
2921
|
-
* substring.
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
2924
|
-
*
|
|
3276
|
+
* substring. A linked worktree carries no profile of its own — the profile is gitignored and
|
|
3277
|
+
* machine-local — so in a worktree this check grades the main checkout's profile, the one the watcher
|
|
3278
|
+
* loads, against the worktree's root, which the sibling-worktree glob covers. A false warning there
|
|
3279
|
+
* would send the operator to `init --force` inside the worktree, which writes a profile naming the
|
|
3280
|
+
* worktree that no run ever loads.
|
|
2925
3281
|
*/
|
|
2926
3282
|
const PROFILE_PATHS_CHECK = {
|
|
2927
3283
|
id: 'profile-paths',
|
|
@@ -2932,11 +3288,39 @@ const PROFILE_PATHS_CHECK = {
|
|
|
2932
3288
|
if (ctx.profile === undefined)
|
|
2933
3289
|
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
2934
3290
|
const covered = locationStrings(ctx.profile).some((entry) => namesRoot(entry, ctx.repoRoot));
|
|
3291
|
+
if (!covered && ctx.remoteJob) {
|
|
3292
|
+
return fail(`neither a path nor a pattern in ${PROFILE_PATH} covers this repository root (${ctx.repoRoot}): the profile was generated on another machine and committed, and this job's create-if-absent \`${CLI} init\` kept it, so a run loading it would find its edit, write, read and script rules matching nothing here. Stop tracking it so the job generates its own: ${profileUntrackRemedy(ctx)}`);
|
|
3293
|
+
}
|
|
2935
3294
|
return covered
|
|
2936
3295
|
? pass(`the profile's rules cover this repository root (${ctx.repoRoot}) — by naming it, or by a pattern such as the sibling-worktree glob that matches it — so they apply to this checkout`)
|
|
2937
3296
|
: warn(`neither a path nor a pattern in ${PROFILE_PATH} covers this repository root (${ctx.repoRoot}): the profile was generated for another location, so a run loading it would find its edit, write, read and script rules matching nothing here — re-run \`${CLI} init --force\` from the checkout the profile should be generated for, which writes a .bak sibling before regenerating it`);
|
|
2938
3297
|
},
|
|
2939
3298
|
};
|
|
3299
|
+
/**
|
|
3300
|
+
* Is the permission profile carried by the tree `HEAD` names?
|
|
3301
|
+
*
|
|
3302
|
+
* `pass` when it is not; `warn` when it is; **`fail` under `--remote-job`** ({@link CheckContext.remoteJob}).
|
|
3303
|
+
* A committed profile reaches every clone and every job, and a job cannot replace it: its
|
|
3304
|
+
* create-if-absent `init` keeps a present file, and the step that runs `init` refuses a rewrite of a
|
|
3305
|
+
* tracked one. On a person's machine the committed file may still name this checkout, so nothing stops
|
|
3306
|
+
* yet. Both non-pass grades print {@link profileUntrackRemedy}, the one route that works in both places.
|
|
3307
|
+
*
|
|
3308
|
+
* Asks about `ctx.repoRoot`, the checkout `doctor` runs in, not the main checkout the profile is read
|
|
3309
|
+
* from in a linked worktree: what is committed is a property of the tree, not of where it is loaded.
|
|
3310
|
+
*/
|
|
3311
|
+
const PROFILE_TRACKED_CHECK = {
|
|
3312
|
+
id: 'profile-tracked',
|
|
3313
|
+
title: 'the permission profile is machine-local, not committed',
|
|
3314
|
+
run: (ctx) => {
|
|
3315
|
+
if (ctx.repoRoot === undefined)
|
|
3316
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
3317
|
+
if (!pathAtRef(ctx.repoRoot, 'HEAD', PROFILE_PATH)) {
|
|
3318
|
+
return pass(`${PROFILE_PATH} is not in the tree HEAD names, so no clone and no remote job receives this machine's copy; each generates its own`);
|
|
3319
|
+
}
|
|
3320
|
+
const finding = `${PROFILE_PATH} is committed at HEAD: it carries this machine's absolute paths, and a remote job keeps a committed one rather than generating its own. Stop tracking it: ${profileUntrackRemedy(ctx)}`;
|
|
3321
|
+
return ctx.remoteJob ? fail(finding) : warn(finding);
|
|
3322
|
+
},
|
|
3323
|
+
};
|
|
2940
3324
|
/**
|
|
2941
3325
|
* The closure that must **not** be in a generated profile: no `deny` entry may name a browser tool.
|
|
2942
3326
|
*
|
|
@@ -3034,26 +3418,38 @@ function namesHelperScript(entry) {
|
|
|
3034
3418
|
* because those scripts are the interactive-test phase's alone. With the phase off this check says
|
|
3035
3419
|
* nothing whatever about them: a warning nobody with that phase off can act on is one they learn
|
|
3036
3420
|
* to skip.
|
|
3037
|
-
* - A `Read` rule at
|
|
3038
|
-
* instruction files and samples are read by every unattended run, interactive-test
|
|
3039
|
-
*
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
*
|
|
3045
|
-
*
|
|
3046
|
-
*
|
|
3047
|
-
*
|
|
3421
|
+
* - A `Read` rule at the **runtime root**, including where it is also the install root, **not**
|
|
3422
|
+
* phase-gated: instruction files and samples are read by every unattended run, interactive-test
|
|
3423
|
+
* phase or not. So every resolved root set carries at least one required entry.
|
|
3424
|
+
* - **No `Read` rule over an install root distinct from the runtime root**, and the asymmetry is a
|
|
3425
|
+
* measurement rather than a taste. Measured 2026-08-26 on a `directory`-sourced marketplace, under
|
|
3426
|
+
* a generated profile naming no rule over either root: sixteen `Read` calls under the install root
|
|
3427
|
+
* — a cache snapshot the runtime does not substitute — succeeded, over eight distinct instruction
|
|
3428
|
+
* files, while ten under the runtime root were refused in the same run. Observed 2026-09-28 in
|
|
3429
|
+
* Gate 12 round 1, on a GitHub-hosted runner with a GitHub-sourced marketplace, where one root is
|
|
3430
|
+
* both: every `Read` of `<root>/instructions/*.md` asked for permission, and `cat`/`ls` were
|
|
3431
|
+
* refused as outside "the allowed working directory". An install root that is also the runtime
|
|
3432
|
+
* root is therefore not exempt.
|
|
3433
|
+
*
|
|
3434
|
+
* The helper names come from **reading `<root>/scripts/`** ({@link pluginRootHelpers}) at each
|
|
3048
3435
|
* graded root and taking the union, never from a list kept here: they are declared once, in the
|
|
3049
3436
|
* plugin's own `scripts/README.md`, and a copy in this file would be a second declaration that
|
|
3050
3437
|
* drifts the first time one is added. Nothing here classifies a helper by its call site either —
|
|
3051
3438
|
* the root set is what varies, and the name set stays read from disk.
|
|
3052
3439
|
*
|
|
3053
|
-
* **It never fails
|
|
3054
|
-
* operator remedy, and the profile is a file the adopter owns. And when no
|
|
3055
|
-
* none — it names the step and the file it read, because a fabricated path is
|
|
3056
|
-
* operator would paste it and get a profile that is wrong in a way nothing
|
|
3440
|
+
* **It never fails outside `--remote-job`**, for {@link REPO_REGISTRY_CHECK}'s reason: this is a
|
|
3441
|
+
* machine-local gap with an operator remedy, and the profile is a file the adopter owns. And when no
|
|
3442
|
+
* root resolves it invents none — it names the step and the file it read, because a fabricated path is
|
|
3443
|
+
* worse than no path: an operator would paste it and get a profile that is wrong in a way nothing
|
|
3444
|
+
* reports.
|
|
3445
|
+
*
|
|
3446
|
+
* **Under `--remote-job`** ({@link CheckContext.remoteJob}) a missing entry and an unresolved root are
|
|
3447
|
+
* each a `fail`, and every directory {@link pluginRootDirectories} returns for the graded roots is
|
|
3448
|
+
* required in `permissions.additionalDirectories` — the shell-readable grant. The job differs because
|
|
3449
|
+
* the plugin was installed and the profile generated moments earlier on a machine that exists for one
|
|
3450
|
+
* run, so a gap is a launch that parks rather than an operator's paste. Outside the flag that list is
|
|
3451
|
+
* not graded: a missing shell grant has not been observed to stall a run on a person's machine, and a
|
|
3452
|
+
* version-carrying directory would go stale at every upgrade.
|
|
3057
3453
|
*
|
|
3058
3454
|
* **A helper entry under no resolved root is named in every *graded* disposition and moves no
|
|
3059
3455
|
* grade.** A profile carrying dead weight and every required entry still passes; one missing a
|
|
@@ -3077,8 +3473,8 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3077
3473
|
return unevaluated('the repository root did not resolve (see the git check)');
|
|
3078
3474
|
if (ctx.profile === undefined)
|
|
3079
3475
|
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
3080
|
-
// Read here rather than beside `missing` below, because
|
|
3081
|
-
//
|
|
3476
|
+
// Read here rather than beside `missing` below, because the no-root disposition returns above
|
|
3477
|
+
// that point and interpolates a count out of it. A pure read of
|
|
3082
3478
|
// the profile: it neither writes nor throws on a malformed one, and `missing` is still computed
|
|
3083
3479
|
// from it where it always was.
|
|
3084
3480
|
const allowed = permissionEntries(ctx.profile, 'allow');
|
|
@@ -3104,6 +3500,9 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3104
3500
|
const dangling = ungraded === 0
|
|
3105
3501
|
? ''
|
|
3106
3502
|
: `. ${PROFILE_PATH} carries ${ungraded} absolute-directory \`permissions.allow\` ${entryWord(ungraded)} outside this checkout that nothing here can grade until a root resolves, counted rather than named because calling one stale or required would be a judgement no record on this machine supports; \`${CLI} init --force\` carries ${ungraded === 1 ? 'it' : 'them'} forward unverified in the meantime`;
|
|
3503
|
+
if (ctx.remoteJob) {
|
|
3504
|
+
return fail(`${installedPluginsPath()} records no install root for ${PLUGIN_KEY}, so the entries ${PROFILE_PATH} needs for it cannot be named and are not guessed at: a remote job installs the plugin before this preflight, so no record means that install did not take effect on this runner — a run launched now would be refused every read of the plugin's instruction files${dangling}`);
|
|
3505
|
+
}
|
|
3107
3506
|
return warn(`${installedPluginsPath()} records no install root for ${PLUGIN_KEY}, so the entries ${PROFILE_PATH} needs for it cannot be named and are not guessed at: enable the plugin — open this repository with the agent runner once, which applies the ${SETTINGS_PATH} keys \`${CLI} init\` wrote — and re-run \`${CLI} doctor\`, which reads the root back and prints the exact lines to paste${dangling}`);
|
|
3108
3507
|
}
|
|
3109
3508
|
// Entries in the helper form whose directory no root here answers for. Two exclusions, both
|
|
@@ -3118,7 +3517,7 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3118
3517
|
return [];
|
|
3119
3518
|
return resolvedRoots.some((root) => isUnderDirectory(target, root)) ? [] : [`${entry} — ${target}`];
|
|
3120
3519
|
});
|
|
3121
|
-
// Carried into
|
|
3520
|
+
// Carried into both surviving dispositions, so what the report says about dead weight does
|
|
3122
3521
|
// not depend on which arm this machine happens to be in. Empty set, empty clause — as `partial`.
|
|
3123
3522
|
const stray = strays.length === 0
|
|
3124
3523
|
? ''
|
|
@@ -3129,7 +3528,7 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3129
3528
|
// complete that is one unreadable file away from a stall.
|
|
3130
3529
|
const phaseKnown = ctx.config !== undefined;
|
|
3131
3530
|
const qaOn = ctx.config?.phases?.qa === true;
|
|
3132
|
-
const helpers =
|
|
3531
|
+
const helpers = pluginRootHelpers(roots, qaOn);
|
|
3133
3532
|
const groups = roots.map((root) => ({
|
|
3134
3533
|
root,
|
|
3135
3534
|
label: split
|
|
@@ -3139,57 +3538,58 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3139
3538
|
: installRoot === undefined
|
|
3140
3539
|
? `the directory this marketplace is sourced from (the only root that resolved: ${installedPluginsPath()} records no install root)`
|
|
3141
3540
|
: `the one plugin root this machine resolves, recorded in ${installedPluginsPath()}`,
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
})),
|
|
3152
|
-
],
|
|
3541
|
+
// The builder `init --plugin-root-entries` writes through too, so the two cannot differ. The
|
|
3542
|
+
// read rule is outside the phase gate and required at the runtime root, including where it is
|
|
3543
|
+
// also the install root; only an install root distinct from it is exempt.
|
|
3544
|
+
required: pluginRootEntries(root, { isRuntimeRoot: root === runtimeRoot, helpers }).map(({ kind, rule }) => ({
|
|
3545
|
+
rule,
|
|
3546
|
+
symptom: kind === 'read'
|
|
3547
|
+
? 'improvises in place of a contract file it is refused'
|
|
3548
|
+
: 'parks with no error at the first helper script it reaches',
|
|
3549
|
+
})),
|
|
3153
3550
|
}));
|
|
3154
3551
|
const required = groups.flatMap((group) => group.required);
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
}
|
|
3163
|
-
// Two graded roots and no helper name under either is still a broken install, and the read rule
|
|
3164
|
-
// alone would otherwise let it pass in silence once pasted.
|
|
3165
|
-
const partial = qaOn && helpers.length === 0
|
|
3166
|
-
? ' No helper script was found under any graded root, so none is required here: that is a broken or partial install, which re-enabling the plugin repairs.'
|
|
3167
|
-
: '';
|
|
3552
|
+
// A root that resolves always carries the runtime root's `Read`, so `required` is never empty
|
|
3553
|
+
// here; what the helper entries could not be graded against is said on both dispositions.
|
|
3554
|
+
const partial = !phaseKnown
|
|
3555
|
+
? ` ${CONFIG_FILENAME} could not be read, so the phase the helper scripts belong to is unknown and no helper entry was graded (see the config check).`
|
|
3556
|
+
: qaOn && helpers.length === 0
|
|
3557
|
+
? ` No helper script was found under ${split ? 'any graded root' : pluginScriptsDir(firstRoot)}, so none is required here: that is a broken or partial install, which re-enabling the plugin repairs.`
|
|
3558
|
+
: '';
|
|
3168
3559
|
// Stated once, in both dispositions, because an operator reading either has to know why a line
|
|
3169
|
-
// they already pasted at one root reappears at the other, and
|
|
3170
|
-
// rule. `coincide` is the ordinary machine: one directory, one set of entries, no read rule.
|
|
3171
|
-
const coincide = !split && installRoot !== undefined;
|
|
3560
|
+
// they already pasted at one root reappears at the other, and which root carries a read rule.
|
|
3172
3561
|
const why = (split
|
|
3173
3562
|
? ` Both roots are graded because a helper named in an instruction file is resolved by the agent itself while one named in an agent definition body has \`\${CLAUDE_PLUGIN_ROOT}\` substituted by the runtime, and on this machine those two routes were measured to land on different directories.`
|
|
3174
3563
|
: '') +
|
|
3175
|
-
(
|
|
3176
|
-
? ''
|
|
3177
|
-
: ` The \`Read\` entry is graded at the runtime root and not at the install root because reads at the install root were measured (2026-08-26) to succeed under a profile naming no rule over it, while ten at the runtime root were refused in that same run; it is outside the \`phases.qa\` gate, because instruction files and samples are read by every run.`);
|
|
3564
|
+
` The \`Read\` entry is required at the runtime root, including where it is also the install root, and outside the \`phases.qa\` gate, because instruction files and samples are read by every run: on 2026-09-28 a GitHub-hosted runner whose one plugin root was both was refused every \`Read\` of its instruction files under a profile naming no rule over it. It is not required at an install root distinct from the runtime root${split ? ', as here' : ''}: reads there were measured (2026-08-26, a \`directory\`-sourced marketplace) to succeed under a profile naming no rule over it, while ten at the runtime root were refused in that same run.`;
|
|
3178
3565
|
const missing = required.filter((entry) => !allowed.includes(entry.rule));
|
|
3566
|
+
// Graded under `--remote-job` only, through the same pure read `allowed` came from.
|
|
3567
|
+
const granted = permissionEntries(ctx.profile, 'additionalDirectories').map(normalizedRoot);
|
|
3568
|
+
const missingDirectories = ctx.remoteJob
|
|
3569
|
+
? pluginRootDirectories(roots).filter((directory) => !granted.includes(directory))
|
|
3570
|
+
: [];
|
|
3571
|
+
const directoryBlock = missingDirectories.length === 0 ? '' : `\n\npermissions.additionalDirectories:\n${missingDirectories.join('\n')}`;
|
|
3572
|
+
const jobClause = ctx.remoteJob
|
|
3573
|
+
? ' Under --remote-job this is a failure: the plugin was installed and the profile generated moments earlier on a machine that exists for one run, so a gap here is a launch that parks rather than an operator\'s paste.'
|
|
3574
|
+
: '';
|
|
3575
|
+
const jobRemedy = ` In a remote job \`${CLI} init ${PLUGIN_ROOT_ENTRIES_FLAG}\`, which the job's setup step runs, writes these into the profile it generates, so a profile lacking them is one that step did not generate — a committed copy its create-if-absent run kept, which the profile-tracked check names the untrack route for — or one whose plugin root carries a character the permission guard matches literally, which that step warned about in its own log. The lines below are what is missing:`;
|
|
3576
|
+
if (missing.length === 0 && missingDirectories.length > 0) {
|
|
3577
|
+
return fail(`${PROFILE_PATH} carries every \`permissions.allow\` entry this machine's plugin ${split ? 'roots need' : 'root needs'}, but its \`permissions.additionalDirectories\` lacks ${missingDirectories.length === 1 ? 'the plugin root' : `${missingDirectories.length} plugin roots`}, so a shell command in a run is refused a read under ${missingDirectories.length === 1 ? 'it' : 'them'} as outside the allowed working directory.${jobClause}${stray}${jobRemedy}${directoryBlock}`);
|
|
3578
|
+
}
|
|
3179
3579
|
if (missing.length > 0) {
|
|
3180
3580
|
const symptoms = [...new Set(missing.map((entry) => entry.symptom))].join(', and ');
|
|
3181
3581
|
const blocks = groups
|
|
3182
3582
|
.map((group) => ({ group, rules: group.required.filter((entry) => missing.includes(entry)).map((entry) => entry.rule) }))
|
|
3183
3583
|
.filter(({ rules }) => rules.length > 0)
|
|
3184
3584
|
.map(({ group, rules }) => renderRootGroup(group.label, group.root, rules));
|
|
3185
|
-
return warn(`${PROFILE_PATH} is missing ${missing.length} of the ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'}, so an unattended run ${symptoms}. \`${CLI} init\` does not generate ${missing.length === 1 ? 'it' : 'them'} — a root is machine-local and the install root carries the plugin version, so an entry written once goes stale on an upgrade and this check re-derives ${split ? 'both' : 'it'} instead
|
|
3585
|
+
return (ctx.remoteJob ? fail : warn)(`${PROFILE_PATH} is missing ${missing.length} of the ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'}, so an unattended run ${symptoms}.${ctx.remoteJob ? '' : ` \`${CLI} init\` does not generate ${missing.length === 1 ? 'it' : 'them'} — a root is machine-local and the install root carries the plugin version, so an entry written once goes stale on an upgrade and this check re-derives ${split ? 'both' : 'it'} instead.`}${why}${partial}${jobClause}${stray}${ctx.remoteJob ? jobRemedy : ` Add each line below to ${directoryBlock === '' ? 'that list' : 'that list, or to `permissions.additionalDirectories` under its own heading,'} as its own string, unquoted exactly as it stands:`}\n${blocks.join('\n\n')}${directoryBlock}`);
|
|
3186
3586
|
}
|
|
3187
3587
|
// Per-root counts only where there is more than one root to attribute them to; with a single
|
|
3188
3588
|
// root the leading total already says how many, and repeating it reads as a second figure.
|
|
3189
3589
|
const carried = groups
|
|
3190
3590
|
.map((group) => split ? `${group.label} — ${group.root}: ${group.required.length} ${entryWord(group.required.length)}` : `${group.label} — ${group.root}`)
|
|
3191
3591
|
.join('; ');
|
|
3192
|
-
return pass(`${PROFILE_PATH} carries all ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'} — ${carried}.${
|
|
3592
|
+
return pass(`${PROFILE_PATH} carries all ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'} — ${carried}.${ctx.remoteJob ? ` Under --remote-job its \`permissions.additionalDirectories\` was graded too, and carries every plugin root, so a shell command in a run may read under ${split ? 'them' : 'it'}.` : ''}${why}${partial}${stray}`);
|
|
3193
3593
|
},
|
|
3194
3594
|
};
|
|
3195
3595
|
/**
|
|
@@ -3583,6 +3983,12 @@ const RETRIEVAL_INDEX_CHECK = {
|
|
|
3583
3983
|
* for the machine around it — and they are meant to be read together. `daemon-path` comes last of
|
|
3584
3984
|
* the six because it is the only one that grades an *installed* unit: it has nothing to say until the
|
|
3585
3985
|
* five above it are answered, and it says so rather than guessing.
|
|
3986
|
+
* `remote-execution` follows it because it asks the same "can this run unattended" question of a run
|
|
3987
|
+
* the watcher dispatches to GitHub rather than spawns here, and `remote-github` follows that because it
|
|
3988
|
+
* asks GitHub the half of the same question local evidence cannot answer.
|
|
3989
|
+
*
|
|
3990
|
+
* `profile-tracked` sits under `profile-paths` because the two name the same file carried somewhere it
|
|
3991
|
+
* does not belong, and a committed profile is the usual reason a job's `profile-paths` fails.
|
|
3586
3992
|
*
|
|
3587
3993
|
* `plugin-permissions` closes the profile block for the same shape of reason: it is the only profile
|
|
3588
3994
|
* question whose other half is not in the repository at all — the plugin's machine-local install
|
|
@@ -3608,6 +4014,8 @@ export const CHECKS = Object.freeze([
|
|
|
3608
4014
|
REPO_REGISTRY_CHECK,
|
|
3609
4015
|
MACHINE_FOOTPRINT_CHECK,
|
|
3610
4016
|
DAEMON_PATH_CHECK,
|
|
4017
|
+
REMOTE_EXECUTION_CHECK,
|
|
4018
|
+
REMOTE_GITHUB_CHECK,
|
|
3611
4019
|
CONFIG_CHECK,
|
|
3612
4020
|
COMMAND_WRAPPERS_CHECK,
|
|
3613
4021
|
COMMAND_PERMISSIONS_CHECK,
|
|
@@ -3622,6 +4030,7 @@ export const CHECKS = Object.freeze([
|
|
|
3622
4030
|
PLUGIN_WIRING_CHECK,
|
|
3623
4031
|
PROFILE_CHECK,
|
|
3624
4032
|
PROFILE_PATHS_CHECK,
|
|
4033
|
+
PROFILE_TRACKED_CHECK,
|
|
3625
4034
|
PROFILE_BROWSER_DENY_CHECK,
|
|
3626
4035
|
PROFILE_DENY_FLOOR_CHECK,
|
|
3627
4036
|
PLUGIN_PERMISSIONS_CHECK,
|