hafez 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +72 -5
  2. package/dist/apply.d.ts +14 -1
  3. package/dist/apply.js +94 -32
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +83 -16
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +27 -12
  8. package/dist/cli/commands.d.ts +3 -1
  9. package/dist/cli/commands.js +183 -73
  10. package/dist/cli/format.d.ts +11 -3
  11. package/dist/cli/format.js +61 -4
  12. package/dist/cli/index.js +40 -19
  13. package/dist/cli/install.d.ts +9 -0
  14. package/dist/cli/install.js +36 -15
  15. package/dist/cli/onboard.js +30 -9
  16. package/dist/cli/upgrade.js +5 -4
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.js +9 -0
  19. package/dist/dates.d.ts +7 -0
  20. package/dist/dates.js +21 -1
  21. package/dist/db.d.ts +50 -10
  22. package/dist/db.js +251 -65
  23. package/dist/digest.js +14 -7
  24. package/dist/document.d.ts +2 -0
  25. package/dist/document.js +2 -2
  26. package/dist/git.d.ts +13 -2
  27. package/dist/git.js +121 -14
  28. package/dist/graph/activity.d.ts +5 -0
  29. package/dist/graph/activity.js +70 -0
  30. package/dist/graph/client.js +326 -0
  31. package/dist/graph/copy-fonts.d.ts +1 -0
  32. package/dist/graph/copy-fonts.js +13 -0
  33. package/dist/graph/fields.d.ts +8 -0
  34. package/dist/graph/fields.js +12 -0
  35. package/dist/graph/fonts/geist-OFL.txt +93 -0
  36. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  37. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  38. package/dist/graph/fonts/geist.woff2 +0 -0
  39. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  40. package/dist/graph/fonts/outfit.woff2 +0 -0
  41. package/dist/graph/fonts.d.ts +14 -0
  42. package/dist/graph/fonts.js +17 -0
  43. package/dist/graph/live.d.ts +16 -0
  44. package/dist/graph/live.js +99 -0
  45. package/dist/graph/open.d.ts +1 -0
  46. package/dist/graph/open.js +20 -0
  47. package/dist/graph/server.d.ts +17 -0
  48. package/dist/graph/server.js +189 -0
  49. package/dist/health.d.ts +6 -0
  50. package/dist/health.js +310 -0
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +66 -18
  53. package/dist/internal.d.ts +21 -0
  54. package/dist/internal.js +9 -0
  55. package/dist/knowledge-index.d.ts +1 -1
  56. package/dist/read-json.d.ts +26 -0
  57. package/dist/read-json.js +53 -0
  58. package/dist/schema.d.ts +2 -0
  59. package/dist/schema.js +11 -0
  60. package/dist/stale.d.ts +2 -2
  61. package/dist/stale.js +4 -9
  62. package/dist/surfaces/items.d.ts +100 -1
  63. package/dist/surfaces/items.js +148 -10
  64. package/dist/surfaces/surfaces.d.ts +8 -5
  65. package/dist/surfaces/surfaces.js +34 -17
  66. package/dist/templates.d.ts +2 -0
  67. package/dist/templates.js +6 -1
  68. package/dist/types.d.ts +239 -25
  69. package/dist/validate.js +44 -56
  70. package/dist/vault.d.ts +29 -9
  71. package/dist/vault.js +39 -8
  72. package/dist/workstream.d.ts +17 -0
  73. package/dist/workstream.js +78 -0
  74. package/package.json +10 -4
  75. package/skill/hafez/SKILL.md +16 -7
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/index.ts
2
- import { parseFilePath, slugify, kindFromPath, kindDir, isIndexedRelPath, findVaultFile, vaultFileExists, resolveFilePath, scanVaultDir, INDEXED_KINDS } from './vault.js';
2
+ import { parseFilePath, slugify, kindFromPath, kindDir, isMutableRelPath, findVaultFile, findReadableFile, vaultFileExists, resolveFilePath, scanVaultDir, archiveLogPath, noteAtRelPath, MUTABLE_KINDS } from './vault.js';
3
3
  import { createGitJournal } from './git.js';
4
4
  import { validateSessionLogEntry } from './schema.js';
5
5
  import { validateVault } from './validate.js';
6
+ import { healthReport } from './health.js';
6
7
  import { documentForFile } from './document.js';
7
8
  import { createIndex } from './db.js';
8
9
  import { closestSlugs } from './closest.js';
@@ -14,7 +15,9 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
14
15
  import { join, basename } from 'path';
15
16
  import { lock as lockVault } from 'proper-lockfile';
16
17
  export function createHafez(config) {
17
- const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite } = config;
18
+ const { vaultPath, readOnly = false, git: gitConfig = {}, onWrite, indexSync = 'read' } = config;
19
+ // When reads catch the index up with the files (src/internal.ts, off the
20
+ // public types): 'once' is the CLI's (#269), 'manual' the graph server's (#271).
18
21
  // The persistence seam (CONTEXT.md: Journal). Everything below talks to
19
22
  // history through this port; git is just the default adapter.
20
23
  const journal = config.persistence ?? createGitJournal(vaultPath, gitConfig);
@@ -52,6 +55,7 @@ export function createHafez(config) {
52
55
  }
53
56
  }
54
57
  // Layout answers where a slug lives; these bind the vault path once.
58
+ // Mutations resolve the mutable kinds; `read` reaches a session note too.
55
59
  function findFile(slug) { return findVaultFile(vaultPath, slug); }
56
60
  function exists(slug) { return vaultFileExists(vaultPath, slug); }
57
61
  function regenerateIndexSafe() {
@@ -65,8 +69,12 @@ export function createHafez(config) {
65
69
  // outside hafez — a hand edit, a git pull — are represented too; that
66
70
  // costs a stat per file, where the old path paid a parse per file on
67
71
  // every single mutation.
72
+ // Under 'once' the process's first read already caught up.
68
73
  const idx = getIndex();
69
- idx?.syncIfStale();
74
+ if (indexSync === 'once')
75
+ idx?.syncForRead();
76
+ else
77
+ idx?.syncIfStale();
70
78
  generateVaultIndex(vaultPath, idx?.indexEntries());
71
79
  }
72
80
  catch (err) {
@@ -77,7 +85,7 @@ export function createHafez(config) {
77
85
  let _indexInitialized = false;
78
86
  function getIndex() {
79
87
  if (!_indexInitialized) {
80
- _index = readOnly ? createIndex(vaultPath, { readonly: true }) : createIndex(vaultPath);
88
+ _index = readOnly ? createIndex(vaultPath, { readonly: true, sync: indexSync }) : createIndex(vaultPath, { sync: indexSync });
81
89
  // A null read-only open means the on-disk index is missing or from another
82
90
  // schema version. Don't latch it: retry on the next call so a long-lived
83
91
  // read-only instance starts serving once a write-capable process rebuilds.
@@ -92,17 +100,14 @@ export function createHafez(config) {
92
100
  return idx ? fn(idx) : empty;
93
101
  }
94
102
  async function read(slug, depth = 'summary') {
95
- const filePath = findFile(slug);
103
+ const filePath = findReadableFile(vaultPath, slug);
96
104
  if (!filePath) {
97
105
  // A miss carries the closest slugs as its details: the recovery the
98
106
  // `read` miss output renders (#91).
99
- const closest = withIndex(idx => { idx.syncIfStale(); return closestSlugs(slug, idx.indexEntries()); }, []);
107
+ const closest = withIndex(idx => { idx.syncForRead(); return closestSlugs(slug, idx.indexEntries()); }, []);
100
108
  throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`, closest);
101
109
  }
102
110
  const parsed = parseFilePath(filePath);
103
- // findFile only resolves the indexed kinds, so the session arm is unreachable
104
- if (parsed.kind === 'session')
105
- throw new HafezError('NOT_FOUND', `Entity or knowledge '${slug}' not found`);
106
111
  if (depth === 'frontmatter')
107
112
  return { ...parsed, body: '' };
108
113
  if (depth === 'summary') {
@@ -110,6 +115,15 @@ export function createHafez(config) {
110
115
  }
111
116
  return parsed;
112
117
  }
118
+ /** The archive is an attachment of its entity, reached from it on purpose
119
+ * (#34): a disk read behind the entity's own slug, never an index row. */
120
+ async function archive(slug) {
121
+ const entity = resolveFilePath(vaultPath, slug, 'entity');
122
+ if (!existsSync(entity))
123
+ throw new HafezError('NOT_FOUND', `Entity '${slug}' not found`);
124
+ const path = archiveLogPath(vaultPath, slug);
125
+ return existsSync(path) ? readFileSync(path, 'utf-8') : null;
126
+ }
113
127
  function rollbackFiles(originals) {
114
128
  for (const [path, content] of originals) {
115
129
  if (content === null) {
@@ -216,12 +230,10 @@ export function createHafez(config) {
216
230
  const deletedAll = [...new Set(applied.flatMap(a => a.filesDeleted))];
217
231
  const finalize = () => {
218
232
  updateIndexPostCommit(applied);
219
- // index.md regeneration is gated: session-only writes skip it
220
- if ([...writtenAll, ...deletedAll].some(isIndexedRelPath)) {
233
+ // index.md shows the mutable kinds: a session-only write leaves it.
234
+ if ([...writtenAll, ...deletedAll].some(isMutableRelPath)) {
221
235
  regenerateIndexSafe();
222
236
  }
223
- // Session creates carry no index upserts — they are not indexed
224
- // entities and never notified.
225
237
  for (const a of applied) {
226
238
  if (a.indexUpserts.length > 0)
227
239
  onWrite?.(a.result.slug, single ? single.action : 'batch');
@@ -390,9 +402,11 @@ export function createHafez(config) {
390
402
  simulateLinks: (slug, links) => { simulatedLinks.set(slug, links); },
391
403
  // Read from disk, not the index: the validate phase must see the vault
392
404
  // as `validate` will, and a stale index row would let a linked note go.
405
+ // The mutable kinds only: a session note's related are a record of
406
+ // what it touched, and a record must not keep a note from being deleted.
393
407
  referrers: (slug) => {
394
408
  const candidates = new Set(simulated.keys());
395
- for (const kind of INDEXED_KINDS) {
409
+ for (const kind of MUTABLE_KINDS) {
396
410
  for (const fp of scanVaultDir(join(vaultPath, kindDir(kind))))
397
411
  candidates.add(basename(fp, '.md'));
398
412
  }
@@ -476,6 +490,9 @@ export function createHafez(config) {
476
490
  async function validate() {
477
491
  return validateVault(vaultPath);
478
492
  }
493
+ async function health() {
494
+ return healthReport(vaultPath);
495
+ }
479
496
  async function promote(slug, target) {
480
497
  // Rollback safety rides the Spine: applyOperation's promote arm captures
481
498
  // both the source file and (for cross-kind moves) the knowledge target
@@ -491,7 +508,8 @@ export function createHafez(config) {
491
508
  knowledge_count: 0,
492
509
  stale: [], no_next_action: [], recently_touched: [], recently_created: [],
493
510
  };
494
- return {
511
+ // An InternalHafez: syncIndex() is here, off the public Hafez type.
512
+ const hafez = {
495
513
  read,
496
514
  update,
497
515
  promote,
@@ -518,8 +536,13 @@ export function createHafez(config) {
518
536
  children: async (slug) => withIndex(idx => idx.queryChildren(slug), { items: [], total: 0 }),
519
537
  related_to: async (slug, relation) => withIndex(idx => idx.queryRelatedTo(slug, relation), { items: [], total: 0 }),
520
538
  query_knowledge: async (opts) => withIndex(idx => idx.queryKnowledge(opts), { items: [], total: 0 }),
539
+ query_sessions: async (opts) => withIndex(idx => idx.querySessions(opts), { items: [], total: 0 }),
540
+ archive,
521
541
  validate,
522
- search: async (query, kind) => withIndex(idx => { idx.syncIfStale(); return idx.search(query, kind); }, []),
542
+ health,
543
+ search: async (query, kind) => withIndex(idx => { idx.syncForRead(); return idx.search(query, kind); }, []),
544
+ graph: async (opts) => withIndex(idx => idx.queryGraph(opts), { nodes: [], edges: [] }),
545
+ syncIndex: async () => { withIndex(idx => idx.syncIfStale(), undefined); },
523
546
  rebuildIndex: async () => {
524
547
  if (readOnly)
525
548
  throw new HafezError('VALIDATION_FAILED', 'Hafez instance is read-only');
@@ -533,14 +556,39 @@ export function createHafez(config) {
533
556
  const idx = getIndex();
534
557
  if (!idx)
535
558
  return emptyStats;
536
- idx.syncIfStale();
559
+ idx.syncForRead();
537
560
  const s = idx.getStats();
538
561
  s.recently_touched = await rankRecents(s.recently_touched, 'last_touched', 'modified');
539
562
  s.recently_created = await rankRecents(s.recently_created, 'created', 'added');
540
563
  return s;
541
564
  },
542
- changelog: (since) => journal.changelog(since),
565
+ changelog: async (since, limit) => {
566
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0)) {
567
+ throw new HafezError('VALIDATION_FAILED', `changelog limit must be a positive whole number of commits, got ${limit}`);
568
+ }
569
+ return journal.changelog(since, limit);
570
+ },
571
+ changesSince: async (cursor) => {
572
+ const { cursor: next, files } = await journal.changesSince(cursor);
573
+ const changes = files.flatMap(({ path, op }) => {
574
+ const note = noteAtRelPath(path);
575
+ return note ? [{ ...note, op }] : [];
576
+ });
577
+ return { cursor: next, changes };
578
+ },
579
+ close: () => {
580
+ // Forget the handle even if closing it throws: a retry must not
581
+ // re-throw, and getIndex() must never hand out a closed handle.
582
+ try {
583
+ _index?.close();
584
+ }
585
+ finally {
586
+ _index = null;
587
+ _indexInitialized = false;
588
+ }
589
+ },
543
590
  };
591
+ return hafez;
544
592
  }
545
593
  export { HafezError } from './types.js';
546
594
  export { getBrief, getNextActions } from './document.js';
@@ -0,0 +1,21 @@
1
+ import type { Hafez, HafezConfig } from './types.js';
2
+ import type { IndexSync } from './db.js';
3
+ export interface InternalHafezConfig extends HafezConfig {
4
+ /** When the index catches up with files changed outside hafez (a hand edit,
5
+ * a raw git pull). 'read' (default): before every index read, a stat per
6
+ * note, so a long-lived instance never reads a stale index. 'once': the
7
+ * first index read syncs, later reads trust the index — the CLI's mode, one
8
+ * scan per process (#269); a file changed outside hafez after that read is
9
+ * seen by the next process, or after `syncIndex()`. Writes through the
10
+ * instance are indexed as they commit in every mode. 'manual': reads trust
11
+ * the index, and `syncIndex()` syncs it — the graph server's mode, which
12
+ * syncs when its poll sees a commit (#271). */
13
+ indexSync?: IndexSync;
14
+ }
15
+ export interface InternalHafez extends Hafez {
16
+ /** Catch the index up with files changed outside hafez, in every mode:
17
+ * what a read does first under `indexSync` 'read', and the first read under 'once'. */
18
+ syncIndex(): Promise<void>;
19
+ }
20
+ /** createHafez with the internal options: every instance it builds is an InternalHafez. */
21
+ export declare function createInternalHafez(config: InternalHafezConfig): InternalHafez;
@@ -0,0 +1,9 @@
1
+ // src/internal.ts
2
+ // What hafez's own CLI and graph server reach that the package does not
3
+ // export: src/index.ts re-exports none of it, and package.json's `exports`
4
+ // map stops a deep import. Off the library surface by the owner's call (#270).
5
+ import { createHafez } from './index.js';
6
+ /** createHafez with the internal options: every instance it builds is an InternalHafez. */
7
+ export function createInternalHafez(config) {
8
+ return createHafez(config);
9
+ }
@@ -5,7 +5,7 @@
5
5
  * imports from this file. */
6
6
  export interface VaultIndexRow {
7
7
  slug: string;
8
- kind: 'entity' | 'knowledge';
8
+ kind: 'entity' | 'knowledge' | 'session';
9
9
  name: string;
10
10
  type: string | null;
11
11
  status: string | null;
@@ -0,0 +1,26 @@
1
+ import type { Hafez, ParsedFile, ReadDepth } from './types.js';
2
+ import { type Workstream } from './workstream.js';
3
+ import { type ChildLine, type FooterInput } from './surfaces/items.js';
4
+ /** `read --json`'s object for a note read at `depth`: what the CLI prints and
5
+ * what the graph server's `/note/:slug` serves. `whole`, its full-depth read
6
+ * when the caller holds one, saves reading it again. */
7
+ export declare function readJsonOf(os: Hafez, slug: string, depth: ReadDepth, result: ParsedFile, whole?: ParsedFile): Promise<object>;
8
+ /** What hangs off an entity elsewhere: the sessions that touched it (the
9
+ * look back, #35), its archived log entries (#34), its children (#230) and
10
+ * the prefix families its own Next Actions carry unsplit against the
11
+ * entities whose `parent` or `related` names it (#230). Counted at read
12
+ * time, nothing written. */
13
+ export declare function pointersFor(os: Hafez, slug: string, body: string): Promise<{
14
+ sessions: {
15
+ count: number;
16
+ latest: string | null;
17
+ };
18
+ archive_entries: number;
19
+ children: ChildLine[];
20
+ workstreams: Workstream[];
21
+ }>;
22
+ /** The Footer for a file read at full depth: its Handle comes from the
23
+ * whole file. A session note has none: it is never written to. */
24
+ export declare function footerFor(slug: string, result: Exclude<ParsedFile, {
25
+ kind: 'session';
26
+ }>): FooterInput;
@@ -0,0 +1,53 @@
1
+ import { detectAgent } from './harness.js';
2
+ import { getActionItems, parseSessionLogHeading } from './document.js';
3
+ import { agedActions } from './stale.js';
4
+ import { workstreams } from './workstream.js';
5
+ import { CHILDREN_SHOWN, footerOp } from './surfaces/items.js';
6
+ /** `read --json`'s object for a note read at `depth`: what the CLI prints and
7
+ * what the graph server's `/note/:slug` serves. `whole`, its full-depth read
8
+ * when the caller holds one, saves reading it again. */
9
+ export async function readJsonOf(os, slug, depth, result, whole) {
10
+ // A session note is read, never written: no Footer (#35).
11
+ if (result.kind === 'session')
12
+ return result;
13
+ // The Footer and the pointers are the same at every depth: they read the
14
+ // whole file, not the body this depth shows.
15
+ const full = (whole ?? (depth === 'full' ? result : await os.read(slug, 'full')));
16
+ const writes = footerOp(footerFor(slug, full));
17
+ const pointers = full.kind === 'entity' ? await pointersFor(os, slug, full.body) : null;
18
+ const json = pointers && { ...pointers, children: pointers.children.slice(0, CHILDREN_SHOWN), children_total: pointers.children.length };
19
+ // A frontmatter-depth read carries no body, so it has no actions to list.
20
+ if (result.kind !== 'entity' || depth === 'frontmatter')
21
+ return { ...result, ...json, writes };
22
+ return { ...result, actions: agedActions(result.body, result.frontmatter), ...json, writes };
23
+ }
24
+ /** What hangs off an entity elsewhere: the sessions that touched it (the
25
+ * look back, #35), its archived log entries (#34), its children (#230) and
26
+ * the prefix families its own Next Actions carry unsplit against the
27
+ * entities whose `parent` or `related` names it (#230). Counted at read
28
+ * time, nothing written. */
29
+ export async function pointersFor(os, slug, body) {
30
+ const relatedTo = (await os.related_to(slug)).items;
31
+ const sessions = relatedTo.filter((i) => i.kind === 'session');
32
+ const latest = sessions.reduce((max, s) => (max === null || s.session_date > max ? s.session_date : max), null);
33
+ const archive = await os.archive(slug);
34
+ const entries = archive ? archive.split('\n').filter(line => parseSessionLogHeading(line)).length : 0;
35
+ // The children block (#230): the index's children query, last-touched
36
+ // descending then slug for a stable order; the renders cap what is shown.
37
+ const childItems = (await os.children(slug)).items;
38
+ const children = [...childItems]
39
+ .sort((a, b) => b.last_touched.localeCompare(a.last_touched) || a.slug.localeCompare(b.slug))
40
+ .map(c => ({ slug: c.slug, description: c.description, open_actions: c.next_action_count, last_touched: c.last_touched }));
41
+ const naming = [...childItems, ...relatedTo.filter(i => i.kind === 'entity')];
42
+ return { sessions: { count: sessions.length, latest }, archive_entries: entries, children, workstreams: workstreams(body, naming) };
43
+ }
44
+ /** The Footer for a file read at full depth: its Handle comes from the
45
+ * whole file. A session note has none: it is never written to. */
46
+ export function footerFor(slug, result) {
47
+ return { slug, agent: detectAgent(), ...(result.kind === 'entity'
48
+ ? {
49
+ target: { kind: 'entity', type: result.frontmatter.type },
50
+ handle: getActionItems(result.body).find(a => a.handle)?.handle,
51
+ }
52
+ : { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
53
+ }
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export declare function validateEntityFrontmatter(fm: Record<string, any>): string[];
2
2
  export declare function validateKnowledgeFrontmatter(fm: Record<string, any>): string[];
3
+ /** A session note: its name and created stamp, nothing else is required. */
4
+ export declare function validateSessionFrontmatter(fm: Record<string, any>): string[];
3
5
  export declare function validateKnowledgeSubtype(fm: Record<string, any>): string[];
4
6
  export declare function validateSlugReference(slug: string, field: string, exists: (s: string) => boolean): string | null;
5
7
  export declare function validateSessionLogEntry(entry: {
package/dist/schema.js CHANGED
@@ -30,6 +30,17 @@ export function validateKnowledgeFrontmatter(fm) {
30
30
  errors.push(...validateKnowledgeSubtype(fm));
31
31
  return errors;
32
32
  }
33
+ /** A session note: its name and created stamp, nothing else is required. */
34
+ export function validateSessionFrontmatter(fm) {
35
+ const errors = [];
36
+ if (!fm.name || typeof fm.name !== 'string')
37
+ errors.push('missing required field: name');
38
+ if (!fm.created)
39
+ errors.push('missing required field: created');
40
+ if (fm.related !== undefined && !Array.isArray(fm.related))
41
+ errors.push('session related must be an array');
42
+ return errors;
43
+ }
33
44
  export function validateKnowledgeSubtype(fm) {
34
45
  const errors = [];
35
46
  if (fm.subtype && !VALID_SUBTYPES.includes(fm.subtype)) {
package/dist/stale.d.ts CHANGED
@@ -9,8 +9,8 @@ export interface ActionAge {
9
9
  }
10
10
  /** The entity side of the rule, as SQL over an index row `i` with `@today`
11
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. */
12
+ * julianday of two bare YYYY-MM-DD days differs by exactly `daysBetween`
13
+ * (src/dates.ts), so entities and their actions count the same days. */
14
14
  export declare const ENTITY_AGE_DAYS_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)";
15
15
  /** An entity row is stale when its age is past its threshold, not equal to
16
16
  * it — `actionAge`'s rule. */
package/dist/stale.js CHANGED
@@ -2,19 +2,14 @@
2
2
  // Stale (see CONTEXT.md): past the entity's `staleness-days` without
3
3
  // movement. An open next action is stale when unticked that long since its
4
4
  // added day; an undated one is stale. Ages count local days (src/dates.ts).
5
- import { localDay } from './dates.js';
5
+ import { daysBetween, localDay } from './dates.js';
6
6
  import { getActionItems } from './document.js';
7
7
  /** The threshold an entity without `staleness-days` gets. */
8
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
9
  /** The entity side of the rule, as SQL over an index row `i` with `@today`
15
10
  * 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. */
11
+ * julianday of two bare YYYY-MM-DD days differs by exactly `daysBetween`
12
+ * (src/dates.ts), so entities and their actions count the same days. */
18
13
  export const ENTITY_AGE_DAYS_SQL = 'CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)';
19
14
  /** An entity row is stale when its age is past its threshold, not equal to
20
15
  * it — `actionAge`'s rule. */
@@ -24,7 +19,7 @@ export const ENTITY_STALE_SQL = `${ENTITY_AGE_DAYS_SQL} > COALESCE(i.staleness_d
24
19
  export function actionAge(added, stalenessDays, today = localDay()) {
25
20
  if (added === null)
26
21
  return { added, age_days: null, stale: true };
27
- const age_days = dayNumber(today) - dayNumber(added);
22
+ const age_days = daysBetween(added, today);
28
23
  return { added, age_days, stale: age_days > stalenessDays };
29
24
  }
30
25
  /** An entity's threshold: its `staleness-days`, else the default. */
@@ -1,6 +1,7 @@
1
1
  import { type DocTarget, type WriteLineField } from '../contracts.js';
2
2
  import { renderActionHandles } from '../document.js';
3
3
  import type { ActionAge } from '../stale.js';
4
+ import type { Workstream } from '../workstream.js';
4
5
  import type { KnowledgeSubtype, UpdateFields, UpdateResult } from '../types.js';
5
6
  import { type OnboardHarness } from '../harness.js';
6
7
  import type { Mode } from '../cli/install.js';
@@ -9,6 +10,11 @@ import type { Mode } from '../cli/install.js';
9
10
  export type SurfaceId = 'block' | 'hafezSkillDescription' | 'hafezSkillBody' | 'hafezSaveDescription' | 'hafezSaveBody' | 'read' | 'readHeader' | 'readFooter' | 'readMiss' | 'updateSuccess' | 'updateMisuse' | 'createKnowledge' | 'digestError' | 'batchMisuse' | 'mechanised' | 'schema' | 'onboard';
10
11
  /** Places another Item inside this one's text, counted against the same Surface. */
11
12
  export type Place = (id: string) => string;
13
+ /** What a static Surface is composed for. `vaultRepo`: the Block for a cloud
14
+ * container, whose vault is that GitHub repo (#160). */
15
+ export interface SurfaceCtx {
16
+ vaultRepo?: string;
17
+ }
12
18
  export interface Item {
13
19
  /** The Item's row text in the owner table. */
14
20
  label: string;
@@ -21,7 +27,7 @@ export interface Item {
21
27
  evidence: string;
22
28
  /** Static text. A function form may place nested Items, and may give the
23
29
  * fallback a shorter form than the Owner. */
24
- text?: string | ((place: Place, at: 'owner' | 'fallback') => string);
30
+ text?: string | ((place: Place, at: 'owner' | 'fallback', ctx: SurfaceCtx) => string);
25
31
  /** What the binary computes: for Items owned by command output, and for a
26
32
  * static Item a binary Surface places filled in (the create line in the
27
33
  * `read` miss). */
@@ -94,6 +100,41 @@ declare function renderUpdateSummary({ slug, fields, result }: {
94
100
  /** The suffix a Stale open line carries on `read`: its age, or that it is
95
101
  * undated. A fresh line carries none. */
96
102
  declare function renderStaleMarker({ stale, age_days }: ActionAge): string;
103
+ /** The `read` header line naming the sessions that touched an entity, with
104
+ * the look back from it. None: no line. */
105
+ declare function renderSessionsLine({ slug, count, latest }: {
106
+ slug: string;
107
+ count: number;
108
+ latest: string | null;
109
+ }): string;
110
+ /** The `read` header line naming an entity's archived log entries, with the
111
+ * command that reads them. No archive: no line. */
112
+ declare function renderArchivePointer({ slug, entries }: {
113
+ slug: string;
114
+ entries: number;
115
+ }): string;
116
+ /** A child as the `read` header shows it. */
117
+ export interface ChildLine {
118
+ slug: string;
119
+ description: string | null;
120
+ open_actions: number;
121
+ last_touched: string;
122
+ }
123
+ /** How many children the header lists; the rest are one overflow line. */
124
+ export declare const CHILDREN_SHOWN = 5;
125
+ /** The `read` header's children block: the count line (children, open
126
+ * actions across all of them), one `hafez read` line per child (description,
127
+ * open count, last-touched day) for the first `CHILDREN_SHOWN`, and the
128
+ * overflow line naming the query that lists the rest. Children arrive sorted
129
+ * by last-touched descending. No children: no block. */
130
+ declare function renderChildrenBlock({ slug, children }: {
131
+ slug: string;
132
+ children: ChildLine[];
133
+ }): string;
134
+ /** The `read` header's fact line for one unsplit prefix family (#230):
135
+ * its open-line count and the family, and that no child carries it. A
136
+ * fact, never a filled create line or a question. */
137
+ declare function renderWorkstreamLine({ prefix, open_actions }: Workstream): string;
97
138
  /** The line after an update's success while Stale lines remain on the entity. */
98
139
  declare function renderStaleReminder({ handles }: {
99
140
  handles: string[];
@@ -129,6 +170,15 @@ declare function renderTeachingLines({ harness, mode, line2Only }: {
129
170
  mode: Mode;
130
171
  line2Only?: boolean;
131
172
  }): string;
173
+ /** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
174
+ * and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
175
+ export declare const isVaultRepo: (value: string) => boolean;
176
+ /** `onboard --cloud`: the cloud environment's setup script. It cannot clone
177
+ * (it runs without GitHub credentials) and no step may fail session start: a
178
+ * failed install exits 0, leaving a session without Hafez rather than none. */
179
+ declare function renderCloudSetup({ vaultRepo }: {
180
+ vaultRepo: string;
181
+ }): string;
132
182
  export declare const ITEMS: {
133
183
  loadTriggers: {
134
184
  label: string;
@@ -159,6 +209,34 @@ export declare const ITEMS: {
159
209
  description: string | null;
160
210
  }) => string;
161
211
  };
212
+ childrenBlock: {
213
+ label: string;
214
+ owner: "readHeader";
215
+ invokeOnly: string;
216
+ evidence: string;
217
+ render: typeof renderChildrenBlock;
218
+ };
219
+ workstreamLine: {
220
+ label: string;
221
+ owner: "readHeader";
222
+ invokeOnly: string;
223
+ evidence: string;
224
+ render: typeof renderWorkstreamLine;
225
+ };
226
+ sessionsLine: {
227
+ label: string;
228
+ owner: "readHeader";
229
+ invokeOnly: string;
230
+ evidence: string;
231
+ render: typeof renderSessionsLine;
232
+ };
233
+ archivePointer: {
234
+ label: string;
235
+ owner: "readHeader";
236
+ invokeOnly: string;
237
+ evidence: string;
238
+ render: typeof renderArchivePointer;
239
+ };
162
240
  writeTriggers: {
163
241
  label: string;
164
242
  owner: "block";
@@ -319,6 +397,13 @@ export declare const ITEMS: {
319
397
  evidence: string;
320
398
  text: string;
321
399
  };
400
+ healthProcedure: {
401
+ label: string;
402
+ owner: "hafezSkillBody";
403
+ invokeOnly: string;
404
+ evidence: string;
405
+ text: string;
406
+ };
322
407
  batchShape: {
323
408
  label: string;
324
409
  owner: "hafezSkillBody";
@@ -354,6 +439,20 @@ export declare const ITEMS: {
354
439
  evidence: string;
355
440
  render: typeof renderHarnessPermissions;
356
441
  };
442
+ cloudVault: {
443
+ label: string;
444
+ owner: "block";
445
+ invokeOnly: string;
446
+ evidence: string;
447
+ text: (_place: Place, _at: "owner" | "fallback", { vaultRepo }: SurfaceCtx) => string;
448
+ };
449
+ cloudSetup: {
450
+ label: string;
451
+ owner: "onboard";
452
+ invokeOnly: string;
453
+ evidence: string;
454
+ render: typeof renderCloudSetup;
455
+ };
357
456
  teachingLines: {
358
457
  label: string;
359
458
  owner: "onboard";