Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 29 additions & 20 deletions src/core/usage-policy/classification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,50 +31,57 @@ 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 <path> <token>`, 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 <path> <token>`, retiring the `hyp ignore --sync` misnomer
* @param {UsageClass} cls
* @param {string} targetPath absolute path of the folder to classify
* @returns {string[]}
*/
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]
}

/**
Expand All @@ -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 <cwd> <token>`; the exit criterion is that no non-ignore class is ever taught with an ignore-spelled command
* @param {{ cwd: string }} args
* @returns {string}
*/
Expand All @@ -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')
Expand Down
42 changes: 27 additions & 15 deletions test/core/usage-policy-classification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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)
})
Expand Down Expand Up @@ -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 {
Expand All @@ -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)

Expand Down Expand Up @@ -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} */ ({
Expand All @@ -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() }
}

Expand Down