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.
- vichar-0.1.0.dist-info/METADATA +191 -0
- vichar-0.1.0.dist-info/RECORD +8 -0
- vichar-0.1.0.dist-info/WHEEL +5 -0
- vichar-0.1.0.dist-info/entry_points.txt +3 -0
- vichar-0.1.0.dist-info/licenses/LICENSE +21 -0
- vichar-0.1.0.dist-info/top_level.txt +2 -0
- vichar.py +772 -0
- vichar_server.py +255 -0
|
@@ -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,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.
|
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> <code>" + proj["id"] + "</code></p>"
|
|
121
|
+
"<p>" + str(len(open_e)) + " open " + str(len(proposed)) + " proposed 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 POST /ratify POST /close GET /log 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()
|