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
5 changes: 3 additions & 2 deletions config/ai.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# untrusted candidate producers. Their model label is informational only.

ai:
# Master switch for future planner/executor runs. The deterministic validation
# and artifact commands remain safe to run while this is false.
# Operational interlock. While false, the planner writes an empty manifest and
# the provenance gate rejects attempted artifact publication. Deterministic
# validation commands remain safe to run.
enabled: false

# The manifest is bound to exactly one executor. Do not schedule Claude and
Expand Down
6 changes: 6 additions & 0 deletions docs/P3.2-executor-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ CLI are already on `main`. Then open a source-only PR, confirm the green
`verify-agent-artifacts` as a required `main` status check. Only then enable one
executor to open `claude/*` or `codex/*` artifact PRs.

Create and commit `config/ai.yaml` from `config/ai.example.yaml` before the first
executor run. It contains no secret. Set `ai.enabled: true`, select exactly one
`executor_kind`, and start with a small valid budget. Without that explicit opt-in
the planner writes an empty manifest and the provenance gate rejects any artifact
PR.

## Deterministic command sequence

Run with a GitHub token in `STAR_SYNC_TOKEN` or `GITHUB_TOKEN` (README discovery):
Expand Down
18 changes: 17 additions & 1 deletion packages/classifier/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import {
AiAnnotationsSchema,
buildClassificationManifest,
ClassificationCandidatesSchema,
ClassificationManifestSchema,
serializeClassificationManifest,
Expand All @@ -11,7 +12,7 @@ import { createGithubClient } from '@starred/github-client';
import { Command } from 'commander';
import { verifyAgentPullRequestFromGit } from './agent-gate';
import { assembleAiArtifacts, verifyAiArtifacts } from './assemble';
import { loadAiConfig } from './config';
import { assertAiClassificationEnabled, loadAiConfig } from './config';
import { loadCanonicalDataset } from './dataset';
import { reconcileRun } from './executor';
import { planClassification } from './planner';
Expand Down Expand Up @@ -95,6 +96,20 @@ program
readFileSync(opts.stars, 'utf8'),
readFileSync(opts.meta, 'utf8'),
);
if (!config.ai.enabled) {
const manifest = buildClassificationManifest({
promptVersion: config.ai.prompt_version,
executionProfileVersion: config.ai.execution_profile.execution_profile_version,
executorKind: config.ai.executor_kind,
datasetSha256: dataset.datasetSha256,
jobs: [],
});
writeText(opts.out, serializeClassificationManifest(manifest));
process.stdout.write(
`AI classification disabled; wrote an empty manifest without README discovery: ${opts.out}\n`,
);
return;
}
const existingAnnotations =
opts.current !== undefined && existsSync(opts.current)
? AiAnnotationsSchema.parse(readJson(opts.current)).annotations
Expand Down Expand Up @@ -300,6 +315,7 @@ program
return;
}
const config = loadAiConfig(program.opts<{ config?: string }>().config).ai;
assertAiClassificationEnabled(config);
const token = process.env.STAR_SYNC_TOKEN ?? process.env.GITHUB_TOKEN;
if (token === undefined || token === '') {
throw new Error(
Expand Down
17 changes: 15 additions & 2 deletions packages/classifier/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
import { parse as parseYaml } from 'yaml';
import { z } from 'zod';

export const DEFAULT_AI_CONFIG_PATH = 'config/ai.yaml';

/**
* Versioned agent-executor configuration. This contract intentionally contains
* no API key, provider, model, or timeout: Claude Routines and Codex
Expand Down Expand Up @@ -47,8 +49,8 @@ export const AiConfigSchema = z

export type AiConfig = z.infer<typeof AiConfigSchema>;

export function loadAiConfig(path?: string): AiConfig {
if (path !== undefined && existsSync(path)) {
export function loadAiConfig(path = DEFAULT_AI_CONFIG_PATH): AiConfig {
if (existsSync(path)) {
const raw: unknown = parseYaml(readFileSync(path, 'utf8')) ?? {};
return AiConfigSchema.parse(raw);
}
Expand All @@ -62,3 +64,14 @@ export function loadAiConfig(path?: string): AiConfig {
},
});
}

/**
* `enabled` is an operational interlock, not an informational preference. The
* planner emits no jobs while disabled and the provenance gate rejects any
* attempted artifact publication.
*/
export function assertAiClassificationEnabled(config: AiConfig['ai']): void {
if (!config.enabled) {
throw new Error('AI classification is disabled; set ai.enabled=true in config/ai.yaml');
}
}
56 changes: 56 additions & 0 deletions packages/classifier/tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { makeDataset, repo } from './helpers';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');

describe('classifier CLI enablement', () => {
it('ENABLE-1: disabled classification writes an empty trusted manifest without GitHub access', () => {
const dir = mkdtempSync(join(tmpdir(), 'starledger-classifier-cli-'));
try {
const { starsText, metaText, datasetSha256 } = makeDataset([repo('one')]);
const stars = join(dir, 'stars.json');
const meta = join(dir, 'dataset-meta.json');
const config = join(dir, 'ai.yaml');
const out = join(dir, 'manifest.json');
writeFileSync(stars, starsText, 'utf8');
writeFileSync(meta, metaText, 'utf8');
writeFileSync(config, 'ai:\n enabled: false\n', 'utf8');

const stdout = execFileSync(
process.execPath,
[
'--import',
'tsx',
'packages/classifier/src/cli.ts',
'--config',
config,
'plan',
'--stars',
stars,
'--meta',
meta,
'--out',
out,
],
{
cwd: root,
encoding: 'utf8',
env: { ...process.env, STAR_SYNC_TOKEN: '', GITHUB_TOKEN: '' },
},
);

expect(stdout).toContain('AI classification disabled');
expect(JSON.parse(readFileSync(out, 'utf8'))).toMatchObject({
dataset_sha256: datasetSha256,
jobs: [],
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
37 changes: 34 additions & 3 deletions packages/classifier/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { AiConfigSchema, loadAiConfig } from '../src/config';
import {
AiConfigSchema,
assertAiClassificationEnabled,
DEFAULT_AI_CONFIG_PATH,
loadAiConfig,
} from '../src/config';

describe('AiConfigSchema', () => {
it('applies the documented defaults', () => {
Expand All @@ -23,8 +31,31 @@ describe('AiConfigSchema', () => {
);
});

it('loads defaults when no config path is given', () => {
expect(loadAiConfig().ai.enabled).toBe(false);
it('loads defaults when the default config file is absent', () => {
expect(loadAiConfig(join(tmpdir(), 'starledger-no-ai-config.yaml')).ai.enabled).toBe(false);
});

it('loads config/ai.yaml by default when it exists', () => {
const cwd = process.cwd();
const root = mkdtempSync(join(tmpdir(), 'starledger-ai-config-'));
try {
mkdirSync(join(root, 'config'));
writeFileSync(join(root, DEFAULT_AI_CONFIG_PATH), 'ai:\n enabled: true\n', 'utf8');
process.chdir(root);
expect(loadAiConfig().ai.enabled).toBe(true);
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});

it('treats enabled as an operational interlock', () => {
expect(() => assertAiClassificationEnabled(AiConfigSchema.parse({}).ai)).toThrow(
'AI classification is disabled',
);
expect(() =>
assertAiClassificationEnabled(AiConfigSchema.parse({ ai: { enabled: true } }).ai),
).not.toThrow();
});

it('rejects API-provider configuration; P3.0 uses executor-neutral contracts', () => {
Expand Down
Loading