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/lint.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { join, relative, extname, basename } from 'path';
|
|
|
18
18
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
19
19
|
import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
|
|
20
20
|
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
21
|
+
import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
|
|
22
|
+
import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
|
|
21
23
|
|
|
22
24
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
23
25
|
|
|
@@ -25,8 +27,8 @@ function parseArgs(argv) {
|
|
|
25
27
|
const args = { hypoDir: null, json: false, fix: false };
|
|
26
28
|
for (const arg of argv.slice(2)) {
|
|
27
29
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
28
|
-
else if (arg === '--json')
|
|
29
|
-
else if (arg === '--fix')
|
|
30
|
+
else if (arg === '--json') args.json = true;
|
|
31
|
+
else if (arg === '--fix') args.fix = true;
|
|
30
32
|
}
|
|
31
33
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
32
34
|
return args;
|
|
@@ -41,11 +43,69 @@ function parseFrontmatter(content) {
|
|
|
41
43
|
for (const line of m[1].split('\n')) {
|
|
42
44
|
const idx = line.indexOf(':');
|
|
43
45
|
if (idx < 0) continue;
|
|
44
|
-
fm[line.slice(0, idx).trim()] = line
|
|
46
|
+
fm[line.slice(0, idx).trim()] = line
|
|
47
|
+
.slice(idx + 1)
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/^["']|["']$/g, '');
|
|
45
50
|
}
|
|
46
51
|
return fm;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
function parseTagsField(rawValue) {
|
|
55
|
+
if (rawValue == null) return null;
|
|
56
|
+
const trimmed = String(rawValue).trim();
|
|
57
|
+
if (!trimmed) return [];
|
|
58
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
59
|
+
return trimmed
|
|
60
|
+
.slice(1, -1)
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((t) => t.trim().replace(/^["']|["']$/g, ''))
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
return trimmed
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((t) => t.trim().replace(/^["']|["']$/g, ''))
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// type-conditional required fields (spec §6.3, SCHEMA.md §2)
|
|
72
|
+
const TYPE_CONDITIONAL_FIELDS = {
|
|
73
|
+
prd: ['status', 'started'],
|
|
74
|
+
adr: ['source', 'status', 'date'],
|
|
75
|
+
'project-index': ['working_dir', 'status', 'started'],
|
|
76
|
+
'tool-eval': ['status'],
|
|
77
|
+
postmortem: ['outcome'],
|
|
78
|
+
learning: ['source'],
|
|
79
|
+
// feedback: ADR 0031 / fix #37 — projection SoT requires full classification
|
|
80
|
+
feedback: [
|
|
81
|
+
'status',
|
|
82
|
+
'scope',
|
|
83
|
+
'tier',
|
|
84
|
+
'targets',
|
|
85
|
+
'sensitivity',
|
|
86
|
+
'priority',
|
|
87
|
+
'memory_summary',
|
|
88
|
+
'reason',
|
|
89
|
+
'source',
|
|
90
|
+
],
|
|
91
|
+
'prompt-pattern': ['source'],
|
|
92
|
+
'source-summary': ['sources'],
|
|
93
|
+
'weekly-journal': ['week'],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const TYPE_ENUM_FIELDS = {
|
|
97
|
+
prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
|
|
98
|
+
adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
|
|
99
|
+
'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
|
|
100
|
+
// feedback: ADR 0031 / fix #37 — sensitivity:private is forbidden (wiki is a
|
|
101
|
+
// git-pushed public surface)
|
|
102
|
+
feedback: {
|
|
103
|
+
status: ['active', 'superseded', 'archived'],
|
|
104
|
+
tier: ['L1', 'L2'],
|
|
105
|
+
sensitivity: ['public', 'sanitized'],
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
49
109
|
// ── page collector ────────────────────────────────────────────────────────────
|
|
50
110
|
|
|
51
111
|
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
@@ -55,6 +115,11 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
|
55
115
|
if (isIgnored(full, root, ignorePatterns)) continue;
|
|
56
116
|
const st = statSync(full);
|
|
57
117
|
if (st.isDirectory()) {
|
|
118
|
+
// `_`-prefixed dirs (e.g. pages/feedback/_drafts) hold scaffolds / scratch
|
|
119
|
+
// not yet promoted to content — skip so incomplete frontmatter never errors
|
|
120
|
+
// (mirrors loadFeedbackPages in feedback-sync.mjs). `_`-prefixed *files*
|
|
121
|
+
// (e.g. _index.md) are still linted.
|
|
122
|
+
if (entry.startsWith('_')) continue;
|
|
58
123
|
collectPages(full, root, pages, ignorePatterns);
|
|
59
124
|
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
60
125
|
pages.push({ path: full, rel: relative(root, full) });
|
|
@@ -87,11 +152,11 @@ function buildSlugMap(pages) {
|
|
|
87
152
|
// wikilinks between them.
|
|
88
153
|
function stripNonWikilinkRegions(content) {
|
|
89
154
|
let out = content;
|
|
90
|
-
out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, m => m.replace(/[^\n]/g, ' '));
|
|
91
|
-
out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, m => m.replace(/[^\n]/g, ' '));
|
|
92
|
-
out = out.replace(/<!--[\s\S]*?-->/g, m => m.replace(/[^\n]/g, ' '));
|
|
93
|
-
out = out.replace(/``[^`\n]*``/g, m => ' '.repeat(m.length));
|
|
94
|
-
out = out.replace(/`[^`\n]*`/g, m => ' '.repeat(m.length));
|
|
155
|
+
out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
156
|
+
out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
157
|
+
out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
158
|
+
out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
|
|
159
|
+
out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
|
95
160
|
return out;
|
|
96
161
|
}
|
|
97
162
|
|
|
@@ -108,16 +173,33 @@ function extractWikilinks(content) {
|
|
|
108
173
|
|
|
109
174
|
const REQUIRED_FIELDS = ['title', 'type'];
|
|
110
175
|
const VALID_TYPES = [
|
|
111
|
-
'concept',
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
176
|
+
'concept',
|
|
177
|
+
'source-summary',
|
|
178
|
+
'entity',
|
|
179
|
+
'tool-eval',
|
|
180
|
+
'prompt-pattern',
|
|
181
|
+
'playbook',
|
|
182
|
+
'learning',
|
|
183
|
+
'tip',
|
|
184
|
+
'feedback',
|
|
185
|
+
'reference',
|
|
186
|
+
'synthesis',
|
|
187
|
+
'weekly-journal',
|
|
188
|
+
'prd',
|
|
189
|
+
'adr',
|
|
190
|
+
'session-log',
|
|
191
|
+
'session-state',
|
|
192
|
+
'project-index',
|
|
193
|
+
'postmortem',
|
|
194
|
+
'open-questions',
|
|
195
|
+
'schema',
|
|
196
|
+
'source',
|
|
115
197
|
];
|
|
116
198
|
|
|
117
199
|
const issues = [];
|
|
118
200
|
|
|
119
|
-
function issue(severity, rel, msg, fullPath = null) {
|
|
120
|
-
issues.push({ severity, file: rel, message: msg, path: fullPath });
|
|
201
|
+
function issue(severity, rel, msg, fullPath = null, id = null) {
|
|
202
|
+
issues.push({ severity, file: rel, message: msg, path: fullPath, id });
|
|
121
203
|
}
|
|
122
204
|
|
|
123
205
|
function hasHeading(content, heading) {
|
|
@@ -128,18 +210,43 @@ function hasHeading(content, heading) {
|
|
|
128
210
|
function lintSessionStateHeadings(content, rel) {
|
|
129
211
|
if (!rel.match(/^projects\/[^/]+\/session-state\.md$/)) return;
|
|
130
212
|
|
|
131
|
-
if (!SESSION_STATE_NEXT_HEADINGS.some(heading => hasHeading(content, heading))) {
|
|
213
|
+
if (!SESSION_STATE_NEXT_HEADINGS.some((heading) => hasHeading(content, heading))) {
|
|
132
214
|
issue(
|
|
133
215
|
'error',
|
|
134
216
|
rel,
|
|
135
|
-
`Missing required session-state heading: one of ${SESSION_STATE_NEXT_HEADINGS.map(h => `## ${h}`).join(', ')}
|
|
217
|
+
`Missing required session-state heading: one of ${SESSION_STATE_NEXT_HEADINGS.map((h) => `## ${h}`).join(', ')}`,
|
|
136
218
|
);
|
|
137
219
|
}
|
|
138
220
|
}
|
|
139
221
|
|
|
140
|
-
function lintPage({ path, rel }, slugMap) {
|
|
222
|
+
function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
223
|
+
// Directory whitelist: a content page under pages/<subdir>/ must live in a
|
|
224
|
+
// SCHEMA-defined directory. Catches typo'd dirs (e.g. pages/learning/ vs the
|
|
225
|
+
// canonical pages/learnings/) regardless of frontmatter, since a directory
|
|
226
|
+
// absent from SCHEMA breaks wikilink resolution and crystallize routing.
|
|
227
|
+
// `_`-prefixed files (e.g. pages/observability/_index.md) are scaffold/section
|
|
228
|
+
// markers, not content — exempt them so packaged scaffold dirs that aren't a
|
|
229
|
+
// page *type* (e.g. observability, a topical grouping of reference pages) don't
|
|
230
|
+
// trip the guard. A real typo dir is created with content, not just an index.
|
|
231
|
+
// Skipped entirely when pageDirs is empty (SCHEMA.md or the table absent) for
|
|
232
|
+
// back-compat with minimal wikis — mirrors the tag-vocab check.
|
|
233
|
+
if (pageDirs && pageDirs.size > 0 && !basename(rel).startsWith('_')) {
|
|
234
|
+
const segs = rel.replace(/\\/g, '/').split('/');
|
|
235
|
+
if (segs[0] === 'pages' && segs.length > 2 && !pageDirs.has(segs[1])) {
|
|
236
|
+
issue(
|
|
237
|
+
'error',
|
|
238
|
+
rel,
|
|
239
|
+
`Undefined pages/ directory: "pages/${segs[1]}/" not in SCHEMA.md (likely a typo — defined: ${[...pageDirs].sort().join(', ')})`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
141
244
|
let content;
|
|
142
|
-
try {
|
|
245
|
+
try {
|
|
246
|
+
content = readFileSync(path, 'utf-8');
|
|
247
|
+
} catch {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
143
250
|
|
|
144
251
|
if (!content.match(/^---\r?\n/)) {
|
|
145
252
|
issue('warn', rel, 'No frontmatter found');
|
|
@@ -164,6 +271,63 @@ function lintPage({ path, rel }, slugMap) {
|
|
|
164
271
|
issue('warn', rel, 'Missing frontmatter field: updated', path);
|
|
165
272
|
}
|
|
166
273
|
|
|
274
|
+
// type-conditional required fields
|
|
275
|
+
if (fm.type && TYPE_CONDITIONAL_FIELDS[fm.type]) {
|
|
276
|
+
for (const field of TYPE_CONDITIONAL_FIELDS[fm.type]) {
|
|
277
|
+
if (!fm[field]) {
|
|
278
|
+
issue('error', rel, `Missing required field for type "${fm.type}": ${field}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// type-specific enum validation
|
|
284
|
+
if (fm.type && TYPE_ENUM_FIELDS[fm.type]) {
|
|
285
|
+
for (const [field, allowed] of Object.entries(TYPE_ENUM_FIELDS[fm.type])) {
|
|
286
|
+
if (fm[field] && !allowed.includes(fm[field])) {
|
|
287
|
+
issue(
|
|
288
|
+
'error',
|
|
289
|
+
rel,
|
|
290
|
+
`Invalid value for ${field} on type "${fm.type}": "${fm[field]}" (allowed: ${allowed.join(', ')})`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
|
|
297
|
+
if (fm.type === 'feedback') {
|
|
298
|
+
const scope = fm.scope || '';
|
|
299
|
+
if (scope && scope !== 'global' && !/^project:[a-z0-9][a-z0-9-]*$/.test(scope)) {
|
|
300
|
+
issue('error', rel, `Invalid feedback scope: "${scope}" (allowed: global | project:<slug>)`);
|
|
301
|
+
}
|
|
302
|
+
const fbTargets = parseTagsField(fm.targets) || [];
|
|
303
|
+
if (fbTargets.includes('claude-learned')) {
|
|
304
|
+
for (const field of ['global_summary', 'promote_to_global']) {
|
|
305
|
+
if (!fm[field]) {
|
|
306
|
+
issue(
|
|
307
|
+
'error',
|
|
308
|
+
rel,
|
|
309
|
+
`Missing required field for feedback with targets:claude-learned: ${field}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// tag vocabulary + forbidden patterns
|
|
317
|
+
const tags = parseTagsField(fm.tags);
|
|
318
|
+
if (tags && tagVocab && tagVocab.size > 0) {
|
|
319
|
+
for (const tag of tags) {
|
|
320
|
+
const forbidden = checkForbidden(tag);
|
|
321
|
+
if (forbidden) {
|
|
322
|
+
issue('error', rel, `Forbidden tag pattern (${forbidden}): "${tag}"`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!tagVocab.has(tag)) {
|
|
326
|
+
issue('error', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
167
331
|
lintSessionStateHeadings(content, rel);
|
|
168
332
|
|
|
169
333
|
for (const link of extractWikilinks(content)) {
|
|
@@ -178,17 +342,38 @@ function lintPage({ path, rel }, slugMap) {
|
|
|
178
342
|
const args = parseArgs(process.argv);
|
|
179
343
|
|
|
180
344
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
181
|
-
const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
|
|
182
|
-
const pages = scanDirs.flatMap(d => collectPages(d, args.hypoDir, [], ignorePatterns));
|
|
345
|
+
const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
|
|
346
|
+
const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
|
|
183
347
|
const slugMap = buildSlugMap(pages);
|
|
184
|
-
|
|
185
|
-
|
|
348
|
+
const tagVocab = parseSchemaVocab(args.hypoDir);
|
|
349
|
+
const pageDirs = parseSchemaPageDirs(args.hypoDir);
|
|
350
|
+
|
|
351
|
+
for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs);
|
|
352
|
+
|
|
353
|
+
// W8: design-history.md stale relative to session-log.md. Emitted once per
|
|
354
|
+
// project (not per page) — runs outside the page loop. POSIX-separated path
|
|
355
|
+
// literal (not path.join) so consumers can rely on `file.split('/')` shape
|
|
356
|
+
// regardless of host OS — `hooks/hypo-personal-check.mjs:246` depends on this.
|
|
357
|
+
for (const s of findDesignHistoryStale(args.hypoDir)) {
|
|
358
|
+
const gap = s.diffDays != null ? ` (${s.diffDays}일 차이)` : '';
|
|
359
|
+
issue(
|
|
360
|
+
'warn',
|
|
361
|
+
`projects/${s.project}/design-history.md`,
|
|
362
|
+
`design-history stale: session-log 최신=${s.lastSession} > design-history 최신=${s.lastDesignHistory}${gap} — projects/${s.project}/design-history.md에 설계 변경 사항을 append 하세요`,
|
|
363
|
+
null,
|
|
364
|
+
'W8',
|
|
365
|
+
);
|
|
366
|
+
}
|
|
186
367
|
|
|
187
368
|
if (args.fix) {
|
|
188
369
|
const today = new Date().toISOString().slice(0, 10);
|
|
189
370
|
const fixed = new Set();
|
|
190
371
|
for (const iss of issues) {
|
|
191
|
-
if (
|
|
372
|
+
if (
|
|
373
|
+
iss.severity === 'warn' &&
|
|
374
|
+
iss.message === 'Missing frontmatter field: updated' &&
|
|
375
|
+
iss.path
|
|
376
|
+
) {
|
|
192
377
|
const content = readFileSync(iss.path, 'utf-8');
|
|
193
378
|
const fmMatch = /^---\r?\n[\s\S]*?\r?\n---/.exec(content);
|
|
194
379
|
if (fmMatch) {
|
|
@@ -196,25 +381,47 @@ if (args.fix) {
|
|
|
196
381
|
const closingTag = `${lineEnding}---`;
|
|
197
382
|
const insertAt = fmMatch.index + fmMatch[0].lastIndexOf(closingTag);
|
|
198
383
|
if (insertAt < 0) continue;
|
|
199
|
-
const fixedContent =
|
|
384
|
+
const fixedContent =
|
|
385
|
+
content.slice(0, insertAt) + `${lineEnding}updated: ${today}` + content.slice(insertAt);
|
|
200
386
|
writeFileSync(iss.path, fixedContent);
|
|
201
387
|
fixed.add(iss.path);
|
|
202
388
|
}
|
|
203
389
|
}
|
|
204
390
|
}
|
|
205
391
|
if (fixed.size > 0) {
|
|
206
|
-
issues.splice(
|
|
207
|
-
|
|
208
|
-
|
|
392
|
+
issues.splice(
|
|
393
|
+
0,
|
|
394
|
+
issues.length,
|
|
395
|
+
...issues.filter(
|
|
396
|
+
(i) =>
|
|
397
|
+
!(
|
|
398
|
+
i.severity === 'warn' &&
|
|
399
|
+
i.message === 'Missing frontmatter field: updated' &&
|
|
400
|
+
fixed.has(i.path)
|
|
401
|
+
),
|
|
402
|
+
),
|
|
403
|
+
);
|
|
209
404
|
}
|
|
210
405
|
}
|
|
211
406
|
|
|
212
|
-
const errors = issues.filter(i => i.severity === 'error');
|
|
213
|
-
const warns
|
|
407
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
408
|
+
const warns = issues.filter((i) => i.severity === 'warn');
|
|
214
409
|
|
|
215
410
|
if (args.json) {
|
|
216
|
-
const toOut = ({ severity, file, message }) =>
|
|
217
|
-
|
|
411
|
+
const toOut = ({ severity, file, message, id }) =>
|
|
412
|
+
id ? { severity, file, message, id } : { severity, file, message };
|
|
413
|
+
console.log(
|
|
414
|
+
JSON.stringify(
|
|
415
|
+
{
|
|
416
|
+
ok: errors.length === 0,
|
|
417
|
+
errors: errors.map(toOut),
|
|
418
|
+
warns: warns.map(toOut),
|
|
419
|
+
total: issues.length,
|
|
420
|
+
},
|
|
421
|
+
null,
|
|
422
|
+
2,
|
|
423
|
+
),
|
|
424
|
+
);
|
|
218
425
|
} else {
|
|
219
426
|
if (issues.length === 0) {
|
|
220
427
|
console.log('✓ No lint issues found');
|
package/scripts/query.mjs
CHANGED
|
@@ -27,9 +27,9 @@ function parseArgs(argv) {
|
|
|
27
27
|
const args = { hypoDir: null, query: null, limit: 10, json: false };
|
|
28
28
|
for (const arg of argv.slice(2)) {
|
|
29
29
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
30
|
-
else if (arg.startsWith('--q='))
|
|
31
|
-
else if (arg.startsWith('--limit=')) args.limit
|
|
32
|
-
else if (arg === '--json')
|
|
30
|
+
else if (arg.startsWith('--q=')) args.query = arg.slice(4);
|
|
31
|
+
else if (arg.startsWith('--limit=')) args.limit = parseInt(arg.slice(8), 10) || 10;
|
|
32
|
+
else if (arg === '--json') args.json = true;
|
|
33
33
|
}
|
|
34
34
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
35
35
|
return args;
|
|
@@ -57,12 +57,17 @@ function parseFrontmatter(content) {
|
|
|
57
57
|
for (const line of m[1].split('\n')) {
|
|
58
58
|
const idx = line.indexOf(':');
|
|
59
59
|
if (idx < 0) continue;
|
|
60
|
-
fm[line.slice(0, idx).trim()] = line
|
|
60
|
+
fm[line.slice(0, idx).trim()] = line
|
|
61
|
+
.slice(idx + 1)
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/^["']|["']$/g, '');
|
|
61
64
|
}
|
|
62
65
|
return fm;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
function escapeRegex(s) {
|
|
68
|
+
function escapeRegex(s) {
|
|
69
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
70
|
+
}
|
|
66
71
|
|
|
67
72
|
function scoreAndExcerpt(content, terms) {
|
|
68
73
|
const lower = content.toLowerCase();
|
|
@@ -73,7 +78,7 @@ function scoreAndExcerpt(content, terms) {
|
|
|
73
78
|
const lines = content.split('\n');
|
|
74
79
|
let excerpt = '';
|
|
75
80
|
for (const line of lines) {
|
|
76
|
-
if (terms.some(t => line.toLowerCase().includes(t))) {
|
|
81
|
+
if (terms.some((t) => line.toLowerCase().includes(t))) {
|
|
77
82
|
excerpt = line.trim().slice(0, 120);
|
|
78
83
|
break;
|
|
79
84
|
}
|
|
@@ -92,18 +97,28 @@ if (!args.query) {
|
|
|
92
97
|
|
|
93
98
|
const terms = args.query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
94
99
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
95
|
-
const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
|
|
96
|
-
const files = scanDirs.flatMap(d => collectMdFiles(d, args.hypoDir, [], ignorePatterns));
|
|
100
|
+
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
101
|
+
const files = scanDirs.flatMap((d) => collectMdFiles(d, args.hypoDir, [], ignorePatterns));
|
|
97
102
|
|
|
98
103
|
const results = [];
|
|
99
104
|
|
|
100
105
|
for (const { path, rel } of files) {
|
|
101
106
|
let content;
|
|
102
|
-
try {
|
|
107
|
+
try {
|
|
108
|
+
content = readFileSync(path, 'utf-8');
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
103
112
|
const { score, excerpt } = scoreAndExcerpt(content, terms);
|
|
104
113
|
if (score === 0) continue;
|
|
105
114
|
const fm = parseFrontmatter(content);
|
|
106
|
-
results.push({
|
|
115
|
+
results.push({
|
|
116
|
+
slug: rel.replace(/\.md$/, ''),
|
|
117
|
+
title: fm.title || rel,
|
|
118
|
+
type: fm.type || '',
|
|
119
|
+
score,
|
|
120
|
+
excerpt,
|
|
121
|
+
});
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
results.sort((a, b) => b.score - a.score);
|
|
@@ -114,6 +129,7 @@ if (args.json) {
|
|
|
114
129
|
} else {
|
|
115
130
|
if (top.length === 0) {
|
|
116
131
|
console.log(`No results for: ${args.query}`);
|
|
132
|
+
console.log(`→ 관련 페이지가 없습니다. 새 지식이 있다면 /hypo:ingest 로 추가해 보세요.`);
|
|
117
133
|
} else {
|
|
118
134
|
console.log(`Found ${results.length} result(s) for "${args.query}" (showing ${top.length}):\n`);
|
|
119
135
|
for (const r of top) {
|
package/scripts/resume.mjs
CHANGED
|
@@ -23,9 +23,9 @@ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
|
23
23
|
function parseArgs(argv) {
|
|
24
24
|
const args = { hypoDir: null, project: null, json: false };
|
|
25
25
|
for (const arg of argv.slice(2)) {
|
|
26
|
-
if (arg.startsWith('--hypo-dir='))
|
|
26
|
+
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
27
27
|
else if (arg.startsWith('--project=')) args.project = arg.slice(10);
|
|
28
|
-
else if (arg === '--json')
|
|
28
|
+
else if (arg === '--json') args.json = true;
|
|
29
29
|
}
|
|
30
30
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
31
31
|
return args;
|
|
@@ -40,8 +40,11 @@ function resolveActiveProject(hypoDir) {
|
|
|
40
40
|
const content = readFileSync(hotPath, 'utf-8');
|
|
41
41
|
// Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
|
|
42
42
|
// Pick the most recent row by the date column when present.
|
|
43
|
-
const wikiRows = [
|
|
44
|
-
.
|
|
43
|
+
const wikiRows = [
|
|
44
|
+
...content.matchAll(
|
|
45
|
+
/\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
|
|
46
|
+
),
|
|
47
|
+
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
45
48
|
if (wikiRows.length > 0) {
|
|
46
49
|
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
47
50
|
return wikiRows[0].slug;
|
|
@@ -60,7 +63,10 @@ function resolveActiveProject(hypoDir) {
|
|
|
60
63
|
const ssPath = join(projectsDir, p, 'session-state.md');
|
|
61
64
|
if (!existsSync(ssPath)) continue;
|
|
62
65
|
const mtime = statSync(ssPath).mtimeMs;
|
|
63
|
-
if (mtime > latestMtime) {
|
|
66
|
+
if (mtime > latestMtime) {
|
|
67
|
+
latestMtime = mtime;
|
|
68
|
+
latest = p;
|
|
69
|
+
}
|
|
64
70
|
}
|
|
65
71
|
return latest;
|
|
66
72
|
}
|