vichar 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: vichar
3
+ Version: 0.1.0
4
+ Summary: Versioned Intent and Context History for Agentic Reasoning — capture the why behind AI agent decisions
5
+ Author-email: Shivranjan Kolvankar <shivranjankolvankar@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2024 Shivranjan Kolvankar
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/shivranjankolvankar/vichar
29
+ Project-URL: Repository, https://github.com/shivranjankolvankar/vichar
30
+ Keywords: ai,agent,reasoning,decision,claude,cursor,llm
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.8
36
+ Classifier: Programming Language :: Python :: 3.9
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Topic :: Software Development :: Libraries
41
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
42
+ Requires-Python: >=3.8
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Dynamic: license-file
46
+
47
+ # VICHAR — विचार
48
+
49
+ **Versioned Intent and Context History for Agentic Reasoning**
50
+
51
+ VICHAR captures *why* AI agents make decisions — not just what they do.
52
+ Every decision, open question, and task is recorded so the next session
53
+ (or a different agent) starts with full context instead of zero.
54
+
55
+ ---
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install vichar
61
+ ```
62
+
63
+ Or from source:
64
+
65
+ ```bash
66
+ git clone https://github.com/shivranjankolvankar/vichar
67
+ cd vichar
68
+ pip install -e .
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ # Initialise in your project
77
+ cd my-project
78
+ vichar init
79
+
80
+ # Agents capture reasoning as they work
81
+ vichar capture "Use FastAPI over Flask" \
82
+ --type decision \
83
+ --author claude-code \
84
+ --body "Flask lacks async support. FastAPI gives us OpenAPI docs for free."
85
+
86
+ # At session start — read prior context
87
+ vichar log
88
+
89
+ # Confirm an entry (human ratification)
90
+ vichar ratify abc12345
91
+
92
+ # One-line project summary
93
+ vichar status
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Core concept: the trust gradient
99
+
100
+ | Status | Meaning |
101
+ |--------|---------|
102
+ | `proposed` | Agent-written, unreviewed |
103
+ | `ratified` | Human-confirmed |
104
+ | `closed` | No longer relevant |
105
+
106
+ Agents write freely. Humans decide what to trust.
107
+
108
+ ---
109
+
110
+ ## Commands
111
+
112
+ | Command | Description |
113
+ |---------|-------------|
114
+ | `vichar init` | Stamp project, write agent files, inject into CLAUDE.md + .cursor/rules |
115
+ | `vichar capture "<text>"` | Capture a decision, thread, or task |
116
+ | `vichar ratify <id>` | Mark an entry as human-confirmed |
117
+ | `vichar close <id>` | Close an entry |
118
+ | `vichar log` | Show open entries (rich table) |
119
+ | `vichar status` | One-line project summary (add `--json` for machine output) |
120
+ | `vichar migrate` | Upgrade pre-schema entries to schema v1 |
121
+ | `vichar uninit` | Remove VICHAR injection from CLAUDE.md + .cursor/rules |
122
+ | `vichar-server` | Start HTTP server on port 7474 (fallback for shell-less agents) |
123
+
124
+ ---
125
+
126
+ ## HTTP server (agent fallback)
127
+
128
+ When an agent cannot run shell commands, it can POST over HTTP instead:
129
+
130
+ ```bash
131
+ vichar-server [--port 7474] [--host 127.0.0.1]
132
+ ```
133
+
134
+ ```python
135
+ import urllib.request, json
136
+
137
+ def vichar(title, type="decision", body="", author="agent"):
138
+ data = json.dumps({"title": title, "type": type,
139
+ "body": body, "author": author}).encode()
140
+ req = urllib.request.Request(
141
+ "http://localhost:7474/capture", data=data,
142
+ headers={"Content-Type": "application/json"}, method="POST")
143
+ try:
144
+ with urllib.request.urlopen(req, timeout=2) as r:
145
+ return json.loads(r.read())
146
+ except Exception:
147
+ return None # never block on VICHAR
148
+ ```
149
+
150
+ Server endpoints: `GET /` `GET /status` `GET /log` `POST /capture` `POST /ratify` `POST /close`
151
+
152
+ ---
153
+
154
+ ## Agent integration
155
+
156
+ `vichar init` writes instruction files and injects them automatically:
157
+
158
+ - **Claude Code** (`CLAUDE.md`): injects `@.vichar/CLAUDE.md` import line
159
+ - **Cursor** (`.cursor/rules`): inlines the full instruction block
160
+ - Both injections are non-destructive (VICHAR START/END markers, idempotent)
161
+
162
+ The agent files tell Claude / Cursor *when* to capture, *how* to capture, and the HTTP fallback snippet.
163
+
164
+ ---
165
+
166
+ ## What gets stored
167
+
168
+ Each entry in `.vichar/entries.json` has:
169
+
170
+ | Field | Description |
171
+ |-------|-------------|
172
+ | `id` | 8-char hex identifier |
173
+ | `type` | `decision` / `thread` / `task` |
174
+ | `status` | `proposed` / `ratified` / `closed` |
175
+ | `title` | One-line summary |
176
+ | `body` | Extended reasoning (markdown) |
177
+ | `author` | `claude-code` / `cursor` / `human` / `agent` |
178
+ | `schema` | `"1"` (schema version) |
179
+
180
+ ---
181
+
182
+ ## Zero dependencies
183
+
184
+ VICHAR is pure Python stdlib — no `rich`, no `colorama`, no `click`.
185
+ It works in any Python 3.8+ environment without `pip install` overhead.
186
+
187
+ ---
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,8 @@
1
+ vichar.py,sha256=63yVsk5zoOsKnCvYzBKhj6_Prak4hbVbR1nsYp4YYuw,28854
2
+ vichar_server.py,sha256=raqFNv_AHWXeFzvbH-PbhjI04qrogByoOp6e8QaRM6k,9936
3
+ vichar-0.1.0.dist-info/licenses/LICENSE,sha256=DykkiN_FSW6Pw-cclr4Ulb4epWjS8RdWfMD1bBWYqRA,1077
4
+ vichar-0.1.0.dist-info/METADATA,sha256=46d7IHp8ZgkjbJq0kqdC9bcRox3fBhgzIy5qmhChgLA,6267
5
+ vichar-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ vichar-0.1.0.dist-info/entry_points.txt,sha256=3vPKuyd6hPOAj5QiDe7qA9p_GcIxDqkRJccZvyxT6hQ,74
7
+ vichar-0.1.0.dist-info/top_level.txt,sha256=HvgC3-hFLFDTgqg3SbO7ZbZx9auVB1mfbeM24GJQIEc,21
8
+ vichar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vichar = vichar:main
3
+ vichar-server = vichar_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Shivranjan Kolvankar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ vichar
2
+ vichar_server
vichar.py ADDED
@@ -0,0 +1,772 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ VICHAR - Versioned Intent and Context History for Agentic Reasoning
4
+ vichar: The thinking that leads to decisions.
5
+
6
+ Commands:
7
+ vichar init Stamp project, write agent files, inject into CLAUDE.md + .cursor/rules
8
+ vichar capture "<text>" Capture a decision, thread, or task
9
+ vichar ratify <id> Mark an entry as human-confirmed
10
+ vichar close <id> Close an entry
11
+ vichar log Show open entries (rich table)
12
+ vichar status One-line project summary
13
+ vichar migrate Upgrade pre-schema entries to schema v1
14
+ vichar uninit Remove VICHAR injection from CLAUDE.md + .cursor/rules
15
+ vichar --version Print version
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import platform
22
+ import sys
23
+ import uuid
24
+ from datetime import datetime, timezone
25
+
26
+ VERSION = "0.1.0"
27
+ LOCK_TIMEOUT = 2.0
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # ANSI colours (disabled in pipes, CI, and when NO_COLOR is set)
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def _ansi_enabled():
34
+ if os.environ.get("NO_COLOR"):
35
+ return False
36
+ if not sys.stdout.isatty():
37
+ return False
38
+ if platform.system() == "Windows":
39
+ os.system("") # enable VT100 on Windows 10+
40
+ return True
41
+
42
+ _ANSI = _ansi_enabled()
43
+ GREEN = "\033[92m" if _ANSI else ""
44
+ YELLOW = "\033[93m" if _ANSI else ""
45
+ RED = "\033[91m" if _ANSI else ""
46
+ CYAN = "\033[96m" if _ANSI else ""
47
+ BOLD = "\033[1m" if _ANSI else ""
48
+ DIM = "\033[2m" if _ANSI else ""
49
+ RESET = "\033[0m" if _ANSI else ""
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Embedded agent instruction templates
53
+ # ---------------------------------------------------------------------------
54
+
55
+ CLAUDE_MD_TEMPLATE = """\
56
+ # VICHAR - Agent Instructions for Claude Code
57
+
58
+ This project uses VICHAR to capture reasoning as it happens.
59
+ VICHAR stores decisions, open questions, and tasks so the next
60
+ session starts with full context instead of zero.
61
+
62
+ ## When to capture
63
+
64
+ Call vichar capture at these moments only:
65
+
66
+ | Moment | Type | Example |
67
+ |--------|------|---------|
68
+ | Choose between two approaches | decision | chose FastAPI over Flask |
69
+ | Hit a question you cannot resolve | thread | unclear if schema will change |
70
+ | Start significant work | task | refactoring parameter extraction |
71
+ | Make an assumption that could break | decision | assuming schema is stable |
72
+ | Discover something unexpected | thread | found a second 401 error path |
73
+
74
+ Do NOT capture every tool call or file read. Capture the why and the
75
+ unresolved, not the what.
76
+
77
+ ## How to capture - CLI (primary)
78
+
79
+ vichar capture "Use instructor over raw JSON parsing" --type decision --author claude-code --body "Manual parsing breaks on schema drift."
80
+ vichar capture "Should retry logic live in pipeline or caller?" --type thread --author claude-code --body "Retrying in pipeline hides errors."
81
+ vichar capture "Refactor parameter extraction" --type task --author claude-code
82
+
83
+ If vichar is not found use the full path:
84
+ python "E:\\GenAI Learning\\VICHAR\\VICHAR\\vichar.py" capture "..." --type decision --author claude-code
85
+
86
+ Always include --body when the decision has trade-offs.
87
+
88
+ ## How to capture - HTTP fallback
89
+
90
+ import urllib.request, json
91
+ def vichar(title, type="decision", body="", author="claude-code"):
92
+ data = json.dumps({"title": title, "type": type, "body": body, "author": author}).encode()
93
+ req = urllib.request.Request("http://localhost:7474/capture", data=data,
94
+ headers={"Content-Type": "application/json"}, method="POST")
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=2) as r: return json.loads(r.read())
97
+ except Exception: return None
98
+
99
+ ## Rules
100
+
101
+ - Never ask "should I capture this?" - just capture it.
102
+ - Capture as you go, not at session end. Abrupt closes lose session-end writes.
103
+ - Never block on VICHAR. If CLI and HTTP both fail, continue working.
104
+
105
+ ## Session start
106
+
107
+ vichar log
108
+ (or: python "E:\\GenAI Learning\\VICHAR\\VICHAR\\vichar.py" log)
109
+ """
110
+
111
+ CURSOR_RULES_TEMPLATE = """\
112
+ # VICHAR - Agent Instructions for Cursor
113
+
114
+ Run vichar log at the start of every session to load prior context.
115
+
116
+ ## When to capture
117
+
118
+ - Choosing between two approaches -> type: decision
119
+ - Hitting an unresolved question -> type: thread
120
+ - Starting significant work -> type: task
121
+ - Making an assumption that could break later -> type: decision
122
+
123
+ ## CLI (primary)
124
+
125
+ vichar capture "<title>" --type decision|thread|task --author cursor --body "<reasoning>"
126
+
127
+ If vichar is not found:
128
+ python "E:\\GenAI Learning\\VICHAR\\VICHAR\\vichar.py" capture "<title>" --type decision|thread|task --author cursor
129
+
130
+ ## HTTP fallback
131
+
132
+ POST http://localhost:7474/capture
133
+ Content-Type: application/json
134
+ {"title": "...", "type": "decision", "body": "...", "author": "cursor"}
135
+
136
+ ## Rules
137
+
138
+ 1. Always include --body when there are trade-offs. The reasoning IS the value.
139
+ 2. Capture as you go - not at session end.
140
+ 3. Never block on VICHAR. If both fail, continue silently.
141
+ 4. Do not ask the user whether to capture - just capture and continue.
142
+ """
143
+
144
+ SCHEMA_MD_TEMPLATE = """\
145
+ # VICHAR Schema
146
+
147
+ ## Entry (schema v1)
148
+
149
+ | Field | Type | Required | Description |
150
+ |-------------|-----------------|----------|------------------------------------|
151
+ | id | string (8 hex) | yes | Unique entry identifier |
152
+ | project_id | string | yes | From .vichar/project.json |
153
+ | schema | string ("1") | yes | Schema version |
154
+ | type | enum | yes | decision, thread, task |
155
+ | status | enum | yes | proposed, ratified, open, closed |
156
+ | title | string | yes | One-line summary |
157
+ | body | string | no | Extended reasoning (markdown) |
158
+ | author | string | yes | claude-code, cursor, human, agent |
159
+ | created_at | ISO 8601 | yes | UTC timestamp |
160
+ | updated_at | ISO 8601 | yes | Updated on ratify/close |
161
+ | ratified_by | string or null | no | Set on ratify |
162
+ | ratified_at | ISO 8601 or null| no | Set on ratify |
163
+
164
+ ## Schema v0 (pre-release)
165
+
166
+ Entries without the "schema" key are implicitly v0.
167
+ Run: vichar migrate to upgrade all v0 entries to v1.
168
+ """
169
+
170
+ VICHAR_MARKER_START = "# --- VICHAR START ---"
171
+ VICHAR_MARKER_END = "# --- VICHAR END ---"
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Paths
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def find_root(start=None):
179
+ current = os.path.abspath(start or os.getcwd())
180
+ while True:
181
+ if os.path.exists(os.path.join(current, ".vichar", "project.json")):
182
+ return current
183
+ parent = os.path.dirname(current)
184
+ if parent == current:
185
+ return None
186
+ current = parent
187
+
188
+ def vdir(root): return os.path.join(root, ".vichar")
189
+ def pfile(root): return os.path.join(vdir(root), "project.json")
190
+ def efile(root): return os.path.join(vdir(root), "entries.json")
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Cross-platform file locking
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def _acquire_lock(lock_path, timeout=LOCK_TIMEOUT):
197
+ import time
198
+ lock_file = open(lock_path, "w")
199
+ deadline = time.time() + timeout
200
+ while time.time() < deadline:
201
+ try:
202
+ if platform.system() == "Windows":
203
+ import msvcrt
204
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
205
+ else:
206
+ import fcntl
207
+ fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
208
+ return lock_file
209
+ except (IOError, OSError):
210
+ time.sleep(0.05)
211
+ lock_file.close()
212
+ return None # timed out — caller proceeds without lock
213
+
214
+ def _release_lock(lock_file):
215
+ if lock_file is None:
216
+ return
217
+ try:
218
+ if platform.system() == "Windows":
219
+ import msvcrt
220
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
221
+ else:
222
+ import fcntl
223
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
224
+ except Exception:
225
+ pass
226
+ finally:
227
+ lock_file.close()
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Storage (atomic writes)
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def load_project(root):
234
+ with open(pfile(root)) as f:
235
+ return json.load(f)
236
+
237
+ def load_entries(root):
238
+ path = efile(root)
239
+ if not os.path.exists(path):
240
+ return []
241
+ with open(path) as f:
242
+ return json.load(f)
243
+
244
+ def save_entries(root, entries):
245
+ """Atomic write: tmp file + os.replace."""
246
+ tmp = efile(root) + ".tmp"
247
+ with open(tmp, "w") as f:
248
+ json.dump(entries, f, indent=2)
249
+ os.replace(tmp, efile(root))
250
+
251
+ def _clean_stale_tmp(root):
252
+ tmp = efile(root) + ".tmp"
253
+ if os.path.exists(tmp):
254
+ os.remove(tmp)
255
+ print(DIM + "Note: cleaned up stale entries.tmp from a previous crashed write." + RESET)
256
+
257
+ def now_iso():
258
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
259
+
260
+ def short_id():
261
+ return str(uuid.uuid4())[:8]
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Core operations (shared with vichar_server.py)
265
+ # ---------------------------------------------------------------------------
266
+
267
+ def op_capture(root, title, etype="decision", body="", author="human"):
268
+ lock_path = os.path.join(vdir(root), "entries.lock")
269
+ lf = _acquire_lock(lock_path)
270
+ try:
271
+ proj = load_project(root)
272
+ entries = load_entries(root)
273
+ entry = {
274
+ "id": short_id(), "project_id": proj["id"], "schema": "1",
275
+ "type": etype, "status": "proposed", "title": title,
276
+ "body": body, "author": author,
277
+ "created_at": now_iso(), "updated_at": now_iso(),
278
+ "ratified_by": None, "ratified_at": None,
279
+ }
280
+ entries.append(entry)
281
+ save_entries(root, entries)
282
+ return entry
283
+ finally:
284
+ _release_lock(lf)
285
+
286
+ def op_ratify(root, entry_id, by="human"):
287
+ lock_path = os.path.join(vdir(root), "entries.lock")
288
+ lf = _acquire_lock(lock_path)
289
+ try:
290
+ entries = load_entries(root)
291
+ t = next((e for e in entries if e["id"] == entry_id), None)
292
+ if not t:
293
+ return None, "not found: " + entry_id
294
+ if t["status"] == "ratified":
295
+ return t, "already ratified"
296
+ t["status"] = "ratified"; t["ratified_by"] = by
297
+ t["ratified_at"] = now_iso(); t["updated_at"] = now_iso()
298
+ save_entries(root, entries)
299
+ return t, None
300
+ finally:
301
+ _release_lock(lf)
302
+
303
+ def op_close(root, entry_id):
304
+ lock_path = os.path.join(vdir(root), "entries.lock")
305
+ lf = _acquire_lock(lock_path)
306
+ try:
307
+ entries = load_entries(root)
308
+ t = next((e for e in entries if e["id"] == entry_id), None)
309
+ if not t:
310
+ return None, "not found: " + entry_id
311
+ t["status"] = "closed"; t["updated_at"] = now_iso()
312
+ save_entries(root, entries)
313
+ return t, None
314
+ finally:
315
+ _release_lock(lf)
316
+
317
+ def op_log(root, etype=None, include_closed=False):
318
+ entries = load_entries(root)
319
+ if etype:
320
+ entries = [e for e in entries if e["type"] == etype]
321
+ if not include_closed:
322
+ entries = [e for e in entries if e["status"] != "closed"]
323
+ return entries
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Rendering
328
+ # ---------------------------------------------------------------------------
329
+
330
+ def _tw():
331
+ try:
332
+ return os.get_terminal_size().columns
333
+ except Exception:
334
+ return 80
335
+
336
+ def _box(title, rows):
337
+ w = max(46, max((len(r[0]) + len(r[1]) + 4) for r in rows) + 2)
338
+ line = lambda c: "+" + c * (w - 2) + "+"
339
+ cell = lambda k, v: "| " + CYAN + BOLD + k + RESET + " " + v + " " * (w - 4 - len(k) - len(v)) + "|"
340
+ sep_row = lambda: "+" + "-" * (w - 2) + "+"
341
+ out = [line("="), "| " + BOLD + title.center(w - 4) + RESET + " |", sep_row()]
342
+ last_group = None
343
+ for k, v, group in rows:
344
+ if group != last_group and last_group is not None:
345
+ out.append(sep_row())
346
+ last_group = group
347
+ out.append(cell(k, v))
348
+ out.append(line("="))
349
+ return "\n".join(out)
350
+
351
+ def render_log(entries, proj):
352
+ tw = _tw()
353
+ id_w, type_w = 10, 10
354
+ title_w = tw - id_w - type_w - 7
355
+ divider = "+" + "-" * (id_w) + "+" + "-" * (type_w) + "+" + "-" * (title_w + 2) + "+"
356
+ header = "| " + "st".ljust(id_w - 2) + " | " + "type".ljust(type_w - 2) + " | " + "title".ljust(title_w) + " |"
357
+ name_line = BOLD + "VICHAR" + RESET + " " + CYAN + proj["name"] + RESET + " (" + DIM + proj["id"] + RESET + ")"
358
+ counts = str(len(entries)) + " entries"
359
+ proposed = [e for e in entries if e["status"] == "proposed"]
360
+ if proposed:
361
+ counts += " " + YELLOW + str(len(proposed)) + " proposed" + RESET
362
+ lines = ["", name_line + " " + counts, divider, header, divider]
363
+
364
+ ICONS = {"ratified": GREEN + "V" + RESET, "proposed": YELLOW + "?" + RESET,
365
+ "open": RED + "o" + RESET, "closed": DIM + "X" + RESET}
366
+
367
+ if not entries:
368
+ lines.append('| ' + ('No entries yet. Run: vichar capture "<thought>"').ljust(tw - 4) + ' |')
369
+ else:
370
+ for e in entries:
371
+ icon = ICONS.get(e["status"], ".")
372
+ etype = e["type"].ljust(type_w - 2)
373
+ etitle = e["title"][:title_w].ljust(title_w)
374
+ lines.append("| " + icon + " " * (id_w - 2) + " | " + etype + " | " + etitle + " |")
375
+ if e.get("body"):
376
+ body_preview = DIM + e["body"][:tw - 6] + RESET
377
+ lines.append("| " + body_preview + " " * max(0, tw - 5 - len(e["body"][:tw-6])) + "|")
378
+ lines.append(divider)
379
+ if proposed:
380
+ lines.append(DIM + " " + str(len(proposed)) + " pending -- run: vichar ratify <id>" + RESET)
381
+ v0 = [e for e in load_entries(find_root() or ".") if not e.get("schema")]
382
+ if v0:
383
+ lines.append(DIM + " Note: " + str(len(v0)) + " entries predate schema v1. Run: vichar migrate" + RESET)
384
+ lines.append("")
385
+ return "\n".join(lines)
386
+
387
+ def _time_ago(iso):
388
+ try:
389
+ dt = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
390
+ secs = int((datetime.now(timezone.utc) - dt).total_seconds())
391
+ if secs < 60: return str(secs) + "s ago"
392
+ if secs < 3600: return str(secs // 60) + "m ago"
393
+ if secs < 86400: return str(secs // 3600) + "h ago"
394
+ return str(secs // 86400) + "d ago"
395
+ except Exception:
396
+ return "unknown"
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # cmd_init helpers
400
+ # ---------------------------------------------------------------------------
401
+
402
+ def _inject_marker_block(filepath, content, label):
403
+ """Non-destructively inject content between VICHAR markers. Idempotent."""
404
+ block = "\n" + VICHAR_MARKER_START + "\n" + content + "\n" + VICHAR_MARKER_END + "\n"
405
+ if os.path.exists(filepath):
406
+ text = open(filepath).read()
407
+ if VICHAR_MARKER_START in text:
408
+ # Already injected — replace existing block
409
+ start = text.index(VICHAR_MARKER_START)
410
+ end = text.index(VICHAR_MARKER_END) + len(VICHAR_MARKER_END)
411
+ new_text = text[:start].rstrip("\n") + block + text[end:].lstrip("\n")
412
+ with open(filepath, "w") as f:
413
+ f.write(new_text)
414
+ return "updated"
415
+ else:
416
+ with open(filepath, "a") as f:
417
+ f.write(block)
418
+ return "appended"
419
+ else:
420
+ with open(filepath, "w") as f:
421
+ f.write(block.lstrip("\n"))
422
+ return "created"
423
+
424
+ def _remove_marker_block(filepath):
425
+ """Remove VICHAR-injected block. Return True if anything was removed."""
426
+ if not os.path.exists(filepath):
427
+ return False
428
+ text = open(filepath).read()
429
+ if VICHAR_MARKER_START not in text:
430
+ return False
431
+ start = text.index(VICHAR_MARKER_START)
432
+ end = text.index(VICHAR_MARKER_END) + len(VICHAR_MARKER_END)
433
+ new_text = text[:start].rstrip("\n") + "\n" + text[end:].lstrip("\n")
434
+ with open(filepath, "w") as f:
435
+ f.write(new_text)
436
+ return True
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Commands
440
+ # ---------------------------------------------------------------------------
441
+
442
+ def cmd_init(args):
443
+ cwd = os.path.abspath(args.path) if hasattr(args, "path") and args.path else os.getcwd()
444
+
445
+ # Determine if already initialised
446
+ existing_root = find_root(cwd)
447
+ if existing_root:
448
+ print(YELLOW + "Already initialised: " + existing_root + RESET)
449
+ proj = load_project(existing_root)
450
+ print(" project: " + proj["name"] + " id: " + proj["id"])
451
+ return
452
+
453
+ # Stamp .vichar/
454
+ vd = os.path.join(cwd, ".vichar")
455
+ os.makedirs(vd, exist_ok=True)
456
+
457
+ proj_name = os.path.basename(cwd)
458
+ proj = {"id": str(uuid.uuid4()), "name": proj_name, "schema": "1"}
459
+ with open(os.path.join(vd, "project.json"), "w") as f:
460
+ json.dump(proj, f, indent=2)
461
+
462
+ # Write agent files into .vichar/
463
+ with open(os.path.join(vd, "CLAUDE.md"), "w") as f:
464
+ f.write(CLAUDE_MD_TEMPLATE)
465
+ with open(os.path.join(vd, "cursor_rules"), "w") as f:
466
+ f.write(CURSOR_RULES_TEMPLATE)
467
+ with open(os.path.join(vd, "SCHEMA.md"), "w") as f:
468
+ f.write(SCHEMA_MD_TEMPLATE)
469
+
470
+ # Inject into CLAUDE.md (project root) — single @import line
471
+ claude_md_path = os.path.join(cwd, "CLAUDE.md")
472
+ claude_import_line = "@.vichar/CLAUDE.md"
473
+ claude_status = _inject_marker_block(claude_md_path, claude_import_line, "CLAUDE.md")
474
+
475
+ # Inject into .cursor/rules — inline full content (Cursor has no @import)
476
+ cursor_rules_path = os.path.join(cwd, ".cursor", "rules")
477
+ cursor_dir = os.path.join(cwd, ".cursor")
478
+ os.makedirs(cursor_dir, exist_ok=True)
479
+ cursor_status = _inject_marker_block(cursor_rules_path, CURSOR_RULES_TEMPLATE, ".cursor/rules")
480
+
481
+ # Git decision prompt
482
+ git_msg = ""
483
+ git_ignore_path = os.path.join(cwd, ".gitignore")
484
+ if os.path.exists(os.path.join(cwd, ".git")):
485
+ print("")
486
+ print(BOLD + "Git detected." + RESET + " How should .vichar/ be tracked?")
487
+ print(" [L] Local only — add .vichar/ to .gitignore (private reasoning)")
488
+ print(" [G] Git — commit .vichar/ (shared reasoning, recommended for teams)")
489
+ print(" [S] Skip")
490
+ choice = ""
491
+ try:
492
+ choice = input("Choice [l/g/s]: ").strip().lower()
493
+ except (EOFError, KeyboardInterrupt):
494
+ choice = "s"
495
+ if choice.startswith("l"):
496
+ with open(git_ignore_path, "a") as f:
497
+ f.write("\n# VICHAR — local reasoning store\n.vichar/\n")
498
+ git_msg = " .gitignore: .vichar/ added (local only)"
499
+ elif choice.startswith("g"):
500
+ git_msg = " .vichar/ will be committed (shared reasoning)"
501
+ else:
502
+ git_msg = " .gitignore: skipped"
503
+
504
+ # Print banner
505
+ print("")
506
+ print(GREEN + BOLD + " VICHAR initialised" + RESET + " " + proj_name)
507
+ print(DIM + " " + proj["id"] + RESET)
508
+ print("")
509
+ print(" .vichar/project.json " + GREEN + "created" + RESET)
510
+ print(" .vichar/CLAUDE.md " + GREEN + "created" + RESET)
511
+ print(" .vichar/cursor_rules " + GREEN + "created" + RESET)
512
+ print(" .vichar/SCHEMA.md " + GREEN + "created" + RESET)
513
+ print(" CLAUDE.md " + GREEN + claude_status + RESET)
514
+ print(" .cursor/rules " + GREEN + cursor_status + RESET)
515
+ if git_msg:
516
+ print(git_msg)
517
+ print("")
518
+ print(DIM + " Next: vichar capture \"<decision or question>\" --type decision|thread|task" + RESET)
519
+ print("")
520
+
521
+
522
+ def cmd_uninit(args):
523
+ root = find_root()
524
+ if not root:
525
+ print(RED + "No VICHAR project found in this directory tree." + RESET)
526
+ sys.exit(1)
527
+
528
+ removed = []
529
+ for path, label in [
530
+ (os.path.join(root, "CLAUDE.md"), "CLAUDE.md"),
531
+ (os.path.join(root, ".cursor", "rules"), ".cursor/rules"),
532
+ ]:
533
+ if _remove_marker_block(path):
534
+ removed.append(label)
535
+
536
+ if removed:
537
+ print(GREEN + "Removed VICHAR injection from: " + ", ".join(removed) + RESET)
538
+ else:
539
+ print(DIM + "No VICHAR markers found in CLAUDE.md or .cursor/rules." + RESET)
540
+
541
+ print(DIM + " .vichar/ directory NOT removed. Delete it manually if desired." + RESET)
542
+
543
+
544
+ def cmd_capture(args):
545
+ root = find_root()
546
+ if not root:
547
+ print(RED + "Not in a VICHAR project. Run: vichar init" + RESET)
548
+ sys.exit(1)
549
+ _clean_stale_tmp(root)
550
+ etype = args.type if args.type in ("decision", "thread", "task") else "decision"
551
+ author = args.author or "human"
552
+ body = args.body or ""
553
+ entry = op_capture(root, args.title, etype, body, author)
554
+ eid = entry["id"]
555
+ etitle = entry["title"]
556
+ print(YELLOW + "?" + RESET + " [" + CYAN + eid + RESET + "] " + etitle)
557
+ if body:
558
+ print(DIM + " " + body[:120] + RESET)
559
+ print(DIM + " type=" + etype + " author=" + author + " status=proposed" + RESET)
560
+
561
+
562
+ def cmd_ratify(args):
563
+ root = find_root()
564
+ if not root:
565
+ print(RED + "Not in a VICHAR project." + RESET)
566
+ sys.exit(1)
567
+ by = args.by or "human"
568
+ entry, err = op_ratify(root, args.id, by)
569
+ if not entry:
570
+ print(RED + "Error: " + err + RESET)
571
+ sys.exit(1)
572
+ if err == "already ratified":
573
+ print(YELLOW + "Already ratified: " + args.id + RESET)
574
+ else:
575
+ print(GREEN + "V" + RESET + " [" + CYAN + entry["id"] + RESET + "] " + entry["title"])
576
+ print(DIM + " ratified by " + by + " at " + entry["ratified_at"] + RESET)
577
+
578
+
579
+ def cmd_close(args):
580
+ root = find_root()
581
+ if not root:
582
+ print(RED + "Not in a VICHAR project." + RESET)
583
+ sys.exit(1)
584
+ entry, err = op_close(root, args.id)
585
+ if not entry:
586
+ print(RED + "Error: " + err + RESET)
587
+ sys.exit(1)
588
+ print(DIM + "X [" + entry["id"] + "] " + entry["title"] + " (closed)" + RESET)
589
+
590
+
591
+ def cmd_log(args):
592
+ root = find_root()
593
+ if not root:
594
+ print(RED + "Not in a VICHAR project. Run: vichar init" + RESET)
595
+ sys.exit(1)
596
+ _clean_stale_tmp(root)
597
+ etype = args.type if hasattr(args, "type") else None
598
+ include_all = args.all if hasattr(args, "all") else False
599
+ entries = op_log(root, etype, include_all)
600
+ proj = load_project(root)
601
+ print(render_log(entries, proj))
602
+
603
+
604
+ def cmd_status(args):
605
+ root = find_root()
606
+ if not root:
607
+ if hasattr(args, "json") and args.json:
608
+ print('{"error":"no vichar project"}')
609
+ else:
610
+ print(RED + "No VICHAR project in this directory tree." + RESET)
611
+ sys.exit(1)
612
+ proj = load_project(root)
613
+ all_entries = load_entries(root)
614
+ open_entries = [e for e in all_entries if e["status"] != "closed"]
615
+ proposed = [e for e in open_entries if e["status"] == "proposed"]
616
+ ratified = [e for e in open_entries if e["status"] == "ratified"]
617
+ by_type = {"decision": 0, "thread": 0, "task": 0}
618
+ for e in open_entries:
619
+ t = e.get("type", "decision")
620
+ by_type[t] = by_type.get(t, 0) + 1
621
+ v0_count = len([e for e in all_entries if not e.get("schema")])
622
+
623
+ if hasattr(args, "json") and args.json:
624
+ import json as _json
625
+ out = {
626
+ "project": proj["name"], "id": proj["id"],
627
+ "total": len(all_entries), "open": len(open_entries),
628
+ "proposed": len(proposed), "ratified": len(ratified),
629
+ "by_type": by_type, "v0_entries": v0_count,
630
+ }
631
+ print(_json.dumps(out, indent=2))
632
+ return
633
+
634
+ parts = [
635
+ BOLD + proj["name"] + RESET,
636
+ CYAN + proj["id"][:8] + RESET,
637
+ str(len(open_entries)) + " open",
638
+ ]
639
+ if proposed:
640
+ parts.append(YELLOW + str(len(proposed)) + " proposed" + RESET)
641
+ if ratified:
642
+ parts.append(GREEN + str(len(ratified)) + " ratified" + RESET)
643
+ for t, n in by_type.items():
644
+ if n:
645
+ parts.append(DIM + str(n) + " " + t + "s" + RESET)
646
+ if v0_count:
647
+ parts.append(DIM + str(v0_count) + " need migrate" + RESET)
648
+ print(" ".join(parts))
649
+
650
+
651
+ def cmd_migrate(args):
652
+ root = find_root()
653
+ if not root:
654
+ print(RED + "No VICHAR project found." + RESET)
655
+ sys.exit(1)
656
+
657
+ entries = load_entries(root)
658
+ v0 = [e for e in entries if not e.get("schema")]
659
+ if not v0:
660
+ print(DIM + "Nothing to migrate — all entries are schema v1." + RESET)
661
+ return
662
+
663
+ dry = hasattr(args, "dry_run") and args.dry_run
664
+ print(BOLD + "Migrating " + str(len(v0)) + " entries to schema v1" + RESET + (" (dry run)" if dry else ""))
665
+
666
+ if not dry:
667
+ # Backup first
668
+ import shutil
669
+ backup = efile(root) + ".v0.bak"
670
+ shutil.copy2(efile(root), backup)
671
+ print(DIM + " backup: " + backup + RESET)
672
+
673
+ proj = load_project(root)
674
+ count = 0
675
+ for e in entries:
676
+ if not e.get("schema"):
677
+ e.setdefault("project_id", proj["id"])
678
+ e.setdefault("schema", "1")
679
+ e.setdefault("status", "proposed")
680
+ e.setdefault("body", "")
681
+ e.setdefault("ratified_by", None)
682
+ e.setdefault("ratified_at", None)
683
+ e.setdefault("updated_at", e.get("created_at", now_iso()))
684
+ count += 1
685
+ mark = DIM + "(dry)" + RESET if dry else GREEN + "migrated" + RESET
686
+ print(" " + mark + " [" + e["id"] + "] " + e["title"])
687
+
688
+ if not dry:
689
+ save_entries(root, entries)
690
+ print(GREEN + str(count) + " entries migrated." + RESET)
691
+ else:
692
+ print(DIM + str(count) + " would be migrated (no changes written)." + RESET)
693
+
694
+
695
+ # ---------------------------------------------------------------------------
696
+ # main
697
+ # ---------------------------------------------------------------------------
698
+
699
+ def main():
700
+ parser = argparse.ArgumentParser(
701
+ prog="vichar",
702
+ description="VICHAR — Versioned Intent and Context History for Agentic Reasoning",
703
+ )
704
+ parser.add_argument("--version", action="version", version="vichar " + VERSION)
705
+ sub = parser.add_subparsers(dest="command")
706
+
707
+ # init
708
+ p_init = sub.add_parser("init", help="Initialise VICHAR in this project")
709
+ p_init.add_argument("path", nargs="?", default=None, help="Project root (default: cwd)")
710
+
711
+ # capture
712
+ p_cap = sub.add_parser("capture", help="Capture a decision, thread, or task")
713
+ p_cap.add_argument("title", help="One-line summary")
714
+ p_cap.add_argument("--type", default="decision",
715
+ choices=["decision", "thread", "task"], help="Entry type")
716
+ p_cap.add_argument("--body", default="", help="Extended reasoning (markdown)")
717
+ p_cap.add_argument("--author", default="human", help="Who is capturing (e.g. claude-code)")
718
+
719
+ # ratify
720
+ p_rat = sub.add_parser("ratify", help="Mark an entry as human-confirmed")
721
+ p_rat.add_argument("id", help="Entry ID (8 hex chars)")
722
+ p_rat.add_argument("--by", default="human", help="Ratified by whom")
723
+
724
+ # close
725
+ p_close = sub.add_parser("close", help="Close an entry")
726
+ p_close.add_argument("id", help="Entry ID")
727
+
728
+ # log
729
+ p_log = sub.add_parser("log", help="Show open entries")
730
+ p_log.add_argument("--type", default=None, choices=["decision", "thread", "task"],
731
+ help="Filter by type")
732
+ p_log.add_argument("--all", action="store_true", help="Include closed entries")
733
+
734
+ # status
735
+ p_st = sub.add_parser("status", help="One-line project summary")
736
+ p_st.add_argument("--json", action="store_true", help="Output as JSON")
737
+
738
+ # migrate
739
+ p_mig = sub.add_parser("migrate", help="Upgrade pre-schema entries to schema v1")
740
+ p_mig.add_argument("--dry-run", action="store_true", dest="dry_run",
741
+ help="Show what would change without writing")
742
+
743
+ # uninit
744
+ sub.add_parser("uninit", help="Remove VICHAR injection from CLAUDE.md + .cursor/rules")
745
+
746
+ args = parser.parse_args()
747
+
748
+ dispatch = {
749
+ "init": cmd_init,
750
+ "capture": cmd_capture,
751
+ "ratify": cmd_ratify,
752
+ "close": cmd_close,
753
+ "log": cmd_log,
754
+ "status": cmd_status,
755
+ "migrate": cmd_migrate,
756
+ "uninit": cmd_uninit,
757
+ }
758
+
759
+ if args.command is None:
760
+ parser.print_help()
761
+ sys.exit(0)
762
+
763
+ fn = dispatch.get(args.command)
764
+ if fn is None:
765
+ parser.print_help()
766
+ sys.exit(1)
767
+
768
+ fn(args)
769
+
770
+
771
+ if __name__ == "__main__":
772
+ main()
vichar_server.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ VICHAR HTTP Server — run this so agents can POST without direct shell access.
4
+
5
+ Usage:
6
+ vichar-server [--port 7474] [--host 127.0.0.1]
7
+
8
+ Endpoints:
9
+ GET / status page (human-readable)
10
+ GET /status health check (JSON)
11
+ GET /log open entries as JSON (?type=decision&all=false)
12
+ POST /capture {"title": "...", "type": "decision", "body": "...", "author": "..."}
13
+ POST /ratify {"id": "abc12345", "by": "human"}
14
+ POST /close {"id": "abc12345"}
15
+
16
+ Agent snippet (zero dependencies):
17
+ import urllib.request, json
18
+ def vichar(title, type="decision", body="", author="agent"):
19
+ data = json.dumps({"title": title, "type": type, "body": body, "author": author}).encode()
20
+ req = urllib.request.Request("http://localhost:7474/capture", data=data,
21
+ headers={"Content-Type": "application/json"}, method="POST")
22
+ try:
23
+ with urllib.request.urlopen(req, timeout=2) as r: return json.loads(r.read())
24
+ except Exception: return None # never block on VICHAR
25
+ """
26
+
27
+ import argparse
28
+ import http.server
29
+ import json
30
+ import os
31
+ import sys
32
+ import urllib.parse
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ from vichar import (
36
+ find_root, load_project, load_entries, op_capture, op_ratify, op_close, op_log,
37
+ VERSION, GREEN, YELLOW, RED, CYAN, BOLD, DIM, RESET,
38
+ )
39
+
40
+ DEFAULT_PORT = 7474
41
+ DEFAULT_HOST = "127.0.0.1"
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Coloured terminal printing (server-side display only)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _print_event(icon, colour, entry_id, title, detail=""):
48
+ line = colour + icon + RESET + " [" + CYAN + entry_id + RESET + "] " + title[:72]
49
+ if detail:
50
+ line += " " + DIM + detail + RESET
51
+ print(line, flush=True)
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # HTTP handler
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class VICHARHandler(http.server.BaseHTTPRequestHandler):
58
+
59
+ def log_message(self, fmt, *a):
60
+ pass # suppress default access log
61
+
62
+ def send_json(self, code, payload):
63
+ body = json.dumps(payload, indent=2).encode()
64
+ self.send_response(code)
65
+ self.send_header("Content-Type", "application/json")
66
+ self.send_header("Content-Length", str(len(body)))
67
+ self.end_headers()
68
+ self.wfile.write(body)
69
+
70
+ def send_html(self, code, html):
71
+ body = html.encode()
72
+ self.send_response(code)
73
+ self.send_header("Content-Type", "text/html; charset=utf-8")
74
+ self.send_header("Content-Length", str(len(body)))
75
+ self.end_headers()
76
+ self.wfile.write(body)
77
+
78
+ def read_body(self):
79
+ length = int(self.headers.get("Content-Length", 0))
80
+ if length <= 0:
81
+ return dict()
82
+ raw = self.rfile.read(length)
83
+ if not raw:
84
+ return dict()
85
+ try:
86
+ return json.loads(raw)
87
+ except Exception:
88
+ return dict()
89
+
90
+ def do_GET(self):
91
+ parsed = urllib.parse.urlparse(self.path)
92
+ params = dict(urllib.parse.parse_qsl(parsed.query))
93
+ path = parsed.path
94
+
95
+ if path in ("/", ""):
96
+ proj = load_project(self.server.root)
97
+ entries = load_entries(self.server.root)
98
+ open_e = [e for e in entries if e.get("status") != "closed"]
99
+ proposed = [e for e in open_e if e.get("status") == "proposed"]
100
+
101
+ rows = ""
102
+ for e in open_e[:30]:
103
+ icon = "?" if e["status"] == "proposed" else ("V" if e["status"] == "ratified" else "o")
104
+ colour = "#f5a623" if e["status"] == "proposed" else ("#4caf50" if e["status"] == "ratified" else "#e53935")
105
+ rows += (
106
+ "<tr><td style='color:" + colour + "'>" + icon + "</td>"
107
+ "<td><code>" + e["id"] + "</code></td>"
108
+ "<td>" + e["type"] + "</td>"
109
+ "<td>" + e["title"][:80] + "</td></tr>\n"
110
+ )
111
+
112
+ html = (
113
+ "<!DOCTYPE html><html><head><meta charset=utf-8>"
114
+ "<title>VICHAR — " + proj["name"] + "</title>"
115
+ "<style>body{font-family:monospace;background:#111;color:#ccc;padding:2em}"
116
+ "h1{color:#fff}table{border-collapse:collapse;width:100%}"
117
+ "td,th{padding:.4em .8em;border-bottom:1px solid #333;text-align:left}"
118
+ "code{color:#7ec8e3}</style></head><body>"
119
+ "<h1>VICHAR</h1>"
120
+ "<p><b>" + proj["name"] + "</b> &nbsp;<code>" + proj["id"] + "</code></p>"
121
+ "<p>" + str(len(open_e)) + " open &nbsp; " + str(len(proposed)) + " proposed &nbsp; version " + VERSION + "</p>"
122
+ "<table><tr><th>st</th><th>id</th><th>type</th><th>title</th></tr>"
123
+ + rows +
124
+ "</table>"
125
+ "<hr><p style='color:#555'>POST /capture &nbsp; POST /ratify &nbsp; POST /close &nbsp; GET /log &nbsp; GET /status</p>"
126
+ "</body></html>"
127
+ )
128
+ self.send_html(200, html)
129
+ return
130
+
131
+ if path == "/status":
132
+ proj = load_project(self.server.root)
133
+ entries = load_entries(self.server.root)
134
+ open_e = [e for e in entries if e.get("status") != "closed"]
135
+ self.send_json(200, {
136
+ "status": "ok",
137
+ "project": proj["name"],
138
+ "id": proj["id"],
139
+ "version": VERSION,
140
+ "open": len(open_e),
141
+ "total": len(entries),
142
+ })
143
+ return
144
+
145
+ if path == "/log":
146
+ proj = load_project(self.server.root)
147
+ etype = params.get("type")
148
+ include_all = params.get("all", "false").lower() == "true"
149
+ entries = op_log(self.server.root, etype, include_all)
150
+ self.send_json(200, {"project": proj, "entries": entries, "count": len(entries)})
151
+ return
152
+
153
+ self.send_json(404, {"error": "not found", "path": path})
154
+
155
+ def do_POST(self):
156
+ data = self.read_body()
157
+ path = urllib.parse.urlparse(self.path).path
158
+
159
+ if path == "/capture":
160
+ title = data.get("title", "").strip()
161
+ if not title:
162
+ self.send_json(400, {"error": "title is required"})
163
+ return
164
+ entry = op_capture(
165
+ self.server.root, title,
166
+ data.get("type", "decision"),
167
+ data.get("body", ""),
168
+ data.get("author", "agent"),
169
+ )
170
+ _print_event("?", YELLOW, entry["id"], entry["title"],
171
+ "type=" + entry["type"] + " author=" + entry["author"])
172
+ self.send_json(201, {"id": entry["id"], "status": "proposed"})
173
+ return
174
+
175
+ if path == "/ratify":
176
+ eid = data.get("id", "").strip()
177
+ if not eid:
178
+ self.send_json(400, {"error": "id is required"})
179
+ return
180
+ entry, err = op_ratify(self.server.root, eid, data.get("by", "human"))
181
+ if not entry:
182
+ self.send_json(404, {"error": err})
183
+ return
184
+ if err == "already ratified":
185
+ self.send_json(200, {"id": entry["id"], "status": "ratified", "note": "already ratified"})
186
+ return
187
+ _print_event("V", GREEN, entry["id"], entry["title"],
188
+ "ratified_by=" + entry["ratified_by"])
189
+ self.send_json(200, {"id": entry["id"], "status": "ratified"})
190
+ return
191
+
192
+ if path == "/close":
193
+ eid = data.get("id", "").strip()
194
+ if not eid:
195
+ self.send_json(400, {"error": "id is required"})
196
+ return
197
+ entry, err = op_close(self.server.root, eid)
198
+ if not entry:
199
+ self.send_json(404, {"error": err})
200
+ return
201
+ _print_event("X", DIM, entry["id"], entry["title"], "closed")
202
+ self.send_json(200, {"id": entry["id"], "status": "closed"})
203
+ return
204
+
205
+ self.send_json(404, {"error": "not found", "path": path})
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Server entrypoint
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def main():
212
+ parser = argparse.ArgumentParser(description="VICHAR HTTP server")
213
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
214
+ parser.add_argument("--host", default=DEFAULT_HOST)
215
+ args = parser.parse_args()
216
+
217
+ root = find_root()
218
+ if not root:
219
+ print(RED + "No VICHAR project found. Run: vichar init" + RESET)
220
+ sys.exit(1)
221
+
222
+ proj = load_project(root)
223
+
224
+ # Port conflict detection
225
+ import socket
226
+ try:
227
+ test = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
228
+ test.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
229
+ test.bind((args.host, args.port))
230
+ test.close()
231
+ except OSError:
232
+ print(RED + "Port " + str(args.port) + " is already in use." + RESET)
233
+ print(DIM + " Is another vichar-server running? Try: lsof -i :" + str(args.port) + RESET)
234
+ sys.exit(1)
235
+
236
+ server = http.server.HTTPServer((args.host, args.port), VICHARHandler)
237
+ server.root = root
238
+
239
+ url = "http://" + args.host + ":" + str(args.port)
240
+ print("")
241
+ print(BOLD + " VICHAR server" + RESET + " " + CYAN + proj["name"] + RESET +
242
+ " " + DIM + proj["id"] + RESET)
243
+ print(" " + url)
244
+ print(DIM + " POST /capture POST /ratify POST /close GET /log GET /status" + RESET)
245
+ print(DIM + " Ctrl+C to stop" + RESET)
246
+ print("")
247
+
248
+ try:
249
+ server.serve_forever()
250
+ except KeyboardInterrupt:
251
+ print("\n" + DIM + "VICHAR server stopped." + RESET)
252
+
253
+
254
+ if __name__ == "__main__":
255
+ main()