From 0a4634a3154b755735c6192ae2415812a56d2b68 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 17:34:44 -0700 Subject: [PATCH 1/9] Design: hyp policy verb (covers LLP 0110) Generated-by: neutral Co-Authored-By: Claude Opus 4.8 (1M context) --- llp/0111-hyp-policy-verb.design.md | 231 +++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 llp/0111-hyp-policy-verb.design.md diff --git a/llp/0111-hyp-policy-verb.design.md b/llp/0111-hyp-policy-verb.design.md new file mode 100644 index 0000000..bac6d42 --- /dev/null +++ b/llp/0111-hyp-policy-verb.design.md @@ -0,0 +1,231 @@ +# LLP 0111: `hyp policy`, the class-neutral machine-local marking verb + +**Type:** design +**Status:** Active +**Systems:** CLI, Usage-Policy +**Author:** Phil / Claude +**Date:** 2026-07-16 +**Generated-by:** neutral +**Related:** LLP 0110, LLP 0103, LLP 0049, LLP 0106 + +> Machine-local usage-class markings move from flags on `hyp ignore` to a +> dedicated `hyp policy` command group: `set` / `show` / `unset` / `list`. +> The verb names the subsystem (Usage-Policy) instead of misnaming the action +> ("ignore" to opt INTO syncing). `hyp ignore` / `hyp unignore` keep their +> honest LLP 0049 dotfile meaning; the `--sync` / `--local-only` / `--private` +> / `--check` flags survive as deprecated compatibility aliases that delegate +> to the `policy` code paths. The store format, the shared resolver, and the +> three-class lattice are untouched. +> +> @ref LLP 0110 [implements] - retires the `hyp ignore --sync` misnomer behind a class-neutral `hyp policy` verb, exactly the surface the issue settled with the author +> @ref LLP 0103#cli [constrained-by] - extends its CLI paragraph; store format, resolver, and class lattice untouched +> @ref LLP 0049#cli [constrained-by] - `hyp ignore` / `hyp unignore` keep their settled dotfile meaning unchanged + +## Scope and non-goals + +This is a verb-surface change only. Explicitly out of scope: + +- The machine-local store (version-2 class-per-entry JSON, LLP 0103) and its + read/write helpers (`readLocalOnlyEntries` / `writeLocalOnlyEntries`): no + format or migration change. +- The shared resolver (`createUsagePolicyResolver`), the class lattice + (`ignore` > `local-only` > `full`, most-restrictive-wins), and every + enforcement seam: unchanged. +- The `.hypignore` dotfile semantics (LLP 0049): unchanged, including the + bare `hyp ignore [path]` / `hyp unignore [path]` verbs that author it. +- `hyp purge` (LLP 0104): unchanged; marking stays non-destructive. + +## The command surface {#surface} + +A new `policy` command group in the kernel CLI registry (LLP 0009), using the +existing `makeGroupCommand` + two-word command pattern (`daemon start`, +`skills install`) in `src/core/cli/core_commands.js`: + +``` +hyp policy set sync|local-only|ignore # write a machine-local marking +hyp policy show [path] [--json] # report the governing class and source +hyp policy unset [sync|local-only|ignore] # remove the marking (back to default) +hyp policy list [--json] # enumerate machine-local entries +``` + +### Class tokens {#tokens} + +The user-facing class vocabulary is the one the classification hook and the +privacy skill already teach: `sync`, `local-only`, `ignore`. At the CLI edge, +`sync` maps to the stored class `full` (LLP 0103's "asked; syncs" marker); +`local-only` and `ignore` map to themselves. The store keeps speaking `full` +internally: no entry rewrite, and `policy show --json` keeps emitting the +resolver vocabulary (`full` / `local-only` / `ignore`) so existing consumers +of the `hyp ignore --check --json` shape see identical fields. An unknown +class token is a usage error (exit 2) that names the three valid tokens. + +### `policy set ` {#set} + +Writes a machine-local marking for ``, delegating to the existing +marking internals (today `runMarkMachineLocal` in +`src/core/commands/clients.js`), hoisted so both the new verb and the aliases +call one implementation: + +- `` is **required**. The bare grammar makes it necessary + (`hyp policy set sync` would be ambiguous between a path and a class), and + requiring it suits a consent-adjacent surface: say which directory you + mean. Relative paths resolve against the command-context cwd (`ctx.cwd`), + matching the sibling verbs, and the resolved directory is marked exactly + as pointed at (the explicit-path rule the flag forms already apply). The + target need not exist on disk or be a git repo (LLP 0071 property, + unchanged). Callers wanting the repo-root default the bare flag forms had + can pass the repo root; the hook and the skill always pass an explicit + absolute path already. +- `set ignore` writes a machine-local `ignore` entry (what + `hyp ignore --private` writes today). It never writes a `.hypignore` + dotfile; the dotfile author remains bare `hyp ignore` alone. The verb + split is the point: `policy` is the private, machine-local surface, + `ignore` is the committable dotfile surface. +- Idempotence and no-op rules carry over verbatim: a target already governed + at least as restrictively (by either source) is a no-op success naming the + governor; a `sync` mark is idempotent only against an existing explicit + machine-local `full` entry, never against the implicit default (LLP 0103). +- Same structured log event (`usage_policy.mark`) with the component + attribute updated to name the dispatching verb, and the same + resolver-cache-TTL latency caveat in the output. + +### `policy show [path]` {#show} + +The successor to `hyp ignore --check`, same behavior, class-neutral name: +resolves `[path]` (default: cwd, preserving `--check`'s ergonomics), prints +the resolved class, the governing source (`dotfile` vs `machine-local` vs +`none`), the governing file, and the residual already-cached row count with +the `hyp purge` hint. Prospective-only reporting, never destructive +(LLP 0049 #prospective-only). `--json` emits the exact field set +`hyp ignore --check --json` emits today, so the check path is a pure rename +plus delegation: `runIgnoreCheck` becomes the shared implementation both +spellings call. + +### `policy unset [class]` {#unset} + +Removes machine-local markings governing `` (equal to it or an +ancestor, via the shared `isEqualOrDescendant` predicate, not a re-derived +copy). By default it is class-neutral: every machine-local entry governing +the target is removed, "back to the implicit default", which matches the +issue's one-line semantics and the store's one-entry-per-dir shape. An +optional trailing class token scopes removal to that class; this scoped form +is what the `hyp unignore --sync|--local-only|--private` aliases delegate to, +preserving their exact current behavior. `unset` never touches `.hypignore` +dotfiles (that remains bare `hyp unignore`) and never touches cached rows +(LLP 0104 boundary). Idempotent: nothing governing is a no-op success. + +### `policy list` {#list} + +Enumerates the machine-local store: one line per entry, `dir` and class +(rendering `full` with a `sync` gloss in the human output), plus the store +path, with `--json` emitting `{ entries: [{ dir, class }], path }`. This is +the store's first enumeration surface; `show` answers "what governs this +path", `list` answers "what have I marked on this machine". It deliberately +lists only the machine-local store: `.hypignore` dotfiles are discovered +per-path by the ancestor walk and cannot be enumerated without a filesystem +crawl, and `show` already names them when they govern. An empty store lists +zero entries successfully. + +## Compatibility aliases and deprecation posture {#aliases} + +`hyp ignore` / `hyp unignore` keep working in every current form, no breaking +release (LLP 0110 boundary): + +| old spelling | delegates to | +|---|---| +| `hyp ignore --sync

` | `policy set

sync` internals | +| `hyp ignore --local-only

` | `policy set

local-only` internals | +| `hyp ignore --private

` | `policy set

ignore` internals | +| `hyp ignore --check [p]` | `policy show [p]` internals | +| `hyp unignore --sync\|--local-only\|--private [p]` | class-scoped `policy unset` internals | + +- **Delegation, not duplication**: the flag branches in + `runIgnore` / `runUnignore` call the same hoisted implementations the + `policy` subcommands call. One behavior, two spellings; the alias behavior + can never drift from the new verb's. +- **Output-identical**: the aliases keep their exact stdout/stderr and exit + codes, including the optional-path repo-root defaulting the flag forms + have today, so every existing test passes unchanged (LLP 0110 exit + criteria). +- **Deprecation is help-text-only for now**: the `hyp ignore` / `hyp + unignore` registry help marks the flags "deprecated: use hyp policy ...", + and no product surface teaches them anymore (see migration below). No + runtime warning is emitted: the main writers (hook, skill) migrate in this + same change, and a stderr nag would risk breaking scripted callers for no + coverage gain. Removal, if ever, is a future breaking-change decision, + deliberately not scheduled here. +- Bare `hyp ignore [path]`, bare `hyp unignore [path]`: not deprecated at + all. They are the honest dotfile verbs (LLP 0049 #cli) and stay the only + authors of `.hypignore`. + +## Migrating the teaching surfaces {#teaching} + +The issue's trigger was not the flag itself but the surfaces that teach it +during consent-sensitive moments. All of them move to the `policy` spelling: + +- **Classification hook copy (LLP 0106)**: `src/core/usage-policy/classification.js` + is the single shared consent surface. `CLASSIFICATION_CHOICES` swaps its + `flag` field for the class token, `verbArgvForClass` returns + `['policy', 'set', targetPath, token]`, and `buildClassificationPrompt` + prints `hyp policy set sync` (etc). Because the hook dispatches the + argv against the same CLI registry, the two-word `policy set` dispatch + must be in place first (the existing group-command dispatch already + handles two-word verbs). The prompt copy is pinned by tests (LLP 0106 + consequences); those pins update in the same commit as the copy. +- **`hypaware-privacy` skill bodies** (claude and codex, + `hypaware-core/plugins-workspace/*/skills/hypaware-privacy/SKILL.md`): + every `hyp ignore --sync|--local-only|--private` and + `hyp unignore --...` occurrence becomes the matching `hyp policy set` / + `hyp policy unset` form; the dotfile `hyp ignore` mentions stay as they + are. The exit criterion is literal: neither the hook nor the skill ever + prints an `ignore`-spelled command for a non-ignore class. +- **`hyp status` and registry help wording**: anywhere status or help output + points users at the marking flags now points at `hyp policy`. +- **Code `@ref` hygiene**: the `@ref LLP 0103#cli` annotations on the moved + marking/check/unmark internals keep citing 0103 for the store-and-classes + rationale, with glosses updated to cite this design for the verb surface + (LLP 0103 already carries the `Extended-by: LLP 0110` forward-ref). + +## Implementable pieces {#pieces} + +Ordered so each lands green on its own; this section seeds the plan. + +1. **Hoist the marking internals.** Extract the shared implementations + behind today's flag branches (`runMarkMachineLocal`, + `runUnmarkMachineLocal`, `runIgnoreCheck`) into verb-agnostic functions + (target resolution rule, class, output writer) with no behavior change. + Pure refactor; existing tests stay green. +2. **Register the `policy` group and subcommands.** `makeGroupCommand` + entry plus `policy set` / `policy show` / `policy unset` / `policy list` + registrations in `core_commands.js`, arg parsing (positional path and + class token, token-to-class mapping, mutual-exclusion and usage errors), + thin runners in `src/core/commands/` delegating to the hoisted + internals; `policy list` adds the store enumeration read. New tests + mirror `test/core/ignore-private-sync-command.test.js` for the new + spellings, plus `list` and class-neutral `unset` coverage. +3. **Turn the flags into delegating aliases.** `runIgnore` / `runUnignore` + flag branches call the hoisted internals (by construction after piece 1), + help text gains the deprecation wording, and the existing alias tests run + unchanged as the compatibility proof. +4. **Migrate the hook copy.** `classification.js` choices, `verbArgvForClass`, + prompt text, and the pinned consent-copy tests move to + `hyp policy set `. +5. **Migrate skill and status wording.** Both `hypaware-privacy` SKILL.md + bodies and any `hyp status` / help strings that teach the flag forms. +6. **Ref and doc hygiene.** Update `@ref` glosses on moved code so the verb + surface cites this design while the store-and-classes rationale keeps + citing LLP 0103. + +## Risks and edge cases + +- **Grammar ambiguity is designed out**: `set` requires the path, so a + class token can never be mistaken for one; `unset`'s optional trailing + token is unambiguous because it follows the required path. +- **Alias drift** is prevented structurally (single implementation), not by + test discipline alone. +- **Hook/CLI version skew**: a session-start hook from this version dispatches + `policy set` in-process against the same build's registry (the hook and CLI + ship together), so there is no window where the hook teaches a verb the + binary lacks. +- **JSON stability**: `policy show --json` keeps the `--check --json` field + set byte-compatible; `policy list --json` is new and versioned by presence. From bc8356e55cd3c09aed21c6a95f35cf37593f89fa Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 17:44:11 -0700 Subject: [PATCH 2/9] Plan: hyp policy verb (LLP 0112, implements LLP 0111) Generated-by: neutral Co-Authored-By: Claude Opus 4.8 (1M context) --- llp/0112-hyp-policy-verb.plan.md | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 llp/0112-hyp-policy-verb.plan.md diff --git a/llp/0112-hyp-policy-verb.plan.md b/llp/0112-hyp-policy-verb.plan.md new file mode 100644 index 0000000..e050fe8 --- /dev/null +++ b/llp/0112-hyp-policy-verb.plan.md @@ -0,0 +1,61 @@ +# LLP 0112: hyp policy verb implementation plan + +**Type:** plan +**Status:** Active +**Systems:** CLI, Usage-Policy +**Author:** Phil / Claude +**Date:** 2026-07-16 +**Related:** LLP 0111 +**Generated-by:** neutral + +> Task breakdown for the `hyp policy set/show/unset/list` command group and +> the migration of every surface that taught the `hyp ignore --sync` misnomer. +> +> @ref LLP 0111 [implements] - refines its "Implementable pieces" section into ordered, independently-mergeable tasks +> @ref LLP 0110 [implements] - each task keeps the issue's exit criteria: aliases output-identical, no consent surface prints an ignore-spelled command for a non-ignore class + +## Framing + +LLP 0111 fixes the shape of the change: a new `policy` command group that +owns machine-local usage-class markings, with the existing `hyp ignore` / +`hyp unignore` flags surviving as delegating compatibility aliases, and +every teaching surface (classification hook, hypaware-privacy skill, status +and help copy) moved to the new spelling. The store, resolver, and class +lattice are untouched. + +The tasks below follow the design's ordering rule: hoist shared internals +first so both spellings can call one implementation, register the verb, then +rewire aliases and teaching copy, and finish with ref hygiene. Every task +lands with `npm test` green on its own; nothing depends on an unmerged +sibling except where `deps` says so. + +Key code locations: the marking internals (`runMarkMachineLocal`, +`runUnmarkMachineLocal`, `runIgnoreCheck`) live in +`src/core/commands/clients.js`; command registration and the +`makeGroupCommand` pattern live in `src/core/cli/core_commands.js`; the +consent hook copy lives in `src/core/usage-policy/classification.js` with +pinned tests in `test/core/usage-policy-classification.test.js`; the skill +bodies are `hypaware-core/plugins-workspace/{claude,codex}/skills/hypaware-privacy/SKILL.md`. + +## Tasks + +- id: T1 branch: task/hyp-policy-verb/T1 deps: [] complexity: 3 -- Hoist the marking internals out of the flag branches: extract runMarkMachineLocal, runUnmarkMachineLocal, and runIgnoreCheck in src/core/commands/clients.js into verb-agnostic shared implementations (explicit target-resolution rule, class, output writer) that both the flag forms and the future policy runners can call. Pure refactor, zero behavior change; all existing ignore/unignore tests stay green unchanged. Carry the existing @ref LLP 0103#cli annotations along with the moved code (gloss updates come in T6). +- id: T2 branch: task/hyp-policy-verb/T2 deps: [T1] complexity: 3 -- Register the policy command group: makeGroupCommand entry plus policy set / show / unset / list registrations in src/core/cli/core_commands.js and thin runners in src/core/commands/ delegating to the T1 internals. Includes positional parsing (required path for set, optional for show, required path plus optional trailing class for unset), the sync->full token mapping at the CLI edge with unknown-token usage errors (exit 2) naming the three valid tokens, class-neutral unset, the new list enumeration over the machine-local store with --json {entries, path}, and show --json byte-compatible with today's --check --json fields. New tests mirroring test/core/ignore-private-sync-command.test.js for the new spellings plus list and class-neutral unset coverage. Annotate the new verb code with // @ref LLP 0110 [implements], // @ref LLP 0111#surface [implements], and // @ref LLP 0103#cli [constrained-by] where the store-and-classes rationale applies. +- id: T3 branch: task/hyp-policy-verb/T3 deps: [T2] complexity: 4 -- Turn the ignore/unignore flags into delegating aliases: the --sync/--local-only/--private/--check branches in runIgnore/runUnignore call exactly the T1 hoisted implementations the policy runners call, with the flag forms' optional-path repo-root defaulting preserved at the alias edge. Registry help for hyp ignore/hyp unignore gains "deprecated: use hyp policy ..." wording; no runtime stderr warning. The hard part is proving zero behavior change: every existing alias test must pass unchanged (stdout, stderr, exit codes, JSON fields, usage_policy.mark log event) as the compatibility proof required by LLP 0110's exit criteria. Add // @ref LLP 0111#aliases [implements] on the delegation seam. +- id: T4 branch: task/hyp-policy-verb/T4 deps: [T2] complexity: 4 -- Migrate the classification hook copy (LLP 0106): in src/core/usage-policy/classification.js swap CLASSIFICATION_CHOICES flag fields for class tokens, make verbArgvForClass return ['policy','set',targetPath,token], and update buildClassificationPrompt to print hyp policy set . This is consent-sensitive copy: the pinned consent-copy tests in test/core/usage-policy-classification.test.js update in the same commit, and the hook must dispatch the new argv against the registry's two-word policy set path (present since T2). Update the file's @ref glosses to cite LLP 0111#teaching for the verb surface. Exit criterion: the hook never prints an ignore-spelled command for a non-ignore class. +- id: T5 branch: task/hyp-policy-verb/T5 deps: [T2] complexity: 4 -- Migrate the skill and status/help teaching copy: in both hypaware-privacy SKILL.md bodies (claude and codex plugins-workspace trees) replace every hyp ignore --sync/--local-only/--private and hyp unignore -- occurrence with the matching hyp policy set / hyp policy unset form, while leaving the dotfile bare hyp ignore/hyp unignore mentions untouched; sweep hyp status output and any remaining help strings that point users at the marking flags. Consent-sensitive judgment call per occurrence (which mentions are class markings vs dotfile authorship); the literal exit criterion is that no product surface teaches an ignore-spelled command for a non-ignore class. Keep both SKILL.md bodies consistent with each other. +- id: T6 branch: task/hyp-policy-verb/T6 deps: [T3, T4, T5] complexity: 2 -- Ref and doc hygiene sweep: update @ref glosses on all moved and new code so the verb surface cites LLP 0111 (and LLP 0110 where the issue's boundary is the point) while the store-and-classes rationale keeps citing LLP 0103#cli; verify LLP 0103's Extended-by: LLP 0110 forward-ref still reads correctly; run /ref-check across the touched files and fix any dangling anchors. No behavior change; npm test and ref-check both green. + +## Notes for implementers + +- Repo prose style applies everywhere, including help text and SKILL.md + edits: no em dashes; runtime strings prefer "-". +- Idempotence and no-op rules (already-governed no-op success naming the + governor, sync idempotent only against an explicit machine-local full + entry) are behavior carried by the T1 internals; T2 and T3 must not + reimplement them at the edges. +- policy unset shares the existing isEqualOrDescendant predicate; do not + re-derive the ancestor check. +- The usage_policy.mark structured event keeps its shape; only the component + attribute names the dispatching verb (T2 for policy, unchanged for the + aliases in T3). From 89c5f19bfb1df4e5c365a4982c03b29367bf572a Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 17:58:18 -0700 Subject: [PATCH 3/9] Hoist ignore/unignore marking internals into verb-agnostic shared impls Extract runMarkMachineLocal, runUnmarkMachineLocal, and runIgnoreCheck in src/core/commands/clients.js so they take an explicit resolved targetDir instead of computing it from parsed argv, and add repoRootDefaultTarget as the one shared repo-root-default placement rule. This is a pure refactor with zero behavior change: the flag branches in runIgnore/runUnignore now resolve the target and delegate, but stdout/stderr/exit codes are unchanged, so all existing ignore/unignore tests pass unmodified. This sets up both the flag forms and the future hyp policy runners (LLP 0112 T2) to call one implementation instead of drifting. Carries the existing @ref LLP 0103#cli annotations along with the moved code; gloss updates come in T6. Task-Id: T1 --- src/core/commands/clients.js | 122 ++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 351d599..1ced746 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -635,15 +635,16 @@ export async function runIgnore(argv, ctx) { ctx.stderr.write(`error: ${parsed.error}\n`) return 2 } - if (parsed.check) return runIgnoreCheck(parsed, ctx) - if (parsed.private) return runMarkMachineLocal(parsed, ctx, 'ignore') - if (parsed.localOnly) return runMarkMachineLocal(parsed, ctx, 'local-only') - if (parsed.sync) return runMarkMachineLocal(parsed, ctx, 'full') - // Resolve a relative `path` arg against the command-context cwd (matching the // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + if (parsed.check) return runIgnoreCheck({ targetDir: base, ctx, json: parsed.json }) + if (parsed.private) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'ignore' }) + if (parsed.localOnly) + return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'local-only' }) + if (parsed.sync) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'full' }) + // Idempotent (R5): a fresh resolver reflects disk. Any governing ancestor // `.hypignore` already ignores `base` (V1 has no un-ignore directive, any // `.hypignore` resolves to `ignore`), so re-ignoring is a no-op success @@ -656,7 +657,7 @@ export async function runIgnore(argv, ctx) { // Default target: the repo root when `base` is in a git repo, else `base`. // An explicit `path` overrides: write exactly where the caller pointed. - const targetDir = parsed.path ? base : (findRepoRoot(base) ?? base) + const targetDir = repoRootDefaultTarget(base, parsed.path) const file = path.join(targetDir, '.hypignore') try { await fs.writeFile(file, HYPIGNORE_TEMPLATE) @@ -678,16 +679,33 @@ export async function runIgnore(argv, ctx) { return 0 } +/** + * Shared target-resolution rule for the marking verbs that default to a git + * repo root: an explicit `path` argument always wins (the caller pointed at + * it directly), otherwise resolve `base` up to its containing repo root, or + * `base` itself outside a repo. Shared by the `hyp ignore` bare-dotfile + * branch and the machine-local marking branches so both spellings (and the + * future `policy` verb) apply one placement rule (LLP 0103 #cli). + * + * @param {string} base + * @param {string | undefined} explicitPath + * @returns {string} + */ +function repoRootDefaultTarget(base, explicitPath) { + return explicitPath ? base : (findRepoRoot(base) ?? base) +} + /** * `hyp ignore --private [path]` / `hyp ignore --local-only [path]` / * `hyp ignore --sync [path]` * - * Marks the resolved target with `targetClass` in the machine-local - * class-per-entry store (LLP 0103) instead of writing a `.hypignore`: never - * writes into a repo (LLP 0071 R4, LLP 0100 R6), so the target need not exist - * on disk or be a git repo. Resolves the target the same way plain - * `hyp ignore` does — the git repo root when `base` is inside one, else - * `base`; an explicit `path` overrides. + * Marks `targetDir` with `targetClass` in the machine-local class-per-entry + * store (LLP 0103) instead of writing a `.hypignore`: never writes into a + * repo (LLP 0071 R4, LLP 0100 R6), so the target need not exist on disk or be + * a git repo. Verb-agnostic: the caller decides `targetDir` (today via + * {@link repoRootDefaultTarget}, matching plain `hyp ignore`'s placement + * rule), so this implementation is shared by the `--private`/`--local-only`/ + * `--sync` flag branches and, in a future task, the `policy set` runner. * * - `ignore`: rows from the scope are never recorded (enforced at the * capture seam, same as a dotfile `ignore`). @@ -706,20 +724,10 @@ export async function runIgnore(argv, ctx) { * an unlisted directory is not "already answered", LLP 0103). * * @ref LLP 0103#cli [implements]: the shared machine-local marking verb behind `--private` / `--local-only` / `--sync` - * @param {{ path?: string }} parsed - * @param {CommandRunContext} ctx - * @param {UsageClass} targetClass + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args * @returns {Promise} */ -async function runMarkMachineLocal(parsed, ctx, targetClass) { - // Resolve a relative `path` arg against the command-context cwd (matching the - // sibling verbs above), not the Node process cwd, so injected/remote/test - // dispatch adds/removes/checks the tree the caller actually pointed at. - const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') - // Default target: the repo root when `base` is in a git repo, else `base`. - // An explicit `path` overrides: record exactly the directory the caller - // pointed at, mirroring the dotfile verb's placement rule. - const targetDir = parsed.path ? base : (findRepoRoot(base) ?? base) +async function runMarkMachineLocal({ targetDir, ctx, targetClass }) { const resolvedTarget = path.resolve(targetDir) const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) @@ -778,14 +786,14 @@ export async function runUnignore(argv, ctx) { ctx.stderr.write('error: --json is only valid for `hyp ignore --check`\n') return 2 } - if (parsed.private) return runUnmarkMachineLocal(parsed, ctx, 'ignore') - if (parsed.localOnly) return runUnmarkMachineLocal(parsed, ctx, 'local-only') - if (parsed.sync) return runUnmarkMachineLocal(parsed, ctx, 'full') - // Resolve a relative `path` arg against the command-context cwd (matching the // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore' }) + if (parsed.localOnly) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only' }) + if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full' }) + const { governedBy } = createUsagePolicyResolver().resolve(base) if (!governedBy) { ctx.stdout.write(`not ignored (no .hypignore governs ${base})\n`) @@ -811,31 +819,29 @@ export async function runUnignore(argv, ctx) { * `hyp unignore --private [path]` / `hyp unignore --local-only [path]` / * `hyp unignore --sync [path]` * - * Removes every machine-local entry of `targetClass` that governs the - * target — equal to it, or an ancestor of it (the same segment-aware rule - * the shared resolver applies, reused here via {@link isEqualOrDescendant} - * rather than re-derived, R8) — mirroring dotfile `unignore`'s "remove the - * governing thing" semantics. Entries of a different class are left alone - * (LLP 0104 boundary: unmarking is class-scoped and non-destructive of - * cached rows either way). Idempotent: no governing entry of that class is - * a no-op success. + * Removes every machine-local entry of `targetClass` that governs + * `targetDir` — equal to it, or an ancestor of it (the same segment-aware + * rule the shared resolver applies, reused here via + * {@link isEqualOrDescendant} rather than re-derived, R8) — mirroring + * dotfile `unignore`'s "remove the governing thing" semantics. Entries of a + * different class are left alone (LLP 0104 boundary: unmarking is + * class-scoped and non-destructive of cached rows either way). Idempotent: + * no governing entry of that class is a no-op success. Verb-agnostic: the + * caller resolves `targetDir` (today the flag forms' cwd-relative `base`, + * with no repo-root default), so this implementation is shared by the + * `--private`/`--local-only`/`--sync` flag branches and, in a future task, + * the `policy unset` runner. * * @ref LLP 0103#cli [implements]: symmetric class-scoped removal for `--private` / `--local-only` / `--sync` - * @param {{ path?: string }} parsed - * @param {CommandRunContext} ctx - * @param {UsageClass} targetClass + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args * @returns {Promise} */ -async function runUnmarkMachineLocal(parsed, ctx, targetClass) { - // Resolve a relative `path` arg against the command-context cwd (matching the - // sibling verbs above), not the Node process cwd, so injected/remote/test - // dispatch adds/removes/checks the tree the caller actually pointed at. - const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') +async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const entries = await readLocalOnlyEntries({ stateDir }) - const governing = entries.filter((entry) => entry.class === targetClass && isEqualOrDescendant(base, entry.dir)) + const governing = entries.filter((entry) => entry.class === targetClass && isEqualOrDescendant(targetDir, entry.dir)) if (governing.length === 0) { - ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${base})\n`) + ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${targetDir})\n`) return 0 } @@ -871,23 +877,23 @@ async function runUnmarkMachineLocal(parsed, ctx, targetClass) { * as "recorded locally, withheld from forwarding" rather than "never * recorded". * + * Verb-agnostic: the caller resolves `targetDir` (today the flag form's + * cwd-relative `base`, no repo-root default), so this implementation is + * shared by `hyp ignore --check` and, in a future task, the `policy show` + * runner. + * * @ref LLP 0049#prospective-only [implements]: `--check` reports the residual already-cached row count; it never deletes * @ref LLP 0103#cli [implements]: `--check` names which source governs (dotfile vs machine-local entry) and the entry's class - * @param {{ json: boolean, path?: string }} parsed - * @param {CommandRunContext} ctx + * @param {{ targetDir: string, ctx: CommandRunContext, json: boolean }} args * @returns {Promise} */ -async function runIgnoreCheck(parsed, ctx) { - // Resolve a relative `path` arg against the command-context cwd (matching the - // sibling verbs above), not the Node process cwd, so injected/remote/test - // dispatch writes/removes/checks the tree the caller actually pointed at. - const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') +async function runIgnoreCheck({ targetDir, ctx, json }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) - const result = createUsagePolicyResolver({ localOnlyListPath: listPath }).resolve(base) + const result = createUsagePolicyResolver({ localOnlyListPath: listPath }).resolve(targetDir) const ignored = result.class === 'ignore' const governed = result.class !== 'full' - const scopeDir = governed ? await resolveCheckScopeDir({ result, base, stateDir, listPath }) : base + const scopeDir = governed ? await resolveCheckScopeDir({ result, base: targetDir, stateDir, listPath }) : targetDir const residual = governed ? await countResidualCachedRows(scopeDir, ctx) : 0 // LLP 0103: name the governing source distinctly from the raw path, so a // machine-local mark and a committed dotfile read differently even though @@ -895,10 +901,10 @@ async function runIgnoreCheck(parsed, ctx) { const source = !result.governedBy ? 'none' : result.governedBy === listPath ? 'machine-local' : 'dotfile' const purgeHint = residual ? ` (use 'hyp purge' to remove them)` : '' - if (parsed.json) { + if (json) { ctx.stdout.write( JSON.stringify({ - path: base, + path: targetDir, ignored, governedBy: result.governedBy, source, @@ -910,7 +916,7 @@ async function runIgnoreCheck(parsed, ctx) { return 0 } - ctx.stdout.write(`path: ${base}\n`) + ctx.stdout.write(`path: ${targetDir}\n`) ctx.stdout.write(`ignored: ${ignored ? 'yes' : 'no'}\n`) ctx.stdout.write(`class: ${result.class}\n`) ctx.stdout.write(`source: ${source}\n`) From 6ef890a370464376fefbb45029f7036068cf1777 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 18:15:08 -0700 Subject: [PATCH 4/9] Register hyp policy set/show/unset/list, delegating to shared T1 internals Adds the hyp policy command group (LLP 0110/0111) as the class-neutral successor to the ignore --sync/--local-only/--private aliases: thin runners in src/core/commands/policy.js parse CLI args and delegate to the shared runMarkMachineLocal/runUnmarkMachineLocal/runIgnoreCheck internals in clients.js, now exported and parameterized by a `component` attribute so telemetry still names the dispatching verb. runUnmarkMachineLocal gains a class-neutral mode (targetClass undefined) backing `policy unset ` with no trailing token, removing every machine-local entry governing a path regardless of class, alongside the existing class-scoped removal used by both `unignore` and `policy unset `. `policy list` enumerates the machine-local store directly with a --json {entries, path} shape; `policy show` reuses runIgnoreCheck so its --json output stays byte-compatible with `ignore --check --json`. New test/core/policy-command.test.js mirrors the existing ignore-private-sync-command coverage for the new spellings, plus list enumeration and class-neutral unset across multiple governing entries. Task-Id: T2 --- src/core/cli/core_commands.js | 41 +++ src/core/commands/clients.js | 100 +++++--- src/core/commands/policy.js | 278 ++++++++++++++++++++ test/core/policy-command.test.js | 426 +++++++++++++++++++++++++++++++ 4 files changed, 813 insertions(+), 32 deletions(-) create mode 100644 src/core/commands/policy.js create mode 100644 test/core/policy-command.test.js diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index f12014e..db377fa 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -46,6 +46,7 @@ import { runSkillsInstall, runUnignore, } from '../commands/clients.js' +import { runPolicyList, runPolicySet, runPolicyShow, runPolicyUnset } from '../commands/policy.js' /** * @import { CommandRegistration } from '../../../hypaware-plugin-kernel-types.js' @@ -270,6 +271,46 @@ function buildCoreCommands(registry) { ].join('\n'), run: runUnignore, }, + makeGroupCommand({ + registry, + name: 'policy', + summary: 'Mark a folder machine-local usage class (sync, local-only, ignore)', + help: [ + 'The class-neutral successor to the hyp ignore --sync/--local-only/--private', + 'flags: writes to the same machine-local, class-per-entry store (never a', + '.hypignore dotfile). set/show/unset act on one path; list enumerates every', + 'machine-local entry on this machine.', + ].join('\n'), + }), + { + name: 'policy set', + summary: 'Mark a folder machine-local sync, local-only, or ignore', + usage: 'hyp policy set sync|local-only|ignore', + run: runPolicySet, + }, + { + name: 'policy show', + summary: 'Report the usage class governing a folder and its source', + usage: 'hyp policy show [path] [--json]', + run: runPolicyShow, + }, + { + name: 'policy unset', + summary: 'Remove machine-local markings governing a folder (optionally scoped to one class)', + usage: 'hyp policy unset [sync|local-only|ignore]', + help: [ + 'With no trailing class token, removes every machine-local entry governing', + ' (class-neutral: back to the implicit default). With a trailing', + 'sync/local-only/ignore token, removes only entries of that class.', + ].join('\n'), + run: runPolicyUnset, + }, + { + name: 'policy list', + summary: 'Enumerate machine-local usage-class entries', + usage: 'hyp policy list [--json]', + run: runPolicyList, + }, { name: 'purge', summary: 'Delete already-cached rows from the local cache (destructive)', diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 1ced746..893508f 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -640,10 +640,27 @@ export async function runIgnore(argv, ctx) { // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') if (parsed.check) return runIgnoreCheck({ targetDir: base, ctx, json: parsed.json }) - if (parsed.private) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'ignore' }) + if (parsed.private) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'ignore', + component: 'cmd-ignore', + }) if (parsed.localOnly) - return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'local-only' }) - if (parsed.sync) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'full' }) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'local-only', + component: 'cmd-ignore', + }) + if (parsed.sync) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'full', + component: 'cmd-ignore', + }) // Idempotent (R5): a fresh resolver reflects disk. Any governing ancestor // `.hypignore` already ignores `base` (V1 has no un-ignore directive, any @@ -724,10 +741,11 @@ function repoRootDefaultTarget(base, explicitPath) { * an unlisted directory is not "already answered", LLP 0103). * * @ref LLP 0103#cli [implements]: the shared machine-local marking verb behind `--private` / `--local-only` / `--sync` - * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args + * @ref LLP 0111#surface [implements]: also the shared implementation behind `policy set`; only the `component` attribute names the dispatching verb + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass, component: string }} args * @returns {Promise} */ -async function runMarkMachineLocal({ targetDir, ctx, targetClass }) { +export async function runMarkMachineLocal({ targetDir, ctx, targetClass, component }) { const resolvedTarget = path.resolve(targetDir) const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) @@ -746,7 +764,7 @@ async function runMarkMachineLocal({ targetDir, ctx, targetClass }) { const withoutTarget = entries.filter((entry) => entry.dir !== resolvedTarget) await writeLocalOnlyEntries({ stateDir, entries: [...withoutTarget, { dir: resolvedTarget, class: targetClass }] }) getLogger('usage-policy').info('usage_policy.mark', { - [Attr.COMPONENT]: 'cmd-ignore', + [Attr.COMPONENT]: component, [Attr.OPERATION]: 'usage_policy.mark', class: targetClass, status: 'ok', @@ -790,9 +808,10 @@ export async function runUnignore(argv, ctx) { // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') - if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore' }) - if (parsed.localOnly) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only' }) - if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full' }) + if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore', component: 'cmd-unignore' }) + if (parsed.localOnly) + return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only', component: 'cmd-unignore' }) + if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full', component: 'cmd-unignore' }) const { governedBy } = createUsagePolicyResolver().resolve(base) if (!governedBy) { @@ -819,29 +838,40 @@ export async function runUnignore(argv, ctx) { * `hyp unignore --private [path]` / `hyp unignore --local-only [path]` / * `hyp unignore --sync [path]` * - * Removes every machine-local entry of `targetClass` that governs - * `targetDir` — equal to it, or an ancestor of it (the same segment-aware - * rule the shared resolver applies, reused here via - * {@link isEqualOrDescendant} rather than re-derived, R8) — mirroring - * dotfile `unignore`'s "remove the governing thing" semantics. Entries of a - * different class are left alone (LLP 0104 boundary: unmarking is - * class-scoped and non-destructive of cached rows either way). Idempotent: - * no governing entry of that class is a no-op success. Verb-agnostic: the - * caller resolves `targetDir` (today the flag forms' cwd-relative `base`, - * with no repo-root default), so this implementation is shared by the - * `--private`/`--local-only`/`--sync` flag branches and, in a future task, - * the `policy unset` runner. + * Removes every machine-local entry that governs `targetDir`, equal to it, + * or an ancestor of it (the same segment-aware rule the shared resolver + * applies, reused here via {@link isEqualOrDescendant} rather than + * re-derived, R8), mirroring dotfile `unignore`'s "remove the governing + * thing" semantics. When `targetClass` is given, removal is scoped to that + * one class and entries of a different class are left alone (LLP 0104 + * boundary: unmarking is class-scoped and non-destructive of cached rows + * either way): this is what the `--private`/`--local-only`/`--sync` + * `hyp unignore` flag branches pass. When `targetClass` is omitted, removal + * is class-neutral: every machine-local entry governing `targetDir`, of any + * class, is removed - `policy unset `'s "back to the implicit + * default" default (LLP 0111 #unset). Idempotent either way: no governing + * entry (of that class, or of any class) is a no-op success. Verb-agnostic: + * the caller resolves `targetDir` (today the flag forms' cwd-relative + * `base`, with no repo-root default) and supplies `component` to name the + * dispatching verb in the structured log event. * * @ref LLP 0103#cli [implements]: symmetric class-scoped removal for `--private` / `--local-only` / `--sync` - * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args + * @ref LLP 0111#unset [implements]: the class-neutral `targetClass === undefined` branch backing `policy unset ` + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass?: UsageClass, component: string }} args * @returns {Promise} */ -async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { +export async function runUnmarkMachineLocal({ targetDir, ctx, targetClass, component }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const entries = await readLocalOnlyEntries({ stateDir }) - const governing = entries.filter((entry) => entry.class === targetClass && isEqualOrDescendant(targetDir, entry.dir)) + const governing = entries.filter( + (entry) => (targetClass === undefined || entry.class === targetClass) && isEqualOrDescendant(targetDir, entry.dir) + ) if (governing.length === 0) { - ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${targetDir})\n`) + if (targetClass === undefined) { + ctx.stdout.write(`not governed (no machine-local entry governs ${targetDir})\n`) + } else { + ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${targetDir})\n`) + } return 0 } @@ -849,15 +879,20 @@ async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { const remaining = entries.filter((entry) => !governingDirs.has(entry.dir)) await writeLocalOnlyEntries({ stateDir, entries: remaining }) getLogger('usage-policy').info('usage_policy.unmark', { - [Attr.COMPONENT]: 'cmd-unignore', + [Attr.COMPONENT]: component, [Attr.OPERATION]: 'usage_policy.unmark', - class: targetClass, + class: targetClass ?? 'any', status: 'ok', }) - const removedDirs = governing.map((entry) => entry.dir) - ctx.stdout.write( - `removed ${governing.length} ${targetClass} entr${governing.length === 1 ? 'y' : 'ies'}: ${removedDirs.join(', ')}\n` - ) + const entrySuffix = governing.length === 1 ? 'y' : 'ies' + if (targetClass === undefined) { + // Class-neutral: name each removed entry's own class since they can differ. + const removedDescr = governing.map((entry) => `${entry.dir} (${entry.class})`).join(', ') + ctx.stdout.write(`removed ${governing.length} entr${entrySuffix}: ${removedDescr}\n`) + } else { + const removedDirs = governing.map((entry) => entry.dir) + ctx.stdout.write(`removed ${governing.length} ${targetClass} entr${entrySuffix}: ${removedDirs.join(', ')}\n`) + } return 0 } @@ -884,10 +919,11 @@ async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { * * @ref LLP 0049#prospective-only [implements]: `--check` reports the residual already-cached row count; it never deletes * @ref LLP 0103#cli [implements]: `--check` names which source governs (dotfile vs machine-local entry) and the entry's class + * @ref LLP 0111#show [implements]: also the shared implementation behind `policy show`; `--json` stays byte-compatible with the `--check --json` field set * @param {{ targetDir: string, ctx: CommandRunContext, json: boolean }} args * @returns {Promise} */ -async function runIgnoreCheck({ targetDir, ctx, json }) { +export async function runIgnoreCheck({ targetDir, ctx, json }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) const result = createUsagePolicyResolver({ localOnlyListPath: listPath }).resolve(targetDir) diff --git a/src/core/commands/policy.js b/src/core/commands/policy.js new file mode 100644 index 0000000..d251521 --- /dev/null +++ b/src/core/commands/policy.js @@ -0,0 +1,278 @@ +// @ts-check + +import path from 'node:path' +import process from 'node:process' + +import { parseCommandArgv } from '../cli/verb_codec.js' +import { readObservabilityEnv } from '../observability/env.js' +import { localOnlyListPath, readLocalOnlyEntries } from '../usage-policy/index.js' +import { runIgnoreCheck, runMarkMachineLocal, runUnmarkMachineLocal } from './clients.js' + +/** + * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js' + * @import { UsageClass } from '../../../src/core/usage-policy/types.js' + */ + +/** + * The `hyp policy` command group (LLP 0110, LLP 0111): a class-neutral verb + * over the machine-local usage-class store that replaces the + * `hyp ignore --sync`/`--local-only`/`--private` misnomer. `set` / `show` / + * `unset` / `list` are thin runners over the marking internals hoisted in + * `src/core/commands/clients.js`; the `hyp ignore`/`hyp unignore` flag forms + * keep working as delegating compatibility aliases (see + * {@link runMarkMachineLocal}, {@link runUnmarkMachineLocal}, + * {@link runIgnoreCheck}). The store format, the shared resolver, and the + * three-class lattice are untouched (LLP 0103 #cli). + * + * @ref LLP 0110 [implements]: the class-neutral `policy` verb surface that retires the `hyp ignore --sync` misnomer + * @ref LLP 0111#surface [implements]: `policy set` / `show` / `unset` / `list`, registered as a `makeGroupCommand` group + * @ref LLP 0103#cli [constrained-by]: the store, resolver, and class lattice are unchanged; only the verb spelling is new + */ + +/** + * The user-facing class vocabulary the classification hook and the + * hypaware-privacy skill already teach. Mapped onto the store's class + * lattice at the CLI edge only (LLP 0111 #tokens): `sync` is the "asked; + * syncs" marker (stored as `full`); `local-only` and `ignore` map onto + * themselves. `policy show --json` / `policy list --json` keep emitting the + * resolver vocabulary (`full`/`local-only`/`ignore`) unchanged, so existing + * consumers of the `--check --json` shape see identical fields. + * + * @ref LLP 0111#tokens [implements]: the sync -> full token mapping lives at the CLI edge; the store keeps speaking `full` + */ +const CLASS_TOKENS = /** @type {const} */ (['sync', 'local-only', 'ignore']) + +/** @type {Record<(typeof CLASS_TOKENS)[number], UsageClass>} */ +const TOKEN_TO_CLASS = { sync: 'full', 'local-only': 'local-only', ignore: 'ignore' } + +/** @type {Record} */ +const CLASS_TO_TOKEN_GLOSS = { full: 'sync', 'local-only': 'local-only', ignore: 'ignore' } + +const POLICY_SET_USAGE = 'hyp policy set sync|local-only|ignore' +const POLICY_SHOW_USAGE = 'hyp policy show [path] [--json]' +const POLICY_UNSET_USAGE = 'hyp policy unset [sync|local-only|ignore]' +const POLICY_LIST_USAGE = 'hyp policy list [--json]' + +/** + * @param {string[]} argv + * @returns {{ path?: string, token?: string, error?: string }} + */ +function parsePolicySetArgs(argv) { + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + class: { type: 'string', enum: [...CLASS_TOKENS] }, + }, + positional: ['path', 'class'], + required: ['path', 'class'], + }) + if ('help' in parsed) return { error: `usage: ${POLICY_SET_USAGE}` } + if (!parsed.ok) return { error: parsed.error } + const p = /** @type {{ path: string, class: string }} */ (parsed.params) + return { path: p.path, token: p.class } +} + +/** + * @param {string[]} argv + * @returns {{ path?: string, json: boolean, error?: string }} + */ +function parsePolicyShowArgs(argv) { + const empty = { json: false } + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + json: { type: 'boolean', default: false }, + }, + positional: ['path'], + }) + if ('help' in parsed) return { ...empty, error: `usage: ${POLICY_SHOW_USAGE}` } + if (!parsed.ok) return { ...empty, error: parsed.error } + const p = /** @type {{ path?: string, json: boolean }} */ (parsed.params) + return { path: p.path, json: p.json } +} + +/** + * @param {string[]} argv + * @returns {{ path?: string, token?: string, error?: string }} + */ +function parsePolicyUnsetArgs(argv) { + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + class: { type: 'string', enum: [...CLASS_TOKENS] }, + }, + positional: ['path', 'class'], + required: ['path'], + }) + if ('help' in parsed) return { error: `usage: ${POLICY_UNSET_USAGE}` } + if (!parsed.ok) return { error: parsed.error } + const p = /** @type {{ path: string, class?: string }} */ (parsed.params) + return { path: p.path, token: p.class } +} + +/** + * @param {string[]} argv + * @returns {{ json: boolean, error?: string }} + */ +function parsePolicyListArgs(argv) { + const empty = { json: false } + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + json: { type: 'boolean', default: false }, + }, + }) + if ('help' in parsed) return { ...empty, error: `usage: ${POLICY_LIST_USAGE}` } + if (!parsed.ok) return { ...empty, error: parsed.error } + const p = /** @type {{ json: boolean }} */ (parsed.params) + return { json: p.json } +} + +/** + * `hyp policy set sync|local-only|ignore` + * + * Writes a machine-local usage-class marking for `` in the + * class-per-entry store (LLP 0103), delegating to + * {@link runMarkMachineLocal}, the internal both this verb and the + * `hyp ignore --sync`/`--local-only`/`--private` compatibility aliases call. + * `` is required (the bare grammar makes it necessary: `hyp policy set + * sync` would be ambiguous between a path and a class token) and resolved + * against the command-context cwd, matching the sibling verbs; the resolved + * directory is marked exactly where it points, with no repo-root default + * (LLP 0111 #set) - an explicit path already says which directory is meant. + * `set ignore` writes a machine-local `ignore` entry; it never writes + * a `.hypignore` dotfile (that stays bare `hyp ignore`'s job alone). An + * unknown class token is a usage error (exit 2) naming the three valid + * tokens (`sync`, `local-only`, `ignore`). + * + * @ref LLP 0110 [implements]: the class-neutral `policy set` that replaces the `hyp ignore --sync` misnomer for consent-adjacent marking + * @ref LLP 0111#set [implements]: required path, sync -> full token mapping, delegates to the hoisted marking internal + * @ref LLP 0103#cli [constrained-by]: the store, resolver, and class lattice are unchanged; only the verb spelling is new + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicySet(argv, ctx) { + const parsed = parsePolicySetArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), /** @type {string} */ (parsed.path)) + const targetClass = TOKEN_TO_CLASS[/** @type {(typeof CLASS_TOKENS)[number]} */ (parsed.token)] + return runMarkMachineLocal({ targetDir, ctx, targetClass, component: 'cmd-policy-set' }) +} + +/** + * `hyp policy show [path] [--json]` + * + * The class-neutral successor to `hyp ignore --check`: resolves `[path]` + * (default cwd, preserving `--check`'s ergonomics) and reports the resolved + * class, the governing source (`dotfile`/`machine-local`/`none`), the + * governing file, and the residual already-cached row count with the + * `hyp purge` hint. Prospective-only, never destructive. `--json` emits the + * exact field set `hyp ignore --check --json` emits today (byte-compatible), + * since {@link runIgnoreCheck} is the shared implementation both spellings + * call. + * + * @ref LLP 0110 [implements]: the class-neutral `policy show`, the `hyp ignore --check` successor + * @ref LLP 0111#show [implements]: `--json` stays byte-compatible with today's `--check --json` field set + * @ref LLP 0103#cli [constrained-by]: names the governing source (dotfile vs machine-local) and class; store/resolver unchanged + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyShow(argv, ctx) { + const parsed = parsePolicyShowArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + return runIgnoreCheck({ targetDir, ctx, json: parsed.json }) +} + +/** + * `hyp policy unset [sync|local-only|ignore]` + * + * Removes machine-local markings governing `` (equal to it, or an + * ancestor of it). By default (no trailing class token) it is class-neutral: + * every machine-local entry governing the target is removed, "back to the + * implicit default" (LLP 0111 #unset), matching the store's one-entry-per-dir + * shape. An optional trailing class token scopes removal to that class only + * - the scoped form the `hyp unignore --sync`/`--local-only`/`--private` + * aliases delegate to. Both forms delegate to {@link runUnmarkMachineLocal}. + * `unset` never touches `.hypignore` dotfiles and never touches cached rows + * (LLP 0104 boundary). Idempotent: nothing governing (of the given class, or + * of any class) is a no-op success. + * + * @ref LLP 0110 [implements]: the class-neutral `policy unset`, replacing per-class `hyp unignore` flags as the primary spelling + * @ref LLP 0111#unset [implements]: class-neutral by default, an optional trailing class token scopes it + * @ref LLP 0103#cli [constrained-by]: reuses the shared `isEqualOrDescendant` ancestor predicate; store/resolver unchanged + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyUnset(argv, ctx) { + const parsed = parsePolicyUnsetArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), /** @type {string} */ (parsed.path)) + const targetClass = parsed.token + ? TOKEN_TO_CLASS[/** @type {(typeof CLASS_TOKENS)[number]} */ (parsed.token)] + : undefined + return runUnmarkMachineLocal({ targetDir, ctx, targetClass, component: 'cmd-policy-unset' }) +} + +/** + * `hyp policy list [--json]` + * + * Enumerates the machine-local class-per-entry store (LLP 0103): one line + * per entry with its `dir` and class (`full` renders with a `sync` gloss for + * the human reader), plus the store path; `--json` emits + * `{ entries: [{ dir, class }], path }`. This is the store's first + * enumeration surface (LLP 0111 #list): `policy show` answers "what governs + * this path", `list` answers "what have I marked on this machine". It + * deliberately lists only the machine-local store - `.hypignore` dotfiles + * are discovered per-path by the ancestor walk and cannot be enumerated + * without a filesystem crawl, and `show` already names them when they + * govern. An empty store lists zero entries successfully. + * + * @ref LLP 0110 [implements]: names the machine-local store's enumeration surface with the class-neutral verb + * @ref LLP 0111#list [implements]: the store's first enumeration surface; `--json` emits `{ entries, path }` + * @ref LLP 0103#cli [constrained-by]: enumerates the version-2 class-per-entry store as-is; no format change + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyList(argv, ctx) { + const parsed = parsePolicyListArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const stateDir = readObservabilityEnv(ctx.env).stateDir + const listPath = localOnlyListPath(stateDir) + const entries = await readLocalOnlyEntries({ stateDir }) + + if (parsed.json) { + ctx.stdout.write(JSON.stringify({ entries, path: listPath }) + '\n') + return 0 + } + + if (entries.length === 0) { + ctx.stdout.write(`no machine-local entries (${listPath})\n`) + return 0 + } + for (const entry of entries) { + const gloss = entry.class === 'full' ? ` (${CLASS_TO_TOKEN_GLOSS.full})` : '' + ctx.stdout.write(`${entry.dir}: ${entry.class}${gloss}\n`) + } + ctx.stdout.write(`(${listPath})\n`) + return 0 +} diff --git a/test/core/policy-command.test.js b/test/core/policy-command.test.js new file mode 100644 index 0000000..93815e8 --- /dev/null +++ b/test/core/policy-command.test.js @@ -0,0 +1,426 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { registerCoreCommands } from '../../src/core/cli/core_commands.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' +import { + localOnlyListPath, + readLocalOnlyEntries, + writeLocalOnlyEntries, +} from '../../src/core/usage-policy/local_only.js' + +/** + * @import { CommandRegistration, CommandRunContext } from '../../hypaware-plugin-kernel-types.js' + */ + +// `hyp policy set/show/unset/list` (LLP 0110, LLP 0111, task T2): the +// class-neutral verb group over the machine-local class-per-entry store, +// mirroring test/core/ignore-private-sync-command.test.js for the new +// spellings, plus list and class-neutral unset coverage that has no flag-form +// counterpart. These verbs never touch the repo tree - the write target is a +// `HYP_HOME`-state JSON file - so every test roots the repo tree and the +// `HYP_HOME` state tree at two distinct temp directories. + +/** @returns {{ write(chunk: unknown): boolean, text(): string }} */ +function makeBuf() { + let value = '' + return { + write(chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} + +/** @param {string} name */ +function getCommand(name) { + const registry = createCommandRegistry() + registerCoreCommands(registry) + const command = registry.get(name) + assert.ok(command, `${name} is registered`) + return /** @type {CommandRegistration} */ (command) +} + +/** + * @param {string} name + * @param {string[]} argv + * @param {{ cwd: string, hypHome: string }} opts + */ +async function run(name, argv, opts) { + const stdout = makeBuf() + const stderr = makeBuf() + const ctx = /** @type {any} */ ({ + stdout, + stderr, + cwd: opts.cwd, + env: { HYP_HOME: opts.hypHome }, + config: { version: 2 }, + query: { getDataset: () => undefined, listDatasets: () => [] }, + storage: { cacheRoot: path.join(opts.cwd, '.cache'), pendingInfo: async () => ({ pending: false }) }, + }) + const code = await getCommand(name).run(argv, /** @type {CommandRunContext} */ (ctx)) + return { code, stdout: stdout.text(), stderr: stderr.text() } +} + +/** @param {(dirs: { root: string, hypHome: string }) => Promise | void} fn */ +async function withSandbox(fn) { + const root = mkdtempSync(path.join(tmpdir(), 'hyp-policy-repo-')) + const hypHome = mkdtempSync(path.join(tmpdir(), 'hyp-policy-home-')) + try { + await fn({ root, hypHome }) + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(hypHome, { recursive: true, force: true }) + } +} + +/** @param {string} hypHome */ +function stateDirOf(hypHome) { + return path.join(hypHome, 'hypaware') +} + +/* --------------------------------- policy set -------------------------------- */ + +test('hyp policy set ignore marks the path ignore in the machine-local store, never touching the repo', async () => { + await withSandbox(async ({ root, hypHome }) => { + mkdirSync(path.join(root, '.git')) + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }]) + assert.ok(!existsSync(path.join(root, '.hypignore')), 'never writes a .hypignore dotfile') + }) +}) + +test('hyp policy set ignore is idempotent: marking twice is a no-op success', async () => { + await withSandbox(async ({ root, hypHome }) => { + const first = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + + const second = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /already ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }], 'no duplicate entry') + }) +}) + +test('hyp policy set ignore upgrades an existing local-only entry to ignore', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'local-only' }] }) + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }]) + }) +}) + +test('hyp policy set ignore on a path already governed by a stricter .hypignore is a no-op naming the dotfile', async () => { + await withSandbox(async ({ root, hypHome }) => { + writeFileSync(path.join(root, '.hypignore'), 'ignore\n') + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /already ignore/) + assert.match(res.stdout, new RegExp(path.join(root, '.hypignore').replace(/[.\\]/g, '\\$&'))) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'the dotfile already suppresses recording; nothing is added to the store') + }) +}) + +test('hyp policy set sync writes an explicit full entry (the sync -> full token mapping)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as full/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'full' }], 'the store keeps speaking `full`; only the CLI token is `sync`') + }) +}) + +test('hyp policy set sync is idempotent against an existing explicit full entry, but not against the mere implicit default', async () => { + await withSandbox(async ({ root, hypHome }) => { + const first = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /marked .* as full/, 'no entry existed yet, so this must write, not no-op') + + const second = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /already full/, 'now an explicit entry exists, so this is idempotent') + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'full' }], 'no duplicate entry') + }) +}) + +test('hyp policy set local-only marks the path local-only', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'local-only'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as local-only/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'local-only' }]) + }) +}) + +test('hyp policy set rejects an unknown class token with a usage error naming the three valid tokens', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'private'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + assert.match(res.stderr, /sync/) + assert.match(res.stderr, /local-only/) + assert.match(res.stderr, /ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'nothing is written on a rejected token') + }) +}) + +test('hyp policy set requires a path (bare class token alone is ambiguous, so it is rejected)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', ['sync'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + }) +}) + +/* -------------------------------- policy show --------------------------------- */ + +test('hyp policy show [path] --json is byte-compatible with hyp ignore --check --json for a machine-local mark', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'ignore' }] }) + + const legacy = await run('ignore', ['--check', '--json'], { cwd: root, hypHome }) + const next = await run('policy show', [root, '--json'], { cwd: root, hypHome }) + assert.equal(next.code, 0) + assert.equal(legacy.code, 0) + assert.deepEqual(JSON.parse(next.stdout), JSON.parse(legacy.stdout)) + + const parsed = JSON.parse(next.stdout) + assert.equal(parsed.class, 'ignore') + assert.equal(parsed.source, 'machine-local') + assert.equal(parsed.governedBy, localOnlyListPath(stateDirOf(hypHome))) + }) +}) + +test('hyp policy show defaults to cwd and names the dotfile source when a .hypignore governs', async () => { + await withSandbox(async ({ root, hypHome }) => { + writeFileSync(path.join(root, '.hypignore'), 'ignore\n') + + const res = await run('policy show', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.source, 'dotfile') + assert.equal(parsed.class, 'ignore') + }) +}) + +test('hyp policy show names no source when nothing governs (the implicit default)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy show', [root, '--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.class, 'full') + assert.equal(parsed.source, 'none') + }) +}) + +/* -------------------------------- policy unset --------------------------------- */ + +test('hyp policy unset ignore (scoped) removes an ignore entry and is idempotent, leaving other classes untouched', async () => { + await withSandbox(async ({ root, hypHome }) => { + const other = path.join(root, 'other') + mkdirSync(other, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'ignore' }, + { dir: other, class: 'local-only' }, + ], + }) + + const first = await run('policy unset', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /removed 1 ignore entry/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: other, class: 'local-only' }], 'the unrelated local-only entry survives') + + const second = await run('policy unset', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(second.code, 0, 'unsetting an already-clean path still succeeds') + assert.match(second.stdout, /not ignore/) + }) +}) + +test('hyp policy unset sync (scoped) removes an explicit full entry and is idempotent', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'full' }] }) + + const first = await run('policy unset', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /removed 1 full entry/) + assert.deepEqual(await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }), []) + + const second = await run('policy unset', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /not full/) + }) +}) + +test('hyp policy unset (no trailing class token) is class-neutral: removes every machine-local entry governing the target', async () => { + await withSandbox(async ({ root, hypHome }) => { + const sub = path.join(root, 'a', 'b') + mkdirSync(sub, { recursive: true }) + const unrelated = path.join(root, 'sibling') + mkdirSync(unrelated) + // Two entries of different classes both govern `sub` (via ancestry); + // an unrelated sibling entry must survive untouched. + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'local-only' }, + { dir: unrelated, class: 'ignore' }, + ], + }) + + const res = await run('policy unset', [sub], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /removed 1 entry/) + assert.match(res.stdout, /local-only/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: unrelated, class: 'ignore' }], 'only the entry governing sub is removed') + }) +}) + +test('hyp policy unset (no trailing class token) removes multiple entries of different classes governing the same target', async () => { + await withSandbox(async ({ root, hypHome }) => { + // Not physically realizable via the store's one-entry-per-dir shape at a + // single dir, but an ancestor entry plus an exact-dir entry can both + // govern the same target simultaneously. + const sub = path.join(root, 'nested') + mkdirSync(sub, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'local-only' }, + { dir: sub, class: 'ignore' }, + ], + }) + + const res = await run('policy unset', [sub], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /removed 2 entries/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'both the exact-dir entry and the governing ancestor entry are removed') + }) +}) + +test('hyp policy unset (no trailing class token) on an already-clean path is a class-neutral no-op success', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [root], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /not governed/) + }) +}) + +test('hyp policy unset rejects an unknown trailing class token with a usage error naming the three valid tokens', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [root, 'private'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + assert.match(res.stderr, /sync/) + assert.match(res.stderr, /local-only/) + assert.match(res.stderr, /ignore/) + }) +}) + +test('hyp policy unset requires a path', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [], { cwd: root, hypHome }) + assert.equal(res.code, 2) + }) +}) + +/* --------------------------------- policy list --------------------------------- */ + +test('hyp policy list --json enumerates every machine-local entry with the store path', async () => { + await withSandbox(async ({ root, hypHome }) => { + const other = path.join(root, 'other') + mkdirSync(other, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'full' }, + { dir: other, class: 'ignore' }, + ], + }) + + const res = await run('policy list', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.path, localOnlyListPath(stateDirOf(hypHome))) + assert.deepEqual(parsed.entries, [ + { dir: root, class: 'full' }, + { dir: other, class: 'ignore' }, + ]) + }) +}) + +test('hyp policy list --json on an empty store lists zero entries successfully', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy list', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.deepEqual(parsed.entries, []) + assert.equal(parsed.path, localOnlyListPath(stateDirOf(hypHome))) + }) +}) + +test('hyp policy list (human) renders a full entry with a sync gloss', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'full' }] }) + + const res = await run('policy list', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /full \(sync\)/) + }) +}) + +test('hyp policy list (human) on an empty store reports no entries without error', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy list', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /no machine-local entries/) + }) +}) + +/* ------------------------------ group registration ------------------------------ */ + +test('hyp policy (bare) renders group help listing set/show/unset/list', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /set/) + assert.match(res.stdout, /show/) + assert.match(res.stdout, /unset/) + assert.match(res.stdout, /list/) + }) +}) From de5ad60b823deddb06462071d252155fd30eb453 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 18:21:47 -0700 Subject: [PATCH 5/9] Turn ignore/unignore marking flags into deprecated policy aliases The --sync/--local-only/--private/--check branches in runIgnore/runUnignore already delegate to the T1 hoisted internals (runMarkMachineLocal, runUnmarkMachineLocal, runIgnoreCheck) that the T2 policy runners call, so the delegation seam is single-implementation by construction and alias behavior cannot drift from the verb's. This task makes the compatibility posture explicit: - Annotate the delegation seam in both runners with // @ref LLP 0111#aliases [implements], noting the flag forms' repo-root defaulting is preserved at the alias edge (and unignore's cwd-relative no-default target likewise). - Mark the --local-only/--private/--sync/--check flags "deprecated: use hyp policy ..." in the hyp ignore / hyp unignore registry help, while stating the bare dotfile verbs are not deprecated. No runtime stderr warning (LLP 0111 #aliases). Zero behavior change: every existing alias test passes unchanged (stdout, stderr, exit codes, JSON fields, usage_policy.mark log event), the compatibility proof LLP 0110's exit criteria require. npm test (2284), typecheck, and build:types all green. @ref LLP 0111#aliases [implements] Task-Id: T3 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/cli/core_commands.js | 8 ++++++++ src/core/commands/clients.js | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index db377fa..be8b7f0 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -257,6 +257,10 @@ function buildCoreCommands(registry) { 'implicit default made durable, so it is not asked about again). With', '--check, reports the current status - class and governing source -', 'without writing anything.', + '', + 'Deprecated: the --local-only/--private/--sync/--check flags now live as', + 'hyp policy set/show; use hyp policy instead. Bare hyp ignore [path]', + '(the .hypignore dotfile author) is not deprecated.', ].join('\n'), run: runIgnore, }, @@ -268,6 +272,10 @@ function buildCoreCommands(registry) { 'Removes the governing .hypignore. With --local-only, --private, or', '--sync, removes machine-local entries of that class instead', '(symmetric with the matching hyp ignore flag).', + '', + 'Deprecated: the --local-only/--private/--sync flags now live as', + 'hyp policy unset; use hyp policy instead. Bare hyp unignore [path]', + '(the .hypignore remover) is not deprecated.', ].join('\n'), run: runUnignore, }, diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 893508f..1b167ee 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -639,6 +639,7 @@ export async function runIgnore(argv, ctx) { // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + // @ref LLP 0111#aliases [implements]: the --check/--private/--local-only/--sync flag branches are deprecated compatibility aliases that delegate to exactly the hoisted internals the `policy` subcommands call, so alias behavior can never drift from the verb's; the flag forms' repo-root defaulting (repoRootDefaultTarget) is preserved here at the alias edge if (parsed.check) return runIgnoreCheck({ targetDir: base, ctx, json: parsed.json }) if (parsed.private) return runMarkMachineLocal({ @@ -808,6 +809,7 @@ export async function runUnignore(argv, ctx) { // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + // @ref LLP 0111#aliases [implements]: the --private/--local-only/--sync flag branches are deprecated compatibility aliases that delegate to exactly the hoisted class-scoped unmark internal the `policy unset` runner calls; the flag forms' cwd-relative target (no repo-root default) is preserved here at the alias edge if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore', component: 'cmd-unignore' }) if (parsed.localOnly) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only', component: 'cmd-unignore' }) From 1fd0ecf41fcb4310d9d234d46575c0f692996d5c Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 18:23:11 -0700 Subject: [PATCH 6/9] Migrate classification hook copy to hyp policy set (LLP 0106, 0110, 0111) Swap the consent-sensitive classification hook copy off the deprecated `hyp ignore --sync`/`--local-only`/`--private` misnomer and onto the class-neutral `hyp policy set ` verb (registered since T2). - CLASSIFICATION_CHOICES: replace the `flag` field with the CLI-edge class `token` (`sync`/`local-only`/`ignore`). - verbArgvForClass now returns ['policy', 'set', targetPath, token], dispatched against the registry's two-word `policy set` path. - buildClassificationPrompt prints `hyp policy set `. - Pinned consent-copy tests updated in the same commit, plus an exit-criterion assertion that no ignore-spelled command is taught for a non-ignore class. - @ref glosses cite LLP 0111#teaching for the verb surface; the store and class rationale keeps citing LLP 0103#cli. Task-Id: T4 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/usage-policy/classification.js | 49 +++++++++++-------- test/core/usage-policy-classification.test.js | 42 ++++++++++------ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/core/usage-policy/classification.js b/src/core/usage-policy/classification.js index b204365..bf2f42a 100644 --- a/src/core/usage-policy/classification.js +++ b/src/core/usage-policy/classification.js @@ -12,10 +12,11 @@ import { localOnlyListPath } from './local_only.js' * Session-start classification (LLP 0106): on an enrolled machine, an * interactive session opened in a directory with no explicit governing usage * class is asked - once - to classify the folder (sync / local-only / ignore). - * The answer is written through the same CLI marking verbs the human and the - * privacy skill use (LLP 0103 #cli), landing an explicit machine-local entry so - * the folder is never asked about again. Choosing sync writes the explicit - * `full` entry, which is precisely what suppresses the next prompt. + * The answer is written through the same `hyp policy set` verb the human and + * the privacy skill use (LLP 0111 #teaching, LLP 0103 #cli), landing an + * explicit machine-local entry so the folder is never asked about again. + * Choosing sync writes the explicit `full` entry, which is precisely what + * suppresses the next prompt. * * This module is the shared, client-agnostic core the per-client hooks call: * the Claude hook (`hyp claude-hook classify-cwd`, a blocking SessionStart @@ -30,42 +31,49 @@ import { localOnlyListPath } from './local_only.js' /** * The three usage classes, in the order the prompt presents them (least to - * most restrictive), each paired with the `hyp ignore` marking verb that - * records it (LLP 0103 #cli). One store, three writers (skill, hook, hand-run - * CLI); the hook advertises exactly the verbs the other two use. + * most restrictive), each paired with the `hyp policy set` class token that + * records it (LLP 0111 #teaching, LLP 0103 #cli). One store, three writers + * (skill, hook, hand-run CLI); the hook advertises exactly the verb the other + * two use. The token is the CLI-edge vocabulary (`sync` maps onto the stored + * `full`); the deprecated `hyp ignore --sync` misnomer is gone from the copy. * - * @ref LLP 0106 [implements]: the hook's answer is written via the same CLI verbs, landing an LLP 0103 entry - * @type {ReadonlyArray<{ class: UsageClass, flag: '--sync' | '--local-only' | '--private', label: string, blurb: string }>} + * @ref LLP 0106 [implements]: the hook's answer is written via the same CLI verb, landing an LLP 0103 entry + * @ref LLP 0111#teaching [implements]: the consent copy teaches `hyp policy set `, not the `hyp ignore --sync` misnomer + * @type {ReadonlyArray<{ class: UsageClass, token: 'sync' | 'local-only' | 'ignore', label: string, blurb: string }>} */ export const CLASSIFICATION_CHOICES = [ { class: 'full', - flag: '--sync', + token: 'sync', label: 'sync', blurb: "this folder's sessions upload to the shared server (the current default)", }, { class: 'local-only', - flag: '--local-only', + token: 'local-only', label: 'local-only', blurb: 'keep sessions on this machine only, never forward them to the server', }, { class: 'ignore', - flag: '--private', + token: 'ignore', label: 'ignore', blurb: 'do not record this folder\'s sessions at all', }, ] /** - * The `hyp ignore` argv that records `targetPath` as `cls` in the machine-local - * store (LLP 0103): `full` -> `--sync`, `local-only` -> `--local-only`, - * `ignore` -> `--private`. Returned as an argv array (not a shell string) so a - * caller can dispatch it directly against the same verb the human runs, which - * is exactly what the "answer lands via the verbs" contract pins. + * The `hyp policy set` argv that records `targetPath` as `cls` in the + * machine-local store (LLP 0111 #teaching, LLP 0103): `full` -> `sync`, + * `local-only` -> `local-only`, `ignore` -> `ignore`. Returned as an argv + * array (not a shell string) so a caller can dispatch it directly against the + * same two-word `policy set` verb the human runs, which is exactly what the + * "answer lands via the verbs" contract pins. The `policy set` verb has been + * registered since T2, so the hook never advertises a spelling the binary + * lacks. * - * @ref LLP 0106 [implements]: map a chosen class to the T2 marking verb + * @ref LLP 0106 [implements]: map a chosen class to the marking verb the hook dispatches + * @ref LLP 0111#teaching [implements]: emit `policy set `, retiring the `hyp ignore --sync` misnomer * @param {UsageClass} cls * @param {string} targetPath absolute path of the folder to classify * @returns {string[]} @@ -73,7 +81,7 @@ export const CLASSIFICATION_CHOICES = [ export function verbArgvForClass(cls, targetPath) { const choice = CLASSIFICATION_CHOICES.find((c) => c.class === cls) if (!choice) throw new Error(`verbArgvForClass: unknown class ${String(cls)}`) - return ['ignore', choice.flag, targetPath] + return ['policy', 'set', targetPath, choice.token] } /** @@ -86,6 +94,7 @@ export function verbArgvForClass(cls, targetPath) { * * No em dashes and `-` in runtime strings, per the repo style. * + * @ref LLP 0111#teaching [implements]: prints `hyp policy set `; the exit criterion is that no non-ignore class is ever taught with an ignore-spelled command * @param {{ cwd: string }} args * @returns {string} */ @@ -102,7 +111,7 @@ export function buildClassificationPrompt({ cwd }) { ] for (const choice of CLASSIFICATION_CHOICES) { lines.push(` - ${choice.label}: ${choice.blurb}`) - lines.push(` hyp ignore ${choice.flag} ${cwd}`) + lines.push(` hyp policy set ${cwd} ${choice.token}`) } lines.push('') lines.push('Pick one with the user and run its command. If the user is unsure, the safe') diff --git a/test/core/usage-policy-classification.test.js b/test/core/usage-policy-classification.test.js index 4ed50c6..b0a534a 100644 --- a/test/core/usage-policy-classification.test.js +++ b/test/core/usage-policy-classification.test.js @@ -29,19 +29,19 @@ import { // copy is load-bearing (many users' first contact with the class vocabulary), // so it is pinned here like the other consent surfaces. -test('verbArgvForClass maps each class to its hyp ignore marking verb (LLP 0103 T2)', () => { - assert.deepEqual(verbArgvForClass('full', '/work/repo'), ['ignore', '--sync', '/work/repo']) - assert.deepEqual(verbArgvForClass('local-only', '/work/repo'), ['ignore', '--local-only', '/work/repo']) - assert.deepEqual(verbArgvForClass('ignore', '/work/repo'), ['ignore', '--private', '/work/repo']) +test('verbArgvForClass maps each class to its hyp policy set token (LLP 0111 #teaching)', () => { + assert.deepEqual(verbArgvForClass('full', '/work/repo'), ['policy', 'set', '/work/repo', 'sync']) + assert.deepEqual(verbArgvForClass('local-only', '/work/repo'), ['policy', 'set', '/work/repo', 'local-only']) + assert.deepEqual(verbArgvForClass('ignore', '/work/repo'), ['policy', 'set', '/work/repo', 'ignore']) assert.throws(() => verbArgvForClass(/** @type {any} */ ('nope'), '/x'), /unknown class/) }) -test('the three choices are presented least-to-most restrictive with their verbs', () => { +test('the three choices are presented least-to-most restrictive with their tokens', () => { assert.deepEqual(CLASSIFICATION_CHOICES.map((c) => c.class), ['full', 'local-only', 'ignore']) - assert.deepEqual(CLASSIFICATION_CHOICES.map((c) => c.flag), ['--sync', '--local-only', '--private']) + assert.deepEqual(CLASSIFICATION_CHOICES.map((c) => c.token), ['sync', 'local-only', 'ignore']) }) -test('buildClassificationPrompt names the folder, all three classes, and each verb', () => { +test('buildClassificationPrompt names the folder, all three classes, and each policy set command', () => { const prompt = buildClassificationPrompt({ cwd: '/work/secret-repo' }) assert.match(prompt, /\/work\/secret-repo/) assert.match(prompt, /enrolled/) @@ -50,9 +50,14 @@ test('buildClassificationPrompt names the folder, all three classes, and each ve assert.match(prompt, /sync:/) assert.match(prompt, /local-only:/) assert.match(prompt, /ignore:/) - assert.match(prompt, /hyp ignore --sync \/work\/secret-repo/) - assert.match(prompt, /hyp ignore --local-only \/work\/secret-repo/) - assert.match(prompt, /hyp ignore --private \/work\/secret-repo/) + assert.match(prompt, /hyp policy set \/work\/secret-repo sync/) + assert.match(prompt, /hyp policy set \/work\/secret-repo local-only/) + assert.match(prompt, /hyp policy set \/work\/secret-repo ignore/) + // Exit criterion (LLP 0110): the hook never teaches an ignore-spelled command + // for a non-ignore class - the deprecated flag spellings are gone entirely. + assert.equal(/hyp ignore --sync/.test(prompt), false) + assert.equal(/hyp ignore --local-only/.test(prompt), false) + assert.equal(/hyp ignore --private/.test(prompt), false) // Repo style: no em dashes anywhere in the consent copy. assert.equal(prompt.includes('—'), false) }) @@ -160,7 +165,7 @@ test('evaluateCwdClassification never fails the session on a corrupt list or a b assert.equal(result.enrolled, false) }) -test('the classification answer lands via the real hyp ignore verbs (LLP 0106 -> LLP 0103)', async () => { +test('the classification answer lands via the real hyp policy set verb (LLP 0106 -> LLP 0111 -> LLP 0103)', async () => { const root = mkdtempSync(path.join(tmpdir(), 'classify-verb-repo-')) const hypHome = mkdtempSync(path.join(tmpdir(), 'classify-verb-home-')) try { @@ -174,7 +179,7 @@ test('the classification answer lands via the real hyp ignore verbs (LLP 0106 -> // Answer "ignore" by running exactly the argv the hook advertises. const argv = verbArgvForClass('ignore', root) - assert.deepEqual(argv, ['ignore', '--private', root]) + assert.deepEqual(argv, ['policy', 'set', root, 'ignore']) const res = await runVerb(argv, { cwd: root, hypHome }) assert.equal(res.code, 0, res.stderr) @@ -211,8 +216,15 @@ function makeResolver(result) { async function runVerb(argv, opts) { const registry = createCommandRegistry() registerCoreCommands(registry) - const command = /** @type {CommandRegistration} */ (registry.get('ignore')) - assert.ok(command, 'ignore is registered') + // The marking answer now dispatches the two-word `policy set` verb + // (LLP 0111 #teaching), so resolve a two-word command name before falling + // back to the single-word form - exactly how the CLI's group dispatch works. + const twoWord = argv.length >= 2 ? `${argv[0]} ${argv[1]}` : null + const useTwoWord = Boolean(twoWord && registry.get(twoWord)) + const name = useTwoWord ? /** @type {string} */ (twoWord) : argv[0] + const rest = useTwoWord ? argv.slice(2) : argv.slice(1) + const command = /** @type {CommandRegistration} */ (registry.get(name)) + assert.ok(command, `${name} is registered`) const stdout = makeBuf() const stderr = makeBuf() const ctx = /** @type {any} */ ({ @@ -224,7 +236,7 @@ async function runVerb(argv, opts) { query: { getDataset: () => undefined, listDatasets: () => [] }, storage: { cacheRoot: path.join(opts.cwd, '.cache'), pendingInfo: async () => ({ pending: false }) }, }) - const code = await command.run(argv.slice(1), /** @type {CommandRunContext} */ (ctx)) + const code = await command.run(rest, /** @type {CommandRunContext} */ (ctx)) return { code, stdout: stdout.text(), stderr: stderr.text() } } From e5efb31012eaddf3d0c2bc52e8f3bc88bc157eac Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 18:24:37 -0700 Subject: [PATCH 7/9] T5: migrate skill and status/help teaching copy to the hyp policy verb Both hypaware-privacy SKILL.md bodies (claude + codex) now teach the machine-local marking surface as `hyp policy set/show/unset` instead of the `hyp ignore --sync/--local-only/--private/--check` and `hyp unignore --` misnomer. The bare `.hypignore` dotfile verbs are left untouched. Both bodies stay byte-consistent with each other. Swept the remaining runtime help strings that pointed users at the marking flags: the login durable-command hint (DURABLE_HINT) and the purge durability tip now name `hyp policy set ...`, with their pinned teaching-copy test assertions moved in the same commit. The alias behavior tests (hyp ignore --*/hyp unignore --*) are untouched and still pass as the compatibility proof. No product surface teaches an ignore-spelled command for a non-ignore class. Classification hook copy (classification.js) is out of scope here (T4); the JSDoc on the alias runners keeps naming the old flags because those functions are the aliases. @ref LLP 0111#teaching [implements] @ref LLP 0110 [implements] Task-Id: T5 --- .../claude/skills/hypaware-privacy/SKILL.md | 22 +++++++++---------- .../codex/skills/hypaware-privacy/SKILL.md | 22 +++++++++---------- src/core/cli/remote_commands.js | 3 ++- src/core/commands/local_only.js | 2 +- src/core/commands/purge.js | 2 +- test/core/purge-command.test.js | 2 +- test/core/remote-login-command.test.js | 8 +++---- 7 files changed, 31 insertions(+), 30 deletions(-) diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md index 6d560ea..a4ddfb3 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-privacy/SKILL.md @@ -91,9 +91,9 @@ WHERE cwd = '

' ORDER BY date DESC LIMIT 40" --format json --output /tmp/sam Before you propose or apply **anything**, explain the classes in plain language, including what the org can and cannot see in each case: -- **ignore** (`hyp ignore --private `): never recorded going forward; the machine-local rule stops capture at the source. Existing cached rows are **purgeable** (Step 6) but are not removed by marking alone. The org sees **nothing** from this directory. -- **local-only** (`hyp ignore --local-only `): recorded and queryable **here** on this machine, but **never forwarded**. Withheld at the export seam. The org sees **nothing**, while you keep local history. -- **sync** (`hyp ignore --sync `): the explicit "this ships" choice - forwarded to the org server like the default. Marking `--sync` records an explicit decision so this directory is not asked about again. The org sees this directory's captured exchanges. +- **ignore** (`hyp policy set ignore`): never recorded going forward; the machine-local rule stops capture at the source. Existing cached rows are **purgeable** (Step 6) but are not removed by marking alone. The org sees **nothing** from this directory. +- **local-only** (`hyp policy set local-only`): recorded and queryable **here** on this machine, but **never forwarded**. Withheld at the export seam. The org sees **nothing**, while you keep local history. +- **sync** (`hyp policy set sync`): the explicit "this ships" choice - forwarded to the org server like the default. Marking it `sync` records an explicit decision so this directory is not asked about again. The org sees this directory's captured exchanges. Name the trade honestly: `local-only` keeps your history usable locally; `ignore` is stronger (nothing is even recorded once marked) but you lose local queryability too. @@ -112,14 +112,14 @@ Then **apply nothing without per-item user confirmation.** Propose, wait for a y Apply each confirmed decision **only** through the `hyp` verbs below. **Never** author policy files or write anything into the user's repositories - the machine-local store is the only target. ```bash -hyp ignore --private # class: ignore (stop recording this dir) -hyp ignore --local-only # class: local-only (record here, never forward) -hyp ignore --sync # class: sync (explicit "this ships") -hyp ignore --check # report the governing source + class, and residual cached rows; never writes -hyp unignore --private|--local-only|--sync # symmetric removal per class +hyp policy set ignore # class: ignore (stop recording this dir) +hyp policy set local-only # class: local-only (record here, never forward) +hyp policy set sync # class: sync (explicit "this ships") +hyp policy show # report the governing source + class, and residual cached rows; never writes +hyp policy unset [class] # remove markings (class-neutral by default; a trailing class scopes it) ``` -`hyp ignore --check ` names **which source governs** (a committed `.hypignore` dotfile vs a machine-local entry) and the entry's class, and reports how many already-cached rows still sit under it - the residue that purge (below) clears. Marking is always **non-destructive**: it changes future capture/forwarding, not existing cached rows. +`hyp policy show ` names **which source governs** (a committed `.hypignore` dotfile vs a machine-local entry) and the entry's class, and reports how many already-cached rows still sit under it - the residue that purge (below) clears. Marking is always **non-destructive**: it changes future capture/forwarding, not existing cached rows. **For every directory you mark `ignore`, and every session you flag as sensitive, offer `hyp purge` as a separately confirmed step** so that "completely ignored" also means "not sitting in the cache". Purge is destructive and cache-only (it never contacts the server); confirm each purge on its own. @@ -129,10 +129,10 @@ hyp purge --session # delete all cached rows for one session (cheapest: hyp purge --ignored # sweep every cached row whose cwd currently resolves to `ignore` ``` -Purge prompts for confirmation on a TTY; it errors on a bare `hyp purge` with no target. Sequencing matters: **mark `--private` first, then purge** - purging a directory that still resolves to `sync`/default warns that the next backfill will re-import it. Once a directory is `ignore`d, the capture seam blocks re-import, so the purge is durable. A common close-out for a directory the user wants fully gone: +Purge prompts for confirmation on a TTY; it errors on a bare `hyp purge` with no target. Sequencing matters: **mark the directory `ignore` first, then purge** - purging a directory that still resolves to `sync`/default warns that the next backfill will re-import it. Once a directory is `ignore`d, the capture seam blocks re-import, so the purge is durable. A common close-out for a directory the user wants fully gone: ```bash -hyp ignore --private && hyp purge +hyp policy set ignore && hyp purge ``` ## After the review diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md index 9968106..21274a1 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-privacy/SKILL.md @@ -119,9 +119,9 @@ WHERE cwd = '' ORDER BY date DESC LIMIT 40" --format json --output /tmp/sam Before you propose or apply **anything**, explain the classes in plain language, including what the org can and cannot see in each case: -- **ignore** (`hyp ignore --private `): never recorded going forward; the machine-local rule stops capture at the source. Existing cached rows are **purgeable** (Step 6) but are not removed by marking alone. The org sees **nothing** from this directory. -- **local-only** (`hyp ignore --local-only `): recorded and queryable **here** on this machine, but **never forwarded**. Withheld at the export seam. The org sees **nothing**, while you keep local history. -- **sync** (`hyp ignore --sync `): the explicit "this ships" choice - forwarded to the org server like the default. Marking `--sync` records an explicit decision so this directory is not asked about again. The org sees this directory's captured exchanges. +- **ignore** (`hyp policy set ignore`): never recorded going forward; the machine-local rule stops capture at the source. Existing cached rows are **purgeable** (Step 6) but are not removed by marking alone. The org sees **nothing** from this directory. +- **local-only** (`hyp policy set local-only`): recorded and queryable **here** on this machine, but **never forwarded**. Withheld at the export seam. The org sees **nothing**, while you keep local history. +- **sync** (`hyp policy set sync`): the explicit "this ships" choice - forwarded to the org server like the default. Marking it `sync` records an explicit decision so this directory is not asked about again. The org sees this directory's captured exchanges. Name the trade honestly: `local-only` keeps your history usable locally; `ignore` is stronger (nothing is even recorded once marked) but you lose local queryability too. @@ -140,14 +140,14 @@ Then **apply nothing without per-item user confirmation.** Propose, wait for a y Apply each confirmed decision **only** through the `hyp` verbs below. **Never** author policy files or write anything into the user's repositories - the machine-local store is the only target. ```bash -hyp ignore --private # class: ignore (stop recording this dir) -hyp ignore --local-only # class: local-only (record here, never forward) -hyp ignore --sync # class: sync (explicit "this ships") -hyp ignore --check # report the governing source + class, and residual cached rows; never writes -hyp unignore --private|--local-only|--sync # symmetric removal per class +hyp policy set ignore # class: ignore (stop recording this dir) +hyp policy set local-only # class: local-only (record here, never forward) +hyp policy set sync # class: sync (explicit "this ships") +hyp policy show # report the governing source + class, and residual cached rows; never writes +hyp policy unset [class] # remove markings (class-neutral by default; a trailing class scopes it) ``` -`hyp ignore --check ` names **which source governs** (a committed `.hypignore` dotfile vs a machine-local entry) and the entry's class, and reports how many already-cached rows still sit under it - the residue that purge (below) clears. Marking is always **non-destructive**: it changes future capture/forwarding, not existing cached rows. +`hyp policy show ` names **which source governs** (a committed `.hypignore` dotfile vs a machine-local entry) and the entry's class, and reports how many already-cached rows still sit under it - the residue that purge (below) clears. Marking is always **non-destructive**: it changes future capture/forwarding, not existing cached rows. **For every directory you mark `ignore`, and every session you flag as sensitive, offer `hyp purge` as a separately confirmed step** so that "completely ignored" also means "not sitting in the cache". Purge is destructive and cache-only (it never contacts the server); confirm each purge on its own. @@ -157,10 +157,10 @@ hyp purge --session # delete all cached rows for one session (cheapest: hyp purge --ignored # sweep every cached row whose cwd currently resolves to `ignore` ``` -Purge prompts for confirmation on a TTY; it errors on a bare `hyp purge` with no target. Sequencing matters: **mark `--private` first, then purge** - purging a directory that still resolves to `sync`/default warns that the next backfill will re-import it. Once a directory is `ignore`d, the capture seam blocks re-import, so the purge is durable. A common close-out for a directory the user wants fully gone: +Purge prompts for confirmation on a TTY; it errors on a bare `hyp purge` with no target. Sequencing matters: **mark the directory `ignore` first, then purge** - purging a directory that still resolves to `sync`/default warns that the next backfill will re-import it. Once a directory is `ignore`d, the capture seam blocks re-import, so the purge is durable. A common close-out for a directory the user wants fully gone: ```bash -hyp ignore --private && hyp purge +hyp policy set ignore && hyp purge ``` ## After the review diff --git a/src/core/cli/remote_commands.js b/src/core/cli/remote_commands.js index 662ac53..aedad07 100644 --- a/src/core/cli/remote_commands.js +++ b/src/core/cli/remote_commands.js @@ -535,7 +535,8 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon // privacy refinement is now the first-sync review window plus the // hypaware-privacy skill, run afterwards against a settled cache. Login // finishes fast; each fork prints the durable-command hint so the CLI floor - // (`hyp ignore --local-only`, `hyp ignore --private`) stays discoverable. + // (`hyp policy set [path] local-only`, `hyp policy set [path] ignore`) stays + // discoverable. // No sink targets this server yet: provision one so login forwards from one // command (LLP 0063 D2/D5). enrollCentralSink writes join's sink block, seeds diff --git a/src/core/commands/local_only.js b/src/core/commands/local_only.js index ed90bfc..12a9cd4 100644 --- a/src/core/commands/local_only.js +++ b/src/core/commands/local_only.js @@ -12,7 +12,7 @@ import { executeQuerySql } from '../query/sql.js' // after the in-login picker's retirement (LLP 0102): the review window plus // the hypaware-privacy skill are the enrollment-time refinement now, and this // command is the client-independent way to withhold a directory at any time. -export const DURABLE_HINT = "tip: mark a directory local-only anytime with 'hyp ignore --local-only [path]'\n" +export const DURABLE_HINT = "tip: mark a directory local-only anytime with 'hyp policy set [path] local-only'\n" // The dataset the captured-directory enumeration reads. Exported so the // hypaware-privacy skill's survey (LLP 0069 #enumerate, LLP 0100 §skill) and diff --git a/src/core/commands/purge.js b/src/core/commands/purge.js index 40caaa3..e900eac 100644 --- a/src/core/commands/purge.js +++ b/src/core/commands/purge.js @@ -125,7 +125,7 @@ export async function runPurge(argv, ctx) { `still record and will be re-imported by the next backfill:\n` ) for (const dir of resurrectable) ctx.stderr.write(` ${dir}\n`) - ctx.stderr.write("tip: mark them ignored first with 'hyp ignore --private ' so the purge is durable\n") + ctx.stderr.write("tip: mark them ignored first with 'hyp policy set ignore' so the purge is durable\n") } return 0 diff --git a/test/core/purge-command.test.js b/test/core/purge-command.test.js index afe2c90..2a02cd1 100644 --- a/test/core/purge-command.test.js +++ b/test/core/purge-command.test.js @@ -334,7 +334,7 @@ test('runPurge subtree warns about resurrection when the dir still resolves full assert.equal(code, 0) assert.match(stderr.text, /still record and will be re-imported/) assert.match(stderr.text, /home\/u\/repoA/) - assert.match(stderr.text, /hyp ignore --private/) + assert.match(stderr.text, /hyp policy set ignore/) } finally { await fs.rm(cacheRoot, { recursive: true, force: true }) await fs.rm(hypHome, { recursive: true, force: true }) diff --git a/test/core/remote-login-command.test.js b/test/core/remote-login-command.test.js index d6f3aec..47085df 100644 --- a/test/core/remote-login-command.test.js +++ b/test/core/remote-login-command.test.js @@ -751,7 +751,7 @@ test('a --no-daemon login prints the durable hint and provisions the sink (LLP 0 const code = await runRemoteLogin(['prod', '--no-daemon'], ctx, { login }) assert.equal(code, 0) - assert.match(err.join(''), /hyp ignore --local-only/, 'the durable command stays discoverable') + assert.match(err.join(''), /hyp policy set \[path\] local-only/, 'the durable command stays discoverable') await fs.access(seedPath) // the sink is still provisioned assert.match(out.join(''), /forwarding logs to https:\/\/hyp\.internal/) }) @@ -766,7 +766,7 @@ test('a fresh enroll prints the durable hint and never polls a capture wait (LLP const code = await runRemoteLogin(['prod'], ctx, { login, enroll, waitForAttach }) assert.equal(code, 0) - assert.match(err.join(''), /hyp ignore --local-only/) + assert.match(err.join(''), /hyp policy set \[path\] local-only/) assert.match(out.join(''), /capturing @hypaware\/claude/) }) @@ -781,7 +781,7 @@ test('a failed daemon install still prints the durable hint before returning (LL const code = await runRemoteLogin(['prod'], ctx, { login, enroll, waitForAttach }) assert.equal(code, 3) assert.equal(waited, false, 'a failed install does not wait for attach') - assert.match(err.join(''), /hyp ignore --local-only/) + assert.match(err.join(''), /hyp policy set \[path\] local-only/) assert.match(err.join(''), /the daemon install did not finish/) }) @@ -795,7 +795,7 @@ test('a re-login (already-enrolled, re-seed path) prints the durable hint (LLP 0 const code = await runRemoteLogin(['prod'], ctx, { login }) assert.equal(code, 0) - assert.match(err.join(''), /hyp ignore --local-only/) + assert.match(err.join(''), /hyp policy set \[path\] local-only/) assert.match(out.join(''), /seeded forwarding identity for sink 'fwd'/) }) From b2c5cf5a6b502f5b1a45210852624adefdfb4334 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 18:33:42 -0700 Subject: [PATCH 8/9] Ref hygiene sweep for hyp policy verb (LLP 0110/0111) Ran ref-check across the touched hyp-policy-verb surface (clients.js, policy.js, classification.js, core_commands.js, both hypaware-privacy SKILL.md bodies, and LLP 0103/0110/0111/0112) and found the verb-surface @ref citations already correct from T2-T5. The remaining stale spots were three doc comments left over from T1's carry-forward (repoRootDefaultTarget, runMarkMachineLocal, runIgnoreCheck) that still described the policy set/show runners as "a future task" and described repoRootDefaultTarget as shared with the future policy verb, when in fact policy set/show now exist and policy set deliberately never calls repoRootDefaultTarget (it marks the resolved path exactly as pointed at, per LLP 0111 #set). Corrected those glosses to state the current, already-landed relationship instead of a stale forward-looking one. Verified LLP 0103's Extended-by: LLP 0110 forward-ref still reads correctly against the final shipped verb surface. ref-check's only remaining findings in touched files are two pre-existing false positives against LLP 0086's HTML-anchor sections (the tool only scans markdown headings), unrelated to this change set and present before T1-T5 touched this file. No behavior change. npm test (2284 tests) and npm run typecheck both pass. Task-Id: T6 --- src/core/commands/clients.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 1b167ee..e34a628 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -702,8 +702,11 @@ export async function runIgnore(argv, ctx) { * repo root: an explicit `path` argument always wins (the caller pointed at * it directly), otherwise resolve `base` up to its containing repo root, or * `base` itself outside a repo. Shared by the `hyp ignore` bare-dotfile - * branch and the machine-local marking branches so both spellings (and the - * future `policy` verb) apply one placement rule (LLP 0103 #cli). + * branch and the deprecated `--private`/`--local-only`/`--sync` alias + * branches so both keep one placement rule (LLP 0103 #cli). `policy set` + * does not call this: it marks the resolved directory exactly as pointed at, + * with no repo-root default (LLP 0111 #set); only the flag-alias edge needs + * the legacy default preserved (LLP 0111 #aliases). * * @param {string} base * @param {string | undefined} explicitPath @@ -720,10 +723,11 @@ function repoRootDefaultTarget(base, explicitPath) { * Marks `targetDir` with `targetClass` in the machine-local class-per-entry * store (LLP 0103) instead of writing a `.hypignore`: never writes into a * repo (LLP 0071 R4, LLP 0100 R6), so the target need not exist on disk or be - * a git repo. Verb-agnostic: the caller decides `targetDir` (today via + * a git repo. Verb-agnostic: the caller decides `targetDir` (the deprecated + * `--private`/`--local-only`/`--sync` flag branches resolve it via * {@link repoRootDefaultTarget}, matching plain `hyp ignore`'s placement - * rule), so this implementation is shared by the `--private`/`--local-only`/ - * `--sync` flag branches and, in a future task, the `policy set` runner. + * rule; `policy set` passes its already-resolved path with no repo-root + * default, LLP 0111 #set), so this one implementation backs both spellings. * * - `ignore`: rows from the scope are never recorded (enforced at the * capture seam, same as a dotfile `ignore`). @@ -914,10 +918,10 @@ export async function runUnmarkMachineLocal({ targetDir, ctx, targetClass, compo * as "recorded locally, withheld from forwarding" rather than "never * recorded". * - * Verb-agnostic: the caller resolves `targetDir` (today the flag form's - * cwd-relative `base`, no repo-root default), so this implementation is - * shared by `hyp ignore --check` and, in a future task, the `policy show` - * runner. + * Verb-agnostic: the caller resolves `targetDir` (the flag form's + * cwd-relative `base`, no repo-root default; `policy show` resolves the same + * way), so this one implementation backs both `hyp ignore --check` and + * `policy show`. * * @ref LLP 0049#prospective-only [implements]: `--check` reports the residual already-cached row count; it never deletes * @ref LLP 0103#cli [implements]: `--check` names which source governs (dotfile vs machine-local entry) and the entry's class From 1e2b4f265b52f8a69d6593f8a7b0405875e5a5e8 Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Thu, 16 Jul 2026 19:00:56 -0700 Subject: [PATCH 9/9] Complete hyp policy doc sweep: hyp ignore --check -> hyp policy show in sibling skills Folds in the deferred minor review finding from PR #327 review round 1 (the sensitive-scan and reference skills still taught the deprecated hyp ignore --check alias). Doc-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../claude/skills/hypaware-sensitive-scan/SKILL.md | 2 +- .../plugins-workspace/codex/skills/hypaware-reference/SKILL.md | 2 +- .../codex/skills/hypaware-sensitive-scan/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hypaware-core/plugins-workspace/claude/skills/hypaware-sensitive-scan/SKILL.md b/hypaware-core/plugins-workspace/claude/skills/hypaware-sensitive-scan/SKILL.md index e47c830..fcc24e6 100644 --- a/hypaware-core/plugins-workspace/claude/skills/hypaware-sensitive-scan/SKILL.md +++ b/hypaware-core/plugins-workspace/claude/skills/hypaware-sensitive-scan/SKILL.md @@ -92,7 +92,7 @@ All examples redacted. Two groups, then the caveat: current session forward, reverse with `/hypaware-unignore`). - **Caveat (always).** The sensitive rows above are **already recorded**; ignoring is prospective and does not delete them. Show the residual with - `hyp ignore --check ` (reports the governor and how many cached rows from the + `hyp policy show ` (reports the governor and how many cached rows from the scope remain). Retroactive purge is out of scope. ## Notes diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-reference/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-reference/SKILL.md index 988dd59..22b5e29 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-reference/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-reference/SKILL.md @@ -146,7 +146,7 @@ registry. **hypaware-ai-spend-report**, **-adoption-report**, **-security-report**, or **-improvement-report** skills. - Opt a folder out of recording - `hyp ignore` / `hyp unignore` write or remove - a `.hypignore` for the folder subtree (`hyp ignore --check` reports what + a `.hypignore` for the folder subtree (`hyp policy show` reports what governs a path). - "Is it working?" or diagnose a problem - `hyp status` (with `--json` for the stable shape); its `diagnostics:` section carries `repair:` lines to run. diff --git a/hypaware-core/plugins-workspace/codex/skills/hypaware-sensitive-scan/SKILL.md b/hypaware-core/plugins-workspace/codex/skills/hypaware-sensitive-scan/SKILL.md index 3a757f0..0619bdf 100644 --- a/hypaware-core/plugins-workspace/codex/skills/hypaware-sensitive-scan/SKILL.md +++ b/hypaware-core/plugins-workspace/codex/skills/hypaware-sensitive-scan/SKILL.md @@ -94,7 +94,7 @@ All examples redacted. Two groups, then the caveat: `hyp unignore` once the sensitive work is done). - **Caveat (always).** The sensitive rows above are **already recorded**; ignoring is prospective and does not delete them. Show the residual with - `hyp ignore --check ` (reports the governor and how many cached rows from the + `hyp policy show ` (reports the governor and how many cached rows from the scope remain). Retroactive purge is out of scope. ## Notes