hypomnema 1.7.2 → 1.7.4
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 +3 -3
- package/README.md +3 -3
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +7 -7
- package/commands/uninstall.md +16 -4
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +13 -4
- package/hooks/close-gate-store.mjs +435 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +434 -192
- package/package.json +2 -1
- package/scripts/capture.mjs +26 -20
- package/scripts/crystallize.mjs +153 -20
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +34 -18
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +89 -6
- package/scripts/lib/git-hooks-dir.mjs +139 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +72 -24
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +351 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.4",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts/lib/project-create.mjs",
|
|
44
44
|
"scripts/lib/rename-marker.mjs",
|
|
45
45
|
"scripts/lib/schema-vocab.mjs",
|
|
46
|
+
"scripts/lib/slug-resolver.mjs",
|
|
46
47
|
"scripts/lib/template-schema-version.mjs",
|
|
47
48
|
"scripts/lib/wd-match.mjs",
|
|
48
49
|
"scripts/lib/wikilink.mjs",
|
package/scripts/capture.mjs
CHANGED
|
@@ -42,9 +42,11 @@ import {
|
|
|
42
42
|
unlinkSync,
|
|
43
43
|
renameSync,
|
|
44
44
|
realpathSync,
|
|
45
|
-
lstatSync,
|
|
46
45
|
openSync,
|
|
47
46
|
closeSync,
|
|
47
|
+
statSync,
|
|
48
|
+
fstatSync,
|
|
49
|
+
fchmodSync,
|
|
48
50
|
} from 'fs';
|
|
49
51
|
import { randomBytes } from 'crypto';
|
|
50
52
|
import { join, dirname, relative, sep } from 'path';
|
|
@@ -76,6 +78,7 @@ import {
|
|
|
76
78
|
HOOK_EVENT_ALLOWLIST,
|
|
77
79
|
SKILL_ROOT_FILE,
|
|
78
80
|
EXT_PREFIX,
|
|
81
|
+
withSrcExecBits,
|
|
79
82
|
} from './lib/extensions.mjs';
|
|
80
83
|
import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
|
|
81
84
|
|
|
@@ -582,11 +585,29 @@ function log(msg) {
|
|
|
582
585
|
// `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
|
|
583
586
|
// already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
|
|
584
587
|
// on an existing path of any kind, symlink included.
|
|
585
|
-
|
|
588
|
+
// `srcMode`, when given, carries the source file's execute bit onto the wiki
|
|
589
|
+
// copy (openSync's own mode argument is clipped by umask same as writeFileSync,
|
|
590
|
+
// so this still has to be a separate chmod). Omitted for a manifest write: that
|
|
591
|
+
// content is JSON we generated, not a copy of something with a mode worth
|
|
592
|
+
// keeping.
|
|
593
|
+
//
|
|
594
|
+
// The mode is set on the open FD (`fchmodSync`), before the FD is closed, the
|
|
595
|
+
// same ordering the forward writer (extensions.mjs's writeFreshAtomic) already
|
|
596
|
+
// uses. A pathname-based `chmodSync(tmp, ...)` run after `closeSync` reopens
|
|
597
|
+
// `tmp` by name, and a competing process racing this write could have already
|
|
598
|
+
// deleted `tmp` and planted a symlink at that name in the gap between close
|
|
599
|
+
// and chmod — `wx` only protects the file's creation, not everything after it.
|
|
600
|
+
// Chmod-ing the FD closes that window: it always addresses the file this
|
|
601
|
+
// process itself just created, never whatever a symlink at the same name
|
|
602
|
+
// might point to by the time the pathname is looked up again.
|
|
603
|
+
function writeAtomic(dest, buf, srcMode) {
|
|
586
604
|
const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
587
605
|
const fd = openSync(tmp, 'wx');
|
|
588
606
|
try {
|
|
589
607
|
writeFileSync(fd, buf);
|
|
608
|
+
if (srcMode != null) {
|
|
609
|
+
fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
|
|
610
|
+
}
|
|
590
611
|
} finally {
|
|
591
612
|
closeSync(fd);
|
|
592
613
|
}
|
|
@@ -719,7 +740,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
|
|
|
719
740
|
madeDirs.add(cur);
|
|
720
741
|
}
|
|
721
742
|
}
|
|
722
|
-
|
|
743
|
+
const buf = readFileSync(f.srcPath);
|
|
744
|
+
writeAtomic(destPath, buf, statSync(f.srcPath).mode);
|
|
723
745
|
rec.createdFiles.push(destPath);
|
|
724
746
|
}
|
|
725
747
|
}
|
|
@@ -814,22 +836,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
|
|
|
814
836
|
return;
|
|
815
837
|
}
|
|
816
838
|
|
|
817
|
-
// Content round-trips; the executable bit does not (forward-sync writes with the
|
|
818
|
-
// default mode). Say so rather than let a captured `scripts/run.sh` arrive
|
|
819
|
-
// non-executable on the far machine without a word.
|
|
820
|
-
const execFiles = c.files.filter((f) => {
|
|
821
|
-
try {
|
|
822
|
-
return (lstatSync(f.srcPath).mode & 0o111) !== 0;
|
|
823
|
-
} catch {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
});
|
|
827
|
-
if (execFiles.length > 0) {
|
|
828
|
-
log(
|
|
829
|
-
`! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
839
|
if (!args.dryRun) {
|
|
834
840
|
// The ledger is owned by the caller so a throw MID-write is still recoverable: the
|
|
835
841
|
// paths created before the failure are already recorded in it.
|
|
@@ -1055,7 +1061,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
1055
1061
|
manifestPrevBuf: existingManifestBuf,
|
|
1056
1062
|
};
|
|
1057
1063
|
writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
|
|
1058
|
-
writeAtomic(filePath, srcBuf);
|
|
1064
|
+
writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
|
|
1059
1065
|
created.push(rec);
|
|
1060
1066
|
}
|
|
1061
1067
|
captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -102,16 +102,18 @@ import {
|
|
|
102
102
|
hasSessionLogHeading,
|
|
103
103
|
hasLogEntry,
|
|
104
104
|
resolveTranscriptBySessionId,
|
|
105
|
-
|
|
105
|
+
isCloseGateOpen,
|
|
106
106
|
commitWikiChanges,
|
|
107
107
|
vaultCommitLockTarget,
|
|
108
108
|
currentDevice,
|
|
109
109
|
scopeVisible,
|
|
110
110
|
readVisibilityScope,
|
|
111
111
|
withFileLock,
|
|
112
|
+
extractTouchedWikiFilesWithTrust,
|
|
112
113
|
} from '../hooks/hypo-shared.mjs';
|
|
113
114
|
import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
|
|
114
115
|
import { writeProposal } from '../hooks/proposal-store.mjs';
|
|
116
|
+
import { recordGateClosed, resolutionStamp, closeGateStatus } from '../hooks/close-gate-store.mjs';
|
|
115
117
|
|
|
116
118
|
// This script's own absolute path. Used to print copy-pasteable recovery
|
|
117
119
|
// commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
|
|
@@ -225,6 +227,35 @@ function requireProjectDir(args, slug) {
|
|
|
225
227
|
}
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
// When the global gate's own discovery (hot.md pointer table + today
|
|
231
|
+
// close-activity scan, both in hypo-shared.mjs) comes back with NO project at
|
|
232
|
+
// all, a real apply never hits that dead end: it is handed `payload.project`
|
|
233
|
+
// directly and never infers. --check-session-close has no payload, so its one
|
|
234
|
+
// remaining authoritative signal is the same session's own transcript — which
|
|
235
|
+
// project's files did THIS session actually touch. Reusing the exact
|
|
236
|
+
// evidence-resolution helper the widened-lint-scope path already trusts here
|
|
237
|
+
// keeps this a single inference vocabulary (touched wiki files), not a second
|
|
238
|
+
// one: the difference is only which project-shaped question gets asked of it.
|
|
239
|
+
// Never guessed: a transcript touching zero or more than one project's files
|
|
240
|
+
// leaves the check exactly as unresolved as it was before this fallback.
|
|
241
|
+
function deriveTouchedProject(hypoDir, transcriptPath) {
|
|
242
|
+
if (!transcriptPath) return null;
|
|
243
|
+
const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
|
|
244
|
+
// `trusted:false` means the walk itself may be incomplete (a missing/unreadable
|
|
245
|
+
// transcript, or a line that failed to parse). A truncated line could have named
|
|
246
|
+
// a SECOND project the walk never saw, so treating this Set as "the whole
|
|
247
|
+
// truth" would resolve a single-project reading off a scope that is only
|
|
248
|
+
// single-project because part of it is missing, exactly the ambiguity this
|
|
249
|
+
// fallback exists to refuse rather than guess through.
|
|
250
|
+
if (!trusted) return null;
|
|
251
|
+
const slugs = new Set();
|
|
252
|
+
for (const f of files) {
|
|
253
|
+
const m = /^projects\/([^/]+)\//.exec(f);
|
|
254
|
+
if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
|
|
255
|
+
}
|
|
256
|
+
return slugs.size === 1 ? [...slugs][0] : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
228
259
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
229
260
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
230
261
|
// flow can self-verify before /compact triggers PreCompact.
|
|
@@ -261,7 +292,7 @@ function runSessionCloseCheck(args) {
|
|
|
261
292
|
args.transcriptPath ||
|
|
262
293
|
(args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
|
|
263
294
|
null;
|
|
264
|
-
|
|
295
|
+
let status = precompactGateStatus(args.hypoDir, {
|
|
265
296
|
...(args.project
|
|
266
297
|
? { projectOverride: args.project }
|
|
267
298
|
: checkTranscript
|
|
@@ -276,7 +307,30 @@ function runSessionCloseCheck(args) {
|
|
|
276
307
|
// enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
|
|
277
308
|
...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
|
|
278
309
|
});
|
|
310
|
+
|
|
311
|
+
// check/apply divergence (2026-08-25 QA): a real apply never hits discovery
|
|
312
|
+
// dead-ends because payload.project is required input, not an inference. This
|
|
313
|
+
// check has no payload, so when discovery finds NO project at all (not even
|
|
314
|
+
// the recency fallback), it retries scoped to whatever single project this
|
|
315
|
+
// session's own transcript shows it touching. This is a diagnostic estimate,
|
|
316
|
+
// not a preview of what a real apply will do: a payload's `project` field is
|
|
317
|
+
// whatever the caller puts there and can legitimately name a project the
|
|
318
|
+
// transcript never mentions. Only fires on a fully unresolved global result,
|
|
319
|
+
// and only on a TRUSTED single-project reading (see deriveTouchedProject): an
|
|
320
|
+
// already-successful discovery, an ambiguous/empty transcript, or one the walk
|
|
321
|
+
// could not fully read is left untouched rather than guessed at.
|
|
322
|
+
let inferredProject = null;
|
|
323
|
+
if (!args.project && !status.close.project) {
|
|
324
|
+
inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
|
|
325
|
+
if (inferredProject) {
|
|
326
|
+
status = precompactGateStatus(args.hypoDir, {
|
|
327
|
+
projectOverride: inferredProject,
|
|
328
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
279
332
|
const close = status.close;
|
|
333
|
+
const scopedProject = args.project || inferredProject;
|
|
280
334
|
|
|
281
335
|
// When a --session-id is supplied, report whether THIS session's
|
|
282
336
|
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
@@ -301,8 +355,8 @@ function runSessionCloseCheck(args) {
|
|
|
301
355
|
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
302
356
|
// --project override is IGNORED — surface that rather than implying X was
|
|
303
357
|
// checked (it was not).
|
|
304
|
-
const logOnlyWon =
|
|
305
|
-
const scope =
|
|
358
|
+
const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
|
|
359
|
+
const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
306
360
|
|
|
307
361
|
if (args.json) {
|
|
308
362
|
console.log(
|
|
@@ -325,9 +379,13 @@ function runSessionCloseCheck(args) {
|
|
|
325
379
|
skipped: status.skipped,
|
|
326
380
|
// scope is additive; `global` keeps prior semantics for existing readers
|
|
327
381
|
scope,
|
|
328
|
-
...(
|
|
382
|
+
...(scopedProject
|
|
329
383
|
? {
|
|
330
|
-
scoped_project:
|
|
384
|
+
scoped_project: scopedProject,
|
|
385
|
+
// Distinguishes a user-typed --project from this check picking one
|
|
386
|
+
// for itself off the transcript — a reader should not mistake the
|
|
387
|
+
// latter for an explicit ask (see deriveTouchedProject above).
|
|
388
|
+
...(inferredProject ? { project_inferred_from_transcript: true } : {}),
|
|
331
389
|
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
332
390
|
}
|
|
333
391
|
: {}),
|
|
@@ -340,13 +398,20 @@ function runSessionCloseCheck(args) {
|
|
|
340
398
|
process.exit(status.ok ? 0 : 1);
|
|
341
399
|
}
|
|
342
400
|
|
|
401
|
+
// Label the scoped project by how it was chosen — an explicit --project reads
|
|
402
|
+
// as a flag the caller typed; an inferred one reads as this check's own guess
|
|
403
|
+
// off the transcript, so a reader does not credit the caller with an ask
|
|
404
|
+
// nobody made.
|
|
405
|
+
const scopedProjectLabel = args.project
|
|
406
|
+
? `--project=${args.project}`
|
|
407
|
+
: `project=${scopedProject} (inferred from the session transcript, no --project given)`;
|
|
343
408
|
if (logOnlyWon) {
|
|
344
409
|
console.log(
|
|
345
|
-
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and
|
|
410
|
+
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
|
|
346
411
|
);
|
|
347
412
|
} else if (scope === 'project') {
|
|
348
413
|
console.log(
|
|
349
|
-
`Note:
|
|
414
|
+
`Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
|
|
350
415
|
);
|
|
351
416
|
}
|
|
352
417
|
|
|
@@ -398,8 +463,8 @@ function runSessionCloseCheck(args) {
|
|
|
398
463
|
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
399
464
|
console.log(
|
|
400
465
|
status.ok
|
|
401
|
-
? `✓ ${
|
|
402
|
-
: `✗ ${
|
|
466
|
+
? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
467
|
+
: `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
|
|
403
468
|
);
|
|
404
469
|
} else {
|
|
405
470
|
console.log(
|
|
@@ -686,7 +751,7 @@ function runMarkSessionClosed(args) {
|
|
|
686
751
|
// /compact, or an AskUserQuestion close answer). This is the hard backstop for
|
|
687
752
|
// model over-close, where prose guidance lost to a conflicting global rule.
|
|
688
753
|
// Fail-closed when the transcript can't be resolved.
|
|
689
|
-
if (!closeTranscript || !
|
|
754
|
+
if (!closeTranscript || !isCloseGateOpen(closeTranscript)) {
|
|
690
755
|
const reason = !closeTranscript
|
|
691
756
|
? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
|
|
692
757
|
: "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
|
|
@@ -874,7 +939,7 @@ const CLOSE_REFUSAL_HELP = [
|
|
|
874
939
|
* { ok: false, reason, error } reason: session-id-required | transcript-unresolved
|
|
875
940
|
* | no-user-close-signal
|
|
876
941
|
*/
|
|
877
|
-
function verifyCloseAuthority(sessionId) {
|
|
942
|
+
function verifyCloseAuthority(sessionId, hypoDir) {
|
|
878
943
|
if (!sessionId) {
|
|
879
944
|
return {
|
|
880
945
|
ok: false,
|
|
@@ -897,13 +962,15 @@ function verifyCloseAuthority(sessionId) {
|
|
|
897
962
|
`authority here.`,
|
|
898
963
|
};
|
|
899
964
|
}
|
|
900
|
-
|
|
965
|
+
const gateStatus = closeGateStatus({ transcriptPath: transcript, hypoDir, sessionId });
|
|
966
|
+
if (!gateStatus.ok) {
|
|
901
967
|
return {
|
|
902
968
|
ok: false,
|
|
903
969
|
reason: 'no-user-close-signal',
|
|
904
970
|
error:
|
|
905
971
|
"session-close apply refused before any wiki write or commit: this session's transcript " +
|
|
906
|
-
'carries no user close signal. The user did not ask to close.'
|
|
972
|
+
'carries no user close signal. The user did not ask to close. ' +
|
|
973
|
+
`Gate detail: ${gateStatus.reason}`,
|
|
907
974
|
};
|
|
908
975
|
}
|
|
909
976
|
return { ok: true };
|
|
@@ -1036,14 +1103,19 @@ function applySessionClose(args) {
|
|
|
1036
1103
|
// Only a payload-bearing call can write. A payload-less one falls through to the
|
|
1037
1104
|
// "payload is required" error below without touching a byte, so gating it here
|
|
1038
1105
|
// would just replace one refusal with a less accurate one.
|
|
1039
|
-
const closeAuth = args.payload
|
|
1106
|
+
const closeAuth = args.payload
|
|
1107
|
+
? verifyCloseAuthority(args.sessionId, args.hypoDir)
|
|
1108
|
+
: { ok: true };
|
|
1040
1109
|
if (!closeAuth.ok) {
|
|
1041
1110
|
const out = {
|
|
1042
1111
|
ok: false,
|
|
1043
1112
|
stage: 'no-user-close-signal',
|
|
1044
1113
|
reason: closeAuth.reason,
|
|
1045
1114
|
applied: [],
|
|
1046
|
-
|
|
1115
|
+
// `null`, not `false`: this refusal fires before the commit step is ever
|
|
1116
|
+
// reached (see the general result's own `committed` contract below).
|
|
1117
|
+
// `false` is reserved for a commit that actually ran and failed.
|
|
1118
|
+
committed: null,
|
|
1047
1119
|
error: closeAuth.error,
|
|
1048
1120
|
};
|
|
1049
1121
|
console.log(
|
|
@@ -1103,7 +1175,9 @@ function applySessionClose(args) {
|
|
|
1103
1175
|
stage: 'session-id-mismatch',
|
|
1104
1176
|
error: msg,
|
|
1105
1177
|
applied: [],
|
|
1106
|
-
|
|
1178
|
+
// `null`, not `false` — refused before the commit step, same contract as
|
|
1179
|
+
// the `no-user-close-signal` refusal above.
|
|
1180
|
+
committed: null,
|
|
1107
1181
|
};
|
|
1108
1182
|
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
|
|
1109
1183
|
process.exit(1);
|
|
@@ -1734,7 +1808,44 @@ function applySessionClose(args) {
|
|
|
1734
1808
|
// but silently.
|
|
1735
1809
|
let markerWritten = false;
|
|
1736
1810
|
let markerSkipReason = null;
|
|
1811
|
+
// Hoisted so the result JSON below can report it: `null` when this apply never
|
|
1812
|
+
// reached the commit step at all (ok:false before the writes were even
|
|
1813
|
+
// verified), distinct from a commit that ran and reported `committed:false`.
|
|
1814
|
+
let commitOutcome = null;
|
|
1737
1815
|
if (ok && args.sessionId) {
|
|
1816
|
+
// Close-gate resolution: apply succeeding (`ok`) IS the resolution, not
|
|
1817
|
+
// whether the per-session marker below happens to land. The marker can
|
|
1818
|
+
// be withheld for reasons that have nothing to do with whether this
|
|
1819
|
+
// apply's own writes were valid (a stale git tree, a feedback-projection
|
|
1820
|
+
// cap, W8 design-history staleness) — none of that should leave the
|
|
1821
|
+
// resolution unrecorded, because the wiki writes already happened, and
|
|
1822
|
+
// re-running the SAME apply with no fresh user close signal is exactly
|
|
1823
|
+
// what this record exists to block. So this sits OUTSIDE and ahead of
|
|
1824
|
+
// the marker's own commit-gated logic below, resolving its own
|
|
1825
|
+
// transcript rather than sharing the marker's `closeTranscript` (which
|
|
1826
|
+
// stays null whenever the commit fails) — a commit failure withholds
|
|
1827
|
+
// the marker but must not also withhold the resolution.
|
|
1828
|
+
//
|
|
1829
|
+
// Best-effort like every other write in this store: resolutionStamp
|
|
1830
|
+
// returns null on anything it cannot read as a Buffer, recordGateClosed
|
|
1831
|
+
// refuses a null stamp, and both fail silently, so a transcript that
|
|
1832
|
+
// vanishes mid-read (or a cache-write failure) can never turn an
|
|
1833
|
+
// otherwise-successful apply into a failure.
|
|
1834
|
+
try {
|
|
1835
|
+
const resolutionTranscriptPath = resolveTranscriptBySessionId(args.sessionId);
|
|
1836
|
+
if (resolutionTranscriptPath) {
|
|
1837
|
+
recordGateClosed(
|
|
1838
|
+
args.hypoDir,
|
|
1839
|
+
args.sessionId,
|
|
1840
|
+
resolutionStamp(readFileSync(resolutionTranscriptPath)),
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
} catch {
|
|
1844
|
+
// Unreadable at the moment of a successful close is not this apply's
|
|
1845
|
+
// problem to surface — the resolution just stays unrecorded, same as
|
|
1846
|
+
// if this session had never resolved at all (NO_CONSTRAINT).
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1738
1849
|
// IO stays lazy so this preserves the exact side-effect order (codex design
|
|
1739
1850
|
// review): commit first (the only mutation), then resolve the
|
|
1740
1851
|
// transcript, then run the compact gate with that transcript, then scan the
|
|
@@ -1749,7 +1860,6 @@ function applySessionClose(args) {
|
|
|
1749
1860
|
// apply's stage+commit. A lock-timeout is treated exactly like any other
|
|
1750
1861
|
// commit failure below (skip the marker, surface the reason) rather than
|
|
1751
1862
|
// crashing the apply.
|
|
1752
|
-
let commitOutcome;
|
|
1753
1863
|
try {
|
|
1754
1864
|
commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
|
|
1755
1865
|
commitWikiChanges(args.hypoDir, appliedPaths),
|
|
@@ -1779,8 +1889,18 @@ function applySessionClose(args) {
|
|
|
1779
1889
|
gateOk,
|
|
1780
1890
|
transcriptResolved: !!closeTranscript,
|
|
1781
1891
|
// Scan the signal only when the gate passed AND a transcript resolved —
|
|
1782
|
-
//
|
|
1783
|
-
|
|
1892
|
+
// isCloseGateOpen never runs earlier than the original nested `else if`.
|
|
1893
|
+
// Reads the raw walkCloseGate open, not closeGateStatus: this apply's
|
|
1894
|
+
// OWN recordGateClosed call above already ran with this transcript's
|
|
1895
|
+
// full record count as closedAtIndex, and openedAtIndex can never reach
|
|
1896
|
+
// or pass a count taken from the very same transcript (see
|
|
1897
|
+
// closeGateStatus's doc comment) — so gating this diagnostic on .ok
|
|
1898
|
+
// would read false on every apply, unconditionally, not just a stale
|
|
1899
|
+
// one. This field asks a narrower question than closeGateStatus
|
|
1900
|
+
// answers: "did the transcript carry a close signal", not "is this
|
|
1901
|
+
// apply itself still authorized" (verifyCloseAuthority already settled
|
|
1902
|
+
// that, before any byte was written).
|
|
1903
|
+
hasUserSignal: gateOk && !!closeTranscript && isCloseGateOpen(closeTranscript),
|
|
1784
1904
|
});
|
|
1785
1905
|
markerSkipReason = decision.skipReason;
|
|
1786
1906
|
if (decision.write) {
|
|
@@ -1843,6 +1963,19 @@ function applySessionClose(args) {
|
|
|
1843
1963
|
date,
|
|
1844
1964
|
applied,
|
|
1845
1965
|
skipped,
|
|
1966
|
+
// Was the general-shape sibling of the two early-refusal `committed:null`
|
|
1967
|
+
// fields (no-user-close-signal / session-id-mismatch), which this path never
|
|
1968
|
+
// carried before: a reader of `applied:[]` on a no-op re-run had no
|
|
1969
|
+
// `committed` value to check against and no way to tell it apart from a run
|
|
1970
|
+
// that never reached the commit step. `null` here means exactly that: `ok`
|
|
1971
|
+
// came back false before the commit ever ran (see `stage` for which check
|
|
1972
|
+
// failed: post-apply-verification, post-apply-lint, or proposal-pending). It
|
|
1973
|
+
// does NOT mean nothing was written — an overwrite/append can already be on
|
|
1974
|
+
// disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
|
|
1975
|
+
// `true` covers both an actual commit and the legitimate "nothing to stage"
|
|
1976
|
+
// no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
|
|
1977
|
+
// is a real commit failure, surfaced together with markerSkipReason below.
|
|
1978
|
+
committed: commitOutcome ? commitOutcome.committed : null,
|
|
1846
1979
|
// Targets withheld: an overwrite drifted from this session's observed base, or
|
|
1847
1980
|
// an append could not take the file lock in time (`kind: 'append'`). Two
|
|
1848
1981
|
// channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
|
package/scripts/doctor.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'url';
|
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
21
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
22
|
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
|
-
import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
|
|
23
|
+
import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
|
|
24
24
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
25
25
|
import {
|
|
26
26
|
readSyncState,
|
|
@@ -506,7 +506,7 @@ function checkGit(hypoDir) {
|
|
|
506
506
|
? 'Not installed — run /hypo:init to install .hypoignore guard'
|
|
507
507
|
: 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
|
|
508
508
|
);
|
|
509
|
-
} else if (content.includes(
|
|
509
|
+
} else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
|
|
510
510
|
pass(label, 'Hypomnema .hypoignore guard installed');
|
|
511
511
|
} else {
|
|
512
512
|
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|
package/scripts/init.mjs
CHANGED
|
@@ -38,7 +38,16 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
38
38
|
import { fileURLToPath } from 'url';
|
|
39
39
|
import { createHash } from 'crypto';
|
|
40
40
|
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
hooksDirForInstall,
|
|
43
|
+
unsafeHookTargetReason,
|
|
44
|
+
findMarkerSpan,
|
|
45
|
+
WIKI_PRE_COMMIT_MARKER_START,
|
|
46
|
+
WIKI_PRE_COMMIT_MARKER_END,
|
|
47
|
+
SHELL_MARKER_START,
|
|
48
|
+
SHELL_MARKER_END,
|
|
49
|
+
SHELL_FUNCTION_BODY,
|
|
50
|
+
} from './lib/git-hooks-dir.mjs';
|
|
42
51
|
import { readCoreHooksConfig } from './lib/core-hooks.mjs';
|
|
43
52
|
import {
|
|
44
53
|
readPkgJson as readPkgJsonSafe,
|
|
@@ -748,9 +757,6 @@ function installPkgGitHook(dryRun) {
|
|
|
748
757
|
|
|
749
758
|
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
750
759
|
|
|
751
|
-
const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
752
|
-
const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
753
|
-
|
|
754
760
|
// Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
|
|
755
761
|
function shellSingleQuote(p) {
|
|
756
762
|
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
@@ -859,16 +865,13 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
859
865
|
|
|
860
866
|
// ── shell function setup ─────────────────────────────────────────────────────
|
|
861
867
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
868
|
+
// Built FROM SHELL_FUNCTION_BODY (./lib/git-hooks-dir.mjs), not a second copy
|
|
869
|
+
// of the same literal: uninstall's isOwnedShellFunctionBody() compares an
|
|
870
|
+
// existing marker span's body against that same constant byte-for-byte, so
|
|
871
|
+
// this and that check can never drift apart the way two independent string
|
|
872
|
+
// literals could.
|
|
865
873
|
function shellFunctionBlock() {
|
|
866
|
-
return `${SHELL_MARKER_START}
|
|
867
|
-
function claude() {
|
|
868
|
-
echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
|
|
869
|
-
command claude "$@"
|
|
870
|
-
}
|
|
871
|
-
${SHELL_MARKER_END}`;
|
|
874
|
+
return `${SHELL_MARKER_START}${SHELL_FUNCTION_BODY}${SHELL_MARKER_END}`;
|
|
872
875
|
}
|
|
873
876
|
|
|
874
877
|
function detectShellConfig(customPath) {
|
|
@@ -891,19 +894,32 @@ function installShellFunction(shellConfigPath, dryRun) {
|
|
|
891
894
|
}
|
|
892
895
|
|
|
893
896
|
const content = readFileSync(shellConfigPath, 'utf-8');
|
|
894
|
-
const startIdx = content.indexOf(SHELL_MARKER_START);
|
|
895
|
-
const endIdx = content.indexOf(SHELL_MARKER_END);
|
|
896
897
|
|
|
897
|
-
if (
|
|
898
|
+
if (content.includes(SHELL_MARKER_START) || content.includes(SHELL_MARKER_END)) {
|
|
899
|
+
// A block-shaped span is claimed here: validate it the same way uninstall
|
|
900
|
+
// does before touching a single byte. Two bare indexOf() calls cannot tell
|
|
901
|
+
// "well-formed" apart from "duplicated" (only the FIRST end is found, so a
|
|
902
|
+
// second full copy's body gets stranded in the untouched tail) or "swapped"
|
|
903
|
+
// (end before start silently duplicates whatever sits between them into the
|
|
904
|
+
// "replaced" span instead of raising anything). Neither corruption is
|
|
905
|
+
// something this script can safely repair, so a malformed span is left
|
|
906
|
+
// completely alone, the same contract uninstall.mjs holds for removal.
|
|
907
|
+
const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
|
|
908
|
+
if (!span.ok) {
|
|
909
|
+
log('skipped', `${shellConfigPath} (${span.reason}, leaving the file untouched)`);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
898
912
|
// Block exists — check if already up to date
|
|
899
|
-
const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
|
|
913
|
+
const existing = content.slice(span.startIdx, span.endIdx + SHELL_MARKER_END.length);
|
|
900
914
|
if (existing === block) {
|
|
901
915
|
log('skipped', `${shellConfigPath} (shell function up to date)`);
|
|
902
916
|
return;
|
|
903
917
|
}
|
|
904
918
|
// Replace stale block
|
|
905
919
|
const updated =
|
|
906
|
-
content.slice(0, startIdx) +
|
|
920
|
+
content.slice(0, span.startIdx) +
|
|
921
|
+
block +
|
|
922
|
+
content.slice(span.endIdx + SHELL_MARKER_END.length);
|
|
907
923
|
if (!dryRun) writeFileSync(shellConfigPath, updated);
|
|
908
924
|
log('merged', `${shellConfigPath} (shell function updated)`);
|
|
909
925
|
return;
|
|
@@ -69,9 +69,10 @@ function maxDate(dates) {
|
|
|
69
69
|
return dates.reduce((a, b) => (a > b ? a : b));
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// Returns
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// Returns findings: { project, kind, lastSession, lastDesignHistory, diffDays }.
|
|
73
|
+
// `kind` is 'stale' (the file exists but session-log has moved past it) or
|
|
74
|
+
// 'missing' (the file does not exist at all, yet session-log carries at least
|
|
75
|
+
// one design-relevant entry). Date source is body section headings
|
|
75
76
|
// (## YYYY-MM-DD), not frontmatter `updated:` — auto-stage hooks bump the
|
|
76
77
|
// frontmatter on unrelated edits, so it can't signal staleness on its own.
|
|
77
78
|
export function findDesignHistoryStale(hypoDir) {
|
|
@@ -81,17 +82,19 @@ export function findDesignHistoryStale(hypoDir) {
|
|
|
81
82
|
if (!existsSync(projectsDir)) return stale;
|
|
82
83
|
|
|
83
84
|
for (const name of readdirSync(projectsDir)) {
|
|
85
|
+
if (name.startsWith('_')) continue; // e.g. templates/projects/_template — not a real project
|
|
84
86
|
const projectDir = join(projectsDir, name);
|
|
85
87
|
if (!statSync(projectDir).isDirectory()) continue;
|
|
86
88
|
|
|
87
89
|
const dhPath = join(projectDir, 'design-history.md');
|
|
88
|
-
if (!existsSync(dhPath)) continue;
|
|
89
90
|
|
|
90
91
|
// session-log can live as a flat `session-log.md` (legacy) or a directory of
|
|
91
92
|
// daily shards `session-log/YYYY-MM-DD.md` (canonical; legacy
|
|
92
93
|
// monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
|
|
93
94
|
// `.md` in the directory, so daily and monthly shapes are both aggregated —
|
|
94
|
-
// the staleness check needs to see all of them.
|
|
95
|
+
// the staleness check needs to see all of them. Gathered before the
|
|
96
|
+
// existsSync(dhPath) branch below, since a project with zero design-history
|
|
97
|
+
// file still needs this to decide whether it has a design-relevant entry.
|
|
95
98
|
const sessionDates = [];
|
|
96
99
|
const flatSlPath = join(projectDir, 'session-log.md');
|
|
97
100
|
if (existsSync(flatSlPath)) {
|
|
@@ -107,16 +110,32 @@ export function findDesignHistoryStale(hypoDir) {
|
|
|
107
110
|
}
|
|
108
111
|
if (sessionDates.length === 0) continue;
|
|
109
112
|
|
|
113
|
+
if (!existsSync(dhPath)) {
|
|
114
|
+
// The file was never created, so there is nothing to compare dates
|
|
115
|
+
// against — but parseSessionDates already excluded pure "ADR 없음"
|
|
116
|
+
// entries, so a non-empty sessionDates here means at least one entry
|
|
117
|
+
// recorded (or implied) a design change with nowhere to land. This is a
|
|
118
|
+
// bootstrap gap, not a staleness gap: `lastDesignHistory`/`diffDays` stay
|
|
119
|
+
// null and callers must key off `kind` to avoid conflating the two.
|
|
120
|
+
stale.push({
|
|
121
|
+
project: name,
|
|
122
|
+
kind: 'missing',
|
|
123
|
+
lastSession: maxDate(sessionDates).toISOString().slice(0, 10),
|
|
124
|
+
lastDesignHistory: null,
|
|
125
|
+
diffDays: null,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
110
130
|
const dhText = readFileSync(dhPath, 'utf-8');
|
|
111
131
|
const lastSession = maxDate(sessionDates);
|
|
112
132
|
const lastDH = maxDate(parseDates(dhText, DESIGN_HISTORY_DATE_RE));
|
|
113
133
|
|
|
114
|
-
if (!lastSession) continue;
|
|
115
|
-
|
|
116
134
|
if (!lastDH || lastSession > lastDH) {
|
|
117
135
|
const diffDays = lastDH ? Math.round((lastSession - lastDH) / (1000 * 60 * 60 * 24)) : null;
|
|
118
136
|
stale.push({
|
|
119
137
|
project: name,
|
|
138
|
+
kind: 'stale',
|
|
120
139
|
lastSession: lastSession.toISOString().slice(0, 10),
|
|
121
140
|
lastDesignHistory: lastDH ? lastDH.toISOString().slice(0, 10) : '(없음)',
|
|
122
141
|
diffDays,
|