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.
Files changed (2) hide show
  1. package/dist/index.js +96 -25
  2. 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(/^###\s+(.+?)\s*$/);
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[1];
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
- 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. Put the heading in a code fence when a description means it literally.
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
- - **Edit an update's text**: \`shipbench task comment edit <slug> <index> "Corrected text."\`
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.3.0" : "0.0.0-dev";
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
- "--body <text>",
6682
- "Description as Markdown text"
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 program = new Command().name("shipbench").description("Git-native project management for solo developers.").version(VERSION, "-v, --version", "output the version").option(
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 comment = task.command("comment").description("Manage timestamped entries in the task Updates section").argument("[slug]", "Task slug").argument("[text]", "Update text").action(async (slug, text) => {
6935
- if (!slug || text === void 0) {
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, text);
7004
+ const updated = await addComment(adapter, config, slug, resolved);
6942
7005
  chrome(`Added update to ${updated.slug}`);
6943
7006
  });
6944
- comment.command("edit <slug> <index> <text>").description(
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
- ).action(async (slug, index, text) => {
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
- text
7025
+ resolved
6955
7026
  );
6956
7027
  chrome(`Edited update ${parsedIndex} on ${updated.slug}`);
6957
7028
  });
6958
- comment.command("delete <slug> <index>").description("Delete an Updates entry by zero-based index").action(async (slug, index) => {
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.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.3.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.3.0"
43
+ "@shipbench/core": "0.4.0"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsup",