@yemi33/minions 0.1.2123 → 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.
@@ -146,7 +146,7 @@ const RENDER_VERSIONS = {
146
146
  dispatch: 2,
147
147
  engineLog: 2,
148
148
  metrics: 1,
149
- workItems: 1,
149
+ workItems: 2,
150
150
  skills: 1,
151
151
  mcpServers: 1,
152
152
  schedules: 1,
@@ -15,8 +15,71 @@ const _WI_ENRICHMENT_FIELDS = [
15
15
  '_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
16
16
  '_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
17
17
  '_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
18
+ // W-mq08kuog001110a6 — _preDispatchEval IS persisted to disk by
19
+ // engine/dispatch.js#_persistInvalidWorkItem, but the slim /api/status
20
+ // overlay may drop it on later passes. Carry it across the overlay so
21
+ // the Needs-Attention badge survives /state polls.
22
+ '_preDispatchEval',
18
23
  ];
19
24
 
25
+ // W-mq08kuog001110a6 — Needs-Attention badge for stuck pending work items.
26
+ //
27
+ // A pending WI is "needs attention" when the engine is intentionally refusing
28
+ // to dispatch it and a human (or follow-up dispatch) has to unblock it.
29
+ // Surfaces existing engine state (_preDispatchEval / _pendingReason) — no
30
+ // new fields, no dispatcher behavior change.
31
+ //
32
+ // Triggers:
33
+ // * _preDispatchEval.valid === false (pre-dispatch acceptance gate rejected)
34
+ // * _pendingReason in { pr_not_found, no_agent, budget_exceeded, dependency_unmet }
35
+ //
36
+ // Does NOT trigger for transient reasons that auto-clear:
37
+ // * cooldown / retry_cooldown — wait it out
38
+ // * already_dispatched — engine reconciles on next tick
39
+ // * branch_locked — wait for the holding dispatch
40
+ //
41
+ // Returns { kind, short, full } when the item needs attention, else null.
42
+ // Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
43
+ function needsAttentionInfo(item) {
44
+ if (!item || item.status !== 'pending') return null;
45
+
46
+ if (item._preDispatchEval && item._preDispatchEval.valid === false) {
47
+ var rawReason = String(item._preDispatchEval.reason || '(no reason recorded)');
48
+ var firstLine = rawReason.split(/\r?\n/, 1)[0] || rawReason;
49
+ return {
50
+ kind: 'pre_dispatch_eval',
51
+ short: 'Pre-dispatch evaluation rejected: ' + firstLine,
52
+ full: rawReason,
53
+ };
54
+ }
55
+
56
+ var reason = item._pendingReason;
57
+ if (!reason) return null;
58
+
59
+ if (reason === 'pr_not_found') {
60
+ var prRef = item.prNumber || item.targetPr || item.pr_id || '?';
61
+ var prMsg = 'Target PR not tracked: PR #' + prRef + '. The PR was likely merged/closed or never linked.';
62
+ return { kind: 'pr_not_found', short: prMsg, full: prMsg };
63
+ }
64
+ if (reason === 'no_agent') {
65
+ var noAgentMsg = 'No agent assigned and routing.md has no match for type=' + (item.type || 'unknown') + '.';
66
+ return { kind: 'no_agent', short: noAgentMsg, full: noAgentMsg };
67
+ }
68
+ if (reason === 'budget_exceeded') {
69
+ var agent = item.agent || item.dispatched_to || '<unknown>';
70
+ var budgetMsg = 'Assigned agent ' + agent + ' is over its maxBudgetUsd cap.';
71
+ return { kind: 'budget_exceeded', short: budgetMsg, full: budgetMsg };
72
+ }
73
+ if (reason === 'dependency_unmet') {
74
+ var deps = Array.isArray(item.depends_on) && item.depends_on.length
75
+ ? item.depends_on.join(', ')
76
+ : '<unknown>';
77
+ var depMsg = 'Waiting on dependencies: ' + deps + '. (One or more are not yet done.)';
78
+ return { kind: 'dependency_unmet', short: depMsg, full: depMsg };
79
+ }
80
+ return null;
81
+ }
82
+
20
83
  // Pull each project's work-items.json straight off disk through
21
84
  // /state/projects/<name>/work-items.json. The endpoint is a static-file
22
85
  // passthrough with mtime+size ETag — there is no server-side cache in the
@@ -129,6 +192,15 @@ function wiRow(item) {
129
192
  '<td>' + priBadge(item.priority) + '</td>' +
130
193
  '<td>' + statusBadge(item.status || 'pending') +
131
194
  (item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') +
195
+ (function() {
196
+ // W-mq08kuog001110a6 — Needs-Attention badge for stuck pending WIs.
197
+ // Click anywhere on the row to open the modal where the full reason
198
+ // is rendered under "Why this is blocked".
199
+ var info = needsAttentionInfo(item);
200
+ if (!info) return '';
201
+ var tooltip = (info.short || '').replace(/\s+/g, ' ').slice(0, 120);
202
+ return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="' + escapeHtml(tooltip) + ' — click row for full reason">&#x26A0; Needs attention</span>';
203
+ })() +
132
204
  (item._pendingReason && item.status === 'pending' && item._pendingReason !== 'already_dispatched' ? ' <span style="font-size:var(--text-xs);color:var(--muted);margin-left:4px" title="Pending reason: ' + escapeHtml(item._pendingReason) + '">' + escapeHtml(item._pendingReason.replace(/_/g, ' ')) + '</span>' : '') +
133
205
  (item._pendingReason === 'already_dispatched' && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--blue);margin-left:4px" title="In dispatch queue, waiting to be assigned">queued</span>' : '') +
134
206
  (item._managedSpawnPartial && Array.isArray(item._managedSpawnPartial.failed) && item._managedSpawnPartial.failed.length
@@ -597,7 +669,28 @@ function _wiRenderDetail(item) {
597
669
  (item._reopened ? ' <span style="font-size:var(--text-xs);color:var(--purple);margin-left:4px" title="This item was reopened from a previously completed state">reopened</span>' : '') + ' ' +
598
670
  '<span class="dispatch-type ' + (item.type || 'implement') + '">' + escapeHtml(item.type || 'implement') + '</span>' +
599
671
  '<span class="prd-item-priority ' + (item.priority || '') + '">' + escapeHtml(item.priority || 'medium') + '</span>' +
672
+ (function() {
673
+ // W-mq08kuog001110a6 — Needs-Attention chip in the detail modal header.
674
+ var info = needsAttentionInfo(item);
675
+ if (!info) return '';
676
+ return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="See &quot;Why this is blocked&quot; below">&#x26A0; Needs attention</span>';
677
+ })() +
600
678
  '</div>';
679
+ // W-mq08kuog001110a6 — "Why this is blocked" section, rendered FIRST so the
680
+ // human eye lands on the unblocking reason before scrolling through agent,
681
+ // source, dates, etc. _preDispatchEval reasons are evaluator prose and can
682
+ // be multi-line — render verbatim in a <pre> to preserve newlines.
683
+ (function() {
684
+ var info = needsAttentionInfo(item);
685
+ if (!info) return;
686
+ html += field(
687
+ 'Why this is blocked',
688
+ '<div style="border-left:3px solid var(--yellow);padding:8px 10px;background:rgba(210,153,34,0.08);border-radius:var(--radius-sm)">' +
689
+ '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:4px">reason: <code>' + escapeHtml(info.kind) + '</code></div>' +
690
+ '<pre style="margin:0;font-size:var(--text-sm);white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono, monospace)">' + escapeHtml(info.full) + '</pre>' +
691
+ '</div>'
692
+ );
693
+ })();
601
694
  // Description: rendered from the FULL record once it's hydrated. The /api/status
602
695
  // slice drops `description` to keep the SPA payload <500KB; on first paint we
603
696
  // either render the title as a placeholder or, when description is already
@@ -788,4 +881,10 @@ function openInboxNote(filename) {
788
881
  switchPage('inbox');
789
882
  }
790
883
 
791
- window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote };
884
+ if (typeof window !== 'undefined') {
885
+ window.MinionsWork = { wiRow, renderWorkItems, editWorkItem, submitWorkItemEdit, deleteWorkItem, archiveWorkItem, toggleWorkItemArchive, retryWorkItem, wiPrev, wiNext, feedbackWorkItem, submitFeedback, openCreateWorkItemModal, openWorkItemDetail, openAllWorkItems, viewAgentOutput, openInboxNote, needsAttentionInfo };
886
+ }
887
+ // exported for testing — pure helpers safe to require under Node (no DOM deps).
888
+ if (typeof module !== 'undefined' && module.exports) {
889
+ module.exports = { needsAttentionInfo };
890
+ }
@@ -322,6 +322,8 @@
322
322
  .pr-badge.no-build { background: var(--surface); color: var(--muted); border: 1px solid var(--border); }
323
323
  .pr-badge.build-stale { background: rgba(210,153,34,0.15); color: var(--orange); border: 1px dashed var(--orange); }
324
324
  .pr-badge.build-escalated { background: rgba(248,81,73,0.25); color: var(--red); border: 2px solid var(--red); font-weight: 600; }
325
+ /* W-mq08kuog001110a6 — Needs-Attention chip for stuck pending work items. */
326
+ .pr-badge.needs-attention { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); font-weight: 600; cursor: help; }
325
327
  .error-details-btn { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); margin-left: var(--space-2); background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius-lg); cursor: pointer; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
326
328
  .error-details-btn:hover { background: rgba(248,81,73,0.3); }
327
329
  .pr-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-6) 0; }
package/docs/README.md CHANGED
@@ -36,6 +36,7 @@ Architecture, design proposals, and lifecycle references for people working on t
36
36
  - [slim-ux/architecture-suggestions.md](slim-ux/architecture-suggestions.md) — Slim-UX follow-up architecture suggestions paired with `concepts.md`.
37
37
  - [team-memory.md](team-memory.md) — Per-agent memory layer (`knowledge/agents/<id>.md`) and the consolidation/routing rules that populate it from `notes/inbox/`.
38
38
  - [watches.md](watches.md) — Persistent monitoring jobs (`engine/watches.json`): target-type registry, conditions, follow-up actions, and the `watches.d/` plugin folder.
39
+ - [workspace-manifests.md](workspace-manifests.md) — Declarative per-agent permission scoping: `allowed_tools` / `allowed_repos` / `allowed_external_urls` / `memory_scope`, dispatch-time repo gate, and runtime `--allowedTools` narrowing.
39
40
 
40
41
  ## Operations
41
42
 
@@ -0,0 +1,104 @@
1
+ # Workspace Manifests — Declarative Permission Scoping for Agents
2
+
3
+ > **Status:** Implemented in W-mq07avbk000m5543. Inspired by OpenAI's AGENTS.md / Workspace Manifest primitive (April 2026, [link](https://openai.com/index/the-next-evolution-of-the-agents-sdk/)).
4
+
5
+ Workspace manifests move per-agent tool / repo / URL / memory permissions from implicit system-prompt text to **machine-readable, runtime-enforced declarations**. Each agent's manifest lives on its config entry and is enforced at dispatch time and spawn time without requiring agents to read or honour prompt language.
6
+
7
+ ## Schema
8
+
9
+ A manifest lives on each agent definition under `workspace_manifest`. All fields are optional; any missing field is **permissive** (no restriction), so agents without a manifest behave exactly as they did before this feature shipped.
10
+
11
+ ```json
12
+ {
13
+ "agents": {
14
+ "ralph": {
15
+ "name": "Ralph",
16
+ "role": "Engineer",
17
+ "skills": ["implementation", "testing"],
18
+ "workspace_manifest": {
19
+ "allowed_tools": ["Edit", "Read", "Bash", "Grep", "Glob"],
20
+ "allowed_repos": ["github:yemi33/minions", "opg-microsoft/minions"],
21
+ "allowed_external_urls": ["github.com", "*.npmjs.com"],
22
+ "memory_scope": "shared"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ | Field | Type | Default | Semantics |
30
+ | ----- | ---- | ------- | --------- |
31
+ | `allowed_tools` | `string[]` \| `null` | `null` (permissive) | Canonical tool-name whitelist (e.g. `Edit`, `Read`, `Bash`). Empty array = deny all. Case-sensitive. Merged into the runtime adapter's `--allowedTools` flag at spawn time. |
32
+ | `allowed_repos` | `string[]` \| `null` | `null` (permissive) | Canonical PR-scope (`github:owner/repo`, `ado:org/proj/repo`) or bare `owner/repo`. Empty array = deny all. Case-insensitive. Enforced at dispatch time — out-of-scope dispatch fails with `failure_class: 'workspace-manifest-repo-forbidden'`. |
33
+ | `allowed_external_urls` | `string[]` \| `null` | `null` (permissive) | Host allow-list for `web_fetch` / `web_search`. Bare host (`github.com`) = exact match; `*.example.com` = wildcard subdomain **and** apex. Empty array = deny all. Currently advisory — see "Enforcement points" below. |
34
+ | `memory_scope` | `'private'` \| `'shared'` \| `'read-only-shared'` | `'shared'` | What slice of team knowledge the agent can read/write. Currently exposed via `shared.agentMemoryScope(agent)` for callers that want to gate inbox writes; full enforcement at the consolidation layer is future work. |
35
+
36
+ ## Default behaviour & backward compatibility
37
+
38
+ The defaults are deliberately permissive so the manifest can be rolled out incrementally:
39
+
40
+ ```js
41
+ // engine/shared.js
42
+ const WORKSPACE_MANIFEST_DEFAULTS = {
43
+ allowed_tools: null, // no tool restriction
44
+ allowed_repos: null, // no repo restriction
45
+ allowed_external_urls: null, // no URL restriction
46
+ memory_scope: 'shared',
47
+ };
48
+ ```
49
+
50
+ A config that never mentions `workspace_manifest` produces the exact same dispatch and spawn behaviour as the pre-manifest engine. Built-in agents (Ripley, Dallas, Lambert, Rebecca, Ralph) ship without manifests by default — manifests are an opt-in per-agent hardening primitive, not a fleet-wide default.
51
+
52
+ ## Enforcement points
53
+
54
+ | Phase | Location | What it does |
55
+ | ----- | -------- | ------------ |
56
+ | **Dispatch — repo gate** | `engine.js spawnAgent()` (right after project resolution) | Calls `shared.agentCanUseRepo(agent, project)`. Mismatch → `completeDispatch(... FAILURE_CLASS.WORKSPACE_MANIFEST_REPO, agentRetryable: false)` and `cleanupTempAgent` runs. Non-retryable because the structural answer is "widen the manifest or pick a different agent". |
57
+ | **Spawn — tool merge** | `engine.js spawnAgent()` → `_buildAgentSpawnFlags(..., allowedTools)` | `shared.mergeManifestAllowedTools(claudeConfig.allowedTools, manifest.allowed_tools)` produces the intersection of the runtime baseline and the manifest list. Result is passed as `--allowedTools <csv>` to Claude / Copilot / Codex, so the CLI itself enforces the narrowed surface. Empty manifest list (`[]`) = deny-all. Same merge runs on the steering-resume codepath. |
58
+ | **Agent context** | Future work (playbook.js) | Manifest can be surfaced into the agent prompt so the agent sees its declared scope. Today, `shared.resolveAgentManifest(agent)` returns the resolved struct any caller can read. |
59
+ | **URL fetch** | Advisory today | `shared.agentCanFetchUrl(agent, url)` is available for runtime intercepts. Minions itself does not currently intercept `web_fetch` inside the spawned CLI subprocess; a future runtime adapter hook can wire this. |
60
+ | **Memory scope** | Advisory today | `shared.agentMemoryScope(agent)` is available for consolidation / playbook / inbox callers. Semantics: `private` = agent only sees its own `knowledge/agents/<id>.md`; `shared` = full team knowledge (current default); `read-only-shared` = reads shared memory but should not write inbox/notes. |
61
+
62
+ ## Helpers (in `engine/shared.js`)
63
+
64
+ ```js
65
+ const { MEMORY_SCOPES, WORKSPACE_MANIFEST_DEFAULTS,
66
+ validateWorkspaceManifest, resolveAgentManifest,
67
+ agentCanUseRepo, agentCanUseTool, agentCanFetchUrl, agentMemoryScope,
68
+ mergeManifestAllowedTools, formatManifestRejection } = require('./engine/shared');
69
+ ```
70
+
71
+ - `validateWorkspaceManifest(manifest)` → `{ ok, errors }`. `null`/`undefined` is valid (uses defaults).
72
+ - `resolveAgentManifest(agent, config?)` → fresh copy of `WORKSPACE_MANIFEST_DEFAULTS` overlaid with the agent's `workspace_manifest`. Malformed manifest silently falls back to defaults; surface the error via `validateWorkspaceManifest` at config-load time.
73
+ - `agentCanUseRepo(agent, projectOrString)` → `bool`. Accepts a project object or a string identifier (`github:owner/repo`, `ado:org/proj/repo`, or bare `owner/repo`). Case-insensitive.
74
+ - `agentCanUseTool(agent, toolName)` → `bool`. Case-sensitive.
75
+ - `agentCanFetchUrl(agent, url)` → `bool`. Wildcard `*.example.com` matches subdomains **and** apex.
76
+ - `agentMemoryScope(agent)` → one of `MEMORY_SCOPES`. Unknown values fall back to `'shared'`.
77
+ - `mergeManifestAllowedTools(baselineCsv, manifestArray)` → merged CSV. Intersection semantics: `null` manifest = baseline unchanged; empty array = deny-all; empty baseline + manifest = manifest as ceiling.
78
+ - `formatManifestRejection({ agentId, kind, target, allowed })` → structured human-readable rejection string used by the dispatch repo gate.
79
+
80
+ ## Failure classes (`shared.FAILURE_CLASS`)
81
+
82
+ | Code | Meaning |
83
+ | ---- | ------- |
84
+ | `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a project not in its `allowed_repos`. Non-retryable. |
85
+ | `workspace-manifest-tool-forbidden` | Reserved for future tool-call intercepts. Non-retryable as-is. |
86
+ | `workspace-manifest-url-forbidden` | Reserved for future URL-fetch intercepts. Non-retryable as-is. |
87
+
88
+ ## Audit trail
89
+
90
+ Because rejections route through the standard `completeDispatch(... ERROR ...)` path, the existing dispatch + inbox + dashboard timeline machinery already surfaces every manifest rejection. No new state file or event topic was added. Per-agent audit of what each minion actually touched can be derived from existing `engine/dispatch.json` records filtered by `failure_class === 'workspace-manifest-repo-forbidden'`.
91
+
92
+ ## Rollout guidance
93
+
94
+ 1. Leave defaults (no manifests) until you have at least one agent that should be locked down (e.g. a contract agent that should only ever touch one repo, or an external collaborator's agent).
95
+ 2. Set `workspace_manifest.allowed_repos` first — it's the highest-impact gate and is fully enforced at dispatch time.
96
+ 3. Add `workspace_manifest.allowed_tools` for agents whose role doesn't need `Bash` / `WebFetch` / `WebSearch`. The runtime CLI enforces this, so the narrowing is hard-edged.
97
+ 4. Treat `allowed_external_urls` and `memory_scope` as documentation today; they become hard gates as we add runtime intercepts.
98
+
99
+ ## Related
100
+
101
+ - `engine/shared.js` — manifest helpers and `FAILURE_CLASS`.
102
+ - `engine.js spawnAgent()` — dispatch-time enforcement.
103
+ - `test/unit/workspace-manifest.test.js` — full validator + gate + integration tests.
104
+ - [`completion-reports.md`](completion-reports.md) — how the dispatch failure surfaces in the standard completion contract.
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2123",
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"