@yemi33/minions 0.1.2306 → 0.1.2308
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/bin/minions.js +1 -0
- package/dashboard/js/settings.js +31 -1
- package/dashboard/slim/body.html +6 -22
- package/dashboard/slim/js/modals-tiles.js +32 -22
- package/dashboard/slim/js/plans.js +24 -516
- package/dashboard/slim/styles.css +10 -7
- package/dashboard.js +63 -8
- package/docs/completion-reports.md +2 -2
- package/docs/harness-transparency.md +18 -0
- package/docs/live-checkout-mode.md +53 -6
- package/engine/ado-comment.js +5 -2
- package/engine/cc-worker-pool.js +16 -4
- package/engine/cli.js +58 -3
- package/engine/comment-format.js +82 -2
- package/engine/discover-project-skills.js +4 -0
- package/engine/dispatch.js +36 -1
- package/engine/gh-comment.js +8 -3
- package/engine/lifecycle.js +92 -25
- package/engine/live-checkout.js +207 -0
- package/engine/pre-dispatch-eval.js +54 -5
- package/engine/shared.js +90 -1
- package/engine/supervisor.js +13 -0
- package/engine/watchdog.js +10 -0
- package/engine.js +208 -3
- package/package.json +1 -1
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +9 -0
package/dashboard.js
CHANGED
|
@@ -258,8 +258,12 @@ function reloadConfig() {
|
|
|
258
258
|
// Settings change takes effect without a dashboard restart (W-mr0qs0vw). The
|
|
259
259
|
// pool stays dependency-free at load and relies on this push.
|
|
260
260
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
// W-mr1qbj90000d30bd — 0 is the explicit "never idle-reap" sentinel and
|
|
262
|
+
// must NOT fall through to the default via `||` (0 is falsy in JS).
|
|
263
|
+
const cfgIdle = CONFIG.engine?.ccWorkerIdleTimeoutMs;
|
|
264
|
+
const idleMs = Number(cfgIdle) === 0
|
|
265
|
+
? 0
|
|
266
|
+
: (Number(cfgIdle) || shared.ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs);
|
|
263
267
|
ccWorkerPool.setIdleTimeoutMs(idleMs);
|
|
264
268
|
} catch { /* invalid value or pool unavailable — keep prior window */ }
|
|
265
269
|
}
|
|
@@ -434,6 +438,15 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
|
|
|
434
438
|
} else {
|
|
435
439
|
delete currentProject.checkoutMode;
|
|
436
440
|
}
|
|
441
|
+
// W-mqtvnnj1000357fa — mirror the per-project liveCheckoutAutoStash
|
|
442
|
+
// override the same way: present on candidate → copy; absent → clear so
|
|
443
|
+
// the engine fleet-wide `engine.liveCheckoutAutoStash` applies. Without
|
|
444
|
+
// this branch the validated update is silently dropped before reaching disk.
|
|
445
|
+
if (Object.prototype.hasOwnProperty.call(candidateProject, 'liveCheckoutAutoStash')) {
|
|
446
|
+
currentProject.liveCheckoutAutoStash = candidateProject.liveCheckoutAutoStash;
|
|
447
|
+
} else {
|
|
448
|
+
delete currentProject.liveCheckoutAutoStash;
|
|
449
|
+
}
|
|
437
450
|
// W-mqiaw974 (issue #241): the field was renamed from the legacy
|
|
438
451
|
// `worktreeMode`. Drop any stale legacy key on every settings save so a
|
|
439
452
|
// migrated project never carries both fields.
|
|
@@ -10957,6 +10970,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10957
10970
|
// the per-project dropdown. resolveCheckoutMode honors the legacy
|
|
10958
10971
|
// worktreeMode field; 'worktree' (default) or 'live'.
|
|
10959
10972
|
checkoutMode: shared.resolveCheckoutMode(p),
|
|
10973
|
+
// W-mqtvnnj1000357fa — surface the RAW per-project auto-stash override
|
|
10974
|
+
// (true / false / undefined) so the Settings UI can pre-select the
|
|
10975
|
+
// tri-state dropdown. Undefined means "use fleet default"
|
|
10976
|
+
// (engine.liveCheckoutAutoStash); only an explicit boolean overrides.
|
|
10977
|
+
liveCheckoutAutoStash: (typeof p.liveCheckoutAutoStash === 'boolean') ? p.liveCheckoutAutoStash : undefined,
|
|
10960
10978
|
workSources: {
|
|
10961
10979
|
pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
|
|
10962
10980
|
workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
|
|
@@ -11049,12 +11067,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11049
11067
|
// larger models); max 1h (matches CC_CALL_TIMEOUT_MS so the watchdog
|
|
11050
11068
|
// never outlives the outer abort).
|
|
11051
11069
|
ccTurnTimeoutMs: [10000, 3600000],
|
|
11052
|
-
// W-mr0qs0vw — ACP worker idle-reaper window. 1min floor (anything
|
|
11053
|
-
// shorter would reap a warm worker between a user's own messages and
|
|
11054
|
-
// thrash cold-spawns); 8h ceiling (a config typo shouldn't pin a warm
|
|
11055
|
-
// copilot process in memory indefinitely). Stored in ms; the Settings
|
|
11056
|
-
// UI presents minutes and converts.
|
|
11057
|
-
ccWorkerIdleTimeoutMs: [60000, 28800000],
|
|
11058
11070
|
// W-mq9acoo800177bcb — bounded-concurrency for pre-dispatch validator.
|
|
11059
11071
|
// 1 floor (sequential fallback) and 20 ceiling (above this the LLM
|
|
11060
11072
|
// provider's per-second rate limits dominate; throughput gains taper).
|
|
@@ -11070,6 +11082,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11070
11082
|
_setEngineConfig(key, val);
|
|
11071
11083
|
}
|
|
11072
11084
|
}
|
|
11085
|
+
// W-mr0qs0vw / W-mr1qbj90000d30bd — ACP worker idle-reaper window.
|
|
11086
|
+
// 1min floor (anything shorter would reap a warm worker between a
|
|
11087
|
+
// user's own messages and thrash cold-spawns); 8h ceiling (a config
|
|
11088
|
+
// typo shouldn't pin a warm copilot process in memory indefinitely).
|
|
11089
|
+
// Handled OUTSIDE the generic numericFields loop because `0` is a
|
|
11090
|
+
// valid, deliberate sentinel meaning "never idle-reap" (only closeTab
|
|
11091
|
+
// kills the warm worker) — it must bypass the [60000, 28800000]
|
|
11092
|
+
// clamp for that one value instead of being clamped up to 60000 or
|
|
11093
|
+
// falling back to the default via `Number(e[key]) || D[key]`
|
|
11094
|
+
// (0 is falsy in JS). Stored in ms; the Settings UI presents minutes.
|
|
11095
|
+
if (e.ccWorkerIdleTimeoutMs !== undefined) {
|
|
11096
|
+
const rawIdle = Number(e.ccWorkerIdleTimeoutMs);
|
|
11097
|
+
if (rawIdle === 0) {
|
|
11098
|
+
_setEngineConfig('ccWorkerIdleTimeoutMs', 0);
|
|
11099
|
+
} else {
|
|
11100
|
+
let val = rawIdle || D.ccWorkerIdleTimeoutMs;
|
|
11101
|
+
const raw = val;
|
|
11102
|
+
val = Math.max(60000, val);
|
|
11103
|
+
val = Math.min(28800000, val);
|
|
11104
|
+
if (val !== raw) _clamped.push(`ccWorkerIdleTimeoutMs: ${raw} → ${val} (range: 60000–28800000)`);
|
|
11105
|
+
_setEngineConfig('ccWorkerIdleTimeoutMs', val);
|
|
11106
|
+
}
|
|
11107
|
+
}
|
|
11073
11108
|
// String fields
|
|
11074
11109
|
if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
|
|
11075
11110
|
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
@@ -11412,6 +11447,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11412
11447
|
else proj.liveValidation = validatedLv;
|
|
11413
11448
|
}
|
|
11414
11449
|
}
|
|
11450
|
+
// W-mqtvnnj1000357fa — per-project live-checkout auto-stash override.
|
|
11451
|
+
// null / '' / undefined clears the override (engine fleet-wide
|
|
11452
|
+
// `engine.liveCheckoutAutoStash` applies); an explicit boolean pins
|
|
11453
|
+
// the per-project decision. Anything else is coerced to boolean.
|
|
11454
|
+
if (Object.prototype.hasOwnProperty.call(update, 'liveCheckoutAutoStash')) {
|
|
11455
|
+
const rawStash = update.liveCheckoutAutoStash;
|
|
11456
|
+
if (rawStash === null || rawStash === '' || rawStash === undefined) {
|
|
11457
|
+
delete proj.liveCheckoutAutoStash;
|
|
11458
|
+
} else {
|
|
11459
|
+
proj.liveCheckoutAutoStash = !!rawStash;
|
|
11460
|
+
}
|
|
11461
|
+
}
|
|
11415
11462
|
}
|
|
11416
11463
|
}
|
|
11417
11464
|
|
|
@@ -14889,6 +14936,14 @@ if (require.main === module) {
|
|
|
14889
14936
|
if (!alive) {
|
|
14890
14937
|
console.log(`[watchdog] Engine PID ${control.pid} is dead — auto-restarting...`);
|
|
14891
14938
|
restartEngine();
|
|
14939
|
+
// Crash-loop counter (W-mr2c46590003e3ee) — shares the same rolling
|
|
14940
|
+
// window in control.json as engine/supervisor.js and
|
|
14941
|
+
// engine/watchdog.js so a single crash isn't tallied 2-3x across
|
|
14942
|
+
// the three independent respawn mechanisms; only THIS automatic
|
|
14943
|
+
// dead-PID path counts (the user-triggered "Restart Engine" button
|
|
14944
|
+
// in handleEngineRestart above is deliberate, not self-healing, so
|
|
14945
|
+
// it's intentionally excluded).
|
|
14946
|
+
try { shared.recordEngineRespawn('dashboard:in-process-watchdog'); } catch { /* best-effort */ }
|
|
14892
14947
|
} else {
|
|
14893
14948
|
_markEngineAsDegradedIfFrozen();
|
|
14894
14949
|
}
|
|
@@ -118,7 +118,7 @@ All `meta.skill` fields are optional and backward-compatible — older agents th
|
|
|
118
118
|
|---|---|---|
|
|
119
119
|
| `meta.skill.invoked` | object | The project skill the agent actually ran. Shape: `{name, path, kind, intent}` where `kind` is one of `skill`, `command`, `slash-command` (mirrors the discovery layer in `engine/discover-project-skills.js`) and `intent` is the bucket it served (`build`, `review`, `fix`, `test`, `plan`, `research`, `deploy`, `observability`, `meta`). Omit when no skill was invoked. |
|
|
120
120
|
| `meta.skill.findings` | number | Count of findings/artifacts the invoked skill returned. Semantics is per-skill; treat as opaque integer. Omit when no skill was invoked. |
|
|
121
|
-
| `meta.skill.skipped` | object | Set when a project skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope, meta-work on the skill itself, etc.). Shape: `{name, reason}`. Mirror the skip rationale into the PR comment /
|
|
121
|
+
| `meta.skill.skipped` | object | Set when a project skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope, meta-work on the skill itself, etc.). Shape: `{name, reason}`. **Before recording a skip you MUST validate it against the skill's own documented scope** — re-read the skill's SKILL.md checklist and confirm the skip reason does not contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item; if the diff trips any such item, skipping is not permitted (apply the skill or that item instead). Mirror the skip rationale into the PR comment so a human sees it **before** merge, not only via a later API call — `minions pr comment … --skill-skipped-file <f>` / `--skill-skipped-json <j>` folds a visible `> ⚠️ Skipped project skill: <name> — <reason>` callout into the same comment as the verdict (single renderer `engine/comment-format.js#buildSkippedSkillSection`, byte-identical on GitHub and ADO). The `{name, reason}` in the comment MUST match the one recorded here (no drift). |
|
|
122
122
|
|
|
123
123
|
Dispatchers can suppress the block entirely by setting `meta.skipProjectSkills: true` on the work item — the playbook then renders without the skills block. Use this for meta-work that targets the skill itself; the engine still accepts `meta.skill.skipped` in the report regardless. The PR-82 `meta.skipProjectReviewSkills` flag is preserved as an alias (suppresses the block on every dispatch type, not just review).
|
|
124
124
|
|
|
@@ -150,7 +150,7 @@ All `meta.review` fields are optional and backward-compatible — older agents t
|
|
|
150
150
|
|---|---|---|
|
|
151
151
|
| `meta.review.skillInvoked` | object | The project review skill the agent actually ran. Shape: `{name, path, kind}` where `kind` is one of `skill`, `command`, `slash-command` (mirrors the discovery layer in `engine/discover-project-skills.js`). Omit when no skill was invoked. Aliased by `meta.skill.invoked`. |
|
|
152
152
|
| `meta.review.skillFindings` | number | Count of findings the invoked skill returned. Used later to measure skill quality and the value-add of first-principles review on top. Omit when no skill was invoked. Aliased by `meta.skill.findings`. |
|
|
153
|
-
| `meta.review.skillSkipped` | object | Set when a project review skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope diff, meta-review of the skill itself, etc.). Shape: `{name, reason}`. Aliased by `meta.skill.skipped`. |
|
|
153
|
+
| `meta.review.skillSkipped` | object | Set when a project review skill was available but the agent intentionally chose not to run it (trivial diff, out-of-scope diff, meta-review of the skill itself, etc.). Shape: `{name, reason}`. Aliased by `meta.skill.skipped`. **The skip reason is validated against the skill's own scope** — before recording a skip the agent must re-read the skill's SKILL.md checklist and confirm the reason does not contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item; if the diff trips any such item, skipping is not permitted. The same `{name, reason}` must also be surfaced in the PR review comment (visible `> ⚠️ Skipped project skill` callout, via `minions pr comment … --skill-skipped-json`) so a human sees it before merge — no drift between the report and the comment. |
|
|
154
154
|
|
|
155
155
|
Dispatchers can suppress the block entirely by setting `meta.skipProjectReviewSkills: true` on the review work item — the playbook then renders identically to the pre-W-mq16xtdx 8-step contract. Use this for meta-reviews of the review skill itself; the engine still accepts `meta.review.skillSkipped` in the report regardless. (W-mq1cczi90006b21f generalized this to `meta.skipProjectSkills`, which suppresses the block on every dispatch type — both flags are honored.)
|
|
156
156
|
|
|
@@ -119,6 +119,24 @@ evaluation pass) can see what tooling drove a dispatch:
|
|
|
119
119
|
paths review/fix agents append the section themselves per
|
|
120
120
|
`playbooks/shared-rules.md` → "Harness transparency / self-report". Every
|
|
121
121
|
surface consumes the one renderer, so there is no second formatter to drift.
|
|
122
|
+
|
|
123
|
+
**Skipped in-scope project skill (W-mr287sc0000uc214).** The same PR-comment
|
|
124
|
+
surface also folds in a VISIBLE (non-collapsed) `> ⚠️ Skipped project skill:
|
|
125
|
+
<name> — <reason>` callout when the agent self-reported that it intentionally
|
|
126
|
+
skipped an available, in-scope project (review) skill
|
|
127
|
+
(`meta.review.skillSkipped` / `meta.skill.skipped`). This lived only in the
|
|
128
|
+
completion-report JSON before — invisible to a human reading the PR pre-merge.
|
|
129
|
+
`engine/comment-format.js#buildSkippedSkillSection(skillSkipped)` is the single
|
|
130
|
+
neutral renderer (sibling of `buildHarnessUsedSection`); it is threaded through
|
|
131
|
+
`buildMinionsCommentBody` (optional `skillSkipped` arg) and both posters
|
|
132
|
+
(`gh-comment.js` / `ado-comment.js`), and the `minions pr comment` CLI turns
|
|
133
|
+
`--skill-skipped-file` / `--skill-skipped-json` into the record. Two rules
|
|
134
|
+
attach to the skip (see `docs/completion-reports.md` → Review/Project skill
|
|
135
|
+
outcomes): the skip reason must be **validated against the skill's own
|
|
136
|
+
documented checklist** before it is recorded (a skip that contradicts an
|
|
137
|
+
explicit "Flag if…" / "Requirements" item the skill covers is invalid), and
|
|
138
|
+
the `{name, reason}` rendered in the comment must match the one recorded in the
|
|
139
|
+
report (no drift).
|
|
122
140
|
2. **Final agent note** — for a non-clean completion (failure / partial) the
|
|
123
141
|
single final agent report (`engine/lifecycle.js#writeNonCleanAgentReport`)
|
|
124
142
|
folds the grounded harness footprint in as the same `buildHarnessUsedSection`
|
|
@@ -37,7 +37,17 @@ Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status
|
|
|
37
37
|
- Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
|
|
38
38
|
- Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
|
|
39
39
|
|
|
40
|
-
The engine never calls `git reset --hard`, `git clean -fd`,
|
|
40
|
+
The engine never calls `git reset --hard`, `git clean -fd`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. The dispatch-scoped cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`). The **periodic** worktree GC, however, is NOT `worktreePath`-gated — it derives its targets from `git worktree list --porcelain`, which for a live project returns the operator's *own* primary checkout. So `engine/cleanup.js#runPeriodicWorktreeSweep` now **filters out live-checkout projects entirely** (`shared.isLiveCheckoutProject`) before handing the list to the three pruners (PL-live-checkout-reliability-hardening), keeping the operator's real checkout out of the GC decision surface rather than relying only on the pruners' path-equality + ownership-marker gates. (The one **opt-in** exception is auto-stash — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa) — which the operator explicitly enables and which the engine never reverses on the operator's behalf.)
|
|
41
|
+
|
|
42
|
+
#### 2b. Opt-in auto-stash on dirty (W-mqtvnnj1000357fa)
|
|
43
|
+
|
|
44
|
+
By default the dirty refusal above stands. When the operator opts in, the engine instead **stashes** the dirty changes so the dispatch can proceed without manual intervention:
|
|
45
|
+
|
|
46
|
+
- **Enable** via per-project `project.liveCheckoutAutoStash: true` (takes priority) or the fleet-wide fallback `engine.liveCheckoutAutoStash: true` (default `false`). Resolution is `engine/live-checkout.js#resolveLiveCheckoutAutoStash` — an explicit per-project boolean wins; otherwise the engine value applies; otherwise `false`. Both are surfaced as Settings toggles (the per-project control is a tri-state *Use fleet default / On / Off* select; the fleet-wide one lives under **Settings → Worktrees**).
|
|
47
|
+
- When enabled and the tree is dirty, `spawnAgent` delegates the whole flow to `engine/live-checkout.js#applyLiveCheckoutAutoStash`, which calls `performLiveCheckoutAutoStash` to run `git stash push --include-untracked -m "minions-auto-stash-<dispatchId>-<timestamp>"` in `project.localPath` (`--include-untracked` so the `??` files that `git status --porcelain` counts as dirty are parked too). The stash git command runs **outside any file lock**.
|
|
48
|
+
- On stash **success** the helper re-runs `prepareLiveCheckout` (the tree is now clean), clears any stale `_pendingReason: 'live_checkout_dirty'` stamp (via the injected `clearDirtyStamp` callback), writes a `live-checkout-autostash-<wi-id>` inbox note with the stash name + manual-pop guidance, and returns `{ outcome:'stashed', liveResult }` so dispatch proceeds. If the re-preflight throws, it returns `{ outcome:'threw', error }` and `spawnAgent` fails the dispatch as `LIVE_CHECKOUT_FAILED`.
|
|
49
|
+
- On stash **failure** the error is surfaced (logged, never swallowed), the helper returns `{ outcome:'unchanged', liveResult }`, and the dispatch falls through to the normal retry-once-then-fail dirty path above.
|
|
50
|
+
- The engine **never pops the stash** automatically — that is the operator's choice. The stash name is logged and noted so the operator can `git stash pop` (or `git stash apply`) in `project.localPath` manually.
|
|
41
51
|
|
|
42
52
|
#### 2a. Thrown pre-spawn failures are retryable, NOT dirty (#305)
|
|
43
53
|
|
|
@@ -82,6 +92,21 @@ Issue #226 only de-risked the *new-branch* path. The *existing-branch* `git chec
|
|
|
82
92
|
|
|
83
93
|
**Branch existence is checked against `refs/heads/<branch>` specifically** (not a bare `rev-parse --verify <branch>`, which DWIM-resolves a same-named tag or remote ref and would silently detach HEAD).
|
|
84
94
|
|
|
95
|
+
#### 3b. Worktree-conflict failures are non-retryable (W-mr28h2j2000y0de1)
|
|
96
|
+
|
|
97
|
+
A second deterministic existing-branch checkout failure mode is a **worktree conflict**. When the target branch is *also* checked out in a **second worktree** somewhere else — a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left behind by a `checkoutMode` change — a plain `git checkout <branch>` inside the operator checkout refuses with git's own literal wording:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
fatal: 'work/W-…' is already used by worktree at 'C:/office/worktrees/W-…'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Like the blob-fetch case this is **structural, not transient** — it does *not* clear on retry, ever, until a human or the engine removes/reassigns the other worktree. Root-caused live against production incident W-mr25v4je000c70c5, where the WI retried 4 times across 3 agents over ~40 minutes, failing identically each time while the blocking worktree (which held real, un-pushed operator WIP matching the WI's own file scope) sat untouched.
|
|
104
|
+
|
|
105
|
+
- **Detection + typed result.** `prepareLiveCheckout` matches git's stable phrasing (`_isWorktreeConflictError`) and captures the conflicting worktree path (`_extractConflictingWorktreePath`), returning `{ ok:false, reason:'worktree-conflict', op, branch, conflictingWorktreePath, message, originalRef, originalRefType }` instead of throwing. The same no-half-switch HEAD restore (§3a) runs first, so the operator tree is left on its original ref.
|
|
106
|
+
- **Auto-resolve attempt before refusing.** Review feedback on the original PR (calebt_microsoft) was that classifying the conflict as non-retryable doesn't actually *resolve* it — the operator still has to clean up by hand every time. `spawnAgent` now calls `_tryAutoResolveLiveCheckoutWorktreeConflict` first: it only removes the conflicting worktree when **all** of (1) the path resolves inside this project's configured `worktreeRoot` (the standard location for engine-created worktrees), (2) it carries the engine ownership marker (`shared.hasWorktreeOwnerMarker` — never touches a worktree a human created by hand), and (3) `shared.removeWorktree` itself agrees to remove it (which independently refuses a worktree with a **live** dispatch running inside it via `isWorktreePathLive`, and refuses real-repo-root / non-linked-worktree paths). When the removal succeeds, `prepareLiveCheckout` is retried once and, on success, the dispatch proceeds normally — no failure, no alert, no operator action needed. This resolves the common case (a stale worktree orphaned by a crashed dispatch or a `checkoutMode` change) fully automatically.
|
|
107
|
+
- **Non-retryable classification (fallback).** When auto-resolve isn't safe (foreign/manual worktree, still-live dispatch, or removal failure) or the retried checkout still conflicts, `spawnAgent` completes the dispatch with the dedicated **`FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT`** (`'live-checkout-worktree-conflict'`; in `dispatch.js`'s `neverRetry` set) plus a `live-checkout-worktree-conflict-<wi-id>` inbox alert and a `_pendingReason: 'live_checkout_worktree_conflict'` stamp — instead of `LIVE_CHECKOUT_FAILED` (retryable) retry-storming to `maxRetries`.
|
|
108
|
+
- **Recovery.** The alert names the conflicting path and offers two options: (1) if that worktree is stale, `git worktree remove <path>` (or `--force` if dirty — with an explicit warning that force discards its uncommitted changes) to free the branch; (2) if it holds real WIP, finish/commit/push directly from that worktree instead of re-dispatching this WI in live mode, or rename/retarget the branch. The engine only ever suggests `git worktree remove` against the **other** worktree — it never mutates `project.localPath` beyond the best-effort `checkout <originalRef>` undo, and the auto-resolve path above never touches a worktree the engine didn't create or that still has an agent running inside it.
|
|
109
|
+
|
|
85
110
|
### 4. No worktree pool, no per-WI subdirectory isolation
|
|
86
111
|
|
|
87
112
|
Live mode shares one checkout per project. There is no pool to recycle, no quarantine directory, no per-WI subdirectory under the project root. The mutating-concurrency cap (Guarantee 1) is the only isolation mechanism: agents take turns in the same directory.
|
|
@@ -139,6 +164,26 @@ The core invariant holds end-to-end through restore: **the engine only ever swit
|
|
|
139
164
|
|
|
140
165
|
Absent / `null` / `''` reads as `'worktree'` (the default) — explicit is preferred. A legacy `"worktreeMode": "live"` is still honored (and `"worktreeMode": "isolated"` reads as `'worktree'`), but new configs should use `checkoutMode`.
|
|
141
166
|
|
|
167
|
+
### Enabling auto-stash on dirty (config.json)
|
|
168
|
+
|
|
169
|
+
```jsonc
|
|
170
|
+
{
|
|
171
|
+
// Fleet-wide fallback (default false) — applies to every live project that
|
|
172
|
+
// doesn't set its own override.
|
|
173
|
+
"engine": { "liveCheckoutAutoStash": true },
|
|
174
|
+
"projects": [{
|
|
175
|
+
"name": "android-aosp",
|
|
176
|
+
"checkoutMode": "live",
|
|
177
|
+
// Per-project override wins over engine.liveCheckoutAutoStash. Omit the
|
|
178
|
+
// field to inherit the fleet setting; `false` forces fail-on-dirty even
|
|
179
|
+
// when the fleet setting is true.
|
|
180
|
+
"liveCheckoutAutoStash": true
|
|
181
|
+
}]
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
With auto-stash on, a dirty tree is `git stash push --include-untracked`'d before dispatch instead of refusing (see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa)). The engine never pops the stash — recover with `git stash pop` manually.
|
|
186
|
+
|
|
142
187
|
### Recovering from `live_checkout_dirty` refusal
|
|
143
188
|
|
|
144
189
|
When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. (To make the engine *auto-recover* from this instead of refusing — at the cost of discarding the dirty changes — enable [opt-in auto-reset](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).) From the project checkout:
|
|
@@ -180,7 +225,7 @@ The engine has no opinion about local branches; this hygiene is the operator's r
|
|
|
180
225
|
Live-checkout mode is deliberately small. These are NOT supported and will not be added:
|
|
181
226
|
|
|
182
227
|
- **No `auto` mode.** The choice between `worktree` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
|
|
183
|
-
- **No auto-stash on dirty refusal.**
|
|
228
|
+
- **No auto-stash on dirty refusal _by default_.** Out of the box the engine refuses and exits; it never `git stash`es to "make room" for a dispatch, because that silently mutates the operator's tree and conflates engine state with operator state. The operator can **opt in** per-project (`project.liveCheckoutAutoStash`) or fleet-wide (`engine.liveCheckoutAutoStash`) to auto-stash instead of refusing — see [2b](#2b-opt-in-auto-stash-on-dirty-w-mqtvnnj1000357fa). Even then the engine never **pops** the stash on the operator's behalf. (The `liveCheckoutAutoReset` escape hatch — ON by default as of W-mqzbbhn2 — **discards** rather than stashes; set it to `false` per-project / fleet-wide to keep the terminal dirty-tree refusal. See [§2b](#2b-opt-in-auto-reset-on-dirty-livecheckoutautoreset-w-mqvejug6000eeb20).)
|
|
184
229
|
- **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
|
|
185
230
|
- **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
|
|
186
231
|
- **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
|
|
@@ -194,10 +239,11 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
194
239
|
| `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
|
|
195
240
|
| `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20). |
|
|
196
241
|
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
|
|
197
|
-
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch` classification** for partial-clone hydration failures, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
|
|
242
|
+
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight (incl. `BISECT_LOG`; throw-on-git-dir-failure; exit-1-only detached), original-ref capture, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification** for partial-clone hydration failures and cross-worktree branch conflicts, **opt-in dirty auto-reset** (`git fetch origin` + `reset --hard origin/<branch>` + re-check + `live-checkout-autoreset-<wiId>` note when `liveCheckoutAutoReset` is on), **50 MB git maxBuffer** (P-a3f9b203; preflight + capture P-b2e8d4a6; hardening PL-live-checkout-reliability-hardening; auto-reset + maxBuffer W-mqvejug6000eeb20). |
|
|
198
243
|
| `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
|
|
244
|
+
| `engine/live-checkout.js` — `resolveLiveCheckoutAutoStash`, `performLiveCheckoutAutoStash`, `applyLiveCheckoutAutoStash` | Opt-in auto-stash: resolver (per-project boolean wins, else `engine.liveCheckoutAutoStash`, else false) + `git stash push --include-untracked` runner returning `{ ok, stashMessage, error? }` (never throws on git failure) + the `applyLiveCheckoutAutoStash` orchestrator (stash → re-preflight → clear retry stamp → operator inbox note) returning a `{ outcome:'unchanged'\|'stashed'\|'threw' }` discriminant. Engine never auto-pops (W-mqtvnnj1000357fa). |
|
|
199
245
|
| `engine/live-checkout.js` — `maybeRestoreLiveCheckoutFromRecord` | Shared wrapper that fires the dispatch-end restore from a persisted dispatch record; used by `cli.js` + both `timeout.js` reaping paths so a restart-spanning live dispatch is never stranded (PL-live-checkout-reliability-hardening). |
|
|
200
|
-
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
|
|
246
|
+
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, delegates opt-in auto-stash to `applyLiveCheckoutAutoStash` on a dirty tree (W-mqtvnnj1000357fa), handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
|
|
201
247
|
| `engine.js` — `spawnAgent` mid-op / detached-HEAD refusal block | Emits `LIVE_CHECKOUT_MID_OPERATION`, writes `live-checkout-blocked-<wi-id>` alert, stamps `_pendingReason: 'live_checkout_mid_operation'` / `'live_checkout_detached_head'` (P-c5a1f3b8). |
|
|
202
248
|
| `engine.js` — `spawnAgent` originalRef persistence | Persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch` so restore survives an engine restart (P-c5a1f3b8). |
|
|
203
249
|
| `engine.js` — `onAgentClose` live-mode restore wiring | Calls `restoreLiveCheckoutAtDispatchEnd` on every terminal result (P-d9e6b2c4). |
|
|
@@ -208,10 +254,11 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
208
254
|
| `engine/cleanup.js` — `runPeriodicWorktreeSweep` live filter | Excludes live-checkout projects from the registry-derived periodic worktree GC so the operator's primary checkout never enters the GC decision surface (PL-live-checkout-reliability-hardening). |
|
|
209
255
|
| `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` step-4 restore | `reset --hard HEAD` (not the index-leaking `checkout -- .`) + retried untracked removal + `liveTreeDirty` surfaced + `shared.removeWorktree` teardown (PL-live-checkout-reliability-hardening). |
|
|
210
256
|
| `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
|
|
211
|
-
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
|
|
257
|
+
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa). |
|
|
212
258
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
|
|
213
259
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert/bisect or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
|
|
214
260
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH` | Non-retryable refusal class for an existing-branch checkout that could not hydrate the tree on a blobless GVFS partial clone (auth-less cache fetch, headless) — deterministic, so excluded from mechanical retry (PL-live-checkout-reliability-hardening). |
|
|
261
|
+
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT` | Non-retryable refusal class for an existing-branch checkout that refused because the branch is already checked out in another worktree — structural, so excluded from mechanical retry (W-mr28h2j2000y0de1). |
|
|
215
262
|
| `engine.js` — `_liveCheckoutDirtyAttempts` counter | Dedicated two-strike dirty memory (survives the discovery + retry `_pendingReason` scrubs that defeated #434); first dirty failure retries once, second fails non-retryably (PL-live-checkout-reliability-hardening). |
|
|
216
|
-
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY`, `LIVE_CHECKOUT_MID_OPERATION`, and `
|
|
263
|
+
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY`, `LIVE_CHECKOUT_MID_OPERATION`, `LIVE_CHECKOUT_BLOB_FETCH`, and `LIVE_CHECKOUT_WORKTREE_CONFLICT` from mechanical retry. |
|
|
217
264
|
| `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
|
package/engine/ado-comment.js
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* changes) stay `active` so they block/notify the author.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
const { buildMinionsCommentBody } = require('./comment-format');
|
|
37
|
+
const { buildMinionsCommentBody, buildSkippedSkillSection } = require('./comment-format');
|
|
38
38
|
const { acquireAdoToken } = require('./ado-token');
|
|
39
39
|
|
|
40
40
|
const ADO_API_VERSION = '7.1';
|
|
@@ -108,6 +108,7 @@ async function _defaultAcquireToken() {
|
|
|
108
108
|
* @param {string} args.kind comment kind (marker)
|
|
109
109
|
* @param {string} [args.workItemId] originating work-item id (marker)
|
|
110
110
|
* @param {object} [args.harnessUsed] grounded harnessUsed record (folded into body)
|
|
111
|
+
* @param {object} [args.skillSkipped] skipped-project-skill record `{name, reason}` (folded into body, visible)
|
|
111
112
|
* @param {number} [args.timeoutMs=30000]
|
|
112
113
|
* @param {Function} [args.acquireToken] () => Promise<string> — injectable token source
|
|
113
114
|
* @param {Function} [args.fetchImpl=fetch] injectable fetch
|
|
@@ -123,6 +124,7 @@ async function postAdoPrComment({
|
|
|
123
124
|
kind,
|
|
124
125
|
workItemId,
|
|
125
126
|
harnessUsed,
|
|
127
|
+
skillSkipped,
|
|
126
128
|
resolved = false,
|
|
127
129
|
timeoutMs = 30000,
|
|
128
130
|
acquireToken = _defaultAcquireToken,
|
|
@@ -134,7 +136,7 @@ async function postAdoPrComment({
|
|
|
134
136
|
_validatePrNumber(prNumber);
|
|
135
137
|
|
|
136
138
|
// buildMinionsCommentBody validates marker fields and throws on bad input.
|
|
137
|
-
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed });
|
|
139
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped });
|
|
138
140
|
|
|
139
141
|
const token = await acquireToken();
|
|
140
142
|
if (!token || typeof token !== 'string') {
|
|
@@ -184,6 +186,7 @@ module.exports = {
|
|
|
184
186
|
// Re-export the neutral builder so ADO callers have a single import surface,
|
|
185
187
|
// mirroring engine/gh-comment.js.
|
|
186
188
|
buildMinionsCommentBody,
|
|
189
|
+
buildSkippedSkillSection,
|
|
187
190
|
// Internal validators exported for tests.
|
|
188
191
|
_validateOrgBase,
|
|
189
192
|
_validateProject,
|
package/engine/cc-worker-pool.js
CHANGED
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
* - closeTab → `session/cancel` if inflight, then kill proc
|
|
39
39
|
* - Idle > timeout → kill proc (reaper sweep every 60 s; window is
|
|
40
40
|
* ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs, default
|
|
41
|
-
* 30 min, pushed in via setIdleTimeoutMs())
|
|
41
|
+
* 30 min, pushed in via setIdleTimeoutMs()). A
|
|
42
|
+
* timeout of 0 is the "never idle-reap" sentinel
|
|
43
|
+
* (W-mr1qbj90000d30bd) — the warm worker is only
|
|
44
|
+
* ever killed by an explicit closeTab.
|
|
42
45
|
*
|
|
43
46
|
* Auth precheck: when `copilot --acp` exits before the initialize handshake
|
|
44
47
|
* completes, surface
|
|
@@ -171,11 +174,18 @@ let _reaperTimer = null;
|
|
|
171
174
|
const _reapedTabs = new Map();
|
|
172
175
|
|
|
173
176
|
// Update the idle-reaper window at runtime. Called by dashboard.js on every
|
|
174
|
-
// reloadConfig() with `engine.ccWorkerIdleTimeoutMs`.
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
+
// reloadConfig() with `engine.ccWorkerIdleTimeoutMs`. `0` is the explicit
|
|
178
|
+
// "never idle-reap" sentinel (W-mr1qbj90000d30bd) — it sets the window to
|
|
179
|
+
// Infinity so `_reapIdleTabs()` never kills a warm worker for inactivity;
|
|
180
|
+
// only an explicit `closeTab` does. Ignores non-finite / negative values so a
|
|
181
|
+
// config typo can't disable reaping or set a negative window; the floor of
|
|
182
|
+
// 1000ms keeps a misconfigured tiny positive value from thrashing.
|
|
177
183
|
function setIdleTimeoutMs(ms) {
|
|
178
184
|
const n = Number(ms);
|
|
185
|
+
if (n === 0) {
|
|
186
|
+
_idleReaperMs = Infinity;
|
|
187
|
+
return _idleReaperMs;
|
|
188
|
+
}
|
|
179
189
|
if (!Number.isFinite(n) || n <= 0) return _idleReaperMs;
|
|
180
190
|
_idleReaperMs = Math.max(1000, n);
|
|
181
191
|
return _idleReaperMs;
|
|
@@ -876,6 +886,8 @@ function _ensureReaper() {
|
|
|
876
886
|
}
|
|
877
887
|
|
|
878
888
|
function _reapIdleTabs() {
|
|
889
|
+
// W-mr1qbj90000d30bd — idle-reap disabled entirely (sentinel: 0 → Infinity).
|
|
890
|
+
if (_idleReaperMs === Infinity) return;
|
|
879
891
|
const now = _internals.now();
|
|
880
892
|
for (const [tabId, worker] of [..._tabs]) {
|
|
881
893
|
if (worker.inflight) continue;
|
package/engine/cli.js
CHANGED
|
@@ -250,7 +250,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
|
|
|
250
250
|
'mcp-sync': { args: '', summary: 'Print harness propagation diagnostic (same source as `minions doctor --harness`; read-only, no writes)' },
|
|
251
251
|
doctor: { args: '[--harness]', summary: 'Check prerequisites and runtime health (--harness: print harness propagation diagnostic)' },
|
|
252
252
|
config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
|
|
253
|
-
pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--resolved] [--harness-file <f>|--harness-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh (ADO: --resolved posts pre-closed)' },
|
|
253
|
+
pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--resolved] [--harness-file <f>|--harness-json <j>] [--skill-skipped-file <f>|--skill-skipped-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh (ADO: --resolved posts pre-closed)' },
|
|
254
254
|
bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
|
|
255
255
|
});
|
|
256
256
|
|
|
@@ -475,6 +475,49 @@ function resolveHarnessUsedForComment(flags) {
|
|
|
475
475
|
return shared.groundHarnessUsed(parsed, propagated);
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// resolveSkillSkippedForComment(flags) — W-mr287sc0000uc214. Turn the
|
|
479
|
+
// `minions pr comment` skip flags into a canonical `{name, reason}` record for
|
|
480
|
+
// the posters' `skillSkipped` param (engine/gh-comment.js / engine/ado-comment.js),
|
|
481
|
+
// which fold it via buildMinionsCommentBody -> buildSkippedSkillSection into a
|
|
482
|
+
// VISIBLE callout in the same comment as the verdict.
|
|
483
|
+
//
|
|
484
|
+
// Source precedence mirrors the harness resolver: `--skill-skipped-file <path>`
|
|
485
|
+
// (JSON on disk) wins over the inline `--skill-skipped-json <json>`; neither
|
|
486
|
+
// supplied -> returns undefined so the comment body is byte-identical to today
|
|
487
|
+
// (no callout). Read/parse failures are HARD errors (process.exit(2)). Accepts
|
|
488
|
+
// the raw `{name, reason}` shape or a `meta.skill` / `meta.review` wrapper; a
|
|
489
|
+
// record with no non-empty `name` yields undefined (nothing to surface).
|
|
490
|
+
function resolveSkillSkippedForComment(flags) {
|
|
491
|
+
const fileArg = flags['skill-skipped-file'];
|
|
492
|
+
const jsonArg = flags['skill-skipped-json'];
|
|
493
|
+
|
|
494
|
+
let raw;
|
|
495
|
+
if (fileArg !== undefined) {
|
|
496
|
+
try {
|
|
497
|
+
raw = fs.readFileSync(fileArg, 'utf8');
|
|
498
|
+
} catch (e) {
|
|
499
|
+
console.error(`error: could not read --skill-skipped-file ${fileArg}: ${e.message}`);
|
|
500
|
+
process.exit(2);
|
|
501
|
+
}
|
|
502
|
+
} else if (jsonArg !== undefined) {
|
|
503
|
+
raw = String(jsonArg);
|
|
504
|
+
} else {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let parsed;
|
|
509
|
+
try {
|
|
510
|
+
parsed = JSON.parse(raw);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
const src = fileArg !== undefined ? `--skill-skipped-file ${fileArg}` : '--skill-skipped-json';
|
|
513
|
+
console.error(`error: invalid JSON in ${src}: ${e.message}`);
|
|
514
|
+
process.exit(2);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const { _extractSkillSkipped } = require('./comment-format');
|
|
518
|
+
return _extractSkillSkipped(parsed) || undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
478
521
|
const commands = {
|
|
479
522
|
start(...startArgs) {
|
|
480
523
|
// Apply --cli / --model fleet flags before any engine wiring touches
|
|
@@ -2044,6 +2087,10 @@ const commands = {
|
|
|
2044
2087
|
console.log(' --harness-file <path> JSON file with the { skills, mcpServers, commands, docs } record (wins over --harness-json)');
|
|
2045
2088
|
console.log(' --harness-json <json> same record inline as a JSON string');
|
|
2046
2089
|
console.log(' --dispatch-id <id> grounding manifest override (else basename of $MINIONS_COMPLETION_REPORT)');
|
|
2090
|
+
console.log('');
|
|
2091
|
+
console.log('Skipped in-scope project skill (folded in as a VISIBLE "⚠️ Skipped project skill" callout):');
|
|
2092
|
+
console.log(' --skill-skipped-file <path> JSON file with the { name, reason } skip record (wins over --skill-skipped-json)');
|
|
2093
|
+
console.log(' --skill-skipped-json <json> same record inline as a JSON string (also accepts a meta.skill/meta.review wrapper)');
|
|
2047
2094
|
console.log(' --resolved (ADO only) post the thread pre-resolved/closed — for non-actionable FYI/praise notes');
|
|
2048
2095
|
console.log('');
|
|
2049
2096
|
console.log('Posts a PR comment with the hidden minions marker, the collapsible');
|
|
@@ -2117,6 +2164,12 @@ const commands = {
|
|
|
2117
2164
|
// -> undefined -> body byte-identical to today.
|
|
2118
2165
|
const harnessUsed = resolveHarnessUsedForComment(flags);
|
|
2119
2166
|
|
|
2167
|
+
// W-mr287sc0000uc214 — thread the skipped-in-scope-project-skill self-report
|
|
2168
|
+
// into both posters so it's surfaced as a VISIBLE callout in the same comment
|
|
2169
|
+
// as the verdict (not buried in the completion-report JSON). Absent flags
|
|
2170
|
+
// -> undefined -> body byte-identical to today.
|
|
2171
|
+
const skillSkipped = resolveSkillSkippedForComment(flags);
|
|
2172
|
+
|
|
2120
2173
|
// ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
|
|
2121
2174
|
if (isAdo) {
|
|
2122
2175
|
const [prNumberRaw] = positional;
|
|
@@ -2136,7 +2189,7 @@ const commands = {
|
|
|
2136
2189
|
const orgBase = flags['org-base'] || `https://dev.azure.com/${adoOrg}`;
|
|
2137
2190
|
const adoComment = require('./ado-comment');
|
|
2138
2191
|
adoComment.postAdoPrComment({
|
|
2139
|
-
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId, harnessUsed,
|
|
2192
|
+
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId, harnessUsed, skillSkipped,
|
|
2140
2193
|
resolved: flags.resolved === true,
|
|
2141
2194
|
}).then((result) => {
|
|
2142
2195
|
if (result && result.threadId) {
|
|
@@ -2175,7 +2228,7 @@ const commands = {
|
|
|
2175
2228
|
|
|
2176
2229
|
try {
|
|
2177
2230
|
const result = ghComment.postPrComment({
|
|
2178
|
-
repo, prNumber, body, agentId, kind, workItemId, harnessUsed,
|
|
2231
|
+
repo, prNumber, body, agentId, kind, workItemId, harnessUsed, skillSkipped,
|
|
2179
2232
|
});
|
|
2180
2233
|
if (result.output) console.log(result.output);
|
|
2181
2234
|
} catch (e) {
|
|
@@ -2316,6 +2369,8 @@ module.exports = {
|
|
|
2316
2369
|
_dispatchSessionBranch: dispatchSessionBranch,
|
|
2317
2370
|
// P-7a3c9e21 — harness-flag resolver exported for CLI-threading tests.
|
|
2318
2371
|
_resolveHarnessUsedForComment: resolveHarnessUsedForComment,
|
|
2372
|
+
// W-mr287sc0000uc214 — skipped-project-skill flag resolver exported for tests.
|
|
2373
|
+
_resolveSkillSkippedForComment: resolveSkillSkippedForComment,
|
|
2319
2374
|
// W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
|
|
2320
2375
|
_writeHeartbeatNow: writeHeartbeatNow,
|
|
2321
2376
|
_createHeartbeatInterval: createHeartbeatInterval,
|
package/engine/comment-format.js
CHANGED
|
@@ -36,6 +36,13 @@ const HARNESS_KINDS = [
|
|
|
36
36
|
const HARNESS_SUMMARY_ICON = '\u{1F9F0}'; // 🧰
|
|
37
37
|
const HARNESS_WARN_ICON = '⚠️'; // ⚠️
|
|
38
38
|
|
|
39
|
+
// W-mr287sc0000uc214 — the visible "skipped project skill" callout. The label is
|
|
40
|
+
// a stable literal so buildMinionsCommentBody can dedupe (never stack two
|
|
41
|
+
// callouts) and tests can assert on it. Clamps mirror the harness field caps.
|
|
42
|
+
const SKILL_SKIPPED_LABEL = '**Skipped project skill:**';
|
|
43
|
+
const SKILL_SKIPPED_MAX_NAME_LEN = 200;
|
|
44
|
+
const SKILL_SKIPPED_MAX_REASON_LEN = 1000;
|
|
45
|
+
|
|
39
46
|
// Canonical Minions site URL. shared-rules.md ("Brand link (MANDATORY)") requires
|
|
40
47
|
// the human-readable "Minions" mention in a PR/ADO comment to render as a markdown
|
|
41
48
|
// hyperlink to this destination. Keep this in sync with the literal in
|
|
@@ -119,6 +126,64 @@ function buildHarnessUsedSection(harnessUsed) {
|
|
|
119
126
|
+ `${lines.join('\n')}${legend}\n\n</details>`;
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Extract a canonical `{ name, reason }` skip record from a self-reported skip
|
|
131
|
+
* value. Accepts the raw `{ name, reason }` shape directly, or a `meta` wrapper
|
|
132
|
+
* that carries it — `meta.skill` (`{ skipped: { name, reason } }`) or
|
|
133
|
+
* `meta.review` (`{ skillSkipped: { name, reason } }`) — so callers can pass the
|
|
134
|
+
* completion-report block verbatim. Returns null when no non-empty `name` is
|
|
135
|
+
* present so callers treat "no skip" as a single falsy case.
|
|
136
|
+
* @param {*} input
|
|
137
|
+
* @returns {{name:string, reason:string}|null}
|
|
138
|
+
*/
|
|
139
|
+
function _extractSkillSkipped(input) {
|
|
140
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
141
|
+
let node = input;
|
|
142
|
+
if (input.skipped && typeof input.skipped === 'object' && !Array.isArray(input.skipped)) {
|
|
143
|
+
node = input.skipped; // meta.skill shape
|
|
144
|
+
} else if (input.skillSkipped && typeof input.skillSkipped === 'object' && !Array.isArray(input.skillSkipped)) {
|
|
145
|
+
node = input.skillSkipped; // meta.review shape
|
|
146
|
+
}
|
|
147
|
+
const name = typeof node.name === 'string' ? node.name.trim() : '';
|
|
148
|
+
if (!name) return null;
|
|
149
|
+
const reason = typeof node.reason === 'string' ? node.reason.trim() : '';
|
|
150
|
+
return { name, reason };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Render the "skipped project skill" self-report as a VISIBLE Markdown
|
|
155
|
+
* blockquote callout (W-mr287sc0000uc214), e.g.:
|
|
156
|
+
* > ⚠️ **Skipped project skill:** `FMF-review-context` — small 2-file diff…
|
|
157
|
+
*
|
|
158
|
+
* A review/fix agent may self-report that it intentionally skipped an available,
|
|
159
|
+
* in-scope project (review) skill via `meta.review.skillSkipped` /
|
|
160
|
+
* `meta.skill.skipped` (shape `{name, reason}`). That skip used to live ONLY in
|
|
161
|
+
* the completion-report JSON — invisible to a human reading the PR before merge.
|
|
162
|
+
* This is a sibling of buildHarnessUsedSection (same neutral chokepoint, same
|
|
163
|
+
* `_clean` hygiene) but a VISIBLE blockquote rather than a collapsed <details>:
|
|
164
|
+
* the whole point is that a human reviewing the PR sees the skip rationale in the
|
|
165
|
+
* same comment as the APPROVE / REQUEST_CHANGES verdict, before merge.
|
|
166
|
+
*
|
|
167
|
+
* Deliberately platform-NEUTRAL Markdown so the GitHub and ADO posters fold in a
|
|
168
|
+
* byte-identical callout — there is no second renderer to drift. Accepts either
|
|
169
|
+
* the raw `{name, reason}` record or a `meta.skill` / `meta.review` wrapper.
|
|
170
|
+
*
|
|
171
|
+
* Returns '' when the record is absent/malformed (no skip name), so callers can
|
|
172
|
+
* append it unconditionally without emitting a stray callout — reports without
|
|
173
|
+
* meta.review.skillSkipped / meta.skill.skipped render identically to today.
|
|
174
|
+
*
|
|
175
|
+
* @param {object|null} skillSkipped `{name, reason}` or a meta.skill/meta.review wrapper
|
|
176
|
+
* @returns {string} '' when empty, else a one-line `> ⚠️ …` blockquote
|
|
177
|
+
*/
|
|
178
|
+
function buildSkippedSkillSection(skillSkipped) {
|
|
179
|
+
const parsed = _extractSkillSkipped(skillSkipped);
|
|
180
|
+
if (!parsed) return '';
|
|
181
|
+
const name = _clean(parsed.name).slice(0, SKILL_SKIPPED_MAX_NAME_LEN);
|
|
182
|
+
const reasonClean = parsed.reason ? _clean(parsed.reason).slice(0, SKILL_SKIPPED_MAX_REASON_LEN) : '';
|
|
183
|
+
const tail = reasonClean ? ` — ${reasonClean}` : ''; // em dash
|
|
184
|
+
return `> ${HARNESS_WARN_ICON} ${SKILL_SKIPPED_LABEL} \`${name}\`${tail}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
122
187
|
// ── Minions marker + neutral comment-body builder ───────────────────────────
|
|
123
188
|
//
|
|
124
189
|
// The hidden HTML-comment marker and the body composition (marker + harness
|
|
@@ -188,15 +253,26 @@ function _buildMarker({ agentId, kind, workItemId }) {
|
|
|
188
253
|
* Idempotency: if `body` already starts with a minions marker it is returned
|
|
189
254
|
* with the harness/brand transforms applied but no second marker prepended.
|
|
190
255
|
*
|
|
191
|
-
* @param {{agentId:string, kind:string, workItemId?:string, body?:string, harnessUsed?:object}} args
|
|
256
|
+
* @param {{agentId:string, kind:string, workItemId?:string, body?:string, harnessUsed?:object, skillSkipped?:object}} args
|
|
192
257
|
* @returns {string} the final comment body
|
|
193
258
|
*/
|
|
194
|
-
function buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed }) {
|
|
259
|
+
function buildMinionsCommentBody({ agentId, kind, workItemId, body, harnessUsed, skillSkipped }) {
|
|
195
260
|
// Validate the inputs even when the body is pre-marked, so callers can't
|
|
196
261
|
// silently bypass validation by pre-marking their body.
|
|
197
262
|
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
198
263
|
let safeBody = body == null ? '' : String(body);
|
|
199
264
|
|
|
265
|
+
// Fold in the VISIBLE "skipped project skill" callout (W-mr287sc0000uc214)
|
|
266
|
+
// BEFORE the collapsed harness section so a human sees the skip rationale in
|
|
267
|
+
// the same comment as the verdict — not buried in a <details> or reachable
|
|
268
|
+
// only via a separate API call. Dedup on the stable label so a re-marked body
|
|
269
|
+
// already carrying the callout doesn't stack a second one. Absent/malformed
|
|
270
|
+
// skip → '' → no-op (byte-identical to today).
|
|
271
|
+
const skipSection = buildSkippedSkillSection(skillSkipped);
|
|
272
|
+
if (skipSection && !safeBody.includes(SKILL_SKIPPED_LABEL)) {
|
|
273
|
+
safeBody = safeBody ? `${safeBody}\n\n${skipSection}` : skipSection;
|
|
274
|
+
}
|
|
275
|
+
|
|
200
276
|
// Fold the grounded harness-usage digest in as a collapsible <details>
|
|
201
277
|
// section. Append at the END so it survives the marker-idempotency path;
|
|
202
278
|
// dedup on the summary line so a re-marked body that already carries the
|
|
@@ -232,6 +308,7 @@ function parseMinionsMarker(body) {
|
|
|
232
308
|
|
|
233
309
|
module.exports = {
|
|
234
310
|
buildHarnessUsedSection,
|
|
311
|
+
buildSkippedSkillSection,
|
|
235
312
|
linkifyBrandTrailer,
|
|
236
313
|
MINIONS_BRAND_URL,
|
|
237
314
|
// Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
|
|
@@ -247,4 +324,7 @@ module.exports = {
|
|
|
247
324
|
_validateMarkerInputs,
|
|
248
325
|
// Exported for tests / advanced callers that want to mirror the kind mapping.
|
|
249
326
|
HARNESS_KINDS,
|
|
327
|
+
// W-mr287sc0000uc214 — skipped-project-skill callout helpers.
|
|
328
|
+
_extractSkillSkipped,
|
|
329
|
+
SKILL_SKIPPED_LABEL,
|
|
250
330
|
};
|