shadok-ai 0.14.7 → 0.14.8
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/context/ledger-reflex.md +15 -7
- package/context/ledger-skill/SKILL.md +42 -2
- package/context/ledger-skill/ledger-core.d.mts +34 -0
- package/context/ledger-skill/ledger-core.mjs +98 -6
- package/context/ledger-skill/ledger.mjs +39 -8
- package/dist/ledger.d.ts +43 -8
- package/dist/ledger.js +126 -17
- package/dist/ledger.js.map +1 -1
- package/dist/server.js +10 -2
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/public/index.html +13 -1
package/context/ledger-reflex.md
CHANGED
|
@@ -18,19 +18,27 @@ Then let it steer you:
|
|
|
18
18
|
("de mémoire, à faire — dis-moi si c'est déjà réglé"), don't assert;
|
|
19
19
|
- rows printed under **"related rows — they may NOT answer your question"** are
|
|
20
20
|
leads, not an answer: you are still in the UNKNOWN case above;
|
|
21
|
-
- a **stale** record (days old) → confirm before acting
|
|
21
|
+
- a **stale** record (days old) → confirm before acting; the age shown is when
|
|
22
|
+
the claim was last CONFIRMED, not when the text was last edited;
|
|
23
|
+
- a row tagged **`· fact`** has no lifecycle — `current` or `stale`, never
|
|
24
|
+
"done": don't read its status as a position in open → resolved, and don't
|
|
25
|
+
close it because surrounding work finished. A **`· decision`** stands until
|
|
26
|
+
something supersedes it.
|
|
22
27
|
|
|
23
28
|
**You are also pushed the changes.** Ahead of each turn you are given — a user
|
|
24
29
|
message, or a scheduled/automated run — you may receive a
|
|
25
|
-
`⟦ledger · N updates
|
|
26
|
-
|
|
27
|
-
Treat it as current status
|
|
28
|
-
|
|
29
|
-
carry.
|
|
30
|
+
`⟦ledger · N updates⟧` block listing what sibling agents resolved or decided
|
|
31
|
+
since you last heard — `• [id] entity — status ↳ what happened`, all on one
|
|
32
|
+
line. Treat it as current status
|
|
33
|
+
(don't re-raise what it shows resolved). It is the push half; `check` is still
|
|
34
|
+
there for older or specific items the block didn't carry.
|
|
30
35
|
|
|
31
36
|
When you **resolve, decide, or change** something notable, record it so the next
|
|
32
37
|
agent doesn't redo it:
|
|
33
|
-
`node ~/.claude/skills/shadok-ledger/ledger.mjs record --entity "<name>" --status <resolved|open|in-progress
|
|
38
|
+
`node ~/.claude/skills/shadok-ledger/ledger.mjs record --entity "<name>" --status <resolved|open|in-progress> --note "<line>" --source "<PR#/who>"`.
|
|
39
|
+
Open the note with the sentence that says WHAT happened — it is what siblings get
|
|
40
|
+
pushed. For something with no lifecycle, pass `--kind fact` or `--kind decision`
|
|
41
|
+
instead of a status; to say you re-checked a row and nothing moved, `--verified`.
|
|
34
42
|
To update an item you saw in the block, quote its handle instead of retyping the
|
|
35
43
|
name: `… record --id <id> --status resolved --note "<line>"`.
|
|
36
44
|
|
|
@@ -37,7 +37,14 @@ you nothing.
|
|
|
37
37
|
the search found no row that answers you, only rows sharing some words. You
|
|
38
38
|
are still in the UNKNOWN case above. Read them as leads (they may point at the
|
|
39
39
|
right topic under another name), never as a status to assert.
|
|
40
|
-
- a **stale** row (many days old) → hedge and confirm before acting.
|
|
40
|
+
- a **stale** row (many days old) → hedge and confirm before acting. The age
|
|
41
|
+
shown is when the row's claim was last **confirmed**, not when its text was
|
|
42
|
+
last edited.
|
|
43
|
+
- a row tagged **`· fact`** has no lifecycle: it is `current` or `stale`, never
|
|
44
|
+
"done". Do not read its status as a position in open → resolved, and do not
|
|
45
|
+
close it because the work around it finished — it stays true.
|
|
46
|
+
- a row tagged **`· decision`** is a standing decision, not a phase of a task.
|
|
47
|
+
It holds until something supersedes it.
|
|
41
48
|
|
|
42
49
|
This fires on status-dependent claims (a bug being open, a PR unmerged, a task
|
|
43
50
|
undone, a campaign live). A **durable lesson/constraint** ("don't buy the
|
|
@@ -49,13 +56,46 @@ Record it so the next agent doesn't redo it:
|
|
|
49
56
|
|
|
50
57
|
```
|
|
51
58
|
node ~/.claude/skills/shadok-ledger/ledger.mjs record \
|
|
52
|
-
--entity "<name>" --status <resolved|open|in-progress
|
|
59
|
+
--entity "<name>" --status <resolved|open|in-progress> \
|
|
53
60
|
--note "<one line>" --source "<PR#, session, who>"
|
|
54
61
|
```
|
|
55
62
|
|
|
56
63
|
Re-recording the same entity **supersedes** its row (no duplicates). Log facts —
|
|
57
64
|
resolutions, decisions, state changes, launched actions — never chatter.
|
|
58
65
|
|
|
66
|
+
**Open the note with the one sentence that says what happened.** Its first
|
|
67
|
+
sentence is what siblings are shown in the `⟦ledger⟧` block pushed ahead of their
|
|
68
|
+
messages — the entity name alone cannot tell them what changed about it. Put the
|
|
69
|
+
consequence first, details after.
|
|
70
|
+
|
|
71
|
+
## Is it a task, a fact, or a decision?
|
|
72
|
+
|
|
73
|
+
A **task** has a lifecycle, so a status is the right thing to carry and closing
|
|
74
|
+
it is normal. That is the default and needs no flag. The other two do:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
… record --entity "nightly import window" --kind fact \
|
|
78
|
+
--note "Runs 03:00-03:40 Europe/Paris, after the extraction job."
|
|
79
|
+
… record --entity "ledger stays a state table" --kind decision \
|
|
80
|
+
--note "Supersede, not append, so size is bounded by live topics."
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`--kind` replaces `--status` for these two (they get `current` / `decided`), because
|
|
84
|
+
neither has phases to report. This matters: a **fact** marked `resolved` stops
|
|
85
|
+
being read by anything that filters on open work, while remaining perfectly true
|
|
86
|
+
— which is how a job finished on the 9th was still believed pending on the 12th.
|
|
87
|
+
|
|
88
|
+
When you re-check a row and nothing has changed, say so instead of rewriting it:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
… record --id <id> --verified
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That refreshes its freshness only. It deliberately does **not** enter anyone's
|
|
95
|
+
pushed block — "I looked, nothing moved" is not news. Conversely, editing a note
|
|
96
|
+
does **not** refresh freshness: only a status (or `--verified`) counts as having
|
|
97
|
+
looked.
|
|
98
|
+
|
|
59
99
|
Every row has a short **id** (shown as `[id]` in `check`/`list` and in the ledger
|
|
60
100
|
block pushed ahead of your messages). To update a row you already saw, quote its
|
|
61
101
|
id instead of retyping the name — no risk of a typo forking a second row:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Types for the skill's pure core, so the SERVER can import the one source of
|
|
2
|
+
// the ledger's logic instead of growing a second copy of it (CLAUDE.md: this
|
|
3
|
+
// file is the single source, no duplication to drift). The core itself stays
|
|
4
|
+
// plain .mjs — it is copied verbatim into ~/.claude/skills/ at every boot and
|
|
5
|
+
// must run with no build step of its own.
|
|
6
|
+
export interface LedgerCoreRow {
|
|
7
|
+
id?: string;
|
|
8
|
+
entity: string;
|
|
9
|
+
status: string;
|
|
10
|
+
kind?: string;
|
|
11
|
+
note?: string;
|
|
12
|
+
source?: string;
|
|
13
|
+
updatedAt?: number;
|
|
14
|
+
verifiedAt?: number;
|
|
15
|
+
}
|
|
16
|
+
export type LedgerKind = "task" | "fact" | "decision";
|
|
17
|
+
export const KINDS: LedgerKind[];
|
|
18
|
+
export function normEntity(s: unknown): string;
|
|
19
|
+
export function normKind(k: unknown): LedgerKind | null;
|
|
20
|
+
export function rowKind(entry: unknown): LedgerKind;
|
|
21
|
+
export function resolveId<T extends LedgerCoreRow>(entries: T[], idOrPrefix: unknown): T | null;
|
|
22
|
+
export function upsertEntry<T extends LedgerCoreRow>(
|
|
23
|
+
entries: T[],
|
|
24
|
+
patch: Partial<LedgerCoreRow> & { verified?: boolean },
|
|
25
|
+
now: number,
|
|
26
|
+
newId?: string,
|
|
27
|
+
): T[];
|
|
28
|
+
export function tokenize(s: unknown): string[];
|
|
29
|
+
export function searchEntries<T extends LedgerCoreRow>(
|
|
30
|
+
entries: T[],
|
|
31
|
+
query: string,
|
|
32
|
+
opts?: { strong?: number; weak?: number },
|
|
33
|
+
): { strong: T[]; weak: T[] };
|
|
34
|
+
export function ageDays(entry: { updatedAt?: number; verifiedAt?: number }, now: number): number;
|
|
@@ -26,6 +26,52 @@ export function resolveId(entries, idOrPrefix) {
|
|
|
26
26
|
return pre.length === 1 ? pre[0] : null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/* --------------------------------------------------------------------- *
|
|
30
|
+
* A row's KIND: a task, a fact, or a decision.
|
|
31
|
+
*
|
|
32
|
+
* The table used to treat all three identically, and they are not the same
|
|
33
|
+
* object. A TASK has a lifecycle, so a status is the right thing to carry and
|
|
34
|
+
* closing it is normal. A FACT has no lifecycle — only a date on which someone
|
|
35
|
+
* last confirmed it — so closing it makes it DISAPPEAR WHILE IT STAYS TRUE,
|
|
36
|
+
* which is how a finished job was still believed pending three days later. And
|
|
37
|
+
* `decided` was never a phase of the open → in-progress → resolved lifecycle it
|
|
38
|
+
* sat next to: it is a kind wearing a status's clothes.
|
|
39
|
+
*
|
|
40
|
+
* Design: docs/superpowers/specs/2026-10-13-ledger-kind-and-headline.md
|
|
41
|
+
* --------------------------------------------------------------------- */
|
|
42
|
+
|
|
43
|
+
export const KINDS = ["task", "fact", "decision"];
|
|
44
|
+
|
|
45
|
+
/** The status a kind with NO lifecycle gets when none is given, so an agent is
|
|
46
|
+
* never pushed into inventing a word for a thing that has no phases. Keeping
|
|
47
|
+
* `status` always present is what leaves every existing reader — the pushed
|
|
48
|
+
* delta, the GUI viewer, a consumer's recap filter — working untouched. */
|
|
49
|
+
const KIND_STATUS = { fact: "current", decision: "decided" };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate a `--kind`: one of KINDS, or null when absent. An unknown one THROWS
|
|
53
|
+
* rather than being stored — a typo that silently became a fourth kind would be
|
|
54
|
+
* invisible for exactly as long as nobody went looking.
|
|
55
|
+
*/
|
|
56
|
+
export function normKind(k) {
|
|
57
|
+
const s = String(k ?? "").trim().toLowerCase();
|
|
58
|
+
if (!s) return null;
|
|
59
|
+
if (!KINDS.includes(s)) throw new Error(`unknown kind "${k}" — use one of: ${KINDS.join(", ")}`);
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What kind a row IS — read, never written back. An absent `kind` reads as
|
|
65
|
+
* `decision` when the status is `decided` (that is what the word already meant)
|
|
66
|
+
* and as `task` otherwise, so the rows recorded before this existed keep their
|
|
67
|
+
* bytes and still render correctly. Assert only what you saw.
|
|
68
|
+
*/
|
|
69
|
+
export function rowKind(entry) {
|
|
70
|
+
const k = String(entry?.kind ?? "").trim().toLowerCase();
|
|
71
|
+
if (KINDS.includes(k)) return k;
|
|
72
|
+
return normEntity(entry?.status) === "decided" ? "decision" : "task";
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
/**
|
|
30
76
|
* Upsert a row, two ways:
|
|
31
77
|
* - `patch.id` present → UPDATE that exact row in place (resolved via resolveId),
|
|
@@ -35,20 +81,54 @@ export function resolveId(entries, idOrPrefix) {
|
|
|
35
81
|
* its id (the handle is durable across every write), or create a new row with
|
|
36
82
|
* `newId`.
|
|
37
83
|
* `now` (epoch ms) and `newId` are injected so the core stays pure/testable.
|
|
84
|
+
*
|
|
85
|
+
* Two clocks, because an EDIT is not an OBSERVATION. `updatedAt` is when the row
|
|
86
|
+
* was last written; `verifiedAt` is when its claim was last confirmed, and it is
|
|
87
|
+
* what the staleness hedge reads. Rewriting a note used to rejuvenate a row
|
|
88
|
+
* nobody had re-checked in a month, which silently switched that hedge off:
|
|
89
|
+
*
|
|
90
|
+
* create ................................ both move
|
|
91
|
+
* --status given (even the same value) .. both move — re-asserting a status
|
|
92
|
+
* IS confirming it
|
|
93
|
+
* --note / --source / --entity only ..... updatedAt moves, verifiedAt does NOT
|
|
94
|
+
* --verified alone ...................... verifiedAt moves, updatedAt does NOT
|
|
95
|
+
*
|
|
96
|
+
* That last line is deliberate: a row whose `updatedAt` does not move stays out
|
|
97
|
+
* of every sibling's pushed delta, and "I re-checked, nothing changed" is not
|
|
98
|
+
* news — pushing it to every agent is the noise a state table exists to avoid.
|
|
99
|
+
* A row that predates `verifiedAt` inherits its old `updatedAt` as the moment it
|
|
100
|
+
* was last observed, which is the truth and makes the migration lazy, per row.
|
|
38
101
|
*/
|
|
39
102
|
export function upsertEntry(entries, patch, now, newId) {
|
|
103
|
+
const kind = normKind(patch.kind); // refuses an unknown kind before anything is written
|
|
104
|
+
const statusGiven = patch.status != null && String(patch.status).trim() !== "";
|
|
105
|
+
|
|
40
106
|
if (patch.id != null && String(patch.id).trim()) {
|
|
41
107
|
const cur = resolveId(entries, patch.id);
|
|
42
108
|
if (!cur) throw new Error(`no ledger entry with id "${patch.id}"`);
|
|
109
|
+
// `kind` is a property of the ENTITY, not of this write: keep the stored one
|
|
110
|
+
// when none is given, like the id.
|
|
111
|
+
const effKind = kind ?? normKind(cur.kind);
|
|
112
|
+
// Promoting a row to a lifecycle-less kind supplies that kind's status, so
|
|
113
|
+
// `--id X --kind fact` does not leave a stale `resolved` on a fact.
|
|
114
|
+
const status = statusGiven
|
|
115
|
+
? String(patch.status).trim()
|
|
116
|
+
: kind && kind !== rowKind(cur) && KIND_STATUS[kind]
|
|
117
|
+
? KIND_STATUS[kind]
|
|
118
|
+
: cur.status;
|
|
119
|
+
const edited =
|
|
120
|
+
statusGiven || kind != null || patch.note != null || patch.source != null || patch.entity != null;
|
|
121
|
+
const observed = statusGiven || patch.verified === true || status !== cur.status;
|
|
43
122
|
const row = {
|
|
44
123
|
id: cur.id,
|
|
45
124
|
entity:
|
|
46
125
|
patch.entity != null && String(patch.entity).trim() ? String(patch.entity).trim() : cur.entity,
|
|
47
|
-
status
|
|
48
|
-
|
|
126
|
+
status,
|
|
127
|
+
...(effKind ? { kind: effKind } : {}),
|
|
49
128
|
...((patch.note ?? cur.note) ? { note: String(patch.note ?? cur.note).trim() } : {}),
|
|
50
129
|
...((patch.source ?? cur.source) ? { source: String(patch.source ?? cur.source).trim() } : {}),
|
|
51
|
-
updatedAt: now,
|
|
130
|
+
updatedAt: edited ? now : (cur.updatedAt ?? now),
|
|
131
|
+
verifiedAt: observed ? now : (cur.verifiedAt ?? cur.updatedAt ?? now),
|
|
52
132
|
};
|
|
53
133
|
return [...entries.filter((e) => e.id !== cur.id), row];
|
|
54
134
|
}
|
|
@@ -56,13 +136,20 @@ export function upsertEntry(entries, patch, now, newId) {
|
|
|
56
136
|
const key = normEntity(patch.entity);
|
|
57
137
|
if (!key) throw new Error("entity is required");
|
|
58
138
|
const existing = entries.find((e) => normEntity(e.entity) === key);
|
|
139
|
+
const effKind = kind ?? normKind(existing?.kind);
|
|
140
|
+
const status = statusGiven
|
|
141
|
+
? String(patch.status).trim()
|
|
142
|
+
: (effKind && KIND_STATUS[effKind]) || String(patch.status ?? "").trim();
|
|
143
|
+
const observed = statusGiven || patch.verified === true || status !== existing?.status;
|
|
59
144
|
const row = {
|
|
60
145
|
id: existing?.id ?? newId,
|
|
61
146
|
entity: String(patch.entity).trim(),
|
|
62
|
-
status
|
|
147
|
+
status,
|
|
148
|
+
...(effKind ? { kind: effKind } : {}),
|
|
63
149
|
...(patch.note ? { note: String(patch.note).trim() } : {}),
|
|
64
150
|
...(patch.source ? { source: String(patch.source).trim() } : {}),
|
|
65
151
|
updatedAt: now,
|
|
152
|
+
verifiedAt: observed ? now : (existing?.verifiedAt ?? existing?.updatedAt ?? now),
|
|
66
153
|
};
|
|
67
154
|
return [...entries.filter((e) => normEntity(e.entity) !== key), row];
|
|
68
155
|
}
|
|
@@ -181,7 +268,12 @@ export function searchEntries(entries, query, opts = {}) {
|
|
|
181
268
|
};
|
|
182
269
|
}
|
|
183
270
|
|
|
184
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Whole days since the row's claim was last CONFIRMED — the freshness the reflex
|
|
273
|
+
* hedges on. `verifiedAt` first: reading `updatedAt` meant any edit, down to a
|
|
274
|
+
* reworded note, reset the age of a row nobody had re-checked. Falls back to
|
|
275
|
+
* `updatedAt` for the rows recorded before the field existed.
|
|
276
|
+
*/
|
|
185
277
|
export function ageDays(entry, now) {
|
|
186
|
-
return Math.floor((now - (entry.updatedAt ?? now)) / 86_400_000);
|
|
278
|
+
return Math.floor((now - (entry.verifiedAt ?? entry.updatedAt ?? now)) / 86_400_000);
|
|
187
279
|
}
|
|
@@ -10,7 +10,7 @@ import fs from "node:fs";
|
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
|
-
import { upsertEntry, searchEntries, ageDays, resolveId, normEntity } from "./ledger-core.mjs";
|
|
13
|
+
import { upsertEntry, searchEntries, ageDays, resolveId, normEntity, rowKind, KINDS } from "./ledger-core.mjs";
|
|
14
14
|
|
|
15
15
|
const FILE =
|
|
16
16
|
(process.env.SHADOK_LEDGER_FILE || "").trim() ||
|
|
@@ -42,11 +42,17 @@ function mintId(rows) {
|
|
|
42
42
|
return crypto.randomBytes(4).toString("hex");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Flags that take NO value, listed rather than guessed: inferring "the next arg
|
|
46
|
+
* starts with -- so this one is a boolean" would misread `--note "--foo"`. */
|
|
47
|
+
const BOOL_FLAGS = new Set(["verified"]);
|
|
48
|
+
|
|
45
49
|
/** `--key value` pairs → object. Values may be quoted by the shell already. */
|
|
46
50
|
function parseFlags(args) {
|
|
47
51
|
const out = {};
|
|
48
52
|
for (let i = 0; i < args.length; i++) {
|
|
49
|
-
if (args[i].startsWith("--"))
|
|
53
|
+
if (!args[i].startsWith("--")) continue;
|
|
54
|
+
const key = args[i].slice(2);
|
|
55
|
+
out[key] = BOOL_FLAGS.has(key) ? true : (args[++i] ?? "");
|
|
50
56
|
}
|
|
51
57
|
return out;
|
|
52
58
|
}
|
|
@@ -54,8 +60,17 @@ function parseFlags(args) {
|
|
|
54
60
|
function fmt(e, now) {
|
|
55
61
|
const age = ageDays(e, now);
|
|
56
62
|
const when = age <= 0 ? "today" : age === 1 ? "1d ago" : `${age}d ago`;
|
|
63
|
+
const kind = rowKind(e);
|
|
64
|
+
// Say "verified" wherever the two clocks have parted company — and always for
|
|
65
|
+
// a fact, whose whole state IS its freshness. A bare age next to a row last
|
|
66
|
+
// confirmed a month ago but reworded yesterday is the lie this removes.
|
|
67
|
+
const parted = e.verifiedAt && e.updatedAt && e.verifiedAt !== e.updatedAt;
|
|
68
|
+
const stamp = kind === "fact" || parted ? `verified ${when}` : when;
|
|
69
|
+
// `task` is the default and the overwhelming majority: tagging it would be
|
|
70
|
+
// noise on every line. The two that change how a status must be READ are named.
|
|
71
|
+
const tag = kind === "task" ? "" : ` · ${kind}`;
|
|
57
72
|
const handle = e.id ? `[${e.id}] ` : "";
|
|
58
|
-
const head = `• ${handle}${e.entity} — ${e.status} (${
|
|
73
|
+
const head = `• ${handle}${e.entity} — ${e.status}${tag} (${stamp}${e.source ? ` · ${e.source}` : ""})`;
|
|
59
74
|
return e.note ? `${head}\n ${e.note}` : head;
|
|
60
75
|
}
|
|
61
76
|
|
|
@@ -89,11 +104,18 @@ if (cmd === "check") {
|
|
|
89
104
|
// Two shapes: create/supersede by --entity (+ --status), or update an existing
|
|
90
105
|
// row by its --id handle (change any of status/note/source; entity optional).
|
|
91
106
|
const byId = f.id != null && String(f.id).trim();
|
|
92
|
-
const idHasNoFields =
|
|
93
|
-
|
|
107
|
+
const idHasNoFields =
|
|
108
|
+
f.status == null && f.note == null && f.source == null && f.entity == null &&
|
|
109
|
+
f.kind == null && f.verified == null;
|
|
110
|
+
// A fact or a decision has no lifecycle, so `--kind` stands in for `--status`:
|
|
111
|
+
// requiring one would only invite an invented word.
|
|
112
|
+
const hasStatus = !!f.status || !!f.kind;
|
|
113
|
+
if (byId ? idHasNoFields : !f.entity || !hasStatus) {
|
|
94
114
|
console.error(
|
|
95
115
|
'usage: ledger record --entity "<name>" --status <resolved|open|in-progress|decided> [--note "<line>"] [--source "<ref>"]\n' +
|
|
96
|
-
|
|
116
|
+
` or: ledger record --entity "<name>" --kind <${KINDS.join("|")}> [--note "<line>"] [--source "<ref>"]\n` +
|
|
117
|
+
' or: ledger record --id <handle> [--status <…>] [--kind <…>] [--note "<line>"] [--source "<ref>"]\n' +
|
|
118
|
+
' or: ledger record --id <handle> --verified (still true/current — nothing else changed)',
|
|
97
119
|
);
|
|
98
120
|
process.exit(2);
|
|
99
121
|
}
|
|
@@ -102,7 +124,15 @@ if (cmd === "check") {
|
|
|
102
124
|
try {
|
|
103
125
|
rows = upsertEntry(
|
|
104
126
|
rows0,
|
|
105
|
-
{
|
|
127
|
+
{
|
|
128
|
+
id: f.id,
|
|
129
|
+
entity: f.entity,
|
|
130
|
+
status: f.status,
|
|
131
|
+
kind: f.kind,
|
|
132
|
+
verified: f.verified === true,
|
|
133
|
+
note: f.note,
|
|
134
|
+
source: f.source,
|
|
135
|
+
},
|
|
106
136
|
now,
|
|
107
137
|
mintId(rows0),
|
|
108
138
|
);
|
|
@@ -112,7 +142,8 @@ if (cmd === "check") {
|
|
|
112
142
|
}
|
|
113
143
|
save(rows);
|
|
114
144
|
const row = byId ? resolveId(rows, f.id) : rows.find((r) => normEntity(r.entity) === normEntity(f.entity));
|
|
115
|
-
|
|
145
|
+
const k = rowKind(row);
|
|
146
|
+
console.log(`recorded: ${row.entity} — ${row.status}${k === "task" ? "" : ` · ${k}`} [${row.id}]`);
|
|
116
147
|
} else if (cmd === "list") {
|
|
117
148
|
const rows = searchEntries(load(), "").strong;
|
|
118
149
|
console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
|
package/dist/ledger.d.ts
CHANGED
|
@@ -18,9 +18,16 @@ export interface LedgerRow {
|
|
|
18
18
|
id?: string;
|
|
19
19
|
entity: string;
|
|
20
20
|
status: string;
|
|
21
|
+
/** `task` | `fact` | `decision` — absent on every row recorded before it
|
|
22
|
+
* existed, which `rowKind` reads rather than rewriting them. */
|
|
23
|
+
kind?: string;
|
|
21
24
|
note?: string;
|
|
22
25
|
source?: string;
|
|
26
|
+
/** When the row was last WRITTEN. */
|
|
23
27
|
updatedAt?: number;
|
|
28
|
+
/** When its claim was last CONFIRMED — the freshness, which an edit must not
|
|
29
|
+
* move. Absent on older rows; `ageDays` falls back to `updatedAt`. */
|
|
30
|
+
verifiedAt?: number;
|
|
24
31
|
}
|
|
25
32
|
/** The per-instance ledger file, keyed by the launch dir (same encoding as
|
|
26
33
|
* channels/crons). Distinct launch dirs → distinct ledgers. */
|
|
@@ -46,17 +53,45 @@ export declare function deltaSince(rows: LedgerRow[], watermark: number, cap: nu
|
|
|
46
53
|
rows: LedgerRow[];
|
|
47
54
|
total: number;
|
|
48
55
|
};
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
56
|
+
/**
|
|
57
|
+
* The one line of the note that says WHAT happened, DERIVED rather than asked
|
|
58
|
+
* for. The block used to carry `[id] entity — status` and nothing else, so the
|
|
59
|
+
* entity *label* was the whole payload — and a label written as a topic name
|
|
60
|
+
* ("report regeneration, 15 test accounts") cannot say what changed about it.
|
|
61
|
+
* That is how work finished on the 9th was still believed pending on the 12th.
|
|
62
|
+
*
|
|
63
|
+
* Derived, because the alternative — a mandatory `--headline` — asks every agent
|
|
64
|
+
* for writing discipline (which nothing enforces) and covers none of the rows
|
|
65
|
+
* already recorded. The note's first sentence needs neither.
|
|
66
|
+
*/
|
|
67
|
+
export declare function headline(note: string | undefined, max?: number): string;
|
|
68
|
+
/**
|
|
69
|
+
* The delta block the agent sees ahead of a prompt. English (it is written into
|
|
70
|
+
* the transcript — repo side). ONE line, within `BLOCK_MAX` (see above): per row
|
|
71
|
+
* `• [id] entity — status (source)`, the kind when it is not the default `task`,
|
|
72
|
+
* and `↳ <headline>` for what actually happened. Rows that do not fit the budget
|
|
73
|
+
* become the overflow pointer rather than a second line.
|
|
74
|
+
*/
|
|
52
75
|
export declare function formatLedgerBlock(rows: LedgerRow[], total: number): string;
|
|
53
|
-
/** Prepend the block as its own leading
|
|
76
|
+
/** Prepend the block as its own leading line. Idempotent. */
|
|
54
77
|
export declare function markLedgerBlock(text: string, block: string): string;
|
|
55
|
-
/** Does this text open with a pushed ledger block?
|
|
78
|
+
/** Does this text open with a pushed ledger block?
|
|
79
|
+
*
|
|
80
|
+
* `⟧` CLOSES the header, it no longer ENDS the line: a one-line block continues
|
|
81
|
+
* with its rows after it. Testing `endsWith` here was the first thing the
|
|
82
|
+
* one-line format broke, and it fails open — detection returns false, nothing
|
|
83
|
+
* is stripped, and the whole block is shown to the user in the chat. The old
|
|
84
|
+
* multi-line shape satisfies this test too, so replayed transcripts are
|
|
85
|
+
* unaffected. */
|
|
56
86
|
export declare function hasLedgerBlock(text: string): boolean;
|
|
57
|
-
/** Remove a leading ledger block
|
|
58
|
-
*
|
|
59
|
-
*
|
|
87
|
+
/** Remove a leading ledger block, leaving whatever came after (the ⟦platform⟧
|
|
88
|
+
* header and the user's message). A plain message is returned untouched.
|
|
89
|
+
*
|
|
90
|
+
* The block is one line now, so that is usually all this drops — but it must
|
|
91
|
+
* keep understanding the HISTORICAL multi-line shape (header, `• ` bullets,
|
|
92
|
+
* indented `↳` headlines), because `loadHistory` replays transcripts written
|
|
93
|
+
* before the format changed and a block it fails to strip is a block the user
|
|
94
|
+
* reads in the chat. */
|
|
60
95
|
export declare function stripLedgerBlock(text: string): string;
|
|
61
96
|
/** Where this instance stores its agents' ledger watermarks. Beside the table,
|
|
62
97
|
* same launch-dir encoding, distinct name — writing one must never clobber the
|
package/dist/ledger.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
|
+
import { rowKind } from "../context/ledger-skill/ledger-core.mjs";
|
|
5
6
|
/** The per-instance ledger file, keyed by the launch dir (same encoding as
|
|
6
7
|
* channels/crons). Distinct launch dirs → distinct ledgers. */
|
|
7
8
|
export function ledgerFileFor(cwd) {
|
|
@@ -87,42 +88,150 @@ export function deltaSince(rows, watermark, cap) {
|
|
|
87
88
|
return { rows: changed.slice(0, cap), total: changed.length };
|
|
88
89
|
}
|
|
89
90
|
const LEDGER_HEAD = "⟦ledger · ";
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
/* ------------------------------------------------------------------ *
|
|
92
|
+
* The block is ONE LINE, and that is a correctness requirement, not a style.
|
|
93
|
+
*
|
|
94
|
+
* `pilot.submit` puts the text into the TUI as a bracketed PASTE, and Claude
|
|
95
|
+
* Code collapses a paste past a threshold into a `<pasted_content>` wrapper —
|
|
96
|
+
* which its own system prompt tells the model is material the user pasted from
|
|
97
|
+
* somewhere else, to be acted on only where the user's own message asks. With a
|
|
98
|
+
* multi-line block the human's message is INSIDE that wrapper, so it stops being
|
|
99
|
+
* an instruction. Measured end to end: an agent answered "your last message
|
|
100
|
+
* contained only pasted text, with no request of your own alongside it… I
|
|
101
|
+
* haven't acted on that line", and refused a direct order.
|
|
102
|
+
*
|
|
103
|
+
* Both thresholds measured against a live agent and confirmed on 486 real human
|
|
104
|
+
* prompts from this machine's transcripts:
|
|
105
|
+
*
|
|
106
|
+
* lines 1–3 → 0 of 324 wrapped chars 430 on one line → not wrapped
|
|
107
|
+
* lines 4+ → ~30% of 162 wrapped chars 910 on one line → wrapped
|
|
108
|
+
*
|
|
109
|
+
* A prompt is `⟦web⟧ header` + the message, so a one-line human message leaves
|
|
110
|
+
* the block exactly one line of room. The old multi-line block spent 2+ and put
|
|
111
|
+
* EVERY non-empty delta over the cliff: 85 real prompts, 24% of those carrying a
|
|
112
|
+
* block, had the human's message neutralised this way. Hence: one line, and a
|
|
113
|
+
* character budget inside it.
|
|
114
|
+
* ------------------------------------------------------------------ */
|
|
115
|
+
/** Marks what follows the row, inline: "and here is what happened". */
|
|
116
|
+
const CONT_MARK = "↳";
|
|
117
|
+
/** Historical multi-line blocks, still replayed out of old transcripts. */
|
|
118
|
+
const CONT_LINE = /^\s*↳ /;
|
|
119
|
+
/** How much of a note reaches the block. */
|
|
120
|
+
const HEADLINE_MAX = 120;
|
|
121
|
+
/** Below this, a sentence boundary is not believed — see `headline`. */
|
|
122
|
+
const HEADLINE_MIN = 24;
|
|
123
|
+
/**
|
|
124
|
+
* The whole block's character ceiling. Chosen from the measurement above with
|
|
125
|
+
* room left for the ⟦web⟧ header and an ordinary message: a long enough single
|
|
126
|
+
* line collapses into a paste just as a fourth line does, so the budget is what
|
|
127
|
+
* keeps the one-line rule from being defeated by content. Rows that do not fit
|
|
128
|
+
* collapse into the overflow pointer, which is what it has always been for.
|
|
129
|
+
*/
|
|
130
|
+
const BLOCK_MAX = 320;
|
|
131
|
+
/**
|
|
132
|
+
* The one line of the note that says WHAT happened, DERIVED rather than asked
|
|
133
|
+
* for. The block used to carry `[id] entity — status` and nothing else, so the
|
|
134
|
+
* entity *label* was the whole payload — and a label written as a topic name
|
|
135
|
+
* ("report regeneration, 15 test accounts") cannot say what changed about it.
|
|
136
|
+
* That is how work finished on the 9th was still believed pending on the 12th.
|
|
137
|
+
*
|
|
138
|
+
* Derived, because the alternative — a mandatory `--headline` — asks every agent
|
|
139
|
+
* for writing discipline (which nothing enforces) and covers none of the rows
|
|
140
|
+
* already recorded. The note's first sentence needs neither.
|
|
141
|
+
*/
|
|
142
|
+
export function headline(note, max = HEADLINE_MAX) {
|
|
143
|
+
const flat = String(note ?? "").replace(/\s+/g, " ").trim();
|
|
144
|
+
if (!flat)
|
|
145
|
+
return "";
|
|
146
|
+
// A period ends a sentence only when whitespace follows it: this table is full
|
|
147
|
+
// of "claude-code-2.1.285" and "PR#259 / v0.13.4", and splitting on a bare dot
|
|
148
|
+
// would cut every one of them in half. And a boundary that would leave almost
|
|
149
|
+
// nothing is not one — "e.g. the nightly job" is one sentence, not "e.g.".
|
|
150
|
+
let first = flat;
|
|
151
|
+
const bound = /[.!?](?=\s)/g;
|
|
152
|
+
for (let m = bound.exec(flat); m; m = bound.exec(flat)) {
|
|
153
|
+
if (m.index + 1 >= HEADLINE_MIN) {
|
|
154
|
+
first = flat.slice(0, m.index + 1);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
first = first.trim();
|
|
159
|
+
if (first.length <= max)
|
|
160
|
+
return first;
|
|
161
|
+
const cut = first.slice(0, max);
|
|
162
|
+
const sp = cut.lastIndexOf(" ");
|
|
163
|
+
// Break on a word unless that would throw away most of the line.
|
|
164
|
+
return (sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd() + "…";
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The delta block the agent sees ahead of a prompt. English (it is written into
|
|
168
|
+
* the transcript — repo side). ONE line, within `BLOCK_MAX` (see above): per row
|
|
169
|
+
* `• [id] entity — status (source)`, the kind when it is not the default `task`,
|
|
170
|
+
* and `↳ <headline>` for what actually happened. Rows that do not fit the budget
|
|
171
|
+
* become the overflow pointer rather than a second line.
|
|
172
|
+
*/
|
|
93
173
|
export function formatLedgerBlock(rows, total) {
|
|
94
|
-
|
|
95
|
-
|
|
174
|
+
let line = `${LEDGER_HEAD}${total} update${total === 1 ? "" : "s"}⟧`;
|
|
175
|
+
let shown = 0;
|
|
176
|
+
for (const r of rows) {
|
|
96
177
|
const handle = r.id ? `[${r.id}] ` : "";
|
|
97
178
|
const src = r.source ? ` (${r.source})` : "";
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
179
|
+
// A `fact` has no lifecycle and a `decision` is not a phase of one, so the
|
|
180
|
+
// reader must not take their status for a position in open → resolved.
|
|
181
|
+
// `task` is the default and nearly the whole table: tagging it would be noise.
|
|
182
|
+
const kind = rowKind(r);
|
|
183
|
+
const tag = kind === "task" ? "" : ` · ${kind}`;
|
|
184
|
+
const bare = ` • ${handle}${r.entity} — ${r.status}${tag}${src}`;
|
|
185
|
+
// Always carry at least one row: an empty block would say a delta arrived
|
|
186
|
+
// and name nothing, which is worse than a long line.
|
|
187
|
+
if (shown > 0 && line.length + bare.length > BLOCK_MAX)
|
|
188
|
+
break;
|
|
189
|
+
line += bare;
|
|
190
|
+
shown++;
|
|
191
|
+
const h = headline(r.note);
|
|
192
|
+
if (h && line.length + h.length + 3 <= BLOCK_MAX)
|
|
193
|
+
line += ` ${CONT_MARK} ${h}`;
|
|
194
|
+
}
|
|
195
|
+
if (shown < total)
|
|
196
|
+
line += ` • (+${total - shown} more — run: ledger list)`;
|
|
197
|
+
// Defensive: one oversized entity must not be able to defeat the one-line rule
|
|
198
|
+
// by length, which costs exactly as much as a second line would.
|
|
199
|
+
return line.length > BLOCK_MAX * 2 ? line.slice(0, BLOCK_MAX * 2 - 1) + "…" : line;
|
|
103
200
|
}
|
|
104
|
-
/** Prepend the block as its own leading
|
|
201
|
+
/** Prepend the block as its own leading line. Idempotent. */
|
|
105
202
|
export function markLedgerBlock(text, block) {
|
|
106
203
|
if (hasLedgerBlock(text))
|
|
107
204
|
return text;
|
|
108
205
|
return `${block}\n${text}`;
|
|
109
206
|
}
|
|
110
|
-
/** Does this text open with a pushed ledger block?
|
|
207
|
+
/** Does this text open with a pushed ledger block?
|
|
208
|
+
*
|
|
209
|
+
* `⟧` CLOSES the header, it no longer ENDS the line: a one-line block continues
|
|
210
|
+
* with its rows after it. Testing `endsWith` here was the first thing the
|
|
211
|
+
* one-line format broke, and it fails open — detection returns false, nothing
|
|
212
|
+
* is stripped, and the whole block is shown to the user in the chat. The old
|
|
213
|
+
* multi-line shape satisfies this test too, so replayed transcripts are
|
|
214
|
+
* unaffected. */
|
|
111
215
|
export function hasLedgerBlock(text) {
|
|
112
216
|
if (typeof text !== "string")
|
|
113
217
|
return false;
|
|
114
218
|
const first = text.split("\n", 1)[0];
|
|
115
|
-
return first.startsWith(LEDGER_HEAD) && first.
|
|
219
|
+
return first.startsWith(LEDGER_HEAD) && first.includes("⟧");
|
|
116
220
|
}
|
|
117
|
-
/** Remove a leading ledger block
|
|
118
|
-
*
|
|
119
|
-
*
|
|
221
|
+
/** Remove a leading ledger block, leaving whatever came after (the ⟦platform⟧
|
|
222
|
+
* header and the user's message). A plain message is returned untouched.
|
|
223
|
+
*
|
|
224
|
+
* The block is one line now, so that is usually all this drops — but it must
|
|
225
|
+
* keep understanding the HISTORICAL multi-line shape (header, `• ` bullets,
|
|
226
|
+
* indented `↳` headlines), because `loadHistory` replays transcripts written
|
|
227
|
+
* before the format changed and a block it fails to strip is a block the user
|
|
228
|
+
* reads in the chat. */
|
|
120
229
|
export function stripLedgerBlock(text) {
|
|
121
230
|
if (!hasLedgerBlock(text))
|
|
122
231
|
return text;
|
|
123
232
|
const lines = text.split("\n");
|
|
124
233
|
let i = 1; // drop the header line
|
|
125
|
-
while (i < lines.length && lines[i].startsWith("• "))
|
|
234
|
+
while (i < lines.length && (lines[i].startsWith("• ") || CONT_LINE.test(lines[i])))
|
|
126
235
|
i++;
|
|
127
236
|
return lines.slice(i).join("\n");
|
|
128
237
|
}
|