pcode 0.1.5__tar.gz → 0.1.7__tar.gz

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 (178) hide show
  1. {pcode-0.1.5 → pcode-0.1.7}/PKG-INFO +1 -1
  2. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/app.py +42 -20
  3. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/appearance.py +10 -0
  4. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/aside_ui.py +1 -2
  5. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/controller.py +8 -8
  6. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/conversation_tree.py +2 -2
  7. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/delegation.py +2 -6
  8. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/diagnostics.py +11 -1
  9. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/edit_preview.py +3 -7
  10. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/edit_transcript.py +1 -1
  11. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/edit_ui.py +1 -1
  12. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/edits.py +4 -28
  13. pcode-0.1.7/src/pcode/extensions/basic_memory/Brief format.md +91 -0
  14. pcode-0.1.7/src/pcode/extensions/basic_memory/Briefs.base +50 -0
  15. pcode-0.1.7/src/pcode/extensions/basic_memory/Home.md +11 -0
  16. pcode-0.1.7/src/pcode/extensions/basic_memory/__init__.py +731 -0
  17. pcode-0.1.7/src/pcode/extensions/basic_memory/memory-states.css +58 -0
  18. pcode-0.1.7/src/pcode/extensions/plan_mode.py +310 -0
  19. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/file_refs.py +1 -4
  20. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/filesystem.py +7 -14
  21. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/git_diff.py +12 -21
  22. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/inspection.py +2 -18
  23. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/prompt_keys.py +5 -25
  24. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/session_ui.py +4 -4
  25. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/shell.py +5 -44
  26. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/stream_events.py +3 -8
  27. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/tool_display.py +8 -38
  28. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/tool_output_limits.py +1 -2
  29. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/tool_panel.py +1 -1
  30. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/tree_ui.py +1 -1
  31. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/ui.py +2 -6
  32. pcode-0.1.5/src/pcode/extensions/plan_mode.py +0 -172
  33. pcode-0.1.5/src/pcode/paste.py +0 -45
  34. {pcode-0.1.5 → pcode-0.1.7}/.gitignore +0 -0
  35. {pcode-0.1.5 → pcode-0.1.7}/LICENSE +0 -0
  36. {pcode-0.1.5 → pcode-0.1.7}/README.md +0 -0
  37. {pcode-0.1.5 → pcode-0.1.7}/pyproject.toml +0 -0
  38. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/__init__.py +0 -0
  39. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/_job_supervisor.py +0 -0
  40. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/agent.py +0 -0
  41. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/anthropic_oauth.py +0 -0
  42. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/aside.py +0 -0
  43. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/aside_guard.py +0 -0
  44. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/auth.py +0 -0
  45. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/background_delegation.py +0 -0
  46. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/block.py +0 -0
  47. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/browser.py +0 -0
  48. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/cache_diagnostics.py +0 -0
  49. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/cache_settings.py +0 -0
  50. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/cache_warnings.py +0 -0
  51. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/__init__.py +0 -0
  52. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/cli.py +0 -0
  53. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/errors.py +0 -0
  54. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/messages.py +0 -0
  55. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/model.py +0 -0
  56. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/resume.py +0 -0
  57. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/session.py +0 -0
  58. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/session_pool.py +0 -0
  59. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/web.py +0 -0
  60. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/claude_sdk/workspace.py +0 -0
  61. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/cli.py +0 -0
  62. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/clipboard.py +0 -0
  63. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/code_mode.py +0 -0
  64. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/codex_login.py +0 -0
  65. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/command_transcript.py +0 -0
  66. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/commands.py +0 -0
  67. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/compaction.py +0 -0
  68. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/completion.py +0 -0
  69. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/config.py +0 -0
  70. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/config_ui.py +0 -0
  71. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/context_breakdown.py +0 -0
  72. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/context_usage.py +0 -0
  73. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/conversation_ids.py +0 -0
  74. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/copy_ui.py +0 -0
  75. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/delta.py +0 -0
  76. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/__init__.py +0 -0
  77. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/cli.py +0 -0
  78. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/listener.py +0 -0
  79. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/mailbox.py +0 -0
  80. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/outbound.py +0 -0
  81. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/parsing.py +0 -0
  82. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/render.py +0 -0
  83. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/email_remote/state.py +0 -0
  84. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/error_report.py +0 -0
  85. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/ext.py +0 -0
  86. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extension_guide.md +0 -0
  87. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extensions/browser.py +0 -0
  88. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extensions/ponytail.py +0 -0
  89. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extensions/sandbox.py +0 -0
  90. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extensions/session_history.py +0 -0
  91. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/extensions/web_research.py +0 -0
  92. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/frame.py +0 -0
  93. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/gateway.py +0 -0
  94. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/history.py +0 -0
  95. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/history_cursor.py +0 -0
  96. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/history_embeddings.py +0 -0
  97. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/host.py +0 -0
  98. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/host_protocol.py +0 -0
  99. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/host_ui.py +0 -0
  100. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/input_keys.py +0 -0
  101. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/inspector_ui.py +0 -0
  102. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/isolated_delegation.py +0 -0
  103. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/job_notices.py +0 -0
  104. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/jobs.py +0 -0
  105. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/jobs_ui.py +0 -0
  106. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/keyboard_protocol.py +0 -0
  107. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/keymap.py +0 -0
  108. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/layout_speed.py +0 -0
  109. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/links.py +0 -0
  110. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/links_ui.py +0 -0
  111. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/live.py +0 -0
  112. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/llm_proxy.py +0 -0
  113. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/logins.py +0 -0
  114. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/mcp.py +0 -0
  115. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/mcp_notice.py +0 -0
  116. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/mcp_oauth.py +0 -0
  117. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/meridian.py +0 -0
  118. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/meridian_process.py +0 -0
  119. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/meridian_reminders.py +0 -0
  120. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/meridian_setup.py +0 -0
  121. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/model_metadata.py +0 -0
  122. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/model_ui.py +0 -0
  123. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/models.py +0 -0
  124. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/native_results.py +0 -0
  125. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/oauth_pages.py +0 -0
  126. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/output_limits.py +0 -0
  127. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/path_completion.py +0 -0
  128. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/plan_preview.py +0 -0
  129. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/plan_sizes.py +0 -0
  130. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/planning.py +0 -0
  131. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/popup_ui.py +0 -0
  132. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/preferences.py +0 -0
  133. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/prefix_keys.py +0 -0
  134. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/proctitle.py +0 -0
  135. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/profile_benchmark.py +0 -0
  136. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/profiling.py +0 -0
  137. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/project_trust.py +0 -0
  138. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/remote.py +0 -0
  139. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/remote_print.py +0 -0
  140. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/remote_profile.py +0 -0
  141. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/replay_benchmark.py +0 -0
  142. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/repo_context.py +0 -0
  143. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/retries.py +0 -0
  144. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/rpc.py +0 -0
  145. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/runtime.py +0 -0
  146. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/sandbox.py +0 -0
  147. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/session_naming.py +0 -0
  148. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/sessions.py +0 -0
  149. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/shell_mode.py +0 -0
  150. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/shell_tools.py +0 -0
  151. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/skills.py +0 -0
  152. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/stall_log.py +0 -0
  153. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/startup_input.py +0 -0
  154. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/steering.py +0 -0
  155. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/stream_display.py +0 -0
  156. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/strict_tools.py +0 -0
  157. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/syntax.py +0 -0
  158. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/syntax_colors.py +0 -0
  159. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/task_prompt.py +0 -0
  160. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/task_worktrees.py +0 -0
  161. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/terminal_notify.py +0 -0
  162. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/terminal_text.py +0 -0
  163. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/text_selection.py +0 -0
  164. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/theme.py +0 -0
  165. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/theme_gallery.py +0 -0
  166. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/thinking_markdown.py +0 -0
  167. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/token_accounting.py +0 -0
  168. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/transcript_log.py +0 -0
  169. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/transcript_notice.py +0 -0
  170. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/turn.py +0 -0
  171. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/update_check.py +0 -0
  172. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/usage.py +0 -0
  173. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/word_wrap.py +0 -0
  174. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/worker_ui.py +0 -0
  175. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/workers.py +0 -0
  176. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/workspace.py +0 -0
  177. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/workspace_filesystem.py +0 -0
  178. {pcode-0.1.5 → pcode-0.1.7}/src/pcode/worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pcode
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
5
5
  Project-URL: Homepage, https://github.com/cruxwell/pcode
6
6
  Project-URL: Documentation, https://cruxwell.github.io/pcode/
@@ -554,15 +554,15 @@ class PreviewApp:
554
554
  if self.runtime.session:
555
555
  self.replay()
556
556
  return
557
- from pcode.diagnostics import redact
557
+ from pcode.diagnostics import strip_controls
558
558
 
559
559
  tree = self.runtime.tree
560
560
  with self.transcript.restore():
561
561
  for node_id in tree.path(tree.active):
562
562
  node = tree.nodes[node_id]
563
- self.transcript.user(redact(node.prompt))
563
+ self.transcript.user(strip_controls(node.prompt))
564
564
  if node.response:
565
- self.transcript.events((Message(redact(node.response)),))
565
+ self.transcript.events((Message(strip_controls(node.response)),))
566
566
  self.activity.plan = tree.nodes[tree.active].plan if tree.active else []
567
567
 
568
568
  def aside_changed(self, aside) -> None:
@@ -1227,14 +1227,24 @@ class PreviewApp:
1227
1227
  self.transcript.syntax_gallery()
1228
1228
 
1229
1229
  def theme(self, argument: str) -> None:
1230
- self.transcript.theme = argument or (
1231
- "light" if self.transcript.resolved_theme == "dark" else "dark"
1232
- )
1233
- self.persist_defaults(theme=self.transcript.theme)
1234
- selected = self.transcript.theme
1235
- if selected == "auto":
1236
- selected += f" ({self.transcript.resolved_theme})"
1237
- self.transcript.flash(f"Theme: {selected}.")
1230
+ """Pick and save a palette; bare `/theme` flips it for this session only.
1231
+
1232
+ Saving the flip would pin every later session to it, and with it turn
1233
+ off following the terminal's light/dark switches.
1234
+ """
1235
+ theme = argument or ("light" if self.transcript.resolved_theme == "dark" else "dark")
1236
+ self.transcript.theme = theme
1237
+ if not argument:
1238
+ message = f"{theme} for this session; /theme {theme} saves it"
1239
+ else:
1240
+ self.persist_defaults(theme=theme)
1241
+ message = theme
1242
+ if theme == "auto":
1243
+ message += f" ({self.transcript.resolved_theme})"
1244
+ if self._appearance is not None:
1245
+ # The detected value may be stale; the answer repaints if it differs.
1246
+ self._appearance.refresh()
1247
+ self.transcript.flash(f"Theme: {message}.")
1238
1248
  self.transcript.regenerate()
1239
1249
 
1240
1250
  def follow_appearance(self, detected: str) -> None:
@@ -1884,7 +1894,7 @@ class PreviewApp:
1884
1894
 
1885
1895
  `end` stops at that journal offset: a host sends the rest as it happened.
1886
1896
  """
1887
- from pcode.diagnostics import redact
1897
+ from pcode.diagnostics import strip_controls
1888
1898
 
1889
1899
  saved = saved or self.runtime.session
1890
1900
  self.activity.plan = saved.latest_plan()
@@ -1898,9 +1908,9 @@ class PreviewApp:
1898
1908
  for record in saved.transcript_records(end):
1899
1909
  kind = record["kind"]
1900
1910
  if kind in ("turn_started", "steering"):
1901
- self.transcript.user(redact(record["prompt"]))
1911
+ self.transcript.user(strip_controls(record["prompt"]))
1902
1912
  elif kind in ("Thinking", "thinking_partial"):
1903
- self.transcript.thinking(redact(record["text"]).rstrip("\n") + "\n\n")
1913
+ self.transcript.thinking(strip_controls(record["text"]).rstrip("\n") + "\n\n")
1904
1914
  elif kind == "CacheBust":
1905
1915
  continue # Kept in the journal; live runs show it in the footer.
1906
1916
  elif kind == "EditCompleted":
@@ -1912,21 +1922,21 @@ class PreviewApp:
1912
1922
  self.transcript.tool_result(
1913
1923
  ToolSummary(
1914
1924
  record["name"],
1915
- redact(record["detail"]),
1925
+ strip_controls(record["detail"]),
1916
1926
  record.get("failed", False),
1917
1927
  record.get("call_id", ""),
1918
1928
  record.get("elapsed_seconds"),
1919
- redact(record.get("error", "")),
1920
- redact(record.get("command", "")),
1921
- result=redact(result) if isinstance(result, str) else None,
1929
+ strip_controls(record.get("error", "")),
1930
+ strip_controls(record.get("command", "")),
1931
+ result=strip_controls(result) if isinstance(result, str) else None,
1922
1932
  outcome=record.get("outcome", ""),
1923
1933
  parent_call_id=record.get("parent_call_id", ""),
1924
- purpose=redact(record.get("purpose", "")),
1934
+ purpose=strip_controls(record.get("purpose", "")),
1925
1935
  execution=record.get("execution", ""),
1926
1936
  )
1927
1937
  )
1928
1938
  elif kind in ("Message", "partial"):
1929
- self.transcript.events((Message(redact(record["markdown"])),))
1939
+ self.transcript.events((Message(strip_controls(record["markdown"])),))
1930
1940
  if kind == "partial":
1931
1941
  self.transcript.warning("[Partial output from an interrupted run]")
1932
1942
 
@@ -2709,6 +2719,9 @@ def main() -> None:
2709
2719
  "hosts",
2710
2720
  )
2711
2721
  parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
2722
+ parser.add_argument(
2723
+ "--json", action="store_true", help="With --hosts: one JSON array, for scripts and plugins"
2724
+ )
2712
2725
  host_kinds = ("all", "stale", "idle")
2713
2726
  parser.add_argument(
2714
2727
  "--kill-hosts",
@@ -3179,6 +3192,8 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3179
3192
  parser.error("--fork cannot be combined with --attach")
3180
3193
  if args.compact and not args.sessions:
3181
3194
  parser.error("--compact applies to --sessions")
3195
+ if args.json and not args.hosts:
3196
+ parser.error("--json applies to --hosts")
3182
3197
  if args.print:
3183
3198
  if args.theme_preview or args.sessions:
3184
3199
  parser.error("--print cannot be combined with --theme-preview or --sessions")
@@ -3225,6 +3240,13 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
3225
3240
  if args.kill_hosts:
3226
3241
  _kill_hosts(args.kill_hosts, entries, code)
3227
3242
  return
3243
+ if args.json:
3244
+ import json
3245
+
3246
+ # A contract for callers such as the Obsidian bridge: add keys, never rename.
3247
+ keys = ("id", "session_id", "state", "workspace", "title", "last_prompt", "attached")
3248
+ print(json.dumps([{key: getattr(entry, key) for key in keys} for entry in entries]))
3249
+ return
3228
3250
  for entry in entries:
3229
3251
  print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
3230
3252
  if not entries:
@@ -144,6 +144,13 @@ class AppearanceWatch:
144
144
  self.suspended = False
145
145
  if enabled:
146
146
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
147
+ # A switch while the other program had the terminal went unseen.
148
+ self.refresh()
149
+
150
+ def refresh(self) -> None:
151
+ """Ask for the background now; the answer arrives as input like any other."""
152
+ if self.enabled and not self.suspended:
153
+ _write_all(self.fd, QUERY_BACKGROUND)
147
154
 
148
155
  async def query(self) -> None:
149
156
  for delay in QUERY_DELAYS:
@@ -155,6 +162,9 @@ class AppearanceWatch:
155
162
  if self.fd is None or not _isatty(self.fd) or os.environ.get("TERM") == "dumb":
156
163
  return
157
164
  self.enabled = _write_all(self.fd, ENABLE_REPORTS)
165
+ # The startup probe is skipped when input is already waiting, leaving a
166
+ # guess from COLORFGBG that only a later switch would otherwise correct.
167
+ self.refresh()
158
168
  reader = self.reader if self.reader is not None else desktop_reader()
159
169
  pending = None
160
170
  try:
@@ -588,8 +588,7 @@ class AsideBrowser:
588
588
  def links(self) -> list[Link]:
589
589
  """URLs in the selected thread's questions and answers, oldest first.
590
590
 
591
- From the raw text, as `/links` reads the conversation: redaction would
592
- cut a URL holding a token short, and the browser would get the stub.
591
+ From the raw text, as `/links` reads the conversation.
593
592
  """
594
593
  found: dict[str, Link] = {}
595
594
  for aside in self.current():
@@ -905,7 +905,7 @@ class SessionController:
905
905
  registry = getattr(self.runtime, "jobs", None)
906
906
  if registry is None:
907
907
  return []
908
- from pcode.shell import REDUCED_SHELL_OUTPUT, result_projection
908
+ from pcode.shell import REDUCED_SHELL_OUTPUT
909
909
 
910
910
  finished = registry.take_announcements("ui", job_id)
911
911
  for job in finished:
@@ -919,7 +919,7 @@ class SessionController:
919
919
  failed=job.stopped or job.exit_code != 0,
920
920
  elapsed_seconds=job.elapsed,
921
921
  command=command_text(job.command),
922
- result=command_text(result_projection(result)),
922
+ result=command_text(result),
923
923
  purpose=command_text(job.purpose),
924
924
  )
925
925
  saved = getattr(self.runtime, "session", None)
@@ -3451,7 +3451,7 @@ class SessionController:
3451
3451
 
3452
3452
  async def merge_thread(self, thread: str) -> None:
3453
3453
  """Add a side thread to the conversation tree where it was asked."""
3454
- from pcode.diagnostics import redact
3454
+ from pcode.diagnostics import strip_controls
3455
3455
 
3456
3456
  follows = self.check_bridge(thread)
3457
3457
  asked = self.asides.thread(thread)
@@ -3468,8 +3468,8 @@ class SessionController:
3468
3468
  count = f"{len(steps)} side question{'s' if len(steps) > 1 else ''}"
3469
3469
  if moved:
3470
3470
  for question, answer, _ in steps:
3471
- self.view.user(redact(question))
3472
- self.view.show_events((Message(redact(answer)),))
3471
+ self.view.user(strip_controls(question))
3472
+ self.view.show_events((Message(strip_controls(answer)),))
3473
3473
  self.view.note(
3474
3474
  f"Merged {count} into the conversation, which continues from the last answer."
3475
3475
  )
@@ -3481,7 +3481,7 @@ class SessionController:
3481
3481
 
3482
3482
  async def summarize_thread(self, follows: Aside, instructions: str) -> None:
3483
3483
  """Add a summary of `follows`'s thread to the conversation, and show it."""
3484
- from pcode.diagnostics import redact
3484
+ from pcode.diagnostics import strip_controls
3485
3485
 
3486
3486
  asked = self.asides.thread(follows.thread)
3487
3487
  questions = [aside.question for aside, _ in exchanges(asked, follows.reply.messages)]
@@ -3489,8 +3489,8 @@ class SessionController:
3489
3489
  summary = await self.runtime.summarize_aside(follows.reply, request, instructions)
3490
3490
  follows.bridged = "summarized"
3491
3491
  self.view.aside_changed(follows)
3492
- self.view.user(redact(request))
3493
- self.view.show_events((Message(redact(summary)),))
3492
+ self.view.user(strip_controls(request))
3493
+ self.view.show_events((Message(strip_controls(summary)),))
3494
3494
 
3495
3495
  def aside_completions(self, argument: str):
3496
3496
  """Complete a `$MODEL` word in `/btw` arguments from the /model catalog.
@@ -113,11 +113,11 @@ class ConversationTree:
113
113
 
114
114
  def rows(self) -> list[tuple[tuple[str | None, bool], str]]:
115
115
  """Depth-first user/edit and assistant/continue rows, with stable IDs."""
116
- from pcode.diagnostics import redact
116
+ from pcode.diagnostics import strip_controls
117
117
  from pcode.ui import plain
118
118
 
119
119
  def excerpt(text):
120
- return plain(redact(" ".join(text.split())), 100)
120
+ return plain(strip_controls(" ".join(text.split())), 100)
121
121
 
122
122
  rows = [
123
123
  ((None, False), "Conversation start" + (" ← active" if self.active is None else ""))
@@ -39,7 +39,6 @@ from pcode.planning import PlanSnapshot
39
39
  from pcode.preferences import SETTINGS, load_preferences
40
40
  from pcode.retries import RequestCheckpoint
41
41
  from pcode.runtime import ToolStarted, ToolSummary
42
- from pcode.shell import result_projection
43
42
  from pcode.tool_display import (
44
43
  COMMAND_TOOLS,
45
44
  assignment,
@@ -208,9 +207,6 @@ async def stream_child_activity(_ctx, events):
208
207
  detail, failed = result_detail(
209
208
  name, args, event.part.content, outcome, stored_source(args, called)
210
209
  )
211
- content = (
212
- result_projection(event.part.content) if name == "shell" else event.part.content
213
- )
214
210
  child = ToolSummary(
215
211
  name,
216
212
  detail,
@@ -219,8 +215,8 @@ async def stream_child_activity(_ctx, events):
219
215
  elapsed_seconds=max(0, monotonic() - started),
220
216
  command=invocation(name, args),
221
217
  purpose=stated_purpose(args),
222
- error=command_error(content) if failed and name in COMMAND_TOOLS else "",
223
- result=capture(content),
218
+ error=command_error(event.part.content) if failed and name in COMMAND_TOOLS else "",
219
+ result=capture(event.part.content),
224
220
  run_id=parent.run_id or "",
225
221
  outcome=outcome,
226
222
  parent_call_id=parent_id,
@@ -44,7 +44,17 @@ _ASSIGNMENT = re.compile(
44
44
  )
45
45
 
46
46
 
47
+ def strip_controls(text: str) -> str:
48
+ """Drop control characters other than newline and tab."""
49
+ return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
50
+
51
+
47
52
  def redact(text: str) -> str:
53
+ """Best-effort credential removal for error reports and text sent off the machine.
54
+
55
+ Not for the screen: the model has already seen what pcode displays, and
56
+ keyword matching rewrites ordinary code.
57
+ """
48
58
  for name, value in os.environ.items():
49
59
  if len(value) >= 8 and re.search(r"TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY", name):
50
60
  text = text.replace(value, "[redacted]")
@@ -52,7 +62,7 @@ def redact(text: str) -> str:
52
62
  text = re.sub(r"(?i)(https?://)[^/\s@]+@", r"\1[redacted]@", text)
53
63
  text = _TOKEN.sub("[redacted]", text)
54
64
  text = _ASSIGNMENT.sub(r"\1[redacted]", text)
55
- return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text)
65
+ return strip_controls(text)
56
66
 
57
67
 
58
68
  # Enough for a deep cause chain through the agent graph, small enough that a
@@ -15,7 +15,7 @@ from pydantic_ai.messages import (
15
15
  )
16
16
  from pydantic_core import from_json
17
17
 
18
- from pcode.edits import MAX_SOURCE, edit_text, sensitive_path
18
+ from pcode.edits import MAX_SOURCE, edit_text
19
19
  from pcode.runtime import EditPreview
20
20
 
21
21
 
@@ -88,15 +88,11 @@ class StreamingEditPreview:
88
88
  return clear + self._code_preview(index, partial, complete)
89
89
  path = complete.get("path")
90
90
  # Never expose content until the complete path is known and checked.
91
- permitted = isinstance(path, str) and not sensitive_path(path)
91
+ permitted = isinstance(path, str)
92
92
  if permitted:
93
93
  try:
94
94
  resolved = (self.root / path).resolve()
95
- permitted = (
96
- not Path(path).is_absolute()
97
- and resolved.is_relative_to(self.root)
98
- and not sensitive_path(str(resolved.relative_to(self.root)))
99
- )
95
+ permitted = not Path(path).is_absolute() and resolved.is_relative_to(self.root)
100
96
  except OSError, ValueError, RuntimeError:
101
97
  permitted = False
102
98
  if not permitted:
@@ -74,7 +74,7 @@ MARKDOWN_SUFFIXES = (".md", ".markdown")
74
74
 
75
75
 
76
76
  def created_markdown(change: EditCompleted) -> str | None:
77
- """A new Markdown file's redacted source, to show rendered rather than as a diff.
77
+ """A new Markdown file's sanitized source, to show rendered rather than as a diff.
78
78
 
79
79
  Every line of a created file is an addition, so the `+` column says
80
80
  nothing and the raw source buries the document. The patch is the only
@@ -747,7 +747,7 @@ class DiffBrowser:
747
747
  terms = self.terms()
748
748
  if self.scope == "paths":
749
749
  return all(fuzzy_match(term, change.path.casefold()) for term in terms)
750
- # Match the redacted text, so redaction cannot hide the row a query matched.
750
+ # Match the text as shown, so sanitizing cannot hide the row a query matched.
751
751
  text = "\n".join([change_heading(change), patch_text(change.patch, dedent=self.dedent)])
752
752
  return not terms or bool(matching_rows(text, terms))
753
753
 
@@ -11,32 +11,10 @@ MAX_SOURCE = 256 * 1024
11
11
  MAX_LINES = 4000
12
12
  MAX_PATCH = 64 * 1024
13
13
  MAX_PATCH_LINES = 400
14
- _SENSITIVE = re.compile(
15
- r"(?i)(secret|token|password|credential|api.?key|private.?key|\.env|kubeconfig|"
16
- r"(?:^|/)\.kube/|(?:^|/)\.aws/|\.tfvars|\.pem$|\.key$|(?:^|/)id_(?:rsa|ed25519)(?:$|\.))"
17
- )
18
- _OPEN_SECRET = re.compile(
19
- r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
20
- r"([\"'])(.*?)(?:(?<!\\)\2|\Z)",
21
- re.DOTALL,
22
- )
23
-
24
-
25
- def sensitive_path(path: str) -> bool:
26
- return bool(_SENSITIVE.search(path))
27
14
 
28
15
 
29
16
  def edit_text(text: str) -> str:
30
- # Redact whole strings before adding diff prefixes or clipping. A credential
31
- # can span lines, and a streamed quoted value may not have its closing quote.
32
- text = _OPEN_SECRET.sub(r"\1\2[redacted]\2", text)
33
- text = re.sub(
34
- r"(?i)((?:password|passwd|secret|token|api[_-]?key|authorization)[\"']?\s*[:=]\s*)"
35
- r"[^\s\"',;}]+",
36
- r"\1[redacted]",
37
- text,
38
- )
39
- text = re.sub(r"-----BEGIN [^-]*PRIVATE KEY-----.*", "[private key redacted]", text, flags=re.S)
17
+ """Edit content as shown: terminal controls removed, tabs expanded."""
40
18
  return command_text(text)
41
19
 
42
20
 
@@ -44,7 +22,7 @@ _HUNK = re.compile(r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@")
44
22
 
45
23
 
46
24
  def patch_text(patch: str, *, dedent: bool) -> str:
47
- """A stored patch as it is displayed: redacted, and dedented if asked."""
25
+ """A stored patch as it is displayed: sanitized, and dedented if asked."""
48
26
  text = edit_text(patch)
49
27
  return dedent_patch(text) if dedent else text
50
28
 
@@ -124,8 +102,6 @@ def change_from_record(record: dict) -> EditCompleted:
124
102
  def completed_change(path, before, after, *, existed=True, call_id="", omitted=""):
125
103
  """Build display evidence while raw snapshots are still local to execution."""
126
104
  operation = "edited" if existed else "created"
127
- if sensitive_path(path):
128
- return EditCompleted(call_id, "[sensitive path]", operation, omitted="Sensitive file")
129
105
  path = plain(edit_text(path), limit=None)
130
106
  if omitted or before is None:
131
107
  return EditCompleted(
@@ -139,7 +115,7 @@ def completed_change(path, before, after, *, existed=True, call_id="", omitted="
139
115
  return EditCompleted(call_id, path, operation, omitted="File exceeds preview line limit")
140
116
  if before == after:
141
117
  return EditCompleted(call_id, path, "unchanged" if existed else "created")
142
- # Count actual changed source lines, even where redaction hides a change.
118
+ # Count actual changed source lines, even where sanitizing hides a change.
143
119
  raw = list(difflib.unified_diff(source_lines(before), source_lines(after), n=3))
144
120
  added = sum(line.startswith("+") for line in raw[2:])
145
121
  removed = sum(line.startswith("-") for line in raw[2:])
@@ -158,5 +134,5 @@ def completed_change(path, before, after, *, existed=True, call_id="", omitted="
158
134
  truncated = len(lines) > MAX_PATCH_LINES or len(patch) > MAX_PATCH
159
135
  patch = patch[:MAX_PATCH]
160
136
  if not patch:
161
- omitted = "Changes hidden by redaction or newline normalization"
137
+ omitted = "Changes hidden by control-character or newline normalization"
162
138
  return EditCompleted(call_id, path, operation, patch, added, removed, truncated, omitted)
@@ -0,0 +1,91 @@
1
+ ---
2
+ type: format
3
+ tags: [brief]
4
+ ---
5
+ # Brief format
6
+
7
+ The model reads this note before it writes or updates a **brief** or a **plan**. Edit it to change how they're written; the next update follows your version.
8
+
9
+ A **brief** explains one project as it is now: what it is, where it's at, how it works, what's wrong, what's next. A **plan** is a *proposed brief*: the same format, describing the project as it would be once the change is implemented.
10
+
11
+ ## The rules
12
+
13
+ 1. **One note answers the question.** Someone who missed every session (the newcomer test) must understand from the brief alone what the project is, what state it's in, how it works, what's broken and what to do next.
14
+ 2. **Rewrite, don't append.** The body is always the current truth. Only the *Log* is append-only.
15
+ 3. **Update when it matters.** A milestone, a decision, a new or fixed shortcoming, a changed next step. Not routine progress.
16
+ 4. **Evidence, not memory.** Check claims against the code, git and sessions. When they disagree with the brief, change the brief, and cite what you used in `sources`.
17
+ 5. **Change only what's out of date.** Edit the sections that are wrong and leave the rest exactly as it is. The user edits these notes too.
18
+ 6. **Never change `priority`** unless the user asks. Only create a brief when the user asks (`/brief new`).
19
+ 7. **No secrets.** Don't open files that hold credentials (`.env`, `*secret*`, token files, kubeconfigs), and never copy a token, key or password into a note. Name the file instead.
20
+
21
+ ## Where things live
22
+
23
+ - Briefs: `briefs/<Name>.md`. The name is the project (`coder-pydantic`, `Repo onboarding`).
24
+ - Plans: `briefs/Plan — <project> — <title>.md`, with `project:` linking the brief they change.
25
+ - This vault holds one org. Never link notes in another org's vault.
26
+
27
+ ## Scope: briefs, sessions and code are many-to-many
28
+
29
+ - A brief covers a **scope** you name: a repo, a subsystem, an initiative across repos. It's recorded in `scope:` (one line) and `dirs:` (org-relative directories).
30
+ - One session can change several briefs, or none. Update every brief it materially changed, and name them in your reply. Ask only when it's unclear whether a change belongs in a brief at all.
31
+ - To find a project's brief, match the current directory against the briefs' `dirs`.
32
+ - A brief can be built from the code itself (README, layout, main modules, git history), from sessions, or from both.
33
+ - When a brief outgrows its budgets, propose splitting it as a plan.
34
+
35
+ ## Properties
36
+
37
+ | Key | Brief | Plan | Notes |
38
+ |---|---|---|---|
39
+ | `type` | `brief` | `plan` | |
40
+ | `state` | `live` | `proposed` → `approved` → `implemented` (or `dropped`) | |
41
+ | `cssclasses` | none | `[proposed]`, `[approved]` or `[implemented]`, matching `state` | Colours the note in Obsidian |
42
+ | `project` | | link to the brief, e.g. `"[[briefs/coder-pydantic]]"` | None for a brand-new project |
43
+ | `scope` | one line | one line | What the note covers |
44
+ | `dirs` | org-relative directories the note covers | same | How a session finds its brief |
45
+ | `health` | `on-track`, `at-risk` or `blocked` | same | |
46
+ | `stage` | `idea`, `design`, `prototype`, `beta` or `shipped` | the stage after the change | |
47
+ | `priority` | 1 (highest) to 5 | | Set by the user |
48
+ | `tldr` | one sentence | one sentence | Shown on the dashboard |
49
+ | `next` | the single next action | same | Shown on the dashboard |
50
+ | `reviewed` | date the whole brief was last checked | | Briefs only |
51
+ | `created` | | date the plan was written | Plans only |
52
+ | `plans` | links to open plans | | |
53
+ | `sources` | session IDs, plus code paths or commit ranges | same | |
54
+
55
+ Properties are YAML, and one bad value breaks the whole block: Obsidian then shows it as raw red text instead of properties. Wrap every free-text value (`scope`, `tldr`, `next` and each `sources` item) in single quotes, doubling any `'` inside: `tldr: 'Checks the org''s code: once per ticket'`. Unquoted, a `: ` anywhere or a leading `` ` ``, `#`, `*`, `[`, `{`, `&`, `!`, `|`, `>`, `%` or `@` breaks it.
56
+
57
+ ## Sections
58
+
59
+ | Section | Answers | Budget |
60
+ |---|---|---|
61
+ | **TL;DR** (`> [!abstract]`) | What · Where · Next (a plan adds the cost to build) | 3 lines |
62
+ | **Where it's at** | Status line, then what changed since the last review. A plan says what's wrong with today and what's been decided. | ≤ 6 bullets |
63
+ | **What it does** (*What it will do*) | What a user gets, in their terms | ≤ 6 bullets |
64
+ | **How it works** (*How it will work*) | A Mermaid diagram, the pieces, key decisions each with *why* and *rejected* alternatives, and **How we know it works** | 1 diagram, ≤ 12 bullets |
65
+ | **Shortcomings & risks** | A table: problem, evidence, impact (🔴 🟠 🟡). In a plan, a mitigation instead of evidence. | ≤ 8 rows |
66
+ | **Next steps** | Ordered checkboxes, each with *done when* | ≤ 6 |
67
+ | **Open questions** | `> [!question]` callouts, each with *My lean* and an empty `**Answer:**` line | ≤ 5 |
68
+ | **Log** | Dated one-liners, newest first, linking sessions and plans | Append-only |
69
+ | **Sources** | Collapsed `> [!info]-` callout | |
70
+
71
+ Put retired decisions in a collapsed `> [!note]-` callout with the reason, not in the body. Briefs with open plans show them in a `> [!todo] Open plans` callout under the TL;DR.
72
+
73
+ ## Plans
74
+
75
+ - A plan starts with a banner: `> [!todo] PROPOSED · not implemented`, saying it describes the project as it would be after the change.
76
+ - For a change to an existing project, write only the sections that change. Other sections say *Unchanged, see [[brief]]*.
77
+ - Transitions:
78
+
79
+ | To | The plan | The brief |
80
+ |---|---|---|
81
+ | `proposed` | Banner, `cssclasses: [proposed]` | An *Open plans* line: 📝 proposed |
82
+ | `approved` | `state: approved`, `cssclasses: [approved]`, banner `> [!info] APPROVED · being implemented` | ✅ approved |
83
+ | `implemented` | `state: implemented`, `cssclasses: [implemented]`, banner `> [!success] IMPLEMENTED on <date>`, linking the brief. It stays as the record of why. | The changed sections rewritten in present tense, a *Log* line, and the plan removed from *Open plans* and `plans` |
84
+ | `dropped` | `state: dropped`, with the reason in its *Log* | Removed from *Open plans*, with a *Log* line |
85
+
86
+ ## Feedback from the user
87
+
88
+ The user answers open questions in place and adds `> [!comment]` callouts. Their edits arrive as a diff with their next message.
89
+
90
+ - **An answer:** fold it into the right section (a plan records decisions under *Where it's at*; a brief, under *How it works*), remove the question, and log it.
91
+ - **A comment:** act on it, remove the callout, and log what changed.
@@ -0,0 +1,50 @@
1
+ filters:
2
+ and:
3
+ - file.inFolder("briefs")
4
+ formulas:
5
+ state: 'if(note.state == "proposed", "📝 proposed", if(note.state == "approved", "✅ approved", if(note.state == "implemented", "🏁 implemented", if(note.state == "dropped", "✖ dropped", "🟢 live"))))'
6
+ health: 'if(note.health == "on-track", "🟢 on track", if(note.health == "at-risk", "🟠 at risk", if(note.health == "blocked", "🔴 blocked", "⚪ unknown")))'
7
+ properties:
8
+ formula.state:
9
+ displayName: State
10
+ formula.health:
11
+ displayName: Health
12
+ note.tldr:
13
+ displayName: TL;DR
14
+ note.next:
15
+ displayName: Next
16
+ views:
17
+ - type: table
18
+ name: Projects
19
+ filters:
20
+ and:
21
+ - 'type == "brief"'
22
+ order:
23
+ - note.priority
24
+ - file.name
25
+ - formula.health
26
+ - note.stage
27
+ - note.tldr
28
+ - note.next
29
+ - note.reviewed
30
+ sort:
31
+ - property: note.priority
32
+ direction: ASC
33
+ - type: table
34
+ name: Plans
35
+ filters:
36
+ and:
37
+ - 'type == "plan"'
38
+ groupBy:
39
+ property: note.state
40
+ direction: DESC
41
+ order:
42
+ - file.name
43
+ - formula.state
44
+ - note.project
45
+ - note.tldr
46
+ - note.next
47
+ - note.created
48
+ sort:
49
+ - property: note.created
50
+ direction: DESC
@@ -0,0 +1,11 @@
1
+ # Projects
2
+
3
+ One brief per project: what it is, where it's at, how it works, what's wrong, what's next. How they're written: [[Brief format]].
4
+
5
+ ![[views/Briefs.base#Projects]]
6
+
7
+ ## Plans
8
+
9
+ Proposed changes, written as the brief would read once implemented.
10
+
11
+ ![[views/Briefs.base#Plans]]