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.
Files changed (65) hide show
  1. thisdamnthing/__init__.py +3 -0
  2. thisdamnthing/__main__.py +3 -0
  3. thisdamnthing/agents.py +167 -0
  4. thisdamnthing/bootstrap.py +360 -0
  5. thisdamnthing/brain.py +298 -0
  6. thisdamnthing/capabilities.py +273 -0
  7. thisdamnthing/capture.py +78 -0
  8. thisdamnthing/cli.py +269 -0
  9. thisdamnthing/constitution.py +153 -0
  10. thisdamnthing/history.py +147 -0
  11. thisdamnthing/hosts.py +71 -0
  12. thisdamnthing/marketplace.py +385 -0
  13. thisdamnthing/projects.py +153 -0
  14. thisdamnthing/resources/docs/README.md +46 -0
  15. thisdamnthing/resources/docs/agent-bootstrap.md +114 -0
  16. thisdamnthing/resources/docs/authoring.md +145 -0
  17. thisdamnthing/resources/docs/brain.md +129 -0
  18. thisdamnthing/resources/docs/commands.md +49 -0
  19. thisdamnthing/resources/docs/constitution.md +73 -0
  20. thisdamnthing/resources/docs/core-skills.md +17 -0
  21. thisdamnthing/resources/docs/getting-started.md +134 -0
  22. thisdamnthing/resources/docs/projects.md +55 -0
  23. thisdamnthing/resources/docs/skills.md +187 -0
  24. thisdamnthing/resources/docs/stacks.md +217 -0
  25. thisdamnthing/resources/docs/troubleshooting.md +60 -0
  26. thisdamnthing/resources/docs/ui.md +103 -0
  27. thisdamnthing/resources/docs/workspace-care.md +64 -0
  28. thisdamnthing/resources/harness/context.md +48 -0
  29. thisdamnthing/resources/harness/contracts/capture-event.md +38 -0
  30. thisdamnthing/resources/harness/contracts/stack.md +271 -0
  31. thisdamnthing/resources/harness/contracts/ui.md +122 -0
  32. thisdamnthing/resources/harness/hooks/capture.py +6 -0
  33. thisdamnthing/resources/harness/hooks/constitution.py +6 -0
  34. thisdamnthing/resources/harness/hooks/session-start.py +6 -0
  35. thisdamnthing/resources/harness/skills/tdt-add-project/SKILL.md +32 -0
  36. thisdamnthing/resources/harness/skills/tdt-constitution/SKILL.md +60 -0
  37. thisdamnthing/resources/harness/skills/tdt-find-skills/SKILL.md +39 -0
  38. thisdamnthing/resources/harness/skills/tdt-install-stack/SKILL.md +63 -0
  39. thisdamnthing/resources/harness/skills/tdt-remove-stack/SKILL.md +23 -0
  40. thisdamnthing/resources/harness/skills/tdt-review-brain/SKILL.md +27 -0
  41. thisdamnthing/resources/harness/skills/tdt-search/SKILL.md +27 -0
  42. thisdamnthing/resources/harness/skills/tdt-ui/SKILL.md +54 -0
  43. thisdamnthing/resources/harness/skills/tdt-update-stack/SKILL.md +37 -0
  44. thisdamnthing/resources/harness/skills/tdt-workspace/SKILL.md +20 -0
  45. thisdamnthing/resources/marketplace/manifest.schema.json +251 -0
  46. thisdamnthing/resources/marketplace/registry.schema.json +579 -0
  47. thisdamnthing/resources/ui/bridge.js +3 -0
  48. thisdamnthing/resources/ui/shell.html +1 -0
  49. thisdamnthing/resources/ui/shell.js +161 -0
  50. thisdamnthing/resources/ui/tokens.css +28 -0
  51. thisdamnthing/resources/workspace/README.md +30 -0
  52. thisdamnthing/resources/workspace/index.md +12 -0
  53. thisdamnthing/skills.py +279 -0
  54. thisdamnthing/stack_docs.py +97 -0
  55. thisdamnthing/stack_updates.py +108 -0
  56. thisdamnthing/stacks.py +443 -0
  57. thisdamnthing/ui.py +473 -0
  58. thisdamnthing/ui_resources.py +40 -0
  59. thisdamnthing/workspace.py +226 -0
  60. thisdamnthing-0.1.0.dist-info/METADATA +102 -0
  61. thisdamnthing-0.1.0.dist-info/RECORD +65 -0
  62. thisdamnthing-0.1.0.dist-info/WHEEL +5 -0
  63. thisdamnthing-0.1.0.dist-info/entry_points.txt +2 -0
  64. thisdamnthing-0.1.0.dist-info/licenses/LICENSE +202 -0
  65. thisdamnthing-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """ThisDamnThing's agent-neutral workspace core."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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