hafez 2.0.0 → 2.2.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/README.md +24 -4
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +61 -40
- package/dist/batch-ops.d.ts +7 -1
- package/dist/batch-ops.js +9 -5
- package/dist/cli/block-hook.js +24 -9
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +108 -50
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.js +42 -4
- package/dist/cli/index.js +32 -20
- package/dist/cli/install.d.ts +22 -1
- package/dist/cli/install.js +78 -21
- package/dist/cli/onboard.js +38 -9
- package/dist/cli/upgrade.d.ts +4 -2
- package/dist/cli/upgrade.js +21 -7
- package/dist/contracts.js +2 -0
- package/dist/dates.d.ts +4 -0
- package/dist/dates.js +12 -1
- package/dist/db.d.ts +18 -8
- package/dist/db.js +96 -52
- package/dist/git.js +74 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -10
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/surfaces/items.d.ts +56 -1
- package/dist/surfaces/items.js +94 -11
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +31 -16
- package/dist/types.d.ts +67 -19
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +20 -7
- package/dist/vault.js +29 -8
- package/package.json +2 -2
- package/skill/hafez/SKILL.md +5 -2
package/dist/surfaces/items.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ import type { Mode } from '../cli/install.js';
|
|
|
9
9
|
export type SurfaceId = 'block' | 'hafezSkillDescription' | 'hafezSkillBody' | 'hafezSaveDescription' | 'hafezSaveBody' | 'read' | 'readHeader' | 'readFooter' | 'readMiss' | 'updateSuccess' | 'updateMisuse' | 'createKnowledge' | 'digestError' | 'batchMisuse' | 'mechanised' | 'schema' | 'onboard';
|
|
10
10
|
/** Places another Item inside this one's text, counted against the same Surface. */
|
|
11
11
|
export type Place = (id: string) => string;
|
|
12
|
+
/** What a static Surface is composed for. `vaultRepo`: the Block for a cloud
|
|
13
|
+
* container, whose vault is that GitHub repo (#160). */
|
|
14
|
+
export interface SurfaceCtx {
|
|
15
|
+
vaultRepo?: string;
|
|
16
|
+
}
|
|
12
17
|
export interface Item {
|
|
13
18
|
/** The Item's row text in the owner table. */
|
|
14
19
|
label: string;
|
|
@@ -21,7 +26,7 @@ export interface Item {
|
|
|
21
26
|
evidence: string;
|
|
22
27
|
/** Static text. A function form may place nested Items, and may give the
|
|
23
28
|
* fallback a shorter form than the Owner. */
|
|
24
|
-
text?: string | ((place: Place, at: 'owner' | 'fallback') => string);
|
|
29
|
+
text?: string | ((place: Place, at: 'owner' | 'fallback', ctx: SurfaceCtx) => string);
|
|
25
30
|
/** What the binary computes: for Items owned by command output, and for a
|
|
26
31
|
* static Item a binary Surface places filled in (the create line in the
|
|
27
32
|
* `read` miss). */
|
|
@@ -94,6 +99,19 @@ declare function renderUpdateSummary({ slug, fields, result }: {
|
|
|
94
99
|
/** The suffix a Stale open line carries on `read`: its age, or that it is
|
|
95
100
|
* undated. A fresh line carries none. */
|
|
96
101
|
declare function renderStaleMarker({ stale, age_days }: ActionAge): string;
|
|
102
|
+
/** The `read` header line naming the sessions that touched an entity, with
|
|
103
|
+
* the look back from it. None: no line. */
|
|
104
|
+
declare function renderSessionsLine({ slug, count, latest }: {
|
|
105
|
+
slug: string;
|
|
106
|
+
count: number;
|
|
107
|
+
latest: string | null;
|
|
108
|
+
}): string;
|
|
109
|
+
/** The `read` header line naming an entity's archived log entries, with the
|
|
110
|
+
* command that reads them. No archive: no line. */
|
|
111
|
+
declare function renderArchivePointer({ slug, entries }: {
|
|
112
|
+
slug: string;
|
|
113
|
+
entries: number;
|
|
114
|
+
}): string;
|
|
97
115
|
/** The line after an update's success while Stale lines remain on the entity. */
|
|
98
116
|
declare function renderStaleReminder({ handles }: {
|
|
99
117
|
handles: string[];
|
|
@@ -129,6 +147,15 @@ declare function renderTeachingLines({ harness, mode, line2Only }: {
|
|
|
129
147
|
mode: Mode;
|
|
130
148
|
line2Only?: boolean;
|
|
131
149
|
}): string;
|
|
150
|
+
/** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
|
|
151
|
+
* and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
|
|
152
|
+
export declare const isVaultRepo: (value: string) => boolean;
|
|
153
|
+
/** `onboard --cloud`: the cloud environment's setup script. It cannot clone
|
|
154
|
+
* (it runs without GitHub credentials) and no step may fail session start: a
|
|
155
|
+
* failed install exits 0, leaving a session without Hafez rather than none. */
|
|
156
|
+
declare function renderCloudSetup({ vaultRepo }: {
|
|
157
|
+
vaultRepo: string;
|
|
158
|
+
}): string;
|
|
132
159
|
export declare const ITEMS: {
|
|
133
160
|
loadTriggers: {
|
|
134
161
|
label: string;
|
|
@@ -159,6 +186,20 @@ export declare const ITEMS: {
|
|
|
159
186
|
description: string | null;
|
|
160
187
|
}) => string;
|
|
161
188
|
};
|
|
189
|
+
sessionsLine: {
|
|
190
|
+
label: string;
|
|
191
|
+
owner: "readHeader";
|
|
192
|
+
invokeOnly: string;
|
|
193
|
+
evidence: string;
|
|
194
|
+
render: typeof renderSessionsLine;
|
|
195
|
+
};
|
|
196
|
+
archivePointer: {
|
|
197
|
+
label: string;
|
|
198
|
+
owner: "readHeader";
|
|
199
|
+
invokeOnly: string;
|
|
200
|
+
evidence: string;
|
|
201
|
+
render: typeof renderArchivePointer;
|
|
202
|
+
};
|
|
162
203
|
writeTriggers: {
|
|
163
204
|
label: string;
|
|
164
205
|
owner: "block";
|
|
@@ -354,6 +395,20 @@ export declare const ITEMS: {
|
|
|
354
395
|
evidence: string;
|
|
355
396
|
render: typeof renderHarnessPermissions;
|
|
356
397
|
};
|
|
398
|
+
cloudVault: {
|
|
399
|
+
label: string;
|
|
400
|
+
owner: "block";
|
|
401
|
+
invokeOnly: string;
|
|
402
|
+
evidence: string;
|
|
403
|
+
text: (_place: Place, _at: "owner" | "fallback", { vaultRepo }: SurfaceCtx) => string;
|
|
404
|
+
};
|
|
405
|
+
cloudSetup: {
|
|
406
|
+
label: string;
|
|
407
|
+
owner: "onboard";
|
|
408
|
+
invokeOnly: string;
|
|
409
|
+
evidence: string;
|
|
410
|
+
render: typeof renderCloudSetup;
|
|
411
|
+
};
|
|
357
412
|
teachingLines: {
|
|
358
413
|
label: string;
|
|
359
414
|
owner: "onboard";
|
package/dist/surfaces/items.js
CHANGED
|
@@ -116,6 +116,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
|
|
|
116
116
|
return undefined;
|
|
117
117
|
return value === null || value === '' || (Array.isArray(value) && value.length === 0) ? `${name} cleared` : `${name} set`;
|
|
118
118
|
};
|
|
119
|
+
/** `<n> action(s) <verb>` over a Next Actions field's one-line and array forms. */
|
|
120
|
+
function countPhrase(one, many, verb) {
|
|
121
|
+
const n = (one !== undefined ? 1 : 0) + (many?.length ?? 0);
|
|
122
|
+
return n > 0 ? `${n} action${n === 1 ? '' : 's'} ${verb}` : undefined;
|
|
123
|
+
}
|
|
119
124
|
/** What each update field did, for the success line; `null` for a field that
|
|
120
125
|
* changes nothing of its own. Keyed by every update field, so a new field
|
|
121
126
|
* without a phrase fails the build. Key order is the line's order: the
|
|
@@ -123,12 +128,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
|
|
|
123
128
|
const CHANGE_PHRASES = {
|
|
124
129
|
session_log: (_f, r) => r.logged ? 'log added' : undefined,
|
|
125
130
|
clear_actions: f => f.clear_actions ? 'actions cleared' : undefined,
|
|
126
|
-
complete_action: f => f.complete_action
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
},
|
|
131
|
+
complete_action: f => countPhrase(f.complete_action, f.complete_actions, 'ticked'),
|
|
132
|
+
complete_actions: null, // counted with complete_action
|
|
133
|
+
remove_action: f => countPhrase(f.remove_action, f.remove_actions, 'removed'),
|
|
134
|
+
remove_actions: null, // counted with remove_action
|
|
135
|
+
add_actions: f => countPhrase(f.add_action, f.add_actions, 'added'),
|
|
132
136
|
add_action: null, // counted with add_actions
|
|
133
137
|
brief: setOrCleared('brief'),
|
|
134
138
|
current_state: f => f.current_state !== undefined ? 'current state set' : undefined,
|
|
@@ -158,6 +162,20 @@ function renderStaleMarker({ stale, age_days }) {
|
|
|
158
162
|
return '';
|
|
159
163
|
return age_days === null ? ' · stale, undated' : ` · stale ${age_days}d`;
|
|
160
164
|
}
|
|
165
|
+
/** The `read` header line naming the sessions that touched an entity, with
|
|
166
|
+
* the look back from it. None: no line. */
|
|
167
|
+
function renderSessionsLine({ slug, count, latest }) {
|
|
168
|
+
if (count === 0)
|
|
169
|
+
return '';
|
|
170
|
+
return `sessions: ${count}${latest ? ` · latest ${latest}` : ''} · hafez query --kind session --related-to ${slug}`;
|
|
171
|
+
}
|
|
172
|
+
/** The `read` header line naming an entity's archived log entries, with the
|
|
173
|
+
* command that reads them. No archive: no line. */
|
|
174
|
+
function renderArchivePointer({ slug, entries }) {
|
|
175
|
+
if (entries === 0)
|
|
176
|
+
return '';
|
|
177
|
+
return `archive: ${entries} older log ${entries === 1 ? 'entry' : 'entries'} · hafez read ${slug} --archive`;
|
|
178
|
+
}
|
|
161
179
|
/** The line after an update's success while Stale lines remain on the entity. */
|
|
162
180
|
function renderStaleReminder({ handles }) {
|
|
163
181
|
const n = handles.length;
|
|
@@ -217,6 +235,38 @@ function renderTeachingLines({ harness, mode, line2Only = false }) {
|
|
|
217
235
|
'4. If a hafez command ever asks for permission, setup is unfinished: run `hafez onboard` again.',
|
|
218
236
|
].join('\n');
|
|
219
237
|
}
|
|
238
|
+
/** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
|
|
239
|
+
* and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
|
|
240
|
+
export const isVaultRepo = (value) => /^[A-Za-z0-9-]+\/[\w.-]+$/.test(value) && !/\/\.\.?$/.test(value) && !value.endsWith('.git');
|
|
241
|
+
/** Where a cloud container clones a repo attached to the session. */
|
|
242
|
+
const CLOUD_HOME = '/home/user';
|
|
243
|
+
const repoName = (vaultRepo) => vaultRepo.slice(vaultRepo.indexOf('/') + 1);
|
|
244
|
+
/** The Block's self-heal rule in a cloud container: a session started without
|
|
245
|
+
* the vault attached attaches, clones and registers it (verified, #160). */
|
|
246
|
+
function renderCloudVault(vaultRepo) {
|
|
247
|
+
const path = `${CLOUD_HOME}/${repoName(vaultRepo)}`;
|
|
248
|
+
return `If \`hafez init\` shows no registered vault: attach \`${vaultRepo}\` to this session (add_repo, push access), clone it to ${path}, run \`hafez init --register ${path}\`, then continue. Never create a throwaway vault.`;
|
|
249
|
+
}
|
|
250
|
+
/** `onboard --cloud`: the cloud environment's setup script. It cannot clone
|
|
251
|
+
* (it runs without GitHub credentials) and no step may fail session start: a
|
|
252
|
+
* failed install exits 0, leaving a session without Hafez rather than none. */
|
|
253
|
+
function renderCloudSetup({ vaultRepo }) {
|
|
254
|
+
return [
|
|
255
|
+
`Each Claude Code cloud session is a fresh container: no \`hafez\`, no vault, no Block. Paste this into the cloud environment's Setup script; the Trusted network policy is enough.`,
|
|
256
|
+
'',
|
|
257
|
+
fence([
|
|
258
|
+
'#!/bin/bash',
|
|
259
|
+
'npm install -g hafez --allow-scripts=better-sqlite3 || exit 0',
|
|
260
|
+
`VAULT=${CLOUD_HOME}/${repoName(vaultRepo)}`,
|
|
261
|
+
'if [ -d "$VAULT/.git" ]; then',
|
|
262
|
+
' hafez init --register "$VAULT"',
|
|
263
|
+
'fi',
|
|
264
|
+
`hafez onboard --install --mode always-on --harness claude-code --vault-repo ${vaultRepo} || true`,
|
|
265
|
+
].join('\n')),
|
|
266
|
+
'',
|
|
267
|
+
`Attach ${vaultRepo} when you start a session, with push access, and the script registers it. The script cannot clone it: it runs without GitHub credentials. A session started without it attached is told by the Block to attach, clone and register it.`,
|
|
268
|
+
].join('\n');
|
|
269
|
+
}
|
|
220
270
|
const BATCH_EXAMPLE_OP = '{"op":"create","kind":"entity","name":"Token Rotation","fields":{"type":"entity"}}';
|
|
221
271
|
export const ITEMS = {
|
|
222
272
|
loadTriggers: {
|
|
@@ -255,13 +305,28 @@ export const ITEMS = {
|
|
|
255
305
|
* in brackets when it has one. */
|
|
256
306
|
render: ({ slug, description }) => description ? `${slug} (${description})` : slug,
|
|
257
307
|
},
|
|
308
|
+
sessionsLine: {
|
|
309
|
+
label: 'Sessions line: the look back from an entity',
|
|
310
|
+
owner: 'readHeader',
|
|
311
|
+
invokeOnly: 'same',
|
|
312
|
+
evidence: '#35',
|
|
313
|
+
render: renderSessionsLine,
|
|
314
|
+
},
|
|
315
|
+
archivePointer: {
|
|
316
|
+
label: 'Archive pointer',
|
|
317
|
+
owner: 'readHeader',
|
|
318
|
+
invokeOnly: 'same',
|
|
319
|
+
evidence: '#34',
|
|
320
|
+
render: renderArchivePointer,
|
|
321
|
+
},
|
|
258
322
|
writeTriggers: {
|
|
259
|
-
label: 'Write triggers: work landed, insight, weighing, "note this"',
|
|
323
|
+
label: 'Write triggers: work landed, decision made, insight, weighing, "note this"',
|
|
260
324
|
owner: 'block',
|
|
261
325
|
invokeOnly: 'absent',
|
|
262
|
-
evidence: 'M11, M2, never-ask decision #42',
|
|
326
|
+
evidence: 'M11, M2, never-ask decision #42, decision-record miss #159',
|
|
263
327
|
text: place => [
|
|
264
|
-
'- Work landed (a commit, a merged PR, CI green): update the entity you read. The end of its `read` output shows the command.',
|
|
328
|
+
'- Work landed (a commit, a merged PR, CI green): update the entity that owns the work. That is the entity you read, unless the work belongs to another: read that one first. The end of its `read` output shows the command.',
|
|
329
|
+
'- A decision was made: if the repo keeps its own decisions record, write it there now and give Hafez one line that points at it (`hafez update <slug> --log "decision: <one line>, see decisions.md"`). Otherwise it goes in the save\'s `decisions`.',
|
|
265
330
|
`- An insight emerged that is useful beyond this repo: create a knowledge note. ${place('knowledgeBoundary')}`,
|
|
266
331
|
'- The user says "note this" or "record that": write it.',
|
|
267
332
|
`- You are weighing whether something is worth writing: write it, as knowledge with \`--confidence ${CONFIDENCE_LEVELS[0]}\`.`,
|
|
@@ -434,9 +499,11 @@ export const ITEMS = {
|
|
|
434
499
|
evidence: 'help decision #49',
|
|
435
500
|
text: fence([
|
|
436
501
|
`hafez query --filter ${oneOf([...ENTITY_STATUSES, 'stale', 'capture'])}`,
|
|
437
|
-
'hafez search "<terms>" --kind <entity|knowledge>',
|
|
502
|
+
'hafez search "<terms>" --kind <entity|knowledge|session|all>',
|
|
503
|
+
'hafez query --kind session --related-to <slug>',
|
|
438
504
|
'hafez stats',
|
|
439
|
-
].join('\n'))
|
|
505
|
+
].join('\n'))
|
|
506
|
+
+ '\n\n`search` and `query` reach entities and knowledge notes; `--kind all` adds the session notes, `--kind session` lists them alone, newest first. `hafez read <slug> --archive` prints an entity\'s archived log entries.',
|
|
440
507
|
},
|
|
441
508
|
rareOps: {
|
|
442
509
|
label: 'Rare ops: promote, link, unlink, status, delete, validate',
|
|
@@ -513,6 +580,22 @@ export const ITEMS = {
|
|
|
513
580
|
evidence: 'never-ask decision #42; onboard decision #58',
|
|
514
581
|
render: renderHarnessPermissions,
|
|
515
582
|
},
|
|
583
|
+
cloudVault: {
|
|
584
|
+
label: 'Cloud session: no vault registered, attach the vault repo, clone, register',
|
|
585
|
+
owner: 'block',
|
|
586
|
+
invokeOnly: 'absent',
|
|
587
|
+
evidence: '#160, verified 2026-10-16',
|
|
588
|
+
// Placed only in a Block composed for a cloud container, whose begin
|
|
589
|
+
// marker names the repo (renderBlock).
|
|
590
|
+
text: (_place, _at, { vaultRepo = '<owner>/<repo>' }) => renderCloudVault(vaultRepo),
|
|
591
|
+
},
|
|
592
|
+
cloudSetup: {
|
|
593
|
+
label: 'The cloud environment\'s setup script',
|
|
594
|
+
owner: 'onboard',
|
|
595
|
+
invokeOnly: 'not offered',
|
|
596
|
+
evidence: '#160, verified 2026-10-16',
|
|
597
|
+
render: renderCloudSetup,
|
|
598
|
+
},
|
|
516
599
|
teachingLines: {
|
|
517
600
|
label: 'The four teaching lines (to the user, not the agent)',
|
|
518
601
|
owner: 'onboard',
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { type FolderMatch, type Item, type ItemId, type SurfaceId } from './items.js';
|
|
1
|
+
import { type FolderMatch, type Item, type ItemId, type SurfaceCtx, type SurfaceId } from './items.js';
|
|
2
2
|
export interface Surface<Id extends string = ItemId> {
|
|
3
3
|
/** How the owner table names this Surface. */
|
|
4
4
|
label: string;
|
|
5
5
|
/** Static Surfaces only: the text, built by placing Items. */
|
|
6
|
-
compose?: (place: (id: Id) => string) => string;
|
|
6
|
+
compose?: (place: (id: Id) => string, ctx: SurfaceCtx) => string;
|
|
7
7
|
}
|
|
8
8
|
export declare const SURFACES: Record<SurfaceId, Surface>;
|
|
9
9
|
export declare const contentHash: (text: string) => string;
|
|
10
10
|
/** An Item's text as a binary Surface places it at run time, in the form its
|
|
11
11
|
* role there (Owner or fallback) calls for. */
|
|
12
12
|
export declare function placeIn(surfaceId: SurfaceId, id: ItemId): string;
|
|
13
|
-
|
|
13
|
+
/** The Block; with `vaultRepo`, the Block for a cloud container whose vault
|
|
14
|
+
* is that repo. Its begin marker names the repo, so a check reads it from
|
|
15
|
+
* there and never from the Block's wording, which a release may change. */
|
|
16
|
+
export declare function renderBlock(vaultRepo?: string): string;
|
|
14
17
|
/** `onboard --block`, what the SessionStart hook prints: the Block, then the
|
|
15
18
|
* read line for each entity matching the folder. */
|
|
16
19
|
export declare function renderBlockHook(matches: FolderMatch[]): string;
|
|
@@ -26,7 +29,7 @@ export declare function renderOwnerTable(): string;
|
|
|
26
29
|
/** Replace the marked owner-table region of `doc` with a fresh render. */
|
|
27
30
|
export declare function spliceOwnerTable(doc: string): string;
|
|
28
31
|
/** Every problem with the Item table: an Item with no Owner or no evidence
|
|
29
|
-
* pointer, an Item its Owner or static fallback never places, or one placed more
|
|
30
|
-
* Surface or in a Surface that is neither its Owner nor its declared fallback.
|
|
32
|
+
* pointer, an Item its Owner or static fallback never places in any context, or one placed more
|
|
33
|
+
* than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
|
|
31
34
|
* Placement is checked for static Surfaces only; binary ones place at run time. */
|
|
32
35
|
export declare function itemProblems(items: Record<string, Item>, surfaces: Record<string, Surface<string>>): string[];
|
|
@@ -13,11 +13,12 @@ import { ITEMS } from './items.js';
|
|
|
13
13
|
export const SURFACES = {
|
|
14
14
|
block: {
|
|
15
15
|
label: 'Block',
|
|
16
|
-
compose: place => [
|
|
16
|
+
compose: (place, { vaultRepo }) => [
|
|
17
17
|
'## Hafez',
|
|
18
18
|
'',
|
|
19
19
|
'Hafez is this user\'s vault: what they are working on (entities) and what they have learned (knowledge). You reach it only through the `hafez` command.',
|
|
20
20
|
'',
|
|
21
|
+
...(vaultRepo === undefined ? [] : [`**Cloud session.** ${place('cloudVault')}`, '']),
|
|
21
22
|
`**Load.** ${place('loadTriggers')}`,
|
|
22
23
|
'',
|
|
23
24
|
'**Write as it happens.** Each of these is due the moment it occurs:',
|
|
@@ -96,11 +97,14 @@ export const SURFACES = {
|
|
|
96
97
|
schema: { label: '`schema`' },
|
|
97
98
|
onboard: { label: '`onboard`' },
|
|
98
99
|
};
|
|
100
|
+
/** Every context a static Surface is composed in: the default, and the
|
|
101
|
+
* Block for a cloud container. */
|
|
102
|
+
const CONTEXTS = [{}, { vaultRepo: '<owner>/<repo>' }];
|
|
99
103
|
export const contentHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
100
104
|
/** Compose a static Surface, placing each Item's text in the form its role
|
|
101
105
|
* (Owner or fallback) calls for. `onPlace` sees every placement, nested ones
|
|
102
106
|
* included. */
|
|
103
|
-
function composeWith(items, surfaceId, surface, onPlace = () => { }) {
|
|
107
|
+
function composeWith(items, surfaceId, surface, ctx = {}, onPlace = () => { }) {
|
|
104
108
|
const place = (id) => {
|
|
105
109
|
onPlace(id);
|
|
106
110
|
const item = items[id];
|
|
@@ -108,16 +112,16 @@ function composeWith(items, surfaceId, surface, onPlace = () => { }) {
|
|
|
108
112
|
throw new Error(`${surfaceId} places unknown Item ${id}`);
|
|
109
113
|
const text = item.text;
|
|
110
114
|
if (typeof text === 'function')
|
|
111
|
-
return text(place, item.owner === surfaceId ? 'owner' : 'fallback');
|
|
115
|
+
return text(place, item.owner === surfaceId ? 'owner' : 'fallback', ctx);
|
|
112
116
|
if (text === undefined)
|
|
113
117
|
throw new Error(`${surfaceId} places ${id}, which has no text`);
|
|
114
118
|
return text;
|
|
115
119
|
};
|
|
116
120
|
if (!surface.compose)
|
|
117
121
|
throw new Error(`${surfaceId} is not a static surface`);
|
|
118
|
-
return surface.compose(place);
|
|
122
|
+
return surface.compose(place, ctx);
|
|
119
123
|
}
|
|
120
|
-
const compose = (id) => composeWith(ITEMS, id, SURFACES[id]);
|
|
124
|
+
const compose = (id, ctx = {}) => composeWith(ITEMS, id, SURFACES[id], ctx);
|
|
121
125
|
/** An Item's text as a binary Surface places it at run time, in the form its
|
|
122
126
|
* role there (Owner or fallback) calls for. */
|
|
123
127
|
export function placeIn(surfaceId, id) {
|
|
@@ -147,9 +151,13 @@ function skillFile(name, description, body) {
|
|
|
147
151
|
const text = compose(body);
|
|
148
152
|
return `---\nname: ${name}\ndescription: >\n${fold(compose(description))}\nhash: ${contentHash(text)}\n---\n${text}`;
|
|
149
153
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
/** The Block; with `vaultRepo`, the Block for a cloud container whose vault
|
|
155
|
+
* is that repo. Its begin marker names the repo, so a check reads it from
|
|
156
|
+
* there and never from the Block's wording, which a release may change. */
|
|
157
|
+
export function renderBlock(vaultRepo) {
|
|
158
|
+
const inner = compose('block', { vaultRepo });
|
|
159
|
+
const repo = vaultRepo === undefined ? '' : ` vault-repo=${vaultRepo}`;
|
|
160
|
+
return `<!-- hafez:begin ${contentHash(inner)}${repo} -->\n${inner}<!-- hafez:end -->\n`;
|
|
153
161
|
}
|
|
154
162
|
/** `onboard --block`, what the SessionStart hook prints: the Block, then the
|
|
155
163
|
* read line for each entity matching the folder. */
|
|
@@ -185,21 +193,28 @@ export function spliceOwnerTable(doc) {
|
|
|
185
193
|
}
|
|
186
194
|
// --- Drift check 2: the typed Item table ---
|
|
187
195
|
/** Every problem with the Item table: an Item with no Owner or no evidence
|
|
188
|
-
* pointer, an Item its Owner or static fallback never places, or one placed more
|
|
189
|
-
* Surface or in a Surface that is neither its Owner nor its declared fallback.
|
|
196
|
+
* pointer, an Item its Owner or static fallback never places in any context, or one placed more
|
|
197
|
+
* than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
|
|
190
198
|
* Placement is checked for static Surfaces only; binary ones place at run time. */
|
|
191
199
|
export function itemProblems(items, surfaces) {
|
|
192
200
|
const problems = [];
|
|
201
|
+
// Per Surface, the most times each Item is placed in one of its contexts.
|
|
193
202
|
const placed = {};
|
|
194
203
|
for (const [sid, surface] of Object.entries(surfaces)) {
|
|
195
204
|
if (!surface.compose)
|
|
196
205
|
continue;
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
206
|
+
const most = (placed[sid] = {});
|
|
207
|
+
for (const ctx of CONTEXTS) {
|
|
208
|
+
const counts = {};
|
|
209
|
+
try {
|
|
210
|
+
composeWith(items, sid, surface, ctx, id => { counts[id] = (counts[id] ?? 0) + 1; });
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
if (!problems.includes(err.message))
|
|
214
|
+
problems.push(err.message);
|
|
215
|
+
}
|
|
216
|
+
for (const [id, n] of Object.entries(counts))
|
|
217
|
+
most[id] = Math.max(most[id] ?? 0, n);
|
|
203
218
|
}
|
|
204
219
|
}
|
|
205
220
|
for (const [id, item] of Object.entries(items)) {
|
package/dist/types.d.ts
CHANGED
|
@@ -13,8 +13,14 @@ export type PlanStatus = typeof PLAN_STATUSES[number];
|
|
|
13
13
|
* lives on knowledge notes only. Asking for one on the entity branch is a
|
|
14
14
|
* validation error, not a silent fall back to recency, so the entity opts
|
|
15
15
|
* type declares only what that branch can actually sort by. */
|
|
16
|
-
export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count';
|
|
17
|
-
export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count'>;
|
|
16
|
+
export type SortField = 'last_touched' | 'created' | 'name' | 'staleness' | 'last_reinforced' | 'reinforcement_count' | 'session_date';
|
|
17
|
+
export type EntitySortField = Exclude<SortField, 'last_reinforced' | 'reinforcement_count' | 'session_date'>;
|
|
18
|
+
export type KnowledgeSortField = Exclude<SortField, 'session_date'>;
|
|
19
|
+
/** A session note carries only its dates and its name. */
|
|
20
|
+
export type SessionSortField = 'session_date' | 'created' | 'name';
|
|
21
|
+
/** What `search` reaches. Unset is the working set, entity + knowledge;
|
|
22
|
+
* `all` is literal, sessions included. */
|
|
23
|
+
export type SearchKind = 'entity' | 'knowledge' | 'session' | 'all';
|
|
18
24
|
export interface EntityQueryOpts {
|
|
19
25
|
filter?: QueryFilter;
|
|
20
26
|
type?: EntityType;
|
|
@@ -39,14 +45,28 @@ export interface KnowledgeQueryOpts {
|
|
|
39
45
|
createdSince?: string;
|
|
40
46
|
createdBefore?: string;
|
|
41
47
|
tags?: string[];
|
|
42
|
-
sort_by?:
|
|
48
|
+
sort_by?: KnowledgeSortField;
|
|
49
|
+
sort_order?: 'asc' | 'desc';
|
|
50
|
+
limit?: number;
|
|
51
|
+
offset?: number;
|
|
52
|
+
}
|
|
53
|
+
/** Sessions carry no status, domain or tags: the time window (`since`/`before`
|
|
54
|
+
* against `session-date`), sort and paging are all a session query takes. */
|
|
55
|
+
export interface SessionQueryOpts {
|
|
56
|
+
since?: string;
|
|
57
|
+
before?: string;
|
|
58
|
+
createdSince?: string;
|
|
59
|
+
createdBefore?: string;
|
|
60
|
+
sort_by?: SessionSortField;
|
|
43
61
|
sort_order?: 'asc' | 'desc';
|
|
44
62
|
limit?: number;
|
|
45
63
|
offset?: number;
|
|
46
64
|
}
|
|
47
65
|
/** `kind: 'all'` (or unset): reaches both sides, so it takes opts from both
|
|
48
66
|
* and can honour every sort field. Status filters narrow the entity side only
|
|
49
|
-
* — knowledge notes carry no status and are never excluded by one.
|
|
67
|
+
* — knowledge notes carry no status and are never excluded by one. Unset
|
|
68
|
+
* is the working set, entity + knowledge; `all` is literal and reaches the
|
|
69
|
+
* sessions too. */
|
|
50
70
|
export interface AllQueryOpts extends Omit<EntityQueryOpts, 'sort_by'> {
|
|
51
71
|
kind?: 'all';
|
|
52
72
|
confidence?: ConfidenceLevel;
|
|
@@ -63,6 +83,8 @@ export type UnifiedQueryOpts = AllQueryOpts | (EntityQueryOpts & {
|
|
|
63
83
|
kind: 'entity';
|
|
64
84
|
}) | (KnowledgeQueryOpts & {
|
|
65
85
|
kind: 'knowledge';
|
|
86
|
+
}) | (SessionQueryOpts & {
|
|
87
|
+
kind: 'session';
|
|
66
88
|
});
|
|
67
89
|
export interface EntityFrontmatter {
|
|
68
90
|
name: string;
|
|
@@ -114,11 +136,8 @@ export type ParsedVaultFile = {
|
|
|
114
136
|
frontmatter: SessionFrontmatter;
|
|
115
137
|
body: string;
|
|
116
138
|
};
|
|
117
|
-
/** What read() returns:
|
|
118
|
-
|
|
119
|
-
export type ParsedFile = Extract<ParsedVaultFile, {
|
|
120
|
-
kind: 'entity' | 'knowledge';
|
|
121
|
-
}>;
|
|
139
|
+
/** What read() returns: any vault file, so consumers narrow on `kind`. */
|
|
140
|
+
export type ParsedFile = ParsedVaultFile;
|
|
122
141
|
export interface SessionLogEntry {
|
|
123
142
|
type: SessionLogType;
|
|
124
143
|
summary: string;
|
|
@@ -141,7 +160,9 @@ export interface UpdateFields {
|
|
|
141
160
|
add_action?: string;
|
|
142
161
|
add_actions?: string[];
|
|
143
162
|
complete_action?: string;
|
|
163
|
+
complete_actions?: string[];
|
|
144
164
|
remove_action?: string;
|
|
165
|
+
remove_actions?: string[];
|
|
145
166
|
clear_actions?: boolean;
|
|
146
167
|
parent?: string;
|
|
147
168
|
description?: string | null;
|
|
@@ -158,6 +179,8 @@ export interface UpdateFields {
|
|
|
158
179
|
export interface UpdateResult {
|
|
159
180
|
completed_action?: string;
|
|
160
181
|
removed_action?: string;
|
|
182
|
+
completed_actions?: string[];
|
|
183
|
+
removed_actions?: string[];
|
|
161
184
|
logged?: true;
|
|
162
185
|
}
|
|
163
186
|
export interface CreateEntityFields {
|
|
@@ -217,10 +240,19 @@ export interface KnowledgeQueryResult {
|
|
|
217
240
|
last_reinforced: string | null;
|
|
218
241
|
last_touched: string | null;
|
|
219
242
|
}
|
|
243
|
+
export interface SessionQueryResult {
|
|
244
|
+
slug: string;
|
|
245
|
+
name: string;
|
|
246
|
+
session_date: string;
|
|
247
|
+
created: string;
|
|
248
|
+
related: string[];
|
|
249
|
+
}
|
|
220
250
|
export type UnifiedResult = (QueryResult & {
|
|
221
251
|
kind: 'entity';
|
|
222
252
|
}) | (KnowledgeQueryResult & {
|
|
223
253
|
kind: 'knowledge';
|
|
254
|
+
}) | (SessionQueryResult & {
|
|
255
|
+
kind: 'session';
|
|
224
256
|
});
|
|
225
257
|
export interface ValidationIssue {
|
|
226
258
|
slug: string;
|
|
@@ -233,10 +265,11 @@ export interface ValidationReport {
|
|
|
233
265
|
missing_fields: ValidationIssue[];
|
|
234
266
|
total_entities: number;
|
|
235
267
|
total_knowledge: number;
|
|
268
|
+
total_sessions: number;
|
|
236
269
|
}
|
|
237
270
|
export interface SearchResult {
|
|
238
271
|
slug: string;
|
|
239
|
-
kind: 'entity' | 'knowledge';
|
|
272
|
+
kind: 'entity' | 'knowledge' | 'session';
|
|
240
273
|
name: string;
|
|
241
274
|
snippet: string;
|
|
242
275
|
type?: EntityType;
|
|
@@ -271,7 +304,7 @@ export interface VaultStats {
|
|
|
271
304
|
}
|
|
272
305
|
export interface ChangelogEntry {
|
|
273
306
|
slug: string;
|
|
274
|
-
kind: 'entity' | 'knowledge';
|
|
307
|
+
kind: 'entity' | 'knowledge' | 'session';
|
|
275
308
|
operation: 'created' | 'updated' | 'deleted';
|
|
276
309
|
timestamp: string;
|
|
277
310
|
commit_message: string;
|
|
@@ -351,7 +384,7 @@ export interface BatchValidationReport {
|
|
|
351
384
|
valid: boolean;
|
|
352
385
|
operations: BatchOpValidation[];
|
|
353
386
|
}
|
|
354
|
-
export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'VAULT_LOCKED';
|
|
387
|
+
export type HafezErrorCode = 'NOT_FOUND' | 'SLUG_EXISTS' | 'VALIDATION_FAILED' | 'GIT_PUSH_FAILED' | 'GIT_COMMIT_FAILED' | 'GIT_READ_FAILED' | 'VAULT_LOCKED';
|
|
355
388
|
export declare class HafezError extends Error {
|
|
356
389
|
code: HafezErrorCode;
|
|
357
390
|
details?: string[] | undefined;
|
|
@@ -366,10 +399,11 @@ export interface GitConfig {
|
|
|
366
399
|
* production (src/git.ts); tests may inject an in-memory adapter.
|
|
367
400
|
*/
|
|
368
401
|
export interface Journal {
|
|
369
|
-
/** Persist one logical change: stage written+deleted paths (vault-relative
|
|
370
|
-
* and commit under message. Must clean up
|
|
371
|
-
* staged files) before rethrowing on a
|
|
372
|
-
* failure surfaces as GIT_PUSH_FAILED
|
|
402
|
+
/** Persist one logical change: stage written+deleted paths (vault-relative,
|
|
403
|
+
* '/'-separated on every platform) and commit under message. Must clean up
|
|
404
|
+
* its own partial state (e.g. staged files) before rethrowing on a
|
|
405
|
+
* commit-stage failure; a push-stage failure surfaces as GIT_PUSH_FAILED
|
|
406
|
+
* with the commit kept. */
|
|
373
407
|
commit(written: string[], deleted: string[], message: string): Promise<void>;
|
|
374
408
|
/** Bidirectional sync with the remote, if any. */
|
|
375
409
|
sync(): Promise<{
|
|
@@ -377,9 +411,11 @@ export interface Journal {
|
|
|
377
411
|
pushed: boolean;
|
|
378
412
|
remote: boolean;
|
|
379
413
|
}>;
|
|
380
|
-
/** History-derived changelog for
|
|
414
|
+
/** History-derived changelog for the indexed dirs since a date. Empty when
|
|
415
|
+
* the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
|
|
381
416
|
changelog(since: string): Promise<ChangelogEntry[]>;
|
|
382
|
-
/** Newest history timestamp per vault file (modified = any change, added = creation).
|
|
417
|
+
/** Newest history timestamp per vault file (modified = any change, added = creation).
|
|
418
|
+
* Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
|
|
383
419
|
fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
|
|
384
420
|
}
|
|
385
421
|
export interface HafezConfig {
|
|
@@ -428,9 +464,21 @@ export interface Hafez {
|
|
|
428
464
|
items: KnowledgeQueryResult[];
|
|
429
465
|
total: number;
|
|
430
466
|
}>;
|
|
467
|
+
query_sessions(opts?: SessionQueryOpts): Promise<{
|
|
468
|
+
items: SessionQueryResult[];
|
|
469
|
+
total: number;
|
|
470
|
+
}>;
|
|
471
|
+
/** An entity's archived log entries (`entities/archive/<slug>-log.md`), as
|
|
472
|
+
* written; null when it has none. Read from disk, never indexed: the
|
|
473
|
+
* archive is reached from its entity on purpose, never found by search. */
|
|
474
|
+
archive(slug: string): Promise<string | null>;
|
|
431
475
|
validate(): Promise<ValidationReport>;
|
|
432
|
-
search(query: string, kind?:
|
|
476
|
+
search(query: string, kind?: SearchKind): Promise<SearchResult[]>;
|
|
433
477
|
rebuildIndex(): Promise<void>;
|
|
434
478
|
stats(): Promise<VaultStats>;
|
|
435
479
|
changelog(since: string): Promise<ChangelogEntry[]>;
|
|
480
|
+
/** Release the SQLite index handle. Idempotent; the next call that needs
|
|
481
|
+
* the index reopens it. Call before deleting the vault: Windows cannot
|
|
482
|
+
* remove a directory holding an open file. */
|
|
483
|
+
close(): void;
|
|
436
484
|
}
|