Skip to content
4 changes: 4 additions & 0 deletions scripts/ci/architecture-drift.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const rules = {
HQ_DIRECT_DB: "HQ must use the API rather than the SQLite database",
HQ_RAW_AUTH_FETCH: "only the Node proxy may attach the API token",
DIAGNOSIS_COMMANDS_MOVED: "diagnosis commands belong in diagnosis_cmds.rs",
SETTINGS_COMMANDS_MOVED: "settings commands belong in settings_cmds.rs",
};

function files(dir = root) {
Expand Down Expand Up @@ -138,6 +139,9 @@ function scan() {
const backend = block(source, /impl\s+ControlBackend\s+for\s+ApiBackend/);
add("API_STORE_BLOCK_ON", path, callHits(backend, /tauri::async_runtime::block_on\s*\(/g, /self\.store/));
add("DIAGNOSIS_COMMANDS_MOVED", path, regexHits(source, /fn\s+(?:get_log_path|get_panic_notice|get_reason_catalog|export_diagnosis|reveal_log_path|get_stuck_after_minutes|set_stuck_after_minutes)\s*\(/g));
// ApiBackend keeps same-named trait methods; only free Tauri command fns are banned.
const withoutBackend = source.replace(block(source, /impl\s+ControlBackend\s+for\s+ApiBackend/), "");
add("SETTINGS_COMMANDS_MOVED", path, regexHits(withoutBackend, /fn\s+(?:get_agent_env_isolation|set_agent_env_isolation|get_continuous_activation|enable_continuous_activation|get_digest_enabled|set_digest_enabled|get_routing_status|set_product_mode|set_profile_enabled|set_category_learning|get_learning_settings)\s*\(/g));
}
if (path === "src-tauri/src/api.rs" || path.startsWith("src-tauri/src/api/"))
add("ERROR_TEXT_CLASSIFIER", path, regexHits(source, /err(?:or)?\.(?:contains|starts_with)\s*\(/g));
Expand Down
56 changes: 56 additions & 0 deletions scripts/ci/architecture-drift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,59 @@ test("contract: all 7 diagnosis commands are properly moved to diagnosis_cmds.rs
assert.match(handlerList, new RegExp(`\\b${cmd}\\b`), `Command ${cmd} should be listed in generate_handler!`);
}
});

test("rejects settings commands in main.rs", () => {
const root = fixture({ "src-tauri/src/main.rs": "fn get_agent_env_isolation() { }\n" });
const result = run(root, "check");
assert.equal(result.status, 1);
assert.match(result.stderr, /SETTINGS_COMMANDS_MOVED.*src-tauri\/src\/main\.rs/);
});

test("accepts settings-named methods inside ControlBackend for ApiBackend", () => {
const root = fixture({ "src-tauri/src/main.rs": "impl ControlBackend for ApiBackend {\n fn get_digest_enabled() {}\n}\n" });
assert.equal(run(root, "check").status, 0);
});

test("contract: all 11 settings commands are properly moved to settings_cmds.rs and exported in main.rs", () => {
const repoRoot = process.env.TEST_REPO_ROOT || dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const mainRs = readFileSync(join(repoRoot, "src-tauri/src/main.rs"), "utf8");
let settingsRs = "";
try {
settingsRs = readFileSync(join(repoRoot, "src-tauri/src/settings_cmds.rs"), "utf8");
} catch (e) {
// missing in older tree, handled below
}

const commands = [
"get_agent_env_isolation",
"set_agent_env_isolation",
"get_continuous_activation",
"enable_continuous_activation",
"get_digest_enabled",
"set_digest_enabled",
"get_routing_status",
"set_product_mode",
"set_profile_enabled",
"set_category_learning",
"get_learning_settings",
];

const commandDef = (cmd) =>
new RegExp(
`#\\[tauri::command\\]\\s*(?:(?:#\\[[^\\]]*\\]|\\/\\/.*|\\/\\*.*?\\*\\/)\\s*)*(?:pub(?:\\([^)]*\\))?\\s+)?(?:async\\s+)?fn\\s+${cmd}\\s*\\(`,
);

for (const cmd of commands) {
// ApiBackend keeps same-named trait methods in main.rs; only the Tauri
// command attribute must leave.
assert.doesNotMatch(mainRs, commandDef(cmd), `Command ${cmd} should not be defined in main.rs`);
assert.match(settingsRs, commandDef(cmd), `Command ${cmd} should be exported in settings_cmds.rs`);
}

const handlerMatch = mainRs.match(/generate_handler!\[(.*?)\]/s);
assert.ok(handlerMatch, "generate_handler! not found in main.rs");
const handlerList = handlerMatch[1];
for (const cmd of commands) {
assert.match(handlerList, new RegExp(`\\b${cmd}\\b`), `Command ${cmd} should be listed in generate_handler!`);
}
});
2 changes: 2 additions & 0 deletions scripts/dev/spec-close.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { listedInStand } from "../lib/active-specs.mjs";

const SECTION = /^#{2,4}\s*Aktive Specs\s*$/; // same rule as scripts/lib/active-specs.mjs
const HEAD_LINES = 8; // same rule as scripts/lib/active-specs.mjs
const ALLOWED_STATUSES = new Set(["aktiv", "historisch", "entwurf"]); // existing active-specs.mjs contract

const HELP = `spec-close — Spec auf "Status: historisch" setzen und aus STAND.md "Aktive Specs" austragen

Expand Down Expand Up @@ -48,6 +49,7 @@ export function closeSpec(text) {
const value = /^Status:\s*(\S+)\s*$/.exec(line);
if (!value) throw new RefusedError(`Status-Zeile nicht im Format "Status: <Wert>" (ein Wort, kein Zusatz): ${line.trim()}`);
const previous = value[1];
if (!ALLOWED_STATUSES.has(previous)) throw new RefusedError(`Status "${previous}" unbekannt; erlaubt: aktiv, historisch, entwurf.`);
if (previous === "historisch") return { text, changed: false, previous };
lines[i] = "Status: historisch";
return { text: lines.join(eol), changed: true, previous };
Expand Down
43 changes: 31 additions & 12 deletions scripts/lib/dev-hygiene.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -155,31 +155,53 @@ test("hygiene renders markdown with one section per check", () => {
assert.match(md, /kimi\/lost-work/);
});

test("gather reads git and gh read-only and hygiene --strict exits 1 on findings", async () => {
function commandArgs(cmd, args) {
let rest = args;
// git: drop global "-C <path>" / "-c <key=value>" so only the subcommand and its args are matched.
if (cmd === "git") while (rest[0] === "-C" || rest[0] === "-c") rest = rest.slice(2);
return rest;
}

async function assertReadOnlyHygiene(cwd = root) {
const calls = [];
const fake = (cmd, args) => {
calls.push([cmd, ...args]);
const a = args.join(" ");
const a = commandArgs(cmd, args).join(" ");
if (cmd === "gh" && a.includes("--state open")) return { code: 0, stdout: JSON.stringify(input.prsOpen), stderr: "" };
if (cmd === "gh" && a.includes("--state all")) return { code: 0, stdout: JSON.stringify(input.prsAll), stderr: "" };
if (a.includes("worktree list")) return { code: 0, stdout: `worktree ${root}\nHEAD abc\nbranch refs/heads/main\n\n`, stderr: "" };
if (a.includes("worktree list")) return { code: 0, stdout: `worktree ${cwd}\nHEAD abc\nbranch refs/heads/main\n\n`, stderr: "" };
if (a.includes("for-each-ref")) return { code: 0, stdout: "origin/main\norigin/HEAD\norigin/kimi/lost-work\n", stderr: "" };
if (a.includes("merge-base")) return { code: 1, stdout: "", stderr: "" };
if (a.includes("status")) return { code: 0, stdout: "?? MEMORY.md\n?? $OUT\n M tracked.txt\n", stderr: "" };
return { code: 0, stdout: "", stderr: "" };
};
const data = gather({ run: fake, cwd: root, now: NOW });
const data = gather({ run: fake, cwd, now: NOW });
assert.equal(data.root, cwd);
assert.deepEqual(data.remoteBranches.map((b) => b.name), ["main", "kimi/lost-work"]);
assert.deepEqual(data.untracked, ["MEMORY.md", "$OUT"]);
for (const c of calls) {
const a = c.join(" ");
for (const [cmd, ...args] of calls) {
const a = commandArgs(cmd, args).join(" ");
assert.doesNotMatch(a, /\b(push|commit|checkout|reset|merge --|worktree remove|branch -d|pr (create|edit|merge))\b/);
}
const out = [];
const io = { out: (s) => out.push(s), err: (s) => out.push(s) };
assert.equal(await main(["--no-fetch"], io, { run: fake, now: () => NOW }), 0);
assert.equal(await main(["--no-fetch", "--strict"], io, { run: fake, now: () => NOW }), 1);
assert.equal(await main(["--no-fetch", "--root", cwd], io, { run: fake, now: () => NOW }), 0);
assert.equal(await main(["--no-fetch", "--strict", "--root", cwd], io, { run: fake, now: () => NOW }), 1);
assert.match(out.join(""), /# Hygiene/);
}

test("gather reads git and gh read-only and hygiene --strict exits 1 on findings", async () => {
await assertReadOnlyHygiene();
});

test("hygiene guards ignore push and status in the worktree path", async () => {
for (const path of ["/tmp/wt/srv-x-push", "/tmp/wt/srv-x-status", "/tmp/wt/srv-x-push-status"]) {
await assertReadOnlyHygiene(resolve(path));
}
const globals = ["-C", "/tmp/wt/srv-x-push-status", "-c", "alias.push=status"];
assert.deepEqual(commandArgs("git", [...globals, "rev-parse", "--show-toplevel"]), ["rev-parse", "--show-toplevel"]);
assert.deepEqual(commandArgs("git", [...globals, "push", "origin"]), ["push", "origin"]);
assert.deepEqual(commandArgs("gh", ["pr", "create"]), ["pr", "create"]);
});

test("hygiene --help exits 0", async () => {
Expand All @@ -192,10 +214,7 @@ test("hygiene --help exits 0", async () => {

function fakeRun(over = {}) {
return (cmd, args) => {
let rest = args;
// git: drop global "-C <path>" / "-c <key=value>" so only the subcommand and its args are matched.
if (cmd === "git") while (rest[0] === "-C" || rest[0] === "-c") rest = rest.slice(2);
const a = rest.join(" ");
const a = commandArgs(cmd, args).join(" ");
for (const [needle, res] of Object.entries(over)) if (cmd === "git" ? a.startsWith(needle) : a.includes(needle)) return typeof res === "function" ? res(cmd, args) : res;
if (cmd === "gh" && a.includes("--state open")) return { code: 0, stdout: JSON.stringify(input.prsOpen), stderr: "" };
if (cmd === "gh" && a.includes("--state all")) return { code: 0, stdout: JSON.stringify(input.prsAll), stderr: "" };
Expand Down
27 changes: 27 additions & 0 deletions scripts/lib/dev-spec-close.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ function repo(t) {
return dir;
}

test("SPEC-STATUS: unknown status refuses without write", async (t) => {
const dir = repo(t);
const specPath = join(dir, ".pa/task_w1-22.md");
const standPath = join(dir, "STAND.md");
for (const status of ["aktvi", "historic", "Aktiv", "unknown"]) {
for (const flags of [["--apply", "--hq"], []]) {
const text = `# W1-22\r\n\r\nStatus: ${status}\r\n\r\nText\r\n`;
writeFileSync(specPath, text);
const beforeSpec = readFileSync(specPath);
const beforeStand = readFileSync(standPath);
const errors = [];
const calls = [];
const run = (...args) => {
calls.push(args);
return { code: 0, stdout: "", stderr: "" };
};
const code = await main(["w1-22", "--root", dir, ...flags],
{ out: () => {}, err: (s) => errors.push(s) }, { run });
assert.deepEqual(readFileSync(specPath), beforeSpec, `${status}: spec unchanged`);
assert.deepEqual(readFileSync(standPath), beforeStand, `${status}: STAND unchanged`);
assert.equal(code, 3);
assert.match(errors.join(""), /Status.*unbekannt/);
assert.deepEqual(calls, [], "refusal must not run HQ or git");
}
}
});

test("spec-close is a dry run by default and changes both files with --apply", async (t) => {
const dir = repo(t);
const out = [];
Expand Down
119 changes: 10 additions & 109 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ mod routing;
mod ruflo;
mod scout;
mod sessionpersist;
mod settings_cmds;
mod setupgate;
mod skills;
mod stats;
Expand All @@ -119,7 +120,7 @@ mod workers;
mod worktree;

use diagnosis_cmds::*;
use std::collections::BTreeMap;
use settings_cmds::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
Expand Down Expand Up @@ -741,61 +742,6 @@ async fn spawn_pty(
Ok(SpawnPtyResponse { session_id })
}

/// The global environment stage for ordinary agents (`strict` without a row).
#[tauri::command]
async fn get_agent_env_isolation(store: State<'_, Store>) -> Result<String, String> {
Ok(store.agent_env_isolation().await?.as_str().to_string())
}

/// Set the global stage; it applies to the next spawn or respawn only. The
/// window is the human; the Control API demands the verdict token instead.
#[tauri::command]
async fn set_agent_env_isolation(store: State<'_, Store>, stage: String) -> Result<(), String> {
let stage = stage
.parse::<profiles::EnvIsolation>()
.map_err(|e| e.to_string())?;
store.set_agent_env_isolation(stage).await
}

/// Read-only state of the continuous activation switch (locked by default).
#[tauri::command]
async fn get_continuous_activation(store: State<'_, Store>) -> Result<Value, String> {
workers::activation::Activation::status(&store, &workers::activation::NoMachineReadableEvidence)
.await
}

/// W4-03: turn the continuous switch on, fail-closed. Refused unless the
/// verdict token matches, no emergency stop is active, rows 1-26 of the
/// acceptance matrix are evidenced for `policy_revision` and it was not enabled
/// before. The permit is only parked here: no dispatch loop is started yet.
#[tauri::command]
async fn enable_continuous_activation(
app: AppHandle,
store: State<'_, Store>,
activation: State<'_, workers::activation::Activation>,
held: State<'_, HeldSchedulerPermit>,
verdict_token: String,
policy_revision: u64,
) -> Result<(), String> {
let expected = get_verdict_token(app)?;
let permit = activation
.enable(
&store,
&workers::activation::NoMachineReadableEvidence,
&expected,
&verdict_token,
policy_revision,
)
.await
.map_err(|refusal| format!("continuous activation refused: {refusal:?}"))?;
*held.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(permit);
Ok(())
}

/// The permit of an enabled switch; nothing else holds one.
#[derive(Default)]
struct HeldSchedulerPermit(std::sync::Mutex<Option<workers::scheduler::SchedulerPermit>>);

#[tauri::command]
fn write_pty(
manager: State<'_, PtyManager>,
Expand Down Expand Up @@ -1899,29 +1845,6 @@ async fn import_development_plan(
.await
}

/// Whether the hourly digest writer runs at all. On unless switched off.
#[tauri::command]
async fn get_digest_enabled(store: State<'_, Store>) -> Result<bool, String> {
Ok(digest::enabled(&store).await)
}

#[tauri::command]
async fn set_digest_enabled(store: State<'_, Store>, enabled: bool) -> Result<(), String> {
digest::set_enabled(&store, enabled).await
}

#[tauri::command]
async fn get_routing_status(store: State<'_, Store>) -> Result<routing::RoutingStatus, String> {
Ok(routing::routing_status(&store).await)
}

#[tauri::command]
async fn set_product_mode(store: State<'_, Store>, mode: String) -> Result<(), String> {
let parsed = routing::ProductMode::parse(&mode)
.ok_or_else(|| format!("unknown product mode: {mode}"))?;
routing::set_product_mode(&store, parsed).await
}

// -- project statistics (Phase 20) -----------------------------------------

/// One project's statistics: overview, tokens, sessions, timeline, estimate.
Expand Down Expand Up @@ -2194,14 +2117,19 @@ async fn list_learnings(
///
/// The token the four review routes of the control API ask for on top of the
/// API token (see [`crate::api::VERDICT_TOKEN_HEADER`]). It is minted at
/// startup and written to no file, so this command and the `--verdict-token`
/// flag of `pa` are the only two ways to it - and this one answers the window,
/// which is the surface that has a human in front of it.
/// startup and written to no file, so this command, `--verdict-token` on `pa`,
/// and [`verdict_token_from_app`] (`settings_cmds::enable_continuous_activation`)
/// reach it - this one answers the window with a human in front of it.
///
/// Without a running control API there is no token, and saying so is more
/// useful than an empty string that would read as a valid one.
#[tauri::command]
fn get_verdict_token(app: AppHandle) -> Result<String, String> {
verdict_token_from_app(&app)
}

/// Shared read of the control-API verdict token for other command modules.
pub(crate) fn verdict_token_from_app(app: &AppHandle) -> Result<String, String> {
app.try_state::<ApiServer>()
.map(|server| server.verdict_token().to_string())
.ok_or_else(|| "the control api is not running; there is no verdict token".to_string())
Expand Down Expand Up @@ -2234,33 +2162,6 @@ async fn run_learning_critic(
critic::run_critic(&app, &store, &worker_id).await
}

/// Turn learning on or off for one agent profile.
#[tauri::command]
async fn set_profile_enabled(
store: State<'_, Store>,
id: String,
enabled: bool,
) -> Result<(), String> {
learnings::set_profile_enabled(&store, &id, enabled).await
}

/// Turn learning on or off for one agent category: worker, queen, orchestrator
/// or scout.
#[tauri::command]
async fn set_category_learning(
store: State<'_, Store>,
category: String,
enabled: bool,
) -> Result<(), String> {
learnings::set_learning_enabled(&store, &category, enabled).await
}

/// The per-category learning switches, one entry per known category.
#[tauri::command]
async fn get_learning_settings(store: State<'_, Store>) -> Result<BTreeMap<String, bool>, String> {
Ok(learnings::learning_settings(&store).await)
}

// -- question commands (Phase 21) ------------------------------------------

/// The decisions this project is waiting on, and the ones it has made.
Expand Down
Loading
Loading