hypomnema 1.0.1 → 1.2.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/README.ko.md +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -53,7 +53,7 @@ hypomnema/
|
|
|
53
53
|
│ ├── Home.md, Overview.md, hypo-automation.md, hypo-help.md
|
|
54
54
|
│ ├── pages/_index.md
|
|
55
55
|
│ └── projects/_template/
|
|
56
|
-
├── tests/runner.mjs ← no-dependency test runner
|
|
56
|
+
├── tests/runner.mjs ← no-dependency test runner
|
|
57
57
|
├── docs/ ← ARCHITECTURE.md, CONTRIBUTING.md
|
|
58
58
|
├── .claude-plugin/plugin.json← plugin manifest
|
|
59
59
|
└── package.json ← npm metadata, no runtime deps
|
|
@@ -100,7 +100,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
|
|
|
100
100
|
| `UserPromptSubmit` | `hypo-first-prompt.mjs` → `hypo-lookup.mjs` → `hypo-compact-guard.mjs` |
|
|
101
101
|
| `PreCompact` | `hypo-personal-check.mjs` |
|
|
102
102
|
| `PostToolUse` (Write/Edit) | `hypo-auto-stage.mjs` |
|
|
103
|
-
| `Stop` | `hypo-hot-rebuild.mjs` → `hypo-auto-commit.mjs` |
|
|
103
|
+
| `Stop` | `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` |
|
|
104
104
|
| `CwdChanged` | `hypo-cwd-change.mjs` |
|
|
105
105
|
| `FileChanged` | `hypo-file-watch.mjs` |
|
|
106
106
|
|
|
@@ -113,9 +113,10 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
|
|
|
113
113
|
| `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each, with verify-by-date warnings). **MISS** → emit closest-slug signal that prompts Claude to research + `/hypo:ingest` |
|
|
114
114
|
| `hypo-compact-guard` | Detect `/compact` invocations → enforce session-close checklist before allowing compact |
|
|
115
115
|
| `hypo-personal-check` | PreCompact validation: lint blockers, uncommitted changes, missing session-log entries → block compact |
|
|
116
|
-
| `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (
|
|
117
|
-
| `hypo-hot-rebuild` | At session stop, regenerate root `hot.md` from recent activity |
|
|
118
|
-
| `hypo-
|
|
116
|
+
| `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
|
|
117
|
+
| `hypo-hot-rebuild` | At session stop, regenerate root `hot.md` from recent activity; emit growth metrics + cache for next SessionStart |
|
|
118
|
+
| `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
|
|
119
|
+
| `hypo-auto-commit` | At session stop, filter changed paths through `.hypoignore`, commit non-ignored changes, `git pull --no-rebase` + `git push` (silent fail on missing remote) |
|
|
119
120
|
| `hypo-cwd-change` | When working directory changes, re-resolve the active project and inject its `hot.md` |
|
|
120
121
|
| `hypo-file-watch` | Notify on external wiki edits so the in-session view stays consistent |
|
|
121
122
|
|
|
@@ -289,8 +290,9 @@ SessionStart
|
|
|
289
290
|
│ └─► hypo-cwd-change.mjs (re-inject project hot.md)
|
|
290
291
|
│
|
|
291
292
|
└─► Stop
|
|
292
|
-
├─► hypo-hot-rebuild.mjs (regenerate root hot.md)
|
|
293
|
-
|
|
293
|
+
├─► hypo-hot-rebuild.mjs (regenerate root hot.md + growth cache)
|
|
294
|
+
├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
|
|
295
|
+
└─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
|
|
294
296
|
```
|
|
295
297
|
|
|
296
298
|
---
|
|
@@ -307,6 +309,78 @@ Promotion is intentionally manual to keep `<learned_behaviors>` curated. The pip
|
|
|
307
309
|
|
|
308
310
|
---
|
|
309
311
|
|
|
312
|
+
## Observability (v1.1)
|
|
313
|
+
|
|
314
|
+
v1.1 ships an **observability wedge** — the wiki measures whether it's actually being used per session, rather than claiming autonomy it can't yet deliver.
|
|
315
|
+
|
|
316
|
+
### Data flow
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
Stop hook (hypo-session-record.mjs)
|
|
320
|
+
│ appends one JSONL entry per session
|
|
321
|
+
▼
|
|
322
|
+
<hypo-root>/.cache/sessions/index.jsonl ← primary source
|
|
323
|
+
│
|
|
324
|
+
▼
|
|
325
|
+
scripts/session-audit.mjs ← per-session metrics + classification
|
|
326
|
+
│
|
|
327
|
+
▼
|
|
328
|
+
scripts/weekly-report.mjs ← aggregated weekly autonomy score
|
|
329
|
+
│
|
|
330
|
+
▼
|
|
331
|
+
journal/weekly/<YYYY-Www>.md ← committed report (heuristic v0, spec §6.4 SoT)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Transcript dual-source (ADR 0019)
|
|
335
|
+
|
|
336
|
+
`session-audit.mjs` reads transcripts from two locations, in priority order:
|
|
337
|
+
|
|
338
|
+
1. **Primary:** `<hypo-root>/.cache/sessions/index.jsonl` — written by the Stop hook `hypo-session-record.mjs`. Each line: `{ session_id, transcript_path, recorded_at, cwd }`.
|
|
339
|
+
2. **Fallback:** `~/.claude/projects/<encoded>/*.jsonl` — scanned when the index is missing or empty (legacy / freshly-installed wikis).
|
|
340
|
+
|
|
341
|
+
### Classification
|
|
342
|
+
|
|
343
|
+
| Class | Rule |
|
|
344
|
+
|---|---|
|
|
345
|
+
| `staleness-skip` | `recorded_at` older than `--max-age-days` (default 30) |
|
|
346
|
+
| `ingest-missed` | `urls >= 2` and `ingest_count == 0` |
|
|
347
|
+
| `search-many` | `search_count >= 5` (heavy retrieval; suggests missing synthesis) |
|
|
348
|
+
| `search-0` | `search_count == 0` |
|
|
349
|
+
| `normal` | otherwise |
|
|
350
|
+
|
|
351
|
+
Counted tool names: `Grep`, `WebSearch`, `WebFetch`. Counted slash commands: `/hypo:query`, `/hypo:ingest`, `/hypo:feedback`. A single transcript record contributes to exactly one of (tool-use search OR text-based command search) — `computeMetrics` short-circuits after a tool-use match to prevent double counting.
|
|
352
|
+
|
|
353
|
+
### Autonomy score (heuristic v0)
|
|
354
|
+
|
|
355
|
+
`weekly-report.mjs` aggregates the week's results into a 0–100 score. The score is **clamped to `[0, 100]`** and skips `staleness-skip` sessions. Formula sketch (see `pages/observability/_index.md` for the formal definition):
|
|
356
|
+
|
|
357
|
+
```
|
|
358
|
+
numerator = Σ min(search,3) + ingest*3 + feedback*2
|
|
359
|
+
denominator = Σ 1 + (urls > 0 ? min(urls,5)*2 : 0)
|
|
360
|
+
score = clamp(round(num/den * 100), 0, 100)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The score is a **proxy, not ground truth**. The four-week baseline plan (capture v0 numbers, then revisit with LLM-judge classification before v2) is recorded in the same `_index.md`.
|
|
364
|
+
|
|
365
|
+
### Privacy
|
|
366
|
+
|
|
367
|
+
The observability pipeline reads but never republishes raw transcripts. Weekly reports only emit `session_id` plus aggregate counts — no transcript content, no URLs, no tool inputs. Transcripts themselves live under `~/.claude/projects/` or `.cache/sessions/` which `.hypoignore` already excludes from any sync.
|
|
368
|
+
|
|
369
|
+
### Growth metrics (Lane B)
|
|
370
|
+
|
|
371
|
+
A separate, lightweight counter — distinct from the audit pipeline — runs at every Stop / SessionStart pair:
|
|
372
|
+
|
|
373
|
+
- **Stop** (`hypo-hot-rebuild.mjs`) computes `{ addedPages, updatedPages, newWikilinks }` by reading `git status --porcelain` plus a conditional `git diff HEAD --unified=0`, writes the result to `<hypo-root>/.cache/last-session-growth.json`, and echoes one line to stderr.
|
|
374
|
+
- **SessionStart** (`hypo-session-start.mjs`) reads the cache and surfaces the same line in both stderr (cyan) and the LLM's `additionalContext` so user and model see the same "직전 세션" prefix.
|
|
375
|
+
|
|
376
|
+
If `git status` shows no `.md` changes, the diff step is skipped — Stop hook fast path.
|
|
377
|
+
|
|
378
|
+
### Citation convention
|
|
379
|
+
|
|
380
|
+
The six writer-side skills (`crystallize`, `query`, `ingest`, `verify`, `graph`, `lint`) carry an identical footer instructing Claude to cite wiki pages inline as `[[page-slug]]`. The audit script counts these citations as a "wiki was actually consulted" signal in future iterations.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
310
384
|
## Privacy & exclusions
|
|
311
385
|
|
|
312
386
|
`.hypoignore` is the **only** privacy mechanism. The v1.0 `personal / shared / public` mode matrix was deleted in v1.1 — every privacy decision turned out to be a per-path question, and a single ignore file handles per-path natively.
|
|
@@ -329,9 +403,9 @@ Default patterns: `*.pdf`, `*.zip`, `*.pem`, `*.env`, `*.key`, `*.crt`, `*creden
|
|
|
329
403
|
| `upgrade.mjs` regression | ~10 (includes migration fixture) |
|
|
330
404
|
| `lint.mjs` (fix / json / session-state) | ~10 |
|
|
331
405
|
| Misc (`expandHome`, `resolveHypoRoot`, …) | ~remainder |
|
|
332
|
-
| **Total** |
|
|
406
|
+
| **Total** | Run `npm test` for the live count |
|
|
333
407
|
|
|
334
|
-
Run with `npm test`. The runner uses only Node.js built-ins; tests create scoped temp dirs and clean up after themselves.
|
|
408
|
+
Run with `npm test`. The runner uses only Node.js built-ins; tests create scoped temp dirs and clean up after themselves. The count above is a layout sketch — exact totals shift as lanes ship, so `npm test` is the source of truth.
|
|
335
409
|
|
|
336
410
|
---
|
|
337
411
|
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -22,7 +22,7 @@ No build step. The package is plain ESM with **zero npm runtime dependencies**.
|
|
|
22
22
|
git clone https://github.com/sk-lim19f/Hypomnema.git
|
|
23
23
|
cd Hypomnema
|
|
24
24
|
npm install # installs dev tooling only — runtime deps are zero
|
|
25
|
-
npm test #
|
|
25
|
+
npm test # all tests should pass — exact count shifts as lanes ship
|
|
26
26
|
npm run lint # frontmatter + wikilink validation
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -120,7 +120,7 @@ If you need to share new logic, prefer extending an existing helper over adding
|
|
|
120
120
|
|
|
121
121
|
```bash
|
|
122
122
|
npm test # tests/runner.mjs — unit + smoke + contract tests
|
|
123
|
-
npm run lint # scripts/lint.mjs — frontmatter + wikilink validation
|
|
123
|
+
npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale vs session-log)
|
|
124
124
|
```
|
|
125
125
|
|
|
126
126
|
The test runner uses only Node.js built-ins. Tests create scoped temp directories and clean up after themselves; you can run the suite without any environment setup.
|
package/hooks/hooks.json
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-session-end.mjs",
|
|
20
|
+
"timeout": 10
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
14
25
|
"UserPromptSubmit": [
|
|
15
26
|
{
|
|
16
27
|
"hooks": [
|
|
@@ -60,6 +71,15 @@
|
|
|
60
71
|
"timeout": 10
|
|
61
72
|
}
|
|
62
73
|
]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"hooks": [
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-web-fetch-ingest.mjs",
|
|
80
|
+
"timeout": 10
|
|
81
|
+
}
|
|
82
|
+
]
|
|
63
83
|
}
|
|
64
84
|
],
|
|
65
85
|
"Stop": [
|
|
@@ -72,6 +92,15 @@
|
|
|
72
92
|
}
|
|
73
93
|
]
|
|
74
94
|
},
|
|
95
|
+
{
|
|
96
|
+
"hooks": [
|
|
97
|
+
{
|
|
98
|
+
"type": "command",
|
|
99
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-session-record.mjs",
|
|
100
|
+
"timeout": 10
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
75
104
|
{
|
|
76
105
|
"hooks": [
|
|
77
106
|
{
|
|
@@ -80,6 +109,15 @@
|
|
|
80
109
|
"timeout": 60
|
|
81
110
|
}
|
|
82
111
|
]
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"hooks": [
|
|
115
|
+
{
|
|
116
|
+
"type": "command",
|
|
117
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-auto-minimal-crystallize.mjs",
|
|
118
|
+
"timeout": 10
|
|
119
|
+
}
|
|
120
|
+
]
|
|
83
121
|
}
|
|
84
122
|
],
|
|
85
123
|
"CwdChanged": [
|
|
@@ -105,5 +143,5 @@
|
|
|
105
143
|
}
|
|
106
144
|
]
|
|
107
145
|
},
|
|
108
|
-
"shared": ["hypo-shared.mjs"]
|
|
146
|
+
"shared": ["hypo-shared.mjs", "version-check.mjs", "version-check-fetch.mjs"]
|
|
109
147
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
|
-
import { HYPO_DIR } from './hypo-shared.mjs';
|
|
9
|
+
import { HYPO_DIR, loadHypoIgnore, isIgnored, appendSyncFailure } from './hypo-shared.mjs';
|
|
10
|
+
import { join } from 'path';
|
|
10
11
|
|
|
11
12
|
function git(...args) {
|
|
12
13
|
return spawnSync('git', ['-C', HYPO_DIR, ...args], { encoding: 'utf-8', timeout: 30000 });
|
|
@@ -17,7 +18,20 @@ function hasRemote() {
|
|
|
17
18
|
return (r.stdout || '').trim().length > 0;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
git
|
|
21
|
+
// `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
|
|
22
|
+
// enumerate changed paths, drop ignored ones, then stage explicitly.
|
|
23
|
+
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
24
|
+
const porcelain = git('status', '--porcelain', '-uall');
|
|
25
|
+
const paths = [];
|
|
26
|
+
for (const line of (porcelain.stdout || '').split('\n')) {
|
|
27
|
+
if (!line) continue;
|
|
28
|
+
const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
|
|
29
|
+
if (!file) continue;
|
|
30
|
+
if (ignorePatterns.length > 0 && isIgnored(join(HYPO_DIR, file), HYPO_DIR, ignorePatterns))
|
|
31
|
+
continue;
|
|
32
|
+
paths.push(file);
|
|
33
|
+
}
|
|
34
|
+
if (paths.length > 0) git('add', '--', ...paths);
|
|
21
35
|
const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
|
|
22
36
|
if (staged) {
|
|
23
37
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -29,8 +43,13 @@ if (staged) {
|
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
if (hasRemote()) {
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
// fix #9: pull/push failures must not stop the session, but they can no
|
|
47
|
+
// longer be swallowed silently — record each to .cache/sync-state.json so
|
|
48
|
+
// session-start (#10) and doctor (#11) can surface them next session.
|
|
49
|
+
const pull = git('pull', '--no-rebase', '-q');
|
|
50
|
+
if (pull.status !== 0) appendSyncFailure(HYPO_DIR, 'pull', pull.stderr || pull.stdout);
|
|
51
|
+
const push = git('push');
|
|
52
|
+
if (push.status !== 0) appendSyncFailure(HYPO_DIR, 'push', push.stderr || push.stdout);
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-auto-minimal-crystallize.mjs — Stop hook (fix #27 PR-C, ADR 0022 Layer 3)
|
|
4
|
+
*
|
|
5
|
+
* Last hook in the Stop chain: a final-line defense that blocks `Stop` when
|
|
6
|
+
* the current session performed mutation work but never produced a verified
|
|
7
|
+
* session-close. Forces Claude to run minimal session-close before the
|
|
8
|
+
* conversation context evaporates.
|
|
9
|
+
*
|
|
10
|
+
* Decision flow (see ADR 0022 amendment 2026-05-19 Q1+Q2 + 2nd amendment Q-close-gate):
|
|
11
|
+
*
|
|
12
|
+
* 1. stop_hook_active === true → continue (loop guard; PoC 2026-05-14)
|
|
13
|
+
* 2. wiki absent → continue (fail-open)
|
|
14
|
+
* 3. transcript has zero Edit/Write/MultiEdit/NotebookEdit tool_use
|
|
15
|
+
* → continue (substantial-session gate)
|
|
16
|
+
* 4. no recent user close-intent → continue (close-intent gate, see below)
|
|
17
|
+
* 5. readSessionClosedMarker(session_id) valid
|
|
18
|
+
* → continue (close already verified)
|
|
19
|
+
* 6. otherwise → decision:block
|
|
20
|
+
*
|
|
21
|
+
* Close-intent gate (added after PR-C dogfooding revealed every-turn block —
|
|
22
|
+
* codex 2-worker debate 2026-05-19, both REQUEST_CHANGES). Stop fires after
|
|
23
|
+
* EVERY assistant turn, not at session end; blocking on "mutation + no marker"
|
|
24
|
+
* alone nags the user on every turn of a long mutating session. ADR 0022's
|
|
25
|
+
* real intent is "block when the session is ENDING and close is incomplete".
|
|
26
|
+
* We approximate the end signal by reusing isClosePattern() over recent
|
|
27
|
+
* user-message text (the same low-false-positive signal PreCompact uses):
|
|
28
|
+
* only block when the user actually signalled wrap-up ("이만 마치자",
|
|
29
|
+
* "오늘 여기까지", "wrap up", "session close"). last_assistant_message is NOT
|
|
30
|
+
* used — "커밋했습니다"/"작업 완료" type phrases produce false positives.
|
|
31
|
+
*
|
|
32
|
+
* The hook NEVER writes the marker — even in the loop-guard branch. Writer
|
|
33
|
+
* authority lives in `scripts/crystallize.mjs` (`--apply-session-close
|
|
34
|
+
* --session-id=X` or standalone `--mark-session-closed --session-id=X`),
|
|
35
|
+
* which gates the write on sessionCloseFileStatus.ok. Doing the write here
|
|
36
|
+
* would let a Claude that ignored the block (did other work, hit Stop again)
|
|
37
|
+
* silently get a marker without performing the close — exactly the failure
|
|
38
|
+
* mode the per-session marker was introduced to prevent.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync } from 'fs';
|
|
42
|
+
import { join } from 'path';
|
|
43
|
+
import {
|
|
44
|
+
HYPO_DIR,
|
|
45
|
+
PKG_ROOT,
|
|
46
|
+
hasMutatingTranscriptActivity,
|
|
47
|
+
readSessionClosedMarker,
|
|
48
|
+
extractUserMessages,
|
|
49
|
+
isClosePattern,
|
|
50
|
+
isGateSkipped,
|
|
51
|
+
} from './hypo-shared.mjs';
|
|
52
|
+
|
|
53
|
+
function emitContinue() {
|
|
54
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emitBlock(sessionId) {
|
|
58
|
+
// One-line, skill-first. /hypo:crystallize is the documented session-close
|
|
59
|
+
// alias; passing --session-id there writes the per-session marker that clears
|
|
60
|
+
// this block. CLI fallback + bypass live in commands/crystallize.md, not here
|
|
61
|
+
// — keep the Stop reason terse so the actionable instruction stands out.
|
|
62
|
+
const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}).`;
|
|
63
|
+
console.log(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
decision: 'block',
|
|
66
|
+
reason,
|
|
67
|
+
stopReason: 'session-close incomplete (fix #27 PR-C / ADR 0022 Layer 3)',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let raw = '';
|
|
73
|
+
process.stdin.setEncoding('utf-8');
|
|
74
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
75
|
+
process.stdin.on('end', () => {
|
|
76
|
+
try {
|
|
77
|
+
let payload = {};
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(raw) || {};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// Any malformed payload is fail-open — we never want a parse error to
|
|
82
|
+
// strand Claude in a blocked Stop with no recovery context.
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`,
|
|
85
|
+
);
|
|
86
|
+
emitContinue();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 1. loop guard. NEVER write marker here (see file header).
|
|
91
|
+
if (payload.stop_hook_active === true) {
|
|
92
|
+
emitContinue();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isGateSkipped()) {
|
|
97
|
+
emitContinue();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. wiki absent → can't enforce anything meaningful.
|
|
102
|
+
if (!existsSync(HYPO_DIR)) {
|
|
103
|
+
emitContinue();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sessionId = payload.session_id || payload.sessionId || null;
|
|
108
|
+
const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
|
|
109
|
+
|
|
110
|
+
// 3. substantial-session gate. Read-only / Q&A sessions skip the block.
|
|
111
|
+
if (!hasMutatingTranscriptActivity(transcriptPath)) {
|
|
112
|
+
emitContinue();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. close-intent gate. Stop fires every turn; only nudge when the user
|
|
117
|
+
// actually signalled session wrap-up. Without this, a long mutating
|
|
118
|
+
// session is blocked on every turn (PR-C dogfooding regression).
|
|
119
|
+
const userText = transcriptPath ? extractUserMessages(transcriptPath) : '';
|
|
120
|
+
if (!isClosePattern(userText)) {
|
|
121
|
+
emitContinue();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 5. close already verified for this session_id.
|
|
126
|
+
if (sessionId && readSessionClosedMarker(HYPO_DIR, sessionId)) {
|
|
127
|
+
emitContinue();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 6. block — but only when we have a session_id to address the recovery
|
|
132
|
+
// instruction to. Without one, the marker contract can't be honored, so
|
|
133
|
+
// failing-open is safer than blocking forever.
|
|
134
|
+
if (!sessionId) {
|
|
135
|
+
emitContinue();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
emitBlock(sessionId);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Fail-open on any unexpected error.
|
|
142
|
+
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
143
|
+
emitContinue();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -6,17 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
|
-
import { HYPO_DIR } from './hypo-shared.mjs';
|
|
9
|
+
import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
|
|
10
10
|
|
|
11
11
|
let input = {};
|
|
12
12
|
try {
|
|
13
|
-
const raw = await new Promise(r => {
|
|
13
|
+
const raw = await new Promise((r) => {
|
|
14
14
|
let d = '';
|
|
15
|
-
process.stdin.on('data', c => d += c);
|
|
15
|
+
process.stdin.on('data', (c) => (d += c));
|
|
16
16
|
process.stdin.on('end', () => r(d));
|
|
17
17
|
});
|
|
18
18
|
input = JSON.parse(raw);
|
|
19
|
-
} catch {
|
|
19
|
+
} catch (err) {
|
|
20
|
+
process.stderr.write(`[hypo-auto-stage] error: ${err?.message ?? String(err)}\n`);
|
|
20
21
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
21
22
|
process.exit(0);
|
|
22
23
|
}
|
|
@@ -24,7 +25,10 @@ try {
|
|
|
24
25
|
const filePath = input.tool_input?.file_path ?? '';
|
|
25
26
|
|
|
26
27
|
if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
|
|
27
|
-
|
|
28
|
+
const patterns = loadHypoIgnore(HYPO_DIR);
|
|
29
|
+
if (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns)) {
|
|
30
|
+
spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* hypo-compact-guard.mjs — UserPromptSubmit hook
|
|
4
4
|
*
|
|
5
|
-
* Scope: detects "/compact" typed in chat only.
|
|
5
|
+
* Scope: detects "/compact" or "/clear" typed in chat only (ADR 0022 Layer 2, fix #25).
|
|
6
6
|
* The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
|
|
7
|
-
* (PreCompact hook) as the hard gate for that path.
|
|
7
|
+
* (PreCompact hook) as the hard gate for that path. /clear has no PreCompact event, so
|
|
8
|
+
* this hook is the only chat-side gate that can prompt session-close before context wipe.
|
|
8
9
|
*
|
|
9
10
|
* Behavior: if session close is incomplete → instruct Claude to run session close
|
|
10
|
-
* immediately before /compact.
|
|
11
|
+
* immediately before /compact or /clear.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import {
|
|
@@ -15,26 +16,31 @@ import {
|
|
|
15
16
|
hypoIsClean,
|
|
16
17
|
hotMdIsClean,
|
|
17
18
|
readChecklist,
|
|
18
|
-
|
|
19
|
+
isClearCommand,
|
|
20
|
+
isCompactOrClearCommand,
|
|
19
21
|
isGateSkipped,
|
|
20
22
|
} from './hypo-shared.mjs';
|
|
21
23
|
|
|
22
24
|
let input = '';
|
|
23
25
|
process.stdin.setEncoding('utf-8');
|
|
24
|
-
process.stdin.on('data', chunk => {
|
|
26
|
+
process.stdin.on('data', (chunk) => {
|
|
27
|
+
input += chunk;
|
|
28
|
+
});
|
|
25
29
|
process.stdin.on('end', () => {
|
|
26
30
|
try {
|
|
27
|
-
const data
|
|
31
|
+
const data = JSON.parse(input);
|
|
28
32
|
const prompt = (data.prompt || '').trim();
|
|
29
33
|
|
|
30
|
-
if (!
|
|
34
|
+
if (!isCompactOrClearCommand(prompt) || isGateSkipped()) {
|
|
31
35
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
32
36
|
return;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
const detected = isClearCommand(prompt) ? '/clear' : '/compact';
|
|
40
|
+
|
|
35
41
|
const hasSession = lastSubstantialOpIsSession();
|
|
36
|
-
const gitStatus
|
|
37
|
-
const hotStatus
|
|
42
|
+
const gitStatus = hypoIsClean();
|
|
43
|
+
const hotStatus = hotMdIsClean();
|
|
38
44
|
|
|
39
45
|
if (hasSession && gitStatus.clean && hotStatus.clean) {
|
|
40
46
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
@@ -42,30 +48,33 @@ process.stdin.on('end', () => {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
const reasons = [
|
|
45
|
-
!hasSession
|
|
51
|
+
!hasSession ? 'session log entry missing' : '',
|
|
46
52
|
!gitStatus.clean ? gitStatus.reason : '',
|
|
47
53
|
!hotStatus.clean ? hotStatus.reason : '',
|
|
48
54
|
].filter(Boolean);
|
|
49
55
|
|
|
50
|
-
const today
|
|
56
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
51
57
|
const checklist = readChecklist(today);
|
|
52
|
-
const body
|
|
58
|
+
const body = checklist
|
|
53
59
|
? `Checklist:\n${checklist}`
|
|
54
60
|
: 'See hypo-guide.md for the session-close checklist.';
|
|
55
61
|
|
|
56
|
-
console.log(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
console.log(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
continue: true,
|
|
65
|
+
additionalContext: [
|
|
66
|
+
`[WIKI_AUTOCLOSE] ${detected} detected — session close incomplete (${reasons.join(', ')}).`,
|
|
67
|
+
`Do NOT wait for user input. Run wiki session close NOW, then retry ${detected}.`,
|
|
68
|
+
``,
|
|
69
|
+
body,
|
|
70
|
+
``,
|
|
71
|
+
`To bypass: set HYPO_SKIP_GATE=1`,
|
|
72
|
+
].join('\n'),
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
68
76
|
// Fail-open: any parse/runtime error must not block the user's prompt.
|
|
77
|
+
process.stderr.write(`[hypo-compact-guard] error: ${err?.message ?? String(err)}\n`);
|
|
69
78
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
70
79
|
}
|
|
71
80
|
});
|