diff --git a/.claude/commands/pharn-ship-close.md b/.claude/commands/pharn-ship-close.md index 1f8fcae7..e1d37ea2 100644 --- a/.claude/commands/pharn-ship-close.md +++ b/.claude/commands/pharn-ship-close.md @@ -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 > /tmp/briefing-draft.md + mkdir -p .pharn/pharn-ship/ + node pharn/floor/render-ship-briefing.mjs > .pharn/pharn-ship//briefing-draft.md ``` 2. **The one narrow ADVISORY step — only when the render found nothing to quote.** Check whether the diff --git a/.claude/commands/pharn-ship-quick.md b/.claude/commands/pharn-ship-quick.md index 2d5e3ca1..8666c77a 100644 --- a/.claude/commands/pharn-ship-quick.md +++ b/.claude/commands/pharn-ship-quick.md @@ -89,16 +89,13 @@ spec_kind: quick`. The remedy is to re-run `/pharn-ship ` **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 `` and - that SHA as `` — the only two values the line takes (Step 3a captures its own `` 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 `, add `--from-ref ''` (single-quoted); otherwise omit it. ```bash - node pharn/floor/check-quick-scope.mjs --feature '' --base '' + node pharn/floor/check-quick-scope.mjs --feature '' --base auto [--from-ref ''] ``` **Never type a path into it**: the checker builds both path sets itself (`pharn/floor/quick-scope-core.mjs`, diff --git a/.dev/features/low-findings-batch-2/PLAN.md b/.dev/features/low-findings-batch-2/PLAN.md new file mode 100644 index 00000000..c1f8c9a6 --- /dev/null +++ b/.dev/features/low-findings-batch-2/PLAN.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2b38ee..5337f2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//briefing-draft.md` instead of a fixed `/tmp` path. + + ## [6.32.1] - 2026-09-28 ### Fixed @@ -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 diff --git a/README.md b/README.md index 28a9fabb..8e187122 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SKILLS_VERSION b/SKILLS_VERSION index 45d6fb34..b02bea9d 100644 --- a/SKILLS_VERSION +++ b/SKILLS_VERSION @@ -1 +1 @@ -6.32.1 +6.32.2 diff --git a/pharn/floor/check-quick-scope.mjs b/pharn/floor/check-quick-scope.mjs index 63ee6a1f..e66a8d14 100644 --- a/pharn/floor/check-quick-scope.mjs +++ b/pharn/floor/check-quick-scope.mjs @@ -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 --base <40-hex> (from the repo root) +// Usage: node pharn/floor/check-quick-scope.mjs --feature --base <40-hex|auto> [--from-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 }); diff --git a/pharn/floor/check-quick-scope.test.mjs b/pharn/floor/check-quick-scope.test.mjs index 80a41c5a..c8d52ff8 100644 --- a/pharn/floor/check-quick-scope.test.mjs +++ b/pharn/floor/check-quick-scope.test.mjs @@ -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 ''\]/, ""); +} + function runCommitted(line, dir, base, opts = {}) { - const cmd = line.replace("", "demo").replace("", base); + let cmd = normalizeCommittedLine(line).replace("", "demo"); + if (!cmd.includes("--base auto")) cmd = cmd.replace("", 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 }; } @@ -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(), ["", ""], file); - assert.ok(line.includes("--feature ''") && line.includes("--base ''"), `${file}: both values single-quoted`); - const bare = line.replace("''", "").replace("''", ""); - 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(), ["", ""]); + assert.ok(loopLine.includes("--feature ''") && loopLine.includes("--base ''")); + const shipLine = committedLine("pharn-ship.md"); + assert.ok(shipLine.includes("--feature ''") && shipLine.includes("--base auto")); + assert.ok(!shipLine.includes("git rev-parse"), "ship must not type a ref into shell git"); + const bareLoop = loopLine.replace("''", "").replace("''", ""); + assert.doesNotMatch(bareLoop, /["'$`\\;|&(){}<>*?!]/, "pharn-loop.md"); }); test("✧ neither quick section still carries 6.25.0's check-regress.mjs scope line", () => { @@ -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}`); diff --git a/pharn/floor/quick-scope-core.mjs b/pharn/floor/quick-scope-core.mjs index 56e60bd7..d0615402 100644 --- a/pharn/floor/quick-scope-core.mjs +++ b/pharn/floor/quick-scope-core.mjs @@ -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 ^{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 +// ^{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 ` ∪ // `git ls-files -z --others --exclude-standard`, minus `.pharn/`); @@ -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 --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 --base <40-hex|auto> [--from-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", @@ -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`); diff --git a/pharn/floor/regress-base-core.mjs b/pharn/floor/regress-base-core.mjs new file mode 100644 index 00000000..a305bc60 --- /dev/null +++ b/pharn/floor/regress-base-core.mjs @@ -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", + }; +} diff --git a/pharn/floor/regress-base-core.test.mjs b/pharn/floor/regress-base-core.test.mjs new file mode 100644 index 00000000..7cf6c130 --- /dev/null +++ b/pharn/floor/regress-base-core.test.mjs @@ -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"); +});