hypomnema 1.4.2 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +35 -18
- package/README.md +25 -8
- package/commands/audit.md +2 -2
- package/commands/crystallize.md +22 -14
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/CONTRIBUTING.md +8 -7
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-lookup.mjs +37 -2
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +62 -42
- package/hooks/hypo-shared.mjs +430 -85
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +151 -53
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/page-usage.mjs +141 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +5 -5
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +85 -18
- package/skills/crystallize/SKILL.md +17 -11
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +5 -5
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/SCHEMA.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +21 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
package/hooks/version-check.mjs
CHANGED
|
@@ -249,7 +249,7 @@ export function isOptedOut(env = process.env) {
|
|
|
249
249
|
return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
// ── stale-sibling detection
|
|
252
|
+
// ── stale-sibling detection ───────────────────────────────────────────────────
|
|
253
253
|
//
|
|
254
254
|
// A second, OLDER Hypomnema can sit on $PATH (e.g. a stale `npm i -g hypomnema`)
|
|
255
255
|
// while a newer copy owns the active hooks. The CLI bin (`hypomnema`) then routes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"!scripts/bump-version.mjs",
|
|
15
15
|
"!scripts/smoke-pack.mjs",
|
|
16
16
|
"!scripts/smoke-plugin.mjs",
|
|
17
|
+
"!scripts/fix-status-verify.mjs",
|
|
18
|
+
"!scripts/lib/fix-status-verify.mjs",
|
|
19
|
+
"!scripts/lib/fix-manifest.mjs",
|
|
20
|
+
"!scripts/lib/adr-corpus.mjs",
|
|
17
21
|
"commands/",
|
|
18
22
|
"hooks/",
|
|
19
23
|
"skills/",
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* check-tracker-ids.mjs — CLI gate: no wiki-internal tracker IDs (ISSUE-N,
|
|
4
|
-
* fix #N) in OSS-public artifacts.
|
|
4
|
+
* fix #N, FEAT-N, IMPR-N, PRAC-N) in OSS-public artifacts.
|
|
5
5
|
*
|
|
6
6
|
* Rule source: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-
|
|
7
7
|
* artifacts, 2026-06-09). The repo ships through npm + a Claude Code plugin;
|
|
8
|
-
*
|
|
9
|
-
* dangling pointer into the maintainer's PRIVATE wiki
|
|
10
|
-
* (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and
|
|
8
|
+
* an `ISSUE-N` / `fix #N` / `FEAT-N` / `IMPR-N` / `PRAC-N` in any shipped file or
|
|
9
|
+
* in README/CHANGELOG is a dangling pointer into the maintainer's PRIVATE wiki
|
|
10
|
+
* tracker. GitHub refs (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and
|
|
11
|
+
* never flagged.
|
|
11
12
|
*
|
|
12
13
|
* Modes:
|
|
13
14
|
* --all (default) Walk the public-artifact scope and scan every text
|
|
@@ -31,14 +32,16 @@
|
|
|
31
32
|
* auto-ships it) + shipped trees commands/ hooks/ scripts/ skills/ templates/
|
|
32
33
|
* docs/ .github/ .claude-plugin/. Excluded: tests/ and qa-runs/ (internal
|
|
33
34
|
* maintainer artifacts — a tracker id in a test description aids traceability
|
|
34
|
-
* and never reaches an installed user), node_modules/, .git
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* and never reaches an installed user), node_modules/, .git/, and the
|
|
36
|
+
* fix-status-verify subsystem (EXCLUDED_FILES — maintainer-only, carries wiki
|
|
37
|
+
* `decisions/` paths as runtime data, un-shipped from the npm package). The
|
|
38
|
+
* checker's OWN sources are scanned (NOT exempt); their examples use `N`
|
|
39
|
+
* placeholders so they stay clean.
|
|
37
40
|
*
|
|
38
|
-
* ADR scope
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* ADR scope: `ADR NNNN` / `decisions/NNNN` wiki-ADR pointers (DECISION_PATTERNS)
|
|
42
|
+
* are blocked everywhere in scope EXCEPT CHANGELOG.md, whose version history
|
|
43
|
+
* legitimately cites the decision behind a release line. patternsFor() applies the
|
|
44
|
+
* broader set to every in-scope file but the CHANGELOG.
|
|
42
45
|
*/
|
|
43
46
|
|
|
44
47
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
@@ -50,7 +53,7 @@ import {
|
|
|
50
53
|
stripScissors,
|
|
51
54
|
messageHasGitTemplate,
|
|
52
55
|
BLOCKED_PATTERNS,
|
|
53
|
-
|
|
56
|
+
DECISION_PATTERNS,
|
|
54
57
|
TAG_BODY_PATTERNS,
|
|
55
58
|
} from './lib/check-tracker-ids.mjs';
|
|
56
59
|
import {
|
|
@@ -70,7 +73,8 @@ const REPO_ROOT = process.env.CHECK_TRACKER_ROOT || join(__dirname, '..');
|
|
|
70
73
|
|
|
71
74
|
const RULE_REF =
|
|
72
75
|
'Rule: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-artifacts). ' +
|
|
73
|
-
'OSS-public artifacts must not reference the private wiki tracker
|
|
76
|
+
'OSS-public artifacts must not reference the private wiki tracker ' +
|
|
77
|
+
'(ISSUE-N / fix #N / FEAT-N / IMPR-N / PRAC-N). ' +
|
|
74
78
|
'Use a GitHub ref (#N / PR #N) or drop the reference.';
|
|
75
79
|
|
|
76
80
|
// Top-level public-artifact entry points. Files are scanned directly; dirs are
|
|
@@ -94,15 +98,49 @@ const SCOPE_DIRS = [
|
|
|
94
98
|
// and never reaches an installed user). The checker's OWN sources are NOT
|
|
95
99
|
// excluded — they use `N` placeholders in their examples so they scan clean.
|
|
96
100
|
const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'tests', 'qa-runs']);
|
|
97
|
-
|
|
101
|
+
// The fix-status-verify subsystem (run only via `npm run fix:verify`, never by a
|
|
102
|
+
// shipped command/hook/skill) is a maintainer evidence-verification tool. Its
|
|
103
|
+
// manifest carries `decisions/NNNN` wiki paths as RUNTIME DATA (resolved against
|
|
104
|
+
// the maintainer's local wiki), so the anchors cannot be stripped the way a
|
|
105
|
+
// comment can. It is removed from the npm package (package.json `files`) and
|
|
106
|
+
// excluded here so the gate does not flag its load-bearing data.
|
|
107
|
+
// Stored POSIX-style ('/'); paths are normalized before lookup (see toPosix).
|
|
108
|
+
const EXCLUDED_FILES = new Set([
|
|
109
|
+
'scripts/fix-status-verify.mjs',
|
|
110
|
+
'scripts/lib/fix-status-verify.mjs',
|
|
111
|
+
'scripts/lib/fix-manifest.mjs',
|
|
112
|
+
'scripts/lib/adr-corpus.mjs',
|
|
113
|
+
]);
|
|
98
114
|
|
|
99
115
|
// Only text artifacts. Binary / lockfiles add noise and never carry prose refs.
|
|
100
|
-
|
|
116
|
+
// .svg is included because the shipped docs/assets/ logos are hand-authored XML
|
|
117
|
+
// text whose <title>/<desc>/comment fields could carry a dangling wiki pointer.
|
|
118
|
+
const TEXT_EXT = new Set([
|
|
119
|
+
'.md',
|
|
120
|
+
'.mjs',
|
|
121
|
+
'.js',
|
|
122
|
+
'.cjs',
|
|
123
|
+
'.json',
|
|
124
|
+
'.yml',
|
|
125
|
+
'.yaml',
|
|
126
|
+
'.sh',
|
|
127
|
+
'.txt',
|
|
128
|
+
'.svg',
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// Normalize OS separators to '/' so the two scan modes classify a path the same
|
|
132
|
+
// way on every host: --all builds relPath via relative() (platform sep, so '\' on
|
|
133
|
+
// Windows) while --staged takes git's output (always '/'). Without this, a Windows
|
|
134
|
+
// --staged run would neither exclude the verifier files nor recognize scope dirs.
|
|
135
|
+
// No-op on POSIX (sep is already '/').
|
|
136
|
+
function toPosix(relPath) {
|
|
137
|
+
return sep === '/' ? relPath : relPath.split(sep).join('/');
|
|
138
|
+
}
|
|
101
139
|
|
|
102
140
|
function isExcludedRel(relPath) {
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
if (EXCLUDED_FILES.has(
|
|
141
|
+
const p = toPosix(relPath);
|
|
142
|
+
if (p.split('/').some((seg) => EXCLUDED_DIRS.has(seg))) return true;
|
|
143
|
+
if (EXCLUDED_FILES.has(p)) return true;
|
|
106
144
|
return false;
|
|
107
145
|
}
|
|
108
146
|
|
|
@@ -116,22 +154,22 @@ function isTextFile(p) {
|
|
|
116
154
|
function isInScope(relPath) {
|
|
117
155
|
if (isExcludedRel(relPath)) return false;
|
|
118
156
|
if (!isTextFile(relPath)) return false;
|
|
119
|
-
|
|
120
|
-
|
|
157
|
+
const p = toPosix(relPath);
|
|
158
|
+
if (SCOPE_FILES.includes(p)) return true;
|
|
159
|
+
return SCOPE_DIRS.includes(p.split('/')[0]);
|
|
121
160
|
}
|
|
122
161
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
162
|
+
// `ADR NNNN` / `decisions/NNNN` are dangling pointers into the maintainer's
|
|
163
|
+
// private wiki ADR set, so they are blocked everywhere in scope EXCEPT the
|
|
164
|
+
// CHANGELOG, whose version history legitimately cites the decision behind a
|
|
165
|
+
// release line (and never reaches an installed user as a live link). The verifier
|
|
166
|
+
// subsystem that carries `decisions/` paths as runtime data is already removed by
|
|
167
|
+
// EXCLUDED_FILES, so it is never reached here.
|
|
168
|
+
const ADR_EXEMPT_FILES = new Set(['CHANGELOG.md']);
|
|
131
169
|
function patternsFor(relPath) {
|
|
132
|
-
return
|
|
133
|
-
?
|
|
134
|
-
: BLOCKED_PATTERNS;
|
|
170
|
+
return ADR_EXEMPT_FILES.has(toPosix(relPath))
|
|
171
|
+
? BLOCKED_PATTERNS
|
|
172
|
+
: [...BLOCKED_PATTERNS, ...DECISION_PATTERNS];
|
|
135
173
|
}
|
|
136
174
|
|
|
137
175
|
// Recursively collect in-scope text files under an absolute dir.
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import { fileURLToPath } from 'url';
|
|
|
73
73
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
74
74
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
75
75
|
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
76
|
+
import { aggregateColdCandidates } from './lib/page-usage.mjs';
|
|
76
77
|
import { isValidProjectName } from './lib/project-create.mjs';
|
|
77
78
|
import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
|
|
78
79
|
import {
|
|
@@ -83,10 +84,13 @@ import {
|
|
|
83
84
|
sessionClosedMarkerPath,
|
|
84
85
|
readSessionClosedMarker,
|
|
85
86
|
partitionLintScope,
|
|
87
|
+
isUnderProjectDirs,
|
|
86
88
|
sessionLogShardPath,
|
|
87
89
|
sessionLogReadCandidates,
|
|
88
90
|
sessionLogScopePath,
|
|
89
91
|
rootLogEntry,
|
|
92
|
+
hasSessionLogHeading,
|
|
93
|
+
hasLogEntry,
|
|
90
94
|
resolveTranscriptBySessionId,
|
|
91
95
|
hasUserCloseSignal,
|
|
92
96
|
commitWikiChanges,
|
|
@@ -177,7 +181,7 @@ function parseArgs(argv) {
|
|
|
177
181
|
return args;
|
|
178
182
|
}
|
|
179
183
|
|
|
180
|
-
// ── session-close hard gate
|
|
184
|
+
// ── session-close hard gate ───────────────────────────────────────────────────
|
|
181
185
|
// The marker attests "the USER closed this session". Its evidence transcript is
|
|
182
186
|
// resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
|
|
183
187
|
// Claude project dirs — never from a CLI arg. A model owns the whole subprocess
|
|
@@ -208,7 +212,7 @@ function requireProjectDir(args, slug) {
|
|
|
208
212
|
// flow can self-verify before /compact triggers PreCompact.
|
|
209
213
|
|
|
210
214
|
function runSessionCloseCheck(args) {
|
|
211
|
-
//
|
|
215
|
+
// The check mirrors the FULL PreCompact gate via the shared
|
|
212
216
|
// precompactGateStatus (close files + lint + design-history + feedback
|
|
213
217
|
// projection), not just the close files — so a green check means /compact
|
|
214
218
|
// won't block on a human-fixable issue. Pass --transcript-path to widen the
|
|
@@ -221,9 +225,9 @@ function runSessionCloseCheck(args) {
|
|
|
221
225
|
// (codex design Finding 2).
|
|
222
226
|
//
|
|
223
227
|
// --project=<slug> narrows BOTH the close status and the lint scope to that one
|
|
224
|
-
// project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict
|
|
225
|
-
//
|
|
226
|
-
// the marker == compact-ready invariant holds
|
|
228
|
+
// project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict.
|
|
229
|
+
// It is check-only: the marker writers stay global so
|
|
230
|
+
// the marker == compact-ready invariant holds. When narrowed, the
|
|
227
231
|
// transcript widening is suppressed: a transcript touch in some OTHER project
|
|
228
232
|
// would re-add that project's files to the lint scope and re-block the scoped
|
|
229
233
|
// check, defeating the point. The global (no --project) check keeps widening.
|
|
@@ -238,9 +242,9 @@ function runSessionCloseCheck(args) {
|
|
|
238
242
|
});
|
|
239
243
|
const close = status.close;
|
|
240
244
|
|
|
241
|
-
//
|
|
245
|
+
// When a --session-id is supplied, report whether THIS session's
|
|
242
246
|
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
243
|
-
// separate field, NOT folded into `ok
|
|
247
|
+
// separate field, NOT folded into `ok`: `ok` stays the compact-
|
|
244
248
|
// readiness verdict. A green gate with marker_present=false is exactly the
|
|
245
249
|
// hand-edit close state: close is compact-ready but the Stop hook will
|
|
246
250
|
// still block until the marker is written.
|
|
@@ -328,7 +332,7 @@ function runSessionCloseCheck(args) {
|
|
|
328
332
|
}
|
|
329
333
|
// Beyond the close files: the rest of the PreCompact gate (lint, design-history,
|
|
330
334
|
// feedback over-cap/conflict). These are what made a "close-complete" check
|
|
331
|
-
// disagree with the real /compact gate before
|
|
335
|
+
// disagree with the real /compact gate before this check was added.
|
|
332
336
|
for (const b of status.blockers) {
|
|
333
337
|
if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
|
|
334
338
|
}
|
|
@@ -336,7 +340,7 @@ function runSessionCloseCheck(args) {
|
|
|
336
340
|
console.log('');
|
|
337
341
|
for (const n of status.notices) console.log(` · ${n.reason}`);
|
|
338
342
|
}
|
|
339
|
-
//
|
|
343
|
+
// Surface the per-session marker state (separate from compact-
|
|
340
344
|
// readiness) so a green-but-unmarked close is visible at verify time.
|
|
341
345
|
if (args.sessionId) {
|
|
342
346
|
console.log('');
|
|
@@ -450,7 +454,7 @@ function todayLocal() {
|
|
|
450
454
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
451
455
|
}
|
|
452
456
|
|
|
453
|
-
// Spec §5.2.7 / §8.3
|
|
457
|
+
// Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
|
|
454
458
|
// The payload shape MUST mirror that contract — missing a mandatory field is a
|
|
455
459
|
// payload bug, not a no-op. Caller is the LLM session-close flow, which composes
|
|
456
460
|
// the payload deliberately; partial payloads must fail loudly so caller fixes
|
|
@@ -501,14 +505,14 @@ function validatePayloadShape(payload) {
|
|
|
501
505
|
return errs;
|
|
502
506
|
}
|
|
503
507
|
|
|
504
|
-
// ── session-close marker (
|
|
508
|
+
// ── session-close marker (amendment 2026-05-19) ───────────────
|
|
505
509
|
// Standalone marker writer. Used when the LLM closes the session via direct
|
|
506
510
|
// Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
|
|
507
511
|
// crystallize` is the only Reader; writer authority is intentionally split
|
|
508
512
|
// between this CLI and the auto-write at the tail of applySessionClose.
|
|
509
513
|
//
|
|
510
514
|
// Contract: the marker is written only when the FULL /compact gate
|
|
511
|
-
// (precompactGateStatus
|
|
515
|
+
// (precompactGateStatus) is green. A failed gate exits 1 with no
|
|
512
516
|
// marker — the next Stop hook re-blocks.
|
|
513
517
|
|
|
514
518
|
function runMarkSessionClosed(args) {
|
|
@@ -520,12 +524,12 @@ function runMarkSessionClosed(args) {
|
|
|
520
524
|
// --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
|
|
521
525
|
// The gate stays GLOBAL — never narrowed — because a marker that narrowed its
|
|
522
526
|
// gate could attest compact-ready while PreCompact re-checks all of today's
|
|
523
|
-
// projects and stays red (the marker == compact-ready invariant,
|
|
527
|
+
// projects and stays red (the marker == compact-ready invariant,
|
|
524
528
|
// codex design finding 1/2). Validate the attribution slug exists as a
|
|
525
529
|
// directory, exactly as --check does, but only when it is actually used (a
|
|
526
530
|
// --log-only mark attributes to no project, so --project is moot there).
|
|
527
531
|
if (args.project && !args.logOnly) requireProjectDir(args, args.project);
|
|
528
|
-
//
|
|
532
|
+
// The per-session marker is the THIRD session-close completion
|
|
529
533
|
// signal (after the PreCompact gate and `--check-session-close`). It must use
|
|
530
534
|
// the SAME gate that governs /compact — precompactGateStatus — so the marker
|
|
531
535
|
// can never attest "closed" while /compact would still block. This subsumes
|
|
@@ -570,7 +574,7 @@ function runMarkSessionClosed(args) {
|
|
|
570
574
|
}
|
|
571
575
|
process.exit(1);
|
|
572
576
|
}
|
|
573
|
-
// User-close hard gate
|
|
577
|
+
// User-close hard gate: the compact gate above only proves the wiki
|
|
574
578
|
// is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
|
|
575
579
|
// unless the transcript carries a genuine user close signal (NL close phrase,
|
|
576
580
|
// /compact, or an AskUserQuestion close answer). This is the hard backstop for
|
|
@@ -618,7 +622,7 @@ function runMarkSessionClosed(args) {
|
|
|
618
622
|
scope: args.logOnly ? 'log-only' : 'project',
|
|
619
623
|
date: status.dates[0],
|
|
620
624
|
notices: gate.notices,
|
|
621
|
-
//
|
|
625
|
+
// pure feedback-projection drift is a non-blocker: the marker
|
|
622
626
|
// attests "compact-ready (no human-fixable blocker)", and the PreCompact
|
|
623
627
|
// hook self-heals the projection (feedback-sync --write) at /compact. Surface
|
|
624
628
|
// the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
|
|
@@ -652,7 +656,7 @@ function applySessionClose(args) {
|
|
|
652
656
|
// for any actual apply work (readPayload below surfaces "payload is
|
|
653
657
|
// required" the same way it always has).
|
|
654
658
|
if (!args.force && !args.payload) {
|
|
655
|
-
//
|
|
659
|
+
// No-payload "already complete?" probe uses the
|
|
656
660
|
// global invariant, not a recency pick.
|
|
657
661
|
const probe = sessionCloseGlobalStatus(args.hypoDir);
|
|
658
662
|
if (probe.ok) {
|
|
@@ -752,21 +756,49 @@ function applySessionClose(args) {
|
|
|
752
756
|
}
|
|
753
757
|
const date = payload.date || todayLocal();
|
|
754
758
|
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
|
|
759
|
+
// Pre-apply freshness-contract gate: the post-apply verification holds
|
|
760
|
+
// sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
|
|
761
|
+
// definition of "closed today". Enforce that SAME contract on the payload
|
|
762
|
+
// BEFORE writing a byte, so a heading the gate won't recognize is rejected
|
|
763
|
+
// here as a format mismatch — not written and then misdiagnosed downstream as
|
|
764
|
+
// "stale" (the "not updated" vs "format mismatch" conflation). All checks
|
|
765
|
+
// exit 1 with stage='pre-apply-verification' and leave the tree untouched.
|
|
766
|
+
const failPreApply = (msg) => {
|
|
767
|
+
console.log(
|
|
768
|
+
args.json
|
|
769
|
+
? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
|
|
770
|
+
: `✗ ${msg}`,
|
|
771
|
+
);
|
|
769
772
|
process.exit(1);
|
|
773
|
+
};
|
|
774
|
+
// (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
|
|
775
|
+
// post-apply gate checks the session-log file for exactly this heading, so a
|
|
776
|
+
// headingless entry would write then false-fail as "stale". This also doubles
|
|
777
|
+
// as the B-1 derive precondition: when `log` is omitted the root log.md entry
|
|
778
|
+
// is reconstructed from THIS heading, and on a same-day SECOND close the
|
|
779
|
+
// date-level verifier would still pass on the earlier entry, so a no-derive
|
|
780
|
+
// would slip through as ok:true. The `!payload.log` branch keeps the original
|
|
781
|
+
// derive-specific wording (a test asserts it).
|
|
782
|
+
if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
|
|
783
|
+
failPreApply(
|
|
784
|
+
!payload.log
|
|
785
|
+
? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
|
|
786
|
+
`entry from. Give it a dated heading, or supply payload.log explicitly.`
|
|
787
|
+
: `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
|
|
788
|
+
`identifies a session-log by its dated ATX heading; give the entry a ` +
|
|
789
|
+
`"## [${date}] <title>" heading (the brackets are required).`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
// (b) An explicit payload.log entry must match the canonical
|
|
793
|
+
// `## [<date>] session | <project>` line the gate looks for (colon or space
|
|
794
|
+
// delimiter after the slug). Otherwise the write lands but post-apply
|
|
795
|
+
// verification reports log.md as stale. When `log` is omitted the line is
|
|
796
|
+
// derived canonically (rootLogEntry) so this cannot mismatch.
|
|
797
|
+
if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
|
|
798
|
+
failPreApply(
|
|
799
|
+
`payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
|
|
800
|
+
`close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
|
|
801
|
+
);
|
|
770
802
|
}
|
|
771
803
|
|
|
772
804
|
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
@@ -793,7 +825,7 @@ function applySessionClose(args) {
|
|
|
793
825
|
// payloadScope = every file this apply writes or appends. Both lint passes are
|
|
794
826
|
// judged against it; errors elsewhere are surfaced as notices, never blocking.
|
|
795
827
|
//
|
|
796
|
-
// session-log needs TWO entries
|
|
828
|
+
// session-log needs TWO entries: the daily WRITE target (what this
|
|
797
829
|
// apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
|
|
798
830
|
// file. They coincide except in the hybrid cutover month, where a fallback-
|
|
799
831
|
// aware no-op (the identical entry already lives in the legacy monthly file)
|
|
@@ -837,7 +869,7 @@ function applySessionClose(args) {
|
|
|
837
869
|
ok: false,
|
|
838
870
|
stage: 'preflight-lint',
|
|
839
871
|
error: 'lint preflight failed — apply aborted (no payload bytes written)',
|
|
840
|
-
lint: { ...preflightLint, blockingErrors },
|
|
872
|
+
lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
|
|
841
873
|
};
|
|
842
874
|
if (args.json) {
|
|
843
875
|
console.log(JSON.stringify(out, null, 2));
|
|
@@ -875,7 +907,7 @@ function applySessionClose(args) {
|
|
|
875
907
|
const rel = join('projects', project, 'session-log', `${date}.md`);
|
|
876
908
|
const full = join(args.hypoDir, rel);
|
|
877
909
|
const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
|
|
878
|
-
// Fallback-aware idempotency (
|
|
910
|
+
// Fallback-aware idempotency (hybrid cutover): during the month the
|
|
879
911
|
// shard takes over, today's entry may already live in the legacy monthly
|
|
880
912
|
// file from an earlier (pre-cutover) close. Treat presence in EITHER the
|
|
881
913
|
// daily shard or the legacy monthly file as "already written" so a same-day
|
|
@@ -905,7 +937,7 @@ function applySessionClose(args) {
|
|
|
905
937
|
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
906
938
|
// heading lives inside the entry, so freshness / derive / design-history
|
|
907
939
|
// are unchanged.
|
|
908
|
-
//
|
|
940
|
+
// Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
|
|
909
941
|
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
910
942
|
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
911
943
|
// machine that first seeds the daily shard is recorded; later same-day
|
|
@@ -1037,11 +1069,17 @@ function applySessionClose(args) {
|
|
|
1037
1069
|
const postLintOk = !postApplyCrashed && postBlocking.length === 0;
|
|
1038
1070
|
const ok = verification.ok && postLintOk;
|
|
1039
1071
|
|
|
1040
|
-
//
|
|
1072
|
+
// Scope the non-blocking notice to the close-target project: debt under
|
|
1073
|
+
// projects/<project>/ stays listed; debt elsewhere folds to a count so the
|
|
1074
|
+
// same untouched-file debt does not re-list its filenames on every close.
|
|
1075
|
+
const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
|
|
1076
|
+
const otherDebtCount = postNotice.length - closeScopeNotice.length;
|
|
1077
|
+
|
|
1078
|
+
// Amendment 2026-05-19: auto-write the per-session
|
|
1041
1079
|
// closed marker on a verified close. Hook authority is read-only; this is
|
|
1042
1080
|
// one of the two writer paths (the other is --mark-session-closed standalone).
|
|
1043
1081
|
//
|
|
1044
|
-
//
|
|
1082
|
+
// The marker write is governed by the SAME gate as standalone
|
|
1045
1083
|
// --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
|
|
1046
1084
|
// `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
|
|
1047
1085
|
// govern apply SUCCESS (exit code below), but the marker must additionally
|
|
@@ -1049,13 +1087,13 @@ function applySessionClose(args) {
|
|
|
1049
1087
|
// path could issue a marker the standalone path would refuse (the second
|
|
1050
1088
|
// divergence codex flagged).
|
|
1051
1089
|
//
|
|
1052
|
-
//
|
|
1053
|
-
// writes
|
|
1090
|
+
// Apply just wrote the payload, so the tree is dirty by its OWN
|
|
1091
|
+
// writes: the gate's `uncommitted` git blocker would always trip and the
|
|
1054
1092
|
// marker would be skipped, deferring the close to a manual --mark-session-closed
|
|
1055
|
-
// (
|
|
1093
|
+
// ("done but still blocked" regression). Commit the payload HERE, via
|
|
1056
1094
|
// the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
|
|
1057
1095
|
// a committed tree. Push stays deferred to the Stop hook; the resulting
|
|
1058
|
-
// committed-but-unpushed state is a gate notice, not a blocker
|
|
1096
|
+
// committed-but-unpushed state is a gate notice, not a blocker, so
|
|
1059
1097
|
// this still marks. A commit failure (not a repo / pre-commit reject / git error)
|
|
1060
1098
|
// skips the marker WITH a surfaced reason — today's behavior was also "no marker",
|
|
1061
1099
|
// but silently.
|
|
@@ -1077,14 +1115,14 @@ function applySessionClose(args) {
|
|
|
1077
1115
|
// (feedback/W8/hot/lint — git is now committed) is resolved.
|
|
1078
1116
|
markerSkipReason = 'compact-gate-not-ok';
|
|
1079
1117
|
} else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
|
|
1080
|
-
// User-close hard gate
|
|
1118
|
+
// User-close hard gate: apply succeeded (payload files written)
|
|
1081
1119
|
// but the user never signalled session close, so the marker — which attests
|
|
1082
1120
|
// "user closed" — is withheld. The wiki record stands; the session is simply
|
|
1083
1121
|
// not marked closed. Surfaced (not silent) so the caller knows.
|
|
1084
1122
|
markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
|
|
1085
1123
|
} else {
|
|
1086
1124
|
writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
|
|
1087
|
-
// Codex CONCERN
|
|
1125
|
+
// Codex CONCERN: the writer swallows IO errors (best-effort).
|
|
1088
1126
|
// Verify the file actually landed — mirroring the standalone path — instead of
|
|
1089
1127
|
// asserting markerWritten=true, so a .cache permission/disk problem surfaces
|
|
1090
1128
|
// rather than the caller reporting "closed" while the next Stop re-blocks.
|
|
@@ -1111,13 +1149,22 @@ function applySessionClose(args) {
|
|
|
1111
1149
|
applied,
|
|
1112
1150
|
skipped,
|
|
1113
1151
|
verification,
|
|
1114
|
-
//
|
|
1152
|
+
// Surface the marker outcome instead of skipping silently, so the
|
|
1115
1153
|
// caller can tell "closed" from "applied but not marked".
|
|
1116
1154
|
...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
|
|
1117
|
-
lint: {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1155
|
+
lint: {
|
|
1156
|
+
preflight: summarizeLintForOutput(preflightLint),
|
|
1157
|
+
postApply: summarizeLintForOutput(postApplyLint),
|
|
1158
|
+
},
|
|
1159
|
+
// Pre-existing lint debt in files this close did not author: surfaced for
|
|
1160
|
+
// visibility, never gated. Empty on a clean vault. Scoped to the close-target
|
|
1161
|
+
// project's own dir — debt under projects/<project>/ is this close's
|
|
1162
|
+
// neighborhood and stays listed; debt elsewhere (other projects, shared
|
|
1163
|
+
// pages, root files) folds into otherDebtCount so the same untouched-file
|
|
1164
|
+
// debt does not re-list its filenames on every close (run `node
|
|
1165
|
+
// scripts/lint.mjs` for the full list).
|
|
1166
|
+
notices: [...new Set(closeScopeNotice.map((e) => e.file))],
|
|
1167
|
+
otherDebtCount,
|
|
1121
1168
|
};
|
|
1122
1169
|
|
|
1123
1170
|
if (args.json) {
|
|
@@ -1171,13 +1218,18 @@ function applySessionClose(args) {
|
|
|
1171
1218
|
console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
|
|
1172
1219
|
}
|
|
1173
1220
|
}
|
|
1174
|
-
if (
|
|
1221
|
+
if (closeScopeNotice.length > 0) {
|
|
1175
1222
|
console.log(
|
|
1176
|
-
`\n· ${
|
|
1177
|
-
...new Set(
|
|
1223
|
+
`\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
|
|
1224
|
+
...new Set(closeScopeNotice.map((e) => e.file)),
|
|
1178
1225
|
]
|
|
1179
1226
|
.slice(0, 5)
|
|
1180
|
-
.join(', ')}${
|
|
1227
|
+
.join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
if (otherDebtCount > 0) {
|
|
1231
|
+
console.log(
|
|
1232
|
+
`\n· +${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`node scripts/lint.mjs\` for the full list.`,
|
|
1181
1233
|
);
|
|
1182
1234
|
}
|
|
1183
1235
|
}
|
|
@@ -1186,6 +1238,29 @@ function applySessionClose(args) {
|
|
|
1186
1238
|
|
|
1187
1239
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
1188
1240
|
|
|
1241
|
+
// Cap the warn list in MODEL-FACING lint output. `result.lint` is serialized
|
|
1242
|
+
// into the --json apply result the documented close path reads, and lint runs
|
|
1243
|
+
// twice per close (preflight + post-apply), so an un-capped warn list (e.g.
|
|
1244
|
+
// hundreds of broken-wikilink warnings) lands in model context twice on every
|
|
1245
|
+
// close. Errors stay full (few, actionable); warns collapse to a count plus a
|
|
1246
|
+
// small sample for quick diagnosis. The INTERNAL preflightLint / postApplyLint
|
|
1247
|
+
// objects are untouched — the blocking filter and the pending-tag scan still
|
|
1248
|
+
// read the full warn list. lint.mjs --json itself also stays full: its
|
|
1249
|
+
// programmatic consumers (the pending-tag scan, the PreCompact W8 filter, tests)
|
|
1250
|
+
// need every warn.
|
|
1251
|
+
function summarizeLintForOutput(l) {
|
|
1252
|
+
const SAMPLE = 10;
|
|
1253
|
+
const warns = l.warns || [];
|
|
1254
|
+
const out = {
|
|
1255
|
+
ok: l.ok,
|
|
1256
|
+
errors: l.errors || [],
|
|
1257
|
+
warnCount: warns.length,
|
|
1258
|
+
warns: warns.slice(0, SAMPLE),
|
|
1259
|
+
};
|
|
1260
|
+
if (warns.length > SAMPLE) out.warnsTruncated = warns.length - SAMPLE;
|
|
1261
|
+
return out;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1189
1264
|
function parseFrontmatter(content) {
|
|
1190
1265
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1191
1266
|
if (!m) return null;
|
|
@@ -1271,8 +1346,12 @@ const synthesisGroups = Object.entries(tagGroups)
|
|
|
1271
1346
|
.sort((a, b) => b[1].length - a[1].length)
|
|
1272
1347
|
.map(([tag, pages]) => ({ tag, pages }));
|
|
1273
1348
|
|
|
1349
|
+
// Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
|
|
1350
|
+
// injected within the recency window. Advisory only, never gates, never mutates.
|
|
1351
|
+
const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
|
|
1352
|
+
|
|
1274
1353
|
if (args.json) {
|
|
1275
|
-
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
|
|
1354
|
+
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
|
|
1276
1355
|
process.exit(0);
|
|
1277
1356
|
}
|
|
1278
1357
|
|
|
@@ -1302,6 +1381,25 @@ if (drafts.length > 0) {
|
|
|
1302
1381
|
console.log('');
|
|
1303
1382
|
}
|
|
1304
1383
|
|
|
1384
|
+
// Advisory (non-gating): pages the graph treats as live but lookup has not
|
|
1385
|
+
// injected recently. Held until enough page-usage history accrues.
|
|
1386
|
+
if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
|
|
1387
|
+
found = true;
|
|
1388
|
+
console.log(
|
|
1389
|
+
`Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
|
|
1390
|
+
);
|
|
1391
|
+
for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
|
|
1392
|
+
console.log('');
|
|
1393
|
+
} else if (
|
|
1394
|
+
coldCandidates.status === 'insufficient-data' &&
|
|
1395
|
+
coldCandidates.reason === 'span-too-short'
|
|
1396
|
+
) {
|
|
1397
|
+
// Only surface the "held" notice once a log is actually accruing (span under
|
|
1398
|
+
// the cold-start window). A vault with no log at all stays silent so this
|
|
1399
|
+
// advisory never becomes permanent noise on every crystallize run.
|
|
1400
|
+
console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1305
1403
|
if (!found) {
|
|
1306
1404
|
console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
|
|
1307
1405
|
}
|