diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 7e3c444f..8d9c47e2 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -5,7 +5,7 @@ * - sandbox.mode='bwrap' → spawn binary is `bwrap`, not `claude` * - mcp[] → --mcp-config added to inner argv, * and a mode-0600 temp file outside the workspace - * - skills.claude[] → linkSkills called against the workspace + * - skills.claude[] → mountSkills called against the workspace * * Uses ctx._spawnImpl (the same test seam as adapters.claude.test.mjs) so * no real claude/bwrap binary runs. @@ -127,7 +127,7 @@ describe('claude adapter — ctx.environment', () => { expect(fs.existsSync(path.dirname(calls[0].configPath))).toBe(false); }); - test('symlinks skills.claude entries into /.claude/skills/', async () => { + test('mounts skills.claude entries into /.claude/skills/ as read-only copies', async () => { const { impl } = makeSpawnImpl(); const skillSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-')); fs.writeFileSync(path.join(skillSrc, 'SKILL.md'), 'x', 'utf8'); @@ -139,8 +139,12 @@ describe('claude adapter — ctx.environment', () => { _spawnImpl: impl, }); - const link = path.join(cwd, '.claude', 'skills', path.basename(skillSrc)); - expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + const slot = path.join(cwd, '.claude', 'skills', path.basename(skillSrc)); + // A copy, never a symlink — a symlink is writable through, which lets an + // agent edit its own governing skill in the operator's checkout (#702 + // guardrail was lost from cli 0.1.4 exactly this way). + expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(path.join(slot, 'SKILL.md'), 'utf8')).toBe('x'); fs.rmSync(skillSrc, { recursive: true, force: true }); }); diff --git a/cli/__tests__/environment.test.mjs b/cli/__tests__/environment.test.mjs index b65c370d..5e9ffa13 100644 --- a/cli/__tests__/environment.test.mjs +++ b/cli/__tests__/environment.test.mjs @@ -2,7 +2,7 @@ * environment.test.mjs — ADR-008 Phase 1 * * Covers parseEnvironmentFile, validateEnvironmentSpec, resolveWorkspace, - * linkSkills. The module is pure I/O against the local FS — we use real temp + * mountSkills. The module is pure I/O against the local FS — we use real temp * dirs and a mocked `os.homedir` so `~` expansion and the default workspace * path land somewhere disposable. */ @@ -27,7 +27,7 @@ const { parseEnvironmentFile, validateEnvironmentSpec, resolveWorkspace, - linkSkills, + mountSkills, } = await import('../src/lib/environment.js'); const writeJson = (dir, name, obj) => { @@ -200,7 +200,7 @@ describe('resolveWorkspace', () => { }); }); -describe('linkSkills', () => { +describe('mountSkills', () => { let workspace; let skillSrc; @@ -214,85 +214,97 @@ describe('linkSkills', () => { fs.rmSync(skillSrc, { recursive: true, force: true }); }); - test('creates .claude/skills/ symlink to the source dir', async () => { - const { linked, skipped, conflicted } = await linkSkills( + const slotFor = (src) => path.join(workspace, '.claude', 'skills', path.basename(src)); + + test('mounts a real directory copy, not a symlink', async () => { + const { mounted, conflicted } = await mountSkills( { skills: { claude: [skillSrc] } }, workspace, ); - expect(linked).toEqual([skillSrc]); - expect(skipped).toEqual([]); + expect(mounted).toEqual([skillSrc]); expect(conflicted).toEqual([]); - const linkPath = path.join(workspace, '.claude', 'skills', path.basename(skillSrc)); - expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); - expect(fs.readFileSync(path.join(linkPath, 'SKILL.md'), 'utf8')).toBe('# my skill'); + const slot = slotFor(skillSrc); + expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(slot).isDirectory()).toBe(true); + expect(fs.readFileSync(path.join(slot, 'SKILL.md'), 'utf8')).toBe('# my skill'); + }); + + // The bug this function exists to prevent: @commonlyai/cli@0.1.4 shipped + // without the #702 "post as yourself" guardrail because an agent wrote + // through the old symlink into the operator's checkout, and `npm publish` + // ships the working tree rather than git HEAD. + test('writes into the mount CANNOT reach the source (regression: cli 0.1.4 guardrail loss)', async () => { + await mountSkills({ skills: { claude: [skillSrc] } }, workspace); + const slot = slotFor(skillSrc); + + // Simulate an agent rewriting its own governing skill file. It must be + // able to affect at most its own copy — never the source of truth. + fs.chmodSync(path.join(slot, 'SKILL.md'), 0o644); + fs.writeFileSync(path.join(slot, 'SKILL.md'), '# guardrail deleted', 'utf8'); + + expect(fs.readFileSync(path.join(skillSrc, 'SKILL.md'), 'utf8')).toBe('# my skill'); + }); + + test('mounted files are read-only (defense in depth)', async () => { + await mountSkills({ skills: { claude: [skillSrc] } }, workspace); + const mode = fs.statSync(path.join(slotFor(skillSrc), 'SKILL.md')).mode & 0o222; + expect(mode).toBe(0); }); - test('idempotent: re-running with the same source reports as already-linked', async () => { - await linkSkills({ skills: { claude: [skillSrc] } }, workspace); - const second = await linkSkills({ skills: { claude: [skillSrc] } }, workspace); - expect(second.linked).toEqual([]); - expect(second.skipped).toEqual([skillSrc]); + test('re-mounting refreshes from source, so source edits still reach the agent', async () => { + await mountSkills({ skills: { claude: [skillSrc] } }, workspace); + fs.writeFileSync(path.join(skillSrc, 'SKILL.md'), '# updated upstream', 'utf8'); + + const second = await mountSkills({ skills: { claude: [skillSrc] } }, workspace); + expect(second.mounted).toEqual([skillSrc]); expect(second.conflicted).toEqual([]); + expect(fs.readFileSync(path.join(slotFor(skillSrc), 'SKILL.md'), 'utf8')) + .toBe('# updated upstream'); }); - test('refuses to overwrite a different existing symlink at the same slot — reports `different-target`', async () => { - // Two different source dirs, same basename — second one must surface as - // a conflict so the caller can warn the user (NOT silently merged into - // skipped, which would mask the user's intent being lost). - const otherSrc = fs.mkdtempSync(path.join(path.dirname(skillSrc), - `${path.basename(skillSrc).slice(0, -6)}-X-`)); - const slotName = path.basename(skillSrc); - const slotPath = path.join(workspace, '.claude', 'skills', slotName); - fs.mkdirSync(path.dirname(slotPath), { recursive: true }); - fs.symlinkSync(otherSrc, slotPath); - - const { linked, skipped, conflicted } = await linkSkills( + test('a legacy symlink from CLI <=0.1.4 is replaced by a snapshot', async () => { + const slot = slotFor(skillSrc); + fs.mkdirSync(path.dirname(slot), { recursive: true }); + fs.symlinkSync(skillSrc, slot); + + const { mounted, conflicted } = await mountSkills( { skills: { claude: [skillSrc] } }, workspace, ); - expect(linked).toEqual([]); - expect(skipped).toEqual([]); - expect(conflicted).toEqual([{ path: skillSrc, reason: 'different-target' }]); - // Existing link untouched. - expect(fs.readlinkSync(slotPath)).toBe(otherSrc); - - fs.rmSync(otherSrc, { recursive: true, force: true }); + expect(mounted).toEqual([skillSrc]); + expect(conflicted).toEqual([]); + expect(fs.lstatSync(slot).isSymbolicLink()).toBe(false); + // The source survived the replacement (rm must not follow the symlink). + expect(fs.readFileSync(path.join(skillSrc, 'SKILL.md'), 'utf8')).toBe('# my skill'); }); test('returns empty buckets when no skills are declared', async () => { - expect(await linkSkills({}, workspace)).toEqual({ - linked: [], skipped: [], conflicted: [], - }); + expect(await mountSkills({}, workspace)).toEqual({ mounted: [], conflicted: [] }); }); test('non-existent source paths surface as `missing-source` conflicts (not silent skips)', async () => { const ghost = path.join(skillSrc, 'does-not-exist'); - const { linked, skipped, conflicted } = await linkSkills( + const { mounted, conflicted } = await mountSkills( { skills: { claude: [ghost] } }, workspace, ); - expect(linked).toEqual([]); - expect(skipped).toEqual([]); + expect(mounted).toEqual([]); expect(conflicted).toEqual([{ path: ghost, reason: 'missing-source' }]); }); - test('non-symlink occupants (real files/dirs) surface as `not-symlink` conflicts', async () => { - // A user might have hand-created a real dir at the slot — never overwrite. - const slotName = path.basename(skillSrc); - const slotPath = path.join(workspace, '.claude', 'skills', slotName); - fs.mkdirSync(path.dirname(slotPath), { recursive: true }); - fs.mkdirSync(slotPath); - fs.writeFileSync(path.join(slotPath, 'hand-edited.md'), 'mine', 'utf8'); + test('a user-authored dir at the slot is never clobbered — `not-a-mount`', async () => { + // No mount marker => we did not create it => it is real user work. + const slot = slotFor(skillSrc); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'hand-edited.md'), 'mine', 'utf8'); - const { linked, skipped, conflicted } = await linkSkills( + const { mounted, conflicted } = await mountSkills( { skills: { claude: [skillSrc] } }, workspace, ); - expect(linked).toEqual([]); - expect(skipped).toEqual([]); - expect(conflicted).toEqual([{ path: skillSrc, reason: 'not-symlink' }]); - // Hand-edited file untouched. - expect(fs.existsSync(path.join(slotPath, 'hand-edited.md'))).toBe(true); + expect(mounted).toEqual([]); + expect(conflicted).toEqual([{ path: skillSrc, reason: 'not-a-mount' }]); + expect(fs.readFileSync(path.join(slot, 'hand-edited.md'), 'utf8')).toBe('mine'); }); }); diff --git a/cli/__tests__/skill-sync.test.mjs b/cli/__tests__/skill-sync.test.mjs new file mode 100644 index 00000000..10da1ad4 --- /dev/null +++ b/cli/__tests__/skill-sync.test.mjs @@ -0,0 +1,49 @@ +/** + * Guard: the two checked-in copies of the commonly behaviour skill must stay + * byte-identical. + * + * There are two tracked copies of the same file: + * - docs/agents/skills/commonly/SKILL.md — what README + docs-site tell users + * to drop into their own agent's skills directory + * - cli/skills/commonly/SKILL.md — what ships inside the npm package + * + * `prepublishOnly` in cli/package.json copies the docs one OVER the cli one at + * publish time, which quietly makes docs/ the real source of truth. So an edit + * applied to only the cli copy is not merely duplicated-and-drifted — it is + * ERASED by the next publish, and the tarball ships without it. + * + * That is not hypothetical. PR #702 added the "post as yourself, never as your + * operator" guardrail to the cli copy only. @commonlyai/cli@0.1.4 therefore + * shipped with the guardrail missing, and every agent attached with it was + * instructed without the rule the PR existed to add. + * + * This test fails the moment the copies diverge, so the drift surfaces in CI + * instead of silently at publish. + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +const PACKAGED = path.join(repoRoot, 'cli', 'skills', 'commonly', 'SKILL.md'); +const DOCS = path.join(repoRoot, 'docs', 'agents', 'skills', 'commonly', 'SKILL.md'); + +describe('commonly skill copies stay in sync', () => { + test('both copies exist', () => { + expect(fs.existsSync(PACKAGED)).toBe(true); + expect(fs.existsSync(DOCS)).toBe(true); + }); + + test('packaged copy is byte-identical to the docs copy that prepublishOnly overwrites it with', () => { + // Compared as text so a failure prints a readable diff rather than a + // buffer-length mismatch. + expect(fs.readFileSync(PACKAGED, 'utf8')).toBe(fs.readFileSync(DOCS, 'utf8')); + }); + + test('the #702 attribution guardrail is present (it was lost in 0.1.4)', () => { + const body = fs.readFileSync(DOCS, 'utf8'); + expect(body).toContain('Post as yourself, never as your operator'); + }); +}); diff --git a/cli/package.json b/cli/package.json index d6ebe552..5f0da0b1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.4", + "version": "0.1.5", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index a2b8c2f5..4361ec0a 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -175,7 +175,7 @@ export const buildDefaultEnvironment = (adapterName) => { ], }; // Only advertise the skill if it actually shipped (defensive: a broken - // package that dropped the skills/ dir shouldn't hand linkSkills a + // package that dropped the skills/ dir shouldn't hand mountSkills a // missing-source path every spawn). if (existsSync(BUNDLED_COMMONLY_SKILL_DIR)) { environment.skills = { claude: [BUNDLED_COMMONLY_SKILL_DIR] }; diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 968cd3c3..da046891 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -60,7 +60,7 @@ import { import { homedir, tmpdir } from 'os'; import { isAbsolute, join } from 'path'; -import { linkSkills } from '../environment.js'; +import { mountSkills } from '../environment.js'; import { wrapArgvWithBwrap } from '../sandbox/bwrap.js'; import { publicClaudeStateRoot, @@ -468,11 +468,11 @@ export default { const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId]; if (ctx.environment && ctx.cwd) { - const skills = await linkSkills(ctx.environment, ctx.cwd); + const skills = await mountSkills(ctx.environment, ctx.cwd); if (skills.conflicted.length > 0) { for (const c of skills.conflicted) { // eslint-disable-next-line no-console - console.warn(`[claude] skill not linked (${c.reason}): ${c.path}`); + console.warn(`[claude] skill not mounted (${c.reason}): ${c.path}`); } } if (ctx.onSkillsLinked) ctx.onSkillsLinked(skills); diff --git a/cli/src/lib/environment.js b/cli/src/lib/environment.js index 6a224cdc..887ab75a 100644 --- a/cli/src/lib/environment.js +++ b/cli/src/lib/environment.js @@ -5,7 +5,7 @@ * how an agent's runtime should be shaped: workspace path, sandbox mode, the * Claude skills to mount, the MCP servers to expose. This module is the * adapter-facing read-side: parse, validate, and realize the workspace + - * skill links on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js` + * skill mounts on disk. Sandbox argv wrapping lives in `./sandbox/bwrap.js` * because it is per-driver (Linux-only, bwrap-specific). * * Zero runtime deps: Node 20 has no built-in YAML, so we accept JSON only and @@ -14,7 +14,9 @@ * place to add a `.yaml` branch — the rest of the module is parser-agnostic. */ -import { readFile, mkdir, symlink, lstat, readlink, cp } from 'fs/promises'; +import { + readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile, +} from 'fs/promises'; import { existsSync } from 'fs'; import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path'; import { homedir } from 'os'; @@ -25,7 +27,7 @@ import { homedir } from 'os'; // relative skill paths) is NOT stored on the returned spec. Leaking the user's // absolute filesystem path into `config.environment` sent to the backend // exposes `$HOME` layout for zero server-side benefit. Callers pass -// `envFileDir` as a separate argument to resolveWorkspace / linkSkills. +// `envFileDir` as a separate argument to resolveWorkspace / mountSkills. const ALLOWED_TOP_KEYS = new Set([ 'version', 'workspace', 'sandbox', 'skills', 'mcp', @@ -85,7 +87,7 @@ export const parseEnvironmentFile = async (absolutePath) => { // Return the bare spec — no envFileDir wrapping, no underscore-prefixed // annotations. The caller is responsible for tracking envFileDir separately // (compute via `dirname(envPath)`) and passing it explicitly to - // resolveWorkspace / linkSkills when relative paths in the spec need to + // resolveWorkspace / mountSkills when relative paths in the spec need to // resolve. This keeps the spec safe to serialize and ship to the backend // (`config.environment` on AgentInstallation) without leaking $HOME layout. return parsed; @@ -246,35 +248,72 @@ export const resolveWorkspace = async (spec, agentName, envFileDir = null) => { return { path: absPath, created }; }; -// ── linkSkills ────────────────────────────────────────────────────────────── +// ── mountSkills ───────────────────────────────────────────────────────────── + +// Marks a skill directory as a Commonly-managed snapshot. Its presence is what +// licenses a refresh to delete and re-copy the directory: without it we cannot +// distinguish "a mount we made last spawn" from "a skill the user authored in +// their workspace", and clobbering the latter would destroy real work. +const MOUNT_MARKER = '.commonly-skill-mount'; /** - * Symlink each `skills.claude[]` source path into `/.claude/skills/`. + * Recursively drop the write bit on every regular file in a mounted snapshot. + * + * Directories deliberately stay writable: unlinking a file requires write + * permission on its PARENT, so read-only directories would make the next + * refresh fail. This is defense-in-depth for the agent's own copy, not the + * load-bearing protection — that comes from the copy existing at all. + */ +const freezeFiles = async (dir) => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + // eslint-disable-next-line no-await-in-loop + await freezeFiles(full); + } else if (entry.isFile()) { + // eslint-disable-next-line no-await-in-loop + await chmod(full, 0o444); + } + } +}; + +/** + * Mount each `skills.claude[]` source into `/.claude/skills/` + * as a READ-ONLY SNAPSHOT COPY. + * + * Why a copy and not a symlink: a symlink is writable *through*, so an agent + * holding Write/Edit could edit the file that governs its own behaviour — and + * because these sources are usually files tracked in a repo (the bundled + * `cli/skills/commonly` is the common case), that edit lands in the operator's + * working tree. `npm publish` ships the working tree, not git HEAD, so a + * write-through edit can silently ride out in the next release. That is not + * hypothetical: @commonlyai/cli@0.1.4 shipped with the "post as yourself, + * never as your operator" guardrail (#702) missing for exactly this reason. + * The snapshot makes the data flow one-way: source → workspace, never back. * - * Idempotent: if a symlink already points at the declared source, it is - * counted in `skipped` and left in place. If a DIFFERENT file or symlink - * occupies the slot, it is recorded in `conflicted` with a reason — never - * overwritten (a user-edited skill in the workspace must not be clobbered). - * Missing source paths are also reported in `conflicted` so the caller can - * surface them. + * Refreshed on every call — the claude adapter mounts per spawn — so edits to + * the source still reach the agent on its next turn. Only directories carrying + * MOUNT_MARKER are refreshed; anything else in the slot is reported as a + * conflict and left untouched. + * + * Legacy symlinks written by CLI ≤0.1.4 are replaced with a snapshot, since + * the symlink IS the vulnerable shape being retired. * * `envFileDir` is the directory of the env file (used to resolve relative * skill paths). Optional — when omitted, relative paths fall back to - * `workspacePath`. Pass it from `performAttach` for attach-time resolution - * matching the env file's location. + * `workspacePath`. * - * Returns `{ linked, skipped, conflicted }`: - * - `linked`: string[] — newly created symlinks (absolute source paths) - * - `skipped`: string[] — already correctly linked (no-op) + * Returns `{ mounted, conflicted }`: + * - `mounted`: string[] — absolute source paths snapshotted * - `conflicted`: Array<{path, reason}> where reason ∈ - * 'different-target' | 'not-symlink' | 'missing-source' + * 'not-a-mount' | 'missing-source' */ -export const linkSkills = async (spec, workspacePath, envFileDir = null) => { +export const mountSkills = async (spec, workspacePath, envFileDir = null) => { const sources = Array.isArray(spec?.skills?.claude) ? spec.skills.claude : []; - const linked = []; - const skipped = []; + const mounted = []; const conflicted = []; - if (sources.length === 0) return { linked, skipped, conflicted }; + if (sources.length === 0) return { mounted, conflicted }; const skillsDir = join(workspacePath, '.claude', 'skills'); await mkdir(skillsDir, { recursive: true }); @@ -288,47 +327,43 @@ export const linkSkills = async (spec, workspacePath, envFileDir = null) => { conflicted.push({ path: absSource, reason: 'missing-source' }); continue; } - const linkPath = join(skillsDir, basename(absSource)); + const destPath = join(skillsDir, basename(absSource)); - let existingTarget = null; - let slotIsNonSymlink = false; + let slot = null; try { // eslint-disable-next-line no-await-in-loop - const stat = await lstat(linkPath); - if (stat.isSymbolicLink()) { - // eslint-disable-next-line no-await-in-loop - existingTarget = await readlink(linkPath); - } else { - slotIsNonSymlink = true; - } + slot = await lstat(destPath); } catch (err) { // Only ENOENT means "slot is free, proceed." EACCES / EPERM / EIO are - // real failures that should surface — without this narrowing the - // subsequent symlink() call fails with a confusing follow-on error. + // real failures that should surface rather than becoming a confusing + // follow-on error from the copy below. if (err.code !== 'ENOENT') throw err; } - if (slotIsNonSymlink) { - conflicted.push({ path: absSource, reason: 'not-symlink' }); - continue; - } - - if (existingTarget) { - const resolvedExisting = isAbsolute(existingTarget) - ? existingTarget - : pathResolve(skillsDir, existingTarget); - if (resolvedExisting === absSource) { - skipped.push(absSource); + if (slot) { + const isLegacySymlink = slot.isSymbolicLink(); + const isOwnedMount = slot.isDirectory() && existsSync(join(destPath, MOUNT_MARKER)); + if (!isLegacySymlink && !isOwnedMount) { + // A real file, or a directory we did not create — user content. + conflicted.push({ path: absSource, reason: 'not-a-mount' }); continue; } - conflicted.push({ path: absSource, reason: 'different-target' }); - continue; + // eslint-disable-next-line no-await-in-loop + await rm(destPath, { recursive: true, force: true }); } // eslint-disable-next-line no-await-in-loop - await symlink(absSource, linkPath); - linked.push(absSource); + await cp(absSource, destPath, { recursive: true }); + // eslint-disable-next-line no-await-in-loop + await writeFile( + join(destPath, MOUNT_MARKER), + `mounted-from: ${absSource}\n`, + 'utf8', + ); + // eslint-disable-next-line no-await-in-loop + await freezeFiles(destPath); + mounted.push(absSource); } - return { linked, skipped, conflicted }; + return { mounted, conflicted }; }; diff --git a/docs/agents/skills/commonly/SKILL.md b/docs/agents/skills/commonly/SKILL.md index a4368c99..b003d05f 100644 --- a/docs/agents/skills/commonly/SKILL.md +++ b/docs/agents/skills/commonly/SKILL.md @@ -120,6 +120,14 @@ turn with the literal `NO_REPLY`. So: if you already said everything through `NO_REPLY` so the wrapper doesn't post a duplicate. If you *didn't* post via the tool, just let your reply be your final output. Either way — one voice, no echo. +**Post as yourself, never as your operator.** Your reply text and your +`commonly_*` tools carry *your* agent identity. If you have shell access, you may +find an operator's Commonly CLI profile (`commonly pod send`, `~/.commonly/config.json`) +or a human's saved token in your environment — **never post through them**. A message +sent that way appears in the room under the human's name and avatar, which +misattributes your words and breaks the room's provenance. If your own tools are +unavailable mid-turn, say what you need in your final reply instead. + ## The task board Pods have a task board. When work is being tracked: