git-intent 0.3.1__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.
- git_intent-0.3.1.dist-info/METADATA +122 -0
- git_intent-0.3.1.dist-info/RECORD +15 -0
- git_intent-0.3.1.dist-info/WHEEL +5 -0
- git_intent-0.3.1.dist-info/entry_points.txt +2 -0
- git_intent-0.3.1.dist-info/licenses/LICENSE +21 -0
- git_intent-0.3.1.dist-info/top_level.txt +1 -0
- intent_cli/__init__.py +29 -0
- intent_cli/__main__.py +5 -0
- intent_cli/cli.py +146 -0
- intent_cli/constants.py +19 -0
- intent_cli/core.py +356 -0
- intent_cli/errors.py +33 -0
- intent_cli/git.py +83 -0
- intent_cli/helpers.py +25 -0
- intent_cli/store.py +101 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-intent
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Semantic history for agent-driven development. Records what you did and why.
|
|
5
|
+
Author: Zeng Deyang
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dozybot001/Intent
|
|
8
|
+
Project-URL: Repository, https://github.com/dozybot001/Intent
|
|
9
|
+
Keywords: agent,git,semantic-history,intent,developer-tools
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
English | [简体中文](README.CN.md)
|
|
26
|
+
|
|
27
|
+
# Intent
|
|
28
|
+
|
|
29
|
+
> Git records code changes. Intent records why.
|
|
30
|
+
|
|
31
|
+
## The Problem
|
|
32
|
+
|
|
33
|
+
Agent-driven development produces code fast, but reasoning disappears between sessions. Every new session starts from zero — the agent doesn't know what problem was being solved, what was tried, or why a path was chosen.
|
|
34
|
+
|
|
35
|
+
## What's Missing
|
|
36
|
+
|
|
37
|
+
Git records *what* changed. Commit messages and comments add some context. But three things consistently fall through:
|
|
38
|
+
|
|
39
|
+
**Goal continuity.** Commits are isolated snapshots. There's no structure connecting five commits to one task, or saying "this is what we're trying to accomplish."
|
|
40
|
+
|
|
41
|
+
**Decision rationale.** Why JWT over cookies? Why 15-minute expiry? This rarely makes it into commit messages — and when it does, it's unstructured text that agents must parse and guess from.
|
|
42
|
+
|
|
43
|
+
**Work state.** `git status` can be clean while a task is half-done. The next session has no signal that work was interrupted or what comes next.
|
|
44
|
+
|
|
45
|
+
## The Solution
|
|
46
|
+
|
|
47
|
+
Intent adds a `.intent/` directory to your repository — structured, machine-readable metadata that captures semantic history alongside code history.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
.git/ ← how code changed
|
|
51
|
+
.intent/ ← what you were doing and why
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Two objects: **intent** (the goal) and **snap** (a step taken, with rationale). All JSON. Any agent platform can read it.
|
|
55
|
+
|
|
56
|
+
### What changes
|
|
57
|
+
|
|
58
|
+
**Without `.intent/`** — new agent session opens. It reads `git log` and source code. Understands what the code does *now*, but doesn't know the JWT migration was for compliance (might revert it), doesn't know the refresh token is intentionally incomplete, can't tell there's unfinished work. Asks: *"What would you like me to do?"*
|
|
59
|
+
|
|
60
|
+
**With `.intent/`** — new agent session opens. Runs `itt inspect`. Sees an active intent ("Migrate auth to JWT"), last snap ("Add refresh token — incomplete"), and rationale ("token rotation not done, security priority"). Says: *"I'll implement the token rotation next."*
|
|
61
|
+
|
|
62
|
+
The difference: 10 seconds of reading structured metadata vs. minutes of re-explaining context.
|
|
63
|
+
|
|
64
|
+
## Core Loop
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
start → snap → done
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `start` — open an intent (what problem you're solving)
|
|
71
|
+
- `snap` — record a snap (what you did and why)
|
|
72
|
+
- `done` — close when complete
|
|
73
|
+
|
|
74
|
+
## Example
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
itt init
|
|
78
|
+
itt start "Fix login timeout"
|
|
79
|
+
itt snap "Increase timeout to 30s" -m "5s too short for slow networks"
|
|
80
|
+
git add . && git commit -m "fix timeout"
|
|
81
|
+
itt done
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Where This Is Going
|
|
85
|
+
|
|
86
|
+
`.intent/` is a protocol, not just a tool.
|
|
87
|
+
|
|
88
|
+
1. **Agent memory** — agents read `.intent/` on startup, recover last session's context in seconds
|
|
89
|
+
2. **Context exchange** — `.intent/` becomes the standard way to hand off work between agent platforms
|
|
90
|
+
3. **Network effects** — when enough repos contain `.intent/`, new tooling emerges: intent-aware review, decision archaeology, semantic dashboards
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install git-intent
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or from source:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git clone https://github.com/dozybot001/Intent.git && cd Intent
|
|
102
|
+
pip install -e .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Commands
|
|
106
|
+
|
|
107
|
+
| Command | Purpose |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `itt init` | Initialize `.intent/` in a Git repo |
|
|
110
|
+
| `itt start <title>` | Open an intent |
|
|
111
|
+
| `itt snap <title> [-m why]` | Record a snap |
|
|
112
|
+
| `itt done` | Close the active intent |
|
|
113
|
+
| `itt inspect` | Machine-readable workspace snapshot |
|
|
114
|
+
| `itt list <intent\|snap>` | List objects |
|
|
115
|
+
| `itt show <id>` | Show a single object |
|
|
116
|
+
| `itt adopt [id]` | Adopt a candidate snap |
|
|
117
|
+
| `itt revert` | Revert the latest snap |
|
|
118
|
+
|
|
119
|
+
## Documentation
|
|
120
|
+
|
|
121
|
+
- [CLI spec](docs/cli.EN.md) — objects, commands, JSON output contract
|
|
122
|
+
- [Agent integration](docs/agent-integration.md) — copy-paste snippets for Claude Code, Cursor, AGENTS.md
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
git_intent-0.3.1.dist-info/licenses/LICENSE,sha256=XWjTStLaoDw-UgLwMecejVxeaHH8JibnSFYARGzRc6I,1068
|
|
2
|
+
intent_cli/__init__.py,sha256=NvlYQNTOg7avKt8eRLOpsHudf6iMkkCFc6VvQ3fPbeM,766
|
|
3
|
+
intent_cli/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
4
|
+
intent_cli/cli.py,sha256=LIrUjS4vWA2b1HXZdp1O_HzCYU8HjF_y3lhR3gK2c0c,4773
|
|
5
|
+
intent_cli/constants.py,sha256=yOFDntL9kUqoB6ByKjUe5U6kcSVPn17nI2ywKWuuukM,301
|
|
6
|
+
intent_cli/core.py,sha256=9f8R0czttENL_sxxcBONMm3-yR-psWAcC5aH-06VFHI,12269
|
|
7
|
+
intent_cli/errors.py,sha256=ZmfE5cf07vjoHNSKvjEqxQN0YQ_09nXD9KB3W82-xu8,892
|
|
8
|
+
intent_cli/git.py,sha256=79eTLQoelp7X44nDam4l4gLmh73M7hcrofIWmG_VVfc,2402
|
|
9
|
+
intent_cli/helpers.py,sha256=b1MWHFv3BGLO99SNq-ejNLuwtHbxy9p3rlYJRjXrzI4,718
|
|
10
|
+
intent_cli/store.py,sha256=AUz1oJmZTZFgzWFubo13ZNXq4P_p9BbiTFjf-7BTNlA,3766
|
|
11
|
+
git_intent-0.3.1.dist-info/METADATA,sha256=mO7iQkBsi6G4zX-6myCQ076NV8XpUvNtebpQhfOQUJ0,4673
|
|
12
|
+
git_intent-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
git_intent-0.3.1.dist-info/entry_points.txt,sha256=Y1kziqgaGUgTHg3CCfc2Su1XoDvIMJ2vi-dPEDZfuTo,44
|
|
14
|
+
git_intent-0.3.1.dist-info/top_level.txt,sha256=jkyOMCXA-G6FlEj69GA4SKn3RoO1KNL9w2iit7OUpuU,11
|
|
15
|
+
git_intent-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zeng Deyang
|
|
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 @@
|
|
|
1
|
+
intent_cli
|
intent_cli/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Intent CLI package."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PACKAGE_NAME = "git-intent"
|
|
10
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
11
|
+
PYPROJECT_PATH = REPO_ROOT / "pyproject.toml"
|
|
12
|
+
VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def version_from_checkout() -> Optional[str]:
|
|
16
|
+
if not PYPROJECT_PATH.exists():
|
|
17
|
+
return None
|
|
18
|
+
match = VERSION_PATTERN.search(PYPROJECT_PATH.read_text(encoding="utf-8"))
|
|
19
|
+
if not match:
|
|
20
|
+
return None
|
|
21
|
+
return match.group(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__version__ = version_from_checkout()
|
|
25
|
+
if __version__ is None:
|
|
26
|
+
try:
|
|
27
|
+
__version__ = metadata.version(PACKAGE_NAME)
|
|
28
|
+
except metadata.PackageNotFoundError:
|
|
29
|
+
__version__ = "0.3.1"
|
intent_cli/__main__.py
ADDED
intent_cli/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .constants import EXIT_GENERAL_FAILURE, EXIT_SUCCESS
|
|
10
|
+
from .core import IntentRepository
|
|
11
|
+
from .errors import IntentError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def emit(payload: Dict[str, Any]) -> None:
|
|
15
|
+
print(json.dumps(payload, indent=2))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ok(action: str, result: Any, **extra: Any) -> Dict[str, Any]:
|
|
19
|
+
payload: Dict[str, Any] = {"ok": True, "action": action, "result": result}
|
|
20
|
+
payload.update(extra)
|
|
21
|
+
return payload
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="itt",
|
|
27
|
+
description="Intent CLI — semantic history for agents.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"intent-cli {__version__}")
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True, title="commands")
|
|
31
|
+
|
|
32
|
+
sub.add_parser("version", help="Show version")
|
|
33
|
+
|
|
34
|
+
sub.add_parser("init", help="Initialize Intent in the current Git repository")
|
|
35
|
+
|
|
36
|
+
start_p = sub.add_parser("start", help="Create and activate an intent")
|
|
37
|
+
start_p.add_argument("title")
|
|
38
|
+
|
|
39
|
+
snap_p = sub.add_parser("snap", help="Record a snap (adopted by default)")
|
|
40
|
+
snap_p.add_argument("title")
|
|
41
|
+
snap_p.add_argument("-m", "--message", help="Rationale for this snap")
|
|
42
|
+
snap_p.add_argument("--candidate", action="store_true", help="Record as candidate without adopting")
|
|
43
|
+
|
|
44
|
+
adopt_p = sub.add_parser("adopt", help="Adopt a candidate snap")
|
|
45
|
+
adopt_p.add_argument("snap_id", nargs="?")
|
|
46
|
+
adopt_p.add_argument("-m", "--message", help="Rationale for adoption")
|
|
47
|
+
|
|
48
|
+
revert_p = sub.add_parser("revert", help="Revert the latest adopted snap")
|
|
49
|
+
revert_p.add_argument("-m", "--message", help="Rationale for revert")
|
|
50
|
+
|
|
51
|
+
done_p = sub.add_parser("done", help="Close the active intent")
|
|
52
|
+
done_p.add_argument("intent_id", nargs="?")
|
|
53
|
+
|
|
54
|
+
sub.add_parser("inspect", help="Machine-readable workspace snapshot")
|
|
55
|
+
|
|
56
|
+
list_p = sub.add_parser("list", help="List objects")
|
|
57
|
+
list_p.add_argument("type", choices=["intent", "snap"])
|
|
58
|
+
|
|
59
|
+
show_p = sub.add_parser("show", help="Show a single object by ID")
|
|
60
|
+
show_p.add_argument("id")
|
|
61
|
+
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
66
|
+
parser = build_parser()
|
|
67
|
+
args = parser.parse_args(argv)
|
|
68
|
+
repo = IntentRepository(Path.cwd())
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if args.command == "version":
|
|
72
|
+
emit(ok("version", {"version": __version__}))
|
|
73
|
+
return EXIT_SUCCESS
|
|
74
|
+
|
|
75
|
+
if args.command == "init":
|
|
76
|
+
repo.ensure_git()
|
|
77
|
+
config, state = repo.init_workspace()
|
|
78
|
+
emit(ok("init", {"config": config, "state": state}))
|
|
79
|
+
return EXIT_SUCCESS
|
|
80
|
+
|
|
81
|
+
if args.command == "start":
|
|
82
|
+
intent, warnings = repo.create_intent(args.title)
|
|
83
|
+
emit(ok("start", intent, warnings=warnings))
|
|
84
|
+
return EXIT_SUCCESS
|
|
85
|
+
|
|
86
|
+
if args.command == "snap":
|
|
87
|
+
snap, warnings = repo.create_snap(
|
|
88
|
+
args.title,
|
|
89
|
+
rationale=args.message,
|
|
90
|
+
candidate=args.candidate,
|
|
91
|
+
)
|
|
92
|
+
emit(ok("snap", snap, warnings=warnings))
|
|
93
|
+
return EXIT_SUCCESS
|
|
94
|
+
|
|
95
|
+
if args.command == "adopt":
|
|
96
|
+
snap, warnings = repo.adopt_snap(
|
|
97
|
+
snap_id=args.snap_id,
|
|
98
|
+
rationale=args.message,
|
|
99
|
+
)
|
|
100
|
+
emit(ok("adopt", snap, warnings=warnings))
|
|
101
|
+
return EXIT_SUCCESS
|
|
102
|
+
|
|
103
|
+
if args.command == "revert":
|
|
104
|
+
snap, warnings = repo.revert_snap(rationale=args.message)
|
|
105
|
+
emit(ok("revert", snap, warnings=warnings))
|
|
106
|
+
return EXIT_SUCCESS
|
|
107
|
+
|
|
108
|
+
if args.command == "done":
|
|
109
|
+
intent, warnings = repo.close_intent(intent_id=args.intent_id)
|
|
110
|
+
emit(ok("done", intent, warnings=warnings))
|
|
111
|
+
return EXIT_SUCCESS
|
|
112
|
+
|
|
113
|
+
if args.command == "inspect":
|
|
114
|
+
emit(repo.inspect())
|
|
115
|
+
return EXIT_SUCCESS
|
|
116
|
+
|
|
117
|
+
if args.command == "list":
|
|
118
|
+
items = repo.list_objects(args.type)
|
|
119
|
+
emit(ok("list", items, count=len(items)))
|
|
120
|
+
return EXIT_SUCCESS
|
|
121
|
+
|
|
122
|
+
if args.command == "show":
|
|
123
|
+
obj = repo.show_object(args.id)
|
|
124
|
+
emit(ok("show", obj))
|
|
125
|
+
return EXIT_SUCCESS
|
|
126
|
+
|
|
127
|
+
parser.error("unknown command")
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
except IntentError as error:
|
|
131
|
+
emit(error.to_json())
|
|
132
|
+
return error.exit_code
|
|
133
|
+
except Exception as error:
|
|
134
|
+
emit({
|
|
135
|
+
"ok": False,
|
|
136
|
+
"error": {
|
|
137
|
+
"code": "INTERNAL_ERROR",
|
|
138
|
+
"message": str(error),
|
|
139
|
+
"details": {},
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
return EXIT_GENERAL_FAILURE
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|
intent_cli/constants.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SCHEMA_VERSION = "0.2"
|
|
4
|
+
|
|
5
|
+
EXIT_SUCCESS = 0
|
|
6
|
+
EXIT_GENERAL_FAILURE = 1
|
|
7
|
+
EXIT_INVALID_INPUT = 2
|
|
8
|
+
EXIT_STATE_CONFLICT = 3
|
|
9
|
+
EXIT_OBJECT_NOT_FOUND = 4
|
|
10
|
+
|
|
11
|
+
DIR_NAMES = {
|
|
12
|
+
"intent": "intents",
|
|
13
|
+
"snap": "snaps",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ID_PREFIXES = {
|
|
17
|
+
"intent": "intent",
|
|
18
|
+
"snap": "snap",
|
|
19
|
+
}
|
intent_cli/core.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .constants import EXIT_OBJECT_NOT_FOUND, EXIT_STATE_CONFLICT, SCHEMA_VERSION
|
|
7
|
+
from .errors import IntentError
|
|
8
|
+
from .git import build_git_context, ensure_git_worktree
|
|
9
|
+
from .helpers import object_sort_key, utc_now
|
|
10
|
+
from .store import IntentStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IntentRepository:
|
|
14
|
+
def __init__(self, cwd: Path) -> None:
|
|
15
|
+
self.cwd = cwd
|
|
16
|
+
self.store = IntentStore(cwd)
|
|
17
|
+
|
|
18
|
+
# --- guards ---
|
|
19
|
+
|
|
20
|
+
def ensure_git(self) -> None:
|
|
21
|
+
ensure_git_worktree(self.cwd)
|
|
22
|
+
|
|
23
|
+
def ensure_initialized(self) -> None:
|
|
24
|
+
self.store.ensure_initialized()
|
|
25
|
+
|
|
26
|
+
# --- init ---
|
|
27
|
+
|
|
28
|
+
def init_workspace(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
29
|
+
self.ensure_git()
|
|
30
|
+
return self.store.init_workspace()
|
|
31
|
+
|
|
32
|
+
# --- state helpers ---
|
|
33
|
+
|
|
34
|
+
def _load_state(self) -> Dict[str, Any]:
|
|
35
|
+
return self.store.load_state()
|
|
36
|
+
|
|
37
|
+
def _save_state(self, state: Dict[str, Any]) -> None:
|
|
38
|
+
self.store.save_state(state)
|
|
39
|
+
|
|
40
|
+
def _active_intent(self, state: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
41
|
+
state = state or self._load_state()
|
|
42
|
+
return self.store.load_object("intent", state.get("active_intent_id"))
|
|
43
|
+
|
|
44
|
+
def _require_active_intent(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
45
|
+
intent = self._active_intent(state)
|
|
46
|
+
if not intent:
|
|
47
|
+
raise IntentError(
|
|
48
|
+
EXIT_STATE_CONFLICT,
|
|
49
|
+
"STATE_CONFLICT",
|
|
50
|
+
"No active intent.",
|
|
51
|
+
suggested_fix='itt start "Describe the problem"',
|
|
52
|
+
)
|
|
53
|
+
return intent
|
|
54
|
+
|
|
55
|
+
def _candidate_snaps(self, intent_id: str) -> List[Dict[str, Any]]:
|
|
56
|
+
return sorted(
|
|
57
|
+
[
|
|
58
|
+
s for s in self.store.list_objects("snap")
|
|
59
|
+
if s.get("intent_id") == intent_id and s.get("status") == "candidate"
|
|
60
|
+
],
|
|
61
|
+
key=object_sort_key,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _latest_adopted(self, intent_id: str) -> Optional[Dict[str, Any]]:
|
|
65
|
+
adopted = [
|
|
66
|
+
s for s in self.store.list_objects("snap")
|
|
67
|
+
if s.get("intent_id") == intent_id and s.get("status") == "adopted"
|
|
68
|
+
]
|
|
69
|
+
if not adopted:
|
|
70
|
+
return None
|
|
71
|
+
return sorted(adopted, key=object_sort_key, reverse=True)[0]
|
|
72
|
+
|
|
73
|
+
def _derive_workspace_status(self, state: Dict[str, Any]) -> str:
|
|
74
|
+
intent = self._active_intent(state)
|
|
75
|
+
if not intent:
|
|
76
|
+
return "idle"
|
|
77
|
+
candidates = self._candidate_snaps(intent["id"])
|
|
78
|
+
if len(candidates) > 1:
|
|
79
|
+
return "conflict"
|
|
80
|
+
return "active"
|
|
81
|
+
|
|
82
|
+
# --- intent lifecycle ---
|
|
83
|
+
|
|
84
|
+
def create_intent(self, title: str) -> Tuple[Dict[str, Any], List[str]]:
|
|
85
|
+
self.ensure_git()
|
|
86
|
+
self.ensure_initialized()
|
|
87
|
+
state = self._load_state()
|
|
88
|
+
|
|
89
|
+
current = self._active_intent(state)
|
|
90
|
+
if current and current.get("status") == "open":
|
|
91
|
+
raise IntentError(
|
|
92
|
+
EXIT_STATE_CONFLICT,
|
|
93
|
+
"STATE_CONFLICT",
|
|
94
|
+
f"Intent '{current['id']}' is still open.",
|
|
95
|
+
suggested_fix="itt done",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
intent_id = self.store.next_id("intent")
|
|
99
|
+
now = utc_now()
|
|
100
|
+
intent = {
|
|
101
|
+
"id": intent_id,
|
|
102
|
+
"object": "intent",
|
|
103
|
+
"schema_version": SCHEMA_VERSION,
|
|
104
|
+
"created_at": now,
|
|
105
|
+
"updated_at": now,
|
|
106
|
+
"title": title,
|
|
107
|
+
"status": "open",
|
|
108
|
+
}
|
|
109
|
+
self.store.save_object("intent", intent)
|
|
110
|
+
|
|
111
|
+
state["active_intent_id"] = intent_id
|
|
112
|
+
state["workspace_status"] = "active"
|
|
113
|
+
self._save_state(state)
|
|
114
|
+
return intent, []
|
|
115
|
+
|
|
116
|
+
def close_intent(self, intent_id: Optional[str] = None) -> Tuple[Dict[str, Any], List[str]]:
|
|
117
|
+
self.ensure_git()
|
|
118
|
+
self.ensure_initialized()
|
|
119
|
+
state = self._load_state()
|
|
120
|
+
|
|
121
|
+
if intent_id:
|
|
122
|
+
intent = self.store.require_object("intent", intent_id)
|
|
123
|
+
else:
|
|
124
|
+
intent = self._require_active_intent(state)
|
|
125
|
+
|
|
126
|
+
if intent.get("status") == "done":
|
|
127
|
+
raise IntentError(
|
|
128
|
+
EXIT_STATE_CONFLICT,
|
|
129
|
+
"STATE_CONFLICT",
|
|
130
|
+
f"Intent '{intent['id']}' is already done.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
intent["status"] = "done"
|
|
134
|
+
intent["updated_at"] = utc_now()
|
|
135
|
+
self.store.save_object("intent", intent)
|
|
136
|
+
|
|
137
|
+
if intent["id"] == state.get("active_intent_id"):
|
|
138
|
+
state["active_intent_id"] = None
|
|
139
|
+
state["workspace_status"] = "idle"
|
|
140
|
+
self._save_state(state)
|
|
141
|
+
|
|
142
|
+
return intent, []
|
|
143
|
+
|
|
144
|
+
# --- snap lifecycle ---
|
|
145
|
+
|
|
146
|
+
def create_snap(
|
|
147
|
+
self,
|
|
148
|
+
title: str,
|
|
149
|
+
rationale: Optional[str] = None,
|
|
150
|
+
candidate: bool = False,
|
|
151
|
+
) -> Tuple[Dict[str, Any], List[str]]:
|
|
152
|
+
self.ensure_git()
|
|
153
|
+
self.ensure_initialized()
|
|
154
|
+
state = self._load_state()
|
|
155
|
+
intent = self._require_active_intent(state)
|
|
156
|
+
|
|
157
|
+
git_payload, warnings = build_git_context(self.cwd)
|
|
158
|
+
snap_id = self.store.next_id("snap")
|
|
159
|
+
now = utc_now()
|
|
160
|
+
status = "candidate" if candidate else "adopted"
|
|
161
|
+
snap = {
|
|
162
|
+
"id": snap_id,
|
|
163
|
+
"object": "snap",
|
|
164
|
+
"schema_version": SCHEMA_VERSION,
|
|
165
|
+
"created_at": now,
|
|
166
|
+
"updated_at": now,
|
|
167
|
+
"title": title,
|
|
168
|
+
"rationale": rationale or "",
|
|
169
|
+
"status": status,
|
|
170
|
+
"intent_id": intent["id"],
|
|
171
|
+
"git": git_payload,
|
|
172
|
+
}
|
|
173
|
+
self.store.save_object("snap", snap)
|
|
174
|
+
|
|
175
|
+
state["workspace_status"] = self._derive_workspace_status(state)
|
|
176
|
+
self._save_state(state)
|
|
177
|
+
return snap, warnings
|
|
178
|
+
|
|
179
|
+
def adopt_snap(
|
|
180
|
+
self,
|
|
181
|
+
snap_id: Optional[str] = None,
|
|
182
|
+
rationale: Optional[str] = None,
|
|
183
|
+
) -> Tuple[Dict[str, Any], List[str]]:
|
|
184
|
+
self.ensure_git()
|
|
185
|
+
self.ensure_initialized()
|
|
186
|
+
state = self._load_state()
|
|
187
|
+
intent = self._require_active_intent(state)
|
|
188
|
+
|
|
189
|
+
candidates = self._candidate_snaps(intent["id"])
|
|
190
|
+
|
|
191
|
+
if snap_id:
|
|
192
|
+
snap = self.store.require_object("snap", snap_id)
|
|
193
|
+
if snap.get("intent_id") != intent["id"]:
|
|
194
|
+
raise IntentError(
|
|
195
|
+
EXIT_STATE_CONFLICT,
|
|
196
|
+
"STATE_CONFLICT",
|
|
197
|
+
"Snap does not belong to the active intent.",
|
|
198
|
+
details={"snap_id": snap_id, "intent_id": intent["id"]},
|
|
199
|
+
)
|
|
200
|
+
if snap.get("status") != "candidate":
|
|
201
|
+
raise IntentError(
|
|
202
|
+
EXIT_STATE_CONFLICT,
|
|
203
|
+
"STATE_CONFLICT",
|
|
204
|
+
f"Snap '{snap_id}' is not a candidate.",
|
|
205
|
+
)
|
|
206
|
+
elif len(candidates) == 1:
|
|
207
|
+
snap = candidates[0]
|
|
208
|
+
elif len(candidates) == 0:
|
|
209
|
+
raise IntentError(
|
|
210
|
+
EXIT_STATE_CONFLICT,
|
|
211
|
+
"STATE_CONFLICT",
|
|
212
|
+
"No candidate snaps to adopt.",
|
|
213
|
+
suggested_fix='itt snap "Describe the step" --candidate',
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
raise IntentError(
|
|
217
|
+
EXIT_STATE_CONFLICT,
|
|
218
|
+
"STATE_CONFLICT",
|
|
219
|
+
"Multiple candidates exist. Specify which one to adopt.",
|
|
220
|
+
details={
|
|
221
|
+
"candidates": [{"id": c["id"], "title": c["title"]} for c in candidates],
|
|
222
|
+
},
|
|
223
|
+
suggested_fix=f"itt adopt {candidates[-1]['id']}",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
snap["status"] = "adopted"
|
|
227
|
+
if rationale:
|
|
228
|
+
snap["rationale"] = rationale
|
|
229
|
+
snap["updated_at"] = utc_now()
|
|
230
|
+
self.store.save_object("snap", snap)
|
|
231
|
+
|
|
232
|
+
state["workspace_status"] = self._derive_workspace_status(state)
|
|
233
|
+
self._save_state(state)
|
|
234
|
+
return snap, []
|
|
235
|
+
|
|
236
|
+
def revert_snap(self, rationale: Optional[str] = None) -> Tuple[Dict[str, Any], List[str]]:
|
|
237
|
+
self.ensure_git()
|
|
238
|
+
self.ensure_initialized()
|
|
239
|
+
state = self._load_state()
|
|
240
|
+
intent = self._require_active_intent(state)
|
|
241
|
+
|
|
242
|
+
latest = self._latest_adopted(intent["id"])
|
|
243
|
+
if not latest:
|
|
244
|
+
raise IntentError(
|
|
245
|
+
EXIT_STATE_CONFLICT,
|
|
246
|
+
"STATE_CONFLICT",
|
|
247
|
+
"No adopted snap to revert.",
|
|
248
|
+
suggested_fix='itt snap "Describe the step"',
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
latest["status"] = "reverted"
|
|
252
|
+
if rationale:
|
|
253
|
+
latest["rationale"] = rationale
|
|
254
|
+
latest["updated_at"] = utc_now()
|
|
255
|
+
self.store.save_object("snap", latest)
|
|
256
|
+
|
|
257
|
+
state["workspace_status"] = self._derive_workspace_status(state)
|
|
258
|
+
self._save_state(state)
|
|
259
|
+
return latest, []
|
|
260
|
+
|
|
261
|
+
# --- read ---
|
|
262
|
+
|
|
263
|
+
def inspect(self) -> Dict[str, Any]:
|
|
264
|
+
self.ensure_git()
|
|
265
|
+
self.ensure_initialized()
|
|
266
|
+
state = self._load_state()
|
|
267
|
+
intent = self._active_intent(state)
|
|
268
|
+
git_payload, git_warnings = build_git_context(self.cwd)
|
|
269
|
+
|
|
270
|
+
latest_snap = None
|
|
271
|
+
candidate_snaps: List[Dict[str, Any]] = []
|
|
272
|
+
if intent:
|
|
273
|
+
latest_snap = self._latest_adopted(intent["id"])
|
|
274
|
+
candidate_snaps = [
|
|
275
|
+
{"id": c["id"], "title": c["title"]}
|
|
276
|
+
for c in self._candidate_snaps(intent["id"])
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
workspace_status = self._derive_workspace_status(state)
|
|
280
|
+
if workspace_status != state.get("workspace_status"):
|
|
281
|
+
state["workspace_status"] = workspace_status
|
|
282
|
+
self._save_state(state)
|
|
283
|
+
|
|
284
|
+
def brief(obj: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
285
|
+
if not obj:
|
|
286
|
+
return None
|
|
287
|
+
return {k: obj[k] for k in ("id", "title", "status", "rationale") if k in obj}
|
|
288
|
+
|
|
289
|
+
action = self._next_action(intent, candidate_snaps)
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"ok": True,
|
|
293
|
+
"schema_version": SCHEMA_VERSION,
|
|
294
|
+
"workspace_status": workspace_status,
|
|
295
|
+
"intent": brief(intent),
|
|
296
|
+
"latest_snap": brief(latest_snap),
|
|
297
|
+
"candidate_snaps": candidate_snaps,
|
|
298
|
+
"suggested_next_action": action,
|
|
299
|
+
"git": {
|
|
300
|
+
"branch": git_payload["branch"],
|
|
301
|
+
"head": git_payload["head"],
|
|
302
|
+
"working_tree": git_payload["working_tree"],
|
|
303
|
+
},
|
|
304
|
+
"warnings": git_warnings,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def list_objects(self, object_name: str) -> List[Dict[str, Any]]:
|
|
308
|
+
self.ensure_git()
|
|
309
|
+
self.ensure_initialized()
|
|
310
|
+
if object_name not in ("intent", "snap"):
|
|
311
|
+
raise IntentError(
|
|
312
|
+
EXIT_STATE_CONFLICT,
|
|
313
|
+
"STATE_CONFLICT",
|
|
314
|
+
f"Unknown object type: {object_name}",
|
|
315
|
+
suggested_fix="Use 'intent' or 'snap'.",
|
|
316
|
+
)
|
|
317
|
+
return sorted(self.store.list_objects(object_name), key=object_sort_key, reverse=True)
|
|
318
|
+
|
|
319
|
+
def show_object(self, object_id: str) -> Dict[str, Any]:
|
|
320
|
+
self.ensure_git()
|
|
321
|
+
self.ensure_initialized()
|
|
322
|
+
object_name = self._type_from_id(object_id)
|
|
323
|
+
return self.store.require_object(object_name, object_id)
|
|
324
|
+
|
|
325
|
+
# --- internal ---
|
|
326
|
+
|
|
327
|
+
def _type_from_id(self, object_id: str) -> str:
|
|
328
|
+
if object_id.startswith("intent-"):
|
|
329
|
+
return "intent"
|
|
330
|
+
if object_id.startswith("snap-"):
|
|
331
|
+
return "snap"
|
|
332
|
+
raise IntentError(
|
|
333
|
+
EXIT_OBJECT_NOT_FOUND,
|
|
334
|
+
"OBJECT_NOT_FOUND",
|
|
335
|
+
f"Cannot determine type for id '{object_id}'.",
|
|
336
|
+
suggested_fix="Use a valid id like 'intent-001' or 'snap-001'.",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _next_action(
|
|
340
|
+
self,
|
|
341
|
+
intent: Optional[Dict[str, Any]],
|
|
342
|
+
candidates: List[Dict[str, Any]],
|
|
343
|
+
) -> Optional[Dict[str, Any]]:
|
|
344
|
+
if not intent or intent.get("status") != "open":
|
|
345
|
+
return {"command": "itt start 'Describe the problem'", "reason": "No active intent."}
|
|
346
|
+
if len(candidates) > 1:
|
|
347
|
+
return {
|
|
348
|
+
"command": f"itt adopt {candidates[-1]['id']}",
|
|
349
|
+
"reason": "Multiple candidates — pick one to adopt.",
|
|
350
|
+
}
|
|
351
|
+
if len(candidates) == 1:
|
|
352
|
+
return {
|
|
353
|
+
"command": f"itt adopt {candidates[0]['id']}",
|
|
354
|
+
"reason": "One candidate ready for adoption.",
|
|
355
|
+
}
|
|
356
|
+
return {"command": "itt snap 'Describe the step'", "reason": "Intent is active."}
|
intent_cli/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntentError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
exit_code: int,
|
|
10
|
+
code: str,
|
|
11
|
+
message: str,
|
|
12
|
+
details: Optional[Dict[str, Any]] = None,
|
|
13
|
+
suggested_fix: Optional[str] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.exit_code = exit_code
|
|
17
|
+
self.code = code
|
|
18
|
+
self.message = message
|
|
19
|
+
self.details = details or {}
|
|
20
|
+
self.suggested_fix = suggested_fix
|
|
21
|
+
|
|
22
|
+
def to_json(self) -> Dict[str, Any]:
|
|
23
|
+
payload = {
|
|
24
|
+
"ok": False,
|
|
25
|
+
"error": {
|
|
26
|
+
"code": self.code,
|
|
27
|
+
"message": self.message,
|
|
28
|
+
"details": self.details,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
if self.suggested_fix:
|
|
32
|
+
payload["error"]["suggested_fix"] = self.suggested_fix
|
|
33
|
+
return payload
|
intent_cli/git.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .constants import EXIT_GENERAL_FAILURE
|
|
8
|
+
from .errors import IntentError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
12
|
+
return subprocess.run(
|
|
13
|
+
["git", *args],
|
|
14
|
+
cwd=str(cwd),
|
|
15
|
+
check=False,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_git_worktree(cwd: Path) -> None:
|
|
22
|
+
result = run_git(cwd, "rev-parse", "--is-inside-work-tree")
|
|
23
|
+
if result.returncode != 0 or result.stdout.strip() != "true":
|
|
24
|
+
raise IntentError(
|
|
25
|
+
EXIT_GENERAL_FAILURE,
|
|
26
|
+
"GIT_STATE_INVALID",
|
|
27
|
+
"Intent requires a Git repository",
|
|
28
|
+
suggested_fix="git init",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def git_branch(cwd: Path) -> str:
|
|
33
|
+
result = run_git(cwd, "branch", "--show-current")
|
|
34
|
+
if result.returncode == 0:
|
|
35
|
+
value = result.stdout.strip()
|
|
36
|
+
if value:
|
|
37
|
+
return value
|
|
38
|
+
result = run_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
39
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
40
|
+
return result.stdout.strip()
|
|
41
|
+
return "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git_head(cwd: Path, ref: str = "HEAD") -> Optional[str]:
|
|
45
|
+
result = run_git(cwd, "rev-parse", "--short", ref)
|
|
46
|
+
if result.returncode == 0:
|
|
47
|
+
value = result.stdout.strip()
|
|
48
|
+
return value or None
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git_working_tree(cwd: Path) -> str:
|
|
53
|
+
result = run_git(cwd, "status", "--porcelain")
|
|
54
|
+
if result.returncode != 0:
|
|
55
|
+
return "unknown"
|
|
56
|
+
return "clean" if not result.stdout.strip() else "dirty"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_git_context(cwd: Path) -> Tuple[Dict[str, Any], List[str]]:
|
|
60
|
+
branch = git_branch(cwd)
|
|
61
|
+
working_tree = git_working_tree(cwd)
|
|
62
|
+
warnings: List[str] = []
|
|
63
|
+
|
|
64
|
+
head = git_head(cwd)
|
|
65
|
+
if head and working_tree == "clean":
|
|
66
|
+
linkage_quality = "stable_commit"
|
|
67
|
+
else:
|
|
68
|
+
linkage_quality = "working_tree_context"
|
|
69
|
+
if not head:
|
|
70
|
+
warnings.append("Git HEAD could not be resolved; recording working tree context only.")
|
|
71
|
+
|
|
72
|
+
if working_tree == "dirty":
|
|
73
|
+
warnings.append("Git working tree is dirty; recording working tree context.")
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
{
|
|
77
|
+
"branch": branch,
|
|
78
|
+
"head": head,
|
|
79
|
+
"working_tree": working_tree,
|
|
80
|
+
"linkage_quality": linkage_quality,
|
|
81
|
+
},
|
|
82
|
+
warnings,
|
|
83
|
+
)
|
intent_cli/helpers.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now() -> str:
|
|
10
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_json(path: Path) -> Dict[str, Any]:
|
|
14
|
+
return json.loads(path.read_text())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
18
|
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def object_sort_key(item: Dict[str, Any]) -> Tuple[str, int]:
|
|
22
|
+
object_id = item.get("id", "")
|
|
23
|
+
suffix = object_id.rsplit("-", 1)[-1]
|
|
24
|
+
number = int(suffix) if suffix.isdigit() else 0
|
|
25
|
+
return (item.get("created_at", ""), number)
|
intent_cli/store.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .constants import DIR_NAMES, EXIT_GENERAL_FAILURE, EXIT_OBJECT_NOT_FOUND, ID_PREFIXES, SCHEMA_VERSION
|
|
7
|
+
from .errors import IntentError
|
|
8
|
+
from .helpers import read_json, utc_now, write_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IntentStore:
|
|
12
|
+
def __init__(self, cwd: Path) -> None:
|
|
13
|
+
self.cwd = cwd
|
|
14
|
+
self.intent_dir = cwd / ".intent"
|
|
15
|
+
self.config_path = self.intent_dir / "config.json"
|
|
16
|
+
self.state_path = self.intent_dir / "state.json"
|
|
17
|
+
|
|
18
|
+
def is_initialized(self) -> bool:
|
|
19
|
+
return self.intent_dir.exists() and self.config_path.exists() and self.state_path.exists()
|
|
20
|
+
|
|
21
|
+
def ensure_initialized(self) -> None:
|
|
22
|
+
if not self.is_initialized():
|
|
23
|
+
raise IntentError(
|
|
24
|
+
EXIT_GENERAL_FAILURE,
|
|
25
|
+
"NOT_INITIALIZED",
|
|
26
|
+
"Intent is not initialized in this repository.",
|
|
27
|
+
suggested_fix="itt init",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def object_dir(self, object_name: str) -> Path:
|
|
31
|
+
return self.intent_dir / DIR_NAMES[object_name]
|
|
32
|
+
|
|
33
|
+
def init_workspace(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
34
|
+
if self.intent_dir.exists():
|
|
35
|
+
raise IntentError(
|
|
36
|
+
EXIT_GENERAL_FAILURE,
|
|
37
|
+
"ALREADY_EXISTS",
|
|
38
|
+
"Intent is already initialized in this repository.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.intent_dir.mkdir()
|
|
42
|
+
for dir_name in DIR_NAMES.values():
|
|
43
|
+
(self.intent_dir / dir_name).mkdir()
|
|
44
|
+
|
|
45
|
+
config: Dict[str, Any] = {"schema_version": SCHEMA_VERSION}
|
|
46
|
+
state: Dict[str, Any] = {
|
|
47
|
+
"schema_version": SCHEMA_VERSION,
|
|
48
|
+
"active_intent_id": None,
|
|
49
|
+
"workspace_status": "idle",
|
|
50
|
+
"updated_at": utc_now(),
|
|
51
|
+
}
|
|
52
|
+
write_json(self.config_path, config)
|
|
53
|
+
write_json(self.state_path, state)
|
|
54
|
+
return config, state
|
|
55
|
+
|
|
56
|
+
def load_state(self) -> Dict[str, Any]:
|
|
57
|
+
self.ensure_initialized()
|
|
58
|
+
return read_json(self.state_path)
|
|
59
|
+
|
|
60
|
+
def save_state(self, state: Dict[str, Any]) -> None:
|
|
61
|
+
state["updated_at"] = utc_now()
|
|
62
|
+
write_json(self.state_path, state)
|
|
63
|
+
|
|
64
|
+
def next_id(self, object_name: str) -> str:
|
|
65
|
+
directory = self.object_dir(object_name)
|
|
66
|
+
prefix = ID_PREFIXES[object_name]
|
|
67
|
+
max_index = 0
|
|
68
|
+
for path in directory.glob(f"{prefix}-*.json"):
|
|
69
|
+
suffix = path.stem[len(prefix) + 1:]
|
|
70
|
+
if suffix.isdigit():
|
|
71
|
+
max_index = max(max_index, int(suffix))
|
|
72
|
+
return f"{prefix}-{max_index + 1:03d}"
|
|
73
|
+
|
|
74
|
+
def save_object(self, object_name: str, payload: Dict[str, Any]) -> None:
|
|
75
|
+
path = self.object_dir(object_name) / f"{payload['id']}.json"
|
|
76
|
+
write_json(path, payload)
|
|
77
|
+
|
|
78
|
+
def load_object(self, object_name: str, object_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
79
|
+
if not object_id:
|
|
80
|
+
return None
|
|
81
|
+
path = self.object_dir(object_name) / f"{object_id}.json"
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return None
|
|
84
|
+
return read_json(path)
|
|
85
|
+
|
|
86
|
+
def require_object(self, object_name: str, object_id: str) -> Dict[str, Any]:
|
|
87
|
+
payload = self.load_object(object_name, object_id)
|
|
88
|
+
if payload is None:
|
|
89
|
+
raise IntentError(
|
|
90
|
+
EXIT_OBJECT_NOT_FOUND,
|
|
91
|
+
"OBJECT_NOT_FOUND",
|
|
92
|
+
f"{object_name.capitalize()} '{object_id}' was not found.",
|
|
93
|
+
details={"id": object_id, "object": object_name},
|
|
94
|
+
)
|
|
95
|
+
return payload
|
|
96
|
+
|
|
97
|
+
def list_objects(self, object_name: str) -> List[Dict[str, Any]]:
|
|
98
|
+
directory = self.object_dir(object_name)
|
|
99
|
+
if not directory.exists():
|
|
100
|
+
return []
|
|
101
|
+
return [read_json(path) for path in directory.glob("*.json")]
|