hypomnema 1.8.2 → 1.9.0
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 +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-stop.mjs: the ONLY Stop hook registered in hooks/hooks.json.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code runs every hook matched by an event in PARALLEL. Stop used to
|
|
6
|
+
* carry four separate registrations (hot-rebuild, session-record, auto-commit,
|
|
7
|
+
* auto-minimal-crystallize), so the order they ran in was whatever the four
|
|
8
|
+
* processes happened to reach first. hypo-hot-rebuild.mjs was written against
|
|
9
|
+
* an order it did not have: it writes root hot.md and log.md and only THEN
|
|
10
|
+
* claims those paths into the session's touched-paths set, and hypo-auto-commit
|
|
11
|
+
* could take the vault lock in between and commit without them. The rebuild
|
|
12
|
+
* still succeeded, so no health notice fired either, and the fresh bytes just
|
|
13
|
+
* sat uncommitted.
|
|
14
|
+
*
|
|
15
|
+
* One registration, four stages, run in order. The order is the contract now,
|
|
16
|
+
* and STAGES below is the only place it is written down.
|
|
17
|
+
*
|
|
18
|
+
* Stages are spawned as child processes, not imported as functions. Each of the
|
|
19
|
+
* four is a standalone script whose work happens at module top level: it reads
|
|
20
|
+
* the hook payload off its own stdin and prints one hook-protocol JSON object to
|
|
21
|
+
* its own stdout. Importing them would mean rewriting all four into exported
|
|
22
|
+
* functions (auto-minimal-crystallize alone is ~300 lines of top-level decision
|
|
23
|
+
* flow) and would put four sets of module state, and any process.exit or throw,
|
|
24
|
+
* inside one process. A child per stage costs four Node startups, roughly 50ms
|
|
25
|
+
* each against a 110s budget, and buys back the property serialization would
|
|
26
|
+
* otherwise cost: a stage that dies, hangs, or writes garbage cannot take the
|
|
27
|
+
* rest of the chain with it. That was free when the four ran in parallel, and
|
|
28
|
+
* this is what keeps it.
|
|
29
|
+
*
|
|
30
|
+
* Node built-ins only (architecture invariant: hooks never import from
|
|
31
|
+
* scripts/, and a hook's own directory is the only place it may reach).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const HOOKS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Stop chain, in the order it must run.
|
|
42
|
+
*
|
|
43
|
+
* - hot-rebuild first: it writes root hot.md and log.md and claims them into
|
|
44
|
+
* the session's touched-paths set, which is the scope auto-commit later
|
|
45
|
+
* commits. Anything that runs before it contributes nothing to that scope.
|
|
46
|
+
* - session-record next: it only appends to a gitignored cache file, so its
|
|
47
|
+
* position is free; it sits here because it is cheap and cannot fail the
|
|
48
|
+
* ones after it.
|
|
49
|
+
* - auto-commit third: it has to see everything the two above claimed.
|
|
50
|
+
* - auto-minimal-crystallize last: it is the only stage that can emit
|
|
51
|
+
* `decision: "block"`, and its close gate reads the committed state the
|
|
52
|
+
* stage before it produced.
|
|
53
|
+
*
|
|
54
|
+
* `timeoutMs` is each stage's own share of the budget, enforced here rather
|
|
55
|
+
* than by the single hooks.json timeout. Serializing four hooks under one
|
|
56
|
+
* registration makes one registration's timeout the ceiling for all four, so
|
|
57
|
+
* without a per-stage kill a single hung stage would eat the whole budget and
|
|
58
|
+
* the stages after it would never run at all. The values are the per-hook
|
|
59
|
+
* timeouts these four carried in hooks.json before the merge, unchanged.
|
|
60
|
+
*/
|
|
61
|
+
const STAGES = [
|
|
62
|
+
{ file: 'hypo-hot-rebuild.mjs', timeoutMs: 30000 },
|
|
63
|
+
{ file: 'hypo-session-record.mjs', timeoutMs: 10000 },
|
|
64
|
+
{ file: 'hypo-auto-commit.mjs', timeoutMs: 60000 },
|
|
65
|
+
{ file: 'hypo-auto-minimal-crystallize.mjs', timeoutMs: 10000 },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A stage's hook-protocol object, or null when it printed nothing usable.
|
|
70
|
+
*
|
|
71
|
+
* The last non-empty line, not the whole stream: a stage is free to write
|
|
72
|
+
* diagnostics before its result (none do today), and the protocol object is
|
|
73
|
+
* always the last thing out. A parse failure is null, which the caller treats
|
|
74
|
+
* as "this stage said nothing", never as a reason to stop the chain.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} stdout
|
|
77
|
+
* @returns {object|null}
|
|
78
|
+
*/
|
|
79
|
+
function parseStageOutput(stdout) {
|
|
80
|
+
const lines = String(stdout || '')
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
if (lines.length === 0) return null;
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(lines[lines.length - 1]);
|
|
87
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How a stage failed to finish normally, in words a person can act on, or null
|
|
95
|
+
* when it exited 0. `error` is a spawn failure or the per-stage timeout (Node
|
|
96
|
+
* reports the kill as ETIMEDOUT); `signal` without a timeout is a death from
|
|
97
|
+
* outside, such as an OOM kill.
|
|
98
|
+
*
|
|
99
|
+
* @param {{file: string, timeoutMs: number}} stage
|
|
100
|
+
* @param {{error?: Error, status: number|null, signal: string|null}} res
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
function stageFailure(stage, res) {
|
|
104
|
+
if (res.error?.code === 'ETIMEDOUT') return `${stage.file} timed out after ${stage.timeoutMs}ms`;
|
|
105
|
+
if (res.error) return `${stage.file} failed to run: ${res.error.message ?? String(res.error)}`;
|
|
106
|
+
if (res.signal) return `${stage.file} was killed by ${res.signal}`;
|
|
107
|
+
if (res.status !== 0) return `${stage.file} exited ${res.status}`;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let raw = '';
|
|
112
|
+
try {
|
|
113
|
+
raw = await new Promise((resolve) => {
|
|
114
|
+
let d = '';
|
|
115
|
+
process.stdin.on('data', (c) => (d += c));
|
|
116
|
+
process.stdin.on('end', () => resolve(d));
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
raw = '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The merged reply. The hook protocol gives one JSON object per registration,
|
|
123
|
+
// so four stage replies have to collapse into one:
|
|
124
|
+
// - systemMessage: every non-empty one, joined. Dropping any would silence a
|
|
125
|
+
// stage that has something to tell the user.
|
|
126
|
+
// - suppressOutput: true only while nothing wants to be seen. One stage
|
|
127
|
+
// asking for output, or contributing a systemMessage, turns it off.
|
|
128
|
+
// - continue / decision: the first stage that says "do not continue" ends the
|
|
129
|
+
// chain (see the loop).
|
|
130
|
+
const messages = [];
|
|
131
|
+
// Stages that did not finish normally. The chain still runs past them (see the
|
|
132
|
+
// fail-open note in the loop), but the reply must not read as a clean Stop:
|
|
133
|
+
// hot-rebuild dying after its rename and before it claims the path leaves a
|
|
134
|
+
// changed, uncommitted hot.md, and stderr alone is not something a person
|
|
135
|
+
// sees. So each one is also named in systemMessage, which turns suppressOutput
|
|
136
|
+
// off. Not a `decision: "block"`: a transient timeout would then keep the
|
|
137
|
+
// session from ending, over and over.
|
|
138
|
+
const failures = [];
|
|
139
|
+
let suppressOutput = true;
|
|
140
|
+
let blocked = null;
|
|
141
|
+
let halted = null;
|
|
142
|
+
|
|
143
|
+
for (const stage of STAGES) {
|
|
144
|
+
let res;
|
|
145
|
+
try {
|
|
146
|
+
res = spawnSync(process.execPath, [join(HOOKS_DIR, stage.file)], {
|
|
147
|
+
input: raw,
|
|
148
|
+
encoding: 'utf-8',
|
|
149
|
+
timeout: stage.timeoutMs,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
process.stderr.write(`[hypo-stop] error: ${stage.file}: ${err?.message ?? String(err)}\n`);
|
|
153
|
+
failures.push(`${stage.file} failed to run: ${err?.message ?? String(err)}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (res.stderr) process.stderr.write(res.stderr);
|
|
157
|
+
// Fail-open, deliberately, and this is the decision serialization would
|
|
158
|
+
// otherwise reverse. In parallel, a stage that crashed or hung took nothing
|
|
159
|
+
// with it; in a chain, "abort on a non-zero exit" would let a hot-rebuild
|
|
160
|
+
// crash cancel the commit that saves the session's work. So a stage that
|
|
161
|
+
// exits non-zero, gets killed at its timeout, or prints unparseable output is
|
|
162
|
+
// logged, reported in the reply, and skipped, and the chain moves on. The
|
|
163
|
+
// ONLY thing that ends the chain early is a stage explicitly asking for it
|
|
164
|
+
// through the protocol.
|
|
165
|
+
const failure = stageFailure(stage, res);
|
|
166
|
+
if (failure) {
|
|
167
|
+
process.stderr.write(`[hypo-stop] error: ${failure}\n`);
|
|
168
|
+
failures.push(failure);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const out = parseStageOutput(res.stdout);
|
|
172
|
+
if (!out) continue;
|
|
173
|
+
|
|
174
|
+
if (typeof out.systemMessage === 'string' && out.systemMessage.length > 0) {
|
|
175
|
+
messages.push(out.systemMessage);
|
|
176
|
+
}
|
|
177
|
+
if (out.suppressOutput === false) suppressOutput = false;
|
|
178
|
+
|
|
179
|
+
if (out.decision === 'block') {
|
|
180
|
+
// Only auto-minimal-crystallize emits this today, and it is last, so
|
|
181
|
+
// nothing is actually cut short. The rule still has to be written down: a
|
|
182
|
+
// block means Claude is not allowed to stop, and the protocol carries one
|
|
183
|
+
// reason, so a later stage's reply would either be discarded or have to be
|
|
184
|
+
// merged into a decision that is not a merge.
|
|
185
|
+
blocked = { reason: out.reason, stopReason: out.stopReason };
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
if (out.continue === false) {
|
|
189
|
+
halted = { stopReason: out.stopReason };
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (failures.length > 0) {
|
|
195
|
+
messages.push(
|
|
196
|
+
`[hypo-stop] Stop step(s) did not finish normally: ${failures.join('; ')}. ` +
|
|
197
|
+
'Their work may be partly on disk and uncommitted; the steps after them still ran.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const reply = blocked
|
|
202
|
+
? { decision: 'block', reason: blocked.reason, stopReason: blocked.stopReason }
|
|
203
|
+
: { continue: !halted, suppressOutput: suppressOutput && messages.length === 0 };
|
|
204
|
+
if (!blocked && halted && halted.stopReason) reply.stopReason = halted.stopReason;
|
|
205
|
+
if (messages.length > 0) reply.systemMessage = messages.join('\n');
|
|
206
|
+
|
|
207
|
+
console.log(JSON.stringify(reply));
|
package/hooks/proposal-store.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// SessionStart-chain hook (T8's session-start surface) counts pending proposals,
|
|
5
5
|
// so the store must sit where a hook can import it without reaching up into
|
|
6
6
|
// scripts/. scripts/ (crystallize, the T7 CLI) already imports from hooks/, never
|
|
7
|
-
// the reverse. Registered in hooks.json
|
|
7
|
+
// the reverse. Registered in hooks/shared.json alongside base-store.
|
|
8
8
|
//
|
|
9
9
|
// When crystallize's close path finds that an OVERWRITE target drifted away from
|
|
10
10
|
// the base this session observed at start, it withholds the bytes rather than
|
|
@@ -45,17 +45,9 @@
|
|
|
45
45
|
// fatal) and atomic on the write side (tmp+rename), mirroring base-store.
|
|
46
46
|
|
|
47
47
|
import { createHash } from 'node:crypto';
|
|
48
|
-
import {
|
|
49
|
-
existsSync,
|
|
50
|
-
mkdirSync,
|
|
51
|
-
readFileSync,
|
|
52
|
-
writeFileSync,
|
|
53
|
-
renameSync,
|
|
54
|
-
readdirSync,
|
|
55
|
-
unlinkSync,
|
|
56
|
-
realpathSync,
|
|
57
|
-
} from 'node:fs';
|
|
48
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, realpathSync, statSync } from 'node:fs';
|
|
58
49
|
import { join, dirname, resolve } from 'node:path';
|
|
50
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
59
51
|
|
|
60
52
|
/** `<hypoDir>/.cache/proposals/`. */
|
|
61
53
|
export function proposalsDir(hypoDir) {
|
|
@@ -132,14 +124,6 @@ export function makeProposalId(createdAt, target) {
|
|
|
132
124
|
return `${ts}-${slugTarget(target)}-${rand}`;
|
|
133
125
|
}
|
|
134
126
|
|
|
135
|
-
/** Atomic overwrite via tmp+rename, mirroring base-store's atomicWrite. */
|
|
136
|
-
function atomicWrite(path, content) {
|
|
137
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
138
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
139
|
-
writeFileSync(tmp, content);
|
|
140
|
-
renameSync(tmp, path);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
127
|
/**
|
|
144
128
|
* Read+parse one artifact file by absolute path. Returns null when absent,
|
|
145
129
|
* unreadable, or malformed (best-effort: a corrupt artifact must never crash a
|
|
@@ -157,11 +141,97 @@ function readArtifactFile(path) {
|
|
|
157
141
|
}
|
|
158
142
|
}
|
|
159
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Scan an already-`readdirSync`'d proposals dir once, splitting `.json` entries
|
|
146
|
+
* into parsed artifacts and everything readArtifactFile would not hand back.
|
|
147
|
+
* "Everything else" covers three cases indistinguishably (unreadable bytes, a
|
|
148
|
+
* body that is not the artifact shape, an id that does not match its own
|
|
149
|
+
* filename), so a caller that needs to tell those apart already has the
|
|
150
|
+
* filename in hand to go look. Shared by listProposals and
|
|
151
|
+
* listProposalsChecked so a directory is walked and each file parsed exactly
|
|
152
|
+
* once per call, not twice.
|
|
153
|
+
*/
|
|
154
|
+
// atomicWrite's OWN temp-file name (`<path>.<pid>.<rand>.tmp`; see
|
|
155
|
+
// atomic-write.mjs), so a `.json.<pid>.<rand>.tmp` sitting in this store is
|
|
156
|
+
// a completed artifact body whose rename never landed: the process that
|
|
157
|
+
// wrote it died in the gap between `writeFileSync` and `renameSync`. That
|
|
158
|
+
// gap holds real, already-serialized proposal bytes, not empty scratch
|
|
159
|
+
// space, so a scan that only ever looked for `.json` names read a vault
|
|
160
|
+
// with nothing BUT one of these as `parkedTotal: 0`, the same silent-zero
|
|
161
|
+
// this file's own listProposalsChecked doc comment already warns about, just
|
|
162
|
+
// reached through a different failure than a corrupt `.json`.
|
|
163
|
+
const PROPOSAL_TMP_RE = /\.json\.\d+\.[a-z0-9]+\.tmp$/;
|
|
164
|
+
// A live writer's tmp file looks identical to an orphan for the instant
|
|
165
|
+
// between its own writeFileSync and renameSync, so only a tmp file idle
|
|
166
|
+
// longer than this counts as unfinished. writeProposal's write is a single
|
|
167
|
+
// synchronous JSON.stringify + one syscall pair, so anything past a few
|
|
168
|
+
// seconds is already generous; a full minute gives slow disks and CI
|
|
169
|
+
// runners headroom without leaving a real crash unreported for long.
|
|
170
|
+
const PROPOSAL_TMP_GRACE_MS = 60_000;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The filename an orphan tmp's body would have landed at if its rename had
|
|
174
|
+
* finished. Shared with `checkProposals` (doctor.mjs) so its recovery hint
|
|
175
|
+
* names the same target this store would have produced, rather than a second
|
|
176
|
+
* copy of PROPOSAL_TMP_RE's suffix that could drift from this one.
|
|
177
|
+
*/
|
|
178
|
+
export function orphanTmpTargetName(name) {
|
|
179
|
+
return name.replace(PROPOSAL_TMP_RE, '.json');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanProposalFiles(dir, names) {
|
|
183
|
+
const proposals = [];
|
|
184
|
+
const unreadable = [];
|
|
185
|
+
// Subset of `unreadable`: filenames that matched PROPOSAL_TMP_RE past the
|
|
186
|
+
// grace period. A caller keeping only `unreadable` cannot tell a corrupt
|
|
187
|
+
// artifact from a complete proposal body whose rename never landed, and the
|
|
188
|
+
// two have different fixes (discard vs. `mv` back to its `.json` name).
|
|
189
|
+
// Kept as an ADDITIONAL array rather than removed from `unreadable`, so the
|
|
190
|
+
// existing `unreadable.length` contract (what `--json` consumers already
|
|
191
|
+
// parse) does not shrink out from under them; a name can legitimately sit
|
|
192
|
+
// in both.
|
|
193
|
+
const orphanTmp = [];
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
if (PROPOSAL_TMP_RE.test(name)) {
|
|
196
|
+
let stale = true;
|
|
197
|
+
try {
|
|
198
|
+
stale = Date.now() - statSync(join(dir, name)).mtimeMs > PROPOSAL_TMP_GRACE_MS;
|
|
199
|
+
} catch {
|
|
200
|
+
stale = false; // vanished mid-scan: the writer's own rename just landed, not an orphan
|
|
201
|
+
}
|
|
202
|
+
if (stale) {
|
|
203
|
+
unreadable.push(name);
|
|
204
|
+
orphanTmp.push(name);
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!name.endsWith('.json')) continue;
|
|
209
|
+
const path = join(dir, name);
|
|
210
|
+
const parsed = readArtifactFile(path);
|
|
211
|
+
// A well-formed artifact's `id` field equals its filename stem and is itself
|
|
212
|
+
// id-safe. A mismatch means the body was hand-edited or corrupt; trusting its
|
|
213
|
+
// `id` would let a spoofed value drive supersede or T7 apply against the wrong
|
|
214
|
+
// file, so skip it (it stays on disk but is never acted on).
|
|
215
|
+
if (parsed && isValidProposalId(parsed.id) && parsed.id === name.slice(0, -'.json'.length)) {
|
|
216
|
+
proposals.push({ ...parsed, _path: path });
|
|
217
|
+
} else {
|
|
218
|
+
unreadable.push(name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { proposals, unreadable, orphanTmp };
|
|
222
|
+
}
|
|
223
|
+
|
|
160
224
|
/**
|
|
161
225
|
* List parked proposals, newest artifacts included, malformed ones skipped.
|
|
162
226
|
* Each entry is the parsed body plus `_path` (absolute) so callers (the T7 CLI)
|
|
163
227
|
* can act on the exact file without re-deriving the path.
|
|
164
228
|
*
|
|
229
|
+
* A file this could not parse is silently absent from the result, same as
|
|
230
|
+
* before: every existing caller here acts on whatever IS readable and treats
|
|
231
|
+
* one bad artifact as no reason to fail a supersede scan or a T7 listing. A
|
|
232
|
+
* caller that needs to know a bad file exists at all, rather than just not
|
|
233
|
+
* see it, wants listProposalsChecked below, not this.
|
|
234
|
+
*
|
|
165
235
|
* @returns {Array<object>} parsed artifacts; empty when the dir is absent/empty
|
|
166
236
|
*/
|
|
167
237
|
export function listProposals(hypoDir) {
|
|
@@ -173,20 +243,64 @@ export function listProposals(hypoDir) {
|
|
|
173
243
|
} catch {
|
|
174
244
|
return [];
|
|
175
245
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
return scanProposalFiles(dir, names).proposals;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Same enumeration as listProposals, but keeps THREE states apart that all
|
|
251
|
+
* look like "nothing to review" from a bare count: an absent store (zero
|
|
252
|
+
* proposals were ever parked, `ok: true, proposals: [], unreadable: []`); a
|
|
253
|
+
* `readdirSync` failure on the store itself, permissions or a file sitting
|
|
254
|
+
* where the directory should be (the count is unmeasured, not zero, `ok:
|
|
255
|
+
* false`); and, the gap this once shared with listProposals, a `.json`
|
|
256
|
+
* candidate that sat right there in a perfectly listable directory but came
|
|
257
|
+
* back corrupt, permission-denied, or hand-edited into an unrecognizable
|
|
258
|
+
* shape. That third case used to fold into the same `[]` listProposals
|
|
259
|
+
* returns for "no proposals", so a vault with one chmod-000 artifact and zero
|
|
260
|
+
* readable ones reported `parkedTotal: 0` with no line of output anywhere
|
|
261
|
+
* naming the file: a silent success over a backlog that was never actually
|
|
262
|
+
* empty. `unreadable` is exactly those filenames (relative, `.json`
|
|
263
|
+
* included), so a caller can print "N candidate(s) unreadable, run doctor" even
|
|
264
|
+
* when the readable count is zero.
|
|
265
|
+
*
|
|
266
|
+
* listProposals above still gates on `existsSync` and stays fail-open on ANY
|
|
267
|
+
* stat or readdir failure: every one of its callers (the supersede scan in
|
|
268
|
+
* writeProposal, the T7 CLI's ordinary listing) wants "proposals I can act on
|
|
269
|
+
* right now", never a three-way judgment, and none of them has anywhere to
|
|
270
|
+
* put an `ok: false`. This function exists because `checkProposals` and the
|
|
271
|
+
* close report DO have somewhere to put it, and folding "could not tell" into
|
|
272
|
+
* "empty" is exactly the silent-zero this file's own docblock warns about.
|
|
273
|
+
* `existsSync` cannot make that call: it returns false for every stat
|
|
274
|
+
* failure, not just ENOENT, so it cannot tell "never created" apart from
|
|
275
|
+
* "could not be examined" (see the errno check below).
|
|
276
|
+
*
|
|
277
|
+
* @returns {{ok: boolean, proposals: object[], unreadable: string[], orphanTmp: string[]}}
|
|
278
|
+
* `orphanTmp` is the subset of `unreadable` whose rename never landed (see
|
|
279
|
+
* `orphanTmpTargetName`): a complete proposal body, recoverable with `mv`,
|
|
280
|
+
* not a corrupt file to discard.
|
|
281
|
+
*/
|
|
282
|
+
export function listProposalsChecked(hypoDir) {
|
|
283
|
+
const dir = proposalsDir(hypoDir);
|
|
284
|
+
let names;
|
|
285
|
+
try {
|
|
286
|
+
names = readdirSync(dir);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// ENOENT is the only errno that means "no store was ever created" (zero
|
|
289
|
+
// proposals, ok: true). Every other errno (EACCES, ENOTDIR when a file
|
|
290
|
+
// sits where the directory should be, ELOOP, ...) means the store could
|
|
291
|
+
// not be examined at all, so the count is unmeasured, not zero.
|
|
292
|
+
//
|
|
293
|
+
// Cost accepted: a vault that has never parked anything now throws and
|
|
294
|
+
// catches one ENOENT on every listProposalsChecked call, instead of the
|
|
295
|
+
// existsSync precheck this replaced. In exchange, there is no longer a
|
|
296
|
+
// window between "does it exist" and "list it" where the directory could
|
|
297
|
+
// change underneath the two calls, and a real access failure can no
|
|
298
|
+
// longer masquerade as "never created".
|
|
299
|
+
if (e && e.code === 'ENOENT') return { ok: true, proposals: [], unreadable: [], orphanTmp: [] };
|
|
300
|
+
return { ok: false, proposals: [], unreadable: [], orphanTmp: [] };
|
|
188
301
|
}
|
|
189
|
-
|
|
302
|
+
const { proposals, unreadable, orphanTmp } = scanProposalFiles(dir, names);
|
|
303
|
+
return { ok: true, proposals, unreadable, orphanTmp };
|
|
190
304
|
}
|
|
191
305
|
|
|
192
306
|
/**
|
|
@@ -228,13 +342,37 @@ export function deleteProposal(hypoDir, id) {
|
|
|
228
342
|
* @param {string} fields.sessionId owning session
|
|
229
343
|
* @param {string} fields.device machine identifier (crystallize passes currentDevice())
|
|
230
344
|
* @param {string} [fields.createdAt] ISO timestamp; defaults to now
|
|
345
|
+
* @param {string} [fields.parkReason] human-readable cause of the park (crystallize's
|
|
346
|
+
* conflictWhy(c)) — this is the artifact's own copy of the same string a `--json`
|
|
347
|
+
* close now also puts in that close's conflicts[].why, kept here because the T7
|
|
348
|
+
* CLI's list/apply/discard/challenge/resolve actions read only the artifact and
|
|
349
|
+
* never that close's own stdout. Additive: absent on artifacts written before this
|
|
350
|
+
* field existed, and every reader (this module's own readArtifactFile, and the T7
|
|
351
|
+
* CLI's actions above) treats an absent value as "no cause recorded", never as
|
|
352
|
+
* malformed — none of them requires this key to be present.
|
|
353
|
+
* @param {string[]} [fields.lostSections] the `##` headings a section-loss park
|
|
354
|
+
* dropped (crystallize-close-apply.mjs's sectionLossReason). Present only when
|
|
355
|
+
* `parkReason` names that cause; omitted for every other park reason, same as
|
|
356
|
+
* `diskSectionCount` below.
|
|
357
|
+
* @param {number} [fields.diskSectionCount] how many `##` headings disk had at
|
|
358
|
+
* park time, alongside `lostSections`.
|
|
231
359
|
* @returns {{id: string, target: string, path: string, supersedeWarnings: string[]}}
|
|
232
360
|
* `supersedeWarnings` is non-empty only when the new artifact WAS written but an
|
|
233
361
|
* older same-target sibling could not be removed — a non-fatal condition (the
|
|
234
362
|
* payload is parked), reported separately from a write failure (which throws).
|
|
235
363
|
*/
|
|
236
364
|
export function writeProposal(hypoDir, fields) {
|
|
237
|
-
const {
|
|
365
|
+
const {
|
|
366
|
+
target,
|
|
367
|
+
baseHash,
|
|
368
|
+
currentAtProposalHash,
|
|
369
|
+
proposedContent,
|
|
370
|
+
sessionId,
|
|
371
|
+
device,
|
|
372
|
+
parkReason,
|
|
373
|
+
lostSections,
|
|
374
|
+
diskSectionCount,
|
|
375
|
+
} = fields;
|
|
238
376
|
const createdAt = fields.createdAt || new Date().toISOString();
|
|
239
377
|
|
|
240
378
|
// Match on the parsed `target` field, never on the filename slug: two distinct
|
|
@@ -266,6 +404,16 @@ export function writeProposal(hypoDir, fields) {
|
|
|
266
404
|
sessionId: sessionId != null ? String(sessionId) : null,
|
|
267
405
|
device: device != null ? String(device) : null,
|
|
268
406
|
createdAt,
|
|
407
|
+
// Additive fields (see this function's own doc comment): omitted rather
|
|
408
|
+
// than stored as `null` when the caller does not pass them, so an artifact
|
|
409
|
+
// written before this field existed and one written by a caller that
|
|
410
|
+
// genuinely has no cause to report look identical on disk — both simply
|
|
411
|
+
// lack the key, and every reader already treats an absent key as "not
|
|
412
|
+
// recorded", never as a malformed body (readArtifactFile only requires
|
|
413
|
+
// `id` and `target`; nothing here reads these three as mandatory).
|
|
414
|
+
...(parkReason != null ? { parkReason: String(parkReason) } : {}),
|
|
415
|
+
...(Array.isArray(lostSections) ? { lostSections } : {}),
|
|
416
|
+
...(typeof diskSectionCount === 'number' ? { diskSectionCount } : {}),
|
|
269
417
|
};
|
|
270
418
|
// Write the new artifact DURABLY before removing the old one: a crash in the
|
|
271
419
|
// gap leaves a stale sibling (superseded next close), never a lost payload.
|
|
@@ -412,9 +560,13 @@ function sessionChallengeFiles(hypoDir, sessionId) {
|
|
|
412
560
|
* un-superseded old nonce would be a second, unreviewed key to the same write).
|
|
413
561
|
*
|
|
414
562
|
* @param {string} hypoDir
|
|
415
|
-
* @param {{nonce: string, sessionId: string, mintedAt: string,
|
|
563
|
+
* @param {{nonce: string, sessionId: string, closeSessionId?: string, mintedAt: string,
|
|
416
564
|
* items: Array<{id: string, target: string, proposedHash: string,
|
|
417
565
|
* freshness: {state: 'hash'|'absent', hash: string|null}}>}} record
|
|
566
|
+
* `closeSessionId` is the session whose close this batch resumes, which the CALLER
|
|
567
|
+
* is running challenge FROM when the two differ (a later session finishing a park
|
|
568
|
+
* an earlier, possibly gone, session left behind). Optional and additive: a caller
|
|
569
|
+
* that omits it is read as "resumes its own session" (see readChallenge).
|
|
418
570
|
* @returns {string|null} the artifact path, or null when the session id is unsafe
|
|
419
571
|
*/
|
|
420
572
|
export function writeChallenge(hypoDir, record) {
|
|
@@ -475,7 +627,21 @@ export function readChallenge(hypoDir, sessionId) {
|
|
|
475
627
|
if (!f || (f.state !== 'hash' && f.state !== 'absent')) return null;
|
|
476
628
|
if (f.state === 'hash' && typeof f.hash !== 'string') return null;
|
|
477
629
|
}
|
|
478
|
-
|
|
630
|
+
// `closeSessionId` names the close this approval resumes, which can predate
|
|
631
|
+
// this field (see writeChallenge's doc comment). A record that carries it must
|
|
632
|
+
// carry a well-formed one: a hand-edited or malformed value here is a REMINT,
|
|
633
|
+
// the same polarity every other field in this record gets. But a record that
|
|
634
|
+
// never carried it (every challenge minted before this feature, or one minted
|
|
635
|
+
// without `--close-session-id`) is read as resuming its OWN session: the
|
|
636
|
+
// approving session and the parking session were always the same one before
|
|
637
|
+
// this existed, so treating "absent" as "same session" changes no existing
|
|
638
|
+
// behavior and needs no migration.
|
|
639
|
+
if (parsed.closeSessionId !== undefined) {
|
|
640
|
+
if (typeof parsed.closeSessionId !== 'string' || !isValidSessionId(parsed.closeSessionId)) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return { ...parsed, closeSessionId: parsed.closeSessionId ?? sessionId };
|
|
479
645
|
} catch {
|
|
480
646
|
return null;
|
|
481
647
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[
|
|
2
|
+
"atomic-write.mjs",
|
|
3
|
+
"hypo-shared.mjs",
|
|
4
|
+
"version-check.mjs",
|
|
5
|
+
"version-check-fetch.mjs",
|
|
6
|
+
"base-store.mjs",
|
|
7
|
+
"proposal-store.mjs",
|
|
8
|
+
"close-gate-store.mjs",
|
|
9
|
+
"close-journal.mjs",
|
|
10
|
+
"hypo-hot-rebuild.mjs",
|
|
11
|
+
"hypo-session-record.mjs",
|
|
12
|
+
"hypo-auto-commit.mjs",
|
|
13
|
+
"hypo-auto-minimal-crystallize.mjs",
|
|
14
|
+
"close-receipt.mjs"
|
|
15
|
+
]
|
package/hooks/version-check.mjs
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* than overwrites so it never erases the hook's `notifiedFor` marks.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync,
|
|
21
|
+
import { readFileSync, realpathSync, existsSync } from 'fs';
|
|
22
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
22
23
|
import { dirname, join, delimiter } from 'path';
|
|
23
24
|
import { homedir } from 'os';
|
|
24
25
|
|
|
@@ -165,7 +166,7 @@ export function selectPluginVersion(plugins) {
|
|
|
165
166
|
*
|
|
166
167
|
* Lives here because both hooks and scripts need it and the dependency only
|
|
167
168
|
* runs one way (scripts import hooks, never the reverse). This file is already
|
|
168
|
-
* in hooks.json
|
|
169
|
+
* listed in hooks/shared.json, so it reaches every standalone install.
|
|
169
170
|
*
|
|
170
171
|
* Use UPGRADE_APPLY_EITHER when the target channel is genuinely unknown, e.g.
|
|
171
172
|
* an instruction about a DIFFERENT machine. */
|
|
@@ -246,12 +247,13 @@ export function readCache(path) {
|
|
|
246
247
|
}
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
/** Atomic write: tmp file in the same dir, then rename (last-writer-wins).
|
|
250
|
+
/** Atomic write: tmp file in the same dir, then rename (last-writer-wins).
|
|
251
|
+
* Delegates to the shared writer so a failure here cleans its temp file up.
|
|
252
|
+
* It did not before, and the callers below swallow errors, so every failure
|
|
253
|
+
* left a `<path>.<pid>.<rand>.tmp` nothing would ever pick up again. This is a
|
|
254
|
+
* per-session cache path, so those accumulated faster than anywhere else. */
|
|
250
255
|
export function writeCacheAtomic(path, obj) {
|
|
251
|
-
|
|
252
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
253
|
-
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
254
|
-
renameSync(tmp, path);
|
|
256
|
+
atomicWrite(path, JSON.stringify(obj, null, 2));
|
|
255
257
|
}
|
|
256
258
|
|
|
257
259
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"scripts/lib/feedback-scope.mjs",
|
|
40
40
|
"scripts/lib/frontmatter.mjs",
|
|
41
41
|
"scripts/lib/git-hooks-dir.mjs",
|
|
42
|
+
"scripts/lib/hook-inventory.mjs",
|
|
42
43
|
"scripts/lib/hypo-ignore.mjs",
|
|
43
44
|
"scripts/lib/hypo-root.mjs",
|
|
44
45
|
"scripts/lib/page-usage.mjs",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"scripts/lib/schema-vocab.mjs",
|
|
51
52
|
"scripts/lib/slug-resolver.mjs",
|
|
52
53
|
"scripts/lib/template-schema-version.mjs",
|
|
54
|
+
"scripts/lib/time.mjs",
|
|
53
55
|
"scripts/lib/wd-match.mjs",
|
|
54
56
|
"scripts/lib/wikilink.mjs",
|
|
55
57
|
"commands/",
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
* Options:
|
|
13
13
|
* --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
|
|
14
14
|
* --min-group=<n> Min pages per tag group to report (default: 2)
|
|
15
|
-
* --check-session-close Verify the strict session-close memory files
|
|
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
18
|
* gate to that one project — a project-scoped diagnostic, NOT a global
|
|
19
19
|
* compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
|
|
20
20
|
* global (the marker == compact-ready invariant). Ignored on --apply.
|
|
21
|
-
* --apply-session-close Apply a JSON payload that updates the
|
|
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.
|
|
24
24
|
*
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
* "date": "YYYY-MM-DD", // optional — defaults to today (local)
|
|
41
41
|
* "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
|
|
42
42
|
* "projectHot": { "content": "<full file>" }, // overwrite
|
|
43
|
-
* "rootHot": { "content": "<full file>" }, // overwrite
|
|
44
43
|
* "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
|
|
45
44
|
* "log": { "entry": "## [date] session | <project> ..." }, // OPTIONAL (B-1): omit it and apply derives the root log.md entry from this close's sessionLog heading; supply it only for a deliberately custom log line
|
|
46
45
|
* "openQuestions":{ "content": "<full file>" } // optional overwrite
|
|
@@ -53,7 +52,7 @@
|
|
|
53
52
|
* Lint gates:
|
|
54
53
|
* • Preflight — runs `lint.mjs --json` BEFORE any payload byte is written.
|
|
55
54
|
* Errors in files this payload will OVERWRITE (sessionState/projectHot/
|
|
56
|
-
*
|
|
55
|
+
* openQuestions) are filtered out: they're about to be replaced,
|
|
57
56
|
* and not filtering them dead-locks the documented "fix payload and retry"
|
|
58
57
|
* recovery after a post-apply-lint failure (codex P2). Errors in any other
|
|
59
58
|
* file → exit 1 with stage='preflight-lint', no apply occurs. PreCompact's
|