hypomnema 1.4.0 → 1.4.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/README.ko.md +151 -153
- package/README.md +121 -123
- package/commands/audit.md +4 -4
- package/commands/crystallize.md +22 -7
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +5 -3
- package/commands/graph.md +3 -3
- package/commands/ingest.md +6 -4
- package/commands/init.md +5 -3
- package/commands/lint.md +3 -3
- package/commands/query.md +3 -3
- package/commands/rename.md +4 -4
- package/commands/resume.md +4 -2
- package/commands/stats.md +3 -3
- package/commands/upgrade.md +4 -4
- package/commands/verify.md +3 -3
- package/docs/CONTRIBUTING.md +107 -16
- package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
- package/hooks/hypo-shared.mjs +96 -14
- package/package.json +6 -1
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/crystallize.mjs +275 -39
- package/scripts/lib/changelog-classify.mjs +216 -0
- package/scripts/lib/check-bilingual.mjs +125 -22
- package/scripts/lib/check-tracker-ids.mjs +19 -0
- package/scripts/lib/project-create.mjs +23 -5
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lint.mjs +9 -1
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +30 -2
- package/templates/SCHEMA.md +11 -3
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -1
- package/scripts/bump-version.mjs +0 -77
- package/scripts/smoke-pack.mjs +0 -261
- package/scripts/smoke-plugin.mjs +0 -194
|
@@ -61,7 +61,8 @@ When uncertain, surface the question rather than skip it. None of the four block
|
|
|
61
61
|
3. **hot.md (root)** — update `<wiki-root>/hot.md` active-projects pointer table: set the `Last Session` date for this project to today.
|
|
62
62
|
4. **session-log** — append a session entry to `projects/<name>/session-log/YYYY-MM-DD.md` (daily shard; the apply path creates today's file with seeded frontmatter if it does not exist yet).
|
|
63
63
|
5. **open-questions** — only if `pages/open-questions.md` exists and questions were raised or resolved this session: move resolved ones out; add newly raised ones. Skip if unchanged.
|
|
64
|
-
|
|
64
|
+
|
|
65
|
+
> Do **not** hand-write the root `log.md` session entry, and do **not** put a `log` field in the apply payload. The `## [date] session | <project>` entry is a derivable artifact: `--apply-session-close` calls `deriveRootLogEntries` after writing the session-log, reconstructing the canonical line from the session-log heading. (Supply `log` only for a deliberately custom line; it is now an optional payload field.)
|
|
65
66
|
|
|
66
67
|
After completing the checklist, verify it before reporting:
|
|
67
68
|
|
|
@@ -76,7 +77,34 @@ reports and re-run until it prints **"Compact-ready"** — that is the signal th
|
|
|
76
77
|
session is closed. A close-files-only pass is not enough; the real `/compact`
|
|
77
78
|
also blocks on those other checks.
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
Optional `--project=<slug>` narrows the check to ONE project (close status plus
|
|
81
|
+
lint scope). It is a project-scoped **diagnostic**, not the compact-ready
|
|
82
|
+
signal. A green `--project` result (JSON `scope: "project"`) means only that
|
|
83
|
+
project is close-complete; another today-active project can still block
|
|
84
|
+
`/compact`. Use the plain `--check-session-close` (no `--project`) for the
|
|
85
|
+
go/no-go close signal.
|
|
86
|
+
|
|
87
|
+
**When using `--apply-session-close --session-id=<id>`** (the payload-driven
|
|
88
|
+
path), the `--session-id` must be the main conversation's session id. Do NOT
|
|
89
|
+
extract it from a background task or Agent output path (e.g., a UUID from
|
|
90
|
+
`/tmp/.../<uuid>/tasks/...`). Such a UUID is a background task id, not the
|
|
91
|
+
main conversation id. Passing the wrong id causes `markerWritten: false` with
|
|
92
|
+
`markerSkipReason: "transcript-unresolved"` or `"no-user-close-signal"`: the
|
|
93
|
+
5 mandatory files are written (ok: true) but the Stop-chain marker is withheld,
|
|
94
|
+
so the session is not actually closed and the Stop hook re-prompts. The correct
|
|
95
|
+
id comes from the `[WIKI_AUTOCLOSE]` block reason or `$CLAUDE_SESSION_ID`.
|
|
96
|
+
|
|
97
|
+
Once it passes, report each item with ✓:
|
|
98
|
+
- ✓ session-state.md
|
|
99
|
+
- ✓ hot.md (project + root)
|
|
100
|
+
- ✓ session-log entry
|
|
101
|
+
- ✓ open-questions (or skipped if unchanged)
|
|
102
|
+
- ✓ log.md entry
|
|
103
|
+
- **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete. Instead report: "Files applied and verified (ok: true), but the session-close marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
|
|
104
|
+
|
|
105
|
+
If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
|
|
106
|
+
|
|
107
|
+
If `markerWritten: false`, do NOT say "session closed." Surface the skip reason and instruct a re-run with the correct main-conversation `--session-id` (see the bullet above) before treating the session as closed.
|
|
80
108
|
|
|
81
109
|
---
|
|
82
110
|
|
package/templates/SCHEMA.md
CHANGED
|
@@ -154,9 +154,11 @@ Edit the feedback page only — never hand-edit the generated
|
|
|
154
154
|
|
|
155
155
|
## 4. Tag Vocabulary
|
|
156
156
|
|
|
157
|
-
Use lowercase, hyphenated tags.
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
Use lowercase, hyphenated tags. `lint` hard-blocks forbidden patterns (PascalCase,
|
|
158
|
+
plurals, whitespace, generic words); an unknown but well-formed tag is a warning,
|
|
159
|
+
and a session close auto-registers it into the `### Pending` section below so the
|
|
160
|
+
next lint accepts it. Promote a pending tag into a category here (and in
|
|
161
|
+
`~/hypomnema/SCHEMA.md`) once it has settled.
|
|
160
162
|
|
|
161
163
|
**Meta**: `wiki`, `index`, `pages`, `home`, `overview`, `guide`, `operations`, `schema`, `reference`, `hypo`, `commands`, `hot-cache`, `migration`
|
|
162
164
|
**Workflow**: `automation`, `hooks`, `observability`, `autonomy`, `wiki-health`, `weekly`
|
|
@@ -165,6 +167,12 @@ Extend this list (and `~/hypomnema/SCHEMA.md`) before introducing a new tag.
|
|
|
165
167
|
**Status**: `active`, `completed`, `archived`, `draft`, `stable`, `deprecated`, `needs-review`, `proposed`, `superseded`
|
|
166
168
|
**Content classification**: `learning`, `tip`, `feedback`, `gotcha`, `concept`, `pattern`
|
|
167
169
|
|
|
170
|
+
### Pending (auto-registered)
|
|
171
|
+
|
|
172
|
+
Tags auto-registered by a crystallize close because they were not yet in the
|
|
173
|
+
vocabulary above. Review periodically: promote each into a category and delete it
|
|
174
|
+
here, or drop the tag from the page.
|
|
175
|
+
|
|
168
176
|
### Forbidden patterns
|
|
169
177
|
|
|
170
178
|
| Pattern | Reason | Use instead |
|
package/templates/hypo-config.md
CHANGED
package/templates/hypo-guide.md
CHANGED
|
@@ -95,7 +95,10 @@ Ask: *"이 작업이 마무리되었나요? 세션을 정리(crystallize)할까
|
|
|
95
95
|
guarantee: the live gate can still differ on a context-≥70% prompt,
|
|
96
96
|
`HYPO_SKIP_GATE`, or a transcript-scoped lint error — pass `--transcript-path`
|
|
97
97
|
to include the last.) Pass `--session-id=<id>` to also see `marker_present`
|
|
98
|
-
(step 7).
|
|
98
|
+
(step 7). `--project=<slug>` narrows the check to one project (a scoped
|
|
99
|
+
diagnostic, JSON `scope: "project"`): green there means only that slug is
|
|
100
|
+
close-complete, **not** that `/compact` is globally unblocked. Use the plain
|
|
101
|
+
check for the go/no-go signal.
|
|
99
102
|
7. Record the session-closed marker (ADR 0047). The Stop hook blocks until this
|
|
100
103
|
session's per-session marker exists, and a hand-edit close (writing the files
|
|
101
104
|
directly + committing) never writes it; the marker is written only by the
|
package/scripts/bump-version.mjs
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Sync version across package.json, .claude-plugin/plugin.json, and templates/hypo-config.md.
|
|
3
|
-
// Usage: node scripts/bump-version.mjs <new-version>
|
|
4
|
-
|
|
5
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { resolve, dirname } from 'node:path';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
-
const next = process.argv[2];
|
|
11
|
-
|
|
12
|
-
if (!next || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(next)) {
|
|
13
|
-
console.error('Usage: node scripts/bump-version.mjs <semver>');
|
|
14
|
-
console.error('Example: node scripts/bump-version.mjs 1.0.0');
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const targets = [
|
|
19
|
-
{
|
|
20
|
-
path: 'package.json',
|
|
21
|
-
pattern: /("version"\s*:\s*")([^"]+)(")/,
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
path: '.claude-plugin/plugin.json',
|
|
25
|
-
pattern: /("version"\s*:\s*")([^"]+)(")/,
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
path: '.claude-plugin/marketplace.json',
|
|
29
|
-
pattern: /("version"\s*:\s*")([^"]+)(")/,
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
path: 'templates/hypo-config.md',
|
|
33
|
-
pattern: /(^version:\s*")([^"]+)(")/m,
|
|
34
|
-
},
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
for (const { path, pattern } of targets) {
|
|
38
|
-
const abs = resolve(root, path);
|
|
39
|
-
const before = readFileSync(abs, 'utf-8');
|
|
40
|
-
const match = before.match(pattern);
|
|
41
|
-
if (!match) {
|
|
42
|
-
console.error(`✗ ${path}: version pattern not found`);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
const current = match[2];
|
|
46
|
-
if (current === next) {
|
|
47
|
-
console.log(`= ${path} already ${next}`);
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
writeFileSync(abs, before.replace(pattern, `$1${next}$3`));
|
|
51
|
-
console.log(`✓ ${path}: ${current} → ${next}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
console.log(`\nThis bumps the 4 manifest files only. The full release checklist lives in`);
|
|
55
|
-
console.log(`docs/CONTRIBUTING.md "Cutting a release" — follow it, or these steps:`);
|
|
56
|
-
console.log(`\nNext steps:`);
|
|
57
|
-
console.log(` 1. Sync package-lock (bump-version does NOT touch it; the lock carries the`);
|
|
58
|
-
console.log(` version twice and a stale lock fails check:versions + breaks npm ci):`);
|
|
59
|
-
console.log(` npm install --package-lock-only`);
|
|
60
|
-
console.log(` 2. Edit CHANGELOG.md — ensure the "## [${next}]" section has a`);
|
|
61
|
-
console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
|
|
62
|
-
console.log(` 3. Reconcile BOTH READMEs — add a v${next} sentence to README.md AND`);
|
|
63
|
-
console.log(` README.ko.md, and update the first-viewport "current release" pointer.`);
|
|
64
|
-
console.log(` (This step was dropped 3x; check:readme is now the floor gate.)`);
|
|
65
|
-
console.log(` 4. Local pre-checks — the full gate set CI + prepublishOnly run:`);
|
|
66
|
-
console.log(` npm test && npm run lint && npm run check:versions \\`);
|
|
67
|
-
console.log(` && npm run check:bilingual && npm run check:readme \\`);
|
|
68
|
-
console.log(` && npm run smoke:plugin && npm run smoke-pack && npm run check:tracker-ids`);
|
|
69
|
-
console.log(` 5. git add package.json package-lock.json .claude-plugin/ \\`);
|
|
70
|
-
console.log(` templates/hypo-config.md CHANGELOG.md README.md README.ko.md`);
|
|
71
|
-
console.log(` git commit -m "chore(release): v${next}"`);
|
|
72
|
-
console.log(` 6. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
|
|
73
|
-
console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
|
|
74
|
-
console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
|
|
75
|
-
console.log(` 7. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
|
|
76
|
-
console.log(` 8. git push origin <branch> # then push the tag ALONE to trigger release:`);
|
|
77
|
-
console.log(` git push origin v${next} # NOT --tags (avoid triggering stale tags)`);
|
package/scripts/smoke-pack.mjs
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* scripts/smoke-pack.mjs — pre-publish smoke test
|
|
4
|
-
*
|
|
5
|
-
* Runs `npm pack`, installs the resulting tarball into an isolated temp
|
|
6
|
-
* directory, and exercises the installed CLI to verify the published
|
|
7
|
-
* artifact actually works. Catches mistakes that local tests miss:
|
|
8
|
-
* - `files:` / `.npmignore` excluding required assets
|
|
9
|
-
* - `bin` entry pointing to a missing file
|
|
10
|
-
* - runtime requires on paths not shipped in the tarball
|
|
11
|
-
*
|
|
12
|
-
* Usage:
|
|
13
|
-
* node scripts/smoke-pack.mjs [--keep]
|
|
14
|
-
*
|
|
15
|
-
* --keep Leave the temp directory in place for inspection on success.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { spawnSync } from 'node:child_process';
|
|
19
|
-
import {
|
|
20
|
-
mkdtempSync,
|
|
21
|
-
rmSync,
|
|
22
|
-
readFileSync,
|
|
23
|
-
writeFileSync,
|
|
24
|
-
existsSync,
|
|
25
|
-
readdirSync,
|
|
26
|
-
renameSync,
|
|
27
|
-
} from 'node:fs';
|
|
28
|
-
import { join } from 'node:path';
|
|
29
|
-
import { tmpdir } from 'node:os';
|
|
30
|
-
import { fileURLToPath } from 'node:url';
|
|
31
|
-
|
|
32
|
-
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
|
33
|
-
const KEEP = process.argv.includes('--keep');
|
|
34
|
-
|
|
35
|
-
// When this script runs inside `npm publish --dry-run` (the release workflow's
|
|
36
|
-
// publish-credential pre-check), npm exports `npm_config_dry_run=true` into the
|
|
37
|
-
// lifecycle environment. spawnSync inherits process.env, so the nested
|
|
38
|
-
// `npm pack` below would ALSO run in dry-run mode — reporting a tarball
|
|
39
|
-
// filename/size it never actually writes to disk — and the subsequent
|
|
40
|
-
// `npm install <tarball>` then fails with ENOENT (npm maps errno -2 → exit 254).
|
|
41
|
-
// Strip the flag so the nested npm commands always perform real writes,
|
|
42
|
-
// matching the real tag-push publish job (which has no outer `--dry-run`). The
|
|
43
|
-
// outer workflow stays dry-run and still skips the registry PUT.
|
|
44
|
-
// (This was the precheck-254 root cause; the earlier "missing NODE_AUTH_TOKEN"
|
|
45
|
-
// theory was wrong — it is file absence, not auth.)
|
|
46
|
-
const npmEnv = { ...process.env };
|
|
47
|
-
for (const key of Object.keys(npmEnv)) {
|
|
48
|
-
if (key.toLowerCase().replaceAll('-', '_') === 'npm_config_dry_run') {
|
|
49
|
-
delete npmEnv[key];
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
|
|
54
|
-
const PKG_NAME = PKG.name;
|
|
55
|
-
|
|
56
|
-
function run(cmd, args, opts = {}) {
|
|
57
|
-
const res = spawnSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8', ...opts });
|
|
58
|
-
if (res.status !== 0) {
|
|
59
|
-
process.stderr.write(res.stdout || '');
|
|
60
|
-
process.stderr.write(res.stderr || '');
|
|
61
|
-
throw new Error(`${cmd} ${args.join(' ')} exited ${res.status}`);
|
|
62
|
-
}
|
|
63
|
-
return res;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function step(msg) {
|
|
67
|
-
console.log(`\n▸ ${msg}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const work = mkdtempSync(join(tmpdir(), 'hypo-smoke-'));
|
|
71
|
-
const sandboxHome = join(work, 'home');
|
|
72
|
-
const installRoot = join(work, 'install');
|
|
73
|
-
const wikiDir = join(work, 'wiki');
|
|
74
|
-
let cleanupOk = false;
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
// Capture pre-commit hook contents (if any) BEFORE pack so we can prove
|
|
78
|
-
// `npm pack` didn't mutate it. The `prepare` lifecycle script runs during
|
|
79
|
-
// `npm pack` and could theoretically touch .git/hooks/pre-commit; the
|
|
80
|
-
// installer's CI/lifecycle guards must prevent that.
|
|
81
|
-
const preCommitPath = join(REPO, '.git', 'hooks', 'pre-commit');
|
|
82
|
-
const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
|
|
83
|
-
|
|
84
|
-
step('npm pack');
|
|
85
|
-
const pack = run('npm', ['pack', '--json'], { cwd: REPO, env: npmEnv });
|
|
86
|
-
const meta = JSON.parse(pack.stdout)[0];
|
|
87
|
-
const tarball = join(REPO, meta.filename);
|
|
88
|
-
console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
|
|
89
|
-
|
|
90
|
-
step(`install into ${installRoot}`);
|
|
91
|
-
run('mkdir', ['-p', installRoot]);
|
|
92
|
-
writeFileSync(
|
|
93
|
-
join(installRoot, 'package.json'),
|
|
94
|
-
JSON.stringify({
|
|
95
|
-
name: 'hypo-smoke-host',
|
|
96
|
-
version: '0.0.0',
|
|
97
|
-
private: true,
|
|
98
|
-
}) + '\n',
|
|
99
|
-
);
|
|
100
|
-
// No `--silent`: run() only prints npm's stdout/stderr on a non-zero exit, so
|
|
101
|
-
// a real nested-install failure must not be muted (the precheck-254 error was
|
|
102
|
-
// masked by `--silent` for two release cycles).
|
|
103
|
-
run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, env: npmEnv });
|
|
104
|
-
|
|
105
|
-
// Move tarball into the work dir so it's not left in the repo.
|
|
106
|
-
renameSync(tarball, join(work, meta.filename));
|
|
107
|
-
|
|
108
|
-
const cliBin = join(installRoot, 'node_modules', '.bin', 'hypomnema');
|
|
109
|
-
if (!existsSync(cliBin)) {
|
|
110
|
-
throw new Error(`bin not installed at ${cliBin}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
step('hypomnema --help (installed)');
|
|
114
|
-
const help = run(cliBin, ['--help']);
|
|
115
|
-
if (!help.stdout.includes('Usage: hypomnema')) {
|
|
116
|
-
throw new Error('--help output did not match expected banner');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
step('hypomnema init --dry-run (installed)');
|
|
120
|
-
const dryEnv = { ...process.env, HOME: sandboxHome, HYPO_DIR: wikiDir };
|
|
121
|
-
const dry = run(
|
|
122
|
-
cliBin,
|
|
123
|
-
[
|
|
124
|
-
'init',
|
|
125
|
-
'--dry-run',
|
|
126
|
-
`--hypo-dir=${wikiDir}`,
|
|
127
|
-
'--no-hooks',
|
|
128
|
-
'--no-commands',
|
|
129
|
-
'--no-git-init',
|
|
130
|
-
'--no-shell',
|
|
131
|
-
],
|
|
132
|
-
{ env: dryEnv },
|
|
133
|
-
);
|
|
134
|
-
if (!/dry[\s-]?run/i.test(dry.stdout + dry.stderr)) {
|
|
135
|
-
console.log(dry.stdout);
|
|
136
|
-
throw new Error('init --dry-run did not announce dry-run mode');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
step('verify shipped assets are present');
|
|
140
|
-
const required = [
|
|
141
|
-
'scripts/init.mjs',
|
|
142
|
-
'scripts/upgrade.mjs',
|
|
143
|
-
'hooks',
|
|
144
|
-
'commands',
|
|
145
|
-
'templates',
|
|
146
|
-
'README.md',
|
|
147
|
-
];
|
|
148
|
-
const pkgRoot = join(installRoot, 'node_modules', PKG_NAME);
|
|
149
|
-
for (const rel of required) {
|
|
150
|
-
if (!existsSync(join(pkgRoot, rel))) {
|
|
151
|
-
throw new Error(`shipped tarball missing: ${rel}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
step('hypomnema feedback-sync (installed) — check/write idempotency + bootstrap');
|
|
156
|
-
// Seed a fixture wiki + claude-home and exercise the feedback-sync surface
|
|
157
|
-
// through the installed CLI (proves the subcommand is routed + shipped and the
|
|
158
|
-
// Phase D bootstrap helper runs end-to-end). --check exits 1 on a fresh
|
|
159
|
-
// (un-written) projection, so use spawnSync (run() throws on non-zero).
|
|
160
|
-
const fbWiki = join(work, 'fb-wiki');
|
|
161
|
-
const fbHome = join(work, 'fb-home');
|
|
162
|
-
const fbProject = 'smoke';
|
|
163
|
-
run('mkdir', ['-p', join(fbWiki, 'pages', 'feedback')]);
|
|
164
|
-
run('mkdir', ['-p', join(fbHome, 'projects', fbProject, 'memory')]);
|
|
165
|
-
writeFileSync(
|
|
166
|
-
join(fbWiki, 'hypo-config.md'),
|
|
167
|
-
'---\ntitle: config\ntype: reference\n---\n# config\n',
|
|
168
|
-
);
|
|
169
|
-
writeFileSync(
|
|
170
|
-
join(fbWiki, 'pages', 'feedback', 'smoke-rule.md'),
|
|
171
|
-
[
|
|
172
|
-
'---',
|
|
173
|
-
'title: Smoke Rule',
|
|
174
|
-
'type: feedback',
|
|
175
|
-
'status: active',
|
|
176
|
-
'scope: global',
|
|
177
|
-
'tier: L1',
|
|
178
|
-
'targets: [project-memory, claude-learned]',
|
|
179
|
-
'sensitivity: public',
|
|
180
|
-
'priority: 4',
|
|
181
|
-
'memory_summary: smoke rule for the packed CLI',
|
|
182
|
-
'global_summary: always smoke-test the packed CLI',
|
|
183
|
-
'promote_to_global: true',
|
|
184
|
-
'reason: catch packaging regressions',
|
|
185
|
-
'source: session:2026-05-21',
|
|
186
|
-
'updated: 2026-05-21',
|
|
187
|
-
'---',
|
|
188
|
-
'body',
|
|
189
|
-
'',
|
|
190
|
-
].join('\n'),
|
|
191
|
-
);
|
|
192
|
-
writeFileSync(
|
|
193
|
-
join(fbHome, 'CLAUDE.md'),
|
|
194
|
-
'# Global\n<learned_behaviors>\n- [2026-05-20] legacy hand rule — 이유: bootstrap source\n</learned_behaviors>\n',
|
|
195
|
-
);
|
|
196
|
-
writeFileSync(join(fbHome, 'projects', fbProject, 'memory', 'MEMORY.md'), '# Memory Index\n');
|
|
197
|
-
|
|
198
|
-
const fbArgs = [`--hypo-dir=${fbWiki}`, `--claude-home=${fbHome}`, `--project-id=${fbProject}`];
|
|
199
|
-
const fbRun = (extra) =>
|
|
200
|
-
spawnSync(cliBin, ['feedback-sync', ...extra, ...fbArgs], { encoding: 'utf-8' });
|
|
201
|
-
|
|
202
|
-
const fbCheck1 = fbRun(['--check']);
|
|
203
|
-
if (fbCheck1.status !== 1) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
`feedback-sync --check on fresh projection expected exit 1, got ${fbCheck1.status}`,
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
const fbWrite = fbRun(['--write']);
|
|
209
|
-
if (fbWrite.status !== 0) {
|
|
210
|
-
throw new Error(
|
|
211
|
-
`feedback-sync --write expected exit 0, got ${fbWrite.status}: ${fbWrite.stderr}`,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
if (
|
|
215
|
-
!readFileSync(join(fbHome, 'CLAUDE.md'), 'utf-8').includes(
|
|
216
|
-
'HYPO:FEEDBACK-SYNC:START source=smoke-rule',
|
|
217
|
-
)
|
|
218
|
-
) {
|
|
219
|
-
throw new Error('feedback-sync --write did not project the managed block into CLAUDE.md');
|
|
220
|
-
}
|
|
221
|
-
const fbCheck2 = fbRun(['--check']);
|
|
222
|
-
if (fbCheck2.status !== 0) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`feedback-sync --check after --write expected clean exit 0, got ${fbCheck2.status}`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
const fbBootstrap = fbRun(['--bootstrap', '--dry-run']);
|
|
228
|
-
if (fbBootstrap.status !== 0 || !/would create draft/.test(fbBootstrap.stderr)) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
`feedback-sync --bootstrap --dry-run did not announce drafts (exit ${fbBootstrap.status})`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
step('verify pre-commit hook was not mutated by npm pack');
|
|
235
|
-
const preCommitAfter = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
|
|
236
|
-
if (preCommitBefore !== preCommitAfter) {
|
|
237
|
-
throw new Error(
|
|
238
|
-
'npm pack mutated .git/hooks/pre-commit — the prepare lifecycle ' +
|
|
239
|
-
'guard (npm_command=pack) is not firing.',
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
console.log('\n✓ smoke-pack passed');
|
|
244
|
-
cleanupOk = true;
|
|
245
|
-
} catch (err) {
|
|
246
|
-
console.error(`\n✗ smoke-pack failed: ${err.message}`);
|
|
247
|
-
console.error(` work dir preserved: ${work}`);
|
|
248
|
-
process.exitCode = 1;
|
|
249
|
-
} finally {
|
|
250
|
-
if (cleanupOk && !KEEP) {
|
|
251
|
-
rmSync(work, { recursive: true, force: true });
|
|
252
|
-
} else if (KEEP) {
|
|
253
|
-
console.log(`\nwork dir kept: ${work}`);
|
|
254
|
-
}
|
|
255
|
-
// Sweep any stray tarballs left in the repo root from a crashed run.
|
|
256
|
-
for (const f of readdirSync(REPO)) {
|
|
257
|
-
if (f.startsWith(`${PKG_NAME}-`) && f.endsWith('.tgz')) {
|
|
258
|
-
rmSync(join(REPO, f), { force: true });
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
package/scripts/smoke-plugin.mjs
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// smoke-plugin.mjs — a cheap, LOCAL structural check that the Claude Code plugin
|
|
3
|
-
// would load, with no network and no Claude CLI dependency (so it runs in CI). It
|
|
4
|
-
// validates the three surfaces Claude Code actually reads:
|
|
5
|
-
// 1. .claude-plugin/plugin.json — manifest (name, version, commands/skills paths)
|
|
6
|
-
// 2. hooks/hooks.json — every `${CLAUDE_PLUGIN_ROOT}/...` target must exist
|
|
7
|
-
// 3. component files — commands/*.md and skills/*/SKILL.md must be REAL
|
|
8
|
-
// (a .gitkeep alone is not a surface — a plugin with zero commands/skills must fail)
|
|
9
|
-
// plus marketplace↔plugin name parity and that the marketplace `source` resolves to a
|
|
10
|
-
// dir holding .claude-plugin/plugin.json.
|
|
11
|
-
//
|
|
12
|
-
// For a deep, real load check on a dev machine: `claude --plugin-dir . plugin list`.
|
|
13
|
-
// That needs Claude Code installed, so it is NOT used here; this is the CI floor.
|
|
14
|
-
//
|
|
15
|
-
// Usage:
|
|
16
|
-
// node scripts/smoke-plugin.mjs # smoke the repo's plugin
|
|
17
|
-
// node scripts/smoke-plugin.mjs --root <dir> # point at a fixture (tests)
|
|
18
|
-
//
|
|
19
|
-
// Exit 0 = plugin surfaces are structurally valid. Exit 1 = a load-blocking problem.
|
|
20
|
-
|
|
21
|
-
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
|
|
22
|
-
import { join, dirname } from 'path';
|
|
23
|
-
import { fileURLToPath } from 'url';
|
|
24
|
-
|
|
25
|
-
function parseArgs(argv) {
|
|
26
|
-
const args = { root: null };
|
|
27
|
-
for (let i = 0; i < argv.length; i++) {
|
|
28
|
-
const a = argv[i];
|
|
29
|
-
if (a.startsWith('--root=')) args.root = a.slice(7);
|
|
30
|
-
else if (a === '--root') args.root = argv[++i];
|
|
31
|
-
}
|
|
32
|
-
return args;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
-
|
|
37
|
-
function isDir(p) {
|
|
38
|
-
try {
|
|
39
|
-
return statSync(p).isDirectory();
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isFile(p) {
|
|
46
|
-
try {
|
|
47
|
-
return statSync(p).isFile();
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function smoke(root) {
|
|
54
|
-
const errors = [];
|
|
55
|
-
const notes = [];
|
|
56
|
-
const fail = (msg) => errors.push(msg);
|
|
57
|
-
|
|
58
|
-
// 1. plugin.json manifest.
|
|
59
|
-
let plugin = null;
|
|
60
|
-
const pluginPath = join(root, '.claude-plugin', 'plugin.json');
|
|
61
|
-
try {
|
|
62
|
-
plugin = JSON.parse(readFileSync(pluginPath, 'utf-8'));
|
|
63
|
-
} catch (err) {
|
|
64
|
-
fail(`.claude-plugin/plugin.json: ${err?.message ?? err}`);
|
|
65
|
-
}
|
|
66
|
-
if (plugin) {
|
|
67
|
-
if (typeof plugin.name !== 'string' || !plugin.name) fail('plugin.json: missing "name"');
|
|
68
|
-
if (typeof plugin.version !== 'string' || !plugin.version)
|
|
69
|
-
fail('plugin.json: missing "version"');
|
|
70
|
-
// commands/skills are declared as relative dir paths; if declared they must resolve.
|
|
71
|
-
for (const key of ['commands', 'skills']) {
|
|
72
|
-
if (plugin[key] != null) {
|
|
73
|
-
const rel = String(plugin[key]).replace(/^\.\//, '');
|
|
74
|
-
if (!isDir(join(root, rel)))
|
|
75
|
-
fail(`plugin.json: "${key}" path "${plugin[key]}" is not a directory`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 2. Component files must be REAL regular files, not just a .gitkeep placeholder
|
|
81
|
-
// (or a directory that happens to be named like a component — isFile, not exists).
|
|
82
|
-
const commandsMd = isDir(join(root, 'commands'))
|
|
83
|
-
? readdirSync(join(root, 'commands')).filter(
|
|
84
|
-
(f) => f.endsWith('.md') && isFile(join(root, 'commands', f)),
|
|
85
|
-
)
|
|
86
|
-
: [];
|
|
87
|
-
if (commandsMd.length === 0) fail('commands/: no *.md command files (only a placeholder?)');
|
|
88
|
-
else notes.push(`commands: ${commandsMd.length}`);
|
|
89
|
-
|
|
90
|
-
let skillCount = 0;
|
|
91
|
-
if (isDir(join(root, 'skills'))) {
|
|
92
|
-
for (const entry of readdirSync(join(root, 'skills'))) {
|
|
93
|
-
if (isFile(join(root, 'skills', entry, 'SKILL.md'))) skillCount++;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (skillCount === 0) fail('skills/: no */SKILL.md skills (only a placeholder?)');
|
|
97
|
-
else notes.push(`skills: ${skillCount}`);
|
|
98
|
-
|
|
99
|
-
// 3. hooks/hooks.json — every command target AND every `shared` file must be a
|
|
100
|
-
// real regular file on disk. hooks.json is the hook source of truth, so a missing
|
|
101
|
-
// hypo-shared.mjs / version-check.mjs would pass a manifest-only check but break
|
|
102
|
-
// hook imports at runtime.
|
|
103
|
-
const hooksPath = join(root, 'hooks', 'hooks.json');
|
|
104
|
-
if (!existsSync(hooksPath)) {
|
|
105
|
-
fail('hooks/hooks.json: missing');
|
|
106
|
-
} else {
|
|
107
|
-
let hooksJson = null;
|
|
108
|
-
try {
|
|
109
|
-
hooksJson = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
110
|
-
} catch (err) {
|
|
111
|
-
fail(`hooks/hooks.json: ${err?.message ?? err}`);
|
|
112
|
-
}
|
|
113
|
-
if (hooksJson) {
|
|
114
|
-
if (!hooksJson.hooks || typeof hooksJson.hooks !== 'object') {
|
|
115
|
-
fail('hooks/hooks.json: missing top-level "hooks" object');
|
|
116
|
-
} else {
|
|
117
|
-
const targets = new Set();
|
|
118
|
-
for (const groups of Object.values(hooksJson.hooks)) {
|
|
119
|
-
for (const group of groups || []) {
|
|
120
|
-
for (const hk of group?.hooks || []) {
|
|
121
|
-
if (hk?.command) {
|
|
122
|
-
// command looks like `node ${CLAUDE_PLUGIN_ROOT}/hooks/foo.mjs [args]`
|
|
123
|
-
const m = String(hk.command).match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(\S+)/);
|
|
124
|
-
if (m) targets.add(m[1]);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (targets.size === 0)
|
|
130
|
-
fail('hooks/hooks.json: no ${CLAUDE_PLUGIN_ROOT} command targets found');
|
|
131
|
-
for (const rel of targets) {
|
|
132
|
-
if (!isFile(join(root, rel))) fail(`hooks/hooks.json: target "${rel}" is not a file`);
|
|
133
|
-
}
|
|
134
|
-
notes.push(`hook targets: ${targets.size}`);
|
|
135
|
-
}
|
|
136
|
-
// `shared` lists hook-relative support files that the targets import.
|
|
137
|
-
if (Array.isArray(hooksJson.shared)) {
|
|
138
|
-
for (const shared of hooksJson.shared) {
|
|
139
|
-
if (!isFile(join(root, 'hooks', shared)))
|
|
140
|
-
fail(`hooks/hooks.json: shared file "hooks/${shared}" is not a file`);
|
|
141
|
-
}
|
|
142
|
-
notes.push(`shared: ${hooksJson.shared.length}`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// 4. marketplace.json — name parity + source resolves.
|
|
148
|
-
const mpPath = join(root, '.claude-plugin', 'marketplace.json');
|
|
149
|
-
let mp = null;
|
|
150
|
-
try {
|
|
151
|
-
mp = JSON.parse(readFileSync(mpPath, 'utf-8'));
|
|
152
|
-
} catch (err) {
|
|
153
|
-
fail(`.claude-plugin/marketplace.json: ${err?.message ?? err}`);
|
|
154
|
-
}
|
|
155
|
-
if (mp) {
|
|
156
|
-
const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
|
|
157
|
-
if (plugins.length === 0) fail('marketplace.json: "plugins" is empty');
|
|
158
|
-
const name = plugin?.name;
|
|
159
|
-
if (name) {
|
|
160
|
-
const matches = plugins.filter((p) => p && p.name === name);
|
|
161
|
-
if (matches.length !== 1) {
|
|
162
|
-
fail(
|
|
163
|
-
`marketplace.json: expected exactly one entry named "${name}", found ${matches.length}`,
|
|
164
|
-
);
|
|
165
|
-
} else {
|
|
166
|
-
const src = matches[0].source ?? './';
|
|
167
|
-
const srcDir = join(root, String(src).replace(/^\.\//, ''));
|
|
168
|
-
if (!existsSync(join(srcDir, '.claude-plugin', 'plugin.json'))) {
|
|
169
|
-
fail(
|
|
170
|
-
`marketplace.json: source "${src}" does not resolve to a dir containing .claude-plugin/plugin.json`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return { errors, notes };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function main() {
|
|
181
|
-
const args = parseArgs(process.argv.slice(2));
|
|
182
|
-
const root = args.root || REPO_ROOT;
|
|
183
|
-
const { errors, notes } = smoke(root);
|
|
184
|
-
|
|
185
|
-
if (errors.length) {
|
|
186
|
-
for (const e of errors) console.error(` ✗ ${e}`);
|
|
187
|
-
console.error(`\n✗ plugin smoke failed (${errors.length} problem(s)).`);
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
console.log(`✓ plugin surfaces valid (${notes.join(', ')}).`);
|
|
191
|
-
process.exit(0);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
main();
|