hypomnema 1.8.0 → 1.8.2
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/CHANGELOG.md +46 -0
- package/README.ko.md +8 -6
- package/README.md +8 -6
- package/commands/crystallize.md +32 -6
- package/commands/graph.md +7 -4
- package/commands/lint.md +8 -1
- package/commands/query.md +6 -4
- package/commands/resume.md +1 -0
- package/commands/verify.md +12 -1
- package/docs/ARCHITECTURE.md +26 -12
- package/docs/CONTRIBUTING.md +15 -6
- package/hooks/hypo-compact-guard.mjs +126 -23
- package/hooks/hypo-cwd-change.mjs +20 -19
- package/hooks/hypo-first-prompt.mjs +31 -16
- package/hooks/hypo-lookup.mjs +10 -5
- package/hooks/hypo-session-start.mjs +180 -2
- package/hooks/hypo-shared.mjs +410 -83
- package/hooks/hypo-web-fetch-ingest.mjs +9 -13
- package/package.json +2 -1
- package/scripts/doctor.mjs +32 -3
- package/scripts/graph.mjs +22 -2
- package/scripts/init.mjs +5 -1
- package/scripts/lib/crystallize-args.mjs +38 -2
- package/scripts/lib/crystallize-close-apply.mjs +745 -450
- package/scripts/lint.mjs +242 -39
- package/scripts/query.mjs +22 -2
- package/scripts/resume.mjs +177 -2
- package/scripts/upgrade.mjs +2 -2
- package/scripts/verify.mjs +22 -2
- package/templates/SCHEMA.md +23 -1
- package/templates/hypo-automation.md +4 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
- package/skills/crystallize/SKILL.md +0 -189
- package/skills/graph/SKILL.md +0 -58
- package/skills/ingest/SKILL.md +0 -107
- package/skills/lint/SKILL.md +0 -59
- package/skills/query/SKILL.md +0 -62
- package/skills/verify/SKILL.md +0 -96
package/scripts/lint.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* --fix Auto-add missing `updated` field (safe repairs only)
|
|
14
14
|
* --strict Promote selected warnings (STRICT_PROMOTE_IDS) to errors
|
|
15
15
|
* so they exit 1. Opt-in gate for release-checklist /
|
|
16
|
-
* pre-commit
|
|
16
|
+
* pre-commit. Adding the flag did not change what default
|
|
17
|
+
* mode emits; a new warning class still adds warnings
|
|
18
|
+
* there, as every W9..W16 addition has.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
@@ -30,19 +32,45 @@ import {
|
|
|
30
32
|
import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
|
|
31
33
|
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
32
34
|
import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
|
|
33
|
-
import { collectPagesLint, collectPagesLinkable } from './lib/wikilink.mjs';
|
|
35
|
+
import { collectPagesLint, collectPagesLinkable, slugForms } from './lib/wikilink.mjs';
|
|
34
36
|
import { parseFrontmatter, SEQUENCE_ENTRY_RE } from './lib/frontmatter.mjs';
|
|
35
37
|
import { buildSlugMap } from './lib/slug-resolver.mjs';
|
|
36
38
|
|
|
37
39
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
38
40
|
|
|
41
|
+
const ALLOWED_FLAGS = ['--hypo-dir=<path>', '--json', '--fix', '--strict'];
|
|
42
|
+
|
|
39
43
|
function parseArgs(argv) {
|
|
40
44
|
const args = { hypoDir: null, json: false, fix: false, strict: false };
|
|
41
45
|
for (const arg of argv.slice(2)) {
|
|
42
|
-
if (arg.startsWith('--hypo-dir='))
|
|
43
|
-
|
|
46
|
+
if (arg.startsWith('--hypo-dir=')) {
|
|
47
|
+
// A raw value of '' (either `--hypo-dir=` typed directly or a shell
|
|
48
|
+
// variable that expanded to nothing, e.g. --hypo-dir="$VAULT" with
|
|
49
|
+
// VAULT unset) must not fall through to expandHome('') → '' → the
|
|
50
|
+
// `!args.hypoDir` default-resolution branch below. That silent
|
|
51
|
+
// fallback is exactly the bug this flag exists to close: a caller who
|
|
52
|
+
// believes they pointed at a specific vault ends up reading/writing
|
|
53
|
+
// the default one instead.
|
|
54
|
+
const raw = arg.slice(11);
|
|
55
|
+
if (!raw) {
|
|
56
|
+
console.error(`lint.mjs: --hypo-dir requires a non-empty value (got '${arg}')`);
|
|
57
|
+
console.error(`lint.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
args.hypoDir = expandHome(raw);
|
|
61
|
+
} else if (arg === '--json') args.json = true;
|
|
44
62
|
else if (arg === '--fix') args.fix = true;
|
|
45
63
|
else if (arg === '--strict') args.strict = true;
|
|
64
|
+
else {
|
|
65
|
+
// lint.mjs takes no positional arguments, so anything unmatched above
|
|
66
|
+
// (a typo'd flag, "--", a bare "-", a stray word) is a mistake, not a
|
|
67
|
+
// no-op. Silently dropping it let a wrong flag point at the wrong
|
|
68
|
+
// vault for months: docs told models to pass --wiki-dir, but no
|
|
69
|
+
// script here ever parsed that flag.
|
|
70
|
+
console.error(`lint.mjs: unrecognized argument '${arg}'`);
|
|
71
|
+
console.error(`lint.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
46
74
|
}
|
|
47
75
|
if (!args.hypoDir) {
|
|
48
76
|
const info = resolveHypoRootInfo();
|
|
@@ -277,16 +305,41 @@ const VALID_TYPES = [
|
|
|
277
305
|
|
|
278
306
|
const issues = [];
|
|
279
307
|
|
|
308
|
+
// W15 bookkeeping, filled inside lintPage as it already reads+parses every
|
|
309
|
+
// page once. Reused after the main page loop instead of re-reading every file
|
|
310
|
+
// a second time just to compare `updated` dates.
|
|
311
|
+
// bySlugForm maps every page's slug form (full/bare/dirRel, the same three
|
|
312
|
+
// forms buildSlugMap derives) to { updated, count }. `count` is how many
|
|
313
|
+
// pages claim that form, because a shared bare form is NOT the same
|
|
314
|
+
// ambiguity the wikilink checker lives with: W4 only asks whether a target
|
|
315
|
+
// exists, while this rule has to pick one page's date. Picking first-wins
|
|
316
|
+
// would make the verdict depend on directory walk order. A form claimed
|
|
317
|
+
// twice is treated as unresolvable instead.
|
|
318
|
+
// `updated` is null for a page that has none (W3's concern), which is
|
|
319
|
+
// recorded rather than skipped so the source is known to exist.
|
|
320
|
+
// synthesisPages holds the type:synthesis pages that declare
|
|
321
|
+
// sources_consulted, collected for the post-loop comparison below.
|
|
322
|
+
// Dates are compared as strings, which is only the same as comparing time
|
|
323
|
+
// when every value is zero-padded YYYY-MM-DD. `2026-3-1 > 2026-12-15` is true
|
|
324
|
+
// lexically and false in fact, so anything off this shape is excluded from the
|
|
325
|
+
// comparison and reported instead of silently deciding the verdict.
|
|
326
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
327
|
+
const bySlugForm = new Map();
|
|
328
|
+
const synthesisPages = [];
|
|
329
|
+
|
|
280
330
|
// Stable warning class IDs (W1..Wn). `--strict` promotes a frozen subset to
|
|
281
331
|
// errors by ID — never by brittle message-text matching. W8 (design-history
|
|
282
|
-
// stale) predates this scheme; hooks/hypo-
|
|
283
|
-
// 'W8'`, so it must keep that number
|
|
284
|
-
// bug. (spec-v1.3.0 Track E)
|
|
332
|
+
// stale) predates this scheme; the close gate in hooks/hypo-shared.mjs filters
|
|
333
|
+
// `w.id === 'W8'`, so it must keep that number. The W5..W7 gap is honest
|
|
334
|
+
// history, not a bug. (spec-v1.3.0 Track E)
|
|
285
335
|
//
|
|
286
336
|
// STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
|
|
287
337
|
// defects only.
|
|
288
338
|
// W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
|
|
289
339
|
// W9 invalid-YAML → promote (frontmatter a real YAML parser rejects).
|
|
340
|
+
// Exception: the closeRootTargets loop's own W1 findings (hot.md/log.md)
|
|
341
|
+
// are exempted from this promotion below, scoped by id and by that file
|
|
342
|
+
// set only; see the comment at that loop and at the promotion site.
|
|
290
343
|
// W3 missing-updated → excluded (auto-repaired by --fix).
|
|
291
344
|
// W8 design-history-stale → excluded (hypo-personal-check handles it; would
|
|
292
345
|
// double-gate).
|
|
@@ -298,10 +351,19 @@ const issues = [];
|
|
|
298
351
|
// PreCompact hook no longer blocks /compact, so
|
|
299
352
|
// that set is what --check-session-close and
|
|
300
353
|
// --mark-session-closed read, not a compact stop.
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
354
|
+
// W15 synthesis-stale → excluded, same reason as W8/W14 (a freshness
|
|
355
|
+
// signal to triage, not a content defect), and
|
|
356
|
+
// its own id for the same reason W14 has one: the
|
|
357
|
+
// W8-only close-gate filter must not pick it up.
|
|
358
|
+
// W16 sources-unresolved → excluded, same reasons as W15. It reports what
|
|
359
|
+
// W15 could not read, so promoting one without the
|
|
360
|
+
// other would block on the weaker signal.
|
|
361
|
+
// NOTE: npm run lint / CI / release.yml / crystallize / the close-gate all
|
|
362
|
+
// run plain lint, not --strict, so promotion is forward-looking there: these
|
|
363
|
+
// still surface as warnings on those paths. But --strict is not unused —
|
|
364
|
+
// init.mjs's --lint-strict wires `lint --strict` into a vault's own
|
|
365
|
+
// pre-commit hook (scripts/init.mjs, pinned by tests/init.test.mjs), so a
|
|
366
|
+
// vault that opts in does gate a commit on these IDs today.
|
|
305
367
|
const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
|
|
306
368
|
|
|
307
369
|
function issue(severity, rel, msg, fullPath = null, id = null) {
|
|
@@ -387,6 +449,27 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
387
449
|
issue('warn', rel, 'Missing frontmatter field: updated', path, 'W3');
|
|
388
450
|
}
|
|
389
451
|
|
|
452
|
+
// W15 bookkeeping: record this page's slug forms → updated date, and, if it
|
|
453
|
+
// is itself a synthesis page citing sources, queue it for the post-loop
|
|
454
|
+
// check. Recorded here (not re-derived after the loop) because content and
|
|
455
|
+
// fm are already in hand from the reads above.
|
|
456
|
+
{
|
|
457
|
+
const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
|
|
458
|
+
// Deduplicate within one page first. slugForms collapses bare and dirRel
|
|
459
|
+
// to the same string for a page directly under pages/, so counting raw
|
|
460
|
+
// values would make every such page look like a two-page collision.
|
|
461
|
+
for (const form of new Set(Object.values(slugForms(noExt)))) {
|
|
462
|
+
if (!form) continue;
|
|
463
|
+
const prior = bySlugForm.get(form);
|
|
464
|
+
if (prior) prior.count += 1;
|
|
465
|
+
else bySlugForm.set(form, { updated: fm.updated || null, count: 1 });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (fm.type === 'synthesis' && fm.sources_consulted) {
|
|
469
|
+
const sources = parseTagsField(fm.sources_consulted) || [];
|
|
470
|
+
if (sources.length > 0) synthesisPages.push({ rel, updated: fm.updated, sources });
|
|
471
|
+
}
|
|
472
|
+
|
|
390
473
|
// type-conditional required fields
|
|
391
474
|
if (fm.type && TYPE_CONDITIONAL_FIELDS[fm.type]) {
|
|
392
475
|
for (const field of TYPE_CONDITIONAL_FIELDS[fm.type]) {
|
|
@@ -544,31 +627,55 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
|
|
|
544
627
|
|
|
545
628
|
for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
|
|
546
629
|
|
|
547
|
-
// W4 (broken-wikilink
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
630
|
+
// W4 (broken-wikilink) plus a reduced W1 (closed frontmatter block presence)
|
|
631
|
+
// and W9 (invalid YAML inside that block), NOT the full lintPage, for close's
|
|
632
|
+
// root-level write targets that fall outside pages/projects/journal: hot.md
|
|
633
|
+
// and log.md today. closeFileTargetsGlobal is the one list of what close
|
|
634
|
+
// writes (hooks/hypo-shared.mjs), reused here instead of re-derived, so a
|
|
635
|
+
// future close target is covered automatically. Only the entries NOT already
|
|
636
|
+
// under a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries
|
|
637
|
+
// are already linted in full above.
|
|
554
638
|
//
|
|
555
|
-
// Why link-only
|
|
556
|
-
// packaged templates/ (hot.md
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
639
|
+
// Why link-only (plus these two frontmatter checks) and not the full
|
|
640
|
+
// lintPage: measured, not assumed. On the packaged templates/ (hot.md
|
|
641
|
+
// type:reference, log.md type:log, both declared in templates/SCHEMA.md's
|
|
642
|
+
// taxonomy), full lintPage on both files comes back completely clean, so
|
|
643
|
+
// "these types trip W2" is not the reason to hold back. The real reason is
|
|
644
|
+
// that live vaults do not all match the template. A vault whose SCHEMA.md
|
|
645
|
+
// predates the `log` type row (or has none) gets a fresh "Unknown type: log"
|
|
646
|
+
// W2 the moment log.md is run through lintPage. W2 (and the rest of
|
|
647
|
+
// lintPage's field checks) stays out of this loop for exactly that reason:
|
|
648
|
+
// it does not catch a close's whole-file overwrite any better than W4
|
|
649
|
+
// already does, and it would trip on vault-local type taxonomy the write
|
|
650
|
+
// never touched.
|
|
651
|
+
//
|
|
652
|
+
// W1 (no closed frontmatter block) and W9 (invalid YAML inside that block)
|
|
653
|
+
// are the two lintPage checks pulled in on purpose: close's crystallize path
|
|
654
|
+
// overwrites hot.md/log.md wholesale, and a write that drops the frontmatter
|
|
655
|
+
// delimiters or leaves broken YAML behind is exactly the corruption this
|
|
656
|
+
// scan exists to catch, one write earlier than doctor. The W1 predicate here
|
|
657
|
+
// matches hooks/hypo-shared.mjs's frontmatterUpdated exactly (a *closed*
|
|
658
|
+
// `---`...`---` block, not just an opening fence) so this scan and the
|
|
659
|
+
// close-gate's own staleness check agree on what counts as "has frontmatter";
|
|
660
|
+
// an unclosed fence fails both the same way, instead of lint calling it clean
|
|
661
|
+
// while the close gate hard-fails on it. The same measurement above shows why
|
|
662
|
+
// this stays narrow rather than becoming full lintPage: a vault whose root
|
|
663
|
+
// log.md predates the frontmatter convention entirely (no leading `---`
|
|
664
|
+
// block at all, confirmed against a real maintainer vault) would get a fresh
|
|
665
|
+
// W1 the instant this check exists, on content this change never touched.
|
|
666
|
+
// W1 is in STRICT_PROMOTE_IDS, so that vault would start failing `--strict`
|
|
667
|
+
// for a file it always had. The strict-promotion loop below carries a narrow
|
|
668
|
+
// exemption for exactly this case (W1 + this file set only, NOT W9), so a
|
|
669
|
+
// shaped-like-legacy vault keeps passing `--strict` while W1 still fires as a
|
|
670
|
+
// warning here and a genuinely broken close-time write still promotes under
|
|
671
|
+
// `--strict` on pages/projects/journal, where it was always covered. W9 is
|
|
672
|
+
// deliberately NOT exempted: a legacy vault with no frontmatter block at all
|
|
673
|
+
// never reaches the YAML check (there is no block to inspect), so the
|
|
674
|
+
// exemption never needed to cover it, and a root file whose block IS present
|
|
675
|
+
// but broken is exactly the defect this loop exists to promote. W4 and W1
|
|
676
|
+
// both stay warn-only outside `--strict`, and postApply (crystallize.mjs)
|
|
677
|
+
// gates only on errors, so this loop can add a new warning to a close's lint
|
|
678
|
+
// output but can never fail one on its own.
|
|
572
679
|
const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
|
|
573
680
|
(f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
|
|
574
681
|
);
|
|
@@ -581,6 +688,14 @@ for (const rel of closeRootTargets) {
|
|
|
581
688
|
} catch {
|
|
582
689
|
continue;
|
|
583
690
|
}
|
|
691
|
+
const fmBlock = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
692
|
+
if (!fmBlock) {
|
|
693
|
+
issue('warn', rel, 'No closed frontmatter block found', null, 'W1');
|
|
694
|
+
} else {
|
|
695
|
+
for (const reason of checkYamlInvalid(fmBlock[1])) {
|
|
696
|
+
issue('warn', rel, `Invalid YAML frontmatter: ${reason}`, null, 'W9');
|
|
697
|
+
}
|
|
698
|
+
}
|
|
584
699
|
for (const link of extractWikilinks(content)) {
|
|
585
700
|
if (!slugMap.has(link)) {
|
|
586
701
|
issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
|
|
@@ -623,6 +738,81 @@ for (const s of findDesignHistoryStale(args.hypoDir)) {
|
|
|
623
738
|
);
|
|
624
739
|
}
|
|
625
740
|
|
|
741
|
+
// W15: synthesis page stale relative to its own sources_consulted. A
|
|
742
|
+
// type:synthesis page absorbs and condenses what other pages already
|
|
743
|
+
// learned; if a listed source has moved on (its `updated` is newer than the
|
|
744
|
+
// synthesis's own `updated`), the synthesis has not caught up. Runs off the
|
|
745
|
+
// bookkeeping lintPage already filled above, so no page is read twice.
|
|
746
|
+
//
|
|
747
|
+
// A source name absent from bySlugForm (a typo, a directory rather than a
|
|
748
|
+
// page, or a path outside the vault) has no date to compare, so W15 cannot
|
|
749
|
+
// judge it either way. W4 does not cover it: that rule reads `[[...]]` out of
|
|
750
|
+
// page bodies and never looks at frontmatter, so an unresolved name here would
|
|
751
|
+
// be reported nowhere. W16 below reports it instead, which is what keeps a
|
|
752
|
+
// page that resolves 4 of its 11 sources from producing a confident-looking
|
|
753
|
+
// W15 built on a third of the evidence.
|
|
754
|
+
// The skip in the loop states that intent; it does not change the outcome. An
|
|
755
|
+
// undefined date loses every comparison it enters, so dropping the guard
|
|
756
|
+
// leaves the same warnings (measured: removing it keeps all four W15 tests
|
|
757
|
+
// green). It stays because the alternative parks undefined in `newest`.
|
|
758
|
+
//
|
|
759
|
+
// Deliberately excluded from STRICT_PROMOTE_IDS, same reasoning as W8/W14: a
|
|
760
|
+
// synthesis lagging its sources is a content-freshness signal for a human to
|
|
761
|
+
// triage, not a defect `--strict` should hard-block a commit on. Given its
|
|
762
|
+
// own id (not W8) so hypo-shared.mjs's `w.id === 'W8'` close-gate filter
|
|
763
|
+
// never mistakes it for a design-history finding.
|
|
764
|
+
for (const { rel, updated, sources } of synthesisPages) {
|
|
765
|
+
let newest = null;
|
|
766
|
+
const unresolved = [];
|
|
767
|
+
for (const source of sources) {
|
|
768
|
+
const hit = bySlugForm.get(source);
|
|
769
|
+
// Three ways a source yields no date, kept apart because they tell the
|
|
770
|
+
// author to do different things. Every one of them lands in W16 below;
|
|
771
|
+
// removing this branch is what would make W16 stop reporting.
|
|
772
|
+
if (!hit) {
|
|
773
|
+
unresolved.push(`${source} (없는 이름)`);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (hit.count > 1) {
|
|
777
|
+
unresolved.push(`${source} (${hit.count}개 페이지가 같은 이름)`);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (!ISO_DATE_RE.test(hit.updated || '')) {
|
|
781
|
+
unresolved.push(`${source} (updated 없음 또는 YYYY-MM-DD 아님)`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (!newest || hit.updated > newest) newest = hit.updated;
|
|
785
|
+
}
|
|
786
|
+
const ownDateUsable = ISO_DATE_RE.test(updated || '');
|
|
787
|
+
if (newest && ownDateUsable && newest > updated) {
|
|
788
|
+
issue(
|
|
789
|
+
'warn',
|
|
790
|
+
rel,
|
|
791
|
+
`synthesis stale: sources_consulted 최신=${newest} > synthesis updated=${updated}. 새로 쌓인 학습을 반영해 갱신하거나, 반영할 내용이 없으면 updated를 확인하세요`,
|
|
792
|
+
null,
|
|
793
|
+
'W15',
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
// W16: sources W15 could not compare, reported once per page with the reason
|
|
797
|
+
// for each. Without this the rule fails open, and silently: a page whose
|
|
798
|
+
// sources mostly do not resolve still gets a W15 (or no warning at all) with
|
|
799
|
+
// nothing saying how much of the evidence was actually read. The reasons are
|
|
800
|
+
// kept apart because the fix differs: a name that resolves to nothing is a
|
|
801
|
+
// typo or a value this field is not meant to hold, a name claimed by two
|
|
802
|
+
// pages needs the longer form, and a source with no usable `updated` is W3's
|
|
803
|
+
// problem on that page. Excluded from STRICT_PROMOTE_IDS and given its own id
|
|
804
|
+
// for the same reasons as W15.
|
|
805
|
+
if (unresolved.length > 0) {
|
|
806
|
+
issue(
|
|
807
|
+
'warn',
|
|
808
|
+
rel,
|
|
809
|
+
`sources_consulted 비교 불가 ${unresolved.length}/${sources.length}: ${unresolved.join(', ')}. 이 소스들은 staleness 비교에서 빠졌습니다`,
|
|
810
|
+
null,
|
|
811
|
+
'W16',
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
626
816
|
// W12: project directory missing index.md. SCHEMA.md declares project-index
|
|
627
817
|
// at projects/*/index.md and templates/projects/_template/ ships one, so a
|
|
628
818
|
// project without it is a tooling/vault drift, not a legitimate shape — but
|
|
@@ -699,9 +889,20 @@ if (args.fix) {
|
|
|
699
889
|
// --strict: promote selected warnings (by stable ID) to errors *before* the
|
|
700
890
|
// errors/warns split, so exit code, counts, plain-text icons, and --json `ok`
|
|
701
891
|
// all derive from the post-promotion severities through the existing paths.
|
|
892
|
+
//
|
|
893
|
+
// Exemption for the closeRootTargets loop's own W1 (see the comment there):
|
|
894
|
+
// a root hot.md/log.md that predates the frontmatter convention is a
|
|
895
|
+
// pre-existing vault shape, not a defect this scan introduced, so promoting
|
|
896
|
+
// it under --strict would fail vaults that lint has always passed. Scoped by
|
|
897
|
+
// BOTH id (W1 only, not W2/W4/W9) AND file (closeRootTargets, the short
|
|
898
|
+
// hot.md/log.md list, not the full closeFileTargetsGlobal) so a future close
|
|
899
|
+
// target under pages/projects/journal stays fully promotable: it is already
|
|
900
|
+
// linted through lintPage above, where this exemption does not apply.
|
|
901
|
+
const closeRootTargetSet = new Set(closeRootTargets);
|
|
702
902
|
if (args.strict) {
|
|
703
903
|
for (const iss of issues) {
|
|
704
904
|
if (iss.severity === 'warn' && iss.id && STRICT_PROMOTE_IDS.has(iss.id)) {
|
|
905
|
+
if (iss.id === 'W1' && closeRootTargetSet.has(iss.file)) continue;
|
|
705
906
|
iss.severity = 'error';
|
|
706
907
|
}
|
|
707
908
|
}
|
|
@@ -711,10 +912,12 @@ const errors = issues.filter((i) => i.severity === 'error');
|
|
|
711
912
|
const warns = issues.filter((i) => i.severity === 'warn');
|
|
712
913
|
|
|
713
914
|
if (args.json) {
|
|
714
|
-
//
|
|
715
|
-
// (hooks/hypo-
|
|
716
|
-
// unless `--strict` is set, where the full
|
|
717
|
-
// findings are traceable to their warning class.
|
|
915
|
+
// The `id` field, not the warning list, is what default mode holds stable:
|
|
916
|
+
// only W8 carries one (the close gate in hooks/hypo-shared.mjs filters on
|
|
917
|
+
// it). Every other id stays internal unless `--strict` is set, where the full
|
|
918
|
+
// set is exposed so promoted findings are traceable to their warning class.
|
|
919
|
+
// A new rule does add warnings to default output; that is what a new rule is
|
|
920
|
+
// for, and W9 through W16 all did it.
|
|
718
921
|
const toOut = ({ severity, file, message, id }) =>
|
|
719
922
|
id && (id === 'W8' || args.strict)
|
|
720
923
|
? { severity, file, message, id }
|
package/scripts/query.mjs
CHANGED
|
@@ -24,13 +24,33 @@ import { currentDevice, scopeVisible, readVisibilityScope } from '../hooks/hypo-
|
|
|
24
24
|
|
|
25
25
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
26
26
|
|
|
27
|
+
const ALLOWED_FLAGS = ['--hypo-dir=<path>', '--q=<query>', '--limit=<n>', '--json'];
|
|
28
|
+
|
|
27
29
|
function parseArgs(argv) {
|
|
28
30
|
const args = { hypoDir: null, query: null, limit: 10, json: false };
|
|
29
31
|
for (const arg of argv.slice(2)) {
|
|
30
|
-
if (arg.startsWith('--hypo-dir='))
|
|
31
|
-
|
|
32
|
+
if (arg.startsWith('--hypo-dir=')) {
|
|
33
|
+
// See lint.mjs's parseArgs for why an empty raw value (--hypo-dir=,
|
|
34
|
+
// or --hypo-dir="$VAULT" with VAULT unset) is rejected instead of
|
|
35
|
+
// falling through to the default-resolution branch below.
|
|
36
|
+
const raw = arg.slice(11);
|
|
37
|
+
if (!raw) {
|
|
38
|
+
console.error(`query.mjs: --hypo-dir requires a non-empty value (got '${arg}')`);
|
|
39
|
+
console.error(`query.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
args.hypoDir = expandHome(raw);
|
|
43
|
+
} else if (arg.startsWith('--q=')) args.query = arg.slice(4);
|
|
32
44
|
else if (arg.startsWith('--limit=')) args.limit = parseInt(arg.slice(8), 10) || 10;
|
|
33
45
|
else if (arg === '--json') args.json = true;
|
|
46
|
+
else {
|
|
47
|
+
// No positional arguments here either, so an unmatched arg is rejected
|
|
48
|
+
// rather than dropped. See lint.mjs's parseArgs for the background
|
|
49
|
+
// this closes.
|
|
50
|
+
console.error(`query.mjs: unrecognized argument '${arg}'`);
|
|
51
|
+
console.error(`query.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
34
54
|
}
|
|
35
55
|
if (!args.hypoDir) {
|
|
36
56
|
const info = resolveHypoRootInfo();
|
package/scripts/resume.mjs
CHANGED
|
@@ -28,9 +28,15 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
30
30
|
import { join } from 'path';
|
|
31
|
+
import { spawnSync } from 'child_process';
|
|
31
32
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
32
33
|
import { pickProjectByCwd, collectProjectWorkingDirs } from './lib/wd-match.mjs';
|
|
33
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
currentDevice,
|
|
36
|
+
scopeVisible,
|
|
37
|
+
readVisibilityScope,
|
|
38
|
+
sanitizeProjForPrompt,
|
|
39
|
+
} from '../hooks/hypo-shared.mjs';
|
|
34
40
|
|
|
35
41
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
36
42
|
|
|
@@ -188,6 +194,162 @@ function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
188
194
|
return latest;
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
// ── foreign-project uncommitted notice ──────────────────────────────────────
|
|
198
|
+
// resume is a bare CLI script (invoked from a slash command via bash) and
|
|
199
|
+
// never receives a session_id or transcript, so unlike the SessionStart hook
|
|
200
|
+
// it has no accountable-scope to compare against. It always has a resolved
|
|
201
|
+
// `project` by the time this runs (the caller exits earlier when none is
|
|
202
|
+
// found), so that resolved project stands in as "own project" here.
|
|
203
|
+
|
|
204
|
+
const FOREIGN_GIT_TIMEOUT_MS = 5000;
|
|
205
|
+
// Cap on how many foreign project names the notice spells out. Past this the
|
|
206
|
+
// rest collapse into a count, so one session cannot grow the prompt by however
|
|
207
|
+
// many projects are dirty.
|
|
208
|
+
const FOREIGN_NAME_CAP = 5;
|
|
209
|
+
|
|
210
|
+
/** `projects/<slug>/...` → `<slug>`; everything else → null. `null` here does
|
|
211
|
+
* not mean "not one project's work": the caller below folds every non-null
|
|
212
|
+
* hit into a per-name foreign count and every null hit into a nameless
|
|
213
|
+
* "unattributed" count, and both feed the same notice. Local duplicate of
|
|
214
|
+
* hypo-shared.mjs's private (non-exported) `projectOfPath`. Not the same
|
|
215
|
+
* classification as hypo-auto-commit.mjs's commit-message counter (also in
|
|
216
|
+
* hypo-shared.mjs): that one folds a non-`projects/` path to its first path
|
|
217
|
+
* segment (`extensions`, `hot.md`, ...) for a "(N paths across M projects)"
|
|
218
|
+
* tally; this one folds it to `null` because attribution, not tallying, is
|
|
219
|
+
* the job here. A top-level segment is not a project name, and this notice
|
|
220
|
+
* must not present it as one.
|
|
221
|
+
*/
|
|
222
|
+
function projectOfPath(relPath) {
|
|
223
|
+
const parts = relPath.split('/');
|
|
224
|
+
return parts[0] === 'projects' && parts.length > 1 && parts[1] ? parts[1] : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Vault-relative dirty paths (tracked + untracked), normalized to be
|
|
228
|
+
* relative to `hypoDir` itself via `git rev-parse --show-prefix` (empty when
|
|
229
|
+
* `hypoDir` IS the repo top level), the same normalization
|
|
230
|
+
* hypo-shared.mjs's `gitDirtyFiles` applies for staging correctness: without
|
|
231
|
+
* it, a vault nested under a larger host repo reports paths relative to that
|
|
232
|
+
* repo's top level, and every one of them would fail to classify as this
|
|
233
|
+
* vault's own. NUL-separated porcelain so Korean project/page names survive
|
|
234
|
+
* intact. Local duplicate of hypo-shared.mjs's private `gitDirtyFiles`: this
|
|
235
|
+
* caller also re-attributes a rename/copy's `from` path (codex 3rd-round
|
|
236
|
+
* review follow-up), the same as that caller does for staging
|
|
237
|
+
* correctness, so a rename OUT of a foreign project is not silently lost
|
|
238
|
+
* just because its destination happens to land under `ownProject`.
|
|
239
|
+
*
|
|
240
|
+
* Returns `null`, not `[]`, on any git failure (repo missing, `rev-parse` or
|
|
241
|
+
* `status` non-zero, or a timeout): folding "cannot enumerate" into the same
|
|
242
|
+
* empty array a truly clean repo returns would render the two identically,
|
|
243
|
+
* which is the silent failure this notice exists to catch (mirrors
|
|
244
|
+
* `gitDirtyFiles`'s own contract: "an empty return here just means 'cannot
|
|
245
|
+
* attribute', not 'clean'"). A clean repo returns `[]`.
|
|
246
|
+
*/
|
|
247
|
+
function listDirtyPaths(hypoDir) {
|
|
248
|
+
const prefixRes = spawnSync('git', ['-C', hypoDir, 'rev-parse', '--show-prefix'], {
|
|
249
|
+
encoding: 'utf-8',
|
|
250
|
+
timeout: FOREIGN_GIT_TIMEOUT_MS,
|
|
251
|
+
});
|
|
252
|
+
if (prefixRes.status !== 0) return null;
|
|
253
|
+
// trimEnd(), not trim(): the prefix is a real path segment, and a leading
|
|
254
|
+
// space or control char in a directory name is valid there. trim() would
|
|
255
|
+
// strip it off the front, so the stripped prefix no longer matches the
|
|
256
|
+
// (untouched) start of every path `git status` reports, and every path
|
|
257
|
+
// under that directory would wrongly read as "outside the vault" (the
|
|
258
|
+
// notice going silent for exactly the same reason a missing rename `from`
|
|
259
|
+
// does below). Only the trailing `\n` `--show-prefix` always appends needs
|
|
260
|
+
// stripping.
|
|
261
|
+
const prefix = (prefixRes.stdout || '').trimEnd();
|
|
262
|
+
|
|
263
|
+
const r = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
|
|
264
|
+
encoding: 'utf-8',
|
|
265
|
+
timeout: FOREIGN_GIT_TIMEOUT_MS,
|
|
266
|
+
});
|
|
267
|
+
if (r.status !== 0) return null;
|
|
268
|
+
const out = [];
|
|
269
|
+
const records = (r.stdout || '').split('\0');
|
|
270
|
+
const toVaultRelative = (f) => {
|
|
271
|
+
if (!f) return null;
|
|
272
|
+
if (!prefix) return f; // hypoDir IS the repo top level, nothing to strip
|
|
273
|
+
return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
|
|
274
|
+
};
|
|
275
|
+
for (let i = 0; i < records.length; i++) {
|
|
276
|
+
const rec = records[i];
|
|
277
|
+
if (!rec) continue;
|
|
278
|
+
const xy = rec.slice(0, 2);
|
|
279
|
+
const file = rec.slice(3); // destination path for a rename/copy
|
|
280
|
+
const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
|
|
281
|
+
// A rename/copy emits a paired `to\0from` record. Attribute BOTH: the
|
|
282
|
+
// origin project lost a file just as surely as the destination gained
|
|
283
|
+
// one, and dropping `from` (as this used to) silently loses that origin
|
|
284
|
+
// whenever it differs from the destination's project (a rename INTO
|
|
285
|
+
// ownProject from a foreign one would otherwise vanish entirely). One
|
|
286
|
+
// rename/copy is therefore counted as up to 2 dirty paths, inflating
|
|
287
|
+
// foreignCount/unattributedCount by one per cross-project rename; the
|
|
288
|
+
// name Set below still de-dupes, so the project NAME list does not grow.
|
|
289
|
+
let fromFile = null;
|
|
290
|
+
if (isRenameOrCopy) {
|
|
291
|
+
i++;
|
|
292
|
+
fromFile = records[i] || null;
|
|
293
|
+
}
|
|
294
|
+
const rel = toVaultRelative(file);
|
|
295
|
+
if (rel) out.push(rel);
|
|
296
|
+
const relFrom = toVaultRelative(fromFile);
|
|
297
|
+
if (relFrom) out.push(relFrom);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One-line notice covering two counts: uncommitted paths under a named
|
|
303
|
+
* project other than `ownProject` (`projects/<slug>/...`), and uncommitted
|
|
304
|
+
* paths this classifier cannot attribute to any project at all (everything
|
|
305
|
+
* else, root vault infra, `extensions/`, `_specs/`, ...). This is
|
|
306
|
+
* attribution, not narrowing, so the unattributed bucket is surfaced with a
|
|
307
|
+
* count rather than silently dropped just because it has no project name to
|
|
308
|
+
* show. '' only when enumeration succeeded and both counts are zero, so the
|
|
309
|
+
* quiet path stays quiet exactly there. A `null` from `listDirtyPaths`
|
|
310
|
+
* (enumeration failed) gets its own distinct line instead: the caller must
|
|
311
|
+
* not read "could not tell" as "nothing foreign".
|
|
312
|
+
*/
|
|
313
|
+
function foreignUncommittedNotice(hypoDir, ownProject) {
|
|
314
|
+
const dirty = listDirtyPaths(hypoDir);
|
|
315
|
+
if (dirty === null) {
|
|
316
|
+
return '[WIKI: 미커밋 변경의 귀속을 확인하지 못했습니다. git 상태를 근거로 작업 범위를 정하지 마십시오.]';
|
|
317
|
+
}
|
|
318
|
+
const foreignProjects = new Set();
|
|
319
|
+
let foreignCount = 0;
|
|
320
|
+
let unattributedCount = 0;
|
|
321
|
+
for (const f of dirty) {
|
|
322
|
+
const slug = projectOfPath(f);
|
|
323
|
+
if (slug === ownProject) continue;
|
|
324
|
+
if (slug) {
|
|
325
|
+
foreignProjects.add(slug);
|
|
326
|
+
foreignCount++;
|
|
327
|
+
} else {
|
|
328
|
+
unattributedCount++;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (foreignCount === 0 && unattributedCount === 0) return '';
|
|
332
|
+
const clauses = [];
|
|
333
|
+
if (foreignCount > 0) {
|
|
334
|
+
// The slug comes from a directory name in `git status` output, so it is
|
|
335
|
+
// untrusted text on its way into a prompt. sanitizeProjForPrompt is the same
|
|
336
|
+
// guard the hot-cache notices in this file already use; skipping it here would
|
|
337
|
+
// let a newline or a control char in a project directory name break the
|
|
338
|
+
// one-line notice apart and inject into the surrounding context. The name list
|
|
339
|
+
// is also capped, because an unbounded one grows the context by however many
|
|
340
|
+
// projects happen to be dirty.
|
|
341
|
+
const all = [...foreignProjects].sort();
|
|
342
|
+
const shown = all.slice(0, FOREIGN_NAME_CAP).map((s) => `projects/${sanitizeProjForPrompt(s)}`);
|
|
343
|
+
const names =
|
|
344
|
+
all.length > FOREIGN_NAME_CAP
|
|
345
|
+
? `${shown.join(', ')} 외 ${all.length - FOREIGN_NAME_CAP}개`
|
|
346
|
+
: shown.join(', ');
|
|
347
|
+
clauses.push(`현재 프로젝트 외 ${names} 변경 ${foreignCount}건`);
|
|
348
|
+
}
|
|
349
|
+
if (unattributedCount > 0) clauses.push(`귀속 불명 변경 ${unattributedCount}건`);
|
|
350
|
+
return `[WIKI: ${clauses.join(', ')}이 있습니다. 사용자 명시 지시 없이는 이 세션 작업으로 편입하지 마십시오.]`;
|
|
351
|
+
}
|
|
352
|
+
|
|
191
353
|
// ── read session state ────────────────────────────────────────────────────────
|
|
192
354
|
|
|
193
355
|
// Visibility guard: same contract as hypo-file-watch / hypo-session-start /
|
|
@@ -224,6 +386,7 @@ if (!project) {
|
|
|
224
386
|
process.exit(1);
|
|
225
387
|
}
|
|
226
388
|
|
|
389
|
+
const foreignNotice = foreignUncommittedNotice(args.hypoDir, project);
|
|
227
390
|
const device = currentDevice();
|
|
228
391
|
|
|
229
392
|
const state = readSessionState(args.hypoDir, project, device);
|
|
@@ -243,10 +406,22 @@ const sessionState = state.content;
|
|
|
243
406
|
const hotContent = readHot(args.hypoDir, project, device).content;
|
|
244
407
|
|
|
245
408
|
if (args.json) {
|
|
246
|
-
console.log(
|
|
409
|
+
console.log(
|
|
410
|
+
JSON.stringify(
|
|
411
|
+
{
|
|
412
|
+
project,
|
|
413
|
+
sessionState,
|
|
414
|
+
hot: hotContent,
|
|
415
|
+
...(foreignNotice ? { notice: foreignNotice } : {}),
|
|
416
|
+
},
|
|
417
|
+
null,
|
|
418
|
+
2,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
247
421
|
process.exit(0);
|
|
248
422
|
}
|
|
249
423
|
|
|
424
|
+
if (foreignNotice) console.log(foreignNotice + '\n');
|
|
250
425
|
console.log(`Project: ${project}`);
|
|
251
426
|
console.log(`State: projects/${project}/session-state.md\n`);
|
|
252
427
|
console.log('─'.repeat(60));
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -924,7 +924,7 @@ function applyCommands(commandResults, force) {
|
|
|
924
924
|
...existing,
|
|
925
925
|
pkgRoot: PKG_ROOT,
|
|
926
926
|
pkgVersion,
|
|
927
|
-
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.
|
|
927
|
+
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.2',
|
|
928
928
|
commands: newSHAs,
|
|
929
929
|
});
|
|
930
930
|
return applied;
|
|
@@ -950,7 +950,7 @@ function writePluginModeMetadata() {
|
|
|
950
950
|
...existing,
|
|
951
951
|
pkgRoot: PKG_ROOT,
|
|
952
952
|
pkgVersion,
|
|
953
|
-
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.
|
|
953
|
+
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.2',
|
|
954
954
|
});
|
|
955
955
|
return true;
|
|
956
956
|
}
|