@flydocs/cli 0.6.0-alpha.2 → 0.6.0-alpha.21

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 (148) hide show
  1. package/dist/cli.js +705 -393
  2. package/package.json +1 -1
  3. package/template/.claude/CLAUDE.md +62 -63
  4. package/template/.claude/agents/implementation-agent.md +1 -1
  5. package/template/.claude/agents/pm-agent.md +1 -1
  6. package/template/.claude/commands/activate.md +1 -1
  7. package/template/.claude/commands/attach.md +1 -1
  8. package/template/.claude/commands/block.md +2 -2
  9. package/template/.claude/commands/capture.md +1 -1
  10. package/template/.claude/commands/close.md +1 -1
  11. package/template/.claude/commands/flydocs-setup.md +387 -74
  12. package/template/.claude/commands/flydocs-upgrade.md +48 -37
  13. package/template/.claude/commands/implement.md +1 -1
  14. package/template/.claude/commands/knowledge.md +61 -0
  15. package/template/.claude/commands/new-project.md +1 -1
  16. package/template/.claude/commands/onboard.md +275 -0
  17. package/template/.claude/commands/project-update.md +1 -1
  18. package/template/.claude/commands/refine.md +1 -1
  19. package/template/.claude/commands/review.md +1 -1
  20. package/template/.claude/commands/start-session.md +1 -1
  21. package/template/.claude/commands/status.md +1 -1
  22. package/template/.claude/commands/validate.md +1 -1
  23. package/template/.claude/commands/wrap-session.md +1 -1
  24. package/template/.claude/hooks/auto-approve.py +132 -0
  25. package/template/.claude/hooks/post-pr-check.py +108 -0
  26. package/template/.claude/hooks/post-transition-check.py +94 -0
  27. package/template/.claude/hooks/prompt-submit.py +513 -0
  28. package/template/.claude/hooks/session-start.py +146 -0
  29. package/template/.claude/hooks/stop-gate.py +109 -0
  30. package/template/.claude/settings.json +41 -4
  31. package/template/.claude/skills/README.md +23 -25
  32. package/template/.claude/skills/flydocs-workflow/SKILL.md +134 -42
  33. package/template/.claude/skills/flydocs-workflow/cursor-rule.mdc +9 -8
  34. package/template/.claude/skills/flydocs-workflow/reference/comment-templates.md +1 -0
  35. package/template/.claude/skills/flydocs-workflow/reference/golden-rules.md +28 -17
  36. package/template/.claude/skills/flydocs-workflow/reference/graph-schema.md +116 -0
  37. package/template/.claude/skills/flydocs-workflow/reference/pr-workflow.md +120 -0
  38. package/template/.claude/skills/flydocs-workflow/reference/priority-estimates.md +37 -15
  39. package/template/.claude/skills/flydocs-workflow/reference/service-descriptor-schema.md +251 -0
  40. package/template/.claude/skills/flydocs-workflow/reference/status-workflow.md +26 -26
  41. package/template/.claude/skills/flydocs-workflow/scripts/_local/__init__.py +0 -0
  42. package/template/.claude/skills/{flydocs-local/scripts/flydocs_api.py → flydocs-workflow/scripts/_local/file_store.py} +137 -47
  43. package/template/.claude/skills/flydocs-workflow/scripts/flydocs_api.py +693 -0
  44. package/template/{.flydocs → .claude/skills/flydocs-workflow}/scripts/generate_manifest.py +4 -4
  45. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_build.py +132 -1
  46. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_query.py +18 -5
  47. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_session.py +1 -10
  48. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_update.py +4 -4
  49. package/template/.claude/skills/{flydocs-context-graph → flydocs-workflow}/scripts/graph_utils.py +2 -1
  50. package/template/.claude/skills/flydocs-workflow/scripts/issues.py +489 -0
  51. package/template/.claude/skills/flydocs-workflow/scripts/projects.py +144 -0
  52. package/template/.claude/skills/flydocs-workflow/scripts/pull_services.py +128 -0
  53. package/template/.claude/skills/flydocs-workflow/scripts/push_service.py +132 -0
  54. package/template/.claude/skills/flydocs-workflow/scripts/session.py +54 -0
  55. package/template/.claude/skills/flydocs-workflow/scripts/workspace.py +860 -0
  56. package/template/.claude/skills/flydocs-workflow/session.md +63 -25
  57. package/template/.claude/skills/flydocs-workflow/stages/activate.md +18 -7
  58. package/template/.claude/skills/flydocs-workflow/stages/capture.md +10 -5
  59. package/template/.claude/skills/flydocs-workflow/stages/close.md +4 -3
  60. package/template/.claude/skills/flydocs-workflow/stages/implement.md +33 -9
  61. package/template/.claude/skills/flydocs-workflow/stages/refine.md +22 -6
  62. package/template/.claude/skills/flydocs-workflow/stages/review.md +16 -4
  63. package/template/.claude/skills/flydocs-workflow/stages/validate.md +3 -1
  64. package/template/.claude/skills/flydocs-workflow/templates/pr/default.md +33 -0
  65. package/template/.cursor/agents/implementation-agent.md +1 -1
  66. package/template/.cursor/agents/pm-agent.md +2 -2
  67. package/template/.cursor/hooks.json +10 -3
  68. package/template/.env.example +6 -6
  69. package/template/.flydocs/config.json +5 -18
  70. package/template/.flydocs/templates/README.md +13 -14
  71. package/template/.flydocs/templates/quick-capture.md +4 -8
  72. package/template/.flydocs/version +1 -1
  73. package/template/AGENTS.md +39 -32
  74. package/template/CHANGELOG.md +39 -0
  75. package/template/flydocs/README.md +1 -3
  76. package/template/flydocs/context/project.md +6 -3
  77. package/template/flydocs/design-system/README.md +3 -3
  78. package/template/flydocs/knowledge/INDEX.md +38 -53
  79. package/template/flydocs/knowledge/README.md +60 -9
  80. package/template/flydocs/knowledge/templates/decision.md +47 -0
  81. package/template/flydocs/knowledge/templates/feature.md +35 -0
  82. package/template/flydocs/knowledge/templates/note.md +25 -0
  83. package/template/manifest.json +24 -20
  84. package/template/.claude/skills/flydocs-cloud/SKILL.md +0 -111
  85. package/template/.claude/skills/flydocs-cloud/cursor-rule.mdc +0 -50
  86. package/template/.claude/skills/flydocs-cloud/scripts/assign.py +0 -22
  87. package/template/.claude/skills/flydocs-cloud/scripts/assign_cycle.py +0 -28
  88. package/template/.claude/skills/flydocs-cloud/scripts/assign_milestone.py +0 -22
  89. package/template/.claude/skills/flydocs-cloud/scripts/comment.py +0 -29
  90. package/template/.claude/skills/flydocs-cloud/scripts/create_issue.py +0 -63
  91. package/template/.claude/skills/flydocs-cloud/scripts/create_milestone.py +0 -35
  92. package/template/.claude/skills/flydocs-cloud/scripts/create_project.py +0 -33
  93. package/template/.claude/skills/flydocs-cloud/scripts/create_team.py +0 -39
  94. package/template/.claude/skills/flydocs-cloud/scripts/estimate.py +0 -29
  95. package/template/.claude/skills/flydocs-cloud/scripts/flydocs_api.py +0 -210
  96. package/template/.claude/skills/flydocs-cloud/scripts/get_issue.py +0 -24
  97. package/template/.claude/skills/flydocs-cloud/scripts/link.py +0 -28
  98. package/template/.claude/skills/flydocs-cloud/scripts/list_cycles.py +0 -28
  99. package/template/.claude/skills/flydocs-cloud/scripts/list_issues.py +0 -44
  100. package/template/.claude/skills/flydocs-cloud/scripts/list_labels.py +0 -19
  101. package/template/.claude/skills/flydocs-cloud/scripts/list_milestones.py +0 -28
  102. package/template/.claude/skills/flydocs-cloud/scripts/list_projects.py +0 -31
  103. package/template/.claude/skills/flydocs-cloud/scripts/list_teams.py +0 -19
  104. package/template/.claude/skills/flydocs-cloud/scripts/priority.py +0 -29
  105. package/template/.claude/skills/flydocs-cloud/scripts/project_update.py +0 -45
  106. package/template/.claude/skills/flydocs-cloud/scripts/set_labels.py +0 -68
  107. package/template/.claude/skills/flydocs-cloud/scripts/set_team.py +0 -41
  108. package/template/.claude/skills/flydocs-cloud/scripts/transition.py +0 -26
  109. package/template/.claude/skills/flydocs-cloud/scripts/update_description.py +0 -36
  110. package/template/.claude/skills/flydocs-cloud/scripts/update_issue.py +0 -82
  111. package/template/.claude/skills/flydocs-context-graph/SKILL.md +0 -87
  112. package/template/.claude/skills/flydocs-context-graph/schema.md +0 -78
  113. package/template/.claude/skills/flydocs-context-graph/scripts/graph_context.py +0 -338
  114. package/template/.claude/skills/flydocs-context7/SKILL.md +0 -105
  115. package/template/.claude/skills/flydocs-context7/cursor-rule.mdc +0 -49
  116. package/template/.claude/skills/flydocs-context7/scripts/context7.py +0 -293
  117. package/template/.claude/skills/flydocs-estimates/SKILL.md +0 -384
  118. package/template/.claude/skills/flydocs-figma/SKILL.md +0 -377
  119. package/template/.claude/skills/flydocs-figma/references/PROMPTING.md +0 -108
  120. package/template/.claude/skills/flydocs-figma/references/TROUBLESHOOTING.md +0 -112
  121. package/template/.claude/skills/flydocs-local/SKILL.md +0 -103
  122. package/template/.claude/skills/flydocs-local/cursor-rule.mdc +0 -43
  123. package/template/.claude/skills/flydocs-local/scripts/assign.py +0 -20
  124. package/template/.claude/skills/flydocs-local/scripts/comment.py +0 -27
  125. package/template/.claude/skills/flydocs-local/scripts/create_issue.py +0 -44
  126. package/template/.claude/skills/flydocs-local/scripts/estimate.py +0 -37
  127. package/template/.claude/skills/flydocs-local/scripts/get_issue.py +0 -20
  128. package/template/.claude/skills/flydocs-local/scripts/link.py +0 -41
  129. package/template/.claude/skills/flydocs-local/scripts/list_issues.py +0 -34
  130. package/template/.claude/skills/flydocs-local/scripts/priority.py +0 -37
  131. package/template/.claude/skills/flydocs-local/scripts/project_update.py +0 -67
  132. package/template/.claude/skills/flydocs-local/scripts/status_summary.py +0 -16
  133. package/template/.claude/skills/flydocs-local/scripts/transition.py +0 -24
  134. package/template/.claude/skills/flydocs-local/scripts/update_description.py +0 -35
  135. package/template/.claude/skills/flydocs-local/scripts/update_issue.py +0 -84
  136. package/template/.flydocs/hooks/auto-approve.py +0 -71
  137. package/template/.flydocs/hooks/prompt-submit.py +0 -277
  138. package/template/.flydocs/scripts/skill_manager.py +0 -541
  139. package/template/.flydocs/templates/bug.md +0 -166
  140. package/template/.flydocs/templates/chore.md +0 -110
  141. package/template/.flydocs/templates/feature.md +0 -173
  142. package/template/.flydocs/templates/idea.md +0 -122
  143. /package/template/{.flydocs → .claude}/hooks/post-edit.py +0 -0
  144. /package/template/.claude/skills/{flydocs-estimates/references → flydocs-workflow/reference}/provider-costs.md +0 -0
  145. /package/template/.claude/skills/flydocs-workflow/templates/{bug.md → issues/bug.md} +0 -0
  146. /package/template/.claude/skills/flydocs-workflow/templates/{chore.md → issues/chore.md} +0 -0
  147. /package/template/.claude/skills/flydocs-workflow/templates/{feature.md → issues/feature.md} +0 -0
  148. /package/template/.claude/skills/flydocs-workflow/templates/{idea.md → issues/idea.md} +0 -0
@@ -1,11 +1,15 @@
1
- """FlyDocs Local API — filesystem-based issue management."""
1
+ """FlyDocs Local Backend — filesystem-based issue management.
2
+
3
+ All local tier operations are implemented here. The unified client
4
+ delegates to this module when tier is "local".
5
+ """
2
6
 
3
7
  import json
4
- import os
5
8
  import re
6
9
  from datetime import datetime
7
10
  from pathlib import Path
8
11
 
12
+
9
13
  # Status directories map to workflow states
10
14
  STATUSES = {
11
15
  "BACKLOG": "backlog",
@@ -23,33 +27,28 @@ STATUSES = {
23
27
  ISSUE_TYPES = {"feature", "bug", "chore", "idea"}
24
28
 
25
29
 
26
- def _issues_root() -> Path:
27
- """Find flydocs/issues/ relative to repo root."""
28
- cwd = Path.cwd()
29
- # Walk up to find .flydocs/config.json
30
- for parent in [cwd, *cwd.parents]:
31
- if (parent / ".flydocs" / "config.json").exists():
32
- root = parent / "flydocs" / "issues"
33
- root.mkdir(parents=True, exist_ok=True)
34
- return root
35
- raise RuntimeError("Not in a FlyDocs project (no .flydocs/config.json found)")
30
+ def _issues_root(project_root: Path) -> Path:
31
+ """Find flydocs/issues/ relative to project root."""
32
+ root = project_root / "flydocs" / "issues"
33
+ root.mkdir(parents=True, exist_ok=True)
34
+ return root
36
35
 
37
36
 
38
- def _ensure_dirs() -> Path:
37
+ def _ensure_dirs(project_root: Path) -> Path:
39
38
  """Ensure all status directories exist."""
40
- root = _issues_root()
39
+ root = _issues_root(project_root)
41
40
  for dirname in set(STATUSES.values()):
42
41
  (root / dirname).mkdir(exist_ok=True)
43
42
  return root
44
43
 
45
44
 
46
- def _counter_path() -> Path:
47
- return _issues_root().parent.parent / ".flydocs" / "issues.counter"
45
+ def _counter_path(project_root: Path) -> Path:
46
+ return project_root / ".flydocs" / "issues.counter"
48
47
 
49
48
 
50
- def _next_id() -> str:
49
+ def _next_id(project_root: Path) -> str:
51
50
  """Auto-increment and return next FD-XXX identifier."""
52
- path = _counter_path()
51
+ path = _counter_path(project_root)
53
52
  path.parent.mkdir(parents=True, exist_ok=True)
54
53
  current = int(path.read_text().strip()) if path.exists() else 0
55
54
  next_num = current + 1
@@ -76,7 +75,6 @@ def _parse_issue(filepath: Path) -> dict:
76
75
  if ":" in line:
77
76
  key, val = line.split(":", 1)
78
77
  val = val.strip()
79
- # Handle numeric values
80
78
  if val.isdigit():
81
79
  val = int(val)
82
80
  frontmatter[key.strip()] = val
@@ -96,9 +94,9 @@ def _parse_issue(filepath: Path) -> dict:
96
94
  return {**frontmatter, "description": description, "comments": comments, "_path": filepath}
97
95
 
98
96
 
99
- def _find_issue(ref: str) -> Path:
100
- """Find an issue file by its identifier (e.g., FD-001) across all directories."""
101
- root = _issues_root()
97
+ def _find_issue(project_root: Path, ref: str) -> Path:
98
+ """Find an issue file by its identifier across all directories."""
99
+ root = _issues_root(project_root)
102
100
  prefix = ref.upper() + "-"
103
101
  for dirname in set(STATUSES.values()):
104
102
  dirpath = root / dirname
@@ -128,11 +126,14 @@ def _write_issue(filepath: Path, frontmatter: dict, description: str, comments:
128
126
  filepath.write_text("".join(parts) + "\n")
129
127
 
130
128
 
131
- def create_issue(title: str, issue_type: str, description: str = "",
129
+ # --- Public API (matches relay backend interface) ---
130
+
131
+
132
+ def create_issue(project_root: Path, title: str, issue_type: str, description: str = "",
132
133
  priority: int = 3, estimate: int = 0,
133
- assignee: str = "", triage: bool = False) -> dict:
134
- root = _ensure_dirs()
135
- identifier = _next_id()
134
+ assignee: str = "", triage: bool = False, **_kwargs: object) -> dict:
135
+ root = _ensure_dirs(project_root)
136
+ identifier = _next_id(project_root)
136
137
  slug = _slugify(title)
137
138
  filename = f"{identifier}-{slug}.md"
138
139
  now = datetime.now().strftime("%Y-%m-%d")
@@ -152,22 +153,22 @@ def create_issue(title: str, issue_type: str, description: str = "",
152
153
  frontmatter["triage"] = "true"
153
154
 
154
155
  _write_issue(filepath, frontmatter, description or f"## Context\n\n{title}", [])
155
- return {"id": identifier, "identifier": identifier, "title": title}
156
+ return {"id": identifier, "identifier": identifier, "title": title, "url": ""}
156
157
 
157
158
 
158
- def transition(ref: str, new_status: str, comment: str) -> dict:
159
- filepath = _find_issue(ref)
159
+ def transition(project_root: Path, ref: str, status: str, comment: str) -> dict:
160
+ filepath = _find_issue(project_root, ref)
160
161
  data = _parse_issue(filepath)
161
162
  prev_status = _status_from_path(filepath)
162
163
 
164
+ new_status = status.upper()
163
165
  if new_status not in STATUSES:
164
166
  raise ValueError(f"Invalid status: {new_status}. Valid: {', '.join(STATUSES.keys())}")
165
167
 
166
- target_dir = _issues_root() / STATUSES[new_status]
168
+ target_dir = _issues_root(project_root) / STATUSES[new_status]
167
169
  target_dir.mkdir(exist_ok=True)
168
170
  new_path = target_dir / filepath.name
169
171
 
170
- # Append transition comment
171
172
  now = datetime.now().strftime("%Y-%m-%d %H:%M")
172
173
  data["comments"].append(f"**{new_status}** — {comment}\n_{now}_")
173
174
  data["updated"] = datetime.now().strftime("%Y-%m-%d")
@@ -180,11 +181,11 @@ def transition(ref: str, new_status: str, comment: str) -> dict:
180
181
  return {"success": True, "issue": ref, "previousStatus": prev_status, "newStatus": new_status}
181
182
 
182
183
 
183
- def add_comment(ref: str, comment: str) -> dict:
184
- filepath = _find_issue(ref)
184
+ def add_comment(project_root: Path, ref: str, body: str) -> dict:
185
+ filepath = _find_issue(project_root, ref)
185
186
  data = _parse_issue(filepath)
186
187
  now = datetime.now().strftime("%Y-%m-%d %H:%M")
187
- data["comments"].append(f"{comment}\n_{now}_")
188
+ data["comments"].append(f"{body}\n_{now}_")
188
189
  data["updated"] = datetime.now().strftime("%Y-%m-%d")
189
190
 
190
191
  fm = {k: v for k, v in data.items() if k not in ("description", "comments", "_path")}
@@ -192,9 +193,10 @@ def add_comment(ref: str, comment: str) -> dict:
192
193
  return {"success": True, "commentId": len(data["comments"])}
193
194
 
194
195
 
195
- def list_issues(status: str = "", assignee: str = "", limit: int = 50) -> list[dict]:
196
- root = _issues_root()
197
- results = []
196
+ def list_issues(project_root: Path, status: str = "", assignee: str = "",
197
+ limit: int = 50, **_kwargs: object) -> list[dict]:
198
+ root = _issues_root(project_root)
199
+ results: list[dict] = []
198
200
  dirs_to_scan = [STATUSES[status]] if status and status in STATUSES else list(set(STATUSES.values()))
199
201
 
200
202
  for dirname in dirs_to_scan:
@@ -220,8 +222,8 @@ def list_issues(status: str = "", assignee: str = "", limit: int = 50) -> list[d
220
222
  return results
221
223
 
222
224
 
223
- def get_issue(ref: str) -> dict:
224
- filepath = _find_issue(ref)
225
+ def get_issue(project_root: Path, ref: str, **_kwargs: object) -> dict:
226
+ filepath = _find_issue(project_root, ref)
225
227
  data = _parse_issue(filepath)
226
228
  return {
227
229
  "id": data.get("id", ""),
@@ -236,10 +238,13 @@ def get_issue(ref: str) -> dict:
236
238
  }
237
239
 
238
240
 
239
- def assign_issue(ref: str, assignee: str) -> dict:
240
- filepath = _find_issue(ref)
241
+ def assign_issue(project_root: Path, ref: str, assignee: str | None) -> dict:
242
+ filepath = _find_issue(project_root, ref)
241
243
  data = _parse_issue(filepath)
242
- data["assignee"] = assignee
244
+ if assignee is None:
245
+ data.pop("assignee", None)
246
+ else:
247
+ data["assignee"] = assignee
243
248
  data["updated"] = datetime.now().strftime("%Y-%m-%d")
244
249
 
245
250
  fm = {k: v for k, v in data.items() if k not in ("description", "comments", "_path")}
@@ -247,8 +252,8 @@ def assign_issue(ref: str, assignee: str) -> dict:
247
252
  return {"success": True, "issue": ref, "assignee": assignee}
248
253
 
249
254
 
250
- def update_description(ref: str, text: str) -> dict:
251
- filepath = _find_issue(ref)
255
+ def update_description(project_root: Path, ref: str, text: str) -> dict:
256
+ filepath = _find_issue(project_root, ref)
252
257
  data = _parse_issue(filepath)
253
258
  data["updated"] = datetime.now().strftime("%Y-%m-%d")
254
259
 
@@ -257,9 +262,94 @@ def update_description(ref: str, text: str) -> dict:
257
262
  return {"success": True, "issue": ref}
258
263
 
259
264
 
260
- def status_summary() -> dict:
261
- root = _issues_root()
262
- counts = {}
265
+ def update_issue(project_root: Path, ref: str, **fields: object) -> dict:
266
+ """Bulk update — set multiple fields on an issue."""
267
+ filepath = _find_issue(project_root, ref)
268
+ data = _parse_issue(filepath)
269
+ updated: list[str] = []
270
+
271
+ # Handle state transition separately
272
+ state = fields.get("state")
273
+ comment = fields.get("comment")
274
+ if state and isinstance(state, str):
275
+ result = transition(project_root, ref, state, str(comment or f"Transitioned to {state}"))
276
+ updated.append("state")
277
+ # Re-find the file after transition (it may have moved)
278
+ filepath = _find_issue(project_root, ref)
279
+ data = _parse_issue(filepath)
280
+
281
+ # Handle comment separately (if no state transition already handled it)
282
+ if comment and isinstance(comment, str) and "state" not in updated:
283
+ add_comment(project_root, ref, comment)
284
+ updated.append("comment")
285
+ data = _parse_issue(filepath)
286
+
287
+ # Update simple fields
288
+ for field in ("title", "priority", "estimate", "assignee"):
289
+ val = fields.get(field)
290
+ if val is not None:
291
+ data[field] = val
292
+ updated.append(field)
293
+
294
+ # Update description
295
+ desc = fields.get("description")
296
+ if desc and isinstance(desc, str):
297
+ data["description"] = desc
298
+ updated.append("description")
299
+
300
+ data["updated"] = datetime.now().strftime("%Y-%m-%d")
301
+ fm = {k: v for k, v in data.items() if k not in ("description", "comments", "_path")}
302
+ _write_issue(filepath, fm, data.get("description", ""), data.get("comments", []))
303
+
304
+ return {"success": True, "issue": ref, "updated": updated}
305
+
306
+
307
+ def estimate_issue(project_root: Path, ref: str, estimate: int) -> dict:
308
+ filepath = _find_issue(project_root, ref)
309
+ data = _parse_issue(filepath)
310
+ data["estimate"] = estimate
311
+ data["updated"] = datetime.now().strftime("%Y-%m-%d")
312
+
313
+ fm = {k: v for k, v in data.items() if k not in ("description", "comments", "_path")}
314
+ _write_issue(filepath, fm, data["description"], data.get("comments", []))
315
+ return {"success": True, "issue": ref, "estimate": estimate}
316
+
317
+
318
+ def priority_issue(project_root: Path, ref: str, priority: int) -> dict:
319
+ filepath = _find_issue(project_root, ref)
320
+ data = _parse_issue(filepath)
321
+ data["priority"] = priority
322
+ data["updated"] = datetime.now().strftime("%Y-%m-%d")
323
+
324
+ fm = {k: v for k, v in data.items() if k not in ("description", "comments", "_path")}
325
+ _write_issue(filepath, fm, data["description"], data.get("comments", []))
326
+ return {"success": True, "issue": ref, "priority": priority}
327
+
328
+
329
+ def link_issues(project_root: Path, ref: str, related_ref: str, link_type: str) -> dict:
330
+ add_comment(project_root, ref, f"Linked ({link_type}): {related_ref}")
331
+ add_comment(project_root, related_ref, f"Linked ({link_type}): {ref}")
332
+ return {"success": True, "type": link_type}
333
+
334
+
335
+ def project_update(project_root: Path, health: str, body: str, **_kwargs: object) -> dict:
336
+ """Write a project update as a markdown file."""
337
+ updates_dir = project_root / "flydocs" / "updates"
338
+ updates_dir.mkdir(parents=True, exist_ok=True)
339
+
340
+ now = datetime.now()
341
+ timestamp = now.strftime("%Y%m%d-%H%M%S")
342
+ filename = f"{timestamp}.md"
343
+
344
+ content = f"---\nhealth: {health}\ndate: {now.strftime('%Y-%m-%d')}\n---\n\n{body}\n"
345
+ (updates_dir / filename).write_text(content)
346
+
347
+ return {"success": True, "id": timestamp}
348
+
349
+
350
+ def status_summary(project_root: Path) -> dict:
351
+ root = _issues_root(project_root)
352
+ counts: dict[str, int] = {}
263
353
  total = 0
264
354
  for status, dirname in STATUSES.items():
265
355
  dirpath = root / dirname