dotagents-cli 0.3.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.
- dotagents/AGENTS.md +125 -0
- dotagents/__init__.py +3 -0
- dotagents/__main__.py +6 -0
- dotagents/_agents.py +627 -0
- dotagents/_context.py +311 -0
- dotagents/_env.py +600 -0
- dotagents/_hooks.py +172 -0
- dotagents/_merge.py +122 -0
- dotagents/_overlay/AGENTS.md +31 -0
- dotagents/_overlay/CLAUDE.md +3 -0
- dotagents/_overlay/README.md +41 -0
- dotagents/_overlay/dotagents/DECISIONS.md +20 -0
- dotagents/_overlay/dotagents/cmds/README.md +46 -0
- dotagents/_overlays.py +412 -0
- dotagents/_resolve.py +81 -0
- dotagents/_scope.py +265 -0
- dotagents/_skills.py +236 -0
- dotagents/_sync.py +72 -0
- dotagents/_wrappers.py +112 -0
- dotagents/cli/__init__.py +354 -0
- dotagents/cli/_common.py +331 -0
- dotagents/cli/build_pyz.py +107 -0
- dotagents/cli/context.py +129 -0
- dotagents/cli/env.py +168 -0
- dotagents/cli/init.py +117 -0
- dotagents/cli/overlays.py +366 -0
- dotagents_cli-0.3.0.dist-info/METADATA +291 -0
- dotagents_cli-0.3.0.dist-info/RECORD +31 -0
- dotagents_cli-0.3.0.dist-info/WHEEL +4 -0
- dotagents_cli-0.3.0.dist-info/entry_points.txt +2 -0
- dotagents_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
dotagents/_hooks.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Merge dotagents' hooks into an agent's ``settings.json`` without disturbing it.
|
|
2
|
+
|
|
3
|
+
An agent's settings file belongs to the **user**: it may carry unrelated keys and
|
|
4
|
+
hooks we did not write, in shapes we did not choose. So every function here is
|
|
5
|
+
additive and defensive -- foreign entries survive verbatim, malformed ones are
|
|
6
|
+
coerced or dropped rather than raising, and re-running is a no-op.
|
|
7
|
+
|
|
8
|
+
Idempotence is the property that matters: ``dotagents init`` is re-run often, and a
|
|
9
|
+
merge that appended a duplicate hook each time would quietly grow the file until
|
|
10
|
+
the agent ran our command N times per session.
|
|
11
|
+
|
|
12
|
+
The schema (verified against current Claude Code docs, 2026-07-24) is::
|
|
13
|
+
|
|
14
|
+
hooks: { "<Event>": [ { "matcher"?: str,
|
|
15
|
+
"hooks": [ {"type": "command", "command": str,
|
|
16
|
+
"statusMessage"?: str} ] } ] }
|
|
17
|
+
|
|
18
|
+
``hooks.<Event>`` is a **list of matcher-objects**, each holding its own ``hooks``
|
|
19
|
+
list -- not a flat list of commands. ``statusMessage`` is a supported key.
|
|
20
|
+
|
|
21
|
+
Pure stdlib (``json``/``pathlib``) so it works in a plain ``pip install`` and inside
|
|
22
|
+
the ``.pyz``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_hook_entry(
|
|
33
|
+
command: str,
|
|
34
|
+
*,
|
|
35
|
+
matcher: "Optional[str]" = None,
|
|
36
|
+
status_message: "Optional[str]" = None,
|
|
37
|
+
) -> "dict[str, Any]":
|
|
38
|
+
"""One matcher-object wrapping a single ``type: command`` hook.
|
|
39
|
+
|
|
40
|
+
``matcher``/``statusMessage`` are omitted entirely when None rather than
|
|
41
|
+
written as null -- an absent key is the documented "no matcher" form.
|
|
42
|
+
"""
|
|
43
|
+
hook: "dict[str, Any]" = {"type": "command", "command": command}
|
|
44
|
+
if status_message:
|
|
45
|
+
hook["statusMessage"] = status_message
|
|
46
|
+
entry: "dict[str, Any]" = {}
|
|
47
|
+
if matcher is not None:
|
|
48
|
+
entry["matcher"] = matcher
|
|
49
|
+
entry["hooks"] = [hook]
|
|
50
|
+
return entry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _has_status(entry: Any, status_message: str) -> bool:
|
|
54
|
+
"""True if `entry` carries a hook stamped with our `statusMessage`.
|
|
55
|
+
|
|
56
|
+
The status message is a stable label we choose, so it identifies our hook
|
|
57
|
+
across revisions of the command text -- unlike the command itself, which
|
|
58
|
+
changes and would leave the old version orphaned beside the new one.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(entry, dict):
|
|
61
|
+
return False
|
|
62
|
+
nested = entry.get("hooks")
|
|
63
|
+
if not isinstance(nested, list):
|
|
64
|
+
return False
|
|
65
|
+
return any(
|
|
66
|
+
isinstance(h, dict) and h.get("statusMessage") == status_message
|
|
67
|
+
for h in nested
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_ours(entry: Any, command: str) -> bool:
|
|
72
|
+
"""True if `entry` is a well-formed matcher-object containing our command."""
|
|
73
|
+
if not isinstance(entry, dict):
|
|
74
|
+
return False
|
|
75
|
+
nested = entry.get("hooks")
|
|
76
|
+
if not isinstance(nested, list):
|
|
77
|
+
return False
|
|
78
|
+
return any(
|
|
79
|
+
isinstance(h, dict) and h.get("command") == command for h in nested
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def merge_hook(
|
|
84
|
+
existing: Any,
|
|
85
|
+
command: str,
|
|
86
|
+
*,
|
|
87
|
+
matcher: "Optional[str]" = None,
|
|
88
|
+
status_message: "Optional[str]" = None,
|
|
89
|
+
) -> "tuple[list, bool]":
|
|
90
|
+
"""Fold our hook into `existing`, returning ``(normalized_list, changed)``.
|
|
91
|
+
|
|
92
|
+
Rules, in order:
|
|
93
|
+
|
|
94
|
+
* absent / not-a-list ``existing`` -> a fresh single-entry list (changed).
|
|
95
|
+
* an entry already carrying our exact ``command`` -> kept as-is; the first
|
|
96
|
+
such entry wins and any later duplicate is dropped (changed), so repeated
|
|
97
|
+
``init`` runs converge instead of accumulating.
|
|
98
|
+
* foreign entries -> preserved verbatim, untouched, in their original order.
|
|
99
|
+
* malformed entries (bare strings, dicts without a ``hooks`` list, non-dicts)
|
|
100
|
+
-> dropped, flagged changed. Never raises: a user's hand-edited settings
|
|
101
|
+
file must not make ``init`` explode.
|
|
102
|
+
|
|
103
|
+
``status_message`` doubles as our hook's IDENTITY. An entry carrying the same
|
|
104
|
+
status message is treated as an older shape of our own hook and replaced, not
|
|
105
|
+
left beside the new one. Without that, dedup is by exact command string, so
|
|
106
|
+
revising the command we write silently orphans the previous version and the
|
|
107
|
+
old (often broken) hook keeps running next to the new one. The status message
|
|
108
|
+
is a stable label we choose, which makes it a better key than the command text
|
|
109
|
+
it describes.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(existing, list):
|
|
112
|
+
# None/absent is the common case; a non-list is a malformed file we replace.
|
|
113
|
+
return [build_hook_entry(command, matcher=matcher, status_message=status_message)], True
|
|
114
|
+
|
|
115
|
+
normalized: list = []
|
|
116
|
+
changed = False
|
|
117
|
+
seen_ours = False
|
|
118
|
+
|
|
119
|
+
for entry in existing:
|
|
120
|
+
if _is_ours(entry, command):
|
|
121
|
+
if seen_ours:
|
|
122
|
+
changed = True # collapse duplicates of our own command
|
|
123
|
+
continue
|
|
124
|
+
seen_ours = True
|
|
125
|
+
normalized.append(entry)
|
|
126
|
+
continue
|
|
127
|
+
if status_message and _has_status(entry, status_message):
|
|
128
|
+
changed = True # an older shape of OUR hook -- replace, don't duplicate
|
|
129
|
+
continue
|
|
130
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
|
|
131
|
+
changed = True # malformed -- drop it
|
|
132
|
+
continue
|
|
133
|
+
normalized.append(entry) # foreign but well-formed: leave alone
|
|
134
|
+
|
|
135
|
+
if not seen_ours:
|
|
136
|
+
normalized.append(
|
|
137
|
+
build_hook_entry(command, matcher=matcher, status_message=status_message)
|
|
138
|
+
)
|
|
139
|
+
changed = True
|
|
140
|
+
|
|
141
|
+
return normalized, changed
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_settings(path: Path) -> "dict[str, Any]":
|
|
145
|
+
"""Read a settings.json, tolerating absence but never corruption.
|
|
146
|
+
|
|
147
|
+
A missing or empty file yields ``{}``. Invalid JSON raises ``SystemExit`` with
|
|
148
|
+
the path and parse error: silently starting from ``{}`` there would overwrite a
|
|
149
|
+
user's whole settings file on the next write.
|
|
150
|
+
"""
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
return {}
|
|
153
|
+
raw = path.read_text(encoding="utf-8").strip()
|
|
154
|
+
if not raw:
|
|
155
|
+
return {}
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(raw)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise SystemExit(
|
|
160
|
+
"error: %s is not valid JSON (%s). Fix or move it, then re-run." % (path, exc)
|
|
161
|
+
) from exc
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
raise SystemExit("error: %s must contain a JSON object, got %s" % (path, type(data).__name__))
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_settings(path: Path, data: "dict[str, Any]", *, dry_run: bool = False) -> None:
|
|
168
|
+
"""Write settings.json with a stable 2-space indent and trailing newline."""
|
|
169
|
+
if dry_run:
|
|
170
|
+
return
|
|
171
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
dotagents/_merge.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Managed-block merge for `init`'s AGENTS.md/CLAUDE.md (D-init-merge).
|
|
2
|
+
|
|
3
|
+
`init` must never clobber a user-customized AGENTS.md. Content owned by dotagents
|
|
4
|
+
is delimited by literal marker lines and treated as a block *within* the file:
|
|
5
|
+
create if missing, prepend if present-without-markers, refresh-in-place if the
|
|
6
|
+
markers are already there. Detection is by marker presence only, never by prose
|
|
7
|
+
matching, so it survives user reformatting.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
BEGIN_MARKER = "<!-- dotagents:begin -->"
|
|
15
|
+
END_MARKER = "<!-- dotagents:end -->"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_block(
|
|
19
|
+
text: str, begin_marker: str = BEGIN_MARKER, end_marker: str = END_MARKER
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Return the managed block's inner text (without the marker lines) from
|
|
22
|
+
a skeleton/template file that is itself fully marker-wrapped."""
|
|
23
|
+
start = text.index(begin_marker)
|
|
24
|
+
end = text.index(end_marker)
|
|
25
|
+
return text[start : end + len(end_marker)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _backup(target: Path, backup_root: Path) -> None:
|
|
29
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
shutil.copy2(target, backup_root / target.name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def merge_block(
|
|
34
|
+
target: Path,
|
|
35
|
+
block_source_text: str,
|
|
36
|
+
*,
|
|
37
|
+
force: bool = False,
|
|
38
|
+
dry_run: bool = False,
|
|
39
|
+
backup_root: "Path | None" = None,
|
|
40
|
+
begin_marker: str = BEGIN_MARKER,
|
|
41
|
+
end_marker: str = END_MARKER,
|
|
42
|
+
append: bool = False,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Merge `block_source_text` (a fully marker-wrapped skeleton file's
|
|
45
|
+
contents) into `target`, returning the branch taken:
|
|
46
|
+
"created" / "block-inserted" / "block-refreshed" / "replaced (--force, backed up)".
|
|
47
|
+
|
|
48
|
+
Never overwrites content outside the markers unless `force` is True (then
|
|
49
|
+
the whole file is replaced, after being backed up to `backup_root` if the
|
|
50
|
+
target pre-exists).
|
|
51
|
+
|
|
52
|
+
`begin_marker`/`end_marker` default to the Markdown/HTML comment pair; pass a
|
|
53
|
+
different pair for other comment syntaxes (e.g. `#`-prefixed markers for TOML).
|
|
54
|
+
|
|
55
|
+
`append` puts the block at the END of an existing file rather than the start.
|
|
56
|
+
Required for TOML: a `[table]` header captures every key line that follows it,
|
|
57
|
+
so prepending a table would silently swallow the user's existing top-level keys
|
|
58
|
+
into our table.
|
|
59
|
+
"""
|
|
60
|
+
block = _extract_block(block_source_text, begin_marker, end_marker)
|
|
61
|
+
|
|
62
|
+
if force:
|
|
63
|
+
existed = target.exists()
|
|
64
|
+
if existed and backup_root is not None:
|
|
65
|
+
if not dry_run:
|
|
66
|
+
_backup(target, backup_root)
|
|
67
|
+
if not dry_run:
|
|
68
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
target.write_text(block_source_text, encoding="utf-8")
|
|
70
|
+
return "replaced (--force, backed up)" if existed else "created"
|
|
71
|
+
|
|
72
|
+
if not target.exists():
|
|
73
|
+
if not dry_run:
|
|
74
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
target.write_text(block_source_text, encoding="utf-8")
|
|
76
|
+
return "created"
|
|
77
|
+
|
|
78
|
+
existing = target.read_text(encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
if begin_marker in existing and end_marker in existing:
|
|
81
|
+
start = existing.index(begin_marker)
|
|
82
|
+
end = existing.index(end_marker) + len(end_marker)
|
|
83
|
+
new_text = existing[:start] + block + existing[end:]
|
|
84
|
+
if new_text == existing:
|
|
85
|
+
return "skipped (present)"
|
|
86
|
+
if not dry_run:
|
|
87
|
+
target.write_text(new_text, encoding="utf-8")
|
|
88
|
+
return "block-refreshed"
|
|
89
|
+
|
|
90
|
+
if append:
|
|
91
|
+
sep = "" if existing.endswith("\n") else "\n"
|
|
92
|
+
new_text = existing + sep + "\n" + block + "\n"
|
|
93
|
+
else:
|
|
94
|
+
new_text = block + "\n\n" + existing
|
|
95
|
+
if not dry_run:
|
|
96
|
+
target.write_text(new_text, encoding="utf-8")
|
|
97
|
+
return "block-inserted"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def merge_claude_md(
|
|
101
|
+
target: Path,
|
|
102
|
+
block_source_text: str,
|
|
103
|
+
*,
|
|
104
|
+
force: bool = False,
|
|
105
|
+
dry_run: bool = False,
|
|
106
|
+
backup_root: "Path | None" = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Same managed-block rule, but for the CLAUDE.md one-liner: if the file
|
|
109
|
+
already contains an `@AGENTS.md` reference (any form), leave it untouched
|
|
110
|
+
(branch "skipped (present)") instead of requiring exact marker match --
|
|
111
|
+
a bare `@AGENTS.md` file written by hand still counts as "present"."""
|
|
112
|
+
if not force and target.exists():
|
|
113
|
+
existing = target.read_text(encoding="utf-8")
|
|
114
|
+
if "@AGENTS.md" in existing:
|
|
115
|
+
return "skipped (present)"
|
|
116
|
+
return merge_block(
|
|
117
|
+
target, block_source_text, force=force, dry_run=dry_run, backup_root=backup_root
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def timestamped_backup_root(dest: Path) -> Path:
|
|
122
|
+
return dest / "install_backup" / time.strftime("%Y%m%d-%H%M%S")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!-- dotagents:begin -->
|
|
2
|
+
# Agent Directives
|
|
3
|
+
|
|
4
|
+
Startup: annotate that you read `~/.agents/AGENTS.md`.
|
|
5
|
+
|
|
6
|
+
## Always-on rules
|
|
7
|
+
- **Permissions**: read any `AGENTS.md`; full read/write/create/delete inside any
|
|
8
|
+
`.agents/` directory and this config directory (`~/.agents` unless installed
|
|
9
|
+
elsewhere) — never ask.
|
|
10
|
+
- **Read the local `AGENTS.md`**: before working in a directory, read the ones
|
|
11
|
+
governing it — at each level from this global file down to that directory, any of
|
|
12
|
+
`AGENTS.md`, `AGENTS.local.md`, `.agents/AGENTS.md`, `.agents/AGENTS.local.md`.
|
|
13
|
+
Deeper extends/overrides broader; `.agents/` beats the plain file beside it, and
|
|
14
|
+
`.local` wins at its own level: it is the unshared override (machine- or
|
|
15
|
+
user-specific — either way, never committed or copied into a repo, plan, or shared
|
|
16
|
+
config). These files say what lives there and how to work in it, so reading them
|
|
17
|
+
first is cheaper than rediscovering it from the source. How `.agents/` is
|
|
18
|
+
populated — real directory, symlink, committed or not — is yours to decide.
|
|
19
|
+
- **Global-config misses**: if these instructions caused a mistake or rework, or you
|
|
20
|
+
have an improvement idea, drop a note in `~/.agents/dotagents/findings/` and move on
|
|
21
|
+
— don't edit the config. Triage later folds them into `.../dotagents/DECISIONS.md`
|
|
22
|
+
and moves each (never deletes) to `.../findings/processed/`.
|
|
23
|
+
- **This file**: everything between the `dotagents:begin`/`dotagents:end` markers is
|
|
24
|
+
managed — `dotagents init` refreshes it and leaves anything outside untouched. Add
|
|
25
|
+
your own rules and routing below the end marker, not inside.
|
|
26
|
+
|
|
27
|
+
## Load on demand
|
|
28
|
+
Read the matching file BEFORE such a task; skip it otherwise, never preemptively.
|
|
29
|
+
Nothing ships here by default — add one routing line per file as you grow this
|
|
30
|
+
config. A named agent with its own `~/.agents/<agent>.md` reads that too.
|
|
31
|
+
<!-- dotagents:end -->
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# dotagents skeleton — understanding the hierarchy
|
|
2
|
+
|
|
3
|
+
This is the minimal starter installed by `dotagents init`. It gives you the
|
|
4
|
+
scaffolding to build your own agent config from scratch — it intentionally carries
|
|
5
|
+
no opinionated flows, model-routing, or repo-standards content.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
- `AGENTS.md` — the root config, read by every agent session. Keep it lean: always-
|
|
10
|
+
on rules plus a "load on demand" routing list. Its content between the
|
|
11
|
+
`<!-- dotagents:begin -->` / `<!-- dotagents:end -->` markers is managed by
|
|
12
|
+
`dotagents init` — re-running `init` refreshes only that block, never touching
|
|
13
|
+
anything you add outside it.
|
|
14
|
+
- `CLAUDE.md` (and any other per-agent entry file, e.g. `ANTIGRAVITY.md`) — a
|
|
15
|
+
one-liner (`@AGENTS.md`) that points that agent runner at the shared root config.
|
|
16
|
+
Add one per agent runner you use; they all converge on the same `AGENTS.md`.
|
|
17
|
+
- `dotagents/DECISIONS.md` + `dotagents/decisions/` — a design log for this config
|
|
18
|
+
itself (why a rule exists, what changed and when), as a lean index + one file per
|
|
19
|
+
decision. Not loaded in normal sessions.
|
|
20
|
+
- `dotagents/findings/` — where agents drop short notes when the config caused a
|
|
21
|
+
mistake or gap, instead of editing the config mid-task. `dotagents/findings/
|
|
22
|
+
processed/` is where triaged notes land (moved, never deleted) once folded into
|
|
23
|
+
`dotagents/DECISIONS.md`.
|
|
24
|
+
|
|
25
|
+
## Growing your own config
|
|
26
|
+
|
|
27
|
+
As you build out routines you want agents to follow repeatedly (planning, execution,
|
|
28
|
+
code review, repo standards, language-specific conventions), put each in its own file
|
|
29
|
+
under `~/.agents/` and add one routing line to `AGENTS.md`'s "Load on demand" list —
|
|
30
|
+
loaded only when the task matches, never preemptively. This keeps every-session cost
|
|
31
|
+
low regardless of how much topical detail accumulates. How you group those files is
|
|
32
|
+
up to you; the overlays in this project happen to use `flows/` and `kb/`, but nothing
|
|
33
|
+
in `dotagents` requires that layout.
|
|
34
|
+
|
|
35
|
+
One filename *is* conventional: a named agent (Claude, Antigravity, …) reads its own
|
|
36
|
+
`~/.agents/<agent>.md` on top of this file, so per-runner directives go there.
|
|
37
|
+
|
|
38
|
+
If you'd rather start from a fuller, opinionated example than build from scratch, see
|
|
39
|
+
this project's opt-in overlays (`engineering` rules, planning/execution flows, language
|
|
40
|
+
knowledge bases, CI/reference templates) — layer them in deliberately with
|
|
41
|
+
`dotagents install --overlays <path>`, never wholesale.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Config Design Decisions — index
|
|
2
|
+
|
|
3
|
+
Not loaded in normal sessions. Read only when deliberately iterating on
|
|
4
|
+
`~/.agents/AGENTS.md` or its flows.
|
|
5
|
+
|
|
6
|
+
**How findings become config**: when an agent hits a mistake or gap caused by this
|
|
7
|
+
config, it drops a short note in `~/.agents/dotagents/findings/` instead of editing the
|
|
8
|
+
config mid-task. On request, triage that directory: fold each settled note into a
|
|
9
|
+
decision below, then **move** (never delete) the processed file to
|
|
10
|
+
`~/.agents/dotagents/findings/processed/`. This keeps the config's evolution auditable
|
|
11
|
+
without letting routine work rewrite always-on rules under time pressure.
|
|
12
|
+
|
|
13
|
+
**Structure** (mirrors the memory pattern — a lean index plus one file per entry, so a
|
|
14
|
+
session reads this short index and opens only what it needs): each decision is its own
|
|
15
|
+
file `decisions/D<nn>.md` with a one-line `description:` scan key; add one index line
|
|
16
|
+
here per decision. Ships empty — this is your log to grow.
|
|
17
|
+
|
|
18
|
+
## Decisions
|
|
19
|
+
|
|
20
|
+
_(none yet)_
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# `dotagents/cmds/` — your own commands go here
|
|
2
|
+
|
|
3
|
+
`init` creates this directory in your store (`<store>/dotagents/cmds/`). Any
|
|
4
|
+
`*.py` file you drop in it that defines a `duho` command class becomes a
|
|
5
|
+
`dotagents <name>` subcommand — no registration, no config.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# <store>/dotagents/cmds/hello.py
|
|
9
|
+
from duho import Cmd, LoggingArgs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Hello(LoggingArgs, Cmd):
|
|
13
|
+
"""Say hello."""
|
|
14
|
+
|
|
15
|
+
_parsername_ = "hello"
|
|
16
|
+
|
|
17
|
+
who: str = "world"
|
|
18
|
+
("--who",)
|
|
19
|
+
|
|
20
|
+
def __call__(self) -> int:
|
|
21
|
+
print("hello, %s" % self.who)
|
|
22
|
+
return 0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then `dotagents hello --who you`.
|
|
26
|
+
|
|
27
|
+
Files whose name starts with `_` are skipped by discovery — use that prefix for
|
|
28
|
+
shared helper modules.
|
|
29
|
+
|
|
30
|
+
## Precedence
|
|
31
|
+
|
|
32
|
+
Discovery layers sources so a later one overrides a same-named command:
|
|
33
|
+
|
|
34
|
+
built-ins < installed overlays' cmds/ < system < user < project
|
|
35
|
+
|
|
36
|
+
So a command you drop here (user scope) overrides one an overlay ships, and a
|
|
37
|
+
project's `.agents/dotagents/cmds/` overrides yours.
|
|
38
|
+
|
|
39
|
+
## dotagents itself ships none
|
|
40
|
+
|
|
41
|
+
This directory is intentionally empty in a fresh install (D85). `link`/`sync`
|
|
42
|
+
used to ship here; they are now `link-project`/`sync-project`, shipped by the
|
|
43
|
+
opt-in **private-sync** overlay together with their logic — so plain dotagents
|
|
44
|
+
carries no private-sync workflow. Install it with:
|
|
45
|
+
|
|
46
|
+
dotagents overlays add private-sync --source <overlays-checkout>
|