sfora-cli 0.9.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.
- package/README.md +8 -6
- package/dist/SforaFs.js +270 -4
- package/dist/api-client.d.ts +47 -1
- package/dist/api-client.js +60 -3
- package/dist/cli.js +6 -3
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blocks/dropClosure.d.ts +72 -0
- package/dist/format/blocks/dropClosure.js +186 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +66 -0
- package/dist/format/callout.js +130 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +151 -0
- package/dist/format/index.d.ts +18 -4
- package/dist/format/index.js +24 -4
- package/dist/format/lineGeometry.d.ts +70 -0
- package/dist/format/lineGeometry.js +324 -0
- package/dist/format/lint/index.d.ts +20 -0
- package/dist/format/lint/index.js +22 -0
- package/dist/format/lint/lintSource.d.ts +36 -0
- package/dist/format/lint/lintSource.js +154 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- package/dist/format/lint/rules/index.d.ts +10 -0
- package/dist/format/lint/rules/index.js +26 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +79 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +60 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +93 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- package/dist/format/lint/types.d.ts +80 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.js +2 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +41 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +19 -0
- package/dist/format/wikiLinks.js +80 -0
- package/dist/mcp-server.js +5 -2
- 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,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
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -14,9 +14,12 @@ const TOOL_DESCRIPTION = `Run a bash command against the sfora workspace — a U
|
|
|
14
14
|
- /projects/<slug>/posts/<file>.md published posts
|
|
15
15
|
- /projects/<slug>/drafts/<file>.md your drafts
|
|
16
16
|
- /projects/<slug>/board/<NN-stage>/<NNNN>.md tasks (kanban cards), by stage
|
|
17
|
-
- /projects/<slug>/
|
|
17
|
+
- /projects/<slug>/library/documents/<file>.md workspace documents (writable)
|
|
18
|
+
- /projects/<slug>/library/files/<file> uploaded files (read-only)
|
|
19
|
+
- /projects/<slug>/library/repositories/<repo> project source trees (read-only)
|
|
18
20
|
- /projects/<slug>/pulls/<number>.md pull requests (diff + linked work), read-only
|
|
19
|
-
- /projects/<slug>/plan.md the goal + open questions
|
|
21
|
+
- /projects/<slug>/plan.md the goal + open questions (write to set the goal)
|
|
22
|
+
- /projects/<slug>/asks.md coordination asks, read-only
|
|
20
23
|
- /inbox/mentions.md unread mentions
|
|
21
24
|
- /me/api-key your identity
|
|
22
25
|
Every board is the same four fixed columns — 01-triage / 02-todo / 03-in-progress / 04-done. There is no column management; move a card into 04-done to close it. Question cards (kind: question) map their stage to the plan: triage = fuzzy, todo = up for grabs, in-progress = claimed, done = decided.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
|
|
6
6
|
"keywords": [
|
|
@@ -36,15 +36,16 @@
|
|
|
36
36
|
"README.md"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "tsc -p .",
|
|
40
|
-
"dev": "tsx src/cli.ts",
|
|
41
|
-
"typecheck": "tsc --noEmit"
|
|
39
|
+
"build": "node scripts/sync-format.mjs && tsc -p .",
|
|
40
|
+
"dev": "node scripts/sync-format.mjs && tsx src/cli.ts",
|
|
41
|
+
"typecheck": "node scripts/sync-format.mjs && tsc --noEmit"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"
|
|
45
|
-
"
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
45
|
+
"just-bash": "^3.0.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
+
"@types/mdast": "^4.0.4",
|
|
48
49
|
"@types/node": "^22.10.0",
|
|
49
50
|
"tsx": "^4.20.3",
|
|
50
51
|
"typescript": "^5.9.3"
|