hypomnema 1.3.1 → 1.3.3
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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +11 -10
- package/README.md +11 -10
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -11
- 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 +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +2 -2
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +6 -3
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +1082 -89
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +322 -110
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +836 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +12 -9
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +22 -1
- package/templates/hypo-help.md +1 -0
- package/templates/projects/_template/index.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -6,7 +6,16 @@
|
|
|
6
6
|
* Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
appendFileSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
realpathSync,
|
|
18
|
+
} from 'fs';
|
|
10
19
|
import { join, relative, basename } from 'path';
|
|
11
20
|
import { homedir, hostname, tmpdir } from 'os';
|
|
12
21
|
import { spawnSync } from 'child_process';
|
|
@@ -119,28 +128,50 @@ export function lastSubstantialOpIsSession() {
|
|
|
119
128
|
return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
// Returns the wiki's git state split into its two independent axes (ADR 0056):
|
|
132
|
+
// uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
|
|
133
|
+
// ahead — committed-but-unpushed commits (a soft, auto-synced state: the
|
|
134
|
+
// auto-commit Stop hook pushes, and push failures are non-fatal —
|
|
135
|
+
// see hypo-auto-commit.mjs / appendSyncFailure)
|
|
136
|
+
// `clean` stays `!uncommitted && !ahead` for back-compat with callers that only
|
|
137
|
+
// read it. Callers that gate session-close / compact distinguish the two: they
|
|
138
|
+
// block on `uncommitted` and demote `ahead` to a notice (precompactGateStatus,
|
|
139
|
+
// hypo-compact-guard) so a committed-but-unpushed close is still "compact-ready".
|
|
122
140
|
export function hypoIsClean(dir = HYPO_DIR) {
|
|
123
141
|
try {
|
|
124
142
|
const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
|
|
125
143
|
encoding: 'utf-8',
|
|
126
144
|
});
|
|
127
|
-
if (porcelain.status !== 0)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
if (porcelain.status !== 0)
|
|
146
|
+
return {
|
|
147
|
+
clean: false,
|
|
148
|
+
uncommitted: true,
|
|
149
|
+
ahead: false,
|
|
150
|
+
reason: `git check failed in ${dir}`,
|
|
151
|
+
};
|
|
152
|
+
const uncommitted = porcelain.stdout.trim() !== '';
|
|
153
|
+
const aheadRes = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
|
|
131
154
|
encoding: 'utf-8',
|
|
132
155
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
const ahead = /\[ahead \d+\]/.test(aheadRes.stdout || '');
|
|
157
|
+
const reasons = [];
|
|
158
|
+
if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
|
|
159
|
+
if (ahead) reasons.push(`unpushed commits in ${dir}`);
|
|
160
|
+
return {
|
|
161
|
+
clean: !uncommitted && !ahead,
|
|
162
|
+
uncommitted,
|
|
163
|
+
ahead,
|
|
164
|
+
reason: reasons.length ? reasons.join('; ') : undefined,
|
|
165
|
+
};
|
|
136
166
|
} catch {
|
|
137
|
-
return { clean: false, reason: `git check failed in ${dir}` };
|
|
167
|
+
return { clean: false, uncommitted: true, ahead: false, reason: `git check failed in ${dir}` };
|
|
138
168
|
}
|
|
139
169
|
}
|
|
140
170
|
|
|
141
|
-
export function hotMdIsClean() {
|
|
142
|
-
|
|
143
|
-
|
|
171
|
+
export function hotMdIsClean(dir = HYPO_DIR) {
|
|
172
|
+
const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
|
|
173
|
+
if (!existsSync(hotPath)) return { clean: true };
|
|
174
|
+
const content = readFileSync(hotPath, 'utf-8');
|
|
144
175
|
const reasons = [];
|
|
145
176
|
|
|
146
177
|
// Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
|
|
@@ -163,7 +194,7 @@ export function hotMdIsClean() {
|
|
|
163
194
|
// spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
|
|
164
195
|
// 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
|
|
165
196
|
// (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
|
|
166
|
-
// project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
|
|
197
|
+
// project hot.md, root hot.md, session-log/YYYY-MM-DD.md, and log.md.
|
|
167
198
|
// pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
|
|
168
199
|
// cross-project queue, so a session that raises no questions should not be
|
|
169
200
|
// forced to touch it. Gating it would produce false-blocks; spec §5.2.7
|
|
@@ -173,7 +204,7 @@ export function hotMdIsClean() {
|
|
|
173
204
|
// so a second session on the same day that skips updating a file still passes
|
|
174
205
|
// if an earlier close that day already stamped it. freshDates() accepting both
|
|
175
206
|
// local and UTC dates widens that window by up to one UTC offset. A per-session
|
|
176
|
-
// boundary is out of scope for
|
|
207
|
+
// boundary is out of scope for the strict session-close check.
|
|
177
208
|
|
|
178
209
|
/** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
|
|
179
210
|
function frontmatterUpdated(content) {
|
|
@@ -198,6 +229,56 @@ export function hasSessionLogHeading(content, date) {
|
|
|
198
229
|
return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
|
|
199
230
|
}
|
|
200
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Canonical session-log shard path (repo-relative POSIX) for a single day.
|
|
234
|
+
* ADR 0050 / option D: the date IS the filename (`YYYY-MM-DD.md`), so no
|
|
235
|
+
* "current part" resolver is needed and the per-session write touches a small
|
|
236
|
+
* file instead of a multi-thousand-line monthly log. This is the WRITE target.
|
|
237
|
+
*/
|
|
238
|
+
export function sessionLogShardPath(project, date) {
|
|
239
|
+
return `projects/${project}/session-log/${date}.md`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Read candidates for a date's session-log entry, in priority order:
|
|
244
|
+
* 1. the daily shard `YYYY-MM-DD.md` (canonical, small)
|
|
245
|
+
* 2. the legacy monthly `YYYY-MM.md` (pre-shard history is never split)
|
|
246
|
+
* Callers iterate and short-circuit on the first file carrying the dated
|
|
247
|
+
* heading, so once a daily shard exists the large monthly file is never read —
|
|
248
|
+
* which is where the per-close token saving comes from. The monthly fallback
|
|
249
|
+
* keeps pre-cutover months (and a hybrid cutover month) resolving correctly
|
|
250
|
+
* without a retroactive split.
|
|
251
|
+
*/
|
|
252
|
+
export function sessionLogReadCandidates(project, date) {
|
|
253
|
+
return [
|
|
254
|
+
sessionLogShardPath(project, date),
|
|
255
|
+
`projects/${project}/session-log/${date.slice(0, 7)}.md`,
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The session-log file the close gate should hold accountable for `date` — i.e.
|
|
261
|
+
* the read candidate that ACTUALLY carries a today-dated heading (daily shard
|
|
262
|
+
* preferred), or the daily write target when none does yet. The freshness gate
|
|
263
|
+
* accepts a today-heading found in the legacy monthly file via fallback; if the
|
|
264
|
+
* lint scope only ever named the daily shard, a close could pass on a *corrupt*
|
|
265
|
+
* monthly evidence file while that file's lint error was demoted to a
|
|
266
|
+
* non-blocking notice (out of scope). Scoping to the resolved evidence file
|
|
267
|
+
* closes that gap: whatever file the gate trusts as proof, lint judges too.
|
|
268
|
+
*/
|
|
269
|
+
export function sessionLogScopePath(hypoDir, project, date) {
|
|
270
|
+
for (const rel of sessionLogReadCandidates(project, date)) {
|
|
271
|
+
const full = join(hypoDir, rel);
|
|
272
|
+
if (!existsSync(full)) continue;
|
|
273
|
+
try {
|
|
274
|
+
if (hasSessionLogHeading(readFileSync(full, 'utf-8'), date)) return rel;
|
|
275
|
+
} catch {
|
|
276
|
+
/* unreadable candidate — keep looking */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return sessionLogShardPath(project, date);
|
|
280
|
+
}
|
|
281
|
+
|
|
201
282
|
/**
|
|
202
283
|
* True if `content` carries a today-dated `## [date] session | <project>` entry
|
|
203
284
|
* in log.md.
|
|
@@ -217,6 +298,31 @@ export function hasLogEntry(content, date, project) {
|
|
|
217
298
|
).test(content || '');
|
|
218
299
|
}
|
|
219
300
|
|
|
301
|
+
/**
|
|
302
|
+
* True when log.md carries ANY `## [<freshDate>] session | <slug>` heading —
|
|
303
|
+
* project-agnostic. The minimum proof for a log-only close: a
|
|
304
|
+
* non-project (tooling/wiki-only) session has no project mandatory files, but it
|
|
305
|
+
* MUST still leave a today log.md trace so the close is not a content-free bypass.
|
|
306
|
+
* Uses the same canonical heading parser as hasLogEntry (not a loose substring —
|
|
307
|
+
* codex design review: keep the fresh-date/canonical-heading style) so a stray
|
|
308
|
+
* mention of the date elsewhere cannot satisfy it.
|
|
309
|
+
* @param {string} hypoDir
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
export function hasAnyTodayLogEntry(hypoDir) {
|
|
313
|
+
const logPath = join(hypoDir, 'log.md');
|
|
314
|
+
if (!existsSync(logPath)) return false;
|
|
315
|
+
let content = '';
|
|
316
|
+
try {
|
|
317
|
+
content = readFileSync(logPath, 'utf-8');
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
return freshDates().some((d) =>
|
|
322
|
+
new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| \\S', 'm').test(content),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
220
326
|
/**
|
|
221
327
|
* Date strings that count as "today" for freshness checks. Both the local and
|
|
222
328
|
* UTC dates are accepted: Claude writes file dates in the user's local zone,
|
|
@@ -247,7 +353,8 @@ function parseFrontmatterField(content, key) {
|
|
|
247
353
|
|
|
248
354
|
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
249
355
|
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
250
|
-
// when cwd is falsy or matches none.
|
|
356
|
+
// when cwd is falsy or matches none. resume gives this authority OVER recency
|
|
357
|
+
// (ADR 0044); close callers never pass cwd, so it stays inert for them.
|
|
251
358
|
function pickByCwd(hypoDir, slugs, cwd) {
|
|
252
359
|
if (!cwd) return null;
|
|
253
360
|
let best = null;
|
|
@@ -268,15 +375,18 @@ function pickByCwd(hypoDir, slugs, cwd) {
|
|
|
268
375
|
}
|
|
269
376
|
|
|
270
377
|
/**
|
|
271
|
-
* Resolve the
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
378
|
+
* Resolve the active project slug from root hot.md. With a cwd, a project whose
|
|
379
|
+
* working_dir contains it wins (cwd-first, ADR 0044); otherwise the
|
|
380
|
+
* most-recently-active row is returned.
|
|
381
|
+
* The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
|
|
382
|
+
* are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
|
|
383
|
+
* intentionally differs (resume.mjs adds an mtime fallback, this does not).
|
|
384
|
+
* `cwd` is an optional cwd-first selector (ADR 0044): a cwd↔working_dir match
|
|
385
|
+
* wins over recency. resume passes process.cwd(); session-close callers
|
|
386
|
+
* (sessionCloseFileStatus / closeFileTargets) intentionally pass null — close
|
|
387
|
+
* has a different authority (payload.project / freshness, the global invariant
|
|
388
|
+
* of ADR 0043), so it never picks by cwd. When cwd is omitted, behavior is
|
|
389
|
+
* identical to the legacy recency version.
|
|
280
390
|
* @param {string} hypoDir
|
|
281
391
|
* @param {string|null} [cwd]
|
|
282
392
|
* @returns {string|null}
|
|
@@ -299,41 +409,48 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
299
409
|
),
|
|
300
410
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
301
411
|
if (wikiRows.length > 0) {
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
const topDate = wikiRows[0].date;
|
|
307
|
-
const tied = wikiRows.filter((r) => r.date === topDate);
|
|
308
|
-
if (cwd && tied.length > 1) {
|
|
412
|
+
// cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
|
|
413
|
+
// ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null →
|
|
414
|
+
// recency path below (resume=cwd-positive / close=no-pick).
|
|
415
|
+
if (cwd) {
|
|
309
416
|
const picked = pickByCwd(
|
|
310
417
|
hypoDir,
|
|
311
|
-
|
|
418
|
+
wikiRows.map((r) => r.slug),
|
|
312
419
|
cwd,
|
|
313
420
|
);
|
|
314
421
|
if (picked) return picked;
|
|
315
422
|
}
|
|
423
|
+
// No cwd match → most recent by date (stable-sort keeps the first table row
|
|
424
|
+
// on a tie, the legacy behavior).
|
|
425
|
+
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
316
426
|
return wikiRows[0].slug;
|
|
317
427
|
}
|
|
318
428
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
429
|
+
const mdSlugs = [...content.matchAll(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/g)].map((m) => m[2]);
|
|
430
|
+
if (mdSlugs.length > 0) {
|
|
431
|
+
if (cwd) {
|
|
432
|
+
const picked = pickByCwd(hypoDir, mdSlugs, cwd);
|
|
433
|
+
if (picked) return picked;
|
|
434
|
+
}
|
|
435
|
+
return mdSlugs[0]; // legacy: first table row
|
|
436
|
+
}
|
|
321
437
|
return null;
|
|
322
438
|
}
|
|
323
439
|
|
|
324
440
|
/**
|
|
325
|
-
* Strict session-close verification (
|
|
441
|
+
* Strict session-close verification (spec §5.2.7 / §8.3).
|
|
326
442
|
* Confirms the memory files a session close must touch were updated today:
|
|
327
443
|
* - projects/<project>/session-state.md — frontmatter `updated:` is today
|
|
328
444
|
* - projects/<project>/hot.md — frontmatter `updated:` is today
|
|
329
445
|
* - hot.md (root) — frontmatter `updated:` is today
|
|
330
|
-
* - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
|
|
446
|
+
* - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
|
|
447
|
+
* (daily shard, ADR 0050; legacy YYYY-MM.md is still accepted as fallback)
|
|
331
448
|
* - log.md — has a `## [today] session | <project>` entry
|
|
332
449
|
* The log.md check is project-scoped so a session close left incomplete for
|
|
333
450
|
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
334
451
|
* open-questions.md (file #5) is conditional and not gated.
|
|
335
452
|
*
|
|
336
|
-
* `projectOverride` (
|
|
453
|
+
* `projectOverride` (same-date-tie fix): when the caller already holds the
|
|
337
454
|
* authoritative project being closed (e.g. crystallize apply derives it from
|
|
338
455
|
* `payload.project`), it passes that slug so verification checks the SAME
|
|
339
456
|
* project it just wrote — instead of re-deriving via resolveActiveProject(),
|
|
@@ -381,25 +498,30 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
381
498
|
checkUpdated(join('projects', project, 'hot.md'));
|
|
382
499
|
checkUpdated('hot.md');
|
|
383
500
|
|
|
384
|
-
// session-log:
|
|
385
|
-
//
|
|
501
|
+
// session-log: daily shard (ADR 0050), with legacy monthly fallback — must
|
|
502
|
+
// carry a today-dated heading in whichever file holds it. Daily-first read
|
|
503
|
+
// order short-circuits on the small shard. When no match is found, the gap is
|
|
504
|
+
// reported under the canonical daily shard for the local date (dates[0]).
|
|
386
505
|
let sessionLogOk = false;
|
|
387
506
|
for (const date of dates) {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
507
|
+
for (const rel of sessionLogReadCandidates(project, date)) {
|
|
508
|
+
const full = join(hypoDir, rel);
|
|
509
|
+
if (!existsSync(full)) continue;
|
|
510
|
+
let content = '';
|
|
511
|
+
try {
|
|
512
|
+
content = readFileSync(full, 'utf-8');
|
|
513
|
+
} catch {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (hasSessionLogHeading(content, date)) {
|
|
517
|
+
sessionLogOk = true;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
399
520
|
}
|
|
521
|
+
if (sessionLogOk) break;
|
|
400
522
|
}
|
|
401
523
|
if (!sessionLogOk) {
|
|
402
|
-
const logRel =
|
|
524
|
+
const logRel = sessionLogShardPath(project, dates[0]);
|
|
403
525
|
(existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
|
|
404
526
|
}
|
|
405
527
|
|
|
@@ -421,11 +543,298 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
421
543
|
return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
|
|
422
544
|
}
|
|
423
545
|
|
|
546
|
+
// ── global session-close gate (ADR 0043) ────
|
|
547
|
+
// The no-payload close paths must NOT pick one project (recency / cwd) and check
|
|
548
|
+
// it — that re-derivation is the prior session-close false-block, and a cwd
|
|
549
|
+
// tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
|
|
550
|
+
// close. Instead the gate enforces a global invariant: no project may end a
|
|
551
|
+
// session with a partial close. resume stays cwd-positive (ADR 0044); close
|
|
552
|
+
// never picks. The two copies of resolveActiveProject share the cwd-first body
|
|
553
|
+
// but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
|
|
554
|
+
|
|
555
|
+
// Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
|
|
556
|
+
// project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
|
|
557
|
+
// DISCOVERY in closeCandidateSlugs — NOT as close-activity evidence: ADR 0057
|
|
558
|
+
// dropped the row date as an activity signal because project-create and
|
|
559
|
+
// hypo-hot-rebuild both stamp rows today without a real session. Mirrors
|
|
560
|
+
// resolveActiveProject's regex.
|
|
561
|
+
function rootHotRows(hypoDir) {
|
|
562
|
+
const hotPath = join(hypoDir, 'hot.md');
|
|
563
|
+
if (!existsSync(hotPath)) return [];
|
|
564
|
+
let content;
|
|
565
|
+
try {
|
|
566
|
+
content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
567
|
+
} catch {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
return [
|
|
571
|
+
...content.matchAll(
|
|
572
|
+
/\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
|
|
573
|
+
),
|
|
574
|
+
].map((m) => ({ slug: m[3], date: m[2] || '' }));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Candidate slugs the global gate must consider: real project dirs (with a
|
|
578
|
+
// session-state.md, skip _template) ∪ slugs in a today-dated `## [today] session
|
|
579
|
+
// | P` log.md entry ∪ slugs in a today-dated root hot.md row. The log/row unions
|
|
580
|
+
// catch a dangling close whose own project files are missing —
|
|
581
|
+
// sessionCloseFileStatus(projectOverride) reports those as `missing` correctly.
|
|
582
|
+
function closeCandidateSlugs(hypoDir, dates) {
|
|
583
|
+
const slugs = new Set();
|
|
584
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
585
|
+
if (existsSync(projectsDir)) {
|
|
586
|
+
let entries = [];
|
|
587
|
+
try {
|
|
588
|
+
entries = readdirSync(projectsDir);
|
|
589
|
+
} catch {
|
|
590
|
+
entries = [];
|
|
591
|
+
}
|
|
592
|
+
for (const p of entries) {
|
|
593
|
+
if (p === '_template') continue;
|
|
594
|
+
if (existsSync(join(projectsDir, p, 'session-state.md'))) slugs.add(p);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const r of rootHotRows(hypoDir)) {
|
|
598
|
+
if (r.date && dates.includes(r.date)) slugs.add(r.slug);
|
|
599
|
+
}
|
|
600
|
+
const logPath = join(hypoDir, 'log.md');
|
|
601
|
+
if (existsSync(logPath)) {
|
|
602
|
+
let content = '';
|
|
603
|
+
try {
|
|
604
|
+
content = readFileSync(logPath, 'utf-8');
|
|
605
|
+
} catch {
|
|
606
|
+
content = '';
|
|
607
|
+
}
|
|
608
|
+
for (const d of dates) {
|
|
609
|
+
const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
|
|
610
|
+
for (const m of content.matchAll(re)) slugs.add(m[1]);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return slugs;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// True when project P shows an AUTHORITATIVE today close-activity signal: a
|
|
617
|
+
// today-dated session-log heading, or a today-dated `## [today] session | P`
|
|
618
|
+
// log.md entry. These are written ONLY by a real session close (apply, or its
|
|
619
|
+
// auto-derived root log — ADR 0049).
|
|
620
|
+
//
|
|
621
|
+
// ADR 0057: soft state files are EXCLUDED, because each is bumped to today by
|
|
622
|
+
// non-session tooling and is therefore indistinguishable from a real close:
|
|
623
|
+
// - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
|
|
624
|
+
// the "next tasks" section (a cross-block incident: editing one project's
|
|
625
|
+
// tracker bumped session-state and blocked an unrelated project's /compact).
|
|
626
|
+
// - project hot.md `updated:` — project-create stamps the template `updated: today`.
|
|
627
|
+
// - root hot.md ROW date — project-create inserts a today-dated row, and
|
|
628
|
+
// hypo-hot-rebuild defaults a row to today when a project hot.md is missing.
|
|
629
|
+
// (Root hot.md *frontmatter* was already never a signal — it is shared and
|
|
630
|
+
// hypo-hot-rebuild stamps it today every session.)
|
|
631
|
+
//
|
|
632
|
+
// Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
|
|
633
|
+
// project files, then the session-log + log entry. A process crash before the
|
|
634
|
+
// session-log write leaves a torn close that this gate no longer flags. Accepted:
|
|
635
|
+
// a torn close never reached apply's ok=true so it wrote no marker (ADR 0055/0056);
|
|
636
|
+
// the surviving session-state is the resume pointer the next session overwrites;
|
|
637
|
+
// what is lost is a narrative log entry, not continuity.
|
|
638
|
+
function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
639
|
+
for (const d of dates) {
|
|
640
|
+
for (const rel of sessionLogReadCandidates(project, d)) {
|
|
641
|
+
const sl = join(hypoDir, rel);
|
|
642
|
+
if (!existsSync(sl)) continue;
|
|
643
|
+
try {
|
|
644
|
+
if (hasSessionLogHeading(readFileSync(sl, 'utf-8'), d)) return true;
|
|
645
|
+
} catch {
|
|
646
|
+
/* skip */
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const logPath = join(hypoDir, 'log.md');
|
|
651
|
+
if (existsSync(logPath)) {
|
|
652
|
+
try {
|
|
653
|
+
const c = readFileSync(logPath, 'utf-8');
|
|
654
|
+
if (dates.some((d) => hasLogEntry(c, d, project))) return true;
|
|
655
|
+
} catch {
|
|
656
|
+
/* skip */
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Global session-close status for the no-payload close paths (ADR 0043).
|
|
664
|
+
* Checks EVERY project with today close-activity; ok only when all are complete.
|
|
665
|
+
* When no project has today activity, falls back to the legacy single recency
|
|
666
|
+
* project (preserves "force the initial close" behavior, byte-identical gate).
|
|
667
|
+
*
|
|
668
|
+
* stale/missing entries are self-describing paths — project-specific files carry
|
|
669
|
+
* `projects/<slug>/…`, root files (`hot.md`/`log.md`) are shared — so the flat
|
|
670
|
+
* aliases need no project prefix and a single-project session is byte-identical
|
|
671
|
+
* to sessionCloseFileStatus (back-compat for the flat-field readers).
|
|
672
|
+
*
|
|
673
|
+
* @param {string} hypoDir
|
|
674
|
+
* @returns {{ok: boolean, projects: Array<{project:string, ok:boolean, stale:string[], missing:string[]}>,
|
|
675
|
+
* dates: string[], fallback: boolean, primary: string|null,
|
|
676
|
+
* project: string|null, stale: string[], missing: string[]}}
|
|
677
|
+
*/
|
|
678
|
+
export function sessionCloseGlobalStatus(hypoDir) {
|
|
679
|
+
const dates = freshDates();
|
|
680
|
+
const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
|
|
681
|
+
const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
682
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (todayActive.length === 0) {
|
|
686
|
+
const legacy = sessionCloseFileStatus(hypoDir);
|
|
687
|
+
return {
|
|
688
|
+
ok: legacy.ok,
|
|
689
|
+
projects: legacy.project
|
|
690
|
+
? [{ project: legacy.project, ok: legacy.ok, stale: legacy.stale, missing: legacy.missing }]
|
|
691
|
+
: [],
|
|
692
|
+
dates,
|
|
693
|
+
fallback: true,
|
|
694
|
+
primary: legacy.project,
|
|
695
|
+
project: legacy.project,
|
|
696
|
+
stale: legacy.stale,
|
|
697
|
+
missing: legacy.missing,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// primary = the recency project when it is itself today-active, else the first
|
|
702
|
+
// today-active slug (stable order from the candidate set). Used only as the
|
|
703
|
+
// single-slug alias (marker `project` field, message header) — never to gate.
|
|
704
|
+
const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
|
|
705
|
+
const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
|
|
706
|
+
|
|
707
|
+
const projects = ordered.map((p) => {
|
|
708
|
+
const s = sessionCloseFileStatus(hypoDir, { projectOverride: p });
|
|
709
|
+
return { project: p, ok: s.ok, stale: s.stale, missing: s.missing };
|
|
710
|
+
});
|
|
711
|
+
const ok = projects.every((x) => x.ok);
|
|
712
|
+
const stale = [...new Set(projects.flatMap((x) => x.stale))];
|
|
713
|
+
const missing = [...new Set(projects.flatMap((x) => x.missing))];
|
|
714
|
+
return { ok, projects, dates, fallback: false, primary, project: primary, stale, missing };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── derivable-artifact auto-derive: root log.md session entry ──────────────────
|
|
718
|
+
// The root log.md `## [date] session | <slug>` entry is a DERIVABLE artifact —
|
|
719
|
+
// it restates a project's session-log heading, which the close already authored.
|
|
720
|
+
// root hot.md is already auto-derived here (rebuild() above); log.md was the only
|
|
721
|
+
// derivable still left as a manual checklist step, so a hand-edited close that
|
|
722
|
+
// skipped it left the global gate (sessionCloseGlobalStatus) hard-blocking /compact
|
|
723
|
+
// for EVERY today-active project — cross-session, looking like a fresh bug each
|
|
724
|
+
// time. This derives the missing entry from the session-log heading so the gate
|
|
725
|
+
// never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
|
|
726
|
+
// here: it is a proof artifact the close gate actually ran (ADR 0022 invariant).
|
|
727
|
+
//
|
|
728
|
+
// Safety guard (codex design review): derive ONLY for a project whose close is
|
|
729
|
+
// otherwise complete — i.e. its sole gate problem is log.md. If session-state /
|
|
730
|
+
// project hot / session-log are also stale/missing, the authored close is genuinely
|
|
731
|
+
// incomplete and MUST keep blocking; deriving log.md then would mask it.
|
|
732
|
+
|
|
733
|
+
// Build the canonical root log.md heading from a raw session-log heading tail.
|
|
734
|
+
// The gate's session-log freshness check accepts ANY `## [date] ...` heading, but
|
|
735
|
+
// the log.md check requires `## [date] session | <slug>` — so normalise to that
|
|
736
|
+
// shape rather than copying a heading that might not carry `session | <slug>`.
|
|
737
|
+
function deriveLogTitle(tail) {
|
|
738
|
+
let t = (tail || '').trim();
|
|
739
|
+
t = t.replace(/^session\b\s*/i, ''); // drop a leading "session" token
|
|
740
|
+
if (t.startsWith('|')) {
|
|
741
|
+
// Drop a leading "| <old label/slug>" segment up to the first em-dash, so a
|
|
742
|
+
// renamed-project heading (`| oldslug — title`) does not leak its old slug
|
|
743
|
+
// into the derived title. A pipe segment with no separator is a bare label
|
|
744
|
+
// (e.g. `| slug`) → no title.
|
|
745
|
+
const dash = t.indexOf('—');
|
|
746
|
+
t = dash === -1 ? '' : t.slice(dash);
|
|
747
|
+
}
|
|
748
|
+
return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Append any missing root log.md `## [date] session | <slug>` entries derived from
|
|
753
|
+
* each today-active project's session-log heading(s). Idempotent: dedups on the
|
|
754
|
+
* exact generated heading line, so re-running (or a same-day apply that already
|
|
755
|
+
* wrote the entry) is a no-op, and multiple same-day sessions each get their own
|
|
756
|
+
* entry. Best-effort and read-mostly: returns the number of entries appended.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} hypoDir
|
|
759
|
+
* @returns {number} count of entries appended to log.md
|
|
760
|
+
*/
|
|
761
|
+
export function deriveRootLogEntries(hypoDir) {
|
|
762
|
+
const logPath = join(hypoDir, 'log.md');
|
|
763
|
+
if (!existsSync(logPath)) return 0;
|
|
764
|
+
const dates = freshDates();
|
|
765
|
+
const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
766
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
767
|
+
);
|
|
768
|
+
if (todayActive.length === 0) return 0;
|
|
769
|
+
|
|
770
|
+
let logContent;
|
|
771
|
+
try {
|
|
772
|
+
logContent = readFileSync(logPath, 'utf-8');
|
|
773
|
+
} catch {
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
|
|
778
|
+
// prefix of a titled one (`## [d] session | a — first`), so substring checks
|
|
779
|
+
// would wrongly drop a distinct same-day session. Track whole heading lines.
|
|
780
|
+
const seenHeadings = new Set((logContent || '').split(/\r?\n/));
|
|
781
|
+
const additions = [];
|
|
782
|
+
for (const slug of todayActive) {
|
|
783
|
+
// Guard: only the log.md entry may be the gap; an otherwise-incomplete close
|
|
784
|
+
// must keep blocking (do not mask missing authored files).
|
|
785
|
+
const st = sessionCloseFileStatus(hypoDir, { projectOverride: slug });
|
|
786
|
+
const problems = [...st.stale, ...st.missing];
|
|
787
|
+
if (!(problems.length === 1 && problems[0] === 'log.md')) continue;
|
|
788
|
+
|
|
789
|
+
for (const date of dates) {
|
|
790
|
+
// Pick the candidate that actually CARRIES this date's heading (daily shard
|
|
791
|
+
// preferred), mirroring the freshness gate's resolution. Selecting merely
|
|
792
|
+
// the first *existing* file would diverge from freshness: a header-only
|
|
793
|
+
// daily shard (e.g. a seeded-but-not-yet-appended file) would be chosen and
|
|
794
|
+
// the real heading in the legacy monthly fallback missed — freshness would
|
|
795
|
+
// pass while derive failed to recover the log.md entry.
|
|
796
|
+
let slog = null;
|
|
797
|
+
for (const rel of sessionLogReadCandidates(slug, date)) {
|
|
798
|
+
const slogPath = join(hypoDir, rel);
|
|
799
|
+
if (!existsSync(slogPath)) continue;
|
|
800
|
+
let content;
|
|
801
|
+
try {
|
|
802
|
+
content = readFileSync(slogPath, 'utf-8');
|
|
803
|
+
} catch {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (!hasSessionLogHeading(content, date)) continue;
|
|
807
|
+
slog = content;
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
if (slog === null) continue;
|
|
811
|
+
const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
|
|
812
|
+
let m;
|
|
813
|
+
while ((m = headingRe.exec(slog)) !== null) {
|
|
814
|
+
const title = deriveLogTitle(m[1]);
|
|
815
|
+
const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
|
|
816
|
+
if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
|
|
817
|
+
seenHeadings.add(heading);
|
|
818
|
+
additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (additions.length === 0) return 0;
|
|
824
|
+
const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
|
|
825
|
+
try {
|
|
826
|
+
writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
|
|
827
|
+
} catch {
|
|
828
|
+
return 0;
|
|
829
|
+
}
|
|
830
|
+
return additions.length;
|
|
831
|
+
}
|
|
832
|
+
|
|
424
833
|
// ── sync-state ────────────────────────────────────────────
|
|
425
834
|
// `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
|
|
426
|
-
// line. hypo-auto-commit
|
|
427
|
-
//
|
|
428
|
-
// doctor
|
|
835
|
+
// line. hypo-auto-commit appends on pull/push failure; hypo-session-start
|
|
836
|
+
// surfaces open entries and clears them once sync is healthy again;
|
|
837
|
+
// doctor warns while entries remain. Keep the schema defined here only.
|
|
429
838
|
|
|
430
839
|
/** @returns {string} path to the sync-state JSONL file for a wiki root. */
|
|
431
840
|
function syncStatePath(hypoDir) {
|
|
@@ -461,6 +870,61 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
461
870
|
}
|
|
462
871
|
}
|
|
463
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
|
|
875
|
+
* remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
|
|
876
|
+
* network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
|
|
877
|
+
* --apply-session-close path so the .hypoignore staging filter cannot diverge
|
|
878
|
+
* between the two commit loci (ADR 0056).
|
|
879
|
+
*
|
|
880
|
+
* "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
|
|
881
|
+
* failure — the caller's tree is already in the committed state it wanted.
|
|
882
|
+
*
|
|
883
|
+
* @param {string} hypoDir
|
|
884
|
+
* @returns {{committed: boolean, reason?: string}} committed:true when a commit was
|
|
885
|
+
* created OR nothing needed committing; committed:false (with reason) on a real
|
|
886
|
+
* failure: not a git repo, or git status/add/commit erroring.
|
|
887
|
+
*/
|
|
888
|
+
export function commitWikiChanges(hypoDir) {
|
|
889
|
+
const git = (...args) =>
|
|
890
|
+
spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
|
|
891
|
+
if (git('rev-parse', '--is-inside-work-tree').status !== 0)
|
|
892
|
+
return { committed: false, reason: `not a git repository: ${hypoDir}` };
|
|
893
|
+
const porcelain = git('status', '--porcelain', '-uall');
|
|
894
|
+
if (porcelain.status !== 0)
|
|
895
|
+
return { committed: false, reason: `git status failed in ${hypoDir}` };
|
|
896
|
+
// `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
|
|
897
|
+
// enumerate changed paths, drop ignored ones, then stage explicitly.
|
|
898
|
+
const ignorePatterns = loadHypoIgnore(hypoDir);
|
|
899
|
+
const paths = [];
|
|
900
|
+
for (const line of (porcelain.stdout || '').split('\n')) {
|
|
901
|
+
if (!line) continue;
|
|
902
|
+
const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
|
|
903
|
+
if (!file) continue;
|
|
904
|
+
if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
|
|
905
|
+
continue;
|
|
906
|
+
paths.push(file);
|
|
907
|
+
}
|
|
908
|
+
if (paths.length > 0) {
|
|
909
|
+
const add = git('add', '--', ...paths);
|
|
910
|
+
if (add.status !== 0)
|
|
911
|
+
return {
|
|
912
|
+
committed: false,
|
|
913
|
+
reason: `git add failed: ${(add.stderr || '').trim() || 'unknown'}`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
|
|
917
|
+
if (!staged) return { committed: true }; // nothing to commit = success (idempotent)
|
|
918
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
919
|
+
const commit = git('commit', '-m', `auto: ${today} wiki update`);
|
|
920
|
+
if (commit.status !== 0)
|
|
921
|
+
return {
|
|
922
|
+
committed: false,
|
|
923
|
+
reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
|
|
924
|
+
};
|
|
925
|
+
return { committed: true };
|
|
926
|
+
}
|
|
927
|
+
|
|
464
928
|
/**
|
|
465
929
|
* Read sync-state entries.
|
|
466
930
|
* @param {string} hypoDir
|
|
@@ -727,19 +1191,25 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
|
|
|
727
1191
|
*
|
|
728
1192
|
* @param {string} hypoDir
|
|
729
1193
|
* @param {string} sessionId
|
|
730
|
-
* @param {{project?: string, transcript_path?: string}} info
|
|
1194
|
+
* @param {{project?: string, scope?: string, transcript_path?: string}} info
|
|
731
1195
|
*/
|
|
732
1196
|
export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
733
1197
|
if (!sessionId) return;
|
|
734
1198
|
try {
|
|
735
1199
|
const cacheDir = join(hypoDir, '.cache');
|
|
736
1200
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
1201
|
+
// scope distinguishes a project close (the 5 mandatory files were verified
|
|
1202
|
+
// fresh) from a log-only close (a non-project session, no project
|
|
1203
|
+
// attribution). Readers (precompactGateStatus / --check-session-close) key
|
|
1204
|
+
// the gate semantics on this field, so it must be recorded.
|
|
1205
|
+
const scope = info.scope === 'log-only' ? 'log-only' : 'project';
|
|
737
1206
|
const payload = {
|
|
738
1207
|
session_id: sessionId,
|
|
739
1208
|
project: info.project || null,
|
|
1209
|
+
scope,
|
|
740
1210
|
transcript_path: info.transcript_path || null,
|
|
741
1211
|
closed_at: new Date().toISOString(),
|
|
742
|
-
verification: 'session-close-file-status:ok',
|
|
1212
|
+
verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
|
|
743
1213
|
};
|
|
744
1214
|
writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
|
|
745
1215
|
} catch (err) {
|
|
@@ -790,15 +1260,33 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
|
|
|
790
1260
|
}
|
|
791
1261
|
}
|
|
792
1262
|
|
|
793
|
-
// ── transcript activity heuristic (ADR 0022 amendment 2026-05-19) ──
|
|
794
|
-
// Substantial-session gate for the Stop hook: a session
|
|
795
|
-
//
|
|
796
|
-
//
|
|
1263
|
+
// ── transcript activity heuristic (ADR 0022 amendment 2026-05-19; 6a 2026-06-14) ──
|
|
1264
|
+
// Substantial-session gate for the Stop hook: a session "worth" blocking on for
|
|
1265
|
+
// session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
|
|
1266
|
+
// or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
|
|
1267
|
+
// Read/Grep/Glob/Bash calls). Pure Q&A / incidental lookups skip the block.
|
|
1268
|
+
//
|
|
1269
|
+
// 6a rationale: code-review / debugging sessions reach real conclusions worth
|
|
1270
|
+
// crystallizing while touching only read-only tools. The original gate keyed
|
|
1271
|
+
// purely on mutation tools, so those sessions were never nudged to close. The
|
|
1272
|
+
// investigation threshold (5) mirrors session-audit.mjs's "search-many" cutoff
|
|
1273
|
+
// (scripts/session-audit.mjs:206) and sits in the empirical gap between
|
|
1274
|
+
// incidental lookups (0–3 calls) and real investigation (16–22 calls). Bash is
|
|
1275
|
+
// now counted (it is the dominant signal in read-only sessions); over-firing is
|
|
1276
|
+
// bounded by the close-intent gate (the Stop hook only blocks when the user also
|
|
1277
|
+
// signalled wrap-up) — see hypo-auto-minimal-crystallize.mjs.
|
|
797
1278
|
//
|
|
798
|
-
// Bash
|
|
799
|
-
//
|
|
1279
|
+
// NOTE: counting a Bash call here does NOT widen the lint scope. Lint scoping
|
|
1280
|
+
// (precompactGateStatus) still seeds only Edit/Write-touched files plus the
|
|
1281
|
+
// mandatory close files — a Bash-written wiki file is not auto-scoped.
|
|
800
1282
|
|
|
801
1283
|
const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
1284
|
+
// Read-only investigation tools. Bash included: read-only sessions are
|
|
1285
|
+
// Bash-dominant (git/grep/cat), so excluding it would leave most of them
|
|
1286
|
+
// undetectable (a real session had read=0, bash=16).
|
|
1287
|
+
const INVESTIGATION_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'Bash']);
|
|
1288
|
+
// Investigation-volume cutoff for a read-only session to count as substantial.
|
|
1289
|
+
const READONLY_SUBSTANTIAL_THRESHOLD = 5;
|
|
802
1290
|
|
|
803
1291
|
/** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
|
|
804
1292
|
* `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
|
|
@@ -828,27 +1316,30 @@ function extractTranscriptToolNames(entry) {
|
|
|
828
1316
|
}
|
|
829
1317
|
|
|
830
1318
|
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
1319
|
+
* Single-pass tool-use census over a JSONL transcript. Both public predicates
|
|
1320
|
+
* (`hasMutatingTranscriptActivity`, `isSubstantialSession`) derive from this one
|
|
1321
|
+
* census so the mutation leg stays identical between them. (They still diverge
|
|
1322
|
+
* on read-only ≥ threshold sessions, where only `isSubstantialSession` is true.)
|
|
833
1323
|
*
|
|
834
|
-
* Granularity:
|
|
835
|
-
* • Whole-file unreadable / missing path →
|
|
1324
|
+
* Granularity (shared contract):
|
|
1325
|
+
* • Whole-file unreadable / missing path → all counts 0 (fail-open).
|
|
836
1326
|
* • Per-line malformed JSON → that line is skipped, scan continues. Real
|
|
837
1327
|
* transcripts occasionally carry truncated lines; one bad line must not
|
|
838
|
-
* hide a clearly-
|
|
1328
|
+
* hide a clearly-active session that follows. (Codex Worker-2 CONCERN
|
|
839
1329
|
* resolved 2026-05-19: line-level skip is the intended contract.)
|
|
840
1330
|
*
|
|
841
1331
|
* @param {string|null|undefined} transcriptPath
|
|
842
|
-
* @returns {
|
|
1332
|
+
* @returns {{ mutationCount: number, investigationCount: number }}
|
|
843
1333
|
*/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
if (!
|
|
1334
|
+
function transcriptActivityStats(transcriptPath) {
|
|
1335
|
+
const stats = { mutationCount: 0, investigationCount: 0 };
|
|
1336
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return stats;
|
|
1337
|
+
if (!existsSync(transcriptPath)) return stats;
|
|
847
1338
|
let raw;
|
|
848
1339
|
try {
|
|
849
1340
|
raw = readFileSync(transcriptPath, 'utf-8');
|
|
850
1341
|
} catch {
|
|
851
|
-
return
|
|
1342
|
+
return stats;
|
|
852
1343
|
}
|
|
853
1344
|
for (const line of raw.split('\n')) {
|
|
854
1345
|
const trimmed = line.trim();
|
|
@@ -860,10 +1351,41 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
|
860
1351
|
continue;
|
|
861
1352
|
}
|
|
862
1353
|
for (const name of extractTranscriptToolNames(entry)) {
|
|
863
|
-
if (MUTATING_TOOL_NAMES.has(name))
|
|
1354
|
+
if (MUTATING_TOOL_NAMES.has(name)) stats.mutationCount++;
|
|
1355
|
+
else if (INVESTIGATION_TOOL_NAMES.has(name)) stats.investigationCount++;
|
|
864
1356
|
}
|
|
865
1357
|
}
|
|
866
|
-
return
|
|
1358
|
+
return stats;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
|
|
1363
|
+
* tool_use (Edit/Write/MultiEdit/NotebookEdit). Kept as a precise, standalone
|
|
1364
|
+
* helper (and regression oracle) even though the Stop hook now gates on the
|
|
1365
|
+
* broader `isSubstantialSession`.
|
|
1366
|
+
*
|
|
1367
|
+
* @param {string|null|undefined} transcriptPath
|
|
1368
|
+
* @returns {boolean}
|
|
1369
|
+
*/
|
|
1370
|
+
export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
1371
|
+
return transcriptActivityStats(transcriptPath).mutationCount > 0;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* True if the session is "substantial" enough to nudge a session-close:
|
|
1376
|
+
* any mutation, OR a read-only investigation of at least
|
|
1377
|
+
* READONLY_SUBSTANTIAL_THRESHOLD Read/Grep/Glob/Bash calls (6a). The mutation
|
|
1378
|
+
* leg is identical to `hasMutatingTranscriptActivity`, so mutating sessions
|
|
1379
|
+
* behave exactly as before; only high-volume read-only sessions are newly
|
|
1380
|
+
* caught. Over-firing on read-only sessions is bounded downstream by the
|
|
1381
|
+
* close-intent gate in hypo-auto-minimal-crystallize.mjs.
|
|
1382
|
+
*
|
|
1383
|
+
* @param {string|null|undefined} transcriptPath
|
|
1384
|
+
* @returns {boolean}
|
|
1385
|
+
*/
|
|
1386
|
+
export function isSubstantialSession(transcriptPath) {
|
|
1387
|
+
const { mutationCount, investigationCount } = transcriptActivityStats(transcriptPath);
|
|
1388
|
+
return mutationCount > 0 || investigationCount >= READONLY_SUBSTANTIAL_THRESHOLD;
|
|
867
1389
|
}
|
|
868
1390
|
|
|
869
1391
|
// ── session-scoped lint classification ──────────────────────────────────────
|
|
@@ -959,8 +1481,35 @@ export function closeFileTargets(hypoDir) {
|
|
|
959
1481
|
if (project) {
|
|
960
1482
|
out.add(`projects/${project}/session-state.md`);
|
|
961
1483
|
out.add(`projects/${project}/hot.md`);
|
|
962
|
-
|
|
963
|
-
|
|
1484
|
+
out.add(sessionLogScopePath(hypoDir, project, freshDates()[0]));
|
|
1485
|
+
}
|
|
1486
|
+
return out;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Global variant of closeFileTargets for the no-payload lint-scope callers
|
|
1491
|
+
* (ADR 0043). Union of the close files over every today-active project
|
|
1492
|
+
* (fallback: the recency project when none is active). Includes the session-log
|
|
1493
|
+
* evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
|
|
1494
|
+
* what sessionCloseFileStatus actually checks across a local/UTC date boundary.
|
|
1495
|
+
*/
|
|
1496
|
+
export function closeFileTargetsGlobal(hypoDir) {
|
|
1497
|
+
const dates = freshDates();
|
|
1498
|
+
const out = new Set(['hot.md', 'log.md']);
|
|
1499
|
+
let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
1500
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
1501
|
+
);
|
|
1502
|
+
if (active.length === 0) {
|
|
1503
|
+
const recency = resolveActiveProject(hypoDir);
|
|
1504
|
+
if (recency) active = [recency];
|
|
1505
|
+
}
|
|
1506
|
+
for (const p of active) {
|
|
1507
|
+
out.add(`projects/${p}/session-state.md`);
|
|
1508
|
+
out.add(`projects/${p}/hot.md`);
|
|
1509
|
+
// Scope to the file each date's freshness is PROVEN by (daily shard or, via
|
|
1510
|
+
// fallback, the legacy monthly), so a corrupt evidence file can't pass the
|
|
1511
|
+
// gate while its lint error is demoted to an out-of-scope notice.
|
|
1512
|
+
for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
|
|
964
1513
|
}
|
|
965
1514
|
return out;
|
|
966
1515
|
}
|
|
@@ -993,6 +1542,264 @@ export function partitionLintScope(findings, scope) {
|
|
|
993
1542
|
return { blocking, notice };
|
|
994
1543
|
}
|
|
995
1544
|
|
|
1545
|
+
// ── PreCompact gate — single source of truth ────────────────────────────────
|
|
1546
|
+
/**
|
|
1547
|
+
* The full PreCompact gate decision as a READ-ONLY status. This is the single
|
|
1548
|
+
* source of truth for "is the wiki compact-ready?": both hypo-personal-check.mjs
|
|
1549
|
+
* (the PreCompact hook) and `crystallize --check-session-close` call it, so a
|
|
1550
|
+
* green status means /compact will not block on a human-fixable issue.
|
|
1551
|
+
*
|
|
1552
|
+
* Read-only: feedback projection PURE drift is reported as a non-blocking notice
|
|
1553
|
+
* with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
|
|
1554
|
+
* the hook self-heals it with `feedback-sync --write` before continuing (ADR
|
|
1555
|
+
* 0045) and a verify caller needs no human action for it. over-cap and conflict
|
|
1556
|
+
* DO block (ADR 0031 rules 3 & 6 — human demote/import required).
|
|
1557
|
+
*
|
|
1558
|
+
* Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
|
|
1559
|
+
* has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
|
|
1560
|
+
* bypass, and a fail-closed if the self-heal `--write` itself errors. And without
|
|
1561
|
+
* a transcript the lint scope is the mandatory close files only; pass
|
|
1562
|
+
* opts.transcriptPath to widen it to the session's edited files exactly as the
|
|
1563
|
+
* hook does.
|
|
1564
|
+
*
|
|
1565
|
+
* @param {string} hypoDir
|
|
1566
|
+
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
|
|
1567
|
+
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
1568
|
+
*/
|
|
1569
|
+
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
1570
|
+
const blockers = [];
|
|
1571
|
+
const notices = [];
|
|
1572
|
+
const driftTargets = [];
|
|
1573
|
+
const skipped = { lint: false, feedback: false };
|
|
1574
|
+
|
|
1575
|
+
// log-only synthetic close mode. A non-project (tooling / wiki-only)
|
|
1576
|
+
// session has no project to close. Activated either by the explicit writer flag
|
|
1577
|
+
// (opts.logOnly, from `--mark-session-closed --log-only`) or by a log-only marker
|
|
1578
|
+
// for opts.sessionId (the PreCompact / --check-session-close readers). In this
|
|
1579
|
+
// mode the project-close invariant is replaced by a today log.md entry (minimum
|
|
1580
|
+
// proof), and — critically — the active/phantom project is NEVER put in
|
|
1581
|
+
// close.projects, because that set ALSO drives the lint scope and W8 ownership
|
|
1582
|
+
// below. Exempting only the close blocker would still let an unrelated project's
|
|
1583
|
+
// stale design-history / lint block the non-project session (codex design
|
|
1584
|
+
// BLOCKER). git / hot / lint(self) / feedback all still apply — not a bypass.
|
|
1585
|
+
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
1586
|
+
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
1587
|
+
|
|
1588
|
+
// 1. wiki git state (ADR 0056). Uncommitted changes (real unsaved work) BLOCK —
|
|
1589
|
+
// they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
|
|
1590
|
+
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
1591
|
+
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
1592
|
+
// here (the shared gate) keeps the marker == compact-ready invariant (ADR 0047):
|
|
1593
|
+
// a committed-but-unpushed close marks AND compacts, instead of the close writer
|
|
1594
|
+
// committing its own payload and then being blocked by its own (unpushed) commit.
|
|
1595
|
+
const git = hypoIsClean(hypoDir);
|
|
1596
|
+
if (git.uncommitted) blockers.push({ type: 'git', reason: git.reason });
|
|
1597
|
+
else if (git.ahead)
|
|
1598
|
+
notices.push({
|
|
1599
|
+
type: 'git-sync',
|
|
1600
|
+
reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
// 2. root hot.md structure
|
|
1604
|
+
const hot = hotMdIsClean(hypoDir);
|
|
1605
|
+
if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
|
|
1606
|
+
|
|
1607
|
+
// 3. session-close files (global invariant, ADR 0043) — or, in log-only mode,
|
|
1608
|
+
// the minimum proof (a today log.md entry) with NO project attribution.
|
|
1609
|
+
let close;
|
|
1610
|
+
if (logOnly) {
|
|
1611
|
+
const hasLog = hasAnyTodayLogEntry(hypoDir);
|
|
1612
|
+
close = {
|
|
1613
|
+
ok: hasLog,
|
|
1614
|
+
projects: [],
|
|
1615
|
+
dates: freshDates(),
|
|
1616
|
+
fallback: false,
|
|
1617
|
+
primary: null,
|
|
1618
|
+
project: null,
|
|
1619
|
+
stale: [],
|
|
1620
|
+
missing: hasLog ? [] : ['log.md (no today session entry)'],
|
|
1621
|
+
};
|
|
1622
|
+
if (!hasLog) {
|
|
1623
|
+
blockers.push({
|
|
1624
|
+
type: 'close',
|
|
1625
|
+
reason: 'log-only close: log.md has no today session entry (minimum proof)',
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
} else {
|
|
1629
|
+
close = sessionCloseGlobalStatus(hypoDir);
|
|
1630
|
+
if (!close.ok) {
|
|
1631
|
+
blockers.push({
|
|
1632
|
+
type: 'close',
|
|
1633
|
+
reason: `memory files not updated this session: ${[
|
|
1634
|
+
...close.missing.map((f) => `${f} (missing)`),
|
|
1635
|
+
...close.stale.map((f) => `${f} (stale)`),
|
|
1636
|
+
].join(', ')}`,
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
|
|
1642
|
+
const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
|
|
1643
|
+
if (!lintPath || !existsSync(lintPath)) {
|
|
1644
|
+
skipped.lint = true; // no package → fail-open (never block on missing tooling)
|
|
1645
|
+
} else {
|
|
1646
|
+
try {
|
|
1647
|
+
// Pass --hypo-dir explicitly: lint.mjs resolves the vault via HYPO_DIR /
|
|
1648
|
+
// home dirs and ignores cwd, so a --hypo-dir caller (crystallize, tests)
|
|
1649
|
+
// would otherwise lint the ambient wiki, not the one under test.
|
|
1650
|
+
// maxBuffer matches crystallize's runLint (64 MiB): warn-heavy output on a
|
|
1651
|
+
// large wiki easily exceeds Node's 1 MiB default, which would truncate
|
|
1652
|
+
// stdout and (via the catch below) silently fail-open this gate.
|
|
1653
|
+
const r = spawnSync('node', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
|
|
1654
|
+
encoding: 'utf-8',
|
|
1655
|
+
cwd: hypoDir,
|
|
1656
|
+
timeout: 30000,
|
|
1657
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1658
|
+
});
|
|
1659
|
+
// A spawn failure (ENOENT), a timeout/kill (status === null), or a crash
|
|
1660
|
+
// that produced no stdout must NOT be parsed as `{}` and treated as a clean
|
|
1661
|
+
// lint — that path leaves skipped.lint=false with no notice, an INVISIBLE
|
|
1662
|
+
// fail-open. Throw instead so the catch below records skipped.lint=true WITH
|
|
1663
|
+
// a reason notice.
|
|
1664
|
+
if (r.error || r.status === null) {
|
|
1665
|
+
throw new Error(`lint spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
|
|
1666
|
+
}
|
|
1667
|
+
if (!r.stdout || !r.stdout.trim()) {
|
|
1668
|
+
throw new Error(
|
|
1669
|
+
`lint produced no stdout (exit=${r.status})${r.stderr ? `: ${r.stderr.slice(-500)}` : ''}`,
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
const parsed = JSON.parse(r.stdout);
|
|
1673
|
+
const allErrors = parsed.errors || [];
|
|
1674
|
+
const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
|
|
1675
|
+
// log-only base scope = the shared root files only (hot.md / log.md) — NOT
|
|
1676
|
+
// closeFileTargetsGlobal, which would fold the active/phantom project's
|
|
1677
|
+
// mandatory files in and re-introduce the cross-project attribution. The
|
|
1678
|
+
// session's own transcript-touched files are still added below (a log-only
|
|
1679
|
+
// session is accountable for the wiki files it actually edited).
|
|
1680
|
+
const scope = new Set(
|
|
1681
|
+
opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
|
|
1682
|
+
);
|
|
1683
|
+
if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
|
|
1684
|
+
for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
|
|
1685
|
+
}
|
|
1686
|
+
const part = partitionLintScope(allErrors, scope);
|
|
1687
|
+
if (part.blocking.length > 0) {
|
|
1688
|
+
blockers.push({
|
|
1689
|
+
type: 'lint',
|
|
1690
|
+
reason: `lint blockers: ${[...new Set(part.blocking.map((b) => b.id || b.file))].join(', ')}`,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
for (const n of part.notice)
|
|
1694
|
+
notices.push({ type: 'lint', reason: `${n.file}${n.id ? ` (${n.id})` : ''}` });
|
|
1695
|
+
// W8 (design-history stale) is each today-active project's own close
|
|
1696
|
+
// responsibility; others' are non-blocking notices (ADR 0043). In log-only
|
|
1697
|
+
// mode there is NO project this session is accountable for, so every W8 is a
|
|
1698
|
+
// notice — a non-project session must never be blocked by some project's
|
|
1699
|
+
// stale design-history (codex design BLOCKER: the attribution leak this fix
|
|
1700
|
+
// closes).
|
|
1701
|
+
const activeSlugs = logOnly
|
|
1702
|
+
? []
|
|
1703
|
+
: (close.projects || []).map((p) => p.project).filter(Boolean);
|
|
1704
|
+
const mine = new Set(activeSlugs.map((s) => `projects/${s}/design-history.md`));
|
|
1705
|
+
const w8Blocking = logOnly
|
|
1706
|
+
? []
|
|
1707
|
+
: activeSlugs.length > 0
|
|
1708
|
+
? allW8.filter((w) => mine.has(w.file))
|
|
1709
|
+
: allW8;
|
|
1710
|
+
const w8Notice = logOnly
|
|
1711
|
+
? allW8
|
|
1712
|
+
: activeSlugs.length > 0
|
|
1713
|
+
? allW8.filter((w) => !mine.has(w.file))
|
|
1714
|
+
: [];
|
|
1715
|
+
if (w8Blocking.length > 0) {
|
|
1716
|
+
blockers.push({
|
|
1717
|
+
type: 'design-history',
|
|
1718
|
+
reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
for (const w of w8Notice) notices.push({ type: 'design-history', reason: w.file });
|
|
1722
|
+
} catch (e) {
|
|
1723
|
+
skipped.lint = true; // fail-open on tooling error
|
|
1724
|
+
// Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
|
|
1725
|
+
// instead of silently dropping the check — a fail-open is invisible otherwise.
|
|
1726
|
+
notices.push({
|
|
1727
|
+
type: 'lint',
|
|
1728
|
+
reason: `lint skipped (fail-open): ${e.message || e.code || 'unknown error'}`,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// 5. feedback projection (ADR 0031 / 0045). over-cap/conflict block; pure
|
|
1734
|
+
// drift is a self-healable notice (driftTargets = effect requirement the
|
|
1735
|
+
// hook runs as --write). Classification mirrors hypo-personal-check exactly.
|
|
1736
|
+
const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
|
|
1737
|
+
const claudeHome = opts.claudeHome || join(HOME, '.claude');
|
|
1738
|
+
if (!feedbackPath || !existsSync(feedbackPath)) {
|
|
1739
|
+
skipped.feedback = true;
|
|
1740
|
+
} else {
|
|
1741
|
+
try {
|
|
1742
|
+
const r = spawnSync(
|
|
1743
|
+
process.execPath,
|
|
1744
|
+
[
|
|
1745
|
+
feedbackPath,
|
|
1746
|
+
'--check',
|
|
1747
|
+
'--strict',
|
|
1748
|
+
'--no-input',
|
|
1749
|
+
'--json',
|
|
1750
|
+
`--hypo-dir=${hypoDir}`,
|
|
1751
|
+
`--claude-home=${claudeHome}`,
|
|
1752
|
+
],
|
|
1753
|
+
{ encoding: 'utf-8', timeout: 30000 },
|
|
1754
|
+
);
|
|
1755
|
+
if (r.error || r.status === null) {
|
|
1756
|
+
skipped.feedback = true; // spawn failure → fail-open
|
|
1757
|
+
} else if (r.status !== 0) {
|
|
1758
|
+
// Only a non-zero exit carries an actionable issue. A clean check exits 0
|
|
1759
|
+
// (the implicit else below) — that is NOT skipped, just nothing to do.
|
|
1760
|
+
let report = null;
|
|
1761
|
+
try {
|
|
1762
|
+
report = JSON.parse(r.stdout || '');
|
|
1763
|
+
} catch {
|
|
1764
|
+
/* unparseable → fail-open below */
|
|
1765
|
+
}
|
|
1766
|
+
const entries = report ? Object.entries(report.targets || {}) : [];
|
|
1767
|
+
const conflictedT = entries
|
|
1768
|
+
.filter(
|
|
1769
|
+
([, t]) =>
|
|
1770
|
+
t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
|
|
1771
|
+
)
|
|
1772
|
+
.map(([n]) => n);
|
|
1773
|
+
const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
|
|
1774
|
+
const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
|
|
1775
|
+
if (!report || !(conflictedT.length || overCapT.length || driftedT.length)) {
|
|
1776
|
+
skipped.feedback = true; // buildError / unparseable / non-actionable → fail-open
|
|
1777
|
+
} else if (conflictedT.length) {
|
|
1778
|
+
blockers.push({
|
|
1779
|
+
type: 'feedback',
|
|
1780
|
+
reason: `feedback projection conflict (manual edit of ${conflictedT.join(', ')}) — run \`hypomnema feedback-sync --import-target-change --from=<memory|claude>\``,
|
|
1781
|
+
});
|
|
1782
|
+
} else if (overCapT.length) {
|
|
1783
|
+
blockers.push({
|
|
1784
|
+
type: 'feedback',
|
|
1785
|
+
reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
|
|
1786
|
+
});
|
|
1787
|
+
} else {
|
|
1788
|
+
driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
|
|
1789
|
+
notices.push({
|
|
1790
|
+
type: 'feedback',
|
|
1791
|
+
reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
} catch {
|
|
1796
|
+
skipped.feedback = true;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
996
1803
|
// ── session-close checklist ────────────────────────────────────────────────
|
|
997
1804
|
|
|
998
1805
|
/**
|
|
@@ -1036,18 +1843,20 @@ export function isClearCommand(prompt) {
|
|
|
1036
1843
|
return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
|
|
1037
1844
|
}
|
|
1038
1845
|
|
|
1039
|
-
/** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2
|
|
1846
|
+
/** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2). */
|
|
1040
1847
|
export function isCompactOrClearCommand(prompt) {
|
|
1041
1848
|
return isCompactCommand(prompt) || isClearCommand(prompt);
|
|
1042
1849
|
}
|
|
1043
1850
|
|
|
1044
1851
|
/**
|
|
1045
1852
|
* Extract recent user-role message text from a JSONL transcript (last `tailN`
|
|
1046
|
-
* lines). Promoted from hypo-personal-check.mjs
|
|
1853
|
+
* lines). Promoted from hypo-personal-check.mjs so both the
|
|
1047
1854
|
* PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
|
|
1048
1855
|
* signal source. Claude Code transcript format: each line is
|
|
1049
1856
|
* `{ type:"user", message:{ role:"user", content: ... } }`; the older
|
|
1050
|
-
* top-level `{ role, content }` shape is also accepted.
|
|
1857
|
+
* top-level `{ role, content }` shape is also accepted. tool_result blocks
|
|
1858
|
+
* (also role:'user') are excluded so tool output never feeds the close-intent
|
|
1859
|
+
* gate — only top-level `type:'text'` blocks and string content count.
|
|
1051
1860
|
*
|
|
1052
1861
|
* @param {string} transcriptPath
|
|
1053
1862
|
* @param {number} tailN how many trailing lines to scan (default 30)
|
|
@@ -1056,16 +1865,49 @@ export function isCompactOrClearCommand(prompt) {
|
|
|
1056
1865
|
export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
1057
1866
|
try {
|
|
1058
1867
|
const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
1059
|
-
|
|
1868
|
+
// tailN === Infinity → whole transcript (the marker-write hard gate needs the
|
|
1869
|
+
// full prefix; a close request can precede the marker by the entire close
|
|
1870
|
+
// checklist). The Stop hook keeps the 30-line default so a stale old close
|
|
1871
|
+
// signal doesn't re-trigger every turn.
|
|
1872
|
+
const tail = Number.isFinite(tailN) ? lines.slice(-tailN) : lines;
|
|
1060
1873
|
return tail
|
|
1061
1874
|
.map((line) => {
|
|
1062
1875
|
try {
|
|
1063
1876
|
const obj = JSON.parse(line);
|
|
1877
|
+
// Skill-injection vector (ADR 0055): drop system-injected role:user
|
|
1878
|
+
// messages before they pollute the close-intent signal.
|
|
1879
|
+
// • isMeta:true — slash-command bodies, skill bodies, local-command
|
|
1880
|
+
// caveats. Their text is docs/specs, often full of close vocabulary
|
|
1881
|
+
// (e.g. the /hypo:crystallize spec literally contains close phrases),
|
|
1882
|
+
// which would let the gate self-satisfy the moment the model invokes
|
|
1883
|
+
// a close command. Confirmed isMeta:true in the transcript.
|
|
1884
|
+
// • promptSource system|sdk — task-notifications (system) and
|
|
1885
|
+
// SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
|
|
1886
|
+
if (obj.isMeta === true) return '';
|
|
1887
|
+
if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
|
|
1064
1888
|
const msg = obj.message ?? obj;
|
|
1065
1889
|
const role = msg.role ?? obj.role ?? obj.type;
|
|
1066
1890
|
if (role !== 'user') return '';
|
|
1067
1891
|
const content = msg.content ?? obj.content;
|
|
1068
|
-
|
|
1892
|
+
if (typeof content === 'string') {
|
|
1893
|
+
// Stop-hook block feedback is recorded as a role:user string. It is
|
|
1894
|
+
// the hook's OWN nudge ("[WIKI_AUTOCLOSE] … Run crystallize …"), not
|
|
1895
|
+
// user intent — counting it would be circular (the hook that prods the
|
|
1896
|
+
// model to close would become proof the user wanted to close).
|
|
1897
|
+
return content.startsWith('Stop hook feedback') ? '' : content;
|
|
1898
|
+
}
|
|
1899
|
+
if (Array.isArray(content)) {
|
|
1900
|
+
// Only genuine user-typed text blocks. tool_result blocks are also
|
|
1901
|
+
// recorded with role:'user' in the Claude Code transcript; slurping
|
|
1902
|
+
// them via JSON.stringify let tool output (e.g. close-pattern example
|
|
1903
|
+
// strings read out of code/docs) trip the close-intent gate.
|
|
1904
|
+
// Do NOT recurse into tool_result.content, or the pollution returns.
|
|
1905
|
+
return content
|
|
1906
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
1907
|
+
.map((b) => b.text)
|
|
1908
|
+
.join('\n');
|
|
1909
|
+
}
|
|
1910
|
+
return '';
|
|
1069
1911
|
} catch {
|
|
1070
1912
|
return '';
|
|
1071
1913
|
}
|
|
@@ -1087,7 +1929,28 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
|
1087
1929
|
export function isClosePattern(text) {
|
|
1088
1930
|
if (!text || typeof text !== 'string') return false;
|
|
1089
1931
|
const krPatterns = [
|
|
1090
|
-
|
|
1932
|
+
// 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
|
|
1933
|
+
// "세션 끝나면" don't trip.
|
|
1934
|
+
/세션\s*끝(?![가-힣])/,
|
|
1935
|
+
// 세션 마무리/종료 (ADR 0055): the OLD pattern required a fixed verb suffix
|
|
1936
|
+
// (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
|
|
1937
|
+
// 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
|
|
1938
|
+
// blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
|
|
1939
|
+
// noun-modifiers are an open class. WHITELIST instead: match only when
|
|
1940
|
+
// 마무리/종료 is followed by a close-intent verb suffix OR sentence-end. This
|
|
1941
|
+
// structurally rejects noun-modifiers (세션 종료 여부/로직/작업 정리) and
|
|
1942
|
+
// negations (세션 종료 안 해도 돼, 세션 마무리하지 않아도) without enumerating.
|
|
1943
|
+
// The suffixes are COMPLETE terminal forms followed by a non-Hangul boundary
|
|
1944
|
+
// (?![가-힣]), so connective continuations die: 해주는/해주기 (해 alone, then
|
|
1945
|
+
// 주 follows), 해야 하는 / 해도 되는지 (해 alone, then 야/도 follows). 하고 is
|
|
1946
|
+
// deliberately dropped — "마무리하고 블로그"(close) and "마무리하고 싶은지"(not)
|
|
1947
|
+
// are structurally identical, and over-close is the worse failure, so we accept
|
|
1948
|
+
// the rare FN over the FP. The residual: connective forms that happen to put a
|
|
1949
|
+
// space after a complete terminal can't be separated by regex without a
|
|
1950
|
+
// morphological parser — that's bounded by the compound gate (precompact-green
|
|
1951
|
+
// + signal) and the unambiguous /compact and AskUserQuestion channels (ADR 0055
|
|
1952
|
+
// threat boundary), not chased further.
|
|
1953
|
+
/세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
|
|
1091
1954
|
/오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
|
|
1092
1955
|
// 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
|
|
1093
1956
|
/여기(서)?까지(?!\s*(?:구현|작성|완성|수정|변경|추가|삭제|테스트|확인|검토|해줘|해야|하고|하면))/,
|
|
@@ -1100,17 +1963,147 @@ export function isClosePattern(text) {
|
|
|
1100
1963
|
/오늘은?\s*이만/,
|
|
1101
1964
|
];
|
|
1102
1965
|
const enPatterns = [
|
|
1103
|
-
// wrap up: requires session-level context or sentence-end, not code-level
|
|
1104
|
-
/
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1966
|
+
// wrap up: requires session-level context or sentence-end, not code-level
|
|
1967
|
+
// objects. The review/analysis/debug/audit/investigation nouns were added
|
|
1968
|
+
// for 6a — read-only review sessions are now "substantial", so "wrap up the
|
|
1969
|
+
// review" must read as a task-level signal, not a session-close one.
|
|
1970
|
+
// Leading \b on each pattern (ADR 0055) so an EN close phrase embedded as a
|
|
1971
|
+
// substring of a longer token can't trip the gate (e.g. "designing off…").
|
|
1972
|
+
/\bwrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
|
|
1973
|
+
/\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
|
|
1974
|
+
/\bthat'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)\b/i,
|
|
1975
|
+
/\bsigning\s+off\b/i,
|
|
1976
|
+
/\bend(?:ing)?\s+(?:the|this)\s+(?:session|work|day)\b/i,
|
|
1977
|
+
/\bclose\s+(?:the|this)\s+session\b/i,
|
|
1110
1978
|
];
|
|
1111
1979
|
return [...krPatterns, ...enPatterns].some((re) => re.test(text));
|
|
1112
1980
|
}
|
|
1113
1981
|
|
|
1982
|
+
/**
|
|
1983
|
+
* Resolve a session's transcript path from its (globally-unique) session id by
|
|
1984
|
+
* globbing every Claude project dir: ~/.claude/projects/<slug>/<id>.jsonl.
|
|
1985
|
+
*
|
|
1986
|
+
* Why glob, not cwd-derive: the transcript lives under a slug built from the cwd
|
|
1987
|
+
* AT SESSION START, but the marker-writer subprocess can run from a DIFFERENT cwd
|
|
1988
|
+
* (the real over-close ran `cd ~/hypomnema && crystallize …`), so a cwd-derived
|
|
1989
|
+
* slug would miss the file. The session id is a UUID, so the glob disambiguates
|
|
1990
|
+
* without needing the slug — verified globally unique across all project dirs.
|
|
1991
|
+
*
|
|
1992
|
+
* Fail-closed on ambiguity (ADR 0055, codex review): returns the single resolved
|
|
1993
|
+
* path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
|
|
1994
|
+
* so a symlink to the same file is not "multiple"). The caller treats null as
|
|
1995
|
+
* "refuse the marker".
|
|
1996
|
+
*
|
|
1997
|
+
* `projectsRoot` defaults to the real ~/.claude/projects; tests pass a controlled
|
|
1998
|
+
* root so they exercise the real resolver instead of a forgeable path override.
|
|
1999
|
+
*/
|
|
2000
|
+
export function resolveTranscriptBySessionId(
|
|
2001
|
+
sessionId,
|
|
2002
|
+
projectsRoot = join(HOME, '.claude', 'projects'),
|
|
2003
|
+
) {
|
|
2004
|
+
if (!sessionId || typeof sessionId !== 'string') return null;
|
|
2005
|
+
// Session ids are UUID-shaped; reject anything with path separators / glob
|
|
2006
|
+
// chars so a crafted id can't escape the projects root or match siblings.
|
|
2007
|
+
if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
|
|
2008
|
+
try {
|
|
2009
|
+
const base = projectsRoot;
|
|
2010
|
+
const seen = new Set();
|
|
2011
|
+
for (const ent of readdirSync(base, { withFileTypes: true })) {
|
|
2012
|
+
if (!ent.isDirectory()) continue;
|
|
2013
|
+
const p = join(base, ent.name, `${sessionId}.jsonl`);
|
|
2014
|
+
if (!existsSync(p)) continue;
|
|
2015
|
+
try {
|
|
2016
|
+
seen.add(realpathSync(p));
|
|
2017
|
+
} catch {
|
|
2018
|
+
seen.add(p);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
return seen.size === 1 ? [...seen][0] : null;
|
|
2022
|
+
} catch {
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Returns true iff the transcript carries a genuine USER session-close signal —
|
|
2029
|
+
* the hard gate for the session-closed marker writers (ADR 0055). Scans the FULL
|
|
2030
|
+
* transcript: a close request can precede the marker write by the entire close
|
|
2031
|
+
* checklist, so the Stop hook's 30-line tail would miss it.
|
|
2032
|
+
*
|
|
2033
|
+
* Evidence (any one is sufficient):
|
|
2034
|
+
* 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
|
|
2035
|
+
* which already drops injected / tool / hook-feedback text (ADR 0055);
|
|
2036
|
+
* 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
|
|
2037
|
+
* counted: it abandons context, whereas a session-close PRESERVES the work
|
|
2038
|
+
* to the wiki — a different intent;
|
|
2039
|
+
* 3. an AskUserQuestion answer whose SELECTED value names a close action (the
|
|
2040
|
+
* canonical "offer [세션 마무리] → user picks it" flow).
|
|
2041
|
+
*
|
|
2042
|
+
* A Stop-hook block is NOT evidence: it is the hook's own nudge to close, so
|
|
2043
|
+
* counting it would be circular (the incident's block told the model to write the
|
|
2044
|
+
* marker). extractUserMessages already strips it.
|
|
2045
|
+
*
|
|
2046
|
+
* Fail-closed: any read error → false (caller refuses the marker).
|
|
2047
|
+
*/
|
|
2048
|
+
export function hasUserCloseSignal(transcriptPath) {
|
|
2049
|
+
if (!transcriptPath) return false;
|
|
2050
|
+
let lines;
|
|
2051
|
+
try {
|
|
2052
|
+
lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
2053
|
+
} catch {
|
|
2054
|
+
return false;
|
|
2055
|
+
}
|
|
2056
|
+
// (1) NL close over the full, de-polluted transcript.
|
|
2057
|
+
if (isClosePattern(extractUserMessages(transcriptPath, Infinity))) return true;
|
|
2058
|
+
// AskUserQuestion answers (3) must be correlated to a real AskUserQuestion
|
|
2059
|
+
// tool_use by id — otherwise ANY tool_result string containing "have been
|
|
2060
|
+
// answered" (e.g. a Read/Grep of this very file, or of a transcript) would
|
|
2061
|
+
// satisfy the gate, reintroducing the tool_result pollution the de-pollution
|
|
2062
|
+
// layer closes. First pass collects the genuine AskUserQuestion tool_use ids;
|
|
2063
|
+
// the tool_use (assistant) always precedes its tool_result (user) in the log,
|
|
2064
|
+
// so a single forward scan suffices.
|
|
2065
|
+
const askIds = new Set();
|
|
2066
|
+
for (const line of lines) {
|
|
2067
|
+
if (!line.trim()) continue;
|
|
2068
|
+
let obj;
|
|
2069
|
+
try {
|
|
2070
|
+
obj = JSON.parse(line);
|
|
2071
|
+
} catch {
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
// (2) /compact (queue-operation). Not /clear — see doc above.
|
|
2075
|
+
if (
|
|
2076
|
+
obj.type === 'queue-operation' &&
|
|
2077
|
+
typeof obj.content === 'string' &&
|
|
2078
|
+
/^\/compact(?:\s|$)/.test(obj.content.trim())
|
|
2079
|
+
) {
|
|
2080
|
+
return true;
|
|
2081
|
+
}
|
|
2082
|
+
const content = (obj.message ?? obj).content;
|
|
2083
|
+
if (!Array.isArray(content)) continue;
|
|
2084
|
+
for (const b of content) {
|
|
2085
|
+
if (!b || typeof b !== 'object') continue;
|
|
2086
|
+
// record AskUserQuestion tool_use ids
|
|
2087
|
+
if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
|
|
2088
|
+
askIds.add(b.id);
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
// (3) AskUserQuestion answer naming a close action — only when this
|
|
2092
|
+
// tool_result actually answers a recorded AskUserQuestion. The answer lands
|
|
2093
|
+
// in a role:user tool_result string: `… have been answered: "Q"="A". …`.
|
|
2094
|
+
// Match the answer value(s) (the `="…"` side), never the question text, and
|
|
2095
|
+
// run the SAME isClosePattern as the NL path so the two channels agree.
|
|
2096
|
+
if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
|
|
2097
|
+
const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
2098
|
+
for (const m of s.matchAll(/="([^"]*)"/g)) {
|
|
2099
|
+
if (isClosePattern(m[1])) return true;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
1114
2107
|
/**
|
|
1115
2108
|
* Build hook output for Claude Code (additionalContext channel).
|
|
1116
2109
|
* Codex hooks write systemMessage directly in their own files.
|