@lorekit/cli 1.66.0 → 1.68.0
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/bin/lorekit.mjs +23 -12
- package/package.json +1 -1
- package/skill/lorekit-groom/rules/grooming-pass.md +10 -0
- package/skill/lorekit-memory/SKILL.md +6 -1
- package/skill/lorekit-memory/rules/intake.md +41 -0
- package/skill/lorekit-memory/rules/retrospective.md +28 -1
- package/skill/lorekit-setup/SKILL.md +17 -0
- package/skill/lorekit-setup/rules/self-improvement-loops.md +134 -0
- package/src/commands/groom.mjs +3 -0
- package/src/commands/hook.mjs +5 -1
- package/src/commands/policy.mjs +7 -2
- package/src/commands/write.mjs +30 -0
- package/src/core/lessons.mjs +44 -4
- package/src/shared/completions.mjs +9 -4
- package/src/store/remote.mjs +12 -7
- package/src/surfaces.generated.mjs +31 -0
package/bin/lorekit.mjs
CHANGED
|
@@ -103,7 +103,8 @@ ${c.bold('Commands')}
|
|
|
103
103
|
purge: remote only, account-wide, irreversible, --yes required
|
|
104
104
|
non-interactively. Takes no options.
|
|
105
105
|
groom Preview (default) or --run a retention sweep: --policy-id <id> or
|
|
106
|
-
--scope <s> [+ --min-age-days/--unseen-days/--max-seen-count
|
|
106
|
+
--scope <s> [+ --min-age-days/--unseen-days/--max-seen-count/
|
|
107
|
+
--max-read-count/--max-opened-count].
|
|
107
108
|
Remote only. --run soft-archives matches (recoverable via
|
|
108
109
|
restore); prompts for confirmation, --yes to skip. --json.
|
|
109
110
|
policy Manage saved retention rules: list / create / update / delete.
|
|
@@ -385,6 +386,7 @@ ${c.bold('Options')}
|
|
|
385
386
|
--ttl-days <n> Days until auto-expiry 1–365 (local or remote)
|
|
386
387
|
--clear-ttl Remove any existing expiry (make it permanent)
|
|
387
388
|
--org <slug> Write to this org's scope (remote only)
|
|
389
|
+
--cited <a,b> \`scope::key\` refs this lesson applied (remote only)
|
|
388
390
|
--origin-repo <o/n> Override the derived provenance repository
|
|
389
391
|
--origin-branch <b> Override the derived provenance branch
|
|
390
392
|
--origin-commit <s> Override the derived provenance commit SHA
|
|
@@ -899,7 +901,8 @@ ${c.bold('Options')}
|
|
|
899
901
|
|
|
900
902
|
${c.bold('Usage')}
|
|
901
903
|
lorekit groom --policy-id <id> [--run] [--yes] [--json]
|
|
902
|
-
lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
904
|
+
lorekit groom --scope <s> [--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
905
|
+
[--max-read-count <n>] [--max-opened-count <n>] [--run] [--yes] [--json]
|
|
903
906
|
|
|
904
907
|
Resolves the SAME candidates a saved policy or an inline condition set would
|
|
905
908
|
catch, via the retention-policy candidate query — a previewed count always
|
|
@@ -919,8 +922,11 @@ ${c.bold('Options')}
|
|
|
919
922
|
--min-age-days <n> Match only lessons at least n days old
|
|
920
923
|
--unseen-days <n> Match lessons unseen for at least n days (never-seen always matches)
|
|
921
924
|
--max-seen-count <n> Match only lessons that recurred at most n times (WRITES)
|
|
922
|
-
--max-read-count <n> Match only lessons
|
|
923
|
-
reads count here, unlike --unseen-days
|
|
925
|
+
--max-read-count <n> Match only lessons DELIVERED at most n times. Bulk list/search
|
|
926
|
+
reads count here, unlike --unseen-days, so this runs to
|
|
927
|
+
the hundreds — a small value matches nothing
|
|
928
|
+
--max-opened-count <n> Match only lessons an agent DELIBERATELY fetched at most n
|
|
929
|
+
times. Bulk reads do NOT count, so 0 means never chosen
|
|
924
930
|
--run Archive the matches instead of previewing
|
|
925
931
|
-y, --yes Confirm --run; required when non-interactive
|
|
926
932
|
--json Machine-readable result
|
|
@@ -934,13 +940,14 @@ ${c.bold('Usage')}
|
|
|
934
940
|
lorekit policy list [--json]
|
|
935
941
|
lorekit policy create --scope <s> --name <n> [--mode review|auto] [--enabled]
|
|
936
942
|
[--min-age-days <n>] [--unseen-days <n>] [--max-seen-count <n>]
|
|
937
|
-
[--max-read-count <n>]
|
|
943
|
+
[--max-read-count <n>] [--max-opened-count <n>]
|
|
938
944
|
lorekit policy update <id> [--name <n>] [--mode review|auto] [--enabled|--disabled]
|
|
939
945
|
[--min-age-days <n>|--clear-min-age-days] [...] [--json]
|
|
940
946
|
lorekit policy delete <id> [--yes] [--json]
|
|
941
947
|
|
|
942
948
|
A policy is a saved retention rule: a scope plus AND-ed conditions
|
|
943
|
-
(min-age-days / unseen-days / max-seen-count / max-read-count).
|
|
949
|
+
(min-age-days / unseen-days / max-seen-count / max-read-count / max-opened-count).
|
|
950
|
+
\`mode: review\` surfaces it for
|
|
944
951
|
you to run by hand with ${c.cyan('lorekit groom --policy-id')}; \`mode: auto\` gets swept
|
|
945
952
|
nightly, but ONLY once you also pass --enabled — auto starts disabled on
|
|
946
953
|
every new policy so a saved rule never archives anything unattended.
|
|
@@ -952,9 +959,11 @@ ${c.bold('Options')}
|
|
|
952
959
|
--name <n> Policy name (create) / new name (update)
|
|
953
960
|
--mode <review|auto> Match mode (create/update)
|
|
954
961
|
--enabled / --disabled Turn auto-mode on/off (create/update)
|
|
955
|
-
--min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n
|
|
962
|
+
--min-age-days <n>, --unseen-days <n>, --max-seen-count <n>, --max-read-count <n>,
|
|
963
|
+
--max-opened-count <n>
|
|
956
964
|
Conditions (create/update)
|
|
957
|
-
--clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
|
|
965
|
+
--clear-min-age-days, --clear-unseen-days, --clear-max-seen-count,
|
|
966
|
+
--clear-max-read-count, --clear-max-opened-count
|
|
958
967
|
Remove a condition (update only)
|
|
959
968
|
-y, --yes Confirm delete; required when non-interactive
|
|
960
969
|
--json Machine-readable result
|
|
@@ -1059,7 +1068,8 @@ const KNOWN_FLAGS = [
|
|
|
1059
1068
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
1060
1069
|
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'completions', 'complete', 'force', 'deep', 'adapter',
|
|
1061
1070
|
'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
|
|
1062
|
-
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', '
|
|
1071
|
+
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'cited',
|
|
1072
|
+
'remote', 'local',
|
|
1063
1073
|
// `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
|
|
1064
1074
|
// scope/time tab so the flag is a no-op, but `link` is a HUMAN_COMMAND that
|
|
1065
1075
|
// rejects UNKNOWN options — so keeping `view` listed for a release stops an
|
|
@@ -1074,9 +1084,10 @@ const KNOWN_FLAGS = [
|
|
|
1074
1084
|
// `invariants candidates`
|
|
1075
1085
|
'min-seen-count',
|
|
1076
1086
|
// groom / policy / protect / pin / unpin
|
|
1077
|
-
'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'run',
|
|
1087
|
+
'policy-id', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'max-opened-count', 'run',
|
|
1078
1088
|
'name', 'mode', 'enabled', 'disabled',
|
|
1079
|
-
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
1089
|
+
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
1090
|
+
'clear-max-opened-count', 'off',
|
|
1080
1091
|
// `obligations`
|
|
1081
1092
|
'files', 'strict', 'strict-all',
|
|
1082
1093
|
];
|
|
@@ -1091,7 +1102,7 @@ async function main() {
|
|
|
1091
1102
|
const argv = process.argv.slice(2);
|
|
1092
1103
|
const args = parseArgs(argv, {
|
|
1093
1104
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
1094
|
-
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', 'strict', 'strict-all'],
|
|
1105
|
+
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'],
|
|
1095
1106
|
known: KNOWN_FLAGS,
|
|
1096
1107
|
});
|
|
1097
1108
|
|
package/package.json
CHANGED
|
@@ -175,3 +175,13 @@ new count. Then either move to the next scope or close out the pass.
|
|
|
175
175
|
- **Someone else's lesson deserves more caution.** A lesson tagged from another
|
|
176
176
|
agent or a teammate is not automatically yours to delete — when in doubt,
|
|
177
177
|
archive rather than hard-delete, and flag it in the plan.
|
|
178
|
+
- **`codebase-knowledge` is durable structure, not an audit log.** The shared
|
|
179
|
+
`codebase-knowledge` bucket (tag `codebase-knowledge`, `knowledge::<symbol>@<path>`
|
|
180
|
+
and `hotspot::<path>` keys — see the `lorekit-setup` skill) is a live,
|
|
181
|
+
multi-loop signal store: every LoreKit loop in the repo both reads and feeds it.
|
|
182
|
+
Groom it like a signal store, never like the audit-log prose it superficially
|
|
183
|
+
resembles. Lint its structure and dedupe genuine key collisions, but do **not**
|
|
184
|
+
mass-archive by age or volume, and do **not** re-key a `knowledge::<symbol>@<path>`
|
|
185
|
+
entry off its structural key — that key is the join a reader matches against.
|
|
186
|
+
A stale fact carries `verified_at_sha`; prefer letting its TTL expire or the
|
|
187
|
+
owning loop overwrite it over hand-removing it.
|
|
@@ -75,7 +75,12 @@ Read at the moments where prior lessons change what you do:
|
|
|
75
75
|
|
|
76
76
|
Follow [rules/intake.md](./rules/intake.md).
|
|
77
77
|
The short version: resolve the current scope, list lessons narrow-to-broad,
|
|
78
|
-
and treat matches as *considerations*, not commands.
|
|
78
|
+
and treat matches as *considerations*, not commands. When a run is about to
|
|
79
|
+
change code, intake step 6 reads the standard shared **`codebase-knowledge`**
|
|
80
|
+
bucket — the cross-loop `hotspot::<path>` / `knowledge::<symbol>@<path>` record
|
|
81
|
+
for the files this run will touch, matched by `symbol@path`. A run that verifies
|
|
82
|
+
a structural fact contributes it back, so the layer compounds across every
|
|
83
|
+
LoreKit loop in the repo (contract in the `lorekit-setup` skill).
|
|
79
84
|
|
|
80
85
|
## When to write (retrospective)
|
|
81
86
|
|
|
@@ -59,3 +59,44 @@ If lessons matched, note them in one or two lines before proceeding
|
|
|
59
59
|
("LoreKit: 2 relevant lessons — worktree naming, migration order").
|
|
60
60
|
If nothing matched, say nothing and continue.
|
|
61
61
|
If the MCP tools are not connected, note it once and continue without them.
|
|
62
|
+
|
|
63
|
+
## 6. When the run will change code — read the shared codebase-knowledge
|
|
64
|
+
|
|
65
|
+
Steps 1–4 read **your own** bucket. When this run is about to change code, do one
|
|
66
|
+
more read — not optional, it is how the cross-loop synergy reaches you: the shared
|
|
67
|
+
**`codebase-knowledge`** bucket. This is the repo-scoped, cross-loop record of what
|
|
68
|
+
the codebase has taught every LoreKit loop that touched it
|
|
69
|
+
(`knowledge::<symbol>@<path>` facts, `hotspot::<path>` counters). Its keys are
|
|
70
|
+
**structural** (`symbol@path`, a file path) rather than prose, so you match them
|
|
71
|
+
against the concrete files and symbols this run will touch and pull only the
|
|
72
|
+
records that apply. Read it for exactly the files you will change:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
memory.list { scope: "repo::{owner}/{repo}", tags: ["codebase-knowledge"], limit: 100 }
|
|
76
|
+
# keep only hotspot::<path> / knowledge::<symbol>@<path> whose <path> (and <symbol>)
|
|
77
|
+
# the current change will actually touch — a known regression hotspot or a
|
|
78
|
+
# repeatedly-flagged symbol is then planned with that history in hand.
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If this run **verifies** a structural fact about a symbol or file (a consumer
|
|
82
|
+
count you swept, an invariant you confirmed, a defect you fixed at a SHA),
|
|
83
|
+
contribute it back so the next code-changer benefits — write it to
|
|
84
|
+
`codebase-knowledge` under the write contract in the `lorekit-setup` skill,
|
|
85
|
+
`rules/self-improvement-loops.md § Shared codebase-knowledge`. The layer fills
|
|
86
|
+
because its readers also feed it.
|
|
87
|
+
|
|
88
|
+
Four rules make this safe, and they are non-negotiable:
|
|
89
|
+
|
|
90
|
+
- Match **structurally and narrowly** — the paths/symbols of this run, never the
|
|
91
|
+
whole bucket.
|
|
92
|
+
- It **raises care, never lowers a bar** — plan more coverage on a hotspot; an
|
|
93
|
+
absent record is not evidence of safety.
|
|
94
|
+
- A fact may be **stale** (it carries the writer's `verified_at_sha`) — treat it
|
|
95
|
+
as a consideration and re-verify against the code.
|
|
96
|
+
- **Read-only.** Never write another host's bucket; write ownership stays with
|
|
97
|
+
its one owner.
|
|
98
|
+
|
|
99
|
+
Do **not** cross-read another host's `loop::<host>-lessons` — those are prose
|
|
100
|
+
advice with no structural key to match on. The full contract and when to wire a
|
|
101
|
+
cross-read into a host is the `lorekit-setup` skill,
|
|
102
|
+
`rules/self-improvement-loops.md § Cross-bucket reads (targeted, read-only)`.
|
|
@@ -14,6 +14,13 @@ Ask the 30-second question:
|
|
|
14
14
|
If no, stop — write nothing. Empty retrospectives are skipped.
|
|
15
15
|
If yes, continue.
|
|
16
16
|
|
|
17
|
+
> A retrospective records **prose advice** ("last time X went wrong when Y"). A
|
|
18
|
+
> *structural fact* this run verified about a symbol or file — a consumer count
|
|
19
|
+
> you swept, an invariant you confirmed, a defect you fixed at a SHA — is a
|
|
20
|
+
> different thing: it belongs in the shared **`codebase-knowledge`** bucket under
|
|
21
|
+
> a `knowledge::<symbol>@<path>` key, not here. See the intake step 6 write-back
|
|
22
|
+
> note and the `lorekit-setup` skill, `§ Shared codebase-knowledge`.
|
|
23
|
+
|
|
17
24
|
## 2. Phrase it as an observation
|
|
18
25
|
|
|
19
26
|
Write what happened and what worked, not a commandment.
|
|
@@ -69,7 +76,8 @@ memory.write {
|
|
|
69
76
|
value: "<observation in markdown>",
|
|
70
77
|
tags: ["skill::lorekit-memory", "source::stuck-loop"],
|
|
71
78
|
source_agent: "<your agent name, if known>",
|
|
72
|
-
trigger: "stuck-loop"
|
|
79
|
+
trigger: "stuck-loop",
|
|
80
|
+
cited: ["global::pnpm-first"]
|
|
73
81
|
}
|
|
74
82
|
```
|
|
75
83
|
|
|
@@ -77,6 +85,25 @@ Pick `trigger` / `source::*` from what actually happened:
|
|
|
77
85
|
`stuck-loop`, `command-failure`, `gotcha`, `near-miss`, `assumption-wrong`,
|
|
78
86
|
`paid-off`, or `manual`.
|
|
79
87
|
|
|
88
|
+
## 6b. Credit the lessons you actually applied
|
|
89
|
+
|
|
90
|
+
`cited` is a list of `scope::key` references to lessons that **shaped this
|
|
91
|
+
turn** — ones you were shown at session start or looked up, and then acted on.
|
|
92
|
+
Omit it when nothing applied.
|
|
93
|
+
|
|
94
|
+
This is the only signal that distinguishes a lesson that was *used* from one
|
|
95
|
+
that was merely *shown*. LoreKit can count deliveries and deliberate fetches on
|
|
96
|
+
its own, but a lesson injected at session start is already in your context and
|
|
97
|
+
gets applied without ever being fetched again — so nothing but you can report it.
|
|
98
|
+
|
|
99
|
+
- **Name only what you used.** Citing everything you were shown makes the
|
|
100
|
+
counter measure delivery again, which it already does.
|
|
101
|
+
- **A wrong guess costs nothing.** A reference that does not resolve is dropped
|
|
102
|
+
silently, and a lesson cannot cite itself. Naming a lesson you are unsure of
|
|
103
|
+
is cheaper than omitting one you relied on.
|
|
104
|
+
- **Copy the ref exactly as it was shown to you** (`global::pnpm-first`,
|
|
105
|
+
`repo::acme/app::migration-order`). Do not reconstruct it from memory.
|
|
106
|
+
|
|
80
107
|
## 7. Confirm
|
|
81
108
|
|
|
82
109
|
State in one line what you recorded and where
|
|
@@ -93,6 +93,23 @@ It covers: when to add a loop (and when not to), the bucket convention (tag
|
|
|
93
93
|
read/write steps, the promotion gate, the entrenchment guards, a wiring
|
|
94
94
|
checklist, and an interactive setup flow.
|
|
95
95
|
|
|
96
|
+
## The shared codebase-knowledge layer (automatic cross-loop synergy)
|
|
97
|
+
|
|
98
|
+
A per-host lessons bucket is private to one host. There is also **one shared
|
|
99
|
+
bucket every code-touching host reads and, under a contract, writes**:
|
|
100
|
+
`codebase-knowledge` — a repo-scoped, structurally-keyed record
|
|
101
|
+
(`knowledge::<symbol>@<path>` facts, `hotspot::<path>` counters) of what the
|
|
102
|
+
codebase has taught every LoreKit loop that touched it. Because the name is fixed
|
|
103
|
+
and the key is structural, a loop wired by one person compounds with a loop wired
|
|
104
|
+
by another: a host about to change code reads the history for exactly the files it
|
|
105
|
+
will touch, and a host that verifies a structural fact contributes it back. That
|
|
106
|
+
is the synergy that appears for a user who wired a single skill and nothing else.
|
|
107
|
+
|
|
108
|
+
Wire it whenever a host changes code (read at its plan/apply seam) or verifies a
|
|
109
|
+
durable structural fact (write under the contract). The full specification — the
|
|
110
|
+
bucket table, the automatic read side, and the seven-bullet multi-writer write
|
|
111
|
+
contract — is [rules/self-improvement-loops.md § Shared codebase-knowledge](./rules/self-improvement-loops.md#shared-codebase-knowledge-the-standard-cross-loop-layer).
|
|
112
|
+
|
|
96
113
|
## Set up CI state (deterministic hosts)
|
|
97
114
|
|
|
98
115
|
Follow [rules/ci-state-records.md](./rules/ci-state-records.md). It covers: when
|
|
@@ -21,6 +21,8 @@ The design has two tiers connected by a recurrence gate. Both run on LoreKit.
|
|
|
21
21
|
- [Read step (start of every run)](#read-step-start-of-every-run)
|
|
22
22
|
- [Write step (on failure / at the end of a run)](#write-step-on-failure--at-the-end-of-a-run)
|
|
23
23
|
- [The reconcile-on-re-run flow (resolve + record)](#the-reconcile-on-re-run-flow-resolve--record)
|
|
24
|
+
- [Cross-bucket reads (targeted, read-only)](#cross-bucket-reads-targeted-read-only)
|
|
25
|
+
- [Shared codebase-knowledge (the standard cross-loop layer)](#shared-codebase-knowledge-the-standard-cross-loop-layer)
|
|
24
26
|
- [Promotion (fast → slow)](#promotion-fast--slow)
|
|
25
27
|
- [Entrenchment guards (do not skip these)](#entrenchment-guards-do-not-skip-these)
|
|
26
28
|
- [Wiring checklist](#wiring-checklist)
|
|
@@ -247,6 +249,134 @@ record shape are specified in `agents/shared/rules/comment-relevance-memory.md`.
|
|
|
247
249
|
|
|
248
250
|
---
|
|
249
251
|
|
|
252
|
+
## Cross-bucket reads (targeted, read-only)
|
|
253
|
+
|
|
254
|
+
The default is strict: a loop reads **only its own bucket**, filtered by
|
|
255
|
+
`loop::<host>-lessons`. That isolation is deliberate — it keeps one loop's
|
|
256
|
+
lessons from drowning another's, and lets each read fire at its own cadence
|
|
257
|
+
against its own decision point. Wholesale "read every lesson this repo knows"
|
|
258
|
+
is an **anti-pattern**: it reintroduces exactly the noise the tag split exists
|
|
259
|
+
to prevent, and buries the matches that would have fired.
|
|
260
|
+
|
|
261
|
+
There is **one** shape of cross-bucket read that is safe and worth wiring: a host
|
|
262
|
+
reading **another host's Signal or Knowledge bucket, matched by a structural key,
|
|
263
|
+
strictly read-only**. Wire it only when all four hold:
|
|
264
|
+
|
|
265
|
+
1. **The other bucket is keyed by something structural** — a `symbol@path`, a
|
|
266
|
+
file path, a stable fingerprint — never by prose. A structural key is what
|
|
267
|
+
makes a cross-host read meaningful: it matches the reader's own concrete work
|
|
268
|
+
(the files or symbols it is about to touch), not a vague topic.
|
|
269
|
+
2. **The read is bounded to the reader's current work.** Match the other bucket's
|
|
270
|
+
keys against the paths / symbols this run will actually touch and ignore the
|
|
271
|
+
rest — never load the whole bucket as advice.
|
|
272
|
+
3. **The reader treats it as advisory and re-verifies.** A cross-host fact can be
|
|
273
|
+
stale — it carries the *writer's* `verified_at_sha`, not the reader's. It
|
|
274
|
+
**raises care** (more coverage on a hotspot, design around a known invariant)
|
|
275
|
+
but never lowers a bar, skips a step, or suppresses a finding. An absent
|
|
276
|
+
record is never evidence of safety.
|
|
277
|
+
4. **The reader never writes the other bucket.** Write ownership stays with the
|
|
278
|
+
one owning host; a second writer corrupts its provenance. Cross-host is a
|
|
279
|
+
**read** relationship only.
|
|
280
|
+
|
|
281
|
+
Do **not** cross-read another host's `loop::<host>-lessons`. Lessons are prose
|
|
282
|
+
"how to do better" advice tuned to that host's own decisions; they re-key on
|
|
283
|
+
rephrasing and carry no structural anchor to match against, so a cross-read of
|
|
284
|
+
them is the wholesale anti-pattern above. Only Signal / Knowledge buckets with
|
|
285
|
+
structural keys qualify.
|
|
286
|
+
|
|
287
|
+
LoreKit ships **one** standard instance of this pattern — the shared
|
|
288
|
+
`codebase-knowledge` bucket that every code-touching loop reads and writes. It is
|
|
289
|
+
the mechanism behind the automatic synergy below, and it is what makes a
|
|
290
|
+
structural key worth insisting on: a fixed name plus a `symbol@path` key is what
|
|
291
|
+
lets a loop wired by one person be consumed by a loop wired by another. It is
|
|
292
|
+
specified in full next.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Shared codebase-knowledge (the standard cross-loop layer)
|
|
297
|
+
|
|
298
|
+
The cross-bucket read above becomes **automatic** through one bucket every LoreKit
|
|
299
|
+
loop shares by name: `codebase-knowledge`. This is the reason two skills wired
|
|
300
|
+
independently — by different people, in different sessions, in the same repo —
|
|
301
|
+
still compound: they read and write the *same* repo-scoped, structurally-keyed
|
|
302
|
+
record of what the codebase has taught every loop that touched it. A code-changing
|
|
303
|
+
loop plans and edits with that history in hand instead of blind; and because the
|
|
304
|
+
loops that consume it also feed it, the synergy appears for a user who wired a
|
|
305
|
+
single skill and nothing else.
|
|
306
|
+
|
|
307
|
+
### The bucket
|
|
308
|
+
|
|
309
|
+
| Field | Value |
|
|
310
|
+
| --- | --- |
|
|
311
|
+
| **Tag** | `codebase-knowledge` |
|
|
312
|
+
| **Kind** | `signal` (a durable per-repo filter, read on every run that touches code) |
|
|
313
|
+
| **Scope** | `repo::{owner}/{repo}` — a codebase fact is repo-bound |
|
|
314
|
+
| **TTL** | ~90 days, refreshed on re-verification |
|
|
315
|
+
| **Keys** | `knowledge::<symbol>@<path>` — verified facts about one symbol (an invariant it holds, its consumer/dependent count, a defect it produced before); `hotspot::<path>` — per-file counters (`confirmed`, `regressed`, `missed`) |
|
|
316
|
+
|
|
317
|
+
The keys are **structural** (`symbol@path`, `path`) on purpose: a key survives a
|
|
318
|
+
rename of the *finding* but not a rename of the *code*, which is exactly the
|
|
319
|
+
sensitivity that lets a different loop match it against the files it is about to
|
|
320
|
+
touch. Set `kind: signal` and `host` explicitly on every write — LoreKit infers
|
|
321
|
+
them only from a `loop::` tag, and this bucket is not tagged that way.
|
|
322
|
+
|
|
323
|
+
### Read side — automatic for any code-touching host
|
|
324
|
+
|
|
325
|
+
Wire it at the host's **plan/apply seam** — the moment it has the concrete
|
|
326
|
+
file/symbol list it will change (a plan's File Changes list, an apply pack, a
|
|
327
|
+
fix's target file):
|
|
328
|
+
|
|
329
|
+
```text
|
|
330
|
+
memory.list { scope: "repo::{owner}/{repo}", tags: ["codebase-knowledge"], limit: 100 }
|
|
331
|
+
# keep only hotspot::<path> / knowledge::<symbol>@<path> whose <path> (and <symbol>)
|
|
332
|
+
# this run will actually touch. Apply as PLANNING INPUTS: raise coverage on a
|
|
333
|
+
# hotspot, design around a known invariant / consumer count. Advisory and
|
|
334
|
+
# re-verified against the code — never a reason to skip a step or suppress a finding.
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
This is the read-side contract from [Cross-bucket reads](#cross-bucket-reads-targeted-read-only)
|
|
338
|
+
made concrete: structural match, bounded to this run, advisory, an absent record
|
|
339
|
+
never evidence of safety.
|
|
340
|
+
|
|
341
|
+
### Write side — how the layer fills, and why many writers stay safe
|
|
342
|
+
|
|
343
|
+
A host that **verifies** a structural fact contributes it back, so the next loop
|
|
344
|
+
reads it. This is what makes the synergy automatic even for a user with one skill
|
|
345
|
+
and no dedicated reviewer: the loops that consume the layer also feed it.
|
|
346
|
+
Multi-writer is safe **only** behind this write contract — bake in every bullet,
|
|
347
|
+
or do not wire the write:
|
|
348
|
+
|
|
349
|
+
- **Structural key from a real symbol/path list**, never composed from prose. A
|
|
350
|
+
prose key accumulates nothing and no reader can match it.
|
|
351
|
+
- **`verified_at_sha` on every fact** — the HEAD this run verified it at. It is the
|
|
352
|
+
whole mechanism the next reader uses to decide "fact stands" vs "re-verify"; an
|
|
353
|
+
absent or stale SHA makes the fact permanently unverifiable, and it is dropped.
|
|
354
|
+
- **`source_agent` stamped** — which host verified it. Together with
|
|
355
|
+
`verified_at_sha` this is what makes many writers safe: a reader sees who
|
|
356
|
+
verified what, and when, so no writer silently overwrites another's provenance.
|
|
357
|
+
- **Only what THIS run actually verified**, grounded in the code — never a guess,
|
|
358
|
+
and never a value about a person or a telemetry reading. A fact about code,
|
|
359
|
+
keyed to code.
|
|
360
|
+
- **Merge, never clobber.** Read the existing record first; append to `history[]`
|
|
361
|
+
or increment counters (each capped) and carry the rest through unchanged. A
|
|
362
|
+
clobbered counter is indistinguishable from a first write.
|
|
363
|
+
- **Raise care, never suppress.** These records only raise priority/coverage on a
|
|
364
|
+
file or symbol. They never lower a bar or silence a finding, and an absent record
|
|
365
|
+
is never evidence of safety. Suppression, if a host needs it, is a different
|
|
366
|
+
bucket behind verification (the Signal in the reconcile flow above).
|
|
367
|
+
- **Explicit `kind: signal` + `host`, a TTL, and the privacy pre-flight** — as
|
|
368
|
+
every write in this skill.
|
|
369
|
+
|
|
370
|
+
Because the name is fixed, the read is the same call in every host, and the write
|
|
371
|
+
follows one contract, **any two LoreKit-wired loops in the same repo compound
|
|
372
|
+
automatically** — which is the whole reason to standardize the name instead of
|
|
373
|
+
letting each host invent its own. The reference ecosystem is `agent-skills`: the
|
|
374
|
+
`pr-reviewer` agent is the primary writer (it verifies symbol facts and file
|
|
375
|
+
hotspots during review), and every code-changing host — `aw`, `implement-suggestion`,
|
|
376
|
+
`fix-bug`, `ci-auto-fix` — reads the layer at its plan/apply seam.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
250
380
|
## Promotion (fast → slow)
|
|
251
381
|
|
|
252
382
|
After a read or write, a lesson is **promotion-eligible** when either:
|
|
@@ -316,6 +446,10 @@ To add a loop to a host called `<host>`:
|
|
|
316
446
|
- [ ] State the **entrenchment guards** so a future maintainer does not "optimize
|
|
317
447
|
them away".
|
|
318
448
|
- [ ] Confirm the loop **degrades silently** when `memory.*` is not connected.
|
|
449
|
+
- [ ] If the host **touches code**, wire the **[codebase-knowledge](#shared-codebase-knowledge-the-standard-cross-loop-layer)
|
|
450
|
+
read** at its plan/apply seam (match `hotspot::<path>` /
|
|
451
|
+
`knowledge::<symbol>@<path>` to the files it will change). If it **verifies**
|
|
452
|
+
a structural fact, wire the **write** behind that section's contract.
|
|
319
453
|
|
|
320
454
|
---
|
|
321
455
|
|
package/src/commands/groom.mjs
CHANGED
|
@@ -31,6 +31,8 @@ export function parseGroomRequest(args) {
|
|
|
31
31
|
if (maxSeen.error) return { error: maxSeen.error };
|
|
32
32
|
const maxRead = parseIntFlag(args['max-read-count'], 'max-read-count');
|
|
33
33
|
if (maxRead.error) return { error: maxRead.error };
|
|
34
|
+
const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
|
|
35
|
+
if (maxOpened.error) return { error: maxOpened.error };
|
|
34
36
|
|
|
35
37
|
return {
|
|
36
38
|
request: {
|
|
@@ -39,6 +41,7 @@ export function parseGroomRequest(args) {
|
|
|
39
41
|
unseen_days: unseen.value,
|
|
40
42
|
max_seen_count: maxSeen.value,
|
|
41
43
|
max_read_count: maxRead.value,
|
|
44
|
+
max_opened_count: maxOpened.value,
|
|
42
45
|
},
|
|
43
46
|
};
|
|
44
47
|
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -333,7 +333,11 @@ async function run(args, meter) {
|
|
|
333
333
|
}
|
|
334
334
|
if (!shouldRetrospect(stopMode, friction)) return 0;
|
|
335
335
|
if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
|
|
336
|
-
|
|
336
|
+
// The shown set is what BOTH injection paths recorded this session, so the
|
|
337
|
+
// citation ask names the actual candidates rather than a convention. Read
|
|
338
|
+
// here and not earlier: every return above this line emits nothing, and the
|
|
339
|
+
// read is a file the throttled path should not pay for.
|
|
340
|
+
emit(retrospectiveNudge(scope, control, { reasons, shown: [...shownLessons(parsed.sessionId)] }));
|
|
337
341
|
return 0;
|
|
338
342
|
}
|
|
339
343
|
|
package/src/commands/policy.mjs
CHANGED
|
@@ -31,6 +31,7 @@ function formatPolicy(p) {
|
|
|
31
31
|
if (p.unseen_days != null) conditions.push(`unseen_days=${p.unseen_days}`);
|
|
32
32
|
if (p.max_seen_count != null) conditions.push(`max_seen_count=${p.max_seen_count}`);
|
|
33
33
|
if (p.max_read_count != null) conditions.push(`max_read_count=${p.max_read_count}`);
|
|
34
|
+
if (p.max_opened_count != null) conditions.push(`max_opened_count=${p.max_opened_count}`);
|
|
34
35
|
const mode = p.mode === 'auto' ? (p.enabled ? c.green('auto (enabled)') : c.dim('auto (disabled)')) : c.dim('review');
|
|
35
36
|
return `${c.cyan(p.id)} ${c.bold(p.name)} ${c.dim(p.scope)} ${mode}${conditions.length ? ` ${c.dim(conditions.join(', '))}` : ''}`;
|
|
36
37
|
}
|
|
@@ -51,7 +52,7 @@ async function list(args, store) {
|
|
|
51
52
|
|
|
52
53
|
async function create(args, store) {
|
|
53
54
|
if (!args.scope || !args.name) {
|
|
54
|
-
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]`);
|
|
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]`);
|
|
55
56
|
return 1;
|
|
56
57
|
}
|
|
57
58
|
const minAge = parseIntFlag(args['min-age-days'], 'min-age-days');
|
|
@@ -62,6 +63,8 @@ async function create(args, store) {
|
|
|
62
63
|
if (maxSeen.error) { err(`${c.red('Error:')} ${maxSeen.error}`); return 1; }
|
|
63
64
|
const maxRead = parseIntFlag(args['max-read-count'], 'max-read-count');
|
|
64
65
|
if (maxRead.error) { err(`${c.red('Error:')} ${maxRead.error}`); return 1; }
|
|
66
|
+
const maxOpened = parseIntFlag(args['max-opened-count'], 'max-opened-count');
|
|
67
|
+
if (maxOpened.error) { err(`${c.red('Error:')} ${maxOpened.error}`); return 1; }
|
|
65
68
|
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
66
69
|
err(`${c.red('Error:')} --mode must be "review" or "auto"`);
|
|
67
70
|
return 1;
|
|
@@ -76,6 +79,7 @@ async function create(args, store) {
|
|
|
76
79
|
unseen_days: unseen.value,
|
|
77
80
|
max_seen_count: maxSeen.value,
|
|
78
81
|
max_read_count: maxRead.value,
|
|
82
|
+
max_opened_count: maxOpened.value,
|
|
79
83
|
});
|
|
80
84
|
if (!res.ok) {
|
|
81
85
|
const msg = res.error?.message ?? res.error ?? res.networkError ?? 'the server rejected the request';
|
|
@@ -91,7 +95,7 @@ async function create(args, store) {
|
|
|
91
95
|
async function update(args, store) {
|
|
92
96
|
const id = args._[2];
|
|
93
97
|
if (!id) {
|
|
94
|
-
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]`);
|
|
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]`);
|
|
95
99
|
return 1;
|
|
96
100
|
}
|
|
97
101
|
if (args.mode !== undefined && args.mode !== 'review' && args.mode !== 'auto') {
|
|
@@ -109,6 +113,7 @@ async function update(args, store) {
|
|
|
109
113
|
['unseen-days', 'clear-unseen-days', 'unseen_days'],
|
|
110
114
|
['max-seen-count', 'clear-max-seen-count', 'max_seen_count'],
|
|
111
115
|
['max-read-count', 'clear-max-read-count', 'max_read_count'],
|
|
116
|
+
['max-opened-count', 'clear-max-opened-count', 'max_opened_count'],
|
|
112
117
|
]) {
|
|
113
118
|
if (args[clearFlag]) { patch[field] = null; continue; }
|
|
114
119
|
if (args[flag] === undefined) continue;
|
package/src/commands/write.mjs
CHANGED
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
// omitted, a configured default may apply — see below.
|
|
30
30
|
// --clear-ttl Remove any existing expiry (make the memory permanent)
|
|
31
31
|
// --org <slug> Write to this org (remote only)
|
|
32
|
+
// --cited <ref,ref> `scope::key` refs this lesson APPLIED (remote only)
|
|
33
|
+
//
|
|
34
|
+
// `--cited` credits the lore that shaped the work being written up. It is the
|
|
35
|
+
// only signal that distinguishes a lesson that was read from one that was used:
|
|
36
|
+
// a lesson injected at SessionStart is already in context and is applied without
|
|
37
|
+
// ever being fetched, so the delivered/chosen ratio cannot see it. Unresolvable
|
|
38
|
+
// refs cost nothing — the server drops them — so naming a lesson you are unsure
|
|
39
|
+
// of is cheaper than omitting one you relied on.
|
|
32
40
|
//
|
|
33
41
|
// Provenance — where the lesson is being recorded FROM. Derived automatically
|
|
34
42
|
// from git + the CI environment (repo, branch, commit, and the pull request
|
|
@@ -222,6 +230,16 @@ export async function write(args) {
|
|
|
222
230
|
|
|
223
231
|
const orgSlug = typeof args.org === 'string' ? args.org : undefined;
|
|
224
232
|
|
|
233
|
+
// Split on commas like `--tags`, so there is one list-flag convention. The
|
|
234
|
+
// refs themselves are NOT parsed here: `scope::key` splits on the first `::`
|
|
235
|
+
// whose left half is a legal scope, a rule that lives once in
|
|
236
|
+
// `parseMemoryRef` (mcp-core + its edge mirror) and would become a second,
|
|
237
|
+
// weaker implementation if this seam guessed at it. A ref this command cannot
|
|
238
|
+
// resolve is dropped server-side, silently and by design.
|
|
239
|
+
const cited = args.cited
|
|
240
|
+
? String(args.cited).split(',').map((r) => r.trim()).filter(Boolean)
|
|
241
|
+
: [];
|
|
242
|
+
|
|
225
243
|
// ── Provenance ────────────────────────────────────────────────────────────
|
|
226
244
|
// Derived from git + CI unless --no-origin; explicit --origin-* flags win.
|
|
227
245
|
// A field that is neither supplied nor derivable is omitted, never sent as
|
|
@@ -300,6 +318,16 @@ export async function write(args) {
|
|
|
300
318
|
return 1;
|
|
301
319
|
}
|
|
302
320
|
|
|
321
|
+
// The offline store has no citation ledger — migration 00107's table lives in
|
|
322
|
+
// Postgres — so a local write would accept the flag and record nothing. Named
|
|
323
|
+
// rather than dropped: a credit that silently went nowhere is invisible in
|
|
324
|
+
// exactly the place the whole signal exists to be visible.
|
|
325
|
+
if (cited.length && storeName === 'local') {
|
|
326
|
+
err(`${c.red('Error:')} --cited is remote-only (the offline store records no citations)`);
|
|
327
|
+
err(`Re-run with ${c.cyan('--remote')}, or drop --cited to write the lesson locally.`);
|
|
328
|
+
return 1;
|
|
329
|
+
}
|
|
330
|
+
|
|
303
331
|
// ── Write ──────────────────────────────────────────────────────────────────
|
|
304
332
|
const writeArgs = {
|
|
305
333
|
scope,
|
|
@@ -313,6 +341,7 @@ export async function write(args) {
|
|
|
313
341
|
...(ttlDays ? { ttl_days: ttlDays } : {}),
|
|
314
342
|
...(clearTtl ? { clear_ttl: true } : {}),
|
|
315
343
|
...(orgSlug ? { org: orgSlug } : {}),
|
|
344
|
+
...(cited.length ? { cited } : {}),
|
|
316
345
|
...origin,
|
|
317
346
|
};
|
|
318
347
|
|
|
@@ -346,6 +375,7 @@ export async function write(args) {
|
|
|
346
375
|
trigger: trigger || null,
|
|
347
376
|
ttl_days: reportedTtlDays,
|
|
348
377
|
ttl_source: reportedTtlSource,
|
|
378
|
+
cited,
|
|
349
379
|
origin,
|
|
350
380
|
}, null, 2));
|
|
351
381
|
} else {
|
package/src/core/lessons.mjs
CHANGED
|
@@ -828,6 +828,43 @@ function ttlHint(writeScope, control) {
|
|
|
828
828
|
return ` Set ttl_days: ${days} (this scope's configured default) unless the lesson is durable enough to keep forever.`;
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
// How many injected lesson refs the citation hint names before it summarises the
|
|
832
|
+
// rest. The nudge competes for the same context the lessons themselves occupy, so
|
|
833
|
+
// the list is a prompt for recall, not an inventory — an agent that applied a
|
|
834
|
+
// lesson beyond the cap can still name it, because `cited` takes any ref.
|
|
835
|
+
export const CITED_HINT_MAX = 8;
|
|
836
|
+
|
|
837
|
+
// Build the citation clause appended to the retrospective nudge, from the
|
|
838
|
+
// `scope::key` ids this session has already injected (core/state.mjs's shown
|
|
839
|
+
// set). Empty string when nothing was injected — with no candidates the ask
|
|
840
|
+
// would be an instruction to invent references.
|
|
841
|
+
//
|
|
842
|
+
// WHY THE ASK EXISTS. `opened_count / read_count` measures whether a lesson was
|
|
843
|
+
// deliberately FETCHED, and a lesson injected at SessionStart is already in
|
|
844
|
+
// context and gets applied without ever being fetched. So the ratio under-counts
|
|
845
|
+
// the dominant delivery path by construction, and only the agent knows the
|
|
846
|
+
// difference. `cited` (migration 00107) is where it says so.
|
|
847
|
+
//
|
|
848
|
+
// The ids are taken in the store's own order, which is injection order, so the
|
|
849
|
+
// SessionStart set leads — it is both the largest and the one in context for the
|
|
850
|
+
// whole turn. Taking the newest instead would favour a per-prompt injection that
|
|
851
|
+
// arrived seconds before the nudge.
|
|
852
|
+
export function citationHint(shown) {
|
|
853
|
+
const ids = (Array.isArray(shown) ? shown : [...(shown || [])]).filter(
|
|
854
|
+
(id) => typeof id === 'string' && id.length > 0,
|
|
855
|
+
);
|
|
856
|
+
if (ids.length === 0) return '';
|
|
857
|
+
const named = ids.slice(0, CITED_HINT_MAX);
|
|
858
|
+
const rest = ids.length - named.length;
|
|
859
|
+
const more = rest > 0 ? ` (+${rest} more)` : '';
|
|
860
|
+
return (
|
|
861
|
+
` If any injected lesson shaped this turn, name it on the write as` +
|
|
862
|
+
` cited: [${named.map((id) => JSON.stringify(id)).join(', ')}]${more} —` +
|
|
863
|
+
` drop the ones you did not use. LoreKit can see which lessons were delivered,` +
|
|
864
|
+
` never which were applied; this is the only signal that says so.`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
831
868
|
// One-line phrases for the detected friction reason codes (see core/friction.mjs),
|
|
832
869
|
// so the nudge names what happened instead of a generic prompt.
|
|
833
870
|
const REASON_PHRASES = {
|
|
@@ -851,11 +888,14 @@ function describeReasons(reasons) {
|
|
|
851
888
|
// scopeDefaults when the repo/user config defines them. `opts.reasons` is the
|
|
852
889
|
// detected friction reason codes (from core/friction.mjs) when `hooks.stop` is
|
|
853
890
|
// `friction`; when present the nudge names them so the reflection is grounded.
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
|
|
891
|
+
// `opts.shown` is the session's injected `scope::key` set (core/state.mjs), which
|
|
892
|
+
// turns the citation ask into a list the agent can pick from rather than a
|
|
893
|
+
// convention it has to remember; omitting it drops the clause entirely.
|
|
894
|
+
// The lore deep-link lives on the write CONFIRMATION, which is where a link is
|
|
895
|
+
// actually actionable.
|
|
896
|
+
export function retrospectiveNudge(scope, control, { reasons = [], shown = [] } = {}) {
|
|
857
897
|
const writeScope = scope.repoScope || 'global';
|
|
858
|
-
const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}`;
|
|
898
|
+
const hint = `${tagsHint(writeScope, control)}${ttlHint(writeScope, control)}${citationHint(shown)}`;
|
|
859
899
|
const instruction = control && control.hooksInstructions && control.hooksInstructions.Stop
|
|
860
900
|
? `\n\nProject instruction: ${control.hooksInstructions.Stop}` : '';
|
|
861
901
|
const detected = describeReasons(reasons);
|
|
@@ -55,6 +55,7 @@ const FLAG = {
|
|
|
55
55
|
'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' },
|
|
56
56
|
'clear-ttl': { desc: 'Remove any existing expiry' },
|
|
57
57
|
org: { desc: "Write to this org's scope (remote)", arg: 'slug' },
|
|
58
|
+
cited: { desc: 'scope::key refs this lesson applied (remote)', arg: 'a,b' },
|
|
58
59
|
'origin-repo': { desc: 'Override the provenance repository', arg: 'owner/name' },
|
|
59
60
|
'origin-branch': { desc: 'Override the provenance branch', arg: 'branch' },
|
|
60
61
|
'origin-commit': { desc: 'Override the provenance commit', arg: 'sha' },
|
|
@@ -77,7 +78,8 @@ const FLAG = {
|
|
|
77
78
|
'min-age-days': { desc: 'Match lessons at least n days old', arg: 'n' },
|
|
78
79
|
'unseen-days': { desc: 'Match lessons unseen for at least n days', arg: 'n' },
|
|
79
80
|
'max-seen-count': { desc: 'Match lessons that recurred at most n times', arg: 'n' },
|
|
80
|
-
'max-read-count': { desc: 'Match lessons
|
|
81
|
+
'max-read-count': { desc: 'Match lessons delivered at most n times (bulk reads count)', arg: 'n' },
|
|
82
|
+
'max-opened-count': { desc: 'Match lessons deliberately fetched at most n times (0 = never chosen)', arg: 'n' },
|
|
81
83
|
run: { desc: 'Archive the matches instead of previewing' },
|
|
82
84
|
name: { desc: 'Policy name', arg: 'name' },
|
|
83
85
|
enabled: { desc: 'Turn auto-mode on' },
|
|
@@ -86,6 +88,7 @@ const FLAG = {
|
|
|
86
88
|
'clear-unseen-days': { desc: 'Remove the unseen-days condition' },
|
|
87
89
|
'clear-max-seen-count': { desc: 'Remove the max-seen-count condition' },
|
|
88
90
|
'clear-max-read-count': { desc: 'Remove the max-read-count condition' },
|
|
91
|
+
'clear-max-opened-count': { desc: 'Remove the max-opened-count condition' },
|
|
89
92
|
off: { desc: 'Unprotect instead of protect' },
|
|
90
93
|
};
|
|
91
94
|
|
|
@@ -138,7 +141,7 @@ const COMMANDS = [
|
|
|
138
141
|
flags: ['yes', 'endpoint', 'token'] },
|
|
139
142
|
{ name: 'write', summary: 'Create or update a memory', positional: 'address',
|
|
140
143
|
flags: ['dir', 'scope', 'key', 'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl',
|
|
141
|
-
'org', 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin', 'remote', 'local',
|
|
144
|
+
'org', 'cited', 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin', 'remote', 'local',
|
|
142
145
|
'json', 'endpoint', 'token', 'store'] },
|
|
143
146
|
{ name: 'archive', summary: 'Hide a memory without losing it', positional: 'address',
|
|
144
147
|
flags: ['scope', 'key', 'remote', 'local', 'json'] },
|
|
@@ -153,11 +156,13 @@ const COMMANDS = [
|
|
|
153
156
|
flags: ['yes', 'json', 'endpoint', 'token'] },
|
|
154
157
|
{ name: 'groom', summary: 'Preview or run a retention sweep',
|
|
155
158
|
values: { mode: ['review', 'auto'] },
|
|
156
|
-
flags: ['policy-id', 'scope', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', '
|
|
159
|
+
flags: ['policy-id', 'scope', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count', 'max-opened-count',
|
|
160
|
+
'run', 'yes', 'json', 'endpoint', 'token'] },
|
|
157
161
|
{ name: 'policy', summary: 'Manage saved retention rules',
|
|
158
162
|
values: { mode: ['review', 'auto'] },
|
|
159
163
|
flags: ['scope', 'name', 'mode', 'enabled', 'disabled', 'min-age-days', 'unseen-days', 'max-seen-count', 'max-read-count',
|
|
160
|
-
'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
164
|
+
'max-opened-count', 'clear-min-age-days', 'clear-unseen-days', 'clear-max-seen-count', 'clear-max-read-count',
|
|
165
|
+
'clear-max-opened-count', 'yes', 'json', 'endpoint', 'token'] },
|
|
161
166
|
{ name: 'protect', summary: 'Mark a memory protected, excluded from every grooming sweep', positional: 'address',
|
|
162
167
|
flags: ['off', 'scope', 'key', 'json', 'endpoint', 'token'] },
|
|
163
168
|
{ name: 'pin', summary: 'Shorthand for `protect` (protected=true)', positional: 'address',
|
package/src/store/remote.mjs
CHANGED
|
@@ -283,7 +283,7 @@ class RemoteStore {
|
|
|
283
283
|
async write(args = {}) {
|
|
284
284
|
const {
|
|
285
285
|
scope, key, value, tags, source_agent, trigger, kind, host, org, ttl_days, clear_ttl, created_at,
|
|
286
|
-
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
286
|
+
cited, origin_repo, origin_branch, origin_commit, origin_pr,
|
|
287
287
|
} = args;
|
|
288
288
|
const body = { scope, key, value };
|
|
289
289
|
if (tags !== undefined) body.tags = tags;
|
|
@@ -295,6 +295,11 @@ class RemoteStore {
|
|
|
295
295
|
if (ttl_days !== undefined) body.ttl_days = ttl_days;
|
|
296
296
|
if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
|
|
297
297
|
if (created_at !== undefined) body.created_at = created_at;
|
|
298
|
+
// Citations are a fact about the RUN this write belongs to, not a column on
|
|
299
|
+
// the row (migration 00107) — the server resolves each ref, records the
|
|
300
|
+
// credit, and drops what it cannot resolve. Sent verbatim: which refs are
|
|
301
|
+
// legal is the server's grammar to decide, not this transport's.
|
|
302
|
+
if (cited !== undefined) body.cited = cited;
|
|
298
303
|
// Provenance — only sent when known. Omitting a field leaves whatever the
|
|
299
304
|
// row already recorded intact (the RPC coalesces), which is what makes a
|
|
300
305
|
// write from a machine with no git context non-destructive.
|
|
@@ -707,8 +712,8 @@ class RemoteStore {
|
|
|
707
712
|
}
|
|
708
713
|
|
|
709
714
|
// POST /policies → the created policy object.
|
|
710
|
-
async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
|
|
711
|
-
const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count });
|
|
715
|
+
async policyCreate({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
|
|
716
|
+
const body = stripUndefined({ scope, name, mode, enabled, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
|
|
712
717
|
const res = await this._rest('/memories/policies', { method: 'POST', body });
|
|
713
718
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
714
719
|
return { ok: true, policy: res.data };
|
|
@@ -735,8 +740,8 @@ class RemoteStore {
|
|
|
735
740
|
// POST /groom/preview → { count, keys: [{ scope, key }] } — the SAME
|
|
736
741
|
// candidates a groom() run would archive. Pass either `policy_id` or
|
|
737
742
|
// `scope` (+ optional conditions), never both.
|
|
738
|
-
async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
|
|
739
|
-
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count });
|
|
743
|
+
async groomPreview({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
|
|
744
|
+
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
|
|
740
745
|
const res = await this._rest('/memories/groom/preview', { method: 'POST', body });
|
|
741
746
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
742
747
|
return { ok: true, count: res.data?.count ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
@@ -744,8 +749,8 @@ class RemoteStore {
|
|
|
744
749
|
|
|
745
750
|
// POST /groom/run → archives every previewed candidate, in one transaction.
|
|
746
751
|
// Soft-archive only (recoverable via restore); never hard-deletes.
|
|
747
|
-
async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count } = {}) {
|
|
748
|
-
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count });
|
|
752
|
+
async groomRun({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count } = {}) {
|
|
753
|
+
const body = stripUndefined({ policy_id, scope, min_age_days, unseen_days, max_seen_count, max_read_count, max_opened_count });
|
|
749
754
|
const res = await this._rest('/memories/groom/run', { method: 'POST', body });
|
|
750
755
|
if (!res.ok) return { ok: false, error: res.error, httpStatus: res.httpStatus, networkError: res.networkError };
|
|
751
756
|
return { ok: true, archived: res.data?.archived ?? 0, keys: Array.isArray(res.data?.keys) ? res.data.keys : [] };
|
|
@@ -147,6 +147,13 @@ export const MCP_TOOL_DEFS = [
|
|
|
147
147
|
"type": "integer",
|
|
148
148
|
"minimum": 1,
|
|
149
149
|
"description": "Provenance: the pull request number this memory was recorded from. Combined with origin_repo it renders as a link to the PR."
|
|
150
|
+
},
|
|
151
|
+
"cited": {
|
|
152
|
+
"type": "array",
|
|
153
|
+
"items": {
|
|
154
|
+
"type": "string"
|
|
155
|
+
},
|
|
156
|
+
"description": "The lessons that actually shaped this run, as `scope::key` strings — exactly the labels they were injected under. Name only the ones you applied; an empty or omitted list is the honest answer when none were. Silently ignored where a reference names nothing you can see, so a wrong guess costs nothing and the write always succeeds."
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
159
|
}
|
|
@@ -535,6 +542,12 @@ export const MCP_TOOL_DEFS = [
|
|
|
535
542
|
"maximum": 100000,
|
|
536
543
|
"description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
|
|
537
544
|
},
|
|
545
|
+
"max_opened_count": {
|
|
546
|
+
"type": "integer",
|
|
547
|
+
"minimum": 0,
|
|
548
|
+
"maximum": 100000,
|
|
549
|
+
"description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
|
|
550
|
+
},
|
|
538
551
|
"tags": {
|
|
539
552
|
"type": "array",
|
|
540
553
|
"items": {
|
|
@@ -720,6 +733,12 @@ export const MCP_TOOL_DEFS = [
|
|
|
720
733
|
"maximum": 100000,
|
|
721
734
|
"description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned. Omit to leave unchanged; pass explicit null to clear."
|
|
722
735
|
},
|
|
736
|
+
"max_opened_count": {
|
|
737
|
+
"type": "integer",
|
|
738
|
+
"minimum": 0,
|
|
739
|
+
"maximum": 100000,
|
|
740
|
+
"description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat. Omit to leave unchanged; pass explicit null to clear."
|
|
741
|
+
},
|
|
723
742
|
"tags": {
|
|
724
743
|
"type": "array",
|
|
725
744
|
"items": {
|
|
@@ -906,6 +925,12 @@ export const MCP_TOOL_DEFS = [
|
|
|
906
925
|
"maximum": 100000,
|
|
907
926
|
"description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
|
|
908
927
|
},
|
|
928
|
+
"max_opened_count": {
|
|
929
|
+
"type": "integer",
|
|
930
|
+
"minimum": 0,
|
|
931
|
+
"maximum": 100000,
|
|
932
|
+
"description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
|
|
933
|
+
},
|
|
909
934
|
"tags": {
|
|
910
935
|
"type": "array",
|
|
911
936
|
"items": {
|
|
@@ -1076,6 +1101,12 @@ export const MCP_TOOL_DEFS = [
|
|
|
1076
1101
|
"maximum": 100000,
|
|
1077
1102
|
"description": "Match only lessons READ at most this many times — the counter that says whether a lesson was ever actually used, unlike `max_seen_count` which counts WRITES. Counts EVERY read, a bulk `memory.list`/`memory.search` appearance included (unlike `unseen_days`, which only counts targeted opens). Reads have only been counted since the counter shipped, so a long-lived lesson can show a low count it never earned."
|
|
1078
1103
|
},
|
|
1104
|
+
"max_opened_count": {
|
|
1105
|
+
"type": "integer",
|
|
1106
|
+
"minimum": 0,
|
|
1107
|
+
"maximum": 100000,
|
|
1108
|
+
"description": "Match only lessons an agent DELIBERATELY fetched at most this many times — the count behind `last_opened_at`. Unlike `max_read_count` a bulk `memory.list`/`memory.search` ride-along does NOT count, so `0` means \"nothing ever chose this\" rather than \"this lesson happens to live in a narrow scope\". Backfilled over the whole recorded history, so it carries no cutover caveat."
|
|
1109
|
+
},
|
|
1079
1110
|
"tags": {
|
|
1080
1111
|
"type": "array",
|
|
1081
1112
|
"items": {
|