@groeponline/pi-wishcraft 1.3.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.0] - 2026-08-27
6
+
5
7
  ## [1.3.1] - 2026-08-25
6
8
 
7
9
  ### Fixed
package/ROADMAP.md CHANGED
@@ -1,146 +1,41 @@
1
1
  # Roadmap — pi-wishcraft
2
2
 
3
- Baseline: **v1.2.0**, published 2026-08-24.
4
-
5
- Pi is the engine. Wishcraft is the operator experience layer: Signal, Deck, skills, ideas, shell UX, hooks, repairs and policy without forking Pi core.
6
-
7
- Historical 0.x/1.0/vNext campaign detail belongs in `CHANGELOG.md` and `docs/design/`. This file describes only the current product contract and work that is still open.
8
-
9
- ## Product boundary
10
-
11
- Wishcraft may extend the Pi experience, but it does not become:
12
-
13
- - a Pi fork or second agent engine;
14
- - a fleet/orchestration control plane;
15
- - a SaaS integrations bundle;
16
- - a second plugin runtime competing with Pi extensions;
17
- - a telemetry collector by default.
18
-
19
- External systems should connect through small contribution/integration contracts. Vendor-specific control planes belong outside Wishcraft.
3
+ Baseline: **v1.3.1** (`e79737f`, tag `v1.3.1` 2026-08-25, npm `1.3.1`) — `origin/main` @ `0044a87` (vivid). Historical vNext PR0-8 detail is archived in `CHANGELOG.md` and `docs/design/vnext-overview.md`; this file is the current contract.
20
4
 
21
5
  ## Release line
22
6
 
23
- | Release | Theme | State | Done means |
24
- | --- | --- | --- | --- |
25
- | **1.2** | Operator Layer | shipped | Deck, universal Signal, structural appearance, motion gallery/composer, skill workbench and first-class motion accessibility |
26
- | **1.3** | Hardening | current | release invariant, render-path performance, one token source of truth, explicit renderer/lifecycle contracts, regression coverage |
27
- | **1.4** | Extension Contract | next | typed settings registry plus small contribution APIs; no new plugin runtime |
28
- | **1.5** | Craft Ecosystem | later | curated skill/package workflows built on Pi-native distribution and the contribution contract |
29
- | **2.0** | Stable Experience Platform | target | documented compatibility policy, migrations, performance budgets and stable public extension points |
30
-
31
- ## 1.3 — Hardening
32
-
33
- No feature dump. This release exists to make the v1.2 surface trustworthy.
34
-
35
- ### P0 — release integrity
36
-
37
- - One reusable `Verify` contract for PR verification and release gating.
38
- - Verify includes whitespace, typecheck, unit tests, circular dependency check and Pi package contract.
39
- - Release jobs depend on Verify and re-check the synchronized `origin/main` tree before tagging.
40
- - `main` should require the Verify check in repository rules before merge.
41
-
42
- ### P0 — render/runtime performance
43
-
44
- - Deck paint must not run filesystem-backed skill discovery, skill doctor or settings parsing.
45
- - Expensive Deck data is cached as static snapshot state and refreshed only on open, relevant navigation or mutation.
46
- - Live model/Git/context/queue/Signal state remains cheap to repaint.
47
- - Idle motion stays 0 FPS.
48
-
49
- ### P0 — appearance consistency
50
-
51
- - `src/config/types.ts` + `src/config/tokens.ts` are the canonical token contract.
52
- - `src/theme/tokens/*` is compatibility-only; it may re-export but may not define a second palette or semantic mapping.
53
- - One structural base must paint the same semantic roles regardless of the configuration path used to select it.
54
-
55
- ### P0 — Signal contract
56
-
57
- - Signal is the universal status renderer for legacy and structural presets.
58
- - Legacy presets keep their existing segment/layout/color contract unless a structural appearance layer is explicitly selected.
59
- - Terminal one-shots (`success`, `warning`, `error`) settle to `idle/ready` after their finite burst.
60
- - Reduced/off/screen-reader modes communicate state without requiring animation.
61
-
62
- ### P1 — verification matrix
63
-
64
- Add/keep regression coverage for:
65
-
66
- - widths around 40 / 80 / 120+ columns;
67
- - ASCII, Nerd Font and `NO_COLOR` rendering;
68
- - full / reduced / functional / off motion;
69
- - Deck render hot-path invariants;
70
- - legacy preset color compatibility;
71
- - release-gate dependency order;
72
- - Linux and macOS system-segment parsing.
73
-
74
- ## 1.4 — Extension Contract
75
-
76
- The next architecture step is **composability**, not more built-in routes.
77
-
78
- ### Typed settings registry
79
-
80
- One registry should drive:
81
-
82
- ```text
83
- settings definition
84
-
85
- ├─ parsing + validation
86
- ├─ defaults + migrations
87
- ├─ /wishcraft settings
88
- ├─ Pi settings contribution (where supported)
89
- └─ generated documentation/examples
90
- ```
91
-
92
- This removes duplicate knowledge about settings and enables a real zero-config first run.
7
+ | Release | Theme | State | Done means | Pin |
8
+ | --- | --- | --- | --- | --- |
9
+ | **1.2** | Operator Layer | shipped | Deck, Signal 3-lane, 10 bases, gallery/composer, workbench, a11y | tag `v1.2.0` `65e62fd` (784faaa merge) |
10
+ | **1.3** | Hardening | shipped | Verify contract, 0 FPS idle, token SSOT, renderer/lifecycle, regression | `f352b38`→`ecb2d5e` v1.3.0, `e79737f` v1.3.1, RC `v1.3.2-cea7e3e` pending |
11
+ | **1.4** | Extension Contract | **current** | 1.4a-d below; no new runtime | `0bfa5a2` registry on main, `62d6156` contrib on `feat/contribution-api`, `0044a87` vivid on main |
12
+ | **1.5** | Craft Ecosystem | next | skill install via Pi-native dist + recipes, built on 1.4 contract | starts after 1.4c Signal wiring green |
13
+ | **2.0** | Stable Platform | target | compat policy, migrations, perf budgets, stable contracts, provenance | after 1.4a-d + 1.3-P1 |
93
14
 
94
- ### Small contribution API
15
+ ## 1.4 Extension Contract (current, split)
95
16
 
96
- Target capabilities, subject to Pi host APIs:
17
+ **1.4a — Settings registry** — *shipped* `0bfa5a2` `feat(config): typed settings registry` on `origin/main`. `src/config/settings-registry.ts` (28 defs, `SETTING_DEFAULTS`, `get/validate` O(1) maps, `SETTING_GROUPS`). `src/extension/settings/wishcraft-config-items.ts` now renders from registry (124 lines removed). Tests `tests/settings-registry.test.ts` + `wishcraft-config.test.ts` green (557). Still TODO: `contributeSettings()` Pi-contribution + `defaults/migrations` + docgen from registry.
97
18
 
98
- ```text
99
- registerDeckRoute()
100
- registerSignalSource()
101
- registerMotion()
102
- registerAppearanceContribution()
103
- registerRecipeOrAction()
104
- contributeSettings()
105
- ```
19
+ **1.4b — Contribution API** — *pending* `62d6156` `feat(contract): typed contribution API` on `origin/feat/contribution-api` (not yet on `origin/main`). `src/extension/contrib/{types,registry}.ts` ships `registerDeckRoute`/`registerSignalSource` (validate `id /^[a-z0-9][a-z0-9-_]*$/`, dedup, `false` never throw). Deck wired via `src/extension/ui/deck/routes.ts` `getAllDeckRouteDefs()`/`isDeckRoute()`. Still TODO: `registerMotion()`, `registerAppearanceContribution()`, `registerRecipeOrAction()` as separate slices (one PR per contract).
106
20
 
107
- Rules:
21
+ **1.4c — Signal wiring** — *next* after 1.4b merge. `src/signal/render.ts` must call `getContributedSignalSources()` with per-source fault isolation (`try/catch` → `null`), versioned capability scope. Done when both Deck and Signal contributions render without taking down the bar (`tests/contrib-registry.test.ts` + `signal.test.ts` + fault test).
108
22
 
109
- - Pi remains the package/extension runtime.
110
- - Contributions are data/callback contracts, not arbitrary nested plugin loaders.
111
- - Public contracts are versioned and capability-scoped.
112
- - A failing contribution cannot take down Signal or the Deck.
23
+ **1.4d — Preset composer** — Decouple `PresetDef` `tokens/chrome/signal/motion/deck/welcome/glyphs` from `src/config/structural-presets.ts` to allow `contributedAppearance` overrides per preset with validation. Done when `tests/structural-presets.test.ts` + `tokens.test.ts` cover contributed palette/motion.
113
24
 
114
- ## 1.5 Craft Ecosystem
25
+ **Gate for 1.4 done:** `npm run typecheck && npm test && npx madge --circular src` green, `isDeckRoute` + Signal sources both isolated, registry `contributeSettings` tested, no new runtime.
115
26
 
116
- After the contribution/settings contracts are stable:
27
+ ## 1.3 Hardening (shipped, pending tags)
117
28
 
118
- - skill import/install workflows from explicit trusted sources;
119
- - curated recipes/actions that compose existing Wishcraft/Pi capabilities;
120
- - package discovery surfaced through Pi-native package distribution rather than a parallel Wishcraft marketplace;
121
- - export/import of appearance and motion recipes with validation.
29
+ P0 Verify: `whitespace + tsc --noEmit + npm test + madge --circular + scripts/verify-pi-package-contract.mjs`. Release depends on Verify and re-checks `origin/main` before tag. `main` requires `Verify` (repo rules). Promote flow is **official**: `release-candidate/v*` + `promote-release-candidate.yml` (`65143ae`) → tag → npm. P1 matrix still open: widths 40/80/120+, ASCII/Nerd/`NO_COLOR`, `full/reduced/functional/off`, Deck hot-path (no fs in paint), legacy colors, gate order, Linux/macOS segments. Pending RCs: `v1.3.2-cea7e3e` (`ff68fff`), `v1.4.0-0bfa5a2` (`529bc10`), `v1.5.0-0044a87` — collapse into single `1.4.0` promote after 1.4c.
122
30
 
123
- ## 2.0Stable Experience Platform
31
+ ## 1.5Craft Ecosystem (next)
124
32
 
125
- 2.0 is justified when the public contracts, not the feature count, are stable.
33
+ Starts after 1.4c wiring green: skill install from Pi-native dist (no parallel marketplace), curated recipes. One paragraph; detail deferred.
126
34
 
127
- Required before 2.0:
35
+ ## 2.0 — Stable Platform (target)
128
36
 
129
- - compatibility and deprecation policy;
130
- - settings migrations with round-trip tests;
131
- - measured render/performance budgets;
132
- - stable contribution API with fault isolation;
133
- - release provenance and required repository checks;
134
- - docs generated from canonical contracts where practical;
135
- - no known duplicate sources of truth for tokens, settings or release verification.
37
+ Needs: compat/deprecation policy, migrations with round-trip tests, perf budgets, stable contribution API with fault isolation, provenance via required `Verify`, docs from canonical contracts, no duplicate SSOTs.
136
38
 
137
- ## Always-on engineering rules
39
+ ## Always-on
138
40
 
139
- - English operator UI.
140
- - No synchronous discovery work in paint/render loops.
141
- - No background animation when nothing consumes it.
142
- - Core Pi tools are not silently rewritten by Wishcraft repairs.
143
- - Policies and hooks remain explicitly disableable.
144
- - Security-sensitive paths fail closed.
145
- - Package/release verification runs before npm publication.
146
- - New features must fit the operator-layer boundary above.
41
+ English UI, no sync discovery in paint, no background animation when idle (0 FPS, ambient 8 FPS max vivid), no silent Pi core rewrites, policies/hooks disableable, fail closed, Verify before npm, operator-layer boundary.
@@ -1,4 +1,6 @@
1
- # Wishcraft vNext — Stacked PR Release Plan
1
+ # Wishcraft vNext — Stacked PR Release Plan — ARCHIVED (all 8 landed)
2
+
3
+ > **Archived 2026-08-26:** All 8 PRs landed via `784faaa feat: merge Wishcraft vNext operator layer` → `65e62fd v1.2.0` (plus hardening `f352b38`/`ecb2d5e`/`e79737f` and vivid `0044a87`). Historical detail only; active plan is `ROADMAP.md` 1.4a-d. Do not use this as future todo.
2
4
 
3
5
  ## Goal
4
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -29,6 +29,7 @@ import type {
29
29
  } from "./types.ts";
30
30
  import { isStructuralPresetName } from "./structural-presets.ts";
31
31
  import { isMotionLevel, type MotionLevel } from "../motion/accessibility.ts";
32
+ import { SETTING_DEFAULTS } from "./settings-registry.ts";
32
33
 
33
34
  export interface PowerlineConfig {
34
35
  preset: StatusLinePreset;
@@ -65,7 +66,7 @@ export function parsePowerlineConfig(
65
66
  presets: readonly StatusLinePreset[],
66
67
  ): PowerlineConfig {
67
68
  const defaultConfig: PowerlineConfig = {
68
- preset: "default",
69
+ preset: SETTING_DEFAULTS["powerline.preset"],
69
70
  customItems: [],
70
71
  disabledSegments: [],
71
72
  invalidDisabledSegments: [],
@@ -73,9 +74,9 @@ export function parsePowerlineConfig(
73
74
  invalidLayoutSegments: [],
74
75
  separator: null,
75
76
  segmentOptions: {},
76
- placement: "above",
77
+ placement: SETTING_DEFAULTS["powerline.placement"],
77
78
  invalidPlacement: null,
78
- welcome: true,
79
+ welcome: SETTING_DEFAULTS["powerline.welcome"],
79
80
  stashSharpSShortcut: false,
80
81
  costAlert: null,
81
82
  customItemsAuto: false,
@@ -84,7 +85,7 @@ export function parsePowerlineConfig(
84
85
  presets: {},
85
86
  segmentLabels: {},
86
87
  appearance: {},
87
- motionLevel: "full",
88
+ motionLevel: SETTING_DEFAULTS["powerline.motionLevel"],
88
89
  };
89
90
 
90
91
  const directPreset = normalizePreset(value, presets);
@@ -138,7 +139,10 @@ export function parsePowerlineConfig(
138
139
  segmentOptions: normalizeSegmentOptions(value),
139
140
  placement,
140
141
  invalidPlacement,
141
- welcome: value.welcome !== false,
142
+ welcome:
143
+ typeof value.welcome === "boolean"
144
+ ? value.welcome
145
+ : SETTING_DEFAULTS["powerline.welcome"],
142
146
  stashSharpSShortcut: value.stashSharpSShortcut === true,
143
147
  costAlert: normalizeCostAlert(value.costAlert),
144
148
  customItemsAuto:
@@ -149,7 +153,9 @@ export function parsePowerlineConfig(
149
153
  presets: customPresetDefs,
150
154
  segmentLabels: normalizeSegmentLabels(value.segmentLabels),
151
155
  appearance: normalizeAppearance(value.appearance),
152
- motionLevel: isMotionLevel(value.motionLevel) ? value.motionLevel : "full",
156
+ motionLevel: isMotionLevel(value.motionLevel)
157
+ ? value.motionLevel
158
+ : SETTING_DEFAULTS["powerline.motionLevel"],
153
159
  };
154
160
  }
155
161
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Canonical metadata for settings exposed by Wishcraft.
3
+ *
4
+ * Keep defaults, edit kinds, choices, grouping and operator copy here. Runtime
5
+ * parsers may consume the same defaults without depending on the TUI layer.
6
+ */
7
+ import { MOTION_LEVELS } from "../motion/accessibility.ts";
8
+ import { STRUCTURAL_PRESET_NAMES } from "./types.ts";
9
+
10
+ export type SettingValue = boolean | string | number;
11
+ export type SettingKind = "toggle" | "select" | "text" | "number";
12
+ export type SettingGroupId = "status" | "vibes" | "skills" | "harness" | "shortcuts";
13
+
14
+ interface SettingDefinitionBase {
15
+ id: string;
16
+ path: string;
17
+ group: SettingGroupId;
18
+ label: string;
19
+ hint?: string;
20
+ description?: string;
21
+ restartRequired?: boolean;
22
+ }
23
+
24
+ export type SettingDefinition =
25
+ | (SettingDefinitionBase & { kind: "toggle"; defaultValue?: boolean })
26
+ | (SettingDefinitionBase & { kind: "select"; choices: readonly string[]; defaultValue?: string })
27
+ | (SettingDefinitionBase & { kind: "text"; defaultValue?: string })
28
+ | (SettingDefinitionBase & { kind: "number"; defaultValue?: number });
29
+
30
+ export interface SettingGroupDefinition {
31
+ id: SettingGroupId;
32
+ title: string;
33
+ }
34
+
35
+ export const SETTING_DEFAULTS = {
36
+ "powerline.preset": "default",
37
+ "powerline.placement": "above",
38
+ "powerline.welcome": true,
39
+ "powerline.motionLevel": "full",
40
+ "powerline.segmentOptions.tps.windowMs": 1000,
41
+ "wishcraft.readHints": true,
42
+ "wishcraft.hooksEnabled": true,
43
+ "wishcraft.repairsEnabled": true,
44
+ } as const satisfies Record<string, SettingValue>;
45
+
46
+ export const SETTING_GROUPS = [
47
+ { id: "status", title: "Status bar" },
48
+ { id: "vibes", title: "Welcome & vibes" },
49
+ { id: "skills", title: "Skills" },
50
+ { id: "harness", title: "Hooks & repairs (harness)" },
51
+ { id: "shortcuts", title: "Shortcuts" },
52
+ ] as const satisfies readonly SettingGroupDefinition[];
53
+
54
+ const SEPARATORS = [
55
+ "powerline", "powerline-thin", "slash", "pipe", "block", "none",
56
+ "ascii", "dot", "chevron", "star",
57
+ ] as const;
58
+ const STATUS_PRESETS = ["default", "minimal", "compact", "full", "nerd", "ascii", "chef"] as const;
59
+
60
+ export const SETTINGS_REGISTRY = [
61
+ { id: "status.preset", path: "powerline.preset", group: "status", label: "Preset", kind: "select", choices: STATUS_PRESETS, defaultValue: SETTING_DEFAULTS["powerline.preset"] },
62
+ { id: "appearance.base", path: "powerline.appearance.base", group: "status", label: "Structural base", kind: "select", choices: STRUCTURAL_PRESET_NAMES, hint: "colors and motion for Signal; layout preset stays separate" },
63
+ { id: "motion.level", path: "powerline.motionLevel", group: "status", label: "Motion level", kind: "select", choices: MOTION_LEVELS, defaultValue: SETTING_DEFAULTS["powerline.motionLevel"], hint: "full · reduced · functional · off" },
64
+ { id: "status.separator", path: "powerline.separator", group: "status", label: "Separator", kind: "select", choices: SEPARATORS },
65
+ { id: "status.placement", path: "powerline.placement", group: "status", label: "Placement", kind: "select", choices: ["above", "below"], defaultValue: SETTING_DEFAULTS["powerline.placement"] },
66
+ { id: "status.path.mode", path: "powerline.segmentOptions.path.mode", group: "status", label: "Path mode", kind: "select", choices: ["basename", "abbreviated", "full"] },
67
+ { id: "status.path.maxLength", path: "powerline.segmentOptions.path.maxLength", group: "status", label: "Path max length", kind: "number", hint: "0 = unlimited" },
68
+ { id: "status.time.format", path: "powerline.segmentOptions.time.format", group: "status", label: "Time format", kind: "select", choices: ["12h", "24h"] },
69
+ { id: "status.time.seconds", path: "powerline.segmentOptions.time.showSeconds", group: "status", label: "Time seconds", kind: "toggle" },
70
+ { id: "status.git.hostIcons", path: "powerline.segmentOptions.git.hostIcon", group: "status", label: "Git host icons", kind: "toggle" },
71
+ { id: "status.git.aheadBehind", path: "powerline.segmentOptions.git.showAheadBehind", group: "status", label: "Git ahead/behind", kind: "toggle" },
72
+ { id: "status.git.latestCommit", path: "powerline.segmentOptions.git.showCommit", group: "status", label: "Git latest commit", kind: "toggle" },
73
+ { id: "status.context.format", path: "powerline.segmentOptions.context.format", group: "status", label: "Context format", kind: "select", choices: ["full", "percent"] },
74
+ { id: "status.cacheRead.format", path: "powerline.segmentOptions.cache_read.format", group: "status", label: "Cache-read format", kind: "select", choices: ["tokens", "percent", "both"] },
75
+ { id: "status.cost.display", path: "powerline.segmentOptions.cost.subscriptionDisplay", group: "status", label: "Cost display", kind: "select", choices: ["subscription", "reported-cost", "both"] },
76
+ { id: "status.cost.currency", path: "powerline.segmentOptions.cost.currency", group: "status", label: "Currency", kind: "text" },
77
+ { id: "status.ports.udp", path: "powerline.segmentOptions.openPorts.includeUdp", group: "status", label: "Ports include UDP", kind: "toggle" },
78
+ { id: "status.tps.windowMs", path: "powerline.segmentOptions.tps.windowMs", group: "status", label: "TPS window (ms)", kind: "number", defaultValue: SETTING_DEFAULTS["powerline.segmentOptions.tps.windowMs"], hint: "default 1000" },
79
+ { id: "status.tps.mode", path: "powerline.segmentOptions.tps.mode", group: "status", label: "TPS mode", kind: "select", choices: ["both", "out", "in"] },
80
+ { id: "status.tps.label", path: "powerline.segmentLabels.tps", group: "status", label: "TPS label", kind: "text", hint: "empty = no label" },
81
+ { id: "welcome.enabled", path: "powerline.welcome", group: "vibes", label: "Welcome overlay", kind: "toggle", defaultValue: SETTING_DEFAULTS["powerline.welcome"], hint: "on = overlay at startup, off = no welcome" },
82
+ { id: "welcome.lanternMotion", path: "wishcraft.welcome.animateLantern", group: "vibes", label: "Animate wishcraft lantern", kind: "toggle", hint: "flicker on the lantern" },
83
+ { id: "skills.inline", path: "wishcraft.inlineSkills", group: "skills", label: "Inline expand /command and $skill", kind: "toggle", hint: "needs a restart to take effect", restartRequired: true },
84
+ { id: "skills.readHints", path: "wishcraft.readHints", group: "skills", label: "Read hints", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.readHints"], hint: "off = no continuation hint after partial reads" },
85
+ { id: "harness.hooks", path: "wishcraft.hooksEnabled", group: "harness", label: "Hooks enabled", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.hooksEnabled"], hint: "gate for configured preToolUse / postToolUse / sessionStart hooks" },
86
+ { id: "harness.repairs", path: "wishcraft.repairsEnabled", group: "harness", label: "Tool-input repairs", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.repairsEnabled"], hint: "null-for-optional, auto-link, json-array, path aliases" },
87
+ { id: "budget.dailyTokens", path: "wishcraft.tokenBudget.daily", group: "harness", label: "Daily token budget", kind: "number", hint: "colours the cost segment; never blocks. 0 = off" },
88
+ { id: "shortcut.menu", path: "powerlineShortcuts.menu", group: "shortcuts", label: "Menu", kind: "text", hint: "e.g. alt+p" },
89
+ { id: "shortcut.info", path: "powerlineShortcuts.info", group: "shortcuts", label: "Info", kind: "text" },
90
+ { id: "shortcut.stash", path: "powerlineShortcuts.stashHistory", group: "shortcuts", label: "Stash", kind: "text" },
91
+ { id: "shortcut.idea", path: "powerlineShortcuts.ideaCapture", group: "shortcuts", label: "Idea", kind: "text" },
92
+ { id: "shortcut.queue", path: "powerlineShortcuts.queueOpen", group: "shortcuts", label: "Queue", kind: "text" },
93
+ ] as const satisfies readonly SettingDefinition[];
94
+
95
+ const SETTINGS_BY_PATH = new Map<string, SettingDefinition>(SETTINGS_REGISTRY.map((item) => [item.path, item]));
96
+ const SETTINGS_BY_ID = new Map<string, SettingDefinition>(SETTINGS_REGISTRY.map((item) => [item.id, item]));
97
+
98
+ export function getSettingDefinition(pathOrId: string): SettingDefinition | undefined {
99
+ return SETTINGS_BY_PATH.get(pathOrId) ?? SETTINGS_BY_ID.get(pathOrId);
100
+ }
101
+
102
+ export function effectiveSettingValue(
103
+ definition: SettingDefinition,
104
+ storedValue: unknown,
105
+ ): SettingValue | null {
106
+ if (validateSettingValue(definition, storedValue)) return storedValue;
107
+ return definition.defaultValue ?? null;
108
+ }
109
+
110
+ export function validateSettingValue(
111
+ definition: SettingDefinition,
112
+ value: unknown,
113
+ ): value is SettingValue {
114
+ if (definition.kind === "toggle") return typeof value === "boolean";
115
+ if (definition.kind === "number") return typeof value === "number" && Number.isFinite(value);
116
+ if (definition.kind === "text") return typeof value === "string";
117
+ return typeof value === "string" && definition.choices.includes(value);
118
+ }
@@ -23,6 +23,7 @@ import { STRUCTURAL_PRESET_NAMES } from "./types.ts";
23
23
  export { STRUCTURAL_PRESET_NAMES };
24
24
  import type { MotionEvent } from "../motion/types.ts";
25
25
  import { defaultMotionFor } from "../motion/catalog.ts";
26
+ import { resolveAppearance } from "../extension/contrib/appearance.ts";
26
27
 
27
28
  function tokens(partial: Partial<WishcraftTokens>): Partial<WishcraftTokens> {
28
29
  return partial;
@@ -513,7 +514,11 @@ export function isStructuralPresetName(name: string): name is StructuralPresetNa
513
514
  }
514
515
 
515
516
  export function getStructuralPreset(name: StructuralPresetName): StructuralPresetDef {
516
- return STRUCTURAL_PRESETS[name];
517
+ return resolveAppearance(STRUCTURAL_PRESETS[name]);
518
+ }
519
+
520
+ export function getEffectivePreset(name: StructuralPresetName): StructuralPresetDef {
521
+ return getStructuralPreset(name);
517
522
  }
518
523
 
519
524
  /** Display name for a stored base, or the raw value when it is not structural. */
@@ -42,14 +42,15 @@ export function stripFrontmatter(content: string): string {
42
42
  export interface SkillFrontmatter {
43
43
  name: string | null;
44
44
  description: string | null;
45
+ trigger: string | null;
45
46
  }
46
47
 
47
48
  /**
48
- * Parse minimal YAML frontmatter (name/description) from a skill file.
49
+ * Parse minimal YAML frontmatter (name/description/trigger) from a skill file.
49
50
  * Returns nulls when the keys are absent or there is no frontmatter block.
50
51
  */
51
52
  export function parseSkillFrontmatter(content: string): SkillFrontmatter {
52
- const fm: SkillFrontmatter = { name: null, description: null };
53
+ const fm: SkillFrontmatter = { name: null, description: null, trigger: null };
53
54
  const lines = content.split("\n");
54
55
 
55
56
  if (lines[0]?.trim() !== "---") {
@@ -76,6 +77,8 @@ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
76
77
  fm.name = value || null;
77
78
  } else if (key === "description" && fm.description === null) {
78
79
  fm.description = value || null;
80
+ } else if (key === "trigger" && fm.trigger === null) {
81
+ fm.trigger = value ? value.replace(/^\//, "") : null;
79
82
  }
80
83
  }
81
84
 
@@ -0,0 +1,91 @@
1
+ import type { StructuralPresetDef, StructuralPresetName } from "../../config/types.ts";
2
+
3
+ // ponytail: appearance contributions are data patches, not loaders. One preset may have many patches; last wins per field.
4
+
5
+ type SignalPatch = Omit<Partial<StructuralPresetDef["signal"]>, "separators" | "caps"> & {
6
+ separators?: Partial<StructuralPresetDef["signal"]["separators"]>;
7
+ caps?: Partial<StructuralPresetDef["signal"]["caps"]>;
8
+ };
9
+
10
+ type ChromePatch = Omit<Partial<StructuralPresetDef["chrome"]>, "corners" | "dividers"> & {
11
+ corners?: Partial<StructuralPresetDef["chrome"]["corners"]>;
12
+ dividers?: Partial<StructuralPresetDef["chrome"]["dividers"]>;
13
+ };
14
+
15
+ export type AppearancePresetPatch = Omit<
16
+ Partial<StructuralPresetDef>,
17
+ "name" | "tokens" | "signal" | "chrome" | "glyphs"
18
+ > & {
19
+ tokens?: StructuralPresetDef["tokens"];
20
+ signal?: SignalPatch;
21
+ chrome?: ChromePatch;
22
+ glyphs?: Partial<StructuralPresetDef["glyphs"]>;
23
+ };
24
+
25
+ const patches = new Map<StructuralPresetName, AppearancePresetPatch[]>();
26
+
27
+ /** Register an appearance-only patch for a structural preset. Invalid input is rejected without throwing. */
28
+ export function registerAppearanceContribution(
29
+ preset: StructuralPresetName,
30
+ patch: AppearancePresetPatch,
31
+ ): boolean {
32
+ if (!preset || typeof patch !== "object" || patch === null) return false;
33
+ if (Array.isArray(patch) || "name" in patch) return false;
34
+ const list = patches.get(preset) ?? [];
35
+ list.push({ ...patch });
36
+ patches.set(preset, list);
37
+ return true;
38
+ }
39
+
40
+ /** Return registered patches for a preset in application order. */
41
+ export function getAppearancePatches(
42
+ preset: StructuralPresetName,
43
+ ): readonly AppearancePresetPatch[] {
44
+ return patches.get(preset) ?? [];
45
+ }
46
+
47
+ /** Clear all registered appearance contributions. Primarily used for isolation in tests and reloads. */
48
+ export function clearAppearanceContributions(): void {
49
+ patches.clear();
50
+ }
51
+
52
+ /** Resolve a structural preset with all contributed patches while preserving nested defaults and preset identity. */
53
+ export function resolveAppearance(base: StructuralPresetDef): StructuralPresetDef {
54
+ const list = patches.get(base.name as StructuralPresetName);
55
+ if (!list || list.length === 0) return base;
56
+ let out: StructuralPresetDef = { ...base };
57
+ for (const patch of list) {
58
+ out = {
59
+ ...out,
60
+ ...patch,
61
+ name: base.name,
62
+ tokens: patch.tokens ? { ...out.tokens, ...patch.tokens } : out.tokens,
63
+ signal: patch.signal
64
+ ? {
65
+ ...out.signal,
66
+ ...patch.signal,
67
+ separators: patch.signal.separators
68
+ ? { ...out.signal.separators, ...patch.signal.separators }
69
+ : out.signal.separators,
70
+ caps: patch.signal.caps
71
+ ? { ...out.signal.caps, ...patch.signal.caps }
72
+ : out.signal.caps,
73
+ }
74
+ : out.signal,
75
+ chrome: patch.chrome
76
+ ? {
77
+ ...out.chrome,
78
+ ...patch.chrome,
79
+ corners: patch.chrome.corners
80
+ ? { ...out.chrome.corners, ...patch.chrome.corners }
81
+ : out.chrome.corners,
82
+ dividers: patch.chrome.dividers
83
+ ? { ...out.chrome.dividers, ...patch.chrome.dividers }
84
+ : out.chrome.dividers,
85
+ }
86
+ : out.chrome,
87
+ glyphs: patch.glyphs ? { ...out.glyphs, ...patch.glyphs } : out.glyphs,
88
+ };
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,42 @@
1
+ import type { ContributedDeckRoute, ContributedSignalSource } from "./types.ts";
2
+
3
+ // ponytail: single in-memory registry, no new runtime — data/callback contracts only.
4
+ // A failing contribution never takes down Signal or Deck — callers must isolate.
5
+
6
+ const deckRoutes = new Map<string, ContributedDeckRoute>();
7
+ const signalSources = new Map<string, ContributedSignalSource>();
8
+
9
+ function isValidId(id: string): boolean {
10
+ return /^[a-z0-9][a-z0-9-_]*$/.test(id);
11
+ }
12
+
13
+ export function registerDeckRoute(route: ContributedDeckRoute): boolean {
14
+ if (!route || typeof route.id !== "string" || !isValidId(route.id)) return false;
15
+ if (typeof route.label !== "string" || !route.label.trim()) return false;
16
+ if (deckRoutes.has(route.id)) return false;
17
+ if (route.jumpKey != null && !/^[a-z0-9]$/.test(route.jumpKey)) return false;
18
+ deckRoutes.set(route.id, { ...route, label: route.label.trim() });
19
+ return true;
20
+ }
21
+
22
+ export function registerSignalSource(source: ContributedSignalSource): boolean {
23
+ if (!source || typeof source.id !== "string" || !isValidId(source.id)) return false;
24
+ if (typeof source.label !== "string" || !source.label.trim()) return false;
25
+ if (signalSources.has(source.id)) return false;
26
+ signalSources.set(source.id, { ...source });
27
+ return true;
28
+ }
29
+
30
+ export function getContributedDeckRoutes(): readonly ContributedDeckRoute[] {
31
+ return [...deckRoutes.values()];
32
+ }
33
+
34
+ export function getContributedSignalSources(): readonly ContributedSignalSource[] {
35
+ return [...signalSources.values()];
36
+ }
37
+
38
+ /** Test helper — clears the registry. */
39
+ export function clearContributions(): void {
40
+ deckRoutes.clear();
41
+ signalSources.clear();
42
+ }
@@ -0,0 +1,20 @@
1
+ export type DeckRouteId = string;
2
+
3
+ export interface ContributedDeckRoute {
4
+ id: DeckRouteId;
5
+ label: string;
6
+ description?: string;
7
+ jumpKey?: string;
8
+ }
9
+
10
+ export interface ContributedSignalSource {
11
+ id: string;
12
+ label: string;
13
+ /** Called on each Signal repaint; must not throw. */
14
+ render?: (ctx: unknown) => string | null;
15
+ }
16
+
17
+ export interface ContributedMotion {
18
+ id: string;
19
+ name: string;
20
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PowerlineConfig } from "../../config/powerline-config.ts";
2
2
  import type { StatusLinePreset } from "../../config/types.ts";
3
+ import { SETTING_DEFAULTS } from "../../config/settings-registry.ts";
3
4
  import { PRESETS } from "../../config/presets.ts";
4
5
  import { BashTranscriptStore } from "../../../bash-mode/transcript.ts";
5
6
  import { BashCompletionEngine } from "../../../bash-mode/completion.ts";
@@ -31,7 +32,7 @@ import type { RuntimeState } from "./types.ts";
31
32
  // ═══════════════════════════════════════════════════════════════════════════
32
33
 
33
34
  export let config: PowerlineConfig = {
34
- preset: "default",
35
+ preset: SETTING_DEFAULTS["powerline.preset"],
35
36
  customItems: [],
36
37
  disabledSegments: [],
37
38
  invalidDisabledSegments: [],
@@ -39,9 +40,9 @@ export let config: PowerlineConfig = {
39
40
  invalidLayoutSegments: [],
40
41
  separator: null,
41
42
  segmentOptions: {},
42
- placement: "above",
43
+ placement: SETTING_DEFAULTS["powerline.placement"],
43
44
  invalidPlacement: null,
44
- welcome: true,
45
+ welcome: SETTING_DEFAULTS["powerline.welcome"],
45
46
  stashSharpSShortcut: false,
46
47
  costAlert: null,
47
48
  customItemsAuto: false,
@@ -50,7 +51,7 @@ export let config: PowerlineConfig = {
50
51
  presets: {},
51
52
  segmentLabels: {},
52
53
  appearance: {},
53
- motionLevel: "full",
54
+ motionLevel: SETTING_DEFAULTS["powerline.motionLevel"],
54
55
  };
55
56
 
56
57
  export function setConfig(next: PowerlineConfig): void {
@@ -19,6 +19,8 @@
19
19
  * ---------------------------------------------------------------------------
20
20
  */
21
21
 
22
+ import { SETTING_DEFAULTS } from "../../config/settings-registry.ts";
23
+
22
24
  export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "turnEnd";
23
25
 
24
26
  export interface HookCommand {
@@ -81,7 +83,10 @@ export function parseHooksSettings(
81
83
  wishcraftSettings: unknown,
82
84
  ): { enabled: boolean; hooks: WishcraftHooksSettings } {
83
85
  if (!isRecord(wishcraftSettings)) return { enabled: false, hooks: {} };
84
- const enabled = wishcraftSettings.hooksEnabled !== false;
86
+ const enabled =
87
+ typeof wishcraftSettings.hooksEnabled === "boolean"
88
+ ? wishcraftSettings.hooksEnabled
89
+ : SETTING_DEFAULTS["wishcraft.hooksEnabled"];
85
90
  const raw = wishcraftSettings.hooks;
86
91
  if (!isRecord(raw)) return { enabled, hooks: {} };
87
92
  return {