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.
Files changed (69) 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 +6 -3
  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 +2 -0
  21. package/dist/format/cardMarkdown.js +10 -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/mcp-server.js +5 -2
  69. package/package.json +7 -6
package/README.md CHANGED
@@ -21,7 +21,9 @@ shell interpreter backs the interactive mode).
21
21
  ├── projects/<slug>/posts/<YYYY-MM-DD-title>.md # published posts (GET·PUT·DELETE)
22
22
  │ /drafts/<…>.md # your drafts (GET·PUT·DELETE)
23
23
  │ /board/<NN-col>/<NNNN-card>.md # tasks by column (GET·PUT·DELETE)
24
- │ /docs/<…>.md # docs / notes (GET·PUT·DELETE)
24
+ │ /library/documents/<…>.md # docs / notes (GET·PUT·DELETE)
25
+ │ /files/<…> # uploaded files (GET)
26
+ │ /repositories/<repo>/… # source trees (GET)
25
27
  ├── inbox/mentions.md # unread mentions (GET)
26
28
  └── me/api-key # your identity (GET)
27
29
  ```
@@ -154,11 +156,11 @@ sfora:/$ cd /projects/general/posts # cwd persists across commands
154
156
  sfora:/projects/general/posts$ exit
155
157
  ```
156
158
 
157
- Writing a file `PUT`s it (creating a post or, within the 5-minute edit window,
158
- updating one you authored). A bare `@Display Name` that matches an active member
159
- is rehydrated to a real mention server-side. `rm` soft-deletes (author or org
160
- admin/owner). Drafts live under `…/drafts/`; a `scheduledFor:` in the frontmatter
161
- schedules auto-publish.
159
+ Writing a post file publishes a new immutable record. Edit mutable work under
160
+ `…/drafts/` and publish only when it is ready; overwriting an existing published
161
+ filename is rejected. A bare `@Display Name` that matches an active member is
162
+ rehydrated to a real mention server-side. `rm` soft-deletes (author or org
163
+ admin/owner). A `scheduledFor:` in draft frontmatter schedules auto-publish.
162
164
 
163
165
  ## MCP server (Claude Desktop / Cursor)
164
166
 
package/dist/SforaFs.js CHANGED
@@ -79,6 +79,15 @@ function classify(path) {
79
79
  if (dir === "links.md" && seg.length === 3) {
80
80
  return { kind: "linksFile", slug };
81
81
  }
82
+ if (dir === "plan.md" && seg.length === 3) {
83
+ return { kind: "planFile", slug };
84
+ }
85
+ if (dir === "map.md" && seg.length === 3) {
86
+ return { kind: "mapFile", slug };
87
+ }
88
+ if (dir === "asks.md" && seg.length === 3) {
89
+ return { kind: "asksFile", slug };
90
+ }
82
91
  if (dir === "posts" || dir === "drafts") {
83
92
  if (seg.length === 3)
84
93
  return { kind: "postsDir", slug, dir };
@@ -92,6 +101,38 @@ function classify(path) {
92
101
  if (seg.length === 4)
93
102
  return { kind: "docFile", slug, filename: seg[3] };
94
103
  }
104
+ if (dir === "artifacts") {
105
+ if (seg.length === 3)
106
+ return { kind: "filesDir", slug };
107
+ if (seg.length === 4)
108
+ return { kind: "artifactFile", slug, filename: seg[3] };
109
+ }
110
+ if (dir === "library") {
111
+ if (seg.length === 3)
112
+ return { kind: "libraryDir", slug };
113
+ if (seg[3] === "documents") {
114
+ if (seg.length === 4)
115
+ return { kind: "docsDir", slug };
116
+ if (seg.length === 5)
117
+ return { kind: "docFile", slug, filename: seg[4] };
118
+ }
119
+ if (seg[3] === "files") {
120
+ if (seg.length === 4)
121
+ return { kind: "filesDir", slug };
122
+ if (seg.length === 5)
123
+ return { kind: "artifactFile", slug, filename: seg[4] };
124
+ }
125
+ if (seg[3] === "repositories") {
126
+ if (seg.length === 4)
127
+ return { kind: "repositoriesDir", slug };
128
+ return {
129
+ kind: "repositoryPath",
130
+ slug,
131
+ repository: seg[4],
132
+ path: seg.slice(5).join("/"),
133
+ };
134
+ }
135
+ }
95
136
  if (dir === "pulls") {
96
137
  if (seg.length === 3)
97
138
  return { kind: "pullsDir", slug };
@@ -212,9 +253,45 @@ export class SforaFs {
212
253
  return this.#cached(key, async () => {
213
254
  const notes = await this.#client.listNotes(slug);
214
255
  this.#entrySnapshots.set(`/projects/${slug}/docs`, notes.map((n) => n.filename));
256
+ this.#entrySnapshots.set(`/projects/${slug}/library/documents`, notes.map((n) => n.filename));
215
257
  return notes;
216
258
  });
217
259
  }
260
+ #invalidateNotes(slug) {
261
+ this.#cache.delete(`docs:${slug}`);
262
+ }
263
+ #listArtifacts(slug) {
264
+ return this.#cached(`artifacts:${slug}`, async () => {
265
+ const artifacts = await this.#client.listArtifacts(slug);
266
+ const names = artifacts.map((artifact) => artifact.name);
267
+ this.#entrySnapshots.set(`/projects/${slug}/artifacts`, names);
268
+ this.#entrySnapshots.set(`/projects/${slug}/library/files`, names);
269
+ return artifacts;
270
+ });
271
+ }
272
+ #listRepositories(slug) {
273
+ return this.#cached(`repositories:${slug}`, async () => {
274
+ const repositories = await this.#client.listRepositories(slug);
275
+ this.#entrySnapshots.set(`/projects/${slug}/library/repositories`, repositories.map((repository) => repository.dirname));
276
+ return repositories;
277
+ });
278
+ }
279
+ #repositoryTree(slug, repository) {
280
+ return this.#cached(`repository-tree:${slug}/${repository}`, async () => {
281
+ const tree = await this.#client.getRepositoryTree(slug, repository);
282
+ this.#entrySnapshots.set(`/projects/${slug}/library/repositories/${repository}`, tree.entries.map((entry) => entry.path));
283
+ return tree;
284
+ });
285
+ }
286
+ async #repositoryEntry(slug, repository, path) {
287
+ if (!path) {
288
+ const repositories = await this.#listRepositories(slug);
289
+ return repositories.some((candidate) => candidate.dirname === repository)
290
+ ? { kind: "directory" }
291
+ : undefined;
292
+ }
293
+ return (await this.#repositoryTree(slug, repository)).entries.find((entry) => entry.path === path);
294
+ }
218
295
  // ─── pull requests cache (read-only) ─────────────────────────────
219
296
  #listPulls(slug) {
220
297
  const key = `pulls:${slug}`;
@@ -381,6 +458,31 @@ export class SforaFs {
381
458
  catch (e) {
382
459
  throw fromApi(e, "open", normalize(path));
383
460
  }
461
+ case "artifactFile":
462
+ try {
463
+ return await this.#client.readArtifact(loc.slug, loc.filename);
464
+ }
465
+ catch (e) {
466
+ throw fromApi(e, "open", normalize(path));
467
+ }
468
+ case "repositoryPath": {
469
+ if (!loc.path)
470
+ throw eisdir("read", normalize(path));
471
+ const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
472
+ if (!entry)
473
+ throw enoent("open", normalize(path));
474
+ if (entry.kind === "directory")
475
+ throw eisdir("read", normalize(path));
476
+ if (entry.kind === "submodule") {
477
+ return `Submodule ${loc.path}\nRepository: ${loc.repository}\n`;
478
+ }
479
+ try {
480
+ return await this.#client.readRepositoryFile(loc.slug, loc.repository, loc.path);
481
+ }
482
+ catch (e) {
483
+ throw fromApi(e, "open", normalize(path));
484
+ }
485
+ }
384
486
  case "pullFile":
385
487
  try {
386
488
  return await this.#client.readPull(loc.slug, loc.number);
@@ -416,6 +518,27 @@ export class SforaFs {
416
518
  catch (e) {
417
519
  throw fromApi(e, "open", normalize(path));
418
520
  }
521
+ case "planFile":
522
+ try {
523
+ return await this.#client.readPlan(loc.slug);
524
+ }
525
+ catch (e) {
526
+ throw fromApi(e, "open", normalize(path));
527
+ }
528
+ case "mapFile":
529
+ try {
530
+ return await this.#client.readMap(loc.slug);
531
+ }
532
+ catch (e) {
533
+ throw fromApi(e, "open", normalize(path));
534
+ }
535
+ case "asksFile":
536
+ try {
537
+ return await this.#client.readAsks(loc.slug);
538
+ }
539
+ catch (e) {
540
+ throw fromApi(e, "open", normalize(path));
541
+ }
419
542
  case "root":
420
543
  case "projectsDir":
421
544
  case "inboxDir":
@@ -423,6 +546,9 @@ export class SforaFs {
423
546
  case "projectDir":
424
547
  case "postsDir":
425
548
  case "docsDir":
549
+ case "libraryDir":
550
+ case "filesDir":
551
+ case "repositoriesDir":
426
552
  case "pullsDir":
427
553
  case "boardDir":
428
554
  case "boardColumnDir":
@@ -449,6 +575,10 @@ export class SforaFs {
449
575
  if (loc.kind === "inboxFile" || loc.kind === "meFile") {
450
576
  throw eacces("open", norm);
451
577
  }
578
+ // asks.md is a read-only projection — claims go through the asks API.
579
+ if (loc.kind === "asksFile") {
580
+ throw eacces("open", norm);
581
+ }
452
582
  if (loc.kind === "linksFile") {
453
583
  try {
454
584
  await this.#client.setProjectLinks(loc.slug, toText(content));
@@ -458,6 +588,17 @@ export class SforaFs {
458
588
  }
459
589
  return;
460
590
  }
591
+ // plan.md: the PUT edits `## the goal` only; the server ignores (and
592
+ // names) every generated section, so a read-modify-write is always safe.
593
+ if (loc.kind === "planFile") {
594
+ try {
595
+ await this.#client.writePlan(loc.slug, toText(content));
596
+ }
597
+ catch (e) {
598
+ throw fromApi(e, "open", norm);
599
+ }
600
+ return;
601
+ }
461
602
  if (loc.kind === "root" ||
462
603
  loc.kind === "projectsDir" ||
463
604
  loc.kind === "inboxDir" ||
@@ -465,12 +606,21 @@ export class SforaFs {
465
606
  loc.kind === "projectDir" ||
466
607
  loc.kind === "postsDir" ||
467
608
  loc.kind === "docsDir" ||
609
+ loc.kind === "libraryDir" ||
610
+ loc.kind === "filesDir" ||
611
+ loc.kind === "repositoriesDir" ||
468
612
  loc.kind === "pullsDir" ||
469
613
  loc.kind === "boardDir" ||
470
614
  loc.kind === "boardColumnDir" ||
471
615
  loc.kind === "publicDir") {
472
616
  throw eisdir("open", norm);
473
617
  }
618
+ // map.md is derived from the board and refuses writes server-side (405);
619
+ // fail locally with the same message so an agent learns the write doors
620
+ // without a round trip.
621
+ if (loc.kind === "mapFile") {
622
+ throw eacces("open", norm);
623
+ }
474
624
  if (loc.kind === "postFile") {
475
625
  try {
476
626
  await this.#client.writePost(loc.slug, loc.dir, loc.filename, toText(content));
@@ -481,6 +631,16 @@ export class SforaFs {
481
631
  this.#invalidate(loc.slug, loc.dir);
482
632
  return;
483
633
  }
634
+ if (loc.kind === "docFile") {
635
+ try {
636
+ await this.#client.writeNote(loc.slug, loc.filename, toText(content));
637
+ }
638
+ catch (e) {
639
+ throw fromApi(e, "open", norm);
640
+ }
641
+ this.#invalidateNotes(loc.slug);
642
+ return;
643
+ }
484
644
  if (loc.kind === "boardCardFile") {
485
645
  try {
486
646
  const res = await this.#client.writeCard(loc.slug, loc.column, loc.filename, toText(content));
@@ -520,8 +680,14 @@ export class SforaFs {
520
680
  case "projectDir":
521
681
  case "postsDir":
522
682
  case "docsDir":
683
+ case "libraryDir":
684
+ case "filesDir":
685
+ case "repositoriesDir":
523
686
  case "pullsDir":
524
687
  case "boardDir":
688
+ case "planFile":
689
+ case "mapFile":
690
+ case "asksFile":
525
691
  return await this.#projectExists(loc.slug);
526
692
  case "publicDir":
527
693
  case "roadmapFile":
@@ -536,6 +702,10 @@ export class SforaFs {
536
702
  undefined;
537
703
  case "docFile":
538
704
  return (await this.#matchNote(loc.slug, loc.filename)) !== undefined;
705
+ case "artifactFile":
706
+ return (await this.#listArtifacts(loc.slug)).some((artifact) => artifact.name === loc.filename);
707
+ case "repositoryPath":
708
+ return (await this.#repositoryEntry(loc.slug, loc.repository, loc.path)) !== undefined;
539
709
  case "pullFile":
540
710
  return (await this.#matchPull(loc.slug, loc.number)) !== undefined;
541
711
  case "boardCardFile":
@@ -583,12 +753,18 @@ export class SforaFs {
583
753
  case "meFile":
584
754
  return this.#fileStat(new Date());
585
755
  case "linksFile":
756
+ case "planFile":
757
+ case "mapFile":
758
+ case "asksFile":
586
759
  if (!(await this.#projectExists(loc.slug)))
587
760
  throw enoent("stat", norm);
588
761
  return this.#fileStat(new Date());
589
762
  case "projectDir":
590
763
  case "postsDir":
591
764
  case "docsDir":
765
+ case "libraryDir":
766
+ case "filesDir":
767
+ case "repositoriesDir":
592
768
  case "pullsDir":
593
769
  case "boardDir":
594
770
  if (!(await this.#projectExists(loc.slug)))
@@ -625,6 +801,20 @@ export class SforaFs {
625
801
  throw enoent("stat", norm);
626
802
  return this.#fileStat(new Date(note.lastEditedAt || Date.now()));
627
803
  }
804
+ case "artifactFile": {
805
+ const artifact = (await this.#listArtifacts(loc.slug)).find((candidate) => candidate.name === loc.filename);
806
+ if (!artifact)
807
+ throw enoent("stat", norm);
808
+ return this.#fileStat(new Date(artifact.uploadedAt || Date.now()), artifact.size);
809
+ }
810
+ case "repositoryPath": {
811
+ const entry = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
812
+ if (!entry)
813
+ throw enoent("stat", norm);
814
+ return entry.kind === "directory"
815
+ ? this.#dirStat()
816
+ : this.#fileStat(new Date(), "size" in entry ? entry.size ?? 0 : 0);
817
+ }
628
818
  case "pullFile": {
629
819
  const pull = await this.#matchPull(loc.slug, loc.number);
630
820
  if (!pull)
@@ -683,10 +873,13 @@ export class SforaFs {
683
873
  throw enoent("scandir", norm);
684
874
  }
685
875
  const entries = [
876
+ dirent("asks.md", false),
686
877
  dirent("board", true),
687
- dirent("docs", true),
688
878
  dirent("drafts", true),
879
+ dirent("library", true),
689
880
  dirent("links.md", false),
881
+ dirent("plan.md", false),
882
+ dirent("map.md", false),
690
883
  dirent("posts", true),
691
884
  dirent("pulls", true),
692
885
  ];
@@ -696,6 +889,14 @@ export class SforaFs {
696
889
  }
697
890
  return entries;
698
891
  }
892
+ case "libraryDir":
893
+ if (!(await this.#projectExists(loc.slug)))
894
+ throw enoent("scandir", norm);
895
+ return [
896
+ dirent("documents", true),
897
+ dirent("files", true),
898
+ dirent("repositories", true),
899
+ ];
699
900
  case "postsDir": {
700
901
  const entries = await this.#listEntries(loc.slug, loc.dir);
701
902
  return entries.map((e) => dirent(e.filename, false));
@@ -707,6 +908,35 @@ export class SforaFs {
707
908
  const notes = await this.#listNotes(loc.slug);
708
909
  return notes.map((n) => dirent(n.filename, false));
709
910
  }
911
+ case "filesDir": {
912
+ if (!(await this.#projectExists(loc.slug)))
913
+ throw enoent("scandir", norm);
914
+ return (await this.#listArtifacts(loc.slug)).map((artifact) => dirent(artifact.name, false));
915
+ }
916
+ case "repositoriesDir": {
917
+ if (!(await this.#projectExists(loc.slug)))
918
+ throw enoent("scandir", norm);
919
+ return (await this.#listRepositories(loc.slug)).map((repository) => dirent(repository.dirname, true));
920
+ }
921
+ case "repositoryPath": {
922
+ const current = await this.#repositoryEntry(loc.slug, loc.repository, loc.path);
923
+ if (!current)
924
+ throw enoent("scandir", norm);
925
+ if (current.kind !== "directory")
926
+ throw enotdir("scandir", norm);
927
+ const prefix = loc.path ? `${loc.path}/` : "";
928
+ const children = new Map();
929
+ for (const entry of (await this.#repositoryTree(loc.slug, loc.repository)).entries) {
930
+ if (!entry.path.startsWith(prefix) || entry.path === loc.path)
931
+ continue;
932
+ const rest = entry.path.slice(prefix.length);
933
+ const [name, ...tail] = rest.split("/");
934
+ if (!name)
935
+ continue;
936
+ children.set(name, tail.length > 0 || entry.kind === "directory");
937
+ }
938
+ return [...children].map(([name, isDir]) => dirent(name, isDir));
939
+ }
710
940
  case "pullsDir": {
711
941
  if (!(await this.#projectExists(loc.slug))) {
712
942
  throw enoent("scandir", norm);
@@ -743,9 +973,12 @@ export class SforaFs {
743
973
  case "meFile":
744
974
  case "postFile":
745
975
  case "docFile":
976
+ case "artifactFile":
746
977
  case "pullFile":
747
978
  case "boardCardFile":
748
979
  case "roadmapFile":
980
+ case "planFile":
981
+ case "asksFile":
749
982
  throw enotdir("scandir", norm);
750
983
  default:
751
984
  throw enoent("scandir", norm);
@@ -772,6 +1005,9 @@ export class SforaFs {
772
1005
  case "projectDir":
773
1006
  case "postsDir":
774
1007
  case "docsDir":
1008
+ case "libraryDir":
1009
+ case "filesDir":
1010
+ case "repositoriesDir":
775
1011
  case "pullsDir":
776
1012
  case "boardDir":
777
1013
  case "publicDir":
@@ -858,12 +1094,32 @@ export class SforaFs {
858
1094
  }
859
1095
  case "inboxFile":
860
1096
  case "meFile":
861
- case "docFile":
1097
+ case "artifactFile":
1098
+ case "repositoryPath":
862
1099
  case "pullFile":
863
1100
  case "roadmapFile":
864
- // Read-only surfaces (notes aren't deletable through the fs yet; the
865
- // roadmap is a projection; pulls are owned by GitHub).
1101
+ case "planFile":
1102
+ case "asksFile":
1103
+ // Uploaded files and repositories are read-only projections; the
1104
+ // roadmap and pulls are owned by their source systems; the plan and
1105
+ // asks are generated views that can't be unlinked.
866
1106
  throw eacces("unlink", norm);
1107
+ case "docFile": {
1108
+ const note = await this.#matchNote(loc.slug, loc.filename);
1109
+ if (!note) {
1110
+ if (options?.force)
1111
+ return;
1112
+ throw enoent("unlink", norm);
1113
+ }
1114
+ try {
1115
+ await this.#client.deleteNote(loc.slug, loc.filename);
1116
+ }
1117
+ catch (e) {
1118
+ throw fromApi(e, "unlink", norm);
1119
+ }
1120
+ this.#invalidateNotes(loc.slug);
1121
+ return;
1122
+ }
867
1123
  case "root":
868
1124
  case "projectsDir":
869
1125
  case "inboxDir":
@@ -871,6 +1127,9 @@ export class SforaFs {
871
1127
  case "projectDir":
872
1128
  case "postsDir":
873
1129
  case "docsDir":
1130
+ case "libraryDir":
1131
+ case "filesDir":
1132
+ case "repositoriesDir":
874
1133
  case "pullsDir":
875
1134
  case "boardDir":
876
1135
  case "publicDir":
@@ -982,9 +1241,16 @@ export class SforaFs {
982
1241
  ]);
983
1242
  for (const slug of this.#projectSlugs) {
984
1243
  paths.add(`/projects/${slug}`);
1244
+ paths.add(`/projects/${slug}/plan.md`);
1245
+ paths.add(`/projects/${slug}/asks.md`);
985
1246
  paths.add(`/projects/${slug}/posts`);
986
1247
  paths.add(`/projects/${slug}/drafts`);
987
1248
  paths.add(`/projects/${slug}/docs`);
1249
+ paths.add(`/projects/${slug}/artifacts`);
1250
+ paths.add(`/projects/${slug}/library`);
1251
+ paths.add(`/projects/${slug}/library/documents`);
1252
+ paths.add(`/projects/${slug}/library/files`);
1253
+ paths.add(`/projects/${slug}/library/repositories`);
988
1254
  paths.add(`/projects/${slug}/pulls`);
989
1255
  paths.add(`/projects/${slug}/board`);
990
1256
  // `public/` is only real for boards known to be shared.
@@ -38,6 +38,35 @@ export interface Note {
38
38
  title: string;
39
39
  lastEditedAt: number;
40
40
  }
41
+ /** An uploaded project file under the unified Library. */
42
+ export interface Artifact {
43
+ name: string;
44
+ mimeType: string;
45
+ size: number;
46
+ uploadedBy: string | null;
47
+ uploadedAt: number;
48
+ url: string | null;
49
+ }
50
+ /** A GitHub repository attached to a project. Repository content is read-only. */
51
+ export interface Repository {
52
+ projectRepoId: string;
53
+ dirname: string;
54
+ owner: string;
55
+ repo: string;
56
+ defaultBranch: string;
57
+ }
58
+ export interface RepositoryTreeEntry {
59
+ path: string;
60
+ kind: "file" | "directory" | "submodule";
61
+ size?: number;
62
+ }
63
+ export interface RepositoryTree {
64
+ owner: string;
65
+ repo: string;
66
+ ref: string;
67
+ truncated: boolean;
68
+ entries: RepositoryTreeEntry[];
69
+ }
41
70
  /**
42
71
  * A pull request entry. Mirrors a row of `GET …/pulls`. Read-only — the source
43
72
  * of truth is GitHub; sfora syncs these so agents can see the code work
@@ -121,6 +150,16 @@ export declare class SforaApiClient {
121
150
  getProjectLinks(slug: string): Promise<string>;
122
151
  /** `PUT …/links.md` — replace the project's links from a markdown list. */
123
152
  setProjectLinks(slug: string, markdown: string): Promise<void>;
153
+ /** `GET …/plan.md` — the project's plan (goal + question buckets). */
154
+ readMap(slug: string): Promise<string>;
155
+ readPlan(slug: string): Promise<string>;
156
+ /**
157
+ * `PUT …/plan.md` — set the goal. Only the `## the goal` section is
158
+ * honored; the server names everything it ignored in `ignoredSections`.
159
+ */
160
+ writePlan(slug: string, markdown: string): Promise<void>;
161
+ /** `GET …/asks.md` — coordination asks (read-only projection). */
162
+ readAsks(slug: string): Promise<string>;
124
163
  /**
125
164
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
126
165
  * (future) `scheduledFor` set.
@@ -128,7 +167,7 @@ export declare class SforaApiClient {
128
167
  listPosts(projectSlug: string, kind?: PostKind): Promise<Entry[]>;
129
168
  /** `GET …/posts/:filename.md` (or `…/drafts/…`). Returns the raw markdown body. */
130
169
  readPost(projectSlug: string, filename: string, kind?: PostKind): Promise<string>;
131
- /** `PUT …/(posts|drafts)/:filename.md` — create or update from a markdown file. */
170
+ /** Create a post or upsert a mutable draft from a Markdown file. */
132
171
  writePost(projectSlug: string, kind: PostKind, filename: string, markdown: string): Promise<WriteResult>;
133
172
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
134
173
  deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
@@ -136,6 +175,13 @@ export declare class SforaApiClient {
136
175
  listNotes(projectSlug: string): Promise<Note[]>;
137
176
  /** `GET …/docs/:filename.md`. Returns the raw markdown body. */
138
177
  readNote(projectSlug: string, filename: string): Promise<string>;
178
+ writeNote(projectSlug: string, filename: string, markdown: string): Promise<WriteResult>;
179
+ deleteNote(projectSlug: string, filename: string): Promise<void>;
180
+ listArtifacts(projectSlug: string): Promise<Artifact[]>;
181
+ readArtifact(projectSlug: string, filename: string): Promise<string>;
182
+ listRepositories(projectSlug: string): Promise<Repository[]>;
183
+ getRepositoryTree(projectSlug: string, dirname: string): Promise<RepositoryTree>;
184
+ readRepositoryFile(projectSlug: string, dirname: string, path: string): Promise<string>;
139
185
  /** `GET …/pulls` — the project's synced pull requests, open first. */
140
186
  listPulls(projectSlug: string): Promise<Pull[]>;
141
187
  /**
@@ -102,6 +102,25 @@ export class SforaApiClient {
102
102
  async setProjectLinks(slug, markdown) {
103
103
  await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/links.md`, markdown);
104
104
  }
105
+ /** `GET …/plan.md` — the project's plan (goal + question buckets). */
106
+ // The map as a file: derived, read-only (a PUT is refused server-side).
107
+ async readMap(slug) {
108
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/map.md`);
109
+ }
110
+ async readPlan(slug) {
111
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/plan.md`);
112
+ }
113
+ /**
114
+ * `PUT …/plan.md` — set the goal. Only the `## the goal` section is
115
+ * honored; the server names everything it ignored in `ignoredSections`.
116
+ */
117
+ async writePlan(slug, markdown) {
118
+ await this.#request("PUT", `/v1/fs/projects/${encodeURIComponent(slug)}/plan.md`, markdown);
119
+ }
120
+ /** `GET …/asks.md` — coordination asks (read-only projection). */
121
+ async readAsks(slug) {
122
+ return this.#text(`/v1/fs/projects/${encodeURIComponent(slug)}/asks.md`);
123
+ }
105
124
  /**
106
125
  * `GET …/posts` or `…/drafts`. `scheduled` lists drafts that have a
107
126
  * (future) `scheduledFor` set.
@@ -123,7 +142,7 @@ export class SforaApiClient {
123
142
  const file = encodeURIComponent(filename);
124
143
  return this.#text(`/v1/fs/projects/${slug}/${base}/${file}`);
125
144
  }
126
- /** `PUT …/(posts|drafts)/:filename.md` — create or update from a markdown file. */
145
+ /** Create a post or upsert a mutable draft from a Markdown file. */
127
146
  async writePost(projectSlug, kind, filename, markdown) {
128
147
  const base = routeBase(kind);
129
148
  const slug = encodeURIComponent(projectSlug);
@@ -143,14 +162,52 @@ export class SforaApiClient {
143
162
  /** `GET …/docs` — the project's notes, most-recently-edited first. */
144
163
  async listNotes(projectSlug) {
145
164
  const slug = encodeURIComponent(projectSlug);
146
- const data = await this.#json(`/v1/fs/projects/${slug}/docs`);
165
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/documents`);
147
166
  return data.docs ?? [];
148
167
  }
149
168
  /** `GET …/docs/:filename.md`. Returns the raw markdown body. */
150
169
  async readNote(projectSlug, filename) {
151
170
  const slug = encodeURIComponent(projectSlug);
152
171
  const file = encodeURIComponent(filename);
153
- return this.#text(`/v1/fs/projects/${slug}/docs/${file}`);
172
+ return this.#text(`/v1/fs/projects/${slug}/library/documents/${file}`);
173
+ }
174
+ async writeNote(projectSlug, filename, markdown) {
175
+ const slug = encodeURIComponent(projectSlug);
176
+ const file = encodeURIComponent(filename);
177
+ const res = await this.#request("PUT", `/v1/fs/projects/${slug}/library/documents/${file}`, markdown);
178
+ return (await res.json());
179
+ }
180
+ async deleteNote(projectSlug, filename) {
181
+ const slug = encodeURIComponent(projectSlug);
182
+ const file = encodeURIComponent(filename);
183
+ await this.#request("DELETE", `/v1/fs/projects/${slug}/library/documents/${file}`);
184
+ }
185
+ // ─── Unified project Library ────────────────────────────────────
186
+ async listArtifacts(projectSlug) {
187
+ const slug = encodeURIComponent(projectSlug);
188
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/files`);
189
+ return data.artifacts ?? [];
190
+ }
191
+ async readArtifact(projectSlug, filename) {
192
+ const slug = encodeURIComponent(projectSlug);
193
+ const file = encodeURIComponent(filename);
194
+ return this.#text(`/v1/fs/projects/${slug}/library/files/${file}`);
195
+ }
196
+ async listRepositories(projectSlug) {
197
+ const slug = encodeURIComponent(projectSlug);
198
+ const data = await this.#json(`/v1/fs/projects/${slug}/library/repositories`);
199
+ return data.repositories ?? [];
200
+ }
201
+ async getRepositoryTree(projectSlug, dirname) {
202
+ const slug = encodeURIComponent(projectSlug);
203
+ const repo = encodeURIComponent(dirname);
204
+ return this.#json(`/v1/fs/projects/${slug}/library/repositories/${repo}`);
205
+ }
206
+ async readRepositoryFile(projectSlug, dirname, path) {
207
+ const slug = encodeURIComponent(projectSlug);
208
+ const repo = encodeURIComponent(dirname);
209
+ const file = path.split("/").map(encodeURIComponent).join("/");
210
+ return this.#text(`/v1/fs/projects/${slug}/library/repositories/${repo}/${file}`);
154
211
  }
155
212
  // ─── Pull requests (read-only) ───────────────────────────────────
156
213
  /** `GET …/pulls` — the project's synced pull requests, open first. */