` (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.
diff --git a/llp/0112-hyp-policy-verb.plan.md b/llp/0112-hyp-policy-verb.plan.md
new file mode 100644
index 00000000..e050fe86
--- /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).
diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js
index f12014ec..be8b7f08 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'
@@ -256,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,
},
@@ -267,9 +272,53 @@ 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,
},
+ 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/cli/remote_commands.js b/src/core/cli/remote_commands.js
index 662ac53e..aedad071 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/clients.js b/src/core/commands/clients.js
index 351d5993..e34a6283 100644
--- a/src/core/commands/clients.js
+++ b/src/core/commands/clients.js
@@ -635,15 +635,34 @@ 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 ?? '.')
+ // @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({
+ 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',
+ 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
// `.hypignore` resolves to `ignore`), so re-ignoring is a no-op success
@@ -656,7 +675,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 +697,37 @@ 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 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
+ * @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` (the deprecated
+ * `--private`/`--local-only`/`--sync` flag branches resolve it via
+ * {@link repoRootDefaultTarget}, matching plain `hyp ignore`'s placement
+ * 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`).
@@ -706,20 +746,11 @@ 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
+ * @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(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)
+export async function runMarkMachineLocal({ targetDir, ctx, targetClass, component }) {
const resolvedTarget = path.resolve(targetDir)
const stateDir = readObservabilityEnv(ctx.env).stateDir
const listPath = localOnlyListPath(stateDir)
@@ -738,7 +769,7 @@ async function runMarkMachineLocal(parsed, 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',
@@ -778,14 +809,16 @@ 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 ?? '.')
+ // @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' })
+ if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full', component: 'cmd-unignore' })
+
const { governedBy } = createUsagePolicyResolver().resolve(base)
if (!governedBy) {
ctx.stdout.write(`not ignored (no .hypignore governs ${base})\n`)
@@ -811,31 +844,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 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 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 {{ path?: string }} parsed
- * @param {CommandRunContext} ctx
- * @param {UsageClass} targetClass
+ * @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(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 ?? '.')
+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(base, 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 ${base})\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
}
@@ -843,15 +885,20 @@ async function runUnmarkMachineLocal(parsed, 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
}
@@ -871,23 +918,24 @@ async function runUnmarkMachineLocal(parsed, ctx, targetClass) {
* as "recorded locally, withheld from forwarding" rather than "never
* recorded".
*
+ * 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
- * @param {{ json: boolean, path?: string }} parsed
- * @param {CommandRunContext} ctx
+ * @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(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 ?? '.')
+export 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 +943,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 +958,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`)
diff --git a/src/core/commands/local_only.js b/src/core/commands/local_only.js
index ed90bfc1..12a9cd4c 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/policy.js b/src/core/commands/policy.js
new file mode 100644
index 00000000..d251521d
--- /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/src/core/commands/purge.js b/src/core/commands/purge.js
index 40caaa3e..e900eacf 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/src/core/usage-policy/classification.js b/src/core/usage-policy/classification.js
index 7e779201..0ffceeb3 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
* @ref LLP 0113 [implements]: the copy itself mandates menu presentation, tool-neutral with the Claude tool named, so tool-less clients degrade to prose
* @param {{ cwd: string }} args
* @returns {string}
@@ -103,7 +112,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('Present these three choices as a selection menu using your environment\'s')
diff --git a/test/core/policy-command.test.js b/test/core/policy-command.test.js
new file mode 100644
index 00000000..93815e8a
--- /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/)
+ })
+})
diff --git a/test/core/purge-command.test.js b/test/core/purge-command.test.js
index afe2c904..2a02cd1e 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 d6f3aec0..47085df6 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'/)
})
diff --git a/test/core/usage-policy-classification.test.js b/test/core/usage-policy-classification.test.js
index 488ff8bc..061669ed 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)
// @ref LLP 0113 [tests]: the menu mandate is part of the pinned consent copy
assert.match(prompt, /selection menu/)
assert.match(prompt, /AskUserQuestion/)
@@ -164,7 +169,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 {
@@ -178,7 +183,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)
@@ -215,8 +220,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} */ ({
@@ -228,7 +240,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() }
}