@yemi33/minions 0.1.2122 → 0.1.2124

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.
@@ -14,6 +14,7 @@ const { trackEngineUsage } = require('./llm');
14
14
  const { resolveRuntime } = require('./runtimes');
15
15
  const adoGitAuth = require('./ado-git-auth');
16
16
  const queries = require('./queries');
17
+ const harness = require('./harness');
17
18
  const { isBranchActive } = require('./cooldown');
18
19
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
19
20
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
@@ -4040,6 +4041,82 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
4040
4041
  return 0;
4041
4042
  }
4042
4043
 
4044
+ /**
4045
+ * Tri-agent harness mode (W-mq07a9gf000jbc2b): when an evaluator completes,
4046
+ * parse its verdict against the configured rubric/threshold and — if the
4047
+ * artifact didn't pass and the iteration cap hasn't been hit — append a
4048
+ * fresh Generator+Evaluator pair so the harness can iterate on its own
4049
+ * artifact. Returns the number of work items appended (0 = terminal stop,
4050
+ * either pass or cap reached).
4051
+ *
4052
+ * Called from runPostCompletionHooks after a successful run when the
4053
+ * dispatched item carries _harness.role === 'evaluator'.
4054
+ */
4055
+ function handleHarnessIterationResult(stdout, structuredCompletion, meta, config) {
4056
+ const evaluatorItem = meta?.item;
4057
+ if (!evaluatorItem?._harness || evaluatorItem._harness.role !== harness.HARNESS_ROLE.EVALUATOR) return 0;
4058
+
4059
+ let verdict;
4060
+ try {
4061
+ verdict = harness.parseEvaluatorVerdict(stdout || '', structuredCompletion || null);
4062
+ } catch (err) {
4063
+ log('warn', `Harness ${evaluatorItem._harness.missionId}: verdict parse failed — ${err.message}; treating as terminal stop`);
4064
+ return 0;
4065
+ }
4066
+
4067
+ if (!harness.shouldIterateAgain(evaluatorItem._harness, verdict)) {
4068
+ const reason = verdict.pass === true ? 'passed' :
4069
+ (evaluatorItem._harness.iteration >= evaluatorItem._harness.maxIterations ? 'max iterations reached' :
4070
+ 'inconclusive verdict');
4071
+ log('info', `Harness mission ${evaluatorItem._harness.missionId} terminal stop (iteration ${evaluatorItem._harness.iteration}, ${reason}, score=${verdict.score ?? 'n/a'})`);
4072
+ return 0;
4073
+ }
4074
+
4075
+ let nextItems;
4076
+ try {
4077
+ nextItems = harness.createIterationWorkItems(evaluatorItem, verdict, {});
4078
+ } catch (err) {
4079
+ log('warn', `Harness ${evaluatorItem._harness.missionId}: iteration build failed — ${err.message}`);
4080
+ return 0;
4081
+ }
4082
+ if (!Array.isArray(nextItems) || nextItems.length === 0) return 0;
4083
+
4084
+ // Mirror handleDecompositionResult: scan central + per-project work-items.json
4085
+ // and append into the file that owns the evaluator (the trio always lands in
4086
+ // the central file in practice — scheduler.discoverScheduledWork writes
4087
+ // directly to engine/work-items.json via engine.js — but iterate defensively).
4088
+ const projects = shared.getProjects(config);
4089
+ const allPaths = [path.join(MINIONS_DIR, 'work-items.json')];
4090
+ for (const p of projects) allPaths.push(shared.projectWorkItemsPath(p));
4091
+
4092
+ let appendedTo = null;
4093
+ for (const wiPath of allPaths) {
4094
+ let found = false;
4095
+ mutateJsonFileLocked(wiPath, data => {
4096
+ if (!Array.isArray(data)) return data;
4097
+ const evaluator = data.find(i => i.id === evaluatorItem.id);
4098
+ if (!evaluator) return data;
4099
+ found = true;
4100
+ // De-dupe by id in case a previous tick already appended the next pair.
4101
+ const existingIds = new Set(data.map(i => i.id));
4102
+ for (const it of nextItems) {
4103
+ if (existingIds.has(it.id)) continue;
4104
+ data.push(it);
4105
+ }
4106
+ return data;
4107
+ }, { defaultValue: [] });
4108
+ if (found) { appendedTo = wiPath; break; }
4109
+ }
4110
+
4111
+ if (!appendedTo) {
4112
+ log('warn', `Harness ${evaluatorItem._harness.missionId}: evaluator ${evaluatorItem.id} not found in any work-items.json — iteration skipped`);
4113
+ return 0;
4114
+ }
4115
+
4116
+ log('info', `Harness mission ${evaluatorItem._harness.missionId} iterating: appended ${nextItems.length} work items (next iteration: ${nextItems[0]._harness.iteration}, score=${verdict.score ?? 'n/a'})`);
4117
+ return nextItems.length;
4118
+ }
4119
+
4043
4120
  /**
4044
4121
  * W-mpg58wv3 — auto-dispatch a re-review WI when a fix-WI born from a minion
4045
4122
  * REQUEST_CHANGES marks done. Closure-loop for the shared Yemi reviewer slot:
@@ -4386,6 +4463,19 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
4386
4463
  }
4387
4464
  }
4388
4465
 
4466
+ // Tri-agent harness iteration (W-mq07a9gf000jbc2b): if the evaluator just
4467
+ // completed successfully and verdict says retry, append the next Gen+Eval
4468
+ // pair into the same work-items.json. Engine will dispatch them on the
4469
+ // next tick. No interaction with skipDoneStatus — the evaluator itself
4470
+ // still marks DONE; iteration is a sibling write, not a parent decomp.
4471
+ if (effectiveSuccess && meta?.item?._harness?.role === harness.HARNESS_ROLE.EVALUATOR) {
4472
+ try {
4473
+ handleHarnessIterationResult(stdout, structuredCompletion, meta, config);
4474
+ } catch (err) {
4475
+ log('warn', `Harness iteration hook failed for ${meta.item.id}: ${err.message}`);
4476
+ }
4477
+ }
4478
+
4389
4479
  // Verify review work items include a verdict — must run BEFORE updateWorkItemStatus(DONE),
4390
4480
  // same pattern as plan-to-prd (#893): updateWorkItemStatus deletes _retryCount, so the check
4391
4481
  // must read/increment it before that happens. Also sets skipDoneStatus so completedAt isn't
@@ -5204,6 +5294,7 @@ module.exports = {
5204
5294
  isPrAttachmentRequired,
5205
5295
  extractDecompositionJson,
5206
5296
  handleDecompositionResult,
5297
+ handleHarnessIterationResult,
5207
5298
  processCompletionFollowups,
5208
5299
  // W-mpg58wv3 — closure-loop dispatch helpers (exported for testing).
5209
5300
  dispatchReReviewForFix,
@@ -25,7 +25,8 @@ const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
27
  const routing = require('./routing');
28
- const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
28
+ const harness = require('./harness');
29
+ const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, log, WI_STATUS, WORK_TYPE } = shared;
29
30
 
30
31
  const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
31
32
 
@@ -186,9 +187,9 @@ function createScheduledWorkItem(sched) {
186
187
  };
187
188
  }
188
189
 
189
- function writeScheduleRunEntry(runs, scheduleId, workItemId) {
190
+ function writeScheduleRunEntry(runs, scheduleId, workItemId, extra) {
190
191
  const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
191
- runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId };
192
+ runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId, ...(extra || {}) };
192
193
  return runs[scheduleId];
193
194
  }
194
195
 
@@ -222,6 +223,42 @@ function discoverScheduledWork(config) {
222
223
  const lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || null);
223
224
  if (!shouldRunNow(sched, lastRun)) continue;
224
225
 
226
+ // Tri-agent harness mode (W-mq07a9gf000jbc2b): a single schedule firing
227
+ // produces a coordinated Planner → Generator → Evaluator trio rather than
228
+ // a single work item. Validate config first — on bad config, skip this
229
+ // tick WITHOUT recording a schedule run so the operator can fix the
230
+ // config and the next tick will pick it up.
231
+ if (sched.harness_mode === harness.HARNESS_MODE.TRI_AGENT) {
232
+ const validation = harness.validateHarnessConfig(sched);
233
+ if (!validation.valid) {
234
+ log('warn', `Scheduler: harness config invalid for ${sched.id} — skipping (errors: ${validation.errors.join('; ')})`);
235
+ continue;
236
+ }
237
+ try {
238
+ // Resolve schedule-time template variables on the title/description
239
+ // BEFORE handing the schedule to the harness builder so subtask
240
+ // prompts inherit the same substitutions as regular schedules.
241
+ const resolvedSched = {
242
+ ...sched,
243
+ title: resolveScheduleTemplateVars(sched.title),
244
+ description: resolveScheduleTemplateVars(sched.description || sched.title),
245
+ harness_rubric: resolveScheduleTemplateVars(sched.harness_rubric),
246
+ };
247
+ const mission = harness.createTriAgentMission(resolvedSched);
248
+ for (const it of mission.items) work.push(it);
249
+ // Record the mission's planner id as lastWorkItemId for compatibility
250
+ // with the existing schedule-runs shape, plus lastMissionId so the
251
+ // dashboard and consolidation tooling can join across the trio.
252
+ writeScheduleRunEntry(runs, sched.id, mission.items[0].id, {
253
+ lastMissionId: mission.missionId,
254
+ harnessMode: harness.HARNESS_MODE.TRI_AGENT,
255
+ });
256
+ } catch (err) {
257
+ log('warn', `Scheduler: tri-agent mission build failed for ${sched.id}: ${err.message}`);
258
+ }
259
+ continue;
260
+ }
261
+
225
262
  // Substitute schedule-time template vars (e.g. {{date}}) before the work
226
263
  // item is written — single-pass playbook rendering can't reach placeholders
227
264
  // embedded inside task_description, so they must be resolved up front.
package/engine/shared.js CHANGED
@@ -3503,6 +3503,9 @@ const FAILURE_CLASS = {
3503
3503
  MANAGED_SPAWN_HEALTHCHECK_FAILED: 'managed-spawn-healthcheck-failed', // P-7a3b1c92: at least one managed-spawn spec was spawned but failed its healthcheck within timeout_s. Engine killed the failing PIDs; siblings stay alive. Dispatch ERROR with the failing spec name + log tail surfaced in the inbox alert.
3504
3504
  INJECTION_FLAGGED: 'injection-flagged', // F5 (W-mpeklod3000we69c): the agent set `securityFlags.injectionAttempt:true` in its completion report after spotting a prompt-injection attempt inside an <UNTRUSTED-INPUT> fence. Engine writes a security inbox note + stamps `_securityFlag` on the WI and treats the dispatch as non-retryable so a human can review the source before the agent re-runs.
3505
3505
  MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
3506
+ WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
3507
+ WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
3508
+ WORKSPACE_MANIFEST_URL: 'workspace-manifest-url-forbidden', // W-mq07avbk000m5543: out-of-scope external URL fetch. Non-retryable as-is.
3506
3509
  UNKNOWN: 'unknown', // Unclassified failure
3507
3510
  };
3508
3511
  const ESCALATION_POLICY = {
@@ -3594,6 +3597,243 @@ function pruneDefaultClaudeConfig(config) {
3594
3597
  return changed;
3595
3598
  }
3596
3599
 
3600
+ // ── Workspace Manifest (W-mq07avbk000m5543) ──────────────────────────────────
3601
+ //
3602
+ // Per-agent declarative permission manifest, inspired by OpenAI's AGENTS.md /
3603
+ // Workspace Manifest primitive (April 2026). Lives on each agent definition
3604
+ // under `workspace_manifest`:
3605
+ //
3606
+ // {
3607
+ // allowed_tools: string[] | null, // tool-name whitelist; null = no restriction
3608
+ // allowed_repos: string[] | null, // canonical PR-scope or owner/repo allow-list; null = no restriction
3609
+ // allowed_external_urls: string[] | null, // host allow-list (apex or *.subdomain); null = no restriction
3610
+ // memory_scope: 'private' | 'shared' | 'read-only-shared' // default 'shared'
3611
+ // }
3612
+ //
3613
+ // Backward compat: any null/missing field is permissive — a config with no
3614
+ // manifest behaves exactly as the pre-manifest engine did. Enforcement is
3615
+ // performed at three points today:
3616
+ //
3617
+ // 1. Dispatch time (engine.js spawnAgent): `agentCanUseRepo()` is checked
3618
+ // against the resolved project; mismatch fails with FAILURE_CLASS.WORKSPACE_MANIFEST_REPO.
3619
+ // 2. Spawn flags (engine.js spawnAgent): `mergeManifestAllowedTools()` narrows
3620
+ // the runtime adapter's `--allowedTools` flag so the CLI itself enforces it.
3621
+ // 3. Documentation/agent context (playbook.js): the manifest is surfaced in the
3622
+ // agent prompt so the agent knows its scope. URL + memory_scope enforcement
3623
+ // remains advisory pending future runtime intercepts.
3624
+
3625
+ const MEMORY_SCOPES = ['private', 'shared', 'read-only-shared'];
3626
+
3627
+ const WORKSPACE_MANIFEST_DEFAULTS = {
3628
+ allowed_tools: null,
3629
+ allowed_repos: null,
3630
+ allowed_external_urls: null,
3631
+ memory_scope: 'shared',
3632
+ };
3633
+
3634
+ function _isStringArrayOrNull(value, fieldName, errors) {
3635
+ if (value == null) return;
3636
+ if (!Array.isArray(value)) {
3637
+ errors.push(`workspace_manifest.${fieldName} must be an array of strings (got ${typeof value})`);
3638
+ return;
3639
+ }
3640
+ for (let i = 0; i < value.length; i++) {
3641
+ if (typeof value[i] !== 'string' || value[i].length === 0) {
3642
+ errors.push(`workspace_manifest.${fieldName}[${i}] must be a non-empty string`);
3643
+ }
3644
+ }
3645
+ }
3646
+
3647
+ /**
3648
+ * Validate a workspace_manifest object. Returns `{ ok, errors }`.
3649
+ * A `null`/`undefined` manifest is valid (it falls through to defaults).
3650
+ */
3651
+ function validateWorkspaceManifest(manifest) {
3652
+ const errors = [];
3653
+ if (manifest == null) return { ok: true, errors };
3654
+ if (typeof manifest !== 'object' || Array.isArray(manifest)) {
3655
+ return { ok: false, errors: ['workspace_manifest must be an object'] };
3656
+ }
3657
+ _isStringArrayOrNull(manifest.allowed_tools, 'allowed_tools', errors);
3658
+ _isStringArrayOrNull(manifest.allowed_repos, 'allowed_repos', errors);
3659
+ _isStringArrayOrNull(manifest.allowed_external_urls, 'allowed_external_urls', errors);
3660
+ if (manifest.memory_scope != null && !MEMORY_SCOPES.includes(manifest.memory_scope)) {
3661
+ errors.push(`workspace_manifest.memory_scope must be one of ${MEMORY_SCOPES.join('|')} (got "${manifest.memory_scope}")`);
3662
+ }
3663
+ return { ok: errors.length === 0, errors };
3664
+ }
3665
+
3666
+ /**
3667
+ * Resolve an agent's effective workspace manifest by merging the per-agent
3668
+ * value (if any) over `WORKSPACE_MANIFEST_DEFAULTS`. Returns a fresh copy on
3669
+ * every call so callers can mutate the result without leaking changes back
3670
+ * into the shared default reference.
3671
+ *
3672
+ * A malformed `agent.workspace_manifest` (non-object) silently falls back to
3673
+ * defaults — the engine logs a warning at config-load time via
3674
+ * `validateWorkspaceManifest`, so a strict reject here would just turn a
3675
+ * documented config error into a runtime crash.
3676
+ */
3677
+ function resolveAgentManifest(agent, _config) {
3678
+ const out = { ...WORKSPACE_MANIFEST_DEFAULTS };
3679
+ if (!agent || typeof agent !== 'object') return out;
3680
+ const m = agent.workspace_manifest;
3681
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return out;
3682
+ if (Array.isArray(m.allowed_tools)) out.allowed_tools = m.allowed_tools.slice();
3683
+ if (Array.isArray(m.allowed_repos)) out.allowed_repos = m.allowed_repos.slice();
3684
+ if (Array.isArray(m.allowed_external_urls)) out.allowed_external_urls = m.allowed_external_urls.slice();
3685
+ if (typeof m.memory_scope === 'string' && MEMORY_SCOPES.includes(m.memory_scope)) {
3686
+ out.memory_scope = m.memory_scope;
3687
+ }
3688
+ return out;
3689
+ }
3690
+
3691
+ function _repoTargetToScopeCandidates(target) {
3692
+ // Accepts a project object or a string identifier. Returns an array of
3693
+ // lowercased canonical/shorthand strings to match against the allow-list.
3694
+ if (!target) return [];
3695
+ if (typeof target === 'string') {
3696
+ const raw = target.trim();
3697
+ if (!raw) return [];
3698
+ const lower = raw.toLowerCase();
3699
+ const candidates = new Set([lower]);
3700
+ // Strip host prefix to surface the bare owner/repo form too.
3701
+ const m = lower.match(/^(github|ado):(.+)$/);
3702
+ if (m) candidates.add(m[2]);
3703
+ return [...candidates];
3704
+ }
3705
+ if (typeof target === 'object') {
3706
+ try {
3707
+ const scope = getProjectPrScope(target);
3708
+ if (scope) {
3709
+ const lower = scope.toLowerCase();
3710
+ const candidates = new Set([lower]);
3711
+ const m = lower.match(/^(github|ado):(.+)$/);
3712
+ if (m) candidates.add(m[2]);
3713
+ return [...candidates];
3714
+ }
3715
+ } catch { /* defensive: malformed project — fall through */ }
3716
+ }
3717
+ return [];
3718
+ }
3719
+
3720
+ /**
3721
+ * Check whether `agent` (with its workspace_manifest) is allowed to operate
3722
+ * against `repoTarget`. `repoTarget` may be a project object or a string
3723
+ * (canonical `github:owner/repo` / `ado:org/proj/repo` or bare `owner/repo`).
3724
+ *
3725
+ * - `null`/missing manifest or `allowed_repos: null` → permissive (returns true).
3726
+ * - Empty allow-list (`[]`) → denies everything (deliberate lockdown).
3727
+ * - Empty/null target → returns false (cannot scope a target with no identity).
3728
+ * - Comparison is case-insensitive; bare `owner/repo` in the manifest matches
3729
+ * both the host-prefixed canonical form and the bare form on the target.
3730
+ */
3731
+ function agentCanUseRepo(agent, repoTarget) {
3732
+ const manifest = resolveAgentManifest(agent);
3733
+ if (manifest.allowed_repos == null) return true;
3734
+ const candidates = _repoTargetToScopeCandidates(repoTarget);
3735
+ if (candidates.length === 0) return false;
3736
+ const allowed = manifest.allowed_repos.map(r => String(r).trim().toLowerCase()).filter(Boolean);
3737
+ if (allowed.length === 0) return false;
3738
+ // Build the same candidate set for each allow-list entry so bare owner/repo
3739
+ // matches host-prefixed forms and vice versa.
3740
+ for (const entry of allowed) {
3741
+ const entryCandidates = _repoTargetToScopeCandidates(entry);
3742
+ if (entryCandidates.some(e => candidates.includes(e))) return true;
3743
+ }
3744
+ return false;
3745
+ }
3746
+
3747
+ /**
3748
+ * Check whether `agent` may invoke `toolName`. Case-sensitive comparison —
3749
+ * tool names are canonical (e.g. `Edit`, `Read`, `Bash`).
3750
+ *
3751
+ * Null/missing `allowed_tools` → permissive.
3752
+ * Empty array → denies everything.
3753
+ */
3754
+ function agentCanUseTool(agent, toolName) {
3755
+ const manifest = resolveAgentManifest(agent);
3756
+ if (manifest.allowed_tools == null) return true;
3757
+ if (typeof toolName !== 'string' || toolName.length === 0) return false;
3758
+ return manifest.allowed_tools.includes(toolName);
3759
+ }
3760
+
3761
+ /**
3762
+ * Check whether `agent` may fetch `urlString`. Wildcard entries of the form
3763
+ * `*.example.com` match any subdomain AND the apex (`example.com`). Bare
3764
+ * `example.com` matches only the exact host.
3765
+ *
3766
+ * Null/missing `allowed_external_urls` → permissive.
3767
+ * Empty array → denies everything.
3768
+ * Malformed URL → denied (cannot extract host to compare).
3769
+ */
3770
+ function agentCanFetchUrl(agent, urlString) {
3771
+ const manifest = resolveAgentManifest(agent);
3772
+ if (manifest.allowed_external_urls == null) return true;
3773
+ let host = '';
3774
+ try {
3775
+ host = new URL(String(urlString)).hostname.toLowerCase();
3776
+ } catch { return false; }
3777
+ if (!host) return false;
3778
+ if (manifest.allowed_external_urls.length === 0) return false;
3779
+ for (const raw of manifest.allowed_external_urls) {
3780
+ const entry = String(raw).trim().toLowerCase();
3781
+ if (!entry) continue;
3782
+ if (entry.startsWith('*.')) {
3783
+ const suffix = entry.slice(2);
3784
+ if (host === suffix || host.endsWith('.' + suffix)) return true;
3785
+ } else if (host === entry) {
3786
+ return true;
3787
+ }
3788
+ }
3789
+ return false;
3790
+ }
3791
+
3792
+ /** Return the agent's effective memory scope. Unknown values fall back to 'shared'. */
3793
+ function agentMemoryScope(agent) {
3794
+ const manifest = resolveAgentManifest(agent);
3795
+ return MEMORY_SCOPES.includes(manifest.memory_scope) ? manifest.memory_scope : 'shared';
3796
+ }
3797
+
3798
+ /**
3799
+ * Merge a manifest's `allowed_tools` list into the runtime adapter's existing
3800
+ * `allowedTools` CSV baseline. Returns the merged CSV string the runtime
3801
+ * adapter expects (Claude/Copilot/Codex all accept `--allowedTools <csv>`).
3802
+ *
3803
+ * Semantics:
3804
+ * - `manifestTools == null` → baseline unchanged (manifest unrestricted).
3805
+ * - `manifestTools == []` → empty string (deliberate deny-all lockdown).
3806
+ * - `baseline` empty + `manifestTools` non-empty → manifest becomes the new ceiling.
3807
+ * - Otherwise → intersection (manifest narrows baseline); order follows manifest.
3808
+ */
3809
+ function mergeManifestAllowedTools(baseline, manifestTools) {
3810
+ if (manifestTools == null) return baseline == null ? '' : baseline;
3811
+ if (!Array.isArray(manifestTools)) return baseline == null ? '' : baseline;
3812
+ if (manifestTools.length === 0) return '';
3813
+ const baselineSet = (() => {
3814
+ if (baseline == null || baseline === '') return null; // no baseline = no ceiling
3815
+ const parts = String(baseline).split(',').map(s => s.trim()).filter(Boolean);
3816
+ return new Set(parts);
3817
+ })();
3818
+ if (baselineSet == null) return manifestTools.filter(Boolean).join(',');
3819
+ return manifestTools.filter(t => typeof t === 'string' && baselineSet.has(t)).join(',');
3820
+ }
3821
+
3822
+ /**
3823
+ * Build a human-readable rejection message describing an out-of-scope tool/repo/url
3824
+ * call against an agent's manifest. Used by the dispatch repo gate (engine.js)
3825
+ * and surfaced into the dispatch failure record + inbox alert.
3826
+ */
3827
+ function formatManifestRejection({ agentId, kind, target, allowed } = {}) {
3828
+ const agent = agentId || '<unknown-agent>';
3829
+ const k = kind || 'tool';
3830
+ const t = (typeof target === 'string' ? target : JSON.stringify(target)) || '<unknown>';
3831
+ const allowList = Array.isArray(allowed) && allowed.length > 0
3832
+ ? allowed.join(', ')
3833
+ : '(none)';
3834
+ return `workspace_manifest rejection — agent "${agent}" attempted ${k} "${t}" but its workspace_manifest.allowed_${k}s does not include it. Allowed: ${allowList}.`;
3835
+ }
3836
+
3597
3837
  // ── Project Helpers ──────────────────────────────────────────────────────────
3598
3838
 
3599
3839
  function getProjects(config) {
@@ -5926,13 +6166,29 @@ function getPinnedItems() {
5926
6166
  // Generic rate-limit tracker reusable by both ADO and GitHub integrations.
5927
6167
  // Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
5928
6168
 
5929
- function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32 * 60000 } = {}) {
6169
+ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32 * 60000, jitterRatio = 0.2 } = {}) {
6170
+ // Clamp jitterRatio to [0, 0.5]. Out-of-range or non-finite inputs warn and clamp.
6171
+ // jitter is dither on the SCHEDULED WAKE only — the deterministic 60s→32min
6172
+ // ladder is unchanged. Server Retry-After remains the FLOOR; jitter extends
6173
+ // upward only (multiplier in [1, 1 + jitterRatio]), never shortens.
6174
+ let _jitterRatio = jitterRatio;
6175
+ if (typeof _jitterRatio !== 'number' || !Number.isFinite(_jitterRatio) || _jitterRatio < 0 || _jitterRatio > 0.5) {
6176
+ const clamped = (typeof _jitterRatio === 'number' && Number.isFinite(_jitterRatio))
6177
+ ? Math.max(0, Math.min(0.5, _jitterRatio))
6178
+ : 0.2;
6179
+ log('warn', `[${label}] createThrottleTracker: invalid jitterRatio ${_jitterRatio}, clamped to ${clamped}`);
6180
+ _jitterRatio = clamped;
6181
+ }
6182
+
5930
6183
  let state = { throttled: false, retryAfter: 0, consecutiveHits: 0, backoffMs: baseBackoffMs };
5931
6184
 
5932
6185
  function recordThrottle(retryAfterMs) {
5933
6186
  state.consecutiveHits++;
5934
6187
  state.backoffMs = Math.min(state.backoffMs * 2, maxBackoffMs);
5935
- const waitMs = (retryAfterMs > 0) ? retryAfterMs : state.backoffMs;
6188
+ const baseWaitMs = (retryAfterMs > 0) ? retryAfterMs : state.backoffMs;
6189
+ // Apply jitter on top of the base wake. Multiplier is in [1, 1 + jitterRatio]
6190
+ // so the wake is jittered upward only — server Retry-After stays a floor.
6191
+ const waitMs = baseWaitMs * (1 + (Math.random() * _jitterRatio));
5936
6192
  state.throttled = true;
5937
6193
  state.retryAfter = Date.now() + waitMs;
5938
6194
  // Throttle retries are deterministic backoff math — info, not warn.
@@ -6087,6 +6343,17 @@ module.exports = {
6087
6343
  DEFAULT_AGENTS,
6088
6344
  DEFAULT_CLAUDE,
6089
6345
  pruneDefaultClaudeConfig,
6346
+ // Workspace manifest (W-mq07avbk000m5543)
6347
+ MEMORY_SCOPES,
6348
+ WORKSPACE_MANIFEST_DEFAULTS,
6349
+ validateWorkspaceManifest,
6350
+ resolveAgentManifest,
6351
+ agentCanUseRepo,
6352
+ agentCanUseTool,
6353
+ agentCanFetchUrl,
6354
+ agentMemoryScope,
6355
+ mergeManifestAllowedTools,
6356
+ formatManifestRejection,
6090
6357
  getProjects,
6091
6358
  formatUnknownProjectError,
6092
6359
  findProjectByName,
package/engine.js CHANGED
@@ -1232,6 +1232,37 @@ async function spawnAgent(dispatchItem, config) {
1232
1232
  }
1233
1233
  const metaProjectFields = metaProject && typeof metaProject === 'object' ? metaProject : {};
1234
1234
  const project = projectResolution.project ? { ...projectResolution.project, ...metaProjectFields } : {};
1235
+
1236
+ // ── Workspace manifest repo gate (W-mq07avbk000m5543) ────────────────────
1237
+ // Dispatch-time enforcement of `agents.<id>.workspace_manifest.allowed_repos`.
1238
+ // Built-in agents and any agent without a manifest are permissive (returns
1239
+ // true). Temp agents (temp-*) don't appear in config.agents so they fall
1240
+ // through to permissive as well. Mismatch fails non-retryably because the
1241
+ // structural answer is "widen the manifest or pick a different agent" —
1242
+ // re-spawning the same agent against the same project would deterministically
1243
+ // re-trip the gate.
1244
+ const _manifestAgent = (config.agents && config.agents[agentId]) || null;
1245
+ if (project && project.name && !shared.agentCanUseRepo(_manifestAgent, project)) {
1246
+ const _manifest = shared.resolveAgentManifest(_manifestAgent);
1247
+ const _scope = (() => { try { return shared.getProjectPrScope(project) || project.name; } catch { return project.name; } })();
1248
+ const msg = shared.formatManifestRejection({
1249
+ agentId,
1250
+ kind: 'repo',
1251
+ target: _scope,
1252
+ allowed: _manifest.allowed_repos,
1253
+ });
1254
+ log('warn', `spawnAgent: workspace-manifest repo gate denied ${agentId} → ${_scope} for ${id}`);
1255
+ completeDispatch(
1256
+ id,
1257
+ DISPATCH_RESULT.ERROR,
1258
+ msg.slice(0, 800),
1259
+ 'workspace_manifest.allowed_repos blocks this agent from this repo. Widen the manifest or route the work to a different agent.',
1260
+ { failureClass: FAILURE_CLASS.WORKSPACE_MANIFEST_REPO, agentRetryable: false },
1261
+ );
1262
+ cleanupTempAgent(agentId);
1263
+ return null;
1264
+ }
1265
+
1235
1266
  // W-mp73x32w000l143d: decouple agent cwd from worktree placement.
1236
1267
  // resolveSpawnPaths returns:
1237
1268
  // - read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
@@ -2405,7 +2436,10 @@ async function spawnAgent(dispatchItem, config) {
2405
2436
  const args = _buildAgentSpawnFlags(runtime, {
2406
2437
  model: effectiveModel,
2407
2438
  maxTurns: _maxTurnsForType(type, engineConfig),
2408
- allowedTools: claudeConfig.allowedTools,
2439
+ allowedTools: shared.mergeManifestAllowedTools(
2440
+ claudeConfig.allowedTools,
2441
+ shared.resolveAgentManifest(_manifestAgent).allowed_tools,
2442
+ ),
2409
2443
  effort: requestedEffort,
2410
2444
  sessionId: cachedSessionId,
2411
2445
  maxBudget: resolvedMaxBudget,
@@ -2775,7 +2809,10 @@ async function spawnAgent(dispatchItem, config) {
2775
2809
  const resumeArgs = _buildAgentSpawnFlags(runtime, {
2776
2810
  model: effectiveModel,
2777
2811
  maxTurns: engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns,
2778
- allowedTools: claudeConfig?.allowedTools,
2812
+ allowedTools: shared.mergeManifestAllowedTools(
2813
+ claudeConfig?.allowedTools,
2814
+ shared.resolveAgentManifest(_manifestAgent).allowed_tools,
2815
+ ),
2779
2816
  sessionId: steerSessionId,
2780
2817
  maxBudget: resolvedMaxBudget,
2781
2818
  bare: resolvedBare,
@@ -3944,7 +3981,7 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
3944
3981
  // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
3945
3982
 
3946
3983
  const { consolidateInbox } = require('./engine/consolidation');
3947
- const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
3984
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled, getAdoThrottleStateAll } = require('./engine/ado');
3948
3985
  const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/github');
3949
3986
 
3950
3987
  // ─── State Snapshot ─────────────────────────────────────────────────────────
@@ -6878,12 +6915,35 @@ async function discoverWork(config) {
6878
6915
  mutateJsonFileLocked(centralPath, (items) => {
6879
6916
  if (!Array.isArray(items)) items = [];
6880
6917
  let added = 0;
6918
+ // Snapshot active dedup keys BEFORE the loop so multiple items in the
6919
+ // same harness mission (same _missionId) all land in one tick. Without
6920
+ // this snapshot, the first item's push would block subsequent items
6921
+ // in the same mission from joining (W-mq07a9gf000jbc2b — tri-agent
6922
+ // harness mode requires Planner+Generator+Evaluator to land together).
6923
+ const activeMissionIds = new Set();
6924
+ const activeScheduleIds = new Set();
6925
+ for (const existing of items) {
6926
+ if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
6927
+ if (existing._missionId) activeMissionIds.add(existing._missionId);
6928
+ if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
6929
+ }
6930
+ const addedScheduleIdsThisTick = new Set();
6881
6931
  for (const item of taskItems) {
6882
- if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED)) {
6883
- items.push(item);
6884
- added++;
6885
- log('info', `Scheduled task fired: ${item._scheduleId} ${item.title}`);
6932
+ // Mission items dedup by _missionId against pre-existing rows only
6933
+ // (the trio's other items added later in this loop must not block
6934
+ // each other). Plain scheduled items keep the original scheduleId
6935
+ // dedup AND skip if a sibling item from the same tick already
6936
+ // claimed the schedule slot.
6937
+ if (item._missionId) {
6938
+ if (activeMissionIds.has(item._missionId)) continue;
6939
+ } else {
6940
+ if (activeScheduleIds.has(item._scheduleId)) continue;
6941
+ if (addedScheduleIdsThisTick.has(item._scheduleId)) continue;
6886
6942
  }
6943
+ items.push(item);
6944
+ if (!item._missionId && item._scheduleId) addedScheduleIdsThisTick.add(item._scheduleId);
6945
+ added++;
6946
+ log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
6887
6947
  }
6888
6948
  return items;
6889
6949
  }, { defaultValue: [] });
@@ -7349,10 +7409,18 @@ async function tickInner() {
7349
7409
  lastPrStatusPollAt = now;
7350
7410
  // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
7351
7411
  const statusPolls = [];
7352
- if (adoPollEnabled && !isAdoThrottled()) {
7353
- statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
7354
- } else if (adoPollEnabled && isAdoThrottled()) {
7355
- log('info', '[ado] PR status poll skipped throttled');
7412
+ if (adoPollEnabled) {
7413
+ // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
7414
+ // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
7415
+ // with one log line to avoid the per-project iteration cost.
7416
+ const adoThrottleStates = getAdoThrottleStateAll() || {};
7417
+ const adoOrgCount = Object.keys(adoThrottleStates).length;
7418
+ const allAdoThrottled = adoOrgCount > 0 && Object.values(adoThrottleStates).every(s => s && s.throttled);
7419
+ if (allAdoThrottled) {
7420
+ log('info', `[ado] PR status poll skipped — all ${adoOrgCount} known orgs throttled`);
7421
+ } else {
7422
+ statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
7423
+ }
7356
7424
  }
7357
7425
  if (ghPollEnabled && !isGhThrottled()) {
7358
7426
  statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
@@ -7395,10 +7463,18 @@ async function tickInner() {
7395
7463
  lastPrCommentsPollAt = now;
7396
7464
  // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
7397
7465
  const commentPolls = [];
7398
- if (adoPollEnabled && !isAdoThrottled()) {
7399
- commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
7400
- } else if (adoPollEnabled && isAdoThrottled()) {
7401
- log('info', '[ado] PR comment poll skipped throttled');
7466
+ if (adoPollEnabled) {
7467
+ // Per-org throttle skip happens inside forEachActivePr (one log line per skipped project).
7468
+ // Top-level short-circuit: when every known ADO org is throttled, skip the whole phase
7469
+ // with one log line to avoid the per-project iteration cost.
7470
+ const adoThrottleStates = getAdoThrottleStateAll() || {};
7471
+ const adoOrgCount = Object.keys(adoThrottleStates).length;
7472
+ const allAdoThrottled = adoOrgCount > 0 && Object.values(adoThrottleStates).every(s => s && s.throttled);
7473
+ if (allAdoThrottled) {
7474
+ log('info', `[ado] PR comment poll skipped — all ${adoOrgCount} known orgs throttled`);
7475
+ } else {
7476
+ commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
7477
+ }
7402
7478
  }
7403
7479
  if (ghPollEnabled && !isGhThrottled()) {
7404
7480
  commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2122",
3
+ "version": "0.1.2124",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"