hypomnema 1.9.0 → 1.9.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/CHANGELOG.md +38 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/commands/crystallize.md +13 -12
- package/commands/feedback.md +10 -0
- package/docs/CONTRIBUTING.md +1 -0
- package/hooks/close-receipt.mjs +5 -0
- package/hooks/hypo-personal-check.mjs +76 -6
- package/hooks/hypo-shared.mjs +91 -36
- package/package.json +2 -1
- package/scripts/crystallize.mjs +3 -3
- package/scripts/doctor.mjs +23 -5
- package/scripts/feedback-sync.mjs +1786 -162
- package/scripts/feedback.mjs +16 -2
- package/scripts/lib/code-fence.mjs +197 -0
- package/scripts/lib/crystallize-close-apply.mjs +82 -98
- package/scripts/lib/crystallize-close-check.mjs +26 -25
- package/scripts/lib/design-history-stale.mjs +113 -47
- package/scripts/lib/git-hooks-dir.mjs +1 -1
- package/scripts/lint.mjs +75 -9
- package/templates/hypo-automation.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +15 -11
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -584,7 +584,11 @@ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
|
|
|
584
584
|
t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
|
|
585
585
|
);
|
|
586
586
|
if (prefixRes.error || prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
|
|
587
|
-
|
|
587
|
+
// Only the line ending is git's framing (LF, or CRLF where git writes one). The
|
|
588
|
+
// prefix is a real path segment, so a leading or trailing space in a parent
|
|
589
|
+
// directory name belongs to it; trim() would make every path under that vault
|
|
590
|
+
// read as foreign.
|
|
591
|
+
const prefix = (prefixRes.stdout || '').replace(/\r?\n$/, '');
|
|
588
592
|
|
|
589
593
|
const t2 = remainingSpawnTimeoutMs(deadline);
|
|
590
594
|
if (t2 === 0) return [];
|
|
@@ -813,11 +817,11 @@ export function sessionLogScopePath(hypoDir, project, date) {
|
|
|
813
817
|
* the project slug from anything that follows by whitespace, a colon, or
|
|
814
818
|
* end-of-line, so the lookahead correctly rejects "session | foo-bar" when
|
|
815
819
|
* looking for "foo". Both delimiters are canonical: the derive path
|
|
816
|
-
* (rootLogEntry) emits `<project
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* only log.md evidence used the colon form
|
|
820
|
-
* non-deterministically. (Was a pre-existing boundary bug in
|
|
820
|
+
* (rootLogEntry) now emits `<project>: <title>` (colon, since the tone rule
|
|
821
|
+
* banned the em dash), and log.md files written by earlier versions carry
|
|
822
|
+
* `<project>` + space + em dash + `<title>`, which must keep matching. The colon must be
|
|
823
|
+
* accepted: without it, a close whose only log.md evidence used the colon form
|
|
824
|
+
* was misread as "stale" and blocked non-deterministically. (Was a pre-existing boundary bug in
|
|
821
825
|
* sessionCloseFileStatus that the helper extraction inherited.)
|
|
822
826
|
*/
|
|
823
827
|
export function hasLogEntry(content, date, project) {
|
|
@@ -1789,15 +1793,14 @@ function backUpOnce(hotPath, content, testHooks) {
|
|
|
1789
1793
|
* one must never proceed once this step has been skipped by a thrown error.
|
|
1790
1794
|
* @returns {boolean} true when the file was actually changed
|
|
1791
1795
|
*/
|
|
1792
|
-
function ensureVaultGitignorePattern(hypoDir, pattern) {
|
|
1796
|
+
export function ensureVaultGitignorePattern(hypoDir, pattern) {
|
|
1793
1797
|
const path = join(hypoDir, '.gitignore');
|
|
1794
1798
|
const content = existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
1795
1799
|
if (content.split('\n').some((l) => l.trim() === pattern)) return false;
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
atomicWrite(path, next);
|
|
1800
|
+
// An append, not a rewrite: the file may be a symlink into a dotfiles repo, have a mode of
|
|
1801
|
+
// its own, or be saved by an editor at this moment; a replace would change the first two
|
|
1802
|
+
// and could drop the third.
|
|
1803
|
+
appendFileSync(path, `${content.length > 0 && !content.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
1801
1804
|
return true;
|
|
1802
1805
|
}
|
|
1803
1806
|
|
|
@@ -2908,10 +2911,21 @@ function deriveLogTitle(tail) {
|
|
|
2908
2911
|
*/
|
|
2909
2912
|
export function rootLogEntry(slug, date, headingTail) {
|
|
2910
2913
|
const title = deriveLogTitle(headingTail);
|
|
2911
|
-
const heading = `## [${date}] session | ${slug}` + (title ?
|
|
2914
|
+
const heading = `## [${date}] session | ${slug}` + (title ? `: ${title}` : '');
|
|
2912
2915
|
return { heading, block: `${heading}\n→ [[projects/${slug}/hot]]` };
|
|
2913
2916
|
}
|
|
2914
2917
|
|
|
2918
|
+
/**
|
|
2919
|
+
* The form of a log.md line that rootLogEntry's exact-line dedup compares. Earlier
|
|
2920
|
+
* versions wrote an em dash after the slug; this one writes `## [d] session | slug: title`. A
|
|
2921
|
+
* log.md that already carries the old heading must count as having the entry, or
|
|
2922
|
+
* the next derive appends the same session a second time. Only the separator right
|
|
2923
|
+
* after the slug is rewritten, so every other line is returned as it came.
|
|
2924
|
+
*/
|
|
2925
|
+
export function rootLogHeadingKey(line) {
|
|
2926
|
+
return String(line).replace(/^(## \[[^\]]*\] session \| [^\s:]+) — /, '$1: ');
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2915
2929
|
/**
|
|
2916
2930
|
* Append any missing root log.md `## [date] session | <slug>` entries derived from
|
|
2917
2931
|
* each today-active project's session-log heading(s). Idempotent: dedups on the
|
|
@@ -3194,9 +3208,10 @@ export function deriveRootLogEntries(hypoDir) {
|
|
|
3194
3208
|
}
|
|
3195
3209
|
|
|
3196
3210
|
// Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
|
|
3197
|
-
// prefix of a titled one (`## [d] session | a
|
|
3198
|
-
// would wrongly drop a distinct same-day session. Track whole heading lines
|
|
3199
|
-
|
|
3211
|
+
// prefix of a titled one (`## [d] session | a: first`), so substring checks
|
|
3212
|
+
// would wrongly drop a distinct same-day session. Track whole heading lines,
|
|
3213
|
+
// with an old em dash heading read as its colon form.
|
|
3214
|
+
const seenHeadings = new Set((logContent || '').split(/\r?\n/).map(rootLogHeadingKey));
|
|
3200
3215
|
const additions = [];
|
|
3201
3216
|
for (const slug of todayActive) {
|
|
3202
3217
|
// Guard: only the log.md entry may be the gap; an otherwise-incomplete close
|
|
@@ -3256,7 +3271,7 @@ export function deriveRootLogEntries(hypoDir) {
|
|
|
3256
3271
|
} catch {
|
|
3257
3272
|
return 0;
|
|
3258
3273
|
}
|
|
3259
|
-
const seen = new Set((current || '').split(/\r?\n/));
|
|
3274
|
+
const seen = new Set((current || '').split(/\r?\n/).map(rootLogHeadingKey));
|
|
3260
3275
|
const fresh = additions.filter(({ heading }) => {
|
|
3261
3276
|
if (seen.has(heading)) return false;
|
|
3262
3277
|
seen.add(heading);
|
|
@@ -4280,7 +4295,7 @@ export function commitWikiChanges(hypoDir, paths) {
|
|
|
4280
4295
|
const prefixRes = git('rev-parse', '--show-prefix');
|
|
4281
4296
|
if (prefixRes.status !== 0)
|
|
4282
4297
|
return { committed: false, reason: `git rev-parse --show-prefix failed in ${hypoDir}` };
|
|
4283
|
-
const prefix = (prefixRes.stdout || '').replace(/\n$/, '');
|
|
4298
|
+
const prefix = (prefixRes.stdout || '').replace(/\r?\n$/, '');
|
|
4284
4299
|
const toVaultRelative = (f) => {
|
|
4285
4300
|
if (!f || !prefix) return f || null;
|
|
4286
4301
|
return f.startsWith(prefix) ? f.slice(prefix.length) : null;
|
|
@@ -6125,9 +6140,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
6125
6140
|
);
|
|
6126
6141
|
if (r.error || r.status === null) {
|
|
6127
6142
|
skipped.feedback = true; // spawn failure → fail-open
|
|
6128
|
-
} else
|
|
6129
|
-
//
|
|
6130
|
-
//
|
|
6143
|
+
} else {
|
|
6144
|
+
// A non-zero exit carries an actionable issue. A clean check exits 0 and is NOT
|
|
6145
|
+
// skipped, just nothing to do, except that its report can still carry
|
|
6146
|
+
// sideWarnings (a hand-written feedback_<slug>.md in the way): those exit 0, so
|
|
6147
|
+
// the report is read for them too.
|
|
6131
6148
|
let report = null;
|
|
6132
6149
|
try {
|
|
6133
6150
|
report = JSON.parse(r.stdout || '');
|
|
@@ -6138,7 +6155,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
6138
6155
|
const conflictedT = entries
|
|
6139
6156
|
.filter(
|
|
6140
6157
|
([, t]) =>
|
|
6141
|
-
t.intruder ||
|
|
6158
|
+
t.intruder ||
|
|
6159
|
+
t.unpaired ||
|
|
6160
|
+
t.outOfContainer ||
|
|
6161
|
+
(t.conflicts && t.conflicts.length) ||
|
|
6162
|
+
(t.sideEdited && t.sideEdited.length),
|
|
6142
6163
|
)
|
|
6143
6164
|
.map(([n]) => n);
|
|
6144
6165
|
const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
|
|
@@ -6165,14 +6186,15 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
6165
6186
|
// gets bypassed.
|
|
6166
6187
|
const sideWarnT = entries.filter(([, t]) => (t.sideWarnings || []).length);
|
|
6167
6188
|
if (
|
|
6168
|
-
|
|
6169
|
-
!
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6189
|
+
r.status !== 0 &&
|
|
6190
|
+
(!report ||
|
|
6191
|
+
!(
|
|
6192
|
+
conflictedT.length ||
|
|
6193
|
+
overCapT.length ||
|
|
6194
|
+
driftedT.length ||
|
|
6195
|
+
buildErrT.length ||
|
|
6196
|
+
sideWarnT.length
|
|
6197
|
+
))
|
|
6176
6198
|
) {
|
|
6177
6199
|
skipped.feedback = true; // unparseable / non-actionable → fail-open
|
|
6178
6200
|
} else if (buildErrT.length) {
|
|
@@ -6193,9 +6215,30 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
6193
6215
|
reason: `feedback projection cannot be built — ${details} — no rules are loaded from it. ${remedies}`,
|
|
6194
6216
|
});
|
|
6195
6217
|
} else if (conflictedT.length) {
|
|
6218
|
+
// The remedy text comes from the report (feedback-sync names the real
|
|
6219
|
+
// slugs and leaves out --accept-wiki for an unpaired file). A report from an
|
|
6220
|
+
// older installed script has none, and this hook cannot tell from the report
|
|
6221
|
+
// which of its shapes is the right way out (the import command it used to
|
|
6222
|
+
// fall back to does nothing for an intruder-only target), so that target gets
|
|
6223
|
+
// a generic pointer and no state-specific command.
|
|
6224
|
+
const remedies = [
|
|
6225
|
+
...new Set(
|
|
6226
|
+
entries
|
|
6227
|
+
.filter(([n]) => conflictedT.includes(n))
|
|
6228
|
+
.map(([, t]) =>
|
|
6229
|
+
typeof t.conflictRemedy === 'string' && t.conflictRemedy
|
|
6230
|
+
? t.conflictRemedy
|
|
6231
|
+
: 'the installed feedback-sync sent no remedy for this target (its version differs from this hook): run `hypomnema feedback-sync --check` for the details, and update Hypomnema so the hook and the script match.',
|
|
6232
|
+
),
|
|
6233
|
+
),
|
|
6234
|
+
].join(' ');
|
|
6196
6235
|
blockers.push({
|
|
6197
6236
|
type: 'feedback',
|
|
6198
|
-
|
|
6237
|
+
// reasons are rendered with an appended period and joined with ', ', so
|
|
6238
|
+
// the full-sentence remedy loses its own final period here (CLI and doctor keep it)
|
|
6239
|
+
reason:
|
|
6240
|
+
`feedback projection conflict (manual edit of ${conflictedT.join(', ')}): ` +
|
|
6241
|
+
remedies.replace(/\.$/, ''),
|
|
6199
6242
|
});
|
|
6200
6243
|
} else if (overCapT.length) {
|
|
6201
6244
|
blockers.push({
|
|
@@ -6209,14 +6252,26 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
6209
6252
|
reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
|
|
6210
6253
|
});
|
|
6211
6254
|
}
|
|
6212
|
-
// Additive, and deliberately outside the chain above: a side-file
|
|
6213
|
-
//
|
|
6214
|
-
//
|
|
6215
|
-
//
|
|
6255
|
+
// Additive, and deliberately outside the chain above: a side-file problem
|
|
6256
|
+
// is orthogonal to the primary target's health, so it is a notice whatever
|
|
6257
|
+
// else is (or is not) going on. Two kinds reach here. "cannot read side
|
|
6258
|
+
// file" is an I/O error whose only way out is a permission bit on that
|
|
6259
|
+
// path (`--ensure-container` cannot touch it), so only that kind carries
|
|
6260
|
+
// the permissions advice. "not overwriting ..." is feedback-sync declining
|
|
6261
|
+
// to replace a hand-written file that lacks its provenance header; the
|
|
6262
|
+
// warning text already says to rename or delete it, and calling it
|
|
6263
|
+
// unreadable or pointing at permissions would be wrong. The reason has no
|
|
6264
|
+
// trailing period: the hook that renders it appends one.
|
|
6216
6265
|
for (const [n, t] of sideWarnT) {
|
|
6266
|
+
const unreadable = t.sideWarnings.some((w) => w.startsWith('cannot read side file'));
|
|
6217
6267
|
notices.push({
|
|
6218
6268
|
type: 'feedback',
|
|
6219
|
-
reason:
|
|
6269
|
+
reason:
|
|
6270
|
+
`feedback projection side file warning (${n}): ${t.sideWarnings.join('; ')}. ` +
|
|
6271
|
+
'The primary projection still loads every rule' +
|
|
6272
|
+
(unreadable
|
|
6273
|
+
? '. Fix the permissions on that path; --ensure-container does not fix this'
|
|
6274
|
+
: ''),
|
|
6220
6275
|
});
|
|
6221
6276
|
}
|
|
6222
6277
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"scripts/upgrade.mjs",
|
|
28
28
|
"scripts/verify.mjs",
|
|
29
29
|
"scripts/weekly-report.mjs",
|
|
30
|
+
"scripts/lib/code-fence.mjs",
|
|
30
31
|
"scripts/lib/core-hooks.mjs",
|
|
31
32
|
"scripts/lib/crystallize-args.mjs",
|
|
32
33
|
"scripts/lib/crystallize-close-apply.mjs",
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* --check-session-close Verify the strict session-close memory files: 4 mandatory + open-questions conditional
|
|
16
16
|
* --project=<slug> Override the recency-inferred project on --check / --mark (single segment
|
|
17
17
|
* [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
|
|
18
|
-
* gate to that one project
|
|
19
|
-
*
|
|
20
|
-
* global (the marker
|
|
18
|
+
* gate to that one project: a project-scoped diagnostic, NOT a global
|
|
19
|
+
* close verdict. On --mark it is ATTRIBUTION only; the gate stays
|
|
20
|
+
* global (the marker gate is the global gate with the git axis narrowed). Ignored on --apply.
|
|
21
21
|
* --apply-session-close Apply a JSON payload that updates the 4 mandatory memory files
|
|
22
22
|
* (+ optional open-questions). Idempotent — re-running with the same
|
|
23
23
|
* payload is a no-op. Always finishes with the strict gate check.
|
package/scripts/doctor.mjs
CHANGED
|
@@ -2170,7 +2170,12 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
|
|
|
2170
2170
|
|
|
2171
2171
|
// 1) integrity violations → FAIL
|
|
2172
2172
|
const broken = targets.find(
|
|
2173
|
-
([, t]) =>
|
|
2173
|
+
([, t]) =>
|
|
2174
|
+
(t.conflicts && t.conflicts.length) ||
|
|
2175
|
+
t.unpaired ||
|
|
2176
|
+
t.intruder ||
|
|
2177
|
+
t.outOfContainer ||
|
|
2178
|
+
(t.sideEdited && t.sideEdited.length),
|
|
2174
2179
|
);
|
|
2175
2180
|
if (broken) {
|
|
2176
2181
|
const [name, t] = broken;
|
|
@@ -2181,10 +2186,19 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
|
|
|
2181
2186
|
? 'unpaired managed marker'
|
|
2182
2187
|
: t.intruder
|
|
2183
2188
|
? 'hand-edited line inside managed region'
|
|
2184
|
-
:
|
|
2189
|
+
: t.outOfContainer
|
|
2190
|
+
? 'managed block outside its container'
|
|
2191
|
+
: `side file edited after it was generated (${t.sideEdited.join(', ')})`;
|
|
2185
2192
|
fail(
|
|
2186
2193
|
'Feedback projection integrity',
|
|
2187
|
-
|
|
2194
|
+
// feedback-sync decides the remedy by shape (import and accept for a
|
|
2195
|
+
// conflicting block, move the lines for an intruder, repair for unpaired
|
|
2196
|
+
// markers). An older installed script that does not send one gets a generic
|
|
2197
|
+
// pointer: the import command it used to get is only right when a block is in
|
|
2198
|
+
// conflict.
|
|
2199
|
+
`${name}: ${reason}. ` +
|
|
2200
|
+
(t.conflictRemedy ||
|
|
2201
|
+
'The installed feedback-sync sent no remedy for this target (its version differs from this doctor): run `hypomnema feedback-sync --check` for the details, and update Hypomnema so the script matches.'),
|
|
2188
2202
|
);
|
|
2189
2203
|
} else {
|
|
2190
2204
|
// 2) build error. Split by kind:
|
|
@@ -2245,10 +2259,14 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
|
|
|
2245
2259
|
// touch. feedback-sync already put the path in the message.
|
|
2246
2260
|
for (const [name, t] of targets) {
|
|
2247
2261
|
for (const w of t.sideWarnings || []) {
|
|
2262
|
+
// The advice follows the kind, as in the PreCompact gate: only an unreadable file
|
|
2263
|
+
// is a permission problem; "not overwriting ..." already says to rename or delete.
|
|
2248
2264
|
warn(
|
|
2249
2265
|
'Feedback projection side file',
|
|
2250
|
-
`${name}: ${w}
|
|
2251
|
-
|
|
2266
|
+
`${name}: ${w}. ` +
|
|
2267
|
+
(w.startsWith('cannot read side file')
|
|
2268
|
+
? 'fix the permissions on that path; the primary projection still loads (`--ensure-container` does not fix this)'
|
|
2269
|
+
: 'the primary projection still loads every rule'),
|
|
2252
2270
|
);
|
|
2253
2271
|
}
|
|
2254
2272
|
}
|