hafez 1.4.0 → 2.1.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 +26 -33
- package/dist/apply.d.ts +5 -2
- package/dist/apply.js +249 -114
- package/dist/batch-ops.d.ts +95 -26
- package/dist/batch-ops.js +178 -18
- package/dist/cli/args.d.ts +43 -5
- package/dist/cli/args.js +69 -8
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +4 -3
- package/dist/cli/commands.js +300 -99
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +18 -4
- package/dist/cli/index.js +73 -31
- package/dist/cli/install.d.ts +65 -0
- package/dist/cli/install.js +333 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +273 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +15 -4
- package/dist/cli/upgrade.js +103 -44
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +17 -1
- package/dist/contracts.js +46 -0
- package/dist/db.d.ts +15 -2
- package/dist/db.js +69 -48
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +41 -3
- package/dist/document.js +218 -62
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +11 -7
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -104
- package/dist/merge.js +7 -5
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +527 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/types.d.ts +49 -16
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.js +1 -1
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -210
- package/skill/SKILL.md +0 -137
package/README.md
CHANGED
|
@@ -29,18 +29,15 @@ Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
|
|
|
29
29
|
3. Create my vault — a git repo with entities/ and knowledge/ directories and an
|
|
30
30
|
initial commit. Suggest ~/vault but ask me where I want it first.
|
|
31
31
|
4. Register it: hafez init --register <vault-path>
|
|
32
|
-
5.
|
|
33
|
-
from the installed package ("$(npm root -g)/hafez/skill/SKILL.md") to
|
|
34
|
-
~/.claude/skills/hafez/SKILL.md. Any other harness: skip this — step 7
|
|
35
|
-
covers integration.
|
|
36
|
-
6. Read hafez help --agent, then verify end-to-end: create a test capture,
|
|
32
|
+
5. Read hafez help --agent, then verify end-to-end: create a test capture,
|
|
37
33
|
hafez read it back, and show me hafez stats.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
6. Run hafez onboard and follow what it prints: ask me how Hafez should
|
|
35
|
+
run, install it (the two skills, and the Block in your instructions
|
|
36
|
+
file), and, if the vault is empty, interview me to seed it with my
|
|
37
|
+
current projects.
|
|
41
38
|
```
|
|
42
39
|
|
|
43
|
-
From then on, just mention your projects by name in any session — the
|
|
40
|
+
From then on, just mention your projects by name in any session — the Block
|
|
44
41
|
teaches the agent when to read and write the vault, no commands required.
|
|
45
42
|
|
|
46
43
|
Upgrading from an earlier name of this tool? Remove the old skill directory
|
|
@@ -67,13 +64,9 @@ cd ~/vault && git init && git commit --allow-empty -m "init vault"
|
|
|
67
64
|
hafez init --register ~/vault # remember this vault (one-time)
|
|
68
65
|
hafez init # show current vault resolution
|
|
69
66
|
|
|
70
|
-
# Claude Code
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# Then, in your agent — any harness: "run hafez onboard and follow it" —
|
|
75
|
-
# guided first run (seed the vault with your current projects, choose an
|
|
76
|
-
# integration level)
|
|
67
|
+
# Then, in Claude Code or Codex: "run hafez onboard and follow it" —
|
|
68
|
+
# guided setup (choose how Hafez runs, install the two skills and the Block,
|
|
69
|
+
# seed an empty vault)
|
|
77
70
|
```
|
|
78
71
|
|
|
79
72
|
Prefer installing without the npm registry? The GitHub release tarball is the
|
|
@@ -107,14 +100,11 @@ npm install -g hafez@<version> --allow-scripts=better-sqlite3
|
|
|
107
100
|
# From the GitHub release tarball instead (npm 12+ needs --allow-remote):
|
|
108
101
|
npm install -g https://github.com/shrvn-r/hafez/releases/download/v<version>/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
|
|
109
102
|
|
|
110
|
-
# 2.
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# Under nvm, or for a symlinked dev install, `npm root -g` points at the
|
|
116
|
-
# wrong tree. Resolve it from the binary you actually run instead:
|
|
117
|
-
cp "$(dirname "$(dirname "$(readlink -f "$(which hafez)")")")/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
|
|
103
|
+
# 2. The skills, and the Block if you have one, ship inside the package, so
|
|
104
|
+
# an upgraded package leaves the installed copies stale. The new binary
|
|
105
|
+
# has `hafez upgrade`: it checks them by hash and prints the
|
|
106
|
+
# `hafez onboard --install` line that refreshes them.
|
|
107
|
+
hafez upgrade
|
|
118
108
|
|
|
119
109
|
# 3. Rebuild the read index. A schema bump invalidates it rather than
|
|
120
110
|
# rebuilding it, so the first query after an upgrade can answer empty.
|
|
@@ -126,8 +116,9 @@ probe your npm — `hafez upgrade` does probe it and prints the exact form your
|
|
|
126
116
|
npm accepts. Same instruction, two renderings; the extra flags are harmless
|
|
127
117
|
(`npm warn Unknown cli config`, then it proceeds).
|
|
128
118
|
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
A Hafez section pasted into your instructions file by hand, before the Block
|
|
120
|
+
had markers, is not checked: delete it, then install the Block with
|
|
121
|
+
`hafez onboard`.
|
|
131
122
|
|
|
132
123
|
Your vault is untouched by an upgrade: the files, the git history, and the
|
|
133
124
|
registered vault path all survive. Only the read index is invalidated.
|
|
@@ -158,7 +149,7 @@ hafez query --kind knowledge --domain security
|
|
|
158
149
|
hafez search "token rotation"
|
|
159
150
|
|
|
160
151
|
# Record progress
|
|
161
|
-
hafez update auth-refactor --log "progress: JWT flow implemented"
|
|
152
|
+
hafez update auth-refactor --log "progress: JWT flow implemented"
|
|
162
153
|
hafez update auth-refactor --complete-action "migration plan"
|
|
163
154
|
```
|
|
164
155
|
|
|
@@ -196,18 +187,20 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
|
|
|
196
187
|
| `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
|
|
197
188
|
| `hafez capture <name> [--notes]` | Quick inbox capture |
|
|
198
189
|
| `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
|
|
190
|
+
| `hafez delete <slug>` | Delete a knowledge or session note (entities retire with `--status done`) |
|
|
199
191
|
| `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
|
|
200
192
|
| `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
|
|
201
|
-
| `hafez batch [--dry-run] [--file payload.json]` | JSON ops (stdin or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
|
|
193
|
+
| `hafez batch [--dry-run] [--data '<json>' \| --file payload.json]` | JSON ops (stdin, `--data` or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
|
|
202
194
|
| `hafez stats` | Vault summary: counts, stale items, recents |
|
|
203
195
|
| `hafez changelog --since 7.days.ago` | Git-derived change history |
|
|
204
196
|
| `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
|
|
205
197
|
| `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
|
|
206
|
-
| `hafez onboard` |
|
|
198
|
+
| `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
|
|
199
|
+
| `hafez onboard --install --mode <always-on\|invoke-only>` / `--remove` | Write (or remove) the two skills and, always-on, the marked Block in the harness's instructions file |
|
|
207
200
|
| `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
|
|
208
201
|
| `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
|
|
209
202
|
|
|
210
|
-
This is not exhaustive — `hafez help
|
|
203
|
+
This is not exhaustive — `hafez --help` lists every command, and `hafez schema <op>` prints the machine-readable shape of any op. Add `--json` to any command for structured output.
|
|
211
204
|
|
|
212
205
|
## Library usage
|
|
213
206
|
|
|
@@ -246,9 +239,9 @@ for everything on the [AGENTS.md standard](https://agents.md) (Codex, Cursor,
|
|
|
246
239
|
Copilot, …). This repo follows the same convention: `AGENTS.md` is canonical
|
|
247
240
|
and `CLAUDE.md` imports it.
|
|
248
241
|
|
|
249
|
-
- **`hafez onboard`** —
|
|
250
|
-
- **`hafez help --agent`** —
|
|
251
|
-
- **
|
|
242
|
+
- **`hafez onboard`** — agent-directed setup, run from inside Claude Code or Codex (any other harness: `onboard --install --harness <h>`): where things stand (the vault, and per harness found the Block, skills, hook and allowlist), then the script the agent follows — choose always-on or invoke-only, install, allow the command, optionally add the session-start hook, seed an empty vault, and four lines for the user. Re-running it is reconfigure: it walks only what is not current.
|
|
243
|
+
- **`hafez help --agent`** — the `hafez` skill body, one text from the same source: the reference an agent reads when the skill is not installed.
|
|
244
|
+
- **Two skills, `hafez` and `hafez-save`** — written by `hafez onboard --install` to `~/.claude/skills` and `~/.agents/skills`, so every harness reading either directory gets them: the less common operations and the tool itself, and the session save. The Block, the skills and every sample of command output are rendered from one source and checked byte for byte in CI; the samples are in [`tests/fixtures/surfaces/`](tests/fixtures/surfaces/).
|
|
252
245
|
- **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
|
|
253
246
|
- **`hafez batch`** — apply a whole session's worth of updates atomically from a single JSON payload. Validation errors report every bad op at once with did-you-mean hints.
|
|
254
247
|
- **Markdown-native output** by default, `--json` everywhere for machine consumption.
|
package/dist/apply.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BatchOperation, BatchResult, CreateEntityFields, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields } from './types.js';
|
|
1
|
+
import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
|
|
2
2
|
export interface AppliedOp {
|
|
3
3
|
filesWritten: string[];
|
|
4
4
|
filesDeleted: string[];
|
|
@@ -9,7 +9,10 @@ export interface AppliedOp {
|
|
|
9
9
|
slug: string;
|
|
10
10
|
kind: 'entity' | 'knowledge';
|
|
11
11
|
}>;
|
|
12
|
-
|
|
12
|
+
/** Set by the update arm: the line each Next Actions query matched. */
|
|
13
|
+
updateResult?: UpdateResult;
|
|
14
|
+
/** Set by the delete arm: the kind of note it removed. */
|
|
15
|
+
deletedKind?: DeletableKind;
|
|
13
16
|
}
|
|
14
17
|
/** Everything an apply arm needs from its caller: where the vault is, and how
|
|
15
18
|
* to hand the Spine a pre-write snapshot for rollback. Path resolution, the
|
package/dist/apply.js
CHANGED
|
@@ -22,10 +22,11 @@ import { join, relative, dirname } from 'path';
|
|
|
22
22
|
import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
|
|
23
23
|
import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
|
|
24
24
|
import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
|
|
25
|
-
import { formatSessionLogEntry, documentFor, documentForFile } from './document.js';
|
|
25
|
+
import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
|
|
26
26
|
import { sectionsFor, statusesFor, unreachableFields, describeTarget } from './contracts.js';
|
|
27
27
|
import { localDay } from './dates.js';
|
|
28
|
-
import {
|
|
28
|
+
import { detectAgent } from './harness.js';
|
|
29
|
+
import { specFor, loggedFields } from './batch-ops.js';
|
|
29
30
|
import { HafezError } from './types.js';
|
|
30
31
|
/** A vault file that validation resolved but is gone at apply time. The vault
|
|
31
32
|
* lock excludes other hafez processes, not external actors (Obsidian, a hand
|
|
@@ -49,7 +50,7 @@ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, '
|
|
|
49
50
|
function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
|
|
50
51
|
function today() { return localDay(); }
|
|
51
52
|
// --- Field policy ---
|
|
52
|
-
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
|
|
53
|
+
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'complete_actions', 'remove_action', 'remove_actions', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
|
|
53
54
|
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
54
55
|
// An entity-only field is still reachable on a knowledge note when THAT
|
|
55
56
|
// note's contract declares the capability the field needs. The gate asks the
|
|
@@ -131,6 +132,11 @@ export function buildEntityFrontmatter(name, ef, existsFn) {
|
|
|
131
132
|
throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
|
|
132
133
|
return fm;
|
|
133
134
|
}
|
|
135
|
+
// Every action add — create, update, batch, digest — lands here, so this is
|
|
136
|
+
// the one place the added-date stamp is written (local day, never twice).
|
|
137
|
+
function addStampedAction(doc, body, action) {
|
|
138
|
+
return doc.addNextAction(body, stampAction(action, today()));
|
|
139
|
+
}
|
|
134
140
|
// Body-side optional fields (brief, actions) must behave identically in both
|
|
135
141
|
// paths, like buildEntityFrontmatter's.
|
|
136
142
|
function buildEntityBody(ef) {
|
|
@@ -139,15 +145,15 @@ function buildEntityBody(ef) {
|
|
|
139
145
|
if (ef.brief)
|
|
140
146
|
body = doc.setBrief(body, ef.brief);
|
|
141
147
|
if (ef.add_action !== undefined)
|
|
142
|
-
body = doc
|
|
148
|
+
body = addStampedAction(doc, body, ef.add_action);
|
|
143
149
|
for (const action of ef.add_actions ?? [])
|
|
144
|
-
body = doc
|
|
150
|
+
body = addStampedAction(doc, body, action);
|
|
145
151
|
return body;
|
|
146
152
|
}
|
|
147
153
|
export function buildKnowledgeFrontmatter(name, kf, existsFn) {
|
|
148
154
|
const target = { kind: 'knowledge', subtype: kf.subtype };
|
|
149
155
|
assertFieldsReachable(target, kf);
|
|
150
|
-
const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
|
|
156
|
+
const fm = { name, confidence: kf.confidence ?? 'observation', 'reinforcement-count': 0, created: today() };
|
|
151
157
|
if (kf.domain?.length)
|
|
152
158
|
fm.domain = kf.domain;
|
|
153
159
|
if (kf.related?.length) {
|
|
@@ -177,6 +183,91 @@ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
|
|
|
177
183
|
fm['session-date'] = kf['session-date'];
|
|
178
184
|
return fm;
|
|
179
185
|
}
|
|
186
|
+
// The Next Actions fields are the one group in UpdateFields that all touch
|
|
187
|
+
// the same section, so their order within an op IS their meaning. The
|
|
188
|
+
// contract: prune, tick, add — clear_actions → remove_action(s) →
|
|
189
|
+
// complete_action(s) → add_action → add_actions. Every query is resolved
|
|
190
|
+
// against the list as it stands after the clear and before any edit, so a
|
|
191
|
+
// query never sees a line another field of the same op removed or added;
|
|
192
|
+
// two queries naming one line is a collision, not a silent overwrite. The
|
|
193
|
+
// section is pruned once at the end, after the adds — a tick that empties
|
|
194
|
+
// the open list and an add in the same op edit the section in place rather
|
|
195
|
+
// than removing and recreating it. The "rebuild the list" payload
|
|
196
|
+
// (`clear_actions` + `add_actions`) does what it reads as. Contradictory
|
|
197
|
+
// payloads (clear + a query) are rejected in the validate phase, not here.
|
|
198
|
+
//
|
|
199
|
+
// Lines leaving the list go to the archive log, never nowhere: the removed
|
|
200
|
+
// line, a cleared list, the ticked lines a prune takes with the section, and
|
|
201
|
+
// — once the section holds ARCHIVE_LIMIT or more ticked lines — the oldest of
|
|
202
|
+
// them, leaving one fewer than the limit (the session log's rule). Each line
|
|
203
|
+
// takes the lines nested under it (up to the next checkbox line). Run on
|
|
204
|
+
// every update, so a list grown by hand is trimmed by the next write; never
|
|
205
|
+
// on read.
|
|
206
|
+
const ARCHIVE_LIMIT = 10;
|
|
207
|
+
function applyNextActionFields(doc, body, fields) {
|
|
208
|
+
const result = {};
|
|
209
|
+
const cleared = fields.clear_actions ? doc.actionLines(body) : [];
|
|
210
|
+
if (fields.clear_actions)
|
|
211
|
+
body = doc.clearNextActions(body);
|
|
212
|
+
const match = (field, query) => ({ field, match: doc.matchNextAction(body, query) });
|
|
213
|
+
const removes = [
|
|
214
|
+
...(fields.remove_action !== undefined ? [match('remove_action', fields.remove_action)] : []),
|
|
215
|
+
...(fields.remove_actions ?? []).map(q => match('remove_actions', q)),
|
|
216
|
+
];
|
|
217
|
+
const completes = [
|
|
218
|
+
...(fields.complete_action !== undefined ? [match('complete_action', fields.complete_action)] : []),
|
|
219
|
+
...(fields.complete_actions ?? []).map(q => match('complete_actions', q)),
|
|
220
|
+
];
|
|
221
|
+
// Caller misuse, not a Spine bug: like no-match and ambiguous (matchNextAction),
|
|
222
|
+
// it depends on the list as the earlier ops of a batch leave it, so it is
|
|
223
|
+
// resolved here rather than in the validate phase.
|
|
224
|
+
const seen = new Map();
|
|
225
|
+
for (const { field, match: m } of [...removes, ...completes]) {
|
|
226
|
+
const first = seen.get(m.line);
|
|
227
|
+
if (first !== undefined) {
|
|
228
|
+
throw new HafezError('VALIDATION_FAILED', first === field
|
|
229
|
+
? `${field} matches "${m.text}" twice — one line, one query`
|
|
230
|
+
: `${first} and ${field} both match "${m.text}" — one line, one field`);
|
|
231
|
+
}
|
|
232
|
+
seen.set(m.line, field);
|
|
233
|
+
}
|
|
234
|
+
// The singular query, when sent, is first in its list; the array form is the rest.
|
|
235
|
+
const texts = (list) => list.map(q => q.match.text);
|
|
236
|
+
const removeOffset = fields.remove_action !== undefined ? 1 : 0;
|
|
237
|
+
const completeOffset = fields.complete_action !== undefined ? 1 : 0;
|
|
238
|
+
if (removeOffset)
|
|
239
|
+
result.removed_action = removes[0].match.text;
|
|
240
|
+
if (fields.remove_actions !== undefined)
|
|
241
|
+
result.removed_actions = texts(removes.slice(removeOffset));
|
|
242
|
+
if (completeOffset)
|
|
243
|
+
result.completed_action = completes[0].match.text;
|
|
244
|
+
if (fields.complete_actions !== undefined)
|
|
245
|
+
result.completed_actions = texts(completes.slice(completeOffset));
|
|
246
|
+
// Every match is a line position in the pre-edit list. A tick leaves every
|
|
247
|
+
// position where it was; a removal shifts the lines below it — so tick
|
|
248
|
+
// first, then remove bottom-up. The archive keeps query order.
|
|
249
|
+
for (const { match: m } of completes)
|
|
250
|
+
body = doc.completeNextAction(body, m).body;
|
|
251
|
+
const removed = [];
|
|
252
|
+
for (const i of removes.map((_, i) => i).sort((x, y) => removes[y].match.line - removes[x].match.line)) {
|
|
253
|
+
({ body, line: removed[i] } = doc.removeNextAction(body, removes[i].match));
|
|
254
|
+
}
|
|
255
|
+
if (fields.add_action !== undefined)
|
|
256
|
+
body = addStampedAction(doc, body, fields.add_action);
|
|
257
|
+
if (fields.add_actions !== undefined) {
|
|
258
|
+
for (const action of fields.add_actions) {
|
|
259
|
+
body = addStampedAction(doc, body, action);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const overflow = doc.extractOldestTickedActions(body, ARCHIVE_LIMIT - 1);
|
|
263
|
+
// With no open line left the prune takes the section, ticked lines and all.
|
|
264
|
+
const pruned = doc.getNextActions(overflow.body).length === 0 ? doc.actionLines(overflow.body) : [];
|
|
265
|
+
return {
|
|
266
|
+
body: doc.pruneNextActions(overflow.body),
|
|
267
|
+
result,
|
|
268
|
+
archived: { cleared, removed, ticked: [...overflow.lines, ...pruned] },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
180
271
|
// Pure field application, like every arm above it: the Spine has already run
|
|
181
272
|
// Op Spec validation (shape + semantics). `doc` is the Document bound to the
|
|
182
273
|
// file's kind (see documentFor).
|
|
@@ -189,26 +280,8 @@ function applyUpdateFields(doc, fm, body, fields) {
|
|
|
189
280
|
if (fields.brief !== undefined) {
|
|
190
281
|
body = fields.brief === null ? doc.removeBrief(body) : doc.setBrief(body, fields.brief);
|
|
191
282
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
body = doc.addNextAction(body, fields.add_action);
|
|
195
|
-
if (fields.add_actions !== undefined) {
|
|
196
|
-
for (const action of fields.add_actions) {
|
|
197
|
-
body = doc.addNextAction(body, action);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (fields.complete_action !== undefined) {
|
|
201
|
-
const r = doc.completeNextAction(body, fields.complete_action);
|
|
202
|
-
body = r.body;
|
|
203
|
-
matchedAction = r.matched;
|
|
204
|
-
}
|
|
205
|
-
if (fields.remove_action !== undefined) {
|
|
206
|
-
const r = doc.removeNextAction(body, fields.remove_action);
|
|
207
|
-
body = r.body;
|
|
208
|
-
matchedAction = r.matched;
|
|
209
|
-
}
|
|
210
|
-
if (fields.clear_actions)
|
|
211
|
-
body = doc.clearNextActions(body);
|
|
283
|
+
const actions = applyNextActionFields(doc, body, fields);
|
|
284
|
+
body = actions.body;
|
|
212
285
|
if (fields.session_log) {
|
|
213
286
|
body = doc.prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
|
|
214
287
|
}
|
|
@@ -233,6 +306,8 @@ function applyUpdateFields(doc, fm, body, fields) {
|
|
|
233
306
|
}
|
|
234
307
|
if (fields.confidence !== undefined)
|
|
235
308
|
fm.confidence = fields.confidence;
|
|
309
|
+
if (fields.parent !== undefined)
|
|
310
|
+
fm.parent = fields.parent;
|
|
236
311
|
if (fields.tags !== undefined)
|
|
237
312
|
fm.tags = fields.tags;
|
|
238
313
|
if (fields.related !== undefined)
|
|
@@ -253,85 +328,46 @@ function applyUpdateFields(doc, fm, body, fields) {
|
|
|
253
328
|
}
|
|
254
329
|
if (fm['last-touched'] !== today())
|
|
255
330
|
fm['last-touched'] = today();
|
|
256
|
-
|
|
331
|
+
const updateResult = { ...actions.result, ...(fields.session_log && { logged: true }) };
|
|
332
|
+
return { fm, body, updateResult, archivedActions: archivedActionEntries(actions.archived) };
|
|
257
333
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const newPath = knowledgePath(vaultPath, slug);
|
|
268
|
-
// Promote must behave like create AT THE TARGET. A diverged vault can
|
|
269
|
-
// hold the same slug as both an entity and a knowledge note (findFile
|
|
270
|
-
// resolves entities first, so the validate phase sees only the entity);
|
|
271
|
-
// without this check the write below silently destroys the note.
|
|
272
|
-
if (existsSync(newPath)) {
|
|
273
|
-
throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists in ${kindDir('knowledge')}/`);
|
|
274
|
-
}
|
|
275
|
-
const notesSection = sourceDoc.findSection(body, 'Notes');
|
|
276
|
-
const notesContent = notesSection?.content ?? '';
|
|
277
|
-
const newFm = {
|
|
278
|
-
name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
|
|
279
|
-
};
|
|
280
|
-
if (fm.description)
|
|
281
|
-
newFm.description = fm.description;
|
|
282
|
-
// resource is intentionally dropped on entity→knowledge promote (entity-only field)
|
|
283
|
-
if (fm.domain)
|
|
284
|
-
newFm.domain = fm.domain;
|
|
285
|
-
if (fm.related)
|
|
286
|
-
newFm.related = fm.related;
|
|
287
|
-
if (fm.tags)
|
|
288
|
-
newFm.tags = fm.tags;
|
|
289
|
-
const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
|
|
290
|
-
const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
|
|
291
|
-
// An entities-only vault has no knowledge/ yet — create it like the
|
|
292
|
-
// create-knowledge arm does, and map any fs failure onto a HafezError
|
|
293
|
-
// so no raw ENOENT/ENOTDIR escapes the promote path.
|
|
294
|
-
try {
|
|
295
|
-
mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
|
|
296
|
-
writeFileSync(newPath, serializeFile(newFm, finalBody));
|
|
297
|
-
unlinkSync(filePath);
|
|
298
|
-
}
|
|
299
|
-
catch (err) {
|
|
300
|
-
throw new HafezError('VALIDATION_FAILED', `Failed to promote '${slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
|
|
301
|
-
}
|
|
302
|
-
modifiedFiles.push(relative(vaultPath, newPath));
|
|
303
|
-
// The deletion must be staged too — `git add` of a removed path stages
|
|
304
|
-
// the removal; omitting it leaves the vault permanently dirty.
|
|
305
|
-
deletedFiles.push(relative(vaultPath, filePath));
|
|
334
|
+
/** The archive log's entries for action lines leaving the list. Entry-shaped
|
|
335
|
+
* like the session log entries archived beside them, so the file stays one
|
|
336
|
+
* entry stream (merge.ts unions it by entry). */
|
|
337
|
+
function archivedActionEntries(archived) {
|
|
338
|
+
const entries = [];
|
|
339
|
+
const plural = (n) => n === 1 ? '' : 's';
|
|
340
|
+
if (archived.cleared.length > 0) {
|
|
341
|
+
const n = archived.cleared.length;
|
|
342
|
+
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Cleared ${n} action${plural(n)}`, body: archived.cleared.join('\n') }));
|
|
306
343
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// hijack the splice points.
|
|
314
|
-
const doc = documentFor({ kind: 'entity' });
|
|
315
|
-
let newBody = body;
|
|
316
|
-
if (target === 'entity') {
|
|
317
|
-
newBody = doc.renameSection(newBody, 'Notes', 'Context');
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
// project
|
|
321
|
-
newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
|
|
322
|
-
newBody = doc.renameSection(newBody, 'Context', 'Purpose');
|
|
323
|
-
newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
|
|
324
|
-
}
|
|
325
|
-
newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
|
|
326
|
-
type: 'decision',
|
|
327
|
-
agent: 'system',
|
|
328
|
-
summary: `Promoted from ${currentType} to ${target}`,
|
|
329
|
-
}));
|
|
330
|
-
newBody = doc.syncRelatedSection(newBody, fm);
|
|
331
|
-
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
332
|
-
modifiedFiles.push(relative(vaultPath, filePath));
|
|
344
|
+
for (const line of archived.removed) {
|
|
345
|
+
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: line }));
|
|
346
|
+
}
|
|
347
|
+
const n = archived.ticked.length;
|
|
348
|
+
if (n > 0) {
|
|
349
|
+
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Archived ${n} ticked action${plural(n)}`, body: archived.ticked.join('\n') }));
|
|
333
350
|
}
|
|
334
|
-
return
|
|
351
|
+
return entries;
|
|
352
|
+
}
|
|
353
|
+
/** Mechanised session log (#85, moved into the Spine by #107): an entity
|
|
354
|
+
* update with no session_log gets a `progress` entry naming the fields
|
|
355
|
+
* written, so history is complete whichever caller wrote it. Action-only
|
|
356
|
+
* updates are exempt, and only entities carry a Session Log. */
|
|
357
|
+
function withMechanisedLog(kind, fields) {
|
|
358
|
+
if (kind !== 'entity' || fields.session_log)
|
|
359
|
+
return fields;
|
|
360
|
+
const logged = loggedFields(fields);
|
|
361
|
+
if (logged.length === 0)
|
|
362
|
+
return fields;
|
|
363
|
+
return {
|
|
364
|
+
...fields,
|
|
365
|
+
session_log: {
|
|
366
|
+
type: 'progress',
|
|
367
|
+
summary: `Updated ${logged.map(f => f.replace(/_/g, '-')).join(', ')}`,
|
|
368
|
+
agent: fields.agent ?? detectAgent(),
|
|
369
|
+
},
|
|
370
|
+
};
|
|
335
371
|
}
|
|
336
372
|
// --- The arms ---
|
|
337
373
|
function applyUpdate(op, ctx) {
|
|
@@ -343,29 +379,32 @@ function applyUpdate(op, ctx) {
|
|
|
343
379
|
const doc = documentForFile(parsedFile);
|
|
344
380
|
let fm = parsedFile.frontmatter;
|
|
345
381
|
let body = parsedFile.body;
|
|
346
|
-
const applied = applyUpdateFields(doc, fm, body, op.fields);
|
|
382
|
+
const applied = applyUpdateFields(doc, fm, body, withMechanisedLog(kind, op.fields));
|
|
347
383
|
fm = applied.fm;
|
|
348
384
|
body = applied.body;
|
|
349
|
-
//
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
if (doc.countSessionLogEntries(body) >= 10) {
|
|
385
|
+
// Archival: action lines that left the list, and the oldest session log entry
|
|
386
|
+
const archiveEntries = [...applied.archivedActions];
|
|
387
|
+
if (doc.countSessionLogEntries(body) >= ARCHIVE_LIMIT) {
|
|
353
388
|
const extracted = doc.extractOldestSessionLogEntry(body);
|
|
354
389
|
if (extracted) {
|
|
355
|
-
|
|
356
|
-
mkdirSync(dirname(archivePath), { recursive: true });
|
|
357
|
-
appendFileSync(archivePath, extracted.entry + '\n\n');
|
|
390
|
+
archiveEntries.push(extracted.entry);
|
|
358
391
|
body = extracted.remaining;
|
|
359
|
-
archived = true;
|
|
360
392
|
}
|
|
361
393
|
}
|
|
394
|
+
const archivePath = archiveLogPath(vaultPath, op.slug);
|
|
395
|
+
const wroteArchive = archiveEntries.length > 0;
|
|
396
|
+
if (wroteArchive) {
|
|
397
|
+
ctx.captureOriginal(archivePath);
|
|
398
|
+
mkdirSync(dirname(archivePath), { recursive: true });
|
|
399
|
+
appendFileSync(archivePath, archiveEntries.map(e => e + '\n\n').join(''));
|
|
400
|
+
}
|
|
362
401
|
body = doc.syncRelatedSection(body, fm);
|
|
363
402
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
364
403
|
// Only the files THIS update wrote. Keying off the archive file merely
|
|
365
404
|
// existing swept any external hand-edit sitting in entities/archive/
|
|
366
405
|
// into an unrelated commit.
|
|
367
406
|
const files = [relative(vaultPath, filePath)];
|
|
368
|
-
if (
|
|
407
|
+
if (wroteArchive)
|
|
369
408
|
files.push(relative(vaultPath, archivePath));
|
|
370
409
|
return {
|
|
371
410
|
filesWritten: files,
|
|
@@ -374,7 +413,7 @@ function applyUpdate(op, ctx) {
|
|
|
374
413
|
commitMessage: `update: ${op.slug}`,
|
|
375
414
|
indexRemovals: [],
|
|
376
415
|
indexUpserts: [{ slug: op.slug, kind }],
|
|
377
|
-
|
|
416
|
+
updateResult: applied.updateResult,
|
|
378
417
|
};
|
|
379
418
|
}
|
|
380
419
|
function applyCreateEntity(op, ctx) {
|
|
@@ -551,7 +590,79 @@ function applyPromote(op, ctx) {
|
|
|
551
590
|
const fm = parsedFile.frontmatter;
|
|
552
591
|
const body = parsedFile.body;
|
|
553
592
|
const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
|
|
554
|
-
const
|
|
593
|
+
const modifiedFiles = [];
|
|
594
|
+
const deletedFiles = [];
|
|
595
|
+
if (op.target === 'knowledge') {
|
|
596
|
+
// Cross-kind: move file from entities/ to knowledge/. The source body
|
|
597
|
+
// is read under the entity Document binding.
|
|
598
|
+
const sourceDoc = documentFor({ kind: 'entity' });
|
|
599
|
+
const newPath = knowledgePath(vaultPath, op.slug);
|
|
600
|
+
// Promote must behave like create AT THE TARGET. A diverged vault can
|
|
601
|
+
// hold the same slug as both an entity and a knowledge note (findFile
|
|
602
|
+
// resolves entities first, so the validate phase sees only the entity);
|
|
603
|
+
// without this check the write below silently destroys the note.
|
|
604
|
+
if (existsSync(newPath)) {
|
|
605
|
+
throw new HafezError('SLUG_EXISTS', `Slug '${op.slug}' already exists in ${kindDir('knowledge')}/`);
|
|
606
|
+
}
|
|
607
|
+
const notesSection = sourceDoc.findSection(body, 'Notes');
|
|
608
|
+
const notesContent = notesSection?.content ?? '';
|
|
609
|
+
const newFm = {
|
|
610
|
+
name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
|
|
611
|
+
};
|
|
612
|
+
if (fm.description)
|
|
613
|
+
newFm.description = fm.description;
|
|
614
|
+
// resource is intentionally dropped on entity→knowledge promote (entity-only field)
|
|
615
|
+
if (fm.domain)
|
|
616
|
+
newFm.domain = fm.domain;
|
|
617
|
+
if (fm.related)
|
|
618
|
+
newFm.related = fm.related;
|
|
619
|
+
if (fm.tags)
|
|
620
|
+
newFm.tags = fm.tags;
|
|
621
|
+
const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
|
|
622
|
+
const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
|
|
623
|
+
// An entities-only vault has no knowledge/ yet — create it like the
|
|
624
|
+
// create-knowledge arm does, and map any fs failure onto a HafezError
|
|
625
|
+
// so no raw ENOENT/ENOTDIR escapes the promote path.
|
|
626
|
+
try {
|
|
627
|
+
mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
|
|
628
|
+
writeFileSync(newPath, serializeFile(newFm, finalBody));
|
|
629
|
+
unlinkSync(filePath);
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
|
|
633
|
+
}
|
|
634
|
+
modifiedFiles.push(relative(vaultPath, newPath));
|
|
635
|
+
// The deletion must be staged too — `git add` of a removed path stages
|
|
636
|
+
// the removal; omitting it leaves the vault permanently dirty.
|
|
637
|
+
deletedFiles.push(relative(vaultPath, filePath));
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
// Same-kind: capture→entity, capture→project, entity→project
|
|
641
|
+
fm.type = op.target;
|
|
642
|
+
fm['last-touched'] = today();
|
|
643
|
+
// All body reshaping goes through Document — structural sections only,
|
|
644
|
+
// so quoted heading mentions inside content can no longer suppress or
|
|
645
|
+
// hijack the splice points.
|
|
646
|
+
const doc = documentFor({ kind: 'entity' });
|
|
647
|
+
let newBody = body;
|
|
648
|
+
if (op.target === 'entity') {
|
|
649
|
+
newBody = doc.renameSection(newBody, 'Notes', 'Context');
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
// project
|
|
653
|
+
newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
|
|
654
|
+
newBody = doc.renameSection(newBody, 'Context', 'Purpose');
|
|
655
|
+
newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
|
|
656
|
+
}
|
|
657
|
+
newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
|
|
658
|
+
type: 'decision',
|
|
659
|
+
agent: 'system',
|
|
660
|
+
summary: `Promoted from ${sourceType} to ${op.target}`,
|
|
661
|
+
}));
|
|
662
|
+
newBody = doc.syncRelatedSection(newBody, fm);
|
|
663
|
+
writeFileSync(filePath, serializeFile(fm, newBody));
|
|
664
|
+
modifiedFiles.push(relative(vaultPath, filePath));
|
|
665
|
+
}
|
|
555
666
|
return {
|
|
556
667
|
filesWritten: modifiedFiles,
|
|
557
668
|
filesDeleted: deletedFiles,
|
|
@@ -561,6 +672,29 @@ function applyPromote(op, ctx) {
|
|
|
561
672
|
indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
|
|
562
673
|
};
|
|
563
674
|
}
|
|
675
|
+
function applyDelete(op, ctx) {
|
|
676
|
+
const { vaultPath } = ctx;
|
|
677
|
+
// By path, not findVaultFile's entity-first order: the Op Spec ruled out
|
|
678
|
+
// an entity, and resolved the slug to exactly one of these two.
|
|
679
|
+
const knowledgeFile = knowledgePath(vaultPath, op.slug);
|
|
680
|
+
const kind = existsSync(knowledgeFile) ? 'knowledge' : 'session';
|
|
681
|
+
const filePath = kind === 'knowledge' ? knowledgeFile : resolveFilePath(vaultPath, op.slug, 'session');
|
|
682
|
+
if (!existsSync(filePath))
|
|
683
|
+
vanished(op.slug);
|
|
684
|
+
// The snapshot is the rollback: a commit-stage failure writes it back.
|
|
685
|
+
ctx.captureOriginal(filePath);
|
|
686
|
+
unlinkSync(filePath);
|
|
687
|
+
return {
|
|
688
|
+
filesWritten: [],
|
|
689
|
+
filesDeleted: [relative(vaultPath, filePath)],
|
|
690
|
+
result: { op: 'delete', slug: op.slug, status: 'ok' },
|
|
691
|
+
commitMessage: `delete: ${op.slug} (${kind})`,
|
|
692
|
+
// Sessions are not indexed; a knowledge row goes with its file.
|
|
693
|
+
indexRemovals: kind === 'knowledge' ? [op.slug] : [],
|
|
694
|
+
indexUpserts: [],
|
|
695
|
+
deletedKind: kind,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
564
698
|
const APPLY = {
|
|
565
699
|
'update': applyUpdate,
|
|
566
700
|
'create-entity': applyCreateEntity,
|
|
@@ -570,6 +704,7 @@ const APPLY = {
|
|
|
570
704
|
'link': applyLink,
|
|
571
705
|
'unlink': applyUnlink,
|
|
572
706
|
'promote': applyPromote,
|
|
707
|
+
'delete': applyDelete,
|
|
573
708
|
};
|
|
574
709
|
/** The single apply path for every mutation. Writes vault files but never
|
|
575
710
|
* touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
|