Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .claude/commands/pharn-ship-close.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ _"no baseline"_ is expected and harmless when no epoch is open.
1. **Render deterministically** (`pharn/pharn-contracts/ship-briefing.md`).

```bash
node pharn/floor/render-ship-briefing.mjs <name> > /tmp/briefing-draft.md
mkdir -p .pharn/pharn-ship/<name>
node pharn/floor/render-ship-briefing.mjs <name> > .pharn/pharn-ship/<name>/briefing-draft.md
```

2. **The one narrow ADVISORY step — only when the render found nothing to quote.** Check whether the
Expand Down
13 changes: 5 additions & 8 deletions .claude/commands/pharn-ship-quick.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,13 @@ spec_kind: quick`. The remedy is to re-run `/pharn-ship <description>` **without
`regression-report.json` read. (Step 2's regress item, above, is the full-mode procedure this one item
omits — every other Step-2 item runs as written.) Its first check is **kept**: item 7.

7. **The scope check: KEPT — run it before `/pharn-verify`.** First resolve the base by the branches of
`/pharn-regress`'s `BASE_RULE` (`stage-regress-core.mjs` — cited, not restated, P4) that apply here — `/pharn-ship`
has no `--base` flag, so a base is never read out of the description: `HEAD` when the working tree is dirty (an
uncommitted build), else `git merge-base HEAD origin/main`, else ask the human for the base commit's 40-hex SHA.
`git rev-parse HEAD` and `git merge-base HEAD origin/main` each print one. Then run it, substituting `<name>` and
that SHA as `<base sha>` — the only two values the line takes (Step 3a captures its own `<base sha>` later,
separately):
7. **The scope check: KEPT — run it before `/pharn-verify`.** Run ONE pinned line — the checker resolves the base
by the same `BASE_RULE` as `/pharn-regress`'s `base` phase (`regress-base-core.mjs` / `stage-regress-core.mjs`
— cited, not restated, P4) inside Node. **Never run `git rev-parse` or `git merge-base` in the shell** for this
step: when the invoker passed `--base <ref>`, add `--from-ref '<ref>'` (single-quoted); otherwise omit it.

```bash
node pharn/floor/check-quick-scope.mjs --feature '<name>' --base '<base sha>'
node pharn/floor/check-quick-scope.mjs --feature '<name>' --base auto [--from-ref '<ref>']
```

**Never type a path into it**: the checker builds both path sets itself (`pharn/floor/quick-scope-core.mjs`,
Expand Down
13 changes: 13 additions & 0 deletions .dev/features/low-findings-batch-2/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# PLAN — low-findings-batch-2

- increment: LOW L1 (quick base resolution in floor code) + L8 (briefing draft path under `.pharn/`).
- layer(s): product floor + `pharn-ship` command
- constitution_refs: [P0, P4, P7]

## Files

- `pharn/floor/regress-base-core.mjs` — NEW
- `pharn/floor/regress-base-core.test.mjs` — NEW
- `pharn/floor/quick-scope-core.mjs` — EDIT (`--base auto`, `--from-ref`)
- `pharn/floor/check-quick-scope.test.mjs` — EDIT (ship line shape)
- `.claude/commands/pharn-ship.md` — EDIT (items 7, 2c)
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
moves, they silently match nothing. `npm test`'s glob was already narrowed for the same reason (`package.json`,
`_test_glob_comment`).

## [6.32.2] - 2026-09-28

### Fixed

- 2026-09-28: **Quick scope resolves regress base inside Node (LOW L1).** `/pharn-ship --quick` item 7 pins `--base auto` (optional `--from-ref`) so untrusted refs never reach shell `git rev-parse`; `regress-base-core.mjs` owns BASE_RULE git argv.
- 2026-09-28: **GATE-2 briefing draft under `.pharn/` (LOW L8).** Step 2c renders to `.pharn/pharn-ship/<name>/briefing-draft.md` instead of a fixed `/tmp` path.


## [6.32.1] - 2026-09-28

### Fixed
Expand Down Expand Up @@ -85,7 +93,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
trusted text of the command and no path an artifact names is read in its place, a re-read after a compaction (a
Read result is not kept by one), and a not-loaded rule that stops the run and never runs a part from memory. **No
stage, order, route, check, stop decision, retry bound, ledger rule, commit rule or human gate moved**; what a run
does in addition is the part Reads and, if a part cannot be read, one of four new stops. **Bytes (measured):** sent at invocation, `pharn-loop.md` 77,971 → about 41,100,
does in addition is the part Reads and, if a part cannot be read, one of four stops. **Bytes (measured):** sent at invocation, `pharn-loop.md` 77,971 → about 41,100,
`pharn-ship.md` 67,543 → about 34,100. **Requests and tokens (estimates, from a request profile counted over the
pinned steps):** a full run carries 29–38% fewer of this text's bytes across its requests, a quick run 17–22%. The
close part costs the loop one added request, and ship one only at a STOP before verify. Net of that request, the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ model or human judgment remains advisory.
npx @pharn-dev/pharn@latest init
```

[![pharn](https://img.shields.io/badge/pharn-6.32.1-blue)](./CHANGELOG.md)
[![pharn](https://img.shields.io/badge/pharn-6.32.2-blue)](./CHANGELOG.md)
[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE)
[![CI](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/ci.yml)
[![CodeQL](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml/badge.svg)](https://github.com/pharn-dev/pharn-oss/actions/workflows/codeql.yml)
Expand Down
2 changes: 1 addition & 1 deletion SKILLS_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.32.1
6.32.2
6 changes: 4 additions & 2 deletions pharn/floor/check-quick-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
// a top-level await in the graph that never settles (node exits 13), a kill by signal, and a stdout that cannot be
// written.
//
// Usage: node pharn/floor/check-quick-scope.mjs --feature <name> --base <40-hex> (from the repo root)
// Usage: node pharn/floor/check-quick-scope.mjs --feature <name> --base <40-hex|auto> [--from-ref <git-ref>]
// (from the repo root)
// Exit: 0 clean · 1 escaped (a blocking P0 fix #7 finding per path) · 2 inconclusive, `reason_code` one of
// usage-error | base-not-commit | path-containment | plan-unreadable | plan-files-unparseable | git-failed | crashed.
// usage-error | base-not-commit | base-unresolved | path-containment | plan-unreadable |
// plan-files-unparseable | git-failed | crashed.

/** quick-scope-core.mjs EXIT, restated because that module cannot be imported here. Pinned by a test. */
const EXIT = Object.freeze({ clean: 0, escaped: 1, inconclusive: 2 });
Expand Down
27 changes: 16 additions & 11 deletions pharn/floor/check-quick-scope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,13 @@ function committedLine(file) {

/** Run a committed line in `dir` under `sh -c`, substituting only its two placeholders. `opts` joins spawnSync's options
* (a document echoing more than 1 MiB of paths needs this spawn's own `maxBuffer` raised). */
function normalizeCommittedLine(line) {
return line.replace(/\s*\[--from-ref '<ref>'\]/, "");
}

function runCommitted(line, dir, base, opts = {}) {
const cmd = line.replace("<name>", "demo").replace("<base sha>", base);
let cmd = normalizeCommittedLine(line).replace("<name>", "demo");
if (!cmd.includes("--base auto")) cmd = cmd.replace("<base sha>", base);
const r = spawnSync("sh", ["-c", cmd], { cwd: dir, encoding: "utf8", env: envWithoutQ(), ...opts });
return { status: r.status, doc: parseDoc(r.stdout), stdout: r.stdout, stderr: r.stderr };
}
Expand All @@ -153,15 +158,15 @@ function runOldLine(dir, base, declared) {

// ── The line itself ────────────────────────────────────────────────────────────────────────────────────────────

test("✧ both committed lines take ONLY the slug and the base, each single-quoted, with no other shell-active character", () => {
for (const file of COMMANDS) {
const line = committedLine(file);
assert.deepEqual([...line.matchAll(/<[^>]*>/g)].map((m) => m[0]).sort(), ["<base sha>", "<name>"], file);
assert.ok(line.includes("--feature '<name>'") && line.includes("--base '<base sha>'"), `${file}: both values single-quoted`);
const bare = line.replace("'<name>'", "").replace("'<base sha>'", "");
assert.doesNotMatch(bare, /["'$`\\;|&(){}<>*?!]/, `${file}: no other shell-active character`);
}
assert.equal(committedLine("pharn-loop.md"), committedLine("pharn-ship.md"), "the two quick modes pin the same line");
test("✧ committed quick scope lines: loop passes slug+sha; ship resolves base in Node (LOW L1)", () => {
const loopLine = committedLine("pharn-loop.md");
assert.deepEqual([...loopLine.matchAll(/<[^>]*>/g)].map((m) => m[0]).sort(), ["<base sha>", "<name>"]);
assert.ok(loopLine.includes("--feature '<name>'") && loopLine.includes("--base '<base sha>'"));
const shipLine = committedLine("pharn-ship.md");
assert.ok(shipLine.includes("--feature '<name>'") && shipLine.includes("--base auto"));
assert.ok(!shipLine.includes("git rev-parse"), "ship must not type a ref into shell git");
const bareLoop = loopLine.replace("'<name>'", "").replace("'<base sha>'", "");
assert.doesNotMatch(bareLoop, /["'$`\\;|&(){}<>*?!]/, "pharn-loop.md");
});

test("✧ neither quick section still carries 6.25.0's check-regress.mjs scope line", () => {
Expand Down Expand Up @@ -402,7 +407,7 @@ test("refusals: every bad input exits 2 with its closed reason_code, never 0 or

test("✧ CLOSURE (L36) — every reason_code the checker emits is a member, and every member but `crashed` has an inconclusive() call", async () => {
const { REASON_CODES } = await import("./quick-scope-core.mjs");
assert.equal(REASON_CODES.length, 7, "NON-VACUITY (L34)");
assert.equal(REASON_CODES.length, 8, "NON-VACUITY (L34)");
const src = readFileSync(join(HERE, "quick-scope-core.mjs"), "utf8");
const emitted = new Set([...src.matchAll(/inconclusive\("([a-z-]+)"/g)].map((m) => m[1]));
for (const code of emitted) assert.ok(REASON_CODES.includes(code), `an emitted code outside the set: ${code}`);
Expand Down
38 changes: 29 additions & 9 deletions pharn/floor/quick-scope-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
// and a name holding a comma split into pieces that were each declared or exempt (exit 0 again). The same literal had
// shipped in `/pharn-ship --quick` since 6.25.0; `/pharn-loop --quick` would have run it unattended.
//
// THE FIX, by construction: the pinned line carries exactly two values — the feature slug and a resolved 40-hex base —
// and this module validates both: the slug against gate-run-core.mjs's FEATURE_SLUG_RE (the loop's own S1 slug rule),
// the base against SHA_RE AND `git rev-parse --verify --quiet <base>^{commit}`. Everything else is computed here, by code:
// THE FIX, by construction: the pinned line carries the feature slug and a base token: either a resolved 40-hex base or
// the literal `auto`, optionally with one git ref carried as `--from-ref`. This module validates the slug against
// gate-run-core.mjs's FEATURE_SLUG_RE (the loop's own S1 slug rule), resolves `auto` through `regress-base-core.mjs`'s
// BASE_RULE git argv, then validates the resulting base against SHA_RE AND `git rev-parse --verify --quiet
// <base>^{commit}`. Everything else is computed here, by code:
// • the declared writes and the changed paths by pharn/floor/scope-inputs.mjs — the ONE owner stage-regress.mjs's
// partition phase also calls (PLAN.md ∪ AC-TESTS.md `## Files`; `git diff --name-only --no-renames -z <base>` ∪
// `git ls-files -z --others --exclude-standard`, minus `.pharn/`);
Expand Down Expand Up @@ -51,16 +53,19 @@ import { FEATURE_SLUG_RE, SHA_RE } from "./gate-run-core.mjs";
import { containmentWalk, gitSync } from "./stage-runtime.mjs";
import { declaredWrites, changedPaths } from "./scope-inputs.mjs";
import { partitionScope, scopeFindings, normPath } from "./check-regress.mjs";
import { resolveRegressBase } from "./regress-base-core.mjs";

const FEATURES_DIR = "pharn/features";
const FLAGS = new Set(["--feature", "--base"]);
const USAGE = "usage: check-quick-scope.mjs --feature <name> --base <40-hex>";
const FLAGS = new Set(["--feature", "--base", "--from-ref"]);
const REQUIRED_FLAGS = new Set(["--feature", "--base"]);
const USAGE = "usage: check-quick-scope.mjs --feature <name> --base <40-hex|auto> [--from-ref <git-ref>]";

/** The closed refusal vocabulary (exported so the tests iterate it — L29). `crashed` is the entry's alone: it reports a
* module that cannot load, a throw while checking, or a result outside this module's contract. */
export const REASON_CODES = Object.freeze([
"usage-error",
"base-not-commit",
"base-unresolved",
"path-containment",
"plan-unreadable",
"plan-files-unparseable",
Expand Down Expand Up @@ -91,16 +96,31 @@ function parseArgs(args) {
if (i + 1 >= args.length) inconclusive("usage-error", `${a} requires a value — ${USAGE}`);
values.set(a, args[i + 1]);
}
for (const f of FLAGS) if (!values.has(f)) inconclusive("usage-error", `${f} is required — ${USAGE}`);
return { feature: values.get("--feature"), base: values.get("--base") };
for (const f of REQUIRED_FLAGS) if (!values.has(f)) inconclusive("usage-error", `${f} is required — ${USAGE}`);
return {
feature: values.get("--feature"),
base: values.get("--base"),
fromRef: values.has("--from-ref") ? values.get("--from-ref") : null,
};
}

function check(args) {
const { feature, base } = parseArgs(args);
let { feature, base, fromRef } = parseArgs(args);
if (!FEATURE_SLUG_RE.test(feature)) {
inconclusive("usage-error", `--feature must be a plain slug matching ${FEATURE_SLUG_RE}, got ${JSON.stringify(feature)}`);
}
if (!SHA_RE.test(base)) inconclusive("usage-error", `--base must be a resolved 40-hex commit SHA, got ${JSON.stringify(base)}`);
if (base === "auto") {
const resolved = resolveRegressBase({ explicitRef: fromRef });
if (!resolved.ok) {
if (resolved.reason_code === "base-not-commit") inconclusive("base-not-commit", resolved.reason);
if (resolved.reason_code === "base-unresolved") inconclusive("base-unresolved", resolved.reason);
if (resolved.reason_code === "git-failed") inconclusive("git-failed", resolved.reason);
inconclusive("git-failed", `base resolution failed with unknown reason ${JSON.stringify(resolved.reason_code)}`);
}
base = resolved.sha;
} else if (!SHA_RE.test(base)) {
inconclusive("usage-error", `--base must be a resolved 40-hex commit SHA or the literal auto, got ${JSON.stringify(base)}`);
}
const rev = gitSync(["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
if (!rev.ok || rev.stdout.trim() !== base) inconclusive("base-not-commit", `--base ${base} does not name a commit in this repository`);

Expand Down
64 changes: 64 additions & 0 deletions pharn/floor/regress-base-core.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// pharn/floor/regress-base-core.mjs — resolve the regress/quick base commit by BASE_RULE (6.28.5, LOW L1).
// ONE owner for the git work `/pharn-regress`'s `base` phase and `/pharn-ship --quick` item 7 share: explicit
// ref wins; else a dirty tree → HEAD; else merge-base HEAD origin/main; else ask. Refs reach git ONLY through
// `gitSync` argv arrays — never through shell interpolation in command prose.

import { gitSync } from "./stage-runtime.mjs";
import { resolveBaseSource } from "./stage-regress-core.mjs";
import { isExcluded } from "./worktree-fingerprint.mjs";
import { SHA_RE } from "./gate-run-core.mjs";

function porcelainPath(line) {
return line.slice(3).trim();
}

/**
* @param {{ explicitRef?: string | null }} opts
* @returns {{ ok: true, sha: string } | { ok: false, reason_code: "base-not-commit" | "base-unresolved" | "git-failed", reason: string }}
*/
export function resolveRegressBase({ explicitRef = null } = {}) {
if (explicitRef !== null && explicitRef !== "") {
const r = gitSync(["rev-parse", "--verify", "--quiet", `${explicitRef}^{commit}`]);
const sha = r.ok ? r.stdout.trim() : "";
if (!r.ok || !SHA_RE.test(sha)) {
return {
ok: false,
reason_code: "base-not-commit",
reason: `--from-ref ${JSON.stringify(explicitRef)} does not resolve to a commit in this repository`,
};
}
return { ok: true, sha };
}

const porcelain = gitSync(["status", "--porcelain"]);
if (!porcelain.ok) {
return { ok: false, reason_code: "git-failed", reason: `git status failed: ${porcelain.detail}` };
}
const workingTreeDirty = porcelain.stdout
.split(/\r?\n/)
.filter(Boolean)
.some((line) => !isExcluded(porcelainPath(line), null));

const mb = gitSync(["merge-base", "HEAD", "origin/main"]);
const hasMergeBase = mb.ok && SHA_RE.test(mb.stdout.trim());
const source = resolveBaseSource({ workingTreeDirty, hasMergeBase });

if (source.kind === "head") {
const head = gitSync(["rev-parse", "HEAD"]);
const sha = head.ok ? head.stdout.trim() : "";
if (!head.ok || !SHA_RE.test(sha)) {
return {
ok: false,
reason_code: "git-failed",
reason: "git rev-parse HEAD failed" + (head.ok ? "" : `: ${head.detail}`),
};
}
return { ok: true, sha };
}
if (source.kind === "merge-base") return { ok: true, sha: mb.stdout.trim() };
return {
ok: false,
reason_code: "base-unresolved",
reason: "working tree is clean and git merge-base HEAD origin/main is unavailable — supply --from-ref",
};
}
21 changes: 21 additions & 0 deletions pharn/floor/regress-base-core.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveRegressBase } from "./regress-base-core.mjs";
import { gitSync } from "./stage-runtime.mjs";
import { SHA_RE } from "./gate-run-core.mjs";

test("resolveRegressBase: explicit ref resolves via git argv, not shell", () => {
const head = gitSync(["rev-parse", "HEAD"]);
assert.ok(head.ok);
const sha = head.stdout.trim();
assert.ok(SHA_RE.test(sha));
const r = resolveRegressBase({ explicitRef: "HEAD" });
assert.equal(r.ok, true);
assert.equal(r.sha, sha);
});

test("resolveRegressBase: garbage ref is base-not-commit", () => {
const r = resolveRegressBase({ explicitRef: "not-a-ref-$(touch x)" });
assert.equal(r.ok, false);
assert.equal(r.reason_code, "base-not-commit");
});
Loading