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 .kit/shared/memory.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@
{"id":"9c7fa6","area":"cli","kind":"convention","title":"A config section is declared in three places, and the third one warns","body":"Adding a .kit.toml section means kitConfig (type), CONFIG_SECTIONS (config-surface.ts, generates docs/CONFIGURATION.md) AND KNOWN_SECTIONS (config.ts), which loadConfig warns from. The first two were pinned to each other; the third had drifted by two — [supply_chain] and [coverage] are real, honoured sections that printed 'unknown section … (likely a typo)' on every kit invocation. A warning that fires on correct configuration trains the operator to ignore the one that fires on a real typo. config-surface.test.ts now pins all three in both directions.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-24T11:36:15.667Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"0D2CmPQKGkbvtwM5zfM80w3jUa1h1F0AY8Nit6EFMzREVdIbI/PU+ULLVgERFb8G+f2zw46CBt1jSk4qyDAnCw=="}
{"id":"f47c5a","area":"cli","kind":"convention","title":"A gate that exists but is never invoked is the default failure, not the exception","body":"kit adopted its own ADR gate in #403 and no workflow, hook or agent instruction ever called it — armed and unfired for a month, while the rules provably caught violations. A gate nobody runs emits nothing, and nothing reads exactly like a clean run. self-audit-ci already proves every script a workflow points AT exists; the inverse (a gate that exists is pointed at by something) had no rule. When adding a gate to this repo, wire the invocation AND pin it with a test that strips comments and forbids continue-on-error / || true — a gate named in a comment is not a gate, and one that cannot fail the build is a report. General case tracked in #533.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-25T12:06:22.827Z","source_ref":"a49e85c","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"CNhZy+Of23FnzPSF6O95FvxAriwbLWBumewW/9QtgPN606vE4IiidXydMmKQj7JyfBJPOWRBCZM1c7QoSIr4Dw=="}
{"id":"d1814d","area":"cli","kind":"decision","title":"pre-commit excludes full npm test until suite timeouts are fixed","body":"kit-public uses externally managed .githooks. [hooks].pre-commit should require staged security scan + build, not full npm test: a real pre-commit run on 2026-08-27 hit Node test file timeouts in dist/policy-gate.test.js and dist/secrets-propagate.test.js. Re-add full npm test only after those suite timeouts are fixed or the suite is split for hook use.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-27T09:22:36.795Z","source_ref":"3cfa838","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"bbdKUzm6w6dIFEacsfFMdkuLhhwBjZ732zvs08X0ZPlv6NQ7J3CgDJ2GpZhf+M8k7Yff8gvlpPej4b+sYHWOBA=="}
{"id":"f57f88","area":"ops","kind":"convention","title":"post-merge health loop","body":"After merge, release, deploy, or tag push, do not rely on email or GitHub check conclusions alone. Run the connected health inbox command, treat red and unknown as not green, inspect each connected source, and keep acting or waiting until every connected sensor is green before telling the user the arc is closed. For this repo before a release is published, run the local built CLI via node dist/cli.js health.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-28T14:47:56.828Z","source_ref":"cc8e4a0","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"f2AEPcCAs2OVwuS6fbIm2JZG7UMoUZN4+54DI4V2b9Fmc6pDCfLkCkqnmH1/51XQd8431xlVfhrkw5mQkuuCDw=="}
2 changes: 2 additions & 0 deletions src/cli-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("formatHealth", () => {
it("counts red findings and renders a line per finding", () => {
const out = formatHealth(findings);
assert.equal(out.redCount, 1);
assert.equal(out.nonGreenCount, 2);
assert.equal(out.lines.length, 3);
assert.ok(out.lines.some((l) => l.includes("workflow failing: CI")));
assert.ok(out.lines.some((l) => l.includes("acme/webapp")));
Expand All @@ -26,5 +27,6 @@ describe("formatHealth", () => {
it("redCount is 0 when nothing is red", () => {
const out = formatHealth([{ sensor: "a", source: "s", status: "green", title: "ok" }]);
assert.equal(out.redCount, 0);
assert.equal(out.nonGreenCount, 0);
});
});
9 changes: 7 additions & 2 deletions src/cli-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,21 @@ export async function buildHealthCtx(config: kitConfig): Promise<HealthCtx> {
...Object.keys(pkg.devDependencies ?? {}),
];
const { detectServices } = await import("./service-registry.js");
services = await detectServices({ deps, fileExists: async (p) => existsSync(resolve(cwd, p)) });
const detected = await detectServices({
deps,
fileExists: async (p) => existsSync(resolve(cwd, p)),
});
services = Array.from(new Set([...Object.keys(config.services ?? {}), ...detected]));
} catch {
services = [];
services = Object.keys(config.services ?? {});
}
return {
cwd,
config,
gitRemote: remote.ok && remote.stdout.trim().length > 0,
gitlabCi: existsSync(resolve(cwd, ".gitlab-ci.yml")),
bitbucketPipelines: existsSync(resolve(cwd, "bitbucket-pipelines.yml")),
githubDependabot: existsSync(resolve(cwd, ".github", "dependabot.yml")),
vercel,
services,
};
Expand Down
16 changes: 9 additions & 7 deletions src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function cmdHealth(): Promise<boolean> {
config,
{ operation: "health", operationType: "read", metadata: {} },
async () => {
const { runHealth, selectSensors, defaultHealthDeps, formatHealth } =
const { runHealth, selectSensors, defaultHealthDeps, formatHealth, healthOk } =
await import("../health.js");
const { syncHealthFindings } = await import("../health-track.js");

Expand All @@ -69,12 +69,12 @@ export async function cmdHealth(): Promise<boolean> {
await syncHealthFindings(findings); // mirror red into PAL (fail-open)

if (jsonMode) {
const redCount = findings.filter((f) => f.status === "red").length;
console.log(JSON.stringify({ ok: redCount === 0, findings }, null, 2));
return redCount === 0;
const ok = healthOk(findings);
console.log(JSON.stringify({ ok, findings }, null, 2));
return ok;
}

const { lines, redCount } = formatHealth(findings);
const { lines, redCount, nonGreenCount } = formatHealth(findings);
console.log(`${c.bold}kit health${c.reset} ${c.dim}${sensors.length} sensor(s)${c.reset}`);
if (findings.length === 0) {
console.log(` ${c.dim}no connected external systems detected${c.reset}`);
Expand All @@ -83,8 +83,10 @@ export async function cmdHealth(): Promise<boolean> {
const color = line.startsWith("✗") ? c.red : line.startsWith("?") ? c.yellow : c.green;
console.log(` ${color}${line}${c.reset}`);
}
if (redCount > 0) console.log(`${c.red}${redCount} red${c.reset}`);
return redCount === 0;
if (nonGreenCount > 0) {
console.log(`${c.red}${nonGreenCount} not green (${redCount} red)${c.reset}`);
}
return nonGreenCount === 0;
},
);
}
Expand Down
54 changes: 54 additions & 0 deletions src/health-sensors/github-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
parseGitHubRuns,
failingWorkflows,
pendingWorkflows,
activeWorkflowNames,
githubActionsSensor,
} from "./github-actions.js";
Expand Down Expand Up @@ -103,6 +104,30 @@ describe("parseGitHubRuns / failingWorkflows", () => {
["CI"],
);
});

it("keeps a newer in-progress run as not-green instead of falling back to old success", () => {
const parsed = parseGitHubRuns(
JSON.stringify([
{
name: "CI",
status: "in_progress",
conclusion: "",
createdAt: "2026-08-28T14:38:28Z",
databaseId: 10,
},
{
name: "CI",
status: "completed",
conclusion: "success",
createdAt: "2026-08-28T13:30:43Z",
databaseId: 9,
},
]),
);
assert.deepEqual(pendingWorkflows(parsed), [
{ name: "CI", status: "in_progress", createdAt: "2026-08-28T14:38:28Z" },
]);
});
});

describe("activeWorkflowNames", () => {
Expand Down Expand Up @@ -197,6 +222,35 @@ describe("githubActionsSensor.probe", () => {
assert.equal(out[0].status, "green");
});

it("emits unknown while the latest workflow run is still pending", async () => {
const pending = JSON.stringify([
{
name: "CI",
status: "in_progress",
conclusion: "",
createdAt: "2026-08-28T14:38:28Z",
databaseId: 10,
},
{
name: "CI",
status: "completed",
conclusion: "success",
createdAt: "2026-08-28T13:30:43Z",
databaseId: 9,
},
]);
const out = await githubActionsSensor.probe(
ctx,
deps({
"gh repo": { stdout: JSON.stringify({ nameWithOwner: "acme/webapp" }), ok: true },
"gh run": { stdout: pending, ok: true },
}),
);
assert.equal(out.length, 1);
assert.equal(out[0].status, "unknown");
assert.match(out[0].title, /pending/);
});

it("returns unknown when gh is not authed", async () => {
const out = await githubActionsSensor.probe(
ctx,
Expand Down
64 changes: 47 additions & 17 deletions src/health-sensors/github-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ export interface GhRun {
databaseId: number;
}

const FAIL_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure"]);
const FAIL_CONCLUSIONS = new Set([
"failure",
"timed_out",
"startup_failure",
"cancelled",
"action_required",
]);

export function parseGitHubRuns(json: string): GhRun[] {
try {
Expand Down Expand Up @@ -40,17 +46,28 @@ export function failingWorkflows(
runs: GhRun[],
active?: Set<string>,
): { name: string; createdAt: string }[] {
return latestWorkflowRuns(runs, active)
.filter((r) => r.status === "completed" && FAIL_CONCLUSIONS.has(r.conclusion))
.map((r) => ({ name: r.name, createdAt: r.createdAt }));
}

export function pendingWorkflows(
runs: GhRun[],
active?: Set<string>,
): { name: string; status: string; createdAt: string }[] {
return latestWorkflowRuns(runs, active)
.filter((r) => r.status !== "completed")
.map((r) => ({ name: r.name, status: r.status, createdAt: r.createdAt }));
}

function latestWorkflowRuns(runs: GhRun[], active?: Set<string>): GhRun[] {
const latest = new Map<string, GhRun>();
for (const r of runs) {
if (r.status !== "completed") continue;
const cur = latest.get(r.name);
if (!cur || r.createdAt > cur.createdAt) latest.set(r.name, r);
}
const filterDisabled = active !== undefined && active.size > 0;
return [...latest.values()]
.filter((r) => FAIL_CONCLUSIONS.has(r.conclusion))
.filter((r) => !filterDisabled || active.has(r.name))
.map((r) => ({ name: r.name, createdAt: r.createdAt }));
return [...latest.values()].filter((r) => !filterDisabled || active.has(r.name));
}

export const githubActionsSensor: HealthSensor = {
Expand Down Expand Up @@ -112,8 +129,10 @@ export const githubActionsSensor: HealthSensor = {
const wfRes = await deps.runCli("gh", ["workflow", "list", "--json", "name,state"]);
const active = wfRes.ok ? activeWorkflowNames(wfRes.stdout) : new Set<string>();

const failing = failingWorkflows(parseGitHubRuns(listRes.stdout), active);
if (failing.length === 0) {
const runs = parseGitHubRuns(listRes.stdout);
const pending = pendingWorkflows(runs, active);
const failing = failingWorkflows(runs, active);
if (pending.length === 0 && failing.length === 0) {
return [
{
sensor: "github-actions",
Expand All @@ -123,14 +142,25 @@ export const githubActionsSensor: HealthSensor = {
},
];
}
return failing.map((w) => ({
sensor: "github-actions",
source: nwo,
status: "red" as const,
severity: "high" as const,
title: `GitHub Actions workflow failing: ${w.name}`,
detail: `latest run of "${w.name}" failed (${w.createdAt})`,
suggestedClass: "code" as const,
}));
return [
...pending.map((w) => ({
sensor: "github-actions",
source: nwo,
status: "unknown" as const,
severity: "medium" as const,
title: `GitHub Actions workflow pending: ${w.name}`,
detail: `latest run of "${w.name}" is ${w.status} (${w.createdAt}); wait before declaring green`,
suggestedClass: "human" as const,
})),
...failing.map((w) => ({
sensor: "github-actions",
source: nwo,
status: "red" as const,
severity: "high" as const,
title: `GitHub Actions workflow failing: ${w.name}`,
detail: `latest run of "${w.name}" failed (${w.createdAt})`,
suggestedClass: "code" as const,
})),
];
},
};
122 changes: 122 additions & 0 deletions src/health-sensors/github-dependabot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
dependabotPrFindings,
githubDependabotSensor,
parseDependabotPrs,
} from "./github-dependabot.js";
import type { HealthCtx, HealthDeps } from "../health.js";

const ctx: HealthCtx = { cwd: "/tmp/repo", config: {}, gitRemote: true, githubDependabot: true };

function deps(over: Record<string, { stdout: string; ok: boolean }> = {}): HealthDeps {
return {
runCli: async (cmd, args) => {
const key = `${cmd} ${args[0]}`;
const r = over[key];
if (r) return { stdout: r.stdout, stderr: "", exitCode: r.ok ? 0 : 1, ok: r.ok };
return { stdout: "", stderr: "", exitCode: 0, ok: true };
},
httpGet: async () => ({ ok: true, status: 200, body: "" }),
};
}

describe("parseDependabotPrs / dependabotPrFindings", () => {
it("turns failing Dependabot PR checks into red health findings", () => {
const prs = parseDependabotPrs(
JSON.stringify([
{
number: 12,
title: "Bump x",
url: "https://github.com/acme/web/pull/12",
mergeStateStatus: "CLEAN",
statusCheckRollup: [
{ __typename: "CheckRun", name: "CI", status: "COMPLETED", conclusion: "FAILURE" },
],
},
]),
);
const out = dependabotPrFindings("acme/web", prs);
assert.equal(out.length, 1);
assert.equal(out[0].status, "red");
assert.match(out[0].title, /checks failing/);
});

it("turns action-required Dependabot PR checks into red health findings", () => {
const prs = parseDependabotPrs(
JSON.stringify([
{
number: 15,
title: "Bump w",
url: "https://github.com/acme/web/pull/15",
mergeStateStatus: "CLEAN",
statusCheckRollup: [
{
__typename: "CheckRun",
name: "CI",
status: "COMPLETED",
conclusion: "ACTION_REQUIRED",
},
],
},
]),
);
const out = dependabotPrFindings("acme/web", prs);
assert.equal(out.length, 1);
assert.equal(out[0].status, "red");
assert.match(out[0].title, /checks failing/);
});

it("turns pending Dependabot PR checks into unknown health findings", () => {
const prs = parseDependabotPrs(
JSON.stringify([
{
number: 13,
title: "Bump y",
url: "https://github.com/acme/web/pull/13",
mergeStateStatus: "CLEAN",
statusCheckRollup: [{ __typename: "CheckRun", name: "CI", status: "IN_PROGRESS" }],
},
]),
);
const out = dependabotPrFindings("acme/web", prs);
assert.equal(out.length, 1);
assert.equal(out[0].status, "unknown");
assert.match(out[0].title, /checks pending/);
});

it("turns a ready open Dependabot PR into an action item", () => {
const prs = parseDependabotPrs(
JSON.stringify([
{
number: 14,
title: "Bump z",
url: "https://github.com/acme/web/pull/14",
mergeStateStatus: "CLEAN",
statusCheckRollup: [
{ __typename: "CheckRun", name: "CI", status: "COMPLETED", conclusion: "SUCCESS" },
],
},
]),
);
const out = dependabotPrFindings("acme/web", prs);
assert.equal(out.length, 1);
assert.equal(out[0].status, "red");
assert.equal(out[0].severity, "low");
assert.match(out[0].title, /ready for review/);
});
});

describe("githubDependabotSensor.probe", () => {
it("is green when no Dependabot PRs are open", async () => {
const out = await githubDependabotSensor.probe(
ctx,
deps({
"gh repo": { stdout: JSON.stringify({ nameWithOwner: "acme/web" }), ok: true },
"gh pr": { stdout: "[]", ok: true },
}),
);
assert.equal(out.length, 1);
assert.equal(out[0].status, "green");
});
});
Loading
Loading