@shipbench/core 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gmmurray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @shipbench/core
2
+
3
+ The headless library behind [ShipBench](https://github.com/gmmurray/shipbench) —
4
+ Git-native project management where the task board lives in the repository as
5
+ plain Markdown.
6
+
7
+ This package parses, validates, and writes that board. It has **no filesystem
8
+ access, no UI, and no network calls of its own**: every read and write goes
9
+ through a `StorageAdapter` you supply, so the same logic runs against a local
10
+ checkout, the GitHub API, or an in-memory fixture in a test.
11
+
12
+ Most people want the [`shipbench` CLI](https://www.npmjs.com/package/shipbench)
13
+ instead. Use this package when you are building a tool on top of the
14
+ convention.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @shipbench/core
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { FsAdapter, loadConfig, listTasks, createTask } from '@shipbench/core';
26
+
27
+ const adapter = new FsAdapter(process.cwd());
28
+ const config = await loadConfig(adapter);
29
+
30
+ const { tasks, warnings } = await listTasks(adapter, config);
31
+
32
+ await createTask(adapter, config, {
33
+ title: 'Build the API',
34
+ priority: 'high',
35
+ tags: ['backend'],
36
+ });
37
+ ```
38
+
39
+ ## Adapters
40
+
41
+ Two implementations ship with the package:
42
+
43
+ - **`FsAdapter`** — full read/write against the local filesystem.
44
+ - **`GitHubAdapter`** — reads `.shipbench/` through the GitHub Contents API.
45
+ Cross-runtime, so it works in Node, browsers, and Cloudflare Workers.
46
+
47
+ The interface is deliberately small so additional hosts stay easy to add:
48
+
49
+ ```ts
50
+ interface ReadableStorageAdapter {
51
+ readFile(path: string): Promise<string>;
52
+ readFileIfExists(path: string): Promise<string | null>;
53
+ listFiles(directory: string): Promise<string[]>;
54
+ readFiles(paths: string[]): Promise<Map<string, string>>;
55
+ }
56
+
57
+ interface StorageAdapter extends ReadableStorageAdapter {
58
+ writeFile(path: string, content: string): Promise<void>;
59
+ writeFiles(files: Map<string, string>): Promise<void>;
60
+ deleteFile(path: string): Promise<void>;
61
+ }
62
+ ```
63
+
64
+ Consumers that only display data should hold a `ReadableStorageAdapter` — it
65
+ turns an accidental write into a compile error.
66
+
67
+ ## What it gives you
68
+
69
+ - **Tasks** — `createTask`, `getTask`, `updateTask`, `moveTask`, `reorderTask`,
70
+ `deleteTask`, `listTasks`, `searchTasks`
71
+ - **Archiving** — `archiveTask`, `unarchiveTask`, `listArchivedTasks`
72
+ - **Dependencies** — `listAvailableTasks`, `listBlockedTasks`,
73
+ `buildTaskDependencyGraph`
74
+ - **Comments** — `addComment`, `editComment`, `deleteComment`
75
+ - **Config and setup** — `loadConfig`, `validateConfig`, `initProject`,
76
+ `DEFAULT_CONFIG`
77
+ - **Slugs** — `slugify`, `resolveSlugCollision`
78
+
79
+ ## Design rules worth knowing
80
+
81
+ **Strict on write, graceful on read.** Invalid statuses and priorities are
82
+ rejected on write. On read, a task with an unrecognized status comes back with a
83
+ validation warning rather than being dropped — the board never silently loses
84
+ work.
85
+
86
+ **Unknown frontmatter is preserved.** Core passes through fields it does not own,
87
+ with a warning. It never strips data it did not write.
88
+
89
+ **Timestamps are managed for you.** `created` is set once; `updated` moves on
90
+ every mutation. Both ISO 8601.
91
+
92
+ **Partial configs are fine.** `config.json` is deep-merged over `DEFAULT_CONFIG`
93
+ at read time, so any field can be omitted.
94
+
95
+ ## The `/layout` subpath
96
+
97
+ Manual task ordering is shared contract, not private logic — hosts running
98
+ optimistic updates must produce the same order core would write. It is exported
99
+ from a pure subpath that imports only types:
100
+
101
+ ```ts
102
+ import { orderedTasksForColumn, layoutAfterMove } from '@shipbench/core/layout';
103
+ ```
104
+
105
+ Import ordering helpers from `@shipbench/core/layout`, never the package root —
106
+ the root re-exports `FsAdapter`, which pulls in `node:fs` and will break a
107
+ browser bundle.
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,68 @@
1
+ // src/layout.ts
2
+ function byCreatedDesc(a, b) {
3
+ return Date.parse(b.frontmatter.created) - Date.parse(a.frontmatter.created);
4
+ }
5
+ function byUpdatedDesc(a, b) {
6
+ return Date.parse(b.frontmatter.updated) - Date.parse(a.frontmatter.updated);
7
+ }
8
+ function layoutWithoutTask(layout, slug, existingSlugs) {
9
+ const next = {};
10
+ for (const [columnId, slugs] of Object.entries(layout)) {
11
+ next[columnId] = slugs.filter(
12
+ (candidate) => candidate !== slug && (existingSlugs === void 0 || existingSlugs.has(candidate))
13
+ );
14
+ }
15
+ return next;
16
+ }
17
+ function layoutAfterMove({
18
+ layout,
19
+ tasks,
20
+ slug,
21
+ toStatus,
22
+ position,
23
+ doneColumn
24
+ }) {
25
+ const existingSlugs = new Set(tasks.map((task) => task.slug));
26
+ const next = layoutWithoutTask(layout, slug, existingSlugs);
27
+ delete next[doneColumn];
28
+ if (toStatus === doneColumn) return next;
29
+ const currentOrder = next[toStatus] ?? [];
30
+ const placed = new Set(currentOrder);
31
+ const leftovers = tasks.filter(
32
+ (task) => task.frontmatter.status === toStatus && task.slug !== slug && !placed.has(task.slug)
33
+ ).slice().sort(byCreatedDesc).map((task) => task.slug);
34
+ const destination = [...currentOrder, ...leftovers];
35
+ const insertAt = position < 0 || position > destination.length ? destination.length : position;
36
+ destination.splice(insertAt, 0, slug);
37
+ next[toStatus] = destination;
38
+ return next;
39
+ }
40
+ function orderedTasksForColumn(tasks, layout, columnId, validStatuses, doneColumn) {
41
+ if (!validStatuses.has(columnId)) {
42
+ return tasks.filter((task) => !validStatuses.has(task.frontmatter.status)).slice().sort(byCreatedDesc);
43
+ }
44
+ if (columnId === doneColumn) {
45
+ return tasks.filter((task) => task.frontmatter.status === columnId).slice().sort(byUpdatedDesc);
46
+ }
47
+ const inColumn = tasks.filter((task) => task.frontmatter.status === columnId);
48
+ const bySlug = new Map(inColumn.map((task) => [task.slug, task]));
49
+ const ordered = [];
50
+ const placed = /* @__PURE__ */ new Set();
51
+ for (const slug of layout?.[columnId] ?? []) {
52
+ const task = bySlug.get(slug);
53
+ if (task) {
54
+ ordered.push(task);
55
+ placed.add(slug);
56
+ }
57
+ }
58
+ const leftovers = inColumn.filter((task) => !placed.has(task.slug)).slice().sort(byCreatedDesc);
59
+ return [...ordered, ...leftovers];
60
+ }
61
+
62
+ export {
63
+ byCreatedDesc,
64
+ byUpdatedDesc,
65
+ layoutWithoutTask,
66
+ layoutAfterMove,
67
+ orderedTasksForColumn
68
+ };
@@ -0,0 +1,252 @@
1
+ import { S as StorageAdapter, R as ReadableStorageAdapter, T as Task, a as ShipbenchConfig, L as LoadConfigOptions, P as ProjectWarning, b as TaskFrontmatter, c as TaskReadResult, B as BoardLayout } from './layout-BS-84zwS.js';
2
+ export { d as BoardAPI, C as ColumnDef, e as ConfigLoadWarning, D as DoneDisplayConfig, f as PriorityConfig, g as TaskComment, h as TaskValidationWarning, i as byCreatedDesc, j as byUpdatedDesc, l as layoutAfterMove, k as layoutWithoutTask, o as orderedTasksForColumn } from './layout-BS-84zwS.js';
3
+
4
+ declare class FsAdapter implements StorageAdapter {
5
+ private rootDir;
6
+ constructor(rootDir: string);
7
+ private resolve;
8
+ readFile(path: string): Promise<string>;
9
+ readFileIfExists(path: string): Promise<string | null>;
10
+ writeFile(path: string, content: string): Promise<void>;
11
+ deleteFile(path: string): Promise<void>;
12
+ listFiles(directory: string): Promise<string[]>;
13
+ readFiles(paths: string[]): Promise<Map<string, string>>;
14
+ writeFiles(files: Map<string, string>): Promise<void>;
15
+ }
16
+
17
+ interface GitHubAdapterOptions {
18
+ owner: string;
19
+ repo: string;
20
+ token: string;
21
+ branch?: string;
22
+ /** Inject a custom fetch (useful for tests). Defaults to global fetch. */
23
+ fetch?: typeof fetch;
24
+ /** Override the commit author message prefix. */
25
+ commitMessagePrefix?: string;
26
+ }
27
+ declare class GitHubApiError extends Error {
28
+ readonly status: number;
29
+ readonly statusText: string;
30
+ readonly operation: string;
31
+ readonly path: string;
32
+ constructor(options: {
33
+ status: number;
34
+ statusText: string;
35
+ operation: string;
36
+ path: string;
37
+ body?: string;
38
+ });
39
+ }
40
+ /**
41
+ * GitHub Contents API adapter.
42
+ *
43
+ * Implements the read-only `StorageAdapter` surface (satisfies `BoardAPI`
44
+ * consumption in Harbor's remote mode). `writeFile` / `writeFiles` are
45
+ * concrete methods used only by Harbor's onboarding flows (seed commits,
46
+ * workspace init, config recovery) — never wired up to the interface
47
+ * because ongoing task CRUD never routes through GitHub. Delete is
48
+ * intentionally unimplemented: nothing in the current or planned
49
+ * architecture ever deletes via the GitHub API.
50
+ */
51
+ declare class GitHubAdapter implements ReadableStorageAdapter {
52
+ private owner;
53
+ private repo;
54
+ private token;
55
+ private branch;
56
+ private fetchImpl;
57
+ private commitMessagePrefix;
58
+ constructor(options: GitHubAdapterOptions);
59
+ private contentsUrl;
60
+ private contentsReadUrl;
61
+ private headers;
62
+ private errorFromResponse;
63
+ private readFileContent;
64
+ readFile(path: string): Promise<string>;
65
+ readFileIfExists(path: string): Promise<string | null>;
66
+ /** Returns the SHA of an existing file, or undefined if it does not exist. */
67
+ private getSha;
68
+ writeFile(path: string, content: string): Promise<void>;
69
+ listFiles(directory: string): Promise<string[]>;
70
+ readFiles(paths: string[]): Promise<Map<string, string>>;
71
+ writeFiles(files: Map<string, string>): Promise<void>;
72
+ }
73
+
74
+ interface TaskAvailabilityOptions {
75
+ /** Column whose tasks are candidates. Defaults to `config.default_column`. */
76
+ status?: string;
77
+ /** Already-read tasks from `.shipbench/tasks/archive/`. */
78
+ archivedTasks?: readonly Task[];
79
+ /** Archive file slugs, including files whose contents did not parse. */
80
+ archivedSlugs?: Iterable<string>;
81
+ }
82
+ /**
83
+ * Returns tasks in the actionable column whose dependencies are all complete.
84
+ * This function is pure and performs no adapter reads.
85
+ */
86
+ declare function listAvailableTasks(tasks: readonly Task[], config: ShipbenchConfig, options?: TaskAvailabilityOptions): Task[];
87
+ /**
88
+ * Returns tasks in the actionable column with at least one unsatisfied
89
+ * dependency. This function is pure and performs no adapter reads.
90
+ */
91
+ declare function listBlockedTasks(tasks: readonly Task[], config: ShipbenchConfig, options?: TaskAvailabilityOptions): Task[];
92
+
93
+ declare function loadConfig(adapter: ReadableStorageAdapter, options?: LoadConfigOptions): Promise<ShipbenchConfig>;
94
+ declare function validateConfig(config: ShipbenchConfig): string[];
95
+
96
+ declare const DEFAULT_CONFIG: ShipbenchConfig;
97
+
98
+ interface TaskDependencyGraphNode {
99
+ status: string;
100
+ depends_on: string[];
101
+ blocks: string[];
102
+ }
103
+ type TaskDependencyGraph = Record<string, TaskDependencyGraphNode>;
104
+ interface TaskDependencyGraphOptions {
105
+ /** Already-read tasks from `.shipbench/tasks/archive/`. */
106
+ archivedTasks?: readonly Task[];
107
+ /** Archive file slugs, including files whose contents did not parse. */
108
+ archivedSlugs?: Iterable<string>;
109
+ }
110
+ interface TaskDependencyIndex {
111
+ liveTasksBySlug: ReadonlyMap<string, Task>;
112
+ archivedTasksBySlug: ReadonlyMap<string, Task>;
113
+ /** Archive file identities, including files whose contents did not parse. */
114
+ archivedSlugs: ReadonlySet<string>;
115
+ }
116
+ type TaskDependencyResolution = {
117
+ kind: 'live';
118
+ status: string;
119
+ task: Task;
120
+ } | {
121
+ kind: 'archived';
122
+ status: 'archived';
123
+ task?: Task;
124
+ } | {
125
+ kind: 'missing';
126
+ status: 'missing';
127
+ };
128
+ declare function createTaskDependencyIndex(liveTasks: readonly Task[], archivedTasks?: readonly Task[], archivedSlugs?: Iterable<string>): TaskDependencyIndex;
129
+ declare function resolveTaskDependency(slug: string, index: TaskDependencyIndex): TaskDependencyResolution;
130
+ declare function dependencyStatus(slug: string, index: TaskDependencyIndex): string;
131
+ declare function taskDependenciesAreSatisfied(task: Task, index: TaskDependencyIndex, doneColumn: string): boolean;
132
+ /**
133
+ * Builds forward and reverse dependency adjacency for live tasks plus any
134
+ * supplied archived tasks. Referenced slugs outside that set are represented
135
+ * as missing nodes so callers never need a second lookup.
136
+ */
137
+ declare function buildTaskDependencyGraph(liveTasks: readonly Task[], options?: TaskDependencyGraphOptions): TaskDependencyGraph;
138
+
139
+ interface GithubRepositoryParts {
140
+ owner: string;
141
+ repo: string;
142
+ }
143
+ declare function parseGithubUrl(input: string): GithubRepositoryParts | null;
144
+ declare function parseGithubRemoteUrl(input: string): GithubRepositoryParts | null;
145
+ declare function normalizeGithubUrl(input: string): string | null;
146
+ declare function normalizeGithubRemoteUrl(input: string): string | null;
147
+
148
+ interface InitProjectOptions {
149
+ name: string;
150
+ }
151
+ interface MissingProjectInitializationState {
152
+ kind: 'missing';
153
+ }
154
+ interface InitializedProjectInitializationState {
155
+ kind: 'initialized';
156
+ config: ShipbenchConfig;
157
+ warnings: ProjectWarning[];
158
+ }
159
+ interface IncompleteProjectInitializationState {
160
+ kind: 'incomplete';
161
+ paths: string[];
162
+ }
163
+ interface MalformedProjectInitializationState {
164
+ kind: 'malformed';
165
+ errors: string[];
166
+ }
167
+ interface InvalidProjectInitializationState {
168
+ kind: 'invalid';
169
+ errors: string[];
170
+ }
171
+ type ProjectInitializationState = MissingProjectInitializationState | InitializedProjectInitializationState | IncompleteProjectInitializationState | MalformedProjectInitializationState | InvalidProjectInitializationState;
172
+ interface InitProjectResult {
173
+ created: boolean;
174
+ config: ShipbenchConfig;
175
+ warnings: ProjectWarning[];
176
+ }
177
+ type FailedProjectInitializationState = Exclude<ProjectInitializationState, MissingProjectInitializationState | InitializedProjectInitializationState>;
178
+ declare class ProjectInitializationError extends Error {
179
+ readonly state: FailedProjectInitializationState;
180
+ constructor(state: FailedProjectInitializationState);
181
+ }
182
+ declare function inspectProjectInitialization(adapter: ReadableStorageAdapter): Promise<ProjectInitializationState>;
183
+ declare function initProject(adapter: StorageAdapter, options: InitProjectOptions): Promise<InitProjectResult>;
184
+
185
+ type TaskSearchField = 'title' | 'tags' | 'body';
186
+ interface TaskSearchMatch {
187
+ slug: string;
188
+ title: string;
189
+ matched_fields: TaskSearchField[];
190
+ snippet?: string;
191
+ }
192
+ /**
193
+ * Splits the query on whitespace and finds tasks in which every
194
+ * case-insensitive term occurs as a substring of a title, tag, or Markdown
195
+ * body. Terms may occur in different fields. Input order is preserved so
196
+ * callers can sort before search and limit the returned matches afterward.
197
+ */
198
+ declare function searchTasks(tasks: readonly Task[], query: string): TaskSearchMatch[];
199
+
200
+ declare function slugify(title: string): string;
201
+ declare function resolveSlugCollision(slug: string, existingSlugs: Set<string>): string;
202
+
203
+ type Awaitable<T> = T | PromiseLike<T>;
204
+ interface GetTaskOptions {
205
+ archived?: boolean;
206
+ }
207
+ interface ListTasksOptions {
208
+ /** Already-read or concurrently-loading tasks from `tasks/archive/`. */
209
+ archivedTasks?: Awaitable<readonly Task[]>;
210
+ /** Archive file slugs, including files whose contents did not parse. */
211
+ archivedSlugs?: Awaitable<Iterable<string>>;
212
+ }
213
+ declare class ArchiveBlockedError extends Error {
214
+ readonly slug: string;
215
+ readonly dependentSlugs: string[];
216
+ constructor(slug: string, dependentSlugs: string[]);
217
+ }
218
+ declare function listTasks(adapter: ReadableStorageAdapter, config: ShipbenchConfig, options?: ListTasksOptions): Promise<TaskReadResult>;
219
+ declare function getTask(adapter: ReadableStorageAdapter, config: ShipbenchConfig, slug: string, options?: GetTaskOptions): Promise<Task | null>;
220
+ declare function listArchivedTasks(adapter: ReadableStorageAdapter, config: ShipbenchConfig): Promise<TaskReadResult>;
221
+ /**
222
+ * Returns every task-file slug represented by a read, including malformed files
223
+ * that produced a warning instead of a Task.
224
+ */
225
+ declare function taskFileSlugs(result: TaskReadResult): string[];
226
+ declare function createTask(adapter: StorageAdapter, config: ShipbenchConfig, title: string, fields?: Partial<TaskFrontmatter>): Promise<Task>;
227
+ declare function updateTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, fields: Partial<TaskFrontmatter>, body?: string): Promise<{
228
+ task: Task;
229
+ layout?: BoardLayout;
230
+ }>;
231
+ declare function addComment(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, text: string): Promise<Task>;
232
+ declare function editComment(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, index: number, text: string): Promise<Task>;
233
+ declare function deleteComment(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, index: number): Promise<Task>;
234
+ /**
235
+ * Move a task to a (possibly different) column and position. `position` is a
236
+ * 0-based index into the destination column's `layout` entry; `-1` appends.
237
+ * Writes the task file only when status changes; always writes layout.json
238
+ * with the updated order. Returns both the (possibly status-changed) task
239
+ * and the authoritative new layout so callers can reconcile.
240
+ */
241
+ declare function reorderTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, toStatus: string, position: number): Promise<{
242
+ task: Task;
243
+ layout: BoardLayout;
244
+ }>;
245
+ declare function moveTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, toStatus: string): Promise<Task>;
246
+ declare function deleteTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string): Promise<void>;
247
+ declare function archiveTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string, options?: {
248
+ force?: boolean;
249
+ }): Promise<Task>;
250
+ declare function unarchiveTask(adapter: StorageAdapter, config: ShipbenchConfig, slug: string): Promise<Task>;
251
+
252
+ export { ArchiveBlockedError, BoardLayout, DEFAULT_CONFIG, FsAdapter, type GetTaskOptions, GitHubAdapter, GitHubApiError, type GithubRepositoryParts, type InitProjectOptions, type InitProjectResult, type ListTasksOptions, LoadConfigOptions, ProjectInitializationError, type ProjectInitializationState, ProjectWarning, ReadableStorageAdapter, ShipbenchConfig, StorageAdapter, Task, type TaskAvailabilityOptions, type TaskDependencyGraph, type TaskDependencyGraphNode, type TaskDependencyGraphOptions, type TaskDependencyIndex, type TaskDependencyResolution, TaskFrontmatter, TaskReadResult, type TaskSearchField, type TaskSearchMatch, addComment, archiveTask, buildTaskDependencyGraph, createTask, createTaskDependencyIndex, deleteComment, deleteTask, dependencyStatus, editComment, getTask, initProject, inspectProjectInitialization, listArchivedTasks, listAvailableTasks, listBlockedTasks, listTasks, loadConfig, moveTask, normalizeGithubRemoteUrl, normalizeGithubUrl, parseGithubRemoteUrl, parseGithubUrl, reorderTask, resolveSlugCollision, resolveTaskDependency, searchTasks, slugify, taskDependenciesAreSatisfied, taskFileSlugs, unarchiveTask, updateTask, validateConfig };