Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,26 @@ jobs:

# No lockfile + only optional peer deps → a bare install is enough.
# The test suite runs via node directly (native TS in node 24).
# SPEC-1b-3 D5: contract tests import the REAL @getpipher/armory-gateway
# (private repo — token-less clone fails). SIBLINGS_PAT is a per-repo secret.
- name: Clone armory-gateway (private sibling — contract tests require it)
env:
SIBLINGS_PAT: ${{ secrets.SIBLINGS_PAT }}
run: git clone --depth 1 https://x-access-token:${SIBLINGS_PAT}@github.com/getpipher/armory-gateway.git ../armory-gateway

# Bare clone has no node_modules — the contract-test import pulls gateway's
# full src tree (client.ts → @modelcontextprotocol/sdk …), so its own deps
# must be installed before `npm test`.
- name: Install gateway deps (bare clone has none)
working-directory: ../armory-gateway
run: npm install --ignore-scripts

- run: npm install --ignore-scripts

- name: Run tests
run: npm test
env:
ARMORY_GATEWAY_PATH: ${{ github.workspace }}/../armory-gateway

- name: Skip if already published
id: check
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,21 @@ Run the store tests: `npm test` (497/497 across 15 suites).

## License

MIT.
MIT.

## MCP tool scoping (armory-gateway integration)

When [`@getpipher/armory-gateway`](https://github.com/getpipher/armory-gateway) is installed in the
same pi, armory-todo can scope the session's MCP tools to your active work: tag a TODO with
`mcp:<server>` or `mcp:<server>__<tool>` and set it `in_progress`.

todo add "ship the fix" --tags mcp:github,mcp:nanuqfi__transfer
todo update td-xxx --status in_progress

- While that TODO is `in_progress`, gateway narrows visible MCP tools to the union of the tagged
servers/tools (multiple in_progress TODOs combine). No tags → no narrowing, ever.
- The injected TODO block shows the scoping state in every session — a narrowed session can always
see why, and parking/completing the TODO widens tools back immediately.
- Invalid `mcp:` tags are skipped (a typo narrows less, never more). Scoping is a convenience —
armory-fleet's `mcpDeny` remains the enforced security policy.
- Without armory-gateway installed, todo behaves exactly as before.
29 changes: 29 additions & 0 deletions docs/SPEC-1b-3-gateway-visibility-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPEC-1b-3 — Gateway visibility provider (armory-todo, PR-3 / D3 + D4 + D5)

> Relocated from the SPEC-1b-3 staging spec (`SPEC-1b-3-memory-todo-adapters.md` §6, §7) — armory-todo's half of the memory/todo adapter slice.

## §6 — D3: Todo visibility provider (as shipped)

**`src/visibility-provider.ts` (pure, pi-independent):**

```ts
export function parseMcpTag(tag: string): { server: string; tool?: string } | null;
export function collectScopedTools(store: Store): string[] | undefined;
```

- `parseMcpTag`: `mcp:` prefix required; remainder either bare `server` or `server__tool` (first-`__` split, both parts non-empty); metacharacters `/[*?[\]]` rejected — mirrors fleet's as-built validator class. Invalid → `null`.
- `collectScopedTools`: `store.todos.filter(t => t.status === "in_progress")` → union (Set) of parsed tags → bare tags emitted as `server`, tool tags as a template `server__tool` → empty → `undefined`.
- Provider closure: `loadStore()` per call (§3.7) → `collectScopedTools`. **Internal try/catch → `undefined` + `console.warn`** on unexpected errors: the contract's throw→hide-all is the wrong failure direction for a convenience scoper (it would turn a store hiccup into a full MCP outage). Q5 governs errors *escaping* providers; this adapter handles its own and never throws — documented here as the deliberate stance.
- **Wiring (`extensions/todo.ts`):** appended to the existing `session_start` handler — guarded import, `registerVisibilityProvider(provider)`, silent skip when absent. Idempotent.

## §7 — D4 + D5: Linkage & release gates (as shipped)

- **D4** — `test/helpers/gateway-link.mts`: `linkGateway(): string | null` reads `ARMORY_GATEWAY_PATH`; unset → `null` (real-module contract test `t.skip`s with a loud notice naming the env var); set → idempotent `node_modules/@getpipher/armory-gateway` symlink → bare-specifier resolution works under plain node 24. No `package.json` dependency changes (Q4-B — public repo, no `file:` devDep).
- **D5** — `.github/workflows/release.yml`: the armory-gateway clone step sits BEFORE `npm install`, and the test step exports `ARMORY_GATEWAY_PATH: ${{ github.workspace }}/../armory-gateway`. No continue-on-error — a failed clone fails the release (fleet precedent). `SIBLINGS_PAT` is a per-repo secret on getpipher/armory-todo (RECTOR sets it; least privilege, same as fleet's).
- **D6** — README "MCP tool scoping" section (tag grammar + examples; `in_progress` semantics; union; fail-open on bad tags; the injected-block explainability note; absent-gateway = inert). No unwired claims.

## As-built notes

- V1/V2 proven 2026-09-03: bare-specifier symlink resolution under plain node 24, and `?dup=1` two-instance symbol-store convergence (real-module contract test 4/4 with `ARMORY_GATEWAY_PATH` set, no skip).
- V4 seam upgrade: plan-phase verification found `scopeAllows`/`resolveVisibilityScope` are on the gateway's public seam — `scopeAllows` IS exported, so the contract tests pin the bare-entry matcher widening SEAM-LEVEL (a `?dup=1` dup instance's `scopeAllows({mode:"list", tools:new Set(["github"])}, "github", "anything") === true` — requires gateway PR-1's widened build).
- Test-fixture note: the pairs-test `want` literal in the brief was unsorted against its own `.sort()` call (lexicographic: `github__` < `gitlab__`) — corrected test-side, production byte-verbatim (controller ratification tracked in the Task 5 report).
8 changes: 8 additions & 0 deletions extensions/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ export default function (pi: ExtensionAPI) {
} catch {
// reap optional — never crash the session notify
}
// SPEC-1b-3: register the gateway visibility provider (silent skip when
// @getpipher/armory-gateway is absent — standalone todo unchanged).
try {
const { registerGatewayVisibilityProvider } = await import("../src/gateway-adapter.ts");
await registerGatewayVisibilityProvider();
} catch {
// gateway absent — standalone degradation
}
const showCount = cfg?.notify?.sessionStartCount !== false;
const open = listTodos();
let msg = "";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
]
},
"scripts": {
"test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup todo-reap triage public-api; do node test/$t.test.mts || exit 1; done"
"test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup todo-reap triage public-api visibility-provider gateway-adapter; do node test/$t.test.mts || exit 1; done"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
Expand Down
40 changes: 40 additions & 0 deletions src/gateway-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Gateway adapter for armory-todo (SPEC-1b-3 D3): registers a VisibilityProvider
// against @getpipher/armory-gateway's IoC registry. The specifier is NEVER
// statically imported — guarded dynamic import keeps public-npm installs standalone.
// Absent gateway → { registered: false }, silent (the normal public state).

import { loadStore, type Store } from "./todo-store.ts";
import { collectScopedTools } from "./visibility-provider.ts";

export interface GatewayModuleLike {
registerVisibilityProvider(fn: (input: { agent?: string; task?: string }) => Promise<string[] | null | undefined>): void;
}

export interface GatewayAdapterDeps {
importGateway?: () => Promise<GatewayModuleLike>;
loadStoreFn?: () => Store;
}

export async function registerGatewayVisibilityProvider(
deps: GatewayAdapterDeps = {},
): Promise<{ registered: boolean }> {
let gw: GatewayModuleLike;
try {
gw = await (deps.importGateway ?? (() => import("@getpipher/armory-gateway")))();
} catch {
return { registered: false };
}
const load = deps.loadStoreFn ?? loadStore;
// Deliberate stance (SPEC-1b-3 §6): this adapter handles its own errors and
// never throws. The contract's throw→hide-all is the wrong failure direction
// for a convenience scoper — an internal error passes through unscoped instead.
gw.registerVisibilityProvider(async () => {
try {
return collectScopedTools(load());
} catch (err) {
console.warn(`armory-todo: visibility provider error — passing through unscoped: ${err instanceof Error ? err.message : String(err)}`);
return undefined;
}
});
return { registered: true };
}
49 changes: 49 additions & 0 deletions src/visibility-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Pure, pi-independent MCP tool-scoping for armory-todo (SPEC-1b-3 D3).
//
// Convention: tags on in_progress todos matching mcp:<server> or
// mcp:<server>__<tool> scope the session's MCP tools (gateway visibility
// contract). Invalid mcp: tags are skipped SILENTLY — a typo means LESS
// narrowing, never more (scoping is the convenience plane; fleet's mcpDeny
// is the security plane). The injected TODO block makes the scoping state
// self-explanatory in every session.
//
// Kept free of any pi/typebox imports so it can be unit-tested standalone.

import type { Store } from "./todo-store.ts";

export interface ScopedToolTag {
server: string;
tool?: string;
}

/** Parse one tag. Returns null for anything that is not exactly the convention
* (missing mcp: prefix, empty parts, glob metacharacters /[*?[\]]/ — mirrors
* fleet's mcpDeny validator class). a__b__c parses as server=a, tool=b__c
* (first-__ split; harmless — gateway tool names never contain __). */
export function parseMcpTag(tag: string): ScopedToolTag | null {
if (!tag.startsWith("mcp:")) return null;
const rest = tag.slice(4);
if (!rest || /[*?[\]]/.test(rest)) return null;
const idx = rest.indexOf("__");
if (idx === -1) return { server: rest };
const server = rest.slice(0, idx);
const tool = rest.slice(idx + 2);
if (!server || !tool) return null;
return { server, tool };
}

/** Union of mcp: tags across ALL in_progress todos. Non-empty → prefixed names
* (bare servers emitted bare — gateway's 1b-3 matcher scopes them whole-server);
* none → undefined (not applicable → config-only pass-through). */
export function collectScopedTools(store: Store): string[] | undefined {
const names = new Set<string>();
for (const todo of store.todos) {
if (todo.status !== "in_progress") continue;
for (const tag of todo.tags ?? []) {
const parsed = parseMcpTag(tag);
if (!parsed) continue;
names.add(parsed.tool ? `${parsed.server}__${parsed.tool}` : parsed.server);
}
}
return names.size > 0 ? [...names] : undefined;
}
67 changes: 67 additions & 0 deletions test/gateway-adapter.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Gateway-adapter contract tests for armory-todo SPEC-1b-3 (node:test for skip).
// Run: node test/gateway-adapter.test.mts
// Real-module tests need ARMORY_GATEWAY_PATH (+ PR-1 merged for the bare-entry pin).

import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { linkGateway } from "./helpers/gateway-link.mts";

const tmp = mkdtempSync(join(tmpdir(), "armory-todo-adapter-"));
process.env.TODO_DIR = tmp;

const { registerGatewayVisibilityProvider } = await import("../src/gateway-adapter.ts");
const { addTodo, updateTodo } = await import("../src/todo-store.ts");

test("injected fake: provider registers and scopes from the live store", async () => {
let received: ((input: unknown) => Promise<unknown>) | undefined;
const fake = { registerVisibilityProvider(fn: (input: unknown) => Promise<unknown>) { received = fn; } };
const out = await registerGatewayVisibilityProvider({ importGateway: async () => fake });
assert.deepEqual(out, { registered: true });
const t1 = addTodo({ title: "scoped work", tags: ["mcp:github"], project: "p" });
updateTodo(t1.id, { status: "in_progress" });
assert.deepEqual(await received!({}), ["github"]);
updateTodo(t1.id, { status: "parked" });
assert.equal(await received!({}), undefined, "no in_progress mcp: tags → undefined (pass-through)");
});

test("provider NEVER throws — store failure underneath resolves undefined + warns", async () => {
let received: ((input: unknown) => Promise<unknown>) | undefined;
const fake = { registerVisibilityProvider(fn: (input: unknown) => Promise<unknown>) { received = fn; } };
await registerGatewayVisibilityProvider({
importGateway: async () => fake,
loadStoreFn: () => { throw new Error("store exploded"); },
});
const result = await received!({});
assert.equal(result, undefined, "internal catch → undefined (never the hide-all throw path)");
});

test("import failure → { registered: false }, no throw", async () => {
const out = await registerGatewayVisibilityProvider({ importGateway: async () => { throw new Error("module absent"); } });
assert.deepEqual(out, { registered: false });
});

test("REAL gateway module: registration, convergence, seam-level bare-entry matcher pin", async (t) => {
const gwPath = linkGateway();
if (!gwPath) {
t.skip("ARMORY_GATEWAY_PATH unset — skipping real-module contract tests (set it to the armory-gateway repo)");
return;
}
const out = await registerGatewayVisibilityProvider();
assert.deepEqual(out, { registered: true });
const sym = Symbol.for("@getpipher/armory-gateway:registry");
const store = (globalThis as Record<symbol, { visibility?: unknown }> | undefined)![sym];
assert.ok(store?.visibility, "symbol-store visibility slot truthy after registration");
const resolved = import.meta.resolve("@getpipher/armory-gateway");
const dup = (await import(resolved + "?dup=1")) as {
registeredKinds(): { visibility: boolean };
scopeAllows(scope: { mode: "all" } | { mode: "list"; tools: Set<string> }, server: string, tool: string): boolean;
};
assert.equal(dup.registeredKinds().visibility, true, "dup instance sees the same slot");
// V4 seam-level pin (plan-phase upgrade): the bare-entry matcher widening is real
const scope = { mode: "list" as const, tools: new Set(["github"]) };
assert.equal(dup.scopeAllows(scope, "github", "anything"), true, "bare entry → whole server (requires PR-1 gateway)");
assert.equal(dup.scopeAllows(scope, "gitlab", "anything"), false);
});
20 changes: 20 additions & 0 deletions test/helpers/gateway-link.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// PLAN-1b-3 D4: env-gated real-module resolution for contract tests.
// ARMORY_GATEWAY_PATH unset → null (caller t.skip's with a loud notice).
// Set → idempotently symlink the gateway repo into node_modules/@getpipher/
// so the adapter's bare-specifier guarded import resolves (verified: plan V1).

import { existsSync, mkdirSync, symlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

export function linkGateway(): string | null {
const target = process.env.ARMORY_GATEWAY_PATH;
if (!target) return null;
const abs = resolve(target);
if (!existsSync(abs)) return null;
const pkgDir = join(resolve(dirname(fileURLToPath(import.meta.url))), "..", "..", "node_modules", "@getpipher");
mkdirSync(pkgDir, { recursive: true });
const link = join(pkgDir, "armory-gateway");
if (!existsSync(link)) symlinkSync(abs, link, "dir");
return abs;
}
68 changes: 68 additions & 0 deletions test/visibility-provider.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Pure visibility-provider tests for armory-todo SPEC-1b-3 (run: node test/visibility-provider.test.mts).
// Uses TODO_DIR to avoid touching the real store.

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const tmp = mkdtempSync(join(tmpdir(), "armory-todo-vis-"));
process.env.TODO_DIR = tmp;

let passed = 0;
let failed = 0;
function ok(name: string, cond: boolean, extra = ""): void {
if (cond) passed++;
else { failed++; console.error(` ✗ ${name} ${extra}`); }
}
function eq<T>(name: string, got: T, want: T): void {
ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
}

const { parseMcpTag, collectScopedTools } = await import("../src/visibility-provider.ts");
const { addTodo, updateTodo, loadStore } = await import("../src/todo-store.ts");

// --- parseMcpTag grammar ---
eq("bare server", JSON.stringify(parseMcpTag("mcp:github")), JSON.stringify({ server: "github" }));
eq("server__tool pair", JSON.stringify(parseMcpTag("mcp:github__create_issue")), JSON.stringify({ server: "github", tool: "create_issue" }));
eq("missing prefix → null", parseMcpTag("github"), null);
eq("empty rest → null", parseMcpTag("mcp:"), null);
eq("glob star → null", parseMcpTag("mcp:github__*"), null);
eq("glob question → null", parseMcpTag("mcp:g?hub"), null);
eq("bracket → null", parseMcpTag("mcp:gh[x]"), null);
eq("empty server part → null", parseMcpTag("mcp:__tool"), null);
eq("empty tool part → null", parseMcpTag("mcp:server__"), null);
eq("first-__ split: a__b__c → server a, tool b__c (harmless, never matches)",
JSON.stringify(parseMcpTag("mcp:a__b__c")), JSON.stringify({ server: "a", tool: "b__c" }));

// --- collectScopedTools over a real store ---
eq("no todos → undefined", collectScopedTools({ version: 3, updatedAt: "", todos: [] }), undefined);

const inProg = addTodo({ title: "hunt", tags: ["mcp:github"], project: "p" });
updateTodo(inProg.id, { status: "in_progress" });
eq("in_progress + bare tag → [server]", JSON.stringify(collectScopedTools(loadStore())), JSON.stringify(["github"]));

updateTodo(inProg.id, { tags: ["mcp:github__create_issue", "mcp:gitlab__merge"] });
eq("in_progress + pairs → prefixed names",
JSON.stringify(collectScopedTools(loadStore()).sort()),
JSON.stringify(["github__create_issue", "gitlab__merge"])); // lexicographic: "github" < "gitlab"

const second = addTodo({ title: "second", tags: ["mcp:nanuqfi"], project: "p" });
updateTodo(second.id, { status: "in_progress" });
const union = collectScopedTools(loadStore())!;
ok("union across in_progress todos", union.includes("nanuqfi") && union.includes("github__create_issue"));

updateTodo(second.id, { status: "parked" });
ok("parked todo's tags drop out", !collectScopedTools(loadStore())!.includes("nanuqfi"));

const done = addTodo({ title: "done one", tags: ["mcp:shouldnotcount"], project: "p" });
updateTodo(done.id, { status: "done" });
ok("done todos never scope", !JSON.stringify(collectScopedTools(loadStore())).includes("shouldnotcount"));

const invalid = addTodo({ title: "bad tags", tags: ["mcp:github__*", "notmcp:x"], project: "p" });
updateTodo(invalid.id, { status: "in_progress" });
updateTodo(inProg.id, { status: "parked" });
eq("only invalid tags → undefined (fail-open, not [])", collectScopedTools(loadStore()), undefined);

console.log(`\nvisibility-provider: ${passed} passed, ${failed} failed`);
rmSync(tmp, { recursive: true, force: true });
process.exit(failed ? 1 : 0);
Loading