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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20.19.0, 22.12.0]
node: [22.12.0, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to junto are documented in this file. The project follows Semantic Versioning.

## [Unreleased]

### Changed

- Require Node.js 22.12 or newer; Node.js 20 reached end of life on 2026-04-30. CI now tests Node.js 22.12 and 24, and bundles target Node.js 22.
- Update `@modelcontextprotocol/sdk` to 1.30.1.

### Fixed

- Bind command gate verdicts to source contents outside `staleIgnore` in Git repository roots, so edits that bypass the edit hooks (Bash, formatters, codegen) make them stale.
- Block case variants and Windows trailing-dot or short-name aliases of protected `.junto/` evidence paths in the guard hook.
- Persist rule obligations and phase transitions under the task lock, keeping concurrent staleness and approval updates; refuse `done` if a required gate was invalidated during the transition.
- Reject reserved Windows device names for review gates, as for command gates.

## [0.5.0] - 2026-09-22

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ corepack pnpm --filter @junto/core add <package>
```

- Do not install pnpm globally or change `"packageManager": "pnpm@10.17.1"`.
- Node.js 20.19+ or 22.12+ is required by the Vite dependency chain.
- Node.js 22.12+ is required (Node.js 20 reached end of life on 2026-04-30).
- Add every new package project to the root `tsconfig.json` references.
- pnpm explicitly permits the esbuild install script through `pnpm.onlyBuiltDependencies`.

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ claude plugin install junto@junto

No `npx`, downloaded binary, or install script is required.

Requirements: Claude Code with plugin support and Node.js 20 or newer available on `PATH`.
Requirements: Claude Code with plugin support and Node.js 22.12 or newer available on `PATH`.

Verify the installed version or update an existing installation:

Expand Down Expand Up @@ -72,9 +72,12 @@ advisory and never replace quality-gate evidence.
## Evidence, not promises

`junto__verify` runs test commands itself and records the result. A model cannot merely claim that tests passed.
When source files change, previous verdicts become stale and gates must run again.
When source files change, previous verdicts become stale and gates must run again. In a Git repository
root, command gate verdicts are also bound to the contents of files outside `staleIgnore`, so edits made
through Bash, formatters, or code generators are caught at the next transition as well.

The `guard.js` hook blocks evidence writes through Edit/Write/MultiEdit, but it **cannot block Bash**.
The `guard.js` hook blocks evidence writes through Edit/Write/MultiEdit, including case and Windows
path aliases of `.junto/`, but it **cannot block Bash**.
It prevents accidents and shortcuts; it is not a security boundary against a malicious actor.

## OpenCodeReview gates and rules
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"packageManager": "pnpm@10.17.1",
"pnpm": { "onlyBuiltDependencies": ["esbuild"] },
"engines": { "node": ">=20" },
"engines": { "node": ">=22.12" },
"scripts": {
"typecheck": "tsc -b",
"test": "vitest run",
Expand All @@ -14,7 +14,7 @@
"@junto/core": "workspace:*"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.30.1",
"@types/node": "^22.0.0",
"esbuild": "^0.24.0",
"typescript": "^5.9.2",
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface RunGateOptions {
name: string
spec: GateSpec
runner: string
sourceFingerprint?: string
}

/** Take a byte-limited tail without splitting a UTF-8 character. */
Expand All @@ -30,7 +31,8 @@ function tail(text: string, maxBytes: number): string {
return new TextDecoder("utf-8", { fatal: false }).decode(buf.subarray(buf.byteLength - maxBytes))
}

function validGateName(name: string): boolean {
/** Shared by command and review gates, which write the same verdict file names. */
export function validGateName(name: string): boolean {
return VALID_GATE_NAME.test(name)
&& name !== "."
&& name !== ".."
Expand Down Expand Up @@ -136,6 +138,7 @@ export async function runGate(opts: RunGateOptions): Promise<VerdictFile> {
outputBytes: Buffer.byteLength(outcome.output, "utf-8"),
outputFile: `verdicts/${name}.log`,
runner,
...(opts.sourceFingerprint ? { sourceFingerprint: opts.sourceFingerprint } : {}),
...(outcome.reason ? { reason: outcome.reason } : {}),
}

Expand Down
33 changes: 29 additions & 4 deletions packages/core/src/review-freshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createHash } from "node:crypto"
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, realpathSync } from "node:fs"
import { basename, isAbsolute, join, relative, resolve } from "node:path"
import { taskDir } from "./paths.js"
import type { Config, Task } from "./schema.js"
import type { Config, GateSpec, Task } from "./schema.js"
import { shouldStale } from "./stale.js"

const IGNORED_UNTRACKED = new Set(["node_modules", "dist", "build", ".temp", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"])
const digest = (value: string | Buffer): string => createHash("sha256").update(value).digest("hex")
Expand All @@ -19,8 +20,13 @@ function fileDigest(path: string): string {
} finally { closeSync(fd) }
}

/** Bind review evidence to source and policy without storing source text or reading environment files. */
export function captureReviewFingerprint(root: string, task: Task, config: Config): string {
interface SourceState {
head: string | null
index: Buffer
files: Array<[string, number | null, string]>
}

function sourceState(root: string, include: (path: string) => boolean = () => true): SourceState {
// Native resolution expands Windows 8.3 aliases used by runner temporary directories.
const canonicalRoot = realpathSync.native(root)
const git = (...args: string[]): Buffer => execFileSync("git", args, {
Expand All @@ -38,7 +44,7 @@ export function captureReviewFingerprint(root: string, task: Task, config: Confi
const files: Array<[string, number | null, string]> = []
for (const path of [...paths].sort()) {
const parts = path.split("/")
if (parts[0] === ".junto" || basename(path) === ".env" || basename(path).startsWith(".env.")) continue
if (parts[0] === ".junto" || basename(path) === ".env" || basename(path).startsWith(".env.") || !include(path)) continue
if (!tracked.has(path) && parts.some(part => IGNORED_UNTRACKED.has(part))) continue
const full = resolve(canonicalRoot, path)
const rel = relative(canonicalRoot, full)
Expand All @@ -56,10 +62,29 @@ export function captureReviewFingerprint(root: string, task: Task, config: Confi
files.push([path, stat.mode, fileDigest(full)])
} else throw new Error("Review fingerprints do not support source directories or submodules")
}
return { head, index, files }
}

/** Bind review evidence to source and policy without storing source text or reading environment files. */
export function captureReviewFingerprint(root: string, task: Task, config: Config): string {
const { head, index, files } = sourceState(root)
const context = ["brief.md", "plan.md", "review-background.md"].map(name => {
const path = join(taskDir(root, task.id), name)
return existsSync(path) ? digest(readFileSync(path)) : null
})
return digest(JSON.stringify({ version: 1, head, index: digest(index), files,
base: task.baseCommit, title: task.title, context, config }))
}

/**
* Bind a command gate verdict to the source it ran against, so edits that bypass the edit hooks
* (Bash, formatters, codegen) still make it stale. Only file contents outside `staleIgnore` and the
* gate's own spec count: commits, staging and documentation edits keep the verdict fresh.
* Returns null where fingerprints are unsupported (no Git repository root, submodules); those
* projects keep relying on the edit hooks alone.
*/
export function captureSourceFingerprint(root: string, config: Config, spec: GateSpec): string | null {
let files: SourceState["files"]
try { ({ files } = sourceState(root, path => shouldStale(path, config.staleIgnore))) } catch { return null }
return digest(JSON.stringify({ version: 1, kind: "command-gate", files, spec }))
}
11 changes: 6 additions & 5 deletions packages/core/src/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { execa } from "execa"
import { resolveExecutable } from "./exec.js"
import { OUTPUT_TAIL_BYTES } from "./gates.js"
import { OUTPUT_TAIL_BYTES, validGateName } from "./gates.js"
import { taskDir } from "./paths.js"
import { SCHEMA_VERSION, type GateState, type VerdictFile } from "./schema.js"
import type { ReviewScope } from "./review-scope.js"
Expand Down Expand Up @@ -489,17 +489,18 @@ export interface RunReviewGateOptions {

export const DEFAULT_FAIL_ON: ReviewSeverity[] = ["critical", "high"]

const VALID_GATE_NAME = /^[A-Za-z0-9._-]+$/

/**
* Run a review provider as a gate. Only the provider's real result decides the state:
* a missing reviewer is `skipped` (never a pass), a broken reviewer is `fail`, and findings at or
* above a failOn severity are `fail`. Nothing the model says can change that.
*/
export async function runReviewGate(opts: RunReviewGateOptions): Promise<VerdictFile> {
const { root, taskId, name, provider, runner } = opts
if (!VALID_GATE_NAME.test(name) || name === "." || name === "..") {
throw new Error(`Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-".`)
if (!validGateName(name)) {
throw new Error(
`Invalid gate name "${name}". Use only letters, digits, ".", "_", and "-", `
+ "and do not use a reserved Windows device name.",
)
}
const failOn = opts.failOn ?? DEFAULT_FAIL_ON
const startedAt = new Date().toISOString()
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export interface VerdictFile {
reason?: string
/** Present on source-bound reviews; legacy reviews must be rerun. */
reviewFingerprint?: string
/** Present on command gates run in a Git repository root; binds the verdict to source contents. */
sourceFingerprint?: string
}

export function parseTask(raw: unknown): Task {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/test/review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,9 @@ describe("runReviewGate", () => {
await expect(runReviewGate({ root: tmpRoot, taskId: "t", name: "../x", provider: new MockReviewProvider(), context: {}, runner: "t" }))
.rejects.toThrow(/Invalid gate name/)
})

it.each(["nul", "CON", "com1.log"])("rejects reserved Windows device gate names like command gates do: %s", async (name) => {
await expect(runReviewGate({ root: tmpRoot, taskId: "t", name, provider: new MockReviewProvider(), context: {}, runner: "t" }))
.rejects.toThrow(/Invalid gate name/)
})
})
2 changes: 1 addition & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "./src/server.ts",
"dependencies": {
"@junto/core": "workspace:*",
"@modelcontextprotocol/sdk": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.30.1",
"execa": "^9.6.1",
"zod": "^3.23.8"
}
Expand Down
34 changes: 26 additions & 8 deletions packages/mcp/src/tools/advance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import { join } from "node:path"
import {
canEnter,
captureReviewFingerprint,
captureSourceFingerprint,
gateStateSchema,
readActiveId,
readConfig,
readTask,
taskDir,
writeTask,
updateTask,
type Config,
type GateState,
type Phase,
type Task,
type TransitionContext,
} from "@junto/core"
import type { ToolContext } from "../context.js"
import { resolveTaskPolicy } from "./policy.js"
import { persistTaskPolicy, resolveTaskPolicy } from "./policy.js"

/** Read disk facts at the I/O boundary so `canEnter` remains pure. */
export function buildTransitionContext(root: string, task: Task, config: Config): TransitionContext {
Expand All @@ -37,6 +38,15 @@ export function buildTransitionContext(root: string, task: Task, config: Config)
return null
}
}
if (verdict.sourceFingerprint !== undefined) {
// Catches edits the hooks cannot see; an unsupported or changed fingerprint fails closed.
const spec = config.gates[name]
if (spec === undefined || captureSourceFingerprint(root, config, spec) !== verdict.sourceFingerprint) {
const gate = task.gates[name]
if (gate) gate.stale = true
return null
}
}
return gateStateSchema.parse(verdict.state)
} catch {
if (config.gates[name]?.type === "review") {
Expand Down Expand Up @@ -69,16 +79,24 @@ export async function advanceTool(ctx: ToolContext, input: { to: Phase }): Promi
const stored = readTask(ctx.root, id)
const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config)
// Persist new obligations even when blocked so the user approval hook sees them.
if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task)
persistTaskPolicy(ctx.root, stored, task)
const check = canEnter(task, input.to, { ...buildTransitionContext(ctx.root, task, config), unknownRuleGates: unknownGates })
if (!check.ok) throw new Error(`Cannot transition to "${input.to}". ${check.reason}`)

// Apply only the transition to the latest task so concurrent hook updates (staleness,
// approval) survive, and refuse if the task moved or a gate was invalidated after the check.
const now = new Date().toISOString()
const previous = task.phases[task.phase]
if (previous !== undefined) task.phases[task.phase] = { ...previous, status: "done", at: now }
task.phases[input.to] = { ...(task.phases[input.to] ?? {}), status: "active", at: now }
task.phase = input.to
writeTask(ctx.root, task)
updateTask(ctx.root, id, current => {
const invalidated = Object.entries(current.gates).some(([name, gate]) => gate.required
&& (gate.stale || (gate.invalidationVersion ?? 0) !== (task.gates[name]?.invalidationVersion ?? 0)))
if (current.phase !== task.phase || (input.to === "done" && invalidated)) {
throw new Error(`Cannot transition to "${input.to}". The task changed during the transition; retry.`)
}
const previous = current.phases[current.phase]
if (previous !== undefined) current.phases[current.phase] = { ...previous, status: "done", at: now }
current.phases[input.to] = { ...(current.phases[input.to] ?? {}), status: "active", at: now }
current.phase = input.to
})

const nudge = input.to === "panel"
? " Run /junto:panel to review the approved plan, then advance to build."
Expand Down
6 changes: 3 additions & 3 deletions packages/mcp/src/tools/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { mkdirSync, writeFileSync } from "node:fs"
import { isAbsolute, join, resolve } from "node:path"
import {
OpenCodeReviewProvider, RuleMatcher, SkillResolver, buildPlan,
readActiveId, readConfig, readTask, resolveReviewScopes, resolveTaskChanges, taskDir, writeTask,
readActiveId, readConfig, readTask, resolveReviewScopes, resolveTaskChanges, taskDir,
type DelegatePreview, type JuntoPlan, type ReviewScope,
} from "@junto/core"
import type { ToolContext } from "../context.js"
import { configRules, resolveTaskPolicy } from "./policy.js"
import { configRules, persistTaskPolicy, resolveTaskPolicy } from "./policy.js"

export interface ResolvedPlan extends JuntoPlan {
base: string | null
Expand All @@ -31,7 +31,7 @@ export async function resolvePlan(ctx: ToolContext): Promise<ResolvedPlan> {
const files = changes.filter(c => c.status !== "deleted").map(c => c.path)

const { task, unknownGates } = await resolveTaskPolicy(ctx.root, stored, config, changes)
if (JSON.stringify(task) !== JSON.stringify(stored)) writeTask(ctx.root, task)
persistTaskPolicy(ctx.root, stored, task)
const rules = configRules(config)

const plan = buildPlan(task.title, files, new RuleMatcher(rules), {
Expand Down
18 changes: 17 additions & 1 deletion packages/mcp/src/tools/policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RuleMatcher, resolveTaskChanges, type ChangedFile, type Config, type Rule, type Task } from "@junto/core"
import { RuleMatcher, resolveTaskChanges, updateTask, type ChangedFile, type Config, type Rule, type Task } from "@junto/core"

export function configRules(config: Config): Rule[] {
return (config.rules ?? []).map(r => ({
Expand Down Expand Up @@ -30,3 +30,19 @@ export async function resolveTaskPolicy(root: string, task: Task, config: Config
unknownGates,
}
}

/**
* Persist resolved obligations under the task lock. They only ever add gates or raise flags, so
* merging them into the latest task keeps concurrent hook updates (staleness, approval).
*/
export function persistTaskPolicy(root: string, stored: Task, resolved: Task): void {
if (JSON.stringify(resolved) === JSON.stringify(stored)) return
updateTask(root, resolved.id, current => {
for (const [name, gate] of Object.entries(resolved.gates)) {
const existing = current.gates[name]
if (existing === undefined) current.gates[name] = { ...gate }
else existing.required ||= gate.required
}
if (resolved.ruleApprovalRequired) current.ruleApprovalRequired = true
})
}
Loading
Loading