hypomnema 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
package/scripts/feedback.mjs
CHANGED
|
@@ -2,35 +2,100 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Hypomnema feedback script
|
|
4
4
|
*
|
|
5
|
-
* Creates or appends to pages/feedback/<topic>.md with a
|
|
6
|
-
*
|
|
5
|
+
* Creates or appends to pages/feedback/<topic>.md with a behaviour-correction
|
|
6
|
+
* entry. The wiki feedback page is the single source of truth (ADR 0031 / fix
|
|
7
|
+
* #37); MEMORY.md and CLAUDE.md <learned_behaviors> are one-way *projections*
|
|
8
|
+
* derived from it. Never hand-edit those targets — this script writes the page
|
|
9
|
+
* and then runs `feedback-sync --write` to refresh the projection automatically.
|
|
10
|
+
*
|
|
7
11
|
* Used by /hypo:feedback after Claude drafts the feedback content.
|
|
8
12
|
*
|
|
9
13
|
* Usage:
|
|
10
|
-
* node scripts/feedback.mjs --topic=<slug> --entry=<text> [
|
|
14
|
+
* node scripts/feedback.mjs --topic=<slug> --entry=<text> [classification flags]
|
|
15
|
+
*
|
|
16
|
+
* Page write:
|
|
17
|
+
* --topic=<slug> Feedback topic slug (e.g. "response-length")
|
|
18
|
+
* --entry=<text> Rule body (one-line rule or short paragraph)
|
|
19
|
+
* --title=<text> Frontmatter title (default: topic)
|
|
20
|
+
*
|
|
21
|
+
* Classification (lint #8 schema — required on create):
|
|
22
|
+
* --scope=<v> global | project:<slug> (required)
|
|
23
|
+
* --tier=<v> L1 | L2 (required)
|
|
24
|
+
* --targets=<list> comma list of project-memory,claude-learned (default: project-memory)
|
|
25
|
+
* --sensitivity=<v> public | sanitized (default: public)
|
|
26
|
+
* --priority=<1-5> projection sort weight (default: 3)
|
|
27
|
+
* --memory-summary=<t> one-line MEMORY.md index summary (required)
|
|
28
|
+
* --reason=<t> why this rule exists (required)
|
|
29
|
+
* --source=<t> provenance (default: session:<today>)
|
|
30
|
+
* --behavior=<t> optional: the behaviour being corrected
|
|
31
|
+
*
|
|
32
|
+
* Required only when --targets includes claude-learned:
|
|
33
|
+
* --global-summary=<t> one-line CLAUDE.md learned-behaviour summary
|
|
34
|
+
* --promote-to-global mark the page for global CLAUDE.md projection
|
|
11
35
|
*
|
|
12
|
-
*
|
|
13
|
-
* --hypo-dir=<path>
|
|
14
|
-
* --
|
|
15
|
-
* --
|
|
16
|
-
* --
|
|
17
|
-
* --
|
|
36
|
+
* Modes:
|
|
37
|
+
* --hypo-dir=<path> Hypomnema root (default: HYPO_DIR / hypo-config.md / ~/hypomnema)
|
|
38
|
+
* --claude-home=<path> Projection target home for the post-step (default: ~/.claude)
|
|
39
|
+
* --project-id=<id> Projection MEMORY project-id for the post-step (default: derived from cwd)
|
|
40
|
+
* --no-sync Skip the automatic `feedback-sync --write` post-step
|
|
41
|
+
* --dry-run Preview without writing (also skips projection)
|
|
42
|
+
* --list List existing feedback topics
|
|
18
43
|
*/
|
|
19
44
|
|
|
20
45
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
|
21
|
-
import { join
|
|
46
|
+
import { join } from 'path';
|
|
47
|
+
import { spawnSync } from 'child_process';
|
|
48
|
+
import { fileURLToPath } from 'url';
|
|
22
49
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
23
50
|
|
|
51
|
+
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
52
|
+
|
|
24
53
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
25
54
|
|
|
26
55
|
function parseArgs(argv) {
|
|
27
|
-
const args = {
|
|
56
|
+
const args = {
|
|
57
|
+
hypoDir: null,
|
|
58
|
+
topic: null,
|
|
59
|
+
entry: null,
|
|
60
|
+
title: null,
|
|
61
|
+
scope: null,
|
|
62
|
+
tier: null,
|
|
63
|
+
targets: null,
|
|
64
|
+
sensitivity: 'public',
|
|
65
|
+
priority: '3',
|
|
66
|
+
memorySummary: null,
|
|
67
|
+
globalSummary: null,
|
|
68
|
+
promoteToGlobal: false,
|
|
69
|
+
reason: null,
|
|
70
|
+
source: null,
|
|
71
|
+
behavior: null,
|
|
72
|
+
claudeHome: null,
|
|
73
|
+
projectId: null,
|
|
74
|
+
noSync: false,
|
|
75
|
+
dryRun: false,
|
|
76
|
+
list: false,
|
|
77
|
+
};
|
|
28
78
|
for (const arg of argv.slice(2)) {
|
|
29
79
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
30
|
-
else if (arg.startsWith('--topic='))
|
|
31
|
-
else if (arg.startsWith('--entry='))
|
|
32
|
-
else if (arg
|
|
33
|
-
else if (arg
|
|
80
|
+
else if (arg.startsWith('--topic=')) args.topic = arg.slice(8);
|
|
81
|
+
else if (arg.startsWith('--entry=')) args.entry = arg.slice(8);
|
|
82
|
+
else if (arg.startsWith('--title=')) args.title = arg.slice(8);
|
|
83
|
+
else if (arg.startsWith('--scope=')) args.scope = arg.slice(8);
|
|
84
|
+
else if (arg.startsWith('--tier=')) args.tier = arg.slice(7);
|
|
85
|
+
else if (arg.startsWith('--targets=')) args.targets = arg.slice(10);
|
|
86
|
+
else if (arg.startsWith('--sensitivity=')) args.sensitivity = arg.slice(14);
|
|
87
|
+
else if (arg.startsWith('--priority=')) args.priority = arg.slice(11);
|
|
88
|
+
else if (arg.startsWith('--memory-summary=')) args.memorySummary = arg.slice(17);
|
|
89
|
+
else if (arg.startsWith('--global-summary=')) args.globalSummary = arg.slice(17);
|
|
90
|
+
else if (arg === '--promote-to-global') args.promoteToGlobal = true;
|
|
91
|
+
else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
|
|
92
|
+
else if (arg.startsWith('--source=')) args.source = arg.slice(9);
|
|
93
|
+
else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
|
|
94
|
+
else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
|
|
95
|
+
else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
|
|
96
|
+
else if (arg === '--no-sync') args.noSync = true;
|
|
97
|
+
else if (arg === '--dry-run') args.dryRun = true;
|
|
98
|
+
else if (arg === '--list') args.list = true;
|
|
34
99
|
}
|
|
35
100
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
36
101
|
return args;
|
|
@@ -44,7 +109,7 @@ function listTopics(hypoDir) {
|
|
|
44
109
|
console.log('No feedback pages found.');
|
|
45
110
|
return;
|
|
46
111
|
}
|
|
47
|
-
const files = readdirSync(feedbackDir).filter(f => f.endsWith('.md'));
|
|
112
|
+
const files = readdirSync(feedbackDir).filter((f) => f.endsWith('.md'));
|
|
48
113
|
if (files.length === 0) {
|
|
49
114
|
console.log('No feedback pages found.');
|
|
50
115
|
return;
|
|
@@ -53,54 +118,195 @@ function listTopics(hypoDir) {
|
|
|
53
118
|
for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
|
|
54
119
|
}
|
|
55
120
|
|
|
56
|
-
// ──
|
|
121
|
+
// ── classification validation (mirrors lint #8 / ADR 0031 §6) ──────────────────
|
|
57
122
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
123
|
+
const SCOPE_RE = /^(global|project:[a-z0-9][a-z0-9-]*)$/;
|
|
124
|
+
const TIER_ENUM = ['L1', 'L2'];
|
|
125
|
+
const SENSITIVITY_ENUM = ['public', 'sanitized']; // private is forbidden (wiki is git-public)
|
|
126
|
+
const TARGET_ENUM = ['project-memory', 'claude-learned'];
|
|
62
127
|
|
|
63
|
-
|
|
128
|
+
function parseTargets(raw) {
|
|
129
|
+
return String(raw || '')
|
|
130
|
+
.split(',')
|
|
131
|
+
.map((t) => t.trim())
|
|
132
|
+
.filter(Boolean);
|
|
133
|
+
}
|
|
64
134
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
135
|
+
// Validate the create-mode classification. Returns an array of error strings.
|
|
136
|
+
function validateClassification(args, targets) {
|
|
137
|
+
const errs = [];
|
|
138
|
+
if (!args.scope) errs.push('--scope is required (global | project:<slug>)');
|
|
139
|
+
else if (!SCOPE_RE.test(args.scope)) errs.push(`--scope invalid: "${args.scope}"`);
|
|
140
|
+
if (!args.tier) errs.push('--tier is required (L1 | L2)');
|
|
141
|
+
else if (!TIER_ENUM.includes(args.tier)) errs.push(`--tier invalid: "${args.tier}"`);
|
|
142
|
+
if (!SENSITIVITY_ENUM.includes(args.sensitivity))
|
|
143
|
+
errs.push(
|
|
144
|
+
`--sensitivity invalid: "${args.sensitivity}" (public | sanitized; private is forbidden)`,
|
|
145
|
+
);
|
|
146
|
+
if (!targets.length) errs.push('--targets is required (project-memory[,claude-learned])');
|
|
147
|
+
for (const t of targets)
|
|
148
|
+
if (!TARGET_ENUM.includes(t)) errs.push(`--targets invalid member: "${t}"`);
|
|
149
|
+
const pri = Number(args.priority);
|
|
150
|
+
if (!Number.isInteger(pri) || pri < 1 || pri > 5)
|
|
151
|
+
errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
|
|
152
|
+
if (!args.memorySummary) errs.push('--memory-summary is required');
|
|
153
|
+
if (!args.reason) errs.push('--reason is required');
|
|
154
|
+
|
|
155
|
+
// CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
|
|
156
|
+
// carry the two conditional fields (lint #8). Enforce here so we never write a
|
|
157
|
+
// claude-learned page that lint rejects or that silently never projects.
|
|
158
|
+
if (targets.includes('claude-learned')) {
|
|
159
|
+
if (!args.globalSummary)
|
|
160
|
+
errs.push('--global-summary is required when --targets includes claude-learned');
|
|
161
|
+
if (!args.promoteToGlobal)
|
|
162
|
+
errs.push('--promote-to-global is required when --targets includes claude-learned');
|
|
163
|
+
if (args.scope !== 'global')
|
|
164
|
+
errs.push('claude-learned projection requires --scope=global (ADR 0031 §6)');
|
|
165
|
+
if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1 (ADR 0031 §6)');
|
|
166
|
+
}
|
|
167
|
+
return errs;
|
|
168
|
+
}
|
|
76
169
|
|
|
77
|
-
|
|
170
|
+
// ── frontmatter rendering ──────────────────────────────────────────────────────
|
|
78
171
|
|
|
79
|
-
|
|
172
|
+
// Frontmatter scalars are written one-per-line as `key: value`. A raw newline in
|
|
173
|
+
// a value would inject a forged frontmatter key (e.g. a `reason` containing
|
|
174
|
+
// "\nstatus: archived"), silently overriding classification AFTER validation.
|
|
175
|
+
// These fields are one-liners by contract, so collapse any whitespace run
|
|
176
|
+
// (including newlines / control chars) to a single space and trim.
|
|
177
|
+
function oneLine(v) {
|
|
178
|
+
return String(v ?? '')
|
|
179
|
+
.replace(/\s+/g, ' ')
|
|
180
|
+
.trim();
|
|
181
|
+
}
|
|
80
182
|
|
|
81
|
-
|
|
82
|
-
|
|
183
|
+
function renderPage(args, targets, today) {
|
|
184
|
+
const title = oneLine(args.title || args.topic);
|
|
185
|
+
const lines = [
|
|
186
|
+
'---',
|
|
187
|
+
`title: ${title}`,
|
|
188
|
+
'tags: [feedback]',
|
|
189
|
+
'type: feedback',
|
|
190
|
+
'status: active',
|
|
191
|
+
`scope: ${args.scope}`,
|
|
192
|
+
`tier: ${args.tier}`,
|
|
193
|
+
`targets: [${targets.join(', ')}]`,
|
|
194
|
+
`sensitivity: ${args.sensitivity}`,
|
|
195
|
+
`priority: ${Number(args.priority)}`,
|
|
196
|
+
`memory_summary: ${oneLine(args.memorySummary)}`,
|
|
197
|
+
];
|
|
198
|
+
if (targets.includes('claude-learned')) {
|
|
199
|
+
lines.push(`global_summary: ${oneLine(args.globalSummary)}`);
|
|
200
|
+
lines.push(`promote_to_global: ${args.promoteToGlobal}`);
|
|
83
201
|
}
|
|
202
|
+
lines.push(`reason: ${oneLine(args.reason)}`);
|
|
203
|
+
lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
|
|
204
|
+
lines.push(`corrected_at: ${today}`);
|
|
205
|
+
lines.push(`updated: ${today}`);
|
|
206
|
+
lines.push(`created: ${today}`);
|
|
207
|
+
if (args.behavior) lines.push(`behavior: ${oneLine(args.behavior)}`);
|
|
208
|
+
lines.push('---');
|
|
209
|
+
lines.push('');
|
|
210
|
+
lines.push(`# ${title}`);
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push('## 규칙');
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push(args.entry);
|
|
215
|
+
lines.push('');
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── write feedback page ─────────────────────────────────────────────────────────
|
|
84
220
|
|
|
85
|
-
|
|
221
|
+
// Append-mode `updated:` bump: appending a dated entry to an existing page makes
|
|
222
|
+
// it the freshest correction, so it should sort first. Rewrite the `updated:`
|
|
223
|
+
// line ONLY inside the leading frontmatter block (between the first pair of
|
|
224
|
+
// `---` fences). A naive multiline replace would also rewrite any body line that
|
|
225
|
+
// happens to start with "updated:" (codex review) — so we scope to the fence.
|
|
226
|
+
function bumpUpdated(content, today) {
|
|
227
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
228
|
+
if (!m) return content; // no frontmatter → nothing to bump
|
|
229
|
+
const fm = m[1];
|
|
230
|
+
const bumped = /^updated:\s*/m.test(fm)
|
|
231
|
+
? fm.replace(/^(updated:\s*).*$/m, `$1${today}`)
|
|
232
|
+
: `${fm}\nupdated: ${today}`;
|
|
233
|
+
return content.replace(m[0], `---\n${bumped}\n---`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function writeFeedback(args, today) {
|
|
237
|
+
const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
|
|
238
|
+
const filePath = join(feedbackDir, `${args.topic}.md`);
|
|
239
|
+
const targets = parseTargets(args.targets || 'project-memory');
|
|
240
|
+
|
|
241
|
+
let content;
|
|
242
|
+
let mode;
|
|
243
|
+
if (existsSync(filePath)) {
|
|
244
|
+
// Append a dated entry; preserve existing frontmatter classification.
|
|
245
|
+
mode = 'append';
|
|
246
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
247
|
+
const appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
|
|
248
|
+
content = bumpUpdated(appended, today);
|
|
249
|
+
} else {
|
|
250
|
+
mode = 'create';
|
|
251
|
+
const errs = validateClassification(args, targets);
|
|
252
|
+
if (errs.length) {
|
|
253
|
+
console.error('Error: feedback classification incomplete:');
|
|
254
|
+
for (const e of errs) console.error(` - ${e}`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
content = renderPage(args, targets, today);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (args.dryRun) {
|
|
86
261
|
console.log('[DRY RUN — no changes made]\n');
|
|
87
|
-
console.log(`Would
|
|
262
|
+
console.log(`Would ${mode}: ${filePath}\n`);
|
|
88
263
|
console.log(content);
|
|
89
|
-
return;
|
|
264
|
+
return { wrote: false };
|
|
90
265
|
}
|
|
91
266
|
|
|
92
267
|
if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
|
|
93
268
|
writeFileSync(filePath, content);
|
|
94
|
-
console.log(`✓
|
|
269
|
+
console.log(`✓ ${mode === 'create' ? 'Created' : 'Updated'}: ${filePath}`);
|
|
95
270
|
|
|
96
271
|
// append to log.md
|
|
97
|
-
const logPath = join(hypoDir, 'log.md');
|
|
98
|
-
const logEntry = `\n- ${today} feedback: [[pages/feedback/${topic}]] — ${entry
|
|
272
|
+
const logPath = join(args.hypoDir, 'log.md');
|
|
273
|
+
const logEntry = `\n- ${today} feedback: [[pages/feedback/${args.topic}]] — ${args.entry
|
|
274
|
+
.split('\n')[0]
|
|
275
|
+
.slice(0, 80)}\n`;
|
|
99
276
|
if (existsSync(logPath)) {
|
|
100
277
|
const log = readFileSync(logPath, 'utf-8');
|
|
101
278
|
writeFileSync(logPath, log.trimEnd() + logEntry);
|
|
102
279
|
console.log(`↪ Appended to log.md`);
|
|
103
280
|
}
|
|
281
|
+
return { wrote: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── projection post-step (ADR 0031) ────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
// Refresh the MEMORY.md / CLAUDE.md projection from the just-written page. This
|
|
287
|
+
// is best-effort and NON-blocking: a projection failure (over-cap, conflict,
|
|
288
|
+
// unresolved project-id) must not fail the page write — the SoT is already
|
|
289
|
+
// saved. We surface a one-line stderr warning and let the user reconcile.
|
|
290
|
+
function runProjection(args) {
|
|
291
|
+
const cli = join(SCRIPT_DIR, 'feedback-sync.mjs');
|
|
292
|
+
if (!existsSync(cli)) return;
|
|
293
|
+
// Forward --claude-home / --project-id when given so the projection targets a
|
|
294
|
+
// caller-controlled location (tests, CI) instead of always defaulting to the
|
|
295
|
+
// real ~/.claude. Omitted → feedback-sync's own defaults (the production path:
|
|
296
|
+
// ~/.claude + project-id derived from cwd).
|
|
297
|
+
const cliArgs = [cli, '--write', '--no-input', `--hypo-dir=${args.hypoDir}`];
|
|
298
|
+
if (args.claudeHome) cliArgs.push(`--claude-home=${args.claudeHome}`);
|
|
299
|
+
if (args.projectId) cliArgs.push(`--project-id=${args.projectId}`);
|
|
300
|
+
const r = spawnSync(process.execPath, cliArgs, { encoding: 'utf-8' });
|
|
301
|
+
if (r.status === 0) {
|
|
302
|
+
console.log('↪ Projection refreshed (MEMORY.md / CLAUDE.md learned-behaviors)');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const detail = (r.stderr || '').trim().split('\n').slice(-1)[0] || `exit ${r.status}`;
|
|
306
|
+
console.error(
|
|
307
|
+
`⚠ feedback-sync --write did not complete cleanly (${detail}). ` +
|
|
308
|
+
`The page is saved; run \`hypomnema feedback-sync --check\` to reconcile the projection.`,
|
|
309
|
+
);
|
|
104
310
|
}
|
|
105
311
|
|
|
106
312
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
@@ -127,4 +333,7 @@ if (!args.entry) {
|
|
|
127
333
|
process.exit(1);
|
|
128
334
|
}
|
|
129
335
|
|
|
130
|
-
|
|
336
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
337
|
+
const { wrote } = writeFeedback(args, today);
|
|
338
|
+
|
|
339
|
+
if (wrote && !args.noSync) runProjection(args);
|
package/scripts/graph.mjs
CHANGED
|
@@ -24,8 +24,8 @@ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
|
24
24
|
function parseArgs(argv) {
|
|
25
25
|
const args = { hypoDir: null, format: 'json', minEdges: 0 };
|
|
26
26
|
for (const arg of argv.slice(2)) {
|
|
27
|
-
if (arg.startsWith('--hypo-dir='))
|
|
28
|
-
else if (arg.startsWith('--format='))
|
|
27
|
+
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
28
|
+
else if (arg.startsWith('--format=')) args.format = arg.slice(9);
|
|
29
29
|
else if (arg.startsWith('--min-edges=')) args.minEdges = parseInt(arg.slice(12), 10) || 0;
|
|
30
30
|
}
|
|
31
31
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
@@ -75,7 +75,7 @@ function extractWikilinks(content) {
|
|
|
75
75
|
|
|
76
76
|
function buildGraph(pages, slugIndex) {
|
|
77
77
|
const edges = [];
|
|
78
|
-
const inDegree
|
|
78
|
+
const inDegree = new Map();
|
|
79
79
|
const outDegree = new Map();
|
|
80
80
|
|
|
81
81
|
for (const p of pages) {
|
|
@@ -85,7 +85,11 @@ function buildGraph(pages, slugIndex) {
|
|
|
85
85
|
|
|
86
86
|
for (const p of pages) {
|
|
87
87
|
let content;
|
|
88
|
-
try {
|
|
88
|
+
try {
|
|
89
|
+
content = readFileSync(p.path, 'utf-8');
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
89
93
|
for (const link of extractWikilinks(content)) {
|
|
90
94
|
const target = slugIndex.get(link);
|
|
91
95
|
if (target && target !== p.slug) {
|
|
@@ -101,24 +105,28 @@ function buildGraph(pages, slugIndex) {
|
|
|
101
105
|
|
|
102
106
|
// ── label escapers ────────────────────────────────────────────────────────────
|
|
103
107
|
|
|
104
|
-
function escapeMermaid(s) {
|
|
105
|
-
|
|
108
|
+
function escapeMermaid(s) {
|
|
109
|
+
return s.replace(/"/g, '#quot;');
|
|
110
|
+
}
|
|
111
|
+
function escapeDot(s) {
|
|
112
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
113
|
+
}
|
|
106
114
|
|
|
107
115
|
// ── formatters ────────────────────────────────────────────────────────────────
|
|
108
116
|
|
|
109
117
|
function formatJson(pages, graph, minEdges) {
|
|
110
118
|
const nodes = pages
|
|
111
|
-
.map(p => ({
|
|
119
|
+
.map((p) => ({
|
|
112
120
|
slug: p.slug,
|
|
113
121
|
in: graph.inDegree.get(p.slug) || 0,
|
|
114
122
|
out: graph.outDegree.get(p.slug) || 0,
|
|
115
123
|
}))
|
|
116
|
-
.filter(n => minEdges === 0 || n.in + n.out >= minEdges)
|
|
117
|
-
.sort((a, b) =>
|
|
124
|
+
.filter((n) => minEdges === 0 || n.in + n.out >= minEdges)
|
|
125
|
+
.sort((a, b) => b.in + b.out - (a.in + a.out));
|
|
118
126
|
|
|
119
|
-
const edges = graph.edges.filter(e => {
|
|
120
|
-
const fn = nodes.find(n => n.slug === e.from);
|
|
121
|
-
const tn = nodes.find(n => n.slug === e.to);
|
|
127
|
+
const edges = graph.edges.filter((e) => {
|
|
128
|
+
const fn = nodes.find((n) => n.slug === e.from);
|
|
129
|
+
const tn = nodes.find((n) => n.slug === e.to);
|
|
122
130
|
return fn && tn;
|
|
123
131
|
});
|
|
124
132
|
|
|
@@ -128,11 +136,11 @@ function formatJson(pages, graph, minEdges) {
|
|
|
128
136
|
function formatMermaid(pages, graph, minEdges) {
|
|
129
137
|
const activeNodes = new Set(
|
|
130
138
|
pages
|
|
131
|
-
.filter(p => {
|
|
139
|
+
.filter((p) => {
|
|
132
140
|
const total = (graph.inDegree.get(p.slug) || 0) + (graph.outDegree.get(p.slug) || 0);
|
|
133
141
|
return total >= minEdges;
|
|
134
142
|
})
|
|
135
|
-
.map(p => p.slug)
|
|
143
|
+
.map((p) => p.slug),
|
|
136
144
|
);
|
|
137
145
|
|
|
138
146
|
const lines = ['graph TD'];
|
|
@@ -149,11 +157,11 @@ function formatMermaid(pages, graph, minEdges) {
|
|
|
149
157
|
function formatDot(pages, graph, minEdges) {
|
|
150
158
|
const activeNodes = new Set(
|
|
151
159
|
pages
|
|
152
|
-
.filter(p => {
|
|
160
|
+
.filter((p) => {
|
|
153
161
|
const total = (graph.inDegree.get(p.slug) || 0) + (graph.outDegree.get(p.slug) || 0);
|
|
154
162
|
return total >= minEdges;
|
|
155
163
|
})
|
|
156
|
-
.map(p => p.slug)
|
|
164
|
+
.map((p) => p.slug),
|
|
157
165
|
);
|
|
158
166
|
|
|
159
167
|
const lines = ['digraph wiki {', ' rankdir=LR;'];
|
|
@@ -171,13 +179,18 @@ function formatDot(pages, graph, minEdges) {
|
|
|
171
179
|
const args = parseArgs(process.argv);
|
|
172
180
|
|
|
173
181
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
174
|
-
const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
|
|
175
|
-
const pages
|
|
182
|
+
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
183
|
+
const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
|
|
176
184
|
const slugIndex = buildSlugIndex(pages);
|
|
177
|
-
const graph
|
|
185
|
+
const graph = buildGraph(pages, slugIndex);
|
|
178
186
|
|
|
179
187
|
switch (args.format) {
|
|
180
|
-
case 'mermaid':
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
case 'mermaid':
|
|
189
|
+
console.log(formatMermaid(pages, graph, args.minEdges));
|
|
190
|
+
break;
|
|
191
|
+
case 'dot':
|
|
192
|
+
console.log(formatDot(pages, graph, args.minEdges));
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
console.log(formatJson(pages, graph, args.minEdges));
|
|
183
196
|
}
|
package/scripts/ingest.mjs
CHANGED
|
@@ -16,25 +16,56 @@
|
|
|
16
16
|
* Options:
|
|
17
17
|
* --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
|
|
18
18
|
* --json Output as JSON
|
|
19
|
+
* --check=<path> Privacy guard: exit 1 if <path> matches a `.hypoignore`
|
|
20
|
+
* pattern, exit 0 silently otherwise. Used by `/hypo:ingest`
|
|
21
|
+
* to refuse ingesting secrets before they reach `sources/`.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
22
|
-
import { join, extname, basename } from 'path';
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
25
|
+
import { join, extname, basename, isAbsolute } from 'path';
|
|
23
26
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
24
27
|
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
25
28
|
|
|
26
29
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
27
30
|
|
|
28
31
|
function parseArgs(argv) {
|
|
29
|
-
const args = { hypoDir: null, json: false };
|
|
32
|
+
const args = { hypoDir: null, json: false, check: null };
|
|
30
33
|
for (const arg of argv.slice(2)) {
|
|
31
34
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
32
|
-
else if (arg === '--json')
|
|
35
|
+
else if (arg === '--json') args.json = true;
|
|
36
|
+
else if (arg.startsWith('--check=')) args.check = expandHome(arg.slice(8));
|
|
33
37
|
}
|
|
34
38
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
35
39
|
return args;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// Find the first `.hypoignore` pattern that matches `target`, or null.
|
|
43
|
+
// `target` is resolved relative to `hypoDir` when not already absolute, so
|
|
44
|
+
// both wiki-relative destinations (`sources/<slug>.md`) and user-supplied
|
|
45
|
+
// input paths land inside the wiki tree for anchored-pattern matching.
|
|
46
|
+
//
|
|
47
|
+
// A symlink with an innocuous name (`note.md` → `.env`) must still be
|
|
48
|
+
// refused, so an existing target is also checked by its realpath. realpath
|
|
49
|
+
// throws ENOENT for a not-yet-created destination — fall back to the lexical
|
|
50
|
+
// path in that case. Both the lexical and resolved paths are tested, for
|
|
51
|
+
// defense-in-depth when only one of them matches a pattern.
|
|
52
|
+
function matchingIgnorePattern(target, hypoDir, patterns) {
|
|
53
|
+
const lexical = isAbsolute(target) ? target : join(hypoDir, target);
|
|
54
|
+
let resolved = lexical;
|
|
55
|
+
try {
|
|
56
|
+
resolved = realpathSync(lexical);
|
|
57
|
+
} catch {
|
|
58
|
+
/* not on disk — lexical only */
|
|
59
|
+
}
|
|
60
|
+
const candidates = resolved === lexical ? [lexical] : [lexical, resolved];
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (isIgnored(candidate, hypoDir, [pattern])) return pattern;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
39
70
|
|
|
40
71
|
function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
@@ -57,7 +88,10 @@ function parseFrontmatter(content) {
|
|
|
57
88
|
for (const line of m[1].split('\n')) {
|
|
58
89
|
const idx = line.indexOf(':');
|
|
59
90
|
if (idx < 0) continue;
|
|
60
|
-
fm[line.slice(0, idx).trim()] = line
|
|
91
|
+
fm[line.slice(0, idx).trim()] = line
|
|
92
|
+
.slice(idx + 1)
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/^["']|["']$/g, '');
|
|
61
95
|
}
|
|
62
96
|
return fm;
|
|
63
97
|
}
|
|
@@ -67,9 +101,30 @@ function parseFrontmatter(content) {
|
|
|
67
101
|
const args = parseArgs(process.argv);
|
|
68
102
|
|
|
69
103
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
104
|
+
|
|
105
|
+
// ── --check guard ────────────────────────────────────────────────────────────
|
|
106
|
+
// Privacy boundary (spec §6.8 / §8.10): refuse to ingest a path that matches
|
|
107
|
+
// `.hypoignore` before it ever reaches `sources/`. Separate from the
|
|
108
|
+
// listing-mode filter below — this is the explicit-reject path.
|
|
109
|
+
if (args.check) {
|
|
110
|
+
const matched = matchingIgnorePattern(args.check, args.hypoDir, ignorePatterns);
|
|
111
|
+
if (matched) {
|
|
112
|
+
console.error(
|
|
113
|
+
`Refused: '${args.check}' matches .hypoignore pattern '${matched}' — not ingesting.`,
|
|
114
|
+
);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
70
120
|
const sourcesDir = join(args.hypoDir, 'sources');
|
|
71
121
|
const allSources = existsSync(sourcesDir)
|
|
72
|
-
? readdirSync(sourcesDir).filter(
|
|
122
|
+
? readdirSync(sourcesDir).filter(
|
|
123
|
+
(e) =>
|
|
124
|
+
!e.startsWith('.') &&
|
|
125
|
+
!statSync(join(sourcesDir, e)).isDirectory() &&
|
|
126
|
+
!isIgnored(join(sourcesDir, e), args.hypoDir, ignorePatterns),
|
|
127
|
+
)
|
|
73
128
|
: [];
|
|
74
129
|
|
|
75
130
|
// collect all source: references in wiki pages
|
|
@@ -78,7 +133,11 @@ const referencedSources = new Set();
|
|
|
78
133
|
|
|
79
134
|
for (const f of pageFiles) {
|
|
80
135
|
let content;
|
|
81
|
-
try {
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(f, 'utf-8');
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
82
141
|
const fm = parseFrontmatter(content);
|
|
83
142
|
if (fm.source && !fm.source.startsWith('session:')) {
|
|
84
143
|
referencedSources.add(fm.source);
|
|
@@ -86,7 +145,7 @@ for (const f of pageFiles) {
|
|
|
86
145
|
}
|
|
87
146
|
|
|
88
147
|
// sources with no summary page
|
|
89
|
-
const orphaned = allSources.filter(s => {
|
|
148
|
+
const orphaned = allSources.filter((s) => {
|
|
90
149
|
const slug = basename(s, extname(s));
|
|
91
150
|
return !referencedSources.has(s) && !referencedSources.has(slug);
|
|
92
151
|
});
|
|
@@ -95,24 +154,38 @@ const orphaned = allSources.filter(s => {
|
|
|
95
154
|
const missingSource = [];
|
|
96
155
|
for (const f of pageFiles) {
|
|
97
156
|
let content;
|
|
98
|
-
try {
|
|
157
|
+
try {
|
|
158
|
+
content = readFileSync(f, 'utf-8');
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
99
162
|
const fm = parseFrontmatter(content);
|
|
100
163
|
if (!fm.source || fm.source.startsWith('session:')) continue;
|
|
101
164
|
const sourceFile = join(sourcesDir, fm.source);
|
|
102
|
-
const sourceFileWithExt = allSources.find(
|
|
165
|
+
const sourceFileWithExt = allSources.find(
|
|
166
|
+
(s) => s === fm.source || basename(s, extname(s)) === fm.source,
|
|
167
|
+
);
|
|
103
168
|
if (!sourceFileWithExt && !existsSync(sourceFile)) {
|
|
104
169
|
missingSource.push({ page: f, source: fm.source });
|
|
105
170
|
}
|
|
106
171
|
}
|
|
107
172
|
|
|
108
173
|
if (args.json) {
|
|
109
|
-
console.log(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
174
|
+
console.log(
|
|
175
|
+
JSON.stringify(
|
|
176
|
+
{
|
|
177
|
+
totalSources: allSources.length,
|
|
178
|
+
orphaned,
|
|
179
|
+
missingSource,
|
|
180
|
+
},
|
|
181
|
+
null,
|
|
182
|
+
2,
|
|
183
|
+
),
|
|
184
|
+
);
|
|
114
185
|
} else {
|
|
115
|
-
console.log(
|
|
186
|
+
console.log(
|
|
187
|
+
'[hypomnema] Listing pending ingest targets — synthesis is performed by /hypo:ingest inside Claude Code.',
|
|
188
|
+
);
|
|
116
189
|
console.log(`Sources: ${allSources.length} total`);
|
|
117
190
|
|
|
118
191
|
if (orphaned.length === 0) {
|