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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.ko.md +8 -6
- package/README.md +8 -6
- package/commands/crystallize.md +32 -6
- package/commands/graph.md +7 -4
- package/commands/lint.md +8 -1
- package/commands/query.md +6 -4
- package/commands/resume.md +1 -0
- package/commands/verify.md +12 -1
- package/docs/ARCHITECTURE.md +26 -12
- package/docs/CONTRIBUTING.md +15 -6
- package/hooks/hypo-compact-guard.mjs +126 -23
- package/hooks/hypo-cwd-change.mjs +20 -19
- package/hooks/hypo-first-prompt.mjs +31 -16
- package/hooks/hypo-lookup.mjs +10 -5
- package/hooks/hypo-session-start.mjs +180 -2
- package/hooks/hypo-shared.mjs +410 -83
- package/hooks/hypo-web-fetch-ingest.mjs +9 -13
- package/package.json +2 -1
- package/scripts/doctor.mjs +32 -3
- package/scripts/graph.mjs +22 -2
- package/scripts/init.mjs +5 -1
- package/scripts/lib/crystallize-args.mjs +38 -2
- package/scripts/lib/crystallize-close-apply.mjs +745 -450
- package/scripts/lint.mjs +242 -39
- package/scripts/query.mjs +22 -2
- package/scripts/resume.mjs +177 -2
- package/scripts/upgrade.mjs +2 -2
- package/scripts/verify.mjs +22 -2
- package/templates/SCHEMA.md +23 -1
- package/templates/hypo-automation.md +4 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
- package/skills/crystallize/SKILL.md +0 -189
- package/skills/graph/SKILL.md +0 -58
- package/skills/ingest/SKILL.md +0 -107
- package/skills/lint/SKILL.md +0 -59
- package/skills/query/SKILL.md +0 -62
- 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
|
|
13
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
12
|
-
* hook already
|
|
13
|
-
* to lead with
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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` +
|
package/hooks/hypo-lookup.mjs
CHANGED
|
@@ -293,10 +293,14 @@ process.stdin.on('end', () => {
|
|
|
293
293
|
.join(', ');
|
|
294
294
|
console.log(
|
|
295
295
|
JSON.stringify(
|
|
296
|
-
buildOutput(
|
|
297
|
-
|
|
298
|
-
|
|
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
|
),
|