From c25e03f714f9593077be1a43ae7ff16bc17efd45 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 21:47:39 +0700 Subject: [PATCH 1/4] feat: mcp: tag grammar + in_progress tool-scope collection --- package.json | 2 +- src/visibility-provider.ts | 49 ++++++++++++++++++++++ test/visibility-provider.test.mts | 68 +++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/visibility-provider.ts create mode 100644 test/visibility-provider.test.mts diff --git a/package.json b/package.json index bf59a49..c7dc5db 100644 --- a/package.json +++ b/package.json @@ -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; do node test/$t.test.mts || exit 1; done" }, "peerDependencies": { "@earendil-works/pi-ai": "*", diff --git a/src/visibility-provider.ts b/src/visibility-provider.ts new file mode 100644 index 0000000..1f691ce --- /dev/null +++ b/src/visibility-provider.ts @@ -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: or +// mcp:__ 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(); + 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; +} diff --git a/test/visibility-provider.test.mts b/test/visibility-provider.test.mts new file mode 100644 index 0000000..5261e34 --- /dev/null +++ b/test/visibility-provider.test.mts @@ -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(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); From 42df1c7675899073834f43bb91acb6f2ecb3dafa Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 21:54:41 +0700 Subject: [PATCH 2/4] feat: register gateway visibility provider from session_start (guarded import) --- README.md | 18 ++++- docs/SPEC-1b-3-gateway-visibility-provider.md | 29 ++++++++ extensions/todo.ts | 8 +++ package.json | 2 +- src/gateway-adapter.ts | 40 +++++++++++ test/gateway-adapter.test.mts | 67 +++++++++++++++++++ test/helpers/gateway-link.mts | 20 ++++++ 7 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 docs/SPEC-1b-3-gateway-visibility-provider.md create mode 100644 src/gateway-adapter.ts create mode 100644 test/gateway-adapter.test.mts create mode 100644 test/helpers/gateway-link.mts diff --git a/README.md b/README.md index 34b7656..90530df 100644 --- a/README.md +++ b/README.md @@ -327,4 +327,20 @@ Run the store tests: `npm test` (497/497 across 15 suites). ## License -MIT. \ No newline at end of file +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:` or `mcp:__` 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. diff --git a/docs/SPEC-1b-3-gateway-visibility-provider.md b/docs/SPEC-1b-3-gateway-visibility-provider.md new file mode 100644 index 0000000..ff2bc8c --- /dev/null +++ b/docs/SPEC-1b-3-gateway-visibility-provider.md @@ -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). diff --git a/extensions/todo.ts b/extensions/todo.ts index 41ced33..a683ed6 100644 --- a/extensions/todo.ts +++ b/extensions/todo.ts @@ -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 = ""; diff --git a/package.json b/package.json index c7dc5db..31b1e21 100644 --- a/package.json +++ b/package.json @@ -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 visibility-provider; 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": "*", diff --git a/src/gateway-adapter.ts b/src/gateway-adapter.ts new file mode 100644 index 0000000..03c5c30 --- /dev/null +++ b/src/gateway-adapter.ts @@ -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): void; +} + +export interface GatewayAdapterDeps { + importGateway?: () => Promise; + 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 }; +} diff --git a/test/gateway-adapter.test.mts b/test/gateway-adapter.test.mts new file mode 100644 index 0000000..e12ea0b --- /dev/null +++ b/test/gateway-adapter.test.mts @@ -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) | undefined; + const fake = { registerVisibilityProvider(fn: (input: unknown) => Promise) { 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) | undefined; + const fake = { registerVisibilityProvider(fn: (input: unknown) => Promise) { 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 | 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 }, 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); +}); diff --git a/test/helpers/gateway-link.mts b/test/helpers/gateway-link.mts new file mode 100644 index 0000000..910eaf9 --- /dev/null +++ b/test/helpers/gateway-link.mts @@ -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; +} From e5b9e553f3849233a7b0db81fe4eec369c084f9e Mon Sep 17 00:00:00 2001 From: RECTOR Date: Thu, 3 Sep 2026 21:59:28 +0700 Subject: [PATCH 3/4] ci: clone private armory-gateway for contract tests at the release gate --- .github/workflows/release.yml | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec9a2ce..0d3e904 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,10 +30,19 @@ 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 + - 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 diff --git a/README.md b/README.md index 90530df..3d49867 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,7 @@ Run the store tests: `npm test` (497/497 across 15 suites). ## License MIT. + ## MCP tool scoping (armory-gateway integration) When [`@getpipher/armory-gateway`](https://github.com/getpipher/armory-gateway) is installed in the From 153c24662533bdb0be580a732e466ca02049f7fb Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 4 Sep 2026 09:34:33 +0700 Subject: [PATCH 4/4] ci: install gateway deps in the cloned sibling (bare clone has no node_modules) --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d3e904..0a2064d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,13 @@ jobs: 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