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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.ko.md +5 -5
- package/README.md +5 -5
- 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/verify.md +12 -1
- package/docs/ARCHITECTURE.md +7 -7
- package/docs/CONTRIBUTING.md +2 -0
- 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 +5 -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/lib/crystallize-args.mjs +38 -2
- package/scripts/lib/crystallize-close-apply.mjs +66 -5
- package/scripts/lint.mjs +166 -11
- package/scripts/query.mjs +22 -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
|
@@ -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,
|
|
19
|
-
*
|
|
20
|
-
* buildOutput() in hypo-shared.mjs
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 =
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
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",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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='))
|
|
29
|
-
|
|
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='))
|
|
23
|
-
|
|
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 |
|
|
613
|
-
*
|
|
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
|
-
|
|
1679
|
+
const gateStatus = precompactGateStatus(args.hypoDir, {
|
|
1637
1680
|
closeScope: [project],
|
|
1638
1681
|
...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
|
|
1639
1682
|
...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
|
|
1640
|
-
})
|
|
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
|
-
|
|
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
|
|
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,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-
|
|
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.
|
|
@@ -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
|
-
//
|
|
763
|
-
// (hooks/hypo-
|
|
764
|
-
// unless `--strict` is set, where the full
|
|
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='))
|
|
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/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
|
}
|