spovishun-skills 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/adapters/claude/index.js +44 -2
  3. package/adapters/claude/update.js +12 -3
  4. package/bin/update.js +8 -1
  5. package/hooks/notion-task-inject.js +11 -3
  6. package/lib/placeholder-map.js +6 -5
  7. package/lib/skill-frontmatter.js +87 -0
  8. package/package.json +1 -1
  9. package/scripts/notion/create-epic.js +101 -0
  10. package/scripts/notion/create-task.js +129 -0
  11. package/scripts/notion/get-board.js +188 -0
  12. package/scripts/notion/get-claude-md.js +86 -0
  13. package/scripts/notion/get-task.js +125 -0
  14. package/scripts/notion/lib/cache.js +39 -0
  15. package/scripts/notion/lib/config-reader.js +79 -0
  16. package/scripts/notion/lib/constants.js +34 -0
  17. package/scripts/notion/lib/extract-branch.js +51 -0
  18. package/scripts/notion/lib/format-task.js +31 -0
  19. package/scripts/notion/lib/load-token.js +23 -0
  20. package/scripts/notion/lib/notion-http.js +40 -0
  21. package/scripts/notion/lib/page-id.js +15 -0
  22. package/scripts/notion/lib/project-prefix.js +42 -0
  23. package/scripts/notion/lib/query-tasks.js +33 -0
  24. package/scripts/notion/lib/resolve-relations.js +25 -0
  25. package/scripts/notion/lib/section-parser.js +40 -0
  26. package/scripts/notion/list-epics.js +98 -0
  27. package/scripts/notion/package.json +7 -0
  28. package/scripts/notion/update-status.js +48 -0
  29. package/skills/newepic/SKILL.md +5 -3
  30. package/skills/newepic/manifest.yaml +1 -3
  31. package/skills/newtask/SKILL.md +14 -6
  32. package/skills/newtask/manifest.yaml +1 -5
  33. package/skills/notion-content-reader/SKILL.md +3 -3
  34. package/skills/notion-content-reader/manifest.yaml +1 -1
  35. package/skills/notion-navigator/SKILL.md +4 -2
  36. package/skills/notion-navigator/manifest.yaml +1 -5
  37. package/skills/notion-spovishun-task-manager/SKILL.md +16 -9
  38. package/skills/notion-spovishun-task-manager/manifest.yaml +1 -3
  39. package/skills/notion-spovishun-task-manager/references/board-v2-stages.md +4 -2
  40. package/skills/notion-task-to-code/SKILL.md +4 -4
  41. package/skills/notion-task-to-code/manifest.yaml +1 -1
  42. package/skills/notion-workflow-spovishun/SKILL.md +8 -8
  43. package/skills/notion-workflow-spovishun/manifest.yaml +1 -1
  44. package/skills/task-decomposer/SKILL.md +11 -5
  45. package/skills/task-decomposer/manifest.yaml +1 -5
package/CHANGELOG.md CHANGED
@@ -5,6 +5,156 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.2] — 2026-06-04
9
+
10
+ Patch release with two breaking-but-necessary changes:
11
+
12
+ 1. Fixes a runtime-breaking confusion between Notion `database_id` and
13
+ `data_source_id` in five bundled skills (4xx from the Notion API).
14
+ 2. Closes a long-standing self-inconsistency: skills referenced
15
+ `scripts/notion/*.js` CLI helpers that **did not ship with the plugin**, so
16
+ every freshly-installed consumer hit `MODULE_NOT_FOUND` the moment a skill
17
+ tried to read the board. The scripts are now part of the plugin and the
18
+ Claude adapter installs them into `.claude/scripts/notion/`.
19
+
20
+ ### Fixed
21
+
22
+ - **`lib/placeholder-map.js`** — removed two false aliases that mapped the configured
23
+ `database_id` / `epics_database_id` under `*_COLLECTION_ID` / `*_DATA_SOURCE_ID` names.
24
+ In Notion's data-sources model a database id and its data_source (collection) id are
25
+ different UUIDs. Skills that interpolated those aliases into MCP
26
+ `parent: { type: "data_source_id", data_source_id: "<value>" }` parents failed at
27
+ runtime with `404 object_not_found` — proven against the live Notion API during the
28
+ spovishun-93 dogfooding.
29
+ - **`skills/newtask`**, **`skills/newepic`**, **`skills/notion-spovishun-task-manager`**,
30
+ **`skills/task-decomposer`** — the MCP create-page examples now use
31
+ `parent: { type: "database_id", database_id: "{{NOTION_DATABASE_ID}}" }` (or
32
+ `{{NOTION_EPICS_DATABASE_ID}}` for epics). Inline notes flag the single-data-source
33
+ prerequisite of the `database_id` parent and point to `notion-task-board-manager`
34
+ for the multi-source / live-fetched `data_source_id` pattern.
35
+ - **`skills/task-decomposer`** — Step 0 board lookup no longer constructs a broken
36
+ `data_source_url: "collection://<database_id>"` URL; switched to
37
+ `node scripts/notion/get-board.js`, which uses the REST `/databases/{id}/query`
38
+ endpoint that is consistent with the rest of the consumer-side tooling.
39
+ - **`skills/notion-navigator`** — prose now references `{{NOTION_DATABASE_ID}}` /
40
+ `{{NOTION_EPICS_DATABASE_ID}}` and clarifies they are database_ids, not collection
41
+ ids, with a pointer to live-fetching the data_source_id when MCP needs it.
42
+ - **`hooks/notion-task-inject.js`** — renamed the misleading `NOTION_BOARD_COLLECTION_ID`
43
+ env-var reference to `NOTION_DATABASE_ID` (the value was always a `database_id`; the
44
+ hook calls `/v1/databases/{id}/query`). The old name is still accepted as a
45
+ deprecated alias so existing consumer `.env` files keep working.
46
+
47
+ ### Changed
48
+
49
+ - **Board v2 (Scrum) Stage default flipped to explicit `Backlog`.** Pre-v1.2.2, the
50
+ `notion-spovishun-task-manager/references/board-v2-stages.md` doc told `newtask` to
51
+ leave `Stage` empty — that left new tasks invisible to the `Stage = Backlog` Backlog
52
+ view filter unless you also kept the `Stage is empty` clause around forever. New tasks
53
+ now set `Stage: "Backlog"` explicitly in the MCP create body (omit the property
54
+ entirely on Board v1 / when `notion.picker.stage_filter` is unset). Doc and skill
55
+ guidance updated together; the Backlog view filter clause is unchanged for
56
+ backward compatibility with pre-v1.2.2 tasks.
57
+
58
+ ### Removed
59
+
60
+ - **Placeholders `NOTION_BOARD_COLLECTION_ID` and `NOTION_EPICS_DATA_SOURCE_ID`** —
61
+ no longer surfaced by the placeholder map; dropped from every manifest that declared
62
+ them (`newtask`, `newepic`, `notion-navigator`, `notion-spovishun-task-manager`,
63
+ `task-decomposer`). Any skill body still referencing one will fail install with a
64
+ clear `UNKNOWN_PLACEHOLDER` error — intended, surfaces the bug.
65
+
66
+ ### Added — Notion CLI scripts now ship with the plugin
67
+
68
+ - **`scripts/notion/`** — 7 CLI helpers (`get-board.js`, `get-task.js`,
69
+ `get-claude-md.js`, `create-task.js`, `create-epic.js`, `list-epics.js`,
70
+ `update-status.js`) plus 12 shared `lib/` modules. Ported from the Spovishun
71
+ project and generalized: no hard-coded Notion UUIDs, no hard-coded
72
+ `spovishun-` task-id prefix. All values resolve at run time:
73
+ - `NOTION_DATABASE_ID` / `NOTION_EPICS_DATABASE_ID` / `NOTION_CLAUDE_MD_PAGE_ID`
74
+ env vars take precedence; otherwise read from `spovishun-skills.config.yaml`.
75
+ - `PROJECT_PREFIX` env var or a slugified `project.name` drives the task-id
76
+ regex (e.g. `myapp-42` for `project.name: "MyApp"`).
77
+ - **`adapters/claude/index.js`** — new `installScripts()` step that mirrors
78
+ `scripts/notion/` into the consumer's `.claude/scripts/notion/` when
79
+ `stack.notion` is true. No-op otherwise. Codex / Windsurf targets do not
80
+ receive scripts (those adapters surface skills as inline text).
81
+ - **`scripts/notion/create-task.js`** — supports a new `stage` field on stdin
82
+ for Board v2 (Scrum). Default `"Backlog"`; pass `null` to omit the Stage
83
+ column entirely on Board v1.
84
+ - **`scripts/notion/lib/config-reader.js`** — strips a UTF-8 BOM before
85
+ scanning the consumer config so `Out-File -Encoding utf8` (PowerShell 5.1)
86
+ configs don't silently parse to empty.
87
+
88
+ ### Changed — skill bodies use installed script path
89
+
90
+ - All eight skills that invoke a Notion CLI helper (`newtask`, `newepic`,
91
+ `task-decomposer`, `notion-spovishun-task-manager`, `notion-task-to-code`,
92
+ `notion-workflow-spovishun`, `notion-content-reader`) now reference
93
+ `node .claude/scripts/notion/<script>.js` instead of the un-prefixed
94
+ `node scripts/notion/<script>.js` that pointed nowhere on a fresh install.
95
+
96
+ ### Manifests bumped
97
+
98
+ - `newtask` 1.0.0 → 1.0.1
99
+ - `newepic` 1.0.0 → 1.0.1
100
+ - `notion-navigator` 1.0.0 → 1.0.1
101
+ - `notion-spovishun-task-manager` 1.0.0 → 1.0.1
102
+ - `task-decomposer` 1.0.0 → 1.0.1
103
+ - `notion-task-to-code` 1.0.0 → 1.0.1
104
+ - `notion-workflow-spovishun` 1.0.0 → 1.0.1
105
+ - `notion-content-reader` 1.0.0 → 1.0.1
106
+
107
+ ### Migration notes for consumers
108
+
109
+ 1. Re-run `npx spovishun-skills install --target=<target>` after upgrading.
110
+ 2. The `NOTION_BOARD_COLLECTION_ID` env var in `.env` keeps working but is deprecated;
111
+ rename it to `NOTION_DATABASE_ID` at your convenience.
112
+ 3. Board v2 users on Notion: new tasks created via these skills will explicitly land
113
+ in `Stage = "Backlog"` (was: empty). If you have a Backlog view filtering on
114
+ `Stage is empty` only, broaden it to `Stage = Backlog OR Stage is empty`.
115
+
116
+ ### Known follow-ups (not addressed in this patch)
117
+
118
+ - First-install pruning over hand-authored `.claude/` (carried over from v1.2.1). A
119
+ deprecated `diagram-design/` skill folder and a legacy `.claude/skills/_templates/`
120
+ directory are not removed because the pre-existing install has no provenance.
121
+
122
+ ## [1.2.1] — 2026-06-03
123
+
124
+ Patch release. Fixes a Claude-adapter bug surfaced by dogfooding the published plugin in the
125
+ Spovishun project: the Claude install was writing 24 of 36 skills without any YAML frontmatter,
126
+ which silently degraded Claude Code's skill triggering (it fell back to the body's first H1
127
+ instead of the manifest's `description`).
128
+
129
+ ### Fixed
130
+
131
+ - **`adapters/claude/index.js`** now synthesizes a `---\nname: <id>\ndescription: ...\n---`
132
+ frontmatter block at the top of every `.claude/skills/<id>/SKILL.md` when the canonical body
133
+ does not already start with one. Bodies that ship with their own inline frontmatter (e.g.
134
+ `code-reviewer`) are written verbatim — no double-wrap. Agents and templates are unchanged.
135
+ - **`adapters/claude/update.js`** mirrors the install behavior so `update --target=claude` and
136
+ `sync` produce byte-identical output to a fresh install for the same upstream commit.
137
+ - **`bin/update.js`** prepends the same synthesized frontmatter when building its upstream
138
+ checksum map, so the three-way classifier sees AUTO_APPLY (not "drifted") once a re-install
139
+ with the new adapter has run.
140
+
141
+ ### Added
142
+
143
+ - **`lib/skill-frontmatter.js`** — small pure helper module. `composeSkillDescription(manifest)`
144
+ appends a `Triggers: <en+uk joined>.` suffix to the manifest's `description` so triggering
145
+ matches the convention used by hand-authored inline-frontmatter skills like `code-reviewer`.
146
+ `ensureSkillFrontmatter(body, manifest)` is the no-op-if-present write-side guard.
147
+ - Tests: `test/skill-frontmatter.test.js` (unit) + new `manifest-only` / `inline-frontmatter`
148
+ / `agent-unchanged` cases in `test/install-claude.test.js`. New fixture
149
+ `test/fixtures/source/skills/inline-frontmatter-skill/` covers the no-double-wrap path.
150
+
151
+ ### Known follow-ups (not addressed in this patch)
152
+
153
+ - Pruning hand-authored artifacts the lockfile never owned. A first install over a hand-authored
154
+ `.claude/` cannot remove pre-existing files (e.g. a deprecated `diagram-design/` skill folder,
155
+ or a legacy `.claude/skills/_templates/` directory) because they have no provenance. Likely
156
+ fix: teach `install` to prune known-removed skill ids and detect the legacy template location.
157
+
8
158
  ## [1.2.0] — 2026-06-02
9
159
 
10
160
  Folder-layout supporting files across all adapters, templates as a first-class artifact kind,
@@ -7,6 +7,7 @@ import { buildPlaceholderMap } from '../../lib/placeholder-map.js';
7
7
  import { sha256 } from '../../lib/checksum.js';
8
8
  import { mergeSettings } from '../../lib/settings-merger.js';
9
9
  import { readLockfile, LOCKFILE_NAME } from '../../lib/lockfile.js';
10
+ import { ensureSkillFrontmatter } from '../../lib/skill-frontmatter.js';
10
11
 
11
12
  const KIND_LAYOUT = {
12
13
  skill: { subdir: 'skills', bodyFilename: 'SKILL.md' },
@@ -47,11 +48,14 @@ export async function installClaude({ consumerCwd, pkgRoot, config, artifacts, w
47
48
 
48
49
  const manifestPlaceholders = (artifact.manifest?.placeholders ?? []).map((p) => p.key);
49
50
  const renderedBody = renderTemplate(artifact.bodyText, { configMap, manifestPlaceholders });
50
- const checksum = sha256(renderedBody);
51
+ const bodyToWrite = artifact.kind === 'skill'
52
+ ? ensureSkillFrontmatter(renderedBody, artifact.manifest)
53
+ : renderedBody;
54
+ const checksum = sha256(bodyToWrite);
51
55
 
52
56
  const artifactDir = join(claudeDir, layout.subdir, artifact.id);
53
57
  mkdirSync(artifactDir, { recursive: true });
54
- writeFileSync(join(artifactDir, layout.bodyFilename), renderedBody, 'utf8');
58
+ writeFileSync(join(artifactDir, layout.bodyFilename), bodyToWrite, 'utf8');
55
59
 
56
60
  for (const file of artifact.files ?? []) {
57
61
  const destPath = join(artifactDir, file.relPath);
@@ -71,6 +75,7 @@ export async function installClaude({ consumerCwd, pkgRoot, config, artifacts, w
71
75
  patchSettings(claudeDir, {});
72
76
  installHooks(pkgRoot, claudeDir);
73
77
  installRules(pkgRoot, claudeDir, configMap);
78
+ installScripts(pkgRoot, claudeDir, config, warn);
74
79
 
75
80
  return lockEntries;
76
81
  }
@@ -180,6 +185,43 @@ function copyRulesRecursive(baseDir, currentDir, claudeDir, configMap) {
180
185
  }
181
186
  }
182
187
 
188
+ /**
189
+ * Copies CLI scripts that skill bodies invoke (e.g. `node .claude/scripts/notion/get-board.js`)
190
+ * into the consumer's `.claude/scripts/` tree. The whole `scripts/` subtree is
191
+ * mirrored recursively, preserving subdirectories. Each `notion/` script is
192
+ * gated on `stack.notion` — if the consumer is not running Notion, no scripts
193
+ * are copied at all.
194
+ *
195
+ * Codex / Windsurf adapters do not call this — those targets surface skills
196
+ * as inline text where shell-script delivery makes no sense.
197
+ */
198
+ function installScripts(pkgRoot, claudeDir, config, warn) {
199
+ const scriptsRoot = join(pkgRoot, 'scripts');
200
+ if (!existsSync(scriptsRoot)) return;
201
+
202
+ const notionDir = join(scriptsRoot, 'notion');
203
+ if (existsSync(notionDir)) {
204
+ if (!config.stack?.notion) return;
205
+ const dest = join(claudeDir, 'scripts', 'notion');
206
+ mkdirSync(dest, { recursive: true });
207
+ copyDirRecursive(notionDir, dest);
208
+ }
209
+ }
210
+
211
+ function copyDirRecursive(srcDir, destDir) {
212
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
213
+ if (entry.name.startsWith('.')) continue;
214
+ const srcPath = join(srcDir, entry.name);
215
+ const destPath = join(destDir, entry.name);
216
+ if (entry.isDirectory()) {
217
+ mkdirSync(destPath, { recursive: true });
218
+ copyDirRecursive(srcPath, destPath);
219
+ } else if (entry.isFile()) {
220
+ copyFileSync(srcPath, destPath);
221
+ }
222
+ }
223
+ }
224
+
183
225
  function patchSettings(claudeDir, pluginHooks) {
184
226
  const settingsPath = join(claudeDir, 'settings.json');
185
227
  let existing = {};
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { threeWayMerge } from '../../lib/three-way-merge.js';
4
+ import { ensureSkillFrontmatter } from '../../lib/skill-frontmatter.js';
4
5
 
5
6
  const KIND_LAYOUT = {
6
7
  skill: { subdir: 'skills', bodyFilename: 'SKILL.md' },
@@ -39,12 +40,20 @@ export async function updateClaude({
39
40
  mkdirSync(outDir, { recursive: true });
40
41
  const outPath = join(outDir, layout.bodyFilename);
41
42
 
43
+ // Defensive: callers in bin/update.js already prepend the synthesized
44
+ // skill frontmatter, but ensureSkillFrontmatter is a no-op when the
45
+ // block is already there. Keeping it here means a direct adapter call
46
+ // with a raw rendered body still produces a Claude-loadable file.
47
+ const upstream = artifact.kind === 'skill'
48
+ ? ensureSkillFrontmatter(rendered, artifact.manifest)
49
+ : rendered;
50
+
42
51
  if (!conflict) {
43
- writeFileSync(outPath, rendered, 'utf8');
52
+ writeFileSync(outPath, upstream, 'utf8');
44
53
  return;
45
54
  }
46
55
 
47
- const ours = installedEntry ? installedEntry.content : rendered;
48
- const { content } = threeWayMerge({ ours, theirs: rendered, oursLabel, theirsLabel });
56
+ const ours = installedEntry ? installedEntry.content : upstream;
57
+ const { content } = threeWayMerge({ ours, theirs: upstream, oursLabel, theirsLabel });
49
58
  writeFileSync(outPath, content, 'utf8');
50
59
  }
package/bin/update.js CHANGED
@@ -15,6 +15,7 @@ import { sha256 } from '../lib/checksum.js';
15
15
  import { classifyArtifact, ACTIONS } from '../lib/update-classifier.js';
16
16
  import { updateClaude } from '../adapters/claude/update.js';
17
17
  import { updateWindsurf } from '../adapters/windsurf/update.js';
18
+ import { ensureSkillFrontmatter } from '../lib/skill-frontmatter.js';
18
19
 
19
20
  const here = dirname(fileURLToPath(import.meta.url));
20
21
 
@@ -97,7 +98,13 @@ export async function runUpdate({
97
98
  const upstreamMap = new Map();
98
99
  for (const artifact of upstreamArtifacts) {
99
100
  const manifestPlaceholders = (artifact.manifest?.placeholders ?? []).map((p) => p.key);
100
- const rendered = renderTemplate(artifact.bodyText, { configMap, manifestPlaceholders });
101
+ const renderedBody = renderTemplate(artifact.bodyText, { configMap, manifestPlaceholders });
102
+ // Claude installs prepend a synthesized YAML frontmatter to every skill
103
+ // body (see adapters/claude/index.js). Mirror that here so the
104
+ // upstream checksum matches what is actually on disk after install/sync.
105
+ const rendered = target === 'claude' && artifact.kind === 'skill'
106
+ ? ensureSkillFrontmatter(renderedBody, artifact.manifest)
107
+ : renderedBody;
101
108
  const checksum = sha256(rendered);
102
109
  upstreamMap.set(`${artifact.kind}:${artifact.id}`, { artifact, rendered, checksum });
103
110
  }
@@ -21,7 +21,10 @@
21
21
  * Configuration. Env vars take precedence; otherwise resolved from the consumer's
22
22
  * spovishun-skills.config.yaml (so a plain `install` works without extra env setup):
23
23
  * NOTION_TOKEN or NOTION_SKILLS_TOKEN — Notion API token (required; from env/.env)
24
- * NOTION_BOARD_COLLECTION_ID ⟵ notion.database_id — task board database ID (required)
24
+ * NOTION_DATABASE_ID ⟵ notion.database_id — task board database ID (required)
25
+ * (NOTION_BOARD_COLLECTION_ID is accepted as a
26
+ * deprecated alias; it was a misnamed pre-1.2.2
27
+ * env var that actually held the database id.)
25
28
  * PROJECT_PREFIX ⟵ slug(project.name) — branch prefix → feature/<prefix>-N
26
29
  * GIT_DEVELOP_BRANCH ⟵ git.dev_branch — base branch name, default "develop"
27
30
  *
@@ -125,7 +128,12 @@ function slug(name) {
125
128
  }
126
129
 
127
130
  const NOTION_TOKEN = process.env.NOTION_SKILLS_TOKEN || process.env.NOTION_TOKEN;
128
- const DATABASE_ID = process.env.NOTION_BOARD_COLLECTION_ID || readConfigValue('notion', 'database_id');
131
+ // NOTION_BOARD_COLLECTION_ID is the deprecated 1.2.0/1.2.1 alias it was always
132
+ // a misnomer (the hook queries /v1/databases/{id}/query, not a data source).
133
+ // Keep accepting it so existing consumer .env files keep working.
134
+ const DATABASE_ID = process.env.NOTION_DATABASE_ID
135
+ || process.env.NOTION_BOARD_COLLECTION_ID
136
+ || readConfigValue('notion', 'database_id');
129
137
  const PROJECT_PREFIX = process.env.PROJECT_PREFIX || slug(readConfigValue('project', 'name')) || 'project';
130
138
  const DEVELOP_BRANCH = process.env.GIT_DEVELOP_BRANCH || readConfigValue('git', 'dev_branch') || 'develop';
131
139
  // Board v2 (Scrum) optional Stage select filter. Empty string = unset = no filter (Board v1).
@@ -627,7 +635,7 @@ async function main() {
627
635
  if (!hasTrigger) process.exit(0);
628
636
 
629
637
  if (!DATABASE_ID) {
630
- process.stderr.write('[notion-task-inject] NOTION_BOARD_COLLECTION_ID not set, skipping\n');
638
+ process.stderr.write('[notion-task-inject] NOTION_DATABASE_ID not set, skipping\n');
631
639
  process.exit(0);
632
640
  }
633
641
 
@@ -35,13 +35,14 @@ export function buildPlaceholderMap(config) {
35
35
 
36
36
  if (config.stack?.notion) {
37
37
  set('NOTION_TOKEN_ENV', config.notion?.token_env);
38
+ // database_id and data_source_id (a.k.a. "collection id") are DIFFERENT
39
+ // identifiers in Notion's data-sources model. We only expose database_id
40
+ // here. Skills that need a data_source_id must fetch it live from the DB
41
+ // (`<data-source url="collection://...">`) — never interpolate one from
42
+ // config, and never alias database_id under a `*_COLLECTION_ID` /
43
+ // `*_DATA_SOURCE_ID` name (a 1.2.0/1.2.1 bug; tracked in CHANGELOG 1.2.2).
38
44
  set('NOTION_DATABASE_ID', config.notion?.database_id);
39
- // NOTION_BOARD_COLLECTION_ID = data_source_id alias for the board database.
40
- // Notion exposes the same identifier under both names depending on API path.
41
- set('NOTION_BOARD_COLLECTION_ID', config.notion?.database_id);
42
45
  set('NOTION_EPICS_DATABASE_ID', config.notion?.epics_database_id);
43
- // NOTION_EPICS_DATA_SOURCE_ID alias for the epics database (see comment above).
44
- set('NOTION_EPICS_DATA_SOURCE_ID', config.notion?.epics_database_id);
45
46
  set('NOTION_ROOT_PAGE_ID', config.notion?.root_page_id);
46
47
  set('NOTION_DOCS_ROOT_ID', config.notion?.docs_root_id);
47
48
  set('NOTION_CLAUDE_MD_PAGE_ID', config.notion?.claude_md_page_id);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Helpers for synthesizing the YAML frontmatter block that Claude Code expects
3
+ * at the top of every `.claude/skills/<id>/SKILL.md` file.
4
+ *
5
+ * Claude Code parses `name` and `description` from the frontmatter to drive
6
+ * skill triggering. A SKILL.md without it falls back to the body's first H1,
7
+ * which silently degrades trigger quality. Most manifests carry their
8
+ * metadata only in `manifest.yaml`, so the Claude adapter must synthesize the
9
+ * block from the manifest at install time.
10
+ */
11
+
12
+ const FRONTMATTER_FENCE = '---';
13
+
14
+ /**
15
+ * Detects a leading YAML frontmatter block. Accepts both `\n` and `\r\n`
16
+ * line endings on the opening fence.
17
+ */
18
+ export function hasFrontmatter(text) {
19
+ if (typeof text !== 'string' || text.length < 4) return false;
20
+ return text.startsWith(`${FRONTMATTER_FENCE}\n`) || text.startsWith(`${FRONTMATTER_FENCE}\r\n`);
21
+ }
22
+
23
+ /**
24
+ * Builds a minimal but trigger-rich `description:` value for the synthesized
25
+ * frontmatter. Starts from `manifest.description` and appends a
26
+ * `Triggers: <terms>.` sentence built from `manifest.triggers.en` and
27
+ * `manifest.triggers.uk` so triggering matches the convention used by
28
+ * skills with hand-authored inline frontmatter (e.g. code-reviewer).
29
+ *
30
+ * Returns an empty string if neither piece is available — the caller decides
31
+ * whether that is an error worth surfacing.
32
+ */
33
+ export function composeSkillDescription(manifest) {
34
+ const base = typeof manifest?.description === 'string' ? manifest.description.trim() : '';
35
+ const triggers = collectTriggers(manifest);
36
+ if (triggers.length === 0) return base;
37
+ const suffix = `Triggers: ${triggers.join(', ')}.`;
38
+ if (!base) return suffix;
39
+ const needsPeriod = !/[.!?]$/.test(base);
40
+ return needsPeriod ? `${base}. ${suffix}` : `${base} ${suffix}`;
41
+ }
42
+
43
+ function collectTriggers(manifest) {
44
+ const en = Array.isArray(manifest?.triggers?.en) ? manifest.triggers.en : [];
45
+ const uk = Array.isArray(manifest?.triggers?.uk) ? manifest.triggers.uk : [];
46
+ const seen = new Set();
47
+ const out = [];
48
+ for (const t of [...en, ...uk]) {
49
+ if (typeof t !== 'string') continue;
50
+ const trimmed = t.trim();
51
+ if (!trimmed || seen.has(trimmed)) continue;
52
+ seen.add(trimmed);
53
+ out.push(trimmed);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Builds the `---\nname: ...\ndescription: ...\n---\n` block for the given
60
+ * manifest. `description` values are emitted as JSON-encoded strings to
61
+ * survive embedded quotes, colons, and trailing punctuation without
62
+ * hand-rolled escaping.
63
+ */
64
+ export function buildSkillFrontmatter(manifest) {
65
+ const name = manifest?.id ?? '';
66
+ const description = composeSkillDescription(manifest);
67
+ const lines = [FRONTMATTER_FENCE, `name: ${name}`];
68
+ if (description) {
69
+ lines.push(`description: ${JSON.stringify(description)}`);
70
+ }
71
+ lines.push(FRONTMATTER_FENCE, '');
72
+ return lines.join('\n');
73
+ }
74
+
75
+ /**
76
+ * Returns `renderedBody` unchanged if it already starts with a frontmatter
77
+ * fence (hand-authored inline frontmatter wins — we never double-wrap).
78
+ * Otherwise prepends a synthesized block built from `manifest`.
79
+ *
80
+ * Only call for `kind === 'skill'`. Agents intentionally ship richer
81
+ * Claude-specific frontmatter (tools, model, maxTurns) and templates carry
82
+ * no metadata Claude consumes.
83
+ */
84
+ export function ensureSkillFrontmatter(renderedBody, manifest) {
85
+ if (hasFrontmatter(renderedBody)) return renderedBody;
86
+ return buildSkillFrontmatter(manifest) + renderedBody;
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spovishun-skills",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Portable Claude Code skills, agents, hooks and rules — bootstrapped from the Spovishun project. Installs into Claude Code, Codex, Windsurf and Cursor via per-assistant adapters.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('./lib/notion-http');
5
+ const { loadToken } = require('./lib/load-token');
6
+ const constants = require('./lib/constants');
7
+
8
+ const VALID_STATUSES = ['Planned', 'Active', 'Completed'];
9
+
10
+ function readStdin() {
11
+ return new Promise((resolve, reject) => {
12
+ let data = '';
13
+ process.stdin.setEncoding('utf8');
14
+ process.stdin.on('data', chunk => { data += chunk; });
15
+ process.stdin.on('end', () => resolve(data));
16
+ process.stdin.on('error', reject);
17
+ });
18
+ }
19
+
20
+ async function main() {
21
+ const token = loadToken();
22
+ if (!token) {
23
+ process.stderr.write('Error: NOTION_TOKEN or NOTION_SKILLS_TOKEN is required\n');
24
+ process.exit(2);
25
+ }
26
+
27
+ if (!constants.EPICS_DATABASE_ID) {
28
+ process.stderr.write('Error: NOTION_EPICS_DATABASE_ID is not configured (set env var or notion.epics_database_id in spovishun-skills.config.yaml)\n');
29
+ process.exit(2);
30
+ }
31
+
32
+ const raw = await readStdin();
33
+ let input;
34
+ try {
35
+ input = JSON.parse(raw);
36
+ } catch {
37
+ process.stderr.write('Error: invalid JSON on stdin\n');
38
+ process.exit(1);
39
+ }
40
+
41
+ const { name, goal, status, relatedNotionTask, icon, content } = input;
42
+
43
+ if (!name || typeof name !== 'string' || !name.trim()) {
44
+ process.stderr.write('Error: "name" is required and must be a non-empty string\n');
45
+ process.exit(1);
46
+ }
47
+ if (goal !== undefined && typeof goal !== 'string') {
48
+ process.stderr.write('Error: "goal" must be a string\n');
49
+ process.exit(1);
50
+ }
51
+ const epicStatus = status ?? 'Planned';
52
+ if (!VALID_STATUSES.includes(epicStatus)) {
53
+ process.stderr.write(`Error: "status" must be one of: ${VALID_STATUSES.join(', ')}\n`);
54
+ process.exit(1);
55
+ }
56
+ if (relatedNotionTask !== undefined && relatedNotionTask !== null && typeof relatedNotionTask !== 'string') {
57
+ process.stderr.write('Error: "relatedNotionTask" must be a string URL\n');
58
+ process.exit(1);
59
+ }
60
+ if (content !== undefined && typeof content !== 'string') {
61
+ process.stderr.write('Error: "content" must be a string\n');
62
+ process.exit(1);
63
+ }
64
+
65
+ const properties = {
66
+ Name: { title: [{ type: 'text', text: { content: name.trim() } }] },
67
+ Status: { select: { name: epicStatus } },
68
+ };
69
+ if (goal && goal.trim()) {
70
+ properties.Goal = { rich_text: [{ type: 'text', text: { content: goal.trim() } }] };
71
+ }
72
+ if (relatedNotionTask && relatedNotionTask.trim()) {
73
+ properties['Related Notion task'] = { url: relatedNotionTask.trim() };
74
+ }
75
+
76
+ const body = {
77
+ parent: { database_id: constants.EPICS_DATABASE_ID },
78
+ properties,
79
+ children: content
80
+ ? [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content } }] } }]
81
+ : [],
82
+ };
83
+
84
+ if (icon && typeof icon === 'string') {
85
+ body.icon = { type: 'emoji', emoji: icon };
86
+ }
87
+
88
+ const result = await http.post(token, '/v1/pages', body);
89
+
90
+ if (result?.object === 'error') {
91
+ process.stderr.write(`Notion API error: ${result.message || result.code}\n`);
92
+ process.exit(1);
93
+ }
94
+
95
+ process.stdout.write(JSON.stringify({ id: result.id, url: result.url }) + '\n');
96
+ }
97
+
98
+ main().catch(err => {
99
+ process.stderr.write(`Error: ${err.message}\n`);
100
+ process.exit(1);
101
+ });
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('./lib/notion-http');
5
+ const { loadToken } = require('./lib/load-token');
6
+ const constants = require('./lib/constants');
7
+ const { toDashed } = require('./lib/page-id');
8
+
9
+ const VALID_PRIORITIES = ['High', 'Medium', 'Low'];
10
+ // Board v2 (Scrum) Stage. New tasks default to Backlog so they appear in the
11
+ // Backlog view (`Stage = Backlog`) and can be promoted into a Sprint. Pass
12
+ // `stage` on stdin to override. Boards without a Stage property (Board v1)
13
+ // should set `stage: null` to omit the Stage field entirely.
14
+ const VALID_STAGES = ['Backlog', 'Sprint', 'Archive'];
15
+ const DEFAULT_STAGE = 'Backlog';
16
+
17
+ function readStdin() {
18
+ return new Promise((resolve, reject) => {
19
+ let data = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', chunk => { data += chunk; });
22
+ process.stdin.on('end', () => resolve(data));
23
+ process.stdin.on('error', reject);
24
+ });
25
+ }
26
+
27
+ function normalizeRelationIds(value, fieldName) {
28
+ if (value === undefined || value === null) return [];
29
+ if (!Array.isArray(value)) {
30
+ process.stderr.write(`Error: "${fieldName}" must be an array of page IDs\n`);
31
+ process.exit(1);
32
+ }
33
+ return value.map(id => {
34
+ if (typeof id !== 'string' || !id.trim()) {
35
+ process.stderr.write(`Error: "${fieldName}" entries must be non-empty strings\n`);
36
+ process.exit(1);
37
+ }
38
+ return toDashed(id.trim());
39
+ });
40
+ }
41
+
42
+ async function main() {
43
+ const token = loadToken();
44
+ if (!token) {
45
+ process.stderr.write('Error: NOTION_TOKEN or NOTION_SKILLS_TOKEN is required\n');
46
+ process.exit(2);
47
+ }
48
+
49
+ if (!constants.DATABASE_ID) {
50
+ process.stderr.write('Error: NOTION_DATABASE_ID is not configured (set env var or notion.database_id in spovishun-skills.config.yaml)\n');
51
+ process.exit(2);
52
+ }
53
+
54
+ const raw = await readStdin();
55
+ let input;
56
+ try {
57
+ input = JSON.parse(raw);
58
+ } catch {
59
+ process.stderr.write('Error: invalid JSON on stdin\n');
60
+ process.exit(1);
61
+ }
62
+
63
+ const { title, priority, content, icon, epicId, blockedBy, stage } = input;
64
+
65
+ if (!title || typeof title !== 'string' || !title.trim()) {
66
+ process.stderr.write('Error: "title" is required and must be a non-empty string\n');
67
+ process.exit(1);
68
+ }
69
+ if (!VALID_PRIORITIES.includes(priority)) {
70
+ process.stderr.write(`Error: "priority" must be one of: ${VALID_PRIORITIES.join(', ')}\n`);
71
+ process.exit(1);
72
+ }
73
+ if (stage !== undefined && stage !== null && !VALID_STAGES.includes(stage)) {
74
+ process.stderr.write(`Error: "stage" must be one of: ${VALID_STAGES.join(', ')} (or null to omit)\n`);
75
+ process.exit(1);
76
+ }
77
+ if (content !== undefined && typeof content !== 'string') {
78
+ process.stderr.write('Error: "content" must be a string\n');
79
+ process.exit(1);
80
+ }
81
+ if (epicId !== undefined && epicId !== null && (typeof epicId !== 'string' || !epicId.trim())) {
82
+ process.stderr.write('Error: "epicId" must be a non-empty string when provided\n');
83
+ process.exit(1);
84
+ }
85
+
86
+ const blockedByIds = normalizeRelationIds(blockedBy, 'blockedBy');
87
+
88
+ const properties = {
89
+ Name: { title: [{ type: 'text', text: { content: title.trim() } }] },
90
+ Priority: { select: { name: priority } },
91
+ Status: { status: { name: 'To do' } },
92
+ };
93
+ // stage === null means "Board v1, no Stage column"; omit. Default = Backlog.
94
+ if (stage !== null) {
95
+ properties.Stage = { select: { name: stage ?? DEFAULT_STAGE } };
96
+ }
97
+ if (epicId) {
98
+ properties.Epic = { relation: [{ id: toDashed(epicId.trim()) }] };
99
+ }
100
+ if (blockedByIds.length > 0) {
101
+ properties['Blocked by'] = { relation: blockedByIds.map(id => ({ id })) };
102
+ }
103
+
104
+ const body = {
105
+ parent: { database_id: constants.DATABASE_ID },
106
+ properties,
107
+ children: content
108
+ ? [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content } }] } }]
109
+ : [],
110
+ };
111
+
112
+ if (icon && typeof icon === 'string') {
113
+ body.icon = { type: 'emoji', emoji: icon };
114
+ }
115
+
116
+ const result = await http.post(token, '/v1/pages', body);
117
+
118
+ if (result?.object === 'error') {
119
+ process.stderr.write(`Notion API error: ${result.message || result.code}\n`);
120
+ process.exit(1);
121
+ }
122
+
123
+ process.stdout.write(JSON.stringify({ id: result.id, url: result.url }) + '\n');
124
+ }
125
+
126
+ main().catch(err => {
127
+ process.stderr.write(`Error: ${err.message}\n`);
128
+ process.exit(1);
129
+ });