intent-cli-python 0.6.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intent-cli-python
3
- Version: 0.6.0
3
+ Version: 1.1.0
4
4
  Summary: Semantic history for agent-driven development. Records what you did and why.
5
5
  Author: Zeng Deyang
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dozybot001/Intent
8
8
  Project-URL: Repository, https://github.com/dozybot001/Intent
9
9
  Keywords: agent,git,semantic-history,intent,developer-tools
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Programming Language :: Python :: 3
@@ -30,6 +30,16 @@ Semantic history for agent-driven development. Records **what you did** and **wh
30
30
 
31
31
  Intent CLI gives AI agents a structured way to track goals, interactions, and decisions across sessions. Instead of losing context when a conversation ends, agents persist their understanding into three simple objects stored alongside your code.
32
32
 
33
+ ## Why
34
+
35
+ Git records how code changes. But it doesn't record **why you're on this path**, what you decided along the way, or where you left off.
36
+
37
+ Today that context lives in chat logs, PR threads, and your head. It works — until the session ends, the agent forgets, or a teammate picks up your work blind.
38
+
39
+ Intent treats these as a missing layer: **semantic history**. Not more docs, not better commit messages — a small set of formal objects that capture goals, interactions, and decisions so they survive context loss.
40
+
41
+ > The shift is simple: development is moving from *writing code* to *guiding agents and distilling decisions*. The history layer should reflect that.
42
+
33
43
  ## Three objects, one graph
34
44
 
35
45
  | Object | What it captures |
@@ -40,14 +50,40 @@ Intent CLI gives AI agents a structured way to track goals, interactions, and de
40
50
 
41
51
  Objects link automatically: creating an intent attaches all active decisions; creating a decision attaches all active intents. Relationships are always bidirectional and append-only.
42
52
 
53
+ ### How decisions are created
54
+
55
+ Decisions require human involvement. Two paths:
56
+
57
+ - **Explicit**: include `decision-[text]` (or `决定-[text]`) in your query and the agent creates it directly. E.g. "decision-all API responses use envelope format"
58
+ - **Agent-proposed**: the agent spots a potential long-term constraint in conversation and asks you to confirm before recording it
59
+
43
60
  ## Install
44
61
 
45
62
  ```bash
63
+ # Clone the repository
64
+ git clone https://github.com/dozybot001/Intent.git
65
+
66
+ # Install the CLI (pipx recommended)
67
+ pipx install intent-cli-python
68
+
69
+ # Or using pip
46
70
  pip install intent-cli-python
47
71
  ```
48
72
 
49
73
  Requires Python 3.9+ and Git.
50
74
 
75
+ ### Install the skills.sh skill
76
+
77
+ ```bash
78
+ npx skills add dozybot001/Intent -g
79
+ ```
80
+
81
+ This installs the `intent-cli` skill into your global skills library for supported agents such as Codex and Claude Code.
82
+
83
+ > **Tip:** `itt` is a new tool — current models have never seen it in training data. Your agent may forget to call it mid-conversation. A short nudge like *"use itt to record this"* is usually enough to bring it back on track.
84
+ >
85
+ > This isn't busywork — every record is a **semantic asset**. An upcoming platform, **IntHub**, will turn these assets into searchable, shareable project intelligence.
86
+
51
87
  ## Quick start
52
88
 
53
89
  ```bash
@@ -81,13 +117,14 @@ itt inspect
81
117
  | `itt version` | Print version |
82
118
  | `itt init` | Initialize `.intent/` in current git repo |
83
119
  | `itt inspect` | Show the live object graph snapshot |
120
+ | `itt doctor` | Validate the object graph for broken references and invalid states |
84
121
 
85
122
  ### Intent
86
123
 
87
124
  | Command | Description |
88
125
  |---|---|
89
126
  | `itt intent create TITLE --query Q` | Create a new intent |
90
- | `itt intent list [--status S]` | List intents |
127
+ | `itt intent list [--status S] [--decision ID]` | List intents |
91
128
  | `itt intent show ID` | Show intent details |
92
129
  | `itt intent activate ID` | Resume a suspended intent |
93
130
  | `itt intent suspend ID` | Suspend an active intent |
@@ -108,7 +145,7 @@ itt inspect
108
145
  | Command | Description |
109
146
  |---|---|
110
147
  | `itt decision create TITLE --rationale R` | Create a long-lived decision |
111
- | `itt decision list [--status S]` | List decisions |
148
+ | `itt decision list [--status S] [--intent ID]` | List decisions |
112
149
  | `itt decision show ID` | Show decision details |
113
150
  | `itt decision deprecate ID` | Deprecate a decision |
114
151
  | `itt decision attach ID --intent ID` | Manually link a decision to an intent |
@@ -136,6 +173,12 @@ All data lives in `.intent/` at your git repo root:
136
173
  decision-001.json
137
174
  ```
138
175
 
176
+ ## Docs
177
+
178
+ - [Vision](docs/EN/vision.md) — why semantic history matters. **If this project interests you, start here.**
179
+ - [CLI Design](docs/EN/cli.md) — object model, commands, JSON contract
180
+ - [Roadmap](docs/EN/roadmap.md) — phase plan
181
+
139
182
  ## License
140
183
 
141
184
  MIT
@@ -6,6 +6,16 @@ Semantic history for agent-driven development. Records **what you did** and **wh
6
6
 
7
7
  Intent CLI gives AI agents a structured way to track goals, interactions, and decisions across sessions. Instead of losing context when a conversation ends, agents persist their understanding into three simple objects stored alongside your code.
8
8
 
9
+ ## Why
10
+
11
+ Git records how code changes. But it doesn't record **why you're on this path**, what you decided along the way, or where you left off.
12
+
13
+ Today that context lives in chat logs, PR threads, and your head. It works — until the session ends, the agent forgets, or a teammate picks up your work blind.
14
+
15
+ Intent treats these as a missing layer: **semantic history**. Not more docs, not better commit messages — a small set of formal objects that capture goals, interactions, and decisions so they survive context loss.
16
+
17
+ > The shift is simple: development is moving from *writing code* to *guiding agents and distilling decisions*. The history layer should reflect that.
18
+
9
19
  ## Three objects, one graph
10
20
 
11
21
  | Object | What it captures |
@@ -16,14 +26,40 @@ Intent CLI gives AI agents a structured way to track goals, interactions, and de
16
26
 
17
27
  Objects link automatically: creating an intent attaches all active decisions; creating a decision attaches all active intents. Relationships are always bidirectional and append-only.
18
28
 
29
+ ### How decisions are created
30
+
31
+ Decisions require human involvement. Two paths:
32
+
33
+ - **Explicit**: include `decision-[text]` (or `决定-[text]`) in your query and the agent creates it directly. E.g. "decision-all API responses use envelope format"
34
+ - **Agent-proposed**: the agent spots a potential long-term constraint in conversation and asks you to confirm before recording it
35
+
19
36
  ## Install
20
37
 
21
38
  ```bash
39
+ # Clone the repository
40
+ git clone https://github.com/dozybot001/Intent.git
41
+
42
+ # Install the CLI (pipx recommended)
43
+ pipx install intent-cli-python
44
+
45
+ # Or using pip
22
46
  pip install intent-cli-python
23
47
  ```
24
48
 
25
49
  Requires Python 3.9+ and Git.
26
50
 
51
+ ### Install the skills.sh skill
52
+
53
+ ```bash
54
+ npx skills add dozybot001/Intent -g
55
+ ```
56
+
57
+ This installs the `intent-cli` skill into your global skills library for supported agents such as Codex and Claude Code.
58
+
59
+ > **Tip:** `itt` is a new tool — current models have never seen it in training data. Your agent may forget to call it mid-conversation. A short nudge like *"use itt to record this"* is usually enough to bring it back on track.
60
+ >
61
+ > This isn't busywork — every record is a **semantic asset**. An upcoming platform, **IntHub**, will turn these assets into searchable, shareable project intelligence.
62
+
27
63
  ## Quick start
28
64
 
29
65
  ```bash
@@ -57,13 +93,14 @@ itt inspect
57
93
  | `itt version` | Print version |
58
94
  | `itt init` | Initialize `.intent/` in current git repo |
59
95
  | `itt inspect` | Show the live object graph snapshot |
96
+ | `itt doctor` | Validate the object graph for broken references and invalid states |
60
97
 
61
98
  ### Intent
62
99
 
63
100
  | Command | Description |
64
101
  |---|---|
65
102
  | `itt intent create TITLE --query Q` | Create a new intent |
66
- | `itt intent list [--status S]` | List intents |
103
+ | `itt intent list [--status S] [--decision ID]` | List intents |
67
104
  | `itt intent show ID` | Show intent details |
68
105
  | `itt intent activate ID` | Resume a suspended intent |
69
106
  | `itt intent suspend ID` | Suspend an active intent |
@@ -84,7 +121,7 @@ itt inspect
84
121
  | Command | Description |
85
122
  |---|---|
86
123
  | `itt decision create TITLE --rationale R` | Create a long-lived decision |
87
- | `itt decision list [--status S]` | List decisions |
124
+ | `itt decision list [--status S] [--intent ID]` | List decisions |
88
125
  | `itt decision show ID` | Show decision details |
89
126
  | `itt decision deprecate ID` | Deprecate a decision |
90
127
  | `itt decision attach ID --intent ID` | Manually link a decision to an intent |
@@ -112,6 +149,12 @@ All data lives in `.intent/` at your git repo root:
112
149
  decision-001.json
113
150
  ```
114
151
 
152
+ ## Docs
153
+
154
+ - [Vision](docs/EN/vision.md) — why semantic history matters. **If this project interests you, start here.**
155
+ - [CLI Design](docs/EN/cli.md) — object model, commands, JSON contract
156
+ - [Roadmap](docs/EN/roadmap.md) — phase plan
157
+
115
158
  ## License
116
159
 
117
160
  MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "intent-cli-python"
7
- version = "0.6.0"
7
+ version = "1.1.0"
8
8
  description = "Semantic history for agent-driven development. Records what you did and why."
9
9
  requires-python = ">=3.9"
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ authors = [
14
14
  ]
15
15
  keywords = ["agent", "git", "semantic-history", "intent", "developer-tools"]
16
16
  classifiers = [
17
- "Development Status :: 4 - Beta",
17
+ "Development Status :: 5 - Production/Stable",
18
18
  "Environment :: Console",
19
19
  "Intended Audience :: Developers",
20
20
  "Programming Language :: Python :: 3",
@@ -0,0 +1,8 @@
1
+ """Intent CLI — semantic history for agent-driven development."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("intent-cli-python")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
@@ -5,14 +5,14 @@ import json
5
5
  import sys
6
6
  from datetime import datetime, timezone
7
7
 
8
+ from intent_cli import __version__
8
9
  from intent_cli.output import success, error
9
10
  from intent_cli.store import (
10
11
  git_root, ensure_init, init_workspace,
11
12
  next_id, read_object, write_object, list_objects, read_config,
13
+ validate_graph, VALID_STATUSES,
12
14
  )
13
15
 
14
- VERSION = "0.6.0"
15
-
16
16
 
17
17
  def _now():
18
18
  return datetime.now(timezone.utc).isoformat()
@@ -30,12 +30,25 @@ def _require_init():
30
30
  suggested_fix="itt init")
31
31
 
32
32
 
33
+ def _validate_status_filter(object_type, status):
34
+ """Validate a --status filter against the object's state machine."""
35
+ if status is None:
36
+ return
37
+ allowed = sorted(VALID_STATUSES[object_type])
38
+ if status not in allowed:
39
+ error(
40
+ "INVALID_INPUT",
41
+ f"Invalid status '{status}' for {object_type}. Allowed values: {', '.join(allowed)}.",
42
+ suggested_fix=f"Use one of: {', '.join(allowed)}",
43
+ )
44
+
45
+
33
46
  # ---------------------------------------------------------------------------
34
47
  # Global commands
35
48
  # ---------------------------------------------------------------------------
36
49
 
37
50
  def cmd_version(_args):
38
- success("version", {"version": VERSION})
51
+ success("version", {"version": __version__})
39
52
 
40
53
 
41
54
  def cmd_init(_args):
@@ -98,7 +111,7 @@ def cmd_inspect(_args):
98
111
 
99
112
  print(json.dumps({
100
113
  "ok": True,
101
- "schema_version": config.get("schema_version", "0.6"),
114
+ "schema_version": config.get("schema_version", "1.0"),
102
115
  "active_intents": active_intents,
103
116
  "suspend_intents": suspend_intents,
104
117
  "active_decisions": active_decisions,
@@ -107,6 +120,11 @@ def cmd_inspect(_args):
107
120
  }, indent=2, ensure_ascii=False))
108
121
 
109
122
 
123
+ def cmd_doctor(_args):
124
+ base = _require_init()
125
+ success("doctor", validate_graph(base))
126
+
127
+
110
128
  # ---------------------------------------------------------------------------
111
129
  # Intent commands
112
130
  # ---------------------------------------------------------------------------
@@ -129,6 +147,7 @@ def cmd_intent_create(args):
129
147
  "title": args.title,
130
148
  "status": "active",
131
149
  "source_query": args.query,
150
+ "rationale": args.rationale,
132
151
  "decision_ids": decision_ids,
133
152
  "snap_ids": [],
134
153
  }
@@ -144,7 +163,11 @@ def cmd_intent_create(args):
144
163
 
145
164
  def cmd_intent_list(args):
146
165
  base = _require_init()
147
- success("intent.list", list_objects(base, "intent", status=args.status))
166
+ _validate_status_filter("intent", args.status)
167
+ objects = list_objects(base, "intent", status=args.status)
168
+ if args.decision:
169
+ objects = [obj for obj in objects if args.decision in obj.get("decision_ids", [])]
170
+ success("intent.list", objects)
148
171
 
149
172
 
150
173
  def cmd_intent_show(args):
@@ -234,6 +257,7 @@ def cmd_snap_create(args):
234
257
  "status": "active",
235
258
  "intent_id": intent_id,
236
259
  "query": args.query,
260
+ "rationale": args.rationale,
237
261
  "summary": args.summary,
238
262
  "feedback": args.feedback,
239
263
  }
@@ -247,6 +271,7 @@ def cmd_snap_create(args):
247
271
 
248
272
  def cmd_snap_list(args):
249
273
  base = _require_init()
274
+ _validate_status_filter("snap", args.status)
250
275
  objects = list_objects(base, "snap", status=args.status)
251
276
  if args.intent:
252
277
  objects = [s for s in objects if s.get("intent_id") == args.intent]
@@ -322,7 +347,11 @@ def cmd_decision_create(args):
322
347
 
323
348
  def cmd_decision_list(args):
324
349
  base = _require_init()
325
- success("decision.list", list_objects(base, "decision", status=args.status))
350
+ _validate_status_filter("decision", args.status)
351
+ objects = list_objects(base, "decision", status=args.status)
352
+ if args.intent:
353
+ objects = [obj for obj in objects if args.intent in obj.get("intent_ids", [])]
354
+ success("decision.list", objects)
326
355
 
327
356
 
328
357
  def cmd_decision_show(args):
@@ -382,6 +411,7 @@ def main():
382
411
  sub.add_parser("version")
383
412
  sub.add_parser("init")
384
413
  sub.add_parser("inspect")
414
+ sub.add_parser("doctor")
385
415
 
386
416
  # --- intent ---
387
417
  p_intent = sub.add_parser("intent")
@@ -390,9 +420,11 @@ def main():
390
420
  p = s_intent.add_parser("create")
391
421
  p.add_argument("title")
392
422
  p.add_argument("--query", default="")
423
+ p.add_argument("--rationale", default="")
393
424
 
394
425
  p = s_intent.add_parser("list")
395
426
  p.add_argument("--status", default=None)
427
+ p.add_argument("--decision", default=None)
396
428
 
397
429
  p = s_intent.add_parser("show")
398
430
  p.add_argument("id")
@@ -414,6 +446,7 @@ def main():
414
446
  p.add_argument("title")
415
447
  p.add_argument("--intent", required=True)
416
448
  p.add_argument("--query", default="")
449
+ p.add_argument("--rationale", default="")
417
450
  p.add_argument("--summary", default="")
418
451
  p.add_argument("--feedback", default="")
419
452
 
@@ -441,6 +474,7 @@ def main():
441
474
 
442
475
  p = s_decision.add_parser("list")
443
476
  p.add_argument("--status", default=None)
477
+ p.add_argument("--intent", default=None)
444
478
 
445
479
  p = s_decision.add_parser("show")
446
480
  p.add_argument("id")
@@ -463,6 +497,7 @@ def main():
463
497
  "version": cmd_version,
464
498
  "init": cmd_init,
465
499
  "inspect": cmd_inspect,
500
+ "doctor": cmd_doctor,
466
501
  }
467
502
  if args.command in dispatch_global:
468
503
  dispatch_global[args.command](args)
@@ -0,0 +1,228 @@
1
+ """Storage layer — .intent/ directory I/O and ID generation."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ INTENT_DIR = ".intent"
8
+ SUBDIRS = {"intent": "intents", "snap": "snaps", "decision": "decisions"}
9
+ VALID_STATUSES = {
10
+ "intent": {"active", "suspend", "done"},
11
+ "snap": {"active", "reverted"},
12
+ "decision": {"active", "deprecated"},
13
+ }
14
+
15
+
16
+ def git_root():
17
+ """Return git repo root as Path, or None."""
18
+ try:
19
+ out = subprocess.run(
20
+ ["git", "rev-parse", "--show-toplevel"],
21
+ capture_output=True, text=True, check=True,
22
+ )
23
+ return Path(out.stdout.strip())
24
+ except (subprocess.CalledProcessError, FileNotFoundError):
25
+ return None
26
+
27
+
28
+ def intent_dir():
29
+ """Return Path to .intent/, or None if not in a git repo."""
30
+ root = git_root()
31
+ return root / INTENT_DIR if root else None
32
+
33
+
34
+ def ensure_init():
35
+ """Return .intent/ Path if initialized, else None."""
36
+ d = intent_dir()
37
+ return d if d and d.is_dir() else None
38
+
39
+
40
+ def init_workspace():
41
+ """Create .intent/ structure. Returns (path, error_code)."""
42
+ root = git_root()
43
+ if root is None:
44
+ return None, "GIT_STATE_INVALID"
45
+ d = root / INTENT_DIR
46
+ if d.is_dir():
47
+ return None, "ALREADY_EXISTS"
48
+ d.mkdir()
49
+ for sub in SUBDIRS.values():
50
+ (d / sub).mkdir()
51
+ (d / "config.json").write_text(json.dumps({"schema_version": "1.0"}, indent=2))
52
+ return d, None
53
+
54
+
55
+ def next_id(base, object_type):
56
+ """Generate next zero-padded ID for a given object type."""
57
+ subdir = base / SUBDIRS[object_type]
58
+ max_num = 0
59
+ for f in subdir.glob(f"{object_type}-*.json"):
60
+ try:
61
+ num = int(f.stem.split("-", 1)[1])
62
+ max_num = max(max_num, num)
63
+ except (ValueError, IndexError):
64
+ continue
65
+ return f"{object_type}-{max_num + 1:03d}"
66
+
67
+
68
+ def read_object(base, object_type, obj_id):
69
+ """Read object JSON by ID. Returns dict or None."""
70
+ path = base / SUBDIRS[object_type] / f"{obj_id}.json"
71
+ if not path.is_file():
72
+ return None
73
+ return json.loads(path.read_text())
74
+
75
+
76
+ def write_object(base, object_type, obj_id, data):
77
+ """Write object dict to JSON file."""
78
+ path = base / SUBDIRS[object_type] / f"{obj_id}.json"
79
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
80
+
81
+
82
+ def list_objects(base, object_type, status=None):
83
+ """List all objects of a type, optionally filtered by status."""
84
+ subdir = base / SUBDIRS[object_type]
85
+ result = []
86
+ for f in sorted(subdir.glob(f"{object_type}-*.json")):
87
+ obj = json.loads(f.read_text())
88
+ if status is None or obj.get("status") == status:
89
+ result.append(obj)
90
+ return result
91
+
92
+
93
+ def read_config(base):
94
+ """Read config.json. Returns dict."""
95
+ return json.loads((base / "config.json").read_text())
96
+
97
+
98
+ def validate_graph(base):
99
+ """Validate the object graph and return a structured report."""
100
+ config = read_config(base)
101
+ intents = {obj["id"]: obj for obj in list_objects(base, "intent")}
102
+ snaps = {obj["id"]: obj for obj in list_objects(base, "snap")}
103
+ decisions = {obj["id"]: obj for obj in list_objects(base, "decision")}
104
+ issues = []
105
+
106
+ if config.get("schema_version") != "1.0":
107
+ issues.append({
108
+ "code": "SCHEMA_VERSION_MISMATCH",
109
+ "object": "config",
110
+ "id": "config.json",
111
+ "message": f"Unsupported schema_version '{config.get('schema_version')}'. Expected '1.0'.",
112
+ })
113
+
114
+ def add_issue(code, object_type, obj_id, message):
115
+ issues.append({
116
+ "code": code,
117
+ "object": object_type,
118
+ "id": obj_id,
119
+ "message": message,
120
+ })
121
+
122
+ for object_type, objects in (
123
+ ("intent", intents),
124
+ ("snap", snaps),
125
+ ("decision", decisions),
126
+ ):
127
+ for obj_id, obj in objects.items():
128
+ if obj.get("object") != object_type:
129
+ add_issue(
130
+ "OBJECT_TYPE_MISMATCH",
131
+ object_type,
132
+ obj_id,
133
+ f"Stored object type is '{obj.get('object')}', expected '{object_type}'.",
134
+ )
135
+ status = obj.get("status")
136
+ if status not in VALID_STATUSES[object_type]:
137
+ add_issue(
138
+ "INVALID_STATUS",
139
+ object_type,
140
+ obj_id,
141
+ f"Invalid status '{status}' for {object_type}.",
142
+ )
143
+
144
+ for intent_id, intent in intents.items():
145
+ for snap_id in intent.get("snap_ids", []):
146
+ snap = snaps.get(snap_id)
147
+ if snap is None:
148
+ add_issue(
149
+ "MISSING_REFERENCE",
150
+ "intent",
151
+ intent_id,
152
+ f"References missing snap {snap_id} in snap_ids.",
153
+ )
154
+ continue
155
+ if snap.get("intent_id") != intent_id:
156
+ add_issue(
157
+ "BROKEN_LINK",
158
+ "intent",
159
+ intent_id,
160
+ f"Snap {snap_id} points to intent {snap.get('intent_id')}, not {intent_id}.",
161
+ )
162
+ for decision_id in intent.get("decision_ids", []):
163
+ decision = decisions.get(decision_id)
164
+ if decision is None:
165
+ add_issue(
166
+ "MISSING_REFERENCE",
167
+ "intent",
168
+ intent_id,
169
+ f"References missing decision {decision_id} in decision_ids.",
170
+ )
171
+ continue
172
+ if intent_id not in decision.get("intent_ids", []):
173
+ add_issue(
174
+ "BROKEN_LINK",
175
+ "intent",
176
+ intent_id,
177
+ f"Decision {decision_id} does not link back to this intent.",
178
+ )
179
+
180
+ for snap_id, snap in snaps.items():
181
+ intent_id = snap.get("intent_id")
182
+ intent = intents.get(intent_id)
183
+ if intent is None:
184
+ add_issue(
185
+ "MISSING_REFERENCE",
186
+ "snap",
187
+ snap_id,
188
+ f"Points to missing intent {intent_id}.",
189
+ )
190
+ continue
191
+ if snap_id not in intent.get("snap_ids", []):
192
+ add_issue(
193
+ "BROKEN_LINK",
194
+ "snap",
195
+ snap_id,
196
+ f"Intent {intent_id} does not include this snap in snap_ids.",
197
+ )
198
+
199
+ for decision_id, decision in decisions.items():
200
+ for intent_id in decision.get("intent_ids", []):
201
+ intent = intents.get(intent_id)
202
+ if intent is None:
203
+ add_issue(
204
+ "MISSING_REFERENCE",
205
+ "decision",
206
+ decision_id,
207
+ f"References missing intent {intent_id} in intent_ids.",
208
+ )
209
+ continue
210
+ if decision_id not in intent.get("decision_ids", []):
211
+ add_issue(
212
+ "BROKEN_LINK",
213
+ "decision",
214
+ decision_id,
215
+ f"Intent {intent_id} does not link back to this decision.",
216
+ )
217
+
218
+ return {
219
+ "healthy": not issues,
220
+ "issue_count": len(issues),
221
+ "summary": {
222
+ "schema_version": config.get("schema_version", "1.0"),
223
+ "intent_count": len(intents),
224
+ "snap_count": len(snaps),
225
+ "decision_count": len(decisions),
226
+ },
227
+ "issues": issues,
228
+ }
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intent-cli-python
3
- Version: 0.6.0
3
+ Version: 1.1.0
4
4
  Summary: Semantic history for agent-driven development. Records what you did and why.
5
5
  Author: Zeng Deyang
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dozybot001/Intent
8
8
  Project-URL: Repository, https://github.com/dozybot001/Intent
9
9
  Keywords: agent,git,semantic-history,intent,developer-tools
10
- Classifier: Development Status :: 4 - Beta
10
+ Classifier: Development Status :: 5 - Production/Stable
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Programming Language :: Python :: 3
@@ -30,6 +30,16 @@ Semantic history for agent-driven development. Records **what you did** and **wh
30
30
 
31
31
  Intent CLI gives AI agents a structured way to track goals, interactions, and decisions across sessions. Instead of losing context when a conversation ends, agents persist their understanding into three simple objects stored alongside your code.
32
32
 
33
+ ## Why
34
+
35
+ Git records how code changes. But it doesn't record **why you're on this path**, what you decided along the way, or where you left off.
36
+
37
+ Today that context lives in chat logs, PR threads, and your head. It works — until the session ends, the agent forgets, or a teammate picks up your work blind.
38
+
39
+ Intent treats these as a missing layer: **semantic history**. Not more docs, not better commit messages — a small set of formal objects that capture goals, interactions, and decisions so they survive context loss.
40
+
41
+ > The shift is simple: development is moving from *writing code* to *guiding agents and distilling decisions*. The history layer should reflect that.
42
+
33
43
  ## Three objects, one graph
34
44
 
35
45
  | Object | What it captures |
@@ -40,14 +50,40 @@ Intent CLI gives AI agents a structured way to track goals, interactions, and de
40
50
 
41
51
  Objects link automatically: creating an intent attaches all active decisions; creating a decision attaches all active intents. Relationships are always bidirectional and append-only.
42
52
 
53
+ ### How decisions are created
54
+
55
+ Decisions require human involvement. Two paths:
56
+
57
+ - **Explicit**: include `decision-[text]` (or `决定-[text]`) in your query and the agent creates it directly. E.g. "decision-all API responses use envelope format"
58
+ - **Agent-proposed**: the agent spots a potential long-term constraint in conversation and asks you to confirm before recording it
59
+
43
60
  ## Install
44
61
 
45
62
  ```bash
63
+ # Clone the repository
64
+ git clone https://github.com/dozybot001/Intent.git
65
+
66
+ # Install the CLI (pipx recommended)
67
+ pipx install intent-cli-python
68
+
69
+ # Or using pip
46
70
  pip install intent-cli-python
47
71
  ```
48
72
 
49
73
  Requires Python 3.9+ and Git.
50
74
 
75
+ ### Install the skills.sh skill
76
+
77
+ ```bash
78
+ npx skills add dozybot001/Intent -g
79
+ ```
80
+
81
+ This installs the `intent-cli` skill into your global skills library for supported agents such as Codex and Claude Code.
82
+
83
+ > **Tip:** `itt` is a new tool — current models have never seen it in training data. Your agent may forget to call it mid-conversation. A short nudge like *"use itt to record this"* is usually enough to bring it back on track.
84
+ >
85
+ > This isn't busywork — every record is a **semantic asset**. An upcoming platform, **IntHub**, will turn these assets into searchable, shareable project intelligence.
86
+
51
87
  ## Quick start
52
88
 
53
89
  ```bash
@@ -81,13 +117,14 @@ itt inspect
81
117
  | `itt version` | Print version |
82
118
  | `itt init` | Initialize `.intent/` in current git repo |
83
119
  | `itt inspect` | Show the live object graph snapshot |
120
+ | `itt doctor` | Validate the object graph for broken references and invalid states |
84
121
 
85
122
  ### Intent
86
123
 
87
124
  | Command | Description |
88
125
  |---|---|
89
126
  | `itt intent create TITLE --query Q` | Create a new intent |
90
- | `itt intent list [--status S]` | List intents |
127
+ | `itt intent list [--status S] [--decision ID]` | List intents |
91
128
  | `itt intent show ID` | Show intent details |
92
129
  | `itt intent activate ID` | Resume a suspended intent |
93
130
  | `itt intent suspend ID` | Suspend an active intent |
@@ -108,7 +145,7 @@ itt inspect
108
145
  | Command | Description |
109
146
  |---|---|
110
147
  | `itt decision create TITLE --rationale R` | Create a long-lived decision |
111
- | `itt decision list [--status S]` | List decisions |
148
+ | `itt decision list [--status S] [--intent ID]` | List decisions |
112
149
  | `itt decision show ID` | Show decision details |
113
150
  | `itt decision deprecate ID` | Deprecate a decision |
114
151
  | `itt decision attach ID --intent ID` | Manually link a decision to an intent |
@@ -136,6 +173,12 @@ All data lives in `.intent/` at your git repo root:
136
173
  decision-001.json
137
174
  ```
138
175
 
176
+ ## Docs
177
+
178
+ - [Vision](docs/EN/vision.md) — why semantic history matters. **If this project interests you, start here.**
179
+ - [CLI Design](docs/EN/cli.md) — object model, commands, JSON contract
180
+ - [Roadmap](docs/EN/roadmap.md) — phase plan
181
+
139
182
  ## License
140
183
 
141
184
  MIT
@@ -10,4 +10,5 @@ src/intent_cli_python.egg-info/PKG-INFO
10
10
  src/intent_cli_python.egg-info/SOURCES.txt
11
11
  src/intent_cli_python.egg-info/dependency_links.txt
12
12
  src/intent_cli_python.egg-info/entry_points.txt
13
- src/intent_cli_python.egg-info/top_level.txt
13
+ src/intent_cli_python.egg-info/top_level.txt
14
+ tests/test_cli.py
@@ -0,0 +1,368 @@
1
+ """Tests for Intent CLI — covers all 20 commands, state machines, and error codes."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from importlib import metadata
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+
14
+ @pytest.fixture
15
+ def workspace(tmp_path):
16
+ """Create a git repo with .intent/ initialized."""
17
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
18
+ subprocess.run(
19
+ ["git", "commit", "--allow-empty", "-m", "init"],
20
+ cwd=tmp_path, capture_output=True, check=True,
21
+ )
22
+ result = _run(tmp_path, "init")
23
+ assert result["ok"] is True
24
+ return tmp_path
25
+
26
+
27
+ def _run(cwd, *args):
28
+ """Run itt command and return parsed JSON."""
29
+ r = subprocess.run(
30
+ [sys.executable, "-m", "intent_cli", *args],
31
+ cwd=cwd, capture_output=True, text=True,
32
+ )
33
+ return json.loads(r.stdout)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Global commands
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class TestGlobal:
41
+ def test_version(self, workspace):
42
+ r = _run(workspace, "version")
43
+ assert r["ok"] is True
44
+ assert r["result"]["version"] == metadata.version("intent-cli-python")
45
+
46
+ def test_init_already_exists(self, workspace):
47
+ r = _run(workspace, "init")
48
+ assert r["ok"] is False
49
+ assert r["error"]["code"] == "ALREADY_EXISTS"
50
+
51
+ def test_init_not_git(self, tmp_path):
52
+ r = _run(tmp_path, "init")
53
+ assert r["ok"] is False
54
+ assert r["error"]["code"] == "GIT_STATE_INVALID"
55
+
56
+ def test_inspect_empty(self, workspace):
57
+ r = _run(workspace, "inspect")
58
+ assert r["ok"] is True
59
+ assert r["active_intents"] == []
60
+ assert r["active_decisions"] == []
61
+ assert r["recent_snaps"] == []
62
+
63
+ def test_not_initialized(self, tmp_path):
64
+ subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
65
+ subprocess.run(
66
+ ["git", "commit", "--allow-empty", "-m", "init"],
67
+ cwd=tmp_path, capture_output=True, check=True,
68
+ )
69
+ r = _run(tmp_path, "inspect")
70
+ assert r["ok"] is False
71
+ assert r["error"]["code"] == "NOT_INITIALIZED"
72
+
73
+ def test_doctor_healthy(self, workspace):
74
+ r = _run(workspace, "doctor")
75
+ assert r["ok"] is True
76
+ assert r["result"]["healthy"] is True
77
+ assert r["result"]["issue_count"] == 0
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Intent commands
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class TestIntent:
85
+ def test_create(self, workspace):
86
+ r = _run(workspace, "intent", "create", "Fix bug", "--query", "why crash?")
87
+ assert r["ok"] is True
88
+ assert r["result"]["id"] == "intent-001"
89
+ assert r["result"]["status"] == "active"
90
+ assert r["result"]["source_query"] == "why crash?"
91
+
92
+ def test_create_auto_attaches_decisions(self, workspace):
93
+ _run(workspace, "intent", "create", "Goal A", "--query", "q")
94
+ _run(workspace, "decision", "create", "Rule 1", "--rationale", "r")
95
+ r = _run(workspace, "intent", "create", "Goal B", "--query", "q")
96
+ assert "decision-001" in r["result"]["decision_ids"]
97
+
98
+ def test_list(self, workspace):
99
+ _run(workspace, "intent", "create", "A", "--query", "q")
100
+ _run(workspace, "intent", "create", "B", "--query", "q")
101
+ r = _run(workspace, "intent", "list")
102
+ assert len(r["result"]) == 2
103
+
104
+ def test_list_filter_status(self, workspace):
105
+ _run(workspace, "intent", "create", "A", "--query", "q")
106
+ _run(workspace, "intent", "create", "B", "--query", "q")
107
+ _run(workspace, "intent", "suspend", "intent-001")
108
+ r = _run(workspace, "intent", "list", "--status", "active")
109
+ assert len(r["result"]) == 1
110
+ assert r["result"][0]["id"] == "intent-002"
111
+
112
+ def test_list_filter_decision(self, workspace):
113
+ _run(workspace, "intent", "create", "A", "--query", "q")
114
+ _run(workspace, "intent", "create", "B", "--query", "q")
115
+ _run(workspace, "decision", "create", "Rule", "--rationale", "r")
116
+ _run(workspace, "decision", "deprecate", "decision-001")
117
+ _run(workspace, "decision", "create", "Rule 2", "--rationale", "r")
118
+ r = _run(workspace, "intent", "list", "--decision", "decision-001")
119
+ assert len(r["result"]) == 2
120
+
121
+ def test_list_invalid_status(self, workspace):
122
+ r = _run(workspace, "intent", "list", "--status", "paused")
123
+ assert r["ok"] is False
124
+ assert r["error"]["code"] == "INVALID_INPUT"
125
+
126
+ def test_show(self, workspace):
127
+ _run(workspace, "intent", "create", "A", "--query", "q")
128
+ r = _run(workspace, "intent", "show", "intent-001")
129
+ assert r["result"]["title"] == "A"
130
+
131
+ def test_show_not_found(self, workspace):
132
+ r = _run(workspace, "intent", "show", "intent-999")
133
+ assert r["error"]["code"] == "OBJECT_NOT_FOUND"
134
+
135
+ def test_suspend_activate(self, workspace):
136
+ _run(workspace, "intent", "create", "A", "--query", "q")
137
+ r = _run(workspace, "intent", "suspend", "intent-001")
138
+ assert r["result"]["status"] == "suspend"
139
+ r = _run(workspace, "intent", "activate", "intent-001")
140
+ assert r["result"]["status"] == "active"
141
+
142
+ def test_activate_catches_up_decisions(self, workspace):
143
+ _run(workspace, "intent", "create", "A", "--query", "q")
144
+ _run(workspace, "intent", "suspend", "intent-001")
145
+ _run(workspace, "decision", "create", "New rule", "--rationale", "r")
146
+ r = _run(workspace, "intent", "activate", "intent-001")
147
+ assert "decision-001" in r["result"]["decision_ids"]
148
+
149
+ def test_done(self, workspace):
150
+ _run(workspace, "intent", "create", "A", "--query", "q")
151
+ r = _run(workspace, "intent", "done", "intent-001")
152
+ assert r["result"]["status"] == "done"
153
+
154
+ def test_done_is_terminal(self, workspace):
155
+ _run(workspace, "intent", "create", "A", "--query", "q")
156
+ _run(workspace, "intent", "done", "intent-001")
157
+ r = _run(workspace, "intent", "activate", "intent-001")
158
+ assert r["error"]["code"] == "STATE_CONFLICT"
159
+
160
+ def test_suspend_only_active(self, workspace):
161
+ _run(workspace, "intent", "create", "A", "--query", "q")
162
+ _run(workspace, "intent", "done", "intent-001")
163
+ r = _run(workspace, "intent", "suspend", "intent-001")
164
+ assert r["error"]["code"] == "STATE_CONFLICT"
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Snap commands
169
+ # ---------------------------------------------------------------------------
170
+
171
+ class TestSnap:
172
+ def test_create(self, workspace):
173
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
174
+ r = _run(workspace, "snap", "create", "Did X", "--intent", "intent-001",
175
+ "--summary", "details")
176
+ assert r["ok"] is True
177
+ assert r["result"]["id"] == "snap-001"
178
+ assert r["result"]["intent_id"] == "intent-001"
179
+
180
+ def test_create_updates_intent_snap_ids(self, workspace):
181
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
182
+ _run(workspace, "snap", "create", "S1", "--intent", "intent-001")
183
+ _run(workspace, "snap", "create", "S2", "--intent", "intent-001")
184
+ r = _run(workspace, "intent", "show", "intent-001")
185
+ assert r["result"]["snap_ids"] == ["snap-001", "snap-002"]
186
+
187
+ def test_create_requires_active_intent(self, workspace):
188
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
189
+ _run(workspace, "intent", "done", "intent-001")
190
+ r = _run(workspace, "snap", "create", "S", "--intent", "intent-001")
191
+ assert r["error"]["code"] == "STATE_CONFLICT"
192
+
193
+ def test_create_intent_not_found(self, workspace):
194
+ r = _run(workspace, "snap", "create", "S", "--intent", "intent-999")
195
+ assert r["error"]["code"] == "OBJECT_NOT_FOUND"
196
+
197
+ def test_list(self, workspace):
198
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
199
+ _run(workspace, "snap", "create", "S1", "--intent", "intent-001")
200
+ _run(workspace, "snap", "create", "S2", "--intent", "intent-001")
201
+ r = _run(workspace, "snap", "list")
202
+ assert len(r["result"]) == 2
203
+
204
+ def test_list_filter_intent(self, workspace):
205
+ _run(workspace, "intent", "create", "A", "--query", "q")
206
+ _run(workspace, "intent", "create", "B", "--query", "q")
207
+ _run(workspace, "snap", "create", "S1", "--intent", "intent-001")
208
+ _run(workspace, "snap", "create", "S2", "--intent", "intent-002")
209
+ r = _run(workspace, "snap", "list", "--intent", "intent-002")
210
+ assert len(r["result"]) == 1
211
+ assert r["result"][0]["id"] == "snap-002"
212
+
213
+ def test_list_invalid_status(self, workspace):
214
+ r = _run(workspace, "snap", "list", "--status", "done")
215
+ assert r["ok"] is False
216
+ assert r["error"]["code"] == "INVALID_INPUT"
217
+
218
+ def test_feedback(self, workspace):
219
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
220
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
221
+ r = _run(workspace, "snap", "feedback", "snap-001", "looks good")
222
+ assert r["result"]["feedback"] == "looks good"
223
+
224
+ def test_feedback_overwrites(self, workspace):
225
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
226
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
227
+ _run(workspace, "snap", "feedback", "snap-001", "first")
228
+ r = _run(workspace, "snap", "feedback", "snap-001", "second")
229
+ assert r["result"]["feedback"] == "second"
230
+
231
+ def test_revert(self, workspace):
232
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
233
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
234
+ r = _run(workspace, "snap", "revert", "snap-001")
235
+ assert r["result"]["status"] == "reverted"
236
+
237
+ def test_revert_is_terminal(self, workspace):
238
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
239
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
240
+ _run(workspace, "snap", "revert", "snap-001")
241
+ r = _run(workspace, "snap", "revert", "snap-001")
242
+ assert r["error"]["code"] == "STATE_CONFLICT"
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Decision commands
247
+ # ---------------------------------------------------------------------------
248
+
249
+ class TestDecision:
250
+ def test_create(self, workspace):
251
+ r = _run(workspace, "decision", "create", "Rule", "--rationale", "reason")
252
+ assert r["ok"] is True
253
+ assert r["result"]["id"] == "decision-001"
254
+ assert r["result"]["status"] == "active"
255
+
256
+ def test_create_auto_attaches_intents(self, workspace):
257
+ _run(workspace, "intent", "create", "A", "--query", "q")
258
+ r = _run(workspace, "decision", "create", "Rule", "--rationale", "r")
259
+ assert "intent-001" in r["result"]["intent_ids"]
260
+ # Verify bidirectional
261
+ i = _run(workspace, "intent", "show", "intent-001")
262
+ assert "decision-001" in i["result"]["decision_ids"]
263
+
264
+ def test_list(self, workspace):
265
+ _run(workspace, "decision", "create", "R1", "--rationale", "r")
266
+ _run(workspace, "decision", "create", "R2", "--rationale", "r")
267
+ r = _run(workspace, "decision", "list")
268
+ assert len(r["result"]) == 2
269
+
270
+ def test_list_filter_intent(self, workspace):
271
+ _run(workspace, "intent", "create", "A", "--query", "q")
272
+ _run(workspace, "intent", "create", "B", "--query", "q")
273
+ _run(workspace, "decision", "create", "Rule A", "--rationale", "r")
274
+ _run(workspace, "decision", "deprecate", "decision-001")
275
+ _run(workspace, "intent", "done", "intent-002")
276
+ _run(workspace, "decision", "create", "Rule B", "--rationale", "r")
277
+ r = _run(workspace, "decision", "list", "--intent", "intent-002")
278
+ assert len(r["result"]) == 1
279
+ assert r["result"][0]["id"] == "decision-001"
280
+
281
+ def test_list_invalid_status(self, workspace):
282
+ r = _run(workspace, "decision", "list", "--status", "activeish")
283
+ assert r["ok"] is False
284
+ assert r["error"]["code"] == "INVALID_INPUT"
285
+
286
+ def test_deprecate(self, workspace):
287
+ _run(workspace, "decision", "create", "R", "--rationale", "r")
288
+ r = _run(workspace, "decision", "deprecate", "decision-001")
289
+ assert r["result"]["status"] == "deprecated"
290
+
291
+ def test_deprecate_is_terminal(self, workspace):
292
+ _run(workspace, "decision", "create", "R", "--rationale", "r")
293
+ _run(workspace, "decision", "deprecate", "decision-001")
294
+ r = _run(workspace, "decision", "deprecate", "decision-001")
295
+ assert r["error"]["code"] == "STATE_CONFLICT"
296
+
297
+ def test_deprecated_not_auto_attached(self, workspace):
298
+ _run(workspace, "decision", "create", "R", "--rationale", "r")
299
+ _run(workspace, "decision", "deprecate", "decision-001")
300
+ r = _run(workspace, "intent", "create", "New goal", "--query", "q")
301
+ assert "decision-001" not in r["result"]["decision_ids"]
302
+
303
+ def test_attach(self, workspace):
304
+ _run(workspace, "intent", "create", "A", "--query", "q")
305
+ _run(workspace, "intent", "create", "B", "--query", "q")
306
+ _run(workspace, "decision", "create", "R", "--rationale", "r")
307
+ # decision-001 auto-attached to both. Manually attach to verify idempotency.
308
+ r = _run(workspace, "decision", "attach", "decision-001", "--intent", "intent-001")
309
+ assert r["ok"] is True
310
+
311
+ def test_attach_not_found(self, workspace):
312
+ _run(workspace, "decision", "create", "R", "--rationale", "r")
313
+ r = _run(workspace, "decision", "attach", "decision-001", "--intent", "intent-999")
314
+ assert r["error"]["code"] == "OBJECT_NOT_FOUND"
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Inspect
319
+ # ---------------------------------------------------------------------------
320
+
321
+ class TestInspect:
322
+ def test_full_graph(self, workspace):
323
+ _run(workspace, "intent", "create", "Active", "--query", "q")
324
+ _run(workspace, "intent", "create", "Will suspend", "--query", "q")
325
+ _run(workspace, "intent", "suspend", "intent-002")
326
+ _run(workspace, "decision", "create", "Rule", "--rationale", "r")
327
+ _run(workspace, "snap", "create", "S1", "--intent", "intent-001",
328
+ "--summary", "did something")
329
+
330
+ r = _run(workspace, "inspect")
331
+ assert r["ok"] is True
332
+ assert len(r["active_intents"]) == 1
333
+ assert r["active_intents"][0]["id"] == "intent-001"
334
+ assert r["active_intents"][0]["latest_snap_id"] == "snap-001"
335
+ assert len(r["suspend_intents"]) == 1
336
+ assert r["suspend_intents"][0]["id"] == "intent-002"
337
+ assert len(r["active_decisions"]) == 1
338
+ assert len(r["recent_snaps"]) == 1
339
+
340
+ def test_orphan_snap_warning(self, workspace):
341
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
342
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
343
+ # Delete intent file to create orphan
344
+ intent_file = workspace / ".intent" / "intents" / "intent-001.json"
345
+ intent_file.unlink()
346
+ r = _run(workspace, "inspect")
347
+ assert any("Orphan" in w for w in r["warnings"])
348
+
349
+ def test_doctor_reports_broken_links(self, workspace):
350
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
351
+ _run(workspace, "snap", "create", "S", "--intent", "intent-001")
352
+ snap_file = workspace / ".intent" / "snaps" / "snap-001.json"
353
+ data = json.loads(snap_file.read_text())
354
+ data["intent_id"] = "intent-999"
355
+ snap_file.write_text(json.dumps(data, indent=2))
356
+ r = _run(workspace, "doctor")
357
+ assert r["result"]["healthy"] is False
358
+ assert any(issue["code"] == "MISSING_REFERENCE" for issue in r["result"]["issues"])
359
+
360
+ def test_doctor_reports_invalid_status(self, workspace):
361
+ _run(workspace, "intent", "create", "Goal", "--query", "q")
362
+ intent_file = workspace / ".intent" / "intents" / "intent-001.json"
363
+ data = json.loads(intent_file.read_text())
364
+ data["status"] = "paused"
365
+ intent_file.write_text(json.dumps(data, indent=2))
366
+ r = _run(workspace, "doctor")
367
+ assert r["result"]["healthy"] is False
368
+ assert any(issue["code"] == "INVALID_STATUS" for issue in r["result"]["issues"])
@@ -1 +0,0 @@
1
- """Intent CLI — semantic history for agent-driven development."""
@@ -1,90 +0,0 @@
1
- """Storage layer — .intent/ directory I/O and ID generation."""
2
-
3
- import json
4
- import subprocess
5
- from pathlib import Path
6
-
7
- INTENT_DIR = ".intent"
8
- SUBDIRS = {"intent": "intents", "snap": "snaps", "decision": "decisions"}
9
-
10
-
11
- def git_root():
12
- """Return git repo root as Path, or None."""
13
- try:
14
- out = subprocess.run(
15
- ["git", "rev-parse", "--show-toplevel"],
16
- capture_output=True, text=True, check=True,
17
- )
18
- return Path(out.stdout.strip())
19
- except (subprocess.CalledProcessError, FileNotFoundError):
20
- return None
21
-
22
-
23
- def intent_dir():
24
- """Return Path to .intent/, or None if not in a git repo."""
25
- root = git_root()
26
- return root / INTENT_DIR if root else None
27
-
28
-
29
- def ensure_init():
30
- """Return .intent/ Path if initialized, else None."""
31
- d = intent_dir()
32
- return d if d and d.is_dir() else None
33
-
34
-
35
- def init_workspace():
36
- """Create .intent/ structure. Returns (path, error_code)."""
37
- root = git_root()
38
- if root is None:
39
- return None, "GIT_STATE_INVALID"
40
- d = root / INTENT_DIR
41
- if d.is_dir():
42
- return None, "ALREADY_EXISTS"
43
- d.mkdir()
44
- for sub in SUBDIRS.values():
45
- (d / sub).mkdir()
46
- (d / "config.json").write_text(json.dumps({"schema_version": "0.6"}, indent=2))
47
- return d, None
48
-
49
-
50
- def next_id(base, object_type):
51
- """Generate next zero-padded ID for a given object type."""
52
- subdir = base / SUBDIRS[object_type]
53
- max_num = 0
54
- for f in subdir.glob(f"{object_type}-*.json"):
55
- try:
56
- num = int(f.stem.split("-", 1)[1])
57
- max_num = max(max_num, num)
58
- except (ValueError, IndexError):
59
- continue
60
- return f"{object_type}-{max_num + 1:03d}"
61
-
62
-
63
- def read_object(base, object_type, obj_id):
64
- """Read object JSON by ID. Returns dict or None."""
65
- path = base / SUBDIRS[object_type] / f"{obj_id}.json"
66
- if not path.is_file():
67
- return None
68
- return json.loads(path.read_text())
69
-
70
-
71
- def write_object(base, object_type, obj_id, data):
72
- """Write object dict to JSON file."""
73
- path = base / SUBDIRS[object_type] / f"{obj_id}.json"
74
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
75
-
76
-
77
- def list_objects(base, object_type, status=None):
78
- """List all objects of a type, optionally filtered by status."""
79
- subdir = base / SUBDIRS[object_type]
80
- result = []
81
- for f in sorted(subdir.glob(f"{object_type}-*.json")):
82
- obj = json.loads(f.read_text())
83
- if status is None or obj.get("status") == status:
84
- result.append(obj)
85
- return result
86
-
87
-
88
- def read_config(base):
89
- """Read config.json. Returns dict."""
90
- return json.loads((base / "config.json").read_text())