autonomous-sdlc-harness 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.js +32 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +8 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/core/defaultBranchPush.js +26 -0
- package/dist/core/defaultBranchPush.js.map +1 -0
- package/dist/core/git.js +35 -3
- package/dist/core/git.js.map +1 -1
- package/dist/doctor/checks.js +148 -54
- package/dist/doctor/checks.js.map +1 -1
- package/dist/generators/permissionProfile.js +60 -29
- 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 +5 -2
- package/dist/generators/repoRoot.js.map +1 -1
- package/dist/retrieval/runtime.js +34 -11
- package/dist/retrieval/runtime.js.map +1 -1
- package/package.json +2 -2
- package/templates/github/workflows/harness-resume.yml +21 -5
- package/templates/github/workflows/harness-run.yml +42 -20
- package/templates/repo/gitignore +6 -1
- package/templates/scripts/autonomous-watcher.sh +183 -52
- package/templates/scripts/lib/harness-run-lib.sh +303 -35
- package/templates/scripts/remote-run.sh +48 -38
- package/templates/state-dir/autonomous_logs/README.md +1 -1
package/dist/doctor/checks.js
CHANGED
|
@@ -85,8 +85,9 @@ import { delimiter, join, posix, resolve as resolvePath } from 'node:path';
|
|
|
85
85
|
import { formatProblem } from '../config/check.js';
|
|
86
86
|
import { loadConfig } from '../config/io.js';
|
|
87
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';
|
|
88
|
+
import { branchResolves, checkedOutBranch, commitsAhead, configuredRemotes, hasCommits, mainWorktreeRoot, pathAtRef, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
|
|
89
89
|
import { isJsonObject, readJsonFile } from '../core/json.js';
|
|
90
|
+
import { defaultBranchPushCommand, defaultBranchPushReason, WORKFLOW_SCOPE_COMMAND, WORKFLOW_SCOPE_REASON, } from '../core/defaultBranchPush.js';
|
|
90
91
|
import { layerCoverage } from '../core/layerCoverage.js';
|
|
91
92
|
import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
|
|
92
93
|
import { nameList } from '../core/nameList.js';
|
|
@@ -102,7 +103,7 @@ import { CLAUDE_MD_PATH, SETUP_PENDING_CLOSE, SETUP_PENDING_OPEN, SKELETON_GUIDA
|
|
|
102
103
|
import { caseLabelMatches, isGlobPattern, PRE_PUSH_HOOK, readProtectedCaseLabel, resolveGithooksDir, resolveProtectedBranches, } from '../generators/githooks.js';
|
|
103
104
|
import { PUSH_CMD_KEY, PUSH_DESTINATION_PLACEHOLDER, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
|
|
104
105
|
import { DOCS_SEARCH_SERVER_SCRIPT_NAME, outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
|
|
105
|
-
import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootEntries, pluginRootEntryTarget, pluginRootHelpers,
|
|
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';
|
|
106
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';
|
|
107
108
|
import { clarificationsIgnoreRules, contentsIgnoredDirectories, GITIGNORE_BLOCK_HEADER, GITIGNORE_PATH, MCP_PATH, } from '../generators/repoRoot.js';
|
|
108
109
|
import { configKeyPath, configuredWrapperFile, scriptInvocation, selectWrapper, wrappedKeyMismatch, wrappedKeyMismatchMessage, WRAPPER_SCRIPTS, wrapperCommandLine, } from '../generators/scripts.js';
|
|
@@ -392,11 +393,17 @@ function serversStartedByProfile(profile) {
|
|
|
392
393
|
* has to report on rather than crash against, and the same holds for a config or a profile that does
|
|
393
394
|
* not parse. Each failure becomes a field the check that owns that subject renders.
|
|
394
395
|
*
|
|
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
|
+
*
|
|
395
401
|
* `probeRegistry` and `probeGithub` are the caller's answers rather than this function's, and both
|
|
396
402
|
* 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.
|
|
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.
|
|
398
405
|
*/
|
|
399
|
-
export function buildCheckContext(cwd, probeRegistry = false, probeGithub = false) {
|
|
406
|
+
export function buildCheckContext(cwd, probeRegistry = false, probeGithub = false, remoteJob = false) {
|
|
400
407
|
let repoRoot;
|
|
401
408
|
let repoProblem;
|
|
402
409
|
try {
|
|
@@ -406,15 +413,19 @@ export function buildCheckContext(cwd, probeRegistry = false, probeGithub = fals
|
|
|
406
413
|
repoProblem = messageOf(error);
|
|
407
414
|
}
|
|
408
415
|
if (repoRoot === undefined)
|
|
409
|
-
return { cwd, repoProblem, configProblems: [], probeRegistry, probeGithub };
|
|
416
|
+
return { cwd, repoProblem, configProblems: [], probeRegistry, probeGithub, remoteJob };
|
|
410
417
|
const loaded = loadConfig(repoRoot);
|
|
411
|
-
const
|
|
418
|
+
const profileRoot = mainWorktreeRoot(repoRoot) ?? repoRoot;
|
|
419
|
+
const profilePath = join(profileRoot, PROFILE_PATH);
|
|
412
420
|
let profile;
|
|
413
421
|
let profileProblem;
|
|
414
422
|
try {
|
|
415
423
|
const parsed = readJsonFile(profilePath);
|
|
416
424
|
if (parsed === undefined) {
|
|
417
|
-
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`;
|
|
418
429
|
}
|
|
419
430
|
else if (!isJsonObject(parsed)) {
|
|
420
431
|
profileProblem = `${profilePath} is not a JSON object, so it is not a settings file the agent runner can load`;
|
|
@@ -437,6 +448,7 @@ export function buildCheckContext(cwd, probeRegistry = false, probeGithub = fals
|
|
|
437
448
|
profilePath,
|
|
438
449
|
probeRegistry,
|
|
439
450
|
probeGithub,
|
|
451
|
+
remoteJob,
|
|
440
452
|
};
|
|
441
453
|
}
|
|
442
454
|
/** Is this repository inside a work tree at all — the precondition every repo-wiring check has. */
|
|
@@ -1840,7 +1852,9 @@ const DAEMON_PATH_CHECK = {
|
|
|
1840
1852
|
* dispatches through it. Three `warn`s: no `harness-resume.yml`, because a usage-paused hosted run then
|
|
1841
1853
|
* waits for `/autonomous-sdlc-harness:branch-resume`; a `harness-run.yml` that
|
|
1842
1854
|
* `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
|
|
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,
|
|
1844
1858
|
* because a remote run skips the interactive-test phase and the branch still reaches review — the
|
|
1845
1859
|
* phase is then owed a local run. It needs no GitHub answer, so it is asked here rather than in
|
|
1846
1860
|
* {@link REMOTE_GITHUB_CHECK}.
|
|
@@ -1900,7 +1914,7 @@ const REMOTE_EXECUTION_CHECK = {
|
|
|
1900
1914
|
notes.push(`whether origin/${branch} carries ${WORKFLOW_RUN_PATH} is not graded, because there is no origin/${branch} (see the remote check)`);
|
|
1901
1915
|
}
|
|
1902
1916
|
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
|
|
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)}`);
|
|
1904
1918
|
}
|
|
1905
1919
|
}
|
|
1906
1920
|
const noted = notes.length > 0 ? `; ${notes.join('; ')}` : '';
|
|
@@ -3203,7 +3217,10 @@ const PLUGIN_WIRING_CHECK = {
|
|
|
3203
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`);
|
|
3204
3218
|
},
|
|
3205
3219
|
};
|
|
3206
|
-
/**
|
|
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
|
+
*/
|
|
3207
3224
|
const PROFILE_CHECK = {
|
|
3208
3225
|
id: 'permission-profile',
|
|
3209
3226
|
title: `${PROFILE_PATH} exists and parses`,
|
|
@@ -3215,6 +3232,27 @@ const PROFILE_CHECK = {
|
|
|
3215
3232
|
return pass(`${ctx.profilePath} parses as the settings document an unattended run loads`);
|
|
3216
3233
|
},
|
|
3217
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
|
+
}
|
|
3218
3256
|
/**
|
|
3219
3257
|
* Do the profile's absolute paths still name this checkout?
|
|
3220
3258
|
*
|
|
@@ -3223,6 +3261,11 @@ const PROFILE_CHECK = {
|
|
|
3223
3261
|
* at a location that no longer exists. A `warn`, because the fix is a re-run rather than an edit and
|
|
3224
3262
|
* because a hand-tuned profile is a file the adopter may deliberately have pointed elsewhere.
|
|
3225
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
|
+
*
|
|
3226
3269
|
* The question asked is "does any rule cover this root", not "is every path correct": a profile
|
|
3227
3270
|
* generated here mentions the root in its edit, write and read rules, so its complete absence is the
|
|
3228
3271
|
* signal.
|
|
@@ -3230,10 +3273,11 @@ const PROFILE_CHECK = {
|
|
|
3230
3273
|
* "Cover" is deliberately not "contain" ({@link namesRoot}). A generated profile names two locations —
|
|
3231
3274
|
* the checkout it was generated at, and the sibling-worktree pattern that is emitted unconditionally
|
|
3232
3275
|
* beside it — and a sibling worktree is covered by the second while appearing in neither as a
|
|
3233
|
-
* substring.
|
|
3234
|
-
*
|
|
3235
|
-
*
|
|
3236
|
-
*
|
|
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.
|
|
3237
3281
|
*/
|
|
3238
3282
|
const PROFILE_PATHS_CHECK = {
|
|
3239
3283
|
id: 'profile-paths',
|
|
@@ -3244,11 +3288,39 @@ const PROFILE_PATHS_CHECK = {
|
|
|
3244
3288
|
if (ctx.profile === undefined)
|
|
3245
3289
|
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
3246
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
|
+
}
|
|
3247
3294
|
return covered
|
|
3248
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`)
|
|
3249
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`);
|
|
3250
3297
|
},
|
|
3251
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
|
+
};
|
|
3252
3324
|
/**
|
|
3253
3325
|
* The closure that must **not** be in a generated profile: no `deny` entry may name a browser tool.
|
|
3254
3326
|
*
|
|
@@ -3346,15 +3418,18 @@ function namesHelperScript(entry) {
|
|
|
3346
3418
|
* because those scripts are the interactive-test phase's alone. With the phase off this check says
|
|
3347
3419
|
* nothing whatever about them: a warning nobody with that phase off can act on is one they learn
|
|
3348
3420
|
* to skip.
|
|
3349
|
-
* - A `Read` rule at
|
|
3350
|
-
* instruction files and samples are read by every unattended run, interactive-test
|
|
3351
|
-
*
|
|
3352
|
-
*
|
|
3353
|
-
*
|
|
3354
|
-
*
|
|
3355
|
-
*
|
|
3356
|
-
*
|
|
3357
|
-
*
|
|
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.
|
|
3358
3433
|
*
|
|
3359
3434
|
* The helper names come from **reading `<root>/scripts/`** ({@link pluginRootHelpers}) at each
|
|
3360
3435
|
* graded root and taking the union, never from a list kept here: they are declared once, in the
|
|
@@ -3362,10 +3437,19 @@ function namesHelperScript(entry) {
|
|
|
3362
3437
|
* drifts the first time one is added. Nothing here classifies a helper by its call site either —
|
|
3363
3438
|
* the root set is what varies, and the name set stays read from disk.
|
|
3364
3439
|
*
|
|
3365
|
-
* **It never fails
|
|
3366
|
-
* operator remedy, and the profile is a file the adopter owns. And when no
|
|
3367
|
-
* none — it names the step and the file it read, because a fabricated path is
|
|
3368
|
-
* 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.
|
|
3369
3453
|
*
|
|
3370
3454
|
* **A helper entry under no resolved root is named in every *graded* disposition and moves no
|
|
3371
3455
|
* grade.** A profile carrying dead weight and every required entry still passes; one missing a
|
|
@@ -3389,8 +3473,8 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3389
3473
|
return unevaluated('the repository root did not resolve (see the git check)');
|
|
3390
3474
|
if (ctx.profile === undefined)
|
|
3391
3475
|
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
3392
|
-
// Read here rather than beside `missing` below, because
|
|
3393
|
-
//
|
|
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
|
|
3394
3478
|
// the profile: it neither writes nor throws on a malformed one, and `missing` is still computed
|
|
3395
3479
|
// from it where it always was.
|
|
3396
3480
|
const allowed = permissionEntries(ctx.profile, 'allow');
|
|
@@ -3416,6 +3500,9 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3416
3500
|
const dangling = ungraded === 0
|
|
3417
3501
|
? ''
|
|
3418
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
|
+
}
|
|
3419
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}`);
|
|
3420
3507
|
}
|
|
3421
3508
|
// Entries in the helper form whose directory no root here answers for. Two exclusions, both
|
|
@@ -3430,7 +3517,7 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3430
3517
|
return [];
|
|
3431
3518
|
return resolvedRoots.some((root) => isUnderDirectory(target, root)) ? [] : [`${entry} — ${target}`];
|
|
3432
3519
|
});
|
|
3433
|
-
// Carried into
|
|
3520
|
+
// Carried into both surviving dispositions, so what the report says about dead weight does
|
|
3434
3521
|
// not depend on which arm this machine happens to be in. Empty set, empty clause — as `partial`.
|
|
3435
3522
|
const stray = strays.length === 0
|
|
3436
3523
|
? ''
|
|
@@ -3452,9 +3539,9 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3452
3539
|
? `the directory this marketplace is sourced from (the only root that resolved: ${installedPluginsPath()} records no install root)`
|
|
3453
3540
|
: `the one plugin root this machine resolves, recorded in ${installedPluginsPath()}`,
|
|
3454
3541
|
// The builder `init --plugin-root-entries` writes through too, so the two cannot differ. The
|
|
3455
|
-
// read rule is outside the phase gate and
|
|
3456
|
-
//
|
|
3457
|
-
required: pluginRootEntries(root, {
|
|
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 }) => ({
|
|
3458
3545
|
rule,
|
|
3459
3546
|
symptom: kind === 'read'
|
|
3460
3547
|
? 'improvises in place of a contract file it is refused'
|
|
@@ -3462,44 +3549,47 @@ const PLUGIN_PERMISSIONS_CHECK = {
|
|
|
3462
3549
|
})),
|
|
3463
3550
|
}));
|
|
3464
3551
|
const required = groups.flatMap((group) => group.required);
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
}
|
|
3473
|
-
// Two graded roots and no helper name under either is still a broken install, and the read rule
|
|
3474
|
-
// alone would otherwise let it pass in silence once pasted.
|
|
3475
|
-
const partial = qaOn && helpers.length === 0
|
|
3476
|
-
? ' 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.'
|
|
3477
|
-
: '';
|
|
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
|
+
: '';
|
|
3478
3559
|
// Stated once, in both dispositions, because an operator reading either has to know why a line
|
|
3479
|
-
// they already pasted at one root reappears at the other, and
|
|
3480
|
-
// rule. `coincide` is the ordinary machine: one directory, one set of entries, no read rule.
|
|
3481
|
-
const coincide = !split && installRoot !== undefined;
|
|
3560
|
+
// they already pasted at one root reappears at the other, and which root carries a read rule.
|
|
3482
3561
|
const why = (split
|
|
3483
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.`
|
|
3484
3563
|
: '') +
|
|
3485
|
-
(
|
|
3486
|
-
? ''
|
|
3487
|
-
: ` 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.`;
|
|
3488
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
|
+
}
|
|
3489
3579
|
if (missing.length > 0) {
|
|
3490
3580
|
const symptoms = [...new Set(missing.map((entry) => entry.symptom))].join(', and ');
|
|
3491
3581
|
const blocks = groups
|
|
3492
3582
|
.map((group) => ({ group, rules: group.required.filter((entry) => missing.includes(entry)).map((entry) => entry.rule) }))
|
|
3493
3583
|
.filter(({ rules }) => rules.length > 0)
|
|
3494
3584
|
.map(({ group, rules }) => renderRootGroup(group.label, group.root, rules));
|
|
3495
|
-
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}`);
|
|
3496
3586
|
}
|
|
3497
3587
|
// Per-root counts only where there is more than one root to attribute them to; with a single
|
|
3498
3588
|
// root the leading total already says how many, and repeating it reads as a second figure.
|
|
3499
3589
|
const carried = groups
|
|
3500
3590
|
.map((group) => split ? `${group.label} — ${group.root}: ${group.required.length} ${entryWord(group.required.length)}` : `${group.label} — ${group.root}`)
|
|
3501
3591
|
.join('; ');
|
|
3502
|
-
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}`);
|
|
3503
3593
|
},
|
|
3504
3594
|
};
|
|
3505
3595
|
/**
|
|
@@ -3897,6 +3987,9 @@ const RETRIEVAL_INDEX_CHECK = {
|
|
|
3897
3987
|
* the watcher dispatches to GitHub rather than spawns here, and `remote-github` follows that because it
|
|
3898
3988
|
* asks GitHub the half of the same question local evidence cannot answer.
|
|
3899
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.
|
|
3992
|
+
*
|
|
3900
3993
|
* `plugin-permissions` closes the profile block for the same shape of reason: it is the only profile
|
|
3901
3994
|
* question whose other half is not in the repository at all — the plugin's machine-local install
|
|
3902
3995
|
* root — so it is answerable only once the profile itself has been read, and a reader whose
|
|
@@ -3937,6 +4030,7 @@ export const CHECKS = Object.freeze([
|
|
|
3937
4030
|
PLUGIN_WIRING_CHECK,
|
|
3938
4031
|
PROFILE_CHECK,
|
|
3939
4032
|
PROFILE_PATHS_CHECK,
|
|
4033
|
+
PROFILE_TRACKED_CHECK,
|
|
3940
4034
|
PROFILE_BROWSER_DENY_CHECK,
|
|
3941
4035
|
PROFILE_DENY_FLOOR_CHECK,
|
|
3942
4036
|
PLUGIN_PERMISSIONS_CHECK,
|