htmlspec-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ type JsonValue = unknown;
2
+
3
+ export type JsonPatchOperation =
4
+ | { op: "add" | "replace" | "test"; path: string; value: JsonValue }
5
+ | { op: "remove"; path: string }
6
+ | { op: "move" | "copy"; from: string; path: string };
7
+
8
+ type PointerToken = string | number | "-";
9
+
10
+ export function applyJsonPatch<T>(document: T, operations: JsonPatchOperation[]): T {
11
+ let current: unknown = document;
12
+
13
+ for (const operation of operations) {
14
+ switch (operation.op) {
15
+ case "add":
16
+ current = setAtPointer(current, operation.path, cloneJson(operation.value), "add");
17
+ break;
18
+ case "replace":
19
+ current = setAtPointer(current, operation.path, cloneJson(operation.value), "replace");
20
+ break;
21
+ case "remove":
22
+ current = removeAtPointer(current, operation.path);
23
+ break;
24
+ case "copy":
25
+ current = setAtPointer(current, operation.path, cloneJson(getAtPointer(current, operation.from)), "add");
26
+ break;
27
+ case "move": {
28
+ const value = cloneJson(getAtPointer(current, operation.from));
29
+ current = removeAtPointer(current, operation.from);
30
+ current = setAtPointer(current, operation.path, value, "add");
31
+ break;
32
+ }
33
+ case "test":
34
+ if (JSON.stringify(getAtPointer(current, operation.path)) !== JSON.stringify(operation.value)) {
35
+ throw new Error(`JSON Patch test failed at ${operation.path}`);
36
+ }
37
+ break;
38
+ default:
39
+ throw new Error(`Unsupported JSON Patch op ${(operation as { op: string }).op}`);
40
+ }
41
+ }
42
+
43
+ return current as T;
44
+ }
45
+
46
+ export function assertJsonPatch(value: unknown): JsonPatchOperation[] {
47
+ if (!Array.isArray(value)) {
48
+ throw new Error("JSON Patch must be an array of operations");
49
+ }
50
+
51
+ for (const [index, operation] of value.entries()) {
52
+ if (!operation || typeof operation !== "object") {
53
+ throw new Error(`JSON Patch operation ${index} must be an object`);
54
+ }
55
+ const op = operation as Record<string, unknown>;
56
+ if (typeof op.op !== "string" || typeof op.path !== "string") {
57
+ throw new Error(`JSON Patch operation ${index} requires string op and path`);
58
+ }
59
+ if ((op.op === "add" || op.op === "replace" || op.op === "test") && !("value" in op)) {
60
+ throw new Error(`JSON Patch operation ${index} requires value`);
61
+ }
62
+ if ((op.op === "move" || op.op === "copy") && typeof op.from !== "string") {
63
+ throw new Error(`JSON Patch operation ${index} requires string from`);
64
+ }
65
+ if (!["add", "replace", "remove", "move", "copy", "test"].includes(op.op)) {
66
+ throw new Error(`JSON Patch operation ${index} has unsupported op ${op.op}`);
67
+ }
68
+ }
69
+
70
+ return value as JsonPatchOperation[];
71
+ }
72
+
73
+ function getAtPointer(document: unknown, pointer: string): unknown {
74
+ const tokens = parsePointer(pointer);
75
+ let current = document;
76
+ for (const token of tokens) {
77
+ if (Array.isArray(current)) {
78
+ if (token === "-") throw new Error(`Cannot read array append token in ${pointer}`);
79
+ current = current[numberToken(token)];
80
+ } else if (isRecord(current)) {
81
+ current = current[String(token)];
82
+ } else {
83
+ throw new Error(`Cannot resolve ${pointer}`);
84
+ }
85
+ }
86
+ return current;
87
+ }
88
+
89
+ function setAtPointer(document: unknown, pointer: string, value: unknown, mode: "add" | "replace"): unknown {
90
+ const tokens = parsePointer(pointer);
91
+ if (tokens.length === 0) return value;
92
+
93
+ const { parent, token } = parentFor(document, tokens, pointer);
94
+ if (Array.isArray(parent)) {
95
+ if (token === "-") {
96
+ if (mode === "replace") throw new Error(`Cannot replace array append token in ${pointer}`);
97
+ parent.push(value);
98
+ return document;
99
+ }
100
+ const index = numberToken(token);
101
+ if (mode === "replace" && index >= parent.length) {
102
+ throw new Error(`Cannot replace missing array index ${index} in ${pointer}`);
103
+ }
104
+ if (mode === "add") parent.splice(index, 0, value);
105
+ else parent[index] = value;
106
+ return document;
107
+ }
108
+
109
+ if (!isRecord(parent)) throw new Error(`Cannot set ${pointer}`);
110
+ if (mode === "replace" && !(String(token) in parent)) {
111
+ throw new Error(`Cannot replace missing key ${String(token)} in ${pointer}`);
112
+ }
113
+ parent[String(token)] = value;
114
+ return document;
115
+ }
116
+
117
+ function removeAtPointer(document: unknown, pointer: string): unknown {
118
+ const tokens = parsePointer(pointer);
119
+ if (tokens.length === 0) return undefined;
120
+
121
+ const { parent, token } = parentFor(document, tokens, pointer);
122
+ if (Array.isArray(parent)) {
123
+ if (token === "-") throw new Error(`Cannot remove array append token in ${pointer}`);
124
+ parent.splice(numberToken(token), 1);
125
+ return document;
126
+ }
127
+
128
+ if (!isRecord(parent) || !(String(token) in parent)) {
129
+ throw new Error(`Cannot remove missing key ${String(token)} in ${pointer}`);
130
+ }
131
+ delete parent[String(token)];
132
+ return document;
133
+ }
134
+
135
+ function parentFor(document: unknown, tokens: PointerToken[], pointer: string): { parent: unknown; token: PointerToken } {
136
+ let parent = document;
137
+ for (const token of tokens.slice(0, -1)) {
138
+ if (Array.isArray(parent)) {
139
+ if (token === "-") throw new Error(`Cannot traverse array append token in ${pointer}`);
140
+ parent = parent[numberToken(token)];
141
+ } else if (isRecord(parent)) {
142
+ parent = parent[String(token)];
143
+ } else {
144
+ throw new Error(`Cannot resolve parent for ${pointer}`);
145
+ }
146
+ }
147
+
148
+ return { parent, token: tokens[tokens.length - 1]! };
149
+ }
150
+
151
+ function parsePointer(pointer: string): PointerToken[] {
152
+ if (pointer === "") return [];
153
+ if (!pointer.startsWith("/")) throw new Error(`Invalid JSON Pointer ${pointer}`);
154
+ return pointer
155
+ .slice(1)
156
+ .split("/")
157
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
158
+ }
159
+
160
+ function numberToken(token: PointerToken): number {
161
+ if (typeof token === "number") return token;
162
+ if (token === "-") throw new Error("Expected array index, got append token");
163
+ const index = Number.parseInt(token, 10);
164
+ if (!Number.isInteger(index) || String(index) !== token || index < 0) {
165
+ throw new Error(`Invalid array index ${token}`);
166
+ }
167
+ return index;
168
+ }
169
+
170
+ function isRecord(value: unknown): value is Record<string, unknown> {
171
+ return typeof value === "object" && value !== null && !Array.isArray(value);
172
+ }
173
+
174
+ function cloneJson<T>(value: T): T {
175
+ if (value === undefined) return value;
176
+ return JSON.parse(JSON.stringify(value)) as T;
177
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, extname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { SPEC_ROOT, slugify } from "./spec-data";
6
+
7
+ export function projectPath(...parts: string[]): string {
8
+ return resolve(process.cwd(), ...parts);
9
+ }
10
+
11
+ export function specRoot(): string {
12
+ return projectPath(SPEC_ROOT);
13
+ }
14
+
15
+ export function changePath(id: string): string {
16
+ return projectPath(SPEC_ROOT, "changes", `${slugify(id)}.html`);
17
+ }
18
+
19
+ export function templateSourcePath(): string {
20
+ return fileURLToPath(new URL("../skills/htmlspec-kit/assets/change-template.html", import.meta.url));
21
+ }
22
+
23
+ export async function ensureSpecLayout(): Promise<void> {
24
+ await mkdir(projectPath(SPEC_ROOT, "changes"), { recursive: true });
25
+ await mkdir(projectPath(SPEC_ROOT, "specs"), { recursive: true });
26
+ await mkdir(projectPath(SPEC_ROOT, "archive"), { recursive: true });
27
+ await mkdir(projectPath(SPEC_ROOT, "templates"), { recursive: true });
28
+ }
29
+
30
+ export async function copyTemplate(force = false): Promise<string> {
31
+ await ensureSpecLayout();
32
+ const destination = projectPath(SPEC_ROOT, "templates", "change-template.html");
33
+ if (!force && existsSync(destination)) return destination;
34
+
35
+ await mkdir(dirname(destination), { recursive: true });
36
+ const template = await readFile(templateSourcePath(), "utf8");
37
+ await writeFile(destination, template, "utf8");
38
+ return destination;
39
+ }
40
+
41
+ export function resolveExistingSpecPath(idOrPath: string): string {
42
+ const direct = resolve(process.cwd(), idOrPath);
43
+ if (existsSync(direct)) return direct;
44
+
45
+ const normalized = extname(idOrPath) === ".html" ? idOrPath.slice(0, -5) : idOrPath;
46
+ const id = slugify(normalized);
47
+ const candidates = [
48
+ projectPath(SPEC_ROOT, "changes", `${id}.html`),
49
+ projectPath(SPEC_ROOT, "specs", `${id}.html`),
50
+ projectPath(SPEC_ROOT, "archive", `${id}.html`),
51
+ projectPath(SPEC_ROOT, `${id}.html`),
52
+ ];
53
+
54
+ const match = candidates.find((candidate) => existsSync(candidate));
55
+ if (match) return match;
56
+
57
+ throw new Error(`Spec ${idOrPath} not found under ${SPEC_ROOT}/changes, ${SPEC_ROOT}/specs, or ${SPEC_ROOT}/archive`);
58
+ }
59
+
60
+ export async function readInputContent(options: { from?: string; message?: string }): Promise<string> {
61
+ if (options.from) {
62
+ return readFile(resolve(process.cwd(), options.from), "utf8");
63
+ }
64
+
65
+ if (options.message) return options.message;
66
+
67
+ if (!process.stdin.isTTY) {
68
+ return new Response(Bun.stdin.stream()).text();
69
+ }
70
+
71
+ throw new Error("Provide content with --from <file>, --message <html>, or stdin");
72
+ }
73
+
74
+ export function titleizePath(filePath: string): string {
75
+ return filePath.startsWith(process.cwd()) ? filePath.slice(process.cwd().length + 1) : filePath;
76
+ }
@@ -0,0 +1,265 @@
1
+ import type {
2
+ ChangeStatus,
3
+ Design,
4
+ HtmlSpecData,
5
+ SpecSection,
6
+ TaskGroup,
7
+ TaskItem,
8
+ TaskStatus,
9
+ } from "./types";
10
+
11
+ export const SPEC_ROOT = "spec";
12
+ export const SPEC_SCRIPT_ID = "SPEC";
13
+ export const FORMAT_VERSION = "0.1.0";
14
+
15
+ export function nowIso(): string {
16
+ return new Date().toISOString();
17
+ }
18
+
19
+ export function slugify(value: string): string {
20
+ return value
21
+ .trim()
22
+ .toLowerCase()
23
+ .replace(/['"]/g, "")
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "")
26
+ .slice(0, 80);
27
+ }
28
+
29
+ export function titleFromId(id: string): string {
30
+ return id
31
+ .split("-")
32
+ .filter(Boolean)
33
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
34
+ .join(" ");
35
+ }
36
+
37
+ export function createHtmlSpecData(id: string, title = titleFromId(id)): HtmlSpecData {
38
+ const at = nowIso();
39
+
40
+ return {
41
+ format: "htmlspec",
42
+ version: FORMAT_VERSION,
43
+ id,
44
+ title,
45
+ status: "draft",
46
+ owner: "agent",
47
+ createdAt: at,
48
+ updatedAt: at,
49
+ summaryHtml: "<p>One-paragraph summary of the change and why it matters.</p>",
50
+ proposal: {
51
+ whyHtml: "<p>Explain the motivation for this change. What problem does this solve? Why now?</p>",
52
+ whatChanges: [
53
+ "Describe new capabilities, modifications, or removals. Mark breaking changes with BREAKING.",
54
+ ],
55
+ capabilities: {
56
+ new: [
57
+ {
58
+ name: "capability-name",
59
+ description: "Brief description of the capability this change introduces.",
60
+ },
61
+ ],
62
+ modified: [],
63
+ },
64
+ impact: ["Affected code, APIs, dependencies, or systems."],
65
+ },
66
+ spec: createDefaultSpec(),
67
+ design: createDefaultDesign(),
68
+ tasks: createDefaultTasks(),
69
+ history: [{ at, action: "created" }],
70
+ };
71
+ }
72
+
73
+ export function createDefaultSpec(): SpecSection {
74
+ return {
75
+ overviewHtml: "<p>Create one requirements delta per capability listed in proposal.capabilities. Use SHALL/MUST and testable scenarios.</p>",
76
+ operations: [
77
+ {
78
+ type: "ADDED",
79
+ requirements: [
80
+ {
81
+ id: "REQ-001",
82
+ title: "Primary behavior",
83
+ bodyHtml: "<p>The system SHALL provide the user-visible behavior described by this change.</p>",
84
+ scenarios: [
85
+ {
86
+ id: "SCN-001",
87
+ title: "Happy path",
88
+ given: "the user is in the expected starting state",
89
+ when: "the user performs the new action",
90
+ then: "the system produces the expected result",
91
+ },
92
+ ],
93
+ }
94
+ ],
95
+ },
96
+ ],
97
+ };
98
+ }
99
+
100
+ export function createDefaultDesign(): Design {
101
+ return {
102
+ contextHtml: "<p>Describe background, current state, constraints, and stakeholders.</p>",
103
+ goals: ["State what this design achieves."],
104
+ nonGoals: ["State what is explicitly out of scope."],
105
+ decisions: [
106
+ {
107
+ id: "architecture",
108
+ title: "Architecture",
109
+ decisionHtml: "<p>Describe the key modules, data flow, and boundaries.</p>",
110
+ rationaleHtml: "<p>Explain why this approach is preferred.</p>",
111
+ alternatives: ["List meaningful alternatives considered."],
112
+ },
113
+ ],
114
+ risksTradeoffs: ["Risk or trade-off -> mitigation."],
115
+ migrationPlan: ["Deployment, rollback, or migration step if applicable."],
116
+ openQuestions: ["List unresolved decisions before implementation starts."],
117
+ };
118
+ }
119
+
120
+ export function createDefaultTasks(): TaskGroup[] {
121
+ return [
122
+ {
123
+ id: "1",
124
+ title: "Setup",
125
+ items: [
126
+ {
127
+ id: "1.1",
128
+ title: "Create or update module structure",
129
+ status: "todo",
130
+ },
131
+ {
132
+ id: "1.2",
133
+ title: "Add dependencies or configuration",
134
+ status: "todo",
135
+ },
136
+ ],
137
+ },
138
+ {
139
+ id: "2",
140
+ title: "Implementation",
141
+ items: [
142
+ {
143
+ id: "2.1",
144
+ title: "Implement behavior defined by the spec operations",
145
+ status: "todo",
146
+ },
147
+ {
148
+ id: "2.2",
149
+ title: "Add tests for each acceptance scenario",
150
+ status: "todo",
151
+ },
152
+ ],
153
+ },
154
+ ];
155
+ }
156
+
157
+ export function touch(data: HtmlSpecData, action: string): HtmlSpecData {
158
+ data.updatedAt = nowIso();
159
+ data.history.push({ at: data.updatedAt, action });
160
+ return data;
161
+ }
162
+
163
+ export function setChangeStatus(data: HtmlSpecData, status: ChangeStatus): HtmlSpecData {
164
+ data.status = status;
165
+ return touch(data, `status:${status}`);
166
+ }
167
+
168
+ export function findTask(data: HtmlSpecData, taskId: string): { group: TaskGroup; task: TaskItem } {
169
+ for (const group of data.tasks) {
170
+ const task = group.items.find((item) => item.id === taskId);
171
+ if (task) return { group, task };
172
+ }
173
+ throw new Error(`Task ${taskId} not found in ${data.id}`);
174
+ }
175
+
176
+ export function setTaskStatus(data: HtmlSpecData, taskId: string, status: TaskStatus): HtmlSpecData {
177
+ const { task } = findTask(data, taskId);
178
+ task.status = status;
179
+ return touch(data, `task:${taskId}:${status}`);
180
+ }
181
+
182
+ export function addTask(
183
+ data: HtmlSpecData,
184
+ title: string,
185
+ options: { group?: string; taskId?: string; status?: TaskStatus } = {},
186
+ ): HtmlSpecData {
187
+ const group = getOrCreateTaskGroup(data, options.group ?? "Implementation");
188
+ const id = options.taskId ?? nextTaskId(group);
189
+
190
+ if (group.items.some((item) => item.id === id)) {
191
+ throw new Error(`Task ${id} already exists in group ${group.id}`);
192
+ }
193
+
194
+ group.items.push({
195
+ id,
196
+ title,
197
+ status: options.status ?? "todo",
198
+ });
199
+
200
+ return touch(data, `task:${id}:added`);
201
+ }
202
+
203
+ function getOrCreateTaskGroup(data: HtmlSpecData, groupSelector: string): TaskGroup {
204
+ const existing = data.tasks.find(
205
+ (group) => group.id === groupSelector || group.title.toLowerCase() === groupSelector.toLowerCase(),
206
+ );
207
+ if (existing) return existing;
208
+
209
+ const numericIds = data.tasks
210
+ .map((group) => Number.parseInt(group.id, 10))
211
+ .filter((id) => Number.isInteger(id));
212
+ const nextId = String((numericIds.length ? Math.max(...numericIds) : 0) + 1);
213
+ const group = { id: nextId, title: groupSelector, items: [] };
214
+ data.tasks.push(group);
215
+ return group;
216
+ }
217
+
218
+ function nextTaskId(group: TaskGroup): string {
219
+ const numericIds = group.items
220
+ .map((item) => {
221
+ const suffix = item.id.split(".").at(-1);
222
+ return suffix ? Number.parseInt(suffix, 10) : Number.NaN;
223
+ })
224
+ .filter((id) => Number.isInteger(id));
225
+ const next = (numericIds.length ? Math.max(...numericIds) : 0) + 1;
226
+ return `${group.id}.${next}`;
227
+ }
228
+
229
+ export function validateSpecData(data: HtmlSpecData): string[] {
230
+ const issues: string[] = [];
231
+
232
+ if (data.format !== "htmlspec") issues.push("format must be htmlspec");
233
+ if (!data.id) issues.push("id is required");
234
+ if (!data.title) issues.push("title is required");
235
+ if (!data.spec || !Array.isArray(data.spec.operations)) issues.push("spec.operations must be an array");
236
+ if (!data.design || !Array.isArray(data.design.decisions)) issues.push("design.decisions must be an array");
237
+ if (!Array.isArray(data.tasks)) issues.push("tasks must be an array");
238
+
239
+ for (const operation of data.spec?.operations ?? []) {
240
+ if (!["ADDED", "MODIFIED", "REMOVED", "RENAMED"].includes(operation.type)) {
241
+ issues.push(`invalid spec operation ${operation.type}`);
242
+ }
243
+ for (const requirement of operation.requirements ?? []) {
244
+ if (!requirement.id || !requirement.title) issues.push("each requirement needs id and title");
245
+ if (!Array.isArray(requirement.scenarios) || requirement.scenarios.length === 0) {
246
+ issues.push(`requirement ${requirement.id} must have at least one scenario`);
247
+ }
248
+ }
249
+ }
250
+
251
+ const taskIds = new Set<string>();
252
+ for (const group of data.tasks ?? []) {
253
+ if (!group.id || !group.title) issues.push("each task group needs id and title");
254
+ for (const task of group.items ?? []) {
255
+ if (!task.id || !task.title) issues.push("each task needs id and title");
256
+ if (taskIds.has(task.id)) issues.push(`duplicate task id ${task.id}`);
257
+ taskIds.add(task.id);
258
+ if (!["todo", "in-progress", "done"].includes(task.status)) {
259
+ issues.push(`task ${task.id} has invalid status ${task.status}`);
260
+ }
261
+ }
262
+ }
263
+
264
+ return issues;
265
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
1
+ export type TaskStatus = "todo" | "in-progress" | "done";
2
+ export type ChangeStatus = "draft" | "ready" | "in-progress" | "done" | "archived";
3
+ export type DeltaOperation = "ADDED" | "MODIFIED" | "REMOVED" | "RENAMED";
4
+
5
+ export interface Capability {
6
+ name: string;
7
+ description: string;
8
+ }
9
+
10
+ export interface Scenario {
11
+ id: string;
12
+ title: string;
13
+ given?: string;
14
+ when: string;
15
+ then: string;
16
+ }
17
+
18
+ export interface Requirement {
19
+ id: string;
20
+ title: string;
21
+ bodyHtml: string;
22
+ scenarios: Scenario[];
23
+ reasonHtml?: string;
24
+ migrationHtml?: string;
25
+ from?: string;
26
+ to?: string;
27
+ }
28
+
29
+ export interface RequirementDelta {
30
+ type: DeltaOperation;
31
+ requirements: Requirement[];
32
+ }
33
+
34
+ export interface SpecSection {
35
+ overviewHtml: string;
36
+ operations: RequirementDelta[];
37
+ }
38
+
39
+ export interface DesignDecision {
40
+ id: string;
41
+ title: string;
42
+ decisionHtml: string;
43
+ rationaleHtml: string;
44
+ alternatives: string[];
45
+ }
46
+
47
+ export interface DesignSection {
48
+ id: string;
49
+ title: string;
50
+ bodyHtml: string;
51
+ }
52
+
53
+ export interface Design {
54
+ contextHtml: string;
55
+ goals: string[];
56
+ nonGoals: string[];
57
+ decisions: DesignDecision[];
58
+ risksTradeoffs: string[];
59
+ migrationPlan: string[];
60
+ openQuestions: string[];
61
+ }
62
+
63
+ export interface Proposal {
64
+ whyHtml: string;
65
+ whatChanges: string[];
66
+ capabilities: {
67
+ new: Capability[];
68
+ modified: Capability[];
69
+ };
70
+ impact: string[];
71
+ }
72
+
73
+ export interface TaskItem {
74
+ id: string;
75
+ title: string;
76
+ status: TaskStatus;
77
+ notes?: string;
78
+ }
79
+
80
+ export interface TaskGroup {
81
+ id: string;
82
+ title: string;
83
+ items: TaskItem[];
84
+ }
85
+
86
+ export interface HistoryEntry {
87
+ at: string;
88
+ action: string;
89
+ }
90
+
91
+ export interface HtmlSpecData {
92
+ format: "htmlspec";
93
+ version: string;
94
+ id: string;
95
+ title: string;
96
+ status: ChangeStatus;
97
+ owner: string;
98
+ createdAt: string;
99
+ updatedAt: string;
100
+ summaryHtml: string;
101
+ proposal: Proposal;
102
+ spec: SpecSection;
103
+ design: Design;
104
+ tasks: TaskGroup[];
105
+ history: HistoryEntry[];
106
+ }