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.
- package/CHANGELOG.md +150 -0
- package/adapters/claude/index.js +44 -2
- package/adapters/claude/update.js +12 -3
- package/bin/update.js +8 -1
- package/hooks/notion-task-inject.js +11 -3
- package/lib/placeholder-map.js +6 -5
- package/lib/skill-frontmatter.js +87 -0
- package/package.json +1 -1
- package/scripts/notion/create-epic.js +101 -0
- package/scripts/notion/create-task.js +129 -0
- package/scripts/notion/get-board.js +188 -0
- package/scripts/notion/get-claude-md.js +86 -0
- package/scripts/notion/get-task.js +125 -0
- package/scripts/notion/lib/cache.js +39 -0
- package/scripts/notion/lib/config-reader.js +79 -0
- package/scripts/notion/lib/constants.js +34 -0
- package/scripts/notion/lib/extract-branch.js +51 -0
- package/scripts/notion/lib/format-task.js +31 -0
- package/scripts/notion/lib/load-token.js +23 -0
- package/scripts/notion/lib/notion-http.js +40 -0
- package/scripts/notion/lib/page-id.js +15 -0
- package/scripts/notion/lib/project-prefix.js +42 -0
- package/scripts/notion/lib/query-tasks.js +33 -0
- package/scripts/notion/lib/resolve-relations.js +25 -0
- package/scripts/notion/lib/section-parser.js +40 -0
- package/scripts/notion/list-epics.js +98 -0
- package/scripts/notion/package.json +7 -0
- package/scripts/notion/update-status.js +48 -0
- package/skills/newepic/SKILL.md +5 -3
- package/skills/newepic/manifest.yaml +1 -3
- package/skills/newtask/SKILL.md +14 -6
- package/skills/newtask/manifest.yaml +1 -5
- package/skills/notion-content-reader/SKILL.md +3 -3
- package/skills/notion-content-reader/manifest.yaml +1 -1
- package/skills/notion-navigator/SKILL.md +4 -2
- package/skills/notion-navigator/manifest.yaml +1 -5
- package/skills/notion-spovishun-task-manager/SKILL.md +16 -9
- package/skills/notion-spovishun-task-manager/manifest.yaml +1 -3
- package/skills/notion-spovishun-task-manager/references/board-v2-stages.md +4 -2
- package/skills/notion-task-to-code/SKILL.md +4 -4
- package/skills/notion-task-to-code/manifest.yaml +1 -1
- package/skills/notion-workflow-spovishun/SKILL.md +8 -8
- package/skills/notion-workflow-spovishun/manifest.yaml +1 -1
- package/skills/task-decomposer/SKILL.md +11 -5
- 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,
|
package/adapters/claude/index.js
CHANGED
|
@@ -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
|
|
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),
|
|
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,
|
|
52
|
+
writeFileSync(outPath, upstream, 'utf8');
|
|
44
53
|
return;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
const ours = installedEntry ? installedEntry.content :
|
|
48
|
-
const { content } = threeWayMerge({ ours, theirs:
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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]
|
|
638
|
+
process.stderr.write('[notion-task-inject] NOTION_DATABASE_ID not set, skipping\n');
|
|
631
639
|
process.exit(0);
|
|
632
640
|
}
|
|
633
641
|
|
package/lib/placeholder-map.js
CHANGED
|
@@ -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.
|
|
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
|
+
});
|