shipbench 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +96 -25
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -4029,6 +4029,8 @@ var CONFIG_PATH2 = ".shipbench/config.json";
|
|
|
4029
4029
|
var LAYOUT_PATH2 = ".shipbench/layout.json";
|
|
4030
4030
|
var UPDATES_HEADING = "## Task Updates";
|
|
4031
4031
|
var ISO_8601_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
4032
|
+
var ATX_HEADING = /^(#{1,6})\s+(.+?)\s*$/;
|
|
4033
|
+
var ENTRY_HEADING_TEXT = /^\d{4}-\d{2}-\d{2}/;
|
|
4032
4034
|
var updatesParseWarnings = /* @__PURE__ */ new WeakMap();
|
|
4033
4035
|
var ArchiveBlockedError = class extends Error {
|
|
4034
4036
|
constructor(slug, dependentSlugs) {
|
|
@@ -4109,11 +4111,17 @@ function parseTaskBody(rawBody) {
|
|
|
4109
4111
|
const outsideFence = fence === null;
|
|
4110
4112
|
const fenceOnLine = /^\s{0,3}(`{3,}|~{3,})/.test(line);
|
|
4111
4113
|
if (outsideFence && !fenceOnLine) {
|
|
4112
|
-
const heading = line.match(
|
|
4113
|
-
if (heading) {
|
|
4114
|
+
const heading = line.match(ATX_HEADING);
|
|
4115
|
+
if (heading && ENTRY_HEADING_TEXT.test(heading[2])) {
|
|
4116
|
+
if (heading[1].length !== 3) {
|
|
4117
|
+
return malformedUpdates(
|
|
4118
|
+
body,
|
|
4119
|
+
`expected each entry heading to use "### <ISO 8601 timestamp>".`
|
|
4120
|
+
);
|
|
4121
|
+
}
|
|
4114
4122
|
const previousError = finishComment();
|
|
4115
4123
|
if (previousError) return malformedUpdates(body, previousError);
|
|
4116
|
-
const nextTimestamp = heading[
|
|
4124
|
+
const nextTimestamp = heading[2];
|
|
4117
4125
|
if (!ISO_8601_TIMESTAMP.test(nextTimestamp) || Number.isNaN(Date.parse(nextTimestamp))) {
|
|
4118
4126
|
return malformedUpdates(
|
|
4119
4127
|
body,
|
|
@@ -4124,12 +4132,6 @@ function parseTaskBody(rawBody) {
|
|
|
4124
4132
|
textLines = [];
|
|
4125
4133
|
continue;
|
|
4126
4134
|
}
|
|
4127
|
-
if (/^#{1,6}(?:\s|$)/.test(line)) {
|
|
4128
|
-
return malformedUpdates(
|
|
4129
|
-
body,
|
|
4130
|
-
`expected each entry heading to use "### <ISO 8601 timestamp>".`
|
|
4131
|
-
);
|
|
4132
|
-
}
|
|
4133
4135
|
}
|
|
4134
4136
|
if (timestamp === null) {
|
|
4135
4137
|
if (line.trim()) {
|
|
@@ -4163,6 +4165,35 @@ function assertBodyWithoutUpdatesMarker(body) {
|
|
|
4163
4165
|
}
|
|
4164
4166
|
fence = updateFence(line, fence);
|
|
4165
4167
|
}
|
|
4168
|
+
if (fence) {
|
|
4169
|
+
throw new Error(
|
|
4170
|
+
"Invalid task description: close the code fence this description opens. An open fence runs past the end of the description and hides the Updates section from every read."
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
function assertCommentTextIsParsable(text) {
|
|
4175
|
+
let fence = null;
|
|
4176
|
+
for (const line of text.split(/\r?\n/)) {
|
|
4177
|
+
if (!fence) {
|
|
4178
|
+
if (line.trimEnd() === UPDATES_HEADING) {
|
|
4179
|
+
throw new Error(
|
|
4180
|
+
`Invalid task update: remove the "${UPDATES_HEADING}" heading \u2014 a second one would leave the section unreadable. Put it in a code fence if the update means it literally.`
|
|
4181
|
+
);
|
|
4182
|
+
}
|
|
4183
|
+
const heading = line.match(ATX_HEADING);
|
|
4184
|
+
if (heading && ENTRY_HEADING_TEXT.test(heading[2])) {
|
|
4185
|
+
throw new Error(
|
|
4186
|
+
`Invalid task update: "${line.trim()}" reads as an entry heading and would split this update in two. Indent it, fence it, or drop the leading "#".`
|
|
4187
|
+
);
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
fence = updateFence(line, fence);
|
|
4191
|
+
}
|
|
4192
|
+
if (fence) {
|
|
4193
|
+
throw new Error(
|
|
4194
|
+
"Invalid task update: close the code fence this update opens. An open fence runs past the end of the update and swallows the entries below it."
|
|
4195
|
+
);
|
|
4196
|
+
}
|
|
4166
4197
|
}
|
|
4167
4198
|
function parseFrontmatter(fileContent) {
|
|
4168
4199
|
try {
|
|
@@ -4462,6 +4493,7 @@ async function addComment(adapter, config, slug, text) {
|
|
|
4462
4493
|
if (!normalizedText) {
|
|
4463
4494
|
throw new Error("Task update text must not be blank.");
|
|
4464
4495
|
}
|
|
4496
|
+
assertCommentTextIsParsable(normalizedText);
|
|
4465
4497
|
const path = `${TASKS_DIR}/${slug}.md`;
|
|
4466
4498
|
const content = await adapter.readFile(path);
|
|
4467
4499
|
const task = parseTaskFile(slug, content);
|
|
@@ -4497,6 +4529,7 @@ async function editComment(adapter, config, slug, index, text) {
|
|
|
4497
4529
|
if (!normalizedText) {
|
|
4498
4530
|
throw new Error("Task update text must not be blank.");
|
|
4499
4531
|
}
|
|
4532
|
+
assertCommentTextIsParsable(normalizedText);
|
|
4500
4533
|
const path = `${TASKS_DIR}/${slug}.md`;
|
|
4501
4534
|
const content = await adapter.readFile(path);
|
|
4502
4535
|
const task = parseTaskFile(slug, content);
|
|
@@ -4835,6 +4868,8 @@ Write a description with the task instead of after it: \`shipbench task create "
|
|
|
4835
4868
|
|
|
4836
4869
|
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.
|
|
4837
4870
|
|
|
4871
|
+
Both commands also take \`--body <text>\` and \`--body-file <path>\` in place of the positional text, and \`--body-file\` is the one to reach for when an update runs to several lines: ShipBench reads the file as UTF-8 itself, so the prose never passes through shell quoting or a shell's encoding.
|
|
4872
|
+
|
|
4838
4873
|
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/\`.
|
|
4839
4874
|
`;
|
|
4840
4875
|
}
|
|
@@ -4905,7 +4940,11 @@ This heuristic is guidance, not a validation rule. Core stores each entry as \`{
|
|
|
4905
4940
|
|
|
4906
4941
|
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.
|
|
4907
4942
|
|
|
4908
|
-
|
|
4943
|
+
Append and edit both accept \`--body <text>\` or \`--body-file <path>\` instead of the positional text, the same pair \`task create\` and \`task edit\` take. Use \`--body-file\` for anything multi-line: ShipBench reads the file as UTF-8, so the text never passes through shell quoting or encoding.
|
|
4944
|
+
|
|
4945
|
+
Update text is prose. Markdown headings inside it are yours to use \u2014 only a column-0 \`### <ISO 8601 timestamp>\` line opens a new entry. Three things are rejected on write, because the next read would mis-file them: a \`## Task Updates\` heading of its own, a column-0 heading whose text is a date, and an unclosed code fence.
|
|
4946
|
+
|
|
4947
|
+
A description may not contain a \`## Task Updates\` heading of its own \u2014 the next read would file part of it as entries, so ShipBench rejects the write. It also may not leave a code fence open, which would swallow the marker below it and hide every entry. Put the heading in a code fence when a description means it literally.
|
|
4909
4948
|
|
|
4910
4949
|
Do not hand-edit content below the \`## Task Updates\` marker when the CLI is available.
|
|
4911
4950
|
|
|
@@ -4994,7 +5033,8 @@ Prefer the ShipBench CLI for task mutations when it is available. The CLI routes
|
|
|
4994
5033
|
- **Rewrite a description**: \`shipbench task edit <slug> --body-file=description.md\` (replaces it whole; \`--body ""\` clears it)
|
|
4995
5034
|
- **Create a dependent task**: \`shipbench task create "Task title" --depends-on=other-slug,another-slug\`
|
|
4996
5035
|
- **Add a time-anchored update**: \`shipbench task comment <slug> "What changed and why."\`
|
|
4997
|
-
- **
|
|
5036
|
+
- **Add a multi-line update**: \`shipbench task comment <slug> --body-file update.md\`
|
|
5037
|
+
- **Edit an update's text**: \`shipbench task comment edit <slug> <index> "Corrected text."\` (also takes \`--body-file\`)
|
|
4998
5038
|
- **Delete an update**: \`shipbench task comment delete <slug> <index>\`
|
|
4999
5039
|
- **Move a task**: \`shipbench task move <slug> --to=in-progress\`
|
|
5000
5040
|
- **Complete a task**: \`shipbench task move <slug> --to=done\`
|
|
@@ -6587,7 +6627,7 @@ async function runTui(options2) {
|
|
|
6587
6627
|
}
|
|
6588
6628
|
|
|
6589
6629
|
// src/cli.ts
|
|
6590
|
-
var VERSION = true ? "0.
|
|
6630
|
+
var VERSION = true ? "0.4.0" : "0.0.0-dev";
|
|
6591
6631
|
function commaList(value) {
|
|
6592
6632
|
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6593
6633
|
}
|
|
@@ -6677,10 +6717,9 @@ function formatTaskDependencyGraph(graph) {
|
|
|
6677
6717
|
return lines;
|
|
6678
6718
|
}
|
|
6679
6719
|
function bodyOption() {
|
|
6680
|
-
return new Option(
|
|
6681
|
-
"
|
|
6682
|
-
|
|
6683
|
-
).conflicts(["bodyFile"]);
|
|
6720
|
+
return new Option("--body <text>", "Description as Markdown text").conflicts([
|
|
6721
|
+
"bodyFile"
|
|
6722
|
+
]);
|
|
6684
6723
|
}
|
|
6685
6724
|
function bodyFileOption() {
|
|
6686
6725
|
return new Option(
|
|
@@ -6688,6 +6727,17 @@ function bodyFileOption() {
|
|
|
6688
6727
|
'Read the description from a UTF-8 file; "-" reads stdin'
|
|
6689
6728
|
).conflicts(["body"]);
|
|
6690
6729
|
}
|
|
6730
|
+
function updateTextOption() {
|
|
6731
|
+
return new Option("--body <text>", "Update text as Markdown").conflicts([
|
|
6732
|
+
"bodyFile"
|
|
6733
|
+
]);
|
|
6734
|
+
}
|
|
6735
|
+
function updateTextFileOption() {
|
|
6736
|
+
return new Option(
|
|
6737
|
+
"--body-file <path>",
|
|
6738
|
+
'Read the update text from a UTF-8 file; "-" reads stdin'
|
|
6739
|
+
).conflicts(["body"]);
|
|
6740
|
+
}
|
|
6691
6741
|
async function readProcessStdin() {
|
|
6692
6742
|
const chunks = [];
|
|
6693
6743
|
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
@@ -6734,7 +6784,17 @@ function createCli(opts) {
|
|
|
6734
6784
|
);
|
|
6735
6785
|
}
|
|
6736
6786
|
};
|
|
6737
|
-
const
|
|
6787
|
+
const resolveUpdateText = async (command, positional, raw) => {
|
|
6788
|
+
const fromOption = await resolveBody(raw);
|
|
6789
|
+
if (positional != null && fromOption !== void 0) {
|
|
6790
|
+
command.error(
|
|
6791
|
+
"Pass the update text once: either positionally or with --body / --body-file, not both."
|
|
6792
|
+
);
|
|
6793
|
+
return void 0;
|
|
6794
|
+
}
|
|
6795
|
+
return positional ?? fromOption;
|
|
6796
|
+
};
|
|
6797
|
+
const program = new Command().name("shipbench").description("Git-native project management for solo developers.").enablePositionalOptions().version(VERSION, "-v, --version", "output the version").option(
|
|
6738
6798
|
"-C <path>",
|
|
6739
6799
|
"Run as if ShipBench was started in the specified directory"
|
|
6740
6800
|
);
|
|
@@ -6931,19 +6991,30 @@ function createCli(opts) {
|
|
|
6931
6991
|
body.trim() ? `Updated description on ${edited.slug}` : `Cleared description on ${edited.slug}`
|
|
6932
6992
|
);
|
|
6933
6993
|
});
|
|
6934
|
-
const
|
|
6935
|
-
|
|
6994
|
+
const BODY_FILE_HELP = "\nPrefer --body-file for anything multi-line: the file is read as UTF-8 by\nShipBench, so the text never passes through shell quoting or encoding.\n";
|
|
6995
|
+
const commentCommand = task.command("comment").description("Manage timestamped entries in the task Updates section").argument("[slug]", "Task slug").argument("[text]", "Update text").addOption(updateTextOption()).addOption(updateTextFileOption()).addHelpText("after", BODY_FILE_HELP);
|
|
6996
|
+
commentCommand.action(async (slug, text, raw) => {
|
|
6997
|
+
const resolved = await resolveUpdateText(commentCommand, text, raw);
|
|
6998
|
+
if (!slug || resolved === void 0) {
|
|
6936
6999
|
throw new InvalidArgumentError(
|
|
6937
|
-
"Append requires a task slug and update text."
|
|
7000
|
+
"Append requires a task slug and update text (positional, --body <text>, or --body-file <path>)."
|
|
6938
7001
|
);
|
|
6939
7002
|
}
|
|
6940
7003
|
const config = await loadCliConfig();
|
|
6941
|
-
const updated = await addComment(adapter, config, slug,
|
|
7004
|
+
const updated = await addComment(adapter, config, slug, resolved);
|
|
6942
7005
|
chrome(`Added update to ${updated.slug}`);
|
|
6943
7006
|
});
|
|
6944
|
-
|
|
7007
|
+
const commentEditCommand = commentCommand.command("edit <slug> <index> [text]").description(
|
|
6945
7008
|
"Edit an Updates entry by zero-based index without changing its timestamp"
|
|
6946
|
-
).
|
|
7009
|
+
).addOption(updateTextOption()).addOption(updateTextFileOption()).addHelpText("after", BODY_FILE_HELP);
|
|
7010
|
+
commentEditCommand.action(async (slug, index, text, raw) => {
|
|
7011
|
+
const resolved = await resolveUpdateText(commentEditCommand, text, raw);
|
|
7012
|
+
if (resolved === void 0) {
|
|
7013
|
+
commentEditCommand.error(
|
|
7014
|
+
"Provide the replacement text positionally, or with --body <text> or --body-file <path>."
|
|
7015
|
+
);
|
|
7016
|
+
return;
|
|
7017
|
+
}
|
|
6947
7018
|
const config = await loadCliConfig();
|
|
6948
7019
|
const parsedIndex = parseNonNegativeInteger(index);
|
|
6949
7020
|
const updated = await editComment(
|
|
@@ -6951,11 +7022,11 @@ function createCli(opts) {
|
|
|
6951
7022
|
config,
|
|
6952
7023
|
slug,
|
|
6953
7024
|
parsedIndex,
|
|
6954
|
-
|
|
7025
|
+
resolved
|
|
6955
7026
|
);
|
|
6956
7027
|
chrome(`Edited update ${parsedIndex} on ${updated.slug}`);
|
|
6957
7028
|
});
|
|
6958
|
-
|
|
7029
|
+
commentCommand.command("delete <slug> <index>").description("Delete an Updates entry by zero-based index").action(async (slug, index) => {
|
|
6959
7030
|
const config = await loadCliConfig();
|
|
6960
7031
|
const parsedIndex = parseNonNegativeInteger(index);
|
|
6961
7032
|
const updated = await deleteComment(adapter, config, slug, parsedIndex);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipbench",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Git-native project management for solo developers. Your task board lives in your repository as Markdown.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"chokidar": "^5.0.0",
|
|
35
35
|
"commander": "^15.0.0",
|
|
36
|
-
"@shipbench/board": "0.
|
|
36
|
+
"@shipbench/board": "0.4.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "^26.0.1",
|
|
40
40
|
"gray-matter": "^4.0.3",
|
|
41
41
|
"tsup": "^8.0.0",
|
|
42
42
|
"typescript": "^5.5.0",
|
|
43
|
-
"@shipbench/core": "0.
|
|
43
|
+
"@shipbench/core": "0.4.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsup",
|