hafez 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -33
- package/dist/apply.d.ts +5 -2
- package/dist/apply.js +225 -114
- package/dist/batch-ops.d.ts +89 -26
- package/dist/batch-ops.js +176 -18
- package/dist/cli/args.d.ts +43 -5
- package/dist/cli/args.js +69 -8
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +4 -3
- package/dist/cli/commands.js +300 -99
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +18 -4
- package/dist/cli/index.js +72 -30
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +13 -4
- package/dist/cli/upgrade.js +90 -44
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +17 -1
- package/dist/contracts.js +44 -0
- package/dist/db.d.ts +15 -2
- package/dist/db.js +69 -48
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +41 -3
- package/dist/document.js +218 -62
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +11 -7
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -104
- package/dist/merge.js +7 -5
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/types.d.ts +45 -16
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.js +1 -1
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -210
- package/skill/SKILL.md +0 -137
package/dist/harness.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/harness.ts
|
|
2
|
+
// Which Harness is running this command, read from the environment. The one
|
|
3
|
+
// helper the Apply update arm, the CLI's --log and `digest` call to name the
|
|
4
|
+
// agent in a session log, and `onboard` calls to pick the Harness it sets up.
|
|
5
|
+
// Also where each Harness keeps its instructions file.
|
|
6
|
+
/** Recorded when no Harness is detectable. */
|
|
7
|
+
export const NEUTRAL_AGENT = 'agent';
|
|
8
|
+
/** Variables Codex sets on the commands it runs. CODEX_HOME and the Claude
|
|
9
|
+
* Code plugin's CODEX_COMPANION_* are deliberately absent: both can be set in
|
|
10
|
+
* a shell Codex is not running. */
|
|
11
|
+
const CODEX_VARS = ['CODEX_THREAD_ID', 'CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED'];
|
|
12
|
+
export function detectAgent(env = process.env) {
|
|
13
|
+
const set = (key) => Boolean(env[key]);
|
|
14
|
+
// Codex first: Codex started from inside Claude Code inherits CLAUDECODE,
|
|
15
|
+
// and the innermost Harness is the one running the command.
|
|
16
|
+
if (CODEX_VARS.some(set))
|
|
17
|
+
return 'codex';
|
|
18
|
+
if (set('CLAUDECODE'))
|
|
19
|
+
return 'claude-code';
|
|
20
|
+
return NEUTRAL_AGENT;
|
|
21
|
+
}
|
|
22
|
+
/** Every Harness `onboard` can configure, and where its always-loaded
|
|
23
|
+
* user-level instructions file lives, relative to the home directory (harness
|
|
24
|
+
* research, docs/research/2026-09-29-harness-instruction-surfaces.md): the
|
|
25
|
+
* first candidate that exists, else the last. Cursor keeps its User Rules in a
|
|
26
|
+
* Settings UI, not a file: `null`, and the Block is printed for manual paste.
|
|
27
|
+
* `dir` is the config directory whose presence says the Harness is installed. */
|
|
28
|
+
export const HARNESSES = {
|
|
29
|
+
'claude-code': { label: 'Claude Code', dir: '.claude', instructions: ['.claude/CLAUDE.md'] },
|
|
30
|
+
codex: { label: 'Codex', dir: '.codex', instructions: ['.codex/AGENTS.override.md', '.codex/AGENTS.md'] },
|
|
31
|
+
cursor: { label: 'Cursor', dir: '.cursor', instructions: null },
|
|
32
|
+
gemini: { label: 'Gemini CLI', dir: '.gemini', instructions: ['.gemini/GEMINI.md'] },
|
|
33
|
+
copilot: { label: 'Copilot CLI', dir: '.copilot', instructions: ['.copilot/copilot-instructions.md'] },
|
|
34
|
+
};
|
|
35
|
+
/** The Harnesses bare `onboard` sets up from inside — the ones detected from
|
|
36
|
+
* the environment — and, relative to the home directory, where each keeps
|
|
37
|
+
* what the agent applies: the SessionStart hook and the permission entries.
|
|
38
|
+
* `keystroke` invokes the `hafez-save` skill there. */
|
|
39
|
+
export const ONBOARD_HARNESSES = {
|
|
40
|
+
'claude-code': { keystroke: '/hafez-save', hooks: '.claude/settings.json', allowEntry: 'Bash(hafez:*)' },
|
|
41
|
+
codex: {
|
|
42
|
+
keystroke: '$hafez-save',
|
|
43
|
+
hooks: '.codex/hooks.json',
|
|
44
|
+
rules: '.codex/rules/default.rules',
|
|
45
|
+
rule: 'prefix_rule(pattern = ["hafez"], decision = "allow")',
|
|
46
|
+
config: '.codex/config.toml',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
export const isOnboardHarness = (value) => Object.hasOwn(ONBOARD_HARNESSES, value);
|
|
50
|
+
export const isHarness = (value) => Object.hasOwn(HARNESSES, value);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Hafez, HafezConfig } from './types.js';
|
|
2
2
|
export declare function createHafez(config: HafezConfig): Hafez;
|
|
3
3
|
export { HafezError } from './types.js';
|
|
4
|
-
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
4
|
+
export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
|
|
5
5
|
export { getBrief, getNextActions } from './document.js';
|
|
6
6
|
export { parseSessionLogHeading, parseSessionLog } from './document.js';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { parseFilePath, slugify, kindFromPath, kindDir,
|
|
2
|
+
import { parseFilePath, slugify, kindFromPath, kindDir, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
|
|
3
3
|
import { createGitJournal } from './git.js';
|
|
4
|
-
import {
|
|
4
|
+
import { validateSessionLogEntry } from './schema.js';
|
|
5
|
+
import { validateVault } from './validate.js';
|
|
5
6
|
import { documentForFile } from './document.js';
|
|
6
7
|
import { createIndex } from './db.js';
|
|
8
|
+
import { closestSlugs } from './closest.js';
|
|
7
9
|
import { generateVaultIndex } from './knowledge-index.js';
|
|
8
10
|
import { HafezError } from './types.js';
|
|
9
|
-
import { BatchOperationSchema, specFor, slugBearingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
|
|
11
|
+
import { BatchOperationSchema, specFor, slugBearingOpNames, sessionAddressingOpNames, notFoundError, normalizeOperations, opErrorLine, batchInvalidHeader } from './batch-ops.js';
|
|
10
12
|
import { applyOperation, buildEntityFrontmatter, buildKnowledgeFrontmatter, validateKindFields } from './apply.js';
|
|
11
13
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
12
14
|
import { join, basename } from 'path';
|
|
@@ -91,19 +93,22 @@ export function createHafez(config) {
|
|
|
91
93
|
}
|
|
92
94
|
async function read(slug, depth = 'summary') {
|
|
93
95
|
const filePath = findFile(slug);
|
|
94
|
-
if (!filePath)
|
|
95
|
-
|
|
96
|
+
if (!filePath) {
|
|
97
|
+
// A miss carries the closest slugs as its details: the recovery the
|
|
98
|
+
// `read` miss output renders (#91).
|
|
99
|
+
const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
|
|
100
|
+
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
|
|
101
|
+
}
|
|
96
102
|
const parsed = parseFilePath(filePath);
|
|
97
103
|
// findFile only resolves the indexed kinds, so the session arm is unreachable
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const body = parsed.body;
|
|
104
|
+
if (parsed.kind === 'session')
|
|
105
|
+
throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
|
|
101
106
|
if (depth === 'frontmatter')
|
|
102
|
-
return {
|
|
107
|
+
return { ...parsed, body: '' };
|
|
103
108
|
if (depth === 'summary') {
|
|
104
|
-
return {
|
|
109
|
+
return { ...parsed, body: documentForFile(parsed).bodyBeforeSessionLog(parsed.body) };
|
|
105
110
|
}
|
|
106
|
-
return
|
|
111
|
+
return parsed;
|
|
107
112
|
}
|
|
108
113
|
function rollbackFiles(originals) {
|
|
109
114
|
for (const [path, content] of originals) {
|
|
@@ -259,7 +264,7 @@ export function createHafez(config) {
|
|
|
259
264
|
}
|
|
260
265
|
async function update(slug, fields) {
|
|
261
266
|
const { applied } = await runMutations([{ op: 'update', slug, fields }], { action: 'update' });
|
|
262
|
-
return
|
|
267
|
+
return applied[0].updateResult ?? {};
|
|
263
268
|
}
|
|
264
269
|
async function link(slug, target, relation) {
|
|
265
270
|
await runMutations([{ op: 'link', slug, target, relation }], { action: 'link' });
|
|
@@ -267,6 +272,10 @@ export function createHafez(config) {
|
|
|
267
272
|
async function unlink(slug, target, relation) {
|
|
268
273
|
await runMutations([{ op: 'unlink', slug, target, relation }], { action: 'unlink' });
|
|
269
274
|
}
|
|
275
|
+
async function del(slug) {
|
|
276
|
+
const { applied } = await runMutations([{ op: 'delete', slug }], { action: 'delete' });
|
|
277
|
+
return applied[0].deletedKind;
|
|
278
|
+
}
|
|
270
279
|
// "Recent" with day-granularity dates: primary sort is the frontmatter day,
|
|
271
280
|
// ties break on git commit time (the real recency signal), then name for
|
|
272
281
|
// files with no commit history yet. Without this the top-5 lists leak
|
|
@@ -303,10 +312,36 @@ export function createHafez(config) {
|
|
|
303
312
|
// slug → what it would be after earlier ops in this batch
|
|
304
313
|
const simulated = new Map();
|
|
305
314
|
const simulatedSessions = new Set();
|
|
306
|
-
|
|
315
|
+
// Deletes earlier in the batch: the slug is absent to every later op, so
|
|
316
|
+
// delete-then-create of one slug is a rewrite, not a collision.
|
|
317
|
+
const deleted = new Set();
|
|
318
|
+
const deletedSessions = new Set();
|
|
319
|
+
// slug → its parent/related after the earlier ops; files absent here
|
|
320
|
+
// carry what is on disk.
|
|
321
|
+
const simulatedLinks = new Map();
|
|
322
|
+
const existsAnywhere = (slug) => !deleted.has(slug) && (simulated.has(slug) || exists(slug));
|
|
323
|
+
const linksOf = (slug) => {
|
|
324
|
+
const sim = simulatedLinks.get(slug);
|
|
325
|
+
if (sim)
|
|
326
|
+
return sim;
|
|
327
|
+
const fp = findFile(slug);
|
|
328
|
+
if (!fp)
|
|
329
|
+
return {};
|
|
330
|
+
try {
|
|
331
|
+
const pf = parseFilePath(fp);
|
|
332
|
+
if (pf.kind === 'entity')
|
|
333
|
+
return { parent: pf.frontmatter.parent, related: pf.frontmatter.related };
|
|
334
|
+
return { related: pf.frontmatter.related };
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
307
340
|
// Vault context for the Op Spec table's semantic checks (src/batch-ops.ts).
|
|
308
341
|
const ctx = {
|
|
309
342
|
resolve: (slug) => {
|
|
343
|
+
if (deleted.has(slug))
|
|
344
|
+
return null;
|
|
310
345
|
const sim = simulated.get(slug);
|
|
311
346
|
if (sim)
|
|
312
347
|
return sim;
|
|
@@ -335,9 +370,48 @@ export function createHafez(config) {
|
|
|
335
370
|
}
|
|
336
371
|
},
|
|
337
372
|
existsAnywhere,
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
373
|
+
// resolveFilePath, not a bare join: delete acts on this answer, and a
|
|
374
|
+
// '../' slug must never reach a file outside sessions/.
|
|
375
|
+
sessionExists: (slug) => {
|
|
376
|
+
if (deletedSessions.has(slug))
|
|
377
|
+
return false;
|
|
378
|
+
if (simulatedSessions.has(slug))
|
|
379
|
+
return true;
|
|
380
|
+
try {
|
|
381
|
+
return existsSync(resolveFilePath(vaultPath, slug, 'session'));
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
simulate: (slug, info) => { deleted.delete(slug); simulated.set(slug, info); },
|
|
388
|
+
simulateSession: (slug) => { deletedSessions.delete(slug); simulatedSessions.add(slug); },
|
|
389
|
+
linksOf,
|
|
390
|
+
simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
|
|
391
|
+
// Read from disk, not the index: the validate phase must see the vault
|
|
392
|
+
// as `validate` will, and a stale index row would let a linked note go.
|
|
393
|
+
referrers: (slug) => {
|
|
394
|
+
const candidates = new Set(simulated.keys());
|
|
395
|
+
for (const kind of INDEXED_KINDS) {
|
|
396
|
+
for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
|
|
397
|
+
candidates.add(basename(fp, '.md'));
|
|
398
|
+
}
|
|
399
|
+
return [...candidates]
|
|
400
|
+
.filter(from => from !== slug && !deleted.has(from))
|
|
401
|
+
.filter(from => { const l = linksOf(from); return l.parent === slug || (l.related ?? []).includes(slug); })
|
|
402
|
+
.sort();
|
|
403
|
+
},
|
|
404
|
+
simulateDelete: (slug, kind) => {
|
|
405
|
+
if (kind === 'session') {
|
|
406
|
+
deletedSessions.add(slug);
|
|
407
|
+
simulatedSessions.delete(slug);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
deleted.add(slug);
|
|
411
|
+
simulated.delete(slug);
|
|
412
|
+
// A file created under this slug later in the batch starts unlinked.
|
|
413
|
+
simulatedLinks.set(slug, {});
|
|
414
|
+
},
|
|
341
415
|
validateKindFields,
|
|
342
416
|
validateSessionLogEntry: (entry) => validateSessionLogEntry(entry),
|
|
343
417
|
buildEntityFrontmatter: (name, fields) => { buildEntityFrontmatter(name, fields, existsAnywhere); },
|
|
@@ -369,7 +443,8 @@ export function createHafez(config) {
|
|
|
369
443
|
// first (and scripts branch on its exit code) — keep that precedence.
|
|
370
444
|
if (raw && typeof raw.slug === 'string' && raw.slug &&
|
|
371
445
|
slugBearingOpNames().includes(entry.op) &&
|
|
372
|
-
!existsAnywhere(raw.slug)
|
|
446
|
+
!existsAnywhere(raw.slug) &&
|
|
447
|
+
!(sessionAddressingOpNames().includes(entry.op) && ctx.sessionExists(raw.slug))) {
|
|
373
448
|
entry.errors.unshift(notFoundError(raw.slug));
|
|
374
449
|
entry.primaryCode = 'NOT_FOUND';
|
|
375
450
|
}
|
|
@@ -399,93 +474,7 @@ export function createHafez(config) {
|
|
|
399
474
|
return results;
|
|
400
475
|
}
|
|
401
476
|
async function validate() {
|
|
402
|
-
|
|
403
|
-
broken_slugs: [],
|
|
404
|
-
orphaned_knowledge: [],
|
|
405
|
-
oversized_related: [],
|
|
406
|
-
missing_fields: [],
|
|
407
|
-
total_entities: 0,
|
|
408
|
-
total_knowledge: 0,
|
|
409
|
-
};
|
|
410
|
-
const allSlugs = new Set();
|
|
411
|
-
const knowledgeSlugs = new Set();
|
|
412
|
-
const referencedKnowledge = new Set();
|
|
413
|
-
// Scan entities
|
|
414
|
-
const entDir = join(vaultPath, kindDir('entity'));
|
|
415
|
-
{
|
|
416
|
-
const files = scanVaultDir(entDir);
|
|
417
|
-
report.total_entities = files.length;
|
|
418
|
-
for (const filePath of files) {
|
|
419
|
-
const slug = basename(filePath, '.md');
|
|
420
|
-
allSlugs.add(slug);
|
|
421
|
-
let fm;
|
|
422
|
-
try {
|
|
423
|
-
fm = parseFilePath(filePath).frontmatter;
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
const errors = validateEntityFrontmatter(fm);
|
|
430
|
-
for (const e of errors)
|
|
431
|
-
report.missing_fields.push({ slug, field: '', issue: e });
|
|
432
|
-
if (fm.parent) {
|
|
433
|
-
if (!exists(fm.parent))
|
|
434
|
-
report.broken_slugs.push({ slug, field: 'parent', issue: `parent '${fm.parent}' does not exist` });
|
|
435
|
-
}
|
|
436
|
-
if (fm.related && Array.isArray(fm.related)) {
|
|
437
|
-
for (const r of fm.related) {
|
|
438
|
-
if (!exists(r))
|
|
439
|
-
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
440
|
-
// Track referenced knowledge
|
|
441
|
-
referencedKnowledge.add(r);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Scan knowledge
|
|
447
|
-
const knDir = join(vaultPath, kindDir('knowledge'));
|
|
448
|
-
{
|
|
449
|
-
const files = scanVaultDir(knDir);
|
|
450
|
-
report.total_knowledge = files.length;
|
|
451
|
-
for (const filePath of files) {
|
|
452
|
-
const slug = basename(filePath, '.md');
|
|
453
|
-
allSlugs.add(slug);
|
|
454
|
-
knowledgeSlugs.add(slug);
|
|
455
|
-
let fm;
|
|
456
|
-
try {
|
|
457
|
-
fm = parseFilePath(filePath).frontmatter;
|
|
458
|
-
}
|
|
459
|
-
catch {
|
|
460
|
-
report.missing_fields.push({ slug, field: '', issue: 'failed to parse frontmatter' });
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
const errors = validateKnowledgeFrontmatter(fm);
|
|
464
|
-
for (const e of errors)
|
|
465
|
-
report.missing_fields.push({ slug, field: '', issue: e });
|
|
466
|
-
if (fm.related && Array.isArray(fm.related)) {
|
|
467
|
-
for (const r of fm.related) {
|
|
468
|
-
if (!exists(r))
|
|
469
|
-
report.broken_slugs.push({ slug, field: 'related', issue: `related '${r}' does not exist` });
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
// Find orphaned knowledge: no related, and not referenced by any entity
|
|
475
|
-
for (const slug of knowledgeSlugs) {
|
|
476
|
-
let fm;
|
|
477
|
-
try {
|
|
478
|
-
fm = parseFilePath(join(knDir, `${slug}.md`)).frontmatter;
|
|
479
|
-
}
|
|
480
|
-
catch {
|
|
481
|
-
continue; // already reported above
|
|
482
|
-
}
|
|
483
|
-
const hasRelated = fm.related && Array.isArray(fm.related) && fm.related.length > 0;
|
|
484
|
-
if (!hasRelated && !referencedKnowledge.has(slug)) {
|
|
485
|
-
report.orphaned_knowledge.push(slug);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return report;
|
|
477
|
+
return validateVault(vaultPath);
|
|
489
478
|
}
|
|
490
479
|
async function promote(slug, target) {
|
|
491
480
|
// Rollback safety rides the Spine: applyOperation's promote arm captures
|
|
@@ -512,6 +501,7 @@ export function createHafez(config) {
|
|
|
512
501
|
capture,
|
|
513
502
|
link,
|
|
514
503
|
unlink,
|
|
504
|
+
delete: del,
|
|
515
505
|
batch,
|
|
516
506
|
// Same normalization as the apply path — dry-run must judge the payload
|
|
517
507
|
// apply would actually run.
|
package/dist/merge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/merge.ts
|
|
2
2
|
import { parseContent, serializeFile, ARRAY_FIELDS } from './vault.js';
|
|
3
|
-
import { parseSessionLogHeading, splitLogEntries, documentFor } from './document.js';
|
|
3
|
+
import { parseSessionLogHeading, splitLogEntries, documentFor, stripActionStamp } from './document.js';
|
|
4
4
|
import { APPEND_ONLY_SECTIONS } from './contracts.js';
|
|
5
5
|
// Fields that are ISO date strings — latest date wins
|
|
6
6
|
const DATE_FIELDS = ['last-touched', 'last-reinforced', 'created'];
|
|
@@ -168,14 +168,16 @@ function parseLogEntries(content) {
|
|
|
168
168
|
function mergeNextActions(remoteContent, localContent) {
|
|
169
169
|
const remote = parseActionItems(remoteContent);
|
|
170
170
|
const local = parseActionItems(localContent);
|
|
171
|
-
// Start with local items; index by action text for the remote pass
|
|
171
|
+
// Start with local items; index by action text for the remote pass. Identity
|
|
172
|
+
// is the unstamped text: one action stamped on two days is one action, and
|
|
173
|
+
// the local line (its stamp included) wins.
|
|
172
174
|
const merged = local.items.map(i => ({ ...i, notes: [...i.notes] }));
|
|
173
|
-
const byText = new Map(merged.map(i => [i.text, i]));
|
|
175
|
+
const byText = new Map(merged.map(i => [stripActionStamp(i.text), i]));
|
|
174
176
|
for (const item of remote.items) {
|
|
175
|
-
const existing = byText.get(item.text);
|
|
177
|
+
const existing = byText.get(stripActionStamp(item.text));
|
|
176
178
|
if (!existing) {
|
|
177
179
|
merged.push({ ...item, notes: [...item.notes] });
|
|
178
|
-
byText.set(item.text, merged[merged.length - 1]);
|
|
180
|
+
byText.set(stripActionStamp(item.text), merged[merged.length - 1]);
|
|
179
181
|
continue;
|
|
180
182
|
}
|
|
181
183
|
// Checked on either side means done
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The notes whose names near-match `name` — all but one of the shorter
|
|
2
|
+
* name's tokens shared, at least three — and those that only resemble it (two
|
|
3
|
+
* shared or more). Up to three of each, most tokens shared first. */
|
|
4
|
+
export declare function resemblances<T extends {
|
|
5
|
+
name: string;
|
|
6
|
+
}>(name: string, notes: readonly T[]): {
|
|
7
|
+
near: T[];
|
|
8
|
+
weak: T[];
|
|
9
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/near-match.ts
|
|
2
|
+
// The Near-match rule (decision #60): how closely a new knowledge note's name
|
|
3
|
+
// resembles existing ones. Names only, never bodies, so the verdict is
|
|
4
|
+
// deterministic and explainable. Tokens are the name's words, lowercased,
|
|
5
|
+
// with the search stopwords dropped.
|
|
6
|
+
import { tokenize } from './stopwords.js';
|
|
7
|
+
/** A near-match shares at least this many tokens, whatever the name lengths. */
|
|
8
|
+
const NEAR_MIN_SHARED = 3;
|
|
9
|
+
/** A weaker resemblance shares at least this many. */
|
|
10
|
+
const WEAK_MIN_SHARED = 2;
|
|
11
|
+
/** Each list holds at most this many notes. */
|
|
12
|
+
const MAX_LISTED = 3;
|
|
13
|
+
function tokens(name) {
|
|
14
|
+
return new Set(tokenize(name));
|
|
15
|
+
}
|
|
16
|
+
/** The notes whose names near-match `name` — all but one of the shorter
|
|
17
|
+
* name's tokens shared, at least three — and those that only resemble it (two
|
|
18
|
+
* shared or more). Up to three of each, most tokens shared first. */
|
|
19
|
+
export function resemblances(name, notes) {
|
|
20
|
+
const mine = tokens(name);
|
|
21
|
+
const near = [];
|
|
22
|
+
const weak = [];
|
|
23
|
+
for (const note of notes) {
|
|
24
|
+
const theirs = tokens(note.name);
|
|
25
|
+
const shared = [...mine].filter(t => theirs.has(t)).length;
|
|
26
|
+
const shorter = Math.min(mine.size, theirs.size);
|
|
27
|
+
if (shared >= Math.max(NEAR_MIN_SHARED, shorter - 1))
|
|
28
|
+
near.push({ note, shared });
|
|
29
|
+
else if (shared >= WEAK_MIN_SHARED)
|
|
30
|
+
weak.push({ note, shared });
|
|
31
|
+
}
|
|
32
|
+
const top = (xs) => xs.sort((a, b) => b.shared - a.shared).slice(0, MAX_LISTED).map(x => x.note);
|
|
33
|
+
return { near: top(near), weak: top(weak) };
|
|
34
|
+
}
|
package/dist/stale.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The threshold an entity without `staleness-days` gets. */
|
|
2
|
+
export declare const DEFAULT_STALENESS_DAYS = 14;
|
|
3
|
+
/** An open action's added day, its age in days, and whether it is stale;
|
|
4
|
+
* undated gives null for both. */
|
|
5
|
+
export interface ActionAge {
|
|
6
|
+
added: string | null;
|
|
7
|
+
age_days: number | null;
|
|
8
|
+
stale: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** The entity side of the rule, as SQL over an index row `i` with `@today`
|
|
11
|
+
* bound to localDay(): whole days from its last-touched day to today.
|
|
12
|
+
* julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
|
|
13
|
+
* difference, so entities and their actions count the same days. */
|
|
14
|
+
export declare const ENTITY_AGE_DAYS_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)";
|
|
15
|
+
/** An entity row is stale when its age is past its threshold, not equal to
|
|
16
|
+
* it — `actionAge`'s rule. */
|
|
17
|
+
export declare const ENTITY_STALE_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER) > COALESCE(i.staleness_days, 14)";
|
|
18
|
+
/** How old an action added on `added` is today, and whether that is past
|
|
19
|
+
* `stalenessDays`: older than the threshold, not equal to it. */
|
|
20
|
+
export declare function actionAge(added: string | null, stalenessDays: number, today?: string): ActionAge;
|
|
21
|
+
/** An entity's threshold: its `staleness-days`, else the default. */
|
|
22
|
+
export declare function stalenessDaysOf(frontmatter: {
|
|
23
|
+
'staleness-days'?: number;
|
|
24
|
+
}): number;
|
|
25
|
+
/** One line of Next Actions as `read --json` gives it: an open line with its
|
|
26
|
+
* Handle and age, a ticked line as text alone. */
|
|
27
|
+
export type AgedAction = ({
|
|
28
|
+
handle: string;
|
|
29
|
+
text: string;
|
|
30
|
+
} & ActionAge) | {
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
33
|
+
/** An entity body's Next Actions in file order, each open line aged against
|
|
34
|
+
* the entity's threshold. */
|
|
35
|
+
export declare function agedActions(body: string, frontmatter: {
|
|
36
|
+
'staleness-days'?: number;
|
|
37
|
+
}): AgedAction[];
|
|
38
|
+
/** The Handles of an entity body's Stale lines, in file order. */
|
|
39
|
+
export declare function staleHandles(body: string, frontmatter: {
|
|
40
|
+
'staleness-days'?: number;
|
|
41
|
+
}): string[];
|
package/dist/stale.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/stale.ts
|
|
2
|
+
// Stale (see CONTEXT.md): past the entity's `staleness-days` without
|
|
3
|
+
// movement. An open next action is stale when unticked that long since its
|
|
4
|
+
// added day; an undated one is stale. Ages count local days (src/dates.ts).
|
|
5
|
+
import { localDay } from './dates.js';
|
|
6
|
+
import { getActionItems } from './document.js';
|
|
7
|
+
/** The threshold an entity without `staleness-days` gets. */
|
|
8
|
+
export const DEFAULT_STALENESS_DAYS = 14;
|
|
9
|
+
/** A YYYY-MM-DD day as a whole day count, so differences ignore DST. */
|
|
10
|
+
const dayNumber = (day) => {
|
|
11
|
+
const [y, m, d] = day.split('-').map(Number);
|
|
12
|
+
return Date.UTC(y, m - 1, d) / 86_400_000;
|
|
13
|
+
};
|
|
14
|
+
/** The entity side of the rule, as SQL over an index row `i` with `@today`
|
|
15
|
+
* bound to localDay(): whole days from its last-touched day to today.
|
|
16
|
+
* julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
|
|
17
|
+
* difference, so entities and their actions count the same days. */
|
|
18
|
+
export const ENTITY_AGE_DAYS_SQL = 'CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)';
|
|
19
|
+
/** An entity row is stale when its age is past its threshold, not equal to
|
|
20
|
+
* it — `actionAge`'s rule. */
|
|
21
|
+
export const ENTITY_STALE_SQL = `${ENTITY_AGE_DAYS_SQL} > COALESCE(i.staleness_days, ${DEFAULT_STALENESS_DAYS})`;
|
|
22
|
+
/** How old an action added on `added` is today, and whether that is past
|
|
23
|
+
* `stalenessDays`: older than the threshold, not equal to it. */
|
|
24
|
+
export function actionAge(added, stalenessDays, today = localDay()) {
|
|
25
|
+
if (added === null)
|
|
26
|
+
return { added, age_days: null, stale: true };
|
|
27
|
+
const age_days = dayNumber(today) - dayNumber(added);
|
|
28
|
+
return { added, age_days, stale: age_days > stalenessDays };
|
|
29
|
+
}
|
|
30
|
+
/** An entity's threshold: its `staleness-days`, else the default. */
|
|
31
|
+
export function stalenessDaysOf(frontmatter) {
|
|
32
|
+
return frontmatter['staleness-days'] ?? DEFAULT_STALENESS_DAYS;
|
|
33
|
+
}
|
|
34
|
+
/** An entity body's Next Actions in file order, each open line aged against
|
|
35
|
+
* the entity's threshold. */
|
|
36
|
+
export function agedActions(body, frontmatter) {
|
|
37
|
+
const days = stalenessDaysOf(frontmatter);
|
|
38
|
+
return getActionItems(body).map(item => item.handle === undefined ? { text: item.text } : { handle: item.handle, text: item.text, ...actionAge(item.added, days) });
|
|
39
|
+
}
|
|
40
|
+
/** The Handles of an entity body's Stale lines, in file order. */
|
|
41
|
+
export function staleHandles(body, frontmatter) {
|
|
42
|
+
return agedActions(body, frontmatter).flatMap(a => 'handle' in a && a.stale ? [a.handle] : []);
|
|
43
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const STOPWORDS: ReadonlySet<string>;
|
|
2
|
+
/** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
|
|
3
|
+
* so `launchpads` and `launchpad` are one word. */
|
|
4
|
+
export declare function tokenize(text: string, { stem }?: {
|
|
5
|
+
stem?: boolean;
|
|
6
|
+
}): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/stopwords.ts
|
|
2
|
+
// Words too common to tell two texts apart: dropped from full-text search
|
|
3
|
+
// terms (db.ts) and, through `tokenize`, from the words the Near-match rule
|
|
4
|
+
// (near-match.ts) and the read-miss closest slugs (closest.ts) compare.
|
|
5
|
+
export const STOPWORDS = new Set([
|
|
6
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
|
|
7
|
+
'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
8
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
|
|
9
|
+
'may', 'might', 'can', 'shall', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
|
|
10
|
+
'that', 'this', 'these', 'those', 'it', 'its', 'my', 'your', 'his', 'her',
|
|
11
|
+
'our', 'their', 'what', 'which', 'who', 'whom', 'how', 'when', 'where', 'why',
|
|
12
|
+
'all', 'each', 'every', 'both', 'few', 'more', 'most', 'some', 'any', 'such',
|
|
13
|
+
'about', 'into', 'over', 'after', 'before',
|
|
14
|
+
'work', 'works', 'working', 'use', 'using', 'used', 'get', 'got', 'make', 'made',
|
|
15
|
+
'need', 'want', 'like', 'just', 'also', 'still', 'already', 'very', 'much',
|
|
16
|
+
]);
|
|
17
|
+
/** Lowercased words with stopwords dropped. `stem` also strips a plural `s`,
|
|
18
|
+
* so `launchpads` and `launchpad` are one word. */
|
|
19
|
+
export function tokenize(text, { stem = false } = {}) {
|
|
20
|
+
const words = text
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
23
|
+
.filter(w => w && !STOPWORDS.has(w));
|
|
24
|
+
return stem ? words.map(w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)) : words;
|
|
25
|
+
}
|