thisdamnthing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thisdamnthing/__init__.py +3 -0
- thisdamnthing/__main__.py +3 -0
- thisdamnthing/agents.py +167 -0
- thisdamnthing/bootstrap.py +360 -0
- thisdamnthing/brain.py +298 -0
- thisdamnthing/capabilities.py +273 -0
- thisdamnthing/capture.py +78 -0
- thisdamnthing/cli.py +269 -0
- thisdamnthing/constitution.py +153 -0
- thisdamnthing/history.py +147 -0
- thisdamnthing/hosts.py +71 -0
- thisdamnthing/marketplace.py +385 -0
- thisdamnthing/projects.py +153 -0
- thisdamnthing/resources/docs/README.md +46 -0
- thisdamnthing/resources/docs/agent-bootstrap.md +114 -0
- thisdamnthing/resources/docs/authoring.md +145 -0
- thisdamnthing/resources/docs/brain.md +129 -0
- thisdamnthing/resources/docs/commands.md +49 -0
- thisdamnthing/resources/docs/constitution.md +73 -0
- thisdamnthing/resources/docs/core-skills.md +17 -0
- thisdamnthing/resources/docs/getting-started.md +134 -0
- thisdamnthing/resources/docs/projects.md +55 -0
- thisdamnthing/resources/docs/skills.md +187 -0
- thisdamnthing/resources/docs/stacks.md +217 -0
- thisdamnthing/resources/docs/troubleshooting.md +60 -0
- thisdamnthing/resources/docs/ui.md +103 -0
- thisdamnthing/resources/docs/workspace-care.md +64 -0
- thisdamnthing/resources/harness/context.md +48 -0
- thisdamnthing/resources/harness/contracts/capture-event.md +38 -0
- thisdamnthing/resources/harness/contracts/stack.md +271 -0
- thisdamnthing/resources/harness/contracts/ui.md +122 -0
- thisdamnthing/resources/harness/hooks/capture.py +6 -0
- thisdamnthing/resources/harness/hooks/constitution.py +6 -0
- thisdamnthing/resources/harness/hooks/session-start.py +6 -0
- thisdamnthing/resources/harness/skills/tdt-add-project/SKILL.md +32 -0
- thisdamnthing/resources/harness/skills/tdt-constitution/SKILL.md +60 -0
- thisdamnthing/resources/harness/skills/tdt-find-skills/SKILL.md +39 -0
- thisdamnthing/resources/harness/skills/tdt-install-stack/SKILL.md +63 -0
- thisdamnthing/resources/harness/skills/tdt-remove-stack/SKILL.md +23 -0
- thisdamnthing/resources/harness/skills/tdt-review-brain/SKILL.md +27 -0
- thisdamnthing/resources/harness/skills/tdt-search/SKILL.md +27 -0
- thisdamnthing/resources/harness/skills/tdt-ui/SKILL.md +54 -0
- thisdamnthing/resources/harness/skills/tdt-update-stack/SKILL.md +37 -0
- thisdamnthing/resources/harness/skills/tdt-workspace/SKILL.md +20 -0
- thisdamnthing/resources/marketplace/manifest.schema.json +251 -0
- thisdamnthing/resources/marketplace/registry.schema.json +579 -0
- thisdamnthing/resources/ui/bridge.js +3 -0
- thisdamnthing/resources/ui/shell.html +1 -0
- thisdamnthing/resources/ui/shell.js +161 -0
- thisdamnthing/resources/ui/tokens.css +28 -0
- thisdamnthing/resources/workspace/README.md +30 -0
- thisdamnthing/resources/workspace/index.md +12 -0
- thisdamnthing/skills.py +279 -0
- thisdamnthing/stack_docs.py +97 -0
- thisdamnthing/stack_updates.py +108 -0
- thisdamnthing/stacks.py +443 -0
- thisdamnthing/ui.py +473 -0
- thisdamnthing/ui_resources.py +40 -0
- thisdamnthing/workspace.py +226 -0
- thisdamnthing-0.1.0.dist-info/METADATA +102 -0
- thisdamnthing-0.1.0.dist-info/RECORD +65 -0
- thisdamnthing-0.1.0.dist-info/WHEEL +5 -0
- thisdamnthing-0.1.0.dist-info/entry_points.txt +2 -0
- thisdamnthing-0.1.0.dist-info/licenses/LICENSE +202 -0
- thisdamnthing-0.1.0.dist-info/top_level.txt +1 -0
thisdamnthing/agents.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Workspace-local host selection and owner-preserving skill enablement."""
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .workspace import WorkspaceError, read_config, managed_path
|
|
6
|
+
from .bootstrap import PROVIDERS, digest, encode, existing_text, load_manifest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def detected():
|
|
10
|
+
"""Only executable discovery; never launch a host or inspect global settings."""
|
|
11
|
+
return [host for host in PROVIDERS if shutil.which(host)]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def enabled(root, config=None):
|
|
15
|
+
config = read_config(root) if config is None else config
|
|
16
|
+
selected = config.get('enabled_agents')
|
|
17
|
+
if selected is not None:
|
|
18
|
+
if (not isinstance(selected, list) or any(not isinstance(h, str) or h not in PROVIDERS for h in selected)
|
|
19
|
+
or len(set(selected)) != len(selected)):
|
|
20
|
+
raise WorkspaceError('Invalid enabled_agents in workspace config')
|
|
21
|
+
return selected
|
|
22
|
+
# Legacy consent comes only from ownership records, never from host folders.
|
|
23
|
+
from . import stacks, skills
|
|
24
|
+
manifest = load_manifest(root)
|
|
25
|
+
records = [manifest['files'], *(e['files'] for e in stacks.registry(root)),
|
|
26
|
+
*skills.state(root)['skills'].values()]
|
|
27
|
+
return [host for host, (instruction, folder, settings) in PROVIDERS.items()
|
|
28
|
+
if host in config['adapters'] or instruction in manifest['instructions']
|
|
29
|
+
or settings in manifest['hooks']
|
|
30
|
+
or any(any(p.startswith(folder + '/') for p in record) for record in records)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def folders(root):
|
|
34
|
+
return ['.tdt/skills', *(PROVIDERS[h][1] for h in enabled(root))]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def plan_skills(root, hosts):
|
|
38
|
+
"""Add bridges to each original owner's record; preflight before any write."""
|
|
39
|
+
from . import stacks, skills
|
|
40
|
+
skills.ready(root)
|
|
41
|
+
entries = stacks.available(root)
|
|
42
|
+
users = skills.state(root)
|
|
43
|
+
changes = {}
|
|
44
|
+
|
|
45
|
+
def add(name, record, content):
|
|
46
|
+
for host in hosts:
|
|
47
|
+
target = f'{PROVIDERS[host][1]}/{name}/SKILL.md'
|
|
48
|
+
if host == 'claude' and managed_path(root, f'.claude/commands/{name}.md').exists():
|
|
49
|
+
raise WorkspaceError(f'Claude command collision: {name}')
|
|
50
|
+
if target in record:
|
|
51
|
+
continue
|
|
52
|
+
if managed_path(root, str(Path(target).parent)).exists():
|
|
53
|
+
raise WorkspaceError(f'Skill directory collision: {target}')
|
|
54
|
+
if target in changes:
|
|
55
|
+
raise WorkspaceError(f'Duplicate skill ownership: {name}')
|
|
56
|
+
changes[target] = content
|
|
57
|
+
record[target] = digest(content)
|
|
58
|
+
|
|
59
|
+
for entry in entries:
|
|
60
|
+
stacks.check_owned(root, entry)
|
|
61
|
+
for relative in entry['manifest']['skills']:
|
|
62
|
+
name = relative.split('/')[1]
|
|
63
|
+
add(name, entry['files'], stacks.bridge(name))
|
|
64
|
+
for name, record in users['skills'].items():
|
|
65
|
+
for path, expected in record.items():
|
|
66
|
+
current = existing_text(root, path)
|
|
67
|
+
if current is None or digest(current) != expected:
|
|
68
|
+
raise WorkspaceError(f'Owned skill file edited or missing: {path}')
|
|
69
|
+
# Preserve the approved description exactly, including older serialized YAML.
|
|
70
|
+
canonical = existing_text(root, f'.tdt/skills/{name}/SKILL.md')
|
|
71
|
+
if canonical is None or '\n---\n' not in canonical[4:]:
|
|
72
|
+
raise WorkspaceError(f'Missing user skill front matter: {name}')
|
|
73
|
+
head = canonical[:canonical.index('\n---\n', 4) + 5]
|
|
74
|
+
add(name, record, head + '\n' +
|
|
75
|
+
f'Read and follow .tdt/skills/{name}/SKILL.md from the workspace root.\n')
|
|
76
|
+
if entries:
|
|
77
|
+
changes[stacks.REGISTRY] = encode(entries)
|
|
78
|
+
if users['skills']:
|
|
79
|
+
changes[skills.STATE] = encode(users)
|
|
80
|
+
return changes
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def enable(root, host):
|
|
84
|
+
from .workspace import initialize
|
|
85
|
+
initialize(root, host)
|
|
86
|
+
return f'Enabled {host} for all owned workspace skills. Start a new host session and review project trust and /hooks.'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def disable(root, host):
|
|
90
|
+
"""Remove only recorded host integrations, preserving canonical resources."""
|
|
91
|
+
from . import stacks, skills
|
|
92
|
+
from .brain import locked
|
|
93
|
+
from .bootstrap import BEGIN, END, MANIFEST, parse_settings
|
|
94
|
+
from .workspace import read_json
|
|
95
|
+
if host not in PROVIDERS:
|
|
96
|
+
raise WorkspaceError('Unknown agent selection')
|
|
97
|
+
with locked(root):
|
|
98
|
+
skills.ready(root)
|
|
99
|
+
entries = stacks.available(root)
|
|
100
|
+
users = skills.state(root)
|
|
101
|
+
config = read_config(root)
|
|
102
|
+
selected = enabled(root, config)
|
|
103
|
+
if host not in selected:
|
|
104
|
+
return f'{host.capitalize()} integration already disabled.'
|
|
105
|
+
state = load_manifest(root)
|
|
106
|
+
instruction, folder, settings = PROVIDERS[host]
|
|
107
|
+
changes = {}
|
|
108
|
+
records = [state['files'], *(e['files'] for e in entries), *users['skills'].values()]
|
|
109
|
+
for record in records:
|
|
110
|
+
for path, expected in list(record.items()):
|
|
111
|
+
if not path.startswith(folder + '/'):
|
|
112
|
+
continue
|
|
113
|
+
current = existing_text(root, path)
|
|
114
|
+
if current is None or digest(current) != expected:
|
|
115
|
+
raise WorkspaceError(f'Owned agent bridge edited or missing: {path}')
|
|
116
|
+
if path in changes:
|
|
117
|
+
raise WorkspaceError(f'Duplicate agent bridge ownership: {path}')
|
|
118
|
+
changes[path] = None
|
|
119
|
+
del record[path]
|
|
120
|
+
previous = state['instructions'].get(instruction)
|
|
121
|
+
if previous is not None:
|
|
122
|
+
current = existing_text(root, instruction)
|
|
123
|
+
if (not isinstance(previous, str) or current is None
|
|
124
|
+
or current.count(BEGIN) != 1 or current.count(END) != 1
|
|
125
|
+
or previous not in current):
|
|
126
|
+
raise WorkspaceError(f'Owned instruction block changed or missing: {instruction}')
|
|
127
|
+
remaining = current.replace(previous, '', 1)
|
|
128
|
+
changes[instruction] = remaining if remaining.strip() else None
|
|
129
|
+
del state['instructions'][instruction]
|
|
130
|
+
hook_records = (('SessionStart', 'hooks'), ('Stop', 'capture_hooks'),
|
|
131
|
+
('UserPromptSubmit', 'policy_hooks'))
|
|
132
|
+
if any(settings in state[key] for _, key in hook_records):
|
|
133
|
+
raw = existing_text(root, settings)
|
|
134
|
+
data = parse_settings(raw) if raw is not None else None
|
|
135
|
+
if not isinstance(data, dict) or not isinstance(data.get('hooks'), dict):
|
|
136
|
+
raise WorkspaceError(f'Owned hook settings missing or invalid: {settings}')
|
|
137
|
+
for event, key in hook_records:
|
|
138
|
+
previous = state[key].get(settings)
|
|
139
|
+
if previous is None:
|
|
140
|
+
continue
|
|
141
|
+
values = data['hooks'].get(event)
|
|
142
|
+
if not isinstance(values, list) or values.count(previous) != 1:
|
|
143
|
+
raise WorkspaceError(f'Owned {event} hook changed, duplicated or missing: {settings}')
|
|
144
|
+
values.remove(previous)
|
|
145
|
+
if not values:
|
|
146
|
+
del data['hooks'][event]
|
|
147
|
+
del state[key][settings]
|
|
148
|
+
if not data['hooks']:
|
|
149
|
+
del data['hooks']
|
|
150
|
+
# Settings have entry ownership, never whole-file ownership.
|
|
151
|
+
changes[settings] = encode(data)
|
|
152
|
+
config['enabled_agents'] = [h for h in selected if h != host]
|
|
153
|
+
config['adapters'].pop(host, None)
|
|
154
|
+
owned = read_json(root, '.tdt/state/owned-files.json')
|
|
155
|
+
if not isinstance(owned, list) or any(not isinstance(p, str) for p in owned):
|
|
156
|
+
raise WorkspaceError('Invalid owned-files list')
|
|
157
|
+
changes[MANIFEST] = encode(state)
|
|
158
|
+
changes['.tdt/config.json'] = encode(config)
|
|
159
|
+
changes['.tdt/state/owned-files.json'] = encode(sorted(set(owned) -
|
|
160
|
+
{p for p, value in changes.items() if value is None}))
|
|
161
|
+
if entries:
|
|
162
|
+
changes[stacks.REGISTRY] = encode(entries)
|
|
163
|
+
if users['skills']:
|
|
164
|
+
changes[skills.STATE] = encode(users)
|
|
165
|
+
stacks.transaction(root, changes)
|
|
166
|
+
stacks.prune(root, [p for p, value in changes.items() if value is None])
|
|
167
|
+
return f'Disabled {host} workspace integration. Start a new host session; canonical skills and brain are preserved.'
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Preflight and merge owned adapter entries without changing host permissions."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .workspace import WorkspaceError, managed_path, read_json, resource_text
|
|
11
|
+
|
|
12
|
+
MANIFEST = ".tdt/state/bootstrap.json"
|
|
13
|
+
PROVIDERS = {"claude": ("CLAUDE.md", ".claude/skills", ".claude/settings.json"),
|
|
14
|
+
"codex": ("AGENTS.md", ".agents/skills", ".codex/hooks.json")}
|
|
15
|
+
SKILLS = ("tdt-constitution", "tdt-workspace", "tdt-review-brain", "tdt-search", "tdt-add-project", "tdt-ui", "tdt-install-stack", "tdt-find-skills", "tdt-update-stack", "tdt-remove-stack")
|
|
16
|
+
LEGACY_SKILLS = {name.replace('tdt-', 'tdt.', 1): name for name in SKILLS}
|
|
17
|
+
LEGACY_SKILLS.update({'tdt.ask-brain': 'tdt-search', 'tdt-ask-brain': 'tdt-search'})
|
|
18
|
+
# Original core-only templates had no bootstrap ownership manifest. Adopt only
|
|
19
|
+
# these exact shipped bytes (or today's bytes), never arbitrary user edits.
|
|
20
|
+
LEGACY_STACK_DOCS = {
|
|
21
|
+
"docs/README.md": "1a61c9f1b17d09bd94ceeb794b7c69737096bfd12a86523e8b7e93b549fea759",
|
|
22
|
+
'.tdt/contracts/stack.md': '9bceaa545780b68c06aadfe4c32393e45f2a83e8d19428abf7162c9859892d6d',
|
|
23
|
+
'docs/stacks.md': 'aa6771c1535a4ebf6e227226e7b4b3933b587272a4d44726ab709b8e82840218',
|
|
24
|
+
}
|
|
25
|
+
RESOURCES = {
|
|
26
|
+
"docs/constitution.md": "docs/constitution.md",
|
|
27
|
+
".tdt/hooks/constitution.py": "harness/hooks/constitution.py",
|
|
28
|
+
".tdt/skills/tdt-constitution/SKILL.md": "harness/skills/tdt-constitution/SKILL.md",
|
|
29
|
+
**{f"docs/{name}.md": f"docs/{name}.md" for name in
|
|
30
|
+
("README", "getting-started", "troubleshooting", "authoring", "commands", "workspace-care", "core-skills")},
|
|
31
|
+
".tdt/contracts/stack.md": "harness/contracts/stack.md",
|
|
32
|
+
"docs/stacks.md": "docs/stacks.md",
|
|
33
|
+
**{f".tdt/skills/{name}/SKILL.md": f"harness/skills/{name}/SKILL.md"
|
|
34
|
+
for name in ("tdt-search", "tdt-add-project", "tdt-install-stack", "tdt-update-stack", "tdt-remove-stack")},
|
|
35
|
+
".tdt/skills/tdt-find-skills/SKILL.md": "harness/skills/tdt-find-skills/SKILL.md",
|
|
36
|
+
"docs/skills.md": "docs/skills.md",
|
|
37
|
+
"docs/projects.md": "docs/projects.md",
|
|
38
|
+
".tdt/hooks/capture.py": "harness/hooks/capture.py",
|
|
39
|
+
".tdt/skills/tdt-review-brain/SKILL.md": "harness/skills/tdt-review-brain/SKILL.md",
|
|
40
|
+
"docs/brain.md": "docs/brain.md",
|
|
41
|
+
".tdt/context.md": "harness/context.md",
|
|
42
|
+
".tdt/skills/tdt-workspace/SKILL.md": "harness/skills/tdt-workspace/SKILL.md",
|
|
43
|
+
".tdt/hooks/session-start.py": "harness/hooks/session-start.py",
|
|
44
|
+
".tdt/contracts/capture-event.md": "harness/contracts/capture-event.md",
|
|
45
|
+
"docs/agent-bootstrap.md": "docs/agent-bootstrap.md",
|
|
46
|
+
}
|
|
47
|
+
BEGIN, END = "<!-- tdt:begin -->", "<!-- tdt:end -->"
|
|
48
|
+
BLOCK = (BEGIN + "\nRead .tdt/context.md at session start. The canonical ThisDamnThing\n"
|
|
49
|
+
"skills and hooks live under .tdt/. Before each user request, run\n"
|
|
50
|
+
"`tdt constitution show` and follow the current workspace policy. Report\n"
|
|
51
|
+
"load failures before affected actions. This is a guidance-only fallback\n"
|
|
52
|
+
"when request hooks are unavailable. See docs/constitution.md and\n"
|
|
53
|
+
"docs/agent-bootstrap.md for host\n"
|
|
54
|
+
"invocation and trust requirements. This workspace starts with zero stacks.\n"
|
|
55
|
+
"Enable another host with `tdt agent enable claude` or `tdt agent enable codex`.\n"
|
|
56
|
+
+ END)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_settings(raw):
|
|
60
|
+
def unique(pairs):
|
|
61
|
+
result = {}
|
|
62
|
+
for key, value in pairs:
|
|
63
|
+
if key in result:
|
|
64
|
+
raise WorkspaceError(f"Duplicate provider JSON key: {key}")
|
|
65
|
+
result[key] = value
|
|
66
|
+
return result
|
|
67
|
+
return json.loads(raw, object_pairs_hook=unique)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def encode(value):
|
|
71
|
+
return json.dumps(value, indent=2) + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def digest(text):
|
|
75
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def existing_text(root, relative):
|
|
79
|
+
path = managed_path(root, relative)
|
|
80
|
+
for parent in path.parents:
|
|
81
|
+
if parent == root.parent:
|
|
82
|
+
break
|
|
83
|
+
if parent.exists() and not parent.is_dir():
|
|
84
|
+
raise WorkspaceError(f"Expected directory: {parent}")
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return None
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
raise WorkspaceError(f"Expected file: {relative}")
|
|
89
|
+
return path.read_bytes().decode("utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_manifest(root):
|
|
93
|
+
if not managed_path(root, MANIFEST).exists():
|
|
94
|
+
return {"format_version": 1, "files": {}, "instructions": {}, "hooks": {}, "capture_hooks": {}, "policy_hooks": {}}
|
|
95
|
+
data = read_json(root, MANIFEST)
|
|
96
|
+
if (not isinstance(data, dict) or data.get("format_version") != 1
|
|
97
|
+
or any(not isinstance(data.get(k), dict)
|
|
98
|
+
for k in ("files", "instructions", "hooks"))):
|
|
99
|
+
raise WorkspaceError("Invalid bootstrap ownership record")
|
|
100
|
+
data.setdefault("policy_hooks", {})
|
|
101
|
+
if not isinstance(data["policy_hooks"], dict) or set(data["policy_hooks"]) - {p[2] for p in PROVIDERS.values()}:
|
|
102
|
+
raise WorkspaceError("Invalid policy hook ownership record")
|
|
103
|
+
data.setdefault("capture_hooks", {})
|
|
104
|
+
if not isinstance(data["capture_hooks"], dict):
|
|
105
|
+
raise WorkspaceError("Invalid capture hook ownership record")
|
|
106
|
+
# Never use recorded paths to write arbitrary files.
|
|
107
|
+
allowed_files = {*RESOURCES, *(f"{p[1]}/{skill}/SKILL.md"
|
|
108
|
+
for p in PROVIDERS.values()
|
|
109
|
+
for skill in SKILLS)}
|
|
110
|
+
for old, new in LEGACY_SKILLS.items():
|
|
111
|
+
allowed_files.update(path.replace('/' + new + '/', '/' + old + '/')
|
|
112
|
+
for path in tuple(allowed_files) if '/' + new + '/' in path)
|
|
113
|
+
if (set(data["files"]) - allowed_files
|
|
114
|
+
or set(data["instructions"]) - {"README.md", *(p[0] for p in PROVIDERS.values())}
|
|
115
|
+
or set(data["hooks"]) - {p[2] for p in PROVIDERS.values()}
|
|
116
|
+
or set(data["capture_hooks"]) - {p[2] for p in PROVIDERS.values()}):
|
|
117
|
+
raise WorkspaceError("Unexpected paths in bootstrap ownership record")
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def plan_bootstrap(root, agent, config, owned):
|
|
122
|
+
"""Return all changes only after every requested path and merge is checked."""
|
|
123
|
+
from .agents import enabled
|
|
124
|
+
selected = enabled(root, config) if managed_path(root, ".tdt/config.json").exists() else []
|
|
125
|
+
requested = list(PROVIDERS) if agent == "both" else ([agent] if agent not in (None, "none") else [])
|
|
126
|
+
selected = list(dict.fromkeys([*selected, *requested]))
|
|
127
|
+
state = load_manifest(root)
|
|
128
|
+
pending = {}
|
|
129
|
+
generated = {target: resource_text(source) for target, source in RESOURCES.items()}
|
|
130
|
+
config = deepcopy(config)
|
|
131
|
+
config["enabled_agents"] = selected
|
|
132
|
+
readme = existing_text(root, "README.md") or ""
|
|
133
|
+
previous_readme = state["instructions"].get("README.md")
|
|
134
|
+
guidance = BEGIN + "\n" + resource_text("workspace/README.md").rstrip() + "\n" + END
|
|
135
|
+
if previous_readme is not None:
|
|
136
|
+
if (not isinstance(previous_readme, str) or readme.count(BEGIN) != 1
|
|
137
|
+
or readme.count(END) != 1 or previous_readme not in readme):
|
|
138
|
+
raise WorkspaceError("Owned README block changed or missing")
|
|
139
|
+
updated_readme = readme.replace(previous_readme, guidance, 1)
|
|
140
|
+
else:
|
|
141
|
+
if BEGIN in readme or END in readme:
|
|
142
|
+
raise WorkspaceError("Unowned ThisDamnThing README marker")
|
|
143
|
+
updated_readme = readme + ("\n\n" if readme else "") + guidance + "\n"
|
|
144
|
+
if readme != updated_readme:
|
|
145
|
+
pending["README.md"] = updated_readme
|
|
146
|
+
state["instructions"]["README.md"] = guidance
|
|
147
|
+
for host in selected:
|
|
148
|
+
instruction, skills, settings = PROVIDERS[host]
|
|
149
|
+
if host == "claude":
|
|
150
|
+
legacy = managed_path(root, ".claude/commands/tdt-workspace.md")
|
|
151
|
+
if legacy.exists():
|
|
152
|
+
raise WorkspaceError("Claude command name conflict: .claude/commands/tdt-workspace.md")
|
|
153
|
+
generated[f"{skills}/tdt-workspace/SKILL.md"] = (
|
|
154
|
+
"---\nname: tdt-workspace\ndescription: Orient the user in a ThisDamnThing workspace "
|
|
155
|
+
"and diagnose its core files and agent setup.\n---\n\n"
|
|
156
|
+
"Read and follow .tdt/skills/tdt-workspace/SKILL.md from the\n"
|
|
157
|
+
"workspace root (the ancestor containing .tdt/config.json).\n")
|
|
158
|
+
if host == "claude" and managed_path(root, ".claude/commands/tdt-review-brain.md").exists():
|
|
159
|
+
raise WorkspaceError("Claude command name conflict: tdt-review-brain")
|
|
160
|
+
generated[f"{skills}/tdt-review-brain/SKILL.md"] = (
|
|
161
|
+
"---\nname: tdt-review-brain\ndescription: Review pending knowledge candidates "
|
|
162
|
+
"and apply explicit user approval, edits or rejection.\n---\n\n"
|
|
163
|
+
"Read and follow .tdt/skills/tdt-review-brain/SKILL.md from the workspace root.\n")
|
|
164
|
+
for name, description in (("tdt-update-stack", "Inspect and approve a newer installed stack version."), ("tdt-remove-stack", "Uninstall a selected stack while preserving user work."), ("tdt-constitution", "Define or update workspace permission rules in natural language."), ("tdt-find-skills", "Find reusable workflows in completed workspace sessions and propose skills for approval."), ("tdt-install-stack", "Discover, inspect and install optional workflow stacks."), ("tdt-ui", "Use UI for local browser questions and custom interactive interviews."), ("tdt-search", "Answer using approved linked knowledge with references and honest gaps."),
|
|
165
|
+
("tdt-add-project", "Register an external project and offer bounded onboarding.")):
|
|
166
|
+
if host == "claude" and managed_path(root, f".claude/commands/{name}.md").exists():
|
|
167
|
+
raise WorkspaceError(f"Claude command name conflict: {name}")
|
|
168
|
+
generated[f"{skills}/{name}/SKILL.md"] = (
|
|
169
|
+
f"---\nname: {name}\ndescription: {description}\n---\n\n"
|
|
170
|
+
f"Read and follow .tdt/skills/{name}/SKILL.md from the workspace root.\n")
|
|
171
|
+
current = existing_text(root, instruction) or ""
|
|
172
|
+
previous = state["instructions"].get(instruction)
|
|
173
|
+
if previous is not None:
|
|
174
|
+
if (not isinstance(previous, str) or current.count(BEGIN) != 1
|
|
175
|
+
or current.count(END) != 1 or previous not in current):
|
|
176
|
+
raise WorkspaceError(f"Owned instruction block changed or missing: {instruction}")
|
|
177
|
+
updated = current.replace(previous, BLOCK, 1)
|
|
178
|
+
else:
|
|
179
|
+
if BEGIN in current or END in current:
|
|
180
|
+
raise WorkspaceError(f"Unowned ThisDamnThing instruction marker: {instruction}")
|
|
181
|
+
updated = current + ("\n\n" if current else "") + BLOCK + "\n"
|
|
182
|
+
if current != updated:
|
|
183
|
+
pending[instruction] = updated
|
|
184
|
+
state["instructions"][instruction] = BLOCK
|
|
185
|
+
|
|
186
|
+
raw = existing_text(root, settings)
|
|
187
|
+
data = parse_settings(raw) if raw is not None else {}
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
raise WorkspaceError(f"Expected settings object: {settings}")
|
|
190
|
+
hooks = data.setdefault("hooks", {})
|
|
191
|
+
if not isinstance(hooks, dict):
|
|
192
|
+
raise WorkspaceError(f"Expected hooks object: {settings}")
|
|
193
|
+
for event, script, record_key in (("SessionStart", "session-start.py", "hooks"),
|
|
194
|
+
("Stop", "capture.py", "capture_hooks"),
|
|
195
|
+
("UserPromptSubmit", "constitution.py", "policy_hooks")):
|
|
196
|
+
entries = hooks.setdefault(event, [])
|
|
197
|
+
if not isinstance(entries, list) or any(not isinstance(e, dict) for e in entries):
|
|
198
|
+
raise WorkspaceError(f"Expected {event} object list: {settings}")
|
|
199
|
+
command = shlex.join([sys.executable, str(root / f".tdt/hooks/{script}"), host])
|
|
200
|
+
entry = {"hooks": [{"type": "command", "command": command, "timeout": 10}]}
|
|
201
|
+
if host == "codex" and event in ("UserPromptSubmit", "SessionStart"):
|
|
202
|
+
entry["hooks"][0]["additionalContextLimit"] = 10000
|
|
203
|
+
previous = state[record_key].get(settings)
|
|
204
|
+
if previous is not None:
|
|
205
|
+
if entries.count(previous) != 1:
|
|
206
|
+
raise WorkspaceError(f"Owned {event} hook changed, duplicated or missing: {settings}")
|
|
207
|
+
index = entries.index(previous)
|
|
208
|
+
others = entries[:index] + entries[index + 1:]
|
|
209
|
+
else:
|
|
210
|
+
index, others = len(entries), entries
|
|
211
|
+
if any(".tdt/" in json.dumps(e) for e in others):
|
|
212
|
+
raise WorkspaceError(f"Unowned ThisDamnThing hook conflict: {settings}")
|
|
213
|
+
if previous is None:
|
|
214
|
+
entries.append(entry)
|
|
215
|
+
else:
|
|
216
|
+
entries[index] = entry
|
|
217
|
+
state[record_key][settings] = entry
|
|
218
|
+
if raw is None or parse_settings(raw) != data:
|
|
219
|
+
pending[settings] = encode(data)
|
|
220
|
+
config["adapters"][host] = {"bootstrap_version": 1,
|
|
221
|
+
"capture": "Stop continuation; runtime unverified"}
|
|
222
|
+
|
|
223
|
+
# Rename only hash-verified files that this bootstrap already owns. Keep
|
|
224
|
+
# unselected hosts discoverable too, without changing their settings/hooks.
|
|
225
|
+
for relative, expected in list(state['files'].items()):
|
|
226
|
+
replacement = relative
|
|
227
|
+
for old, new in LEGACY_SKILLS.items():
|
|
228
|
+
replacement = replacement.replace('/' + old + '/', '/' + new + '/')
|
|
229
|
+
if replacement == relative:
|
|
230
|
+
continue
|
|
231
|
+
current = existing_text(root, relative)
|
|
232
|
+
if current is None or digest(current) != expected:
|
|
233
|
+
raise WorkspaceError(f'Owned legacy skill changed or missing: {relative}')
|
|
234
|
+
for old, new in LEGACY_SKILLS.items():
|
|
235
|
+
current = current.replace(old, new)
|
|
236
|
+
generated.setdefault(replacement, current)
|
|
237
|
+
pending[relative] = None
|
|
238
|
+
del state['files'][relative]
|
|
239
|
+
|
|
240
|
+
for relative, content in generated.items():
|
|
241
|
+
if relative.startswith('.claude/skills/'):
|
|
242
|
+
name = Path(relative).parent.name
|
|
243
|
+
if managed_path(root, f'.claude/commands/{name}.md').exists():
|
|
244
|
+
raise WorkspaceError(f'Claude command name conflict: {name}')
|
|
245
|
+
current = existing_text(root, relative)
|
|
246
|
+
previous = state["files"].get(relative)
|
|
247
|
+
if previous is not None:
|
|
248
|
+
if current is None or digest(current) != previous:
|
|
249
|
+
raise WorkspaceError(f"Owned bootstrap file changed or missing: {relative}")
|
|
250
|
+
elif current is not None and not (relative in LEGACY_STACK_DOCS
|
|
251
|
+
and digest(current) in (digest(content), LEGACY_STACK_DOCS[relative])):
|
|
252
|
+
raise WorkspaceError(f"Bootstrap name conflict; existing file preserved: {relative}")
|
|
253
|
+
# Reserve skill directories as a whole to avoid hijacking existing skills.
|
|
254
|
+
if relative.endswith("/SKILL.md") and previous is None:
|
|
255
|
+
parent = managed_path(root, str(Path(relative).parent))
|
|
256
|
+
if parent.exists():
|
|
257
|
+
raise WorkspaceError(f"Bootstrap skill directory conflict: {parent}")
|
|
258
|
+
if current != content:
|
|
259
|
+
pending[relative] = content
|
|
260
|
+
state["files"][relative] = digest(content)
|
|
261
|
+
if not isinstance(owned, list) or any(not isinstance(v, str) for v in owned):
|
|
262
|
+
raise WorkspaceError("Invalid owned-files list")
|
|
263
|
+
pending[MANIFEST] = encode(state)
|
|
264
|
+
pending[".tdt/state/owned-files.json"] = encode(sorted((set(owned) | {
|
|
265
|
+
*state["files"], MANIFEST}) - {p for p, text in pending.items() if text is None}))
|
|
266
|
+
pending[".tdt/config.json"] = encode(config)
|
|
267
|
+
# Includes manifests and state; fail before the first write on all path conflicts.
|
|
268
|
+
for relative in pending:
|
|
269
|
+
existing_text(root, relative)
|
|
270
|
+
return pending
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def diagnose_adapters(root, config):
|
|
274
|
+
lines, healthy = [], True
|
|
275
|
+
state = {}
|
|
276
|
+
try:
|
|
277
|
+
state = load_manifest(root)
|
|
278
|
+
for relative, expected in state["files"].items():
|
|
279
|
+
current = existing_text(root, relative)
|
|
280
|
+
if current is None or digest(current) != expected:
|
|
281
|
+
raise WorkspaceError(f"Bootstrap file missing or changed: {relative}")
|
|
282
|
+
for relative, block in state["instructions"].items():
|
|
283
|
+
current = existing_text(root, relative) or ""
|
|
284
|
+
if (not isinstance(block, str) or block not in current
|
|
285
|
+
or current.count(BEGIN) != 1 or current.count(END) != 1):
|
|
286
|
+
raise WorkspaceError(f"Bootstrap instruction missing or changed: {relative}")
|
|
287
|
+
recorded_hooks = [(relative, entry, "SessionStart", "session-start.py")
|
|
288
|
+
for relative, entry in state["hooks"].items()]
|
|
289
|
+
recorded_hooks += [(relative, entry, "Stop", "capture.py")
|
|
290
|
+
for relative, entry in state["capture_hooks"].items()]
|
|
291
|
+
recorded_hooks += [(relative, entry, "UserPromptSubmit", "constitution.py")
|
|
292
|
+
for relative, entry in state["policy_hooks"].items()]
|
|
293
|
+
for relative, entry, event, script in recorded_hooks:
|
|
294
|
+
raw = existing_text(root, relative)
|
|
295
|
+
if raw is None:
|
|
296
|
+
raise WorkspaceError(f"Missing hook configuration: {relative}")
|
|
297
|
+
data = parse_settings(raw)
|
|
298
|
+
hooks = data.get("hooks", {}) if isinstance(data, dict) else {}
|
|
299
|
+
entries = hooks.get(event, []) if isinstance(hooks, dict) else []
|
|
300
|
+
if not isinstance(entries, list) or entries.count(entry) != 1:
|
|
301
|
+
raise WorkspaceError(f"{event} hook missing or changed: {relative}")
|
|
302
|
+
try:
|
|
303
|
+
handler = entry["hooks"][0]
|
|
304
|
+
argv = shlex.split(handler["command"])
|
|
305
|
+
if (len(argv) != 3 or not Path(argv[0]).is_file()
|
|
306
|
+
or argv[1] != str(root / f".tdt/hooks/{script}")):
|
|
307
|
+
raise ValueError("stale Python or workspace path")
|
|
308
|
+
except (KeyError, IndexError, TypeError, ValueError) as exc:
|
|
309
|
+
raise WorkspaceError(f"Invalid startup command in {relative}; "
|
|
310
|
+
"rerun init --agent for this host") from exc
|
|
311
|
+
if data.get("disableAllHooks") is True:
|
|
312
|
+
lines.append(f"WARNING: hooks disabled in {relative}")
|
|
313
|
+
healthy = False
|
|
314
|
+
from .agents import enabled, detected
|
|
315
|
+
selected, installed = enabled(root, config), detected()
|
|
316
|
+
for host, (instruction, skills, settings) in PROVIDERS.items():
|
|
317
|
+
executable = "found on PATH" if host in installed else "not found on PATH"
|
|
318
|
+
lines.append(f"{host.capitalize()} executable: {executable}")
|
|
319
|
+
if host not in selected:
|
|
320
|
+
lines.append(f"{host.capitalize()} integration: disabled; use tdt agent enable {host}")
|
|
321
|
+
continue
|
|
322
|
+
adapter = config["adapters"].get(host)
|
|
323
|
+
if not isinstance(adapter, dict) or adapter.get("bootstrap_version") != 1:
|
|
324
|
+
raise WorkspaceError(f"{host} ownership recognized but adapter needs refresh; run tdt agent enable {host}")
|
|
325
|
+
if (instruction not in state["instructions"] or settings not in state["hooks"]
|
|
326
|
+
or settings not in state["capture_hooks"]
|
|
327
|
+
or settings not in state["policy_hooks"]
|
|
328
|
+
or f"{skills}/tdt-review-brain/SKILL.md" not in state["files"]
|
|
329
|
+
or f"{skills}/tdt-workspace/SKILL.md" not in state["files"]
|
|
330
|
+
or any(f"{skills}/{name}/SKILL.md" not in state["files"] for name in SKILLS)
|
|
331
|
+
or not set(RESOURCES).issubset(state["files"])):
|
|
332
|
+
raise WorkspaceError(f"Incomplete {host} bootstrap ownership record")
|
|
333
|
+
lines.append(f"{host.capitalize()} integration: enabled; files installed; runtime unverified")
|
|
334
|
+
if host == "codex":
|
|
335
|
+
lines.append(" Trust required: trusted project layer and review SessionStart, UserPromptSubmit and Stop hooks in /hooks.")
|
|
336
|
+
else:
|
|
337
|
+
lines.append(" Review project trust and /hooks; host or managed settings may disable hooks.")
|
|
338
|
+
except (WorkspaceError, ValueError, OSError) as exc:
|
|
339
|
+
lines.append(f"ERROR: {exc}")
|
|
340
|
+
healthy = False
|
|
341
|
+
lines.append("Automatic capture: Stop continuation installed; requires live host trust"
|
|
342
|
+
if state.get("capture_hooks") else "Automatic capture: no Stop hooks installed")
|
|
343
|
+
from .constitution import load as load_policy
|
|
344
|
+
try:
|
|
345
|
+
policy = load_policy(root)
|
|
346
|
+
lines.append("Workspace constitution: " + policy["sha256"] + "; guidance only, live request delivery unverified")
|
|
347
|
+
except (WorkspaceError, ValueError, OSError) as exc:
|
|
348
|
+
lines.append(f"ERROR: workspace constitution: {exc}")
|
|
349
|
+
healthy = False
|
|
350
|
+
from .brain import candidates
|
|
351
|
+
try:
|
|
352
|
+
pending = len(candidates(root))
|
|
353
|
+
requests = managed_path(root, ".tdt/state/captures")
|
|
354
|
+
incomplete = sum(read_json(root, str(p.relative_to(root))).get("status") == "requested"
|
|
355
|
+
for p in requests.glob("*.json"))
|
|
356
|
+
lines.append(f"Brain: {pending} pending candidates; {incomplete} incomplete capture requests")
|
|
357
|
+
except (WorkspaceError, ValueError, OSError) as exc:
|
|
358
|
+
lines.append(f"ERROR: brain state: {exc}")
|
|
359
|
+
healthy = False
|
|
360
|
+
return lines, healthy
|