@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/dist/index.js ADDED
@@ -0,0 +1,1763 @@
1
+ import {
2
+ byCreatedDesc,
3
+ byUpdatedDesc,
4
+ layoutAfterMove,
5
+ layoutWithoutTask,
6
+ orderedTasksForColumn
7
+ } from "./chunk-7T2E4KSJ.js";
8
+
9
+ // src/adapters/fs.ts
10
+ import { mkdir, readdir, readFile, unlink, writeFile } from "fs/promises";
11
+ import { dirname, join } from "path";
12
+ var FsAdapter = class {
13
+ constructor(rootDir) {
14
+ this.rootDir = rootDir;
15
+ }
16
+ rootDir;
17
+ resolve(path) {
18
+ return join(this.rootDir, path);
19
+ }
20
+ async readFile(path) {
21
+ return readFile(this.resolve(path), "utf-8");
22
+ }
23
+ async readFileIfExists(path) {
24
+ try {
25
+ return await this.readFile(path);
26
+ } catch (error) {
27
+ if (error.code === "ENOENT") return null;
28
+ throw error;
29
+ }
30
+ }
31
+ async writeFile(path, content) {
32
+ const fullPath = this.resolve(path);
33
+ await mkdir(dirname(fullPath), { recursive: true });
34
+ await writeFile(fullPath, content, "utf-8");
35
+ }
36
+ async deleteFile(path) {
37
+ await unlink(this.resolve(path));
38
+ }
39
+ async listFiles(directory) {
40
+ try {
41
+ return await readdir(this.resolve(directory));
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+ async readFiles(paths) {
47
+ const results = /* @__PURE__ */ new Map();
48
+ for (const path of paths) {
49
+ const content = await this.readFile(path);
50
+ results.set(path, content);
51
+ }
52
+ return results;
53
+ }
54
+ async writeFiles(files) {
55
+ for (const [path, content] of files) {
56
+ await this.writeFile(path, content);
57
+ }
58
+ }
59
+ };
60
+
61
+ // src/adapters/github.ts
62
+ var GitHubApiError = class extends Error {
63
+ status;
64
+ statusText;
65
+ operation;
66
+ path;
67
+ constructor(options) {
68
+ const { status, statusText, operation, path, body } = options;
69
+ super(
70
+ `GitHubAdapter.${operation}: ${status} ${statusText} for "${path}"${body ? ` \u2014 ${body}` : ""}`
71
+ );
72
+ this.name = "GitHubApiError";
73
+ this.status = status;
74
+ this.statusText = statusText;
75
+ this.operation = operation;
76
+ this.path = path;
77
+ }
78
+ };
79
+ var API_BASE = "https://api.github.com";
80
+ function utf8ToBase64(s) {
81
+ const bytes = new TextEncoder().encode(s);
82
+ let bin = "";
83
+ for (const b of bytes) bin += String.fromCharCode(b);
84
+ return btoa(bin);
85
+ }
86
+ function base64ToUtf8(s) {
87
+ const bin = atob(s.replace(/\s+/g, ""));
88
+ const bytes = new Uint8Array(bin.length);
89
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
90
+ return new TextDecoder().decode(bytes);
91
+ }
92
+ function encodePath(path) {
93
+ return path.split("/").map(encodeURIComponent).join("/");
94
+ }
95
+ var GitHubAdapter = class {
96
+ owner;
97
+ repo;
98
+ token;
99
+ branch;
100
+ fetchImpl;
101
+ commitMessagePrefix;
102
+ constructor(options) {
103
+ this.owner = options.owner;
104
+ this.repo = options.repo;
105
+ this.token = options.token;
106
+ this.branch = options.branch;
107
+ const fetchImpl = options.fetch ?? fetch;
108
+ this.fetchImpl = (input, init) => fetchImpl(input, init);
109
+ this.commitMessagePrefix = options.commitMessagePrefix ?? "shipbench:";
110
+ }
111
+ contentsUrl(path) {
112
+ return `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodePath(path)}`;
113
+ }
114
+ contentsReadUrl(path) {
115
+ const url = this.contentsUrl(path);
116
+ return this.branch === void 0 ? url : `${url}?ref=${encodeURIComponent(this.branch)}`;
117
+ }
118
+ headers() {
119
+ return {
120
+ Authorization: `Bearer ${this.token}`,
121
+ Accept: "application/vnd.github.v3+json",
122
+ "X-GitHub-Api-Version": "2022-11-28",
123
+ "User-Agent": "@shipbench/core"
124
+ };
125
+ }
126
+ async errorFromResponse(res, operation, path) {
127
+ let body = "";
128
+ try {
129
+ body = await res.text();
130
+ } catch {
131
+ }
132
+ return new GitHubApiError({
133
+ status: res.status,
134
+ statusText: res.statusText,
135
+ operation,
136
+ path,
137
+ body
138
+ });
139
+ }
140
+ async readFileContent(path, missingAsNull) {
141
+ const url = this.contentsReadUrl(path);
142
+ const res = await this.fetchImpl(url, { headers: this.headers() });
143
+ if (missingAsNull && res.status === 404) return null;
144
+ if (!res.ok) throw await this.errorFromResponse(res, "readFile", path);
145
+ const json = await res.json();
146
+ if (Array.isArray(json)) {
147
+ throw new Error(
148
+ `GitHubAdapter.readFile: "${path}" is a directory, not a file.`
149
+ );
150
+ }
151
+ if (json.encoding !== "base64") {
152
+ throw new Error(
153
+ `GitHubAdapter.readFile: unexpected encoding "${json.encoding}" for "${path}".`
154
+ );
155
+ }
156
+ return base64ToUtf8(json.content);
157
+ }
158
+ async readFile(path) {
159
+ const content = await this.readFileContent(path, false);
160
+ return content;
161
+ }
162
+ async readFileIfExists(path) {
163
+ return this.readFileContent(path, true);
164
+ }
165
+ /** Returns the SHA of an existing file, or undefined if it does not exist. */
166
+ async getSha(path) {
167
+ const url = this.contentsReadUrl(path);
168
+ const res = await this.fetchImpl(url, { headers: this.headers() });
169
+ if (res.status === 404) return void 0;
170
+ if (!res.ok) throw await this.errorFromResponse(res, "getSha", path);
171
+ const json = await res.json();
172
+ if (Array.isArray(json)) {
173
+ throw new Error(
174
+ `GitHubAdapter.getSha: "${path}" is a directory, not a file.`
175
+ );
176
+ }
177
+ return json.sha;
178
+ }
179
+ async writeFile(path, content) {
180
+ const sha = await this.getSha(path);
181
+ const body = {
182
+ message: `${this.commitMessagePrefix} ${sha ? "update" : "create"} ${path}`,
183
+ content: utf8ToBase64(content)
184
+ };
185
+ if (this.branch !== void 0) body.branch = this.branch;
186
+ if (sha) body.sha = sha;
187
+ const res = await this.fetchImpl(this.contentsUrl(path), {
188
+ method: "PUT",
189
+ headers: { ...this.headers(), "Content-Type": "application/json" },
190
+ body: JSON.stringify(body)
191
+ });
192
+ if (!res.ok) throw await this.errorFromResponse(res, "writeFile", path);
193
+ }
194
+ async listFiles(directory) {
195
+ const url = this.contentsReadUrl(directory);
196
+ const res = await this.fetchImpl(url, { headers: this.headers() });
197
+ if (res.status === 404) return [];
198
+ if (!res.ok)
199
+ throw await this.errorFromResponse(res, "listFiles", directory);
200
+ const json = await res.json();
201
+ if (!Array.isArray(json)) {
202
+ throw new Error(
203
+ `GitHubAdapter.listFiles: "${directory}" is a file, not a directory.`
204
+ );
205
+ }
206
+ return json.filter((e) => e.type === "file").map((e) => e.name);
207
+ }
208
+ async readFiles(paths) {
209
+ const entries = await Promise.all(
210
+ paths.map(async (p) => [p, await this.readFile(p)])
211
+ );
212
+ return new Map(entries);
213
+ }
214
+ async writeFiles(files) {
215
+ for (const [path, content] of files) {
216
+ await this.writeFile(path, content);
217
+ }
218
+ }
219
+ };
220
+
221
+ // src/dependencies.ts
222
+ function createTaskDependencyIndex(liveTasks, archivedTasks = [], archivedSlugs = []) {
223
+ return {
224
+ liveTasksBySlug: new Map(liveTasks.map((task) => [task.slug, task])),
225
+ archivedTasksBySlug: new Map(archivedTasks.map((task) => [task.slug, task])),
226
+ archivedSlugs: /* @__PURE__ */ new Set([
227
+ ...archivedSlugs,
228
+ ...archivedTasks.map((task) => task.slug)
229
+ ])
230
+ };
231
+ }
232
+ function dependencySlugs(task) {
233
+ const dependencies = task.frontmatter.depends_on;
234
+ if (dependencies === void 0) return [];
235
+ if (!Array.isArray(dependencies)) return null;
236
+ if (dependencies.some((dependency) => typeof dependency !== "string")) {
237
+ return null;
238
+ }
239
+ return [...new Set(dependencies)];
240
+ }
241
+ function resolveTaskDependency(slug, index) {
242
+ const liveTask = index.liveTasksBySlug.get(slug);
243
+ if (liveTask) {
244
+ return {
245
+ kind: "live",
246
+ status: liveTask.frontmatter.status,
247
+ task: liveTask
248
+ };
249
+ }
250
+ const archivedTask = index.archivedTasksBySlug.get(slug);
251
+ if (archivedTask) {
252
+ return { kind: "archived", status: "archived", task: archivedTask };
253
+ }
254
+ if (index.archivedSlugs.has(slug)) {
255
+ return { kind: "archived", status: "archived" };
256
+ }
257
+ return { kind: "missing", status: "missing" };
258
+ }
259
+ function dependencyStatus(slug, index) {
260
+ return resolveTaskDependency(slug, index).status;
261
+ }
262
+ function taskDependenciesAreSatisfied(task, index, doneColumn) {
263
+ const dependencies = dependencySlugs(task);
264
+ if (dependencies === null) return false;
265
+ return dependencies.every((dependency) => {
266
+ if (dependency === task.slug) return false;
267
+ const resolution = resolveTaskDependency(dependency, index);
268
+ return resolution.kind === "archived" || resolution.kind === "live" && resolution.status === doneColumn;
269
+ });
270
+ }
271
+ function buildTaskDependencyGraph(liveTasks, options = {}) {
272
+ const archivedTasks = options.archivedTasks ?? [];
273
+ const index = createTaskDependencyIndex(
274
+ liveTasks,
275
+ archivedTasks,
276
+ options.archivedSlugs
277
+ );
278
+ const nodes = /* @__PURE__ */ new Map();
279
+ const sourceTasks = [
280
+ ...liveTasks,
281
+ ...archivedTasks.filter((task) => !index.liveTasksBySlug.has(task.slug))
282
+ ];
283
+ for (const task of sourceTasks) {
284
+ nodes.set(task.slug, {
285
+ status: dependencyStatus(task.slug, index),
286
+ depends_on: dependencySlugs(task) ?? [],
287
+ blocks: []
288
+ });
289
+ }
290
+ for (const task of sourceTasks) {
291
+ const dependencies = dependencySlugs(task) ?? [];
292
+ for (const dependency of dependencies) {
293
+ const dependencyNode = nodes.get(dependency) ?? {
294
+ status: dependencyStatus(dependency, index),
295
+ depends_on: [],
296
+ blocks: []
297
+ };
298
+ dependencyNode.blocks.push(task.slug);
299
+ nodes.set(dependency, dependencyNode);
300
+ }
301
+ }
302
+ return Object.fromEntries(
303
+ [...nodes.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([slug, node]) => [
304
+ slug,
305
+ {
306
+ ...node,
307
+ blocks: [...new Set(node.blocks)].sort((a, b) => a.localeCompare(b))
308
+ }
309
+ ])
310
+ );
311
+ }
312
+
313
+ // src/availability.ts
314
+ function compareTaskReadiness(a, b, config) {
315
+ const priorityRank = (task) => {
316
+ const priority = task.frontmatter.priority ?? config.priority.default;
317
+ return config.priority.values.indexOf(priority);
318
+ };
319
+ const priorityDifference = priorityRank(b) - priorityRank(a);
320
+ if (priorityDifference !== 0) return priorityDifference;
321
+ const aCreated = Date.parse(a.frontmatter.created);
322
+ const bCreated = Date.parse(b.frontmatter.created);
323
+ if (Number.isFinite(aCreated) && Number.isFinite(bCreated)) {
324
+ const createdDifference = aCreated - bCreated;
325
+ if (createdDifference !== 0) return createdDifference;
326
+ } else if (Number.isFinite(aCreated)) {
327
+ return -1;
328
+ } else if (Number.isFinite(bCreated)) {
329
+ return 1;
330
+ }
331
+ return a.slug.localeCompare(b.slug);
332
+ }
333
+ function listTasksByAvailability(tasks, config, mode, options = {}) {
334
+ const status = options.status ?? config.default_column;
335
+ const dependencyIndex = createTaskDependencyIndex(
336
+ tasks,
337
+ options.archivedTasks,
338
+ options.archivedSlugs
339
+ );
340
+ return tasks.filter((task) => {
341
+ if (task.frontmatter.status !== status) return false;
342
+ const available = taskDependenciesAreSatisfied(
343
+ task,
344
+ dependencyIndex,
345
+ config.done_column
346
+ );
347
+ return mode === "available" ? available : !available;
348
+ }).sort((a, b) => compareTaskReadiness(a, b, config));
349
+ }
350
+ function listAvailableTasks(tasks, config, options = {}) {
351
+ return listTasksByAvailability(tasks, config, "available", options);
352
+ }
353
+ function listBlockedTasks(tasks, config, options = {}) {
354
+ return listTasksByAvailability(tasks, config, "blocked", options);
355
+ }
356
+
357
+ // src/defaults.ts
358
+ var DEFAULT_CONFIG = {
359
+ version: 1,
360
+ // Safety net for partial configs read via loadConfig's deep-merge. Fresh
361
+ // projects always override this with a real name at `shipbench init` time.
362
+ name: "Untitled Project",
363
+ columns: [
364
+ { id: "todo", label: "To Do" },
365
+ { id: "in-progress", label: "In Progress" },
366
+ { id: "done", label: "Done" }
367
+ ],
368
+ default_column: "todo",
369
+ done_column: "done",
370
+ done_display: {
371
+ max: 20
372
+ },
373
+ priority: {
374
+ values: ["low", "medium", "high"],
375
+ default: "medium"
376
+ },
377
+ schema: {
378
+ custom_fields: {}
379
+ },
380
+ layout: {}
381
+ };
382
+
383
+ // src/config.ts
384
+ var CONFIG_PATH = ".shipbench/config.json";
385
+ var LAYOUT_PATH = ".shipbench/layout.json";
386
+ function assertLayoutShape(value) {
387
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
388
+ throw new Error("Layout must contain a JSON object.");
389
+ }
390
+ for (const [column, slugs] of Object.entries(value)) {
391
+ if (!Array.isArray(slugs) || slugs.some((slug) => typeof slug !== "string")) {
392
+ throw new Error(`Layout entry "${column}" must be an array of slugs.`);
393
+ }
394
+ }
395
+ return value;
396
+ }
397
+ function resolveLayout(raw, legacyLayout, columnIds, options) {
398
+ const source = raw === null ? CONFIG_PATH : LAYOUT_PATH;
399
+ let shaped;
400
+ try {
401
+ shaped = raw === null ? assertLayoutShape(legacyLayout ?? {}) : assertLayoutShape(JSON.parse(raw));
402
+ } catch (error) {
403
+ const detail = error instanceof Error ? error.message : "Invalid layout.";
404
+ options.onWarning?.({
405
+ path: source,
406
+ message: `${detail} Manual ordering was ignored; using deterministic fallback order.`
407
+ });
408
+ return {};
409
+ }
410
+ const unknownKeys = Object.keys(shaped).filter((key) => !columnIds.has(key));
411
+ if (unknownKeys.length === 0) return shaped;
412
+ const pruned = {};
413
+ for (const [key, slugs] of Object.entries(shaped)) {
414
+ if (columnIds.has(key)) pruned[key] = slugs;
415
+ }
416
+ options.onWarning?.({
417
+ path: source,
418
+ message: `Layout key(s) ${unknownKeys.map((key) => `"${key}"`).join(", ")} do not match any configured column. Manual ordering for ${unknownKeys.length === 1 ? "that column" : "those columns"} was ignored; using deterministic fallback order there.`
419
+ });
420
+ return pruned;
421
+ }
422
+ function deepMerge(defaults, overrides) {
423
+ const result = { ...defaults };
424
+ for (const key of Object.keys(overrides)) {
425
+ const val = overrides[key];
426
+ if (val !== void 0 && typeof val === "object" && val !== null && !Array.isArray(val) && typeof result[key] === "object" && result[key] !== null && !Array.isArray(result[key])) {
427
+ result[key] = deepMerge(
428
+ result[key],
429
+ val
430
+ );
431
+ } else if (val !== void 0) {
432
+ result[key] = val;
433
+ }
434
+ }
435
+ return result;
436
+ }
437
+ async function loadConfig(adapter, options = {}) {
438
+ const raw = await adapter.readFile(CONFIG_PATH);
439
+ const parsedConfig = JSON.parse(raw);
440
+ if (typeof parsedConfig !== "object" || parsedConfig === null || Array.isArray(parsedConfig)) {
441
+ throw new Error("Config must contain a JSON object.");
442
+ }
443
+ const userConfig = parsedConfig;
444
+ const layoutRaw = await adapter.readFileIfExists(LAYOUT_PATH);
445
+ const legacyLayout = userConfig.layout;
446
+ const { layout: _layout, ...configOverrides } = userConfig;
447
+ const config = deepMerge(structuredClone(DEFAULT_CONFIG), configOverrides);
448
+ if (!Object.hasOwn(userConfig, "default_column")) {
449
+ const firstColumn = Array.isArray(config.columns) ? config.columns[0] : null;
450
+ config.default_column = firstColumn?.id ?? DEFAULT_CONFIG.default_column;
451
+ }
452
+ const columnIds = new Set(
453
+ Array.isArray(config.columns) ? config.columns.filter(
454
+ (column) => typeof column === "object" && column !== null && typeof column.id === "string"
455
+ ).map((column) => column.id) : []
456
+ );
457
+ config.layout = resolveLayout(layoutRaw, legacyLayout, columnIds, options);
458
+ const errors = validateConfig(config);
459
+ if (errors.length > 0) {
460
+ throw new Error(`Invalid ShipBench config: ${errors.join(" ")}`);
461
+ }
462
+ return config;
463
+ }
464
+ function validateConfig(config) {
465
+ const errors = [];
466
+ const candidate = config;
467
+ if (typeof candidate.name !== "string" || !candidate.name.trim()) {
468
+ errors.push("Config must define a non-empty name.");
469
+ }
470
+ const columns = Array.isArray(candidate.columns) ? candidate.columns : null;
471
+ if (!columns) {
472
+ errors.push("Config columns must be an array.");
473
+ } else if (!columns.length) {
474
+ errors.push("Config must define at least one column.");
475
+ }
476
+ const seen = /* @__PURE__ */ new Set();
477
+ const duplicates = /* @__PURE__ */ new Set();
478
+ for (const [index, column] of (columns ?? []).entries()) {
479
+ if (typeof column !== "object" || column === null || Array.isArray(column)) {
480
+ errors.push(`Column ${index + 1} must be an object.`);
481
+ continue;
482
+ }
483
+ const id = column.id;
484
+ const label = column.label;
485
+ if (typeof id !== "string" || !id.trim()) {
486
+ errors.push(`Column ${index + 1} must define a non-empty id.`);
487
+ continue;
488
+ }
489
+ if (typeof label !== "string" || !label.trim()) {
490
+ errors.push(`Column "${id}" must define a non-empty label.`);
491
+ }
492
+ if (seen.has(id)) duplicates.add(id);
493
+ seen.add(id);
494
+ }
495
+ for (const id of duplicates) {
496
+ errors.push(`Duplicate column ID "${id}".`);
497
+ }
498
+ const columnIds = seen;
499
+ const defaultColumn = candidate.default_column;
500
+ const doneColumn = candidate.done_column;
501
+ if (typeof defaultColumn !== "string" || !columnIds.has(defaultColumn)) {
502
+ errors.push(
503
+ `default_column "${String(defaultColumn)}" does not match any column ID.`
504
+ );
505
+ }
506
+ if (typeof doneColumn !== "string" || !columnIds.has(doneColumn)) {
507
+ errors.push(
508
+ `done_column "${String(doneColumn)}" does not match any column ID.`
509
+ );
510
+ }
511
+ const priority = typeof candidate.priority === "object" && candidate.priority !== null && !Array.isArray(candidate.priority) ? candidate.priority : null;
512
+ const priorityValues = priority && Array.isArray(priority.values) ? priority.values.filter(
513
+ (value) => typeof value === "string"
514
+ ) : null;
515
+ if (!priorityValues || priorityValues.length !== priority?.values?.length) {
516
+ errors.push("priority.values must be an array of strings.");
517
+ }
518
+ if (!priority || typeof priority.default !== "string" || !priorityValues?.includes(priority.default)) {
519
+ errors.push(
520
+ `Default priority "${String(priority?.default)}" is not in the priority values list.`
521
+ );
522
+ }
523
+ const doneDisplay = typeof candidate.done_display === "object" && candidate.done_display !== null && !Array.isArray(candidate.done_display) ? candidate.done_display : null;
524
+ const doneMax = doneDisplay?.max;
525
+ if (typeof doneMax !== "number" || !Number.isFinite(doneMax) || !Number.isInteger(doneMax)) {
526
+ errors.push(
527
+ `done_display.max must be an integer (got ${JSON.stringify(doneMax)}).`
528
+ );
529
+ }
530
+ const layout = typeof candidate.layout === "object" && candidate.layout !== null && !Array.isArray(candidate.layout) ? candidate.layout : null;
531
+ if (!layout) {
532
+ errors.push("Layout must contain a JSON object.");
533
+ }
534
+ for (const [layoutKey, slugs] of Object.entries(layout ?? {})) {
535
+ if (!columnIds.has(layoutKey)) {
536
+ errors.push(`Layout key "${layoutKey}" does not match any column ID.`);
537
+ }
538
+ if (!Array.isArray(slugs) || slugs.some((slug) => typeof slug !== "string")) {
539
+ errors.push(`Layout entry "${layoutKey}" must be an array of slugs.`);
540
+ }
541
+ }
542
+ return errors;
543
+ }
544
+
545
+ // src/github-url.ts
546
+ var GITHUB_URL_RE = /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/;
547
+ var GITHUB_SCP_REMOTE_RE = /^git@github\.com:([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?$/;
548
+ var GITHUB_SSH_PATH_RE = /^\/([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/;
549
+ function validParts(owner, repo) {
550
+ return owner !== "." && owner !== ".." && repo !== "." && repo !== "..";
551
+ }
552
+ function parseGithubUrl(input) {
553
+ const match = GITHUB_URL_RE.exec(input.trim());
554
+ if (!match) return null;
555
+ const owner = match[1];
556
+ const repo = match[2];
557
+ return validParts(owner, repo) ? { owner, repo } : null;
558
+ }
559
+ function parseGithubRemoteUrl(input) {
560
+ const https = parseGithubUrl(input);
561
+ if (https) return https;
562
+ const trimmed = input.trim();
563
+ const scpMatch = GITHUB_SCP_REMOTE_RE.exec(trimmed);
564
+ if (scpMatch) {
565
+ const owner = scpMatch[1];
566
+ const repo = scpMatch[2];
567
+ return validParts(owner, repo) ? { owner, repo } : null;
568
+ }
569
+ try {
570
+ const url = new URL(trimmed);
571
+ if (url.protocol !== "ssh:" || url.hostname !== "github.com" || url.username !== "git" || url.password || url.port || url.search || url.hash) {
572
+ return null;
573
+ }
574
+ const pathMatch = GITHUB_SSH_PATH_RE.exec(url.pathname);
575
+ if (!pathMatch) return null;
576
+ const owner = pathMatch[1];
577
+ const repo = pathMatch[2];
578
+ return validParts(owner, repo) ? { owner, repo } : null;
579
+ } catch {
580
+ return null;
581
+ }
582
+ }
583
+ function normalizeGithubUrl(input) {
584
+ const parsed = parseGithubUrl(input);
585
+ return parsed ? `https://github.com/${parsed.owner}/${parsed.repo}` : null;
586
+ }
587
+ function normalizeGithubRemoteUrl(input) {
588
+ const parsed = parseGithubRemoteUrl(input);
589
+ return parsed ? `https://github.com/${parsed.owner}/${parsed.repo}` : null;
590
+ }
591
+
592
+ // src/tasks.ts
593
+ import matter from "gray-matter";
594
+
595
+ // src/slug.ts
596
+ function slugify(title) {
597
+ return title.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
598
+ }
599
+ function resolveSlugCollision(slug, existingSlugs) {
600
+ if (!existingSlugs.has(slug)) return slug;
601
+ let counter = 2;
602
+ while (existingSlugs.has(`${slug}-${counter}`)) {
603
+ counter++;
604
+ }
605
+ return `${slug}-${counter}`;
606
+ }
607
+
608
+ // src/tasks.ts
609
+ var TASKS_DIR = ".shipbench/tasks";
610
+ var ARCHIVE_DIR = `${TASKS_DIR}/archive`;
611
+ var CONFIG_PATH2 = ".shipbench/config.json";
612
+ var LAYOUT_PATH2 = ".shipbench/layout.json";
613
+ var UPDATES_HEADING = "## Task Updates";
614
+ var ISO_8601_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
615
+ var updatesParseWarnings = /* @__PURE__ */ new WeakMap();
616
+ var ArchiveBlockedError = class extends Error {
617
+ constructor(slug, dependentSlugs) {
618
+ super(
619
+ `Cannot archive "${slug}" because live tasks depend on it: ${dependentSlugs.join(", ")}`
620
+ );
621
+ this.slug = slug;
622
+ this.dependentSlugs = dependentSlugs;
623
+ this.name = "ArchiveBlockedError";
624
+ }
625
+ slug;
626
+ dependentSlugs;
627
+ };
628
+ var KNOWN_FRONTMATTER_FIELDS = /* @__PURE__ */ new Set([
629
+ "title",
630
+ "status",
631
+ "priority",
632
+ "assignee",
633
+ "tags",
634
+ "depends_on",
635
+ "created",
636
+ "updated"
637
+ ]);
638
+ function normalizeTimestamp(v) {
639
+ return v instanceof Date ? v.toISOString() : v;
640
+ }
641
+ function updateFence(line, current) {
642
+ const match = line.match(/^\s{0,3}(`{3,}|~{3,})/);
643
+ if (!match) return current;
644
+ const sequence = match[1];
645
+ const marker = sequence[0];
646
+ if (!current) return { marker, length: sequence.length };
647
+ if (current.marker === marker && sequence.length >= current.length)
648
+ return null;
649
+ return current;
650
+ }
651
+ function malformedUpdates(body, detail) {
652
+ return {
653
+ body: body.trim(),
654
+ comments: [],
655
+ warning: `Malformed Updates section: ${detail} Raw Markdown was preserved in the task body.`
656
+ };
657
+ }
658
+ function parseTaskBody(rawBody) {
659
+ const body = rawBody.trim();
660
+ if (!body) return { body: "", comments: [] };
661
+ const lines = body.split(/\r?\n/);
662
+ const updatesHeadings = [];
663
+ let fence = null;
664
+ for (const [index, line] of lines.entries()) {
665
+ if (!fence && line.trimEnd() === UPDATES_HEADING) {
666
+ updatesHeadings.push(index);
667
+ }
668
+ fence = updateFence(line, fence);
669
+ }
670
+ if (updatesHeadings.length === 0) return { body, comments: [] };
671
+ if (updatesHeadings.length > 1) {
672
+ return malformedUpdates(
673
+ body,
674
+ `found more than one "${UPDATES_HEADING}" heading.`
675
+ );
676
+ }
677
+ const updatesIndex = updatesHeadings[0];
678
+ const description = lines.slice(0, updatesIndex).join("\n").trim();
679
+ const updateLines = lines.slice(updatesIndex + 1);
680
+ const comments = [];
681
+ let timestamp = null;
682
+ let textLines = [];
683
+ fence = null;
684
+ const finishComment = () => {
685
+ if (timestamp === null) return null;
686
+ const text = textLines.join("\n").trim();
687
+ if (!text) return `entry "${timestamp}" has no text.`;
688
+ comments.push({ timestamp, text });
689
+ return null;
690
+ };
691
+ for (const line of updateLines) {
692
+ const outsideFence = fence === null;
693
+ const fenceOnLine = /^\s{0,3}(`{3,}|~{3,})/.test(line);
694
+ if (outsideFence && !fenceOnLine) {
695
+ const heading = line.match(/^###\s+(.+?)\s*$/);
696
+ if (heading) {
697
+ const previousError = finishComment();
698
+ if (previousError) return malformedUpdates(body, previousError);
699
+ const nextTimestamp = heading[1];
700
+ if (!ISO_8601_TIMESTAMP.test(nextTimestamp) || Number.isNaN(Date.parse(nextTimestamp))) {
701
+ return malformedUpdates(
702
+ body,
703
+ `"${nextTimestamp}" is not an ISO 8601 timestamp.`
704
+ );
705
+ }
706
+ timestamp = nextTimestamp;
707
+ textLines = [];
708
+ continue;
709
+ }
710
+ if (/^#{1,6}(?:\s|$)/.test(line)) {
711
+ return malformedUpdates(
712
+ body,
713
+ `expected each entry heading to use "### <ISO 8601 timestamp>".`
714
+ );
715
+ }
716
+ }
717
+ if (timestamp === null) {
718
+ if (line.trim()) {
719
+ return malformedUpdates(
720
+ body,
721
+ `expected "### <ISO 8601 timestamp>" before entry text.`
722
+ );
723
+ }
724
+ } else {
725
+ textLines.push(line);
726
+ }
727
+ fence = updateFence(line, fence);
728
+ }
729
+ if (fence) {
730
+ return malformedUpdates(body, "an entry contains an unclosed code fence.");
731
+ }
732
+ const finalError = finishComment();
733
+ if (finalError) return malformedUpdates(body, finalError);
734
+ if (comments.length === 0) {
735
+ return malformedUpdates(body, "the section contains no entries.");
736
+ }
737
+ return { body: description, comments };
738
+ }
739
+ function parseFrontmatter(fileContent) {
740
+ try {
741
+ return matter(fileContent);
742
+ } catch (error) {
743
+ matter.clearCache();
744
+ throw error;
745
+ }
746
+ }
747
+ function parseTaskFile(slug, fileContent) {
748
+ const { data, content: bodyContent } = parseFrontmatter(fileContent);
749
+ const parsedBody = parseTaskBody(bodyContent);
750
+ const frontmatter = {
751
+ ...data,
752
+ created: normalizeTimestamp(data.created),
753
+ updated: normalizeTimestamp(data.updated)
754
+ };
755
+ const task = {
756
+ slug,
757
+ frontmatter,
758
+ body: parsedBody.body,
759
+ comments: parsedBody.comments
760
+ };
761
+ if (parsedBody.warning) {
762
+ updatesParseWarnings.set(task, parsedBody.warning);
763
+ }
764
+ return task;
765
+ }
766
+ function stripUndefined(obj) {
767
+ const out = {};
768
+ for (const [k, v] of Object.entries(obj)) {
769
+ if (v !== void 0) out[k] = v;
770
+ }
771
+ return out;
772
+ }
773
+ function serializeTask(task) {
774
+ const sections = [];
775
+ if (task.body.trim()) sections.push(task.body.trim());
776
+ if ((task.comments ?? []).length > 0) {
777
+ const entries = task.comments.map((comment) => `### ${comment.timestamp}
778
+ ${comment.text.trim()}`).join("\n\n");
779
+ sections.push(`${UPDATES_HEADING}
780
+
781
+ ${entries}`);
782
+ }
783
+ return matter.stringify(
784
+ `
785
+ ${sections.join("\n\n")}
786
+ `,
787
+ stripUndefined(task.frontmatter)
788
+ );
789
+ }
790
+ function assertValidStatus(status, config) {
791
+ const valid = new Set(config.columns.map((c) => c.id));
792
+ if (!valid.has(status)) {
793
+ throw new Error(
794
+ `Invalid status "${status}". Valid: ${[...valid].join(", ")}`
795
+ );
796
+ }
797
+ }
798
+ function assertValidPriority(priority, config) {
799
+ if (!config.priority.values.includes(priority)) {
800
+ throw new Error(
801
+ `Invalid priority "${priority}". Valid: ${config.priority.values.join(", ")}`
802
+ );
803
+ }
804
+ }
805
+ function normalizeDependsOn(value) {
806
+ if (!value) return void 0;
807
+ const deduped = [...new Set(value.map((s) => s.trim()).filter(Boolean))];
808
+ return deduped.length > 0 ? deduped : void 0;
809
+ }
810
+ async function assertValidDependsOn(adapter, config, slug, dependsOn) {
811
+ if (!dependsOn || dependsOn.length === 0) return;
812
+ if (dependsOn.includes(slug)) {
813
+ throw new Error(`Task "${slug}" cannot depend on itself.`);
814
+ }
815
+ const { tasks } = await listTasks(adapter, config);
816
+ const bySlug = new Map(tasks.map((t) => [t.slug, t]));
817
+ for (const dep of dependsOn) {
818
+ const target = bySlug.get(dep);
819
+ if (!target) {
820
+ throw new Error(
821
+ `Unknown dependency "${dep}" \u2014 no task file matches that slug.`
822
+ );
823
+ }
824
+ if (target.frontmatter.depends_on?.includes(slug)) {
825
+ throw new Error(
826
+ `Dependency cycle: "${dep}" already depends on "${slug}".`
827
+ );
828
+ }
829
+ }
830
+ }
831
+ function validateTask(task, config, knownSlugs) {
832
+ const warnings = [];
833
+ const validStatuses = new Set(config.columns.map((c) => c.id));
834
+ const updatesWarning = updatesParseWarnings.get(task);
835
+ if (updatesWarning) {
836
+ warnings.push({
837
+ slug: task.slug,
838
+ field: "updates",
839
+ message: updatesWarning
840
+ });
841
+ }
842
+ if (!validStatuses.has(task.frontmatter.status)) {
843
+ warnings.push({
844
+ slug: task.slug,
845
+ field: "status",
846
+ message: `Unknown status "${task.frontmatter.status}". Valid: ${[...validStatuses].join(", ")}`
847
+ });
848
+ }
849
+ if (task.frontmatter.priority && !config.priority.values.includes(task.frontmatter.priority)) {
850
+ warnings.push({
851
+ slug: task.slug,
852
+ field: "priority",
853
+ message: `Unknown priority "${task.frontmatter.priority}". Valid: ${config.priority.values.join(", ")}`
854
+ });
855
+ }
856
+ const dependsOn = task.frontmatter.depends_on;
857
+ if (dependsOn !== void 0 && !Array.isArray(dependsOn)) {
858
+ warnings.push({
859
+ slug: task.slug,
860
+ field: "depends_on",
861
+ message: "Expected depends_on to be a list of task slugs."
862
+ });
863
+ } else if (dependsOn) {
864
+ for (const dep of dependsOn) {
865
+ if (!knownSlugs.has(dep)) {
866
+ warnings.push({
867
+ slug: task.slug,
868
+ field: "depends_on",
869
+ message: `Dangling dependency "${dep}" \u2014 no live task file matches that slug (it may be archived).`
870
+ });
871
+ }
872
+ }
873
+ }
874
+ for (const field of Object.keys(task.frontmatter)) {
875
+ if (!KNOWN_FRONTMATTER_FIELDS.has(field)) {
876
+ warnings.push({
877
+ slug: task.slug,
878
+ field,
879
+ message: `Unknown frontmatter field "${field}" (preserved).`
880
+ });
881
+ }
882
+ }
883
+ return warnings;
884
+ }
885
+ async function listTasksInDirectory(adapter, config, directory, additionalKnownSlugs = []) {
886
+ const files = await adapter.listFiles(directory);
887
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
888
+ if (mdFiles.length === 0) {
889
+ return { tasks: [], warnings: [] };
890
+ }
891
+ const paths = mdFiles.map((f) => `${directory}/${f}`);
892
+ const contents = await adapter.readFiles(paths);
893
+ const tasks = [];
894
+ const warnings = [];
895
+ for (const [path, content] of contents) {
896
+ const slug = path.replace(`${directory}/`, "").replace(/\.md$/, "");
897
+ try {
898
+ tasks.push(parseTaskFile(slug, content));
899
+ } catch (error) {
900
+ const detail = error instanceof Error ? error.message : String(error);
901
+ warnings.push({
902
+ slug,
903
+ field: "frontmatter",
904
+ message: `Could not parse frontmatter in "${path}": ${detail}`
905
+ });
906
+ }
907
+ }
908
+ const knownSlugs = /* @__PURE__ */ new Set([
909
+ ...await additionalKnownSlugs,
910
+ ...mdFiles.map((file) => file.replace(/\.md$/, ""))
911
+ ]);
912
+ for (const task of tasks) {
913
+ warnings.push(...validateTask(task, config, knownSlugs));
914
+ }
915
+ return { tasks, warnings };
916
+ }
917
+ async function listTasks(adapter, config, options = {}) {
918
+ const archivedSlugs = Promise.all([
919
+ Promise.resolve(options.archivedTasks ?? []),
920
+ Promise.resolve(options.archivedSlugs ?? [])
921
+ ]).then(([tasks, fileSlugs]) => [
922
+ ...tasks.map((task) => task.slug),
923
+ ...fileSlugs
924
+ ]);
925
+ return listTasksInDirectory(adapter, config, TASKS_DIR, archivedSlugs);
926
+ }
927
+ async function getTask(adapter, config, slug, options = {}) {
928
+ void config;
929
+ const directory = options.archived ? ARCHIVE_DIR : TASKS_DIR;
930
+ const content = await adapter.readFileIfExists(`${directory}/${slug}.md`);
931
+ return content === null ? null : parseTaskFile(slug, content);
932
+ }
933
+ async function listArchivedTasks(adapter, config) {
934
+ const liveFiles = await adapter.listFiles(TASKS_DIR);
935
+ const liveSlugs = liveFiles.filter((file) => file.endsWith(".md")).map((file) => file.replace(/\.md$/, ""));
936
+ return listTasksInDirectory(adapter, config, ARCHIVE_DIR, liveSlugs);
937
+ }
938
+ function taskFileSlugs(result) {
939
+ return [
940
+ .../* @__PURE__ */ new Set([
941
+ ...result.tasks.map((task) => task.slug),
942
+ ...result.warnings.map((warning) => warning.slug)
943
+ ])
944
+ ];
945
+ }
946
+ async function createTask(adapter, config, title, fields) {
947
+ const [existingFiles, archivedFiles] = await Promise.all([
948
+ adapter.listFiles(TASKS_DIR),
949
+ adapter.listFiles(ARCHIVE_DIR)
950
+ ]);
951
+ const existingSlugs = new Set(
952
+ [...existingFiles, ...archivedFiles].filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
953
+ );
954
+ const baseSlug = slugify(title);
955
+ if (!baseSlug) {
956
+ throw new Error(
957
+ "Task title must contain at least one slug-able character."
958
+ );
959
+ }
960
+ const slug = resolveSlugCollision(baseSlug, existingSlugs);
961
+ const now = (/* @__PURE__ */ new Date()).toISOString();
962
+ const status = fields?.status ?? config.default_column;
963
+ assertValidStatus(status, config);
964
+ const priority = fields?.priority ?? config.priority.default;
965
+ assertValidPriority(priority, config);
966
+ const dependsOn = normalizeDependsOn(fields?.depends_on);
967
+ await assertValidDependsOn(adapter, config, slug, dependsOn);
968
+ const task = {
969
+ slug,
970
+ frontmatter: {
971
+ title,
972
+ status,
973
+ priority,
974
+ assignee: fields?.assignee,
975
+ tags: fields?.tags,
976
+ depends_on: dependsOn,
977
+ created: now,
978
+ updated: now
979
+ },
980
+ body: "",
981
+ comments: []
982
+ };
983
+ await adapter.writeFile(`${TASKS_DIR}/${slug}.md`, serializeTask(task));
984
+ const currentLayout = config.layout ?? {};
985
+ const columnOrder = currentLayout[status] ?? [];
986
+ const nextLayout = status === config.done_column ? currentLayout : {
987
+ ...currentLayout,
988
+ [status]: [...columnOrder, slug]
989
+ };
990
+ await writeLayout(adapter, config, nextLayout);
991
+ return task;
992
+ }
993
+ async function updateTask(adapter, config, slug, fields, body) {
994
+ const path = `${TASKS_DIR}/${slug}.md`;
995
+ const content = await adapter.readFile(path);
996
+ const task = parseTaskFile(slug, content);
997
+ if (fields.status) assertValidStatus(fields.status, config);
998
+ if (fields.priority) assertValidPriority(fields.priority, config);
999
+ const dependsOnProvided = "depends_on" in fields;
1000
+ const dependsOn = dependsOnProvided ? normalizeDependsOn(fields.depends_on) : void 0;
1001
+ if (dependsOnProvided) {
1002
+ await assertValidDependsOn(adapter, config, slug, dependsOn);
1003
+ }
1004
+ const statusChanged = fields.status !== void 0 && fields.status !== task.frontmatter.status;
1005
+ task.frontmatter = {
1006
+ ...task.frontmatter,
1007
+ ...fields,
1008
+ ...dependsOnProvided ? { depends_on: dependsOn } : {},
1009
+ // `created` is set once at creation and is not user-modifiable.
1010
+ created: task.frontmatter.created,
1011
+ updated: (/* @__PURE__ */ new Date()).toISOString()
1012
+ };
1013
+ if (body !== void 0) {
1014
+ task.body = body;
1015
+ }
1016
+ await adapter.writeFile(path, serializeTask(task));
1017
+ if (statusChanged) {
1018
+ const layout = await reorderLayout(
1019
+ adapter,
1020
+ config,
1021
+ slug,
1022
+ task.frontmatter.status,
1023
+ -1
1024
+ );
1025
+ return { task, layout };
1026
+ }
1027
+ return { task };
1028
+ }
1029
+ async function addComment(adapter, config, slug, text) {
1030
+ void config;
1031
+ const normalizedText = text.trim();
1032
+ if (!normalizedText) {
1033
+ throw new Error("Task update text must not be blank.");
1034
+ }
1035
+ const path = `${TASKS_DIR}/${slug}.md`;
1036
+ const content = await adapter.readFile(path);
1037
+ const task = parseTaskFile(slug, content);
1038
+ if (updatesParseWarnings.has(task)) {
1039
+ throw new Error(
1040
+ `Cannot add an update to "${slug}" because its Updates section is malformed. Fix the section in the task file first.`
1041
+ );
1042
+ }
1043
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1044
+ task.comments.push({ timestamp: now, text: normalizedText });
1045
+ task.frontmatter.updated = now;
1046
+ await adapter.writeFile(path, serializeTask(task));
1047
+ return task;
1048
+ }
1049
+ function assertMutableComments(task, slug, action) {
1050
+ if (updatesParseWarnings.has(task)) {
1051
+ throw new Error(
1052
+ `Cannot ${action} an update on "${slug}" because its Updates section is malformed. Fix the section in the task file first.`
1053
+ );
1054
+ }
1055
+ }
1056
+ function assertCommentIndex(task, slug, index) {
1057
+ if (!Number.isInteger(index) || index < 0 || index >= task.comments.length) {
1058
+ const expected = task.comments.length === 0 ? "the task has no Updates entries" : `expected a zero-based index from 0 to ${task.comments.length - 1}`;
1059
+ throw new Error(
1060
+ `Invalid task update index ${index} for "${slug}": ${expected}.`
1061
+ );
1062
+ }
1063
+ }
1064
+ async function editComment(adapter, config, slug, index, text) {
1065
+ void config;
1066
+ const normalizedText = text.trim();
1067
+ if (!normalizedText) {
1068
+ throw new Error("Task update text must not be blank.");
1069
+ }
1070
+ const path = `${TASKS_DIR}/${slug}.md`;
1071
+ const content = await adapter.readFile(path);
1072
+ const task = parseTaskFile(slug, content);
1073
+ assertMutableComments(task, slug, "edit");
1074
+ assertCommentIndex(task, slug, index);
1075
+ const comment = task.comments[index];
1076
+ task.comments[index] = { ...comment, text: normalizedText };
1077
+ task.frontmatter.updated = (/* @__PURE__ */ new Date()).toISOString();
1078
+ await adapter.writeFile(path, serializeTask(task));
1079
+ return task;
1080
+ }
1081
+ async function deleteComment(adapter, config, slug, index) {
1082
+ void config;
1083
+ const path = `${TASKS_DIR}/${slug}.md`;
1084
+ const content = await adapter.readFile(path);
1085
+ const task = parseTaskFile(slug, content);
1086
+ assertMutableComments(task, slug, "delete");
1087
+ assertCommentIndex(task, slug, index);
1088
+ task.comments.splice(index, 1);
1089
+ task.frontmatter.updated = (/* @__PURE__ */ new Date()).toISOString();
1090
+ await adapter.writeFile(path, serializeTask(task));
1091
+ return task;
1092
+ }
1093
+ async function listExistingSlugs(adapter) {
1094
+ const files = await adapter.listFiles(TASKS_DIR);
1095
+ return new Set(
1096
+ files.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
1097
+ );
1098
+ }
1099
+ function layoutWithoutDoneColumn(layout, doneColumn) {
1100
+ const next = { ...layout };
1101
+ delete next[doneColumn];
1102
+ return next;
1103
+ }
1104
+ async function writeLayout(adapter, config, layout) {
1105
+ const persistedLayout = layoutWithoutDoneColumn(layout, config.done_column);
1106
+ const serializedLayout = `${JSON.stringify(persistedLayout, null, 2)}
1107
+ `;
1108
+ const rawConfig = await adapter.readFile(CONFIG_PATH2);
1109
+ const userConfig = JSON.parse(rawConfig);
1110
+ if (Object.hasOwn(userConfig, "layout")) {
1111
+ delete userConfig.layout;
1112
+ await adapter.writeFiles(
1113
+ /* @__PURE__ */ new Map([
1114
+ [LAYOUT_PATH2, serializedLayout],
1115
+ [CONFIG_PATH2, `${JSON.stringify(userConfig, null, 2)}
1116
+ `]
1117
+ ])
1118
+ );
1119
+ } else {
1120
+ await adapter.writeFile(LAYOUT_PATH2, serializedLayout);
1121
+ }
1122
+ return persistedLayout;
1123
+ }
1124
+ async function reorderTask(adapter, config, slug, toStatus, position) {
1125
+ assertValidStatus(toStatus, config);
1126
+ const path = `${TASKS_DIR}/${slug}.md`;
1127
+ const content = await adapter.readFile(path);
1128
+ const task = parseTaskFile(slug, content);
1129
+ if (task.frontmatter.status !== toStatus) {
1130
+ task.frontmatter = {
1131
+ ...task.frontmatter,
1132
+ status: toStatus,
1133
+ updated: (/* @__PURE__ */ new Date()).toISOString()
1134
+ };
1135
+ await adapter.writeFile(path, serializeTask(task));
1136
+ }
1137
+ const layout = await reorderLayout(adapter, config, slug, toStatus, position);
1138
+ return { task, layout };
1139
+ }
1140
+ async function reorderLayout(adapter, config, slug, toStatus, position) {
1141
+ const { tasks: allTasks } = await listTasks(adapter, config);
1142
+ return writeLayout(
1143
+ adapter,
1144
+ config,
1145
+ layoutAfterMove({
1146
+ layout: config.layout ?? {},
1147
+ tasks: allTasks,
1148
+ slug,
1149
+ toStatus,
1150
+ position,
1151
+ doneColumn: config.done_column
1152
+ })
1153
+ );
1154
+ }
1155
+ async function moveTask(adapter, config, slug, toStatus) {
1156
+ const { task } = await reorderTask(adapter, config, slug, toStatus, -1);
1157
+ return task;
1158
+ }
1159
+ async function deleteTask(adapter, config, slug) {
1160
+ await adapter.deleteFile(`${TASKS_DIR}/${slug}.md`);
1161
+ const existingSlugs = await listExistingSlugs(adapter);
1162
+ const layout = layoutWithoutTask(config.layout ?? {}, slug, existingSlugs);
1163
+ await writeLayout(adapter, config, layout);
1164
+ }
1165
+ async function archiveTask(adapter, config, slug, options) {
1166
+ const livePath = `${TASKS_DIR}/${slug}.md`;
1167
+ const content = await adapter.readFile(livePath);
1168
+ const task = parseTaskFile(slug, content);
1169
+ if (task.frontmatter.status !== config.done_column && !options?.force) {
1170
+ const { tasks } = await listTasks(adapter, config);
1171
+ const dependentSlugs = tasks.filter(
1172
+ (candidate) => candidate.slug !== slug && candidate.frontmatter.depends_on?.includes(slug)
1173
+ ).map((candidate) => candidate.slug).sort();
1174
+ if (dependentSlugs.length > 0) {
1175
+ throw new ArchiveBlockedError(slug, dependentSlugs);
1176
+ }
1177
+ }
1178
+ await adapter.writeFile(`${ARCHIVE_DIR}/${slug}.md`, content);
1179
+ await adapter.deleteFile(livePath);
1180
+ const existingSlugs = await listExistingSlugs(adapter);
1181
+ const layout = layoutWithoutTask(config.layout ?? {}, slug, existingSlugs);
1182
+ const currentPersistedLayout = layoutWithoutDoneColumn(
1183
+ config.layout ?? {},
1184
+ config.done_column
1185
+ );
1186
+ if (JSON.stringify(layout) !== JSON.stringify(currentPersistedLayout)) {
1187
+ await writeLayout(adapter, config, layout);
1188
+ }
1189
+ return task;
1190
+ }
1191
+ async function unarchiveTask(adapter, config, slug) {
1192
+ const archivedPath = `${ARCHIVE_DIR}/${slug}.md`;
1193
+ const content = await adapter.readFile(archivedPath);
1194
+ const task = parseTaskFile(slug, content);
1195
+ await adapter.writeFile(`${TASKS_DIR}/${slug}.md`, content);
1196
+ await adapter.deleteFile(archivedPath);
1197
+ const status = task.frontmatter.status;
1198
+ if (status !== config.done_column) {
1199
+ const existingSlugs = await listExistingSlugs(adapter);
1200
+ const currentLayout = layoutWithoutTask(
1201
+ config.layout ?? {},
1202
+ slug,
1203
+ existingSlugs
1204
+ );
1205
+ const nextLayout = {
1206
+ ...currentLayout,
1207
+ [status]: [...currentLayout[status] ?? [], slug]
1208
+ };
1209
+ await writeLayout(adapter, config, nextLayout);
1210
+ }
1211
+ return task;
1212
+ }
1213
+
1214
+ // src/init.ts
1215
+ var ProjectInitializationError = class extends Error {
1216
+ constructor(state) {
1217
+ super(formatProjectInitializationError(state));
1218
+ this.state = state;
1219
+ this.name = "ProjectInitializationError";
1220
+ }
1221
+ state;
1222
+ };
1223
+ var CONFIG_PATH3 = ".shipbench/config.json";
1224
+ var LAYOUT_PATH3 = ".shipbench/layout.json";
1225
+ var README_PATH = ".shipbench/README.md";
1226
+ var AGENTS_PATH = ".shipbench/AGENTS.md";
1227
+ var TASKS_DIR2 = ".shipbench/tasks";
1228
+ var ARCHIVE_DIR2 = `${TASKS_DIR2}/archive`;
1229
+ function formatProjectInitializationError(state) {
1230
+ if (state.kind === "incomplete") {
1231
+ return `ShipBench project is incomplete: ${CONFIG_PATH3} is missing while these project files exist: ${state.paths.join(", ")}. Restore the config or move the partial .shipbench directory aside before retrying.`;
1232
+ }
1233
+ const label = state.kind === "malformed" ? "malformed" : "invalid";
1234
+ return `ShipBench project is ${label}: ${state.errors.join(" ")}`;
1235
+ }
1236
+ function parseJsonObject(raw, label) {
1237
+ try {
1238
+ const value = JSON.parse(raw);
1239
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1240
+ return { error: `${label} must contain a JSON object.` };
1241
+ }
1242
+ return { value };
1243
+ } catch (error) {
1244
+ const detail = error instanceof Error ? error.message : "Invalid JSON.";
1245
+ return { error: `${label} contains malformed JSON: ${detail}` };
1246
+ }
1247
+ }
1248
+ async function findPartialProjectPaths(adapter) {
1249
+ const [readme, agents, layout, liveFiles, archivedFiles] = await Promise.all([
1250
+ adapter.readFileIfExists(README_PATH),
1251
+ adapter.readFileIfExists(AGENTS_PATH),
1252
+ adapter.readFileIfExists(LAYOUT_PATH3),
1253
+ adapter.listFiles(TASKS_DIR2),
1254
+ adapter.listFiles(ARCHIVE_DIR2)
1255
+ ]);
1256
+ const paths = [];
1257
+ if (readme !== null) paths.push(README_PATH);
1258
+ if (agents !== null) paths.push(AGENTS_PATH);
1259
+ if (layout !== null) paths.push(LAYOUT_PATH3);
1260
+ paths.push(
1261
+ ...liveFiles.filter((file) => file.endsWith(".md")).map((file) => `${TASKS_DIR2}/${file}`),
1262
+ ...archivedFiles.filter((file) => file.endsWith(".md")).map((file) => `${ARCHIVE_DIR2}/${file}`)
1263
+ );
1264
+ return [...new Set(paths)].sort();
1265
+ }
1266
+ async function inspectProjectInitialization(adapter) {
1267
+ const rawConfig = await adapter.readFileIfExists(CONFIG_PATH3);
1268
+ if (rawConfig === null) {
1269
+ const paths = await findPartialProjectPaths(adapter);
1270
+ return paths.length > 0 ? { kind: "incomplete", paths } : { kind: "missing" };
1271
+ }
1272
+ const parsedConfig = parseJsonObject(rawConfig, "config.json");
1273
+ if ("error" in parsedConfig) {
1274
+ return { kind: "malformed", errors: [parsedConfig.error] };
1275
+ }
1276
+ let config;
1277
+ const configWarnings = [];
1278
+ try {
1279
+ config = await loadConfig(adapter, {
1280
+ onWarning: (warning) => {
1281
+ configWarnings.push(warning);
1282
+ }
1283
+ });
1284
+ } catch (error) {
1285
+ return {
1286
+ kind: "invalid",
1287
+ errors: [
1288
+ error instanceof Error ? error.message : "Could not resolve ShipBench configuration."
1289
+ ]
1290
+ };
1291
+ }
1292
+ try {
1293
+ const { warnings } = await listTasks(adapter, config);
1294
+ return {
1295
+ kind: "initialized",
1296
+ config,
1297
+ warnings: [...configWarnings, ...warnings]
1298
+ };
1299
+ } catch (error) {
1300
+ return {
1301
+ kind: "invalid",
1302
+ errors: [
1303
+ `Could not read ShipBench tasks: ${error instanceof Error ? error.message : "Unknown task read error."}`
1304
+ ]
1305
+ };
1306
+ }
1307
+ }
1308
+ function generateReadme(name) {
1309
+ return `# ${name} \u2014 ShipBench Project Board
1310
+
1311
+ This directory contains the ShipBench project board for **${name}**. Everything lives in Git alongside your code \u2014 no external service required.
1312
+
1313
+ ## Structure
1314
+
1315
+ - \`config.json\` \u2014 Human-owned board configuration (columns, priorities, schema)
1316
+ - \`layout.json\` \u2014 Machine-managed partial index of manual placements
1317
+ - \`tasks/\` \u2014 Individual task files as Markdown with YAML frontmatter
1318
+ - \`tasks/archive/\` \u2014 Archived task files, kept byte-for-byte for later restore
1319
+ - \`README.md\` \u2014 This file. Human-facing reference for the board configuration.
1320
+ - \`AGENTS.md\` \u2014 Machine-facing reference for autonomous agents
1321
+
1322
+ ## Working with the board
1323
+
1324
+ Tasks can be managed through any combination of:
1325
+
1326
+ - **The ShipBench CLI** (\`shipbench\` commands) \u2014 recommended for scripted or agent-driven changes; centralizes slug generation, validation, timestamps, and layout updates.
1327
+ - **The Board UI** (\`shipbench board\`) \u2014 local kanban in your browser, with live file watching.
1328
+ - **The terminal board** (\`shipbench board terminal\`) \u2014 the same board as a read-only live view, for leaving open in a pane beside your work.
1329
+ - **Harbor** \u2014 hosted view for browsing project boards across repos.
1330
+ - **Direct file editing** \u2014 always valid; task files are plain Markdown.
1331
+
1332
+ ## \`config.json\` reference
1333
+
1334
+ Every field has a sensible default. \`config.json\` is deep-merged over ShipBench's built-in defaults on read, so you can delete any block you don't care about and it will fall back to default behavior. \`shipbench init\` scaffolds the full file for discoverability.
1335
+
1336
+ ### \`version\`
1337
+
1338
+ Schema version. Currently informational. Leave as \`1\`.
1339
+
1340
+ ### \`name\`
1341
+
1342
+ The project's display name. Every consumer (CLI, Board, Harbor) reads this for the breadcrumb root. Defaults to the basename of the current directory when \`shipbench init\` runs; override with \`--name\`.
1343
+
1344
+ ### \`columns\`
1345
+
1346
+ The source of truth for valid task \`status\` values. Each entry is:
1347
+
1348
+ - \`id\` \u2014 used verbatim in task frontmatter \`status\` fields.
1349
+ - \`label\` \u2014 what the Board UI displays as the column header.
1350
+
1351
+ Add a column by appending to the array (e.g. \`{ "id": "review", "label": "Review" }\`). Tasks that reference a column ID that no longer exists surface in an "Uncategorized" column on the board \u2014 they're never dropped.
1352
+
1353
+ ### \`default_column\`
1354
+
1355
+ The column ID used when a task is created without an explicit \`status\` (\`shipbench task create "..."\`, the Board's new-task dialog). Must reference an existing column ID. If omitted, falls back to the first column in \`columns\`.
1356
+
1357
+ ### \`done_column\`
1358
+
1359
+ The single column ID that represents task completion. Two behaviors ride on this:
1360
+
1361
+ - The board ignores manual \`layout\` order for this column and time-sorts by \`updated\` desc (most-recently-touched at the top). Within-column drag reorder is disabled.
1362
+ - \`done_display\` (below) applies to it.
1363
+
1364
+ ### \`done_display\`
1365
+
1366
+ Controls how the done column is rendered.
1367
+
1368
+ - \`max\` \u2014 number of most-recent done tasks shown by default. Older tasks live behind a \`Show N more\` toggle. Set to \`0\` (or any negative number) to disable the cap and show everything. Search bypasses the cap so hidden matches remain findable.
1369
+
1370
+ Omit \`done_display\` to fall back to \`{ "max": 20 }\`.
1371
+
1372
+ ### \`priority\`
1373
+
1374
+ - \`values\` \u2014 the allowed \`priority\` values for task frontmatter.
1375
+ - \`default\` \u2014 the value assigned when a task is created without a priority. Must appear in \`values\`.
1376
+
1377
+ Priority is optional on individual tasks; it just needs to match \`values\` when set.
1378
+
1379
+ ### \`schema.custom_fields\`
1380
+
1381
+ Reserved for future user-defined frontmatter fields. Ignored today. Safe to leave as \`{}\`.
1382
+
1383
+ ## \`layout.json\`
1384
+
1385
+ \`layout.json\` is a partial, machine-managed index of manual placements. It is not a complete snapshot of visible board order: it can omit whole columns and unlisted tasks, never retains \`done_column\`, may carry stale slugs until a relevant write prunes them, and may be absent or gitignored.
1386
+
1387
+ Visible order comes from \`config.json\`, the task files, and this partial index together:
1388
+
1389
+ - Configured columns render in \`config.columns\` order, followed by Uncategorized tasks.
1390
+ - Tasks whose slug appears in \`layout[columnId]\` render in that order.
1391
+ - Tasks with a matching status but no layout entry render below, sorted by \`created\` desc.
1392
+ - Slugs in \`layout\` that don't correspond to a task on disk are ignored at render time.
1393
+ - The Uncategorized column and the \`done_column\` both ignore \`layout\` entirely.
1394
+ - The CLI and Board do not record \`layout[done_column]\`; any existing entry is removed on the next layout write.
1395
+
1396
+ Do not read \`layout.json\` alone to determine board order. \`shipbench task list\` returns live tasks in canonical board order, and its JSON output includes each task's zero-based \`position\` within its column. Code clients can apply \`orderedTasksForColumn\` to the task files. Treat the index as machine-managed: do not hand-edit or hand-order it. You may gitignore it if ordering should stay machine-local, but Harbor and fresh clones will then fall back to deterministic \`created\`-descending order for unlisted tasks.
1397
+
1398
+ ## Task files
1399
+
1400
+ Every file in \`tasks/\` is a Markdown document with a YAML frontmatter block. See \`AGENTS.md\` for the frontmatter schema and field rules \u2014 the same rules apply whether a human or an agent is editing.
1401
+
1402
+ Read the narrowest thing that answers the question. Because each task has a slug, read one task when one task is enough. Use list, search, or archive reads only for broader questions.
1403
+
1404
+ Each task may end with a reserved \`## Task Updates\` section. Use it for time-anchored decisions, pivots, and external events that would lose meaning without their timestamp. Keep timeless facts in the description instead. Append with \`shipbench task comment <slug> "What changed and why."\`, edit text with \`shipbench task comment edit <slug> <index> "Corrected text."\`, or delete with \`shipbench task comment delete <slug> <index>\`. Indices are zero-based. Edits preserve the entry's timestamp; Git preserves earlier text and deleted entries.
1405
+
1406
+ Archived tasks live in \`tasks/archive/\` and are excluded from normal board reads. Archiving moves the file without changing its frontmatter or timestamps; unarchiving restores the same file to \`tasks/\`.
1407
+ `;
1408
+ }
1409
+ function generateAgentsMd(name) {
1410
+ const config = DEFAULT_CONFIG;
1411
+ const validStatuses = config.columns.map((c) => c.id).join(", ");
1412
+ const validPriorities = config.priority.values.join(", ");
1413
+ return `# ${name} \u2014 ShipBench Agent Instructions
1414
+
1415
+ This file describes how to interact with the ShipBench task board for **${name}**.
1416
+
1417
+ ## Directory Structure
1418
+
1419
+ \`\`\`
1420
+ .shipbench/
1421
+ config.json # Board configuration \u2014 read this for valid values
1422
+ layout.json # Partial placement index \u2014 do not read as visible order
1423
+ tasks/ # One Markdown file per task
1424
+ <slug>.md
1425
+ archive/ # Archived tasks \u2014 do not read unless asked
1426
+ <slug>.md
1427
+ \`\`\`
1428
+
1429
+ ## Task File Format
1430
+
1431
+ Each task is a Markdown file with YAML frontmatter:
1432
+
1433
+ \`\`\`markdown
1434
+ ---
1435
+ title: Task title here
1436
+ status: todo
1437
+ priority: medium
1438
+ assignee:
1439
+ tags: []
1440
+ depends_on: []
1441
+ created: 2024-01-01T00:00:00.000Z
1442
+ updated: 2024-01-01T00:00:00.000Z
1443
+ ---
1444
+
1445
+ Task description in Markdown.
1446
+ \`\`\`
1447
+
1448
+ ## Field Rules
1449
+
1450
+ - **title** (required): Display name of the task.
1451
+ - **status** (required): Must be one of: ${validStatuses}. Read \`config.json\` columns for current valid values.
1452
+ - **priority** (optional): Must be one of: ${validPriorities}. Defaults to "${config.priority.default}".
1453
+ - **assignee** (optional): Freeform string label (e.g. \`claude\`, \`antigravity\`, or \`human\`). Informational only \u2014 task eligibility is governed strictly by \`status\` and \`depends_on\`. Moving a task to \`in-progress\` signals that work has started.
1454
+ - **tags** (optional): Array of freeform strings.
1455
+ - **depends_on** (optional): Array of task slugs that must be finished before this task can start. An omitted field and an empty array mean the same thing. A slug must name a task file that exists; a task may not depend on itself, and two tasks may not depend on each other.
1456
+ - **created** (required): ISO 8601 timestamp. Set once on creation, never modify.
1457
+ - **updated** (required): ISO 8601 timestamp. Update on every modification.
1458
+
1459
+ ## Task Updates
1460
+
1461
+ A task may end with a reserved \`## Task Updates\` section containing timestamped entries:
1462
+
1463
+ \`\`\`markdown
1464
+ ## Task Updates
1465
+
1466
+ ### 2026-07-24T20:00:00.000Z
1467
+ Raised priority after the customer escalation.
1468
+ \`\`\`
1469
+
1470
+ Before adding an entry, ask: **Would this fact still be true or relevant regardless of when it happened?** If yes, edit the task description in place. If its meaning depends on a moment \u2014 a decision, pivot, scope change, or external event \u2014 add an Update.
1471
+
1472
+ This heuristic is guidance, not a validation rule. Core stores each entry as \`{ timestamp, text }\` and never judges or reformats the prose. A project may use Updates as a general comments log if that serves its workflow.
1473
+
1474
+ Append through \`shipbench task comment <slug> "What changed and why."\`. Edit text with \`shipbench task comment edit <slug> <index> "Corrected text."\`; delete an entry with \`shipbench task comment delete <slug> <index>\`. Indices are zero-based. Editing never changes the entry's timestamp. Git preserves earlier text and deleted entries.
1475
+
1476
+ Do not hand-edit content below the \`## Task Updates\` marker when the CLI is available.
1477
+
1478
+ ## Choosing What to Work On
1479
+
1480
+ Read the narrowest thing that answers your question. Because each task has a slug, use a body-free list or search to narrow the candidates, then run \`task get\` or read one \`.shipbench/tasks/<slug>.md\` file. Read multiple descriptions or archived tasks only when needed.
1481
+
1482
+ \`depends_on\` is the authoritative dependency signal. Start with this read-only query:
1483
+
1484
+ \`\`\`bash
1485
+ shipbench task list --available --json
1486
+ \`\`\`
1487
+
1488
+ \`--available\` selects tasks from the configured default column whose dependencies are all in the \`${config.done_column}\` column or \`tasks/archive/\`. Archived dependencies count as satisfied. Results are ranked by configured priority, then oldest creation time, so the first result is a useful candidate rather than a mandatory assignment.
1489
+
1490
+ That ranking is not the board's order. \`--available\` sorts by priority and age and does not read manual placement, while a plain \`shipbench task list\` returns the order the columns are actually arranged in. The two can disagree \u2014 a task sitting first in its column may come back third here \u2014 and neither is the more correct answer. JSON carries both: the array is in ranked order, and each task's \`position\` is its board placement, computed before the ranking. Read whichever answers the question you have.
1491
+
1492
+ Narrow the candidate set without loading every description:
1493
+
1494
+ \`\`\`bash
1495
+ shipbench task list --available --tag backend --json
1496
+ shipbench task list --available --tag backend,auth --assignee agent --json
1497
+ \`\`\`
1498
+
1499
+ \`--tag\` accepts comma-separated values or repeated flags and uses AND semantics. \`--status\`, \`--assignee\`, \`--priority\`, and \`--limit\` can narrow the same query. Use \`--status\` when the project's actionable column differs from its configured default.
1500
+
1501
+ After selecting a slug, load that task's full frontmatter, description, and Updates:
1502
+
1503
+ \`\`\`bash
1504
+ shipbench task get <slug>
1505
+ \`\`\`
1506
+
1507
+ Use the other discovery commands when the task needs more context:
1508
+
1509
+ - **Diagnose blocked work**: \`shipbench task list --blocked --json\`
1510
+ - **Search titles, tags, and descriptions**: \`shipbench task search "<query>" --json\`
1511
+ - **Load complete matching descriptions**: \`shipbench task search "<query>" --json --include-body\`
1512
+ - **Search live and archived tasks**: \`shipbench task search "<query>" --all --json\`
1513
+ - **Inspect the dependency DAG**: \`shipbench task graph --json\` (add \`--archived\` to resolve archived nodes)
1514
+ - **List archived tasks**: \`shipbench task list --archived --json\`
1515
+
1516
+ Following that principle, add \`--include-body\` to a JSON \`task list\` only when you need every returned description and Updates array. Add it to a JSON \`task search\` when you need complete matching descriptions instead of snippets. Prefer \`task get\` after narrowing when one matching task answers the question.
1517
+
1518
+ \`--available\` and \`--blocked\` are mutually exclusive and cannot be combined with \`--archived\`.
1519
+
1520
+ A task with unfinished dependencies is not ready, even if nothing prevents you from editing it \u2014 \`depends_on\` is data, not a lock.
1521
+
1522
+ Prose sections in a task body (\`## Depends on\`, \`## Blocked by\`, and similar) are commentary. Read them for context, but do not treat them as the dependency graph.
1523
+
1524
+ Note that \`depends_on\` and the task's column are orthogonal. A column says where a task is; \`depends_on\` says what has to land first.
1525
+
1526
+ ## Reading Board Order
1527
+
1528
+ \`layout.json\` is a partial, machine-managed index, not the visible order. It can omit \`done_column\`, unlisted tasks, and whole columns; retain stale slugs until another layout write; or be absent or gitignored. Reading it alone can therefore give the wrong answer.
1529
+
1530
+ \`shipbench task list\` reports live tasks in configured column order and visible within-column order; JSON output includes each task's zero-based \`position\` within its column. Code clients can apply \`orderedTasksForColumn\` to the task files. When working directly with the plain files, combine task statuses with \`config.json\` and the ordering rules in \`README.md\`; do not use \`layout.json\` alone as the answer.
1531
+
1532
+ ## Changing Board Order
1533
+
1534
+ \`shipbench task move\` accepts placement flags \u2014 \`--top\`, \`--bottom\`, \`--before <slug>\`, \`--after <slug>\`, and \`--position <n>\` (0-based, \`-1\` appends) \u2014 and \`--to\` is optional, so omitting it reorders within the task's current column. Anchors are the clearer interface: \`--before build-api\` states an intent, while a raw index depends on what the column looks like right now. Placement flags are mutually exclusive and cannot target the done column, which is always sorted by \`updated\` desc.
1535
+
1536
+ This is the only sanctioned way to reorder. \`layout.json\` stays off-limits to hand edits.
1537
+
1538
+ Ordering is a human judgment call, so reorder only when the user explicitly asks \u2014 never as a side effect of other board work, the same posture as \`task delete\`.
1539
+
1540
+ ## File Naming
1541
+
1542
+ - Filenames are slugified from the title: lowercase, hyphens for non-slug characters, no special characters.
1543
+ - If a slug already exists in either \`tasks/\` or \`tasks/archive/\`, append a numeric suffix: \`my-task-2.md\`. Archived slugs are never reused.
1544
+
1545
+ ## Operations
1546
+
1547
+ Prefer the ShipBench CLI for task mutations when it is available. The CLI routes through core, so slug generation, validation, timestamps, collision handling, and layout updates stay consistent.
1548
+
1549
+ ### Preferred CLI Operations
1550
+
1551
+ - **List available tasks**: \`shipbench task list --available --json\`
1552
+ - **List blocked tasks**: \`shipbench task list --blocked --json\`
1553
+ - **Filter by tags**: \`shipbench task list --available --tag backend,auth --json\`
1554
+ - **Read one task**: \`shipbench task get <slug>\`
1555
+ - **Search tasks**: \`shipbench task search "<query>" --json\`
1556
+ - **Inspect dependencies**: \`shipbench task graph --json\`
1557
+ - **Include descriptions in a list**: \`shipbench task list --json --include-body\`
1558
+ - **Create a task**: \`shipbench task create "Task title" --status=todo\`
1559
+ - **Create a dependent task**: \`shipbench task create "Task title" --depends-on=other-slug,another-slug\`
1560
+ - **Add a time-anchored update**: \`shipbench task comment <slug> "What changed and why."\`
1561
+ - **Edit an update's text**: \`shipbench task comment edit <slug> <index> "Corrected text."\`
1562
+ - **Delete an update**: \`shipbench task comment delete <slug> <index>\`
1563
+ - **Move a task**: \`shipbench task move <slug> --to=in-progress\`
1564
+ - **Complete a task**: \`shipbench task move <slug> --to=done\`
1565
+ - **Reorder a task when explicitly asked**: \`shipbench task move <slug> --before=other-slug\` (also \`--top\`, \`--bottom\`, \`--after\`, \`--position <n>\`)
1566
+ - **Archive a task**: \`shipbench task archive <slug>\`
1567
+ - **Bulk archive done tasks when explicitly requested**: \`shipbench task archive --done\` (add \`--keep=N\` to retain a specific number)
1568
+ - **List archived tasks**: \`shipbench task list --archived\`
1569
+ - **Unarchive a task**: \`shipbench task unarchive <slug>\`
1570
+ - **Delete a task**: \`shipbench task delete <slug>\`
1571
+ - **Open the board**: \`shipbench board\`
1572
+ - **Watch the board in the terminal**: \`shipbench board terminal\` (read-only; \`--status\`, \`--tag\`, \`--assignee\`, \`--priority\` narrow it)
1573
+
1574
+ ### Direct File Operations
1575
+
1576
+ Use direct edits only when the CLI is unavailable or when changing task description/frontmatter fields the CLI does not support yet.
1577
+
1578
+ - **Create a task**: Add a new \`.md\` file in \`tasks/\` following the format above.
1579
+ - **Move a task**: Change the \`status\` field and update the \`updated\` timestamp.
1580
+ - **Edit a task**: Modify frontmatter fields and/or the description above \`## Task Updates\`. Always update \`updated\`.
1581
+ - **Add an Update without the CLI**: Append a \`### <ISO 8601 timestamp>\` heading and text below the trailing \`## Task Updates\` marker.
1582
+ - **Edit an Update without the CLI**: Change only its text; preserve the \`###\` timestamp heading and update the frontmatter \`updated\` value.
1583
+ - **Delete an Update without the CLI**: Remove its heading and text, remove an empty \`## Task Updates\` section, and update the frontmatter \`updated\` value.
1584
+ - **Delete a task**: Remove the \`.md\` file.
1585
+
1586
+ ## Important
1587
+
1588
+ - Never invent status values not listed in \`config.json\`.
1589
+ - Reorder tasks only when the user explicitly asks for it.
1590
+ - Always update the \`updated\` timestamp when modifying a task.
1591
+ - Do not modify \`config.json\` unless explicitly asked.
1592
+ - Do not read \`layout.json\` as the visible order or modify it; the CLI and Board own this partial index.
1593
+ - Do not read or modify \`tasks/archive/\` unless the user explicitly asks about archived work.
1594
+ - Do not modify the \`created\` timestamp.
1595
+ `;
1596
+ }
1597
+ function generateWelcomeTask(name) {
1598
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1599
+ const status = DEFAULT_CONFIG.default_column;
1600
+ const priority = DEFAULT_CONFIG.priority.default;
1601
+ return `---
1602
+ title: Welcome to ${name}
1603
+ status: ${status}
1604
+ priority: ${priority}
1605
+ tags: [getting-started]
1606
+ created: ${now}
1607
+ updated: ${now}
1608
+ ---
1609
+
1610
+ Your ShipBench project board for **${name}** is set up and ready to go.
1611
+
1612
+ ## Next Steps
1613
+
1614
+ - Create new tasks with \`shipbench task create "My first task"\`
1615
+ - Open the board with \`shipbench board\`, or watch it in a terminal pane with \`shipbench board terminal\`
1616
+ - Edit this file or delete it when you're ready
1617
+ `;
1618
+ }
1619
+ async function initProject(adapter, options) {
1620
+ const initialState = await inspectProjectInitialization(adapter);
1621
+ if (initialState.kind === "initialized") {
1622
+ return {
1623
+ created: false,
1624
+ config: initialState.config,
1625
+ warnings: initialState.warnings
1626
+ };
1627
+ }
1628
+ if (initialState.kind !== "missing") {
1629
+ throw new ProjectInitializationError(initialState);
1630
+ }
1631
+ const { layout: _layout, ...defaultConfig } = DEFAULT_CONFIG;
1632
+ const config = { ...defaultConfig, name: options.name };
1633
+ await adapter.writeFiles(
1634
+ /* @__PURE__ */ new Map([
1635
+ [".shipbench/config.json", `${JSON.stringify(config, null, 2)}
1636
+ `],
1637
+ [".shipbench/layout.json", "{}\n"],
1638
+ [".shipbench/README.md", generateReadme(options.name)],
1639
+ [".shipbench/AGENTS.md", generateAgentsMd(options.name)],
1640
+ [
1641
+ ".shipbench/tasks/welcome-to-shipbench.md",
1642
+ generateWelcomeTask(options.name)
1643
+ ]
1644
+ ])
1645
+ );
1646
+ const createdState = await inspectProjectInitialization(adapter);
1647
+ if (createdState.kind !== "initialized") {
1648
+ if (createdState.kind === "missing") {
1649
+ throw new Error("ShipBench initialization did not create config.json.");
1650
+ }
1651
+ throw new ProjectInitializationError(createdState);
1652
+ }
1653
+ return {
1654
+ created: true,
1655
+ config: createdState.config,
1656
+ warnings: createdState.warnings
1657
+ };
1658
+ }
1659
+
1660
+ // src/search.ts
1661
+ var SNIPPET_CONTEXT_BEFORE = 40;
1662
+ var SNIPPET_CONTEXT_AFTER = 80;
1663
+ function bodySnippet(normalizedBody, normalizedTerms) {
1664
+ const lowercaseBody = normalizedBody.toLowerCase();
1665
+ let matchIndex = -1;
1666
+ let matchLength = 0;
1667
+ for (const term of normalizedTerms) {
1668
+ const termIndex = lowercaseBody.indexOf(term);
1669
+ if (termIndex !== -1 && (matchIndex === -1 || termIndex < matchIndex || termIndex === matchIndex && term.length > matchLength)) {
1670
+ matchIndex = termIndex;
1671
+ matchLength = term.length;
1672
+ }
1673
+ }
1674
+ if (matchIndex === -1) return void 0;
1675
+ const start = Math.max(0, matchIndex - SNIPPET_CONTEXT_BEFORE);
1676
+ const end = Math.min(
1677
+ normalizedBody.length,
1678
+ matchIndex + matchLength + SNIPPET_CONTEXT_AFTER
1679
+ );
1680
+ const excerpt = normalizedBody.slice(start, end).trim();
1681
+ return `${start > 0 ? "\u2026" : ""}${excerpt}${end < normalizedBody.length ? "\u2026" : ""}`;
1682
+ }
1683
+ function searchTasks(tasks, query) {
1684
+ const normalizedQuery = query.trim().toLowerCase();
1685
+ if (!normalizedQuery) return [];
1686
+ const normalizedTerms = normalizedQuery.split(/\s+/);
1687
+ const matches = [];
1688
+ for (const task of tasks) {
1689
+ const normalizedTitle = task.frontmatter.title.toLowerCase();
1690
+ const normalizedTags = (task.frontmatter.tags ?? []).map(
1691
+ (tag) => tag.toLowerCase()
1692
+ );
1693
+ const normalizedBody = task.body.replace(/\s+/g, " ").trim();
1694
+ const lowercaseBody = normalizedBody.toLowerCase();
1695
+ const everyTermMatches = normalizedTerms.every(
1696
+ (term) => normalizedTitle.includes(term) || normalizedTags.some((tag) => tag.includes(term)) || lowercaseBody.includes(term)
1697
+ );
1698
+ if (!everyTermMatches) continue;
1699
+ const matchedFields = [];
1700
+ if (normalizedTerms.some((term) => normalizedTitle.includes(term))) {
1701
+ matchedFields.push("title");
1702
+ }
1703
+ if (normalizedTerms.some(
1704
+ (term) => normalizedTags.some((tag) => tag.includes(term))
1705
+ )) {
1706
+ matchedFields.push("tags");
1707
+ }
1708
+ const snippet = bodySnippet(normalizedBody, normalizedTerms);
1709
+ if (snippet !== void 0) matchedFields.push("body");
1710
+ matches.push({
1711
+ slug: task.slug,
1712
+ title: task.frontmatter.title,
1713
+ matched_fields: matchedFields,
1714
+ ...snippet !== void 0 ? { snippet } : {}
1715
+ });
1716
+ }
1717
+ return matches;
1718
+ }
1719
+ export {
1720
+ ArchiveBlockedError,
1721
+ DEFAULT_CONFIG,
1722
+ FsAdapter,
1723
+ GitHubAdapter,
1724
+ GitHubApiError,
1725
+ ProjectInitializationError,
1726
+ addComment,
1727
+ archiveTask,
1728
+ buildTaskDependencyGraph,
1729
+ byCreatedDesc,
1730
+ byUpdatedDesc,
1731
+ createTask,
1732
+ createTaskDependencyIndex,
1733
+ deleteComment,
1734
+ deleteTask,
1735
+ dependencyStatus,
1736
+ editComment,
1737
+ getTask,
1738
+ initProject,
1739
+ inspectProjectInitialization,
1740
+ layoutAfterMove,
1741
+ layoutWithoutTask,
1742
+ listArchivedTasks,
1743
+ listAvailableTasks,
1744
+ listBlockedTasks,
1745
+ listTasks,
1746
+ loadConfig,
1747
+ moveTask,
1748
+ normalizeGithubRemoteUrl,
1749
+ normalizeGithubUrl,
1750
+ orderedTasksForColumn,
1751
+ parseGithubRemoteUrl,
1752
+ parseGithubUrl,
1753
+ reorderTask,
1754
+ resolveSlugCollision,
1755
+ resolveTaskDependency,
1756
+ searchTasks,
1757
+ slugify,
1758
+ taskDependenciesAreSatisfied,
1759
+ taskFileSlugs,
1760
+ unarchiveTask,
1761
+ updateTask,
1762
+ validateConfig
1763
+ };