a plugin can offer a commander, and offering is not starting

first half: the discovery and the two columns. no routes and no UI yet.

PRESENCE IS THE DECLARATION. a plugin with commanders/<name>/COMMANDER.md offers
one, and manifest.ts says nothing — same as tasks/ and skills/, and for the same
reason: the directory already says it, and a second place to say it is a second
place for the two to disagree. read in place through capabilitySources, so only an
installed AND ENABLED plugin offers anything and plugins:update changes a template
without republishing.

A TEMPLATE IS NOT A COMMANDER. what a plugin ships is an OFFER: nothing runs,
nothing is scheduled, no directory exists until an owner creates one from it. that
is the shape asked for — a plugin may propose recurring work, only a person may
start it.

`plugin` IS A COLUMN, not a reserved category name. provenance should be
structural: a category called "av-club" is a string an owner can rename, merge or
delete, and the day they do, a plugin's commanders quietly become ordinary ones
with nothing on disk saying otherwise. it also survives an uninstall, which is the
point — the commander is still there and the UI can say where it came from.

`plugin_config` holds the owner's answers because the form has to be RE-OPENABLE.
somebody changing which directory is watched should see the one it is watching, not
an empty form. the watcher script is generated from this, so this is the source of
truth rather than the script.

inputs reuse TASK.md's vocabulary exactly, so the existing form renderer draws them
and the existing validation applies. "which folder, which extensions, what output"
is the same question a task asks.

`missingInputs` is the configure-before-running rule, and it is checked at ARM time
rather than at creation. a half-configured commander that exists and is visible is
useful; an ARMED one is what would run somebody's disk against a blank setting.

columns verified live after db:push: plugin text, plugin_config jsonb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 02:45:08 +01:00
co-authored by Claude Opus 5
parent 3b0083aec2
commit e4f5e03105
3 changed files with 165 additions and 5 deletions
@@ -3,6 +3,7 @@ import {
doublePrecision,
index,
integer,
jsonb,
pgTable,
serial,
text,
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
* its own, and an array supports appending in place (`array_append`) without reading the whole column
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
*/
referencePaths: text('reference_paths')
.array()
.notNull()
.default([]),
referencePaths: text('reference_paths').array().notNull().default([]),
/**
* What the owner called it, held here rather than only in the transcript's summary record.
*
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
watcherArmed: boolean('watcher_armed').notNull().default(false),
/**
* The plugin that shipped this commander's template, or null for one somebody made themselves.
*
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
* provenance should be structural. A category called "av-club" is a string an owner can rename,
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
* with nothing on disk saying otherwise.
*
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
* still in its directory, and the UI can say what it came from rather than pretending it was
* always hand-made.
*/
plugin: text('plugin'),
/**
* The owner's answers to the template's `inputs`, or null when there was no template.
*
* Held here rather than only in the generated watcher script because the form has to be
* re-openable: somebody changing which directory is watched should see what it is watching now,
* not an empty form. The script is generated FROM this, so this is the source of truth.
*/
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
/**
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
@@ -0,0 +1,137 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { capabilitySources } from '../../capability-sources';
// Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency,
// and adding one to read four lines of frontmatter is not a trade worth making.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
/**
* Commanders a plugin ships, ready to be created but not running.
*
* ── Presence is the declaration ──
*
* A plugin with a `commanders/<name>/COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for
* the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a
* second place to say it is a second place for the two to disagree.
*
* Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a
* disabled plugin stops offering its commander, and `plugins:update` changes the template without
* anything having to be republished.
*
* ── A template is not a commander ──
*
* What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the
* owner creates one from it and fills in the inputs. That is the whole shape the owner asked for:
* a plugin may propose recurring work; only a person may start it.
*
* ── Why the inputs reuse TASK.md's vocabulary ──
*
* `inputs:` here is the same structure a task declares, so the same form renderer draws it and the
* same validation applies. A commander asking "which folder, which extensions, what output" is asking
* exactly what a task asks, and inventing a second schema for it would mean two form renderers to
* keep in step for no gain.
*/
export type CommanderInput = {
type: string;
description?: string;
default?: string;
options?: string[];
/** Unset and required means the commander cannot arm. See `missingInputs`. */
required?: boolean;
};
export type PluginCommanderTemplate = {
/** Directory name, unique within its plugin — `heic-to-webp`. */
dirName: string;
/** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */
plugin: string;
name: string;
description: string;
/** What it watches, in a sentence, for the card. */
watches?: string;
inputs: Record<string, CommanderInput>;
/** The prose that becomes the commander's `CLAUDE.md`. */
body: string;
/** Absolute path to the template directory, so the watcher script can be read at creation. */
dir: string;
};
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { fm: {}, body: raw };
try {
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]! };
} catch {
// A template with unparseable frontmatter is skipped by the caller rather than crashing the list —
// one plugin's bad YAML must not take away every other plugin's commanders.
return { fm: {}, body: match[2]! };
}
}
export async function listPluginCommanders(): Promise<PluginCommanderTemplate[]> {
const sources = await capabilitySources('commanders');
const found: PluginCommanderTemplate[] = [];
for (const source of sources) {
// Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists:
// a commander somebody makes themselves is made in the UI, not from a template on disk.
if (!source.plugin) continue;
let entries;
try {
entries = await readdir(source.dir, { withFileTypes: true });
} catch {
continue; // most plugins ship none — ordinary, not a fault
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = join(source.dir, entry.name);
const file = Bun.file(join(dir, 'COMMANDER.md'));
if (!(await file.exists())) continue;
const { fm, body } = parse(await file.text());
if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly
found.push({
dirName: entry.name,
plugin: source.plugin,
name: String(fm.name),
description: String(fm.description ?? ''),
watches: fm.watches ? String(fm.watches) : undefined,
inputs: (fm.inputs as Record<string, CommanderInput>) ?? {},
body,
dir,
});
}
}
return found.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
// Both halves reach this from a request body and are joined onto a path below.
const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..';
if (unsafe(plugin) || unsafe(dirName)) return null;
const all = await listPluginCommanders();
return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null;
}
/**
* Which required inputs have no answer.
*
* This is the "configure before running" rule, and it is enforced at ARM time rather than at creation.
* Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be
* finished later. Arming one is what would run somebody's disk against a blank setting.
*/
export function missingInputs(
inputs: Record<string, CommanderInput>,
config: Record<string, string> | null | undefined,
): string[] {
return Object.entries(inputs)
.filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim())
.map(([key]) => key);
}
+3 -1
View File
@@ -43,7 +43,9 @@ export type CapabilitySource = {
plugin: string | null;
};
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
export async function capabilitySources(
type: ItemType | 'affordances' | 'notifications' | 'commanders',
): Promise<CapabilitySource[]> {
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];