hafez 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -4
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +61 -40
- package/dist/batch-ops.d.ts +7 -1
- package/dist/batch-ops.js +9 -5
- package/dist/cli/block-hook.js +24 -9
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +108 -50
- package/dist/cli/format.d.ts +8 -3
- package/dist/cli/format.js +42 -4
- package/dist/cli/index.js +32 -20
- package/dist/cli/install.d.ts +22 -1
- package/dist/cli/install.js +78 -21
- package/dist/cli/onboard.js +38 -9
- package/dist/cli/upgrade.d.ts +4 -2
- package/dist/cli/upgrade.js +21 -7
- package/dist/contracts.js +2 -0
- package/dist/dates.d.ts +4 -0
- package/dist/dates.js +12 -1
- package/dist/db.d.ts +18 -8
- package/dist/db.js +96 -52
- package/dist/git.js +74 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -10
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/surfaces/items.d.ts +56 -1
- package/dist/surfaces/items.js +94 -11
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +31 -16
- package/dist/types.d.ts +67 -19
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +20 -7
- package/dist/vault.js +29 -8
- package/package.json +2 -2
- package/skill/hafez/SKILL.md +5 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Hafez
|
|
2
2
|
|
|
3
|
+
[](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
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
214
|
-
const
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body:
|
|
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 = [
|
|
406
|
+
const files = [vaultRel(vaultPath, filePath)];
|
|
383
407
|
if (wroteArchive)
|
|
384
|
-
files.push(
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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(
|
|
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(
|
|
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(
|
|
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: [
|
|
687
|
+
filesDeleted: [vaultRel(vaultPath, filePath)],
|
|
666
688
|
result: { op: 'delete', slug: op.slug, status: 'ok' },
|
|
667
689
|
commitMessage: `delete: ${op.slug} (${kind})`,
|
|
668
|
-
|
|
669
|
-
indexRemovals: kind === 'knowledge' ? [op.slug] : [],
|
|
690
|
+
indexRemovals: [op.slug],
|
|
670
691
|
indexUpserts: [],
|
|
671
692
|
deletedKind: kind,
|
|
672
693
|
};
|
package/dist/batch-ops.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/cli/block-hook.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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`
|