hafez 1.2.0 → 1.4.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 (52) hide show
  1. package/README.md +67 -9
  2. package/dist/apply.d.ts +30 -0
  3. package/dist/apply.js +582 -0
  4. package/dist/batch-ops.d.ts +470 -0
  5. package/dist/batch-ops.js +446 -0
  6. package/dist/cli/args.d.ts +19 -0
  7. package/dist/cli/args.js +53 -0
  8. package/dist/cli/batch-errors.js +6 -3
  9. package/dist/cli/commands.d.ts +24 -108
  10. package/dist/cli/commands.js +297 -313
  11. package/dist/cli/help-agent.d.ts +0 -5
  12. package/dist/cli/help-agent.js +44 -5
  13. package/dist/cli/index.d.ts +41 -0
  14. package/dist/cli/index.js +172 -172
  15. package/dist/cli/resolve-vault.js +2 -1
  16. package/dist/cli/schema-introspect.d.ts +1 -1
  17. package/dist/cli/schema-introspect.js +6 -22
  18. package/dist/cli/upgrade.d.ts +42 -0
  19. package/dist/cli/upgrade.js +305 -0
  20. package/dist/cli.js +0 -0
  21. package/dist/contracts.d.ts +55 -6
  22. package/dist/contracts.js +110 -23
  23. package/dist/dates.d.ts +10 -0
  24. package/dist/dates.js +27 -0
  25. package/dist/db.d.ts +27 -2
  26. package/dist/db.js +318 -56
  27. package/dist/digest.js +2 -0
  28. package/dist/document.d.ts +117 -0
  29. package/dist/document.js +578 -0
  30. package/dist/export-okf.js +48 -34
  31. package/dist/git.d.ts +7 -8
  32. package/dist/git.js +198 -63
  33. package/dist/index.d.ts +3 -4
  34. package/dist/index.js +302 -915
  35. package/dist/knowledge-index.d.ts +23 -1
  36. package/dist/knowledge-index.js +71 -13
  37. package/dist/merge.d.ts +1 -1
  38. package/dist/merge.js +106 -86
  39. package/dist/schema.js +4 -4
  40. package/dist/templates.d.ts +6 -0
  41. package/dist/templates.js +27 -27
  42. package/dist/types.d.ts +86 -12
  43. package/dist/vault.d.ts +51 -6
  44. package/dist/vault.js +114 -11
  45. package/package.json +2 -2
  46. package/skill/SKILL.md +51 -110
  47. package/dist/parser.d.ts +0 -18
  48. package/dist/parser.js +0 -68
  49. package/dist/query.d.ts +0 -24
  50. package/dist/query.js +0 -167
  51. package/dist/sections.d.ts +0 -54
  52. package/dist/sections.js +0 -297
@@ -0,0 +1,42 @@
1
+ export type Comparison = 'behind' | 'equal' | 'ahead' | 'unknown';
2
+ /** `command` entries are directly executable (after user approval); `action`
3
+ * entries are prose, so an agent that shells out every entry does not try to
4
+ * run English. */
5
+ export interface UpgradeStep {
6
+ kind: 'command' | 'action';
7
+ text: string;
8
+ }
9
+ export interface UpgradeState {
10
+ current: string;
11
+ latest: string | null;
12
+ comparison: Comparison;
13
+ registryReachable: boolean;
14
+ npmMajor: number | null;
15
+ install: 'registry' | 'dev-checkout';
16
+ skill: 'present' | 'absent';
17
+ skillSource: string;
18
+ skillTarget: string;
19
+ /** Mutually exclusive with the registry install in `steps` — exposed
20
+ * separately so an agent iterating `steps` cannot run both. */
21
+ alternativeInstall: string | null;
22
+ steps: UpgradeStep[];
23
+ }
24
+ export interface UpgradeDeps {
25
+ fetch: typeof globalThis.fetch;
26
+ registryUrl: string;
27
+ packageRoot: string;
28
+ installedSkillPath: string;
29
+ npmVersion: () => string;
30
+ }
31
+ /** Three-way compare on the numeric segments. Hand-rolled string compare is
32
+ * the trap this exists to avoid: `1.10.0` < `1.9.0` lexicographically.
33
+ * Anything that is not plain X.Y.Z (a prerelease, a build tag) is `unknown` —
34
+ * this tool never guesses at a version it cannot order. */
35
+ export declare function compareVersions(local: string, latest: string): Comparison;
36
+ export declare function buildUpgradeState(deps: UpgradeDeps): Promise<UpgradeState>;
37
+ export declare function renderUpgrade(state: UpgradeState): string;
38
+ /** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
39
+ * `(args, ctx)`) — the tests inject it directly. */
40
+ export declare function cmdUpgrade(args: string[], ctx: {
41
+ json: boolean;
42
+ }, deps?: UpgradeDeps): Promise<string>;
@@ -0,0 +1,305 @@
1
+ // src/cli/upgrade.ts
2
+ //
3
+ // `hafez upgrade` — agent-directed update check. It CHECKS AND PRINTS; it
4
+ // never installs. The printed steps install code and overwrite the user's
5
+ // agent instructions, so the privileged step is the human's — same discipline
6
+ // as `onboard` (writes nothing outside the vault) and as the release skill
7
+ // (`npm publish` is the user's action). It also sidesteps a process replacing
8
+ // its own binary mid-run.
9
+ //
10
+ // `pre-vault` on purpose: it must still work when the vault is unresolvable
11
+ // or the native SQLite binding is unbuilt, which is exactly when someone is
12
+ // trying to upgrade. Nothing here touches the core.
13
+ //
14
+ // Untrusted input: the registry response is rendered into a document an agent
15
+ // will execute commands from. Only the `version` field is read, only after it
16
+ // matches plain X.Y.Z, and no caught error's message, cause, or stack ever
17
+ // reaches stdout — `JSON.parse` embeds a slice of the offending body in its
18
+ // message, so every failure branch renders a fixed string constant.
19
+ import { execFileSync } from 'child_process';
20
+ import { existsSync, readFileSync } from 'fs';
21
+ import { homedir } from 'os';
22
+ import { dirname, join } from 'path';
23
+ import { fileURLToPath } from 'url';
24
+ import { UsageError } from './args.js';
25
+ import { INTEGRATION_SNIPPET } from './onboard.js';
26
+ const PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
27
+ const REGISTRY_TIMEOUT_MS = 3000;
28
+ const NPM_PROBE_TIMEOUT_MS = 3000;
29
+ const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/hafez/latest';
30
+ const DEV_CHECKOUT_WORK = 'git pull && npm run build && npm run install:local';
31
+ const NPM_PROBE_NOTE = 'The local npm version could not be determined, so every install flag is included above. ' +
32
+ 'npm majors that predate a flag print `npm warn Unknown cli config` and proceed — that warning is harmless.';
33
+ const ETARGET_NOTE = 'If the install fails with ETARGET, your configured registry (a mirror or proxy) does not have the pinned version — ' +
34
+ 'append `--registry=https://registry.npmjs.org` to the install command.';
35
+ const VERIFY_NOTE = 'Re-run `hafez upgrade` to verify.';
36
+ /** Three-way compare on the numeric segments. Hand-rolled string compare is
37
+ * the trap this exists to avoid: `1.10.0` < `1.9.0` lexicographically.
38
+ * Anything that is not plain X.Y.Z (a prerelease, a build tag) is `unknown` —
39
+ * this tool never guesses at a version it cannot order. */
40
+ export function compareVersions(local, latest) {
41
+ if (!PLAIN_SEMVER.test(local) || !PLAIN_SEMVER.test(latest))
42
+ return 'unknown';
43
+ const a = local.split('.').map(Number);
44
+ const b = latest.split('.').map(Number);
45
+ for (let i = 0; i < 3; i++) {
46
+ if (a[i] < b[i])
47
+ return 'behind';
48
+ if (a[i] > b[i])
49
+ return 'ahead';
50
+ }
51
+ return 'equal';
52
+ }
53
+ /** A path that cannot be made safe by single-quoting it. Spaces, `$(`, and
54
+ * globs are all inert inside single quotes; a quote, newline, or control
55
+ * character is not. */
56
+ function unquotable(path) {
57
+ return /['\u0000-\u001f]/.test(path);
58
+ }
59
+ function exists(path) {
60
+ try {
61
+ return existsSync(path);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** The installed version, from our own package.json — trusted input, so it is
68
+ * echoed as-is. A non-plain version (a dev prerelease) still prints; it just
69
+ * makes the comparison `unknown`. */
70
+ function readLocalVersion(packageRoot) {
71
+ try {
72
+ const parsed = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
73
+ return typeof parsed?.version === 'string' && parsed.version.length > 0 ? parsed.version : 'unknown';
74
+ }
75
+ catch {
76
+ return 'unknown';
77
+ }
78
+ }
79
+ /** Reachability and parseability are different facts, so they get different
80
+ * statuses: no usable answer at all (transport failure, timeout, non-2xx, or
81
+ * an unparseable body) is `reachable: false`; a parsed answer whose `version`
82
+ * is not plain X.Y.Z is `reachable: true` with a null latest. Both print no
83
+ * install command — there is no verified version to pin. The `reachable:
84
+ * false` message covers the mirror-404 case too, so it does not claim the
85
+ * user is offline when the registry answered. */
86
+ async function checkRegistry(deps) {
87
+ try {
88
+ const res = await deps.fetch(deps.registryUrl, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) });
89
+ if (!res.ok)
90
+ return { latest: null, reachable: false };
91
+ const body = (await res.json());
92
+ const version = body?.version;
93
+ if (typeof version !== 'string' || !PLAIN_SEMVER.test(version))
94
+ return { latest: null, reachable: true };
95
+ return { latest: version, reachable: true };
96
+ }
97
+ catch {
98
+ // Deliberately discards the caught value: its message can carry a slice of
99
+ // the response body straight into an agent's execution context.
100
+ return { latest: null, reachable: false };
101
+ }
102
+ }
103
+ function probeNpmMajor(deps) {
104
+ try {
105
+ const major = Number(deps.npmVersion().trim().split('.')[0]);
106
+ return Number.isInteger(major) && major > 0 ? major : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ /** The dev-checkout upgrade runs git and npm scripts, so it MUST name its
113
+ * directory: an agent's cwd is the user's own project, not this checkout, and
114
+ * a bare `git pull && npm run build` would upgrade whatever repo it happens to
115
+ * be standing in. */
116
+ function devCheckoutStep(packageRoot) {
117
+ if (unquotable(packageRoot)) {
118
+ return {
119
+ kind: 'action',
120
+ text: `Upgrade the development checkout at ${packageRoot} by hand — its path contains characters that single-quoting cannot make safe. In that directory run: ${DEV_CHECKOUT_WORK}`,
121
+ };
122
+ }
123
+ return { kind: 'command', text: `cd '${packageRoot}' && ${DEV_CHECKOUT_WORK}` };
124
+ }
125
+ function skillCopyStep(source, target, sourceExists) {
126
+ if (!sourceExists) {
127
+ // Emitting the cp anyway would hand the agent a step that fails with
128
+ // ENOENT and reads as a failed upgrade, for a check that found nothing.
129
+ return {
130
+ kind: 'action',
131
+ text: `No bundled skill at ${source}, so the installed copy at ${target} cannot be refreshed from this ` +
132
+ 'package. Re-copy it from the installed hafez package after upgrading — see the README Updating section.',
133
+ };
134
+ }
135
+ if (unquotable(source) || unquotable(target)) {
136
+ return {
137
+ kind: 'action',
138
+ text: 'Copy the bundled skill file over the installed one by hand — the paths contain characters ' +
139
+ `that single-quoting cannot make safe. Source: ${source} — Target: ${target}`,
140
+ };
141
+ }
142
+ return { kind: 'command', text: `mkdir -p '${dirname(target)}' && cp '${source}' '${target}'` };
143
+ }
144
+ /** The action list, in execution order. Every command is built from static
145
+ * constants; the only interpolated values are the regex-validated version and
146
+ * the quoted absolute paths (package root, skill source, skill target). */
147
+ function deriveSteps(state, ctx) {
148
+ const steps = [];
149
+ let alternativeInstall = null;
150
+ const upgrading = state.comparison === 'behind' && state.latest !== null;
151
+ if (upgrading) {
152
+ if (state.install === 'dev-checkout') {
153
+ // A contributor whose ~/.local/bin/hafez symlinks into a source tree
154
+ // would have the symlink shadow any global install forever — and the
155
+ // mandatory re-run would report `behind` forever with it.
156
+ steps.push(devCheckoutStep(ctx.packageRoot));
157
+ }
158
+ else {
159
+ // Pinned, never `@latest`: an unpinned command resolves at install time
160
+ // and can install something other than what was just verified.
161
+ const scripts = state.npmMajor === null || state.npmMajor >= 11 ? ' --allow-scripts=better-sqlite3' : '';
162
+ const remote = state.npmMajor === null || state.npmMajor >= 12 ? ' --allow-remote=root' : '';
163
+ steps.push({ kind: 'command', text: `npm install -g hafez@${state.latest}${scripts}` });
164
+ // Same package, same install scripts — the tarball needs the scripts flag too.
165
+ alternativeInstall =
166
+ `npm install -g https://github.com/shrvn-r/hafez/releases/download/v${state.latest}/hafez.tgz${scripts}${remote}`;
167
+ if (state.npmMajor === null)
168
+ steps.push({ kind: 'action', text: NPM_PROBE_NOTE });
169
+ steps.push({ kind: 'action', text: ETARGET_NOTE });
170
+ }
171
+ }
172
+ // Unconditional when the skill exists: the bundled skill ships INSIDE the
173
+ // installed package, so on the main upgrade path the bundled copy is equally
174
+ // stale and any byte-compare reports "current" in exactly the silent-drift
175
+ // case detection would exist for. Copying an identical file is a no-op.
176
+ if (state.skill === 'present')
177
+ steps.push(skillCopyStep(state.skillSource, state.skillTarget, ctx.skillSourceExists));
178
+ // A SCHEMA_VERSION bump does not self-heal: the read-only path warns and
179
+ // returns null, so the first post-upgrade query answers *empty* until a
180
+ // write command or an explicit rebuild.
181
+ if (upgrading)
182
+ steps.push({ kind: 'command', text: 'hafez index rebuild' });
183
+ if (steps.length > 0)
184
+ steps.push({ kind: 'action', text: VERIFY_NOTE });
185
+ return { steps, alternativeInstall };
186
+ }
187
+ export async function buildUpgradeState(deps) {
188
+ const current = readLocalVersion(deps.packageRoot);
189
+ const registry = await checkRegistry(deps);
190
+ const base = {
191
+ current,
192
+ latest: registry.latest,
193
+ comparison: registry.latest === null ? 'unknown' : compareVersions(current, registry.latest),
194
+ registryReachable: registry.reachable,
195
+ npmMajor: probeNpmMajor(deps),
196
+ // Published tarballs carry only dist, skill, README.md and LICENSE.
197
+ install: exists(join(deps.packageRoot, 'src')) ? 'dev-checkout' : 'registry',
198
+ skill: exists(deps.installedSkillPath) ? 'present' : 'absent',
199
+ skillSource: join(deps.packageRoot, 'skill', 'SKILL.md'),
200
+ skillTarget: deps.installedSkillPath,
201
+ };
202
+ return {
203
+ ...base,
204
+ ...deriveSteps(base, { packageRoot: deps.packageRoot, skillSourceExists: exists(base.skillSource) }),
205
+ };
206
+ }
207
+ function versionLines(state) {
208
+ const lines = [`Installed version: ${state.current}`];
209
+ if (state.latest === null) {
210
+ lines.push(state.registryReachable
211
+ ? "Published version: unknown — the registry's answer is not a plain X.Y.Z. Check manually at https://www.npmjs.com/package/hafez."
212
+ : "Published version: couldn't check (offline?) — the registry gave no usable answer. If HAFEZ_REGISTRY_URL points at a mirror, it may simply not carry hafez. Nothing has changed.");
213
+ return lines;
214
+ }
215
+ lines.push(`Published version: ${state.latest}`);
216
+ if (state.comparison === 'behind')
217
+ lines.push('Your install is BEHIND the published version — steps below.');
218
+ else if (state.comparison === 'equal')
219
+ lines.push('Your install is current. Checked against the npm registry only — a just-cut GitHub Release may not be published yet.');
220
+ else if (state.comparison === 'ahead')
221
+ lines.push('Local build is ahead of the published version — nothing to do.');
222
+ else
223
+ lines.push('Could not compare: the installed version is not a plain X.Y.Z. Check manually.');
224
+ return lines;
225
+ }
226
+ export function renderUpgrade(state) {
227
+ const out = [
228
+ '# Hafez — Upgrade Check',
229
+ '',
230
+ 'You are an agent reading this on behalf of your user. This command checks',
231
+ 'and prints — it never installs anything. The steps below install code and',
232
+ "overwrite the user's agent instructions, so show them verbatim, get an",
233
+ 'explicit yes, and only then run them. Never run them unattended.',
234
+ '',
235
+ '## Status',
236
+ '',
237
+ ...versionLines(state),
238
+ ];
239
+ if (state.install === 'dev-checkout') {
240
+ out.push('Install source: development checkout (this package has a src/ directory) — upgrade with git, not npm.');
241
+ }
242
+ out.push('');
243
+ out.push(state.skill === 'present'
244
+ ? `Skill: installed at ${state.skillTarget}. The skill ships inside the hafez package, so an upgraded package leaves this copy stale — the re-copy step is idempotent, run it either way.`
245
+ : `Skill: none at ${state.skillTarget} — nothing to re-copy (a non-Claude-Code harness, not drift).`);
246
+ out.push('');
247
+ out.push('Integration snippet — compare this against the Hafez section of the');
248
+ out.push("user's instructions file (Claude Code: ~/.claude/CLAUDE.md; any");
249
+ out.push('AGENTS.md harness: its global AGENTS.md) and reconcile differences');
250
+ out.push('with them. Do not rewrite their file unasked.');
251
+ out.push('');
252
+ out.push(INTEGRATION_SNIPPET);
253
+ out.push('');
254
+ out.push('## Steps');
255
+ out.push('');
256
+ if (state.steps.length === 0) {
257
+ out.push('Nothing to do.');
258
+ return out.join('\n') + '\n';
259
+ }
260
+ out.push('In this order, after the user approves:');
261
+ out.push('');
262
+ state.steps.forEach((step, i) => {
263
+ out.push(`${i + 1}. [${step.kind}] ${step.text}`);
264
+ });
265
+ if (state.alternativeInstall !== null) {
266
+ out.push('');
267
+ out.push('Alternative to the npm install above — ONLY if hafez was installed from');
268
+ out.push('the GitHub release tarball rather than the npm registry. Run one or the');
269
+ out.push('other, never both:');
270
+ out.push('');
271
+ out.push(` ${state.alternativeInstall}`);
272
+ }
273
+ return out.join('\n') + '\n';
274
+ }
275
+ function defaultDeps() {
276
+ const here = dirname(fileURLToPath(import.meta.url));
277
+ return {
278
+ fetch: (...args) => globalThis.fetch(...args),
279
+ // The documented escape hatch for private registries and proxied networks.
280
+ registryUrl: process.env.HAFEZ_REGISTRY_URL || DEFAULT_REGISTRY_URL,
281
+ packageRoot: join(here, '..', '..'),
282
+ installedSkillPath: join(homedir(), '.claude', 'skills', 'hafez', 'SKILL.md'),
283
+ // Bounded for the same reason the registry fetch is: a global prefix on a
284
+ // network mount or a cold cache can hang npm, and `upgrade` has to stay
285
+ // answerable when the environment is the thing that is broken. A timeout
286
+ // kills the probe, which degrades to npmMajor: null and every flag.
287
+ npmVersion: () => execFileSync('npm', ['--version'], {
288
+ encoding: 'utf-8',
289
+ stdio: ['ignore', 'pipe', 'ignore'],
290
+ timeout: NPM_PROBE_TIMEOUT_MS,
291
+ }),
292
+ };
293
+ }
294
+ const USAGE = 'Usage: hafez upgrade [--json]';
295
+ /** `deps` is the third parameter, unreachable from dispatch (PreVaultRun is
296
+ * `(args, ctx)`) — the tests inject it directly. */
297
+ export async function cmdUpgrade(args, ctx, deps = defaultDeps()) {
298
+ for (const arg of args) {
299
+ if (arg.startsWith('--'))
300
+ throw new UsageError(`Unknown flag ${arg}\n${USAGE}`);
301
+ throw new UsageError(`upgrade takes no arguments\n${USAGE}`);
302
+ }
303
+ const state = await buildUpgradeState(deps);
304
+ return ctx.json ? JSON.stringify(state, null, 2) : renderUpgrade(state);
305
+ }
package/dist/cli.js CHANGED
File without changes
@@ -1,3 +1,9 @@
1
+ import type { KnowledgeSubtype } from './types.js';
2
+ export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
3
+ export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
4
+ export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
5
+ export declare const CONFIDENCE_LEVELS: readonly ["observation", "pattern", "principle"];
6
+ export declare const VALID_SUBTYPES: readonly ["insight", "plan"];
1
7
  export interface TypeContract {
2
8
  type: string;
3
9
  description: string;
@@ -12,15 +18,58 @@ export interface TypeContract {
12
18
  export declare const ALL_CONTRACTS: TypeContract[];
13
19
  export declare function getContract(type: string): TypeContract;
14
20
  export declare function hasCapability(type: string, section: string): boolean;
15
- import type { KnowledgeSubtype } from './types.js';
16
- export declare const ENTITY_TYPES: readonly ["capture", "entity", "project"];
17
- export declare const ENTITY_STATUSES: readonly ["active", "paused", "done"];
18
- export declare const SESSION_LOG_TYPES: readonly ["progress", "decision", "blocker", "research"];
19
- export declare const CONFIDENCE_LEVELS: readonly ["observation", "pattern", "principle"];
20
- export declare const VALID_SUBTYPES: readonly ["insight", "plan"];
21
+ /** A knowledge note with no persisted subtype is an insight — the one place
22
+ * that default lives. Every consumer (templates, index rows, exports) reads
23
+ * it from here so classification can't drift between surfaces. */
24
+ export declare const DEFAULT_KNOWLEDGE_SUBTYPE: "insight";
21
25
  export declare const ALIAS_KEYS: {
22
26
  readonly 'update.fields.insight': "synthesis";
23
27
  readonly 'create.knowledge.fields.insight': "synthesis";
24
28
  readonly 'create.session.fields.insight': "synthesis";
25
29
  };
26
30
  export declare const SUBTYPE_SECTIONS: Record<KnowledgeSubtype, string[]>;
31
+ /** Sections whose content only ever grows — sync merges union their lines
32
+ * instead of letting the newer side replace the older (see merge.ts). */
33
+ export declare const APPEND_ONLY_SECTIONS: ReadonlySet<string>;
34
+ /** Session-file sections. Sessions have no TypeContract (they are not typed
35
+ * entities), but their headings are still structural for Document splitting. */
36
+ export declare const SESSION_SECTIONS: readonly ["Summary", "Entities Touched", "Decisions"];
37
+ /** Statuses a plan-subtype knowledge note may carry. Plans are tracked work
38
+ * with a lifecycle; the file validator and the mutation gate read this one
39
+ * tuple so they cannot drift. */
40
+ export declare const PLAN_STATUSES: readonly ["active", "done"];
41
+ /** Statuses by knowledge subtype. An empty list means `status` is not part of
42
+ * that subtype's contract at all — mutations setting it are rejected. */
43
+ export declare const SUBTYPE_STATUSES: Record<KnowledgeSubtype, readonly string[]>;
44
+ /** What a mutation is aimed at — enough to answer "does this document's
45
+ * contract have that field / that section?". Knowledge capabilities are
46
+ * subtype-level (plans and insights have different bodies and lifecycles),
47
+ * entity capabilities are type-level. */
48
+ export type DocTarget = {
49
+ kind: 'entity';
50
+ type: string;
51
+ } | {
52
+ kind: 'knowledge';
53
+ subtype?: KnowledgeSubtype;
54
+ };
55
+ /** Sections a document of this shape can carry — the one answer to "would
56
+ * text written into section X survive on this document?". */
57
+ export declare function sectionsFor(target: DocTarget): string[];
58
+ /** Statuses this document may carry. Empty ⇒ `status` is not a field of its
59
+ * contract, so setting it is a validation error. */
60
+ export declare function statusesFor(target: DocTarget): readonly string[];
61
+ /** Create-op fields that write their text into a named body section. The
62
+ * chosen template decides whether that section exists; when it doesn't, the
63
+ * text has nowhere to land, so the op is rejected (see `unreachableFields`).
64
+ * Update-op content fields are absent on purpose — `setSection` /
65
+ * `appendToSection` create a missing section, so update never loses text. */
66
+ export declare const CREATE_FIELD_SECTIONS: Record<string, string>;
67
+ /** Fields present in `fields` whose target section this document cannot hold.
68
+ * Policy is reject-never-route: text is never silently dropped and never
69
+ * redirected into a section the caller did not name. */
70
+ export declare function unreachableFields(target: DocTarget, fields: Record<string, unknown>): Array<{
71
+ field: string;
72
+ section: string;
73
+ }>;
74
+ /** Human label for a target, used in every gate's error text. */
75
+ export declare function describeTarget(target: DocTarget): string;
package/dist/contracts.js CHANGED
@@ -1,24 +1,48 @@
1
+ // --- Canonical enum tuples (single source of truth) ---
2
+ // Every consumer (file validators, the Op Spec table, help renderer, and the
3
+ // enum unions in types.ts) imports these — no other file may declare local
4
+ // copies, and the contracts below are built out of them rather than beside
5
+ // them.
6
+ export const ENTITY_TYPES = ['capture', 'entity', 'project'];
7
+ export const ENTITY_STATUSES = ['active', 'paused', 'done'];
8
+ export const SESSION_LOG_TYPES = ['progress', 'decision', 'blocker', 'research'];
9
+ export const CONFIDENCE_LEVELS = ['observation', 'pattern', 'principle'];
10
+ // 'insight' is the subtype (frontmatter classification), 'Synthesis' is the section heading.
11
+ // The rename was insight→synthesis for the section/field only; the subtype stays 'insight'.
12
+ export const VALID_SUBTYPES = ['insight', 'plan'];
13
+ /** The item frontmatter contract, shared by all three item types — they
14
+ * differ in sections and lifecycle, never in which fields are mandatory.
15
+ * schema.validateEntityFrontmatter enforces exactly this list (parity test in
16
+ * tests/contracts.test.ts). */
17
+ const ITEM_REQUIRED = ['name', 'type', 'status', 'created', 'last-touched'];
18
+ /** Optional frontmatter for the types that can sit in a hierarchy. A capture
19
+ * has not been triaged yet, so it carries neither links nor staleness. */
20
+ const ITEM_OPTIONAL = ['domain', 'parent', 'related', 'tags', 'staleness-days'];
1
21
  const CAPTURE = {
2
22
  type: 'capture',
3
23
  description: 'Quick capture. A link, a thought, a bug report. No structure required — just get it in.',
4
24
  kind: 'item',
5
- requiredFields: ['name', 'type', 'status', 'created', 'last-touched'],
25
+ requiredFields: ITEM_REQUIRED,
6
26
  optionalFields: ['domain', 'tags'],
7
- validStatuses: ['active', 'done'],
27
+ // A capture is triage-stage: it is either open or dealt with, never paused.
28
+ validStatuses: ENTITY_STATUSES.filter(s => s !== 'paused'),
8
29
  defaultSections: ['Notes'],
9
30
  // Brief/Next Actions: the CLI never type-gated setBrief/addNextAction, so captures
10
31
  // carrying a delivery brief and actions are established practice — the contract
11
- // follows observed behaviour (decided 2026-07-19).
12
- optionalSections: ['Session Log', 'Brief', 'Next Actions'],
32
+ // follows observed behaviour (decided 2026-07-19). Current State likewise:
33
+ // update --current-state is kind-gated, not type-gated, so captures can carry
34
+ // it — omitting it here made the heading non-structural under the per-type
35
+ // Document binding and a later Brief rewrite silently deleted the section.
36
+ optionalSections: ['Session Log', 'Brief', 'Next Actions', 'Current State'],
13
37
  canPromoteTo: ['entity', 'project', 'knowledge'],
14
38
  };
15
39
  const ENTITY = {
16
40
  type: 'entity',
17
41
  description: 'Tracked work with substance. A bug, feature, decision, or task. The atomic unit of work.',
18
42
  kind: 'item',
19
- requiredFields: ['name', 'type', 'status', 'created', 'last-touched'],
20
- optionalFields: ['domain', 'parent', 'related', 'tags', 'staleness-days'],
21
- validStatuses: ['active', 'paused', 'done'],
43
+ requiredFields: ITEM_REQUIRED,
44
+ optionalFields: ITEM_OPTIONAL,
45
+ validStatuses: [...ENTITY_STATUSES],
22
46
  defaultSections: ['Context', 'Session Log'],
23
47
  optionalSections: ['Brief', 'Next Actions', 'Current State'],
24
48
  canPromoteTo: ['project'],
@@ -27,9 +51,9 @@ const PROJECT = {
27
51
  type: 'project',
28
52
  description: 'Scoped effort with goals and deliverables. Can have children. Can be finite or perpetual.',
29
53
  kind: 'item',
30
- requiredFields: ['name', 'type', 'status', 'created', 'last-touched'],
31
- optionalFields: ['domain', 'parent', 'related', 'tags', 'staleness-days'],
32
- validStatuses: ['active', 'paused', 'done'],
54
+ requiredFields: ITEM_REQUIRED,
55
+ optionalFields: ITEM_OPTIONAL,
56
+ validStatuses: [...ENTITY_STATUSES],
33
57
  defaultSections: ['Purpose', 'Goals', 'Session Log'],
34
58
  optionalSections: ['Brief', 'Next Actions', 'Current State', 'Open Questions'],
35
59
  canPromoteTo: [],
@@ -58,22 +82,26 @@ export function getContract(type) {
58
82
  throw new Error(`Unknown type: ${type}. Valid types: ${Object.keys(CONTRACT_MAP).join(', ')}`);
59
83
  return contract;
60
84
  }
61
- export function hasCapability(type, section) {
85
+ /** Every section a type's contract declares, default and optional alike —
86
+ * the one place the two lists are concatenated (sectionsFor and hasCapability
87
+ * both answer from it). */
88
+ function contractSections(type) {
62
89
  const contract = getContract(type);
63
- return contract.defaultSections.includes(section) || contract.optionalSections.includes(section);
90
+ return [...contract.defaultSections, ...contract.optionalSections];
64
91
  }
65
- // --- Canonical enum tuples (single source of truth) ---
66
- // Every consumer (file validators, CLI input validator, help renderer) imports these.
67
- // The cli-schema-parity test enforces that no other file declares local copies.
68
- export const ENTITY_TYPES = ['capture', 'entity', 'project'];
69
- export const ENTITY_STATUSES = ['active', 'paused', 'done'];
70
- export const SESSION_LOG_TYPES = ['progress', 'decision', 'blocker', 'research'];
71
- export const CONFIDENCE_LEVELS = ['observation', 'pattern', 'principle'];
72
- // 'insight' is the subtype (frontmatter classification), 'Synthesis' is the section heading.
73
- // The rename was insight→synthesis for the section/field only; the subtype stays 'insight'.
74
- export const VALID_SUBTYPES = ['insight', 'plan'];
92
+ export function hasCapability(type, section) {
93
+ return contractSections(type).includes(section);
94
+ }
95
+ /** A knowledge note with no persisted subtype is an insight — the one place
96
+ * that default lives. Every consumer (templates, index rows, exports) reads
97
+ * it from here so classification can't drift between surfaces. */
98
+ export const DEFAULT_KNOWLEDGE_SUBTYPE = 'insight';
75
99
  // --- Alias table ---
76
- // Declared-alias fields that parseBatchInput normalizes to their canonical name.
100
+ // Declared-alias fields, keyed by the op path they may appear on and mapped to
101
+ // their canonical name. The Op Spec layer (normalizeAliases in batch-ops.ts)
102
+ // normalizes them once, before validation, so every entry point applies the same
103
+ // canonical op; an op path absent from this table (create.entity.fields.insight)
104
+ // is the kind gate — the strict shape rejects the alias there.
77
105
  // schema-introspect cannot infer these from Zod alone (both are declared .optional()),
78
106
  // so rendering code reads this table to strip aliased keys from user-facing output.
79
107
  export const ALIAS_KEYS = {
@@ -85,3 +113,62 @@ export const SUBTYPE_SECTIONS = {
85
113
  insight: ['Synthesis', 'Evidence', 'Sources'],
86
114
  plan: ['Goal', 'Steps', 'Dependencies'],
87
115
  };
116
+ /** Sections whose content only ever grows — sync merges union their lines
117
+ * instead of letting the newer side replace the older (see merge.ts). */
118
+ export const APPEND_ONLY_SECTIONS = new Set(['Evidence', 'Sources']);
119
+ /** Session-file sections. Sessions have no TypeContract (they are not typed
120
+ * entities), but their headings are still structural for Document splitting. */
121
+ export const SESSION_SECTIONS = ['Summary', 'Entities Touched', 'Decisions'];
122
+ /** Statuses a plan-subtype knowledge note may carry. Plans are tracked work
123
+ * with a lifecycle; the file validator and the mutation gate read this one
124
+ * tuple so they cannot drift. */
125
+ export const PLAN_STATUSES = ['active', 'done'];
126
+ /** Statuses by knowledge subtype. An empty list means `status` is not part of
127
+ * that subtype's contract at all — mutations setting it are rejected. */
128
+ export const SUBTYPE_STATUSES = {
129
+ insight: [],
130
+ plan: PLAN_STATUSES,
131
+ };
132
+ /** Sections a document of this shape can carry — the one answer to "would
133
+ * text written into section X survive on this document?". */
134
+ export function sectionsFor(target) {
135
+ if (target.kind === 'knowledge') {
136
+ return [...SUBTYPE_SECTIONS[target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE]];
137
+ }
138
+ return contractSections(target.type);
139
+ }
140
+ /** Statuses this document may carry. Empty ⇒ `status` is not a field of its
141
+ * contract, so setting it is a validation error. */
142
+ export function statusesFor(target) {
143
+ return target.kind === 'knowledge'
144
+ ? SUBTYPE_STATUSES[target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE]
145
+ : getContract(target.type).validStatuses;
146
+ }
147
+ /** Create-op fields that write their text into a named body section. The
148
+ * chosen template decides whether that section exists; when it doesn't, the
149
+ * text has nowhere to land, so the op is rejected (see `unreachableFields`).
150
+ * Update-op content fields are absent on purpose — `setSection` /
151
+ * `appendToSection` create a missing section, so update never loses text. */
152
+ export const CREATE_FIELD_SECTIONS = {
153
+ purpose: 'Purpose',
154
+ notes: 'Notes',
155
+ brief: 'Brief',
156
+ add_action: 'Next Actions',
157
+ add_actions: 'Next Actions',
158
+ synthesis: 'Synthesis',
159
+ };
160
+ /** Fields present in `fields` whose target section this document cannot hold.
161
+ * Policy is reject-never-route: text is never silently dropped and never
162
+ * redirected into a section the caller did not name. */
163
+ export function unreachableFields(target, fields) {
164
+ const available = new Set(sectionsFor(target));
165
+ return Object.entries(CREATE_FIELD_SECTIONS)
166
+ .filter(([field, section]) => fields[field] !== undefined && !available.has(section))
167
+ .map(([field, section]) => ({ field, section }));
168
+ }
169
+ /** Human label for a target, used in every gate's error text. */
170
+ export function describeTarget(target) {
171
+ return target.kind === 'entity'
172
+ ? `type '${target.type}'`
173
+ : `knowledge subtype '${target.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}'`;
174
+ }
@@ -0,0 +1,10 @@
1
+ /** The local calendar day as YYYY-MM-DD. The single source of "today". */
2
+ export declare function localDay(d?: Date): string;
3
+ /**
4
+ * Bare YYYY-MM-DD → local midnight, for git's approxidate. A date-only
5
+ * `--since` is otherwise filled with the *current* time of day ("since right
6
+ * now"), and any explicit instant must anchor to the same local day boundary
7
+ * the stamps and query comparisons use. Relative forms (`7.days.ago`) are
8
+ * already local and pass through untouched.
9
+ */
10
+ export declare function normalizeSinceDate(since: string): string;
package/dist/dates.js ADDED
@@ -0,0 +1,27 @@
1
+ // src/dates.ts
2
+ // The vault's one calendar-day convention: the LOCAL day.
3
+ //
4
+ // A hafez vault is personal — "today" means the user's today, so every
5
+ // surface that names a day (created / last-touched / session-log stamping,
6
+ // the dates `query --since/--before` compare against, and the `--since` the
7
+ // changelog hands to git) resolves to the local calendar day. Deriving
8
+ // stamps from UTC while comparing against local-midnight boundaries made the
9
+ // same date mean different days: west of UTC after ~13:00 local, a fresh
10
+ // entity was stamped with tomorrow.
11
+ function pad(n) {
12
+ return String(n).padStart(2, '0');
13
+ }
14
+ /** The local calendar day as YYYY-MM-DD. The single source of "today". */
15
+ export function localDay(d = new Date()) {
16
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
17
+ }
18
+ /**
19
+ * Bare YYYY-MM-DD → local midnight, for git's approxidate. A date-only
20
+ * `--since` is otherwise filled with the *current* time of day ("since right
21
+ * now"), and any explicit instant must anchor to the same local day boundary
22
+ * the stamps and query comparisons use. Relative forms (`7.days.ago`) are
23
+ * already local and pass through untouched.
24
+ */
25
+ export function normalizeSinceDate(since) {
26
+ return /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
27
+ }