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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.ko.md +8 -6
  5. package/README.md +8 -6
  6. package/commands/crystallize.md +32 -6
  7. package/commands/graph.md +7 -4
  8. package/commands/lint.md +8 -1
  9. package/commands/query.md +6 -4
  10. package/commands/resume.md +1 -0
  11. package/commands/verify.md +12 -1
  12. package/docs/ARCHITECTURE.md +26 -12
  13. package/docs/CONTRIBUTING.md +15 -6
  14. package/hooks/hypo-compact-guard.mjs +126 -23
  15. package/hooks/hypo-cwd-change.mjs +20 -19
  16. package/hooks/hypo-first-prompt.mjs +31 -16
  17. package/hooks/hypo-lookup.mjs +10 -5
  18. package/hooks/hypo-session-start.mjs +180 -2
  19. package/hooks/hypo-shared.mjs +410 -83
  20. package/hooks/hypo-web-fetch-ingest.mjs +9 -13
  21. package/package.json +2 -1
  22. package/scripts/doctor.mjs +32 -3
  23. package/scripts/graph.mjs +22 -2
  24. package/scripts/init.mjs +5 -1
  25. package/scripts/lib/crystallize-args.mjs +38 -2
  26. package/scripts/lib/crystallize-close-apply.mjs +745 -450
  27. package/scripts/lint.mjs +242 -39
  28. package/scripts/query.mjs +22 -2
  29. package/scripts/resume.mjs +177 -2
  30. package/scripts/upgrade.mjs +2 -2
  31. package/scripts/verify.mjs +22 -2
  32. package/templates/SCHEMA.md +23 -1
  33. package/templates/hypo-automation.md +4 -2
  34. package/templates/hypo-config.md +1 -1
  35. package/templates/hypo-guide.md +1 -1
  36. package/skills/crystallize/SKILL.md +0 -189
  37. package/skills/graph/SKILL.md +0 -58
  38. package/skills/ingest/SKILL.md +0 -107
  39. package/skills/lint/SKILL.md +0 -59
  40. package/skills/query/SKILL.md +0 -62
  41. 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; default mode stays byte-identical.
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=')) args.hypoDir = expandHome(arg.slice(11));
43
- else if (arg === '--json') args.json = true;
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-personal-check.mjs filters `w.id ===
283
- // 'W8'`, so it must keep that number — the W5..W7 gap is honest history, not a
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
- // NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
302
- // crystallize / the close-gate all run plain lint), so promotion is
303
- // forward-looking: these surface as warnings today. The deliverable is
304
- // "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
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 only, NOT the full lintPage) for close's root-level
548
- // write targets that fall outside pages/projects/journal: hot.md and log.md
549
- // today. closeFileTargetsGlobal is the one list of what close writes
550
- // (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
551
- // close target is covered automatically. Only the entries NOT already under
552
- // a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
553
- // already linted in full above.
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 and not the full lintPage: measured, not assumed. On the
556
- // packaged templates/ (hot.md type:reference, log.md type:log, both declared
557
- // in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
558
- // completely clean, so "these types trip W2" is not the reason to hold back.
559
- // The real reason is that live vaults do not all match the template. A vault
560
- // whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
561
- // "Unknown type: log" W2 the moment log.md is run through lintPage, and a
562
- // vault whose root log.md predates the frontmatter convention entirely (no
563
- // leading `---` block at all, confirmed against a real maintainer vault)
564
- // gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
565
- // STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
566
- // would start failing on a file whose content this fix never touched. Full
567
- // lintPage stays reserved for pages/projects/journal, where every file was
568
- // already being linted before this change and there is no such newly-exposed
569
- // vault. W4 itself stays warn-only outside --strict, and postApply
570
- // (crystallize.mjs) gates only on errors, so this addition can add a new
571
- // warning to a close's lint output but can never fail one.
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
- // Default mode is byte-identical: only W8 carries an `id` in the JSON payload
715
- // (hooks/hypo-personal-check.mjs filters on it). All other IDs stay internal
716
- // unless `--strict` is set, where the full ID set is exposed so promoted
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=')) args.hypoDir = expandHome(arg.slice(11));
31
- else if (arg.startsWith('--q=')) args.query = arg.slice(4);
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();
@@ -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 { currentDevice, scopeVisible, readVisibilityScope } from '../hooks/hypo-shared.mjs';
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(JSON.stringify({ project, sessionState, hot: hotContent }, null, 2));
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));
@@ -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.1',
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.1',
953
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.2',
954
954
  });
955
955
  return true;
956
956
  }