Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions bin/nForma.cjs
Original file line number Diff line number Diff line change
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
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
4 changes: 4 additions & 0 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
17 changes: 17 additions & 0 deletions bin/update-scoreboard.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,23 @@ test('SC-TC11b: --model antigravity is accepted and recorded', () => {
}
});

// SC-TC11c: 'kimi' (Kimi Code CLI family) is a recognized --model — accepted and recorded.
test('SC-TC11c: --model kimi is accepted and recorded', () => {
const sb = tmpScoreboard();
try {
const { exitCode } = runCLI([
'--model', 'kimi', '--result', 'TP', '--task', 'quick-99',
'--round', '1', '--verdict', 'APPROVE', '--scoreboard', sb,
]);
assert.strictEqual(exitCode, 0, 'kimi must be a valid --model');
const data = JSON.parse(fs.readFileSync(sb, 'utf8'));
assert.ok(data.models.kimi, 'kimi must appear in the scoreboard models map');
assert.strictEqual(data.models.kimi.score, 1, 'one TP vote → score 1');
} finally {
cleanup(sb);
}
});

// SC-TC12: invalid --result value → exits 1
test('SC-TC12: invalid --result value exits 1', () => {
const { stderr, exitCode } = runCLI([
Expand Down
Loading