diff --git a/.claude/skills/afx/SKILL.md b/.claude/skills/afx/SKILL.md index 14212783d..84c7feb29 100644 --- a/.claude/skills/afx/SKILL.md +++ b/.claude/skills/afx/SKILL.md @@ -257,5 +257,6 @@ afx open # Open file in annotation viewer (NOT system ope afx shell # Spawn utility shell afx attach # Attach to running builder terminal afx rename # Rename current shell session +afx decisions init # Create the decisions file; turns on the Decisions tab afx architect # Start architect session in current terminal ``` diff --git a/.codex/skills/afx/SKILL.md b/.codex/skills/afx/SKILL.md index 14212783d..84c7feb29 100644 --- a/.codex/skills/afx/SKILL.md +++ b/.codex/skills/afx/SKILL.md @@ -257,5 +257,6 @@ afx open # Open file in annotation viewer (NOT system ope afx shell # Spawn utility shell afx attach # Attach to running builder terminal afx rename # Rename current shell session +afx decisions init # Create the decisions file; turns on the Decisions tab afx architect # Start architect session in current terminal ``` diff --git a/codev-skeleton/.claude/skills/afx/SKILL.md b/codev-skeleton/.claude/skills/afx/SKILL.md index be491012c..d61b911f1 100644 --- a/codev-skeleton/.claude/skills/afx/SKILL.md +++ b/codev-skeleton/.claude/skills/afx/SKILL.md @@ -235,5 +235,6 @@ afx open # Open file in annotation viewer (NOT system ope afx shell # Spawn utility shell afx attach # Attach to running builder terminal afx rename # Rename current shell session +afx decisions init # Create the decisions file; turns on the Decisions tab afx architect # Start architect session in current terminal ``` diff --git a/codev-skeleton/.codex/skills/afx/SKILL.md b/codev-skeleton/.codex/skills/afx/SKILL.md index be491012c..d61b911f1 100644 --- a/codev-skeleton/.codex/skills/afx/SKILL.md +++ b/codev-skeleton/.codex/skills/afx/SKILL.md @@ -235,5 +235,6 @@ afx open # Open file in annotation viewer (NOT system ope afx shell # Spawn utility shell afx attach # Attach to running builder terminal afx rename # Rename current shell session +afx decisions init # Create the decisions file; turns on the Decisions tab afx architect # Start architect session in current terminal ``` diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index f42808ba9..eacaa8a01 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -491,6 +491,21 @@ Dismissal is CLI-only; the dashboard and VSCode held-count indicators surface th --- +### afx decisions init + +Turn on the dashboard's Decisions tab (Issue #1840). + +```bash +afx decisions init [--path ] +``` + +**Options:** +- `--path ` - File to create, relative to the workspace root (default: `dashboard.decisionsFile`, else `codev/state/pending-decisions.md`) + +Creates the decisions file from its template and prints the sentence to tell the main architect. It never replaces an existing file, and refuses a path outside the workspace. See [Decisions tab](#decisions-tab). + +--- + ### afx interrupt Interrupt a builder mid-turn by sending an ESC keystroke to its PTY. @@ -934,6 +949,8 @@ Both are Tower-global (they apply to the whole Tower, not per-project) and optio ### Decisions tab +Turn the tab on with `afx decisions init`. It creates the decisions file from its template, prints where it went, and gives the one sentence to tell the main architect: "Route every ask for my word through the decisions file." It refuses a file that already exists, because that file holds the owner's rulings, and a path outside the workspace. `--path ` creates another file, relative to the workspace root; the tab shows it once `dashboard.decisionsFile` names it. + When a workspace has a pending-decisions file, the dashboard shows a **Decisions** tab (Spec 1819). The tab is a view of that file. Each item under `## Open` becomes a card with a click box per option (the first option, the recommended one, is marked) and a comment box. Submitting writes the chosen word, then the comment, on the lines right after the item's `**Your word:**` line, exactly as the owner would type them, and changes nothing else. `## Ruled` is shown below as a collapsed, read-only list. The file is the only state. While the tab is open it re-reads the file every 2 seconds, so edits made elsewhere appear without a reload, and an unsent draft survives them. The file's format and the process around it are in [`decision-protocol.md`](../decision-protocol.md): one hub architect, `main`, is the only agent that edits the file, other architects send it their items, and the hub relays each ruling. The tab reads exactly the item format that document describes. diff --git a/codev-skeleton/resources/decision-protocol.md b/codev-skeleton/resources/decision-protocol.md index 484c9124b..85db58a8b 100644 --- a/codev-skeleton/resources/decision-protocol.md +++ b/codev-skeleton/resources/decision-protocol.md @@ -191,8 +191,9 @@ A second question gets a second pair of lines under the first. ## 4. The file -Create it at `codev/state/pending-decisions.md`, or at the path `dashboard.decisionsFile` names; -the Decisions tab appears once the file exists. +Create it with `afx decisions init`, which writes the template below to +`codev/state/pending-decisions.md`, or to the path `dashboard.decisionsFile` names, and never +replaces a file that is already there. The Decisions tab appears once the file exists. ```markdown # Pending owner decisions — all architects (hub: main) diff --git a/codev-skeleton/roles/architect.md b/codev-skeleton/roles/architect.md index 300db825e..37242f5f6 100644 --- a/codev-skeleton/roles/architect.md +++ b/codev-skeleton/roles/architect.md @@ -70,8 +70,8 @@ human should be able to answer from your message without opening the diff. ### The owner decisions file A workspace may collect every ask for the human's word in `codev/state/pending-decisions.md`, -the dashboard's **Decisions** tab, with one architect named `main` as the hub and its only -editor. The process and the item format are in `codev/resources/decision-protocol.md`. +the dashboard's **Decisions** tab, with the architect `main` as hub and only editor. When the +owner asks to use decisions, run `afx decisions init` and follow `codev/resources/decision-protocol.md`. ## UX verification diff --git a/codev/projects/1840-afx-decisions-init-one-command/status.yaml b/codev/projects/1840-afx-decisions-init-one-command/status.yaml new file mode 100644 index 000000000..cbfe528ea --- /dev/null +++ b/codev/projects/1840-afx-decisions-init-one-command/status.yaml @@ -0,0 +1,17 @@ +id: '1840' +title: afx-decisions-init-one-command +protocol: air +phase: pr +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: approved + requested_at: '2026-10-19T17:35:05.884Z' + approved_at: '2026-10-19T18:32:28.632Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-19T17:21:36.050Z' +updated_at: '2026-10-19T18:32:28.633Z' +pr_ready_for_human: false diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index d9ab097a3..87cc2e1cc 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -682,6 +682,21 @@ Dismissal is CLI-only; the dashboard and VSCode held-count indicators surface th --- +### afx decisions init + +Turn on the dashboard's Decisions tab (Issue #1840). + +```bash +afx decisions init [--path ] +``` + +**Options:** +- `--path ` - File to create, relative to the workspace root (default: `dashboard.decisionsFile`, else `codev/state/pending-decisions.md`) + +Creates the decisions file from its template and prints the sentence to tell the main architect. It never replaces an existing file, and refuses a path outside the workspace. See [Decisions tab](#decisions-tab). + +--- + ### afx interrupt Interrupt a builder mid-turn by sending an ESC keystroke to its PTY. @@ -1164,6 +1179,8 @@ Both are Tower-global (they apply to the whole Tower, not per-project) and optio ### Decisions tab +Turn the tab on with `afx decisions init`. It creates the decisions file from its template, prints where it went, and gives the one sentence to tell the main architect: "Route every ask for my word through the decisions file." It refuses a file that already exists, because that file holds the owner's rulings, and a path outside the workspace. `--path ` creates another file, relative to the workspace root; the tab shows it once `dashboard.decisionsFile` names it. + When a workspace has a pending-decisions file, the dashboard shows a **Decisions** tab (Spec 1819). The tab is a view of that file. Each item under `## Open` becomes a card with a click box per option (the first option, the recommended one, is marked) and a comment box. Submitting writes the chosen word, then the comment, on the lines right after the item's `**Your word:**` line, exactly as the owner would type them, and changes nothing else. `## Ruled` is shown below as a collapsed, read-only list. The file is the only state. While the tab is open it re-reads the file every 2 seconds, so edits made elsewhere appear without a reload, and an unsent draft survives them. The file's format and the process around it are in [`decision-protocol.md`](../decision-protocol.md): one hub architect, `main`, is the only agent that edits the file, other architects send it their items, and the hub relays each ruling. The tab reads exactly the item format that document describes. diff --git a/codev/resources/decision-protocol.md b/codev/resources/decision-protocol.md index 484c9124b..85db58a8b 100644 --- a/codev/resources/decision-protocol.md +++ b/codev/resources/decision-protocol.md @@ -191,8 +191,9 @@ A second question gets a second pair of lines under the first. ## 4. The file -Create it at `codev/state/pending-decisions.md`, or at the path `dashboard.decisionsFile` names; -the Decisions tab appears once the file exists. +Create it with `afx decisions init`, which writes the template below to +`codev/state/pending-decisions.md`, or to the path `dashboard.decisionsFile` names, and never +replaces a file that is already there. The Decisions tab appears once the file exists. ```markdown # Pending owner decisions — all architects (hub: main) diff --git a/codev/roles/architect.md b/codev/roles/architect.md index 300db825e..37242f5f6 100644 --- a/codev/roles/architect.md +++ b/codev/roles/architect.md @@ -70,8 +70,8 @@ human should be able to answer from your message without opening the diff. ### The owner decisions file A workspace may collect every ask for the human's word in `codev/state/pending-decisions.md`, -the dashboard's **Decisions** tab, with one architect named `main` as the hub and its only -editor. The process and the item format are in `codev/resources/decision-protocol.md`. +the dashboard's **Decisions** tab, with the architect `main` as hub and only editor. When the +owner asks to use decisions, run `afx decisions init` and follow `codev/resources/decision-protocol.md`. ## UX verification diff --git a/codev/state/air-1840_thread.md b/codev/state/air-1840_thread.md new file mode 100644 index 000000000..e44d32f90 --- /dev/null +++ b/codev/state/air-1840_thread.md @@ -0,0 +1,41 @@ +# air-1840 — `afx decisions init` + +## 2026-10-19 — implement + +- Template lives in `servers/decisions/template.ts` (`DECISIONS_FILE_TEMPLATE`); the doc test pins + section 4's fenced block to it (`fileSkeleton + '\n' === constant`). Kept the doc's line order + (`_Status:` then `_Zone:`), which is what #1837 shipped and what the existing test pins — the + issue lists `_Zone` before `_Status`, read as a list of contents, not an order. +- Command: `commands/decisions.ts`. Resolution and containment reuse `configuredDecisionsPath` + + `resolveDecisionsPath` from `servers/decisions/file.ts`, so the command and the tab apply one + rule. The write is `flag: 'wx'`, so an existing file, directory or dangling symlink at the path + is refused without a check-then-write race. +- `--path` must be relative (same rule as `dashboard.decisionsFile`), so the printed path can go + into config verbatim. +- Judgment call: when `--path` names a file the tab does not read (it differs from the configured + path), the command does not claim the tab will appear; it prints the `dashboard.decisionsFile` + setting that shows it instead. Writing config is not in the issue, so it does not. +- Architect role: kept the section at three lines, as asked, by folding the doc pointer into the + new sentence. + +## 2026-10-19 — PR #1841, CMAP round 1 + +gemini=APPROVE, codex=REQUEST_CHANGES, claude=COMMENT (first run failed: `consult --type pr` from a +builder needs `--issue 1840`; it could not infer the project). + +Fixed: +- codex: the `--path` hint printed `"dashboard.decisionsFile": "…"`, which reads as a flat key; it + now prints the nested JSON `{"dashboard":{"decisionsFile":"…"}}`. +- claude: an absolute config aborted `init --path`, but a config resolving outside the workspace + did not. Now any config the tab refuses is refused here too, `--path` or not (fail loud). +- claude: added `### afx decisions init` to the command reference in both trees; the EEXIST message + no longer claims a directory "holds the owner's rulings". + +Not taken: listing the command in the afx skill's "Other commands" block (optional; that list is +already incomplete). `_Status:`/`_Zone:` order kept, which claude agreed with. + +## 2026-10-19 — architect review + +Architect (main) approved the integration review and asked for one line in the afx skill's +"Other commands" block: added to all four copies (`.claude`/`.codex`, both trees; each pair kept +byte-identical). pr gate stays pending; the architect takes the merge word to the owner. diff --git a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts index 40533252d..97bade775 100644 --- a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts @@ -14,6 +14,7 @@ import { parseDecisions } from '../servers/decisions/parse.js'; import { spliceRuling } from '../servers/decisions/splice.js'; import { rulingNotice } from '../servers/decisions/notice.js'; import { resolveStamp } from '../servers/decisions/stamp.js'; +import { DECISIONS_FILE_TEMPLATE } from '../servers/decisions/template.js'; const REPO = resolve(import.meta.dirname, '../../../../..'); const read = (rel: string) => readFileSync(resolve(REPO, rel), 'utf8'); @@ -49,6 +50,13 @@ describe('decision-protocol.md — its examples are what the tab parses', () => expect(parsed.items).toEqual([]); }); + it('the file skeleton is the template `afx decisions init` writes, and section 4 names the command above it (#1840)', () => { + expect(`${fileSkeleton}\n`).toBe(DECISIONS_FILE_TEMPLATE); + const section4 = doc.slice(doc.indexOf('## 4. The file\n')); + expect(section4.indexOf('`afx decisions init`')).toBeGreaterThan(0); + expect(section4.indexOf('`afx decisions init`')).toBeLessThan(section4.indexOf('```markdown')); + }); + it('the item template reads its id, question and options; the first option is recommended', () => { const [item] = parseDecisions(posted(template)).items; expect(item.id).toBe('-'); @@ -225,11 +233,13 @@ describe('decision-protocol.md — reaches both trees and is linked where adopte const rel = `${tree}/resources/commands/agent-farm.md`; const section = read(rel).match(/^### Decisions tab\n[\s\S]*?(?=^### )/m)?.[0] ?? ''; expect(section).toContain('](../decision-protocol.md)'); + expect(section.split('\n\n')[1]).toMatch(/^Turn the tab on with `afx decisions init`\./); expect(existsSync(resolve(REPO, dirname(rel), '../decision-protocol.md'))).toBe(true); }); it(`${tree}: the architect role points at the doc`, () => { expect(read(`${tree}/roles/architect.md`)).toContain('`codev/resources/decision-protocol.md`'); + expect(read(`${tree}/roles/architect.md`)).toContain('run `afx decisions init` and follow `codev/resources/decision-protocol.md`'); }); } diff --git a/packages/codev/src/agent-farm/__tests__/decisions-init.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-init.test.ts new file mode 100644 index 000000000..7d3d9c380 --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/decisions-init.test.ts @@ -0,0 +1,127 @@ +/** + * Issue #1840 — `afx decisions init` creates the decisions file from its template, at + * the path the tab reads, and refuses anything that would overwrite rulings or leave + * the workspace. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createDecisionsFile, decisionsInit, ARCHITECT_INSTRUCTION } from '../commands/decisions.js'; +import { DEFAULT_DECISIONS_FILE, decisionsFilePresent } from '../servers/decisions/file.js'; +import { DECISIONS_FILE_TEMPLATE } from '../servers/decisions/template.js'; +import { parseDecisions } from '../servers/decisions/parse.js'; + +let tmpDir: string; +let origHome: string | undefined; +let workspace: string; + +beforeEach(() => { + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'decisions-init-'))); + origHome = process.env.HOME; + process.env.HOME = path.join(tmpDir, 'home'); + workspace = path.join(tmpDir, 'workspace'); + fs.mkdirSync(path.join(workspace, 'codev'), { recursive: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.env.HOME = origHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeConfig(config: unknown): void { + fs.mkdirSync(path.join(workspace, '.codev'), { recursive: true }); + fs.writeFileSync(path.join(workspace, '.codev', 'config.json'), JSON.stringify(config)); +} + +const read = (rel: string) => fs.readFileSync(path.join(workspace, rel), 'utf8'); + +describe('afx decisions init', () => { + it('creates codev/state/ and the default file from the template, and the tab then shows', async () => { + expect(fs.existsSync(path.join(workspace, 'codev', 'state'))).toBe(false); + const result = await createDecisionsFile({ workspaceRoot: workspace }); + expect(result).toEqual({ displayPath: DEFAULT_DECISIONS_FILE, configuredPath: DEFAULT_DECISIONS_FILE }); + expect(read(DEFAULT_DECISIONS_FILE)).toBe(DECISIONS_FILE_TEMPLATE); + expect(parseDecisions(DECISIONS_FILE_TEMPLATE).problems).toEqual([]); + expect(await decisionsFilePresent(workspace, vi.fn())).toBe(true); + }); + + it('refuses an existing file and leaves its rulings as they are', async () => { + const file = path.join(workspace, DEFAULT_DECISIONS_FILE); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'the owner rulings\n'); + await expect(createDecisionsFile({ workspaceRoot: workspace })).rejects.toThrow( + `${DEFAULT_DECISIONS_FILE} already exists; init never replaces it`, + ); + expect(read(DEFAULT_DECISIONS_FILE)).toBe('the owner rulings\n'); + }); + + it('writes where dashboard.decisionsFile points', async () => { + writeConfig({ dashboard: { decisionsFile: 'docs/decisions.md' } }); + const result = await createDecisionsFile({ workspaceRoot: workspace }); + expect(result).toEqual({ displayPath: 'docs/decisions.md', configuredPath: 'docs/decisions.md' }); + expect(read('docs/decisions.md')).toBe(DECISIONS_FILE_TEMPLATE); + }); + + it('--path wins over dashboard.decisionsFile, resolved against the workspace root', async () => { + writeConfig({ dashboard: { decisionsFile: 'docs/decisions.md' } }); + const result = await createDecisionsFile({ workspaceRoot: workspace, path: './notes/../owner.md' }); + expect(result).toEqual({ displayPath: 'owner.md', configuredPath: 'docs/decisions.md' }); + expect(read('owner.md')).toBe(DECISIONS_FILE_TEMPLATE); + expect(fs.existsSync(path.join(workspace, 'docs'))).toBe(false); + }); + + it('refuses a path outside the workspace, through .. or through a symlink, and writes nothing', async () => { + await expect(createDecisionsFile({ workspaceRoot: workspace, path: '../outside.md' })).rejects.toThrow( + '../outside.md resolves outside the workspace', + ); + fs.mkdirSync(path.join(tmpDir, 'elsewhere')); + fs.symlinkSync(path.join(tmpDir, 'elsewhere'), path.join(workspace, 'codev', 'state')); + await expect(createDecisionsFile({ workspaceRoot: workspace })).rejects.toThrow('resolves outside the workspace'); + writeConfig({ dashboard: { decisionsFile: '../configured.md' } }); + await expect(createDecisionsFile({ workspaceRoot: workspace })).rejects.toThrow('resolves outside the workspace'); + // The config the tab refuses is refused here even when --path names a good file. + await expect(createDecisionsFile({ workspaceRoot: workspace, path: 'owner.md' })).rejects.toThrow( + 'dashboard.decisionsFile (../configured.md) resolves outside the workspace', + ); + expect(fs.existsSync(path.join(workspace, 'owner.md'))).toBe(false); + expect(fs.readdirSync(tmpDir).sort()).toEqual(['elsewhere', 'workspace']); + expect(fs.readdirSync(path.join(tmpDir, 'elsewhere'))).toEqual([]); + }); + + it('refuses an absolute or empty --path, and a broken dashboard.decisionsFile', async () => { + const absolute = path.join(workspace, 'owner.md'); + await expect(createDecisionsFile({ workspaceRoot: workspace, path: absolute })).rejects.toThrow( + '--path must be relative to the workspace root', + ); + await expect(createDecisionsFile({ workspaceRoot: workspace, path: ' ' })).rejects.toThrow('--path must be relative'); + writeConfig({ dashboard: { decisionsFile: absolute } }); + await expect(createDecisionsFile({ workspaceRoot: workspace })).rejects.toThrow( + 'dashboard.decisionsFile must be relative to the workspace root', + ); + expect(fs.existsSync(absolute)).toBe(false); + }); + + it('prints the path, that the tab appears, and the sentence for the architect', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + await decisionsInit({ workspaceRoot: workspace }); + const printed = log.mock.calls.map(call => call.join(' ')).join('\n'); + expect(printed).toContain(`Created ${DEFAULT_DECISIONS_FILE}`); + expect(printed).toContain('The Decisions tab appears in the dashboard within a few seconds.'); + expect(printed).toContain(`Tell your main architect: "${ARCHITECT_INSTRUCTION}"`); + expect(ARCHITECT_INSTRUCTION).toBe('Route every ask for my word through the decisions file.'); + }); + + it('a --path the tab does not read says which setting shows it', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + await decisionsInit({ workspaceRoot: workspace, path: 'owner.md' }); + const printed = log.mock.calls.map(call => call.join(' ')).join('\n'); + expect(printed).toContain('Created owner.md'); + expect(printed).toContain( + `The Decisions tab reads ${DEFAULT_DECISIONS_FILE}. Set {"dashboard":{"decisionsFile":"owner.md"}} in .codev/config.json`, + ); + expect(printed).not.toContain('appears in the dashboard within a few seconds'); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts b/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts index 30672a1d9..1bb6a4f4c 100644 --- a/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts +++ b/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts @@ -28,6 +28,7 @@ import { test, expect } from './tower-auth.js'; import type { Locator, Page } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, relative, resolve } from 'node:path'; @@ -43,6 +44,7 @@ if (!TOWER_PORT || TOWER_PORT === '4100') { const TOWER_URL = `http://localhost:${TOWER_PORT}`; const REPO_ROOT = resolve(import.meta.dirname, '../../../../../../'); const DEFAULT_FILE = 'codev/state/pending-decisions.md'; +const AFX = resolve(import.meta.dirname, '../../../../bin/afx.js'); const QUESTION = { main: 'Merge the onboarding scan fix?', @@ -358,6 +360,25 @@ test.describe('Spec 1819: Decisions tab', () => { await expect(page.getByRole('tab', { name: 'Work' })).toBeVisible(); await expect(page.getByRole('tab', { name: /Decisions/ })).toHaveCount(0); }); + + test('afx decisions init turns the tab on in an open dashboard, without a reload (#1840)', async ({ page }) => { + const ws = makeWorkspace({ 'codev/README.md': '# a workspace that has not used decisions\n' }); + await page.goto(ws.url); + await expect(page.getByRole('tab', { name: 'Work' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('tab', { name: /Decisions/ })).toHaveCount(0); + await page.evaluate(() => { (window as { loadedOnce?: boolean }).loadedOnce = true; }); + + const out = execFileSync(process.execPath, [AFX, 'decisions', 'init'], { cwd: ws.root, encoding: 'utf8' }); + expect(out).toContain(`Created ${DEFAULT_FILE}`); + expect(out).toContain('Tell your main architect: "Route every ask for my word through the decisions file."'); + + const tab = page.getByRole('tab', { name: /Decisions/ }); + await expect(tab).toBeVisible({ timeout: 10_000 }); + expect(await page.evaluate(() => (window as { loadedOnce?: boolean }).loadedOnce)).toBe(true); + await tab.click(); + await expect(page.locator('.decisions-path')).toHaveText(DEFAULT_FILE); + await screenshot(page, 'decisions-init-tab'); + }); }); test.describe('Issue #1836: time-only and missing stamps carry no warning', () => { diff --git a/packages/codev/src/agent-farm/cli.ts b/packages/codev/src/agent-farm/cli.ts index 4defc6fdf..65d6e8364 100644 --- a/packages/codev/src/agent-farm/cli.ts +++ b/packages/codev/src/agent-farm/cli.ts @@ -890,6 +890,25 @@ export async function runAgentFarm(args: string[]): Promise { } }); + // Decisions commands (Issue #1840) — the owner decisions file behind the Decisions tab + const decisionsCmd = program + .command('decisions') + .description("The owner decisions file behind the dashboard's Decisions tab"); + + decisionsCmd + .command('init') + .description('Create the decisions file from its template, which turns on the Decisions tab') + .option('--path ', 'File to create, relative to the workspace root (default: dashboard.decisionsFile, else codev/state/pending-decisions.md)') + .action(async (options) => { + const { decisionsInit } = await import('./commands/decisions.js'); + try { + await decisionsInit({ path: options.path }); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + // Team commands (Spec 587) — deprecated in favor of standalone `team` CLI (Spec 599) const teamCmd = program .command('team') diff --git a/packages/codev/src/agent-farm/commands/decisions.ts b/packages/codev/src/agent-farm/commands/decisions.ts new file mode 100644 index 000000000..13b1ddde6 --- /dev/null +++ b/packages/codev/src/agent-farm/commands/decisions.ts @@ -0,0 +1,71 @@ +/** + * `afx decisions init` (Issue #1840) — the one command that turns on the dashboard's + * Decisions tab: it creates the decisions file from its template. + * + * The target is `--path`, else `dashboard.decisionsFile`, else the default, relative to + * the workspace root and held to the tab's own rule: it must resolve, symlinks followed, + * inside the workspace. An existing file is never replaced — it holds the owner's rulings. + */ + +import { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import { configuredDecisionsPath, resolveDecisionsPath } from '../servers/decisions/file.js'; +import { DECISIONS_FILE_TEMPLATE } from '../servers/decisions/template.js'; +import { getConfig } from '../utils/config.js'; +import { logger } from '../utils/logger.js'; + +export const ARCHITECT_INSTRUCTION = 'Route every ask for my word through the decisions file.'; + +export interface DecisionsInitOptions { + /** The file to create, relative to the workspace root. */ + path?: string; + /** Defaults to the workspace this CLI runs in. */ + workspaceRoot?: string; +} + +export interface DecisionsInitResult { + /** The created file, relative to the workspace root. */ + displayPath: string; + /** The file the tab reads, relative to the workspace root. */ + configuredPath: string; +} + +/** Create the decisions file and return where it went. Throws, writing nothing, on any refusal. */ +export async function createDecisionsFile(options: DecisionsInitOptions = {}): Promise { + if (options.path !== undefined && (options.path.trim() === '' || path.isAbsolute(options.path))) { + throw new Error(`--path must be relative to the workspace root, got ${JSON.stringify(options.path)}`); + } + const workspaceRoot = options.workspaceRoot ?? getConfig().workspaceRoot; + const configured = await resolveDecisionsPath(workspaceRoot, configuredDecisionsPath(workspaceRoot, { fresh: true })); + const target = options.path === undefined ? configured : await resolveDecisionsPath(workspaceRoot, options.path); + if (!target.inside) { + throw new Error(`${target.displayPath} resolves outside the workspace (${target.canonicalPath}); the Decisions tab refuses it`); + } + if (!configured.inside) { + throw new Error(`dashboard.decisionsFile (${configured.displayPath}) resolves outside the workspace; the Decisions tab refuses it`); + } + + await fsp.mkdir(path.dirname(target.canonicalPath), { recursive: true }); + try { + await fsp.writeFile(target.canonicalPath, DECISIONS_FILE_TEMPLATE, { flag: 'wx' }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + throw new Error(`${target.displayPath} already exists; init never replaces it, because a decisions file holds the owner's rulings`); + } + return { displayPath: target.displayPath, configuredPath: configured.displayPath }; +} + +export async function decisionsInit(options: DecisionsInitOptions = {}): Promise { + const { displayPath, configuredPath } = await createDecisionsFile(options); + logger.success(`Created ${displayPath}`); + if (displayPath === configuredPath) { + logger.info('The Decisions tab appears in the dashboard within a few seconds.'); + } else { + const setting = JSON.stringify({ dashboard: { decisionsFile: displayPath } }); + logger.info( + `The Decisions tab reads ${configuredPath}. Set ${setting} in .codev/config.json, ` + + 'and the tab appears within a few seconds.', + ); + } + logger.info(`Tell your main architect: "${ARCHITECT_INSTRUCTION}"`); +} diff --git a/packages/codev/src/agent-farm/servers/decisions/template.ts b/packages/codev/src/agent-farm/servers/decisions/template.ts new file mode 100644 index 000000000..d72cae65f --- /dev/null +++ b/packages/codev/src/agent-farm/servers/decisions/template.ts @@ -0,0 +1,15 @@ +// The decisions file a workspace starts from (Issue #1840). `afx decisions init` writes +// it; section 4 of resources/decision-protocol.md shows it, and a test pins the two equal. + +export const DECISIONS_FILE_TEMPLATE = `# Pending owner decisions — all architects (hub: main) + +Rule inline: one word on the line under an item's **Your word:** line, or in the Decisions tab. +Newest first. Only main edits this file; architects send items with \`afx send architect:main 'DECISION …'\`. + +_Status: 0 open._ +_Zone: UTC_ + +## Open + +## Ruled +`;