hypomnema 1.8.1 → 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.
@@ -15,11 +15,11 @@
15
15
  * graph before adding a new source.
16
16
  *
17
17
  * Output contract — Claude Code docs, "Add context for Claude":
18
- * PostToolUse uses **nested** hookSpecificOutput.additionalContext, NOT
19
- * the top-level additionalContext that UserPromptSubmit hooks use.
20
- * buildOutput() in hypo-shared.mjs is the top-level helper used by
21
- * hypo-first-prompt / hypo-lookup; intentionally not reused here. See
22
- * commit 515458f for the per-event matrix this codebase follows.
18
+ * PostToolUse uses **nested** hookSpecificOutput.additionalContext, the
19
+ * same shape every other injecting event in this codebase now uses.
20
+ * buildOutput() in hypo-shared.mjs emits that nested shape given the
21
+ * hookEventName, so this hook calls it too instead of building the
22
+ * object by hand.
23
23
  *
24
24
  * URL redaction:
25
25
  * additionalContext lands in the transcript. Query strings and
@@ -41,7 +41,7 @@
41
41
  * in-hook failure branch is needed.
42
42
  */
43
43
 
44
- import { isGateSkipped } from './hypo-shared.mjs';
44
+ import { isGateSkipped, buildOutput } from './hypo-shared.mjs';
45
45
 
46
46
  let input = {};
47
47
  try {
@@ -58,13 +58,9 @@ try {
58
58
  }
59
59
 
60
60
  const context = buildContext(input);
61
- const output = { continue: true, suppressOutput: true };
62
- if (context) {
63
- output.hookSpecificOutput = {
64
- hookEventName: 'PostToolUse',
65
- additionalContext: context,
66
- };
67
- }
61
+ const output = context
62
+ ? buildOutput('PostToolUse', context, { continue: true, suppressOutput: true })
63
+ : { continue: true, suppressOutput: true };
68
64
  console.log(JSON.stringify(output));
69
65
 
70
66
  function buildContext(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,6 +87,7 @@
87
87
  "fix:verify": "node scripts/fix-status-verify.mjs",
88
88
  "graph": "node scripts/graph.mjs",
89
89
  "smoke-pack": "node scripts/smoke-pack.mjs",
90
+ "check:release-vehicle": "node scripts/check-release-vehicle.mjs",
90
91
  "check:versions": "node scripts/check-versions.mjs",
91
92
  "smoke:plugin": "node scripts/smoke-plugin.mjs",
92
93
  "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
@@ -34,6 +34,7 @@ import {
34
34
  collectProjectWorkingDirs,
35
35
  detectSessionCloseArtifact,
36
36
  localAndUtcDates,
37
+ normalizeVerifiedScope,
37
38
  SESSION_CLOSED_MARKER_STALE_MS,
38
39
  isUsablePkgRootLocal,
39
40
  selfLocationPkgRootFrom,
@@ -916,14 +917,32 @@ function deriveCommitProjects(hypoDir, hash) {
916
917
  // an artifact with no verifiable scope, so this never matches, ever.
917
918
  // • 'projects' — a project file, or a commit that touched one or more
918
919
  // identifiable projects/<slug>/ paths. EVERY named project must appear
919
- // in the marker's own `projects` list.
920
+ // in the marker's own `projects` list, AND, when the marker carries a
921
+ // `verified_scope` (session-close-scope-boundary spec §3), in that
922
+ // scope's own `projects` too — an AND, never an OR. `projects` is
923
+ // evidence-based attribution; `verified_scope` is what the gate that
924
+ // wrote this marker actually checked, and the two can diverge (an
925
+ // unnarrowed gate run can attribute to more projects than it verified).
926
+ // A `verified_scope` of kind 'log-only' verified no project at all, so it
927
+ // can never cover a 'projects'-scoped artifact. A marker with NO
928
+ // `verified_scope` (every marker before this field existed) skips this
929
+ // extra check entirely — it neither tightens nor loosens the membership
930
+ // check above, so old markers read back byte-for-byte as before.
920
931
  function markerCoversArtifact(marker, artifact) {
921
932
  if (!marker.dates.includes(artifact.date)) return false;
922
933
  switch (artifact.scope.kind) {
923
934
  case 'root-universal':
924
935
  return true;
925
936
  case 'projects':
926
- return artifact.scope.projects.every((p) => marker.projects.includes(p));
937
+ if (!artifact.scope.projects.every((p) => marker.projects.includes(p))) return false;
938
+ if (!marker.verifiedScope) return true;
939
+ // Unreachable for a 'projects' artifact today: a log-only close writes
940
+ // `projects: []`, so the membership check above already returned false.
941
+ // Kept deliberately, not by oversight — it states the kind's meaning
942
+ // (log-only verified no project at all) so this stays correct if
943
+ // `projects` ever carries something for a log-only close.
944
+ if (marker.verifiedScope.kind === 'log-only') return false;
945
+ return artifact.scope.projects.every((p) => marker.verifiedScope.projects.includes(p));
927
946
  default:
928
947
  return false; // 'unscoped'
929
948
  }
@@ -1035,7 +1054,17 @@ function checkSessionCloseArtifacts(hypoDir) {
1035
1054
  // and doctor has no corroborating signal of its own to add, so it
1036
1055
  // must refuse it too rather than re-opening the same hole standalone.
1037
1056
  const projects = Array.isArray(data?.projects) ? data.projects.filter(Boolean) : [];
1038
- markers.push({ projects: [...new Set(projects)], dates: localAndUtcDates(new Date(ts)) });
1057
+ // verified_scope (session-close-scope-boundary spec §3): the SAME
1058
+ // collapse the writer runs (hooks/hypo-shared.mjs), not a hand-rolled
1059
+ // copy — a shape this reader doesn't recognize, or a 'project'/'global'
1060
+ // scope with an empty `projects`, reads back as "field absent" (`null`),
1061
+ // never as a false claim.
1062
+ const verifiedScope = normalizeVerifiedScope(data?.verified_scope);
1063
+ markers.push({
1064
+ projects: [...new Set(projects)],
1065
+ dates: localAndUtcDates(new Date(ts)),
1066
+ verifiedScope,
1067
+ });
1039
1068
  } catch {
1040
1069
  // corrupt marker — not this check's job to clean up
1041
1070
  }
package/scripts/graph.mjs CHANGED
@@ -22,12 +22,32 @@ import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
22
22
 
23
23
  // ── arg parsing ───────────────────────────────────────────────────────────────
24
24
 
25
+ const ALLOWED_FLAGS = ['--hypo-dir=<path>', '--format=<fmt>', '--min-edges=<n>'];
26
+
25
27
  function parseArgs(argv) {
26
28
  const args = { hypoDir: null, format: 'json', minEdges: 0 };
27
29
  for (const arg of argv.slice(2)) {
28
- if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
29
- else if (arg.startsWith('--format=')) args.format = arg.slice(9);
30
+ if (arg.startsWith('--hypo-dir=')) {
31
+ // See lint.mjs's parseArgs for why an empty raw value (--hypo-dir=,
32
+ // or --hypo-dir="$VAULT" with VAULT unset) is rejected instead of
33
+ // falling through to the default-resolution branch below.
34
+ const raw = arg.slice(11);
35
+ if (!raw) {
36
+ console.error(`graph.mjs: --hypo-dir requires a non-empty value (got '${arg}')`);
37
+ console.error(`graph.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
38
+ process.exit(2);
39
+ }
40
+ args.hypoDir = expandHome(raw);
41
+ } else if (arg.startsWith('--format=')) args.format = arg.slice(9);
30
42
  else if (arg.startsWith('--min-edges=')) args.minEdges = parseInt(arg.slice(12), 10) || 0;
43
+ else {
44
+ // No positional arguments here either, so an unmatched arg is rejected
45
+ // rather than dropped. See lint.mjs's parseArgs for the background
46
+ // this closes.
47
+ console.error(`graph.mjs: unrecognized argument '${arg}'`);
48
+ console.error(`graph.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
49
+ process.exit(2);
50
+ }
31
51
  }
32
52
  if (!args.hypoDir) {
33
53
  const info = resolveHypoRootInfo();
@@ -3,6 +3,22 @@ import { isValidProjectName } from './project-create.mjs';
3
3
 
4
4
  // ── arg parsing ──────────────────────────────────────────────────────────────
5
5
 
6
+ const ALLOWED_FLAGS = [
7
+ '--hypo-dir=<path>',
8
+ '--min-group=<n>',
9
+ '--check-session-close',
10
+ '--apply-session-close',
11
+ '--mark-session-closed',
12
+ '--log-only',
13
+ '--session-id=<id>',
14
+ '--payload=<path|->',
15
+ '--transcript-path=<path>',
16
+ '--session-cwd=<path>',
17
+ '--project=<slug>',
18
+ '--force',
19
+ '--json',
20
+ ];
21
+
6
22
  export function parseArgs(argv) {
7
23
  const args = {
8
24
  hypoDir: null,
@@ -19,8 +35,20 @@ export function parseArgs(argv) {
19
35
  project: null,
20
36
  };
21
37
  for (const arg of argv.slice(2)) {
22
- if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
23
- else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
38
+ if (arg.startsWith('--hypo-dir=')) {
39
+ // See scripts/lint.mjs's parseArgs for why an empty raw value
40
+ // (--hypo-dir=, or --hypo-dir="$VAULT" with VAULT unset) is rejected
41
+ // instead of falling through to resolveHypoRoot() below. For this
42
+ // script specifically, a silent fallback here means a session-close
43
+ // apply writes to the wrong vault.
44
+ const raw = arg.slice(11);
45
+ if (!raw) {
46
+ console.error(`crystallize.mjs: --hypo-dir requires a non-empty value (got '${arg}')`);
47
+ console.error(`crystallize.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
48
+ process.exit(2);
49
+ }
50
+ args.hypoDir = expandHome(raw);
51
+ } else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
24
52
  else if (arg === '--check-session-close') args.checkSessionClose = true;
25
53
  else if (arg === '--apply-session-close') args.applySessionClose = true;
26
54
  else if (arg === '--mark-session-closed') args.markSessionClosed = true;
@@ -32,6 +60,14 @@ export function parseArgs(argv) {
32
60
  else if (arg.startsWith('--project=')) args.project = arg.slice(10);
33
61
  else if (arg === '--force') args.force = true;
34
62
  else if (arg === '--json') args.json = true;
63
+ else {
64
+ // No positional arguments here either, so an unmatched arg is rejected
65
+ // rather than dropped. See scripts/lint.mjs's parseArgs for the
66
+ // background this closes.
67
+ console.error(`crystallize.mjs: unrecognized argument '${arg}'`);
68
+ console.error(`crystallize.mjs accepts: ${ALLOWED_FLAGS.join(', ')}`);
69
+ process.exit(2);
70
+ }
35
71
  }
36
72
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
37
73
  // --project=<slug> override (check/mark only). Validate the SYNTAX here so a
@@ -472,10 +472,31 @@ export function runMarkSessionClosed(args) {
472
472
  process.exit(1);
473
473
  }
474
474
  const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
475
+ // verified_scope (session-close-scope-boundary spec §3, revised 2026-09-07):
476
+ // records the set the gate ABOVE actually made a row for and evaluated —
477
+ // never `markerProjects` (evidence-based attribution, `projects` above).
478
+ // `closeScope: [args.project]` widens resolveCloseScope's mine/foreign
479
+ // partition (it feeds `opts.closeScope`, never `opts.projectOverride`), so
480
+ // it does NOT narrow `sessionCloseGlobalStatus`: the gate ran unnarrowed
481
+ // regardless of --project (resolveGateProjectOverride's own doc comment;
482
+ // hypo-shared.mjs's sessionCloseGlobalStatus/precompactGateStatus doc
483
+ // comments). `kind` is therefore always 'global' here. 'project' stays a
484
+ // shape normalizeVerifiedScope and doctor's reader accept — for a future
485
+ // writer that DOES pass opts.projectOverride, which none of the four
486
+ // marker-writing paths do today. The earlier premise here (an explicit
487
+ // --project earns 'project') was a doctor regression: a transcript-widened
488
+ // scope can attribute a project the gate never put a row for, and stamping
489
+ // markerProjects verbatim let that project's close artifacts pass doctor's
490
+ // correlation unchecked.
491
+ const evaluatedProjects = (status.projects || []).map((p) => p.project).filter(Boolean);
492
+ const verifiedScope = args.logOnly
493
+ ? { kind: 'log-only' }
494
+ : { kind: 'global', projects: evaluatedProjects };
475
495
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
476
496
  project: markerProject,
477
497
  projects: args.logOnly ? [] : markerProjects,
478
498
  ...(args.logOnly ? { scope: 'log-only' } : {}),
499
+ verifiedScope,
479
500
  });
480
501
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
481
502
  // the file actually landed before claiming success — otherwise CLI exits 0
@@ -609,8 +630,12 @@ const CLOSE_REFUSAL_HELP = [
609
630
  * counts (extractUserMessages drops injected, tool, and hook-feedback text).
610
631
  *
611
632
  * { ok: true }
612
- * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
613
- * | no-user-close-signal
633
+ * { ok: false, reason, error, gateReason? } reason: session-id-required |
634
+ * transcript-unresolved | no-user-close-signal. `gateReason` is only present
635
+ * when `reason` is `no-user-close-signal`, and carries closeGateStatus's own
636
+ * reason string (no-open / transcript-rewrite-detected /
637
+ * no-new-open-since-resolution) for a caller that wants to tell those three
638
+ * apart without parsing `error`.
614
639
  */
615
640
  function verifyCloseAuthority(sessionId, hypoDir) {
616
641
  if (!sessionId) {
@@ -640,6 +665,13 @@ function verifyCloseAuthority(sessionId, hypoDir) {
640
665
  return {
641
666
  ok: false,
642
667
  reason: 'no-user-close-signal',
668
+ // The user-facing `reason` above stays the single collapsed string
669
+ // tests and downstream tooling already key on (see this function's own
670
+ // doc comment). `gateReason` carries closeGateStatus's actual reason
671
+ // (no-open / transcript-rewrite-detected / no-new-open-since-resolution)
672
+ // as a separate, machine-readable field, so a caller can tell the three
673
+ // apart without parsing the "Gate detail: ..." substring out of `error`.
674
+ gateReason: gateStatus.reason,
643
675
  error:
644
676
  "session-close apply refused before any wiki write or commit: this session's transcript " +
645
677
  'carries no user close signal. The user did not ask to close. ' +
@@ -755,6 +787,11 @@ function refuseUnlessCloseRequested(args) {
755
787
  ok: false,
756
788
  stage: 'no-user-close-signal',
757
789
  reason: closeAuth.reason,
790
+ // Only set when `reason` is 'no-user-close-signal' (undefined otherwise,
791
+ // and JSON.stringify drops an undefined-valued key). Lets a caller tell
792
+ // apart the three closeGateStatus refusals collapsed into that one
793
+ // `reason` string, without parsing "Gate detail: ..." out of `error`.
794
+ gateReason: closeAuth.gateReason,
758
795
  applied: [],
759
796
  // `null`, not `false`: this refusal fires before the commit step is ever
760
797
  // reached (see the general result's own `committed` contract below).
@@ -1613,6 +1650,12 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1613
1650
  }
1614
1651
  let closeTranscript = null;
1615
1652
  let gateOk = false;
1653
+ // verified_scope evidence (session-close-scope-boundary spec §3, revised
1654
+ // 2026-09-07): the set the gate below actually put a row for, filled in
1655
+ // once the gate below runs. Stays [] on any path that never reaches it
1656
+ // (uncommitted, no transcript) — normalizeVerifiedScope drops an empty
1657
+ // 'global' scope to "field absent" rather than persist a false claim.
1658
+ let gateEvaluatedProjects = [];
1616
1659
  if (commitOutcome.committed) {
1617
1660
  closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1618
1661
  // closeScope: apply KNOWS which project it just closed, and it wrote
@@ -1633,11 +1676,19 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1633
1676
  // `project` alone, going green on a foreign project's incomplete close
1634
1677
  // instead of demoting it to a notice.
1635
1678
  const autoMarkerOverride = resolveGateProjectOverride(args.hypoDir, { project });
1636
- gateOk = precompactGateStatus(args.hypoDir, {
1679
+ const gateStatus = precompactGateStatus(args.hypoDir, {
1637
1680
  closeScope: [project],
1638
1681
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1639
1682
  ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
1640
- }).ok;
1683
+ });
1684
+ gateOk = gateStatus.ok;
1685
+ // `closeScope` above widens the partition, it never narrows
1686
+ // sessionCloseGlobalStatus (only opts.projectOverride does, and this
1687
+ // call never sets it) — so gate.close.projects is the actual evaluated
1688
+ // set, not necessarily just `[project]`.
1689
+ gateEvaluatedProjects = (gateStatus.close.projects || [])
1690
+ .map((p) => p.project)
1691
+ .filter(Boolean);
1641
1692
  }
1642
1693
  const decision = planMarkerDecision({
1643
1694
  ok,
@@ -1664,7 +1715,17 @@ function runMarkerPhase(args, project, appliedPaths, ok) {
1664
1715
  if (decision.write) {
1665
1716
  // apply KNOWS its authoritative payload.project — stamp it as the v4
1666
1717
  // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
1667
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
1718
+ // verified_scope (revised 2026-09-07): `closeScope: [project]` above
1719
+ // widens resolveCloseScope's partition, it does not narrow
1720
+ // sessionCloseGlobalStatus — only opts.projectOverride does, and this
1721
+ // call never sets it. The gate ran unnarrowed, so `kind` is 'global',
1722
+ // with `projects` the set gate.close actually evaluated
1723
+ // (gateEvaluatedProjects), never `[project]` verbatim.
1724
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
1725
+ project,
1726
+ projects: [project],
1727
+ verifiedScope: { kind: 'global', projects: gateEvaluatedProjects },
1728
+ });
1668
1729
  // Codex CONCERN: the writer swallows IO errors (best-effort).
1669
1730
  // Verify the file actually landed — mirroring the standalone path — instead of
1670
1731
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
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,11 +305,33 @@ 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.
@@ -301,6 +351,13 @@ const issues = [];
301
351
  // PreCompact hook no longer blocks /compact, so
302
352
  // that set is what --check-session-close and
303
353
  // --mark-session-closed read, not a compact stop.
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.
304
361
  // NOTE: npm run lint / CI / release.yml / crystallize / the close-gate all
305
362
  // run plain lint, not --strict, so promotion is forward-looking there: these
306
363
  // still surface as warnings on those paths. But --strict is not unused —
@@ -392,6 +449,27 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
392
449
  issue('warn', rel, 'Missing frontmatter field: updated', path, 'W3');
393
450
  }
394
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
+
395
473
  // type-conditional required fields
396
474
  if (fm.type && TYPE_CONDITIONAL_FIELDS[fm.type]) {
397
475
  for (const field of TYPE_CONDITIONAL_FIELDS[fm.type]) {
@@ -660,6 +738,81 @@ for (const s of findDesignHistoryStale(args.hypoDir)) {
660
738
  );
661
739
  }
662
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
+
663
816
  // W12: project directory missing index.md. SCHEMA.md declares project-index
664
817
  // at projects/*/index.md and templates/projects/_template/ ships one, so a
665
818
  // project without it is a tooling/vault drift, not a legitimate shape — but
@@ -759,10 +912,12 @@ const errors = issues.filter((i) => i.severity === 'error');
759
912
  const warns = issues.filter((i) => i.severity === 'warn');
760
913
 
761
914
  if (args.json) {
762
- // Default mode is byte-identical: only W8 carries an `id` in the JSON payload
763
- // (hooks/hypo-personal-check.mjs filters on it). All other IDs stay internal
764
- // unless `--strict` is set, where the full ID set is exposed so promoted
765
- // 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.
766
921
  const toOut = ({ severity, file, message, id }) =>
767
922
  id && (id === 'W8' || args.strict)
768
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();
@@ -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
  }