sfora-cli 0.8.0 → 0.10.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 (71) hide show
  1. package/README.md +8 -6
  2. package/dist/SforaFs.js +270 -4
  3. package/dist/api-client.d.ts +47 -1
  4. package/dist/api-client.js +60 -3
  5. package/dist/cli.js +24 -11
  6. package/dist/format/__tests__/byteStable.d.ts +5 -0
  7. package/dist/format/__tests__/byteStable.js +64 -0
  8. package/dist/format/blocks/dropClosure.d.ts +72 -0
  9. package/dist/format/blocks/dropClosure.js +186 -0
  10. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  11. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  12. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  13. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  14. package/dist/format/blocks/parsers.d.ts +105 -0
  15. package/dist/format/blocks/parsers.js +442 -0
  16. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  17. package/dist/format/blocks/structured-block-schema.js +30 -0
  18. package/dist/format/callout.d.ts +66 -0
  19. package/dist/format/callout.js +130 -0
  20. package/dist/format/cardMarkdown.d.ts +4 -0
  21. package/dist/format/cardMarkdown.js +12 -0
  22. package/dist/format/checklist.d.ts +34 -0
  23. package/dist/format/checklist.js +151 -0
  24. package/dist/format/index.d.ts +18 -4
  25. package/dist/format/index.js +24 -4
  26. package/dist/format/lineGeometry.d.ts +70 -0
  27. package/dist/format/lineGeometry.js +324 -0
  28. package/dist/format/lint/index.d.ts +20 -0
  29. package/dist/format/lint/index.js +22 -0
  30. package/dist/format/lint/lintSource.d.ts +36 -0
  31. package/dist/format/lint/lintSource.js +154 -0
  32. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  33. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  34. package/dist/format/lint/rules/index.d.ts +10 -0
  35. package/dist/format/lint/rules/index.js +26 -0
  36. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  37. package/dist/format/lint/rules/malformed-callout.js +79 -0
  38. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  39. package/dist/format/lint/rules/malformed-checklist.js +60 -0
  40. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  41. package/dist/format/lint/rules/malformed-frontmatter.js +93 -0
  42. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  43. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  44. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  45. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  46. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  47. package/dist/format/lint/rules/orphan-reference.js +87 -0
  48. package/dist/format/lint/types.d.ts +80 -0
  49. package/dist/format/lint/types.js +16 -0
  50. package/dist/format/markdown/dates.js +2 -0
  51. package/dist/format/markdown/document.js +2 -0
  52. package/dist/format/markdown/index.js +2 -0
  53. package/dist/format/markdown/mentions.js +2 -0
  54. package/dist/format/markdown/slug.js +2 -0
  55. package/dist/format/markdown/yaml.js +2 -0
  56. package/dist/format/noteMarkdown.js +2 -0
  57. package/dist/format/parseWithFallback.d.ts +13 -0
  58. package/dist/format/parseWithFallback.js +98 -0
  59. package/dist/format/plaintext.d.ts +5 -0
  60. package/dist/format/plaintext.js +41 -0
  61. package/dist/format/postMarkdown.js +3 -1
  62. package/dist/format/taskUploadFilename.d.ts +6 -0
  63. package/dist/format/taskUploadFilename.js +13 -0
  64. package/dist/format/wayfinder.d.ts +50 -0
  65. package/dist/format/wayfinder.js +203 -0
  66. package/dist/format/wikiLinks.d.ts +19 -0
  67. package/dist/format/wikiLinks.js +80 -0
  68. package/dist/local/workspace.d.ts +12 -0
  69. package/dist/local/workspace.js +100 -7
  70. package/dist/mcp-server.js +11 -5
  71. package/package.json +7 -6
@@ -1,3 +1,5 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
1
3
  // Pure markdown <-> note (doc) serialization for the agent filesystem API
2
4
  // (/v1/fs). Notes are Notion-style single-author docs surfaced under the in-app
3
5
  // "Docs" tab; over the FS API they're stable named files `<slug>.md` (no date
@@ -0,0 +1,13 @@
1
+ import { type ParsedDocument } from "./markdown/document.js";
2
+ export declare const MAX_PARSE_INPUT_BYTES = 4000000;
3
+ export declare const MAX_FRONTMATTER_SCAN_BYTES = 64000;
4
+ export declare const MAX_PARSE_WALLCLOCK_MS = 250;
5
+ export type ParseFallbackReason = "input-too-large" | "unterminated-frontmatter" | "parse-threw" | "budget-exceeded";
6
+ export interface ParseFallbackIssue {
7
+ reason: ParseFallbackReason;
8
+ message: string;
9
+ }
10
+ export interface FallbackParsedDocument extends ParsedDocument {
11
+ errors: ParseFallbackIssue[];
12
+ }
13
+ export declare function parseDocumentWithFallback(source: string): FallbackParsedDocument;
@@ -0,0 +1,98 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // A parseDocument that cannot throw and cannot run unbounded.
4
+ //
5
+ // Every byte that reaches the document parser is hostile until proven otherwise:
6
+ // agents PUT files over /v1/fs, humans paste whatever their clipboard held, and
7
+ // the local `.sfora/` workspace is an ordinary directory anyone can write to. A
8
+ // throw there is not a parse failure, it is a 500 on a file the user can still
9
+ // see on disk. So the contract here is: always return a ParsedDocument, and say
10
+ // what was given up.
11
+ //
12
+ // Adapted from inkeep/open-knowledge's `parseWithFallback` (see
13
+ // docs/research/open-knowledge-engine.md §5.3). Theirs recurses to isolate the
14
+ // offending block because their parser is a 26-plugin mdast pipeline; ours is a
15
+ // frontmatter fence plus an H1 scan, so there is no sub-document to isolate and
16
+ // the only sensible degradation is the whole document as body text. What we keep
17
+ // is the shape: bounded work, a typed reason per degradation, never a throw.
18
+ import { parseDocument } from "./markdown/document.js";
19
+ // Above this, we do not parse at all. 4 MB is ~40x the largest document the fs
20
+ // API will accept today; anything past it is a paste accident or an attack.
21
+ export const MAX_PARSE_INPUT_BYTES = 4_000_000;
22
+ // A frontmatter fence must close inside this prefix. Bounding the search means a
23
+ // document that opens with `---` and never closes it costs a fixed scan instead
24
+ // of one proportional to the whole file.
25
+ export const MAX_FRONTMATTER_SCAN_BYTES = 64_000;
26
+ // Not a cutoff — parseDocument is single-pass and has nothing to abort — but a
27
+ // tripwire: if the scan ever exceeds this, something has become superlinear and
28
+ // we want it in the errors array rather than in a latency graph.
29
+ export const MAX_PARSE_WALLCLOCK_MS = 250;
30
+ function now() {
31
+ return typeof performance !== "undefined" &&
32
+ typeof performance.now === "function"
33
+ ? performance.now()
34
+ : Date.now();
35
+ }
36
+ const MAX_ERROR_MESSAGE_LEN = 500;
37
+ function messageOf(err) {
38
+ if (err instanceof Error)
39
+ return err.message.slice(0, MAX_ERROR_MESSAGE_LEN);
40
+ return String(err ?? "unknown").slice(0, MAX_ERROR_MESSAGE_LEN);
41
+ }
42
+ // The raw-text degradation: no title, no frontmatter, the source verbatim as
43
+ // body. Deliberately lossy but never destructive — the bytes survive, so the
44
+ // file still renders as a code-ish blob and a later write does not erase it.
45
+ function degraded(source, issue) {
46
+ return { title: "", body: source, frontmatter: {}, errors: [issue] };
47
+ }
48
+ // True when the document opens a frontmatter fence it never closes within the
49
+ // scan window. Cheap: bounded slice, indexOf, no backtracking.
50
+ function hasUnterminatedFrontmatter(source) {
51
+ const head = source.startsWith("") ? source.slice(1) : source;
52
+ // Exactly parseDocument's opener — a `--- ` line is not a fence, so flagging
53
+ // it here would invent a degradation the real parser never suffers.
54
+ if (!/^---\r?\n/.test(head))
55
+ return false;
56
+ const window = head.slice(0, MAX_FRONTMATTER_SCAN_BYTES);
57
+ return !/\r?\n---[ \t]*(\r?\n|$)/.test(window.slice(3));
58
+ }
59
+ export function parseDocumentWithFallback(source) {
60
+ if (source.length > MAX_PARSE_INPUT_BYTES) {
61
+ return degraded(source, {
62
+ reason: "input-too-large",
63
+ message: `${source.length} chars exceeds MAX_PARSE_INPUT_BYTES (${MAX_PARSE_INPUT_BYTES})`,
64
+ });
65
+ }
66
+ if (hasUnterminatedFrontmatter(source)) {
67
+ return degraded(source, {
68
+ reason: "unterminated-frontmatter",
69
+ message: `no closing --- fence within MAX_FRONTMATTER_SCAN_BYTES (${MAX_FRONTMATTER_SCAN_BYTES})`,
70
+ });
71
+ }
72
+ const started = now();
73
+ let parsed;
74
+ try {
75
+ parsed = parseDocument(source);
76
+ }
77
+ catch (err) {
78
+ return degraded(source, {
79
+ reason: "parse-threw",
80
+ message: messageOf(err),
81
+ });
82
+ }
83
+ const elapsed = now() - started;
84
+ if (elapsed > MAX_PARSE_WALLCLOCK_MS) {
85
+ // The parse succeeded, so we keep its result — this records that it cost
86
+ // more than it should have.
87
+ return {
88
+ ...parsed,
89
+ errors: [
90
+ {
91
+ reason: "budget-exceeded",
92
+ message: `parse took ${Math.round(elapsed)}ms (budget ${MAX_PARSE_WALLCLOCK_MS}ms)`,
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ return { ...parsed, errors: [] };
98
+ }
@@ -0,0 +1,5 @@
1
+ export interface StripToPlainTextOptions {
2
+ wikiTokens?: "drop" | "keep";
3
+ images?: "drop" | "alt";
4
+ }
5
+ export declare function stripToPlainText(body: string | undefined | null, options?: StripToPlainTextOptions): string;
@@ -0,0 +1,41 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // Flatten a markdown body to a single line of plain text — the shared engine
4
+ // behind every excerpt/preview in sfora (feed rows, search hits, the docs
5
+ // library card, the briefing). Strips everything that would otherwise leak raw
6
+ // syntax into a one-liner: fenced code, HTML, images, mentions, wiki tokens,
7
+ // links, tables, list and checkbox markers, headings, blockquotes, emphasis.
8
+ //
9
+ // Pure: no React, no Convex, no Node.
10
+ //
11
+ // Two call sites disagreed on two details when this was three copies, so those
12
+ // two stayed as options rather than being flattened to one answer:
13
+ // • a label-less `[[token]]` — the web renderer drops it (the raw id is
14
+ // noise in a preview), the backend keeps it (its tokens are human forms
15
+ // like `c:42` that read fine).
16
+ // • an image — the web renderer drops it entirely, the backend keeps the alt
17
+ // text as words.
18
+ import { wikiLinkPattern } from "./wikiLinks.js";
19
+ export function stripToPlainText(body, options = {}) {
20
+ const { wikiTokens = "drop", images = "drop" } = options;
21
+ let out = (body ?? "")
22
+ .replace(/```[\s\S]*?```/g, " ") // fenced code blocks
23
+ .replace(/<[^>]+>/g, " ") // raw HTML tags
24
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, images === "alt" ? "$1" : " ")
25
+ .replace(/@\[([^\]]+)\]\([^)]*\)/g, "@$1") // mention → @Name
26
+ .replace(/\[\[[^\]|]*\|([^\]]*)\]\]/g, "$1"); // [[target|Label]] → Label
27
+ // Label-less tokens.
28
+ out = out.replace(wikiLinkPattern(), wikiTokens === "keep" ? "$1" : " ");
29
+ return out
30
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
31
+ .replace(/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/gm, " ") // table separator rows
32
+ .replace(/^\s*\|(.+)\|\s*$/gm, (_, inner) => inner.replace(/\s*\|\s*/g, " · ")) // table rows → cell · cell
33
+ .replace(/^#{1,6}\s+/gm, "") // headings
34
+ .replace(/^\s*[-*+]\s+\[[ xX]\]\s+/gm, "") // task checkbox bullets
35
+ .replace(/^\s*[-*+]\s+/gm, "") // list bullets
36
+ .replace(/^\s*\d+[.)]\s+/gm, "") // ordered list markers
37
+ .replace(/^\s*>\s?/gm, "") // blockquotes
38
+ .replace(/(\*\*|__|\*|_|~~|`)/g, "") // emphasis + inline code marks
39
+ .replace(/\s+/g, " ")
40
+ .trim();
41
+ }
@@ -1,3 +1,5 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
1
3
  // Pure markdown <-> post serialization for the agent filesystem API (/v1/fs).
2
4
  //
3
5
  // Frontmatter/YAML, mention rendering, slugs, and the document shape now live in
@@ -11,7 +13,7 @@
11
13
  // trip can be reconstructed (see `rehydrateMentions`).
12
14
  import { buildDocument, parseDocument, rehydrateMentions, renderMentions, serializeFrontmatter, slugFromFilename, slugify, toISO, } from "./markdown/index.js";
13
15
  // Re-export the shared helpers so existing importers (cardMarkdown, httpHelpers)
14
- // keep their `from "./postMarkdown"` paths working.
16
+ // keep their `from "./postMarkdown.js"` paths working.
15
17
  export { rehydrateMentions, slugFromFilename, slugify };
16
18
  // ─── Filenames ─────────────────────────────────────────────────────
17
19
  // The YYYY-MM-DD part of a filename. Published posts use publishedAt; drafts
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The `task` verb creates work. Canonical board filenames begin with a card
3
+ * number, but carrying that prefix into a cloud PUT would target that existing
4
+ * card. Strip it here; explicit updates belong to the filesystem API.
5
+ */
6
+ export declare function taskUploadFilename(filename: string): string;
@@ -0,0 +1,13 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ /**
4
+ * The `task` verb creates work. Canonical board filenames begin with a card
5
+ * number, but carrying that prefix into a cloud PUT would target that existing
6
+ * card. Strip it here; explicit updates belong to the filesystem API.
7
+ */
8
+ export function taskUploadFilename(filename) {
9
+ const stem = filename
10
+ .replace(/\.md$/i, "")
11
+ .replace(/^\d+(?:[-_. ]+|$)/, "");
12
+ return `${stem || "task"}.md`;
13
+ }
@@ -0,0 +1,50 @@
1
+ export declare const WAYFINDER_TICKET_TYPES: readonly ["grilling", "prototype", "research", "task"];
2
+ export type WayfinderTicketType = (typeof WAYFINDER_TICKET_TYPES)[number];
3
+ export type WayfinderTicketState = "open" | "claimed" | "decided" | "out-of-scope";
4
+ export interface WayfinderTicket {
5
+ name: string;
6
+ type: WayfinderTicketType;
7
+ state: WayfinderTicketState;
8
+ /** Names of the tickets blocking this one. */
9
+ blockedBy: string[];
10
+ /**
11
+ * When set, the picture classes this node from it and skips frontierOf's
12
+ * name-resolution. A caller with server-side blocker buckets (the Plan) is
13
+ * the truth about blockedness: a DELETED blocker card is cleared there,
14
+ * while a dangling NAME in an authored map stays unresolved — without the
15
+ * override, a question whose blocker was deleted would render ghost-blocked
16
+ * in the very view whose data source already ruled it takeable.
17
+ */
18
+ onFrontier?: boolean;
19
+ }
20
+ export interface WayfinderMap {
21
+ /** What reaching the end of the map looks like. Drawn as the terminal node. */
22
+ destination?: string;
23
+ tickets: WayfinderTicket[];
24
+ }
25
+ /**
26
+ * Parse a ticket-list section (the lines between headings). Non-matching
27
+ * lines are ignored — the list can sit inside a larger map document. Returns
28
+ * an empty list rather than null: a map with no tickets yet is a real state
29
+ * (freshly charted, everything still in the fog), not a parse failure.
30
+ */
31
+ export declare function parseWayfinderTickets(section: string): WayfinderTicket[];
32
+ /**
33
+ * A ticket is on the FRONTIER when it is open and everything blocking it is
34
+ * closed (decided or out of scope). Blockers named but absent from the map
35
+ * count as unresolved — a dangling name is a map error the picture should
36
+ * make visible, not hide.
37
+ */
38
+ export declare function frontierOf(tickets: WayfinderTicket[]): WayfinderTicket[];
39
+ export declare function wayfinderNodeId(index: number): string;
40
+ /**
41
+ * Draw a map as mermaid flowchart source the engine renders natively (the
42
+ * renderer honors classDef, so states carry color in both themes via the
43
+ * palette the diagram theme derives from --fg/--accent).
44
+ *
45
+ * Reading the picture: solid arrows are blocking edges pointing at what they
46
+ * unblock; the frontier is bold; decided tickets are quiet; out-of-scope
47
+ * tickets sit dashed at the edge; the destination, when named, is the
48
+ * terminal node every unblocked leaf feeds.
49
+ */
50
+ export declare function wayfinderMapToMermaid(map: WayfinderMap): string;
@@ -0,0 +1,203 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // Wayfinder maps — the shared vocabulary for charting a big effort as a map
4
+ // of decision tickets, and the one place its textual form and its picture are
5
+ // defined.
6
+ //
7
+ // The concepts come from mattpocock/skills' wayfinder (studied in
8
+ // docs/research/wayfinder-sfora-map.md; repo vendored in context/): a MAP with
9
+ // a destination, decision TICKETS of four types, BLOCKING edges between them,
10
+ // and states — open on the frontier, claimed, decided, ruled out of scope,
11
+ // with unspecifiable work waiting in the fog. sfora's Plan already carries the
12
+ // same lifecycle (fuzzy / up-for-grabs / claimed / decided), so this module
13
+ // speaks both dialects: wayfinder's names on the wire, the Plan's states
14
+ // mappable one-to-one.
15
+ //
16
+ // Two exports matter:
17
+ // parseWayfinderTickets — read tickets from the map's markdown ticket list
18
+ // wayfinderMapToMermaid — draw the map as a flowchart the engine renders
19
+ //
20
+ // Pure string transforms — no React, no Convex, no Node.
21
+ //
22
+ // ── The ticket-list line grammar ───────────────────────────────────────────
23
+ //
24
+ // - [ ] Pick the auth provider (grilling)
25
+ // - [~] Prototype the login screen (prototype) <- Pick the auth provider
26
+ // - [x] Name the destination
27
+ // - [-] Native mobile app
28
+ //
29
+ // State rides in the bracket: `[ ]` open, `[~]` claimed, `[x]` decided,
30
+ // `[-]` out of scope. The type rides in a trailing parenthesis (grilling when
31
+ // absent — wayfinder's default). `<- A, B` names the tickets blocking this
32
+ // one, by title. Everything is referred to BY NAME, per wayfinder's rule that
33
+ // a wall of ids is illegible.
34
+ export const WAYFINDER_TICKET_TYPES = [
35
+ "grilling",
36
+ "prototype",
37
+ "research",
38
+ "task",
39
+ ];
40
+ const STATE_BY_MARK = {
41
+ " ": "open",
42
+ "~": "claimed",
43
+ x: "decided",
44
+ X: "decided",
45
+ "-": "out-of-scope",
46
+ };
47
+ const TICKET_LINE = /^[-*]\s+\[([ ~xX-])\]\s+(.+)$/;
48
+ /**
49
+ * Parse a ticket-list section (the lines between headings). Non-matching
50
+ * lines are ignored — the list can sit inside a larger map document. Returns
51
+ * an empty list rather than null: a map with no tickets yet is a real state
52
+ * (freshly charted, everything still in the fog), not a parse failure.
53
+ */
54
+ export function parseWayfinderTickets(section) {
55
+ const tickets = [];
56
+ for (const raw of section.split(/\r?\n/)) {
57
+ const match = TICKET_LINE.exec(raw.trim());
58
+ if (!match)
59
+ continue;
60
+ let rest = match[2].trim();
61
+ let blockedBy = [];
62
+ const arrow = rest.indexOf("<-");
63
+ if (arrow >= 0) {
64
+ blockedBy = rest
65
+ .slice(arrow + 2)
66
+ .split(",")
67
+ .map((name) => name.trim())
68
+ .filter(Boolean);
69
+ rest = rest.slice(0, arrow).trim();
70
+ }
71
+ let type = "grilling";
72
+ const typed = /^(.*)\((grilling|prototype|research|task)\)$/.exec(rest);
73
+ if (typed) {
74
+ type = typed[2];
75
+ rest = typed[1].trim();
76
+ }
77
+ if (!rest)
78
+ continue;
79
+ tickets.push({
80
+ name: rest,
81
+ type,
82
+ state: STATE_BY_MARK[match[1]] ?? "open",
83
+ blockedBy,
84
+ });
85
+ }
86
+ return tickets;
87
+ }
88
+ // A blocker clears the way once it is CLOSED — decided or ruled out of scope.
89
+ // Wayfinder's rule is that any terminal ticket unblocks; ruling a blocker out
90
+ // of scope answers the question as surely as deciding it does.
91
+ const TERMINAL_STATES = new Set([
92
+ "decided",
93
+ "out-of-scope",
94
+ ]);
95
+ /**
96
+ * A ticket is on the FRONTIER when it is open and everything blocking it is
97
+ * closed (decided or out of scope). Blockers named but absent from the map
98
+ * count as unresolved — a dangling name is a map error the picture should
99
+ * make visible, not hide.
100
+ */
101
+ export function frontierOf(tickets) {
102
+ const byName = new Map(tickets.map((ticket) => [ticket.name, ticket]));
103
+ return tickets.filter((ticket) => {
104
+ if (ticket.state !== "open")
105
+ return false;
106
+ return ticket.blockedBy.every((name) => {
107
+ const blocker = byName.get(name);
108
+ return blocker !== undefined && TERMINAL_STATES.has(blocker.state);
109
+ });
110
+ });
111
+ }
112
+ // Node ids must be word-safe for the flowchart grammar; the label carries the
113
+ // real name. Stable across renders: derived from position, not content.
114
+ // Exported so a consumer can join a rendered node's data-id back to the
115
+ // ticket at the same index without re-implementing the scheme.
116
+ export function wayfinderNodeId(index) {
117
+ return `t${index}`;
118
+ }
119
+ const TYPE_GLYPH = {
120
+ grilling: "", // the default carries no marker — most tickets are grilling
121
+ prototype: " ◇",
122
+ research: " ※",
123
+ task: " ⚙",
124
+ };
125
+ function escapeLabel(name) {
126
+ // Brackets and quotes would close the node's label early.
127
+ return name.replace(/["[\]]/g, " ").replace(/\s+/g, " ").trim();
128
+ }
129
+ /**
130
+ * Draw a map as mermaid flowchart source the engine renders natively (the
131
+ * renderer honors classDef, so states carry color in both themes via the
132
+ * palette the diagram theme derives from --fg/--accent).
133
+ *
134
+ * Reading the picture: solid arrows are blocking edges pointing at what they
135
+ * unblock; the frontier is bold; decided tickets are quiet; out-of-scope
136
+ * tickets sit dashed at the edge; the destination, when named, is the
137
+ * terminal node every unblocked leaf feeds.
138
+ */
139
+ export function wayfinderMapToMermaid(map) {
140
+ const { tickets } = map;
141
+ const byName = new Map(tickets.map((ticket, index) => [ticket.name, index]));
142
+ const frontier = new Set(frontierOf(tickets).map((ticket) => ticket.name));
143
+ const lines = ["flowchart TD"];
144
+ for (const [index, ticket] of tickets.entries()) {
145
+ const label = escapeLabel(ticket.name) + TYPE_GLYPH[ticket.type];
146
+ // Shape says state at a glance even without color: decided closes into a
147
+ // stadium, out-of-scope stays a plain box, open work is a sharp rect.
148
+ const node = ticket.state === "decided"
149
+ ? `${wayfinderNodeId(index)}(["${label} ✓"])`
150
+ : `${wayfinderNodeId(index)}["${label}"]`;
151
+ lines.push(` ${node}`);
152
+ }
153
+ for (const [index, ticket] of tickets.entries()) {
154
+ for (const blocker of ticket.blockedBy) {
155
+ const from = byName.get(blocker);
156
+ if (from === undefined) {
157
+ // A dangling blocker name gets its own node so the error is VISIBLE.
158
+ const ghost = `missing${lines.length}`;
159
+ lines.push(` ${ghost}["${escapeLabel(blocker)} ?"]`);
160
+ lines.push(` ${ghost} -.-> ${wayfinderNodeId(index)}`);
161
+ continue;
162
+ }
163
+ lines.push(` ${wayfinderNodeId(from)} --> ${wayfinderNodeId(index)}`);
164
+ }
165
+ }
166
+ if (map.destination) {
167
+ const dest = `dest(["${escapeLabel(map.destination)}"])`;
168
+ lines.push(` ${dest}`);
169
+ // Every ticket nothing depends on feeds the destination — the map's
170
+ // remaining route at a glance.
171
+ const blockedNames = new Set(tickets.flatMap((ticket) => ticket.blockedBy));
172
+ for (const [index, ticket] of tickets.entries()) {
173
+ if (ticket.state === "out-of-scope")
174
+ continue;
175
+ if (!blockedNames.has(ticket.name)) {
176
+ lines.push(` ${wayfinderNodeId(index)} --> dest`);
177
+ }
178
+ }
179
+ }
180
+ // States as classes; the renderer resolves classDef props over its theme.
181
+ // Each state gets a DISTINCT stroke so the four read apart on screen — the
182
+ // same tones PlanHill assigns the same buckets, so hill and map speak one
183
+ // palette. Frontier and claimed differ by color, not only weight; decided
184
+ // is quiet but named; out of scope sits dashed.
185
+ lines.push(" classDef claimed stroke:var(--color-accent-amber),stroke-width:2px");
186
+ lines.push(" classDef frontier stroke:var(--color-brand-blue),stroke-width:2px");
187
+ lines.push(" classDef outofscope stroke:var(--color-text-tertiary),stroke-dasharray:4 3");
188
+ lines.push(" classDef decided stroke:var(--color-success)");
189
+ const byState = (predicate) => tickets.flatMap((ticket, index) => (predicate(ticket) ? [wayfinderNodeId(index)] : []));
190
+ const claimed = byState((t) => t.state === "claimed");
191
+ const front = byState((t) => t.onFrontier ?? frontier.has(t.name));
192
+ const out = byState((t) => t.state === "out-of-scope");
193
+ const done = byState((t) => t.state === "decided");
194
+ if (claimed.length > 0)
195
+ lines.push(` class ${claimed.join(",")} claimed`);
196
+ if (front.length > 0)
197
+ lines.push(` class ${front.join(",")} frontier`);
198
+ if (out.length > 0)
199
+ lines.push(` class ${out.join(",")} outofscope`);
200
+ if (done.length > 0)
201
+ lines.push(` class ${done.join(",")} decided`);
202
+ return lines.join("\n");
203
+ }
@@ -0,0 +1,19 @@
1
+ export type WikiLinkKind = "post" | "card" | "note" | "pr";
2
+ export declare const WIKI_LINK_PREFIXES: ReadonlyArray<readonly [prefix: string, kind: WikiLinkKind]>;
3
+ export declare const WIKI_LINK_PREFIX_FOR: Readonly<Record<WikiLinkKind, string>>;
4
+ export declare function buildWikiLink(kind: WikiLinkKind, id: string, label: string): string;
5
+ export interface WikiLink {
6
+ kind: WikiLinkKind;
7
+ id: string;
8
+ target: string;
9
+ label: string;
10
+ hasLabel: boolean;
11
+ }
12
+ export declare function wikiLinkPattern(): RegExp;
13
+ export declare function parseWikiToken(raw: string): WikiLink;
14
+ export declare function parseWikiTarget(target: string): {
15
+ kind: WikiLinkKind;
16
+ id: string;
17
+ };
18
+ export declare function parseWikiLinks(body: string): WikiLink[];
19
+ export declare function matchWikiToken(text: string): WikiLink | null;
@@ -0,0 +1,80 @@
1
+ // GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
2
+ // Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
3
+ // The `[[…]]` wiki-link grammar — sfora's cross-entity link token, and the one
4
+ // place its prefix table lives.
5
+ //
6
+ // [[<postId>]] → a post (bare, no prefix)
7
+ // [[n:<noteId>]] → a doc / note
8
+ // [[c:<cardId>]] → a task / card (also accepts `c:#42` / `c:42`)
9
+ // [[pr:<n>]] / [[gh:<n>]] → a pull request
10
+ //
11
+ // Any form may carry a display label after a pipe: `[[c:42|Fix login]]`.
12
+ //
13
+ // Pure string transforms — no React, no Convex, no Node. Consumers: the Convex
14
+ // reference indexer (convex/references.ts), the web renderer
15
+ // (src/lib/utils/markdown.ts), and, by hand-copy, the mobile preprocessor
16
+ // (packages/mobile/src/lib/markdown-tokens.ts — outside the pnpm workspace).
17
+ // Prefix → kind, longest-first so `pr:` is tried before any future `p:`.
18
+ export const WIKI_LINK_PREFIXES = [
19
+ ["pr:", "pr"],
20
+ ["gh:", "pr"],
21
+ ["n:", "note"],
22
+ ["c:", "card"],
23
+ ];
24
+ // The prefix to WRITE for a kind when building a canonical token. Note `gh:` is
25
+ // an accepted input alias for `pr:` but is never emitted.
26
+ export const WIKI_LINK_PREFIX_FOR = {
27
+ post: "",
28
+ card: "c:",
29
+ note: "n:",
30
+ pr: "pr:",
31
+ };
32
+ // Build the canonical `[[<prefix><id>|<label>]]` form. The label is sanitized:
33
+ // `[`, `]`, and `|` inside it would break the token grammar.
34
+ export function buildWikiLink(kind, id, label) {
35
+ const clean = label.replace(/[[\]|]/g, " ").trim() || "Untitled";
36
+ return `[[${WIKI_LINK_PREFIX_FOR[kind]}${id}|${clean}]]`;
37
+ }
38
+ // A fresh global matcher each call: a shared /g regex carries `lastIndex`
39
+ // between callers and would silently skip tokens.
40
+ export function wikiLinkPattern() {
41
+ return /\[\[([^\]]+)\]\]/g;
42
+ }
43
+ // Split a token body (the text between `[[` and `]]`) into target + label.
44
+ export function parseWikiToken(raw) {
45
+ const pipe = raw.indexOf("|");
46
+ const target = (pipe >= 0 ? raw.slice(0, pipe) : raw).trim();
47
+ const explicit = pipe >= 0 ? raw.slice(pipe + 1).trim() : "";
48
+ const { kind, id } = parseWikiTarget(target);
49
+ return {
50
+ kind,
51
+ id,
52
+ target,
53
+ label: explicit || target,
54
+ hasLabel: explicit.length > 0,
55
+ };
56
+ }
57
+ // Classify a target (prefix included) into its kind + bare identifier.
58
+ export function parseWikiTarget(target) {
59
+ for (const [prefix, kind] of WIKI_LINK_PREFIXES) {
60
+ if (target.startsWith(prefix)) {
61
+ return { kind, id: target.slice(prefix.length) };
62
+ }
63
+ }
64
+ return { kind: "post", id: target };
65
+ }
66
+ // Every `[[…]]` token in a body, in document order, not deduped.
67
+ export function parseWikiLinks(body) {
68
+ const out = [];
69
+ const re = wikiLinkPattern();
70
+ let m;
71
+ while ((m = re.exec(body)) !== null)
72
+ out.push(parseWikiToken(m[1]));
73
+ return out;
74
+ }
75
+ // Parse a string that must be EXACTLY one token and nothing else — the test
76
+ // behind "this whole line is a single link, so unfurl it as a card".
77
+ export function matchWikiToken(text) {
78
+ const m = /^\[\[([^\]]+)\]\]$/.exec(text.trim());
79
+ return m ? parseWikiToken(m[1]) : null;
80
+ }
@@ -16,6 +16,18 @@
16
16
  */
17
17
  /** Directory name that marks a local sfora workspace. */
18
18
  export declare const WORKSPACE_DIR = ".sfora";
19
+ /**
20
+ * Migrate an existing workspace's board onto the four fixed stage dirs (One
21
+ * Flow). Idempotent: a board already in the canonical shape is left untouched.
22
+ * Legacy columns are mapped by name (done→done, in progress→doing, triage/
23
+ * undecided/…→triage, else todo); each card file MOVES into its stage dir, and
24
+ * a card leaving an unrecognized column keeps that column's name as a label so
25
+ * nothing is lost. Called on open so old \`.sfora/\` dirs reshape silently.
26
+ */
27
+ export declare function migrateWorkspaceStages(root: string): Promise<{
28
+ migrated: boolean;
29
+ moved: number;
30
+ }>;
19
31
  /**
20
32
  * Walk up from `cwd` looking for a `.sfora/` workspace. A directory only
21
33
  * counts when it has workspace markers (board/posts/docs) — `~/.sfora` is also