hypomnema 1.8.0 → 1.8.2

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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.ko.md +8 -6
  5. package/README.md +8 -6
  6. package/commands/crystallize.md +32 -6
  7. package/commands/graph.md +7 -4
  8. package/commands/lint.md +8 -1
  9. package/commands/query.md +6 -4
  10. package/commands/resume.md +1 -0
  11. package/commands/verify.md +12 -1
  12. package/docs/ARCHITECTURE.md +26 -12
  13. package/docs/CONTRIBUTING.md +15 -6
  14. package/hooks/hypo-compact-guard.mjs +126 -23
  15. package/hooks/hypo-cwd-change.mjs +20 -19
  16. package/hooks/hypo-first-prompt.mjs +31 -16
  17. package/hooks/hypo-lookup.mjs +10 -5
  18. package/hooks/hypo-session-start.mjs +180 -2
  19. package/hooks/hypo-shared.mjs +410 -83
  20. package/hooks/hypo-web-fetch-ingest.mjs +9 -13
  21. package/package.json +2 -1
  22. package/scripts/doctor.mjs +32 -3
  23. package/scripts/graph.mjs +22 -2
  24. package/scripts/init.mjs +5 -1
  25. package/scripts/lib/crystallize-args.mjs +38 -2
  26. package/scripts/lib/crystallize-close-apply.mjs +745 -450
  27. package/scripts/lint.mjs +242 -39
  28. package/scripts/query.mjs +22 -2
  29. package/scripts/resume.mjs +177 -2
  30. package/scripts/upgrade.mjs +2 -2
  31. package/scripts/verify.mjs +22 -2
  32. package/templates/SCHEMA.md +23 -1
  33. package/templates/hypo-automation.md +4 -2
  34. package/templates/hypo-config.md +1 -1
  35. package/templates/hypo-guide.md +1 -1
  36. package/skills/crystallize/SKILL.md +0 -189
  37. package/skills/graph/SKILL.md +0 -58
  38. package/skills/ingest/SKILL.md +0 -107
  39. package/skills/lint/SKILL.md +0 -59
  40. package/skills/query/SKILL.md +0 -62
  41. package/skills/verify/SKILL.md +0 -96
@@ -9,20 +9,47 @@
9
9
  * PreCompact event at all, so this hook is the only chat-side gate that can
10
10
  * prompt session-close before a context wipe.
11
11
  *
12
- * Behavior: if session close is incomplete → instruct Claude to run session close
13
- * immediately before /compact or /clear.
12
+ * Behavior: if session close is incomplete, tell Claude so (a description, not
13
+ * an instruction — session-close-scope-boundary spec §5) before /compact or
14
+ * /clear runs.
15
+ *
16
+ * This hook never calls precompactGateStatus: its own hooks.json timeout is
17
+ * 10s and the gate's lint spawn alone budgets 30s, three times over. Instead
18
+ * it re-checks only the cheap axes (session log, git, hot.md) directly, and
19
+ * narrows the git axis with the SAME project-vs-foreign path rule the gate
20
+ * uses (isForeignProjectFile / classifyForeignOnlyDirty in hypo-shared.mjs), so
21
+ * a different project's dangling close file does not fire a false alarm here
22
+ * either. Unlike the gate, this hook never passes transcriptTouched into that
23
+ * rule: parsing the transcript is exactly the evidence spec §5 excludes to
24
+ * stay inside the 10s budget, so only the cheap path-prefix axis runs here.
14
25
  */
15
26
 
16
27
  import {
17
28
  lastSubstantialOpIsSession,
18
29
  hypoIsClean,
30
+ gitDirtyFiles,
19
31
  hotMdIsClean,
20
32
  readChecklist,
21
33
  isClearCommand,
22
34
  isCompactOrClearCommand,
23
35
  isGateSkipped,
36
+ resolveGateProjectOverride,
37
+ classifyForeignOnlyDirty,
38
+ buildOutput,
39
+ HYPO_DIR,
24
40
  } from './hypo-shared.mjs';
25
41
 
42
+ // A fixed slice of this hook's own 10s hooks.json budget (hooks.json:60),
43
+ // shared as ONE deadline across every git spawn hypoIsClean and
44
+ // gitDirtyFiles make below (up to four): each call re-checks the time left
45
+ // and skips its own spawn once the shared budget is spent, so no NEW spawn
46
+ // starts once this budget is gone. That bounds the git-spawn total, not the
47
+ // hook's wall-clock time end to end: measured 145ms on a normal run and
48
+ // 6273ms with a slow fsmonitor in the mix, both inside the 10s hooks.json
49
+ // timeout, but a spawnSync child that outlives a SIGTERM has no coded upper
50
+ // bound here.
51
+ const GIT_DEADLINE_BUDGET_MS = 6000;
52
+
26
53
  let input = '';
27
54
  process.stdin.setEncoding('utf-8');
28
55
  process.stdin.on('data', (chunk) => {
@@ -40,24 +67,98 @@ process.stdin.on('end', () => {
40
67
 
41
68
  const detected = isClearCommand(prompt) ? '/clear' : '/compact';
42
69
 
43
- const hasSession = lastSubstantialOpIsSession();
44
- const gitStatus = hypoIsClean();
45
- const hotStatus = hotMdIsClean();
70
+ // These two run on EVERY /compact or /clear, unlike resolveGateProjectOverride
71
+ // below (which only runs when gitStatus.uncommitted is already true). A
72
+ // throw here would reach the outermost catch on every single prompt, not
73
+ // just the git-dirty ones, so the exposure is wider than the resolveGate
74
+ // case: fail closed locally instead ("reason present"), never fail open
75
+ // ("looks clean"). A read failure on either file is not the same fact as
76
+ // that file being genuinely absent or well-formed, so the fallback reason
77
+ // says "unreadable", not "missing" or "invalid", to keep the two causes
78
+ // tellable apart from the additionalContext text alone.
79
+ // lastSubstantialOpIsSession() now reads a MISSING log.md (ENOENT) as
80
+ // `false`, and reads only via a single readFileSync call (no separate
81
+ // existsSync precheck), so there is no check-then-read window where the
82
+ // file is deleted between the two and falls back to the old fail-open
83
+ // `true`. That keeps state-table row 1 (spec §5, "session log entry
84
+ // missing") surfaced: a brand-new vault with an all-foreign dirty tree
85
+ // and a clean hot.md still reports the missing log instead of going
86
+ // fully silent. Any OTHER read failure (EISDIR, EACCES, ...) is a real
87
+ // problem, so the function rethrows it, and the try/catch below turns
88
+ // that into the same fail-closed `hasSession = false` plus a stderr line.
89
+ let hasSession;
90
+ try {
91
+ hasSession = lastSubstantialOpIsSession();
92
+ } catch (err) {
93
+ process.stderr.write(
94
+ `[hypo-compact-guard] error: lastSubstantialOpIsSession failed, treating as session log entry missing: ${err?.message ?? String(err)}\n`,
95
+ );
96
+ hasSession = false;
97
+ }
98
+ const deadline = { end: performance.now() + GIT_DEADLINE_BUDGET_MS };
99
+ const gitStatus = hypoIsClean(undefined, { deadline });
100
+ let hotStatus;
101
+ try {
102
+ hotStatus = hotMdIsClean();
103
+ } catch (err) {
104
+ process.stderr.write(
105
+ `[hypo-compact-guard] error: hotMdIsClean failed, treating as hot.md unreadable: ${err?.message ?? String(err)}\n`,
106
+ );
107
+ hotStatus = { clean: false, reason: `hot.md unreadable: ${err?.message ?? String(err)}` };
108
+ }
46
109
 
47
- // Block on uncommitted (real unsaved work); unpushed commits (ahead)
48
- // are a soft, auto-synced state and must not block /compact or /clear — mirrors
49
- // the precompactGateStatus demote so the chat-side gate stays consistent.
50
- if (hasSession && !gitStatus.uncommitted && hotStatus.clean) {
51
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
52
- return;
110
+ // Uncommitted (real unsaved work) blocks; unpushed commits (ahead) are a
111
+ // soft, auto-synced state and never reach `gitStatus.reason` here, since
112
+ // `uncommitted` is what gates it — mirrors the precompactGateStatus
113
+ // demote so the chat-side gate stays consistent.
114
+ let gitReason = gitStatus.uncommitted ? gitStatus.reason : '';
115
+ if (gitStatus.uncommitted) {
116
+ // resolveGateProjectOverride (session-close-scope-boundary spec §2):
117
+ // the same cwd-to-project resolution PreCompact and Stop already use.
118
+ // null just means "no project this cwd unambiguously owns" — that
119
+ // keeps the git axis judged globally, exactly like today. Scoped
120
+ // inside `uncommitted` on purpose: it only narrows the git notice, so
121
+ // a clean /compact has no reason to pay for a projects/ scan.
122
+ //
123
+ // Unlike classifyForeignOnlyDirty, this call is NOT contract-bound to
124
+ // stay silent: it walks through collectProjectWorkingDirs' own
125
+ // readdirSync, which sits outside that function's try/catch. Left
126
+ // uncaught here, that throw would escape past this hook's git-axis
127
+ // logic into the outermost catch and come back as a FULLY suppressed
128
+ // {suppressOutput:true} — silently dropping the session-log and
129
+ // hot.md reasons too, not just this one. Catch it locally and demote
130
+ // to null (its own "no project" sentinel) so a broken vault still
131
+ // gets every reason it is due.
132
+ let attributionScope = null;
133
+ try {
134
+ attributionScope = resolveGateProjectOverride(HYPO_DIR, {
135
+ sessionCwd: data.cwd ?? null,
136
+ });
137
+ } catch (err) {
138
+ process.stderr.write(
139
+ `[hypo-compact-guard] error: resolveGateProjectOverride failed, treating as no override: ${err?.message ?? String(err)}\n`,
140
+ );
141
+ }
142
+ if (attributionScope) {
143
+ const dirty = gitDirtyFiles(HYPO_DIR, { deadline });
144
+ const classification = classifyForeignOnlyDirty(HYPO_DIR, dirty, {
145
+ effectiveOverride: attributionScope,
146
+ });
147
+ if (classification === 'foreign-only') gitReason = '';
148
+ }
53
149
  }
54
150
 
55
151
  const reasons = [
56
152
  !hasSession ? 'session log entry missing' : '',
57
- gitStatus.uncommitted ? gitStatus.reason : '',
153
+ gitReason,
58
154
  !hotStatus.clean ? hotStatus.reason : '',
59
155
  ].filter(Boolean);
60
156
 
157
+ if (reasons.length === 0) {
158
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
159
+ return;
160
+ }
161
+
61
162
  const today = new Date().toISOString().slice(0, 10);
62
163
  const checklist = readChecklist(today);
63
164
  const body = checklist
@@ -65,17 +166,19 @@ process.stdin.on('end', () => {
65
166
  : 'See hypo-guide.md for the session-close checklist.';
66
167
 
67
168
  console.log(
68
- JSON.stringify({
69
- continue: true,
70
- additionalContext: [
71
- `[WIKI_AUTOCLOSE] ${detected} detected — session close incomplete (${reasons.join(', ')}).`,
72
- `Do NOT wait for user input. Run wiki session close NOW, then retry ${detected}.`,
73
- ``,
74
- body,
75
- ``,
76
- `To bypass: set HYPO_SKIP_GATE=1`,
77
- ].join('\n'),
78
- }),
169
+ JSON.stringify(
170
+ buildOutput(
171
+ 'UserPromptSubmit',
172
+ [
173
+ `[WIKI_AUTOCLOSE] ${detected} detected: session close incomplete (${reasons.join(', ')}).`,
174
+ ``,
175
+ body,
176
+ ``,
177
+ `To bypass: set HYPO_SKIP_GATE=1`,
178
+ ].join('\n'),
179
+ { continue: true },
180
+ ),
181
+ ),
79
182
  );
80
183
  } catch (err) {
81
184
  // Fail-open: any parse/runtime error must not block the user's prompt.
@@ -10,7 +10,6 @@ import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import {
12
12
  HYPO_DIR,
13
- buildOutput,
14
13
  loadHypoIgnore,
15
14
  isIgnored,
16
15
  sessionMarkerPath,
@@ -153,16 +152,17 @@ process.stdin.on('end', () => {
153
152
  // working_dir distinct from the vault, surface where wiki files live.
154
153
  const vaultOrientation = buildVaultOrientation(newCwd);
155
154
  const orientPrefix = vaultOrientation ? `${vaultOrientation}\n\n` : '';
155
+ // Built inline rather than through buildOutput(): CwdChanged has no
156
+ // documented context-injection path, so the nested hookSpecificOutput
157
+ // shape buildOutput() now emits would be wrong for this event. The
158
+ // follow-up that moves this hook to systemMessage removes these three
159
+ // literals; until then they keep today's behaviour unchanged.
156
160
  console.log(
157
- JSON.stringify(
158
- buildOutput(
159
- `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
160
- {
161
- continue: true,
162
- suppressOutput: true,
163
- },
164
- ),
165
- ),
161
+ JSON.stringify({
162
+ continue: true,
163
+ suppressOutput: true,
164
+ additionalContext: `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
165
+ }),
166
166
  );
167
167
  return;
168
168
  }
@@ -199,9 +199,11 @@ process.stdin.on('end', () => {
199
199
  if (!globalContent) {
200
200
  if (suggestPrefix) {
201
201
  console.log(
202
- JSON.stringify(
203
- buildOutput(suggestPrefix.trimEnd(), { continue: true, suppressOutput: true }),
204
- ),
202
+ JSON.stringify({
203
+ continue: true,
204
+ suppressOutput: true,
205
+ additionalContext: suggestPrefix.trimEnd(),
206
+ }),
205
207
  );
206
208
  } else {
207
209
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -209,12 +211,11 @@ process.stdin.on('end', () => {
209
211
  return;
210
212
  }
211
213
  console.log(
212
- JSON.stringify(
213
- buildOutput(
214
- `${suggestPrefix}[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`,
215
- { continue: true, suppressOutput: true },
216
- ),
217
- ),
214
+ JSON.stringify({
215
+ continue: true,
216
+ suppressOutput: true,
217
+ additionalContext: `${suggestPrefix}[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`,
218
+ }),
218
219
  );
219
220
  } catch (err) {
220
221
  process.stderr.write(`[hypo-cwd-change] error: ${err?.message ?? String(err)}\n`);
@@ -8,9 +8,12 @@
8
8
  * resume summary into the reply (the old "answer only if related"
9
9
  * conditional is removed; the line is injected unconditionally).
10
10
  *
11
- * hot.md / session-state.md content is NOT re-injected here — the upstream
12
- * hook already placed it in additionalContext. This hook only forces the LLM
13
- * to lead with the summary line drawn from that context.
11
+ * hot.md / session-state.md content is NOT re-injected here. On the SessionStart
12
+ * path the upstream hook already put it in the model's context, and this hook
13
+ * only forces the LLM to lead with a summary line drawn from it. On the
14
+ * cwd-change path nothing was injected at all (CwdChanged has no documented
15
+ * injection path), so that branch asks for a verbatim line instead of a
16
+ * summary.
14
17
  * Marker expires after 10 minutes.
15
18
  */
16
19
 
@@ -71,23 +74,35 @@ process.stdin.on('end', () => {
71
74
  // for the model to fill the placeholders with. Provide a concrete fallback
72
75
  // line so the model doesn't leak literal `[one-line summary]` text on a
73
76
  // first-ever session (codex v2 review 2026-05-26).
74
- const exampleLine = hasSnapshot
75
- ? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
76
- : scopedOut
77
- ? `${projSafe}: this project has a prior snapshot, but it is scoped to another machine and is not visible here. What would you like to work on?`
78
- : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
79
- const fillNote = hasSnapshot
80
- ? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
81
- `context already injected this session — do NOT emit the literal brackets.`
82
- : scopedOut
83
- ? `Use the line above verbatim. The project has prior work; its snapshot ` +
84
- `simply belongs to another machine, so treat it as an existing project ` +
85
- `whose history you cannot see from here.`
86
- : `Use the line above verbatim — there is no prior snapshot to summarize.`;
77
+ // A cwd-change marker arms this hook, but CwdChanged has no documented
78
+ // context-injection path, so no [HOT] / [SESSION STATE] ever reached the
79
+ // model for that move. Asking for a summary would make the model invent
80
+ // one, or emit the literal brackets. This is not a temporary branch: the
81
+ // follow-up that moves the hook to systemMessage sends that text to the
82
+ // user, not the model, so the model still gets nothing for a cwd move.
83
+ const cwdMove = marker.source === 'cwd-change';
84
+ const exampleLine = cwdMove
85
+ ? `${verb} ${projSafe}. What would you like to work on here?`
86
+ : hasSnapshot
87
+ ? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
88
+ : scopedOut
89
+ ? `${projSafe}: this project has a prior snapshot, but it is scoped to another machine and is not visible here. What would you like to work on?`
90
+ : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
91
+ const fillNote = cwdMove
92
+ ? `Use the line above verbatim. No prior context was injected for this move.`
93
+ : hasSnapshot
94
+ ? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
95
+ `context already injected this session — do NOT emit the literal brackets.`
96
+ : scopedOut
97
+ ? `Use the line above verbatim. The project has prior work; its snapshot ` +
98
+ `simply belongs to another machine, so treat it as an existing project ` +
99
+ `whose history you cannot see from here.`
100
+ : `Use the line above verbatim — there is no prior snapshot to summarize.`;
87
101
 
88
102
  console.log(
89
103
  JSON.stringify(
90
104
  buildOutput(
105
+ 'UserPromptSubmit',
91
106
  `<hypomnema-session-resume>\n` +
92
107
  `[WIKI SESSION START: project=${projSafe}${snapshotNote}]\n` +
93
108
  `\n` +
@@ -293,10 +293,14 @@ process.stdin.on('end', () => {
293
293
  .join(', ');
294
294
  console.log(
295
295
  JSON.stringify(
296
- buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
297
- continue: true,
298
- suppressOutput: true,
299
- }),
296
+ buildOutput(
297
+ 'UserPromptSubmit',
298
+ `[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`,
299
+ {
300
+ continue: true,
301
+ suppressOutput: true,
302
+ },
303
+ ),
300
304
  ),
301
305
  );
302
306
  return;
@@ -336,7 +340,7 @@ process.stdin.on('end', () => {
336
340
  .join(', ');
337
341
  console.log(
338
342
  JSON.stringify(
339
- buildOutput(`[WIKI LOOKUP: index hit but files missing] ${slugs}`, {
343
+ buildOutput('UserPromptSubmit', `[WIKI LOOKUP: index hit but files missing] ${slugs}`, {
340
344
  continue: true,
341
345
  suppressOutput: true,
342
346
  }),
@@ -362,6 +366,7 @@ process.stdin.on('end', () => {
362
366
  console.log(
363
367
  JSON.stringify(
364
368
  buildOutput(
369
+ 'UserPromptSubmit',
365
370
  `[WIKI LOOKUP: ${injected.length} page(s) matched]\n\n` +
366
371
  injected.join('\n\n') +
367
372
  overflow,
@@ -484,6 +484,170 @@ function pendingProposalNotice() {
484
484
  return '';
485
485
  }
486
486
  }
487
+ // ── foreign-project uncommitted notice ──────────────────────────────────────
488
+ // Same signal precompactGateStatus already computes for its own gate
489
+ // (closeAccountableScope / sessionTouchTrusted, hypo-shared.mjs), surfaced
490
+ // here instead for the AGENT reading additionalContext: a project this
491
+ // session isn't scoped to may still have uncommitted changes sitting in the
492
+ // shared vault, and without this line the agent has no way to tell those
493
+ // apart from its own unfinished work. Cannot import hypo-shared.mjs's
494
+ // `projectOfPath` / `gitDirtyFiles` here, not a technical constraint (both
495
+ // are simply not exported), but an ownership decision: hypo-shared.mjs
496
+ // belongs to a different lane, so this hook keeps a local, self-contained
497
+ // duplicate rather than adding an export for it. The cost is real: a future
498
+ // fix to gitDirtyFiles (e.g. its rename re-attribution) does not propagate
499
+ // here automatically.
500
+
501
+ const FOREIGN_GIT_TIMEOUT_MS = 3000;
502
+ // Cap on how many foreign project names the notice spells out. Past this the
503
+ // rest collapse into a count, so one session cannot grow the prompt by however
504
+ // many projects are dirty.
505
+ const FOREIGN_NAME_CAP = 5;
506
+
507
+ /** `projects/<slug>/...` → `<slug>`; everything else → null. `null` here does
508
+ * not mean "not one project's work": the caller below folds every non-null
509
+ * hit into a per-name foreign count and every null hit into a nameless
510
+ * "unattributed" count, and both feed the same notice. Not the same
511
+ * classification hypo-auto-commit.mjs's commit message uses for its own
512
+ * "(N paths across M projects)" count (hypo-shared.mjs's private
513
+ * `projectOfPath`): that one folds a non-`projects/` path to its first path
514
+ * segment (`extensions`, `hot.md`, ...) for a tally; this one folds it to
515
+ * `null` because attribution, not tallying, is the job here. A top-level
516
+ * segment is not a project name, and this notice must not present it as
517
+ * one.
518
+ */
519
+ function projectOfPath(relPath) {
520
+ const parts = relPath.split('/');
521
+ return parts[0] === 'projects' && parts.length > 1 && parts[1] ? parts[1] : null;
522
+ }
523
+
524
+ /** Vault-relative dirty paths (tracked + untracked), normalized to be
525
+ * relative to `hypoDir` itself via `git rev-parse --show-prefix` (empty when
526
+ * `hypoDir` IS the repo top level), the same normalization
527
+ * hypo-shared.mjs's `gitDirtyFiles` applies for staging correctness: without
528
+ * it, a vault nested under a larger host repo reports paths relative to that
529
+ * repo's top level, and every one of them would fail to classify as this
530
+ * vault's own. NUL-separated porcelain so Korean project/page names survive
531
+ * intact. This also re-attributes a rename/copy's `from` path (codex
532
+ * 3rd-round review follow-up), the same as gitDirtyFiles does for
533
+ * staging correctness, so a rename OUT of a foreign project is not silently
534
+ * lost just because its destination happens to land under `ownProject`.
535
+ *
536
+ * Returns `null`, not `[]`, on any git failure (repo missing, `rev-parse` or
537
+ * `status` non-zero, or a timeout): folding "cannot enumerate" into the same
538
+ * empty array a truly clean repo returns would render the two identically,
539
+ * which is the silent failure this notice exists to catch (mirrors
540
+ * `gitDirtyFiles`'s own contract: "an empty return here just means 'cannot
541
+ * attribute', not 'clean'"). A clean repo returns `[]`.
542
+ */
543
+ function listDirtyPaths(hypoDir) {
544
+ const prefixRes = spawnSync('git', ['-C', hypoDir, 'rev-parse', '--show-prefix'], {
545
+ encoding: 'utf-8',
546
+ timeout: FOREIGN_GIT_TIMEOUT_MS,
547
+ });
548
+ if (prefixRes.status !== 0) return null;
549
+ // trimEnd(), not trim(): the prefix is a real path segment, and a leading
550
+ // space or control char in a directory name is valid there. trim() would
551
+ // strip it off the front, so the stripped prefix no longer matches the
552
+ // (untouched) start of every path `git status` reports, and every path
553
+ // under that directory would wrongly read as "outside the vault" (the
554
+ // notice going silent for exactly the same reason a missing rename `from`
555
+ // does below). Only the trailing `\n` `--show-prefix` always appends needs
556
+ // stripping.
557
+ const prefix = (prefixRes.stdout || '').trimEnd();
558
+
559
+ const r = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
560
+ encoding: 'utf-8',
561
+ timeout: FOREIGN_GIT_TIMEOUT_MS,
562
+ });
563
+ if (r.status !== 0) return null;
564
+ const out = [];
565
+ const records = (r.stdout || '').split('\0');
566
+ const toVaultRelative = (f) => {
567
+ if (!f) return null;
568
+ if (!prefix) return f; // hypoDir IS the repo top level, nothing to strip
569
+ return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
570
+ };
571
+ for (let i = 0; i < records.length; i++) {
572
+ const rec = records[i];
573
+ if (!rec) continue;
574
+ const xy = rec.slice(0, 2);
575
+ const file = rec.slice(3); // destination path for a rename/copy
576
+ const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
577
+ // A rename/copy emits a paired `to\0from` record. Attribute BOTH: the
578
+ // origin project lost a file just as surely as the destination gained
579
+ // one, and dropping `from` (as this used to) silently loses that origin
580
+ // whenever it differs from the destination's project (a rename INTO
581
+ // ownProject from a foreign one would otherwise vanish entirely). One
582
+ // rename/copy is therefore counted as up to 2 dirty paths, inflating
583
+ // foreignCount/unattributedCount by one per cross-project rename; the
584
+ // name Set below still de-dupes, so the project NAME list does not grow.
585
+ let fromFile = null;
586
+ if (isRenameOrCopy) {
587
+ i++;
588
+ fromFile = records[i] || null;
589
+ }
590
+ const rel = toVaultRelative(file);
591
+ if (rel) out.push(rel);
592
+ const relFrom = toVaultRelative(fromFile);
593
+ if (relFrom) out.push(relFrom);
594
+ }
595
+ return out;
596
+ }
597
+
598
+ /** One-line notice covering two counts: uncommitted paths under a named
599
+ * project other than `ownProject` (`projects/<slug>/...`, or, when
600
+ * `ownProject` is null, no cwd-matched project this session, so ANY named
601
+ * project counts), and uncommitted paths this classifier cannot attribute to
602
+ * any project at all (everything else, root vault infra, `extensions/`,
603
+ * `_specs/`, ...). This is attribution, not narrowing, so the
604
+ * unattributed bucket is surfaced with a count rather than silently dropped
605
+ * just because it has no project name to show. '' only when enumeration
606
+ * succeeded and both counts are zero, so the quiet path stays quiet exactly
607
+ * there. A `null` from `listDirtyPaths` (enumeration failed) gets its own
608
+ * distinct line instead: the caller must not read "could not tell" as
609
+ * "nothing foreign".
610
+ */
611
+ function foreignUncommittedNotice(hypoDir, ownProject) {
612
+ const dirty = listDirtyPaths(hypoDir);
613
+ if (dirty === null) {
614
+ return '[WIKI: 미커밋 변경의 귀속을 확인하지 못했습니다. git 상태를 근거로 작업 범위를 정하지 마십시오.]';
615
+ }
616
+ const foreignProjects = new Set();
617
+ let foreignCount = 0;
618
+ let unattributedCount = 0;
619
+ for (const f of dirty) {
620
+ const slug = projectOfPath(f);
621
+ if (slug === ownProject) continue;
622
+ if (slug) {
623
+ foreignProjects.add(slug);
624
+ foreignCount++;
625
+ } else {
626
+ unattributedCount++;
627
+ }
628
+ }
629
+ if (foreignCount === 0 && unattributedCount === 0) return '';
630
+ const clauses = [];
631
+ if (foreignCount > 0) {
632
+ // The slug comes from a directory name in `git status` output, so it is
633
+ // untrusted text on its way into a prompt. sanitizeProjForPrompt is the same
634
+ // guard the hot-cache notices in this file already use; skipping it here would
635
+ // let a newline or a control char in a project directory name break the
636
+ // one-line notice apart and inject into the surrounding context. The name list
637
+ // is also capped, because an unbounded one grows the context by however many
638
+ // projects happen to be dirty.
639
+ const all = [...foreignProjects].sort();
640
+ const shown = all.slice(0, FOREIGN_NAME_CAP).map((s) => `projects/${sanitizeProjForPrompt(s)}`);
641
+ const names =
642
+ all.length > FOREIGN_NAME_CAP
643
+ ? `${shown.join(', ')} 외 ${all.length - FOREIGN_NAME_CAP}개`
644
+ : shown.join(', ');
645
+ clauses.push(`현재 프로젝트 외 ${names} 변경 ${foreignCount}건`);
646
+ }
647
+ if (unattributedCount > 0) clauses.push(`귀속 불명 변경 ${unattributedCount}건`);
648
+ return `[WIKI: ${clauses.join(', ')}이 있습니다. 사용자 명시 지시 없이는 이 세션 작업으로 편입하지 마십시오.]`;
649
+ }
650
+
487
651
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
488
652
  const HOT_CHARS = 2000;
489
653
  const STATE_CHARS = 2000;
@@ -613,6 +777,17 @@ process.stdin.on('end', () => {
613
777
  const MARKER_FILE = sessionMarkerPath(sessionId);
614
778
  const hit = findProjectFiles(cwd);
615
779
 
780
+ // ownProject = the cwd-matched project (or null on a MISS, per
781
+ // foreignUncommittedNotice's docstring). Late-appended into `notices` /
782
+ // `noticePrefix`, same pattern the MISS branch's suggestLine uses below —
783
+ // both need `hit` first, which isn't resolved until this line.
784
+ const foreignNotice = foreignUncommittedNotice(HYPO_DIR, hit ? hit.proj : null);
785
+ if (foreignNotice) {
786
+ notices.push(foreignNotice);
787
+ noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
788
+ process.stderr.write(`\n\x1b[33m${foreignNotice}\x1b[0m\n`);
789
+ }
790
+
616
791
  // Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
617
792
  // describe the tree this session actually starts from, remote merges
618
793
  // included, or the first close would raise a proposal against content the
@@ -673,6 +848,7 @@ process.stdin.on('end', () => {
673
848
  console.log(
674
849
  JSON.stringify(
675
850
  buildOutput(
851
+ 'SessionStart',
676
852
  `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
677
853
  outExtra,
678
854
  ),
@@ -736,6 +912,7 @@ process.stdin.on('end', () => {
736
912
  console.log(
737
913
  JSON.stringify(
738
914
  buildOutput(
915
+ 'SessionStart',
739
916
  `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, ${reason}]`,
740
917
  outExtra,
741
918
  ),
@@ -760,7 +937,7 @@ process.stdin.on('end', () => {
760
937
  if (!existsSync(GLOBAL_HOT)) {
761
938
  const notice = notices.join('\n\n');
762
939
  if (notice) {
763
- console.log(JSON.stringify(buildOutput(notice, outExtra)));
940
+ console.log(JSON.stringify(buildOutput('SessionStart', notice, outExtra)));
764
941
  } else {
765
942
  console.log(JSON.stringify(outExtra));
766
943
  }
@@ -775,7 +952,7 @@ process.stdin.on('end', () => {
775
952
  // would otherwise be silently dropped here.
776
953
  const notice = notices.join('\n\n');
777
954
  if (notice) {
778
- console.log(JSON.stringify(buildOutput(notice, outExtra)));
955
+ console.log(JSON.stringify(buildOutput('SessionStart', notice, outExtra)));
779
956
  } else {
780
957
  console.log(JSON.stringify(outExtra));
781
958
  }
@@ -784,6 +961,7 @@ process.stdin.on('end', () => {
784
961
  console.log(
785
962
  JSON.stringify(
786
963
  buildOutput(
964
+ 'SessionStart',
787
965
  `${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
788
966
  outExtra,
789
967
  ),