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.
Files changed (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +249 -114
  4. package/dist/batch-ops.d.ts +95 -26
  5. package/dist/batch-ops.js +178 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +73 -31
  18. package/dist/cli/install.d.ts +65 -0
  19. package/dist/cli/install.js +333 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +273 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +15 -4
  26. package/dist/cli/upgrade.js +103 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +46 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +527 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +49 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. 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. If you are Claude Code, install the bundled skill: copy skill/SKILL.md
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
- 7. Run hafez onboard and follow what it prints: interview me to seed the
39
- vault with my current projects, then help me choose how deeply to
40
- integrate Hafez into my agent setup.
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 skill
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 only (optional): the bundled skill
71
- mkdir -p ~/.claude/skills/hafez
72
- cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
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. Claude Code only: re-copy the bundled skill. It ships inside the package,
111
- # so an upgraded package leaves the installed copy stale.
112
- mkdir -p ~/.claude/skills/hafez
113
- cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
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
- Also compare the Hafez block in your global agent instructions against the one
130
- `hafez upgrade` prints, and reconcile any differences.
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" --agent claude
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` | Agent-directed first run: seeding interview + integration setup |
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 --agent` prints the full, always-current reference. Add `--json` to any command for structured output.
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`** — an agent-directed first-run guide: a seeding interview for the empty vault, then a choice of integration level (an always-active block for the harness's global instructions file, or invoke-only). Re-running it later is the intended way to seed newly started work — on a set-up vault it's just the interview.
250
- - **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
251
- - **Claude Code skill** — an optional adapter for one harness: the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
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
- matchedAction?: string;
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 { specFor } from './batch-ops.js';
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.addNextAction(body, ef.add_action);
148
+ body = addStampedAction(doc, body, ef.add_action);
143
149
  for (const action of ef.add_actions ?? [])
144
- body = doc.addNextAction(body, action);
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
- let matchedAction;
193
- if (fields.add_action !== undefined)
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
- return { fm, body, matchedAction };
331
+ const updateResult = { ...actions.result, ...(fields.session_log && { logged: true }) };
332
+ return { fm, body, updateResult, archivedActions: archivedActionEntries(actions.archived) };
257
333
  }
258
- function promoteCore(ctx, slug, target, filePath, fm, body, kind) {
259
- const { vaultPath } = ctx;
260
- const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
261
- const modifiedFiles = [];
262
- const deletedFiles = [];
263
- if (target === 'knowledge') {
264
- // Cross-kind: move file from entities/ to knowledge/. The source body
265
- // is read under the entity Document binding.
266
- const sourceDoc = documentFor({ kind: 'entity' });
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
- else {
308
- // Same-kind: capture→entity, capture→project, entity→project
309
- fm.type = target;
310
- fm['last-touched'] = today();
311
- // All body reshaping goes through Document — structural sections only,
312
- // so quoted heading mentions inside content can no longer suppress or
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 { modifiedFiles, deletedFiles };
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
- // Session log archival
350
- const archivePath = archiveLogPath(vaultPath, op.slug);
351
- let archived = false;
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
- ctx.captureOriginal(archivePath);
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 (archived)
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
- matchedAction: applied.matchedAction,
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 { modifiedFiles, deletedFiles } = promoteCore(ctx, op.slug, op.target, filePath, fm, body, kind);
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]