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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # Hafez
2
2
 
3
+ [![CI](https://github.com/shrvn-r/hafez/actions/workflows/public.yml/badge.svg?branch=main)](https://github.com/shrvn-r/hafez/actions/workflows/public.yml)
4
+
3
5
  **A knowledge and project vault shared between you and your agents.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. Plain markdown files you own, backed by SQLite full-text search and git.
4
6
 
5
7
  ## Why
@@ -77,6 +79,23 @@ plus `--allow-remote=root`, which npm 12+ requires for a remote URL:
77
79
  npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
78
80
  ```
79
81
 
82
+ ### Cloud sessions
83
+
84
+ A Claude Code cloud session (claude.ai/code, the mobile app) starts in a fresh
85
+ container: no `hafez`, no vault, no Block. Keep the vault in a private GitHub
86
+ repo and print the environment's setup script for it:
87
+
88
+ ```bash
89
+ hafez onboard --cloud --vault-repo <owner>/<vault-repo>
90
+ ```
91
+
92
+ Paste the script into the cloud environment's Setup script; the Trusted network
93
+ policy is enough. It installs Hafez, registers the vault when it is attached to
94
+ the session, and writes a Block with one rule more: a session started without
95
+ the vault attached attaches it, clones it and registers it, with one approval
96
+ from you. The script never clones the vault itself, because it runs without
97
+ GitHub credentials. Writes push to the vault's `main` like anywhere else.
98
+
80
99
  ## Updating
81
100
 
82
101
  Tell your agent: **update hafez**. Or paste: "Run `hafez upgrade` and follow
@@ -161,7 +180,7 @@ Three kinds of documents, all markdown + YAML frontmatter:
161
180
 
162
181
  - **Entities** (`entities/*.md`) — the things you're working on. Types: `capture` (inbox item), `entity` (anything worth tracking), `project` (has outcomes). Statuses: `active | paused | done`. Bodies start from a per-type template (capture: `## Notes`; entity: `## Context`; project: `## Purpose` + `## Goals`) plus a `## Session Log` of dated agent entries; `## Brief` (freeform handoff context) and `## Next Actions` (checkbox list) appear when first written to.
163
182
  - **Knowledge** (`knowledge/*.md`) — durable insights that outlive any one project. Subtypes `insight | plan`; confidence levels `observation → pattern → principle` (promoted manually). Bodies hold `## Synthesis`, `## Evidence`, and `## Sources` — evidence accumulates over time via `--add-evidence`.
164
- - **Sessions** (`sessions/*.md`) — session summaries written via `batch`, kept as history, not indexed.
183
+ - **Sessions** (`sessions/*.md`) — session summaries written at save time, never updated. Readable (`hafez read <slug>`), listed newest first (`hafez query --kind session`), and the look back from an entity (`hafez query --related-to <slug>`); search and query reach them only under `--kind session` or `--kind all`.
165
184
 
166
185
  Frontmatter at a glance:
167
186
 
@@ -181,9 +200,9 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
181
200
 
182
201
  | Command | What it does |
183
202
  |---|---|
184
- | `hafez read <slug> [--depth frontmatter\|summary\|full]` | Read an entity or knowledge note |
185
- | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
186
- | `hafez search <terms> [--kind ...]` | FTS5 full-text search |
203
+ | `hafez read <slug> [--depth frontmatter\|summary\|full] [--archive]` | Read an entity, knowledge or session note; `--archive` an entity's archived log entries |
204
+ | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|session\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
205
+ | `hafez search <terms> [--kind entity\|knowledge\|session\|all]` | FTS5 full-text search (default: entities and knowledge) |
187
206
  | `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
188
207
  | `hafez capture <name> [--notes]` | Quick inbox capture |
189
208
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
@@ -197,6 +216,7 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
197
216
  | `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
198
217
  | `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
199
218
  | `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 |
219
+ | `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
200
220
  | `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
201
221
  | `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
202
222
 
package/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type VaultKind } from './vault.js';
1
2
  import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
2
3
  export interface AppliedOp {
3
4
  filesWritten: string[];
@@ -7,7 +8,7 @@ export interface AppliedOp {
7
8
  indexRemovals: string[];
8
9
  indexUpserts: Array<{
9
10
  slug: string;
10
- kind: 'entity' | 'knowledge';
11
+ kind: VaultKind;
11
12
  }>;
12
13
  /** Set by the update arm: the line each Next Actions query matched. */
13
14
  updateResult?: UpdateResult;
package/dist/apply.js CHANGED
@@ -18,8 +18,8 @@
18
18
  // pure (no filesystem access); the frontmatter builders live here and are
19
19
  // exported so the batch validate phase can run them against a simulated vault.
20
20
  import { writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
21
- import { join, relative, dirname } from 'path';
22
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
21
+ import { join, dirname } from 'path';
22
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, vaultRel, 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
25
  import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
@@ -50,7 +50,7 @@ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, '
50
50
  function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
51
51
  function today() { return localDay(); }
52
52
  // --- Field policy ---
53
- const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
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'];
54
54
  const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
55
55
  // An entity-only field is still reachable on a knowledge note when THAT
56
56
  // note's contract declares the capability the field needs. The gate asks the
@@ -185,8 +185,8 @@ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
185
185
  }
186
186
  // The Next Actions fields are the one group in UpdateFields that all touch
187
187
  // the same section, so their order within an op IS their meaning. The
188
- // contract: prune, tick, add — clear_actions → remove_action →
189
- // complete_action → add_action → add_actions. Every query is resolved
188
+ // contract: prune, tick, add — clear_actions → remove_action(s) →
189
+ // complete_action(s) → add_action → add_actions. Every query is resolved
190
190
  // against the list as it stands after the clear and before any edit, so a
191
191
  // query never sees a line another field of the same op removed or added;
192
192
  // two queries naming one line is a collision, not a silent overwrite. The
@@ -207,27 +207,51 @@ const ARCHIVE_LIMIT = 10;
207
207
  function applyNextActionFields(doc, body, fields) {
208
208
  const result = {};
209
209
  const cleared = fields.clear_actions ? doc.actionLines(body) : [];
210
- let removedLine;
211
210
  if (fields.clear_actions)
212
211
  body = doc.clearNextActions(body);
213
- const removed = fields.remove_action !== undefined ? doc.matchNextAction(body, fields.remove_action) : undefined;
214
- const completed = fields.complete_action !== undefined ? doc.matchNextAction(body, fields.complete_action) : undefined;
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
+ ];
215
221
  // Caller misuse, not a Spine bug: like no-match and ambiguous (matchNextAction),
216
222
  // it depends on the list as the earlier ops of a batch leave it, so it is
217
223
  // resolved here rather than in the validate phase.
218
- if (removed && completed && removed.line === completed.line) {
219
- throw new HafezError('VALIDATION_FAILED', `remove_action and complete_action both match "${removed.text}" — one line, one field`);
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));
220
254
  }
221
- if (removed)
222
- result.removed_action = removed.text;
223
- if (completed)
224
- result.completed_action = completed.text;
225
- // Both matches are line positions in the pre-edit list. A tick leaves every
226
- // position where it was; a removal shifts the lines below it — so tick first.
227
- if (completed)
228
- body = doc.completeNextAction(body, completed).body;
229
- if (removed)
230
- ({ body, line: removedLine } = doc.removeNextAction(body, removed));
231
255
  if (fields.add_action !== undefined)
232
256
  body = addStampedAction(doc, body, fields.add_action);
233
257
  if (fields.add_actions !== undefined) {
@@ -241,7 +265,7 @@ function applyNextActionFields(doc, body, fields) {
241
265
  return {
242
266
  body: doc.pruneNextActions(overflow.body),
243
267
  result,
244
- archived: { cleared, removed: removedLine, ticked: [...overflow.lines, ...pruned] },
268
+ archived: { cleared, removed, ticked: [...overflow.lines, ...pruned] },
245
269
  };
246
270
  }
247
271
  // Pure field application, like every arm above it: the Spine has already run
@@ -317,8 +341,8 @@ function archivedActionEntries(archived) {
317
341
  const n = archived.cleared.length;
318
342
  entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Cleared ${n} action${plural(n)}`, body: archived.cleared.join('\n') }));
319
343
  }
320
- if (archived.removed !== undefined) {
321
- entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: archived.removed }));
344
+ for (const line of archived.removed) {
345
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: line }));
322
346
  }
323
347
  const n = archived.ticked.length;
324
348
  if (n > 0) {
@@ -379,9 +403,9 @@ function applyUpdate(op, ctx) {
379
403
  // Only the files THIS update wrote. Keying off the archive file merely
380
404
  // existing swept any external hand-edit sitting in entities/archive/
381
405
  // into an unrelated commit.
382
- const files = [relative(vaultPath, filePath)];
406
+ const files = [vaultRel(vaultPath, filePath)];
383
407
  if (wroteArchive)
384
- files.push(relative(vaultPath, archivePath));
408
+ files.push(vaultRel(vaultPath, archivePath));
385
409
  return {
386
410
  filesWritten: files,
387
411
  filesDeleted: [],
@@ -409,7 +433,7 @@ function applyCreateEntity(op, ctx) {
409
433
  body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
410
434
  writeFileSync(filePath, serializeFile(fm, body));
411
435
  return {
412
- filesWritten: [relative(vaultPath, filePath)],
436
+ filesWritten: [vaultRel(vaultPath, filePath)],
413
437
  filesDeleted: [],
414
438
  result: { op: 'create', slug, status: 'ok', created: true },
415
439
  commitMessage: `create: ${op.name}`,
@@ -431,7 +455,7 @@ function applyCreateKnowledge(op, ctx) {
431
455
  body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
432
456
  writeFileSync(filePath, serializeFile(fm, body));
433
457
  return {
434
- filesWritten: [relative(vaultPath, filePath)],
458
+ filesWritten: [vaultRel(vaultPath, filePath)],
435
459
  filesDeleted: [],
436
460
  result: { op: 'create', slug, status: 'ok', created: true },
437
461
  commitMessage: `create: ${op.name}`,
@@ -466,15 +490,13 @@ function applyCreateSession(op, ctx) {
466
490
  let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
467
491
  sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
468
492
  writeFileSync(filePath, serializeFile(fm, sessionBody));
469
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
470
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
471
493
  return {
472
- filesWritten: [relative(vaultPath, filePath)],
494
+ filesWritten: [vaultRel(vaultPath, filePath)],
473
495
  filesDeleted: [],
474
496
  result: { op: 'create', slug, status: 'ok', created: true },
475
497
  commitMessage: `create: ${op.name}`,
476
498
  indexRemovals: [],
477
- indexUpserts: [],
499
+ indexUpserts: [{ slug, kind: 'session' }],
478
500
  };
479
501
  }
480
502
  function applyCapture(op, ctx) {
@@ -494,7 +516,7 @@ function applyCapture(op, ctx) {
494
516
  mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
495
517
  writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
496
518
  return {
497
- filesWritten: [relative(vaultPath, filePath)],
519
+ filesWritten: [vaultRel(vaultPath, filePath)],
498
520
  filesDeleted: [],
499
521
  result: { op: 'capture', slug, status: 'ok', created: true },
500
522
  commitMessage: `capture: ${op.name}`,
@@ -521,7 +543,7 @@ function applyLink(op, ctx) {
521
543
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
522
544
  writeFileSync(filePath, serializeFile(fm, body));
523
545
  return {
524
- filesWritten: [relative(vaultPath, filePath)],
546
+ filesWritten: [vaultRel(vaultPath, filePath)],
525
547
  filesDeleted: [],
526
548
  result: { op: 'link', slug: op.slug, status: 'ok' },
527
549
  commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
@@ -547,7 +569,7 @@ function applyUnlink(op, ctx) {
547
569
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
548
570
  writeFileSync(filePath, serializeFile(fm, body));
549
571
  return {
550
- filesWritten: [relative(vaultPath, filePath)],
572
+ filesWritten: [vaultRel(vaultPath, filePath)],
551
573
  filesDeleted: [],
552
574
  result: { op: 'unlink', slug: op.slug, status: 'ok' },
553
575
  commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
@@ -607,10 +629,10 @@ function applyPromote(op, ctx) {
607
629
  catch (err) {
608
630
  throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
609
631
  }
610
- modifiedFiles.push(relative(vaultPath, newPath));
632
+ modifiedFiles.push(vaultRel(vaultPath, newPath));
611
633
  // The deletion must be staged too — `git add` of a removed path stages
612
634
  // the removal; omitting it leaves the vault permanently dirty.
613
- deletedFiles.push(relative(vaultPath, filePath));
635
+ deletedFiles.push(vaultRel(vaultPath, filePath));
614
636
  }
615
637
  else {
616
638
  // Same-kind: capture→entity, capture→project, entity→project
@@ -637,7 +659,7 @@ function applyPromote(op, ctx) {
637
659
  }));
638
660
  newBody = doc.syncRelatedSection(newBody, fm);
639
661
  writeFileSync(filePath, serializeFile(fm, newBody));
640
- modifiedFiles.push(relative(vaultPath, filePath));
662
+ modifiedFiles.push(vaultRel(vaultPath, filePath));
641
663
  }
642
664
  return {
643
665
  filesWritten: modifiedFiles,
@@ -662,11 +684,10 @@ function applyDelete(op, ctx) {
662
684
  unlinkSync(filePath);
663
685
  return {
664
686
  filesWritten: [],
665
- filesDeleted: [relative(vaultPath, filePath)],
687
+ filesDeleted: [vaultRel(vaultPath, filePath)],
666
688
  result: { op: 'delete', slug: op.slug, status: 'ok' },
667
689
  commitMessage: `delete: ${op.slug} (${kind})`,
668
- // Sessions are not indexed; a knowledge row goes with its file.
669
- indexRemovals: kind === 'knowledge' ? [op.slug] : [],
690
+ indexRemovals: [op.slug],
670
691
  indexUpserts: [],
671
692
  deletedKind: kind,
672
693
  };
@@ -27,7 +27,9 @@ export declare const UpdateFieldsSchema: z.ZodObject<{
27
27
  add_action: z.ZodOptional<z.ZodString>;
28
28
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
29
  complete_action: z.ZodOptional<z.ZodString>;
30
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
30
31
  remove_action: z.ZodOptional<z.ZodString>;
32
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
31
33
  clear_actions: z.ZodOptional<z.ZodBoolean>;
32
34
  parent: z.ZodOptional<z.ZodString>;
33
35
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -110,7 +112,9 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
110
112
  add_action: z.ZodOptional<z.ZodString>;
111
113
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
114
  complete_action: z.ZodOptional<z.ZodString>;
115
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
113
116
  remove_action: z.ZodOptional<z.ZodString>;
117
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
114
118
  clear_actions: z.ZodOptional<z.ZodBoolean>;
115
119
  parent: z.ZodOptional<z.ZodString>;
116
120
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -279,7 +283,9 @@ declare const OP_SPEC_ROWS: readonly [{
279
283
  add_action: z.ZodOptional<z.ZodString>;
280
284
  add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
281
285
  complete_action: z.ZodOptional<z.ZodString>;
286
+ complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
282
287
  remove_action: z.ZodOptional<z.ZodString>;
288
+ remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
283
289
  clear_actions: z.ZodOptional<z.ZodBoolean>;
284
290
  parent: z.ZodOptional<z.ZodString>;
285
291
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -296,7 +302,7 @@ declare const OP_SPEC_ROWS: readonly [{
296
302
  add_source: z.ZodOptional<z.ZodString>;
297
303
  }, z.core.$strict>;
298
304
  }, z.core.$strict>;
299
- readonly description: "Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action` → `complete_action` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity's archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.";
305
+ readonly description: "Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action(s)` → `complete_action(s)` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity's archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.";
300
306
  readonly exampleInclude: readonly ["status"];
301
307
  readonly check: (op: Extract<BatchOperation, {
302
308
  op: "update";
package/dist/batch-ops.js CHANGED
@@ -52,7 +52,9 @@ export const UpdateFieldsSchema = z.object({
52
52
  add_action: nonEmptyText.optional(),
53
53
  add_actions: z.array(nonEmptyText).optional(),
54
54
  complete_action: nonEmptyText.optional(),
55
+ complete_actions: z.array(nonEmptyText).min(1).optional(),
55
56
  remove_action: nonEmptyText.optional(),
57
+ remove_actions: z.array(nonEmptyText).min(1).optional(),
56
58
  clear_actions: z.boolean().optional(),
57
59
  parent: z.string().optional(), // entity-only; the slug must exist, no cycle
58
60
  // Knowledge metadata
@@ -120,7 +122,7 @@ const CreateSessionBranch = z.object({
120
122
  }).strict();
121
123
  /** Update fields the mechanised session log neither counts nor names:
122
124
  * ticking a box is not history worth an entry, and `agent` only names it. */
123
- const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'agent']);
125
+ const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'complete_actions', 'remove_action', 'remove_actions', 'clear_actions', 'agent']);
124
126
  /** The fields an update's mechanised session log entry names — empty when
125
127
  * the update earns none. Shared by the update check (which rejects an
126
128
  * `agent` with no entry to name) and the Apply update arm (which writes it). */
@@ -262,7 +264,9 @@ export function duplicateSessionWarning(slug) {
262
264
  * batch). The builder runs before the claim — it must see the pre-claim
263
265
  * world so a self-referencing parent still fails. */
264
266
  function claimNew(ctx, errors, slug, info, build) {
265
- const taken = ctx.existsAnywhere(slug);
267
+ // A session note holds its slug too: every kind shares one index row per
268
+ // slug, so a create must not take one a session already has.
269
+ const taken = ctx.existsAnywhere(slug) || ctx.sessionExists(slug);
266
270
  if (taken)
267
271
  errors.push(`Slug '${slug}' already exists`);
268
272
  if (build) {
@@ -316,7 +320,7 @@ const OP_SPEC_ROWS = [
316
320
  name: 'update',
317
321
  op: 'update',
318
322
  schema: UpdateBranch,
319
- description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action` → `complete_action` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity\'s archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
323
+ description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action(s)` → `complete_action(s)` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity\'s archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
320
324
  exampleInclude: ['status'],
321
325
  check(op, ctx) {
322
326
  const errors = [];
@@ -341,9 +345,9 @@ const OP_SPEC_ROWS = [
341
345
  // only — on a knowledge note validateKindFields has already rejected
342
346
  // the fields themselves, and a second error about their order is noise.
343
347
  if (target?.kind === 'entity' && op.fields.clear_actions) {
344
- const queries = ['remove_action', 'complete_action'].filter(f => op.fields[f] !== undefined);
348
+ const queries = ['remove_action', 'remove_actions', 'complete_action', 'complete_actions'].filter(f => op.fields[f] !== undefined);
345
349
  if (queries.length > 0) {
346
- errors.push(`clear_actions empties the list before ${queries.join(' and ')} would run — drop the clear or use a separate op`);
350
+ errors.push(`clear_actions empties the list before ${queries.slice(0, -1).join(', ')}${queries.length > 1 ? ' and ' : ''}${queries.at(-1)} would run — drop the clear or use a separate op`);
347
351
  }
348
352
  }
349
353
  // `agent` names the entry the Spine adds; with nothing added it would be
@@ -26,11 +26,16 @@ export async function blockHook(ctx) {
26
26
  // leave an index file in it.
27
27
  if (typeof vaultPath !== 'string' || !isVault(vaultPath))
28
28
  return null;
29
- const cwd = realpathSync(ctx.cwd ?? process.cwd());
29
+ const dir = ctx.cwd ?? process.cwd();
30
+ // A deleted working directory: no folder to match.
31
+ if (!existsSync(dir))
32
+ return null;
33
+ const cwd = canonical(dir);
30
34
  const folders = foldersOf(cwd);
31
35
  const remotes = remoteUrls(cwd);
32
36
  const { createHafez } = await import('../index.js');
33
- const { items } = await createHafez({ vaultPath, git: { push: false } }).query({ filter: 'all' });
37
+ const os = createHafez({ vaultPath, git: { push: false } });
38
+ const { items } = await os.query({ filter: 'all' }).finally(() => os.close());
34
39
  const matches = items
35
40
  .filter(e => folders.some(f => basename(f) === e.slug) || (e.resource !== null && resourceMatches(e.resource, folders, remotes)))
36
41
  // Active first: the status tuple is ordered active, paused, done.
@@ -59,13 +64,25 @@ function foldersOf(cwd) {
59
64
  const [top, common] = out?.split('\n') ?? [];
60
65
  // Before git 2.31 `--path-format` is echoed back, not obeyed: no git root.
61
66
  if (top && common && isAbsolute(top) && isAbsolute(common)) {
62
- folders.push(top);
67
+ const root = canonical(top);
68
+ folders.push(root);
63
69
  // A linked worktree shares the main checkout's `.git` as its common dir.
64
- if (basename(common) === '.git' && dirname(common) !== top)
65
- folders.push(dirname(common));
70
+ // The shape is read off git's own path: a symlinked `.git` resolves to a
71
+ // directory with some other name.
72
+ const main = canonical(dirname(common));
73
+ if (basename(common) === '.git' && main !== root)
74
+ folders.push(main);
66
75
  }
67
76
  return [...new Set(folders)];
68
77
  }
78
+ /** One spelling per folder, so paths from git, the working directory and a
79
+ * `resource` compare equal: git prints `C:/…` on Windows, realpath `C:\…`.
80
+ * The native realpath, since only it returns the filesystem's own letter case
81
+ * (`c:\users\…` is `C:\Users\…`) and long names for 8.3 ones. */
82
+ function canonical(path) {
83
+ const abs = resolve(path);
84
+ return existsSync(abs) ? realpathSync.native(abs) : abs;
85
+ }
69
86
  /** Every remote's URL, normalised. */
70
87
  function remoteUrls(cwd) {
71
88
  const out = git(cwd, 'config', '--get-regexp', '^remote\\..*\\.url$');
@@ -82,9 +99,7 @@ function normaliseUrl(url) {
82
99
  .replace(/\.git$/, '');
83
100
  }
84
101
  function resourceMatches(resource, folders, remotes) {
85
- if (isAbsolute(resource)) {
86
- const path = resolve(resource);
87
- return folders.includes(existsSync(path) ? realpathSync(path) : path);
88
- }
102
+ if (isAbsolute(resource))
103
+ return folders.includes(canonical(resource));
89
104
  return remotes.includes(normaliseUrl(resource));
90
105
  }
@@ -9,7 +9,7 @@ export interface CommandOpts {
9
9
  export declare function cmdRead(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
10
10
  /** Every flag the query parser accepts, for the branch-support matrix. */
11
11
  export declare const QUERY_FLAG_NAMES: string[];
12
- export type QueryBranch = 'entity' | 'knowledge' | 'all' | 'related-to';
12
+ export type QueryBranch = 'entity' | 'knowledge' | 'session' | 'all' | 'related-to';
13
13
  /** Which flags each kind branch actually applies. A branch that cannot honour
14
14
  * a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
15
15
  * stale` used to return every knowledge note with exit 0, and `--confidence`