@storybloq/storybloq 1.4.3 → 1.4.4

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/README.md CHANGED
@@ -355,6 +355,8 @@ Full type definitions ship with the package (`exports.types`).
355
355
 
356
356
  Each record is its own file. IDs are sequential within type (`T-001`, `T-002`, ...). Relationships are single-canonical-owner: a ticket's `blockedBy` field points at blocker tickets, and the reverse (who-blocks-me) is derived by scanning.
357
357
 
358
+ Create operations are safe to run in parallel. ID assignment and the create write happen together under a project lock, so concurrent creators are serialized and each receives a distinct sequential ID. A create can never silently overwrite an existing record; under heavy simultaneous contention a creator fails loudly with an error rather than colliding.
359
+
358
360
  Ticket and issue records preserve unknown JSON fields. Use `storybloq ticket meta` and `storybloq issue meta` to read or mutate those custom passthrough fields without touching core Storybloq fields. Values are JSON, and dot paths address nested objects, for example `storybloq ticket meta set T-001 integration.linear '"ABC-123"'`.
359
361
 
360
362
  ## Example workflow
package/dist/cli.js CHANGED
@@ -991,7 +991,7 @@ import {
991
991
  mkdir
992
992
  } from "fs/promises";
993
993
  import { existsSync as existsSync4 } from "fs";
994
- import { join as join5, resolve as resolve3, relative as relative2, extname as extname2, dirname as dirname2, basename } from "path";
994
+ import { join as join5, resolve as resolve3, relative as relative2, extname as extname2, dirname as dirname2, basename, sep, isAbsolute as isAbsolute2 } from "path";
995
995
  import lockfile from "proper-lockfile";
996
996
  async function loadProject(root, options) {
997
997
  const absRoot = resolve3(root);
@@ -1625,7 +1625,8 @@ async function guardPath(target, root) {
1625
1625
  } catch {
1626
1626
  resolvedDir = targetDir;
1627
1627
  }
1628
- if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(resolvedRoot + "/")) {
1628
+ const rel = relative2(resolvedRoot, resolvedDir);
1629
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute2(rel)) {
1629
1630
  throw new ProjectLoaderError(
1630
1631
  "invalid_input",
1631
1632
  `Path ${target} resolves outside project root`
@@ -1996,6 +1997,7 @@ __export(output_formatter_exports, {
1996
1997
  EMPTY_SCAFFOLD_HEADING: () => EMPTY_SCAFFOLD_HEADING,
1997
1998
  ExitCode: () => ExitCode,
1998
1999
  errorEnvelope: () => errorEnvelope,
2000
+ escapeMarkdownDocument: () => escapeMarkdownDocument,
1999
2001
  escapeMarkdownInline: () => escapeMarkdownInline,
2000
2002
  fencedBlock: () => fencedBlock,
2001
2003
  formatBlockedTickets: () => formatBlockedTickets,
@@ -2056,6 +2058,9 @@ function partialEnvelope(data, warnings) {
2056
2058
  };
2057
2059
  }
2058
2060
  function escapeMarkdownInline(text) {
2061
+ return text.replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
2062
+ }
2063
+ function escapeMarkdownDocument(text) {
2059
2064
  return text.replace(/\\/g, "\\\\").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/([`*_~\[\]()|])/g, "\\$1").replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
2060
2065
  }
2061
2066
  function fencedBlock(content, lang) {
@@ -2812,11 +2817,11 @@ function formatPhaseExport(state, phaseId, format) {
2812
2817
  );
2813
2818
  }
2814
2819
  const lines = [];
2815
- lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
2820
+ lines.push(`# ${escapeMarkdownDocument(phase.name)} (${phase.id})`);
2816
2821
  lines.push("");
2817
2822
  lines.push(`Status: ${phaseStatus}`);
2818
2823
  if (phase.description) {
2819
- lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
2824
+ lines.push(`Description: ${escapeMarkdownDocument(phase.description)}`);
2820
2825
  }
2821
2826
  if (leaves.length > 0) {
2822
2827
  lines.push("");
@@ -2824,28 +2829,28 @@ function formatPhaseExport(state, phaseId, format) {
2824
2829
  for (const t of leaves) {
2825
2830
  const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
2826
2831
  const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
2827
- lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
2832
+ lines.push(`${indicator} ${t.id}: ${escapeMarkdownDocument(t.title)}${parentNote}`);
2828
2833
  }
2829
2834
  }
2830
2835
  if (crossPhaseDeps.size > 0) {
2831
2836
  lines.push("");
2832
2837
  lines.push("## Cross-Phase Dependencies");
2833
2838
  for (const [, dep] of crossPhaseDeps) {
2834
- lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
2839
+ lines.push(`- ${dep.id}: ${escapeMarkdownDocument(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
2835
2840
  }
2836
2841
  }
2837
2842
  if (relatedIssues.length > 0) {
2838
2843
  lines.push("");
2839
2844
  lines.push("## Open Issues");
2840
2845
  for (const i of relatedIssues) {
2841
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
2846
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}`);
2842
2847
  }
2843
2848
  }
2844
2849
  if (activeBlockers.length > 0) {
2845
2850
  lines.push("");
2846
2851
  lines.push("## Active Blockers");
2847
2852
  for (const b of activeBlockers) {
2848
- lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
2853
+ lines.push(`- ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
2849
2854
  }
2850
2855
  }
2851
2856
  return lines.join("\n");
@@ -2898,7 +2903,7 @@ function formatFullExport(state, format) {
2898
2903
  );
2899
2904
  }
2900
2905
  const lines = [];
2901
- lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
2906
+ lines.push(`# ${escapeMarkdownDocument(state.config.project)} \u2014 Full Export`);
2902
2907
  lines.push("");
2903
2908
  lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
2904
2909
  lines.push(`Issues: ${state.activeIssueCount} open`);
@@ -2909,16 +2914,16 @@ function formatFullExport(state, format) {
2909
2914
  for (const p of phases) {
2910
2915
  const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
2911
2916
  lines.push("");
2912
- lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
2917
+ lines.push(`### ${indicator} ${escapeMarkdownDocument(p.phase.name)} (${p.phase.id})`);
2913
2918
  if (p.phase.description) {
2914
- lines.push(escapeMarkdownInline(p.phase.description));
2919
+ lines.push(escapeMarkdownDocument(p.phase.description));
2915
2920
  }
2916
2921
  const tickets = state.phaseTickets(p.phase.id);
2917
2922
  if (tickets.length > 0) {
2918
2923
  lines.push("");
2919
2924
  for (const t of tickets) {
2920
2925
  const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
2921
- lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
2926
+ lines.push(`${ti} ${t.id}: ${escapeMarkdownDocument(t.title)}`);
2922
2927
  }
2923
2928
  }
2924
2929
  }
@@ -2927,7 +2932,7 @@ function formatFullExport(state, format) {
2927
2932
  lines.push("## Issues");
2928
2933
  for (const i of state.issues) {
2929
2934
  const resolved = i.status === "resolved" ? " \u2713" : "";
2930
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
2935
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}${resolved}`);
2931
2936
  }
2932
2937
  }
2933
2938
  const activeNotes = state.notes.filter((n) => n.status === "active");
@@ -2936,8 +2941,8 @@ function formatFullExport(state, format) {
2936
2941
  lines.push("## Notes");
2937
2942
  for (const n of activeNotes) {
2938
2943
  const title = n.title ?? n.id;
2939
- const tagInfo = n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
2940
- lines.push(`- ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
2944
+ const tagInfo = n.tags.length > 0 ? ` (${n.tags.map(escapeMarkdownDocument).join(", ")})` : "";
2945
+ lines.push(`- ${n.id}: ${escapeMarkdownDocument(title)}${tagInfo}`);
2941
2946
  }
2942
2947
  }
2943
2948
  const activeLessons = state.lessons.filter((l) => l.status === "active");
@@ -2946,8 +2951,8 @@ function formatFullExport(state, format) {
2946
2951
  lines.push("## Lessons");
2947
2952
  for (const l of activeLessons) {
2948
2953
  const reinforced = l.reinforcements > 0 ? ` (\xD7${l.reinforcements})` : "";
2949
- const tagInfo = l.tags.length > 0 ? ` [${l.tags.join(", ")}]` : "";
2950
- lines.push(`- ${l.id}: ${escapeMarkdownInline(l.title)}${reinforced}${tagInfo}`);
2954
+ const tagInfo = l.tags.length > 0 ? ` [${l.tags.map(escapeMarkdownDocument).join(", ")}]` : "";
2955
+ lines.push(`- ${l.id}: ${escapeMarkdownDocument(l.title)}${reinforced}${tagInfo}`);
2951
2956
  }
2952
2957
  }
2953
2958
  const blockers = state.roadmap.blockers;
@@ -2956,7 +2961,7 @@ function formatFullExport(state, format) {
2956
2961
  lines.push("## Blockers");
2957
2962
  for (const b of blockers) {
2958
2963
  const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
2959
- lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
2964
+ lines.push(`${cleared} ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
2960
2965
  }
2961
2966
  }
2962
2967
  return lines.join("\n");
@@ -3521,8 +3526,8 @@ function handleNodeList(ctx) {
3521
3526
  }
3522
3527
  const lines = ["# Nodes", ""];
3523
3528
  const header = "| Name | Path | Stack | Role | Health | Depends On |";
3524
- const sep5 = "|------|------|-------|------|--------|------------|";
3525
- lines.push(header, sep5);
3529
+ const sep6 = "|------|------|-------|------|--------|------------|";
3530
+ lines.push(header, sep6);
3526
3531
  for (const [name, node] of entries) {
3527
3532
  const path2 = node.path ?? "";
3528
3533
  const stack = node.stack ?? "";
@@ -3881,7 +3886,7 @@ var init_session_types = __esm({
3881
3886
 
3882
3887
  // src/autonomous/session-selector.ts
3883
3888
  import { readdirSync, realpathSync as realpathSync4 } from "fs";
3884
- import { basename as basename2, join as join8, sep } from "path";
3889
+ import { basename as basename2, join as join8, sep as sep2 } from "path";
3885
3890
  function canonicalSessionsRoot(root) {
3886
3891
  try {
3887
3892
  return realpathSync4.native(sessionsRoot(root));
@@ -3892,7 +3897,7 @@ function canonicalSessionsRoot(root) {
3892
3897
  function isContainedSessionDir(root, dir) {
3893
3898
  const canonRoot = canonicalSessionsRoot(root);
3894
3899
  if (canonRoot === null) return false;
3895
- const rootPrefix = canonRoot.endsWith(sep) ? canonRoot : canonRoot + sep;
3900
+ const rootPrefix = canonRoot.endsWith(sep2) ? canonRoot : canonRoot + sep2;
3896
3901
  let canonDir;
3897
3902
  try {
3898
3903
  canonDir = realpathSync4.native(dir);
@@ -5478,9 +5483,9 @@ var init_activation = __esm({
5478
5483
 
5479
5484
  // src/autonomous/review-lenses/path-safety.ts
5480
5485
  import { realpathSync as realpathSync5 } from "fs";
5481
- import { join as join12, resolve as resolve6, sep as sep2 } from "path";
5486
+ import { join as join12, resolve as resolve6, sep as sep3 } from "path";
5482
5487
  function resolveAndValidate(projectRoot, file) {
5483
- const resolvedRoot = resolve6(projectRoot) + sep2;
5488
+ const resolvedRoot = resolve6(projectRoot) + sep3;
5484
5489
  const fullPath = resolve6(join12(projectRoot, file));
5485
5490
  if (!fullPath.startsWith(resolvedRoot)) return null;
5486
5491
  let realPath;
@@ -7997,7 +8002,7 @@ import {
7997
8002
  existsSync as existsSync9,
7998
8003
  statSync as statSync3
7999
8004
  } from "fs";
8000
- import { join as join16, dirname as dirname4, resolve as resolve7, sep as sep3, posix, win32 } from "path";
8005
+ import { join as join16, dirname as dirname4, resolve as resolve7, sep as sep4, posix, win32 } from "path";
8001
8006
  import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
8002
8007
  import { spawnSync } from "child_process";
8003
8008
  function assertValidSessionId(sessionId) {
@@ -8067,13 +8072,13 @@ function assertValidManifestPath(entryPath, label) {
8067
8072
  function assertNoSymlinkAncestors(targetPath, stopAt) {
8068
8073
  const stopReal = resolve7(stopAt);
8069
8074
  const target = resolve7(targetPath);
8070
- if (!target.startsWith(stopReal + sep3) && target !== stopReal) {
8075
+ if (!target.startsWith(stopReal + sep4) && target !== stopReal) {
8071
8076
  throw new Error(
8072
8077
  `review-snapshot: assertNoSymlinkAncestors: target ${target} is not under stopAt ${stopReal}`
8073
8078
  );
8074
8079
  }
8075
8080
  const suffix = target === stopReal ? "" : target.slice(stopReal.length + 1);
8076
- const segments = suffix.length > 0 ? suffix.split(sep3) : [];
8081
+ const segments = suffix.length > 0 ? suffix.split(sep4) : [];
8077
8082
  let current = stopReal;
8078
8083
  for (const segment of segments) {
8079
8084
  current = join16(current, segment);
@@ -8114,14 +8119,14 @@ function deriveCanonicalPaths(projectRoot, sessionId, reviewId) {
8114
8119
  }
8115
8120
  const canonicalSessionsRoot2 = realpathSync6(sessionsDir);
8116
8121
  const sessionDir2 = join16(canonicalSessionsRoot2, sessionId);
8117
- if (!sessionDir2.startsWith(canonicalSessionsRoot2 + sep3)) {
8122
+ if (!sessionDir2.startsWith(canonicalSessionsRoot2 + sep4)) {
8118
8123
  throw new Error(
8119
8124
  `review-snapshot: sessionDir escapes sessions root: ${sessionDir2}`
8120
8125
  );
8121
8126
  }
8122
8127
  const snapshotParent = join16(sessionDir2, "review-snapshot");
8123
8128
  const finalDir = join16(snapshotParent, reviewId);
8124
- if (!finalDir.startsWith(snapshotParent + sep3)) {
8129
+ if (!finalDir.startsWith(snapshotParent + sep4)) {
8125
8130
  throw new Error(
8126
8131
  `review-snapshot: finalDir escapes snapshot parent: ${finalDir}`
8127
8132
  );
@@ -8146,7 +8151,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
8146
8151
  );
8147
8152
  }
8148
8153
  const realFinalDir = realpathSync6(paths.finalDir);
8149
- if (!realFinalDir.startsWith(paths.canonicalSessionsRoot + sep3) && realFinalDir !== paths.canonicalSessionsRoot) {
8154
+ if (!realFinalDir.startsWith(paths.canonicalSessionsRoot + sep4) && realFinalDir !== paths.canonicalSessionsRoot) {
8150
8155
  throw new Error(
8151
8156
  `review-snapshot: finalDir escapes sessions root: ${paths.finalDir}`
8152
8157
  );
@@ -8159,7 +8164,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
8159
8164
  );
8160
8165
  }
8161
8166
  const realManifestPath = realpathSync6(manifestPath);
8162
- if (!realManifestPath.startsWith(realFinalDir + sep3)) {
8167
+ if (!realManifestPath.startsWith(realFinalDir + sep4)) {
8163
8168
  throw new Error(
8164
8169
  `review-snapshot: manifest.json escapes finalDir: ${manifestPath}`
8165
8170
  );
@@ -8204,7 +8209,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
8204
8209
  for (const entry of manifest.files) {
8205
8210
  assertValidManifestPath(entry.path, `manifest entry`);
8206
8211
  const stored = join16(paths.finalDir, entry.path);
8207
- if (!stored.startsWith(paths.finalDir + sep3)) {
8212
+ if (!stored.startsWith(paths.finalDir + sep4)) {
8208
8213
  throw new Error(
8209
8214
  `review-snapshot: manifest entry escapes finalDir: ${entry.path}`
8210
8215
  );
@@ -8290,7 +8295,7 @@ import {
8290
8295
  readFileSync as readFileSync8,
8291
8296
  realpathSync as realpathSync7
8292
8297
  } from "fs";
8293
- import { resolve as resolve8, sep as sep4 } from "path";
8298
+ import { resolve as resolve8, sep as sep5 } from "path";
8294
8299
  import { createHash as createHash4 } from "crypto";
8295
8300
  function loadSnapshot(ctx) {
8296
8301
  let manifest;
@@ -8413,7 +8418,7 @@ function verifyPathAndLookup(evidence, snapshotDir, byPath) {
8413
8418
  });
8414
8419
  }
8415
8420
  const resolved = resolve8(snapshotDir, evidence.file);
8416
- const snapshotDirWithSep = snapshotDir.endsWith(sep4) ? snapshotDir : snapshotDir + sep4;
8421
+ const snapshotDirWithSep = snapshotDir.endsWith(sep5) ? snapshotDir : snapshotDir + sep5;
8417
8422
  if (!resolved.startsWith(snapshotDirWithSep) && resolved !== snapshotDir) {
8418
8423
  return failOutcome("invalid_path", {
8419
8424
  file: evidence.file,
@@ -8468,7 +8473,7 @@ function verifyPayloadIntegrity(file, resolved, entry, snapshotDir, snapshotDirR
8468
8473
  );
8469
8474
  }
8470
8475
  }
8471
- const snapshotDirRealWithSep = dirReal.endsWith(sep4) ? dirReal : dirReal + sep4;
8476
+ const snapshotDirRealWithSep = dirReal.endsWith(sep5) ? dirReal : dirReal + sep5;
8472
8477
  let realResolved;
8473
8478
  try {
8474
8479
  realResolved = realpathSync7(resolved);
@@ -17571,7 +17576,7 @@ function getInstalledVersion() {
17571
17576
  }
17572
17577
  }
17573
17578
  function getRunningVersion() {
17574
- return "1.4.3";
17579
+ return "1.4.4";
17575
17580
  }
17576
17581
  var init_version_check = __esm({
17577
17582
  "src/autonomous/version-check.ts"() {
@@ -19863,7 +19868,7 @@ function registerAllTools(server, pinnedRoot) {
19863
19868
  const result = await runMcpReadTool(pinnedRoot, handleStatus);
19864
19869
  try {
19865
19870
  const { readUpdateCacheSync: readUpdateCacheSync2, refreshUpdateCacheInBackground: refreshUpdateCacheInBackground2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
19866
- const running = "1.4.3";
19871
+ const running = "1.4.4";
19867
19872
  const info = readUpdateCacheSync2(running);
19868
19873
  refreshUpdateCacheInBackground2();
19869
19874
  if (info?.updateAvailable && result.content[0]?.type === "text") {
@@ -20075,7 +20080,7 @@ function registerAllTools(server, pinnedRoot) {
20075
20080
  );
20076
20081
  });
20077
20082
  server.registerTool("storybloq_ticket_create", {
20078
- description: "Create a new ticket",
20083
+ description: "Create a new ticket. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
20079
20084
  inputSchema: {
20080
20085
  title: z12.string().describe("Ticket title"),
20081
20086
  type: z12.enum(TICKET_TYPES).describe("Ticket type: task, feature, chore"),
@@ -20162,7 +20167,7 @@ function registerAllTools(server, pinnedRoot) {
20162
20167
  (root, format) => handleTicketMetaUnset(args.id, args.path, format, root)
20163
20168
  ));
20164
20169
  server.registerTool("storybloq_issue_create", {
20165
- description: "Create a new issue",
20170
+ description: "Create a new issue. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
20166
20171
  inputSchema: {
20167
20172
  title: z12.string().describe("Issue title"),
20168
20173
  severity: z12.enum(ISSUE_SEVERITIES).describe("Issue severity: critical, high, medium, low"),
@@ -20273,7 +20278,7 @@ function registerAllTools(server, pinnedRoot) {
20273
20278
  }
20274
20279
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleNoteGet(args.id, ctx)));
20275
20280
  server.registerTool("storybloq_note_create", {
20276
- description: "Create a new note",
20281
+ description: "Create a new note. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
20277
20282
  inputSchema: {
20278
20283
  content: z12.string().describe("Note content"),
20279
20284
  title: z12.string().optional().describe("Note title"),
@@ -20337,7 +20342,7 @@ function registerAllTools(server, pinnedRoot) {
20337
20342
  inputSchema: {}
20338
20343
  }, () => runMcpReadTool(pinnedRoot, (ctx) => handleLessonDigest(ctx)));
20339
20344
  server.registerTool("storybloq_lesson_create", {
20340
- description: "Create a new lesson",
20345
+ description: "Create a new lesson. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
20341
20346
  inputSchema: {
20342
20347
  title: z12.string().describe("Lesson title \u2014 concise lesson name"),
20343
20348
  content: z12.string().describe("The actionable rule (1-3 sentences)"),
@@ -21281,7 +21286,7 @@ var init_inbox_watcher = __esm({
21281
21286
  // src/mcp/index.ts
21282
21287
  var mcp_exports = {};
21283
21288
  import { realpathSync as realpathSync8, existsSync as existsSync19 } from "fs";
21284
- import { resolve as resolve13, join as join44, isAbsolute as isAbsolute2 } from "path";
21289
+ import { resolve as resolve13, join as join44, isAbsolute as isAbsolute3 } from "path";
21285
21290
  import { z as z14 } from "zod";
21286
21291
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21287
21292
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -21289,7 +21294,7 @@ function tryDiscoverRoot() {
21289
21294
  const envRoot = process.env[ENV_VAR2] ?? process.env[LEGACY_ENV_VAR2];
21290
21295
  const envName = process.env[ENV_VAR2] ? ENV_VAR2 : LEGACY_ENV_VAR2;
21291
21296
  if (envRoot) {
21292
- if (!isAbsolute2(envRoot)) {
21297
+ if (!isAbsolute3(envRoot)) {
21293
21298
  process.stderr.write(`Warning: ${envName} must be an absolute path, got: ${envRoot}
21294
21299
  `);
21295
21300
  return null;
@@ -21506,7 +21511,7 @@ var init_mcp = __esm({
21506
21511
  ENV_VAR2 = "STORYBLOQ_PROJECT_ROOT";
21507
21512
  LEGACY_ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
21508
21513
  CONFIG_PATH2 = ".story/config.json";
21509
- version = "1.4.3";
21514
+ version = "1.4.4";
21510
21515
  main().catch((err) => {
21511
21516
  process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}
21512
21517
  `);
@@ -23112,7 +23117,7 @@ var init_codex_review = __esm({
23112
23117
 
23113
23118
  // src/core/symlink-write.ts
23114
23119
  import { lstat as lstat3, mkdir as mkdir7, readlink, realpath as realpath2, rename as rename3, unlink as unlink4, writeFile as writeFile4 } from "fs/promises";
23115
- import { dirname as dirname8, isAbsolute as isAbsolute3, resolve as resolve15 } from "path";
23120
+ import { dirname as dirname8, isAbsolute as isAbsolute4, resolve as resolve15 } from "path";
23116
23121
  async function resolveSymlinkTarget(path2) {
23117
23122
  try {
23118
23123
  return await realpath2(path2);
@@ -23121,7 +23126,7 @@ async function resolveSymlinkTarget(path2) {
23121
23126
  let cur = path2;
23122
23127
  for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth++) {
23123
23128
  const link = await readlink(cur);
23124
- const next = isAbsolute3(link) ? link : resolve15(dirname8(cur), link);
23129
+ const next = isAbsolute4(link) ? link : resolve15(dirname8(cur), link);
23125
23130
  let st;
23126
23131
  try {
23127
23132
  st = await lstat3(next);
@@ -28943,7 +28948,7 @@ async function runCli() {
28943
28948
  registerNodeCommand: registerNodeCommand2,
28944
28949
  registerFeedbackCommand: registerFeedbackCommand2
28945
28950
  } = await Promise.resolve().then(() => (init_register(), register_exports));
28946
- const version2 = "1.4.3";
28951
+ const version2 = "1.4.4";
28947
28952
  const { preCommandHousekeeping: preCommandHousekeeping2 } = await Promise.resolve().then(() => (init_housekeeping(), housekeeping_exports));
28948
28953
  await preCommandHousekeeping2(version2);
28949
28954
  class HandledError extends Error {
package/dist/index.d.ts CHANGED
@@ -1503,49 +1503,16 @@ declare const SnapshotV1Schema: z.ZodObject<{
1503
1503
  file: z.ZodString;
1504
1504
  message: z.ZodString;
1505
1505
  }, "strip", z.ZodTypeAny, {
1506
- type: string;
1507
1506
  message: string;
1507
+ type: string;
1508
1508
  file: string;
1509
1509
  }, {
1510
- type: string;
1511
1510
  message: string;
1511
+ type: string;
1512
1512
  file: string;
1513
1513
  }>, "many">>;
1514
1514
  gitHead: z.ZodOptional<z.ZodString>;
1515
1515
  }, "strip", z.ZodTypeAny, {
1516
- version: 1;
1517
- config: {
1518
- version: number;
1519
- type: string;
1520
- language: string;
1521
- project: string;
1522
- features: {
1523
- issues: boolean;
1524
- tickets: boolean;
1525
- handovers: boolean;
1526
- roadmap: boolean;
1527
- reviews: boolean;
1528
- } & {
1529
- [k: string]: unknown;
1530
- };
1531
- orchestrator?: string | undefined;
1532
- nodes?: Record<string, unknown> | undefined;
1533
- federation?: Record<string, unknown> | undefined;
1534
- schemaVersion?: number | undefined;
1535
- recipe?: string | undefined;
1536
- recipeOverrides?: {
1537
- maxTicketsPerSession?: number | undefined;
1538
- compactThreshold?: string | undefined;
1539
- reviewBackends?: string[] | undefined;
1540
- codexReviewBackends?: string[] | undefined;
1541
- handoverInterval?: number | undefined;
1542
- stages?: Record<string, Record<string, unknown>> | undefined;
1543
- branchStrategy?: "none" | "per-ticket" | undefined;
1544
- maxParallelAgents?: number | undefined;
1545
- } | undefined;
1546
- } & {
1547
- [k: string]: unknown;
1548
- };
1549
1516
  issues: z.objectOutputType<{
1550
1517
  id: z.ZodString;
1551
1518
  title: z.ZodString;
@@ -1583,8 +1550,8 @@ declare const SnapshotV1Schema: z.ZodObject<{
1583
1550
  crossNodeBlockedBy: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1584
1551
  }, z.ZodTypeAny, "passthrough">[];
1585
1552
  roadmap: {
1586
- date: string;
1587
1553
  title: string;
1554
+ date: string;
1588
1555
  phases: z.objectOutputType<{
1589
1556
  id: z.ZodString;
1590
1557
  label: z.ZodString;
@@ -1602,6 +1569,7 @@ declare const SnapshotV1Schema: z.ZodObject<{
1602
1569
  } & {
1603
1570
  [k: string]: unknown;
1604
1571
  };
1572
+ version: 1;
1605
1573
  project: string;
1606
1574
  notes: z.objectOutputType<{
1607
1575
  id: z.ZodString;
@@ -1626,21 +1594,11 @@ declare const SnapshotV1Schema: z.ZodObject<{
1626
1594
  supersedes: z.ZodNullable<z.ZodString>;
1627
1595
  status: z.ZodEnum<["active", "deprecated", "superseded"]>;
1628
1596
  }, z.ZodTypeAny, "passthrough">[];
1629
- createdAt: string;
1630
- handoverFilenames: string[];
1631
- warnings?: {
1632
- type: string;
1633
- message: string;
1634
- file: string;
1635
- }[] | undefined;
1636
- gitHead?: string | undefined;
1637
- }, {
1638
- version: 1;
1639
1597
  config: {
1640
- version: number;
1641
1598
  type: string;
1642
- language: string;
1599
+ version: number;
1643
1600
  project: string;
1601
+ language: string;
1644
1602
  features: {
1645
1603
  issues: boolean;
1646
1604
  tickets: boolean;
@@ -1668,6 +1626,15 @@ declare const SnapshotV1Schema: z.ZodObject<{
1668
1626
  } & {
1669
1627
  [k: string]: unknown;
1670
1628
  };
1629
+ createdAt: string;
1630
+ handoverFilenames: string[];
1631
+ warnings?: {
1632
+ message: string;
1633
+ type: string;
1634
+ file: string;
1635
+ }[] | undefined;
1636
+ gitHead?: string | undefined;
1637
+ }, {
1671
1638
  issues: z.objectInputType<{
1672
1639
  id: z.ZodString;
1673
1640
  title: z.ZodString;
@@ -1705,8 +1672,8 @@ declare const SnapshotV1Schema: z.ZodObject<{
1705
1672
  crossNodeBlockedBy: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1706
1673
  }, z.ZodTypeAny, "passthrough">[];
1707
1674
  roadmap: {
1708
- date: string;
1709
1675
  title: string;
1676
+ date: string;
1710
1677
  phases: z.objectInputType<{
1711
1678
  id: z.ZodString;
1712
1679
  label: z.ZodString;
@@ -1724,7 +1691,40 @@ declare const SnapshotV1Schema: z.ZodObject<{
1724
1691
  } & {
1725
1692
  [k: string]: unknown;
1726
1693
  };
1694
+ version: 1;
1727
1695
  project: string;
1696
+ config: {
1697
+ type: string;
1698
+ version: number;
1699
+ project: string;
1700
+ language: string;
1701
+ features: {
1702
+ issues: boolean;
1703
+ tickets: boolean;
1704
+ handovers: boolean;
1705
+ roadmap: boolean;
1706
+ reviews: boolean;
1707
+ } & {
1708
+ [k: string]: unknown;
1709
+ };
1710
+ orchestrator?: string | undefined;
1711
+ nodes?: Record<string, unknown> | undefined;
1712
+ federation?: Record<string, unknown> | undefined;
1713
+ schemaVersion?: number | undefined;
1714
+ recipe?: string | undefined;
1715
+ recipeOverrides?: {
1716
+ maxTicketsPerSession?: number | undefined;
1717
+ compactThreshold?: string | undefined;
1718
+ reviewBackends?: string[] | undefined;
1719
+ codexReviewBackends?: string[] | undefined;
1720
+ handoverInterval?: number | undefined;
1721
+ stages?: Record<string, Record<string, unknown>> | undefined;
1722
+ branchStrategy?: "none" | "per-ticket" | undefined;
1723
+ maxParallelAgents?: number | undefined;
1724
+ } | undefined;
1725
+ } & {
1726
+ [k: string]: unknown;
1727
+ };
1728
1728
  createdAt: string;
1729
1729
  notes?: z.objectInputType<{
1730
1730
  id: z.ZodString;
@@ -1750,8 +1750,8 @@ declare const SnapshotV1Schema: z.ZodObject<{
1750
1750
  status: z.ZodEnum<["active", "deprecated", "superseded"]>;
1751
1751
  }, z.ZodTypeAny, "passthrough">[] | undefined;
1752
1752
  warnings?: {
1753
- type: string;
1754
1753
  message: string;
1754
+ type: string;
1755
1755
  file: string;
1756
1756
  }[] | undefined;
1757
1757
  handoverFilenames?: string[] | undefined;
@@ -1964,9 +1964,18 @@ declare function successEnvelope<T>(data: T): SuccessEnvelope<T>;
1964
1964
  declare function errorEnvelope(code: ErrorCode, message: string): ErrorEnvelope;
1965
1965
  declare function partialEnvelope<T>(data: T, warnings: readonly LoadWarning[]): PartialEnvelope<T>;
1966
1966
  /**
1967
- * Escapes characters that would create Markdown structure in inline text.
1968
- * Handles heading, list, blockquote, ordered list at line start.
1969
- * Handles inline structural characters.
1967
+ * Escapes only heading and list markers at the start of a line (unordered and
1968
+ * ordered) so an embedded field cannot start a new Markdown block inside the
1969
+ * formatter's own `md` output.
1970
+ *
1971
+ * This is NOT a Markdown or HTML sanitizer. Inline-structural, HTML-entity, and
1972
+ * backslash escaping were intentionally removed: the only consumers of formatter
1973
+ * output are plain-text sinks (CLI stdout and MCP `text` results to the model),
1974
+ * neither of which decodes entities or renders Markdown, so that escaping never
1975
+ * got decoded and instead leaked as visible noise like `&amp;`, `\(`, `\[`.
1976
+ * Blockquotes (`>`) and inline characters are passed through for plain-text
1977
+ * readability. Do not reintroduce inline/HTML escaping unless a real
1978
+ * Markdown/HTML renderer is added downstream.
1970
1979
  */
1971
1980
  declare function escapeMarkdownInline(text: string): string;
1972
1981
  /**
package/dist/index.js CHANGED
@@ -423,7 +423,7 @@ import {
423
423
  mkdir
424
424
  } from "fs/promises";
425
425
  import { existsSync as existsSync2 } from "fs";
426
- import { join as join2, resolve, relative as relative2, extname as extname2, dirname, basename } from "path";
426
+ import { join as join2, resolve, relative as relative2, extname as extname2, dirname, basename, sep, isAbsolute } from "path";
427
427
  import lockfile from "proper-lockfile";
428
428
 
429
429
  // src/models/lesson.ts
@@ -1256,7 +1256,8 @@ async function guardPath(target, root) {
1256
1256
  } catch {
1257
1257
  resolvedDir = targetDir;
1258
1258
  }
1259
- if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(resolvedRoot + "/")) {
1259
+ const rel = relative2(resolvedRoot, resolvedDir);
1260
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
1260
1261
  throw new ProjectLoaderError(
1261
1262
  "invalid_input",
1262
1263
  `Path ${target} resolves outside project root`
@@ -2912,6 +2913,9 @@ function partialEnvelope(data, warnings) {
2912
2913
  };
2913
2914
  }
2914
2915
  function escapeMarkdownInline(text) {
2916
+ return text.replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
2917
+ }
2918
+ function escapeMarkdownDocument(text) {
2915
2919
  return text.replace(/\\/g, "\\\\").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/([`*_~\[\]()|])/g, "\\$1").replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
2916
2920
  }
2917
2921
  function fencedBlock(content, lang) {
@@ -3445,11 +3449,11 @@ function formatPhaseExport(state, phaseId, format) {
3445
3449
  );
3446
3450
  }
3447
3451
  const lines = [];
3448
- lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
3452
+ lines.push(`# ${escapeMarkdownDocument(phase.name)} (${phase.id})`);
3449
3453
  lines.push("");
3450
3454
  lines.push(`Status: ${phaseStatus}`);
3451
3455
  if (phase.description) {
3452
- lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
3456
+ lines.push(`Description: ${escapeMarkdownDocument(phase.description)}`);
3453
3457
  }
3454
3458
  if (leaves.length > 0) {
3455
3459
  lines.push("");
@@ -3457,28 +3461,28 @@ function formatPhaseExport(state, phaseId, format) {
3457
3461
  for (const t of leaves) {
3458
3462
  const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
3459
3463
  const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
3460
- lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
3464
+ lines.push(`${indicator} ${t.id}: ${escapeMarkdownDocument(t.title)}${parentNote}`);
3461
3465
  }
3462
3466
  }
3463
3467
  if (crossPhaseDeps.size > 0) {
3464
3468
  lines.push("");
3465
3469
  lines.push("## Cross-Phase Dependencies");
3466
3470
  for (const [, dep] of crossPhaseDeps) {
3467
- lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
3471
+ lines.push(`- ${dep.id}: ${escapeMarkdownDocument(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
3468
3472
  }
3469
3473
  }
3470
3474
  if (relatedIssues.length > 0) {
3471
3475
  lines.push("");
3472
3476
  lines.push("## Open Issues");
3473
3477
  for (const i of relatedIssues) {
3474
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
3478
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}`);
3475
3479
  }
3476
3480
  }
3477
3481
  if (activeBlockers.length > 0) {
3478
3482
  lines.push("");
3479
3483
  lines.push("## Active Blockers");
3480
3484
  for (const b of activeBlockers) {
3481
- lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
3485
+ lines.push(`- ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
3482
3486
  }
3483
3487
  }
3484
3488
  return lines.join("\n");
@@ -3531,7 +3535,7 @@ function formatFullExport(state, format) {
3531
3535
  );
3532
3536
  }
3533
3537
  const lines = [];
3534
- lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
3538
+ lines.push(`# ${escapeMarkdownDocument(state.config.project)} \u2014 Full Export`);
3535
3539
  lines.push("");
3536
3540
  lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
3537
3541
  lines.push(`Issues: ${state.activeIssueCount} open`);
@@ -3542,16 +3546,16 @@ function formatFullExport(state, format) {
3542
3546
  for (const p of phases) {
3543
3547
  const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
3544
3548
  lines.push("");
3545
- lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
3549
+ lines.push(`### ${indicator} ${escapeMarkdownDocument(p.phase.name)} (${p.phase.id})`);
3546
3550
  if (p.phase.description) {
3547
- lines.push(escapeMarkdownInline(p.phase.description));
3551
+ lines.push(escapeMarkdownDocument(p.phase.description));
3548
3552
  }
3549
3553
  const tickets = state.phaseTickets(p.phase.id);
3550
3554
  if (tickets.length > 0) {
3551
3555
  lines.push("");
3552
3556
  for (const t of tickets) {
3553
3557
  const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
3554
- lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
3558
+ lines.push(`${ti} ${t.id}: ${escapeMarkdownDocument(t.title)}`);
3555
3559
  }
3556
3560
  }
3557
3561
  }
@@ -3560,7 +3564,7 @@ function formatFullExport(state, format) {
3560
3564
  lines.push("## Issues");
3561
3565
  for (const i of state.issues) {
3562
3566
  const resolved = i.status === "resolved" ? " \u2713" : "";
3563
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
3567
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}${resolved}`);
3564
3568
  }
3565
3569
  }
3566
3570
  const activeNotes = state.notes.filter((n) => n.status === "active");
@@ -3569,8 +3573,8 @@ function formatFullExport(state, format) {
3569
3573
  lines.push("## Notes");
3570
3574
  for (const n of activeNotes) {
3571
3575
  const title = n.title ?? n.id;
3572
- const tagInfo = n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
3573
- lines.push(`- ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
3576
+ const tagInfo = n.tags.length > 0 ? ` (${n.tags.map(escapeMarkdownDocument).join(", ")})` : "";
3577
+ lines.push(`- ${n.id}: ${escapeMarkdownDocument(title)}${tagInfo}`);
3574
3578
  }
3575
3579
  }
3576
3580
  const activeLessons = state.lessons.filter((l) => l.status === "active");
@@ -3579,8 +3583,8 @@ function formatFullExport(state, format) {
3579
3583
  lines.push("## Lessons");
3580
3584
  for (const l of activeLessons) {
3581
3585
  const reinforced = l.reinforcements > 0 ? ` (\xD7${l.reinforcements})` : "";
3582
- const tagInfo = l.tags.length > 0 ? ` [${l.tags.join(", ")}]` : "";
3583
- lines.push(`- ${l.id}: ${escapeMarkdownInline(l.title)}${reinforced}${tagInfo}`);
3586
+ const tagInfo = l.tags.length > 0 ? ` [${l.tags.map(escapeMarkdownDocument).join(", ")}]` : "";
3587
+ lines.push(`- ${l.id}: ${escapeMarkdownDocument(l.title)}${reinforced}${tagInfo}`);
3584
3588
  }
3585
3589
  }
3586
3590
  const blockers = state.roadmap.blockers;
@@ -3589,7 +3593,7 @@ function formatFullExport(state, format) {
3589
3593
  lines.push("## Blockers");
3590
3594
  for (const b of blockers) {
3591
3595
  const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
3592
- lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
3596
+ lines.push(`${cleared} ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
3593
3597
  }
3594
3598
  }
3595
3599
  return lines.join("\n");
package/dist/mcp.js CHANGED
@@ -933,7 +933,7 @@ import {
933
933
  mkdir
934
934
  } from "fs/promises";
935
935
  import { existsSync as existsSync4 } from "fs";
936
- import { join as join5, resolve as resolve3, relative as relative2, extname as extname2, dirname as dirname2, basename } from "path";
936
+ import { join as join5, resolve as resolve3, relative as relative2, extname as extname2, dirname as dirname2, basename, sep, isAbsolute as isAbsolute2 } from "path";
937
937
  import lockfile from "proper-lockfile";
938
938
  async function loadProject(root, options) {
939
939
  const absRoot = resolve3(root);
@@ -1567,7 +1567,8 @@ async function guardPath(target, root) {
1567
1567
  } catch {
1568
1568
  resolvedDir = targetDir;
1569
1569
  }
1570
- if (resolvedDir !== resolvedRoot && !resolvedDir.startsWith(resolvedRoot + "/")) {
1570
+ const rel = relative2(resolvedRoot, resolvedDir);
1571
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute2(rel)) {
1571
1572
  throw new ProjectLoaderError(
1572
1573
  "invalid_input",
1573
1574
  `Path ${target} resolves outside project root`
@@ -1817,6 +1818,9 @@ function errorEnvelope(code, message) {
1817
1818
  return { version: 1, error: { code, message } };
1818
1819
  }
1819
1820
  function escapeMarkdownInline(text) {
1821
+ return text.replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
1822
+ }
1823
+ function escapeMarkdownDocument(text) {
1820
1824
  return text.replace(/\\/g, "\\\\").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/([`*_~\[\]()|])/g, "\\$1").replace(/(^|\n)([#\-+*])/g, "$1\\$2").replace(/(^|\n)(\d+)\./g, "$1$2\\.");
1821
1825
  }
1822
1826
  function fencedBlock(content, lang) {
@@ -2550,11 +2554,11 @@ function formatPhaseExport(state, phaseId, format) {
2550
2554
  );
2551
2555
  }
2552
2556
  const lines = [];
2553
- lines.push(`# ${escapeMarkdownInline(phase.name)} (${phase.id})`);
2557
+ lines.push(`# ${escapeMarkdownDocument(phase.name)} (${phase.id})`);
2554
2558
  lines.push("");
2555
2559
  lines.push(`Status: ${phaseStatus}`);
2556
2560
  if (phase.description) {
2557
- lines.push(`Description: ${escapeMarkdownInline(phase.description)}`);
2561
+ lines.push(`Description: ${escapeMarkdownDocument(phase.description)}`);
2558
2562
  }
2559
2563
  if (leaves.length > 0) {
2560
2564
  lines.push("");
@@ -2562,28 +2566,28 @@ function formatPhaseExport(state, phaseId, format) {
2562
2566
  for (const t of leaves) {
2563
2567
  const indicator = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
2564
2568
  const parentNote = t.parentTicket && umbrellaAncestors.has(t.parentTicket) ? ` (under ${t.parentTicket})` : "";
2565
- lines.push(`${indicator} ${t.id}: ${escapeMarkdownInline(t.title)}${parentNote}`);
2569
+ lines.push(`${indicator} ${t.id}: ${escapeMarkdownDocument(t.title)}${parentNote}`);
2566
2570
  }
2567
2571
  }
2568
2572
  if (crossPhaseDeps.size > 0) {
2569
2573
  lines.push("");
2570
2574
  lines.push("## Cross-Phase Dependencies");
2571
2575
  for (const [, dep] of crossPhaseDeps) {
2572
- lines.push(`- ${dep.id}: ${escapeMarkdownInline(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
2576
+ lines.push(`- ${dep.id}: ${escapeMarkdownDocument(dep.title)} [${dep.status}] (${dep.phase ?? "unphased"})`);
2573
2577
  }
2574
2578
  }
2575
2579
  if (relatedIssues.length > 0) {
2576
2580
  lines.push("");
2577
2581
  lines.push("## Open Issues");
2578
2582
  for (const i of relatedIssues) {
2579
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}`);
2583
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}`);
2580
2584
  }
2581
2585
  }
2582
2586
  if (activeBlockers.length > 0) {
2583
2587
  lines.push("");
2584
2588
  lines.push("## Active Blockers");
2585
2589
  for (const b of activeBlockers) {
2586
- lines.push(`- ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
2590
+ lines.push(`- ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
2587
2591
  }
2588
2592
  }
2589
2593
  return lines.join("\n");
@@ -2636,7 +2640,7 @@ function formatFullExport(state, format) {
2636
2640
  );
2637
2641
  }
2638
2642
  const lines = [];
2639
- lines.push(`# ${escapeMarkdownInline(state.config.project)} \u2014 Full Export`);
2643
+ lines.push(`# ${escapeMarkdownDocument(state.config.project)} \u2014 Full Export`);
2640
2644
  lines.push("");
2641
2645
  lines.push(`Tickets: ${state.completeLeafTicketCount}/${state.leafTicketCount} complete`);
2642
2646
  lines.push(`Issues: ${state.activeIssueCount} open`);
@@ -2647,16 +2651,16 @@ function formatFullExport(state, format) {
2647
2651
  for (const p of phases) {
2648
2652
  const indicator = p.status === "complete" ? "[x]" : p.status === "inprogress" ? "[~]" : "[ ]";
2649
2653
  lines.push("");
2650
- lines.push(`### ${indicator} ${escapeMarkdownInline(p.phase.name)} (${p.phase.id})`);
2654
+ lines.push(`### ${indicator} ${escapeMarkdownDocument(p.phase.name)} (${p.phase.id})`);
2651
2655
  if (p.phase.description) {
2652
- lines.push(escapeMarkdownInline(p.phase.description));
2656
+ lines.push(escapeMarkdownDocument(p.phase.description));
2653
2657
  }
2654
2658
  const tickets = state.phaseTickets(p.phase.id);
2655
2659
  if (tickets.length > 0) {
2656
2660
  lines.push("");
2657
2661
  for (const t of tickets) {
2658
2662
  const ti = t.status === "complete" ? "[x]" : t.status === "inprogress" ? "[~]" : "[ ]";
2659
- lines.push(`${ti} ${t.id}: ${escapeMarkdownInline(t.title)}`);
2663
+ lines.push(`${ti} ${t.id}: ${escapeMarkdownDocument(t.title)}`);
2660
2664
  }
2661
2665
  }
2662
2666
  }
@@ -2665,7 +2669,7 @@ function formatFullExport(state, format) {
2665
2669
  lines.push("## Issues");
2666
2670
  for (const i of state.issues) {
2667
2671
  const resolved = i.status === "resolved" ? " \u2713" : "";
2668
- lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownInline(i.title)}${resolved}`);
2672
+ lines.push(`- ${i.id} [${i.severity}]: ${escapeMarkdownDocument(i.title)}${resolved}`);
2669
2673
  }
2670
2674
  }
2671
2675
  const activeNotes = state.notes.filter((n) => n.status === "active");
@@ -2674,8 +2678,8 @@ function formatFullExport(state, format) {
2674
2678
  lines.push("## Notes");
2675
2679
  for (const n of activeNotes) {
2676
2680
  const title = n.title ?? n.id;
2677
- const tagInfo = n.tags.length > 0 ? ` (${n.tags.join(", ")})` : "";
2678
- lines.push(`- ${n.id}: ${escapeMarkdownInline(title)}${tagInfo}`);
2681
+ const tagInfo = n.tags.length > 0 ? ` (${n.tags.map(escapeMarkdownDocument).join(", ")})` : "";
2682
+ lines.push(`- ${n.id}: ${escapeMarkdownDocument(title)}${tagInfo}`);
2679
2683
  }
2680
2684
  }
2681
2685
  const activeLessons = state.lessons.filter((l) => l.status === "active");
@@ -2684,8 +2688,8 @@ function formatFullExport(state, format) {
2684
2688
  lines.push("## Lessons");
2685
2689
  for (const l of activeLessons) {
2686
2690
  const reinforced = l.reinforcements > 0 ? ` (\xD7${l.reinforcements})` : "";
2687
- const tagInfo = l.tags.length > 0 ? ` [${l.tags.join(", ")}]` : "";
2688
- lines.push(`- ${l.id}: ${escapeMarkdownInline(l.title)}${reinforced}${tagInfo}`);
2691
+ const tagInfo = l.tags.length > 0 ? ` [${l.tags.map(escapeMarkdownDocument).join(", ")}]` : "";
2692
+ lines.push(`- ${l.id}: ${escapeMarkdownDocument(l.title)}${reinforced}${tagInfo}`);
2689
2693
  }
2690
2694
  }
2691
2695
  const blockers = state.roadmap.blockers;
@@ -2694,7 +2698,7 @@ function formatFullExport(state, format) {
2694
2698
  lines.push("## Blockers");
2695
2699
  for (const b of blockers) {
2696
2700
  const cleared = isBlockerCleared(b) ? "[x]" : "[ ]";
2697
- lines.push(`${cleared} ${escapeMarkdownInline(b.name)}${b.note ? ` \u2014 ${escapeMarkdownInline(b.note)}` : ""}`);
2701
+ lines.push(`${cleared} ${escapeMarkdownDocument(b.name)}${b.note ? ` \u2014 ${escapeMarkdownDocument(b.note)}` : ""}`);
2698
2702
  }
2699
2703
  }
2700
2704
  return lines.join("\n");
@@ -3195,8 +3199,8 @@ function handleNodeList(ctx) {
3195
3199
  }
3196
3200
  const lines = ["# Nodes", ""];
3197
3201
  const header = "| Name | Path | Stack | Role | Health | Depends On |";
3198
- const sep5 = "|------|------|-------|------|--------|------------|";
3199
- lines.push(header, sep5);
3202
+ const sep6 = "|------|------|-------|------|--------|------------|";
3203
+ lines.push(header, sep6);
3200
3204
  for (const [name, node] of entries) {
3201
3205
  const path2 = node.path ?? "";
3202
3206
  const stack = node.stack ?? "";
@@ -3555,7 +3559,7 @@ var init_session_types = __esm({
3555
3559
 
3556
3560
  // src/autonomous/session-selector.ts
3557
3561
  import { readdirSync, realpathSync as realpathSync4 } from "fs";
3558
- import { basename as basename2, join as join8, sep } from "path";
3562
+ import { basename as basename2, join as join8, sep as sep2 } from "path";
3559
3563
  function canonicalSessionsRoot(root) {
3560
3564
  try {
3561
3565
  return realpathSync4.native(sessionsRoot(root));
@@ -3566,7 +3570,7 @@ function canonicalSessionsRoot(root) {
3566
3570
  function isContainedSessionDir(root, dir) {
3567
3571
  const canonRoot = canonicalSessionsRoot(root);
3568
3572
  if (canonRoot === null) return false;
3569
- const rootPrefix = canonRoot.endsWith(sep) ? canonRoot : canonRoot + sep;
3573
+ const rootPrefix = canonRoot.endsWith(sep2) ? canonRoot : canonRoot + sep2;
3570
3574
  let canonDir;
3571
3575
  try {
3572
3576
  canonDir = realpathSync4.native(dir);
@@ -5907,7 +5911,7 @@ var init_update_check = __esm({
5907
5911
  // src/mcp/index.ts
5908
5912
  init_esm_shims();
5909
5913
  import { realpathSync as realpathSync8, existsSync as existsSync19 } from "fs";
5910
- import { resolve as resolve13, join as join44, isAbsolute as isAbsolute2 } from "path";
5914
+ import { resolve as resolve13, join as join44, isAbsolute as isAbsolute3 } from "path";
5911
5915
  import { z as z14 } from "zod";
5912
5916
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5913
5917
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -7080,9 +7084,9 @@ import { join as join13 } from "path";
7080
7084
  // src/autonomous/review-lenses/path-safety.ts
7081
7085
  init_esm_shims();
7082
7086
  import { realpathSync as realpathSync5 } from "fs";
7083
- import { join as join12, resolve as resolve6, sep as sep2 } from "path";
7087
+ import { join as join12, resolve as resolve6, sep as sep3 } from "path";
7084
7088
  function resolveAndValidate(projectRoot, file) {
7085
- const resolvedRoot = resolve6(projectRoot) + sep2;
7089
+ const resolvedRoot = resolve6(projectRoot) + sep3;
7086
7090
  const fullPath = resolve6(join12(projectRoot, file));
7087
7091
  if (!fullPath.startsWith(resolvedRoot)) return null;
7088
7092
  let realPath;
@@ -9434,7 +9438,7 @@ import {
9434
9438
  readFileSync as readFileSync8,
9435
9439
  realpathSync as realpathSync7
9436
9440
  } from "fs";
9437
- import { resolve as resolve8, sep as sep4 } from "path";
9441
+ import { resolve as resolve8, sep as sep5 } from "path";
9438
9442
  import { createHash as createHash4 } from "crypto";
9439
9443
 
9440
9444
  // src/autonomous/review-lenses/review-snapshot.ts
@@ -9452,7 +9456,7 @@ import {
9452
9456
  existsSync as existsSync9,
9453
9457
  statSync as statSync3
9454
9458
  } from "fs";
9455
- import { join as join16, dirname as dirname4, resolve as resolve7, sep as sep3, posix, win32 } from "path";
9459
+ import { join as join16, dirname as dirname4, resolve as resolve7, sep as sep4, posix, win32 } from "path";
9456
9460
  import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
9457
9461
  import { spawnSync } from "child_process";
9458
9462
  var SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -9524,13 +9528,13 @@ function assertValidManifestPath(entryPath, label) {
9524
9528
  function assertNoSymlinkAncestors(targetPath, stopAt) {
9525
9529
  const stopReal = resolve7(stopAt);
9526
9530
  const target = resolve7(targetPath);
9527
- if (!target.startsWith(stopReal + sep3) && target !== stopReal) {
9531
+ if (!target.startsWith(stopReal + sep4) && target !== stopReal) {
9528
9532
  throw new Error(
9529
9533
  `review-snapshot: assertNoSymlinkAncestors: target ${target} is not under stopAt ${stopReal}`
9530
9534
  );
9531
9535
  }
9532
9536
  const suffix = target === stopReal ? "" : target.slice(stopReal.length + 1);
9533
- const segments = suffix.length > 0 ? suffix.split(sep3) : [];
9537
+ const segments = suffix.length > 0 ? suffix.split(sep4) : [];
9534
9538
  let current = stopReal;
9535
9539
  for (const segment of segments) {
9536
9540
  current = join16(current, segment);
@@ -9571,14 +9575,14 @@ function deriveCanonicalPaths(projectRoot, sessionId, reviewId) {
9571
9575
  }
9572
9576
  const canonicalSessionsRoot2 = realpathSync6(sessionsDir);
9573
9577
  const sessionDir2 = join16(canonicalSessionsRoot2, sessionId);
9574
- if (!sessionDir2.startsWith(canonicalSessionsRoot2 + sep3)) {
9578
+ if (!sessionDir2.startsWith(canonicalSessionsRoot2 + sep4)) {
9575
9579
  throw new Error(
9576
9580
  `review-snapshot: sessionDir escapes sessions root: ${sessionDir2}`
9577
9581
  );
9578
9582
  }
9579
9583
  const snapshotParent = join16(sessionDir2, "review-snapshot");
9580
9584
  const finalDir = join16(snapshotParent, reviewId);
9581
- if (!finalDir.startsWith(snapshotParent + sep3)) {
9585
+ if (!finalDir.startsWith(snapshotParent + sep4)) {
9582
9586
  throw new Error(
9583
9587
  `review-snapshot: finalDir escapes snapshot parent: ${finalDir}`
9584
9588
  );
@@ -9603,7 +9607,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
9603
9607
  );
9604
9608
  }
9605
9609
  const realFinalDir = realpathSync6(paths.finalDir);
9606
- if (!realFinalDir.startsWith(paths.canonicalSessionsRoot + sep3) && realFinalDir !== paths.canonicalSessionsRoot) {
9610
+ if (!realFinalDir.startsWith(paths.canonicalSessionsRoot + sep4) && realFinalDir !== paths.canonicalSessionsRoot) {
9607
9611
  throw new Error(
9608
9612
  `review-snapshot: finalDir escapes sessions root: ${paths.finalDir}`
9609
9613
  );
@@ -9616,7 +9620,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
9616
9620
  );
9617
9621
  }
9618
9622
  const realManifestPath = realpathSync6(manifestPath);
9619
- if (!realManifestPath.startsWith(realFinalDir + sep3)) {
9623
+ if (!realManifestPath.startsWith(realFinalDir + sep4)) {
9620
9624
  throw new Error(
9621
9625
  `review-snapshot: manifest.json escapes finalDir: ${manifestPath}`
9622
9626
  );
@@ -9661,7 +9665,7 @@ function readReviewSnapshotManifestWithBytes(projectRoot, sessionId, reviewId) {
9661
9665
  for (const entry of manifest.files) {
9662
9666
  assertValidManifestPath(entry.path, `manifest entry`);
9663
9667
  const stored = join16(paths.finalDir, entry.path);
9664
- if (!stored.startsWith(paths.finalDir + sep3)) {
9668
+ if (!stored.startsWith(paths.finalDir + sep4)) {
9665
9669
  throw new Error(
9666
9670
  `review-snapshot: manifest entry escapes finalDir: ${entry.path}`
9667
9671
  );
@@ -9866,7 +9870,7 @@ function verifyPathAndLookup(evidence, snapshotDir, byPath) {
9866
9870
  });
9867
9871
  }
9868
9872
  const resolved = resolve8(snapshotDir, evidence.file);
9869
- const snapshotDirWithSep = snapshotDir.endsWith(sep4) ? snapshotDir : snapshotDir + sep4;
9873
+ const snapshotDirWithSep = snapshotDir.endsWith(sep5) ? snapshotDir : snapshotDir + sep5;
9870
9874
  if (!resolved.startsWith(snapshotDirWithSep) && resolved !== snapshotDir) {
9871
9875
  return failOutcome("invalid_path", {
9872
9876
  file: evidence.file,
@@ -9921,7 +9925,7 @@ function verifyPayloadIntegrity(file, resolved, entry, snapshotDir, snapshotDirR
9921
9925
  );
9922
9926
  }
9923
9927
  }
9924
- const snapshotDirRealWithSep = dirReal.endsWith(sep4) ? dirReal : dirReal + sep4;
9928
+ const snapshotDirRealWithSep = dirReal.endsWith(sep5) ? dirReal : dirReal + sep5;
9925
9929
  let realResolved;
9926
9930
  try {
9927
9931
  realResolved = realpathSync7(resolved);
@@ -16743,7 +16747,7 @@ function getInstalledVersion() {
16743
16747
  }
16744
16748
  }
16745
16749
  function getRunningVersion() {
16746
- return "1.4.3";
16750
+ return "1.4.4";
16747
16751
  }
16748
16752
 
16749
16753
  // src/autonomous/guide.ts
@@ -18753,7 +18757,7 @@ function registerAllTools(server, pinnedRoot) {
18753
18757
  const result = await runMcpReadTool(pinnedRoot, handleStatus);
18754
18758
  try {
18755
18759
  const { readUpdateCacheSync: readUpdateCacheSync2, refreshUpdateCacheInBackground: refreshUpdateCacheInBackground2 } = await Promise.resolve().then(() => (init_update_check(), update_check_exports));
18756
- const running = "1.4.3";
18760
+ const running = "1.4.4";
18757
18761
  const info = readUpdateCacheSync2(running);
18758
18762
  refreshUpdateCacheInBackground2();
18759
18763
  if (info?.updateAvailable && result.content[0]?.type === "text") {
@@ -18965,7 +18969,7 @@ function registerAllTools(server, pinnedRoot) {
18965
18969
  );
18966
18970
  });
18967
18971
  server.registerTool("storybloq_ticket_create", {
18968
- description: "Create a new ticket",
18972
+ description: "Create a new ticket. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
18969
18973
  inputSchema: {
18970
18974
  title: z12.string().describe("Ticket title"),
18971
18975
  type: z12.enum(TICKET_TYPES).describe("Ticket type: task, feature, chore"),
@@ -19052,7 +19056,7 @@ function registerAllTools(server, pinnedRoot) {
19052
19056
  (root, format) => handleTicketMetaUnset(args.id, args.path, format, root)
19053
19057
  ));
19054
19058
  server.registerTool("storybloq_issue_create", {
19055
- description: "Create a new issue",
19059
+ description: "Create a new issue. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
19056
19060
  inputSchema: {
19057
19061
  title: z12.string().describe("Issue title"),
19058
19062
  severity: z12.enum(ISSUE_SEVERITIES).describe("Issue severity: critical, high, medium, low"),
@@ -19163,7 +19167,7 @@ function registerAllTools(server, pinnedRoot) {
19163
19167
  }
19164
19168
  }, (args) => runMcpReadTool(pinnedRoot, (ctx) => handleNoteGet(args.id, ctx)));
19165
19169
  server.registerTool("storybloq_note_create", {
19166
- description: "Create a new note",
19170
+ description: "Create a new note. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
19167
19171
  inputSchema: {
19168
19172
  content: z12.string().describe("Note content"),
19169
19173
  title: z12.string().optional().describe("Note title"),
@@ -19227,7 +19231,7 @@ function registerAllTools(server, pinnedRoot) {
19227
19231
  inputSchema: {}
19228
19232
  }, () => runMcpReadTool(pinnedRoot, (ctx) => handleLessonDigest(ctx)));
19229
19233
  server.registerTool("storybloq_lesson_create", {
19230
- description: "Create a new lesson",
19234
+ description: "Create a new lesson. ID assignment is serialized under the project lock, so concurrent creates that acquire the lock receive distinct sequential IDs.",
19231
19235
  inputSchema: {
19232
19236
  title: z12.string().describe("Lesson title \u2014 concise lesson name"),
19233
19237
  content: z12.string().describe("The actionable rule (1-3 sentences)"),
@@ -20112,12 +20116,12 @@ async function trimFailedDirectory(inboxPath) {
20112
20116
  var ENV_VAR2 = "STORYBLOQ_PROJECT_ROOT";
20113
20117
  var LEGACY_ENV_VAR2 = "CLAUDESTORY_PROJECT_ROOT";
20114
20118
  var CONFIG_PATH2 = ".story/config.json";
20115
- var version = "1.4.3";
20119
+ var version = "1.4.4";
20116
20120
  function tryDiscoverRoot() {
20117
20121
  const envRoot = process.env[ENV_VAR2] ?? process.env[LEGACY_ENV_VAR2];
20118
20122
  const envName = process.env[ENV_VAR2] ? ENV_VAR2 : LEGACY_ENV_VAR2;
20119
20123
  if (envRoot) {
20120
- if (!isAbsolute2(envRoot)) {
20124
+ if (!isAbsolute3(envRoot)) {
20121
20125
  process.stderr.write(`Warning: ${envName} must be an absolute path, got: ${envRoot}
20122
20126
  `);
20123
20127
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storybloq/storybloq",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "license": "PolyForm-Noncommercial-1.0.0",
5
5
  "description": "Project memory for Claude Code. Storybloq stores tickets, issues, handovers, and lessons in a .story/ folder inside your repo so every session starts where the last one left off. Pairs with the Storybloq Mac app on the App Store.",
6
6
  "homepage": "https://storybloq.com",
@@ -13,7 +13,7 @@ Invocation differs by client: use `/story` in Claude Code, `$story` in Codex, or
13
13
 
14
14
  This guard runs on EVERY `/story` invocation regardless of subcommand (`/story`, `/story auto`, `/story review`, `/story plan`, `/story guided`, `/story handover`, `/story snapshot`, `/story export`, `/story design`, `/story review-lenses`, `/story settings`, `/story help`, `/story status`, etc.). It MUST complete before ANY other action in this invocation.
15
15
 
16
- **Guard prelude: force-surface deferred MCP tools.** Before running step 1 of this guard, make a single `ToolSearch` call with `query: "storybloq"` (max_results: 20). On Claude Code desktop/web, `storybloq_*` tool schemas are deferred — without this prelude the subsequent `storybloq_status` call in step 1 is not dispatchable. The prelude is explicitly part of the guard, not a separate pre-guard step; it satisfies the whitelist below.
16
+ **Guard prelude: force-surface deferred MCP tools.** Before running step 1 of this guard, make a `ToolSearch` call with `query: "storybloq"` and `max_results: 80` (high enough to surface the full `storybloq_*` tool set, currently ~53 tools, in one call; a smaller cap truncates alphabetically and can drop `storybloq_status`). On Claude Code desktop/web, `storybloq_*` tool schemas are deferred, so without this prelude the subsequent `storybloq_status` call in step 1 is not dispatchable. If `storybloq_status` is still not available after that call (some clients clamp or ignore `max_results`), make a second targeted `ToolSearch` with `query: "storybloq_status"` and `max_results: 5`, which ranks that exact tool to the top, before concluding anything about MCP availability. The prelude is explicitly part of the guard, not a separate pre-guard step; it satisfies the whitelist below.
17
17
 
18
18
  - If `ToolSearch` itself is not available or returns an error on this harness, SKIP the prelude and continue to step 1. Do NOT treat a missing `ToolSearch` tool as evidence that MCP is unavailable — step 1's `storybloq_status` call will either succeed (MCP already surfaced) or its failure will route the skill to the Step 0 setup/CLI-fallback path below.
19
19
  - The prelude is idempotent: on terminal CLI sessions where `storybloq_*` tools are already in the base list, it simply returns the same tool set.
@@ -62,7 +62,7 @@ Check if the storybloq MCP tools are available.
62
62
  **Deferred tools note (Claude Code app).** Claude Code desktop/web may register MCP tools at session start but defer exposing their full schemas to your tool list until you explicitly request them. A naive "look for `storybloq_status` in available tools" check fails on a cold session even when the MCP server is healthy and connected, routing the skill to the CLI fallback unnecessarily. The Step 0.5 guard prelude above (the `ToolSearch` call) has already force-surfaced any deferred tools by this point, so this step only needs to check the current tool list:
63
63
 
64
64
  1. **Check for storybloq MCP tools in your tool list.** If any `storybloq_*` tools (for example `storybloq_status`) are present, MCP is available -- proceed to Step 1.
65
- 2. **If no `storybloq_*` tools are present**, try a second `ToolSearch` call with `query: "storybloq"` (max_results: 20) as a safety net in case the guard prelude was skipped or failed silently. If the response lists any `storybloq_*` tools, proceed to Step 1.
65
+ 2. **If no `storybloq_*` tools are present**, try a `ToolSearch` call with `query: "storybloq"` and `max_results: 80` (and, if `storybloq_status` is still not listed, a targeted `query: "storybloq_status"`, `max_results: 5`) as a safety net in case the guard prelude was skipped or failed silently. If the response lists any `storybloq_*` tools, proceed to Step 1.
66
66
  3. **If `ToolSearch` is unavailable on this harness OR returned no matches**, MCP is genuinely unavailable -- continue with the setup/fallback path below. Missing `ToolSearch` is never by itself evidence that MCP is broken; it just means the harness exposes tools differently.
67
67
 
68
68
  **If MCP tools are NOT available:**
@@ -138,18 +138,17 @@ Open with the project name and progress. Mention what the last session accomplis
138
138
 
139
139
  You MUST show the following tables after the prose intro. Do not summarize them in paragraph form.
140
140
 
141
- **Ready to Work table** -- call `storybloq_recommend` for context-aware suggestions. Always render as a markdown table:
141
+ **Ready to Work table** -- call `storybloq_recommend` for context-aware suggestions. `storybloq_recommend` MIXES tickets and issues, so render as a neutral markdown table:
142
142
 
143
143
  ```
144
144
  ## Ready to Work
145
- | Ticket | Title | Phase |
146
- |--------|-----------------------------------|------------|
147
- | T-001 | Project setup | foundation |
148
- | T-011 | Rate agreement conditions schema | foundation |
149
- | T-012 | Audit trail infrastructure | foundation |
145
+ | Item | Type | Title | Context |
146
+ |---------|--------|----------------------------------|----------------|
147
+ | T-011 | ticket | Rate agreement conditions schema | foundation |
148
+ | ISS-042 | issue | Auth token expiry bug | severity: high |
150
149
  ```
151
150
 
152
- Show up to 5 unblocked tickets. If more exist, note "(+N more unblocked)".
151
+ Ticket rows show their phase in Context; issue rows show severity. Show up to 5 recommendations. If more exist, note "(+N more)". Note: tickets are filtered to unblocked ones, but issues are ranked by severity and have no blocker model, so a listed issue may be externally blocked -- verify it is actionable before starting.
153
152
 
154
153
  **Decisions Pending** (show only if there are TBD items in CLAUDE.md or undecided tech choices):
155
154