hypomnema 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
package/scripts/init.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* --no-hooks Skip hook installation
|
|
14
14
|
* --no-commands Skip slash command installation to ~/.claude/commands/hypo/
|
|
15
15
|
* --force-commands Overwrite user-modified slash command files (creates .bak)
|
|
16
|
-
* --codex Also install Codex hooks (~/.codex/hooks/)
|
|
16
|
+
* --codex Also install Codex hooks + extensions (~/.codex/{hooks,commands}/)
|
|
17
17
|
* --git-remote=<url> Git remote URL
|
|
18
18
|
* --no-git-init Skip git initialization
|
|
19
19
|
* --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
@@ -21,78 +21,145 @@
|
|
|
21
21
|
* --help, -h Show this help message
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
chmodSync,
|
|
30
|
+
copyFileSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
} from 'fs';
|
|
25
35
|
import { join, basename } from 'path';
|
|
26
36
|
import { homedir } from 'os';
|
|
27
37
|
import { execSync, spawnSync } from 'child_process';
|
|
28
38
|
import { fileURLToPath } from 'url';
|
|
29
39
|
import { createHash } from 'crypto';
|
|
30
40
|
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
import {
|
|
42
|
+
readPkgJson as readPkgJsonSafe,
|
|
43
|
+
writePkgJsonAtomic,
|
|
44
|
+
sha256 as sha256Buf,
|
|
45
|
+
isRegularFile,
|
|
46
|
+
readFileIfRegular,
|
|
47
|
+
} from './lib/pkg-json.mjs';
|
|
48
|
+
import { syncExtensions } from './lib/extensions.mjs';
|
|
49
|
+
|
|
50
|
+
const HOME = homedir();
|
|
34
51
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
35
|
-
const PKG_ROOT
|
|
36
|
-
|
|
52
|
+
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
53
|
+
|
|
54
|
+
// Shown after every fatal package-integrity error. These conditions mean the
|
|
55
|
+
// shipped hooks/hooks.json is missing or malformed — never a user mistake —
|
|
56
|
+
// so the only useful next step is a re-install of the package.
|
|
57
|
+
const PKG_INTEGRITY_HINT =
|
|
58
|
+
'→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
|
|
59
|
+
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
37
60
|
const COMMANDS_SRC = join(PKG_ROOT, 'commands');
|
|
38
|
-
const TEMPLATES
|
|
39
|
-
const PKG_VERSION
|
|
40
|
-
try {
|
|
41
|
-
|
|
61
|
+
const TEMPLATES = join(PKG_ROOT, 'templates');
|
|
62
|
+
const PKG_VERSION = (() => {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
42
68
|
})();
|
|
43
69
|
|
|
44
|
-
function sha256(buf) {
|
|
70
|
+
function sha256(buf) {
|
|
71
|
+
return sha256Buf(buf);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── subcommand dispatch ──────────────────────────────────────────────────────
|
|
75
|
+
// Route `hypomnema <verb> [flags]` to the matching script. README, CHANGELOG,
|
|
76
|
+
// and the upgrade-flow docs all document `hypomnema upgrade --apply`,
|
|
77
|
+
// `hypomnema upgrade --check`, `hypomnema doctor`, and `hypomnema uninstall`,
|
|
78
|
+
// but without this dispatcher the positional verb was silently dropped and
|
|
79
|
+
// init.mjs ran instead — so users got an init-shaped output and assumed the
|
|
80
|
+
// command "worked" while the documented behavior never happened.
|
|
81
|
+
//
|
|
82
|
+
// `hypomnema` with no verb (or with only flags like `--hypo-dir=…` / `--help`)
|
|
83
|
+
// keeps running init for backwards compatibility — that's the documented
|
|
84
|
+
// Path-B onboarding command. An explicit `hypomnema init` is accepted too,
|
|
85
|
+
// and is stripped before flag parsing so the rest of this file is unchanged.
|
|
86
|
+
const KNOWN_SUBCOMMANDS = new Set(['init', 'upgrade', 'doctor', 'uninstall', 'feedback-sync']);
|
|
87
|
+
const _verb = process.argv[2];
|
|
88
|
+
if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
|
|
89
|
+
const _target = join(SCRIPT_DIR, `${_verb}.mjs`);
|
|
90
|
+
const _r = spawnSync(process.execPath, [_target, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
91
|
+
process.exit(_r.status ?? 1);
|
|
92
|
+
}
|
|
93
|
+
if (_verb === 'init') process.argv.splice(2, 1);
|
|
45
94
|
|
|
46
95
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
47
96
|
|
|
48
97
|
function parseArgs(argv) {
|
|
49
98
|
const args = {
|
|
50
|
-
hypoDir:
|
|
51
|
-
hooks:
|
|
52
|
-
commands:
|
|
99
|
+
hypoDir: resolveHypoRoot(),
|
|
100
|
+
hooks: true,
|
|
101
|
+
commands: true,
|
|
53
102
|
forceCommands: false,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
103
|
+
forceExtensions: false,
|
|
104
|
+
codex: false,
|
|
105
|
+
gitRemote: null,
|
|
106
|
+
gitInit: true,
|
|
107
|
+
dryRun: false,
|
|
108
|
+
fromRemote: null,
|
|
109
|
+
shellSetup: true,
|
|
60
110
|
shellConfig: null,
|
|
61
111
|
};
|
|
62
112
|
for (const arg of argv.slice(2)) {
|
|
63
113
|
if (arg === '--help' || arg === '-h') {
|
|
64
|
-
console.log(`Usage:
|
|
114
|
+
console.log(`Usage: hypomnema [<command>] [options]
|
|
115
|
+
|
|
116
|
+
Commands:
|
|
117
|
+
init (default) Scaffold a wiki, install hooks, merge settings.json
|
|
118
|
+
upgrade Reconcile hooks / settings.json / slash commands against the
|
|
119
|
+
installed package (use --check for dry-run, --apply to commit)
|
|
120
|
+
doctor Health check: directories, files, hooks, settings.json, git
|
|
121
|
+
uninstall Remove hooks and registrations (dry-run by default; pass --apply)
|
|
122
|
+
feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
|
|
123
|
+
projection (--check default, --write to apply; ADR 0031)
|
|
65
124
|
|
|
66
|
-
|
|
125
|
+
Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
|
|
126
|
+
|
|
127
|
+
Init options:
|
|
67
128
|
--hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
|
|
68
129
|
--no-hooks Skip hook installation
|
|
69
130
|
--no-commands Skip slash command installation to ~/.claude/commands/hypo/
|
|
70
131
|
--force-commands Overwrite user-modified slash command files (creates .bak)
|
|
71
|
-
--
|
|
132
|
+
--force-extensions Overwrite user-modified / conflicting extension copies (creates .bak)
|
|
133
|
+
--codex Also install Codex hooks + extensions (~/.codex/{hooks,commands}/)
|
|
72
134
|
--git-remote=<url> Git remote URL
|
|
73
135
|
--no-git-init Skip git initialization
|
|
74
136
|
--from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
75
137
|
--no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
|
|
76
138
|
--shell-config=<path> Shell config file path (default: auto-detect)
|
|
77
139
|
--dry-run Show what would be done without making changes
|
|
78
|
-
--help, -h Show this help message
|
|
140
|
+
--help, -h Show this help message
|
|
141
|
+
|
|
142
|
+
Subcommand-specific flags (upgrade/doctor/uninstall) live in the
|
|
143
|
+
docstring at the top of scripts/<command>.mjs.`);
|
|
79
144
|
process.exit(0);
|
|
80
|
-
}
|
|
81
|
-
else if (arg
|
|
82
|
-
else if (arg === '--no-
|
|
83
|
-
else if (arg === '--
|
|
84
|
-
else if (arg === '--force-
|
|
85
|
-
else if (arg === '--codex')
|
|
86
|
-
else if (arg.startsWith('--git-remote='))
|
|
87
|
-
else if (arg === '--no-git-init')
|
|
145
|
+
} else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
146
|
+
else if (arg === '--no-hooks') args.hooks = false;
|
|
147
|
+
else if (arg === '--no-commands') args.commands = false;
|
|
148
|
+
else if (arg === '--force-commands') args.forceCommands = true;
|
|
149
|
+
else if (arg === '--force-extensions') args.forceExtensions = true;
|
|
150
|
+
else if (arg === '--codex') args.codex = true;
|
|
151
|
+
else if (arg.startsWith('--git-remote=')) args.gitRemote = arg.slice(13);
|
|
152
|
+
else if (arg === '--no-git-init') args.gitInit = false;
|
|
88
153
|
else if (arg.startsWith('--from-remote=')) {
|
|
89
154
|
const url = arg.slice(14).trim();
|
|
90
|
-
if (!url) {
|
|
155
|
+
if (!url) {
|
|
156
|
+
console.error('Error: --from-remote requires a non-empty URL');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
91
159
|
args.fromRemote = url;
|
|
92
|
-
}
|
|
93
|
-
else if (arg === '--
|
|
94
|
-
else if (arg
|
|
95
|
-
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
160
|
+
} else if (arg === '--dry-run') args.dryRun = true;
|
|
161
|
+
else if (arg === '--no-shell') args.shellSetup = false;
|
|
162
|
+
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
96
163
|
}
|
|
97
164
|
return args;
|
|
98
165
|
}
|
|
@@ -101,11 +168,27 @@ Options:
|
|
|
101
168
|
|
|
102
169
|
const results = { created: [], skipped: [], merged: [], errors: [] };
|
|
103
170
|
|
|
104
|
-
function log(action, path) {
|
|
171
|
+
function log(action, path) {
|
|
172
|
+
results[action].push(path);
|
|
173
|
+
}
|
|
105
174
|
|
|
106
175
|
// ── directory structure ──────────────────────────────────────────────────────
|
|
107
176
|
|
|
108
|
-
const HYPO_DIRS = [
|
|
177
|
+
const HYPO_DIRS = [
|
|
178
|
+
'pages',
|
|
179
|
+
'projects',
|
|
180
|
+
'sources',
|
|
181
|
+
'journal/daily',
|
|
182
|
+
'journal/weekly',
|
|
183
|
+
'journal/monthly',
|
|
184
|
+
'pages/observability',
|
|
185
|
+
// User extensions companion (ADR 0024). init creates the baseline
|
|
186
|
+
// dirs; #29 (E2) adds the hard-copy / manifest / settings sync into them.
|
|
187
|
+
'extensions/hooks',
|
|
188
|
+
'extensions/commands',
|
|
189
|
+
'extensions/skills',
|
|
190
|
+
'extensions/agents',
|
|
191
|
+
];
|
|
109
192
|
|
|
110
193
|
function ensureDir(dir, dryRun) {
|
|
111
194
|
if (existsSync(dir)) return;
|
|
@@ -117,8 +200,14 @@ function ensureDir(dir, dryRun) {
|
|
|
117
200
|
|
|
118
201
|
function copyTemplate(srcName, destPath, dryRun, transform) {
|
|
119
202
|
const src = join(TEMPLATES, srcName);
|
|
120
|
-
if (!existsSync(src)) {
|
|
121
|
-
|
|
203
|
+
if (!existsSync(src)) {
|
|
204
|
+
log('errors', `template missing: ${srcName}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (existsSync(destPath)) {
|
|
208
|
+
log('skipped', destPath);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
122
211
|
if (!dryRun) {
|
|
123
212
|
let content = readFileSync(src, 'utf-8');
|
|
124
213
|
content = content.replace(/YYYY-MM-DD/g, new Date().toISOString().slice(0, 10));
|
|
@@ -132,10 +221,13 @@ function copyTemplate(srcName, destPath, dryRun, transform) {
|
|
|
132
221
|
|
|
133
222
|
function writeHypoConfig(hypoDir, dryRun) {
|
|
134
223
|
const dest = join(hypoDir, 'hypo-config.md');
|
|
135
|
-
if (existsSync(dest)) {
|
|
224
|
+
if (existsSync(dest)) {
|
|
225
|
+
log('skipped', dest);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
136
228
|
const today = new Date().toISOString().slice(0, 10);
|
|
137
|
-
const src
|
|
138
|
-
const base
|
|
229
|
+
const src = join(TEMPLATES, 'hypo-config.md');
|
|
230
|
+
const base = existsSync(src) ? readFileSync(src, 'utf-8') : '';
|
|
139
231
|
const content = base.replace(/YYYY-MM-DD/g, today);
|
|
140
232
|
if (!dryRun) writeFileSync(dest, content);
|
|
141
233
|
log('created', dest);
|
|
@@ -145,13 +237,31 @@ function writeHypoConfig(hypoDir, dryRun) {
|
|
|
145
237
|
|
|
146
238
|
function writeWikiignore(hypoDir, dryRun) {
|
|
147
239
|
const dest = join(hypoDir, '.hypoignore');
|
|
148
|
-
if (existsSync(dest)) {
|
|
149
|
-
|
|
240
|
+
if (existsSync(dest)) {
|
|
241
|
+
log('skipped', dest);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const src = join(TEMPLATES, '.hypoignore');
|
|
150
245
|
const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
|
|
151
246
|
if (!dryRun) writeFileSync(dest, content);
|
|
152
247
|
log('created', dest);
|
|
153
248
|
}
|
|
154
249
|
|
|
250
|
+
// ── .gitignore ───────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function writeGitignore(hypoDir, dryRun) {
|
|
253
|
+
const dest = join(hypoDir, '.gitignore');
|
|
254
|
+
if (existsSync(dest)) {
|
|
255
|
+
log('skipped', dest);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Template is named without leading dot to survive npm pack (which strips .gitignore)
|
|
259
|
+
const src = join(TEMPLATES, 'gitignore');
|
|
260
|
+
const content = existsSync(src) ? readFileSync(src, 'utf-8') : '.cache/\n';
|
|
261
|
+
if (!dryRun) writeFileSync(dest, content);
|
|
262
|
+
log('created', dest);
|
|
263
|
+
}
|
|
264
|
+
|
|
155
265
|
// ── hook installation ────────────────────────────────────────────────────────
|
|
156
266
|
|
|
157
267
|
function loadHookMap() {
|
|
@@ -160,14 +270,17 @@ function loadHookMap() {
|
|
|
160
270
|
cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
|
|
161
271
|
} catch {
|
|
162
272
|
console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
|
|
273
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
163
274
|
process.exit(1);
|
|
164
275
|
}
|
|
165
276
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
166
277
|
console.error('Error: hooks/hooks.json must be a JSON object');
|
|
278
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
167
279
|
process.exit(1);
|
|
168
280
|
}
|
|
169
281
|
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
|
|
170
282
|
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
283
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
171
284
|
process.exit(1);
|
|
172
285
|
}
|
|
173
286
|
function _extractCommandFileName(command) {
|
|
@@ -183,52 +296,71 @@ function loadHookMap() {
|
|
|
183
296
|
}
|
|
184
297
|
|
|
185
298
|
function _isHookGroup(group) {
|
|
186
|
-
return
|
|
299
|
+
return (
|
|
300
|
+
group &&
|
|
187
301
|
typeof group === 'object' &&
|
|
188
302
|
!Array.isArray(group) &&
|
|
189
303
|
Array.isArray(group.hooks) &&
|
|
190
304
|
group.hooks.length > 0 &&
|
|
191
|
-
group.hooks.every(
|
|
192
|
-
hook
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
305
|
+
group.hooks.every(
|
|
306
|
+
(hook) =>
|
|
307
|
+
hook &&
|
|
308
|
+
typeof hook === 'object' &&
|
|
309
|
+
!Array.isArray(hook) &&
|
|
310
|
+
hook.type === 'command' &&
|
|
311
|
+
_extractCommandFileName(hook.command),
|
|
312
|
+
)
|
|
313
|
+
);
|
|
198
314
|
}
|
|
199
315
|
|
|
200
316
|
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
201
317
|
function _extractFileNames(groups) {
|
|
202
|
-
return groups.flatMap(group => {
|
|
318
|
+
return groups.flatMap((group) => {
|
|
203
319
|
if (typeof group === 'string') return [group.trim()];
|
|
204
|
-
return group.hooks.map(hook => _extractCommandFileName(hook.command));
|
|
320
|
+
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
205
321
|
});
|
|
206
322
|
}
|
|
207
323
|
|
|
208
324
|
for (const [event, groups] of Object.entries(cfg.hooks)) {
|
|
209
|
-
const valid =
|
|
325
|
+
const valid =
|
|
326
|
+
Array.isArray(groups) &&
|
|
210
327
|
groups.length > 0 &&
|
|
211
|
-
groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
328
|
+
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
212
329
|
_extractFileNames(groups).length > 0;
|
|
213
330
|
if (!valid) {
|
|
214
|
-
console.error(
|
|
331
|
+
console.error(
|
|
332
|
+
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
333
|
+
);
|
|
334
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
215
335
|
process.exit(1);
|
|
216
336
|
}
|
|
217
337
|
}
|
|
218
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
cfg.shared !== undefined &&
|
|
340
|
+
(!Array.isArray(cfg.shared) || !cfg.shared.every((f) => _isHookFileName(f)))
|
|
341
|
+
) {
|
|
219
342
|
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
343
|
+
console.error(PKG_INTEGRITY_HINT);
|
|
220
344
|
process.exit(1);
|
|
221
345
|
}
|
|
222
|
-
return Object.fromEntries(
|
|
346
|
+
return Object.fromEntries(
|
|
347
|
+
Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]),
|
|
348
|
+
);
|
|
223
349
|
}
|
|
224
350
|
|
|
225
351
|
function installHooks(targetDir, dryRun) {
|
|
226
|
-
if (!existsSync(HOOKS_SRC)) {
|
|
352
|
+
if (!existsSync(HOOKS_SRC)) {
|
|
353
|
+
log('errors', `hooks source missing: ${HOOKS_SRC}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
227
356
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
228
357
|
for (const file of readdirSync(HOOKS_SRC)) {
|
|
229
358
|
if (!file.endsWith('.mjs')) continue;
|
|
230
359
|
const dest = join(targetDir, file);
|
|
231
|
-
if (existsSync(dest)) {
|
|
360
|
+
if (existsSync(dest)) {
|
|
361
|
+
log('skipped', dest);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
232
364
|
if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
|
|
233
365
|
log('created', dest);
|
|
234
366
|
}
|
|
@@ -237,8 +369,13 @@ function installHooks(targetDir, dryRun) {
|
|
|
237
369
|
function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|
|
238
370
|
let settings = {};
|
|
239
371
|
if (existsSync(settingsPath)) {
|
|
240
|
-
try {
|
|
241
|
-
|
|
372
|
+
try {
|
|
373
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
374
|
+
} catch {
|
|
375
|
+
log(
|
|
376
|
+
'errors',
|
|
377
|
+
`settings.json is not valid JSON — fix or back it up before re-running: ${settingsPath}`,
|
|
378
|
+
);
|
|
242
379
|
return;
|
|
243
380
|
}
|
|
244
381
|
}
|
|
@@ -250,8 +387,8 @@ function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|
|
|
250
387
|
for (const file of files) {
|
|
251
388
|
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
252
389
|
const already = settings.hooks[event]
|
|
253
|
-
.flatMap(g => g.hooks || [])
|
|
254
|
-
.some(h => h.command === cmd);
|
|
390
|
+
.flatMap((g) => g.hooks || [])
|
|
391
|
+
.some((h) => h.command === cmd);
|
|
255
392
|
if (already) continue;
|
|
256
393
|
settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
|
|
257
394
|
log('merged', `${event}: ${file}`);
|
|
@@ -278,16 +415,18 @@ echo "[post-commit] hooks/ changed — syncing to ~/.claude/hooks/ ..."
|
|
|
278
415
|
node "$REPO_ROOT/scripts/upgrade.mjs" --apply
|
|
279
416
|
`;
|
|
280
417
|
|
|
281
|
-
function pkgJsonPath() {
|
|
418
|
+
function pkgJsonPath() {
|
|
419
|
+
return join(HOME, '.claude', 'hypo-pkg.json');
|
|
420
|
+
}
|
|
282
421
|
|
|
283
422
|
function writePkgJson(dryRun, extraFields = {}) {
|
|
284
|
-
const dest
|
|
423
|
+
const dest = pkgJsonPath();
|
|
285
424
|
const existing = readPkgJsonSafe(dest);
|
|
286
|
-
const merged
|
|
425
|
+
const merged = {
|
|
287
426
|
...existing,
|
|
288
|
-
pkgRoot:
|
|
289
|
-
pkgVersion:
|
|
290
|
-
schemaVersion: '
|
|
427
|
+
pkgRoot: PKG_ROOT,
|
|
428
|
+
pkgVersion: PKG_VERSION,
|
|
429
|
+
schemaVersion: '2.0',
|
|
291
430
|
...extraFields,
|
|
292
431
|
};
|
|
293
432
|
if (!dryRun) {
|
|
@@ -309,27 +448,36 @@ function writePkgJson(dryRun, extraFields = {}) {
|
|
|
309
448
|
// Recorded SHAs are kept in ~/.claude/hypo-pkg.json under `commands: { "<name>.md": "<sha>" }`.
|
|
310
449
|
|
|
311
450
|
function installCommands(targetDir, dryRun, force) {
|
|
312
|
-
if (!existsSync(COMMANDS_SRC)) {
|
|
451
|
+
if (!existsSync(COMMANDS_SRC)) {
|
|
452
|
+
log('errors', `commands source missing: ${COMMANDS_SRC}`);
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
313
455
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
314
456
|
|
|
315
|
-
const prevPkg
|
|
316
|
-
const prevSHAs =
|
|
317
|
-
const newSHAs
|
|
457
|
+
const prevPkg = readPkgJsonSafe(pkgJsonPath());
|
|
458
|
+
const prevSHAs = prevPkg.commands && typeof prevPkg.commands === 'object' ? prevPkg.commands : {};
|
|
459
|
+
const newSHAs = {};
|
|
318
460
|
|
|
319
461
|
function writeFresh(dest, srcContent) {
|
|
320
462
|
if (dryRun) return;
|
|
321
463
|
const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
|
|
322
464
|
writeFileSync(tmp, srcContent);
|
|
323
|
-
try {
|
|
324
|
-
|
|
465
|
+
try {
|
|
466
|
+
renameSync(tmp, dest);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
try {
|
|
469
|
+
unlinkSync(tmp);
|
|
470
|
+
} catch {}
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
325
473
|
}
|
|
326
474
|
|
|
327
475
|
for (const file of readdirSync(COMMANDS_SRC)) {
|
|
328
476
|
if (!file.endsWith('.md')) continue;
|
|
329
|
-
const srcPath
|
|
330
|
-
const dest
|
|
477
|
+
const srcPath = join(COMMANDS_SRC, file);
|
|
478
|
+
const dest = join(targetDir, file);
|
|
331
479
|
const srcContent = readFileSync(srcPath);
|
|
332
|
-
const srcSHA
|
|
480
|
+
const srcSHA = sha256(srcContent);
|
|
333
481
|
|
|
334
482
|
// Fresh install
|
|
335
483
|
if (!existsSync(dest)) {
|
|
@@ -396,7 +544,10 @@ function installCommands(targetDir, dryRun, force) {
|
|
|
396
544
|
newSHAs[file] = recordedSHA;
|
|
397
545
|
log('skipped', `${dest} (user-modified — re-run with --force-commands to overwrite)`);
|
|
398
546
|
} else {
|
|
399
|
-
log(
|
|
547
|
+
log(
|
|
548
|
+
'skipped',
|
|
549
|
+
`${dest} (file exists but Hypomnema does not own it — re-run with --force-commands to take ownership)`,
|
|
550
|
+
);
|
|
400
551
|
}
|
|
401
552
|
}
|
|
402
553
|
|
|
@@ -408,7 +559,10 @@ function installPkgGitHook(dryRun) {
|
|
|
408
559
|
if (!existsSync(gitDir)) return;
|
|
409
560
|
const hooksDir = join(gitDir, 'hooks');
|
|
410
561
|
const hookPath = join(hooksDir, 'post-commit');
|
|
411
|
-
if (existsSync(hookPath)) {
|
|
562
|
+
if (existsSync(hookPath)) {
|
|
563
|
+
log('skipped', hookPath);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
412
566
|
if (!dryRun) {
|
|
413
567
|
mkdirSync(hooksDir, { recursive: true });
|
|
414
568
|
writeFileSync(hookPath, PKG_GIT_HOOK_CONTENT, { mode: 0o755 });
|
|
@@ -416,10 +570,63 @@ function installPkgGitHook(dryRun) {
|
|
|
416
570
|
log('created', hookPath);
|
|
417
571
|
}
|
|
418
572
|
|
|
573
|
+
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
576
|
+
const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
577
|
+
|
|
578
|
+
function wikiPreCommitContent() {
|
|
579
|
+
const worker = join(PKG_ROOT, 'hooks', 'hypo-pre-commit.mjs');
|
|
580
|
+
// Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
|
|
581
|
+
const escaped = worker.replace(/'/g, "'\\''");
|
|
582
|
+
return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\nnode '${escaped}'\nexit $?\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function installWikiPreCommitHook(hypoDir, dryRun, force) {
|
|
586
|
+
const gitDir = join(hypoDir, '.git');
|
|
587
|
+
if (!existsSync(gitDir)) return; // no git repo — silently skip
|
|
588
|
+
const hooksDir = join(gitDir, 'hooks');
|
|
589
|
+
const hookPath = join(hooksDir, 'pre-commit');
|
|
590
|
+
const newContent = wikiPreCommitContent();
|
|
591
|
+
|
|
592
|
+
if (existsSync(hookPath)) {
|
|
593
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
594
|
+
if (existing.includes(WIKI_PRE_COMMIT_MARKER_START)) {
|
|
595
|
+
if (existing === newContent) {
|
|
596
|
+
log('skipped', `${hookPath} (pre-commit up to date)`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!dryRun) {
|
|
600
|
+
writeFileSync(hookPath, newContent);
|
|
601
|
+
chmodSync(hookPath, 0o755);
|
|
602
|
+
}
|
|
603
|
+
log('merged', `${hookPath} (pre-commit updated)`);
|
|
604
|
+
} else if (force) {
|
|
605
|
+
if (!dryRun) {
|
|
606
|
+
writeFileSync(hookPath + '.bak', existing);
|
|
607
|
+
writeFileSync(hookPath, newContent);
|
|
608
|
+
chmodSync(hookPath, 0o755);
|
|
609
|
+
}
|
|
610
|
+
log('merged', `${hookPath} (force-overwritten, backup at pre-commit.bak)`);
|
|
611
|
+
} else {
|
|
612
|
+
log(
|
|
613
|
+
'skipped',
|
|
614
|
+
`${hookPath} (user pre-commit exists — re-run with --force-commands to install)`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!dryRun) {
|
|
620
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
621
|
+
writeFileSync(hookPath, newContent, { mode: 0o755 });
|
|
622
|
+
}
|
|
623
|
+
log('created', hookPath);
|
|
624
|
+
}
|
|
625
|
+
|
|
419
626
|
// ── shell function setup ─────────────────────────────────────────────────────
|
|
420
627
|
|
|
421
628
|
const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
422
|
-
const SHELL_MARKER_END
|
|
629
|
+
const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
423
630
|
|
|
424
631
|
function shellFunctionBlock() {
|
|
425
632
|
return `${SHELL_MARKER_START}
|
|
@@ -433,7 +640,7 @@ ${SHELL_MARKER_END}`;
|
|
|
433
640
|
function detectShellConfig(customPath) {
|
|
434
641
|
if (customPath) return customPath;
|
|
435
642
|
const shell = process.env.SHELL || '';
|
|
436
|
-
if (shell.includes('zsh'))
|
|
643
|
+
if (shell.includes('zsh')) return join(HOME, '.zshrc');
|
|
437
644
|
if (shell.includes('bash')) return join(HOME, '.bashrc');
|
|
438
645
|
// fallback: prefer .zshrc if it exists, else .bashrc
|
|
439
646
|
const zshrc = join(HOME, '.zshrc');
|
|
@@ -451,14 +658,18 @@ function installShellFunction(shellConfigPath, dryRun) {
|
|
|
451
658
|
|
|
452
659
|
const content = readFileSync(shellConfigPath, 'utf-8');
|
|
453
660
|
const startIdx = content.indexOf(SHELL_MARKER_START);
|
|
454
|
-
const endIdx
|
|
661
|
+
const endIdx = content.indexOf(SHELL_MARKER_END);
|
|
455
662
|
|
|
456
663
|
if (startIdx !== -1 && endIdx !== -1) {
|
|
457
664
|
// Block exists — check if already up to date
|
|
458
665
|
const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
|
|
459
|
-
if (existing === block) {
|
|
666
|
+
if (existing === block) {
|
|
667
|
+
log('skipped', `${shellConfigPath} (shell function up to date)`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
460
670
|
// Replace stale block
|
|
461
|
-
const updated =
|
|
671
|
+
const updated =
|
|
672
|
+
content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
|
|
462
673
|
if (!dryRun) writeFileSync(shellConfigPath, updated);
|
|
463
674
|
log('merged', `${shellConfigPath} (shell function updated)`);
|
|
464
675
|
return;
|
|
@@ -476,7 +687,10 @@ function installShellFunction(shellConfigPath, dryRun) {
|
|
|
476
687
|
|
|
477
688
|
function cloneFromRemote(url, hypoDir, dryRun) {
|
|
478
689
|
if (existsSync(hypoDir)) {
|
|
479
|
-
log(
|
|
690
|
+
log(
|
|
691
|
+
'errors',
|
|
692
|
+
`--from-remote: target directory already exists: ${hypoDir}. Remove it or choose a different --hypo-dir.`,
|
|
693
|
+
);
|
|
480
694
|
return false;
|
|
481
695
|
}
|
|
482
696
|
console.log(`Cloning ${url} → ${hypoDir} ...`);
|
|
@@ -488,7 +702,10 @@ function cloneFromRemote(url, hypoDir, dryRun) {
|
|
|
488
702
|
}
|
|
489
703
|
if (!existsSync(join(hypoDir, 'hypo-config.md'))) {
|
|
490
704
|
spawnSync('rm', ['-rf', hypoDir]);
|
|
491
|
-
log(
|
|
705
|
+
log(
|
|
706
|
+
'errors',
|
|
707
|
+
`--from-remote: cloned repo is not a Hypomnema wiki (hypo-config.md missing). Removed ${hypoDir}.`,
|
|
708
|
+
);
|
|
492
709
|
return false;
|
|
493
710
|
}
|
|
494
711
|
}
|
|
@@ -538,7 +755,10 @@ function firstCommit(hypoDir, remote, dryRun) {
|
|
|
538
755
|
if (!dryRun) {
|
|
539
756
|
git(hypoDir, ['add', '-A'], { stdio: 'ignore' });
|
|
540
757
|
const commitR = git(hypoDir, ['commit', '-m', `chore: init hypomnema (${today})`]);
|
|
541
|
-
if (commitR.status !== 0) {
|
|
758
|
+
if (commitR.status !== 0) {
|
|
759
|
+
log('errors', 'first commit failed');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
542
762
|
if (remote) {
|
|
543
763
|
const actualOrigin = git(hypoDir, ['remote', 'get-url', 'origin']);
|
|
544
764
|
const pushTarget = actualOrigin.status === 0 ? actualOrigin.stdout.trim() : remote;
|
|
@@ -555,7 +775,7 @@ function firstCommit(hypoDir, remote, dryRun) {
|
|
|
555
775
|
const args = parseArgs(process.argv);
|
|
556
776
|
|
|
557
777
|
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
558
|
-
const HOOK_MAP =
|
|
778
|
+
const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
559
779
|
|
|
560
780
|
if (args.fromRemote) {
|
|
561
781
|
// ── from-remote path: clone → read config → install hooks ──────────────────
|
|
@@ -570,31 +790,63 @@ if (args.fromRemote) {
|
|
|
570
790
|
ensureDir(args.hypoDir, args.dryRun);
|
|
571
791
|
for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
|
|
572
792
|
|
|
793
|
+
// 1b. extensions baseline: drop a .gitkeep into each so the empty dirs are
|
|
794
|
+
// git-trackable in the user's wiki repo (ADR 0024).
|
|
795
|
+
for (const t of ['hooks', 'commands', 'skills', 'agents']) {
|
|
796
|
+
copyTemplate(
|
|
797
|
+
join('extensions', t, '.gitkeep'),
|
|
798
|
+
join(args.hypoDir, 'extensions', t, '.gitkeep'),
|
|
799
|
+
args.dryRun,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
573
803
|
// 2. template files
|
|
574
|
-
copyTemplate('index.md',
|
|
575
|
-
copyTemplate('hot.md',
|
|
576
|
-
copyTemplate('log.md',
|
|
577
|
-
copyTemplate('SCHEMA.md',
|
|
578
|
-
copyTemplate('hypo-guide.md',
|
|
579
|
-
copyTemplate('Home.md',
|
|
580
|
-
copyTemplate('Overview.md',
|
|
581
|
-
copyTemplate('hypo-help.md',
|
|
582
|
-
copyTemplate('hypo-automation.md',join(args.hypoDir, 'hypo-automation.md'),args.dryRun);
|
|
583
|
-
copyTemplate('session-state.md',
|
|
804
|
+
copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
|
|
805
|
+
copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
|
|
806
|
+
copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
|
|
807
|
+
copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
|
|
808
|
+
copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
|
|
809
|
+
copyTemplate('Home.md', join(args.hypoDir, 'Home.md'), args.dryRun);
|
|
810
|
+
copyTemplate('Overview.md', join(args.hypoDir, 'Overview.md'), args.dryRun);
|
|
811
|
+
copyTemplate('hypo-help.md', join(args.hypoDir, 'hypo-help.md'), args.dryRun);
|
|
812
|
+
copyTemplate('hypo-automation.md', join(args.hypoDir, 'hypo-automation.md'), args.dryRun);
|
|
813
|
+
copyTemplate('session-state.md', join(args.hypoDir, 'session-state.md'), args.dryRun);
|
|
584
814
|
copyTemplate(join('pages', '_index.md'), join(args.hypoDir, 'pages', '_index.md'), args.dryRun);
|
|
815
|
+
copyTemplate(
|
|
816
|
+
join('pages', 'observability', '_index.md'),
|
|
817
|
+
join(args.hypoDir, 'pages', 'observability', '_index.md'),
|
|
818
|
+
args.dryRun,
|
|
819
|
+
);
|
|
585
820
|
|
|
586
821
|
// projects/_template structure
|
|
587
822
|
ensureDir(join(args.hypoDir, 'projects', '_template'), args.dryRun);
|
|
588
823
|
ensureDir(join(args.hypoDir, 'projects', '_template', 'decisions'), args.dryRun);
|
|
589
824
|
ensureDir(join(args.hypoDir, 'projects', '_template', 'session-log'), args.dryRun);
|
|
590
|
-
copyTemplate(
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
825
|
+
copyTemplate(
|
|
826
|
+
join('projects', '_template', 'hot.md'),
|
|
827
|
+
join(args.hypoDir, 'projects', '_template', 'hot.md'),
|
|
828
|
+
args.dryRun,
|
|
829
|
+
);
|
|
830
|
+
copyTemplate(
|
|
831
|
+
join('projects', '_template', 'index.md'),
|
|
832
|
+
join(args.hypoDir, 'projects', '_template', 'index.md'),
|
|
833
|
+
args.dryRun,
|
|
834
|
+
);
|
|
835
|
+
copyTemplate(
|
|
836
|
+
join('projects', '_template', 'prd.md'),
|
|
837
|
+
join(args.hypoDir, 'projects', '_template', 'prd.md'),
|
|
838
|
+
args.dryRun,
|
|
839
|
+
);
|
|
840
|
+
copyTemplate(
|
|
841
|
+
join('projects', '_template', 'session-state.md'),
|
|
842
|
+
join(args.hypoDir, 'projects', '_template', 'session-state.md'),
|
|
843
|
+
args.dryRun,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// 3. hypo-config.md + .hypoignore + .gitignore
|
|
596
847
|
writeHypoConfig(args.hypoDir, args.dryRun);
|
|
597
848
|
writeWikiignore(args.hypoDir, args.dryRun);
|
|
849
|
+
writeGitignore(args.hypoDir, args.dryRun);
|
|
598
850
|
}
|
|
599
851
|
|
|
600
852
|
// 4. hooks
|
|
@@ -615,6 +867,40 @@ if (args.hooks || args.commands) {
|
|
|
615
867
|
writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
|
|
616
868
|
}
|
|
617
869
|
|
|
870
|
+
// 4b. user extensions companion sync (ADR 0024). Runs after
|
|
871
|
+
// writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
|
|
872
|
+
// (preserving the commands map) rather than racing it.
|
|
873
|
+
if (args.hooks) {
|
|
874
|
+
const extResult = syncExtensions({
|
|
875
|
+
extDir: join(args.hypoDir, 'extensions'),
|
|
876
|
+
hypoDir: args.hypoDir,
|
|
877
|
+
target: 'claude',
|
|
878
|
+
settingsPath: join(HOME, '.claude', 'settings.json'),
|
|
879
|
+
pkgPath: pkgJsonPath(),
|
|
880
|
+
apply: !args.dryRun,
|
|
881
|
+
force: args.forceExtensions,
|
|
882
|
+
});
|
|
883
|
+
for (const a of extResult.actions) {
|
|
884
|
+
if (a.action === 'create' || a.action === 'update' || a.action === 'force-update') {
|
|
885
|
+
log('created', `extension ${a.file} (${a.action})`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
for (const r of extResult.registered) log('merged', `extension ${r}`);
|
|
889
|
+
for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
|
|
890
|
+
// E3 (#31): a hard conflict (unowned/symlinked target) blocks install — surface
|
|
891
|
+
// the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
|
|
892
|
+
if (extResult.conflicts.length > 0) {
|
|
893
|
+
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|
|
894
|
+
for (const c of extResult.conflicts) log('errors', `extension ${c.file} (${c.action})`);
|
|
895
|
+
}
|
|
896
|
+
for (const d of extResult.drifts) {
|
|
897
|
+
log(
|
|
898
|
+
'skipped',
|
|
899
|
+
`[WIKI: extension ${d.name} drift detected. Use --force-extensions to overwrite.]`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
618
904
|
// 5. shell function (claude wrapper)
|
|
619
905
|
if (args.shellSetup) {
|
|
620
906
|
const shellConfigPath = detectShellConfig(args.shellConfig);
|
|
@@ -626,6 +912,37 @@ if (args.codex) {
|
|
|
626
912
|
const codexHooks = join(HOME, '.codex', 'hooks');
|
|
627
913
|
installHooks(codexHooks, args.dryRun);
|
|
628
914
|
mergeSettingsJson(join(HOME, '.codex', 'settings.json'), codexHooks, args.dryRun, HOOK_MAP);
|
|
915
|
+
|
|
916
|
+
// 6b. user extensions companion → codex (E4, #32). Mirrors the claude sync above
|
|
917
|
+
// for the codex target: hooks + commands only (skills/agents are skipped with a
|
|
918
|
+
// notice). The per-target SHA map merges into the same ~/.claude/hypo-pkg.json
|
|
919
|
+
// under extensions.codex, alongside extensions.claude written in step 4b.
|
|
920
|
+
const extCodex = syncExtensions({
|
|
921
|
+
extDir: join(args.hypoDir, 'extensions'),
|
|
922
|
+
hypoDir: args.hypoDir,
|
|
923
|
+
target: 'codex',
|
|
924
|
+
settingsPath: join(HOME, '.codex', 'settings.json'),
|
|
925
|
+
pkgPath: pkgJsonPath(),
|
|
926
|
+
apply: !args.dryRun,
|
|
927
|
+
force: args.forceExtensions,
|
|
928
|
+
});
|
|
929
|
+
for (const a of extCodex.actions) {
|
|
930
|
+
if (a.action === 'create' || a.action === 'update' || a.action === 'force-update') {
|
|
931
|
+
log('created', `codex extension ${a.file} (${a.action})`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
for (const r of extCodex.registered) log('merged', `codex extension ${r}`);
|
|
935
|
+
for (const w of extCodex.warnings) log('skipped', `codex extension: ${w}`);
|
|
936
|
+
if (extCodex.conflicts.length > 0) {
|
|
937
|
+
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|
|
938
|
+
for (const c of extCodex.conflicts) log('errors', `codex extension ${c.file} (${c.action})`);
|
|
939
|
+
}
|
|
940
|
+
for (const d of extCodex.drifts) {
|
|
941
|
+
log(
|
|
942
|
+
'skipped',
|
|
943
|
+
`[WIKI: extension ${d.name} drift detected. Use --force-extensions to overwrite.]`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
629
946
|
}
|
|
630
947
|
|
|
631
948
|
// 7. git setup (skip when cloned from remote — already has .git + remote)
|
|
@@ -638,6 +955,9 @@ if (args.hooks) {
|
|
|
638
955
|
installPkgGitHook(args.dryRun);
|
|
639
956
|
}
|
|
640
957
|
|
|
958
|
+
// 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8)
|
|
959
|
+
installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands);
|
|
960
|
+
|
|
641
961
|
// 9. first commit + push (skip when cloned from remote — already has commits)
|
|
642
962
|
if (args.gitInit && !args.fromRemote) {
|
|
643
963
|
firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
|
|
@@ -646,10 +966,18 @@ if (args.gitInit && !args.fromRemote) {
|
|
|
646
966
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
647
967
|
|
|
648
968
|
const lines = [];
|
|
649
|
-
if (results.created.length)
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
969
|
+
if (results.created.length)
|
|
970
|
+
lines.push(
|
|
971
|
+
`✓ Created (${results.created.length}):\n${results.created.map((p) => ` ${p}`).join('\n')}`,
|
|
972
|
+
);
|
|
973
|
+
if (results.skipped.length)
|
|
974
|
+
lines.push(
|
|
975
|
+
`⊘ Skipped / already exists (${results.skipped.length}):\n${results.skipped.map((p) => ` ${p}`).join('\n')}`,
|
|
976
|
+
);
|
|
977
|
+
if (results.merged.length)
|
|
978
|
+
lines.push(`↪ Merged into settings.json:\n${results.merged.map((p) => ` ${p}`).join('\n')}`);
|
|
979
|
+
if (results.errors.length)
|
|
980
|
+
lines.push(`✗ Errors:\n${results.errors.map((p) => ` ${p}`).join('\n')}`);
|
|
653
981
|
|
|
654
982
|
if (args.dryRun) lines.unshift('[DRY RUN — no changes made]');
|
|
655
983
|
|