hypomnema 1.3.3 → 1.4.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.
@@ -0,0 +1,194 @@
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();
package/scripts/stats.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
17
17
  import { join, extname } from 'path';
18
18
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
19
+ import { loadHypoIgnore, isIgnored, isScanIgnored } from './lib/hypo-ignore.mjs';
20
20
 
21
21
  // ── arg parsing ──────────────────────────────────────────────────────────────
22
22
 
@@ -37,7 +37,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
37
37
  for (const entry of readdirSync(dir)) {
38
38
  if (entry.startsWith('.')) continue;
39
39
  const full = join(dir, entry);
40
- if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
40
+ if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
41
41
  const st = statSync(full);
42
42
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
43
43
  else if (extname(entry) === '.md') acc.push(full);
@@ -45,16 +45,25 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
45
45
  return acc;
46
46
  }
47
47
 
48
+ // Local top-level-only extractor, behavior-aligned with lib/frontmatter.mjs:
49
+ // skip indented / list lines, first-wins, strip a trailing `# comment`. Without
50
+ // the comment strip a `failure_type: overreach # note` would aggregate under the
51
+ // literal "overreach # note" key (FEAT-1). Kept local (not the shared import) to
52
+ // hold stats' dependency surface flat per the FEAT-1 scope split.
48
53
  function parseFrontmatter(content) {
49
54
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
55
  if (!m) return null;
51
56
  const fm = {};
52
- for (const line of m[1].split('\n')) {
57
+ for (const line of m[1].split(/\r?\n/)) {
58
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
53
59
  const idx = line.indexOf(':');
54
60
  if (idx < 0) continue;
55
- fm[line.slice(0, idx).trim()] = line
61
+ const key = line.slice(0, idx).trim();
62
+ if (!key || Object.hasOwn(fm, key)) continue; // first-wins
63
+ fm[key] = line
56
64
  .slice(idx + 1)
57
65
  .trim()
66
+ .replace(/\s+#.*$/, '')
58
67
  .replace(/^["']|["']$/g, '');
59
68
  }
60
69
  return fm;
@@ -92,6 +101,7 @@ const sources = existsSync(join(args.hypoDir, 'sources'))
92
101
  : [];
93
102
 
94
103
  const typeCounts = {};
104
+ const failureTypeCounts = {}; // FEAT-1: feedback pages carrying a failure_type
95
105
  let missingFrontmatter = 0;
96
106
 
97
107
  for (const f of pageFiles) {
@@ -108,6 +118,9 @@ for (const f of pageFiles) {
108
118
  }
109
119
  const t = fm.type || 'unknown';
110
120
  typeCounts[t] = (typeCounts[t] || 0) + 1;
121
+ if (t === 'feedback' && fm.failure_type) {
122
+ failureTypeCounts[fm.failure_type] = (failureTypeCounts[fm.failure_type] || 0) + 1;
123
+ }
111
124
  }
112
125
 
113
126
  let adrCount = 0;
@@ -122,6 +135,9 @@ const lastActivity = getLastActivity(args.hypoDir);
122
135
 
123
136
  const stats = {
124
137
  pages: { total: pageFiles.length, byType: typeCounts, missingFrontmatter },
138
+ // Omit the key entirely when no feedback page is classified (OQ-4: no empty
139
+ // section noise) so consumers can branch on presence.
140
+ ...(Object.keys(failureTypeCounts).length ? { failureTypes: failureTypeCounts } : {}),
125
141
  projects: projects.length,
126
142
  sources: sources.length,
127
143
  adrs: adrCount,
@@ -139,6 +155,10 @@ if (args.json) {
139
155
  if (missingFrontmatter) {
140
156
  console.log(` missing frontmatter: ${missingFrontmatter}`);
141
157
  }
158
+ const ftEntries = Object.entries(failureTypeCounts).sort((a, b) => b[1] - a[1]);
159
+ if (ftEntries.length) {
160
+ console.log(` failure types: ${ftEntries.map(([t, n]) => `${t} (${n})`).join(', ')}`);
161
+ }
142
162
  console.log(`Projects: ${projects.length}`);
143
163
  console.log(`Sources: ${sources.length}`);
144
164
  console.log(`ADRs: ${adrCount}`);
@@ -42,6 +42,7 @@ import { fileURLToPath } from 'url';
42
42
  import { createHash } from 'crypto';
43
43
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
44
44
  import { parseFrontmatter } from './lib/frontmatter.mjs';
45
+ import { templateSchemaVersion } from './lib/template-schema-version.mjs';
45
46
  import {
46
47
  readPkgJson as readPkgJsonSafe,
47
48
  writePkgJsonAtomic,
@@ -477,7 +478,14 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
477
478
  // right to keep SCHEMA.md as user-owned (Option C); auto-stub of the 9 new
478
479
  // fields is rejected because scope/tier/targets/sensitivity/reason/source
479
480
  // are semantic decisions whose wrong defaults would project wrong behavior.
480
- const isV1ToV2 = fromVersion === '1.0' && toVersion === '2.0';
481
+ // Fire for any 1.x → 2.x major crossing, not just 1.0 2.0 exactly: the ADR
482
+ // 0031 feedback-field backfill that this body explains was introduced at 2.0
483
+ // and still applies to a user landing on any later 2.x (e.g. 2.1's additive
484
+ // failure_type — FEAT-1). Keying on the exact target string would silently
485
+ // drop this guidance the moment the template minor moves.
486
+ const fromV = parseVersion(fromVersion);
487
+ const toV = parseVersion(toVersion);
488
+ const isV1ToV2 = fromV?.major === 1 && toV?.major === 2;
481
489
  const specificBody = isV1ToV2
482
490
  ? `## What changed in SCHEMA 2.0
483
491
 
@@ -755,7 +763,7 @@ function applyCommands(commandResults, force) {
755
763
  ...existing,
756
764
  pkgRoot: PKG_ROOT,
757
765
  pkgVersion,
758
- schemaVersion: '2.0',
766
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
759
767
  commands: newSHAs,
760
768
  });
761
769
  return applied;
@@ -781,7 +789,7 @@ function writePluginModeMetadata() {
781
789
  ...existing,
782
790
  pkgRoot: PKG_ROOT,
783
791
  pkgVersion,
784
- schemaVersion: '2.0',
792
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
785
793
  });
786
794
  return true;
787
795
  }
@@ -18,7 +18,7 @@
18
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
19
19
  import { join, relative, extname } from 'path';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
 
23
23
  // ── arg parsing ──────────────────────────────────────────────────────────────
24
24
 
@@ -40,7 +40,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
40
40
  for (const entry of readdirSync(dir)) {
41
41
  if (entry.startsWith('.')) continue;
42
42
  const full = join(dir, entry);
43
- if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
43
+ if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
44
44
  const st = statSync(full);
45
45
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
46
46
  else if (extname(entry) === '.md') acc.push(full);
@@ -2,7 +2,7 @@
2
2
  title: Wiki Schema
3
3
  type: schema
4
4
  updated: YYYY-MM-DD
5
- version: 2.0
5
+ version: 2.1
6
6
  ---
7
7
 
8
8
  # Wiki Schema
@@ -118,11 +118,35 @@ memory_summary: <one line for the MEMORY.md index>
118
118
  reason: <why this rule is needed>
119
119
  source: session:YYYY-MM-DD | commit:<hash> | pr:<n> | https://...
120
120
 
121
+ # optional (2.1) — failure taxonomy for incident-driven corrections:
122
+ failure_type: <enum> # one of the 8 values below; OMIT for pure
123
+ # preferences / new conventions ("always do X")
124
+
121
125
  # conditional — required when `targets` includes `claude-learned`:
122
126
  global_summary: <one line for the <learned_behaviors> entry>
123
127
  promote_to_global: true # explicit opt-in to global projection
124
128
  ```
125
129
 
130
+ `failure_type` (optional, added 2.1) classifies feedback that came from a real
131
+ failure incident so recurring mistake types become machine-aggregatable
132
+ (surfaced by `hypomnema stats`). Leave it off when the page records a preference
133
+ rather than a failure. The eight values, in classification-precedence order
134
+ (most specific first — a failure matching several takes the earliest):
135
+
136
+ | value | when |
137
+ |-------|------|
138
+ | `hallucination` | fabricated a fact / API / path |
139
+ | `false-completion` | declared "done" without running the required gate or test |
140
+ | `process-stall` | stopped instead of asking / continuing when it should have |
141
+ | `over-caution` | re-asked or re-gated despite standing authority |
142
+ | `overreach` | acted beyond the requested scope |
143
+ | `incompleteness` | started correctly but omitted a required step or scope |
144
+ | `instruction-miss` | ignored an explicit this-session instruction |
145
+ | `convention-violation` | broke a standing documented convention (not restated) |
146
+
147
+ `lint` rejects any value outside this set; an omitted `failure_type` is always
148
+ allowed.
149
+
126
150
  Edit the feedback page only — never hand-edit the generated
127
151
  `<!-- HYPO:FEEDBACK-SYNC:START … -->` managed blocks (sync detects tampering as a conflict).
128
152
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.3.3"
4
+ version: "1.4.0"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7