pcode 0.1.5__tar.gz → 0.1.6__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.
- {pcode-0.1.5 → pcode-0.1.6}/PKG-INFO +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/app.py +12 -12
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/aside_ui.py +1 -2
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/controller.py +8 -8
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/conversation_tree.py +2 -2
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/delegation.py +2 -6
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/diagnostics.py +11 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/edit_preview.py +3 -7
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/edit_transcript.py +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/edit_ui.py +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/edits.py +4 -28
- pcode-0.1.6/src/pcode/extensions/basic_memory/Brief format.md +89 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/Briefs.base +50 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/Home.md +11 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/__init__.py +682 -0
- pcode-0.1.6/src/pcode/extensions/basic_memory/memory-states.css +58 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/plan_mode.py +41 -7
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/filesystem.py +7 -14
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/git_diff.py +12 -21
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/inspection.py +2 -18
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/session_ui.py +4 -4
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/shell.py +5 -44
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/stream_events.py +3 -8
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/tool_display.py +8 -38
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/tool_output_limits.py +1 -2
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/tool_panel.py +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/tree_ui.py +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/ui.py +1 -1
- {pcode-0.1.5 → pcode-0.1.6}/.gitignore +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/LICENSE +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/README.md +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/pyproject.toml +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/__init__.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/agent.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/appearance.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/aside.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/auth.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/block.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/browser.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/errors.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/messages.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/model.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/session.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/web.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/cli.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/commands.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/compaction.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/completion.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/config.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/config_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/copy_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/delta.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/error_report.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/ext.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extension_guide.md +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/extensions/web_research.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/file_refs.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/frame.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/gateway.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/history.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/host.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/inspector_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/jobs.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/jobs_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/keymap.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/links.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/live.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/logins.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/mcp.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/meridian.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/meridian_reminders.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/models.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/native_results.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/paste.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/planning.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/popup_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/preferences.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/profiling.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/prompt_keys.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/remote.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/retries.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/rpc.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/runtime.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/sessions.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/skills.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/stall_log.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/steering.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/syntax.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/text_selection.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/theme.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/turn.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/update_check.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/usage.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/worker_ui.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/workers.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/workspace.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/workspace_filesystem.py +0 -0
- {pcode-0.1.5 → pcode-0.1.6}/src/pcode/worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
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
|
|
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(
|
|
563
|
+
self.transcript.user(strip_controls(node.prompt))
|
|
564
564
|
if node.response:
|
|
565
|
-
self.transcript.events((Message(
|
|
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:
|
|
@@ -1884,7 +1884,7 @@ class PreviewApp:
|
|
|
1884
1884
|
|
|
1885
1885
|
`end` stops at that journal offset: a host sends the rest as it happened.
|
|
1886
1886
|
"""
|
|
1887
|
-
from pcode.diagnostics import
|
|
1887
|
+
from pcode.diagnostics import strip_controls
|
|
1888
1888
|
|
|
1889
1889
|
saved = saved or self.runtime.session
|
|
1890
1890
|
self.activity.plan = saved.latest_plan()
|
|
@@ -1898,9 +1898,9 @@ class PreviewApp:
|
|
|
1898
1898
|
for record in saved.transcript_records(end):
|
|
1899
1899
|
kind = record["kind"]
|
|
1900
1900
|
if kind in ("turn_started", "steering"):
|
|
1901
|
-
self.transcript.user(
|
|
1901
|
+
self.transcript.user(strip_controls(record["prompt"]))
|
|
1902
1902
|
elif kind in ("Thinking", "thinking_partial"):
|
|
1903
|
-
self.transcript.thinking(
|
|
1903
|
+
self.transcript.thinking(strip_controls(record["text"]).rstrip("\n") + "\n\n")
|
|
1904
1904
|
elif kind == "CacheBust":
|
|
1905
1905
|
continue # Kept in the journal; live runs show it in the footer.
|
|
1906
1906
|
elif kind == "EditCompleted":
|
|
@@ -1912,21 +1912,21 @@ class PreviewApp:
|
|
|
1912
1912
|
self.transcript.tool_result(
|
|
1913
1913
|
ToolSummary(
|
|
1914
1914
|
record["name"],
|
|
1915
|
-
|
|
1915
|
+
strip_controls(record["detail"]),
|
|
1916
1916
|
record.get("failed", False),
|
|
1917
1917
|
record.get("call_id", ""),
|
|
1918
1918
|
record.get("elapsed_seconds"),
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
result=
|
|
1919
|
+
strip_controls(record.get("error", "")),
|
|
1920
|
+
strip_controls(record.get("command", "")),
|
|
1921
|
+
result=strip_controls(result) if isinstance(result, str) else None,
|
|
1922
1922
|
outcome=record.get("outcome", ""),
|
|
1923
1923
|
parent_call_id=record.get("parent_call_id", ""),
|
|
1924
|
-
purpose=
|
|
1924
|
+
purpose=strip_controls(record.get("purpose", "")),
|
|
1925
1925
|
execution=record.get("execution", ""),
|
|
1926
1926
|
)
|
|
1927
1927
|
)
|
|
1928
1928
|
elif kind in ("Message", "partial"):
|
|
1929
|
-
self.transcript.events((Message(
|
|
1929
|
+
self.transcript.events((Message(strip_controls(record["markdown"])),))
|
|
1930
1930
|
if kind == "partial":
|
|
1931
1931
|
self.transcript.warning("[Partial output from an interrupted run]")
|
|
1932
1932
|
|
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
3472
|
-
self.view.show_events((Message(
|
|
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
|
|
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(
|
|
3493
|
-
self.view.show_events((Message(
|
|
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
|
|
116
|
+
from pcode.diagnostics import strip_controls
|
|
117
117
|
from pcode.ui import plain
|
|
118
118
|
|
|
119
119
|
def excerpt(text):
|
|
120
|
-
return plain(
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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,89 @@
|
|
|
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
|
+
## Sections
|
|
56
|
+
|
|
57
|
+
| Section | Answers | Budget |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| **TL;DR** (`> [!abstract]`) | What · Where · Next (a plan adds the cost to build) | 3 lines |
|
|
60
|
+
| **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 |
|
|
61
|
+
| **What it does** (*What it will do*) | What a user gets, in their terms | ≤ 6 bullets |
|
|
62
|
+
| **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 |
|
|
63
|
+
| **Shortcomings & risks** | A table: problem, evidence, impact (🔴 🟠 🟡). In a plan, a mitigation instead of evidence. | ≤ 8 rows |
|
|
64
|
+
| **Next steps** | Ordered checkboxes, each with *done when* | ≤ 6 |
|
|
65
|
+
| **Open questions** | `> [!question]` callouts, each with *My lean* and an empty `**Answer:**` line | ≤ 5 |
|
|
66
|
+
| **Log** | Dated one-liners, newest first, linking sessions and plans | Append-only |
|
|
67
|
+
| **Sources** | Collapsed `> [!info]-` callout | |
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
## Plans
|
|
72
|
+
|
|
73
|
+
- A plan starts with a banner: `> [!todo] PROPOSED · not implemented`, saying it describes the project as it would be after the change.
|
|
74
|
+
- For a change to an existing project, write only the sections that change. Other sections say *Unchanged, see [[brief]]*.
|
|
75
|
+
- Transitions:
|
|
76
|
+
|
|
77
|
+
| To | The plan | The brief |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `proposed` | Banner, `cssclasses: [proposed]` | An *Open plans* line: 📝 proposed |
|
|
80
|
+
| `approved` | `state: approved`, `cssclasses: [approved]`, banner `> [!info] APPROVED · being implemented` | ✅ approved |
|
|
81
|
+
| `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` |
|
|
82
|
+
| `dropped` | `state: dropped`, with the reason in its *Log* | Removed from *Open plans*, with a *Log* line |
|
|
83
|
+
|
|
84
|
+
## Feedback from the user
|
|
85
|
+
|
|
86
|
+
The user answers open questions in place and adds `> [!comment]` callouts. Their edits arrive as a diff with their next message.
|
|
87
|
+
|
|
88
|
+
- **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.
|
|
89
|
+
- **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]]
|