@yemi33/minions 0.1.2123 → 0.1.2125

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/dashboard.js CHANGED
@@ -11477,7 +11477,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11477
11477
  invalidateStatusCache();
11478
11478
  return jsonReply(res, 200, { ok: true });
11479
11479
  }},
11480
- { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
11480
+ { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message, supersede? (all|unacked|<steerId>), scope? (agent|current-dispatch)', handler: async (req, res) => {
11481
11481
  const body = await readBody(req);
11482
11482
  const { agent, message } = body;
11483
11483
  if (!agent || !message) return jsonReply(res, 400, { error: 'agent and message required' });
@@ -11491,7 +11491,54 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11491
11491
  return jsonReply(res, 409, { error: 'Agent session is finishing; retry when the next session starts' });
11492
11492
  }
11493
11493
 
11494
- const entry = steering.writeSteeringMessage(agentId, text);
11494
+ // W-mq066js7000fff1f-e (Gap E) — supersede prior messages before
11495
+ // writing the new one. Accept 'all', 'unacked', or a specific
11496
+ // steerId. Bad shapes are silently ignored (best-effort).
11497
+ const supersede = body.supersede ? String(body.supersede).trim() : '';
11498
+ let superseded = [];
11499
+ if (supersede) {
11500
+ const provisionalSteerId = `steer-pending-${Date.now()}`;
11501
+ superseded = steering.supersedeMessages(agentId, supersede, { reason: `superseded by new steer (${supersede})`, newSteerId: provisionalSteerId });
11502
+ }
11503
+
11504
+ // W-mq066js7000fff1f-e — dedupe: if no supersede arg AND an
11505
+ // identical body is already pending within the 5-min window, echo
11506
+ // back the existing steerId instead of writing a duplicate file.
11507
+ if (!supersede) {
11508
+ const dup = steering.findRecentDuplicate(agentId, text);
11509
+ if (dup) {
11510
+ const delivery = _steeringDeliveryState(agentId);
11511
+ return jsonReply(res, 200, {
11512
+ ok: true,
11513
+ deduplicated: true,
11514
+ steerId: dup.steerId,
11515
+ status: dup.status || steering.STATUS.QUEUED,
11516
+ file: dup.file,
11517
+ // Gap D observability URL — points at the SQL delivery-state row
11518
+ // for /api/steering/:id (back-compat with master's contract).
11519
+ deliveryUrl: dup.steerId ? `/api/steering/${dup.steerId}` : null,
11520
+ message: 'Identical steering message already pending — returning existing entry',
11521
+ ...delivery,
11522
+ inboxCount: steering.listUnreadSteeringMessages(agentId).length,
11523
+ });
11524
+ }
11525
+ }
11526
+
11527
+ // W-mq066js7000fff1f-f (Gap F) — per-dispatch scoping. With
11528
+ // scope='current-dispatch' we stamp the active dispatch id so the
11529
+ // engine filters this entry out of any future unrelated dispatch's
11530
+ // resume prompt. With scope='agent' (default) the message is
11531
+ // agent-wide and picks up on the next dispatch regardless of id.
11532
+ const scope = body.scope ? String(body.scope).trim().toLowerCase() : 'agent';
11533
+ let targetDispatchId = null;
11534
+ let scopeApplied = scope;
11535
+ if (scope === 'current-dispatch') {
11536
+ const active = (getDispatchQueue().active || []).find(d => d.agent === agentId);
11537
+ if (active?.id) targetDispatchId = String(active.id);
11538
+ else scopeApplied = 'agent'; // no active dispatch → fall back
11539
+ }
11540
+
11541
+ const entry = steering.writeSteeringMessage(agentId, text, { targetDispatchId });
11495
11542
  const delivery = _steeringDeliveryState(agentId);
11496
11543
 
11497
11544
  // Also append to live-output.log so it shows in the chat view
@@ -11506,12 +11553,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11506
11553
  const steerId = entry?.steerId || null;
11507
11554
  return jsonReply(res, 200, {
11508
11555
  ok: true,
11556
+ deduplicated: false,
11557
+ steerId,
11558
+ status: entry?.status || (steerId ? steering.STATUS.QUEUED : null),
11559
+ targetDispatchId: entry?.targetDispatchId || null,
11560
+ scope: scopeApplied,
11561
+ superseded: superseded.map(s => ({ steerId: s.steerId, previousStatus: s.previousStatus })),
11509
11562
  message: delivery.pendingDelivery ? 'Steering message pending delivery' : 'Steering message queued',
11510
11563
  ...delivery,
11511
11564
  file: entry?.file || null,
11512
11565
  inboxCount: steering.listUnreadSteeringMessages(agentId).length,
11513
- steerId,
11514
- status: steerId ? 'queued' : null,
11515
11566
  deliveryUrl: steerId ? `/api/steering/${steerId}` : null,
11516
11567
  });
11517
11568
  }},
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,
@@ -19,6 +19,43 @@ function _generateSteerId() {
19
19
  return `steer-${crypto.randomBytes(8).toString('hex').slice(0, 10)}`;
20
20
  }
21
21
 
22
+ // W-mq066js7000fff1f-e (Gap E): identical-text dedupe window. POST
23
+ // /api/agents/steer collapses identical bodies within this window to
24
+ // the existing steerId instead of writing a duplicate inbox file.
25
+ const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
26
+
27
+ // W-mq066js7000fff1f-e/f: status enum for the inbox frontmatter +
28
+ // supersede operations. Mirrors the steering_deliveries observability
29
+ // table (Gap D) so callers can speak the same vocabulary regardless
30
+ // of which storage backend is live.
31
+ const STATUS = Object.freeze({
32
+ QUEUED: 'queued',
33
+ LIVE_KILL: 'live_kill',
34
+ DEFERRED: 'deferred',
35
+ RE_SPAWNING: 're_spawning',
36
+ DELIVERED: 'delivered',
37
+ ACKNOWLEDGED: 'acknowledged',
38
+ STRANDED: 'stranded',
39
+ DROPPED: 'dropped',
40
+ });
41
+
42
+ const UNACKED_STATUSES = new Set([
43
+ STATUS.QUEUED,
44
+ STATUS.LIVE_KILL,
45
+ STATUS.DEFERRED,
46
+ STATUS.RE_SPAWNING,
47
+ STATUS.STRANDED,
48
+ ]);
49
+
50
+ // Statuses that should still appear as "live" in the dedupe lookup.
51
+ const DEDUPE_CANDIDATE_STATUSES = new Set([
52
+ STATUS.QUEUED,
53
+ STATUS.LIVE_KILL,
54
+ STATUS.DEFERRED,
55
+ STATUS.RE_SPAWNING,
56
+ STATUS.DELIVERED,
57
+ ]);
58
+
22
59
  function agentInboxDir(agentId) {
23
60
  return path.join(AGENTS_DIR, agentId, 'inbox');
24
61
  }
@@ -73,15 +110,22 @@ function _readEntry(filePath, legacy = false) {
73
110
  ? fmCreatedAtMs
74
111
  : _createdAtFromPath(filePath, stat);
75
112
  const steerId = _frontmatterValue(raw, 'steerId') || null;
113
+ const status = _frontmatterValue(raw, 'status') || STATUS.QUEUED;
114
+ const targetDispatchId = _frontmatterValue(raw, 'targetDispatchId') || null;
115
+ const lastError = _frontmatterValue(raw, 'lastError') || null;
116
+ const source = _frontmatterValue(raw, 'source') || 'human';
76
117
  return {
77
118
  path: filePath,
78
119
  file: path.basename(filePath),
79
120
  createdAtMs,
80
121
  createdAt: new Date(createdAtMs).toISOString(),
81
- steerId,
82
122
  raw,
83
123
  message: _messageFromRaw(raw),
84
124
  steerId,
125
+ status,
126
+ targetDispatchId,
127
+ lastError,
128
+ source,
85
129
  legacy,
86
130
  };
87
131
  }
@@ -94,10 +138,6 @@ function _uniqueSteeringPath(inboxDir, createdAtMs) {
94
138
  return filePath;
95
139
  }
96
140
 
97
- function _generateSteerId() {
98
- return crypto.randomBytes(6).toString('hex');
99
- }
100
-
101
141
  // Contract block describing the ACK-file protocol. Injected into the prompt
102
142
  // alongside any pending steering messages so the agent knows how to confirm
103
143
  // it has read+addressed a labeled message. Mirrored verbatim into
@@ -111,9 +151,24 @@ function ackContractBlock() {
111
151
  ].join('\n');
112
152
  }
113
153
 
154
+ function _renderFrontmatter(data) {
155
+ const createdAtMs = Number(data.createdAtMs) || Date.now();
156
+ const lines = [
157
+ '---',
158
+ `createdAt: ${new Date(createdAtMs).toISOString()}`,
159
+ `createdAtMs: ${createdAtMs}`,
160
+ `source: ${data.source || 'human'}`,
161
+ `steerId: ${data.steerId}`,
162
+ `status: ${data.status || STATUS.QUEUED}`,
163
+ ];
164
+ if (data.targetDispatchId) lines.push(`targetDispatchId: ${data.targetDispatchId}`);
165
+ if (data.lastError) lines.push(`lastError: ${String(data.lastError).replace(/[\r\n]+/g, ' ').trim()}`);
166
+ lines.push('---', '', String(data.message || '').trim(), '');
167
+ return lines.join('\n');
168
+ }
169
+
114
170
  function writeSteeringMessage(agentId, message, opts = {}) {
115
171
  const createdAtMs = Number(opts.createdAtMs) || Date.now();
116
- const createdAt = new Date(createdAtMs).toISOString();
117
172
  const inboxDir = agentInboxDir(agentId);
118
173
  fs.mkdirSync(inboxDir, { recursive: true });
119
174
  const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
@@ -134,17 +189,15 @@ function writeSteeringMessage(agentId, message, opts = {}) {
134
189
  steerId,
135
190
  }));
136
191
  }
137
- const body = [
138
- '---',
139
- `createdAt: ${createdAt}`,
140
- `createdAtMs: ${createdAtMs}`,
141
- `source: ${source}`,
142
- `steerId: ${steerId}`,
143
- '---',
144
- '',
145
- bodyText,
146
- '',
147
- ].join('\n');
192
+ const body = _renderFrontmatter({
193
+ createdAtMs,
194
+ source,
195
+ steerId,
196
+ status: opts.status || STATUS.QUEUED,
197
+ targetDispatchId: opts.targetDispatchId || null,
198
+ lastError: opts.lastError || null,
199
+ message: bodyText,
200
+ });
148
201
  shared.safeWrite(filePath, body);
149
202
 
150
203
  // W-mq066js7000fff1f-a (Gap D): insert a 'queued' row into the
@@ -169,7 +222,22 @@ function writeSteeringMessage(agentId, message, opts = {}) {
169
222
  return _readEntry(filePath);
170
223
  }
171
224
 
172
- function listUnreadSteeringMessages(agentId, opts = {}) {
225
+ function _updateEntryStatus(entry, newStatus, opts = {}) {
226
+ if (!entry?.path) return null;
227
+ const body = _renderFrontmatter({
228
+ createdAtMs: entry.createdAtMs,
229
+ source: entry.source || 'human',
230
+ steerId: entry.steerId,
231
+ status: newStatus,
232
+ targetDispatchId: entry.targetDispatchId,
233
+ lastError: opts.lastError !== undefined ? opts.lastError : entry.lastError,
234
+ message: entry.message,
235
+ });
236
+ shared.safeWrite(entry.path, body);
237
+ return _readEntry(entry.path);
238
+ }
239
+
240
+ function listAllSteeringEntries(agentId, opts = {}) {
173
241
  const includeLegacy = opts.includeLegacy !== false;
174
242
  const entries = [];
175
243
  const inboxDir = agentInboxDir(agentId);
@@ -189,8 +257,40 @@ function listUnreadSteeringMessages(agentId, opts = {}) {
189
257
  return entries;
190
258
  }
191
259
 
192
- function buildPendingSteeringPrompt(agentId) {
193
- const entries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
260
+ function listUnreadSteeringMessages(agentId, opts = {}) {
261
+ // Back-compat shape: "unread" = anything still pending agent attention
262
+ // (queued/live_kill/deferred/re_spawning/delivered/stranded). Explicit
263
+ // 'dropped' or 'acknowledged' rows are filtered so supersede/ack don't
264
+ // resurrect carry-over messages on the next dispatch.
265
+ return listAllSteeringEntries(agentId, opts).filter(entry => {
266
+ const status = entry.status || STATUS.QUEUED;
267
+ return status !== STATUS.DROPPED && status !== STATUS.ACKNOWLEDGED;
268
+ });
269
+ }
270
+
271
+ // W-mq066js7000fff1f-f (Gap F): per-dispatch scoping. Callers that know
272
+ // which dispatch they're about to spawn can pass {currentDispatchId} so
273
+ // messages tagged for a different (older) dispatch are filtered out.
274
+ // Pre-spawn messages (targetDispatchId null) always pass through.
275
+ // Without currentDispatchId, no filtering happens — back-compat for
276
+ // callers that haven't been updated to pass the dispatch id yet.
277
+ function buildPendingSteeringPrompt(agentId, opts = {}) {
278
+ const includePrior = opts.includePrior === true;
279
+ const currentDispatchId = opts.currentDispatchId || null;
280
+ const allEntries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
281
+ const entries = includePrior
282
+ ? allEntries
283
+ : allEntries.filter(entry => {
284
+ // Pre-spawn / agent-scoped messages (null targetDispatchId) always
285
+ // pick up on the next dispatch — the agent never had a chance to
286
+ // hear them yet.
287
+ if (!entry.targetDispatchId) return true;
288
+ // No dispatch context → don't try to filter (back-compat).
289
+ if (!currentDispatchId) return true;
290
+ // Per-dispatch messages only belong to their tagged dispatch.
291
+ return entry.targetDispatchId === currentDispatchId;
292
+ });
293
+
194
294
  if (entries.length === 0) return { entries, prompt: '' };
195
295
 
196
296
  const sections = [
@@ -206,6 +306,69 @@ function buildPendingSteeringPrompt(agentId) {
206
306
  return { entries, prompt: sections.join('\n') };
207
307
  }
208
308
 
309
+ // W-mq066js7000fff1f-e (Gap E): dedupe lookup for POST /api/agents/steer.
310
+ // Returns the existing entry whose normalized body matches `message` and
311
+ // was created within `opts.windowMs` (default 5 min); null otherwise.
312
+ // Only entries in "live" delivery states qualify — dropped/acknowledged
313
+ // rows are ignored so a previously-superseded duplicate body is allowed
314
+ // to be re-sent.
315
+ function findRecentDuplicate(agentId, message, opts = {}) {
316
+ const trimmed = String(message || '').trim();
317
+ if (!trimmed) return null;
318
+ const windowMs = Number(opts.windowMs) > 0 ? Number(opts.windowMs) : DEDUPE_WINDOW_MS;
319
+ const now = Number(opts.now) > 0 ? Number(opts.now) : Date.now();
320
+ const entries = listAllSteeringEntries(agentId);
321
+ // Newest first — UI usually steers in a tight loop, the most recent
322
+ // identical message is the one we want to deduplicate against.
323
+ entries.sort((a, b) => b.createdAtMs - a.createdAtMs);
324
+ for (const entry of entries) {
325
+ const status = entry.status || STATUS.QUEUED;
326
+ if (!DEDUPE_CANDIDATE_STATUSES.has(status)) continue;
327
+ if (now - entry.createdAtMs > windowMs) continue;
328
+ if (entry.message.trim() !== trimmed) continue;
329
+ return entry;
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // W-mq066js7000fff1f-e (Gap E): supersede prior steering messages.
335
+ // mode:
336
+ // 'all' — drop every entry whose status is neither dropped nor
337
+ // acknowledged (queued/live_kill/deferred/re_spawning/
338
+ // delivered/stranded).
339
+ // 'unacked' — drop queued/live_kill/deferred/re_spawning/stranded but
340
+ // leave 'delivered' alone (agent already saw it).
341
+ // <steerId> — drop the single entry with that steerId.
342
+ // Returns the list of dropped {steerId, file, path, previousStatus}.
343
+ function supersedeMessages(agentId, mode, opts = {}) {
344
+ if (!mode) return [];
345
+ const newSteerId = opts.newSteerId || null;
346
+ const reason = opts.reason || (newSteerId ? `superseded by ${newSteerId}` : 'superseded');
347
+ const entries = listAllSteeringEntries(agentId);
348
+ const dropped = [];
349
+ for (const entry of entries) {
350
+ const status = entry.status || STATUS.QUEUED;
351
+ let target = false;
352
+ if (mode === 'all') {
353
+ if (status !== STATUS.ACKNOWLEDGED && status !== STATUS.DROPPED) target = true;
354
+ } else if (mode === 'unacked') {
355
+ if (UNACKED_STATUSES.has(status)) target = true;
356
+ } else {
357
+ // Specific steerId
358
+ if (entry.steerId && entry.steerId === mode) target = true;
359
+ }
360
+ if (!target) continue;
361
+ _updateEntryStatus(entry, STATUS.DROPPED, { lastError: reason });
362
+ dropped.push({
363
+ steerId: entry.steerId,
364
+ file: entry.file,
365
+ path: entry.path,
366
+ previousStatus: status,
367
+ });
368
+ }
369
+ return dropped;
370
+ }
371
+
209
372
  function _eventTimestampMs(obj, observedAtMs) {
210
373
  const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
211
374
  const parsed = value ? Date.parse(value) : NaN;
@@ -342,9 +505,17 @@ module.exports = {
342
505
  ackContractBlock,
343
506
  writeSteeringMessage,
344
507
  listUnreadSteeringMessages,
508
+ listAllSteeringEntries,
345
509
  buildPendingSteeringPrompt,
510
+ findRecentDuplicate,
511
+ supersedeMessages,
346
512
  sessionIdFromEvent,
347
513
  sessionIdFromOutputLine,
348
514
  ackProcessedSteeringMessages,
349
515
  ackSteeringFromAckDir,
516
+ STATUS,
517
+ DEDUPE_WINDOW_MS,
518
+ // Exposed for unit tests only.
519
+ _updateEntryStatus,
520
+ _generateSteerId,
350
521
  };
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 }
@@ -1295,7 +1326,7 @@ async function spawnAgent(dispatchItem, config) {
1295
1326
  // work-item prompts after setup because reused worktrees can live at arbitrary paths.
1296
1327
  const systemPrompt = buildSystemPrompt(agentId, config, project);
1297
1328
  const agentContext = buildAgentContext(agentId, config, project);
1298
- const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
1329
+ const pendingSteering = steering.buildPendingSteeringPrompt(agentId, { currentDispatchId: id });
1299
1330
  const completionReportPath = shared.dispatchCompletionReportPath(id);
1300
1331
  if (completionReportPath) {
1301
1332
  try {
@@ -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,
@@ -2759,7 +2793,7 @@ async function spawnAgent(dispatchItem, config) {
2759
2793
  // Write new prompt with all unACKed steering messages. This keeps delivery
2760
2794
  // durable if the killed process had older pending messages that never
2761
2795
  // produced processing evidence before the resume.
2762
- const pendingForResume = steering.buildPendingSteeringPrompt(agentId);
2796
+ const pendingForResume = steering.buildPendingSteeringPrompt(agentId, { currentDispatchId: id });
2763
2797
  const steerPromptBody = pendingForResume.prompt || steerMsg;
2764
2798
  const steerPrompt = `Message from your human teammate:\n\n${steerPromptBody}\n\nRespond to this, then continue working on your current task.`;
2765
2799
  const steerPromptPath = path.join(dispatchTmpDir, `prompt-steer-${safeId}.md`);
@@ -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.2125",
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"