@lorekit/cli 1.71.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -667,6 +667,75 @@ The server's refusal is printed verbatim with a one-line next step; the CLI make
667
667
  exactly one request and never retries, splits the sweep, or re-scopes around it.
668
668
  Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
669
669
 
670
+ ### `lorekit groom` / `lorekit policy` / `lorekit protect` (aliases: `pin` / `unpin`)
671
+
672
+ Retention automation — the CLI counterpart to the MCP `groom.*`/`policy.*`/
673
+ `memory.protect` tools and the REST `/groom/*`/`/policies*`/`/protect` routes.
674
+ **Remote only**, matching `purge`: `retention_policies` has no local-store
675
+ equivalent.
676
+
677
+ ```bash
678
+ # Preview what a rule would catch, without changing anything
679
+ lorekit groom --scope repo::acme/app --unseen-days 90
680
+
681
+ # Archive the matches (soft-archive, recoverable via `lorekit restore`)
682
+ lorekit groom --scope repo::acme/app --unseen-days 90 --run --yes
683
+
684
+ # Save it as a policy you can re-run by name, or let it sweep nightly
685
+ lorekit policy create --scope repo::acme/app --name "stale repo lore" \
686
+ --unseen-days 90 --mode review
687
+ lorekit policy list
688
+ lorekit policy update <id> --mode auto --enabled
689
+ lorekit policy delete <id>
690
+
691
+ # Exempt one lesson from every rule and every manual sweep
692
+ lorekit pin repo::acme/app::the-load-bearing-lesson
693
+ lorekit unpin repo::acme/app::the-load-bearing-lesson
694
+ ```
695
+
696
+ `groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes]` previews
697
+ by default; `--run` archives. Exactly one of `--policy-id`/`--scope` is
698
+ required. `policy <list|create|update|delete>` manages the SAVED rules
699
+ `--policy-id` runs; `delete` removes the rule only, never the lessons it
700
+ matched.
701
+
702
+ Every condition ANDs together: the five age/activity fields
703
+ (`--min-age-days`/`--unseen-days`/`--max-seen-count`/`--max-read-count`/
704
+ `--max-opened-count`) plus the same **eight dimension filters** the Lore
705
+ Explorer's filter bar offers —
706
+
707
+ | Flag | `--<dim>-mode` values (default) |
708
+ |------|----------------------------------|
709
+ | `--tags <a,b>` | `any` \| `all` \| `none` (`any`) |
710
+ | `--kind <a,b>` | `in` \| `nin` (`in`) |
711
+ | `--host <a,b>` | `in` \| `nin` (`in`) |
712
+ | `--trigger <a,b>` | `in` \| `nin` (`in`) |
713
+ | `--source-agent <a,b>` | `in` \| `nin` (`in`) |
714
+ | `--origin-repo <a,b>` | `in` \| `nin` (`in`) |
715
+ | `--origin-branch <a,b>` | `in` \| `nin` (`in`) |
716
+ | `--origin-pr <a,b>` | `in` \| `nin` (`in`) |
717
+
718
+ — all comma-separated, same convention as `write --tags a,b,c`:
719
+
720
+ ```bash
721
+ lorekit policy create --scope repo::acme/app --name "stale ci review events" \
722
+ --kind bus --kind-mode in --tags ci::pr-review-state --tags-mode all \
723
+ --min-age-days 30
724
+ ```
725
+
726
+ `policy update` pairs every condition — age/count AND every dimension — with
727
+ a `--clear-<field>` boolean (e.g. `--clear-kind`, `--clear-min-age-days`) that
728
+ sends an explicit `null` rather than omitting the field: omitting a field on
729
+ update leaves it unchanged, `null` clears it.
730
+
731
+ ```bash
732
+ lorekit policy update <id> --clear-kind --host reviewer,aw
733
+ ```
734
+
735
+ `protect <scope::key> [--off]` (or the shorter `pin`/`unpin`) marks a lesson as
736
+ excluded from every policy and every `groom --run`, regardless of which rule
737
+ would otherwise match it — persists until explicitly cleared.
738
+
670
739
  ### `lorekit hook`
671
740
 
672
741
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
package/bin/lorekit.mjs CHANGED
@@ -935,7 +935,10 @@ ${c.bold('Options')}
935
935
  ${c.bold('Usage')}
936
936
  lorekit groom --policy-id <id> [--run] [--yes] [--json]
937
937
  lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
938
- [--max-read-count <n>] [--max-opened-count <n>] [--run] [--yes] [--json]
938
+ [--max-read-count <n>] [--max-opened-count <n>]
939
+ [--tags a,b --tags-mode any|all|none]
940
+ [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
941
+ [--run] [--yes] [--json]
939
942
 
940
943
  Resolves the SAME candidates a saved policy or an inline condition set would
941
944
  catch, via the retention-policy candidate query — a previewed count always
@@ -960,6 +963,12 @@ ${c.bold('Options')}
960
963
  the hundreds — a small value matches nothing
961
964
  --max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
962
965
  times. Bulk reads do NOT count, so 0 means never chosen
966
+ --tags <a,b>, --tags-mode <any|all|none>
967
+ Match lessons carrying these labels (default: any)
968
+ --kind, --host, --trigger, --source-agent,
969
+ --origin-repo, --origin-branch, --origin-pr <a,b>
970
+ --<dim>-mode <in|nin> Match lessons whose dimension is one of these
971
+ values (default: in); comma-separated
963
972
  --run Archive the matches instead of previewing
964
973
  -y, --yes Confirm --run; required when non-interactive
965
974
  --json Machine-readable result
@@ -974,12 +983,17 @@ ${c.bold('Usage')}
974
983
  lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
975
984
  [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
976
985
  [--max-read-count <n>] [--max-opened-count <n>]
986
+ [--tags a,b --tags-mode any|all|none]
987
+ [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]
977
988
  lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
978
- [--min-age-days <n>|--clear-min-age-days] [...] [--json]
989
+ [--min-age-days <n>|--clear-min-age-days] [...]
990
+ [--tags a,b|--clear-tags] [--<dim> a,b|--clear-<dim>] [--<dim>-mode in|nin] [--json]
979
991
  lorekit policy delete <id> [--yes] [--json]
980
992
 
981
993
  A policy is a saved retention rule: a scope plus AND-ed conditions
982
- (min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count).
994
+ (min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count,
995
+ plus the eight dimension filters — tags/kind/host/trigger/source-agent/
996
+ origin-repo/origin-branch/origin-pr).
983
997
  \`mode: review\` surfaces it for
984
998
  you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
985
999
  nightly, but ONLY once you also pass --enabled — auto starts disabled on
@@ -995,8 +1009,14 @@ ${c.bold('Options')}
995
1009
  --min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
996
1010
  --max-opened-count <n>
997
1011
  Conditions (create/update)
1012
+ --tags <a,b>, --tags-mode <any|all|none>
1013
+ --kind, --host, --trigger, --source-agent,
1014
+ --origin-repo, --origin-branch, --origin-pr <a,b>
1015
+ --<dim>-mode <in|nin> Dimension conditions (create/update); comma-separated
998
1016
  --clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
999
- --clear-max-read-count, --clear-max-opened-count
1017
+ --clear-max-read-count, --clear-max-opened-count,
1018
+ --clear-tags, --clear-kind, --clear-host, --clear-trigger,
1019
+ --clear-source-agent, --clear-origin-repo, --clear-origin-branch, --clear-origin-pr
1000
1020
  Remove a condition (update only)
1001
1021
  -y, --yes Confirm delete; required when non-interactive
1002
1022
  --json Machine-readable result
@@ -1121,6 +1141,16 @@ const KNOWN_FLAGS = [
1121
1141
  'name', 'mode', 'enabled', 'disabled',
1122
1142
  'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
1123
1143
  'clear-max-opened-count', 'off',
1144
+ // The eight retention dimension filters' `*-mode` value flags (the value
1145
+ // flags themselves — tags/source-agent/trigger/kind/host/origin-repo/
1146
+ // origin-branch/origin-pr — are already listed above for `write`) plus
1147
+ // their `clear-*` booleans (policy update only). See
1148
+ // `shared/flags.mjs`'s `parseDimensionConditions`, the one place that
1149
+ // owns the flag-name ↔ field-name ↔ mode-enum table these mirror.
1150
+ 'tags-mode', 'source-agent-mode', 'trigger-mode', 'kind-mode', 'host-mode',
1151
+ 'origin-repo-mode', 'origin-branch-mode', 'origin-pr-mode',
1152
+ 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host',
1153
+ 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr',
1124
1154
  // `obligations`
1125
1155
  'files', 'strict', 'strict-all',
1126
1156
  // `update`
@@ -1137,7 +1167,7 @@ async function main() {
1137
1167
  const argv = process.argv.slice(2);
1138
1168
  const args = parseArgs(argv, {
1139
1169
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
1140
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'check'],
1170
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'run', 'enabled', 'disabled', 'off', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count', 'clear-max-opened-count', 'strict', 'strict-all', 'clear-tags', 'clear-source-agent', 'clear-trigger', 'clear-kind', 'clear-host', 'clear-origin-repo', 'clear-origin-branch', 'clear-origin-pr', 'check'],
1141
1171
  known: KNOWN_FLAGS,
1142
1172
  });
1143
1173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.71.0",
3
+ "version": "1.71.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,7 +11,7 @@ import { resolveProjectRoot } from '../shared/config.mjs';
11
11
  import { loadControl, resolveDenies } from '../shared/control.mjs';
12
12
  import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
13
13
  import { log, err, c, select } from '../shared/util.mjs';
14
- import { parseIntFlag } from '../shared/flags.mjs';
14
+ import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
15
15
 
16
16
  /** Resolve the groom.preview/groom.run request from CLI args. */
17
17
  export function parseGroomRequest(args) {
@@ -33,6 +33,8 @@ export function parseGroomRequest(args) {
33
33
  if (maxRead.error) return { error: maxRead.error };
34
34
  const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
35
35
  if (maxOpened.error) return { error: maxOpened.error };
36
+ const dims = parseDimensionConditions(args, { clearable: false });
37
+ if (dims.error) return { error: dims.error };
36
38
 
37
39
  return {
38
40
  request: {
@@ -42,6 +44,7 @@ export function parseGroomRequest(args) {
42
44
  max_seen_count: maxSeen.value,
43
45
  max_read_count: maxRead.value,
44
46
  max_opened_count: maxOpened.value,
47
+ ...dims.conditions,
45
48
  },
46
49
  };
47
50
  }
@@ -15,7 +15,19 @@ import { resolveProjectRoot } from '../shared/config.mjs';
15
15
  import { loadControl, resolveDenies } from '../shared/control.mjs';
16
16
  import { resolveStores, remoteUnavailableReason } from '../shared/stores.mjs';
17
17
  import { log, err, c, select } from '../shared/util.mjs';
18
- import { parseIntFlag } from '../shared/flags.mjs';
18
+ import { parseIntFlag, parseDimensionConditions } from '../shared/flags.mjs';
19
+
20
+ /** `field` → display label for `formatPolicy`'s dimension summary. */
21
+ const DIMENSION_DISPLAY = [
22
+ ['tags', 'tags_mode', 'tags'],
23
+ ['source_agent', 'source_agent_mode', 'source_agent'],
24
+ ['trigger', 'trigger_mode', 'trigger'],
25
+ ['kind', 'kind_mode', 'kind'],
26
+ ['host', 'host_mode', 'host'],
27
+ ['origin_repo', 'origin_repo_mode', 'origin_repo'],
28
+ ['origin_branch', 'origin_branch_mode', 'origin_branch'],
29
+ ['origin_pr', 'origin_pr_mode', 'origin_pr'],
30
+ ];
19
31
 
20
32
  function pickRemote({ root, env, args }) {
21
33
  const { remoteDenied } = resolveDenies(root, { env });
@@ -32,6 +44,12 @@ function formatPolicy(p) {
32
44
  if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
33
45
  if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
34
46
  if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
47
+ for (const [field, modeField, label] of DIMENSION_DISPLAY) {
48
+ const values = p[field];
49
+ if (!Array.isArray(values) || values.length === 0) continue;
50
+ const mode = p[modeField];
51
+ conditions.push(`${label}${mode ? `:${mode}` : ''}=[${values.join(',')}]`);
52
+ }
35
53
  const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
36
54
  return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
37
55
  }
@@ -52,7 +70,7 @@ async function list(args, store) {
52
70
 
53
71
  async function create(args, store) {
54
72
  if (!args.scope || !args.name) {
55
- err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
73
+ err(`${c.red('Usage:')} lorekit policy create --scope <scope> --name <name> [--mode review|auto] [--enabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N] [--tags a,b --tags-mode any|all|none] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b [--<dim>-mode in|nin]]`);
56
74
  return 1;
57
75
  }
58
76
  const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
@@ -69,6 +87,8 @@ async function create(args, store) {
69
87
  err(`${c.red('Error:')} --mode must be "review" or "auto"`);
70
88
  return 1;
71
89
  }
90
+ const dims = parseDimensionConditions(args, { clearable: false });
91
+ if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
72
92
 
73
93
  const res = await store.policyCreate({
74
94
  scope: args.scope,
@@ -80,6 +100,7 @@ async function create(args, store) {
80
100
  max_seen_count: maxSeen.value,
81
101
  max_read_count: maxRead.value,
82
102
  max_opened_count: maxOpened.value,
103
+ ...dims.conditions,
83
104
  });
84
105
  if (!res.ok) {
85
106
  const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
@@ -95,7 +116,7 @@ async function create(args, store) {
95
116
  async function update(args, store) {
96
117
  const id = args._[2];
97
118
  if (!id) {
98
- err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N] [--unseen-days N] [--max-seen-count N] [--max-read-count N] [--max-opened-count N]`);
119
+ err(`${c.red('Usage:')} lorekit policy update <id> [--name N] [--mode review|auto] [--enabled|--disabled] [--min-age-days N|--clear-min-age-days] [...] [--tags a,b|--clear-tags] [--kind|--host|--trigger|--source-agent|--origin-repo|--origin-branch|--origin-pr a,b|--clear-<dim>] [--<dim>-mode ...]`);
99
120
  return 1;
100
121
  }
101
122
  if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
@@ -121,6 +142,9 @@ async function update(args, store) {
121
142
  if (parsed.error) { err(`${c.red('Error:')} ${parsed.error}`); return 1; }
122
143
  patch[field] = parsed.value;
123
144
  }
145
+ const dims = parseDimensionConditions(args, { clearable: true });
146
+ if (dims.error) { err(`${c.red('Error:')} ${dims.error}`); return 1; }
147
+ Object.assign(patch, dims.conditions);
124
148
  if (Object.keys(patch).length === 0) {
125
149
  err(`${c.red('Error:')} at least one field to update is required`);
126
150
  return 1;
@@ -14,3 +14,76 @@ export function parseIntFlag(raw, name) {
14
14
  }
15
15
  return { value: Number(String(raw).trim()) };
16
16
  }
17
+
18
+ /**
19
+ * The eight dimension filters a retention policy / inline groom call can
20
+ * carry (migration 00093) — the CLI's flag-name ↔ field-name ↔ valid-mode
21
+ * table. Mirrors `groomDimensionFilterProperties` in
22
+ * `packages/schemas/src/shared/tool-catalog.ts` and `GROOM_DIMENSION_FIELDS`
23
+ * in `supabase/functions/mcp/tools.ts`; kept as ONE list here (not three) so
24
+ * `policy create`/`policy update`/`groom` cannot describe the dimensions
25
+ * differently.
26
+ */
27
+ const DIMENSIONS = [
28
+ { flag: 'tags', field: 'tags', modes: ['any', 'all', 'none'] },
29
+ { flag: 'source-agent', field: 'source_agent', modes: ['in', 'nin'] },
30
+ { flag: 'trigger', field: 'trigger', modes: ['in', 'nin'] },
31
+ { flag: 'kind', field: 'kind', modes: ['in', 'nin'] },
32
+ { flag: 'host', field: 'host', modes: ['in', 'nin'] },
33
+ { flag: 'origin-repo', field: 'origin_repo', modes: ['in', 'nin'] },
34
+ { flag: 'origin-branch', field: 'origin_branch', modes: ['in', 'nin'] },
35
+ { flag: 'origin-pr', field: 'origin_pr', modes: ['in', 'nin'] },
36
+ ];
37
+
38
+ /**
39
+ * Parse the eight retention dimension filters (`--tags`/`--kind`/`--host`/
40
+ * `--trigger`/`--source-agent`/`--origin-repo`/`--origin-branch`/`--origin-pr`,
41
+ * each with a `--<dim>-mode`) out of a parsed-args object.
42
+ *
43
+ * Comma-separated values — matching the existing `write --tags a,b,c`
44
+ * convention. The minimal flag parser (`bin/lorekit.mjs`'s `parseArgs`) is
45
+ * last-wins on a repeated flag, so comma-splitting is the established
46
+ * multi-value shape in this CLI, not a new one invented here.
47
+ *
48
+ * `--<dim>-mode` is validated against that DIMENSION's own enum (`tags-mode`
49
+ * accepts any/all/none; every other dimension accepts in/nin) — an invalid
50
+ * value is rejected rather than silently forwarded to the RPC as a raw
51
+ * Postgres enum error.
52
+ *
53
+ * With `clearable: true` (policy update), `--clear-<dim>` sends an explicit
54
+ * `null` for the VALUE field only, and skips both the value and mode flags for
55
+ * that dimension entirely — matching how `policy update --clear-min-age-days`
56
+ * already ignores `--min-age-days` when both are passed. The MODE field is
57
+ * left untouched by a clear: it is meaningless once the value is null, and
58
+ * silently dropping it if the caller separately set it would be a second
59
+ * surprise on top of the clear.
60
+ *
61
+ * Returns `{ conditions }` containing only the fields the caller actually
62
+ * named — never a full 16-key object of undefineds, so a caller can spread it
63
+ * straight into a request body without a second `stripUndefined` pass losing
64
+ * an intentional `null` — or `{ error }` naming the offending flag.
65
+ */
66
+ export function parseDimensionConditions(args, { clearable = false } = {}) {
67
+ const conditions = {};
68
+ for (const { flag, field, modes } of DIMENSIONS) {
69
+ if (clearable && args[`clear-${flag}`]) {
70
+ conditions[field] = null;
71
+ continue;
72
+ }
73
+ const raw = args[flag];
74
+ if (raw !== undefined) {
75
+ conditions[field] = String(raw)
76
+ .split(',')
77
+ .map((s) => s.trim())
78
+ .filter((s) => s.length > 0);
79
+ }
80
+ const modeRaw = args[`${flag}-mode`];
81
+ if (modeRaw !== undefined) {
82
+ if (!modes.includes(modeRaw)) {
83
+ return { error: `--${flag}-mode must be one of ${modes.join('|')}, got ${JSON.stringify(String(modeRaw))}` };
84
+ }
85
+ conditions[`${field}_mode`] = modeRaw;
86
+ }
87
+ }
88
+ return { conditions };
89
+ }
@@ -42,6 +42,20 @@ function stripUndefined(obj) {
42
42
  return out;
43
43
  }
44
44
 
45
+ // Drop only `undefined` — keeps an explicit `null`. Bare `stripUndefined`
46
+ // above is right for a CREATE body, where an omitted dimension and an
47
+ // explicit `null` mean the same "not filtered". It is wrong for a PATCH,
48
+ // where `null` is the caller's explicit instruction to CLEAR a
49
+ // previously-set condition (`policy update --clear-kind`) — dropping it
50
+ // there means the field silently stays whatever it already was, which is
51
+ // the opposite of what was asked. Named for what it keeps, not what it
52
+ // drops, since "strip" alone reads as "strip everything falsy-ish".
53
+ function stripUndefinedKeepNull(obj) {
54
+ const out = {};
55
+ for (const [k, v] of Object.entries(obj || {})) if (v !== undefined) out[k] = v;
56
+ return out;
57
+ }
58
+
45
59
  /**
46
60
  * A read that could not be answered — a transport failure or a non-2xx status,
47
61
  * as opposed to "the lesson is not there".
@@ -768,9 +782,20 @@ class RemoteStore {
768
782
  return { ok: true, entries: Array.isArray(res.data?.entries) ? res.data.entries : [] };
769
783
  }
770
784
 
771
- // POST /policies → the created policy object.
772
- async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
773
- const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
785
+ // POST /policies → the created policy object. `...dims` carries whichever
786
+ // of the eight dimension filters (+ their `_mode` companions) the caller
787
+ // named — see `parseDimensionConditions` in `shared/flags.mjs`, the shared
788
+ // parser policy create/update and groom all go through.
789
+ async policyCreate({
790
+ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
791
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
792
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
793
+ } = {}) {
794
+ const body = stripUndefined({
795
+ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
796
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
797
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
798
+ });
774
799
  const res = await this._rest('/memories/policies', { method: 'POST', body });
775
800
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
776
801
  return { ok: true, policy: res.data };
@@ -778,10 +803,11 @@ class RemoteStore {
778
803
 
779
804
  // PATCH /policies/:id → the updated policy object. An omitted field is left
780
805
  // unchanged; pass an explicit `null` in the patch to clear a condition —
781
- // this method does not strip nulls, only `undefined` (stripUndefined keeps
782
- // that distinction, which is the whole point of the RPC's JSONB-patch design).
806
+ // this method does not strip nulls, only `undefined` (stripUndefinedKeepNull
807
+ // keeps that distinction, which is the whole point of the RPC's JSONB-patch
808
+ // design and of `policy update --clear-*`).
783
809
  async policyUpdate(id, patch = {}) {
784
- const body = stripUndefined(patch);
810
+ const body = stripUndefinedKeepNull(patch);
785
811
  const res = await this._rest(`/memories/policies/${encodeURIComponent(id)}`, { method: 'PATCH', body });
786
812
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
787
813
  return { ok: true, policy: res.data };
@@ -796,9 +822,18 @@ class RemoteStore {
796
822
 
797
823
  // POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
798
824
  // candidates a groom() run would archive. Pass either `policy_id` or
799
- // `scope` (+ optional conditions), never both.
800
- async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
801
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
825
+ // `scope` (+ optional conditions, including the eight dimension filters —
826
+ // see policyCreate's comment), never both.
827
+ async groomPreview({
828
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
829
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
830
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
831
+ } = {}) {
832
+ const body = stripUndefined({
833
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
834
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
835
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
836
+ });
802
837
  const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
803
838
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
804
839
  return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
@@ -806,8 +841,16 @@ class RemoteStore {
806
841
 
807
842
  // POST /groom/run → archives every previewed candidate, in one transaction.
808
843
  // Soft-archive only (recoverable via restore); never hard-deletes.
809
- async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
810
- const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
844
+ async groomRun({
845
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
846
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
847
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
848
+ } = {}) {
849
+ const body = stripUndefined({
850
+ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count,
851
+ tags, tags_mode, source_agent, source_agent_mode, trigger, trigger_mode, kind, kind_mode, host, host_mode,
852
+ origin_repo, origin_repo_mode, origin_branch, origin_branch_mode, origin_pr, origin_pr_mode,
853
+ });
811
854
  const res = await this._rest('/memories/groom/run', { method: 'POST', body });
812
855
  if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
813
856
  return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };