fix(pi): align the doctor with the pi resolver, correct the strip rationale, update the skill

Second review pass on #282, the three items left open after f4dcfbe.

1. `codeman doctor` and the run mode disagreed about pi. The registry entry
   accepted a bare `which pi` hit while pi-cli-resolver demanded semver-shaped
   `--version` output, so the Dependencies panel could report an installed Pi CLI
   on a box where Run Pi stays hidden, which reads as a broken mode rather than a
   missing install. Both sides now share one exported PI_VERSION_REGEX, and
   PathResolver gains an opt-in `requireVersionMatch` so a binary that fails the
   shape check is reported MISSING instead of installed-with-unknown-version.
   Only pi sets it; every other tool keeps its current behaviour.

2. The isAltScreenStripMode comment justified excluding pi with "the alt screen
   is load-bearing for its fullscreen TUI". That is not what exclusion does: pi
   is tmux-backed, so it falls through to isMuxAltScreenOnlyStripMode, which
   strips the alt-screen toggles anyway. What exclusion actually preserves is
   `\x1b[3J` and the mouse DECSETs, which is the real reason (pi renders into the
   main screen and is mouse-aware). Comment and changeset now say that, and state
   the consequence: fullscreen pi paints into the main buffer, like vim in a tmux
   shell session.

3. skills/codeman still enumerated the five pre-pi modes in nine places, telling
   agents a backend does not exist and understating class-wide caveats by one
   mode. All updated, plus stale session.ts line references refreshed.

Tests: a new static guard derives the mode set from the Zod schema (not a copy)
and fails when a skill enumeration lists a partial set of external CLIs, verified
by mutation. It also documents the one legitimate exception it found: the "writes
no transcript" lists drop codex, which does write a rollout Codeman reads back.
Plus doctor cases for an unrelated `pi` on PATH and registry/resolver regex parity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-13 17:41:44 +02:00
parent f4dcfbe6ca
commit 86c78fece3
11 changed files with 242 additions and 23 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview Static guard: the packaged agent skill's run-mode enumerations stay in
* step with the modes the server actually accepts.
*
* `skills/codeman/**` is injected into cases and read by agents driving Codeman over
* HTTP, so a mode missing from its lists is not cosmetic: the agent is told a backend
* does not exist, or that a whole-class caveat ("these modes write no transcript")
* covers four modes when it covers five. Adding pi (#206) left every one of those lists
* stale while CI stayed green, because nothing tied the prose to the schema.
*
* Two rules, both derived from the RUNTIME source of truth (the Zod enum in schemas.ts,
* not a copy):
*
* 1. The `mode ∈ a|b|c` enumeration in endpoints.md is the mode list, exactly.
* 2. Any prose enumeration of 3+ distinct modes must be COMPLETE with respect to the
* external CLIs: those lists exist to describe what `isExternalCliMode()` gates
* (no Claude transcript, no hooks, no Claude-format parsers), so naming some but
* not all of them is the drift itself. Runs of one or two modes are exempt, since
* a legitimate pair ("claude or shell") is not a class claim. ONE exception is
* allowed and it is a real one: the "writes no transcript" lists drop `codex`,
* which does write a rollout Codeman reads back (the pane carries a unique
* originator precisely so `last-response` can find it), so external-minus-codex
* is a meaningful class rather than an oversight.
*
* Port: N/A (pure static analysis).
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { isExternalCliMode } from '../src/session.js';
import type { SessionMode } from '../src/types/session.js';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const SKILL_DIR = join(HERE, '../skills/codeman');
const SKILL_FILES = ['SKILL.md', 'reference/endpoints.md', 'reference/messaging.md', 'reference/recipes.md'];
/** Modes the API actually accepts, read off the schema rather than restated here. */
function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchema): SessionMode[] {
// `mode` is `z.enum([...]).optional()`; unwrap the optional to reach `.options`.
return (schema as unknown as { shape: { mode: { unwrap(): { options: SessionMode[] } } } }).shape.mode.unwrap()
.options;
}
const MODES = schemaModes(CreateSessionSchema);
const EXTERNAL_MODES = MODES.filter(isExternalCliMode);
/**
* Mode tokens appearing back to back, separated only by list punctuation — `a|b|c`,
* `a`/`b`/`c`, "`a`, `b` and `c`". Newlines collapse to spaces first so a wrapped list
* still reads as one run. The separator budget is deliberately small: it must span
* ", " and " and " without swallowing a sentence between two unrelated mentions.
*/
const MODE_ALTERNATION = MODES.map((m) => `\`?${m}\`?`).join('|');
const ENUMERATION_RUN = new RegExp(`(?:(?:${MODE_ALTERNATION})(?:[\\s,/|]|and\\b|or\\b){0,6}){3,}`, 'g');
function enumerationRuns(text: string): string[] {
const flat = text.replace(/\s+/g, ' ');
return [...flat.matchAll(ENUMERATION_RUN)].map((m) => m[0]);
}
function modesIn(run: string): SessionMode[] {
return MODES.filter((m) => new RegExp(`\\b${m}\\b`).test(run));
}
describe('agent skill run-mode lists', () => {
it('derives the mode list from the schema, and both endpoints agree', () => {
expect(MODES).toContain('pi');
expect(new Set(schemaModes(QuickStartSchema))).toEqual(new Set(MODES));
expect(EXTERNAL_MODES.length).toBeGreaterThan(1);
});
it("documents exactly the accepted modes in endpoints.md's `mode ∈ …` enumeration", () => {
const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8');
const match = doc.match(/`mode` ∈ `([a-z|]+)`/);
expect(match, 'endpoints.md no longer states the accepted `mode` values').not.toBeNull();
expect(new Set(match![1].split('|'))).toEqual(new Set(MODES));
});
it('never enumerates a partial set of external CLI modes', () => {
const complete = new Set<string>(EXTERNAL_MODES);
/** The documented exception: codex writes a rollout, so it is absent from the
* "no transcript" lists on purpose. Every OTHER external mode must still be there. */
const withoutCodex = new Set<string>(EXTERNAL_MODES.filter((m) => m !== 'codex'));
const sameSet = (a: Set<string>, b: Set<string>) => a.size === b.size && [...a].every((v) => b.has(v));
const offenders: string[] = [];
for (const file of SKILL_FILES) {
for (const run of enumerationRuns(readFileSync(join(SKILL_DIR, file), 'utf-8'))) {
const listed = modesIn(run);
if (listed.length < 3) continue;
const externals = new Set<string>(listed.filter(isExternalCliMode));
// Empty is fine (a claude/shell-only list); partial is the drift.
if (externals.size === 0 || sameSet(externals, complete) || sameSet(externals, withoutCodex)) continue;
const missing = EXTERNAL_MODES.filter((m) => !externals.has(m));
offenders.push(`${file}: "${run.trim()}" is missing ${missing.join(', ')}`);
}
}
expect(offenders).toEqual([]);
});
});
+63
View File
@@ -10,6 +10,7 @@ import {
} from '../src/utils/dependency-checker.js';
import type { ProbeHost } from '../src/utils/dependency-checker.js';
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
import { PI_VERSION_REGEX } from '../src/utils/pi-cli-resolver.js';
describe('DEPENDENCY_REGISTRY', () => {
it('has unique ids', () => {
@@ -29,6 +30,20 @@ describe('DEPENDENCY_REGISTRY', () => {
expect(office.every((t) => t.required === false)).toBe(true);
});
it('resolves pi through the SAME version rule the run mode uses', () => {
// `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not
// print semver. If the doctor did not apply the identical rule it would report
// "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode
// rather than a missing install. One regex, shared, is what keeps them agreeing.
const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi');
expect(pi).toBeDefined();
const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path');
expect(spec).toBeDefined();
const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean };
expect(resolver.requireVersionMatch).toBe(true);
expect(resolver.versionRegex).toBe(PI_VERSION_REGEX);
});
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
expect(ms).toBeDefined();
@@ -164,6 +179,54 @@ describe('checkTool', () => {
});
});
describe('checkTool with requireVersionMatch (generic binary names)', () => {
const piTool: ToolDependency = {
id: 'pi',
label: 'Pi CLI',
category: 'core',
required: false,
resolvers: [
{
match: ['linux'],
resolver: {
kind: 'path',
bins: ['pi'],
versionArg: '--version',
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
};
it('accepts a binary that prints a semver version', () => {
const host = fakeHost('linux', { which: () => '/home/u/.npm-global/bin/pi', runVersion: () => '0.84.1\n' });
expect(checkTool(piTool, host)).toMatchObject({
id: 'pi',
status: 'ok',
version: '0.84.1',
path: '/home/u/.npm-global/bin/pi',
});
});
it('reports MISSING for an unrelated `pi` on PATH instead of an installed tool', () => {
// The whole point: a Raspberry Pi helper answers `--version` with prose, and calling
// that "installed" contradicts resolvePiDir(), which rejects it.
const host = fakeHost('linux', { which: () => '/usr/bin/pi', runVersion: () => 'Raspberry Pi utility\n' });
expect(checkTool(piTool, host)).toMatchObject({ id: 'pi', status: 'missing' });
});
it('reports MISSING when the binary answers nothing at all', () => {
const host = fakeHost('linux', { which: () => '/usr/bin/pi', runVersion: () => null });
expect(checkTool(piTool, host)).toMatchObject({ id: 'pi', status: 'missing' });
});
it('leaves tools without the flag reporting ok on an unparsable version (unchanged)', () => {
const host = fakeHost('linux', { which: () => '/usr/bin/tmux', runVersion: () => 'no version here' });
expect(checkTool(tmuxTool, host)).toMatchObject({ id: 'tmux', status: 'ok', version: undefined });
});
});
describe('checkAll', () => {
it('maps every tool to a result', () => {
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));