Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- feat(quorum): **add Kimi Code (`kimi`) as a native CLI quorum family.** Kimi Code ships a `kimi` binary (`~/.kimi-code/bin/kimi`, v0.27.0) whose headless contract matches gemini/antigravity — `-p/--prompt <prompt>` runs one prompt non-interactively and prints the response. Wired the same way antigravity was (PR #274), and simpler because kimi's family name equals its binary name (no `agy`-style exception): `FAMILY_ARGS_TEMPLATE.kimi = ['-p','{prompt}']` (dispatch), both PATH-detection lists + `NF_KEYWORD_MAP` (`mcp__kimi-1__` prefix) + `KNOWN_CLI_PREFIXES` + install-hint in `install.js` (auto-detected on install), `skill-mcp-lint` `VALID_TOOLS.kimi` (so `mcp__kimi-1__kimi` passes the lint gate and a wrong tool like `ask` is still flagged), install metadata in `nForma.cjs`/`update-agents.cjs` (`kimi upgrade` self-update), and `token-dashboard` (managed-oauth flat-fee). `update-scoreboard`'s `VALID_MODELS` already listed `kimi`. **Verified end-to-end with a live `kimi -p` spawn** (the exact argv the quorum builds → exit 0, model replied). Scope: kimi as a quorum *slot* (a model nForma queries), not as an IDE install-target. New kimi cases in the three family test suites, mutation-proven red; the mcp-lint positive test was strengthened to be non-vacuous (a bare `deepEqual([])` passes for an unknown slot too).
- feat(quorum): **the CE-5 deliberation round cap is now configurable via `quorum.max_rounds`** (default 10). CE-5 full-convergence already "rounds until the improvement stream is dry under unanimity", but the *total-round* budget before it escalates was hardcoded to 10 in the skill prose — so a user whose consensus bar is "round until no more improvements are put forward" could have a contentious debate cut short at 10. `hooks/config-loader.js` adds `quorum.max_rounds` with the same partial-merge drop-guard as `min_live_voters`/`full_convergence`, plus a clamp so a dropped or garbage value (< 1, non-integer) restores to the default — the loop always has a finite upper bound, a bad config can never mean "loop forever". `commands/nf/quorum.md` replaces every hardcoded "10 total / 9 deliberation" with `MAX_ROUNDS = quorum.max_rounds`, read at R3.3 alongside `full_convergence`. Also fixes an adjacent papercut: an **absent** `maxSize` (the partial-merge drop) used to emit a spurious "maxSize must be a positive integer" warning on every hook run; it now restores silently like the other keys, and only a *provided-but-garbage* `maxSize` still warns. 6 config tests (QMR1–6) + 3 skill-prose gates, all mutation-proven red.
- feat(skill): **`/nf:goal-writer` gains a delivery target** — a goal whose definition of done is "live in staging" cannot be satisfied by an agent that stops at merge; it burns every remaining turn on work that cannot complete. **Merged is not delivered:** a PR that merges and then dies in a broken pipeline shipped nothing. The skill now resolves an explicit target (`merged` | `staging` | `production`) in step 1 and carries it through every downstream section. `nf:pr-resolve`'s **terminus** is stated explicitly: for a deploying target it must follow the deployment through and verify the change is **healthy** — not merely that the pipeline reported green (a green deploy job in front of a crashlooping service is not delivered) — with the health check written concretely (endpoint, smoke command, or error-rate signal, plus how long to observe). **Hard stops are reframed from "deployment is banned" to "deployment is scoped by the target":** deploying *to* the declared target is authorised work; deploying *past* it is a hard stop (`staging` never authorises a production promotion); **rolling back a deploy this session drove, when observed unhealthy, is authorised** because it restores known-good state; approval gates are waited on, never bypassed; and publish/release, secret rotation, destructive git, data migrations, money/identity and external communication stay hard-stopped at every target. The done-checklist's final item becomes "target reached and verified healthy" so a merge alone cannot satisfy it, the generated `/goal` end state must **name** the target (otherwise the evaluator accepts a merge as completion and the goal closes with nothing shipped), and a deploying target must be disclosed plainly — the session will deploy without asking, and the user must not learn that from a log line afterwards. 10 new contract tests in `bin/goal-writer-blocks.test.cjs`, all mutation-proven red; prose assertions match a whitespace-normalised view of the skill so reflowing a paragraph is not a false failure.
- test(release): **regression gates for the `@next == @latest` alias policy** — the guards shipped in #366/#367 had **zero tests**, which by the repo's own recurrence record (config-path drift fixed 6×, null-CLI 4×) means the class comes back. New `bin/release-guards.test.cjs` covers all five: `publish.sh`'s prerelease-version and caller-supplied-`--tag` refusals, `prepare-release.sh`'s explicit-target and `--auto`-current-version refusals, and `publish.yml`'s version guard plus the absence of the retired `v*-rc*`/`v*-next*` triggers and `dist_tag=next`/`mode=prerelease` modes. **Safety-critical design:** `publish.sh` ends in `npm publish`, so each script runs in a throwaway sandbox against a stub `npm` that only records argv — every test asserts `publish` was never reached, so a *broken* guard fails an assertion instead of publishing. All five mutation-proven **red** (guard removed → test fails; restored → passes), including a positive control that a stable version does reach the publish step, so the prerelease rejection is attributable to the version and not to sandbox breakage.
Expand Down
6 changes: 5 additions & 1 deletion bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ try {
{ family: 'opencode', bin: 'opencode' },
{ family: 'copilot', bin: 'copilot' },
{ family: 'antigravity', bin: 'agy' },
{ family: 'kimi', bin: 'kimi' },
];
const found = [];
for (const { family, bin } of KNOWN_CLIS) {
Expand Down Expand Up @@ -88,6 +89,7 @@ function getInstallHint(mainTool) {
opencode: 'npm i -g opencode',
copilot: 'npm i -g @githubnext/github-copilot-cli',
antigravity: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
kimi: 'kimi upgrade # Kimi Code self-updates; see kimi.com for first install',
};
return hints[mainTool] || '';
}
Expand Down Expand Up @@ -415,6 +417,7 @@ const NF_KEYWORD_MAP = {
cursor: { keywords: ['cursor'], defaultPrefix: 'mcp__cursor-1__' },
windsurf: { keywords: ['windsurf', 'codeium'], defaultPrefix: 'mcp__windsurf-1__' },
antigravity: { keywords: ['antigravity'], defaultPrefix: 'mcp__antigravity-1__' },
kimi: { keywords: ['kimi'], defaultPrefix: 'mcp__kimi-1__' },
augment: { keywords: ['augment'], defaultPrefix: 'mcp__augment-1__' },
trae: { keywords: ['trae'], defaultPrefix: 'mcp__trae-1__' },
cline: { keywords: ['cline'], defaultPrefix: 'mcp__cline-1__' },
Expand Down Expand Up @@ -611,7 +614,7 @@ function ensureMcpSlotsFromProviders() {

// Step 1: Sync slots from ~/.claude.json (fan-out creates MCP entries here)
// Only include slots whose prefix is a known coding agent CLI.
const KNOWN_CLI_PREFIXES = ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'kilo', 'cursor', 'windsurf', 'antigravity', 'augment', 'trae', 'cline'];
const KNOWN_CLI_PREFIXES = ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'kilo', 'cursor', 'windsurf', 'antigravity', 'kimi', 'augment', 'trae', 'cline'];
const { resolveCli } = require('./resolve-cli.cjs');
const { argsTemplateFor } = require('./provider-arg-templates.cjs');
const claudeJsonPath = path.join(os.homedir(), '.claude.json');
Expand Down Expand Up @@ -669,6 +672,7 @@ function ensureMcpSlotsFromProviders() {
{ family: 'opencode', bin: 'opencode' },
{ family: 'copilot', bin: 'copilot' },
{ family: 'antigravity', bin: 'agy' },
{ family: 'kimi', bin: 'kimi' },
];
const existingNames = new Set(mcpSlots);
for (const { family, bin } of KNOWN_CLIS) {
Expand Down
20 changes: 12 additions & 8 deletions bin/nForma.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ const {
probeAndPersistKey,
} = pure;

const { updateAgents, getUpdateStatuses } = require('./update-agents.cjs');
const { updateAgents, getUpdateStatuses, resolveUpdateCommand } = require('./update-agents.cjs');
const reqCore = require('./requirements-core.cjs');
const principleMapping = require('./principle-mapping.cjs');

Expand Down Expand Up @@ -2559,6 +2559,7 @@ async function updateAgentsFlow() {
copilot: { installType: 'gh-extension', ext: 'github/gh-copilot' },
ccr: { installType: 'npm-global', pkg: 'claude-code-router' },
antigravity: { installType: 'curl-script', bin: 'agy', installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash' },
kimi: { installType: 'self-update', bin: 'kimi', installCommand: 'kimi upgrade' },
};

const lines = ['{bold}Update Status{/bold}', '─'.repeat(50)];
Expand Down Expand Up @@ -2623,14 +2624,17 @@ async function updateAgentsFlow() {
const results = [];
for (const { name, meta } of toUpdate) {
setContent('Update Agents', `{gray-fg}Updating ${name}…{/}`);
let cmd, args;
if (meta.installType === 'npm-global') {
cmd = 'npm'; args = ['install', '-g', `${meta.pkg}@latest`];
} else {
cmd = 'gh'; args = ['extension', 'upgrade', 'copilot'];
// Shared resolver (update-agents.cjs) — the single source of truth. The old
// inline `else` hardcoded `gh extension upgrade copilot`, so every non-npm
// family (antigravity curl-script, kimi self-update) was "updated" with
// copilot's command. Route through resolveUpdateCommand instead.
const c = resolveUpdateCommand(meta);
if (!c) {
results.push(`{yellow-fg}? ${name} — no known update command (installType '${meta.installType}'); update manually{/}`);
continue;
}
const res = spawnSync(cmd, args, {
encoding: 'utf8', timeout: 60000,
const res = spawnSync(c.cmd, c.args, {
encoding: 'utf8', timeout: c.shell ? 120000 : 60000, shell: c.shell,
});
const ok = res.status === 0;
results.push(ok
Expand Down
3 changes: 3 additions & 0 deletions bin/provider-arg-templates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const FAMILY_ARGS_TEMPLATE = {
copilot: ['-p', '{prompt}', '--allow-all-tools', '--no-color', '-s'],
// antigravity (`agy` binary): print mode (verified against agy v1.0.8 `-p`/--print)
antigravity: ['-p', '{prompt}'],
// kimi (`kimi` binary, Kimi Code): `-p/--prompt <prompt>` runs one prompt
// non-interactively and prints the response (verified against kimi v0.27.0 --help).
kimi: ['-p', '{prompt}'],
};

/**
Expand Down
13 changes: 10 additions & 3 deletions bin/provider-arg-templates.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@ const { FAMILY_ARGS_TEMPLATE, argsTemplateFor, resolveArgsTemplate } = require('
const { buildSpawnArgs } = require('./call-quorum-slot.cjs');

describe('provider-arg-templates: canonical map', () => {
it('has a template for every quorum CLI family incl. antigravity', () => {
for (const fam of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity']) {
it('has a template for every quorum CLI family incl. antigravity + kimi', () => {
for (const fam of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'antigravity', 'kimi']) {
assert.ok(Array.isArray(FAMILY_ARGS_TEMPLATE[fam]), `${fam} must have a template`);
assert.ok(FAMILY_ARGS_TEMPLATE[fam].includes('{prompt}'), `${fam} template must carry {prompt}`);
}
});

it('codex uses exec; antigravity uses -p print mode', () => {
it('codex uses exec; antigravity + kimi use -p print mode', () => {
assert.deepEqual(argsTemplateFor('codex'), ['exec', '{prompt}']);
assert.deepEqual(argsTemplateFor('antigravity'), ['-p', '{prompt}']);
// kimi (Kimi Code) `-p/--prompt <prompt>` — verified against kimi v0.27.0 --help.
assert.deepEqual(argsTemplateFor('kimi'), ['-p', '{prompt}']);
});

it('argsTemplateFor returns a fresh copy (no shared mutation) and null for unknown', () => {
Expand Down Expand Up @@ -71,6 +73,11 @@ describe('buildSpawnArgs: no crash on a provider missing args_template', () => {
assert.deepEqual(args, ['-p', 'HI']);
});

it('kimi slot (no args_template) dispatches via -p, prompt substituted verbatim', () => {
const { args } = buildSpawnArgs({ name: 'kimi-1', mainTool: 'kimi', cli: '/x/kimi' }, 'HI');
assert.deepEqual(args, ['-p', 'HI']);
});

it('an explicit args_template is still honored', () => {
const { args } = buildSpawnArgs({ name: 'codex-1', mainTool: 'codex', args_template: ['exec', '{prompt}'] }, 'Q');
assert.deepEqual(args, ['exec', 'Q']);
Expand Down
1 change: 1 addition & 0 deletions bin/skill-mcp-lint.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const VALID_TOOLS = {
copilot: ['copilot', 'suggest', 'explain', 'identity', 'health_check', 'deep_health_check', 'help', 'ping'],
opencode: ['opencode', 'opencode_check_update', 'identity', 'health_check', 'deep_health_check', 'help', 'ping'],
antigravity: ['antigravity', 'identity', 'health_check', 'deep_health_check', 'help', 'ping'],
kimi: ['kimi', 'identity', 'health_check', 'deep_health_check', 'help', 'ping'],
};
const FAMILIES = Object.keys(VALID_TOOLS);

Expand Down
19 changes: 18 additions & 1 deletion bin/skill-mcp-lint.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { findMcpToolViolations } = require('./skill-mcp-lint.cjs');
const { findMcpToolViolations, VALID_TOOLS } = require('./skill-mcp-lint.cjs');

const v = (s) => findMcpToolViolations(s, 'f.md');

Expand Down Expand Up @@ -46,6 +46,23 @@ describe('skill-mcp-lint detector', () => {
assert.equal(r[0].rule, 'mcp-bad-tool');
});

it('recognizes the kimi family (slot + real/shared tools)', () => {
// Non-vacuous: assert kimi is a KNOWN family with the expected tool surface.
// A plain deepEqual(v('mcp__kimi-1__kimi'), []) passes even if kimi is absent
// (an unknown slot is skipped, not validated → also []), so pin the family too.
assert.ok(Array.isArray(VALID_TOOLS.kimi), 'kimi must be a registered family');
assert.ok(VALID_TOOLS.kimi.includes('kimi'), 'kimi family must expose its main tool');
assert.deepEqual(v('mcp__kimi-1__kimi'), []);
assert.deepEqual(v('mcp__kimi-1__identity'), []);
assert.deepEqual(v('mcp__kimi-1__health_check'), []);
});

it('flags a tool kimi does not expose (the `ask` class)', () => {
const r = v('mcp__kimi-1__ask');
assert.equal(r.length, 1);
assert.equal(r[0].rule, 'mcp-bad-tool');
});

it('does NOT validate install-specific / external slots', () => {
assert.deepEqual(v('mcp__claude-1__claude'), []);
assert.deepEqual(v('mcp__claude-z-ai__identity'), []);
Expand Down
1 change: 1 addition & 0 deletions bin/token-dashboard.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const COST_PER_M = {
'opencode': { input: 2.00, output: 8.00 },
'copilot': { input: 0.00, output: 0.00, subscription: true },
'antigravity': { input: 0.00, output: 0.00, subscription: true },
'kimi': { input: 0.00, output: 0.00, subscription: true }, // Kimi Code via managed oauth (flat-fee)
'default': { input: 3.00, output: 15.00 },
};

Expand Down
51 changes: 39 additions & 12 deletions bin/update-agents.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const CLI_META = {
// npm/gh package — detectCurrent/detectLatest fall through to null for this
// installType, so it shows as "unknown" rather than crashing the updater.
antigravity: { installType: 'curl-script', bin: 'agy', installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash' },
// Kimi Code self-updates via `kimi upgrade`; installType 'self-update' isn't
// npm-global/gh-extension, so detectCurrent/detectLatest fall through to null
// and it shows "unknown" rather than crashing the updater (same as antigravity).
kimi: { installType: 'self-update', bin: 'kimi', installCommand: 'kimi upgrade' },
Comment thread
jobordu marked this conversation as resolved.
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -256,20 +260,43 @@ function printTable(rows) {
// Run update for one CLI
// ---------------------------------------------------------------------------

/**
* Resolve the update command for an agent from its install metadata.
* Single source of truth shared by this file's runUpdate AND nForma.cjs's
* updateAgentsFlow — they previously duplicated this logic, which is why the
* `else`-branch hardcoded copilot's command reached every non-npm family
* (antigravity's curl-script, kimi's self-update) with the wrong command.
* Returns { cmd, args, shell } or null when there is no known way to update.
*/
function resolveUpdateCommand(meta) {
if (!meta) return null;
if (meta.installType === 'npm-global' && meta.pkg) {
return { cmd: 'npm', args: ['install', '-g', `${meta.pkg}@latest`], shell: false };
}
if (meta.installType === 'gh-extension') {
return { cmd: 'gh', args: ['extension', 'upgrade', 'copilot'], shell: false };
}
// curl-script (antigravity), self-update (kimi), or any future family that
// carries an explicit installCommand — run it through a shell since it may
// contain a pipe (e.g. `curl … | bash`).
if (meta.installCommand) {
return { cmd: meta.installCommand, args: [], shell: true };
}
return null; // unknown installType with no installCommand — caller must handle
}

function runUpdate({ name, meta }) {
try {
let result;
if (meta.installType === 'npm-global') {
result = spawnSync('npm', ['install', '-g', `${meta.pkg}@latest`], {
stdio: 'inherit',
timeout: 60000,
});
} else if (meta.installType === 'gh-extension') {
result = spawnSync('gh', ['extension', 'upgrade', 'copilot'], {
stdio: 'inherit',
timeout: 30000,
});
const c = resolveUpdateCommand(meta);
if (!c) {
console.log(`\n \x1b[33m? ${name}: no known update command (installType '${meta && meta.installType}') — update manually\x1b[0m`);
return;
}
const result = spawnSync(c.cmd, c.args, {
stdio: 'inherit',
shell: c.shell,
timeout: c.shell ? 120000 : 60000,
});
if (result && result.status === 0) {
console.log(`\n Updated: ${name}`);
} else {
Expand Down Expand Up @@ -368,4 +395,4 @@ async function updateAgents() {
// Exports
// ---------------------------------------------------------------------------

module.exports = { updateAgents, getUpdateStatuses };
module.exports = { updateAgents, getUpdateStatuses, resolveUpdateCommand };
Loading
Loading