@passioncode-ai/passioncode 0.1.4

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 (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/README.md +69 -0
  4. package/SECURITY.md +42 -0
  5. package/bin/passioncode.js +65 -0
  6. package/family.json +45 -0
  7. package/lib/launcher.js +361 -0
  8. package/package.json +48 -0
  9. package/payload/.claude-plugin/marketplace.json +47 -0
  10. package/payload/manifest.json +77 -0
  11. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
  29. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
  30. package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
  31. package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
  32. package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
  33. package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
  34. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
  35. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
  36. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
  37. package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
  38. package/payload/plugins/passioncode/hooks/hooks.json +10 -0
  39. package/payload/plugins/passioncode/hooks/probe.js +23 -0
  40. package/payload/plugins/passioncode/hooks/session-start.js +45 -0
  41. package/payload/plugins/passioncode/hooks/update-check.js +126 -0
  42. package/payload/plugins/passioncode/trust.json +6 -0
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """A complete, minimal fabric-service/0.1 service built on fabric_service.py.
3
+
4
+ It is the worked example the skill points at, the target check_service.py tests
5
+ itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
6
+
7
+ sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
8
+ sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import html
15
+ import json
16
+ import os
17
+ from http.server import BaseHTTPRequestHandler
18
+ from pathlib import Path
19
+ import signal
20
+ import sys
21
+ import threading
22
+ from typing import Any, Dict, Optional
23
+ from urllib.parse import parse_qs, urlparse
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+ import fabric_service as fs # noqa: E402
27
+
28
+ VERSION = "0.1.0"
29
+ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>{name}</title>
31
+ <style>body{{font:15px system-ui;margin:2rem;color:#e9e4ec;background:#0a070d}}li{{margin:.3rem 0}}</style>
32
+ </head><body><h1>{name}</h1><p>Status: {status}</p><ul>{rows}</ul></body></html>"""
33
+
34
+
35
+ class Service:
36
+ def __init__(self, args: argparse.Namespace):
37
+ self.id = args.id
38
+ self.instance = args.instance
39
+ self.name = args.name
40
+ self.port = args.port
41
+ self.data = Path(args.data_dir)
42
+ self.token_file = Path(args.token_file) if args.token_file else self.data / "service.token"
43
+ self.degraded = [{"source": "demo", "reason": args.degraded}] if args.degraded else []
44
+ self.started_at = fs.now_iso()
45
+ self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
46
+
47
+ def start(self) -> None:
48
+ # Rule: the lock comes before ANY side effect, including creating the token.
49
+ self.lock = fs.hold_single_instance(self.data)
50
+ self.token = fs.ensure_token(self.token_file)
51
+ self.log = fs.JsonlEventLog(self.data / "events.jsonl")
52
+ self.codes = fs.LoginCodes(self.data / "auth")
53
+ self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
54
+
55
+ def well_known(self) -> Dict[str, Any]:
56
+ return fs.build_well_known(
57
+ service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
58
+ started_at=self.started_at, status="ready", degraded=self.degraded,
59
+ summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
60
+ surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}},
61
+ )
62
+
63
+
64
+ def make_handler(svc: Service):
65
+ class Handler(BaseHTTPRequestHandler):
66
+ server_version = "fabric-sample/" + VERSION
67
+
68
+ def log_message(self, fmt: str, *args: Any) -> None: # quiet by default
69
+ return
70
+
71
+ def _send(self, status: int, body: Any = None, headers: Optional[Dict[str, str]] = None,
72
+ content_type: str = "application/json") -> None:
73
+ payload = b""
74
+ if body is not None:
75
+ payload = body.encode() if isinstance(body, str) else json.dumps(body).encode()
76
+ self.send_response(status)
77
+ self.send_header("Cache-Control", "no-store")
78
+ self.send_header("X-Content-Type-Options", "nosniff")
79
+ self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'")
80
+ if body is not None:
81
+ self.send_header("Content-Type", content_type)
82
+ self.send_header("Content-Length", str(len(payload)))
83
+ for key, value in (headers or {}).items():
84
+ self.send_header(key, value)
85
+ self.end_headers()
86
+ if payload:
87
+ self.wfile.write(payload)
88
+
89
+ def _guard(self) -> bool:
90
+ reason = fs.check_request(svc.port, self.headers.get("Host"), self.headers.get("Origin"),
91
+ self.headers.get("Sec-Fetch-Site"))
92
+ if reason:
93
+ self._send(403, {"error": reason})
94
+ return False
95
+ return True
96
+
97
+ def _token_ok(self) -> bool:
98
+ if fs.token_matches(self.headers.get("Authorization"), svc.token):
99
+ return True
100
+ self._send(401, {"error": "The service token is required."}, {"WWW-Authenticate": "Bearer"})
101
+ return False
102
+
103
+ def _session_ok(self) -> bool:
104
+ return svc.codes.session_valid(fs.cookie_value(self.headers.get("Cookie")))
105
+
106
+ def do_GET(self) -> None: # noqa: N802
107
+ if not self._guard():
108
+ return
109
+ url = urlparse(self.path)
110
+ query = parse_qs(url.query)
111
+ if url.path == "/.well-known/fabric-service":
112
+ return self._send(200, svc.well_known())
113
+ if url.path == "/fabric/v1/events":
114
+ if not self._token_ok():
115
+ return
116
+ try:
117
+ limit = fs.parse_limit((query.get("limit") or [None])[0])
118
+ page = fs.events_page(svc.log.fetch, (query.get("after") or [None])[0], limit)
119
+ except fs.ServiceError as exc:
120
+ return self._send(400, {"error": str(exc)})
121
+ return self._send(200, page)
122
+ if url.path == "/fabric/v1/login":
123
+ cookie = svc.codes.redeem((query.get("code") or [None])[0])
124
+ if not cookie:
125
+ return self._send(403, "This sign-in link was already used or has expired.", content_type="text/plain")
126
+ return self._send(302, None, {"Location": "/", "Set-Cookie": fs.session_cookie_header(cookie)})
127
+ if url.path == "/":
128
+ if not self._session_ok():
129
+ return self._send(401, "Open this dashboard from Fabric Dashboards.", content_type="text/plain")
130
+ rows = "".join("<li>%s — %s</li>" % (html.escape(e["at"]), html.escape(e["text"]))
131
+ for e in reversed(svc.log.fetch(None, 20)))
132
+ return self._send(200, PAGE.format(name=html.escape(svc.name), status="ready", rows=rows),
133
+ content_type="text/html; charset=utf-8")
134
+ self._send(404, {"error": "Not found."})
135
+
136
+ def do_POST(self) -> None: # noqa: N802
137
+ if not self._guard():
138
+ return
139
+ url = urlparse(self.path)
140
+ if url.path == "/fabric/v1/login-code":
141
+ if not self._token_ok():
142
+ return
143
+ return self._send(200, svc.codes.issue())
144
+ if url.path == "/api/emit":
145
+ if not (self._session_ok() or fs.token_matches(self.headers.get("Authorization"), svc.token)):
146
+ return self._send(401, {"error": "Sign in first."})
147
+ if self.headers.get("X-Fabric-Request") != "1":
148
+ return self._send(403, {"error": "Missing request header."})
149
+ length = min(int(self.headers.get("Content-Length") or 0), 65536)
150
+ body = json.loads(self.rfile.read(length) or b"{}")
151
+ event = svc.log.append(body.get("kind", "demo.note"), body.get("level", "info"),
152
+ body.get("text", "A note from the sample service."),
153
+ notify=bool(body.get("notify")), link=body.get("link"))
154
+ return self._send(200, event)
155
+ self._send(404, {"error": "Not found."})
156
+
157
+ return Handler
158
+
159
+
160
+ def make_server(svc: Service) -> fs.LoopbackHTTPServer:
161
+ return fs.LoopbackHTTPServer(("127.0.0.1", svc.port), make_handler(svc))
162
+
163
+
164
+ def serve(args: argparse.Namespace) -> int:
165
+ svc = Service(args)
166
+ svc.start()
167
+ server = make_server(svc)
168
+
169
+ def stop(signum: int, _frame: Any) -> None:
170
+ svc.log.append("service.stopping", "info", "%s is stopping." % svc.name)
171
+ threading.Thread(target=server.shutdown, daemon=True).start()
172
+
173
+ signal.signal(signal.SIGTERM, stop)
174
+ signal.signal(signal.SIGINT, stop)
175
+ try:
176
+ server.serve_forever(poll_interval=0.2)
177
+ finally:
178
+ server.server_close()
179
+ svc.lock.release()
180
+ return 0
181
+
182
+
183
+ def register(args: argparse.Namespace) -> int:
184
+ data = Path(args.data_dir)
185
+ descriptor = {
186
+ "protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name,
187
+ "summary": "Sample fabric-service/0.1 service.",
188
+ "origin": "http://127.0.0.1:%d" % args.port,
189
+ "auth": {"tokenFile": str(Path(args.token_file) if args.token_file else data / "service.token")},
190
+ "lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label
191
+ else {"manager": "none"},
192
+ "paths": {"data": str(data), "logs": []},
193
+ "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
194
+ }
195
+ try:
196
+ path = fs.write_descriptor(descriptor, Path(args.services_dir) if args.services_dir else None)
197
+ except fs.ServiceError as exc:
198
+ print(str(exc), file=sys.stderr)
199
+ return 1
200
+ print(path)
201
+ return 0
202
+
203
+
204
+ def main(argv: Optional[list] = None) -> int:
205
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
206
+ sub = parser.add_subparsers(dest="command", required=True)
207
+ for name in ("serve", "register"):
208
+ p = sub.add_parser(name)
209
+ p.add_argument("--port", type=int, required=True)
210
+ p.add_argument("--data-dir", required=True)
211
+ p.add_argument("--token-file")
212
+ p.add_argument("--id", default="sample")
213
+ p.add_argument("--instance", default="default")
214
+ p.add_argument("--name", default="Sample Service")
215
+ if name == "serve":
216
+ p.add_argument("--degraded", help="report one degraded source with this reason")
217
+ else:
218
+ p.add_argument("--services-dir")
219
+ p.add_argument("--label")
220
+ p.add_argument("--plist")
221
+ args = parser.parse_args(argv)
222
+ return serve(args) if args.command == "serve" else register(args)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: creating-fabric-agents
3
+ description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit — «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch". Runs the intake grill (capability, named consumer, workflow-or-agent, MCP/A2A/local-runner profile, effect declarations), distils source projects into a knowledge pack whose recorded failures become planted eval fixtures, scaffolds the pinned contract bundle, and sets the two-clock eval expectation with the conformance report. NOT for adapting an existing project (use adapting-projects-to-fabric), building the Fabric host or orchestrator, or generic agent design where Fabric compatibility is not part of the request.
4
+ license: MIT
5
+ compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
+ metadata:
7
+ author: passioncode-ai
8
+ version: "0.4.2"
9
+ contract-version: "0.1.0"
10
+ contract-commit: "20a818e648a4c09a60df0126d11626922e8b9094"
11
+ ---
12
+
13
+ # Creating Fabric-compatible agents
14
+
15
+ Design a new agent so that Fabric compatibility is a property of its first commit, not a
16
+ retrofit. The sibling skill `adapting-projects-to-fabric` wraps an interface that already
17
+ exists; this one runs the stages that come *before* an interface exists — the intake
18
+ grill and the knowledge intake — then hands the scaffolding and verification to the same
19
+ pinned machinery, so both doors lead into one pipeline.
20
+
21
+ ## Boundary
22
+
23
+ Use this skill to design and build a new provider. Do not use it to:
24
+
25
+ - adapt a project that already has a stable API, MCP/A2A surface, or CLI — that is
26
+ `adapting-projects-to-fabric`, and entering there skips nothing;
27
+ - design the Fabric host, registry, scheduler, admission service, or binding runtime;
28
+ - build a generic agent with no Fabric requirement — nothing here helps a project that
29
+ will never be a provider;
30
+ - claim admission: the deliverable is an admission-ready provider bundle, never a
31
+ connected provider.
32
+
33
+ ## Step 0 — the intake grill
34
+
35
+ No file is created until every row has an answer. An answer of "later" fails the grill.
36
+
37
+ | Question | The gate |
38
+ |---|---|
39
+ | **What capability?** | one capability, named as `domain.action` (optionally `@surface`), with input and output shapes statable now |
40
+ | **Who calls it?** | **a named consumer** — the project, schedule, or workflow that will actually invoke this capability. No consumer, no agent: a role that "would be useful" is a catalogue entry, not a build order |
41
+ | **Workflow or agent?** | if every step can be named now, build a deterministic workflow behind the capability and skip the autonomy — an agent buys flexibility with latency, cost, and a new failure class, and that price needs a reason |
42
+ | **Which profile?** | `mcp` when Fabric owns planning and retries and the capability has a schema; `a2a` when the provider owns its task lifecycle end to end; `local-runner` when it is a terminal agent Fabric drives. When unsure, the sibling skill's profile-selection reference is the decision record |
43
+ | **What effects?** | declare anything money-, deletion-, or publication-adjacent now — the host's floor and grants are designed against these declarations, and one discovered late invalidates the admission |
44
+ | **Which tenancy?** | record whether the agent assumes one operator; the assumption is cheap to write down and expensive to excavate |
45
+
46
+ ## Step 1 — the knowledge intake
47
+
48
+ If the estate holds prior art — a project that did this job, an audit that names its
49
+ failures, a retro — distil it before writing code:
50
+
51
+ 1. Name the sources: repositories, `docs/`, audits, retrospectives, with references.
52
+ 2. Extract **patterns** (what worked, each citing its origin) and **traps** (recorded
53
+ failures and dead ends).
54
+ 3. **Convert every trap into a planted eval fixture.** The new agent is not done until
55
+ it has been watched rejecting the exact defects its predecessors were burned by —
56
+ knowledge transfers as a check, not as prose.
57
+ 4. Record the pack in the new project as `docs/knowledge-pack.md` with its sources, so
58
+ the provenance of every borrowed decision survives the person who borrowed it.
59
+
60
+ Skipping this step because there is no prior art is legal; skipping it because reading
61
+ is slower than generating is how the same collector truncates at the same row limit
62
+ twice.
63
+
64
+ ## Step 2 — the project skeleton
65
+
66
+ Create the minimal project: repository, `README.md` naming the capability and its
67
+ consumer, and the eval **observables** — for each requirement, the pass/fail criterion
68
+ that would show it met, written now, before the implementation. The input corpus is NOT
69
+ authored now: it grows from real traces once the provider runs, and the step-1 fixtures
70
+ are its seed because a source project's recorded failures count as production.
71
+
72
+ ## Step 3 — scaffold the provider bundle
73
+
74
+ Reuse the sibling skill's scaffolder against the new skeleton with the profile chosen at
75
+ step 0:
76
+
77
+ ```bash
78
+ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py \
79
+ scaffold <project-root> --profile <mcp|a2a|local-runner> \
80
+ --provider-id <stable-uri> --provider-name "<name>" \
81
+ --capability-id <stable-uri> --capability-name <domain.action> \
82
+ --schema-base <immutable-base-uri>
83
+ ```
84
+
85
+ Pin exactly contract `0.1.0` at commit `20a818e648a4c09a60df0126d11626922e8b9094` and
86
+ read the pinned guide before implementing protocol details. If this skill is installed
87
+ without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
88
+ contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
89
+ `NOT_RUN` — do not reconstruct normative rules from memory.
90
+
91
+ The generated placeholders are deliberate blockers; a bundle still containing one is not
92
+ ready for admission.
93
+
94
+ ## Step 4 — implement against the observables
95
+
96
+ If the agent keeps running on the operator's computer — it answers other agents, runs
97
+ long jobs or shows a dashboard — build it as a service with the sibling skill
98
+ `building-fabric-services`: its instance lock, launchd plist, descriptor, well-known
99
+ document and events feed are part of this step, not a later retrofit. If that skill is
100
+ absent, apply the `fabric-service/0.1` rules from the pinned contract's
101
+ `docs/specification/service.md` by hand and mark its probe `NOT_RUN`.
102
+
103
+ Build the capability behind the chosen surface. Keep model choice and internal reasoning
104
+ outside the contract; expose typed outcomes, evidence, and protocol-visible state. Every
105
+ provider output is untrusted until schemas and semantic assertions pass. The step-2
106
+ observables are the definition of done; an observable attached after the code exists
107
+ lets the output decide what counts as success.
108
+
109
+ ## Step 5 — check, report, and set the canary expectation
110
+
111
+ Run the sibling skill's `check` (structural, then exact when the contract checkout is
112
+ available) and complete `fabric/FABRIC-CONFORMANCE.md` with dated receipts, exactly as
113
+ its workflow steps 5–7 prescribe — this skill adds no second verification path on
114
+ purpose. Then record one expectation the report must carry:
115
+
116
+ > When a Fabric host admits this provider, it enters under a **canary binding** — a
117
+ > checker on its output and a budget cap — regardless of who wrote it. Unsupervised
118
+ > operation is a later, recorded promotion citing eval results and run history.
119
+
120
+ ## Completion format
121
+
122
+ Report: capability, consumer, profile with the step-0 grill answers; the knowledge pack
123
+ and which traps became fixtures; created files; contract version and commit; the gate
124
+ table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`; remaining placeholders; and the
125
+ exact next command. Never summarize the outcome as "Fabric-compatible" unless every
126
+ required admission gate passed against the real provider.
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
+ "name": "observatory-log",
4
+ "displayName": "Observatory Log",
5
+ "description": "Records what changed in a watched project at the end of every turn, and asks for the one thing a diff cannot contain: why.",
6
+ "version": "0.12.2",
7
+ "license": "MIT",
8
+ "author": {
9
+ "name": "Sergey Sheleg",
10
+ "url": "https://sshlg.me/"
11
+ },
12
+ "homepage": "https://github.com/passioncode-ai/project-observatory-dashboard",
13
+ "keywords": [
14
+ "observability",
15
+ "memory",
16
+ "git",
17
+ "ledger"
18
+ ]
19
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a record_turn.py result into the Stop hook's structured output.
3
+
4
+ Standard library only, and silent on anything unexpected: a hook that reports
5
+ its own confusion as a failure teaches the operator to disable hooks.
6
+
7
+ It never blocks. The facts are already on the record without the agent's
8
+ cooperation, so the only thing left to ask for is the `why` — and asking is
9
+ worth more than a veto that costs a turn. An absent `why` stays visible in the
10
+ ledger as a proposed record with a null field, which is accountability without
11
+ obstruction.
12
+ """
13
+ from __future__ import annotations
14
+ import json
15
+ import sys
16
+
17
+ OWNER = "agent:claude-code"
18
+
19
+
20
+ def main() -> int:
21
+ try:
22
+ r = json.load(sys.stdin)
23
+ except Exception:
24
+ return 0
25
+ if not r.get("recorded"):
26
+ # A FAULT IS NOT A QUIET TURN. This returned 0 for every `recorded:
27
+ # false`, and the recorder's reasons are two different kinds: "nothing
28
+ # changed" is an answer about the work, while `IllegalTransition:
29
+ # observed -> proposed` is the recorder failing. Measured 2026-09-07:
30
+ # once `tools/corroborate.py` promoted a session's record, every later
31
+ # turn of that session raised that error and said nothing — roughly
32
+ # seventy-two turns in one session, and the only visible sign was a
33
+ # ledger row that had stopped moving.
34
+ #
35
+ # The recorder marks which kind it is; this only has to stop hiding it.
36
+ reason = r.get("reason") or ""
37
+ if not r.get("fault"):
38
+ return 0 # nothing changed, not watched, or explained
39
+ print(json.dumps({"systemMessage":
40
+ "Observatory could NOT record this turn: " + reason +
41
+ "\n\nThe facts of this turn are not in the ledger. "
42
+ "`store/raw/record-turn.json` holds the same reason, and "
43
+ "`./observatory.py findings` raises it.",
44
+ "suppressOutput": True}))
45
+ return 0
46
+ if r.get("hasWhy"):
47
+ return 0 # already explained
48
+
49
+ mid = r.get("memoryId")
50
+ rev = r.get("revision")
51
+ project = r.get("project", "this project")
52
+ files = r.get("files", 0)
53
+ plural = "" if files == 1 else "s"
54
+ unpushed = r.get("unpushed") or 0
55
+ extra = f", {unpushed} unpushed commit(s)" if unpushed else ""
56
+
57
+ message = (
58
+ f"Observatory recorded the facts of this turn in {project}: "
59
+ f"{files} file{plural} changed, +{r.get('insertions', 0)}/-{r.get('deletions', 0)}"
60
+ f"{extra}. Stored as {mid}@{rev}, state proposed, with no `why`.\n\n"
61
+ "The diff already says WHAT changed. Add WHY — one or two sentences a "
62
+ "reader in six months could not reconstruct from the diff: the constraint "
63
+ "that forced the shape, the alternative rejected, the trap avoided.\n\n"
64
+ f" observatory_record(owner=\"{OWNER}\", memory_id=\"{mid}\", "
65
+ f"expected_revision={rev}, statement=<the same claim>, why=<the reason>)\n\n"
66
+ "If the change genuinely needs no explanation — a typo, a rename — say so "
67
+ "and skip it. An empty `why` on a trivial edit is honest; an invented one "
68
+ "is worse than nothing, because it will be read as true."
69
+ )
70
+ # THE NUDGE AT THE MOMENT OF WORK. The agent is already inside this project;
71
+ # rebuilding its graph or touching its note costs least right now. Thresholds
72
+ # match the board's grace (7 days), and None means the artefact does not
73
+ # exist — not adopting graphify is a choice this hook does not argue with.
74
+ stale_bits = []
75
+ g = r.get("graphAgeDays")
76
+ if isinstance(g, int) and g > 7:
77
+ stale_bits.append(f"its code graph is {g} days old — refresh it: "
78
+ f"/graphify . --update")
79
+ w = r.get("wikiAgeDays")
80
+ if isinstance(w, int) and w > 7:
81
+ stale_bits.append(f"its wiki notes were last touched {w} days ago — if this "
82
+ f"work changed what the project IS (modules, scope, "
83
+ f"stack), update the overview note too")
84
+ if stale_bits:
85
+ message += ("\n\nWhile you are here — this project's recorded knowledge "
86
+ "is behind its code:\n - " + "\n - ".join(stale_bits))
87
+ print(json.dumps({"systemMessage": message, "suppressOutput": True},
88
+ ensure_ascii=False))
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ try:
94
+ raise SystemExit(main())
95
+ except SystemExit:
96
+ raise
97
+ except Exception:
98
+ raise SystemExit(0)
@@ -0,0 +1,31 @@
1
+ {
2
+ "description": "Optional Claude Code hooks: record changes in watched Git projects and request missing reasoning. SessionStart reports brief actionable context. Missing workspace, interpreter or watched project degrades quietly; hooks do not block a turn. Project names and paths stay in the private workspace.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "shell": "bash",
10
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
11
+ "timeout": 15,
12
+ "statusMessage": "What the observatory knows about this project"
13
+ }
14
+ ]
15
+ }
16
+ ],
17
+ "Stop": [
18
+ {
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "shell": "bash",
23
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/record-turn.sh\"",
24
+ "timeout": 20,
25
+ "statusMessage": "Recording what changed"
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
31
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ set -uo pipefail
10
+
11
+ emit() { printf '%s\n' "$1"; exit 0; }
12
+
13
+
14
+ if [ -t 0 ]; then payload=""; else payload=$(cat 2>/dev/null || true); fi
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+ root="${OBSERVATORY_ROOT:-}"
39
+ if [ -z "$root" ] && [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
40
+ c="${CLAUDE_PLUGIN_ROOT}"
41
+ for _ in 1 2 3 4 5 6; do
42
+ [ -f "$c/observatory.py" ] && { root="$c"; break; }
43
+ n="$(cd "$c/.." 2>/dev/null && pwd)" || break
44
+ [ "$n" = "$c" ] && break # reached the filesystem root
45
+ c="$n"
46
+ done
47
+ fi
48
+ [ -n "$root" ] || exit 0 # no checkout: silent, not an error
49
+ [ -f "$root/tools/record_turn.py" ] || exit 0 # older checkout: silent
50
+
51
+ py="${OBSERVATORY_PYTHON:-$root/.venv/bin/python}" # set by `full agent install`
52
+ [ -x "$py" ] || py="$(command -v python3 2>/dev/null)"
53
+ [ -n "$py" ] || exit 0 # no interpreter: silent
54
+
55
+ read_field() { # read_field <key>
56
+ [ -n "$payload" ] || return 0
57
+ printf '%s' "$payload" | "$py" -c '
58
+ import json, sys
59
+ try:
60
+ d = json.load(sys.stdin)
61
+ except Exception:
62
+ sys.exit(0)
63
+ print(d.get(sys.argv[1]) or "")
64
+ ' "$1" 2>/dev/null
65
+ }
66
+
67
+
68
+
69
+
70
+ [ "$(read_field stop_hook_active)" = "True" ] && exit 0
71
+ [ "$(read_field stop_hook_active)" = "true" ] && exit 0
72
+
73
+ cwd="$(read_field cwd)"; [ -n "$cwd" ] || cwd="${CLAUDE_PROJECT_DIR:-$PWD}"
74
+ session="$(read_field session_id)"
75
+
76
+
77
+ result="$("$py" "$root/tools/record_turn.py" --cwd "$cwd" --session-id "$session" 2>/dev/null)"
78
+ [ -n "$result" ] || exit 0
79
+
80
+ printf '%s' "$result" | "$py" "${CLAUDE_PLUGIN_ROOT:-$(dirname "$0")/..}/hooks/ask-why.py" 2>/dev/null
81
+ exit 0
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ set -uo pipefail
10
+ if [ -t 0 ]; then payload=""; else payload=$(cat 2>/dev/null || true); fi
11
+ root="${OBSERVATORY_ROOT:-}"
12
+ if [ -z "$root" ] && [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
13
+ c="${CLAUDE_PLUGIN_ROOT}"
14
+ for _ in 1 2 3 4 5 6; do
15
+ [ -f "$c/observatory.py" ] && { root="$c"; break; }
16
+ n="$(cd "$c/.." 2>/dev/null && pwd)" || break
17
+ [ "$n" = "$c" ] && break
18
+ c="$n"
19
+ done
20
+ fi
21
+ [ -n "$root" ] || exit 0
22
+ [ -f "$root/tools/session_start.py" ] || exit 0
23
+ py="${OBSERVATORY_PYTHON:-$root/.venv/bin/python}" # set by `full agent install`
24
+ [ -x "$py" ] || py="$(command -v python3 2>/dev/null)"
25
+ [ -n "$py" ] || exit 0
26
+ printf '%s' "$payload" | "$py" "$root/tools/session_start.py" 2>/dev/null || true
27
+ exit 0