hypomnema 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/commands/capture.md +40 -0
- package/docs/CONTRIBUTING.md +18 -0
- package/hooks/hypo-auto-minimal-crystallize.mjs +69 -2
- package/hooks/hypo-cwd-change.mjs +21 -3
- package/hooks/hypo-shared.mjs +303 -9
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +245 -128
- package/scripts/doctor.mjs +144 -5
- package/scripts/init.mjs +16 -5
- package/scripts/lib/core-hooks.mjs +156 -0
- package/scripts/lib/extensions.mjs +317 -9
- package/scripts/lint.mjs +32 -0
- package/scripts/uninstall.mjs +84 -42
- package/templates/hypo-config.md +1 -1
package/README.ko.md
CHANGED
|
@@ -25,7 +25,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
|
|
|
25
25
|
|
|
26
26
|
### 지금 어디까지 자동인가
|
|
27
27
|
|
|
28
|
-
현재 릴리스는 v1.
|
|
28
|
+
현재 릴리스는 v1.6.1입니다. 조건부나 유예된 세션 마무리에서 미커밋이나 진행 중인 작업이 남아 문구가 모호할 때 조기 nag 대신 지금 닫을지 되묻고, `working_dir` provenance를 강화해 앵커가 없던 프로젝트도 `cwd`-first resume이 앵커하도록 고쳤습니다. 그 앞 v1.6.0은 역방향 확장 capture를 완성했습니다. `hypomnema capture`(그리고 `/hypo:capture` 슬래시 커맨드)가 `~/.claude/` 아래에 보통 방식으로 만든 command·agent·hook을 위키로 끌어와, forward-sync가 다른 머신에 원래 이름 그대로 전파합니다. 손으로 만든 확장의 "한 머신에서 등록, 다른 머신에서 동기화" 갭을 데몬 없이 메웁니다. 그 앞 v1.5.1은 위키가 자기 신선도를 알리는 방식을 다듬었습니다. `verify_by_date`가 지난 페이지는 주입되는 순간 `[STALE ...]`로 표시되고, lookup 사용을 로컬에서 추적해 링크됐지만 한 번도 주입되지 않은 페이지를 crystallize가 cold 후보로 표면화하며, 표준 `session | project: title` 콜론 로그 포맷을 stale로 오진하던 세션 마무리도 견고해졌습니다. 다시 그 앞 v1.5.0은 머신 간 안정성에 집중했습니다. git 동기화 볼트의 `cwd`-first resume와 코드 레포 세션에 위키 위치를 안내하는 것이었습니다.
|
|
29
29
|
|
|
30
30
|
위키 작업(자료 정리·검색·세션 마무리)은 아직 `/hypo:*` 명령이나 자연어로 시작합니다. v2의 목표는 Claude가 시키지 않아도 위키를 읽고 쓰고 합성하는 완전 자율 동작이고, 지금 그쪽으로 가는 중입니다.
|
|
31
31
|
|
|
@@ -33,6 +33,7 @@ Andrej Karpathy의 "LLM 네이티브 위키" 스케치에서 출발했습니다.
|
|
|
33
33
|
|
|
34
34
|
- 피드백 한 곳만 고치면 나머지는 따라옵니다. `pages/feedback/`에 한 번 적으면 위키가 `MEMORY.md`와 `~/.claude/CLAUDE.md`의 `<learned_behaviors>` 블록을 자동으로 갱신합니다(단방향 projection).
|
|
35
35
|
- 확장 파일도 함께 동기화. 위키 안 `~/hypomnema/extensions/{agents,commands,hooks,skills}/`에 둔 파일을 `~/.claude/`에 자동 반영합니다. `--codex`를 붙이면 `hooks`·`commands`는 `~/.codex/`에도 반영하지만, `agents`와 `skills`는 Claude 전용이라 건너뜁니다.
|
|
36
|
+
- 역방향 capture는 반대로 갑니다. `hypomnema capture`(또는 `/hypo:capture`)는 `~/.claude/{commands,agents}/`에 보통 방식으로 만든 command·agent나 `~/.claude/settings.json`에 등록한 정규형 hook을 위키로 끌어와, 이후 다른 머신에 원래 이름 그대로 동기화되게 합니다. hook은 정규형으로 무손실 라운드트립되는 것만 capture하고, 나머지는 이유와 함께 건너뜁니다. capture는 명시적이며 내용이 다른 위키 파일은 덮어쓰지 않습니다. skill은 아직 capture 대상이 아닙니다.
|
|
36
37
|
- 프로젝트 자동 생성. 프로젝트 표식(`package.json`·`Cargo.toml` 등)이 있는 git 저장소로 `cd` 했는데 대응하는 위키 프로젝트가 없으면 만들지 물어봅니다.
|
|
37
38
|
- 세션 종료 자동 정리와 `/clear` 복구. 의미 있는 세션이 끝나면 "마무리 메모를 짧게 남길까요?"가 자동으로 뜹니다. 마무리하지 않은 채 `/clear`를 입력해도 다음 세션 시작 때 이어서 정리할 수 있습니다.
|
|
38
39
|
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ New to the terms below? Keep the [Term decoder](#term-decoder) open in another t
|
|
|
25
25
|
|
|
26
26
|
### Where automation stands today
|
|
27
27
|
|
|
28
|
-
The current release is v1.
|
|
28
|
+
The current release is v1.6.1. It fixes session close so a conditional or deferred wrap-up ("once X is done, wrap up") that still has uncommitted or in-flight work asks you whether to close now instead of nagging prematurely, and it hardens `working_dir` provenance so `cwd`-first resume can anchor a project that was missing its anchor. The v1.6.0 release before it completed reverse extension capture: `hypomnema capture` (and the `/hypo:capture` slash command) pulls a command, agent, or hook you created the normal way under `~/.claude/` into the wiki, so forward-sync propagates it to your other machines under its original name. That closes the "register on one machine, sync on another" gap for extensions you author by hand, without a daemon. The v1.5.1 release before it sharpened how the wiki signals its own freshness: a page whose `verify_by_date` has passed is flagged `[STALE ...]` the moment it is injected, lookup usage is tracked locally so crystallize can surface a linked-but-never-injected page as a cold candidate, and session close was made robust to the standard `session | project: title` colon log format that had been misread as stale. The v1.5.0 line before that turned to cross-machine reliability: `cwd`-first resume on a git-synced vault and a code-repo session told where the wiki lives.
|
|
29
29
|
|
|
30
30
|
Wiki work (ingest, query, session-close) still starts from an explicit `/hypo:*` command or plain language. The v2 goal is full autonomy: Claude reading, writing, and synthesizing the wiki without being asked, which is the direction this is heading.
|
|
31
31
|
|
|
@@ -33,6 +33,7 @@ The lanes that already run on their own: v1.1.0 shipped the observability score
|
|
|
33
33
|
|
|
34
34
|
- Edit feedback in one place and the rest follows. `pages/feedback/` is the single source of truth for behavior corrections, and Hypomnema derives `MEMORY.md` and the `<learned_behaviors>` block inside `~/.claude/CLAUDE.md` from it automatically.
|
|
35
35
|
- Extensions sync alongside. Anything under `~/hypomnema/extensions/{agents,commands,hooks,skills}/` is mirrored into `~/.claude/` automatically. The `--codex` flag also mirrors `hooks` and `commands` into `~/.codex/`; `agents` and `skills` are Claude-only and skipped on purpose.
|
|
36
|
+
- Reverse capture goes the other way. `hypomnema capture` (or `/hypo:capture`) pulls a command or agent you made the normal way under `~/.claude/{commands,agents}/`, or a canonical hook you registered in `~/.claude/settings.json`, into the wiki, so it then syncs to your other machines under its original name. Hooks are captured only when they round-trip losslessly to the canonical form; others are skipped with a reason. Capture is explicit and never overwrites a differing wiki file. Skills are not captured yet.
|
|
36
37
|
- Auto-project creation. When you `cd` into a git repo with a project marker (`package.json`, `Cargo.toml`, etc.) and no matching wiki project exists, Hypomnema offers to scaffold one.
|
|
37
38
|
- Session-close cleanup and `/clear` recovery. After a non-trivial session, a "save a minimal session-close note?" prompt appears automatically; a `/clear` after a forgotten close is detected and recovered at the next session start.
|
|
38
39
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Pull extensions you made the normal way under ~/.claude/{commands,agents}, or a canonical hook registered in ~/.claude/settings.json, into the wiki so they sync to your other machines. Use when the user wants to capture, adopt, or back up a locally-authored slash command, agent, or hook into Hypomnema.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
You are running `/hypo:capture`. Bring a command or agent that the user created the normal way (directly under `~/.claude/commands/` or `~/.claude/agents/`), or a hook they registered in `~/.claude/settings.json`, into the wiki `extensions/` tree so the existing forward-sync propagates it to their other machines under its original name.
|
|
6
|
+
|
|
7
|
+
Scope: commands, agents, and hooks. Commands and agents are enumerated from `~/.claude/{commands,agents}/`; hooks are read from the `~/.claude/settings.json` registration. A hook is captured only when its command is the canonical `node $HOME/.claude/hooks/<name>.mjs` form and it round-trips losslessly (original event, matcher, and timeout preserved); non-canonical or lossy registrations are skipped with a reason instead of being rewritten. Skills are not captured yet.
|
|
8
|
+
|
|
9
|
+
## Step 1: Resolve the package root
|
|
10
|
+
|
|
11
|
+
Bundled scripts here run via `${CLAUDE_PLUGIN_ROOT}/scripts/`. To resolve that package root: if `${CLAUDE_PLUGIN_ROOT}` is already an absolute path, use it; otherwise read `pkgRoot` from `~/.claude/hypo-pkg.json` (only when non-empty and the target script exists under it); otherwise use the `hypo@hypomnema` (or legacy `hypomnema@hypomnema`) installPath in `~/.claude/plugins/installed_plugins.json`; if none resolve, stop and tell the user to run `hypomnema capture` or reinstall instead of guessing the cache layout.
|
|
12
|
+
|
|
13
|
+
If the user specified a Hypomnema directory, pass it as `--hypo-dir="<path>"`. Otherwise omit the flag.
|
|
14
|
+
|
|
15
|
+
## Step 2: List what can be captured
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
With no names and no `--all`, the script lists capturable candidates and stops. Candidates come from two sources: commands and agents are the unowned regular `.md` files under `~/.claude/{commands,agents}/`, and hooks are the canonical `node $HOME/.claude/hooks/<name>.mjs` entries registered in `~/.claude/settings.json`. It excludes anything already managed by the wiki, the reserved `hypo-*` namespace, symlinks and other non-regular files, core hooks, and any hook whose registration would not round-trip losslessly. Skipped hook registrations are printed with the reason; unowned commands and agents that fail these filters are simply omitted from the list, without a per-item reason.
|
|
22
|
+
|
|
23
|
+
Show the list. Note that these are unowned candidates, not a provenance check: explicit selection is the trust boundary. A third-party tool's command, agent, or hook could appear here, so let the user pick deliberately.
|
|
24
|
+
|
|
25
|
+
## Step 3: Capture the user's selection
|
|
26
|
+
|
|
27
|
+
Ask which ones to capture, then run with the chosen names (space-separated), or `--all` if the user wants everything listed:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"] <name> [<name> ...]
|
|
31
|
+
node ${CLAUDE_PLUGIN_ROOT}/scripts/capture.mjs [--hypo-dir="<path>"] --all
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A name is the filename (`mycmd.md`, or `myhook.mjs` for a hook), its stem (`mycmd`), or the `type/file` form (`commands/mycmd.md`, `hooks/myhook.mjs`). Add `--dry-run` to preview without writing.
|
|
35
|
+
|
|
36
|
+
Each capture stores the file in the wiki as `extensions/<type>/hypo-ext-<name>.<ext>` (`.md` for commands and agents, `.mjs` for hooks) with a sidecar `hypo-ext-<name>.manifest.json` that records the original install name (and, for a hook, its event, matcher, and timeout), then adopts it so the currently-installed copy is left in place and tracked. A wiki file that already exists with different content (or a mismatched manifest) is refused, not overwritten.
|
|
37
|
+
|
|
38
|
+
## Step 4: Commit the wiki, then sync elsewhere
|
|
39
|
+
|
|
40
|
+
Show the script output verbatim. When something was captured, remind the user to commit and push the wiki, then run `hypomnema upgrade --apply` on their other machine to install the captured extension under its original name.
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -331,6 +331,17 @@ from being dropped.
|
|
|
331
331
|
Prerequisite: the changelog collector in step 2 reads merged-PR data through the GitHub CLI, so have `gh` installed and authenticated (`gh auth status`) before you start. It is a maintainer-only release script and is not shipped to npm.
|
|
332
332
|
|
|
333
333
|
```bash
|
|
334
|
+
# 0. (minor+ releases only) Ensure the wiki vault carries this release's version
|
|
335
|
+
# spec. Convention (version-plan lifecycle: born-at-stable-path, flip-in-place,
|
|
336
|
+
# recorded in the vault's decisions log): a minor-or-higher release has
|
|
337
|
+
# projects/hypomnema/specs/spec-v<version>.md with status: active and
|
|
338
|
+
# version: <version>. If it is missing, create it from the vault's minimal spec
|
|
339
|
+
# shape (omit patch-only fields for a minor/major). If you created or changed
|
|
340
|
+
# it, run the wiki lint and commit + push the vault before bumping, so the
|
|
341
|
+
# active pre-ship state is recorded remotely. Patch releases are exempt: the
|
|
342
|
+
# CHANGELOG section and PR body cover them, no spec doc required.
|
|
343
|
+
$EDITOR ~/hypomnema/projects/hypomnema/specs/spec-v<version>.md
|
|
344
|
+
|
|
334
345
|
# 1. Bump the version across package.json, plugin.json, marketplace.json,
|
|
335
346
|
# and templates/hypo-config.md. Takes a concrete semver (not patch/minor/major).
|
|
336
347
|
node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
|
|
@@ -397,6 +408,13 @@ npm publish --dry-run --access public # packs + prepublishOnly, NO registry PU
|
|
|
397
408
|
# 8. Push the SPECIFIC tag alone — NOT --tags (that would push stale local tags
|
|
398
409
|
# and could trigger releases for versions you did not intend).
|
|
399
410
|
git push origin v<version>
|
|
411
|
+
|
|
412
|
+
# 9. (minor+ releases only) After the release workflow succeeds, close the spec
|
|
413
|
+
# lifecycle IN PLACE (no path move): set status: archived and
|
|
414
|
+
# archived_date: <release date> in the same spec, run the wiki lint, then
|
|
415
|
+
# commit and push the vault. Do this only after the release is green, never at
|
|
416
|
+
# tag time: a failed release must not leave an archived spec behind.
|
|
417
|
+
$EDITOR ~/hypomnema/projects/hypomnema/specs/spec-v<version>.md
|
|
400
418
|
```
|
|
401
419
|
|
|
402
420
|
The `release.yml` workflow then:
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
* "오늘 여기까지", "wrap up", "session close"). last_assistant_message is NOT
|
|
33
33
|
* used — "커밋했습니다"/"작업 완료" type phrases produce false positives.
|
|
34
34
|
*
|
|
35
|
+
* Reconfirm branch (conditional-close-reconfirm, added after the close-intent
|
|
36
|
+
* gate above was found to be unable to distinguish "close now" from "close
|
|
37
|
+
* once X is done" by regex alone — same sentence shape either way). When the
|
|
38
|
+
* close-intent gate above fires AND the session has a work-incomplete signal
|
|
39
|
+
* (an uncommitted wiki, or an in-flight delegated subagent per
|
|
40
|
+
* hasInFlightSubagent), step 6's block reason is replaced with an
|
|
41
|
+
* AskUserQuestion instruction instead of a crystallize command — the model
|
|
42
|
+
* asks the user "close now?" rather than deciding for them. A correlated
|
|
43
|
+
* "아직"/"나중"/"not yet"/"later" answer (isCloseReconfirmDeclined) suppresses
|
|
44
|
+
* the NEXT such block via `continue`, until a new user close signal re-arms
|
|
45
|
+
* it. This does not touch step 4's isClosePattern gate itself, and does not
|
|
46
|
+
* change the marker-writer's own close-file-status gate.
|
|
47
|
+
*
|
|
35
48
|
* The hook NEVER writes the marker — even in the loop-guard branch. Writer
|
|
36
49
|
* authority lives in `scripts/crystallize.mjs` (`--apply-session-close
|
|
37
50
|
* --session-id=X` or standalone `--mark-session-closed --session-id=X`),
|
|
@@ -52,13 +65,47 @@ import {
|
|
|
52
65
|
isClosePattern,
|
|
53
66
|
isGateSkipped,
|
|
54
67
|
precompactGateStatus,
|
|
68
|
+
hasInFlightSubagent,
|
|
69
|
+
isCloseReconfirmDeclined,
|
|
70
|
+
CLOSE_RECONFIRM_MARK,
|
|
55
71
|
} from './hypo-shared.mjs';
|
|
56
72
|
|
|
57
73
|
function emitContinue() {
|
|
58
74
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
function emitBlock(sessionId, transcriptPath, gate = null) {
|
|
77
|
+
function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
|
|
78
|
+
// Reconfirm branch (work-incomplete + close-intent, ambiguous "now" vs
|
|
79
|
+
// "later"): the model must NOT decide unilaterally. Ask the user via
|
|
80
|
+
// AskUserQuestion instead of naming a marker/crystallize command — doing
|
|
81
|
+
// so here would let the model "resolve" the ambiguity by just running the
|
|
82
|
+
// close, which is exactly the silent-close-on-uncommitted-work failure
|
|
83
|
+
// mode this branch exists to stop. See spec/plan
|
|
84
|
+
// (specs/conditional-close-reconfirm/). The close-now option label below
|
|
85
|
+
// (CLOSE_RECONFIRM_MARK) must stay in sync with hypo-shared.mjs
|
|
86
|
+
// isCloseReconfirmDeclined's correlation check — that coupling is
|
|
87
|
+
// load-bearing (a differently-labeled option would leave the model's
|
|
88
|
+
// AskUserQuestion uncorrelated, so even an explicit user decline could not
|
|
89
|
+
// be recognized as such). Both sides import the same constant so a reworded
|
|
90
|
+
// label can't drift out of sync silently. The decline label ("아직, 계속")
|
|
91
|
+
// stays a literal here — it is matched by the tolerant DECLINE regex in
|
|
92
|
+
// isCloseReconfirmDeclined, not by an exact-mark check.
|
|
93
|
+
if (opts.reconfirm) {
|
|
94
|
+
console.log(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
decision: 'block',
|
|
97
|
+
reason:
|
|
98
|
+
`[WIKI_AUTOCLOSE] close 신호가 잡혔지만 아직 커밋되지 않은 변경(또는 진행 중인 ` +
|
|
99
|
+
`위임 작업)이 있어 지금 닫을지 뒤로 미룰지 모호합니다 (session_id=${sessionId}). ` +
|
|
100
|
+
`임의로 닫지 말고 AskUserQuestion으로 사용자에게 지금 세션을 닫을지 물어보세요. ` +
|
|
101
|
+
`선택지는 "${CLOSE_RECONFIRM_MARK}"와 "아직, 계속"으로 제시합니다. 사용자가 ` +
|
|
102
|
+
`"${CLOSE_RECONFIRM_MARK}"를 고른 뒤에만 세션 마무리를 진행하세요. 그전에는 ` +
|
|
103
|
+
`마커를 쓰거나 종료 명령을 실행하지 마세요.`,
|
|
104
|
+
stopReason: 'session-close incomplete (Layer 3, reconfirm)',
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
62
109
|
// One-line recovery action. The gate-precise branches below name an EXACT
|
|
63
110
|
// command so "only the marker is missing" stays a one-shot fix — but that
|
|
64
111
|
// command must be a runnable `node <pkg>/scripts/crystallize.mjs` invocation,
|
|
@@ -199,7 +246,27 @@ process.stdin.on('end', () => {
|
|
|
199
246
|
gate = null;
|
|
200
247
|
}
|
|
201
248
|
|
|
202
|
-
|
|
249
|
+
// Reconfirm decision (conditional-close-reconfirm): "close" and
|
|
250
|
+
// "work-incomplete" together are exactly the case where the transcript's
|
|
251
|
+
// NL close phrase can't tell "close now" from "close once X is done" —
|
|
252
|
+
// regex can't disambiguate this (see spec background). Narrowed to the
|
|
253
|
+
// two work-incomplete signals only: an uncommitted wiki (`git` blocker;
|
|
254
|
+
// real unsaved work) OR an in-flight delegated subagent. `hot` / `lint` /
|
|
255
|
+
// `close` / `design-history` / `feedback` blockers are real close
|
|
256
|
+
// blockers but not evidence of "later" intent, so they keep the existing
|
|
257
|
+
// wording (unchanged from before this feature).
|
|
258
|
+
const workIncomplete =
|
|
259
|
+
(gate && gate.blockers && gate.blockers.some((b) => b.type === 'git')) ||
|
|
260
|
+
hasInFlightSubagent(payload);
|
|
261
|
+
if (workIncomplete && isCloseReconfirmDeclined(transcriptPath)) {
|
|
262
|
+
// The user already answered "아직" to the ambiguous close signal that
|
|
263
|
+
// triggered this Stop turn — suppressing is a continue, not a
|
|
264
|
+
// (differently-worded) block, so it cannot live inside emitBlock.
|
|
265
|
+
emitContinue();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
|
|
203
270
|
} catch (err) {
|
|
204
271
|
// Fail-open on any unexpected error.
|
|
205
272
|
process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
|
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
sessionMarkerPath,
|
|
17
17
|
shouldSuggestProjectCreation,
|
|
18
18
|
buildProjectSuggestionLine,
|
|
19
|
+
shouldSuggestBackfill,
|
|
20
|
+
buildBackfillSuggestionLine,
|
|
21
|
+
findBackfillCandidate,
|
|
19
22
|
recordSuggestionCooldown,
|
|
20
23
|
sanitizeProjForPrompt,
|
|
21
24
|
pickProjectByCwd,
|
|
@@ -127,10 +130,25 @@ process.stdin.on('end', () => {
|
|
|
127
130
|
return;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
// MISS: cwd matches no project.
|
|
131
|
-
//
|
|
133
|
+
// MISS: cwd matches no project's working_dir. Two distinct causes surface
|
|
134
|
+
// two distinct offers:
|
|
135
|
+
// - cwd's leaf directory name already IS an existing projects/<slug>/
|
|
136
|
+
// with session artifacts but no working_dir anchor → offer to
|
|
137
|
+
// BACKFILL the anchor (a duplicate new-project offer would be wrong).
|
|
138
|
+
// - otherwise, the usual create-a-new-project nudge, same as before.
|
|
139
|
+
// Same nudge-only model as session-start either way.
|
|
132
140
|
let suggestPrefix = '';
|
|
133
|
-
|
|
141
|
+
const backfillCandidate = findBackfillCandidate(newCwd, HYPO_DIR);
|
|
142
|
+
if (backfillCandidate && shouldSuggestBackfill(newCwd, HYPO_DIR)) {
|
|
143
|
+
const suggestLine = buildBackfillSuggestionLine(
|
|
144
|
+
backfillCandidate.slug,
|
|
145
|
+
newCwd,
|
|
146
|
+
backfillCandidate.hasIndex,
|
|
147
|
+
);
|
|
148
|
+
suggestPrefix = `${suggestLine}\n\n`;
|
|
149
|
+
recordSuggestionCooldown(HYPO_DIR, newCwd);
|
|
150
|
+
process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
|
|
151
|
+
} else if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
|
|
134
152
|
const suggestLine = buildProjectSuggestionLine(newCwd);
|
|
135
153
|
suggestPrefix = `${suggestLine}\n\n`;
|
|
136
154
|
recordSuggestionCooldown(HYPO_DIR, newCwd);
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -624,6 +624,80 @@ export function collectProjectWorkingDirs(hypoDir) {
|
|
|
624
624
|
return out;
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
+
// A project without a working_dir anchor (no index.md, or an index.md missing
|
|
628
|
+
// the field) never enters collectProjectWorkingDirs' universe, so cwd-first
|
|
629
|
+
// resume always MISSes for it — the SessionStart/CwdChanged MISS branch would
|
|
630
|
+
// otherwise only ever offer to create a brand-new (duplicate) project. Match
|
|
631
|
+
// the cwd's LEAF basename only (not every ancestor): the anchor being written
|
|
632
|
+
// is `working_dir: <cwd>` itself, so matching an ancestor directory would
|
|
633
|
+
// backfill the wrong (parent) path. Since projects/<slug>/ names are unique on
|
|
634
|
+
// disk, a leaf-basename match identifies at most one project — no separate
|
|
635
|
+
// uniqueness gate is needed here (unlike pickProjectByCwd's cross-machine tier
|
|
636
|
+
// 2, which reasons over synced *paths* that can collide).
|
|
637
|
+
//
|
|
638
|
+
// Known bound: a session whose cwd is a SUBDIRECTORY of the anchorless
|
|
639
|
+
// project's root (rather than the root itself) will not match — this is a
|
|
640
|
+
// deliberate scope limit, not a bug: guessing the project from a mid-tree cwd
|
|
641
|
+
// would risk writing an anchor to the wrong (non-root) path.
|
|
642
|
+
//
|
|
643
|
+
// @param {string} cwd
|
|
644
|
+
// @param {string} [hypoDir=HYPO_DIR]
|
|
645
|
+
// @returns {{slug: string, hasIndex: boolean}|null} the anchorless project
|
|
646
|
+
// this cwd's leaf basename names, or null when there is no match, no
|
|
647
|
+
// session artifacts, or the project already carries a working_dir.
|
|
648
|
+
export function findBackfillCandidate(cwd, hypoDir = HYPO_DIR) {
|
|
649
|
+
const n = normalizeWorkingDir(cwd);
|
|
650
|
+
if (!n) return null;
|
|
651
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
652
|
+
if (!existsSync(projectsDir)) return null;
|
|
653
|
+
const leaf = _lastSeg(n);
|
|
654
|
+
if (!leaf) return null;
|
|
655
|
+
|
|
656
|
+
const ci = isCaseInsensitiveFs();
|
|
657
|
+
let entries;
|
|
658
|
+
try {
|
|
659
|
+
entries = readdirSync(projectsDir);
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
const slug = entries.find((e) => e !== '_template' && _fold(e, ci) === _fold(leaf, ci));
|
|
664
|
+
if (!slug) return null;
|
|
665
|
+
|
|
666
|
+
const dir = join(projectsDir, slug);
|
|
667
|
+
try {
|
|
668
|
+
if (!statSync(dir).isDirectory()) return null;
|
|
669
|
+
} catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
const hasArtifacts =
|
|
673
|
+
existsSync(join(dir, 'session-state.md')) ||
|
|
674
|
+
existsSync(join(dir, 'hot.md')) ||
|
|
675
|
+
existsSync(join(dir, 'session-log'));
|
|
676
|
+
if (!hasArtifacts) return null;
|
|
677
|
+
|
|
678
|
+
const indexPath = join(dir, 'index.md');
|
|
679
|
+
const hasIndex = existsSync(indexPath);
|
|
680
|
+
let workingDir = null;
|
|
681
|
+
if (hasIndex) {
|
|
682
|
+
try {
|
|
683
|
+
const fmBlock = readFileSync(indexPath, 'utf-8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
684
|
+
const line = fmBlock && fmBlock[1].split(/\r?\n/).find((l) => /^working_dir:/.test(l));
|
|
685
|
+
if (line) {
|
|
686
|
+
workingDir = line
|
|
687
|
+
.slice('working_dir:'.length)
|
|
688
|
+
.trim()
|
|
689
|
+
.replace(/\s+#.*$/, '')
|
|
690
|
+
.replace(/^['"]|['"]$/g, '');
|
|
691
|
+
}
|
|
692
|
+
} catch {
|
|
693
|
+
workingDir = null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (hasIndex && workingDir) return null; // already anchored — not a backfill case
|
|
697
|
+
|
|
698
|
+
return { slug, hasIndex };
|
|
699
|
+
}
|
|
700
|
+
|
|
627
701
|
/**
|
|
628
702
|
* When the session cwd is a project working_dir distinct from the vault root,
|
|
629
703
|
* the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
|
|
@@ -1461,6 +1535,23 @@ export function cwdHasProjectMarker(cwd) {
|
|
|
1461
1535
|
return PROJECT_MARKERS.some((m) => existsSync(join(cwd, m)));
|
|
1462
1536
|
}
|
|
1463
1537
|
|
|
1538
|
+
// Shared cooldown/skip gate for BOTH the new-project offer and the backfill
|
|
1539
|
+
// offer below — same store (.cache/project-suggestions.json), same key (cwd),
|
|
1540
|
+
// so a user who just answered one kind of offer for a cwd doesn't immediately
|
|
1541
|
+
// get re-prompted with the other. A corrupt store stays silent (doctor
|
|
1542
|
+
// surfaces the malformation separately).
|
|
1543
|
+
function suggestionNotSuppressed(cwd, hypoDir, now) {
|
|
1544
|
+
const { skips, cooldowns, parseError } = readProjectSuggestions(hypoDir);
|
|
1545
|
+
if (parseError) return false;
|
|
1546
|
+
if (skips.some((s) => s && s.cwd === cwd)) return false;
|
|
1547
|
+
const ts = cooldowns[cwd];
|
|
1548
|
+
if (ts) {
|
|
1549
|
+
const t = Date.parse(ts);
|
|
1550
|
+
if (Number.isFinite(t) && now - t < SUGGESTION_COOLDOWN_MS) return false;
|
|
1551
|
+
}
|
|
1552
|
+
return true;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1464
1555
|
/**
|
|
1465
1556
|
* Decide whether SessionStart/CwdChanged should offer to create a project for
|
|
1466
1557
|
* `cwd`. The caller MUST have already confirmed `cwd` matches no project's
|
|
@@ -1478,15 +1569,25 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
|
|
|
1478
1569
|
if (!cwd) return false;
|
|
1479
1570
|
if (!existsSync(join(cwd, '.git'))) return false;
|
|
1480
1571
|
if (!cwdHasProjectMarker(cwd)) return false;
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1572
|
+
return suggestionNotSuppressed(cwd, hypoDir, now);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Decide whether SessionStart/CwdChanged should offer to BACKFILL a
|
|
1577
|
+
* working_dir anchor for `cwd`. The caller MUST have already located a
|
|
1578
|
+
* findBackfillCandidate() match; this only applies the same cooldown/skip
|
|
1579
|
+
* gate shouldSuggestProjectCreation uses, so the two offers share one
|
|
1580
|
+
* suppression store. No git/marker gate here — the project directory
|
|
1581
|
+
* already existing is itself the trigger condition.
|
|
1582
|
+
*
|
|
1583
|
+
* @param {string} cwd
|
|
1584
|
+
* @param {string} [hypoDir]
|
|
1585
|
+
* @param {number} [now] epoch ms, injectable for tests
|
|
1586
|
+
* @returns {boolean}
|
|
1587
|
+
*/
|
|
1588
|
+
export function shouldSuggestBackfill(cwd, hypoDir = HYPO_DIR, now = Date.now()) {
|
|
1589
|
+
if (!cwd) return false;
|
|
1590
|
+
return suggestionNotSuppressed(cwd, hypoDir, now);
|
|
1490
1591
|
}
|
|
1491
1592
|
|
|
1492
1593
|
/**
|
|
@@ -1509,6 +1610,64 @@ export function buildProjectSuggestionLine(cwd) {
|
|
|
1509
1610
|
return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
|
|
1510
1611
|
}
|
|
1511
1612
|
|
|
1613
|
+
// Neutralize a PATH value for additionalContext injection without truncating
|
|
1614
|
+
// it — unlike sanitizeProjForPrompt (built for a short display name), a path
|
|
1615
|
+
// must render in full or the backfilled working_dir would be wrong. Only
|
|
1616
|
+
// newline/control chars are stripped (the injection vector); everything else,
|
|
1617
|
+
// including shell metacharacters, passes through untouched because this value
|
|
1618
|
+
// is NEVER assembled into a runnable command (see buildBackfillSuggestionLine)
|
|
1619
|
+
// — it is purely descriptive text for the LLM/user to read.
|
|
1620
|
+
function stripControlCharsForPath(raw) {
|
|
1621
|
+
// Per-codepoint (not a regex escape range) so no control byte is written
|
|
1622
|
+
// literally into this source file (the source-corruption trap hit earlier
|
|
1623
|
+
// in this same change). Mirrors sanitizeProjForPrompt's coverage EXACTLY —
|
|
1624
|
+
// C0 (0x00-0x1F), DEL+C1 (0x7F-0x9F, which folds in U+0085 NEL), and the
|
|
1625
|
+
// U+2028/U+2029 line separators. Deliberately does NOT strip bidi format
|
|
1626
|
+
// controls: sanitizeProjForPrompt doesn't either, and matching its exact
|
|
1627
|
+
// decision (rather than inventing a wider one here) is the principled
|
|
1628
|
+
// choice so the two sanitizers never silently diverge in coverage.
|
|
1629
|
+
return Array.from(String(raw == null ? '' : raw))
|
|
1630
|
+
.map((ch) => {
|
|
1631
|
+
const code = ch.codePointAt(0);
|
|
1632
|
+
const isC0OrDelOrC1 = code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
1633
|
+
const isLineSep = code === 0x2028 || code === 0x2029;
|
|
1634
|
+
return isC0OrDelOrC1 || isLineSep ? ' ' : ch;
|
|
1635
|
+
})
|
|
1636
|
+
.join('');
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Build the working_dir-backfill offer line for a cwd that names an EXISTING
|
|
1641
|
+
* `projects/<slug>/` directory (findBackfillCandidate already confirmed it
|
|
1642
|
+
* carries session artifacts but no anchor). `slug` (short, display-only)
|
|
1643
|
+
* routes through sanitizeProjForPrompt; `cwd` (a real path that must not be
|
|
1644
|
+
* corrupted) routes through stripControlCharsForPath instead — truncating a
|
|
1645
|
+
* path would silently backfill the WRONG working_dir. Neither branch
|
|
1646
|
+
* assembles a runnable shell command from either value (codex pre-commit
|
|
1647
|
+
* review: embedding untrusted path/slug text into a copy-paste `node ...`
|
|
1648
|
+
* invocation is a shell-injection vector); both branches are purely
|
|
1649
|
+
* descriptive guidance for the LLM/user to act on.
|
|
1650
|
+
*
|
|
1651
|
+
* The two cases still need different WORDING: a MISSING index.md has nothing
|
|
1652
|
+
* to edit yet, so the offer describes creating a fresh one anchored to the
|
|
1653
|
+
* current cwd (the agent constructs the actual project-create.mjs invocation
|
|
1654
|
+
* itself, safely, outside this string). An index.md that already EXISTS but
|
|
1655
|
+
* simply omits working_dir needs a direct frontmatter addition instead.
|
|
1656
|
+
*
|
|
1657
|
+
* @param {string} slug the matching projects/<slug>/ directory name
|
|
1658
|
+
* @param {string} cwd the session cwd (the value that would become working_dir)
|
|
1659
|
+
* @param {boolean} hasIndex whether projects/<slug>/index.md already exists
|
|
1660
|
+
* @returns {string}
|
|
1661
|
+
*/
|
|
1662
|
+
export function buildBackfillSuggestionLine(slug, cwd, hasIndex) {
|
|
1663
|
+
const safeSlug = sanitizeProjForPrompt(slug);
|
|
1664
|
+
const safeCwd = stripControlCharsForPath(cwd);
|
|
1665
|
+
const action = hasIndex
|
|
1666
|
+
? `projects/${safeSlug}/index.md의 frontmatter에 working_dir: ${safeCwd}를 추가할까요?`
|
|
1667
|
+
: `projects/${safeSlug}/index.md가 없습니다. 현재 세션의 cwd(${safeCwd})를 working_dir로 삼아 index.md를 새로 만들까요?`;
|
|
1668
|
+
return `[WIKI: cwd가 기존 프로젝트 'projects/${safeSlug}/'와 이름이 일치하지만 working_dir 앵커가 없습니다. ${action} (Y/n)]`;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1512
1671
|
// ── clear-marker (amendment 2026-05-14) ──────────────────────
|
|
1513
1672
|
// `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
|
|
1514
1673
|
// point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
|
|
@@ -2598,6 +2757,141 @@ export function hasUserCloseSignal(transcriptPath) {
|
|
|
2598
2757
|
return false;
|
|
2599
2758
|
}
|
|
2600
2759
|
|
|
2760
|
+
/**
|
|
2761
|
+
* True iff the Stop payload's `background_tasks` names an in-flight (not yet
|
|
2762
|
+
* terminal) delegated subagent. Read-only: used to widen the autoclose
|
|
2763
|
+
* reconfirm trigger (see hypo-auto-minimal-crystallize.mjs) to "work is
|
|
2764
|
+
* demonstrably still running", not just "the wiki has uncommitted changes".
|
|
2765
|
+
*
|
|
2766
|
+
* Defined as the NEGATION of a terminal status, not an enumeration of
|
|
2767
|
+
* "running" states — this repo has no `background_tasks` type definition
|
|
2768
|
+
* (grep turns up nothing), so the real status vocabulary is unverified. An
|
|
2769
|
+
* unrecognized/absent status is treated as in-flight; only a recognized
|
|
2770
|
+
* terminal status clears it.
|
|
2771
|
+
*
|
|
2772
|
+
* Fail-open: a missing or non-array `background_tasks` is treated as "no
|
|
2773
|
+
* in-flight subagent" (the field may simply not be sent by this Stop event),
|
|
2774
|
+
* so in-flight detection degrades gracefully to the git-uncommitted signal
|
|
2775
|
+
* alone rather than spuriously blocking.
|
|
2776
|
+
*/
|
|
2777
|
+
export function hasInFlightSubagent(payload) {
|
|
2778
|
+
const tasks = payload && payload.background_tasks;
|
|
2779
|
+
if (!Array.isArray(tasks)) return false;
|
|
2780
|
+
const TERMINAL = /^(completed|complete|done|finished|failed|error|errored|cancelled|canceled)$/i;
|
|
2781
|
+
return tasks.some(
|
|
2782
|
+
(t) => t && t.type === 'subagent' && (t.status == null || !TERMINAL.test(String(t.status))),
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// The reconfirm reason (hypo-auto-minimal-crystallize.mjs emitBlock's
|
|
2787
|
+
// reconfirm branch) instructs the close-now option under this exact label —
|
|
2788
|
+
// used below to correlate an AskUserQuestion tool_use to OUR close-reconfirm
|
|
2789
|
+
// prompt specifically, not just any AskUserQuestion the model happens to ask.
|
|
2790
|
+
// Exported so the hook builds its reason text off this SAME literal instead
|
|
2791
|
+
// of a second hardcoded copy: two independent literals would let a reworded
|
|
2792
|
+
// reason silently break this correlation with no test to catch it.
|
|
2793
|
+
export const CLOSE_RECONFIRM_MARK = '지금 닫기';
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* True iff the LATEST correlated AskUserQuestion answer in the transcript
|
|
2797
|
+
* declined an autoclose reconfirm prompt ("아직" / "나중" / "not yet" /
|
|
2798
|
+
* "later") — order-sensitive so a decline is suppressed again once the user
|
|
2799
|
+
* signals a NEW close intent afterward (re-arm).
|
|
2800
|
+
*
|
|
2801
|
+
* Read-only, forward scan over the FULL transcript (no tail truncation — a
|
|
2802
|
+
* decline can precede the next Stop by any number of turns). Reuses the same
|
|
2803
|
+
* askIds + tool_use_id correlation `hasUserCloseSignal` (above) uses to bind
|
|
2804
|
+
* an AskUserQuestion answer to its own tool_use, so an unrelated tool_result
|
|
2805
|
+
* string can't forge a decline. Unlike `hasUserCloseSignal` (which is a
|
|
2806
|
+
* "any evidence, ever" OR), this tracks a single latest-wins boolean as it
|
|
2807
|
+
* scans: a decline answer sets it true, and any later GENUINE USER close
|
|
2808
|
+
* signal resets it to false — the user asked to close again, so the prior
|
|
2809
|
+
* decline no longer applies.
|
|
2810
|
+
*
|
|
2811
|
+
* Two correlation guards (the same input-boundary concern class as
|
|
2812
|
+
* extractUserMessages' tool_result/injection exclusion above):
|
|
2813
|
+
*
|
|
2814
|
+
* 1. Re-arm is gated to a real user-typed close signal ONLY — mirrors
|
|
2815
|
+
* extractUserMessages' own boundary filter (isMeta / promptSource
|
|
2816
|
+
* system|sdk / tool_result exclusion, see extractUserMessages above)
|
|
2817
|
+
* instead of re-deriving a separate policy. Without this, the MODEL'S
|
|
2818
|
+
* OWN assistant reasoning text (which can itself contain "세션 마무리"),
|
|
2819
|
+
* or a tool_result (e.g. a Read of a file that happens to quote a close
|
|
2820
|
+
* phrase), could silently clear a recorded decline and re-nag the user
|
|
2821
|
+
* who already said "아직" — the opposite of what a decline means.
|
|
2822
|
+
* 2. The AskUserQuestion tool_use is only added to `askIds` when its input
|
|
2823
|
+
* carries CLOSE_RECONFIRM_MARK — i.e. it IS the close-reconfirm prompt,
|
|
2824
|
+
* not some unrelated question the model asked around the same time. An
|
|
2825
|
+
* unrelated question whose answer happens to contain a decline word
|
|
2826
|
+
* ("나중"/"later") must not correlate.
|
|
2827
|
+
*
|
|
2828
|
+
* Fail-open: a missing/unreadable transcript → false (do not suppress; keep
|
|
2829
|
+
* reconfirming) — the caller stays on the safe (nag, not silent-close) side.
|
|
2830
|
+
*/
|
|
2831
|
+
export function isCloseReconfirmDeclined(transcriptPath) {
|
|
2832
|
+
if (!transcriptPath) return false;
|
|
2833
|
+
let lines;
|
|
2834
|
+
try {
|
|
2835
|
+
lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
2836
|
+
} catch {
|
|
2837
|
+
return false;
|
|
2838
|
+
}
|
|
2839
|
+
const DECLINE = /(아직|나중|not\s?yet|later)/i;
|
|
2840
|
+
const askIds = new Set();
|
|
2841
|
+
let declined = false;
|
|
2842
|
+
for (const line of lines) {
|
|
2843
|
+
if (!line.trim()) continue;
|
|
2844
|
+
let obj;
|
|
2845
|
+
try {
|
|
2846
|
+
obj = JSON.parse(line);
|
|
2847
|
+
} catch {
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
const msg = obj.message ?? obj;
|
|
2851
|
+
const content = msg.content ?? obj.content;
|
|
2852
|
+
|
|
2853
|
+
// Re-arm boundary (guard 1): only a genuine user-typed close signal may
|
|
2854
|
+
// re-arm. Same exclusions as extractUserMessages — isMeta injection,
|
|
2855
|
+
// system/sdk synthetic prompts, and (via the array-content branch below)
|
|
2856
|
+
// tool_result blocks, which are role:'user' in the transcript but are NOT
|
|
2857
|
+
// user-typed text.
|
|
2858
|
+
const isInjected = obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
|
|
2859
|
+
const role = msg.role ?? obj.role ?? obj.type;
|
|
2860
|
+
if (!isInjected && role === 'user') {
|
|
2861
|
+
if (typeof content === 'string') {
|
|
2862
|
+
if (!content.startsWith('Stop hook feedback') && isClosePattern(content)) declined = false;
|
|
2863
|
+
} else if (Array.isArray(content)) {
|
|
2864
|
+
for (const b of content) {
|
|
2865
|
+
// Only type:'text' blocks are genuine user-typed text — a
|
|
2866
|
+
// tool_result block (also role:'user') is tool output, not
|
|
2867
|
+
// something the user typed, and must not re-arm.
|
|
2868
|
+
if (b && b.type === 'text' && typeof b.text === 'string' && isClosePattern(b.text)) {
|
|
2869
|
+
declined = false;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
if (!Array.isArray(content)) continue;
|
|
2876
|
+
for (const b of content) {
|
|
2877
|
+
if (!b || typeof b !== 'object') continue;
|
|
2878
|
+
if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
|
|
2879
|
+
// Correlation guard 2: only OUR close-reconfirm prompt counts.
|
|
2880
|
+
const inputStr = JSON.stringify(b.input ?? null);
|
|
2881
|
+
if (inputStr.includes(CLOSE_RECONFIRM_MARK)) askIds.add(b.id);
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2884
|
+
if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
|
|
2885
|
+
const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
2886
|
+
for (const m of s.matchAll(/="([^"]*)"/g)) {
|
|
2887
|
+
if (DECLINE.test(m[1])) declined = true;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
return declined;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2601
2895
|
/**
|
|
2602
2896
|
* Build hook output for Claude Code (additionalContext channel).
|
|
2603
2897
|
* Codex hooks write systemMessage directly in their own files.
|